acumatica-cli 0.30.0__tar.gz → 0.32.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.30.0 → acumatica_cli-0.32.0}/PKG-INFO +25 -24
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/README.md +24 -23
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/pyproject.toml +1 -1
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/pyproject.toml.orig +1 -1
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/cli.py +41 -21
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/client.py +25 -15
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/extract.py +28 -3
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/reconcile.py +2 -2
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/seed.py +55 -7
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/README.md +2 -2
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/bootstrap.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/config.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/inventory.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/run.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/seed_catalog.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/snapshot_map.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/state.py +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/baseline/91-company-packaging.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/bootstrap/segmented-key.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/90-roles.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/91-users.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/master/92-role-users.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/env +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/gitignore +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/overlays/README.md +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/overlays/default-24.200.001/README.md +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/overlays/default-24.200.001/scenario/30-build.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
- {acumatica_cli-0.30.0 → acumatica_cli-0.32.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.32.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>
|
|
@@ -40,21 +40,21 @@ YAML in git is the source of truth; the live tenant is the target. `apply` is th
|
|
|
40
40
|
| ------- | ------- | ------ |
|
|
41
41
|
| Seed a tenant from YAML | `apply` | Idempotent PUT of `config/` into the live tenant |
|
|
42
42
|
| Detect drift | `diff` | Compare seed YAML to live (exit 2 when they diverge) |
|
|
43
|
-
| Pull live config into YAML | `extract` | Inverse of `apply` — GET into `config/{bootstrap,baseline,setup,master}/` |
|
|
43
|
+
| Pull live config into YAML | `survey extract` | Inverse of `apply` — GET into `config/{bootstrap,baseline,setup,master}/` |
|
|
44
44
|
| Run transaction scripts | `run` | Forward documents from `scenario/` (capital, buy, build, sell) |
|
|
45
45
|
| Capture derived balances | `state` | Inquire trial-balance etc. into `state/` (not seed) |
|
|
46
46
|
| Create and destroy tenants | `tenant` | SSH control plane: list / create / delete / recycle |
|
|
47
47
|
| Publish the Bootstrap contract | `bootstrap` | REST publish of AcuBootstrap (or `--export` zip for the UI) |
|
|
48
48
|
| Prove a cold rebuild | `tenant create` then `apply` then `run` then `diff` | Compose on a fresh tenant. No wrap command. |
|
|
49
|
-
| Snapshot a site offline | `inventory` | SM203520 XML ZIP or `ac.exe export xml` writes `inventory/` |
|
|
50
|
-
| Cross-check snapshot vs seed | `reconcile` | `inventory/` plus optional `config/` writes `findings/` only |
|
|
49
|
+
| Snapshot a site offline | `survey inventory` | SM203520 XML ZIP or `ac.exe export xml` writes `inventory/` |
|
|
50
|
+
| Cross-check snapshot vs seed | `survey reconcile` | `inventory/` plus optional `config/` writes `findings/` only |
|
|
51
51
|
| Dump the contract schema | `schema` | OpenAPI `swagger.json` for the pinned endpoint |
|
|
52
52
|
| Scaffold a data repo | `config init` | Full `config/`, `scenario/`, `.env` tree |
|
|
53
53
|
| Preflight a target | `config check` | Discovery, secrets, REST, endpoints, SSH |
|
|
54
54
|
|
|
55
55
|
Seed YAML covers features, company, credit terms, subaccounts, chart of accounts, ledger, UOMs, financial year / calendar / periods, numbering sequences, inventory and distribution masters, roles, and users.
|
|
56
56
|
|
|
57
|
-
REST is the data plane (`apply`, `diff`, `run`, `extract`, `state`, `bootstrap`, `schema`).
|
|
57
|
+
REST is the data plane (`apply`, `diff`, `run`, `survey extract`, `state`, `bootstrap`, `schema`).
|
|
58
58
|
SSH is the control plane (`tenant *`).
|
|
59
59
|
|
|
60
60
|
Hosted sites skip SSH.
|
|
@@ -116,11 +116,12 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
116
116
|
├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
|
|
117
117
|
├── state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
|
|
118
118
|
│ capture derived state into state/ (not seed)
|
|
119
|
-
├──
|
|
120
|
-
│
|
|
121
|
-
|
|
122
|
-
│
|
|
123
|
-
|
|
119
|
+
├── survey existing-tenant dual-reader (never L1 extract/inventory/reconcile)
|
|
120
|
+
│ ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
|
|
121
|
+
│ │ inverse of apply into config/{bootstrap,baseline,setup,master}/
|
|
122
|
+
│ ├── inventory [--out DIR] [--force] [--dry-run] ARTIFACT
|
|
123
|
+
│ │ offline snapshot artifact → inventory/ (not seed)
|
|
124
|
+
│ └── reconcile [--inventory DIR] [--config DIR] [--out DIR] [--force] [--dry-run]
|
|
124
125
|
│ inventory/ + optional config/ → findings/ only
|
|
125
126
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
126
127
|
│
|
|
@@ -141,12 +142,12 @@ When you omit FILES:
|
|
|
141
142
|
Scenario YAML may use `${current_period}` (host-local `MMyyyy`) on steps, expect params, and `once.present` params.
|
|
142
143
|
`config/views` and `state` keep Period pinned — see [docs/demo-seed.md](docs/demo-seed.md#period-token-current_period-vs-pinned-views).
|
|
143
144
|
|
|
144
|
-
`extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
|
|
145
|
+
`survey extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
|
|
145
146
|
|
|
146
|
-
`inventory` and `reconcile` are offline (no REST, SSH, or password).
|
|
147
|
-
`inventory` turns an SM203520 Settings XML ZIP or `ac.exe export xml` folder into `inventory/`.
|
|
147
|
+
`survey inventory` and `survey reconcile` are offline (no REST, SSH, or password).
|
|
148
|
+
`survey inventory` turns an SM203520 Settings XML ZIP or `ac.exe export xml` folder into `inventory/`.
|
|
148
149
|
|
|
149
|
-
`reconcile` compares `inventory/` to optional `config/` and writes `findings/` only.
|
|
150
|
+
`survey reconcile` compares `inventory/` to optional `config/` and writes `findings/` only.
|
|
150
151
|
It never writes seed.
|
|
151
152
|
|
|
152
153
|
Optional `snapshot_map.yaml` (data-repo root, or package defaults) maps DAC tables to catalog entities.
|
|
@@ -168,9 +169,9 @@ Do not confuse them with each other or with `state`:
|
|
|
168
169
|
|
|
169
170
|
| Command | Plane | Input | Writes | Role |
|
|
170
171
|
| ------- | ----- | ----- | ------ | ---- |
|
|
171
|
-
| `extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
|
|
172
|
-
| `inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
|
|
173
|
-
| `reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
|
|
172
|
+
| `survey extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
|
|
173
|
+
| `survey inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
|
|
174
|
+
| `survey reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
|
|
174
175
|
| `state` | REST (live) | `config/views/` | `state/` | Derived balances/totals — not seed, not inventory |
|
|
175
176
|
| `apply` | REST (live) | seed YAML under `config/` | tenant | **Sole** tenant writer (keyed PUT) |
|
|
176
177
|
|
|
@@ -198,8 +199,8 @@ There is no `--flavor`.
|
|
|
198
199
|
| `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
|
|
199
200
|
| `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
|
|
200
201
|
| `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
|
|
201
|
-
| `inventory/` | engagement: offline snapshot tables from `acu inventory` (not seed; not SEED_DIRS) |
|
|
202
|
-
| `findings/` | engagement: `acu reconcile` cross-check output (never apply path) |
|
|
202
|
+
| `inventory/` | engagement: offline snapshot tables from `acu survey inventory` (not seed; not SEED_DIRS) |
|
|
203
|
+
| `findings/` | engagement: `acu survey reconcile` cross-check output (never apply path) |
|
|
203
204
|
| `.env` | secrets + where (`ACU_BASE_URL`) + pin (`ACU_API_VERSION`) |
|
|
204
205
|
|
|
205
206
|
Legacy data repos may still keep root `bootstrap/`…`master/`; bare `apply`/`diff` prefer `config/` when present and never merge both trees.
|
|
@@ -208,7 +209,7 @@ Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-
|
|
|
208
209
|
Commit seed YAML; keep `.env` out of git (scaffold `.gitignore` already lists it).
|
|
209
210
|
|
|
210
211
|
Seed YAML is state: `apply` upserts it, `diff` proves it.
|
|
211
|
-
`acu extract` is the inverse of `apply`: GET live tenant rows into seed YAML under `config/{bootstrap,baseline,setup,master}/` (hard-cut).
|
|
212
|
+
`acu survey extract` is the inverse of `apply`: GET live tenant rows into seed YAML under `config/{bootstrap,baseline,setup,master}/` (hard-cut).
|
|
212
213
|
|
|
213
214
|
Packaged `seed_catalog.yaml` is the sole extract registry (entity, endpoint, keys, file, strip/include, filter-split).
|
|
214
215
|
The demo entity map in [docs/demo-seed.md](docs/demo-seed.md) mirrors those catalog paths.
|
|
@@ -220,7 +221,7 @@ Row failures continue (exit 1 only if any row failed).
|
|
|
220
221
|
Drift stays with `diff`.
|
|
221
222
|
|
|
222
223
|
```sh
|
|
223
|
-
acu --tenant DEV extract --out . --force # refresh config/** from live tenant
|
|
224
|
+
acu --tenant DEV survey extract --out . --force # refresh config/** from live tenant
|
|
224
225
|
git diff config/ # review extract delta before commit
|
|
225
226
|
acu --tenant DEV apply config/ # replay extracted seed
|
|
226
227
|
acu --tenant DEV diff config/ # expect exit 0
|
|
@@ -377,9 +378,9 @@ gmake release patch # or minor | major
|
|
|
377
378
|
2. Fail if `## Unreleased` has no bullets
|
|
378
379
|
3. Bump `pyproject.toml` version (`major` | `minor` | `patch`)
|
|
379
380
|
4. Promote Unreleased body to `## [vX.Y.Z] - YYYY-MM-DD`, leave an empty `## Unreleased`
|
|
380
|
-
5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together
|
|
381
|
-
6. `gmake check` again on the promoted tree
|
|
382
|
-
7.
|
|
381
|
+
5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together
|
|
382
|
+
6. `gmake check` again on the promoted tree — fail keeps the commit, creates no tag, pushes nothing
|
|
383
|
+
7. Tag `vX.Y.Z` and push branch + that tag — GitHub Actions publishes
|
|
383
384
|
|
|
384
385
|
GitHub Actions on tag `v*` re-runs CI, builds sdist+wheel, publishes to PyPI via OIDC trusted publishing, and creates a GitHub Release whose notes are the promoted CHANGELOG section for that tag (plus the artifacts).
|
|
385
386
|
|
|
@@ -22,21 +22,21 @@ YAML in git is the source of truth; the live tenant is the target. `apply` is th
|
|
|
22
22
|
| ------- | ------- | ------ |
|
|
23
23
|
| Seed a tenant from YAML | `apply` | Idempotent PUT of `config/` into the live tenant |
|
|
24
24
|
| Detect drift | `diff` | Compare seed YAML to live (exit 2 when they diverge) |
|
|
25
|
-
| Pull live config into YAML | `extract` | Inverse of `apply` — GET into `config/{bootstrap,baseline,setup,master}/` |
|
|
25
|
+
| Pull live config into YAML | `survey extract` | Inverse of `apply` — GET into `config/{bootstrap,baseline,setup,master}/` |
|
|
26
26
|
| Run transaction scripts | `run` | Forward documents from `scenario/` (capital, buy, build, sell) |
|
|
27
27
|
| Capture derived balances | `state` | Inquire trial-balance etc. into `state/` (not seed) |
|
|
28
28
|
| Create and destroy tenants | `tenant` | SSH control plane: list / create / delete / recycle |
|
|
29
29
|
| Publish the Bootstrap contract | `bootstrap` | REST publish of AcuBootstrap (or `--export` zip for the UI) |
|
|
30
30
|
| Prove a cold rebuild | `tenant create` then `apply` then `run` then `diff` | Compose on a fresh tenant. No wrap command. |
|
|
31
|
-
| Snapshot a site offline | `inventory` | SM203520 XML ZIP or `ac.exe export xml` writes `inventory/` |
|
|
32
|
-
| Cross-check snapshot vs seed | `reconcile` | `inventory/` plus optional `config/` writes `findings/` only |
|
|
31
|
+
| Snapshot a site offline | `survey inventory` | SM203520 XML ZIP or `ac.exe export xml` writes `inventory/` |
|
|
32
|
+
| Cross-check snapshot vs seed | `survey reconcile` | `inventory/` plus optional `config/` writes `findings/` only |
|
|
33
33
|
| Dump the contract schema | `schema` | OpenAPI `swagger.json` for the pinned endpoint |
|
|
34
34
|
| Scaffold a data repo | `config init` | Full `config/`, `scenario/`, `.env` tree |
|
|
35
35
|
| Preflight a target | `config check` | Discovery, secrets, REST, endpoints, SSH |
|
|
36
36
|
|
|
37
37
|
Seed YAML covers features, company, credit terms, subaccounts, chart of accounts, ledger, UOMs, financial year / calendar / periods, numbering sequences, inventory and distribution masters, roles, and users.
|
|
38
38
|
|
|
39
|
-
REST is the data plane (`apply`, `diff`, `run`, `extract`, `state`, `bootstrap`, `schema`).
|
|
39
|
+
REST is the data plane (`apply`, `diff`, `run`, `survey extract`, `state`, `bootstrap`, `schema`).
|
|
40
40
|
SSH is the control plane (`tenant *`).
|
|
41
41
|
|
|
42
42
|
Hosted sites skip SSH.
|
|
@@ -98,11 +98,12 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
98
98
|
├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
|
|
99
99
|
├── state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
|
|
100
100
|
│ capture derived state into state/ (not seed)
|
|
101
|
-
├──
|
|
102
|
-
│
|
|
103
|
-
|
|
104
|
-
│
|
|
105
|
-
|
|
101
|
+
├── survey existing-tenant dual-reader (never L1 extract/inventory/reconcile)
|
|
102
|
+
│ ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
|
|
103
|
+
│ │ inverse of apply into config/{bootstrap,baseline,setup,master}/
|
|
104
|
+
│ ├── inventory [--out DIR] [--force] [--dry-run] ARTIFACT
|
|
105
|
+
│ │ offline snapshot artifact → inventory/ (not seed)
|
|
106
|
+
│ └── reconcile [--inventory DIR] [--config DIR] [--out DIR] [--force] [--dry-run]
|
|
106
107
|
│ inventory/ + optional config/ → findings/ only
|
|
107
108
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
108
109
|
│
|
|
@@ -123,12 +124,12 @@ When you omit FILES:
|
|
|
123
124
|
Scenario YAML may use `${current_period}` (host-local `MMyyyy`) on steps, expect params, and `once.present` params.
|
|
124
125
|
`config/views` and `state` keep Period pinned — see [docs/demo-seed.md](docs/demo-seed.md#period-token-current_period-vs-pinned-views).
|
|
125
126
|
|
|
126
|
-
`extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
|
|
127
|
+
`survey extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
|
|
127
128
|
|
|
128
|
-
`inventory` and `reconcile` are offline (no REST, SSH, or password).
|
|
129
|
-
`inventory` turns an SM203520 Settings XML ZIP or `ac.exe export xml` folder into `inventory/`.
|
|
129
|
+
`survey inventory` and `survey reconcile` are offline (no REST, SSH, or password).
|
|
130
|
+
`survey inventory` turns an SM203520 Settings XML ZIP or `ac.exe export xml` folder into `inventory/`.
|
|
130
131
|
|
|
131
|
-
`reconcile` compares `inventory/` to optional `config/` and writes `findings/` only.
|
|
132
|
+
`survey reconcile` compares `inventory/` to optional `config/` and writes `findings/` only.
|
|
132
133
|
It never writes seed.
|
|
133
134
|
|
|
134
135
|
Optional `snapshot_map.yaml` (data-repo root, or package defaults) maps DAC tables to catalog entities.
|
|
@@ -150,9 +151,9 @@ Do not confuse them with each other or with `state`:
|
|
|
150
151
|
|
|
151
152
|
| Command | Plane | Input | Writes | Role |
|
|
152
153
|
| ------- | ----- | ----- | ------ | ---- |
|
|
153
|
-
| `extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
|
|
154
|
-
| `inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
|
|
155
|
-
| `reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
|
|
154
|
+
| `survey extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
|
|
155
|
+
| `survey inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
|
|
156
|
+
| `survey reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
|
|
156
157
|
| `state` | REST (live) | `config/views/` | `state/` | Derived balances/totals — not seed, not inventory |
|
|
157
158
|
| `apply` | REST (live) | seed YAML under `config/` | tenant | **Sole** tenant writer (keyed PUT) |
|
|
158
159
|
|
|
@@ -180,8 +181,8 @@ There is no `--flavor`.
|
|
|
180
181
|
| `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
|
|
181
182
|
| `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
|
|
182
183
|
| `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
|
|
183
|
-
| `inventory/` | engagement: offline snapshot tables from `acu inventory` (not seed; not SEED_DIRS) |
|
|
184
|
-
| `findings/` | engagement: `acu reconcile` cross-check output (never apply path) |
|
|
184
|
+
| `inventory/` | engagement: offline snapshot tables from `acu survey inventory` (not seed; not SEED_DIRS) |
|
|
185
|
+
| `findings/` | engagement: `acu survey reconcile` cross-check output (never apply path) |
|
|
185
186
|
| `.env` | secrets + where (`ACU_BASE_URL`) + pin (`ACU_API_VERSION`) |
|
|
186
187
|
|
|
187
188
|
Legacy data repos may still keep root `bootstrap/`…`master/`; bare `apply`/`diff` prefer `config/` when present and never merge both trees.
|
|
@@ -190,7 +191,7 @@ Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-
|
|
|
190
191
|
Commit seed YAML; keep `.env` out of git (scaffold `.gitignore` already lists it).
|
|
191
192
|
|
|
192
193
|
Seed YAML is state: `apply` upserts it, `diff` proves it.
|
|
193
|
-
`acu extract` is the inverse of `apply`: GET live tenant rows into seed YAML under `config/{bootstrap,baseline,setup,master}/` (hard-cut).
|
|
194
|
+
`acu survey extract` is the inverse of `apply`: GET live tenant rows into seed YAML under `config/{bootstrap,baseline,setup,master}/` (hard-cut).
|
|
194
195
|
|
|
195
196
|
Packaged `seed_catalog.yaml` is the sole extract registry (entity, endpoint, keys, file, strip/include, filter-split).
|
|
196
197
|
The demo entity map in [docs/demo-seed.md](docs/demo-seed.md) mirrors those catalog paths.
|
|
@@ -202,7 +203,7 @@ Row failures continue (exit 1 only if any row failed).
|
|
|
202
203
|
Drift stays with `diff`.
|
|
203
204
|
|
|
204
205
|
```sh
|
|
205
|
-
acu --tenant DEV extract --out . --force # refresh config/** from live tenant
|
|
206
|
+
acu --tenant DEV survey extract --out . --force # refresh config/** from live tenant
|
|
206
207
|
git diff config/ # review extract delta before commit
|
|
207
208
|
acu --tenant DEV apply config/ # replay extracted seed
|
|
208
209
|
acu --tenant DEV diff config/ # expect exit 0
|
|
@@ -359,9 +360,9 @@ gmake release patch # or minor | major
|
|
|
359
360
|
2. Fail if `## Unreleased` has no bullets
|
|
360
361
|
3. Bump `pyproject.toml` version (`major` | `minor` | `patch`)
|
|
361
362
|
4. Promote Unreleased body to `## [vX.Y.Z] - YYYY-MM-DD`, leave an empty `## Unreleased`
|
|
362
|
-
5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together
|
|
363
|
-
6. `gmake check` again on the promoted tree
|
|
364
|
-
7.
|
|
363
|
+
5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together
|
|
364
|
+
6. `gmake check` again on the promoted tree — fail keeps the commit, creates no tag, pushes nothing
|
|
365
|
+
7. Tag `vX.Y.Z` and push branch + that tag — GitHub Actions publishes
|
|
365
366
|
|
|
366
367
|
GitHub Actions on tag `v*` re-runs CI, builds sdist+wheel, publishes to PyPI via OIDC trusted publishing, and creates a GitHub Release whose notes are the promoted CHANGELOG section for that tag (plus the artifacts).
|
|
367
368
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acumatica-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.32.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"
|
|
@@ -5,13 +5,13 @@ Configure Acumatica ERP from YAML in a git data repo. No UI wizards.
|
|
|
5
5
|
\b
|
|
6
6
|
MENTAL MODEL
|
|
7
7
|
Data repo = directory with .env (walk-up from cwd). Holds:
|
|
8
|
-
config/{bootstrap,baseline,setup,master}/ seed YAML (apply/diff/extract)
|
|
8
|
+
config/{bootstrap,baseline,setup,master}/ seed YAML (apply/diff/survey extract)
|
|
9
9
|
config/views/ observer views (state)
|
|
10
10
|
scenario/ transaction scripts (run)
|
|
11
11
|
.env secrets + where + API pin
|
|
12
12
|
state/ inventory/ findings/ schemas/ command outputs (not seed)
|
|
13
13
|
Two planes (do not mix):
|
|
14
|
-
REST data plane — apply diff run bootstrap extract state schema
|
|
14
|
+
REST data plane — apply diff run bootstrap survey extract state schema
|
|
15
15
|
needs: base_url, tenant, password (SSH optional)
|
|
16
16
|
SSH control plane — tenant list|create|delete|recycle
|
|
17
17
|
needs: non-empty ACU_SSH (user@host)
|
|
@@ -56,9 +56,9 @@ COMMAND MAP (pick by intent)
|
|
|
56
56
|
seed check diff [FILES...] (exit 2 on drift)
|
|
57
57
|
txns run [--dry-run] [FILES...]
|
|
58
58
|
cold rebuild tenant create then apply then run then diff (no wrap cmd)
|
|
59
|
-
pull seed extract [--only ENTITY]...
|
|
59
|
+
pull seed survey extract [--only ENTITY]... (inverse of apply)
|
|
60
60
|
observe state [--diff|--assert-unchanged]
|
|
61
|
-
offline inventory ARTIFACT | reconcile
|
|
61
|
+
offline survey inventory ARTIFACT | survey reconcile
|
|
62
62
|
reference schema
|
|
63
63
|
|
|
64
64
|
\b
|
|
@@ -73,7 +73,7 @@ DEFAULT PATHS (when FILES omitted)
|
|
|
73
73
|
then overlays/default-<api>/ when present
|
|
74
74
|
run -> scenario/ (overlay same-basename wins)
|
|
75
75
|
state -> config/views/ -> writes state/
|
|
76
|
-
extract
|
|
76
|
+
survey extract -> always writes config/{bootstrap,baseline,setup,master}/
|
|
77
77
|
|
|
78
78
|
Run `acu <command> --help` for flags, examples, and prerequisites.
|
|
79
79
|
See package README and docs/demo-seed.md for seed YAML shape.
|
|
@@ -1027,7 +1027,7 @@ def apply_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
|
|
|
1027
1027
|
acu --tenant DEV apply --dry-run config/master/
|
|
1028
1028
|
acu --tenant DEV apply config/baseline/20-accounts.yaml
|
|
1029
1029
|
|
|
1030
|
-
Related: `diff` (drift) · `extract` (inverse pull) · `run` (txns).
|
|
1030
|
+
Related: `diff` (drift) · `survey extract` (inverse pull) · `run` (txns).
|
|
1031
1031
|
"""
|
|
1032
1032
|
total_ok = 0
|
|
1033
1033
|
all_errors: list[str] = []
|
|
@@ -1106,7 +1106,7 @@ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
|
|
|
1106
1106
|
acu --tenant DEV diff config/
|
|
1107
1107
|
acu --tenant DEV diff config/master/90-roles.yaml
|
|
1108
1108
|
|
|
1109
|
-
Related: `apply` (fix) · `extract` (pull) · `state` (balances).
|
|
1109
|
+
Related: `apply` (fix) · `survey extract` (pull) · `state` (balances).
|
|
1110
1110
|
"""
|
|
1111
1111
|
paths = expand_files(files or default_apply_dirs(inst))
|
|
1112
1112
|
drifts: list[str] = []
|
|
@@ -1189,7 +1189,27 @@ def _complete_only(
|
|
|
1189
1189
|
]
|
|
1190
1190
|
|
|
1191
1191
|
|
|
1192
|
-
@cli.
|
|
1192
|
+
@cli.group("survey", context_settings=_HELP_CTX)
|
|
1193
|
+
def survey_group() -> None:
|
|
1194
|
+
"""Existing-tenant dual-reader (REST extract + offline inventory/reconcile).
|
|
1195
|
+
|
|
1196
|
+
Noun owns extract|inventory|reconcile. Never L1 those verbs. No aliases.
|
|
1197
|
+
|
|
1198
|
+
\b
|
|
1199
|
+
Subcommands
|
|
1200
|
+
extract [--only ENTITY]... live GET -> config/ SEED_DIRS (REST)
|
|
1201
|
+
inventory ARTIFACT snapshot ZIP/folder -> inventory/ (offline)
|
|
1202
|
+
reconcile inventory/ + optional config/ -> findings/ (offline)
|
|
1203
|
+
|
|
1204
|
+
\b
|
|
1205
|
+
Examples
|
|
1206
|
+
acu --tenant DEV survey extract
|
|
1207
|
+
acu survey inventory ./export-xml/
|
|
1208
|
+
acu survey reconcile
|
|
1209
|
+
"""
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
@survey_group.command("extract")
|
|
1193
1213
|
@click.option(
|
|
1194
1214
|
"--out",
|
|
1195
1215
|
"out_dir",
|
|
@@ -1230,11 +1250,11 @@ def extract_cmd(
|
|
|
1230
1250
|
|
|
1231
1251
|
\b
|
|
1232
1252
|
Examples
|
|
1233
|
-
acu --tenant DEV extract
|
|
1234
|
-
acu --tenant DEV extract --only Account --only Vendor
|
|
1235
|
-
acu --tenant DEV extract --force --dry-run
|
|
1253
|
+
acu --tenant DEV survey extract
|
|
1254
|
+
acu --tenant DEV survey extract --only Account --only Vendor
|
|
1255
|
+
acu --tenant DEV survey extract --force --dry-run
|
|
1236
1256
|
|
|
1237
|
-
Related: `apply` · `diff` · `inventory` · `state`.
|
|
1257
|
+
Related: `apply` · `diff` · `survey inventory` · `state`.
|
|
1238
1258
|
"""
|
|
1239
1259
|
with AcumaticaClient(inst) as client:
|
|
1240
1260
|
failed = extract.run(
|
|
@@ -1258,7 +1278,7 @@ def _exit_on_drift(inst: Instance, drifts: list[str], files: int) -> None:
|
|
|
1258
1278
|
output.success(f"no drift on {inst.tenant} ({inst.base_url}, {files} file(s))")
|
|
1259
1279
|
|
|
1260
1280
|
|
|
1261
|
-
@
|
|
1281
|
+
@survey_group.command("inventory")
|
|
1262
1282
|
@click.argument(
|
|
1263
1283
|
"artifact",
|
|
1264
1284
|
type=click.Path(exists=True, path_type=Path),
|
|
@@ -1290,15 +1310,15 @@ def inventory_cmd(
|
|
|
1290
1310
|
Existing files skip unless --force.
|
|
1291
1311
|
|
|
1292
1312
|
Not extract (REST → config/ seed). Not state (balances). Never writes
|
|
1293
|
-
config/ or state/. Feed inventory/ into `acu reconcile`.
|
|
1313
|
+
config/ or state/. Feed inventory/ into `acu survey reconcile`.
|
|
1294
1314
|
|
|
1295
1315
|
\b
|
|
1296
1316
|
Exit 0 = clean · 1 = parse/format/version fail · never 2
|
|
1297
1317
|
|
|
1298
1318
|
\b
|
|
1299
1319
|
Examples
|
|
1300
|
-
acu inventory ./export-xml/
|
|
1301
|
-
acu inventory Settings.zip --out inventory/ --force
|
|
1320
|
+
acu survey inventory ./export-xml/
|
|
1321
|
+
acu survey inventory Settings.zip --out inventory/ --force
|
|
1302
1322
|
"""
|
|
1303
1323
|
# V9 long single-op: artifact parse (ZIP/folder IR) via step; banner +
|
|
1304
1324
|
# per-table write/skip emit stay multi-unit stdout after.
|
|
@@ -1312,13 +1332,13 @@ def inventory_cmd(
|
|
|
1312
1332
|
inventory.emit(art, dest, force=force, dry_run=dry_run)
|
|
1313
1333
|
|
|
1314
1334
|
|
|
1315
|
-
@
|
|
1335
|
+
@survey_group.command("reconcile")
|
|
1316
1336
|
@click.option(
|
|
1317
1337
|
"--inventory",
|
|
1318
1338
|
"inventory_dir",
|
|
1319
1339
|
type=click.Path(file_okay=False, path_type=Path),
|
|
1320
1340
|
default=None,
|
|
1321
|
-
help="Inventory tree from `acu inventory` (default: inventory/)",
|
|
1341
|
+
help="Inventory tree from `acu survey inventory` (default: inventory/)",
|
|
1322
1342
|
)
|
|
1323
1343
|
@click.option(
|
|
1324
1344
|
"--config",
|
|
@@ -1361,8 +1381,8 @@ def reconcile_cmd(
|
|
|
1361
1381
|
|
|
1362
1382
|
\b
|
|
1363
1383
|
Examples
|
|
1364
|
-
acu inventory export.zip && acu reconcile
|
|
1365
|
-
acu reconcile --inventory inv/ --config config/ --out findings/
|
|
1384
|
+
acu survey inventory export.zip && acu survey reconcile
|
|
1385
|
+
acu survey reconcile --inventory inv/ --config config/ --out findings/
|
|
1366
1386
|
"""
|
|
1367
1387
|
inv = (
|
|
1368
1388
|
inventory_dir
|
|
@@ -1440,7 +1460,7 @@ def state_cmd(
|
|
|
1440
1460
|
acu --tenant DEV state --assert-unchanged
|
|
1441
1461
|
acu --tenant DEV state config/views/10-trial-balance.yaml
|
|
1442
1462
|
|
|
1443
|
-
Related: `run` (moves balances) · `diff` (seed drift) · `extract`.
|
|
1463
|
+
Related: `run` (moves balances) · `diff` (seed drift) · `survey extract`.
|
|
1444
1464
|
"""
|
|
1445
1465
|
if not files:
|
|
1446
1466
|
default = data_root() / "config" / "views"
|
|
@@ -12,6 +12,11 @@ import httpx
|
|
|
12
12
|
|
|
13
13
|
from .config import Instance
|
|
14
14
|
|
|
15
|
+
# Contract control fields travel bare on wrap and stay on unwrap of
|
|
16
|
+
# value-field rows (V54). Files-style rows that unwrap to only these
|
|
17
|
+
# keys are elided so expanded `files` descriptors stay noise.
|
|
18
|
+
_BARE_FIELDS = frozenset({"id", "delete"})
|
|
19
|
+
|
|
15
20
|
|
|
16
21
|
def wrap(record: dict[str, Any]) -> dict[str, Any]:
|
|
17
22
|
"""Plain dict -> contract-API body: {"Field": {"value": ...}}.
|
|
@@ -33,31 +38,35 @@ def wrap(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
33
38
|
return wrap(v)
|
|
34
39
|
return {"value": v}
|
|
35
40
|
|
|
36
|
-
return {k: v if k in
|
|
41
|
+
return {k: v if k in _BARE_FIELDS else _value(v) for k, v in record.items()}
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
|
|
40
45
|
"""Contract-API entity -> plain dict (value fields + detail arrays).
|
|
41
46
|
|
|
42
|
-
Inverse of wrap (T60/T65): a list of dicts unwraps row by row;
|
|
43
|
-
that are empty or whose rows carry no value fields are elided
|
|
44
|
-
detail array the source never claimed must not surface, and
|
|
45
|
-
`files` descriptors are plain dicts that would unwrap to
|
|
46
|
-
non-value dict is a linked entity — unwrapped recursively,
|
|
47
|
-
when something survives, so bookkeeping dicts (custom,
|
|
48
|
-
stay excluded exactly as before.
|
|
47
|
+
Inverse of wrap (T60/T65/V54): a list of dicts unwraps row by row;
|
|
48
|
+
lists that are empty or whose rows carry no value fields are elided
|
|
49
|
+
(a detail array the source never claimed must not surface, and
|
|
50
|
+
expanded `files` descriptors are plain dicts that would unwrap to
|
|
51
|
+
noise). A non-value dict is a linked entity — unwrapped recursively,
|
|
52
|
+
kept only when something survives, so bookkeeping dicts (custom,
|
|
53
|
+
_links, note) stay excluded exactly as before. `id` and `delete`
|
|
54
|
+
stay on records and on detail rows that also have value fields so a
|
|
55
|
+
later PUT updates the existing line (V54).
|
|
49
56
|
"""
|
|
50
57
|
out: dict[str, Any] = {}
|
|
51
58
|
for k, v in entity.items():
|
|
52
|
-
if isinstance(v, dict)
|
|
59
|
+
if k in _BARE_FIELDS and not isinstance(v, (dict, list)):
|
|
60
|
+
out[k] = v
|
|
61
|
+
elif isinstance(v, dict) and "value" in v:
|
|
53
62
|
out[k] = v["value"]
|
|
54
63
|
elif isinstance(v, dict):
|
|
55
64
|
nested = unwrap(v)
|
|
56
65
|
if nested:
|
|
57
66
|
out[k] = nested
|
|
58
67
|
elif isinstance(v, list) and v and all(isinstance(row, dict) for row in v):
|
|
59
|
-
rows = [unwrap(
|
|
60
|
-
if
|
|
68
|
+
rows = [row for row in (unwrap(r) for r in v) if set(row) - _BARE_FIELDS]
|
|
69
|
+
if rows:
|
|
61
70
|
out[k] = rows
|
|
62
71
|
return out
|
|
63
72
|
|
|
@@ -326,7 +335,7 @@ class AcumaticaClient:
|
|
|
326
335
|
|
|
327
336
|
@staticmethod
|
|
328
337
|
def _checked(r: httpx.Response) -> httpx.Response:
|
|
329
|
-
"""Surface exceptionMessage +
|
|
338
|
+
"""Surface exceptionMessage + innerException + Field.error (V46)."""
|
|
330
339
|
if r.is_error:
|
|
331
340
|
detail = ""
|
|
332
341
|
try:
|
|
@@ -338,12 +347,13 @@ class AcumaticaClient:
|
|
|
338
347
|
or body.get("error")
|
|
339
348
|
or ""
|
|
340
349
|
)
|
|
341
|
-
|
|
342
|
-
if not detail and isinstance(body.get("innerException"), dict):
|
|
350
|
+
if isinstance(body.get("innerException"), dict):
|
|
343
351
|
inner = body["innerException"]
|
|
344
|
-
|
|
352
|
+
inner_msg = (
|
|
345
353
|
inner.get("exceptionMessage") or inner.get("message") or ""
|
|
346
354
|
)
|
|
355
|
+
if inner_msg:
|
|
356
|
+
detail = f"{detail}; {inner_msg}" if detail else inner_msg
|
|
347
357
|
field_errs = AcumaticaClient._field_errors(body)
|
|
348
358
|
if field_errs:
|
|
349
359
|
joined = "; ".join(field_errs)
|
|
@@ -46,6 +46,8 @@ from .models import Model, validation_summary
|
|
|
46
46
|
from .seed import (
|
|
47
47
|
NUMBERING_RUNTIME_FIELDS,
|
|
48
48
|
PASSWORD_FIELDS,
|
|
49
|
+
_calendar_day, # pyright: ignore[reportPrivateUsage]
|
|
50
|
+
_drop_path_ignored, # pyright: ignore[reportPrivateUsage]
|
|
49
51
|
active_bootstrap,
|
|
50
52
|
resolve_endpoint,
|
|
51
53
|
)
|
|
@@ -263,14 +265,16 @@ def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
|
|
|
263
265
|
|
|
264
266
|
# Server-assigned / audit fields that must never enter seed (B11 class).
|
|
265
267
|
# Applied recursively under linked entities and detail rows after expand
|
|
266
|
-
# (T65/T119)
|
|
267
|
-
#
|
|
268
|
+
# (T65/T119). `id`/`delete` stay in unwrap for run-capture PUT (V54);
|
|
269
|
+
# extract still strips them so seed is tenant-portable.
|
|
268
270
|
_SERVER_DERIVED = frozenset(
|
|
269
271
|
{
|
|
270
272
|
"ContactID",
|
|
271
273
|
"CreatedDateTime",
|
|
272
274
|
"LastModifiedDateTime",
|
|
273
275
|
"NoteID",
|
|
276
|
+
"delete",
|
|
277
|
+
"id",
|
|
274
278
|
"tstamp",
|
|
275
279
|
}
|
|
276
280
|
)
|
|
@@ -293,6 +297,26 @@ def _elide_server_derived(value: Any) -> Any:
|
|
|
293
297
|
return value
|
|
294
298
|
|
|
295
299
|
|
|
300
|
+
def _date_strip(value: Any) -> Any:
|
|
301
|
+
"""DateTimeValue seed emit: keep YYYY-MM-DD prefix only (V55)."""
|
|
302
|
+
if isinstance(value, dict):
|
|
303
|
+
return {k: _date_strip(v) for k, v in value.items()}
|
|
304
|
+
if isinstance(value, list):
|
|
305
|
+
return [_date_strip(v) for v in value]
|
|
306
|
+
if isinstance(value, str):
|
|
307
|
+
day = _calendar_day(value)
|
|
308
|
+
if day is not None:
|
|
309
|
+
return day
|
|
310
|
+
return value
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _strip_write_only_get_omit(entity: str, field: str, value: Any) -> Any:
|
|
314
|
+
"""Drop path-qualified write-only GET-omit fields from detail rows (V56)."""
|
|
315
|
+
if not isinstance(value, list):
|
|
316
|
+
return value
|
|
317
|
+
return _drop_path_ignored(entity, field, value)
|
|
318
|
+
|
|
319
|
+
|
|
296
320
|
def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
|
|
297
321
|
"""Apply include/strip + nested server-derived elision to one unwrapped row."""
|
|
298
322
|
keep: dict[str, Any] = {}
|
|
@@ -313,6 +337,7 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
|
|
|
313
337
|
continue
|
|
314
338
|
# Nested ContactID / LastModifiedDateTime under expand (T65/T119).
|
|
315
339
|
cleaned = _elide_server_derived(value)
|
|
340
|
+
cleaned = _strip_write_only_get_omit(spec.entity, field, cleaned)
|
|
316
341
|
if cleaned is None or cleaned == "":
|
|
317
342
|
continue
|
|
318
343
|
if isinstance(cleaned, (dict, list)) and not cleaned:
|
|
@@ -320,7 +345,7 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
|
|
|
320
345
|
keep[field] = cleaned
|
|
321
346
|
ordered = {k: keep[k] for k in spec.keys if k in keep}
|
|
322
347
|
ordered |= {k: keep[k] for k in sorted(keep.keys() - set(spec.keys))}
|
|
323
|
-
return ordered
|
|
348
|
+
return _date_strip(ordered)
|
|
324
349
|
|
|
325
350
|
|
|
326
351
|
def _shape(spec: EntitySpec, live: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
@@ -287,7 +287,7 @@ class FindingsBundle(Model):
|
|
|
287
287
|
|
|
288
288
|
|
|
289
289
|
def load_inventory_tree(path: Path | str) -> InventoryTree:
|
|
290
|
-
"""Load an ``inventory/`` tree written by ``acu inventory`` (I.data)."""
|
|
290
|
+
"""Load an ``inventory/`` tree written by ``acu survey inventory`` (I.data)."""
|
|
291
291
|
root = Path(path)
|
|
292
292
|
if not root.is_dir():
|
|
293
293
|
raise SystemExit(f"{root}: inventory directory not found")
|
|
@@ -295,7 +295,7 @@ def load_inventory_tree(path: Path | str) -> InventoryTree:
|
|
|
295
295
|
if not summary_path.is_file():
|
|
296
296
|
raise SystemExit(
|
|
297
297
|
f"{root}: missing {inventory.SUMMARY_NAME} "
|
|
298
|
-
"(run `acu inventory` first, or pass --inventory DIR)"
|
|
298
|
+
"(run `acu survey inventory` first, or pass --inventory DIR)"
|
|
299
299
|
)
|
|
300
300
|
try:
|
|
301
301
|
raw = yaml.safe_load(summary_path.read_text(encoding="utf-8"))
|
|
@@ -52,6 +52,7 @@ Default Currency = CM201000 list), so an ambiguous file is a hard error,
|
|
|
52
52
|
never a silent Default-endpoint PUT.
|
|
53
53
|
"""
|
|
54
54
|
|
|
55
|
+
import re
|
|
55
56
|
from pathlib import Path
|
|
56
57
|
from typing import Any
|
|
57
58
|
|
|
@@ -88,7 +89,34 @@ PASSWORD_FIELDS = frozenset({"Password", "b64__Password"})
|
|
|
88
89
|
# PUTs them (re-apply must not reset live counters).
|
|
89
90
|
NUMBERING_RUNTIME_FIELDS = frozenset({"LastNbr"})
|
|
90
91
|
# Fields omitted from source↔live compare (V39 write-only + V40 runtime).
|
|
92
|
+
# Field-name deny at every nesting level. Do not add bare ``Value`` here —
|
|
93
|
+
# that would hide real drift on unrelated fields (V56).
|
|
91
94
|
_DIFF_IGNORE_FIELDS = PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS
|
|
95
|
+
# Path-qualified write-only GET-omit (V56): (entity, detail, field).
|
|
96
|
+
# Diff ignores; extract strips; apply still PUTs when present in YAML.
|
|
97
|
+
_DIFF_IGNORE_PATHS = frozenset({("LotSerialClass", "Segments", "Value")})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _diff_ignored(entity: str, *path: str) -> bool:
|
|
101
|
+
"""True when source↔live compare skips this field (V39/V40/V56)."""
|
|
102
|
+
if path and path[-1] in _DIFF_IGNORE_FIELDS:
|
|
103
|
+
return True
|
|
104
|
+
return (entity, *path) in _DIFF_IGNORE_PATHS
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _drop_path_ignored(entity: str, field: str, rows: list[Any]) -> list[Any]:
|
|
108
|
+
"""Strip path-qualified GET-omit fields from detail rows (V56)."""
|
|
109
|
+
drop = {
|
|
110
|
+
sub for (ent, det, sub) in _DIFF_IGNORE_PATHS if ent == entity and det == field
|
|
111
|
+
}
|
|
112
|
+
if not drop:
|
|
113
|
+
return rows
|
|
114
|
+
return [
|
|
115
|
+
{k: v for k, v in row.items() if k not in drop}
|
|
116
|
+
if isinstance(row, dict)
|
|
117
|
+
else row
|
|
118
|
+
for row in rows
|
|
119
|
+
]
|
|
92
120
|
|
|
93
121
|
|
|
94
122
|
def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
|
|
@@ -251,18 +279,33 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
|
|
|
251
279
|
return parsed
|
|
252
280
|
|
|
253
281
|
|
|
282
|
+
# ISO date or DateTimeValue: calendar-day prefix only (V55). Random strings
|
|
283
|
+
# that merely contain T stay untouched.
|
|
284
|
+
_ISO_CALENDAR_DAY = re.compile(r"(\d{4}-\d{2}-\d{2})(T.*)?")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _calendar_day(text: str) -> str | None:
|
|
288
|
+
"""YYYY-MM-DD when ``text`` is ISO date/datetime; else None. No TZ convert."""
|
|
289
|
+
m = _ISO_CALENDAR_DAY.fullmatch(text)
|
|
290
|
+
return None if m is None else m.group(1)
|
|
291
|
+
|
|
292
|
+
|
|
254
293
|
def _norm(value: Any) -> str:
|
|
255
|
-
"""Comparable form: bools case-folded, numbers by value,
|
|
294
|
+
"""Comparable form: bools case-folded, numbers by value, dates by day.
|
|
256
295
|
|
|
257
296
|
Numbers compare by value, not spelling - a YAML `0` against the
|
|
258
297
|
endpoint's `0.0` (DecimalValue fields come back as floats) is not
|
|
259
|
-
drift (T13).
|
|
298
|
+
drift (T13). Date-only ``YYYY-MM-DD`` matches a live DateTimeValue on
|
|
299
|
+
the same calendar day (strip ``T...offset``); a different day still
|
|
300
|
+
drifts (V55). No timezone conversion.
|
|
260
301
|
"""
|
|
261
302
|
if isinstance(value, bool):
|
|
262
303
|
return str(value).lower()
|
|
263
304
|
if isinstance(value, int | float):
|
|
264
305
|
return repr(float(value))
|
|
265
|
-
|
|
306
|
+
text = str(value).strip()
|
|
307
|
+
day = _calendar_day(text)
|
|
308
|
+
return text if day is None else day
|
|
266
309
|
|
|
267
310
|
|
|
268
311
|
def _filter_for(record: dict[str, Any], keys: list[str]) -> str:
|
|
@@ -636,13 +679,18 @@ def diff(client: AcumaticaClient, baseline: BaselineFile | ActionFile) -> list[s
|
|
|
636
679
|
drifts.append(f"{label}: missing on tenant")
|
|
637
680
|
continue
|
|
638
681
|
actual = unwrap(live)
|
|
639
|
-
# V39/V40: never compare write-only
|
|
640
|
-
fields = {
|
|
682
|
+
# V39/V40/V56: never compare write-only, runtime, or GET-omit paths
|
|
683
|
+
fields = {
|
|
684
|
+
k: v for k, v in record.items() if not _diff_ignored(baseline.entity, k)
|
|
685
|
+
}
|
|
641
686
|
for field, expected in fields.items():
|
|
642
687
|
if isinstance(expected, list):
|
|
643
688
|
key = (baseline.detail_keys or {})[field] # load-validated
|
|
644
|
-
|
|
645
|
-
|
|
689
|
+
want_rows = _drop_path_ignored(baseline.entity, field, expected)
|
|
690
|
+
live_rows = _drop_path_ignored(
|
|
691
|
+
baseline.entity, field, actual.get(field, [])
|
|
692
|
+
)
|
|
693
|
+
drifts.extend(_diff_details(label, field, key, want_rows, live_rows))
|
|
646
694
|
elif isinstance(expected, dict):
|
|
647
695
|
drifts.extend(
|
|
648
696
|
_diff_nested(f"{label}.{field}", expected, actual.get(field))
|
|
@@ -33,11 +33,11 @@ acu run scenario/10-seed-capital.yaml && acu state --assert-unchanged
|
|
|
33
33
|
# SSH cold rebuild: acu tenant create --login NAME, then apply, run, diff
|
|
34
34
|
|
|
35
35
|
# Optional: re-seed from live (inverse of apply; always under config/)
|
|
36
|
-
# acu extract --out . --force
|
|
36
|
+
# acu survey extract --out . --force
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Bare `acu apply` / `acu diff` also prefer `config/` when those trees exist.
|
|
40
|
-
`acu extract` hard-cuts emit to `config/{bootstrap,baseline,setup,master}/` (never root SEED_DIRS).
|
|
40
|
+
`acu survey extract` hard-cuts emit to `config/{bootstrap,baseline,setup,master}/` (never root SEED_DIRS).
|
|
41
41
|
|
|
42
42
|
## Layout
|
|
43
43
|
|
|
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
|
|
File without changes
|
{acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/overlays/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/scenario/20-buy.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/scenario/30-build.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.30.0 → acumatica_cli-0.32.0}/src/acumatica_cli/templates/scenario/40-sell.yaml
RENAMED
|
File without changes
|
|
File without changes
|