acumatica-cli 0.4.0__tar.gz → 0.5.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.5.0}/PKG-INFO +47 -10
  2. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/README.md +46 -9
  3. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/bootstrap_project.xml +329 -2
  5. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/client.py +42 -9
  6. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/seed.py +160 -10
  7. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +2 -2
  8. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +2 -2
  9. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/company.yaml +2 -2
  10. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +2 -2
  11. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +1 -1
  12. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +1 -1
  13. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +1 -1
  14. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/__init__.py +0 -0
  15. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/bootstrap.py +0 -0
  16. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  17. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/cli.py +0 -0
  18. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/config.py +0 -0
  19. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/extract.py +0 -0
  20. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
  21. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/firstlogin.py +0 -0
  22. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/models.py +0 -0
  23. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/output.py +0 -0
  24. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  25. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  26. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  27. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  28. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  29. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/env +0 -0
  30. {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/gitignore +0 -0
  31. {acumatica_cli-0.4.0 → acumatica_cli-0.5.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.5.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.5.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,64 @@
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
+ - CashAccount CA202000 -> CashAccountMaint, PrimaryView CashAccount
196
+ (PX.Objects.CA.CashAccount) - Default endpoint answers
197
+ 404. CuryID stays out (derived from the GL account,
198
+ B11 server-derived class); payment-method links are the
199
+ conditional PaymentMethodAccount follow-up, not here.
149
200
  Contract field names = DAC property names verbatim (no rename layer).
150
201
  Features (CS100000) stays OUT of the endpoint - writes through any
151
202
  contract endpoint do not persist (T3 verdict); the plugin owns features.
152
203
  -->
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">
204
+ <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" product-version="26.101">
154
205
  <EntityEndpoint>
155
- <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.4.0" systemContractVersion="4">
206
+ <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.5.0" systemContractVersion="4">
156
207
  <TopLevelEntity name="Company" screen="CS101500">
157
208
  <Fields>
158
209
  <Field name="AcctCD" type="StringValue" />
@@ -455,6 +506,282 @@
455
506
  </Mapping>
456
507
  </Mappings>
457
508
  </TopLevelEntity>
509
+ <TopLevelEntity name="INPreferences" screen="IN101000">
510
+ <Fields>
511
+ <Field name="HoldEntry" type="BooleanValue" />
512
+ <Field name="INProgressAcctID" type="StringValue" />
513
+ <Field name="INProgressSubID" type="StringValue" />
514
+ <Field name="IssuesReasonCode" type="StringValue" />
515
+ <Field name="ReceiptReasonCode" type="StringValue" />
516
+ <Field name="AdjustmentReasonCode" type="StringValue" />
517
+ <Field name="PIReasonCode" type="StringValue" />
518
+ </Fields>
519
+ <Mappings>
520
+ <Mapping field="HoldEntry">
521
+ <To object="setup" field="HoldEntry" />
522
+ </Mapping>
523
+ <Mapping field="INProgressAcctID">
524
+ <To object="setup" field="INProgressAcctID" />
525
+ </Mapping>
526
+ <Mapping field="INProgressSubID">
527
+ <To object="setup" field="INProgressSubID" />
528
+ </Mapping>
529
+ <Mapping field="IssuesReasonCode">
530
+ <To object="setup" field="IssuesReasonCode" />
531
+ </Mapping>
532
+ <Mapping field="ReceiptReasonCode">
533
+ <To object="setup" field="ReceiptReasonCode" />
534
+ </Mapping>
535
+ <Mapping field="AdjustmentReasonCode">
536
+ <To object="setup" field="AdjustmentReasonCode" />
537
+ </Mapping>
538
+ <Mapping field="PIReasonCode">
539
+ <To object="setup" field="PIReasonCode" />
540
+ </Mapping>
541
+ </Mappings>
542
+ </TopLevelEntity>
543
+ <TopLevelEntity name="ReasonCode" screen="CS211000">
544
+ <Fields>
545
+ <Field name="ReasonCodeID" type="StringValue" />
546
+ <Field name="Descr" type="StringValue" />
547
+ <Field name="Usage" type="StringValue" />
548
+ <Field name="AccountID" type="StringValue" />
549
+ <Field name="SubID" type="StringValue" />
550
+ </Fields>
551
+ <Mappings>
552
+ <Mapping field="ReasonCodeID">
553
+ <To object="reasoncode" field="ReasonCodeID" />
554
+ </Mapping>
555
+ <Mapping field="Descr">
556
+ <To object="reasoncode" field="Descr" />
557
+ </Mapping>
558
+ <Mapping field="Usage">
559
+ <To object="reasoncode" field="Usage" />
560
+ </Mapping>
561
+ <Mapping field="AccountID">
562
+ <To object="reasoncode" field="AccountID" />
563
+ </Mapping>
564
+ <Mapping field="SubID">
565
+ <To object="reasoncode" field="SubID" />
566
+ </Mapping>
567
+ </Mappings>
568
+ </TopLevelEntity>
569
+ <TopLevelEntity name="APPreferences" screen="AP101000">
570
+ <Fields>
571
+ <Field name="HoldEntry" type="BooleanValue" />
572
+ </Fields>
573
+ <Mappings>
574
+ <Mapping field="HoldEntry">
575
+ <To object="Setup" field="HoldEntry" />
576
+ </Mapping>
577
+ </Mappings>
578
+ </TopLevelEntity>
579
+ <TopLevelEntity name="ARPreferences" screen="AR101000">
580
+ <Fields>
581
+ <Field name="HoldEntry" type="BooleanValue" />
582
+ </Fields>
583
+ <Mappings>
584
+ <Mapping field="HoldEntry">
585
+ <To object="ARSetupRecord" field="HoldEntry" />
586
+ </Mapping>
587
+ </Mappings>
588
+ </TopLevelEntity>
589
+ <TopLevelEntity name="SOPreferences" screen="SO101000">
590
+ <Fields>
591
+ <Field name="DefaultOrderType" type="StringValue" />
592
+ </Fields>
593
+ <Mappings>
594
+ <Mapping field="DefaultOrderType">
595
+ <To object="sosetup" field="DefaultOrderType" />
596
+ </Mapping>
597
+ </Mappings>
598
+ </TopLevelEntity>
599
+ <TopLevelEntity name="POPreferences" screen="PO101000">
600
+ <Fields>
601
+ <Field name="HoldReceipts" type="BooleanValue" />
602
+ <Field name="RCReturnReasonCodeID" type="StringValue" />
603
+ </Fields>
604
+ <Mappings>
605
+ <Mapping field="HoldReceipts">
606
+ <To object="Setup" field="HoldReceipts" />
607
+ </Mapping>
608
+ <Mapping field="RCReturnReasonCodeID">
609
+ <To object="Setup" field="RCReturnReasonCodeID" />
610
+ </Mapping>
611
+ </Mappings>
612
+ </TopLevelEntity>
613
+ <TopLevelEntity name="AvailabilityCalculationRule" screen="IN201500">
614
+ <Fields>
615
+ <Field name="AvailabilitySchemeID" type="StringValue" />
616
+ <Field name="Description" type="StringValue" />
617
+ </Fields>
618
+ <Mappings>
619
+ <Mapping field="AvailabilitySchemeID">
620
+ <To object="Schemes" field="AvailabilitySchemeID" />
621
+ </Mapping>
622
+ <Mapping field="Description">
623
+ <To object="Schemes" field="Description" />
624
+ </Mapping>
625
+ </Mappings>
626
+ </TopLevelEntity>
627
+ <TopLevelEntity name="PostingClass" screen="IN206000">
628
+ <Fields>
629
+ <Field name="PostClassID" type="StringValue" />
630
+ <Field name="Descr" type="StringValue" />
631
+ <Field name="InvtAcctDefault" type="StringValue" />
632
+ <Field name="SalesAcctDefault" type="StringValue" />
633
+ <Field name="COGSAcctDefault" type="StringValue" />
634
+ <Field name="POAccrualAcctDefault" type="StringValue" />
635
+ <Field name="PPVAcctDefault" type="StringValue" />
636
+ <Field name="LCVarianceAcctDefault" type="StringValue" />
637
+ <Field name="InvtSubMask" type="StringValue" />
638
+ <Field name="SalesSubMask" type="StringValue" />
639
+ <Field name="COGSSubMask" type="StringValue" />
640
+ <Field name="POAccrualSubMask" type="StringValue" />
641
+ <Field name="PPVSubMask" type="StringValue" />
642
+ <Field name="LCVarianceSubMask" type="StringValue" />
643
+ <Field name="InvtAcctID" type="StringValue" />
644
+ <Field name="InvtSubID" type="StringValue" />
645
+ <Field name="SalesAcctID" type="StringValue" />
646
+ <Field name="SalesSubID" type="StringValue" />
647
+ <Field name="COGSAcctID" type="StringValue" />
648
+ <Field name="COGSSubID" type="StringValue" />
649
+ <Field name="POAccrualAcctID" type="StringValue" />
650
+ <Field name="POAccrualSubID" type="StringValue" />
651
+ <Field name="PPVAcctID" type="StringValue" />
652
+ <Field name="PPVSubID" type="StringValue" />
653
+ <Field name="LCVarianceAcctID" type="StringValue" />
654
+ <Field name="LCVarianceSubID" type="StringValue" />
655
+ </Fields>
656
+ <Mappings>
657
+ <Mapping field="PostClassID">
658
+ <To object="postclass" field="PostClassID" />
659
+ </Mapping>
660
+ <Mapping field="Descr">
661
+ <To object="postclass" field="Descr" />
662
+ </Mapping>
663
+ <Mapping field="InvtAcctDefault">
664
+ <To object="postclass" field="InvtAcctDefault" />
665
+ </Mapping>
666
+ <Mapping field="SalesAcctDefault">
667
+ <To object="postclass" field="SalesAcctDefault" />
668
+ </Mapping>
669
+ <Mapping field="COGSAcctDefault">
670
+ <To object="postclass" field="COGSAcctDefault" />
671
+ </Mapping>
672
+ <Mapping field="POAccrualAcctDefault">
673
+ <To object="postclass" field="POAccrualAcctDefault" />
674
+ </Mapping>
675
+ <Mapping field="PPVAcctDefault">
676
+ <To object="postclass" field="PPVAcctDefault" />
677
+ </Mapping>
678
+ <Mapping field="LCVarianceAcctDefault">
679
+ <To object="postclass" field="LCVarianceAcctDefault" />
680
+ </Mapping>
681
+ <Mapping field="InvtSubMask">
682
+ <To object="postclass" field="InvtSubMask" />
683
+ </Mapping>
684
+ <Mapping field="SalesSubMask">
685
+ <To object="postclass" field="SalesSubMask" />
686
+ </Mapping>
687
+ <Mapping field="COGSSubMask">
688
+ <To object="postclass" field="COGSSubMask" />
689
+ </Mapping>
690
+ <Mapping field="POAccrualSubMask">
691
+ <To object="postclass" field="POAccrualSubMask" />
692
+ </Mapping>
693
+ <Mapping field="PPVSubMask">
694
+ <To object="postclass" field="PPVSubMask" />
695
+ </Mapping>
696
+ <Mapping field="LCVarianceSubMask">
697
+ <To object="postclass" field="LCVarianceSubMask" />
698
+ </Mapping>
699
+ <Mapping field="InvtAcctID">
700
+ <To object="postclass" field="InvtAcctID" />
701
+ </Mapping>
702
+ <Mapping field="InvtSubID">
703
+ <To object="postclass" field="InvtSubID" />
704
+ </Mapping>
705
+ <Mapping field="SalesAcctID">
706
+ <To object="postclass" field="SalesAcctID" />
707
+ </Mapping>
708
+ <Mapping field="SalesSubID">
709
+ <To object="postclass" field="SalesSubID" />
710
+ </Mapping>
711
+ <Mapping field="COGSAcctID">
712
+ <To object="postclass" field="COGSAcctID" />
713
+ </Mapping>
714
+ <Mapping field="COGSSubID">
715
+ <To object="postclass" field="COGSSubID" />
716
+ </Mapping>
717
+ <Mapping field="POAccrualAcctID">
718
+ <To object="postclass" field="POAccrualAcctID" />
719
+ </Mapping>
720
+ <Mapping field="POAccrualSubID">
721
+ <To object="postclass" field="POAccrualSubID" />
722
+ </Mapping>
723
+ <Mapping field="PPVAcctID">
724
+ <To object="postclass" field="PPVAcctID" />
725
+ </Mapping>
726
+ <Mapping field="PPVSubID">
727
+ <To object="postclass" field="PPVSubID" />
728
+ </Mapping>
729
+ <Mapping field="LCVarianceAcctID">
730
+ <To object="postclass" field="LCVarianceAcctID" />
731
+ </Mapping>
732
+ <Mapping field="LCVarianceSubID">
733
+ <To object="postclass" field="LCVarianceSubID" />
734
+ </Mapping>
735
+ </Mappings>
736
+ </TopLevelEntity>
737
+ <TopLevelEntity name="CAPreferences" screen="CA101000">
738
+ <Fields>
739
+ <Field name="HoldEntry" type="BooleanValue" />
740
+ <Field name="TransitAcctId" type="StringValue" />
741
+ <Field name="TransitSubID" type="StringValue" />
742
+ </Fields>
743
+ <Mappings>
744
+ <Mapping field="HoldEntry">
745
+ <To object="CASetupRecord" field="HoldEntry" />
746
+ </Mapping>
747
+ <Mapping field="TransitAcctId">
748
+ <To object="CASetupRecord" field="TransitAcctId" />
749
+ </Mapping>
750
+ <Mapping field="TransitSubID">
751
+ <To object="CASetupRecord" field="TransitSubID" />
752
+ </Mapping>
753
+ </Mappings>
754
+ </TopLevelEntity>
755
+ <TopLevelEntity name="CashAccount" screen="CA202000">
756
+ <Fields>
757
+ <Field name="CashAccountCD" type="StringValue" />
758
+ <Field name="Descr" type="StringValue" />
759
+ <Field name="AccountID" type="StringValue" />
760
+ <Field name="SubID" type="StringValue" />
761
+ <Field name="BranchID" type="StringValue" />
762
+ <Field name="Active" type="BooleanValue" />
763
+ </Fields>
764
+ <Mappings>
765
+ <Mapping field="CashAccountCD">
766
+ <To object="CashAccount" field="CashAccountCD" />
767
+ </Mapping>
768
+ <Mapping field="Descr">
769
+ <To object="CashAccount" field="Descr" />
770
+ </Mapping>
771
+ <Mapping field="AccountID">
772
+ <To object="CashAccount" field="AccountID" />
773
+ </Mapping>
774
+ <Mapping field="SubID">
775
+ <To object="CashAccount" field="SubID" />
776
+ </Mapping>
777
+ <Mapping field="BranchID">
778
+ <To object="CashAccount" field="BranchID" />
779
+ </Mapping>
780
+ <Mapping field="Active">
781
+ <To object="CashAccount" field="Active" />
782
+ </Mapping>
783
+ </Mappings>
784
+ </TopLevelEntity>
458
785
  </Endpoint>
459
786
  </EntityEndpoint>
460
787
  </Customization>
@@ -14,15 +14,42 @@ 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). The contract's
22
+ OWN control fields travel bare, never value-wrapped: `id` (row GUID —
23
+ the only handle that makes a detail-row PUT an update instead of an
24
+ insert) and `delete` (row-removal marker).
25
+ """
26
+
27
+ def _value(v: Any) -> Any:
28
+ if isinstance(v, list):
29
+ return [wrap(row) if isinstance(row, dict) else row for row in v]
30
+ return {"value": v}
31
+
32
+ return {k: v if k in ("id", "delete") else _value(v) for k, v in record.items()}
19
33
 
20
34
 
21
35
  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
- }
36
+ """Contract-API entity -> plain dict (value fields + detail arrays).
37
+
38
+ Inverse of wrap (T60): a list of dicts unwraps row by row; lists that
39
+ are empty or whose rows carry no value fields are elided (a detail
40
+ array the source never claimed must not surface, and expanded `files`
41
+ descriptors are plain dicts that would unwrap to noise). Non-value
42
+ dicts (custom, _links, note) stay excluded as before.
43
+ """
44
+ out: dict[str, Any] = {}
45
+ for k, v in entity.items():
46
+ if isinstance(v, dict) and "value" in v:
47
+ out[k] = v["value"]
48
+ elif isinstance(v, list) and v and all(isinstance(row, dict) for row in v):
49
+ rows = [unwrap(row) for row in v]
50
+ if any(rows):
51
+ out[k] = rows
52
+ return out
26
53
 
27
54
 
28
55
  # The list GET's optimized-export failure (B9): the contract API's list GET
@@ -159,7 +186,11 @@ class AcumaticaClient:
159
186
  ).json()
160
187
 
161
188
  def get_record(
162
- self, entity: str, keys: Sequence[Any], endpoint: str | None = None
189
+ self,
190
+ entity: str,
191
+ keys: Sequence[Any],
192
+ endpoint: str | None = None,
193
+ params: dict[str, str] | None = None,
163
194
  ) -> dict[str, Any] | None:
164
195
  """GET one record by key URL — the delegate-view-safe read (B9).
165
196
 
@@ -167,10 +198,12 @@ class AcumaticaClient:
167
198
  scope maps to a BQL-delegate view; the key-URL form skips the
168
199
  optimizer and returns the full record (verified vs 26.101.0225).
169
200
  Returns None when no record matches — the server answers that with
170
- a 500 NoEntitySatisfiesTheConditionException, not a 404.
201
+ a 500 NoEntitySatisfiesTheConditionException, not a 404. Detail
202
+ arrays only travel under a $expand param (T60), same as the list
203
+ GET.
171
204
  """
172
205
  path = "/".join(quote(str(k), safe="") for k in keys)
173
- r = self._http.get(f"{self._url(entity, endpoint)}/{path}")
206
+ r = self._http.get(f"{self._url(entity, endpoint)}/{path}", params=params)
174
207
  if r.status_code == 500:
175
208
  try:
176
209
  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.5.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.5.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,20 @@ 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)
174
235
 
175
236
 
176
237
  def _probe(client: AcumaticaClient, action: ActionFile) -> bool:
@@ -223,11 +284,50 @@ def apply(
223
284
  if dry_run:
224
285
  output.data(f" would PUT {baseline.entity} [{label}]")
225
286
  else:
226
- client.put(baseline.entity, record, endpoint=baseline.endpoint)
287
+ body = record
288
+ if any(isinstance(v, list) for v in record.values()):
289
+ body = _with_detail_ids(client, baseline, record)
290
+ client.put(baseline.entity, body, endpoint=baseline.endpoint)
227
291
  output.data(f" PUT {baseline.entity} [{label}]")
228
292
  return len(baseline.records)
229
293
 
230
294
 
295
+ def _with_detail_ids(
296
+ client: AcumaticaClient, baseline: BaselineFile, record: dict[str, Any]
297
+ ) -> dict[str, Any]:
298
+ """Source record + live detail-row ids — the detail upsert handle (T60).
299
+
300
+ The contract API matches detail rows by row GUID only: a re-PUT
301
+ without ids re-INSERTS every row (live-verified on KitSpecification —
302
+ 500 "Component Item must be unique"). So apply pre-fetches the live
303
+ record, injects each matching live row's `id` (matched by the
304
+ detail_keys field), and appends `{id, delete: true}` rows for live
305
+ rows the source no longer claims — the record owns its list (V4
306
+ detail semantics), so apply converges exactly what diff would flag.
307
+ Record absent live → first PUT creates, rows travel id-less.
308
+ """
309
+ live = _fetch(client, baseline, record)
310
+ if live is None:
311
+ return record
312
+ out = dict(record)
313
+ for field, rows in record.items():
314
+ if not isinstance(rows, list):
315
+ continue
316
+ key = (baseline.detail_keys or {})[field] # load-validated
317
+ live_ids: dict[str, Any] = {}
318
+ for live_row in live.get(field) or []:
319
+ value = live_row.get(key)
320
+ if isinstance(value, dict) and "value" in value and "id" in live_row:
321
+ live_ids[_norm(value["value"])] = live_row["id"]
322
+ merged: list[dict[str, Any]] = []
323
+ for row in rows:
324
+ row_id = live_ids.pop(_norm(row[key]), None)
325
+ merged.append({**row, "id": row_id} if row_id is not None else row)
326
+ merged.extend({"id": row_id, "delete": True} for row_id in live_ids.values())
327
+ out[field] = merged
328
+ return out
329
+
330
+
231
331
  def _fetch(
232
332
  client: AcumaticaClient, baseline: BaselineFile, record: dict[str, Any]
233
333
  ) -> dict[str, Any] | None:
@@ -244,18 +344,27 @@ def _fetch(
244
344
  CuryRecords, B9) 500 on that optimized export; the key-URL
245
345
  single-record GET skips the optimizer, so diff falls back to it on
246
346
  exactly that error (V4: read-back must survive delegate-view entities).
347
+
348
+ Detail arrays only travel under $expand (T60): without it every GET
349
+ answers top-level fields alone, diff would report each source detail
350
+ row missing and apply's id-injection would see nothing to match.
247
351
  """
