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.
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/PKG-INFO +3 -1
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/README.md +2 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/pyproject.toml +1 -1
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/pyproject.toml.orig +1 -1
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/cli.py +4 -1
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/reconcile.py +163 -13
- acumatica_cli-0.17.0/src/acumatica_cli/snapshot_map.yaml +86 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/client.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/config.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/extract.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/inventory.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/run.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/seed.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/seed_catalog.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/state.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/target.py +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/README.md +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/env +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/gitignore +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
- {acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/target +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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).
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
"""
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/20-buy.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/30-build.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.16.0 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/40-sell.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|