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