acumatica-cli 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/PKG-INFO +47 -10
  2. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/README.md +46 -9
  3. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/bootstrap_project.xml +438 -2
  5. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/client.py +52 -9
  6. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/seed.py +199 -10
  7. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +2 -2
  8. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +2 -2
  9. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/bootstrap/company.yaml +2 -2
  10. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +2 -2
  11. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +1 -1
  12. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +1 -1
  13. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +1 -1
  14. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/__init__.py +0 -0
  15. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/bootstrap.py +0 -0
  16. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  17. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/cli.py +0 -0
  18. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/config.py +0 -0
  19. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/extract.py +0 -0
  20. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
  21. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/firstlogin.py +0 -0
  22. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/models.py +0 -0
  23. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/output.py +0 -0
  24. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  25. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  26. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  27. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  28. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  29. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/env +0 -0
  30. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/templates/gitignore +0 -0
  31. {acumatica_cli-0.4.0 → acumatica_cli-0.6.0}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acumatica-cli
3
- Version: 0.4.0
3
+ Version: 0.6.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>
@@ -15,23 +15,27 @@ Project-URL: Homepage, https://lab5.ca
15
15
  Project-URL: Repository, https://github.com/kborovik/acumatica-cli
16
16
  Description-Content-Type: text/markdown
17
17
 
18
- # Acumatica Config-as-Code
18
+ # Acumatica GitOps (Config-as-Code)
19
19
 
20
- **`acu`** configures Acumatica ERP from YAML files in a git repo.
20
+ **`acu`** configures Acumatica ERP from YAML files in a git repo (GitOps).
21
21
 
22
22
  **No UI clicks, no Configuration Wizard.**
23
23
 
24
24
  ## How it works
25
25
 
26
- 1. **Read** the target Acumatica deployment and translate its configuration into YAML config as code.
26
+ 1. **Extract** the target Acumatica deployment with `acu extract` — its configuration becomes YAML, config as code.
27
27
  2. **Develop** changes in the YAML, in git: edit, review, version.
28
28
  3. **Re-deploy** with `acu apply`, then prove the live tenant matches the code with `acu diff`.
29
29
 
30
- Three commands do the work, and every one is safe to re-run:
30
+ Four commands do the work, and every one is safe to re-run:
31
31
 
32
- - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`. Re-running it against an existing tenant republishes the bootstrap package instead of failing.
33
- - **`acu apply`** pushes your YAML into the tenant as keyed upserts. Running it twice changes nothing.
32
+ - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`.
33
+ Re-running it against an existing tenant republishes the bootstrap package instead of failing.
34
+ - **`acu apply`** — pushes your YAML into the tenant as keyed upserts.
35
+ Running it twice changes nothing.
34
36
  - **`acu diff`** — compares your YAML against the live tenant and exits with code 2 on drift.
37
+ - **`acu extract`** — reads a configured tenant and writes the YAML file set back out, the inverse of `apply`.
38
+ Existing files are skipped unless `--force`.
35
39
 
36
40
  > **Tested against** Acumatica ERP **26.101.0225** on Windows Server 2025,
37
41
  > contract REST endpoint **25.200.001**. Other versions will likely work,
@@ -61,16 +65,18 @@ acu --tenant DEV diff # prove zero drift (exit 2 on drift)
61
65
 
62
66
  ```text
63
67
  acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
64
- [--username U] [--password P]
68
+ [--username U] [--password P] [--version] [--completion [SHELL]]
65
69
 
66
70
  ├── tenant tenant CRUD (ac.exe over SSH — control plane)
67
71
  │ ├── list CompanyID, sign-in name, internal CD, type
68
72
  │ ├── create --id N --login NAME create + bootstrap; re-run to republish
69
- │ │ [--type SalesDemo] [--parent N] [--hidden] [--no-init]
73
+ │ │ [--type SalesDemo|T100|U100] [--parent N] [--hidden] [--no-init]
70
74
  │ └── delete --id N [--yes] delete the tenant and its data, recycle app pool
71
75
 
72
76
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
73
77
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
78
+ ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
79
+ │ dump live tenant state as seed YAML (inverse of apply)
74
80
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
75
81
 
76
82
  └── config configuration ops
@@ -79,7 +85,9 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
79
85
  └── check read-only preflight: discovery, secrets, REST, SSH
80
86
  ```
81
87
 
82
- `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`. Run `acu <command> --help` for details on any command.
88
+ `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
89
+ `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
90
+ Run `acu <command> --help` for details on any command.
83
91
 
84
92
  ## The data repo
85
93
 
@@ -186,3 +194,32 @@ Then verify from your workstation — this one test proves both requirements at
186
194
  ```sh
187
195
  ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
188
196
  ```
197
+
198
+ ## Development
199
+
200
+ ```sh
201
+ git clone https://github.com/kborovik/acumatica-cli.git
202
+ cd acumatica-cli
203
+ make install # editable install as a global uv tool
204
+ make check # offline gate: ruff, basedpyright strict, pytest
205
+ ```
206
+
207
+ The default test suite is fully offline.
208
+ REST is faked with `httpx.MockTransport`, SSH with a monkeypatched `subprocess.run` — no live instance is needed.
209
+ `make check` must pass before every commit.
210
+
211
+ ### Live end-to-end tier
212
+
213
+ `make e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
214
+
215
+ Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
216
+ `make e2e` refuses to start without it.
217
+
218
+ The tier is self-contained.
219
+ Each run scaffolds a synthetic single-org company from the packaged `acu config init` templates into a temporary directory, copies the real `.env` into it, and runs the installed `acu` binary from there — no data repo, no pre-existing fixtures on the instance.
220
+ Scratch tenants (`E2E`, `E2EA`, `E2EB`) are created on the way in and always deleted on the way out, so nothing persists.
221
+
222
+ ```sh
223
+ make e2e # whole tier, about 20 minutes
224
+ make e2e FILE=test_provision_lifecycle # one file, by stem or path
225
+ ```
@@ -1,20 +1,24 @@
1
- # Acumatica Config-as-Code
1
+ # Acumatica GitOps (Config-as-Code)
2
2
 
3
- **`acu`** configures Acumatica ERP from YAML files in a git repo.
3
+ **`acu`** configures Acumatica ERP from YAML files in a git repo (GitOps).
4
4
 
5
5
  **No UI clicks, no Configuration Wizard.**
6
6
 
7
7
  ## How it works
8
8
 
9
- 1. **Read** the target Acumatica deployment and translate its configuration into YAML config as code.
9
+ 1. **Extract** the target Acumatica deployment with `acu extract` — its configuration becomes YAML, config as code.
10
10
  2. **Develop** changes in the YAML, in git: edit, review, version.
11
11
  3. **Re-deploy** with `acu apply`, then prove the live tenant matches the code with `acu diff`.
12
12
 
13
- Three commands do the work, and every one is safe to re-run:
13
+ Four commands do the work, and every one is safe to re-run:
14
14
 
15
- - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`. Re-running it against an existing tenant republishes the bootstrap package instead of failing.
16
- - **`acu apply`** pushes your YAML into the tenant as keyed upserts. Running it twice changes nothing.
15
+ - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`.
16
+ Re-running it against an existing tenant republishes the bootstrap package instead of failing.
17
+ - **`acu apply`** — pushes your YAML into the tenant as keyed upserts.
18
+ Running it twice changes nothing.
17
19
  - **`acu diff`** — compares your YAML against the live tenant and exits with code 2 on drift.
20
+ - **`acu extract`** — reads a configured tenant and writes the YAML file set back out, the inverse of `apply`.
21
+ Existing files are skipped unless `--force`.
18
22
 
19
23
  > **Tested against** Acumatica ERP **26.101.0225** on Windows Server 2025,
20
24
  > contract REST endpoint **25.200.001**. Other versions will likely work,
@@ -44,16 +48,18 @@ acu --tenant DEV diff # prove zero drift (exit 2 on drift)
44
48
 
45
49
  ```text
