acumatica-cli 0.6.0__tar.gz → 0.8.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 (32) hide show
  1. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/PKG-INFO +1 -1
  2. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/pyproject.toml +1 -1
  3. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/bootstrap_project.xml +106 -5
  4. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/cli.py +37 -1
  5. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/client.py +13 -3
  6. acumatica_cli-0.8.0/src/acumatica_cli/run.py +422 -0
  7. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/seed.py +2 -2
  8. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +2 -2
  9. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +2 -2
  10. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/bootstrap/company.yaml +2 -2
  11. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +2 -2
  12. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +1 -1
  13. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +1 -1
  14. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +1 -1
  15. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/README.md +0 -0
  16. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/__init__.py +0 -0
  17. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/bootstrap.py +0 -0
  18. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  19. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/config.py +0 -0
  20. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/extract.py +0 -0
  21. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
  22. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/firstlogin.py +0 -0
  23. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/models.py +0 -0
  24. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/output.py +0 -0
  25. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  26. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  27. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  28. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  29. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  30. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/env +0 -0
  31. {acumatica_cli-0.6.0 → acumatica_cli-0.8.0}/src/acumatica_cli/templates/gitignore +0 -0
  32. {acumatica_cli-0.6.0 → acumatica_cli-0.8.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.6.0
3
+ Version: 0.8.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.6.0"
3
+ version = "0.8.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"
@@ -157,7 +157,12 @@
157
157
  fields with no server default (read off the LAB5 PUT
158
158
  2026-07-13): INProgressAcctID/INProgressSubID (the WIP
159
159
  account) + the four reason codes (Issues/Receipt/
160
- Adjustment/PI) - all exposed; HoldEntry = the echo flag.
160
+ Adjustment/PI) - all exposed; HoldEntry = the echo flag;
161
+ INTransitAcctID/INTransitSubID join once the Warehouse
162
+ feature is on (update 500s "'In-Transit Account' cannot
163
+ be empty", P5 probe); UpdateGL defaults OFF - released
164
+ IN documents move quantities but post no GL (COGS and
165
+ PO accrual sat at zero while revenue moved, P5 probe).
161
166
  - ReasonCode CS211000 -> ReasonCodeMaint, PrimaryView reasoncode
162
167
  (PX.Objects.CS.ReasonCode) - no Default-endpoint entity,
163
168
  and the INSetup reason-code fields above hard-require
@@ -169,11 +174,18 @@
169
174
  (ARSetup); HoldEntry echo field.
170
175
  - SOPreferences SO101000 -> SOSetupMaint, PrimaryView sosetup (SOSetup);
171
176
  DefaultOrderType doubles as the echo field and the R1
172
- probe (does order type SO pre-exist on an empty tenant).
177
+ probe (does order type SO pre-exist on an empty tenant);
178
+ HoldShipments/RequireShipmentTotal off + AutoReleaseIN on
179
+ keep the shipment->invoice chain scriptable (template
180
+ defaults park shipments On Hold and refuse confirmation
181
+ without a control total - P5 probes).
173
182
  - POPreferences PO101000 -> POSetupMaint, PrimaryView Setup (POSetup);
174
183
  HoldReceipts echo field + RCReturnReasonCodeID (the one
175
184
  POSetup field the live insert named as required - a
176
- Vendor Return usage reason code, 2026-07-13).
185
+ Vendor Return usage reason code, 2026-07-13) +
186
+ AutoReleaseIN (default off: a released PO receipt leaves
187
+ its IN document Balanced and qty never lands on hand -
188
+ P5 probe).
177
189
  - AvailabilityCalculationRule IN201500 -> INAvailabilitySchemeMaint,
178
190
  PrimaryView Schemes (INAvailabilityScheme) - an empty
179
191
  tenant has zero scheme rows and ItemClass 422s
@@ -209,6 +221,23 @@
209
221
  fixed-day-of-month choice; the AgeDays00-02 aging bounds
