acumatica-cli 0.16.0__tar.gz → 0.17.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.17.0}/PKG-INFO +3 -1
  2. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/README.md +2 -0
  3. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/pyproject.toml.orig +1 -1
  5. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/cli.py +4 -1
  6. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/reconcile.py +163 -13
  7. acumatica_cli-0.17.0/src/acumatica_cli/snapshot_map.yaml +86 -0
  8. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/__init__.py +0 -0
  9. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap.py +0 -0
  10. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  11. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  12. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/client.py +0 -0
  13. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/config.py +0 -0
  14. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/extract.py +0 -0
  15. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/firstlogin.py +0 -0
  16. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/inventory.py +0 -0
  17. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/models.py +0 -0
  18. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/output.py +0 -0
  19. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/run.py +0 -0
  20. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/seed.py +0 -0
  21. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/seed_catalog.yaml +0 -0
  22. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/state.py +0 -0
  23. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/target.py +0 -0
  24. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/README.md +0 -0
  25. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  26. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  27. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  28. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  29. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  30. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  31. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  32. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  33. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  34. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  35. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  36. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  37. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  38. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  39. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  40. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  41. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  42. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  43. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  44. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  45. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  46. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  47. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  48. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  49. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  50. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  51. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  52. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  53. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  54. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  55. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  56. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  57. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  58. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  59. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  60. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  61. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  62. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  63. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/env +0 -0
  64. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/gitignore +0 -0
  65. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  66. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  67. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  68. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  69. {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/target +0 -0
  70. {acumatica_cli-0.16.0 → acumatica_cli-0.17.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.17.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).
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).
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.17.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.17.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,10 @@ 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 plus resolvers:/resolves: for FK
1108
+ int→CD compare (package defaults cover Sub, UnitOfMeasure,
1109
+ ReasonCode/VendorClass Account+Sub). Compare always pad-trims strings.
1107
1110
  """
1108
1111
  inv = (
1109
1112
  inventory_dir
@@ -17,6 +17,14 @@ 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).
20
28
  """
21
29
 
22
30
  from __future__ import annotations
@@ -91,11 +99,36 @@ class SeedFile(Model):
91
99
  return v if isinstance(v, list) else [v]
92
100
 
93
101
 
102
+ class FkResolver(Model):
103
+ """Global inventory FK index: int id column → natural CD column (V38 M4)."""
104
+
105
+ table: str
106
+ id: str
107
+ cd: str
108
+
109
+ @field_validator("table", "id", "cd")
110
+ @classmethod
111
+ def _nonempty(cls, v: str) -> str:
112
+ v = v.strip()
113
+ if not v:
114
+ raise ValueError("resolver table/id/cd must be non-empty")
115
+ return v
116
+
117
+
94
118
  class TableMapEntry(Model):
95
- """One optional snapshot_map row: DAC table → catalog entity."""
119
+ """One optional snapshot_map row: DAC table → catalog entity.
120
+
121
+ Optional ``keys`` / ``fields`` are seed-name → inventory-column aliases
122
+ (V38 M3). Optional ``resolves`` maps seed field → global resolver name
123
+ (V38 M4): inventory value is looked up id→CD before compare. Absent
124
+ aliases → identity names. v1 rows are ``{table, entity}`` only.
125
+ """
96
126
 
97
127
  table: str
98
128
  entity: str
129
+ keys: dict[str, str] = Field(default_factory=dict)
130
+ fields: dict[str, str] = Field(default_factory=dict)
131
+ resolves: dict[str, str] = Field(default_factory=dict)
99
132
 
100
133
  @field_validator("table", "entity")
101
134
  @classmethod
@@ -105,17 +138,54 @@ class TableMapEntry(Model):
105
138
  raise ValueError("table/entity must be non-empty")
106
139
  return v
107
140
 
141
+ @field_validator("keys", "fields", "resolves", mode="before")
142
+ @classmethod
143
+ def _str_map(cls, v: object) -> object:
144
+ """Accept seed→inv (or seed→resolver) string maps; reject empty names."""
145
+ if v is None:
146
+ return {}
147
+ if not isinstance(v, dict):
148
+ raise ValueError("keys/fields/resolves must be a mapping of strings")
149
+ out: dict[str, str] = {}
150
+ for seed_name, target in v.items():
151
+ s = str(seed_name).strip()
152
+ if isinstance(target, dict):
153
+ raise ValueError(
154
+ f"keys/fields/resolves[{s!r}]: expected string value "
155
+ "(use resolves: for FK names; enums are M5)"
156
+ )
157
+ i = str(target).strip()
158
+ if not s or not i:
159
+ raise ValueError("keys/fields/resolves names must be non-empty")
160
+ out[s] = i
161
+ return out
162
+
108
163
 