46
50
  acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
47
- [--username U] [--password P]
51
+ [--username U] [--password P] [--version] [--completion [SHELL]]
48
52
 
49
53
  ├── tenant tenant CRUD (ac.exe over SSH — control plane)
50
54
  │ ├── list CompanyID, sign-in name, internal CD, type
51
55
  │ ├── create --id N --login NAME create + bootstrap; re-run to republish
52
- │ │ [--type SalesDemo] [--parent N] [--hidden] [--no-init]
56
+ │ │ [--type SalesDemo|T100|U100] [--parent N] [--hidden] [--no-init]
53
57
  │ └── delete --id N [--yes] delete the tenant and its data, recycle app pool
54
58
 
55
59
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
56
60
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
61
+ ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
62
+ │ dump live tenant state as seed YAML (inverse of apply)
57
63
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
58
64
 
59
65
  └── config configuration ops
@@ -62,7 +68,9 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
62
68
  └── check read-only preflight: discovery, secrets, REST, SSH
63
69
  ```
64
70
 
65
- `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`. Run `acu <command> --help` for details on any command.
71
+ `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
72
+ `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
73
+ Run `acu <command> --help` for details on any command.
66
74
 
67
75
  ## The data repo
68
76
 
@@ -169,3 +177,32 @@ Then verify from your workstation — this one test proves both requirements at
169
177
  ```sh
170
178
  ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