210
222
  default inconsistently at raw insert ("end day earlier
211
223
  than its start day" 422) so the seed supplies them.
224
+ - Warehouse IN204000 -> INSiteMaint, PrimaryView site (DAC
225
+ PX.Objects.IN.INSite) - the Default Warehouse entity has
226
+ no address surface, and the auto-created warehouse
227
+ Address 422s "'Country' cannot be empty" at insert (P5
228
+ probe 2026-07-13; the T65 vendor class, but Default has
229
+ no path). SiteCD/Descr/Active on the primary view,
230
+ CountryID through the Address view.
231
+ - OrderType SO201000 -> SOOrderTypeMaint, PrimaryView soordertype
232
+ (DAC PX.Objects.SO.SOOrderType) - no Default-endpoint
233
+ entity, and a fresh tenant has ZERO order-type rows
234
+ (they live in the template company, CompanyID 1,
235
+ Active=0 - sqlcmd-verified 2026-07-13): a SalesOrder
236
+ PUT answers 200 with an UNSAVED row (OrderNbr stays
237
+ <SELECT>, CustomerID silently empty) because numbering
238
+ never resolves. Keyed PUT on OrderType materializes the
239
+ template row into the tenant; Active activates it; the
240
+ freight pair is required at persist (422, no default).
212
241
  - CashAccount CA202000 -> CashAccountMaint, PrimaryView CashAccount
213
242
  (PX.Objects.CA.CashAccount) - Default endpoint answers
214
243
  404. CuryID stays out (derived from the GL account,
@@ -218,9 +247,9 @@
218
247
  Features (CS100000) stays OUT of the endpoint - writes through any
219
248
  contract endpoint do not persist (T3 verdict); the plugin owns features.
220
249
  -->
221
- <Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes company + credit terms + financial currency + GL preferences + ledger-company link + financial year + calendars + period activation + module preferences (IN/AP/AR/SO/PO) + availability rule + posting class + cash account + vendor class + statement cycle" product-version="26.101">
250
+ <Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes company + credit terms + financial currency + GL preferences + ledger-company link + financial year + calendars + period activation + module preferences (IN/AP/AR/SO/PO) + availability rule + posting class + cash account + vendor class + statement cycle + warehouse + order type" product-version="26.101">
222
251
  <EntityEndpoint>
223
- <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.6.0" systemContractVersion="4">
252
+ <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.7.0" systemContractVersion="4">
224
253
  <TopLevelEntity name="Company" screen="CS101500">
225
254
  <Fields>
226
255
  <Field name="AcctCD" type="StringValue" />
@@ -532,6 +561,9 @@
532
561
  <Field name="ReceiptReasonCode" type="StringValue" />
533
562
  <Field name="AdjustmentReasonCode" type="StringValue" />
534
563
  <Field name="PIReasonCode" type="StringValue" />
564
+ <Field name="INTransitAcctID" type="StringValue" />
565
+ <Field name="INTransitSubID" type="StringValue" />
566
+ <Field name="UpdateGL" type="BooleanValue" />
535
567
  </Fields>
536
568
  <Mappings>
537
569
  <Mapping field="HoldEntry">
@@ -555,6 +587,15 @@
555
587
  <Mapping field="PIReasonCode">
556
588
  <To object="setup" field="PIReasonCode" />
557
589
  </Mapping>
590
+ <Mapping field="INTransitAcctID">
591
+ <To object="setup" field="INTransitAcctID" />
592
+ </Mapping>
593
+ <Mapping field="INTransitSubID">
594
+ <To object="setup" field="INTransitSubID" />
595
+ </Mapping>
596
+ <Mapping field="UpdateGL">
597
+ <To object="setup" field="UpdateGL" />
598
+ </Mapping>
558
599
  </Mappings>
559
600
  </TopLevelEntity>
560
601
  <TopLevelEntity name="ReasonCode" screen="CS211000">
@@ -606,22 +647,38 @@
606
647
  <TopLevelEntity name="SOPreferences" screen="SO101000">
607
648
  <Fields>
608
649
  <Field name="DefaultOrderType" type="StringValue" />
650
+ <Field name="HoldShipments" type="BooleanValue" />
651
+ <Field name="RequireShipmentTotal" type="BooleanValue" />
652
+ <Field name="AutoReleaseIN" type="BooleanValue" />
609
653
  </Fields>
610
654
  <Mappings>
611
655
  <Mapping field="DefaultOrderType">
612
656
  <To object="sosetup" field="DefaultOrderType" />
613
657
  </Mapping>
658
+ <Mapping field="HoldShipments">
659
+ <To object="sosetup" field="HoldShipments" />
660
+ </Mapping>
661
+ <Mapping field="RequireShipmentTotal">
662
+ <To object="sosetup" field="RequireShipmentTotal" />
663
+ </Mapping>
664
+ <Mapping field="AutoReleaseIN">
665
+ <To object="sosetup" field="AutoReleaseIN" />
666
+ </Mapping>
614
667
  </Mappings>
615
668
  </TopLevelEntity>
616
669
  <TopLevelEntity name="POPreferences" screen="PO101000">
617
670
  <Fields>
618
671
  <Field name="HoldReceipts" type="BooleanValue" />
619
672
  <Field name="RCReturnReasonCodeID" type="StringValue" />
673
+ <Field name="AutoReleaseIN" type="BooleanValue" />
620
674
  </Fields>
621
675
  <Mappings>
622
676
  <Mapping field="HoldReceipts">
623
677
  <To object="Setup" field="HoldReceipts" />
624
678
  </Mapping>
679
+ <Mapping field="AutoReleaseIN">
680
+ <To object="Setup" field="AutoReleaseIN" />
681
+ </Mapping>
625
682
  <Mapping field="RCReturnReasonCodeID">
626
683
  <To object="Setup" field="RCReturnReasonCodeID" />
627
684
  </Mapping>
@@ -861,6 +918,50 @@
861
918
  </Mapping>
862
919
  </Mappings>
863
920
  </TopLevelEntity>
921
+ <TopLevelEntity name="Warehouse" screen="IN204000">
922
+ <Fields>
923
+ <Field name="SiteCD" type="StringValue" />
924
+ <Field name="Descr" type="StringValue" />
925
+ <Field name="Active" type="BooleanValue" />
926
+ <Field name="CountryID" type="StringValue" />
927
+ </Fields>
928
+ <Mappings>
929
+ <Mapping field="SiteCD">
930
+ <To object="site" field="SiteCD" />
931
+ </Mapping>
932
+ <Mapping field="Descr">
933
+ <To object="site" field="Descr" />
934
+ </Mapping>
935
+ <Mapping field="Active">
936
+ <To object="site" field="Active" />
937
+ </Mapping>
938
+ <Mapping field="CountryID">
939
+ <To object="Address" field="CountryID" />
940
+ </Mapping>
941
+ </Mappings>
942
+ </TopLevelEntity>
943
+ <TopLevelEntity name="OrderType" screen="SO201000">
944
+ <Fields>
945
+ <Field name="OrderType" type="StringValue" />
946
+ <Field name="Active" type="BooleanValue" />
947
+ <Field name="FreightAcctID" type="StringValue" />
948
+ <Field name="FreightSubID" type="StringValue" />
949
+ </Fields>
950
+ <Mappings>
951
+ <Mapping field="OrderType">
952
+ <To object="soordertype" field="OrderType" />
953
+ </Mapping>
954
+ <Mapping field="Active">
955
+ <To object="soordertype" field="Active" />
956
+ </Mapping>
957
+ <Mapping field="FreightAcctID">
958
+ <To object="soordertype" field="FreightAcctID" />
959
+ </Mapping>
960
+ <Mapping field="FreightSubID">
961
+ <To object="soordertype" field="FreightSubID" />
962
+ </Mapping>
963
+ </Mappings>
964
+ </TopLevelEntity>
864
965
  <TopLevelEntity name="CashAccount" screen="CA202000">
865
966
  <Fields>
866
967
  <Field name="CashAccountCD" type="StringValue" />
@@ -12,7 +12,7 @@ import click
12
12
  import httpx
13
13
  from click.shell_completion import get_completion_class
14
14
 
15
- from . import bootstrap, extract, firstlogin, output, seed
15
+ from . import bootstrap, extract, firstlogin, output, run, seed
16
16
  from .client import AcumaticaClient
17
17
  from .config import (
18
18
  Instance,
@@ -557,6 +557,42 @@ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
557
557
  _exit_on_drift(inst, drifts, len(paths))
558
558
 
559
559
 
560
+ @cli.command("run")
561
+ @click.argument(
562
+ "files", nargs=-1, required=False, type=click.Path(exists=True, path_type=Path)
563
+ )
564
+ @click.option("--dry-run", is_flag=True, help="Parse and list steps without any HTTP")
565
+ @pass_instance
566
+ def run_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
567
+ """Execute transaction scenario YAML against the live tenant.
568
+
569
+ FILES are scenario YAML files or directories containing them. Omitted,
570
+ they default to the data repo's scenario/ directory. Transactions are
571
+ executed forward (the server assigns document numbers), never upserted;
572
+ delta expectations snapshot before the first step and compare after the
573
+ last, so a scenario re-runs safely on a warm tenant. Exit 0 when every
574
+ expectation holds, 1 on any step error or expectation miss.
575
+ """
576
+ if not files:
577
+ default = data_root() / "scenario"
578
+ if not default.is_dir():
579
+ raise SystemExit(f"{default}: scenario directory does not exist")
580
+ files = (Path(os.path.relpath(default)),)
581
+ paths = expand_files(files)
582
+ scenarios = [run.load_scenario(path) for path in paths]
583
+ ok = True
584
+ if dry_run:
585
+ for scenario in scenarios:
586
+ run.run(None, scenario, dry_run=True)
587
+ else:
588
+ with AcumaticaClient(inst) as client:
589
+ for scenario in scenarios:
590
+ ok = run.run(client, scenario) and ok
591
+ if not ok:
592
+ raise SystemExit(1)
593
+ output.success(f"{len(scenarios)} scenario(s) passed on {inst.tenant}")
594
+
595
+
560
596
  def _complete_only(
561
597
  _ctx: click.Context, _param: click.Parameter, incomplete: str
562
598
  ) -> list[str]:
@@ -230,11 +230,21 @@ class AcumaticaClient:
230
230
  return self._checked(self._http.get(self._url("swagger.json"))).content
231
231
 
232
232
  def put(
233
- self, entity: str, record: dict[str, Any], endpoint: str | None = None
233
+ self,
234
+ entity: str,
235
+ record: dict[str, Any],
236
+ endpoint: str | None = None,
237
+ params: dict[str, str] | None = None,
234
238
  ) -> dict[str, Any]:
235
- """Upsert by the entity's key fields — the idempotence primitive."""
239
+ """Upsert by the entity's key fields — the idempotence primitive.
240
+
241
+ `params` carries OData query options — the contract-inquiry idiom
242
+ (T62) is a PUT of the parameter record with `$expand=Results`.
243
+ """
236
244
  return self._checked(
237
- self._http.put(self._url(entity, endpoint), json=wrap(record))
245
+ self._http.put(
246
+ self._url(entity, endpoint), json=wrap(record), params=params
247
+ )
238
248
  ).json()
239
249
 
240
250
  # seconds between polls of a long-running action's status URL; module
@@ -0,0 +1,422 @@
1
+ """Transaction scenarios: scenario/*.yaml executed against the live tenant.
2
+
3
+ `acu run` is the third data-plane verb (SPEC I.cmd): apply/diff own
4
+ configuration (keyed idempotent upserts), run owns transactions - the
5
+ server assigns document numbers, so a scenario is executed forward,
6
+ never upserted. The scenario file is the contract for the future AI
7
+ scenario generator: declarative steps, checkable expectations.
8
+
9
+ Scenario file format (SPEC I.data):
10
+
11
+ scenario: buy-build-sell
12
+ description: Buy parts -> assemble kits -> sell -> collect
13
+ steps:
14
+ - id: po-shenzhen
15
+ put: PurchaseOrder
16
+ record:
17
+ VendorID: SHENZHEN
18
+ Details:
19
+ - { InventoryID: MB-CM4, OrderQty: 25 }
20
+ capture: { OrderNbr: po_shenzhen } # server-assigned -> ${var}
21
+ - id: release
22
+ action: { entity: PurchaseReceipt, name: ReleasePurchaseReceipt }
23
+ record: { ReceiptNbr: "${rcpt}" }
24
+ wait: # poll until field match
25
+ entity: PurchaseReceipt
26
+ keys: ["${rcpt}"]
27
+ until: { Status: Released }
28
+ expect:
29
+ - get: { entity: Payment, keys: [Payment, "${pmt}"] }
30
+ fields: { Status: Closed }
31
+ - inquire: AccountSummaryInquiry # delta = post - pre
32
+ parameters: { Ledger: ACTUAL, Period: "062026" }
33
+ match: { Account: "40000" }
34
+ delta: { EndingBalance: 4138.00 }
35
+
36
+ Steps run in order; `capture` lifts server-assigned fields into ${var}
37
+ tokens for later steps. `expect` delta assertions snapshot before the
38
+ first step and re-probe after the last, comparing the difference - the
39
+ scenario re-runs safely on a warm tenant (document numbers differ, the
40
+ deltas hold). `get` assertions are absolute (statuses of documents
41
+ created in this run). Exit 0 = every expectation holds; 1 = any step
42
+ error or expectation miss (2 stays diff's drift code).
43
+ """
44
+
45
+ import re
46
+ import time
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ import yaml
51
+ from pydantic import Field, ValidationError, model_validator
52
+
53
+ from . import output
54
+ from .client import AcumaticaClient, unwrap
55
+ from .models import Model, validation_summary
56
+ from .seed import _norm # pyright: ignore[reportPrivateUsage]
57
+
58
+ _VAR = re.compile(r"\$\{([A-Za-z0-9_]+)\}")
59
+ _PATH = re.compile(r"([A-Za-z0-9_]+)(?:\[(\d+)\])?")
60
+
61
+
62
+ class ActionSpec(Model):
63
+ """The contract action a step invokes: entity + action name."""
64
+
65
+ entity: str
66
+ name: str
67
+
68
+
69
+ class WaitSpec(Model):
70
+ """Poll a record by key URL until every `until` field matches."""
71
+
72
+ entity: str
73
+ keys: list[Any]
74
+ until: dict[str, Any]
75
+ timeout: float = 120.0
76
+ endpoint: str | None = None
77
+
78
+
79
+ class GetOp(Model):
80
+ """Key-URL fetch a get step performs; expand pulls detail arrays."""
81
+
82
+ entity: str
83
+ keys: list[Any]
84
+ expand: list[str] | None = None
85
+ endpoint: str | None = None
86
+
87
+
88
+ class Step(Model):
89
+ """One scenario step: exactly one op (put | action | get | wait-only)."""
90
+
91
+ id: str
92
+ put: str | None = None
93
+ action: ActionSpec | None = None
94
+ get: GetOp | None = None
95
+ record: dict[str, Any] | None = None
96
+ parameters: dict[str, Any] | None = None
97
+ capture: dict[str, str] | None = None # {Field-or-path: var_name}
98
+ wait: WaitSpec | None = None
99
+ endpoint: str | None = None
100
+
101
+ @model_validator(mode="after")
102
+ def _one_op(self) -> "Step":
103
+ ops = [op for op in (self.put, self.action, self.get) if op is not None]
104
+ if len(ops) > 1:
105
+ raise ValueError(f"step '{self.id}': put, action, get are exclusive")
106
+ if not ops and self.wait is None:
107
+ raise ValueError(f"step '{self.id}': needs one of put, action, get, wait")
108
+ if self.put is not None and self.record is None:
109
+ raise ValueError(f"step '{self.id}': put needs a record")
110
+ if self.action is not None and self.record is None:
111
+ raise ValueError(f"step '{self.id}': action needs a record")
112
+ if self.capture is not None and self.put is None and self.get is None:
113
+ raise ValueError(f"step '{self.id}': capture rides a put or get step")
114
+ return self
115
+
116
+
117
+ class GetSpec(Model):
118
+ """Key-URL record address for a get expectation."""
119
+
120
+ entity: str
121
+ keys: list[Any]
122
+ endpoint: str | None = None
123
+
124
+
125
+ class Expect(Model):
126
+ """One expectation: absolute `get` fields or `inquire` deltas."""
127
+
128
+ get: GetSpec | None = None
129
+ fields: dict[str, Any] | None = None
130
+ inquire: str | None = None
131
+ parameters: dict[str, Any] | None = None
132
+ match: dict[str, Any] | None = None
133
+ delta: dict[str, float] | None = None
134
+ endpoint: str | None = None
135
+
136
+ @model_validator(mode="after")
137
+ def _one_kind(self) -> "Expect":
138
+ if (self.get is None) == (self.inquire is None):
139
+ raise ValueError("expect: exactly one of get, inquire")
140
+ if self.get is not None and self.fields is None:
141
+ raise ValueError("expect: get needs fields")
142
+ if self.inquire is not None and self.delta is None:
143
+ raise ValueError("expect: inquire needs delta")
144
+ return self
145
+
146
+ def label(self) -> str:
147
+ """Human-readable expectation address for report lines."""
148
+ if self.get is not None:
149
+ keys = ", ".join(str(k) for k in self.get.keys)
150
+ return f"{self.get.entity} [{keys}]"
151
+ match = ""
152
+ if self.match:
153
+ match = f" [{', '.join(f'{k}={v}' for k, v in self.match.items())}]"
154
+ return f"{self.inquire}{match}"
155
+
156
+
157
+ class Scenario(Model):
158
+ """A parsed scenario YAML: named steps plus expectations."""
159
+
160
+ path: Path
161
+ scenario: str
162
+ description: str | None = None
163
+ steps: list[Step]
164
+ expect: list[Expect] = Field(default_factory=list)
165
+
166
+ @model_validator(mode="after")
167
+ def _unique_step_ids(self) -> "Scenario":
168
+ seen: set[str] = set()
169
+ for step in self.steps:
170
+ if step.id in seen:
171
+ raise ValueError(f"duplicate step id '{step.id}'")
172
+ seen.add(step.id)
173
+ return self
174
+
175
+
176
+ def load_scenario(path: Path) -> Scenario:
177
+ """Parse and validate one scenario YAML file."""
178
+ with open(path) as f:
179
+ data = yaml.safe_load(f)
180
+ if not isinstance(data, dict):
181
+ raise SystemExit(f"{path}: expected a mapping at the top level")
182
+ try:
183
+ return Scenario.model_validate({"path": path, **data})
184
+ except ValidationError as exc:
185
+ raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
186
+
187
+
188
+ def _subst(value: Any, variables: dict[str, Any]) -> Any:
189
+ """Interpolate ${var} tokens; unknown names are a hard error."""
190
+
191
+ def _one(text: str) -> Any:
192
+ whole = _VAR.fullmatch(text)
193
+ if whole: # whole-value token keeps the captured value's type
194
+ return _lookup(whole.group(1))
195
+ return _VAR.sub(lambda m: str(_lookup(m.group(1))), text)
196
+
197
+ def _lookup(name: str) -> Any:
198
+ if name not in variables:
199
+ raise SystemExit(
200
+ f"unknown scenario variable '${{{name}}}' - captured so far: "
201
+ f"{', '.join(sorted(variables)) or '(none)'}"
202
+ )
203
+ return variables[name]
204
+
205
+ if isinstance(value, str):
206
+ return _one(value)
207
+ if isinstance(value, dict):
208
+ return {k: _subst(v, variables) for k, v in value.items()}
209
+ if isinstance(value, list):
210
+ return [_subst(v, variables) for v in value]
211
+ return value
212
+
213
+
214
+ def _resolve_path(record: dict[str, Any], path: str) -> Any:
215
+ """Resolve a dotted/indexed capture path (`Shipments[0].ShipmentNbr`).
216
+
217
+ Action-created documents (T66) assign numbers readable only off the
218
+ parent's expanded detail rows - the path walks unwrapped nesting:
219
+ a bare segment reads a field, `Seg[i]` indexes a detail array.
220
+ Missing segments raise with the path named.
221
+ """
222
+ value: Any = record
223
+ for segment in path.split("."):
224
+ m = _PATH.fullmatch(segment)
225
+ if m is None:
226
+ raise RuntimeError(f"capture path {path!r}: bad segment {segment!r}")
227
+ field, index = m.group(1), m.group(2)
228
+ if not isinstance(value, dict) or field not in value:
229
+ raise RuntimeError(
230
+ f"capture path {path!r}: field {field!r} not in the record"
231
+ )
232
+ value = value[field]
233
+ if index is not None:
234
+ if not isinstance(value, list) or int(index) >= len(value):
235
+ raise RuntimeError(
236
+ f"capture path {path!r}: index [{index}] out of range"
237
+ )
238
+ value = value[int(index)]
239
+ return value
240
+
241
+
242
+ def _inquire(client: AcumaticaClient, expect: Expect) -> dict[str, float]:
243
+ """Probe a contract inquiry; sum each delta field over matching rows."""
244
+ assert expect.inquire is not None
245
+ assert expect.delta is not None
246
+ body = client.put(
247
+ expect.inquire,
248
+ expect.parameters or {},
249
+ endpoint=expect.endpoint,
250
+ params={"$expand": "Results"},
251
+ )
252
+ totals = dict.fromkeys(expect.delta, 0.0)
253
+ for row in body.get("Results") or []:
254
+ values = unwrap(row)
255
+ if expect.match and any(
256
+ field not in values or _norm(values[field]) != _norm(want)
257
+ for field, want in expect.match.items()
258
+ ):
259
+ continue
260
+ for field in totals:
261
+ totals[field] += float(values.get(field) or 0.0)
262
+ return totals
263
+
264
+
265
+ def _wait(client: AcumaticaClient, spec: WaitSpec, variables: dict[str, Any]) -> None:
266
+ """Poll the record until every `until` field matches (live state, V4)."""
267
+ keys = _subst(spec.keys, variables)
268
+ until = _subst(spec.until, variables)
269
+ deadline = time.monotonic() + spec.timeout
270
+ state: dict[str, Any] | None = None
271
+ while time.monotonic() < deadline:
272
+ record = client.get_record(spec.entity, keys, spec.endpoint)
273
+ state = unwrap(record) if record is not None else None
274
+ if state is not None and all(
275
+ field in state and _norm(state[field]) == _norm(want)
276
+ for field, want in until.items()
277
+ ):
278
+ return
279
+ time.sleep(client.poll_interval)
280
+ raise RuntimeError(
281
+ f"wait timed out after {spec.timeout:g}s: {spec.entity} "
282
+ f"{keys} never reached {until} (last state: "
283
+ + (
284
+ ", ".join(f"{k}={state.get(k)!r}" for k in until)
285
+ if state is not None
286
+ else "record missing"
287
+ )
288
+ + ")"
289
+ )
290
+
291
+
292
+ def _dry_run(scenario: Scenario) -> None:
293
+ """List steps and expectations without any HTTP."""
294
+ for step in scenario.steps:
295
+ op = (
296
+ f"PUT {step.put}"
297
+ if step.put
298
+ else f"invoke {step.action.entity}/{step.action.name}"
299
+ if step.action
300
+ else f"get {step.get.entity}"
301
+ if step.get
302
+ else f"wait {step.wait.entity}" # pyright: ignore[reportOptionalMemberAccess]
303
+ )
304
+ output.data(f" would {op} [{step.id}]")
305
+ for expect in scenario.expect:
306
+ output.data(f" would check {expect.label()}")
307
+
308
+
309
+ def _run_step(client: AcumaticaClient, step: Step, variables: dict[str, Any]) -> None:
310
+ """Execute one step, folding captured fields into the variable set."""
311
+ if step.put is not None:
312
+ assert step.record is not None
313
+ body = client.put(
314
+ step.put, _subst(step.record, variables), endpoint=step.endpoint
315
+ )
316
+ output.data(f" put {step.put} [{step.id}]")
317
+ for field, var in (step.capture or {}).items():
318
+ echoed = unwrap(body)
319
+ if field not in echoed:
320
+ raise RuntimeError(
321
+ f"step '{step.id}': capture field '{field}' not in the PUT response"
322
+ )
323
+ variables[var] = echoed[field]
324
+ elif step.action is not None:
325
+ assert step.record is not None
326
+ client.invoke(
327
+ step.action.entity,
328
+ step.action.name,
329
+ _subst(step.record, variables),
330
+ _subst(step.parameters, variables),
331
+ step.endpoint,
332
+ )
333
+ output.data(f" invoke {step.action.name} [{step.id}]")
334
+ elif step.get is not None:
335
+ keys = _subst(step.get.keys, variables)
336
+ params = (
337
+ {"$expand": ",".join(sorted(step.get.expand))} if step.get.expand else None
338
+ )
339
+ record = client.get_record(step.get.entity, keys, step.get.endpoint, params)
340
+ if record is None:
341
+ raise RuntimeError(f"step '{step.id}': {step.get.entity} {keys} not found")
342
+ fetched = unwrap(record)
343
+ output.data(f" get {step.get.entity} [{step.id}]")
344
+ for path, var in (step.capture or {}).items():
345
+ variables[var] = _resolve_path(fetched, path)
346
+ if step.wait is not None:
347
+ _wait(client, step.wait, variables)
348
+ output.data(f" wait ok [{step.id}]")
349
+
350
+
351
+ def _check_get(
352
+ client: AcumaticaClient, expect: Expect, variables: dict[str, Any]
353
+ ) -> bool:
354
+ """Absolute field assertions on a key-URL record."""
355
+ assert expect.get is not None
356
+ assert expect.fields is not None
357
+ keys = _subst(expect.get.keys, variables)
358
+ # label from the substituted keys - the raw ${var} token is opaque
359
+ label = f"{expect.get.entity} [{', '.join(str(k) for k in keys)}]"
360
+ record = client.get_record(expect.get.entity, keys, expect.get.endpoint)
361
+ state = unwrap(record) if record is not None else {}
362
+ ok = True
363
+ for field, want in _subst(expect.fields, variables).items():
364
+ got = state.get(field)
365
+ if record is None or field not in state:
366
+ ok = False
367
+ output.data(f" x {label}.{field}: not found")
368
+ elif _norm(got) != _norm(want):
369
+ ok = False
370
+ output.data(f" x {label}.{field}: expected {want!r} got {got!r}")
371
+ else:
372
+ output.data(f" + {label}.{field} = {want!r}")
373
+ return ok
374
+
375
+
376
+ def _check_delta(
377
+ client: AcumaticaClient, expect: Expect, snapshot: dict[str, float]
378
+ ) -> bool:
379
+ """Delta assertions: post-run probe minus the pre-run snapshot."""
380
+ assert expect.delta is not None
381
+ after = _inquire(client, expect)
382
+ ok = True
383
+ for field, want in expect.delta.items():
384
+ got = after[field] - snapshot[field]
385
+ if abs(got - want) > 0.005:
386
+ ok = False
387
+ output.data(
388
+ f" x {expect.label()}.{field}: expected delta {want:+g} got {got:+g}"
389
+ )
390
+ else:
391
+ output.data(f" + {expect.label()}.{field} delta {want:+g}")
392
+ return ok
393
+
394
+
395
+ def run(
396
+ client: AcumaticaClient | None, scenario: Scenario, dry_run: bool = False
397
+ ) -> bool:
398
+ """Execute the scenario; True when every step and expectation held.
399
+
400
+ client may be None only under dry_run - parsing and step listing
401
+ never touch HTTP (a preview costs nothing live). Delta expectations
402
+ snapshot BEFORE the first step (V4): the comparison is post minus
403
+ pre, so a warm tenant re-runs clean.
404
+ """
405
+ output.data(f"{scenario.path} -> {scenario.scenario}")
406
+ if dry_run:
407
+ _dry_run(scenario)
408
+ return True
409
+ assert client is not None # non-dry-run callers pass a live session
410
+ before = [
411
+ _inquire(client, expect) if expect.inquire else {} for expect in scenario.expect
412
+ ]
413
+ variables: dict[str, Any] = {}
414
+ for step in scenario.steps:
415
+ _run_step(client, step, variables)
416
+ ok = True
417
+ for expect, snapshot in zip(scenario.expect, before, strict=True):
418
+ if expect.get is not None:
419
+ ok = _check_get(client, expect, variables) and ok
420
+ else:
421
+ ok = _check_delta(client, expect, snapshot) and ok
422
+ return ok
@@ -7,7 +7,7 @@ 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.6.0 # optional: override the instance endpoint
10
+ endpoint: Bootstrap/1.7.0 # optional: override the instance endpoint
11
11
  records:
12
12
  - CurrencyID: "CAD"
13
13
  Description: Canadian Dollar
@@ -34,7 +34,7 @@ cannot express (calendar generation and the like):
34
34
 
35
35
  action: GenerateCalendar # action name on the endpoint entity
36
36
  entity: MasterCalendar # entity the action hangs off
37
- endpoint: Bootstrap/1.6.0 # optional: override the instance endpoint
37
+ endpoint: Bootstrap/1.7.0 # optional: override the instance endpoint
38
38
  record: { FinancialYear: 2026 }
39
39
  parameters: { FromYear: 2026, ToYear: 2026 } # optional
40
40
  done_when: { filter: "FinancialYear eq '2026'" }
@@ -1,4 +1,4 @@
1
- # General Ledger preferences (GL102000) via the custom Bootstrap/1.6.0
1
+ # General Ledger preferences (GL102000) via the custom Bootstrap/1.7.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.6.0
15
+ endpoint: Bootstrap/1.7.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.6.0 endpoint (the
1
+ # Org-ledger link (GL201500) via the custom Bootstrap/1.7.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.6.0
24
+ endpoint: Bootstrap/1.7.0
25
25
  records:
26
26
  - LedgerCD: ACTUAL
27
27
  OrganizationID: COMPANY
@@ -1,11 +1,11 @@
1
- # Company (CS101500) via the custom Bootstrap/1.6.0 endpoint (the per-file
1
+ # Company (CS101500) via the custom Bootstrap/1.7.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.6.0
8
+ endpoint: Bootstrap/1.7.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.6.0 endpoint (the
1
+ # Credit terms (CS206500) via the custom Bootstrap/1.7.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.6.0
8
+ endpoint: Bootstrap/1.7.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.6.0
22
+ endpoint: Bootstrap/1.7.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.6.0
20
+ endpoint: Bootstrap/1.7.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.6.0
25
+ endpoint: Bootstrap/1.7.0
26
26
  record:
27
27
  Action: Open
28
28
  FromYear: "2026"
File without changes