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.
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/PKG-INFO +47 -10
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/README.md +46 -9
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/pyproject.toml +1 -1
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/bootstrap_project.xml +329 -2
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/client.py +42 -9
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/seed.py +160 -10
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +2 -2
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +2 -2
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/company.yaml +2 -2
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +2 -2
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +1 -1
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +1 -1
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +1 -1
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/bootstrap.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/cli.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/config.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/extract.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/env +0 -0
- {acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/gitignore +0 -0
- {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.
|
|
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. **
|
|
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
|
-
|
|
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`.
|
|
33
|
-
-
|
|
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/`.
|
|
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. **
|
|
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
|
-
|
|
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`.
|
|
16
|
-
-
|
|
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/`.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
23
|
-
|
|
24
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
24
|
+
endpoint: Bootstrap/1.5.0
|
|
25
25
|
records:
|
|
26
26
|
- LedgerCD: ACTUAL
|
|
27
27
|
OrganizationID: COMPANY
|
{acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/company.yaml
RENAMED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# Company (CS101500) via the custom Bootstrap/1.
|
|
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.
|
|
8
|
+
endpoint: Bootstrap/1.5.0
|
|
9
9
|
records:
|
|
10
10
|
- AcctCD: COMPANY
|
|
11
11
|
AcctName: Example Company
|
{acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml
RENAMED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# Credit terms (CS206500) via the custom Bootstrap/1.
|
|
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.
|
|
8
|
+
endpoint: Bootstrap/1.5.0
|
|
9
9
|
records:
|
|
10
10
|
- TermsID: NET30
|
|
11
11
|
Descr: Net 30 Days
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.4.0 → acumatica_cli-0.5.0}/src/acumatica_cli/templates/bootstrap/features.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|