109
164
  class SnapshotMap(Model):
110
165
  """Optional table→entity map (package or data-repo ``snapshot_map.yaml``)."""
111
166
 
167
+ resolvers: dict[str, FkResolver] = Field(default_factory=dict)
112
168
  tables: list[TableMapEntry] = Field(default_factory=list)
113
169
 
170
+ @field_validator("resolvers", mode="before")
171
+ @classmethod
172
+ def _resolvers_map(cls, v: object) -> object:
173
+ if v is None:
174
+ return {}
175
+ if not isinstance(v, dict):
176
+ raise ValueError("resolvers must be a mapping of name -> {table,id,cd}")
177
+ return v
178
+
114
179
  def entity_for(self, table: str) -> str | None:
115
180
  """Catalog entity name for a DAC table, or None when unmapped."""
181
+ entry = self.entry_for(table)
182
+ return entry.entity if entry is not None else None
183
+
184
+ def entry_for(self, table: str) -> TableMapEntry | None:
185
+ """Full map row for a DAC table (aliases + entity), or None."""
116
186
  for row in self.tables:
117
187
  if row.table == table:
118
- return row.entity
188
+ return row
119
189
  return None
120
190
 
121
191
 
@@ -361,6 +431,7 @@ def reconcile(
361
431
  by_entity = _catalog_by_entity(m)
362
432
  catalog_entities = set(by_entity)
363
433
  seed_index = _index_seeds(seeds)
434
+ fk_indexes = _build_fk_indexes(inv, smap.resolvers)
364
435
  config_present = config_dir is not None and config_dir.is_dir()
365
436
 
366
437
  unmapped: list[UnmappedTable] = []
@@ -387,6 +458,7 @@ def reconcile(
387
458
  seeds=seed_index,
388
459
  )
389
460
  )
