acumatica-cli 0.16.0__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/PKG-INFO +3 -1
  2. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/README.md +2 -0
  3. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/pyproject.toml.orig +1 -1
  5. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/cli.py +6 -1
  6. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/reconcile.py +272 -24
  7. acumatica_cli-0.18.0/src/acumatica_cli/snapshot_map.yaml +167 -0
  8. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/__init__.py +0 -0
  9. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/bootstrap.py +0 -0
  10. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  11. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  12. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/client.py +0 -0
  13. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/config.py +0 -0
  14. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/extract.py +0 -0
  15. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/firstlogin.py +0 -0
  16. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/inventory.py +0 -0
  17. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/models.py +0 -0
  18. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/output.py +0 -0
  19. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/run.py +0 -0
  20. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/seed.py +0 -0
  21. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/seed_catalog.yaml +0 -0
  22. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/state.py +0 -0
  23. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/target.py +0 -0
  24. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/README.md +0 -0
  25. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  26. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  27. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  28. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  29. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  30. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  31. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  32. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  33. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  34. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  35. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  36. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  37. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  38. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  39. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  40. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  41. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  42. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  43. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  44. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  45. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  46. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  47. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  48. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  49. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  50. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  51. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  52. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  53. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  54. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  55. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  56. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  57. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  58. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  59. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  60. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  61. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  62. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  63. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/env +0 -0
  64. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/gitignore +0 -0
  65. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  66. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  67. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  68. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  69. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/target +0 -0
  70. {acumatica_cli-0.16.0 → acumatica_cli-0.18.0}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acumatica-cli
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data
5
5
  Author: Konstantin Borovik
6
6
  Author-email: Konstantin Borovik <kb@lab5.ca>
@@ -107,6 +107,8 @@ A path like `config/` expands nested seed dirs in that fixed order.
107
107
  `extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
108
108
  `inventory` is offline (no REST/SSH/password): SM203520 Settings XML ZIP or `ac.exe export xml` folder writes to `inventory/`.
109
109
  `reconcile` is offline: compare `inventory/` to optional `config/` and write `findings/` only (never writes seed).
110
+ Optional `snapshot_map.yaml` (data-repo root or package defaults) maps DAC tables to catalog entities and normalizes join (pad-trim, key/field aliases, Account/Sub FK CD resolve, enum label→code).
111
+ See [docs/demo-seed.md](docs/demo-seed.md).
110
112
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
111
113
  Run `acu <command> --help` for details on any command.
112
114
 
@@ -89,6 +89,8 @@ A path like `config/` expands nested seed dirs in that fixed order.
89
89
  `extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
90
90
  `inventory` is offline (no REST/SSH/password): SM203520 Settings XML ZIP or `ac.exe export xml` folder writes to `inventory/`.
91
91
  `reconcile` is offline: compare `inventory/` to optional `config/` and write `findings/` only (never writes seed).
92
+ Optional `snapshot_map.yaml` (data-repo root or package defaults) maps DAC tables to catalog entities and normalizes join (pad-trim, key/field aliases, Account/Sub FK CD resolve, enum label→code).
93
+ See [docs/demo-seed.md](docs/demo-seed.md).
92
94
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
93
95
  Run `acu <command> --help` for details on any command.
94
96
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.16.0"
3
+ version = "0.18.0"
4
4
  description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
5
5
  license = "PolyForm-Noncommercial-1.0.0"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.16.0"
3
+ version = "0.18.0"
4
4
  description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
