acumatica-cli 0.17.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.17.0 → acumatica_cli-0.18.0}/PKG-INFO +2 -2
  2. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/README.md +1 -1
  3. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/pyproject.toml.orig +1 -1
  5. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/cli.py +5 -3
  6. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/reconcile.py +127 -29
  7. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/snapshot_map.yaml +84 -3
  8. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/__init__.py +0 -0
  9. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/bootstrap.py +0 -0
  10. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  11. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  12. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/client.py +0 -0
  13. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/config.py +0 -0
  14. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/extract.py +0 -0
  15. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/firstlogin.py +0 -0
  16. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/inventory.py +0 -0
  17. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/models.py +0 -0
  18. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/output.py +0 -0
  19. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/run.py +0 -0
  20. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/seed.py +0 -0
  21. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/seed_catalog.yaml +0 -0
  22. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/state.py +0 -0
  23. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/target.py +0 -0
  24. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/README.md +0 -0
  25. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  26. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  27. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  28. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  29. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  30. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  31. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  32. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  33. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  34. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  35. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  36. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  37. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  38. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  39. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  40. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  41. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  42. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  43. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  44. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  45. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  46. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  47. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  48. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  49. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  50. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  51. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  52. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  53. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  54. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  55. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  56. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  57. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  58. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  59. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  60. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  61. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  62. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  63. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/env +0 -0
  64. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/gitignore +0 -0
  65. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  66. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  67. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  68. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  69. {acumatica_cli-0.17.0 → acumatica_cli-0.18.0}/src/acumatica_cli/templates/target +0 -0
  70. {acumatica_cli-0.17.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.17.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,7 +107,7 @@ 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).
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
111
  See [docs/demo-seed.md](docs/demo-seed.md).
112
112
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
113
113
  Run `acu <command> --help` for details on any command.
@@ -89,7 +89,7 @@ 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).
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
93
  See [docs/demo-seed.md](docs/demo-seed.md).
94
94
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
95
95
  Run `acu <command> --help` for details on any command.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.17.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.17.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"