461
+ map_entry = smap.entry_for(table.name)
390
462
  deltas.extend(
391
463
  _deltas_for_table(
392
464
  entity=entity,
@@ -394,6 +466,8 @@ def reconcile(
394
466
  specs=specs,
395
467
  config_present=config_present,
396
468
  seeds=seed_index,
469
+ map_entry=map_entry,
470
+ fk_indexes=fk_indexes,
397
471
  )
398
472
  )
399
473
  _append_usr_custom(custom, table.name, entity, col_names)
@@ -484,16 +558,20 @@ def _rest_gaps_for_table(
484
558
  return gaps
485
559
 
486
560
 
487
- def _deltas_for_table(
561
+ def _deltas_for_table( # noqa: PLR0913 — keyword-only; map_entry + fk_indexes V38
488
562
  *,
489
563
  entity: str,
490
564
  table: inventory.TableData,
491
565
  specs: list[extract.EntitySpec],
492
566
  config_present: bool,
493
567
  seeds: _SeedIndex,
568
+ map_entry: TableMapEntry | None = None,
569
+ fk_indexes: dict[str, dict[str, str]] | None = None,
494
570
  ) -> list[FieldDelta]:
571
+ """Per-spec field deltas for one inventory table (optional map aliases)."""
495
572
  if not config_present:
496
573
  return []
574
+ indexes = fk_indexes if fk_indexes is not None else {}
497
575
  out: list[FieldDelta] = []
498
576
  for spec in specs:
499
577
  seed_file = _find_seed_for_spec(spec, seeds)
@@ -501,7 +579,12 @@ def _deltas_for_table(
501
579
  continue
502
580
  out.extend(
503
581
  _compare_seed_table(
504
- entity=entity, table=table, seed_file=seed_file, spec=spec
582
+ entity=entity,
583
+ table=table,
584
+ seed_file=seed_file,
585
+ spec=spec,
586
+ map_entry=map_entry,
587
+ fk_indexes=indexes,
505
588
  )
506
589
  )
507
590
  return out
@@ -537,37 +620,94 @@ def _find_seed_for_spec(spec: extract.EntitySpec, seeds: _SeedIndex) -> SeedFile
537
620
  return None
538
621
 
539
622
 
540
- def _compare_seed_table(
623
+ def _build_fk_indexes(
624
+ inv: InventoryTree, resolvers: dict[str, FkResolver]
625
+ ) -> dict[str, dict[str, str]]:
626
+ """Build resolver_name → {id_norm: cd_norm} from inventory tables (V38 M4)."""
627
+ indexes: dict[str, dict[str, str]] = {}
628
+ by_name = inv.by_name
629
+ for name, spec in resolvers.items():
630
+ table = by_name.get(spec.table)
631
+ if table is None:
632
+ indexes[name] = {}
633
+ continue
634
+ idx: dict[str, str] = {}
635
+ for row in table.rows:
636
+ if spec.id not in row or spec.cd not in row:
637
+ continue
638
+ idx[_norm(row[spec.id])] = _norm(row[spec.cd])
639
+ indexes[name] = idx
640
+ return indexes
641
+
642
+
643
+ def _resolve_inv_value(
644
+ raw: str,
645
+ *,
646
+ field: str,
647
+ resolves: dict[str, str],
648
+ fk_indexes: dict[str, dict[str, str]],
649
+ ) -> str:
650
+ """Map inventory FK id → CD when field has a resolver; else return raw."""
651
+ resolver_name = resolves.get(field)
652
+ if resolver_name is None:
653
+ return raw
654
+ id_to_cd = fk_indexes.get(resolver_name)
655
+ if not id_to_cd:
656
+ return raw
657
+ return id_to_cd.get(raw, raw)
658
+
659
+
660
+ def _compare_seed_table( # noqa: PLR0913 — keyword-only compare bundle
541
661
  *,
542
662
  entity: str,
543
663
  table: inventory.TableData,
544
664
  seed_file: SeedFile,
545
665
  spec: extract.EntitySpec,
666
+ map_entry: TableMapEntry | None = None,
667
+ fk_indexes: dict[str, dict[str, str]] | None = None,
546
668
  ) -> list[FieldDelta]:
547
669
  """Field-level compare for rows that share a key tuple on both sides.
548
670
 
549
671
  Key fields come from the seed file (authoritative for CaC). Inventory
550
672
  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).
673
+ → skip that seed row (no false delta). Optional map ``keys`` / ``fields``
674
+ rename seed names → inv columns (V38 M3). Optional ``resolves`` + global
675
+ FK indexes convert inv int IDs → CD before compare (V38 M4; prefer seed
676
+ CD space). Join keys and field values are pad-trimmed on both sides (V38)
677
+ so fixed-width / trailing-space DAC columns match seed natural keys.
678
+ Detail lists skipped (not comparable to flat snapshot rows). Findings
679
+ always report seed-side field names and resolved inventory values.
553
680
  """
554
681
  keys = seed_file.keys
555
- inv_index = _index_inventory_rows(table.rows, keys)
682
+ key_alias = map_entry.keys if map_entry is not None else {}
683
+ field_alias = map_entry.fields if map_entry is not None else {}
684
+ resolves = map_entry.resolves if map_entry is not None else {}
685
+ indexes = fk_indexes if fk_indexes is not None else {}
686
+ inv_keys = [key_alias.get(k, k) for k in keys]
687
+ inv_index = _index_inventory_rows(table.rows, inv_keys)
556
688
  deltas: list[FieldDelta] = []
557
689
  for rec in seed_file.records:
558
690
  if not all(k in rec for k in keys):
559
691
  continue
560
- ident = tuple(str(rec[k]) for k in keys)
692
+ # V38: pad-trim key identity both sides so join is padding-invariant.
693
+ # Seed values are looked up under inv column names (alias or identity).
694
+ ident = tuple(_norm(rec[k]) for k in keys)
561
695
  inv_row = inv_index.get(ident)
562
696
  if inv_row is None:
563
697
  continue
564
698
  for field, seed_val in sorted(rec.items()):
565
699
  if field in keys or isinstance(seed_val, (list, dict)):
566
700
  continue
567
- if field not in inv_row:
701
+ inv_field = field_alias.get(field, field)
702
+ if inv_field not in inv_row:
568
703
  continue
569
704
  left = _norm(seed_val)
570
- right = _norm(inv_row[field])
705
+ right = _resolve_inv_value(
706
+ _norm(inv_row[inv_field]),
707
+ field=field,
708
+ resolves=resolves,
709
+ fk_indexes=indexes,
710
+ )
571
711
  if left != right:
572
712
  deltas.append(
573
713
  FieldDelta(
@@ -586,15 +726,25 @@ def _compare_seed_table(
586
726
  def _index_inventory_rows(
587
727
  rows: list[dict[str, str]], keys: list[str]
588
728
  ) -> dict[tuple[str, ...], dict[str, str]]:
729
+ """Index inventory rows by pad-trimmed key tuple (V38 join).
730
+
731
+ ``keys`` are inventory column names (after seed→inv alias resolution).
732
+ """
589
733
  inv_index: dict[tuple[str, ...], dict[str, str]] = {}
590
734
  for row in rows:
591
735
  if all(k in row for k in keys):
592
- inv_index[tuple(str(row[k]) for k in keys)] = row
736
+ inv_index[tuple(_norm(row[k]) for k in keys)] = row
593
737
  return inv_index
594
738
 
595
739
 
596
740
  def _norm(value: Any) -> str:
597
- """Comparable string form (bool/number/string), sibling of seed._norm spirit."""
741
+ """Pad-trim comparable form for join keys and field values (V38).
742
+
743
+ String sides strip leading/trailing whitespace (DAC/NVarChar padding).
744
+ Bools/numbers fold to a stable spelling so seed and inventory agree.
745
+ Sibling of ``seed._norm`` spirit; used on *both* key join and field
746
+ compare so padded inventory never misses a seed row or false-deltas.
747
+ """
598
748
  if isinstance(value, bool):
599
749
  return "true" if value else "false"
600
750
  if value is None:
@@ -0,0 +1,86 @@
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
+ # Data-repo root snapshot_map.yaml wins over this package file.
7
+ # v1 rows remain {table, entity} only — aliases/resolves optional.
8
+
9
+ resolvers:
10
+ account_cd:
11
+ table: Account
12
+ id: AccountID
13
+ cd: AccountCD
14
+ sub_cd:
15
+ table: Sub
16
+ id: SubID
17
+ cd: SubCD
18
+
19
+ tables:
20
+ # Wave 1 renames — key/field aliases where seed ≠ DAC column
21
+ - table: Sub
22
+ entity: Subaccount
23
+ keys:
24
+ SubaccountCD: SubCD
25
+ - table: Terms
26
+ entity: CreditTerms
27
+ - table: UnitOfMeasure
28
+ entity: UnitsOfMeasure
29
+ keys:
30
+ UnitID: Unit
31
+ fields:
32
+ Description: Descr
33
+ - table: INAvailabilityScheme
34
+ entity: AvailabilityCalculationRule
35
+ - table: SOSetup
36
+ entity: SOPreferences
37
+ - table: POSetup
38
+ entity: POPreferences
39
+ - table: ARSetup
40
+ entity: ARPreferences
41
+ - table: APSetup
42
+ entity: APPreferences
43
+
44
+ # Wave 2 — table renames (field aliases / more FKs later)
45
+ - table: INPostClass
46
+ entity: PostingClass
47
+ - table: INItemClass
48
+ entity: ItemClass
49
+ - table: INSite
50
+ entity: Warehouse
51
+ - table: InventoryItem
52
+ entity: StockItem
53
+ - table: INSetup
54
+ entity: INPreferences
55
+ - table: CASetup
56
+ entity: CAPreferences
57
+ - table: GLSetup
58
+ entity: GLPreferences
59
+
60
+ # Wave 3
61
+ - table: INKitSpecHdr
62
+ entity: KitSpecification
63
+ - table: SOOrderType
64
+ entity: OrderType
65
+ - table: ARStatementCycle
66
+ entity: StatementCycle
67
+ - table: OrganizationLedgerLink
68
+ entity: LedgerCompany
69
+
70
+ # Identity tables needing FK CD resolve (M4) — table name == entity
71
+ - table: ReasonCode
72
+ entity: ReasonCode
73
+ resolves:
74
+ AccountID: account_cd
75
+ SubID: sub_cd
76
+ - table: VendorClass
77
+ entity: VendorClass
78
+ resolves:
79
+ APAcctID: account_cd
80
+ APSubID: sub_cd
81
+ DiscTakenAcctID: account_cd
82
+ DiscTakenSubID: sub_cd
83
+ ExpenseAcctID: account_cd
84
+ ExpenseSubID: sub_cd
85
+ POAccrualAcctID: account_cd
86
+ POAccrualSubID: sub_cd