352
+ params = {"$filter": _filter_for(record, baseline.keys[:1])}
353
+ detail_fields = [f for f in (baseline.detail_keys or {}) if f in record]
354
+ if detail_fields:
355
+ params["$expand"] = ",".join(sorted(detail_fields))
248
356
  try:
249
357
  live = client.get_list(
250
- baseline.entity,
251
- params={"$filter": _filter_for(record, baseline.keys[:1])},
252
- endpoint=baseline.endpoint,
358
+ baseline.entity, params=params, endpoint=baseline.endpoint
253
359
  )
254
360
  except RuntimeError as err:
255
361
  if OPTIMIZATION_500 not in str(err):
256
362
  raise
257
363
  return client.get_record(
258
- baseline.entity, [record[k] for k in baseline.keys], baseline.endpoint
364
+ baseline.entity,
365
+ [record[k] for k in baseline.keys],
366
+ baseline.endpoint,
367
+ params={"$expand": params["$expand"]} if detail_fields else None,
259
368
  )
260
369
  for row in live:
261
370
  actual = unwrap(row)
@@ -290,10 +399,51 @@ def diff(client: AcumaticaClient, baseline: BaselineFile | ActionFile) -> list[s
290
399
  continue
291
400
  actual = unwrap(live)
292
401
  for field, expected in record.items():
293
- if field not in actual:
402
+ if isinstance(expected, list):
403
+ key = (baseline.detail_keys or {})[field] # load-validated
404
+ live_rows = actual.get(field, [])
405
+ drifts.extend(_diff_details(label, field, key, expected, live_rows))
406
+ elif field not in actual:
294
407
  drifts.append(f"{label}.{field}: not returned by endpoint")
295
408
  elif _norm(actual[field]) != _norm(expected):
296
409
  drifts.append(
297
410
  f"{label}.{field}: source={expected!r} live={actual[field]!r}"
298
411
  )
299
412
  return drifts
413
+
414
+
415
+ def _diff_details(
416
+ label: str,
417
+ field: str,
418
+ key: str,
419
+ expected: list[dict[str, Any]],
420
+ live_rows: list[dict[str, Any]],
421
+ ) -> list[str]:
422
+ """Detail-array drift (T60): rows matched by detail key, order-insensitive.
423
+
424
+ Unlike top-level records (V4 exemption), an extra live detail row IS
425
+ drift - the record owns its list, apply cannot converge a live row the
426
+ source never claimed. Within a matched row only source-side fields
427
+ compare (server-derived LineNbr and ids stay omitted from source).
428
+ """
429
+ drifts: list[str] = []
430
+ live_by_key = {_norm(row[key]): row for row in live_rows if key in row}
431
+ for row in expected:
432
+ ident = _norm(row[key])
433
+ live_row = live_by_key.pop(ident, None)
434
+ if live_row is None:
435
+ drifts.append(f"{label}.{field}[{row[key]}]: missing on tenant")
436
+ continue
437
+ for sub, want in row.items():
438
+ if sub not in live_row:
439
+ drifts.append(
440
+ f"{label}.{field}[{row[key]}].{sub}: not returned by endpoint"
441
+ )
442
+ elif _norm(live_row[sub]) != _norm(want):
443
+ drifts.append(
444
+ f"{label}.{field}[{row[key]}].{sub}: "
445
+ f"source={want!r} live={live_row[sub]!r}"
446
+ )
447
+ for ident in live_by_key:
448
+ drifts.append(f"{label}.{field}[{ident}]: extra on tenant")
449
+ 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.5.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.5.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.5.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.5.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.5.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.5.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.5.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.5.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.5.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.5.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.5.0
26
26
  record:
27
27
  Action: Open
28
28
  FromYear: "2026"