5
5
  authors = [{ name = "Konstantin Borovik", email = "kb@lab5.ca" }]
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -1103,7 +1103,12 @@ def reconcile_cmd(
1103
1103
  columns land in findings files only. No REST, no SSH, no password.
1104
1104
  Exit 0 clean, 1 IO/parse fail; never 2 (conflicts are findings, not
1105
1105
  drift). Optional snapshot_map.yaml maps DAC tables to catalog
1106
- entities; absent → identity match on entity name.
1106
+ entities (absent → identity match on entity name) and may declare
1107
+ keys:/fields: seed→inv aliases, resolvers:/resolves: for FK int→CD,
1108
+ and enums: label→code (package defaults cover Sub/UOM aliases,
1109
+ ReasonCode/VendorClass/PostingClass/CashAccount Account+Sub,
1110
+ ReasonCode.Usage + Account Type/Active bools, etc.). Compare always
1111
+ pad-trims strings.
1107
1112
  """
1108
1113
  inv = (
1109
1114
  inventory_dir
@@ -17,6 +17,16 @@ Finding kinds (I.data findings/):
17
17
 
18
18
  Optional ``snapshot_map.yaml`` (package data or data-repo root) maps
19
19
  table → entity; absent → identity match on catalog entity name.
20
+
21
+ Compare path pad-trims string keys and fields on both sides (V38) so
22
+ fixed-width / trailing-space inventory values join and equal seed.
23
+ Per-row optional ``keys:`` / ``fields:`` maps rename seed names →
24
+ inventory column names (V38 M3) so e.g. SubaccountCD joins SubCD and
25
+ UnitID joins Unit without false key-skips. Global ``resolvers:`` plus
26
+ per-row ``resolves:`` map inv int FKs → CD strings before compare (V38 M4)
27
+ so seed CD space matches inventory IDs (Account/Sub first). Global
28
+ ``enums:`` plus per-row ``enums:`` map REST labels → DAC codes (V38 M5)
29
+ so e.g. Usage Adjustment vs A and Active true vs 1 do not false-delta.
20
30
  """
21
31
 
22
32
  from __future__ import annotations
@@ -91,11 +101,39 @@ class SeedFile(Model):
91
101
  return v if isinstance(v, list) else [v]
92
102
 
93
103
 
104
+ class FkResolver(Model):
105
+ """Global inventory FK index: int id column → natural CD column (V38 M4)."""
106
+
107
+ table: str
108
+ id: str
109
+ cd: str
110
+
111
+ @field_validator("table", "id", "cd")
112
+ @classmethod
113
+ def _nonempty(cls, v: str) -> str:
114
+ v = v.strip()
115
+ if not v:
116
+ raise ValueError("resolver table/id/cd must be non-empty")
117
+ return v
118
+
119
+
94
120
  class TableMapEntry(Model):
95
- """One optional snapshot_map row: DAC table → catalog entity."""
121
+ """One optional snapshot_map row: DAC table → catalog entity.
122
+
123
+ Optional ``keys`` / ``fields`` are seed-name → inventory-column aliases
124
+ (V38 M3). Optional ``resolves`` maps seed field → global resolver name
125
+ (V38 M4): inventory value is looked up id→CD before compare. Optional
126
+ ``enums`` maps seed field → global enum table name (V38 M5): seed labels
127
+ fold to DAC codes before compare. Absent aliases → identity names. v1
128
+ rows are ``{table, entity}`` only.
129
+ """
96
130
 
97
131
  table: str
98
132
  entity: str
133
+ keys: dict[str, str] = Field(default_factory=dict)
134
+ fields: dict[str, str] = Field(default_factory=dict)
135
+ resolves: dict[str, str] = Field(default_factory=dict)
136
+ enums: dict[str, str] = Field(default_factory=dict)
99
137
 
100
138
  @field_validator("table", "entity")
101
139
  @classmethod
@@ -105,17 +143,88 @@ class TableMapEntry(Model):
105
143
  raise ValueError("table/entity must be non-empty")
106
144
  return v
107
145
 
146
+ @field_validator("keys", "fields", "resolves", "enums", mode="before")
147
+ @classmethod
148
+ def _str_map(cls, v: object) -> object:
149
+ """Accept seed→inv/resolver/enum-name string maps; reject empty names."""
150
+ if v is None:
151
+ return {}
152
+ if not isinstance(v, dict):
153
+ raise ValueError("keys/fields/resolves/enums must be a mapping of strings")
154
+ out: dict[str, str] = {}
155
+ for seed_name, target in v.items():
156
+ s = str(seed_name).strip()
157
+ if isinstance(target, dict):
158
+ raise ValueError(
159
+ f"keys/fields/resolves/enums[{s!r}]: expected string value "
160
+ "(use resolves: for FK names; enums: for label->code tables)"
161
+ )
162
+ i = str(target).strip()
163
+ if not s or not i:
164
+ raise ValueError("keys/fields/resolves/enums names must be non-empty")
165
+ out[s] = i
166
+ return out
167
+
108
168
 
109
169
  class SnapshotMap(Model):
110
170
  """Optional table→entity map (package or data-repo ``snapshot_map.yaml``)."""
111
171
 
172
+ resolvers: dict[str, FkResolver] = Field(default_factory=dict)
173
+ enums: dict[str, dict[str, str]] = Field(default_factory=dict)
112
174
  tables: list[TableMapEntry] = Field(default_factory=list)
113
175
 
176
+ @field_validator("resolvers", mode="before")
177
+ @classmethod
178
+ def _resolvers_map(cls, v: object) -> object:
179
+ if v is None:
180
+ return {}
181
+ if not isinstance(v, dict):
182
+ raise ValueError("resolvers must be a mapping of name -> {table,id,cd}")
183
+ return v
184
+
185
+ @field_validator("enums", mode="before")
186
+ @classmethod
187
+ def _enums_map(cls, v: object) -> object:
188
+ """Global name → {label: code}; stringify YAML bool keys as true/false."""
189
+ if v is None:
190
+ return {}
191
+ if not isinstance(v, dict):
192
+ raise ValueError("enums must be a mapping of name -> {label: code}")
193
+ out: dict[str, dict[str, str]] = {}
194
+ for name, mapping in v.items():
195
+ n = str(name).strip()
196
+ if not n:
197
+ raise ValueError("enums names must be non-empty")
198
+ if not isinstance(mapping, dict):
199
+ raise ValueError(f"enums[{n!r}]: expected mapping of label -> code")
200
+ inner: dict[str, str] = {}
201
+ for label, code in mapping.items():
202
+ if isinstance(label, bool):
203
+ lab = "true" if label else "false"
204
+ else:
205
+ lab = str(label).strip()
206
+ if isinstance(code, bool):
207
+ cd = "true" if code else "false"
208
+ else:
209
+ cd = str(code).strip()
210
+ if not lab or not cd:
211
+ raise ValueError(
212
+ f"enums[{n!r}]: label/code must be non-empty strings"
213
+ )
214
+ inner[lab] = cd
215
+ out[n] = inner
216
+ return out
217
+
114
218
  def entity_for(self, table: str) -> str | None:
115
219
  """Catalog entity name for a DAC table, or None when unmapped."""
220
+ entry = self.entry_for(table)
221
+ return entry.entity if entry is not None else None
222
+
223
+ def entry_for(self, table: str) -> TableMapEntry | None:
224
+ """Full map row for a DAC table (aliases + entity), or None."""
116
225
  for row in self.tables:
117
226
  if row.table == table:
118
- return row.entity
227
+ return row
119
228
  return None
120
229
 
121
230
 
@@ -361,6 +470,8 @@ def reconcile(
361
470
  by_entity = _catalog_by_entity(m)
362
471
  catalog_entities = set(by_entity)
363
472
  seed_index = _index_seeds(seeds)
473
+ fk_indexes = _build_fk_indexes(inv, smap.resolvers)
474
+ enum_tables = smap.enums
364
475
  config_present = config_dir is not None and config_dir.is_dir()
365
476
 
366
477
  unmapped: list[UnmappedTable] = []
@@ -387,6 +498,7 @@ def reconcile(
387
498
  seeds=seed_index,
388
499
  )
389
500
  )
501
+ map_entry = smap.entry_for(table.name)
390
502
  deltas.extend(
391
503
  _deltas_for_table(
392
504
  entity=entity,
@@ -394,6 +506,9 @@ def reconcile(
394
506
  specs=specs,
395
507
  config_present=config_present,
396
508
  seeds=seed_index,
509
+ map_entry=map_entry,
510
+ fk_indexes=fk_indexes,
511
+ enum_tables=enum_tables,
397
512
  )
398
513
  )
399
514
  _append_usr_custom(custom, table.name, entity, col_names)
@@ -484,16 +599,22 @@ def _rest_gaps_for_table(
484
599
  return gaps
485
600
 
486
601
 
487
- def _deltas_for_table(
602
+ def _deltas_for_table( # noqa: PLR0913 — keyword-only; map + V38 indexes
488
603
  *,
489
604
  entity: str,
490
605
  table: inventory.TableData,
491
606
  specs: list[extract.EntitySpec],
492
607
  config_present: bool,
493
608
  seeds: _SeedIndex,
609
+ map_entry: TableMapEntry | None = None,
610
+ fk_indexes: dict[str, dict[str, str]] | None = None,
611
+ enum_tables: dict[str, dict[str, str]] | None = None,
494
612
  ) -> list[FieldDelta]:
613
+ """Per-spec field deltas for one inventory table (optional map aliases)."""
495
614
  if not config_present:
496
615
  return []
616
+ indexes = fk_indexes if fk_indexes is not None else {}
617
+ enums = enum_tables if enum_tables is not None else {}
497
618
  out: list[FieldDelta] = []
498
619
  for spec in specs:
499
620
  seed_file = _find_seed_for_spec(spec, seeds)
@@ -501,7 +622,13 @@ def _deltas_for_table(
501
622
  continue
502
623
  out.extend(
503
624
  _compare_seed_table(
504
- entity=entity, table=table, seed_file=seed_file, spec=spec
625
+ entity=entity,
626
+ table=table,
627
+ seed_file=seed_file,
628
+ spec=spec,
629
+ map_entry=map_entry,
630
+ fk_indexes=indexes,
631
+ enum_tables=enums,
505
632
  )
506
633
  )
507
634
  return out
@@ -537,37 +664,137 @@ def _find_seed_for_spec(spec: extract.EntitySpec, seeds: _SeedIndex) -> SeedFile
537
664
  return None
538
665
 
539
666
 
540
- def _compare_seed_table(
667
+ def _build_fk_indexes(
668
+ inv: InventoryTree, resolvers: dict[str, FkResolver]
669
+ ) -> dict[str, dict[str, str]]:
670
+ """Build resolver_name → {id_norm: cd_norm} from inventory tables (V38 M4)."""
671
+ indexes: dict[str, dict[str, str]] = {}
672
+ by_name = inv.by_name
673
+ for name, spec in resolvers.items():
674
+ table = by_name.get(spec.table)
675
+ if table is None:
676
+ indexes[name] = {}
677
+ continue
678
+ idx: dict[str, str] = {}
679
+ for row in table.rows:
680
+ if spec.id not in row or spec.cd not in row:
681
+ continue
682
+ idx[_norm(row[spec.id])] = _norm(row[spec.cd])
683
+ indexes[name] = idx
684
+ return indexes
685
+
686
+
687
+ def _resolve_inv_value(
688
+ raw: str,
689
+ *,
690
+ field: str,
691
+ resolves: dict[str, str],
692
+ fk_indexes: dict[str, dict[str, str]],
693
+ ) -> str:
694
+ """Map inventory FK id → CD when field has a resolver; else return raw."""
695
+ resolver_name = resolves.get(field)
696
+ if resolver_name is None:
697
+ return raw
698
+ id_to_cd = fk_indexes.get(resolver_name)
699
+ if not id_to_cd:
700
+ return raw
701
+ return id_to_cd.get(raw, raw)
702
+
703
+
704
+ def _enum_norm(
705
+ raw: str,
706
+ *,
707
+ field: str,
708
+ field_enums: dict[str, str],
709
+ enum_tables: dict[str, dict[str, str]],
710
+ ) -> str:
711
+ """Map REST label → DAC code when field has an enum table (V38 M5).
712
+
713
+ Seed side usually carries labels (Adjustment, Asset, true); inventory
714
+ stores codes (A, A, 1). Labels in the table fold to codes; values that
715
+ already equal a code (or are unmapped) pass through unchanged.
716
+ """
717
+ enum_name = field_enums.get(field)
718
+ if enum_name is None:
719
+ return raw
720
+ table = enum_tables.get(enum_name)
721
+ if not table:
722
+ return raw
723
+ if raw in table:
724
+ return table[raw]
725
+ # case-insensitive label match (seed YAML sometimes differs in case)
726
+ lower = raw.lower()
727
+ for lab, code in table.items():
728
+ if lab.lower() == lower:
729
+ return code
730
+ return raw
731
+
732
+
733
+ def _compare_seed_table( # noqa: PLR0913 — keyword-only compare bundle
541
734
  *,
542
735
  entity: str,
543
736
  table: inventory.TableData,
544
737
  seed_file: SeedFile,
545
738
  spec: extract.EntitySpec,
739
+ map_entry: TableMapEntry | None = None,
740
+ fk_indexes: dict[str, dict[str, str]] | None = None,
741
+ enum_tables: dict[str, dict[str, str]] | None = None,
546
742
  ) -> list[FieldDelta]:
547
743
  """Field-level compare for rows that share a key tuple on both sides.
548
744
 
549
745
  Key fields come from the seed file (authoritative for CaC). Inventory
550
746
  column names often match contract field names; missing inventory key
551
- → skip that seed row (no false delta). Values compared as stripped
552
- strings. Detail lists skipped (not comparable to flat snapshot rows).
747
+ → skip that seed row (no false delta). Optional map ``keys`` / ``fields``
748
+ rename seed names → inv columns (V38 M3). Optional ``resolves`` + global
749
+ FK indexes convert inv int IDs → CD before compare (V38 M4; prefer seed
750
+ CD space). Optional ``enums`` + global enum tables fold seed labels →
751
+ codes (V38 M5). Join keys and field values are pad-trimmed on both sides
752
+ (V38) so fixed-width / trailing-space DAC columns match seed natural keys.
753
+ Detail lists skipped (not comparable to flat snapshot rows). Findings
754
+ always report seed-side field names and resolved inventory values.
553
755
  """
554
756
  keys = seed_file.keys
555
- inv_index = _index_inventory_rows(table.rows, keys)
757
+ key_alias = map_entry.keys if map_entry is not None else {}
758
+ field_alias = map_entry.fields if map_entry is not None else {}
759
+ resolves = map_entry.resolves if map_entry is not None else {}
760
+ field_enums = map_entry.enums if map_entry is not None else {}
761
+ indexes = fk_indexes if fk_indexes is not None else {}
762
+ enums = enum_tables if enum_tables is not None else {}
763
+ inv_keys = [key_alias.get(k, k) for k in keys]
764
+ inv_index = _index_inventory_rows(table.rows, inv_keys)
556
765
  deltas: list[FieldDelta] = []
557
766
  for rec in seed_file.records:
558
767
  if not all(k in rec for k in keys):
559
768
  continue
560
- ident = tuple(str(rec[k]) for k in keys)
769
+ # V38: pad-trim key identity both sides so join is padding-invariant.
770
+ # Seed values are looked up under inv column names (alias or identity).
771
+ ident = tuple(_norm(rec[k]) for k in keys)
561
772
  inv_row = inv_index.get(ident)
562
773
  if inv_row is None:
563
774
  continue
564
775
  for field, seed_val in sorted(rec.items()):
565
776
  if field in keys or isinstance(seed_val, (list, dict)):
566
777
  continue
567
- if field not in inv_row:
778
+ inv_field = field_alias.get(field, field)
779
+ if inv_field not in inv_row:
568
780
  continue
569
- left = _norm(seed_val)
570
- right = _norm(inv_row[field])
781
+ left = _enum_norm(
782
+ _norm(seed_val),
783
+ field=field,
784
+ field_enums=field_enums,
785
+ enum_tables=enums,
786
+ )
787
+ right = _enum_norm(
788
+ _resolve_inv_value(
789
+ _norm(inv_row[inv_field]),
790
+ field=field,
791
+ resolves=resolves,
792
+ fk_indexes=indexes,
793
+ ),
794
+ field=field,
795
+ field_enums=field_enums,
796
+ enum_tables=enums,
797
+ )
571
798
  if left != right:
572
799
  deltas.append(
573
800
  FieldDelta(
@@ -586,27 +813,48 @@ def _compare_seed_table(
586
813
  def _index_inventory_rows(
587
814
  rows: list[dict[str, str]], keys: list[str]
588
815
  ) -> dict[tuple[str, ...], dict[str, str]]:
816
+ """Index inventory rows by pad-trimmed key tuple (V38 join).
817
+
818
+ ``keys`` are inventory column names (after seed→inv alias resolution).
819
+ """
589
820
  inv_index: dict[tuple[str, ...], dict[str, str]] = {}
590
821
  for row in rows:
591
822
  if all(k in row for k in keys):
592
- inv_index[tuple(str(row[k]) for k in keys)] = row
823
+ inv_index[tuple(_norm(row[k]) for k in keys)] = row
593
824
  return inv_index
594
825
 
595
826
 
596
827
  def _norm(value: Any) -> str:
597
- """Comparable string form (bool/number/string), sibling of seed._norm spirit."""
828
+ """Pad-trim comparable form for join keys and field values (V38).
829
+
830
+ String sides strip leading/trailing whitespace (DAC/NVarChar padding).
831
+ Bools/numbers fold to a stable spelling so seed and inventory agree.
832
+ Decimal-looking strings with a point collapse trailing zeros so seed
833
+ ``0`` matches inventory ``0.000000`` without treating bare CDs like
834
+ ``000000`` as numbers. Sibling of ``seed._norm`` spirit; used on *both*
835
+ key join and field compare so padded inventory never misses a seed row
836
+ or false-deltas.
837
+ """
598
838
  if isinstance(value, bool):
599
- return "true" if value else "false"
600
- if value is None:
601
- return ""
602
- if isinstance(value, int) and not isinstance(value, bool):
603
- return str(value)
604
- if isinstance(value, float):
839
+ s = "true" if value else "false"
840
+ elif value is None:
841
+ s = ""
842
+ elif isinstance(value, int):
843
+ s = str(value)
844
+ elif isinstance(value, float):
605
845
  as_int = int(value)
606
- if value == float(as_int):
607
- return str(as_int)
608
- return str(value)
609
- return str(value).strip()
846
+ s = str(as_int) if value == float(as_int) else str(value)
847
+ else:
848
+ s = str(value).strip()
849
+ if "." in s:
850
+ try:
851
+ f = float(s)
852
+ except ValueError:
853
+ return s
854
+ as_int = int(f)
855
+ if f == float(as_int):
856
+ s = str(as_int)
857
+ return s
610
858
 
611
859
 
612
860
  # ---------------------------------------------------------------------------
@@ -0,0 +1,167 @@
1
+ # snapshot_map.yaml — package defaults for `acu reconcile` (V38)
2
+ #
3
+ # table → catalog entity. Optional keys:/fields: rename seed names →
4
+ # inventory columns so join/compare works when DAC names differ (M3).
5
+ # Global resolvers: + per-row resolves: map inv int FKs → CD (M4).
6
+ # Global enums: + per-row enums: map REST labels → DAC codes (M5).
7
+ # Data-repo root snapshot_map.yaml wins over this package file.
8
+ # v1 rows remain {table, entity} only — aliases/resolves/enums optional.
9
+
10
+ resolvers:
11
+ account_cd:
12
+ table: Account
13
+ id: AccountID
14
+ cd: AccountCD
15
+ sub_cd:
16
+ table: Sub
17
+ id: SubID
18
+ cd: SubCD
19
+ branch_cd:
20
+ table: Branch
21
+ id: BranchID
22
+ cd: BranchCD
23
+
24
+ enums:
25
+ reason_usage:
26
+ Adjustment: A
27
+ Assembly/Disassembly: D
28
+ Issue: I
29
+ Receipt: R
30
+ Vendor Return: N
31
+ account_type:
32
+ Asset: A
33
+ Liability: L
34
+ Income: I
35
+ Expense: E
36
+ post_option:
37
+ Summary: S
38
+ Detail: D
39
+ due_type:
40
+ Fixed Number of Days: N
41
+ disc_type:
42
+ Fixed Number of Days: N
43
+ visible_to:
44
+ All: AL
45
+ prepare_on:
46
+ Fixed Day of Month: F
47
+ bool_bit:
48
+ "true": "1"
49
+ "false": "0"
50
+
51
+ tables:
52
+ # Wave 1 renames — key/field aliases where seed ≠ DAC column
53
+ - table: Sub
54
+ entity: Subaccount
55
+ keys:
56
+ SubaccountCD: SubCD
57
+ enums:
58
+ Active: bool_bit
59
+ - table: Terms
60
+ entity: CreditTerms
61
+ enums:
62
+ DueType: due_type
63
+ DiscType: disc_type
64
+ VisibleTo: visible_to
65
+ - table: UnitOfMeasure
66
+ entity: UnitsOfMeasure
67
+ keys:
68
+ UnitID: Unit
69
+ fields:
70
+ Description: Descr
71
+ - table: INAvailabilityScheme
72
+ entity: AvailabilityCalculationRule
73
+ - table: SOSetup
74
+ entity: SOPreferences
75
+ enums:
76
+ AutoReleaseIN: bool_bit
77
+ HoldShipments: bool_bit
78
+ RequireShipmentTotal: bool_bit
79
+ - table: POSetup
80
+ entity: POPreferences
81
+ - table: ARSetup
82
+ entity: ARPreferences
83
+ - table: APSetup
84
+ entity: APPreferences
85
+
86
+ # Wave 2 — table renames + PostingClass FK resolve
87
+ - table: INPostClass
88
+ entity: PostingClass
89
+ resolves:
90
+ COGSAcctID: account_cd
91
+ COGSSubID: sub_cd
92
+ InvtAcctID: account_cd
93
+ InvtSubID: sub_cd
94
+ LCVarianceAcctID: account_cd
95
+ LCVarianceSubID: sub_cd
96
+ POAccrualAcctID: account_cd
97
+ POAccrualSubID: sub_cd
98
+ PPVAcctID: account_cd
99
+ PPVSubID: sub_cd
100
+ SalesAcctID: account_cd
101
+ SalesSubID: sub_cd
102
+ - table: INItemClass
103
+ entity: ItemClass
104
+ - table: INSite
105
+ entity: Warehouse
106
+ enums:
107
+ Active: bool_bit
108
+ - table: InventoryItem
109
+ entity: StockItem
110
+ - table: INSetup
111
+ entity: INPreferences
112
+ - table: CASetup
113
+ entity: CAPreferences
114
+ - table: GLSetup
115
+ entity: GLPreferences
116
+
117
+ # Wave 3
118
+ - table: INKitSpecHdr
119
+ entity: KitSpecification
120
+ - table: SOOrderType
121
+ entity: OrderType
122
+ resolves:
123
+ FreightAcctID: account_cd
124
+ FreightSubID: sub_cd
125
+ enums:
126
+ Active: bool_bit
127
+ - table: ARStatementCycle
128
+ entity: StatementCycle
129
+ enums:
130
+ PrepareOn: prepare_on
131
+ - table: OrganizationLedgerLink
132
+ entity: LedgerCompany
133
+
134
+ # Identity tables — FK resolve (M4) + enums (M5)
135
+ - table: Account
136
+ entity: Account
137
+ enums:
138
+ Type: account_type
139
+ PostOption: post_option
140
+ Active: bool_bit
141
+ RequireUnits: bool_bit
142
+ - table: ReasonCode
143
+ entity: ReasonCode
144
+ resolves:
145
+ AccountID: account_cd
146
+ SubID: sub_cd
147
+ enums:
148
+ Usage: reason_usage
149
+ - table: VendorClass
150
+ entity: VendorClass
151
+ resolves:
152
+ APAcctID: account_cd
153
+ APSubID: sub_cd
154
+ DiscTakenAcctID: account_cd
155
+ DiscTakenSubID: sub_cd
156
+ ExpenseAcctID: account_cd
157
+ ExpenseSubID: sub_cd
158
+ POAccrualAcctID: account_cd
159
+ POAccrualSubID: sub_cd
160
+ - table: CashAccount
161
+ entity: CashAccount
162
+ resolves:
163
+ AccountID: account_cd
164
+ SubID: sub_cd
165
+ BranchID: branch_cd
166
+ enums:
167
+ Active: bool_bit