171
179
  ```
180
+
181
+ ## Development
182
+
183
+ ```sh
184
+ git clone https://github.com/kborovik/acumatica-cli.git
185
+ cd acumatica-cli
186
+ make install # editable install as a global uv tool
187
+ make check # offline gate: ruff, basedpyright strict, pytest
188
+ ```
189
+
190
+ The default test suite is fully offline.
191
+ REST is faked with `httpx.MockTransport`, SSH with a monkeypatched `subprocess.run` — no live instance is needed.
192
+ `make check` must pass before every commit.
193
+
194
+ ### Live end-to-end tier
195
+
196
+ `make e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
197
+
198
+ Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
199
+ `make e2e` refuses to start without it.
200
+
201
+ The tier is self-contained.
202
+ Each run scaffolds a synthetic single-org company from the packaged `acu config init` templates into a temporary directory, copies the real `.env` into it, and runs the installed `acu` binary from there — no data repo, no pre-existing fixtures on the instance.
203
+ Scratch tenants (`E2E`, `E2EA`, `E2EB`) are created on the way in and always deleted on the way out, so nothing persists.
204
+
205
+ ```sh
206
+ make e2e # whole tier, about 20 minutes
207
+ make e2e FILE=test_provision_lifecycle # one file, by stem or path
208
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.4.0"
3
+ version = "0.6.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
  readme = "README.md"
@@ -146,13 +146,81 @@
146
146
  PXAction member on the graph; the name literal confirmed
147
147
  in the PX.Data.dll string heap) and processes every grid
148
148
  row matching the filter - no Selected flags involved.
149
+ T61 distribution setup chain (screen->graph->PrimaryView read off the
150
+ live box aspx files 2026-07-13; LAB5 probe evidence in gh issue #10 -
151
+ five prefs screens gate Vendor/Customer/Warehouse/SalesOrder/
152
+ PurchaseOrder/Bill/SalesInvoice/Payment/Shipment with
153
+ PXSetupNotEnteredException 500s until each singleton exists):
154
+ - INPreferences IN101000 -> INSetupMaint, PrimaryView setup (DAC
155
+ PX.Objects.IN.INSetup). Numbering fields default
156
+ (INREGISTER etc.), but the live insert 500 named six
157
+ fields with no server default (read off the LAB5 PUT
158
+ 2026-07-13): INProgressAcctID/INProgressSubID (the WIP
159
+ account) + the four reason codes (Issues/Receipt/
160
+ Adjustment/PI) - all exposed; HoldEntry = the echo flag.
161
+ - ReasonCode CS211000 -> ReasonCodeMaint, PrimaryView reasoncode
162
+ (PX.Objects.CS.ReasonCode) - no Default-endpoint entity,
163
+ and the INSetup reason-code fields above hard-require
164
+ rows. Usage takes the stored-word list; AccountID/SubID
165
+ = the code's GL posting target.
166
+ - APPreferences AP101000 -> APSetupMaint, PrimaryView Setup (APSetup);
167
+ HoldEntry echo field, same singleton pattern.
168
+ - ARPreferences AR101000 -> ARSetupMaint, PrimaryView ARSetupRecord
169
+ (ARSetup); HoldEntry echo field.
170
+ - SOPreferences SO101000 -> SOSetupMaint, PrimaryView sosetup (SOSetup);
171
+ DefaultOrderType doubles as the echo field and the R1
172
+ probe (does order type SO pre-exist on an empty tenant).
173
+ - POPreferences PO101000 -> POSetupMaint, PrimaryView Setup (POSetup);
174
+ HoldReceipts echo field + RCReturnReasonCodeID (the one
175
+ POSetup field the live insert named as required - a
176
+ Vendor Return usage reason code, 2026-07-13).
177
+ - AvailabilityCalculationRule IN201500 -> INAvailabilitySchemeMaint,
178
+ PrimaryView Schemes (INAvailabilityScheme) - an empty
179
+ tenant has zero scheme rows and ItemClass 422s
180
+ "'Availability Calculation Rule' cannot be empty"; the
181
+ Incl* booleans all carry defaults, so the entity exposes
182
+ the key + Description only.
183
+ - PostingClass IN206000 -> INPostClassMaint, PrimaryView postclass
184
+ (INPostClass) - no Default-endpoint entity; StockItem
185
+ posting resolution needs it. Exposes id + descr, the six
186
+ acct-source combos (stored-word lists), the six acct/sub
187
+ segment-mask pairs, and the six sub masks - grid/form
188
+ bindings read off IN206000.aspx.
189
+ - CAPreferences CA101000 -> CASetupMaint, PrimaryView CASetupRecord
190
+ (PX.Objects.CA.CASetup) - CashAccount answers 500
191
+ PXSetupNotEnteredException until the singleton exists
192
+ (live-read 2026-07-13). TransitAcctId (DAC spelling off
193
+ the aspx - lowercase d) + TransitSubID = the cash-in-
194
+ transit pair; HoldEntry = the echo flag.
195
+ - VendorClass AP201000 -> VendorClassMaint, PrimaryView
196
+ VendorClassRecord (PX.Objects.AP.VendorClass) - the
197
+ Default endpoint's VendorClass is skeletal (ClassID/
198
+ Description/Attributes) and Vendor carries no discount
199
+ fields, so vendor inserts 422 on the cash-discount pair
200
+ the class should default (T64, gh issue #13). Exposes
201
+ id + descr + TermsID + the GL-account pairs off the
202
+ screen's GL Accounts tab (aspx-verified 2026-07-13).
203
+ - StatementCycle AR202800 -> ARStatementMaint, PrimaryView
204
+ ARStatementCycleRecord (PX.Objects.AR.ARStatementCycle) -
205
+ no Default-endpoint entity, and Customer inserts 422
206
+ "'Statement Cycle ID' cannot be empty" (T64 follow-up
207
+ proven live 2026-07-13; the class defaults it). PrepareOn
208
+ takes the stored-word list; Day00 rides the
209
+ fixed-day-of-month choice; the AgeDays00-02 aging bounds
210
+ default inconsistently at raw insert ("end day earlier
211
+ than its start day" 422) so the seed supplies them.
212
+ - CashAccount CA202000 -> CashAccountMaint, PrimaryView CashAccount
213
+ (PX.Objects.CA.CashAccount) - Default endpoint answers
214
+ 404. CuryID stays out (derived from the GL account,
215
+ B11 server-derived class); payment-method links are the
216
+ conditional PaymentMethodAccount follow-up, not here.
149
217
  Contract field names = DAC property names verbatim (no rename layer).
150
218
  Features (CS100000) stays OUT of the endpoint - writes through any
151
219
  contract endpoint do not persist (T3 verdict); the plugin owns features.
152
220
  -->
153
- <Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes company + credit terms + financial currency + GL preferences + ledger-company link + financial year + calendars + period activation" product-version="26.101">
221
+ <Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes company + credit terms + financial currency + GL preferences + ledger-company link + financial year + calendars + period activation + module preferences (IN/AP/AR/SO/PO) + availability rule + posting class + cash account + vendor class + statement cycle" product-version="26.101">
154
222
  <EntityEndpoint>
155
- <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.4.0" systemContractVersion="4">
223
+ <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.6.0" systemContractVersion="4">
156
224
  <TopLevelEntity name="Company" screen="CS101500">
157
225
  <Fields>
158
226
  <Field name="AcctCD" type="StringValue" />
@@ -455,6 +523,374 @@
455
523
  </Mapping>
456
524
  </Mappings>
457
525
  </TopLevelEntity>
526
+ <TopLevelEntity name="INPreferences" screen="IN101000">
527
+ <Fields>
528
+ <Field name="HoldEntry" type="BooleanValue" />
529
+ <Field name="INProgressAcctID" type="StringValue" />
530
+ <Field name="INProgressSubID" type="StringValue" />
531
+ <Field name="IssuesReasonCode" type="StringValue" />
532
+ <Field name="ReceiptReasonCode" type="StringValue" />
533
+ <Field name="AdjustmentReasonCode" type="StringValue" />
534
+ <Field name="PIReasonCode" type="StringValue" />
535
+ </Fields>
536
+ <Mappings>
537
+ <Mapping field="HoldEntry">
538
+ <To object="setup" field="HoldEntry" />
539
+ </Mapping>
540
+ <Mapping field="INProgressAcctID">
541
+ <To object="setup" field="INProgressAcctID" />
542
+ </Mapping>
543
+ <Mapping field="INProgressSubID">
544
+ <To object="setup" field="INProgressSubID" />
545
+ </Mapping>
546
+ <Mapping field="IssuesReasonCode">
547
+ <To object="setup" field="IssuesReasonCode" />
548
+ </Mapping>
549
+ <Mapping field="ReceiptReasonCode">
550
+ <To object="setup" field="ReceiptReasonCode" />
551
+ </Mapping>
552
+ <Mapping field="AdjustmentReasonCode">
553
+ <To object="setup" field="AdjustmentReasonCode" />
554
+ </Mapping>
555
+ <Mapping field="PIReasonCode">
556
+ <To object="setup" field="PIReasonCode" />
557
+ </Mapping>
558
+ </Mappings>
559
+ </TopLevelEntity>
560
+ <TopLevelEntity name="ReasonCode" screen="CS211000">
561
+ <Fields>
562
+ <Field name="ReasonCodeID" type="StringValue" />
563
+ <Field name="Descr" type="StringValue" />
564
+ <Field name="Usage" type="StringValue" />
565
+ <Field name="AccountID" type="StringValue" />
566
+ <Field name="SubID" type="StringValue" />
567
+ </Fields>
568
+ <Mappings>
569
+ <Mapping field="ReasonCodeID">
570
+ <To object="reasoncode" field="ReasonCodeID" />
571
+ </Mapping>
572
+ <Mapping field="Descr">
573
+ <To object="reasoncode" field="Descr" />
574
+ </Mapping>
575
+ <Mapping field="Usage">
576
+ <To object="reasoncode" field="Usage" />
577
+ </Mapping>
578
+ <Mapping field="AccountID">
579
+ <To object="reasoncode" field="AccountID" />
580
+ </Mapping>
581
+ <Mapping field="SubID">
582
+ <To object="reasoncode" field="SubID" />
583
+ </Mapping>
584
+ </Mappings>
585
+ </TopLevelEntity>
586
+ <TopLevelEntity name="APPreferences" screen="AP101000">
587
+ <Fields>
588
+ <Field name="HoldEntry" type="BooleanValue" />
589
+ </Fields>
590
+ <Mappings>
591
+ <Mapping field="HoldEntry">
592
+ <To object="Setup" field="HoldEntry" />
593
+ </Mapping>
594
+ </Mappings>
595
+ </TopLevelEntity>
596
+ <TopLevelEntity name="ARPreferences" screen="AR101000">
597
+ <Fields>
598
+ <Field name="HoldEntry" type="BooleanValue" />
599
+ </Fields>
600
+ <Mappings>
601
+ <Mapping field="HoldEntry">
602
+ <To object="ARSetupRecord" field="HoldEntry" />
603
+ </Mapping>
604
+ </Mappings>
605
+ </TopLevelEntity>
606
+ <TopLevelEntity name="SOPreferences" screen="SO101000">
607
+ <Fields>
608
+ <Field name="DefaultOrderType" type="StringValue" />
609
+ </Fields>
610
+ <Mappings>
611
+ <Mapping field="DefaultOrderType">
612
+ <To object="sosetup" field="DefaultOrderType" />
613
+ </Mapping>
614
+ </Mappings>
615
+ </TopLevelEntity>
616
+ <TopLevelEntity name="POPreferences" screen="PO101000">
617
+ <Fields>
618
+ <Field name="HoldReceipts" type="BooleanValue" />
619
+ <Field name="RCReturnReasonCodeID" type="StringValue" />
620
+ </Fields>
621
+ <Mappings>
622
+ <Mapping field="HoldReceipts">
623
+ <To object="Setup" field="HoldReceipts" />
624
+ </Mapping>
625
+ <Mapping field="RCReturnReasonCodeID">
626
+ <To object="Setup" field="RCReturnReasonCodeID" />
627
+ </Mapping>
628
+ </Mappings>
629
+ </TopLevelEntity>
630
+ <TopLevelEntity name="AvailabilityCalculationRule" screen="IN201500">
631
+ <Fields>
632
+ <Field name="AvailabilitySchemeID" type="StringValue" />
633
+ <Field name="Description" type="StringValue" />
634
+ </Fields>
635
+ <Mappings>
636
+ <Mapping field="AvailabilitySchemeID">
637
+ <To object="Schemes" field="AvailabilitySchemeID" />
638
+ </Mapping>
639
+ <Mapping field="Description">
640
+ <To object="Schemes" field="Description" />
641
+ </Mapping>
642
+ </Mappings>
643
+ </TopLevelEntity>
644
+ <TopLevelEntity name="PostingClass" screen="IN206000">
645
+ <Fields>
646
+ <Field name="PostClassID" type="StringValue" />
647
+ <Field name="Descr" type="StringValue" />
648
+ <Field name="InvtAcctDefault" type="StringValue" />
649
+ <Field name="SalesAcctDefault" type="StringValue" />
650
+ <Field name="COGSAcctDefault" type="StringValue" />
651
+ <Field name="POAccrualAcctDefault" type="StringValue" />
652
+ <Field name="PPVAcctDefault" type="StringValue" />
653
+ <Field name="LCVarianceAcctDefault" type="StringValue" />
654
+ <Field name="InvtSubMask" type="StringValue" />
655
+ <Field name="SalesSubMask" type="StringValue" />
656
+ <Field name="COGSSubMask" type="StringValue" />
657
+ <Field name="POAccrualSubMask" type="StringValue" />
658
+ <Field name="PPVSubMask" type="StringValue" />
659
+ <Field name="LCVarianceSubMask" type="StringValue" />
660
+ <Field name="InvtAcctID" type="StringValue" />
661
+ <Field name="InvtSubID" type="StringValue" />
662
+ <Field name="SalesAcctID" type="StringValue" />
663
+ <Field name="SalesSubID" type="StringValue" />
664
+ <Field name="COGSAcctID" type="StringValue" />
665
+ <Field name="COGSSubID" type="StringValue" />
666
+ <Field name="POAccrualAcctID" type="StringValue" />
667
+ <Field name="POAccrualSubID" type="StringValue" />
668
+ <Field name="PPVAcctID" type="StringValue" />
669
+ <Field name="PPVSubID" type="StringValue" />
670
+ <Field name="LCVarianceAcctID" type="StringValue" />
671
+ <Field name="LCVarianceSubID" type="StringValue" />
672
+ </Fields>
673
+ <Mappings>
674
+ <Mapping field="PostClassID">
675
+ <To object="postclass" field="PostClassID" />
676
+ </Mapping>
677
+ <Mapping field="Descr">
678
+ <To object="postclass" field="Descr" />
679
+ </Mapping>
680
+ <Mapping field="InvtAcctDefault">
681
+ <To object="postclass" field="InvtAcctDefault" />
682
+ </Mapping>
683
+ <Mapping field="SalesAcctDefault">
684
+ <To object="postclass" field="SalesAcctDefault" />
685
+ </Mapping>
686
+ <Mapping field="COGSAcctDefault">
687
+ <To object="postclass" field="COGSAcctDefault" />
688
+ </Mapping>
689
+ <Mapping field="POAccrualAcctDefault">
690
+ <To object="postclass" field="POAccrualAcctDefault" />
691
+ </Mapping>
692
+ <Mapping field="PPVAcctDefault">
693
+ <To object="postclass" field="PPVAcctDefault" />
694
+ </Mapping>
695
+ <Mapping field="LCVarianceAcctDefault">
696
+ <To object="postclass" field="LCVarianceAcctDefault" />
697
+ </Mapping>
698
+ <Mapping field="InvtSubMask">
699
+ <To object="postclass" field="InvtSubMask" />
700
+ </Mapping>
701
+ <Mapping field="SalesSubMask">
702
+ <To object="postclass" field="SalesSubMask" />
703
+ </Mapping>
704
+ <Mapping field="COGSSubMask">
705
+ <To object="postclass" field="COGSSubMask" />
706
+ </Mapping>
707
+ <Mapping field="POAccrualSubMask">
708
+ <To object="postclass" field="POAccrualSubMask" />
709
+ </Mapping>
710
+ <Mapping field="PPVSubMask">
711
+ <To object="postclass" field="PPVSubMask" />
712
+ </Mapping>
713
+ <Mapping field="LCVarianceSubMask">
714
+ <To object="postclass" field="LCVarianceSubMask" />
715
+ </Mapping>
716
+ <Mapping field="InvtAcctID">
717
+ <To object="postclass" field="InvtAcctID" />
718
+ </Mapping>
719
+ <Mapping field="InvtSubID">
720
+ <To object="postclass" field="InvtSubID" />
721
+ </Mapping>
722
+ <Mapping field="SalesAcctID">
723
+ <To object="postclass" field="SalesAcctID" />
724
+ </Mapping>
725
+ <Mapping field="SalesSubID">
726
+ <To object="postclass" field="SalesSubID" />
727
+ </Mapping>
728
+ <Mapping field="COGSAcctID">
729
+ <To object="postclass" field="COGSAcctID" />
730
+ </Mapping>
731
+ <Mapping field="COGSSubID">
732
+ <To object="postclass" field="COGSSubID" />
733
+ </Mapping>
734
+ <Mapping field="POAccrualAcctID">
735
+ <To object="postclass" field="POAccrualAcctID" />
736
+ </Mapping>
737
+ <Mapping field="POAccrualSubID">
738
+ <To object="postclass" field="POAccrualSubID" />
739
+ </Mapping>
740
+ <Mapping field="PPVAcctID">
741
+ <To object="postclass" field="PPVAcctID" />
742
+ </Mapping>
743
+ <Mapping field="PPVSubID">
744
+ <To object="postclass" field="PPVSubID" />
745
+ </Mapping>
746
+ <Mapping field="LCVarianceAcctID">
747
+ <To object="postclass" field="LCVarianceAcctID" />
748
+ </Mapping>
749
+ <Mapping field="LCVarianceSubID">
750
+ <To object="postclass" field="LCVarianceSubID" />
751
+ </Mapping>
752
+ </Mappings>
753
+ </TopLevelEntity>
754
+ <TopLevelEntity name="CAPreferences" screen="CA101000">
755
+ <Fields>
756
+ <Field name="HoldEntry" type="BooleanValue" />
757
+ <Field name="TransitAcctId" type="StringValue" />
758
+ <Field name="TransitSubID" type="StringValue" />
759
+ </Fields>
760
+ <Mappings>
761
+ <Mapping field="HoldEntry">
762
+ <To object="CASetupRecord" field="HoldEntry" />
763
+ </Mapping>
764
+ <Mapping field="TransitAcctId">
765
+ <To object="CASetupRecord" field="TransitAcctId" />
766
+ </Mapping>
767
+ <Mapping field="TransitSubID">
768
+ <To object="CASetupRecord" field="TransitSubID" />
769
+ </Mapping>
770
+ </Mappings>
771
+ </TopLevelEntity>
772
+ <TopLevelEntity name="VendorClass" screen="AP201000">
773
+ <Fields>
774
+ <Field name="VendorClassID" type="StringValue" />
775
+ <Field name="Descr" type="StringValue" />
776
+ <Field name="TermsID" type="StringValue" />
777
+ <Field name="APAcctID" type="StringValue" />
778
+ <Field name="APSubID" type="StringValue" />
779
+ <Field name="ExpenseAcctID" type="StringValue" />
780
+ <Field name="ExpenseSubID" type="StringValue" />
781
+ <Field name="DiscTakenAcctID" type="StringValue" />
782
+ <Field name="DiscTakenSubID" type="StringValue" />
783
+ <Field name="POAccrualAcctID" type="StringValue" />
784
+ <Field name="POAccrualSubID" type="StringValue" />
785
+ <Field name="PrepaymentAcctID" type="StringValue" />
786
+ <Field name="PrepaymentSubID" type="StringValue" />
787
+ </Fields>
788
+ <Mappings>
789
+ <Mapping field="VendorClassID">
790
+ <To object="VendorClassRecord" field="VendorClassID" />
791
+ </Mapping>
792
+ <Mapping field="Descr">
793
+ <To object="VendorClassRecord" field="Descr" />
794
+ </Mapping>
795
+ <Mapping field="TermsID">
796
+ <To object="VendorClassRecord" field="TermsID" />
797
+ </Mapping>
798
+ <Mapping field="APAcctID">
799
+ <To object="VendorClassRecord" field="APAcctID" />
800
+ </Mapping>
801
+ <Mapping field="APSubID">
802
+ <To object="VendorClassRecord" field="APSubID" />
803
+ </Mapping>
804
+ <Mapping field="ExpenseAcctID">
805
+ <To object="VendorClassRecord" field="ExpenseAcctID" />
806
+ </Mapping>
807
+ <Mapping field="ExpenseSubID">
808
+ <To object="VendorClassRecord" field="ExpenseSubID" />
809
+ </Mapping>
810
+ <Mapping field="DiscTakenAcctID">
811
+ <To object="VendorClassRecord" field="DiscTakenAcctID" />
812
+ </Mapping>
813
+ <Mapping field="DiscTakenSubID">
814
+ <To object="VendorClassRecord" field="DiscTakenSubID" />
815
+ </Mapping>
816
+ <Mapping field="POAccrualAcctID">
817
+ <To object="VendorClassRecord" field="POAccrualAcctID" />
818
+ </Mapping>
819
+ <Mapping field="POAccrualSubID">
820
+ <To object="VendorClassRecord" field="POAccrualSubID" />
821
+ </Mapping>
822
+ <Mapping field="PrepaymentAcctID">
823
+ <To object="VendorClassRecord" field="PrepaymentAcctID" />
824
+ </Mapping>
825
+ <Mapping field="PrepaymentSubID">
826
+ <To object="VendorClassRecord" field="PrepaymentSubID" />
827
+ </Mapping>
828
+ </Mappings>
829
+ </TopLevelEntity>
830
+ <TopLevelEntity name="StatementCycle" screen="AR202800">
831
+ <Fields>
832
+ <Field name="StatementCycleId" type="StringValue" />
833
+ <Field name="Descr" type="StringValue" />
834
+ <Field name="PrepareOn" type="StringValue" />
835
+ <Field name="Day00" type="ShortValue" />
836
+ <Field name="AgeDays00" type="ShortValue" />
837
+ <Field name="AgeDays01" type="ShortValue" />
838
+ <Field name="AgeDays02" type="ShortValue" />
839
+ </Fields>
840
+ <Mappings>
841
+ <Mapping field="StatementCycleId">
842
+ <To object="ARStatementCycleRecord" field="StatementCycleId" />
843
+ </Mapping>
844
+ <Mapping field="Descr">
845
+ <To object="ARStatementCycleRecord" field="Descr" />
846
+ </Mapping>
847
+ <Mapping field="PrepareOn">
848
+ <To object="ARStatementCycleRecord" field="PrepareOn" />
849
+ </Mapping>
850
+ <Mapping field="Day00">
851
+ <To object="ARStatementCycleRecord" field="Day00" />
852
+ </Mapping>
853
+ <Mapping field="AgeDays00">
854
+ <To object="ARStatementCycleRecord" field="AgeDays00" />
855
+ </Mapping>
856
+ <Mapping field="AgeDays01">
857
+ <To object="ARStatementCycleRecord" field="AgeDays01" />
858
+ </Mapping>
859
+ <Mapping field="AgeDays02">
860
+ <To object="ARStatementCycleRecord" field="AgeDays02" />
861
+ </Mapping>
862
+ </Mappings>
863
+ </TopLevelEntity>
864
+ <TopLevelEntity name="CashAccount" screen="CA202000">
865
+ <Fields>
866
+ <Field name="CashAccountCD" type="StringValue" />
867
+ <Field name="Descr" type="StringValue" />
868
+ <Field name="AccountID" type="StringValue" />
869
+ <Field name="SubID" type="StringValue" />
870
+ <Field name="BranchID" type="StringValue" />
871
+ <Field name="Active" type="BooleanValue" />
872
+ </Fields>
873
+ <Mappings>
874
+ <Mapping field="CashAccountCD">
875
+ <To object="CashAccount" field="CashAccountCD" />
876
+ </Mapping>
877
+ <Mapping field="Descr">
878
+ <To object="CashAccount" field="Descr" />
879
+ </Mapping>
880
+ <Mapping field="AccountID">
881
+ <To object="CashAccount" field="AccountID" />
882
+ </Mapping>
883
+ <Mapping field="SubID">
884
+ <To object="CashAccount" field="SubID" />
885
+ </Mapping>
886
+ <Mapping field="BranchID">
887
+ <To object="CashAccount" field="BranchID" />
888
+ </Mapping>
889
+ <Mapping field="Active">
890
+ <To object="CashAccount" field="Active" />
891
+ </Mapping>
892
+ </Mappings>
893
+ </TopLevelEntity>
458
894
  </Endpoint>
459
895
  </EntityEndpoint>
460
896
  </Customization>
@@ -14,15 +14,52 @@ from .config import Instance
14
14
 
15
15
 
16
16
  def wrap(record: dict[str, Any]) -> dict[str, Any]:
17
- """Plain dict -> contract-API body: {"Field": {"value": ...}}."""
18
- return {k: {"value": v} for k, v in record.items()}
17
+ """Plain dict -> contract-API body: {"Field": {"value": ...}}.
18
+
19
+ A list value is a detail array (T60): the list itself is NEVER
20
+ value-wrapped, each dict row wraps recursively — the shape the live
21
+ JournalTransaction PUT proved (T50/T37 e2e payload). A dict value is
22
+ a linked entity (T65): a bare nested object, wrapped recursively —
23
+ live-verified Vendor MainContact/Address/Country PUT. The contract's
24
+ OWN control fields travel bare, never value-wrapped: `id` (row GUID —
25
+ the only handle that makes a detail-row PUT an update instead of an
26
+ insert) and `delete` (row-removal marker).
27
+ """
28
+
29
+ def _value(v: Any) -> Any:
30
+ if isinstance(v, list):
31
+ return [wrap(row) if isinstance(row, dict) else row for row in v]
32
+ if isinstance(v, dict):
33
+ return wrap(v)
34
+ return {"value": v}
35
+
36
+ return {k: v if k in ("id", "delete") else _value(v) for k, v in record.items()}
19
37
 
20
38
 
21
39
  def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
22
- """Contract-API entity -> plain dict (top-level value fields only)."""
23
- return {
24
- k: v["value"] for k, v in entity.items() if isinstance(v, dict) and "value" in v
25
- }
40
+ """Contract-API entity -> plain dict (value fields + detail arrays).
41
+
42
+ Inverse of wrap (T60/T65): a list of dicts unwraps row by row; lists
43
+ that are empty or whose rows carry no value fields are elided (a
44
+ detail array the source never claimed must not surface, and expanded
45
+ `files` descriptors are plain dicts that would unwrap to noise). A
46
+ non-value dict is a linked entity — unwrapped recursively, kept only
47
+ when something survives, so bookkeeping dicts (custom, _links, note)
48
+ stay excluded exactly as before.
49
+ """
50
+ out: dict[str, Any] = {}
51
+ for k, v in entity.items():
52
+ if isinstance(v, dict) and "value" in v:
53
+ out[k] = v["value"]
54
+ elif isinstance(v, dict):
55
+ nested = unwrap(v)
56
+ if nested:
57
+ out[k] = nested
58
+ elif isinstance(v, list) and v and all(isinstance(row, dict) for row in v):
59
+ rows = [unwrap(row) for row in v]
60
+ if any(rows):
61
+ out[k] = rows
62
+ return out
26
63
 
27
64
 
28
65
  # The list GET's optimized-export failure (B9): the contract API's list GET
@@ -159,7 +196,11 @@ class AcumaticaClient:
159
196
  ).json()
160
197
 
161
198
  def get_record(
162
- self, entity: str, keys: Sequence[Any], endpoint: str | None = None
199
+ self,
200
+ entity: str,
201
+ keys: Sequence[Any],
202
+ endpoint: str | None = None,
203
+ params: dict[str, str] | None = None,
163
204
  ) -> dict[str, Any] | None:
164
205
  """GET one record by key URL — the delegate-view-safe read (B9).
165
206
 
@@ -167,10 +208,12 @@ class AcumaticaClient:
167
208
  scope maps to a BQL-delegate view; the key-URL form skips the
168
209
  optimizer and returns the full record (verified vs 26.101.0225).
169
210
  Returns None when no record matches — the server answers that with
170
- a 500 NoEntitySatisfiesTheConditionException, not a 404.
211
+ a 500 NoEntitySatisfiesTheConditionException, not a 404. Detail
212
+ arrays only travel under a $expand param (T60), same as the list
213
+ GET.
171
214
  """
172
215
  path = "/".join(quote(str(k), safe="") for k in keys)
173
- r = self._http.get(f"{self._url(entity, endpoint)}/{path}")
216
+ r = self._http.get(f"{self._url(entity, endpoint)}/{path}", params=params)
174
217
  if r.status_code == 500:
175
218
  try:
176
219
  missing = "NoEntitySatisfiesTheCondition" in r.json().get(
@@ -7,18 +7,34 @@ Baseline file format:
7
7
 
8
8
  entity: Currency # entity name in the contract endpoint
9
9
  key: CurrencyID # key field(s), string or list
10
- endpoint: Bootstrap/1.4.0 # optional: override the instance endpoint
10
+ endpoint: Bootstrap/1.6.0 # optional: override the instance endpoint
11
11
  records:
12
12
  - CurrencyID: "CAD"
13
13
  Description: Canadian Dollar
14
14
 
15
+ A record field holding a LIST is a detail array (T60) - each row a field
16
+ map, PUT with the whole record (the list itself never value-wrapped).
17
+ Every list field needs a detail_keys entry naming the field that
18
+ identifies its rows; diff matches rows by that key, order-insensitive,
19
+ and unlike top-level records an extra live detail row IS drift - the
20
+ record owns its list:
21
+
22
+ entity: KitSpecification
23
+ key: [KitInventoryID, RevisionID]
24
+ detail_keys: { StockComponents: ComponentID }
25
+ records:
26
+ - KitInventoryID: GW-EDGE
27
+ RevisionID: V1
28
+ StockComponents:
29
+ - { ComponentID: MB-CM4, ComponentQty: 1 }
30
+
15
31
  Action file format (setup/*.yaml) - desired state realized by a contract
16
32
  action plus a done_when live-state probe, for setup verbs a keyed PUT
17
33
  cannot express (calendar generation and the like):
18
34
 
19
35
  action: GenerateCalendar # action name on the endpoint entity
20
36
  entity: MasterCalendar # entity the action hangs off
21
- endpoint: Bootstrap/1.4.0 # optional: override the instance endpoint
37
+ endpoint: Bootstrap/1.6.0 # optional: override the instance endpoint
22
38
  record: { FinancialYear: 2026 }
23
39
  parameters: { FromYear: 2026, ToYear: 2026 } # optional
24
40
  done_when: { filter: "FinancialYear eq '2026'" }
@@ -85,6 +101,7 @@ class BaselineFile(Model):
85
101
  keys: list[str] = Field(alias="key")
86
102
  records: list[dict[str, Any]]
87
103
  endpoint: str | None = None # bootstrap YAML targets the custom endpoint
104
+ detail_keys: dict[str, str] | None = None # {ListField: RowKeyField} (T60)
88
105
 
89
106
  @field_validator("keys", mode="before")
90
107
  @classmethod
@@ -109,8 +126,40 @@ class BaselineFile(Model):
109
126
  "identify each record"
110
127
  )
111
128
  seen.add(ident)
129
+ self._check_details(i, record)
112
130
  return self
113
131
 
132
+ def _check_details(self, i: int, record: dict[str, Any]) -> None:
133
+ # T60, the V25 sibling for detail arrays: every list field needs a
134
+ # detail_keys entry (diff cannot match rows without one) and that
135
+ # key must identify each source row - a dup diffs as permanent
136
+ # false drift exactly like B21's top-level class
137
+ for field, value in record.items():
138
+ if not isinstance(value, list):
139
+ continue
140
+ key = (self.detail_keys or {}).get(field)
141
+ if key is None:
142
+ raise ValueError(
143
+ f"entity '{self.entity}': records[{i}].{field} is a "
144
+ "detail list but has no detail_keys entry - add "
145
+ f"detail_keys: {{{field}: <RowKeyField>}}"
146
+ )
147
+ seen_rows: set[str] = set()
148
+ for j, row in enumerate(value):
149
+ if not isinstance(row, dict) or key not in row:
150
+ raise ValueError(
151
+ f"entity '{self.entity}': records[{i}].{field}[{j}] "
152
+ f"missing detail key field '{key}'"
153
+ )
154
+ ident = str(row[key])
155
+ if ident in seen_rows:
156
+ raise ValueError(
157
+ f"entity '{self.entity}': records[{i}].{field}[{j}] "
158
+ f"duplicates detail key [{ident}] - the detail key "
159
+ "must identify each row"
160
+ )
161
+ seen_rows.add(ident)
162
+
114
163
 
115
164
  class DoneProbe(Model):
116
165
  """The done_when live-state probe: entity/endpoint default to the action's."""
@@ -169,8 +218,37 @@ def _norm(value: Any) -> str:
169
218
  def _filter_for(record: dict[str, Any], keys: list[str]) -> str:
170
219
  # callers pass single-view key sets only: a conjunction spanning the
171
220
  # entity's views answers 200 [] (B14), so _fetch filters on the first
172
- # key alone and matches the rest client-side
173
- return " and ".join(f"{k} eq '{record[k]}'" for k in keys)
221
+ # key alone and matches the rest client-side. Literal typing follows
222
+ # the YAML scalar type: bools and numbers travel bare - a quoted
223
+ # 'false' against Edm.Boolean answers 500 "binary operator with
224
+ # incompatible types" (T61 - INPreferences keyed on HoldEntry), and
225
+ # numeric Edm types are the same class. Strings stay quoted, so
226
+ # numeric-looking codes ('000000') are unaffected.
227
+ def literal(value: Any) -> str:
228
+ if isinstance(value, bool):
229
+ return str(value).lower()
230
+ if isinstance(value, int | float):
231
+ return str(value)
232
+ return f"'{value}'"
233
+
234
+ return " and ".join(f"{k} eq {literal(record[k])}" for k in keys)
235
+
236
+
237
+ def _expand_paths(record: dict[str, Any], prefix: str = "") -> list[str]:
238
+ """$expand paths a record's shape demands (T60/T65).
239
+
240
+ A list field is a detail array - expands by name; a dict field is a
241
+ linked entity - expands by name plus the slash path of every nested
242
+ dict (`MainContact`, `MainContact/Address`).
243
+ """
244
+ paths: list[str] = []
245
+ for field, value in record.items():
246
+ if isinstance(value, list):
247
+ paths.append(f"{prefix}{field}")
248
+ elif isinstance(value, dict):
249
+ paths.append(f"{prefix}{field}")
250
+ paths.extend(_expand_paths(value, f"{prefix}{field}/"))
251
+ return sorted(paths)
174
252
 
175
253
 
176
254
  def _probe(client: AcumaticaClient, action: ActionFile) -> bool:
@@ -223,11 +301,50 @@ def apply(
223
301
  if dry_run:
224
302
  output.data(f" would PUT {baseline.entity} [{label}]")
225
303
  else:
226
- client.put(baseline.entity, record, endpoint=baseline.endpoint)
304
+ body = record
305
+ if any(isinstance(v, list) for v in record.values()):
306
+ body = _with_detail_ids(client, baseline, record)
307
+ client.put(baseline.entity, body, endpoint=baseline.endpoint)
227
308
  output.data(f" PUT {baseline.entity} [{label}]")
228
309
  return len(baseline.records)
229
310
 
230
311
 
312
+ def _with_detail_ids(
313
+ client: AcumaticaClient, baseline: BaselineFile, record: dict[str, Any]
314
+ ) -> dict[str, Any]:
315
+ """Source record + live detail-row ids — the detail upsert handle (T60).
316
+
317
+ The contract API matches detail rows by row GUID only: a re-PUT
318
+ without ids re-INSERTS every row (live-verified on KitSpecification —
319
+ 500 "Component Item must be unique"). So apply pre-fetches the live
320
+ record, injects each matching live row's `id` (matched by the
321
+ detail_keys field), and appends `{id, delete: true}` rows for live
322
+ rows the source no longer claims — the record owns its list (V4
323
+ detail semantics), so apply converges exactly what diff would flag.
324
+ Record absent live → first PUT creates, rows travel id-less.
325
+ """
326
+ live = _fetch(client, baseline, record)
327
+ if live is None:
328
+ return record
329
+ out = dict(record)
330
+ for field, rows in record.items():
331
+ if not isinstance(rows, list):
332
+ continue
333
+ key = (baseline.detail_keys or {})[field] # load-validated
334
+ live_ids: dict[str, Any] = {}
335
+ for live_row in live.get(field) or []:
336
+ value = live_row.get(key)
337
+ if isinstance(value, dict) and "value" in value and "id" in live_row:
338
+ live_ids[_norm(value["value"])] = live_row["id"]
339
+ merged: list[dict[str, Any]] = []
340
+ for row in rows:
341
+ row_id = live_ids.pop(_norm(row[key]), None)
342
+ merged.append({**row, "id": row_id} if row_id is not None else row)
343
+ merged.extend({"id": row_id, "delete": True} for row_id in live_ids.values())
344
+ out[field] = merged
345
+ return out
346
+
347
+
231
348
  def _fetch(
232
349
  client: AcumaticaClient, baseline: BaselineFile, record: dict[str, Any]
233
350
  ) -> dict[str, Any] | None:
@@ -244,18 +361,30 @@ def _fetch(
244
361
  CuryRecords, B9) 500 on that optimized export; the key-URL
245
362
  single-record GET skips the optimizer, so diff falls back to it on
246
363
  exactly that error (V4: read-back must survive delegate-view entities).
364
+
365
+ Detail arrays and linked entities only travel under $expand
366
+ (T60/T65): without it every GET answers top-level scalars alone,
367
+ diff would report each source detail row or nested field missing
368
+ and apply's id-injection would see nothing to match. The expand set
369
+ derives from the record's own shape - a list field expands by name,
370
+ a dict field by its slash path (`MainContact/Address`).
247
371
  """
372
+ params = {"$filter": _filter_for(record, baseline.keys[:1])}
373
+ expand = _expand_paths(record)
374
+ if expand:
375
+ params["$expand"] = ",".join(expand)
248
376
  try:
249
377
  live = client.get_list(
250
- baseline.entity,
251
- params={"$filter": _filter_for(record, baseline.keys[:1])},
252
- endpoint=baseline.endpoint,
378
+ baseline.entity, params=params, endpoint=baseline.endpoint
253
379
  )
254
380
  except RuntimeError as err:
255
381
  if OPTIMIZATION_500 not in str(err):
256
382
  raise
257
383
  return client.get_record(
258
- baseline.entity, [record[k] for k in baseline.keys], baseline.endpoint
384
+ baseline.entity,
385
+ [record[k] for k in baseline.keys],
386
+ baseline.endpoint,
387
+ params={"$expand": params["$expand"]} if expand else None,
259
388
  )
260
389
  for row in live:
261
390
  actual = unwrap(row)
@@ -290,10 +419,70 @@ def diff(client: AcumaticaClient, baseline: BaselineFile | ActionFile) -> list[s
290
419
  continue
291
420
  actual = unwrap(live)
292
421
  for field, expected in record.items():
293
- if field not in actual:
422
+ if isinstance(expected, list):
423
+ key = (baseline.detail_keys or {})[field] # load-validated
424
+ live_rows = actual.get(field, [])
425
+ drifts.extend(_diff_details(label, field, key, expected, live_rows))
426
+ elif isinstance(expected, dict):
427
+ drifts.extend(
428
+ _diff_nested(f"{label}.{field}", expected, actual.get(field))
429
+ )
430
+ elif field not in actual:
294
431
  drifts.append(f"{label}.{field}: not returned by endpoint")
295
432
  elif _norm(actual[field]) != _norm(expected):
296
433
  drifts.append(
297
434
  f"{label}.{field}: source={expected!r} live={actual[field]!r}"
298
435
  )
299
436
  return drifts
437
+
438
+
439
+ def _diff_nested(path: str, expected: dict[str, Any], live: Any) -> list[str]:
440
+ """Linked-entity drift (T65): recurse source-side fields only."""
441
+ if not isinstance(live, dict):
442
+ return [f"{path}: not returned by endpoint"]
443
+ drifts: list[str] = []
444
+ for field, want in expected.items():
445
+ if isinstance(want, dict):
446
+ drifts.extend(_diff_nested(f"{path}.{field}", want, live.get(field)))
447
+ elif field not in live:
448
+ drifts.append(f"{path}.{field}: not returned by endpoint")
449
+ elif _norm(live[field]) != _norm(want):
450
+ drifts.append(f"{path}.{field}: source={want!r} live={live[field]!r}")
451
+ return drifts
452
+
453
+
454
+ def _diff_details(
455
+ label: str,
456
+ field: str,
457
+ key: str,
458
+ expected: list[dict[str, Any]],
459
+ live_rows: list[dict[str, Any]],
460
+ ) -> list[str]:
461
+ """Detail-array drift (T60): rows matched by detail key, order-insensitive.
462
+
463
+ Unlike top-level records (V4 exemption), an extra live detail row IS
464
+ drift - the record owns its list, apply cannot converge a live row the
465
+ source never claimed. Within a matched row only source-side fields
466
+ compare (server-derived LineNbr and ids stay omitted from source).
467
+ """
468
+ drifts: list[str] = []
469
+ live_by_key = {_norm(row[key]): row for row in live_rows if key in row}
470
+ for row in expected:
471
+ ident = _norm(row[key])
472
+ live_row = live_by_key.pop(ident, None)
473
+ if live_row is None:
474
+ drifts.append(f"{label}.{field}[{row[key]}]: missing on tenant")
475
+ continue
476
+ for sub, want in row.items():
477
+ if sub not in live_row:
478
+ drifts.append(
479
+ f"{label}.{field}[{row[key]}].{sub}: not returned by endpoint"
480
+ )
481
+ elif _norm(live_row[sub]) != _norm(want):
482
+ drifts.append(
483
+ f"{label}.{field}[{row[key]}].{sub}: "
484
+ f"source={want!r} live={live_row[sub]!r}"
485
+ )
486
+ for ident in live_by_key:
487
+ drifts.append(f"{label}.{field}[{ident}]: extra on tenant")
488
+ return drifts
@@ -1,4 +1,4 @@
1
- # General Ledger preferences (GL102000) via the custom Bootstrap/1.4.0
1
+ # General Ledger preferences (GL102000) via the custom Bootstrap/1.6.0
2
2
  # endpoint (the per-file endpoint: override) - the Default endpoint has
3
3
  # no GL-setup entity, and every Finance screen throws
4
4
  # PXSetupNotEnteredException until the GLSetup singleton row exists. The
@@ -12,7 +12,7 @@
12
12
  # referenced here must exist.
13
13
  entity: GLPreferences
14
14
  key: RetEarnAccountID
15
- endpoint: Bootstrap/1.4.0
15
+ endpoint: Bootstrap/1.6.0
16
16
  records:
17
17
  - RetEarnAccountID: '32000'
18
18
  YtdNetIncAccountID: '33000'
@@ -1,4 +1,4 @@
1
- # Org-ledger link (GL201500) via the custom Bootstrap/1.4.0 endpoint (the
1
+ # Org-ledger link (GL201500) via the custom Bootstrap/1.6.0 endpoint (the
2
2
  # per-file endpoint: override). The Default endpoint's Ledger.Companies
3
3
  # detail is write-tolerated then silently dropped (PUT answers 200 and
4
4
  # echoes the row, the link table stays empty), so the link writes through
@@ -21,7 +21,7 @@
21
21
  # (company.yaml).
22
22
  entity: LedgerCompany
23
23
  key: [LedgerCD, OrganizationID]
24
- endpoint: Bootstrap/1.4.0
24
+ endpoint: Bootstrap/1.6.0
25
25
  records:
26
26
  - LedgerCD: ACTUAL
27
27
  OrganizationID: COMPANY
@@ -1,11 +1,11 @@
1
- # Company (CS101500) via the custom Bootstrap/1.4.0 endpoint (the per-file
1
+ # Company (CS101500) via the custom Bootstrap/1.6.0 endpoint (the per-file
2
2
  # endpoint: override) - built-in endpoints cannot create the first
3
3
  # organization on a virgin tenant. List values are the EXTERNAL labels
4
4
  # ('Without Branches', not the DAC's 'WithoutBranches'); CountryID feeds
5
5
  # the org's Main Address - required on first save.
6
6
  entity: Company
7
7
  key: AcctCD
8
- endpoint: Bootstrap/1.4.0
8
+ endpoint: Bootstrap/1.6.0
9
9
  records:
10
10
  - AcctCD: COMPANY
11
11
  AcctName: Example Company
@@ -1,11 +1,11 @@
1
- # Credit terms (CS206500) via the custom Bootstrap/1.4.0 endpoint (the
1
+ # Credit terms (CS206500) via the custom Bootstrap/1.6.0 endpoint (the
2
2
  # per-file endpoint: override) - the Default endpoint has no credit-terms
3
3
  # entity. List values are the EXTERNAL labels: VisibleTo = All, Vendors,
4
4
  # Customers, Disabled; DueType/DiscType = Fixed Number of Days, Day of
5
5
  # Next Month, End of Month, ...
6
6
  entity: CreditTerms
7
7
  key: TermsID
8
- endpoint: Bootstrap/1.4.0
8
+ endpoint: Bootstrap/1.6.0
9
9
  records:
10
10
  - TermsID: NET30
11
11
  Descr: Net 30 Days
@@ -19,7 +19,7 @@
19
19
  # generation consumes this setup row.
20
20
  action: GeneratePeriods
21
21
  entity: FinancialYearSettings
22
- endpoint: Bootstrap/1.4.0
22
+ endpoint: Bootstrap/1.6.0
23
23
  record:
24
24
  BegFinYear: "2026-01-01"
25
25
  FinPeriods: 12
@@ -17,7 +17,7 @@
17
17
  # consumes the FinYearSetup row it creates.
18
18
  action: GenerateCalendar
19
19
  entity: MasterCalendar
20
- endpoint: Bootstrap/1.4.0
20
+ endpoint: Bootstrap/1.6.0
21
21
  record:
22
22
  FinancialYear: "2026"
23
23
  parameters:
@@ -22,7 +22,7 @@
22
22
  # consumes the periods generation creates.
23
23
  action: ProcessAll
24
24
  entity: ManagePeriods
25
- endpoint: Bootstrap/1.4.0
25
+ endpoint: Bootstrap/1.6.0
26
26
  record:
27
27
  Action: Open
28
28
  FromYear: "2026"