@@ -1104,9 +1104,11 @@ def reconcile_cmd(
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
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
+ 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.
1110
1112
  """
1111
1113
  inv = (
1112
1114
  inventory_dir
@@ -24,7 +24,9 @@ Per-row optional ``keys:`` / ``fields:`` maps rename seed names →
24
24
  inventory column names (V38 M3) so e.g. SubaccountCD joins SubCD and
25
25
  UnitID joins Unit without false key-skips. Global ``resolvers:`` plus
26
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).
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.
28
30
  """
29
31
 
30
32
  from __future__ import annotations
@@ -120,8 +122,10 @@ class TableMapEntry(Model):
120
122
 
121
123
  Optional ``keys`` / ``fields`` are seed-name → inventory-column aliases
122
124
  (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
+ (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.
125
129
  """
126
130
 
127
131
  table: str
@@ -129,6 +133,7 @@ class TableMapEntry(Model):
129
133
  keys: dict[str, str] = Field(default_factory=dict)
130
134
  fields: dict[str, str] = Field(default_factory=dict)
131
135
  resolves: dict[str, str] = Field(default_factory=dict)
136
+ enums: dict[str, str] = Field(default_factory=dict)
132
137
 
133
138
  @field_validator("table", "entity")
134
139
  @classmethod
@@ -138,25 +143,25 @@ class TableMapEntry(Model):
138
143
  raise ValueError("table/entity must be non-empty")
139
144
  return v
140
145
 
141
- @field_validator("keys", "fields", "resolves", mode="before")
146
+ @field_validator("keys", "fields", "resolves", "enums", mode="before")
142
147
  @classmethod
143
148
  def _str_map(cls, v: object) -> object:
144
- """Accept seed→inv (or seed→resolver) string maps; reject empty names."""
149
+ """Accept seed→inv/resolver/enum-name string maps; reject empty names."""
145
150
  if v is None:
146
151
  return {}
147
152
  if not isinstance(v, dict):
148
- raise ValueError("keys/fields/resolves must be a mapping of strings")
153
+ raise ValueError("keys/fields/resolves/enums must be a mapping of strings")
149
154
  out: dict[str, str] = {}
150
155
  for seed_name, target in v.items():
151
156
  s = str(seed_name).strip()
152
157
  if isinstance(target, dict):
153
158
  raise ValueError(
154
- f"keys/fields/resolves[{s!r}]: expected string value "
155
- "(use resolves: for FK names; enums are M5)"
159
+ f"keys/fields/resolves/enums[{s!r}]: expected string value "
160
+ "(use resolves: for FK names; enums: for label->code tables)"
156
161
  )
157
162
  i = str(target).strip()
158
163
  if not s or not i:
159
- raise ValueError("keys/fields/resolves names must be non-empty")
164
+ raise ValueError("keys/fields/resolves/enums names must be non-empty")
160
165
  out[s] = i
161
166
  return out
162
167
 
@@ -165,6 +170,7 @@ class SnapshotMap(Model):
165
170
  """Optional table→entity map (package or data-repo ``snapshot_map.yaml``)."""
166
171
 
167
172
  resolvers: dict[str, FkResolver] = Field(default_factory=dict)
173
+ enums: dict[str, dict[str, str]] = Field(default_factory=dict)
168
174
  tables: list[TableMapEntry] = Field(default_factory=list)
169
175
 
170
176
  @field_validator("resolvers", mode="before")
@@ -176,6 +182,39 @@ class SnapshotMap(Model):
176
182
  raise ValueError("resolvers must be a mapping of name -> {table,id,cd}")
177
183
  return v
178
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
+
179
218
  def entity_for(self, table: str) -> str | None:
180
219
  """Catalog entity name for a DAC table, or None when unmapped."""
181
220
  entry = self.entry_for(table)
@@ -432,6 +471,7 @@ def reconcile(
432
471
  catalog_entities = set(by_entity)
433
472
  seed_index = _index_seeds(seeds)
434
473
  fk_indexes = _build_fk_indexes(inv, smap.resolvers)
474
+ enum_tables = smap.enums
435
475
  config_present = config_dir is not None and config_dir.is_dir()
436
476
 
437
477
  unmapped: list[UnmappedTable] = []
@@ -468,6 +508,7 @@ def reconcile(
468
508
  seeds=seed_index,
469
509
  map_entry=map_entry,
470
510
  fk_indexes=fk_indexes,
511
+ enum_tables=enum_tables,
471
512
  )
472
513
  )
473
514
  _append_usr_custom(custom, table.name, entity, col_names)
@@ -558,7 +599,7 @@ def _rest_gaps_for_table(
558
599
  return gaps
559
600
 
560
601
 
561
- def _deltas_for_table( # noqa: PLR0913 — keyword-only; map_entry + fk_indexes V38
602
+ def _deltas_for_table( # noqa: PLR0913 — keyword-only; map + V38 indexes
562
603
  *,
563
604
  entity: str,
564
605
  table: inventory.TableData,
@@ -567,11 +608,13 @@ def _deltas_for_table( # noqa: PLR0913 — keyword-only; map_entry + fk_indexes
567
608
  seeds: _SeedIndex,
568
609
  map_entry: TableMapEntry | None = None,
569
610
  fk_indexes: dict[str, dict[str, str]] | None = None,
611
+ enum_tables: dict[str, dict[str, str]] | None = None,
570
612
  ) -> list[FieldDelta]:
571
613
  """Per-spec field deltas for one inventory table (optional map aliases)."""
572
614
  if not config_present:
573
615
  return []
574
616
  indexes = fk_indexes if fk_indexes is not None else {}
617
+ enums = enum_tables if enum_tables is not None else {}
575
618
  out: list[FieldDelta] = []
576
619
  for spec in specs:
577
620
  seed_file = _find_seed_for_spec(spec, seeds)
@@ -585,6 +628,7 @@ def _deltas_for_table( # noqa: PLR0913 — keyword-only; map_entry + fk_indexes
585
628
  spec=spec,
586
629
  map_entry=map_entry,
587
630
  fk_indexes=indexes,
631
+ enum_tables=enums,
588
632
  )
589
633
  )
590
634
  return out
@@ -657,6 +701,35 @@ def _resolve_inv_value(
657
701
  return id_to_cd.get(raw, raw)
658
702
 
659
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
+
660
733
  def _compare_seed_table( # noqa: PLR0913 — keyword-only compare bundle
661
734
  *,
662
735
  entity: str,
@@ -665,6 +738,7 @@ def _compare_seed_table( # noqa: PLR0913 — keyword-only compare bundle
665
738
  spec: extract.EntitySpec,
666
739
  map_entry: TableMapEntry | None = None,
667
740
  fk_indexes: dict[str, dict[str, str]] | None = None,
741
+ enum_tables: dict[str, dict[str, str]] | None = None,
668
742
  ) -> list[FieldDelta]:
669
743
  """Field-level compare for rows that share a key tuple on both sides.
670
744
 
@@ -673,8 +747,9 @@ def _compare_seed_table( # noqa: PLR0913 — keyword-only compare bundle
673
747
  → skip that seed row (no false delta). Optional map ``keys`` / ``fields``
674
748
  rename seed names → inv columns (V38 M3). Optional ``resolves`` + global
675
749
  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.
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.
678
753
  Detail lists skipped (not comparable to flat snapshot rows). Findings
679
754
  always report seed-side field names and resolved inventory values.
680
755
  """
@@ -682,7 +757,9 @@ def _compare_seed_table( # noqa: PLR0913 — keyword-only compare bundle
682
757
  key_alias = map_entry.keys if map_entry is not None else {}
683
758
  field_alias = map_entry.fields if map_entry is not None else {}
684
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 {}
685
761
  indexes = fk_indexes if fk_indexes is not None else {}
762
+ enums = enum_tables if enum_tables is not None else {}
686
763
  inv_keys = [key_alias.get(k, k) for k in keys]
687
764
  inv_index = _index_inventory_rows(table.rows, inv_keys)
688
765
  deltas: list[FieldDelta] = []
@@ -701,12 +778,22 @@ def _compare_seed_table( # noqa: PLR0913 — keyword-only compare bundle
701
778
  inv_field = field_alias.get(field, field)
702
779
  if inv_field not in inv_row:
703
780
  continue
704
- left = _norm(seed_val)
705
- right = _resolve_inv_value(
706
- _norm(inv_row[inv_field]),
781
+ left = _enum_norm(
782
+ _norm(seed_val),
707
783
  field=field,
708
- resolves=resolves,
709
- fk_indexes=indexes,
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,
710
797
  )
711
798
  if left != right:
712
799
  deltas.append(
@@ -742,21 +829,32 @@ def _norm(value: Any) -> str:
742
829
 
743
830
  String sides strip leading/trailing whitespace (DAC/NVarChar padding).
744
831
  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.
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.
747
837
  """
748
838
  if isinstance(value, bool):
749
- return "true" if value else "false"
750
- if value is None:
751
- return ""
752
- if isinstance(value, int) and not isinstance(value, bool):
753
- return str(value)
754
- 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):
755
845
  as_int = int(value)
756
- if value == float(as_int):
757
- return str(as_int)
758
- return str(value)
759
- 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
760
858
 
761
859
 
762
860
  # ---------------------------------------------------------------------------
@@ -3,8 +3,9 @@
3
3
  # table → catalog entity. Optional keys:/fields: rename seed names →
4
4
  # inventory columns so join/compare works when DAC names differ (M3).
5
5
  # Global resolvers: + per-row resolves: map inv int FKs → CD (M4).
6
+ # Global enums: + per-row enums: map REST labels → DAC codes (M5).
6
7
  # Data-repo root snapshot_map.yaml wins over this package file.
7
- # v1 rows remain {table, entity} only — aliases/resolves optional.
8
+ # v1 rows remain {table, entity} only — aliases/resolves/enums optional.
8
9
 
9
10
  resolvers:
10
11
  account_cd:
@@ -15,6 +16,37 @@ resolvers:
15
16
  table: Sub
16
17
  id: SubID
17
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"
18
50
 
19
51
  tables:
20
52
  # Wave 1 renames — key/field aliases where seed ≠ DAC column
@@ -22,8 +54,14 @@ tables:
22
54
  entity: Subaccount
23
55
  keys:
24
56
  SubaccountCD: SubCD
57
+ enums:
58
+ Active: bool_bit
25
59
  - table: Terms
26
60
  entity: CreditTerms
61
+ enums:
62
+ DueType: due_type
63
+ DiscType: disc_type
64
+ VisibleTo: visible_to
27
65
  - table: UnitOfMeasure
28
66
  entity: UnitsOfMeasure
29
67
  keys:
@@ -34,6 +72,10 @@ tables:
34
72
  entity: AvailabilityCalculationRule
35
73
  - table: SOSetup
36
74
  entity: SOPreferences
75
+ enums:
76
+ AutoReleaseIN: bool_bit
77
+ HoldShipments: bool_bit
78
+ RequireShipmentTotal: bool_bit
37
79
  - table: POSetup
38
80
  entity: POPreferences
39
81
  - table: ARSetup
@@ -41,13 +83,28 @@ tables:
41
83
  - table: APSetup
42
84
  entity: APPreferences
43
85
 
44
- # Wave 2 — table renames (field aliases / more FKs later)
86
+ # Wave 2 — table renames + PostingClass FK resolve
45
87
  - table: INPostClass
46
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
47
102
  - table: INItemClass
48
103
  entity: ItemClass
49
104
  - table: INSite
50
105
  entity: Warehouse
106
+ enums:
107
+ Active: bool_bit
51
108
  - table: InventoryItem
52
109
  entity: StockItem
53
110
  - table: INSetup
@@ -62,17 +119,33 @@ tables:
62
119
  entity: KitSpecification
63
120
  - table: SOOrderType
64
121
  entity: OrderType
122
+ resolves:
123
+ FreightAcctID: account_cd
124
+ FreightSubID: sub_cd
125
+ enums:
126
+ Active: bool_bit
65
127
  - table: ARStatementCycle
66
128
  entity: StatementCycle
129
+ enums:
130
+ PrepareOn: prepare_on
67
131
  - table: OrganizationLedgerLink
68
132
  entity: LedgerCompany
69
133
 
70
- # Identity tables needing FK CD resolve (M4) — table name == entity
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
71
142
  - table: ReasonCode
72
143
  entity: ReasonCode
73
144
  resolves:
74
145
  AccountID: account_cd
75
146
  SubID: sub_cd
147
+ enums:
148
+ Usage: reason_usage
76
149
  - table: VendorClass
77
150
  entity: VendorClass
78
151
  resolves:
@@ -84,3 +157,11 @@ tables:
84
157
  ExpenseSubID: sub_cd
85
158
  POAccrualAcctID: account_cd
86
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