From a0fe047845319b874145db66cb5087c381bb24e1 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 15:14:16 -0400 Subject: [PATCH 01/25] =?UTF-8?q?fold-in=20=C2=A7V.58(+)=20and=20=C2=A7T.2?= =?UTF-8?q?51(+):=20github-issue-44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github-issue-44 Add numbering-new-symbol invariant and NumberingSequence NewSymbol insert tasks. Patch §V.40 seed field set: NewSymbol is seed; LastNbr stays runtime. Record §B.38. --- SPEC.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index a6063c1..a0aadb2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -90,7 +90,7 @@ V36: seed authority — v1 writers into `config/` = REST extract + human only; w V37: tenant-snapshot artifact — accept SM203520 XML ZIP (`manifest.xml` + `*.xml`) or `ac.exe export xml` table-XML folder; both normalize one IR → inventory/; binary `.adb` fail-closed named error; never matrix `erp` match; format tag "XML dump" ? for ingress only — never identity side name (table owns identity) V38: reconcile-normalize — pad-trim both sides; snapshot_map aliases + FK CD↔ID + enum label→code; never silent promote config/ (V36); full audit recipe → `.spec/check-extras.md` §V.38 V39: password-seed — User seed Password write-only @ apply when present in YAML; extract strips `Password` + `b64__Password` (never seed hashes); diff ignores password fields -V40: numbering-runtime — NumberingSequence seed ! bounds only (`NumberingID`, `StartNbr`, `EndNbr`, `WarnNbr`, `NbrStep` + StartDate? if screen requires); `LastNbr` (+ advanced counter) = runtime state — extract strips; diff ignores; apply never requires; ! reset live counters every apply (gh #25) +V40: numbering-runtime — NumberingSequence seed ! bounds + NewSymbol (`NumberingID`, `StartNbr`, `EndNbr`, `WarnNbr`, `NbrStep` + StartDate? if screen requires; NewSymbol insert-required per numbering-new-symbol invariant); `LastNbr` (+ advanced counter) = runtime state — extract strips; diff ignores; apply never requires; ! reset live counters every apply (gh #25) V41: prefs-field-depth — Bootstrap *Preferences field lists ! curated subset (demo seed need or ERP-default rebuild risk); ! full DAC mirror; each field ! seed/catalog reason; server-derived/runtime ! seed (B11 class); contract shape change ! version bump (V21) (gh #26) V42: inventory-map-coverage — dual-reader masters ! snapshot_map table→entity so findings = real gaps; intentional unmapped ! docs table; full audit recipe → `.spec/check-extras.md` §V.42 V43: period-token — `acu run` `${current_period}` → host-local `MMyyyy` @ process start every `${var}` site; views/state ! expand; full audit recipe → `.spec/check-extras.md` §V.43 @@ -108,6 +108,7 @@ V54: unwrap-row-id — unwrap keeps `id` + `delete` on records and detail rows t V55: datetime-calendar-day — `_norm` date-only `YYYY-MM-DD` matches live DateTimeValue same calendar day (strip `T…offset`); different day still drift; extract date-strips DateTimeValue seed emit (BegFinYear synth class) (closes §B.35) (gh #40) V56: write-only-get-omit — apply-needed field GET never returns ! diff-ignore (join `_DIFF_IGNORE_FIELDS`) not `not returned by endpoint`; ! general GET-omit=ok (mapping-miss per §V.50 stands); LotSerialClass Auto-Incremental `Segments.Value` first (closes §B.36) (gh #40) V57: membership-diff-empty — mapped GET Role.Users / User.Roles ? empty while UsersInRoles has session-company rows; diff ! flag Role.Users / User.Roles missing; skip those details in diff or read live source that returns rows; AssignUser apply + SQL-proven membership → `acu diff` exit 0 on Role.Users / User.Roles (closes §B.37) (gh #39) +V58: numbering-new-symbol — NumberingSequence insert ! Header NewSymbol (or UserNumbering if screen requires pair); Bootstrap contract maps it; apply PUT new NumberingID w/ `NewSymbol: ` ! 422; re-apply existing id succeeds (no NewSymbol mask 422 on update); GET omits NewSymbol → seed write-only not runtime (LastNbr strip per numbering-runtime invariant; GET-omit per write-only-get-omit invariant) (closes §B.38) (gh #44) ## §T TASKS @@ -232,6 +233,11 @@ T247|x|diff Role.Users / User.Roles: skip empty mapped GET or live-source compar T248|x|offline tests: empty mapped Users/Roles ! drift after AssignUser-shaped seed; non-membership details still flag missing|V13,V57,T247 T249|x|live/e2e: after `92-role-users.yaml` AssignUser, `acu diff` exit 0 on Role.Users / User.Roles (SQL-proven UsersInRoles)|V4,V13,V57,B37,T247 T250|x|docs/CHANGELOG Unreleased — membership GET empty ! diff fail; extract skip stands (gh #39)|V12,V19,V57,T247 +T251|.|bootstrap contract NumberingSequence map Header NewSymbol (+ UserNumbering if screen requires pair); version bump 1.10.0→1.11.0|V21,V58,I.data,B38 +T252|.|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract emit when GET returns (not LastNbr strip); apply insert new NumberingID + re-apply existing|V34,V40,V56,V58,I.data,I.cmd,T251 +T253|.|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 +T254|.|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 +T255|.|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 ## §B BUGS @@ -267,3 +273,4 @@ B34|2026-09-08|unwrap drops detail-row id → KitAssembly StockComponents PUT in B35|2026-09-08|_norm string-compares date-only vs live DateTimeValue same calendar day (gh #40)|V55 B36|2026-09-08|LotSerialClass Auto-Incremental Segments.Value GET-omit → not-returned drift after apply (B11 class; gh #40)|V56 B37|2026-09-08|mapped GET Role.Users / User.Roles returns [] after AssignUser; UsersInRoles has rows; diff flags missing + exit 2 (gh #39)|V57 +B38|2026-09-09|NumberingSequence insert 422: contract maps bounds only; CS201010 requires NewSymbol or UserNumbering; PUT drops unmapped NewSymbol (gh #44)|V58 From 76c055309ce18a566ef79d8424277cccc78e79b0 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 15:20:36 -0400 Subject: [PATCH 02/25] =?UTF-8?q?T251:=20bootstrap=20contract=20NumberingS?= =?UTF-8?q?equence=20map=20Header=20NewSymbol;=20version=20bump=201.10.0?= =?UTF-8?q?=E2=86=921.11.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V21: field-shape change in packaged bootstrap_project.xml bumps Bootstrap 1.10.0 → 1.11.0; sweep src/ + tests/ Bootstrap/ pins. V58/B38: map NewSymbol to Header (CS201010 New Number Symbol). UserNumbering is the OR alternative, not a required pair. Do not map NewSymbol to Sequence (T159-class 422). --- SPEC.md | 2 +- src/acumatica_cli/bootstrap.py | 4 +- src/acumatica_cli/bootstrap_project.xml | 25 ++++++++++--- src/acumatica_cli/seed_catalog.yaml | 2 +- tests/test_bootstrap.py | 27 +++++++------ tests/test_cli.py | 4 +- tests/test_client.py | 4 +- tests/test_extract.py | 6 +-- tests/test_seed.py | 50 ++++++++++++------------- 9 files changed, 71 insertions(+), 53 deletions(-) diff --git a/SPEC.md b/SPEC.md index a0aadb2..c16eae2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -233,7 +233,7 @@ T247|x|diff Role.Users / User.Roles: skip empty mapped GET or live-source compar T248|x|offline tests: empty mapped Users/Roles ! drift after AssignUser-shaped seed; non-membership details still flag missing|V13,V57,T247 T249|x|live/e2e: after `92-role-users.yaml` AssignUser, `acu diff` exit 0 on Role.Users / User.Roles (SQL-proven UsersInRoles)|V4,V13,V57,B37,T247 T250|x|docs/CHANGELOG Unreleased — membership GET empty ! diff fail; extract skip stands (gh #39)|V12,V19,V57,T247 -T251|.|bootstrap contract NumberingSequence map Header NewSymbol (+ UserNumbering if screen requires pair); version bump 1.10.0→1.11.0|V21,V58,I.data,B38 +T251|x|bootstrap contract NumberingSequence map Header NewSymbol (+ UserNumbering if screen requires pair); version bump 1.10.0→1.11.0|V21,V58,I.data,B38 T252|.|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract emit when GET returns (not LastNbr strip); apply insert new NumberingID + re-apply existing|V34,V40,V56,V58,I.data,I.cmd,T251 T253|.|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 T254|.|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 diff --git a/src/acumatica_cli/bootstrap.py b/src/acumatica_cli/bootstrap.py index 38b3645..38f4898 100644 --- a/src/acumatica_cli/bootstrap.py +++ b/src/acumatica_cli/bootstrap.py @@ -9,7 +9,7 @@ endpoint exposes the seeding surface (serialization verified T12). Bootstrap endpoint contract is package SoT (V2/V21/T178): always the -packaged full company ``bootstrap_project.xml`` (``Bootstrap/1.10.0``). +packaged full company ``bootstrap_project.xml`` (``Bootstrap/1.11.0``). Data-repo ``config/bootstrap/project.xml`` or ``bootstrap/project.xml`` is not a seed — present → hard error naming package SoT (no dual contract line). @@ -68,7 +68,7 @@ def _bootstrap_file(root: Path, name: str) -> Path | None: def packaged_contract_xml() -> bytes: - """The CLI-shipped full company Bootstrap contract (``Bootstrap/1.10.0``).""" + """The CLI-shipped full company Bootstrap contract (``Bootstrap/1.11.0``).""" return (resources.files("acumatica_cli") / "bootstrap_project.xml").read_bytes() diff --git a/src/acumatica_cli/bootstrap_project.xml b/src/acumatica_cli/bootstrap_project.xml index 43aca96..0598afb 100644 --- a/src/acumatica_cli/bootstrap_project.xml +++ b/src/acumatica_cli/bootstrap_project.xml @@ -13,7 +13,10 @@ and drops Selected (AllowedRoles was EPLoginTypeAllowsRole / EP202500); T228 bumps 1.8.0 -> 1.9.0 for Role.Users -> UsersByRole (SM201005 Users tab; mapped-detail PUT is a no-op — B33) and 1.9.0 -> 1.10.0 - for Role AssignUser action (PXDatabase UsersInRoles persist). + for Role AssignUser action (PXDatabase UsersInRoles persist); + T251 bumps 1.10.0 -> 1.11.0 for NumberingSequence Header NewSymbol + (CS201010 insert requires New Number Symbol or Manual Numbering; + gh #44 / V58). package_zip() (bootstrap.py) prefers data-repo bootstrap/project.xml when present; otherwise this packaged template is the fallback so config init, @@ -64,16 +67,22 @@ T150 NumberingSequence (gh #25) - Default endpoint has no numbering surface; CS201010 Numbering Sequences (NumberingMaint — NOT the misnamed NumberingSequenceMaint): - - PrimaryView Header (DAC Numbering): key NumberingID + Descr. + - PrimaryView Header (DAC Numbering): key NumberingID + Descr + + NewSymbol (T251/V58; CS201010 "New Number Symbol"). UserNumbering + is the OR alternative (manual numbering), not a required pair. + GET omits NewSymbol — write-only (V56), not LastNbr-class runtime. - Detail view Sequence (DAC NumberingSequence): bounds StartNbr, EndNbr, WarnNbr, NbrStep, StartDate. LastNbr is runtime counter — omitted from contract (V40/T152 strip/ignore if ever present). - - Flat entity (Currency multi-view idiom): NumberingID/Descr on Header, - bounds on Sequence. Single sequence row per NumberingID is the - demo/LAB5 shape; multi-segment / branch-scoped rows are out of v1. + - Flat entity (Currency multi-view idiom): NumberingID/Descr/NewSymbol + on Header, bounds on Sequence. Single sequence row per NumberingID + is the demo/LAB5 shape; multi-segment / branch-scoped rows are out + of v1. T159: view names were wrong in 1.2.0/1.3.0 (Sequence/SequenceDetail); live aspx + NumberingMaint reflection = Header/Sequence. Bump 1.3.0 -> 1.4.0 (V21). Wrong maps made every PUT 422 on NewSymbol mask. + T251: map NewSymbol to Header (not Sequence). Insert of a new + NumberingID without it 422s (B38). T204 SegmentedKey (gh #30 / V50) - Default endpoint has no CS202000 surface; DimensionMaint Segmented Keys: @@ -121,7 +130,7 @@ --> - + @@ -1120,6 +1129,7 @@ + @@ -1133,6 +1143,9 @@ + + + diff --git a/src/acumatica_cli/seed_catalog.yaml b/src/acumatica_cli/seed_catalog.yaml index 9edcf0b..9f05e11 100644 --- a/src/acumatica_cli/seed_catalog.yaml +++ b/src/acumatica_cli/seed_catalog.yaml @@ -6,7 +6,7 @@ # entity contract entity name (the GET source and the emitted `entity:`) # endpoint omitted = the instance Default endpoint; the symbolic name # `bootstrap` stays symbolic in emitted files and resolves to -# the packaged full company Bootstrap/1.10.0 at HTTP/load time +# the packaged full company Bootstrap/1.11.0 at HTTP/load time # (package SoT - V20/V21/T178; data-repo project.xml forbidden) # keys key fields: the emitted `key:`, the record sort order, and # the B9 fallback's $select projection; the tuple must diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 59034e1..821f525 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -140,7 +140,7 @@ def test_load_contract_xml_rejects_data_repo_project_xml(tmp_path: Path) -> None def test_load_contract_xml_package_only(tmp_path: Path) -> None: # No data-repo project.xml → always packaged full company surface name, entities = bootstrap.parse_endpoint(bootstrap.load_contract_xml(tmp_path)) - assert name == "Bootstrap/1.10.0" + assert name == "Bootstrap/1.11.0" assert "Company" in entities assert "OnlyInDataRepo" not in entities @@ -167,11 +167,11 @@ def test_package_zip_carries_the_bootstrap_endpoint() -> None: Verified vs 26.101.0225 by live import round-trip: the child is the XmlSerializer form of Model.Endpoint in the entity/maintenance/5.31 namespace; no .endpoint file is involved. Packaged contract is the single - full company surface (Bootstrap/1.10.0 package SoT — V2/V21/T178). + full company surface (Bootstrap/1.11.0 package SoT — V2/V21/T178). """ ns, endpoint, entities = _packaged_endpoint() assert endpoint.get("name") == "Bootstrap" - assert endpoint.get("version") == "1.10.0" + assert endpoint.get("version") == "1.11.0" # SystemContracts.V4 is the build's only IsCurrent implementation assert endpoint.get("systemContractVersion") == "4" company_fields = { @@ -244,7 +244,8 @@ def test_package_zip_carries_the_bootstrap_endpoint() -> None: f.get("name") for f in details["UserRole"].findall(f"{ns}Fields/{ns}Field") } assert membership_fields == {"Rolename"} - # T150: NumberingSequence bounds on CS201010 (gh #25); no LastNbr (V40) + # T150/T251: NumberingSequence bounds + Header NewSymbol on CS201010 + # (gh #25/#44); no LastNbr (V40); UserNumbering is OR not a pair (V58) assert entities["NumberingSequence"].get("screen") == "CS201010" numbering_fields = { f.get("name"): f.get("type") @@ -253,6 +254,7 @@ def test_package_zip_carries_the_bootstrap_endpoint() -> None: assert numbering_fields == { "NumberingID": "StringValue", "Descr": "StringValue", + "NewSymbol": "StringValue", "StartNbr": "StringValue", "EndNbr": "StringValue", "WarnNbr": "StringValue", @@ -260,6 +262,7 @@ def test_package_zip_carries_the_bootstrap_endpoint() -> None: "StartDate": "DateTimeValue", } assert "LastNbr" not in numbering_fields + assert "UserNumbering" not in numbering_fields # T204/T207: SegmentedKey on CS202000 (gh #30); Header/Detail views (B28) assert entities["SegmentedKey"].get("screen") == "CS202000" segmented_fields = { @@ -380,12 +383,14 @@ def test_package_zip_carries_the_bootstrap_endpoint() -> None: "Password": "UserList", "Roles": ("RolesByUser", ""), }, - # T150/T159 NumberingSequence (gh #25): CS201010 NumberingMaint - # Header (Numbering) + Sequence (NumberingSequence) bounds; flat - # entity; LastNbr omitted V40. Live view names != early T150 guess. + # T150/T159/T251 NumberingSequence (gh #25/#44): CS201010 + # NumberingMaint Header (Numbering) + Sequence (NumberingSequence) + # bounds; NewSymbol on Header not Sequence (T159-class 422); + # LastNbr omitted V40. Live view names != early T150 guess. "NumberingSequence": { "NumberingID": "Header", "Descr": "Header", + "NewSymbol": "Header", "StartNbr": "Sequence", "EndNbr": "Sequence", "WarnNbr": "Sequence", @@ -441,7 +446,7 @@ def test_user_roles_maps_to_roles_by_user() -> None: with empty UsersInRoles is a mapping miss (B31/B28 class). """ ns, endpoint, entities = _packaged_endpoint() - assert endpoint.get("version") == "1.10.0" + assert endpoint.get("version") == "1.11.0" mappings = { m.get("field"): m.find(f"{ns}To") for m in entities["User"].findall(f"{ns}Mappings/{ns}Mapping") @@ -469,7 +474,7 @@ def test_role_users_maps_to_users_by_role() -> None: mapped UsersByRole PUT. """ ns, endpoint, entities = _packaged_endpoint() - assert endpoint.get("version") == "1.10.0" + assert endpoint.get("version") == "1.11.0" mappings = { m.get("field"): m.find(f"{ns}To") for m in entities["Role"].findall(f"{ns}Mappings/{ns}Mapping") @@ -527,7 +532,7 @@ def test_segmented_key_maps_length_to_detail_view() -> None: DimensionID only, so the InventoryID mask stays 10 chars. """ ns, endpoint, entities = _packaged_endpoint() - assert endpoint.get("version") == "1.10.0" + assert endpoint.get("version") == "1.11.0" entity = entities["SegmentedKey"] mappings = { m.get("field"): m.find(f"{ns}To") @@ -545,7 +550,7 @@ def test_segmented_key_maps_length_to_detail_view() -> None: def test_package_prefs_field_depth_curated_not_full_dac() -> None: """T154/T155/V41: curated *Preferences deepen (gh #26) — not full DAC.""" ns, endpoint, entities = _packaged_endpoint() - assert endpoint.get("version") == "1.10.0" + assert endpoint.get("version") == "1.11.0" gl_types = { f.get("name"): f.get("type") for f in entities["GLPreferences"].findall(f"{ns}Fields/{ns}Field") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3876eb3..f03073d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -28,7 +28,7 @@ BOOTSTRAP_YAML = """\ entity: CreditTerms -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 key: TermsID records: - TermsID: NET30 @@ -37,7 +37,7 @@ SETUP_YAML = """\ action: GenerateCalendar entity: MasterCalendar -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 record: FinancialYear: 2026 done_when: diff --git a/tests/test_client.py b/tests/test_client.py index efb6072..81c2b3e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -373,9 +373,9 @@ def test_url_resolves_symbolic_default(instance: Instance) -> None: ) assert ( client._url( # pyright: ignore[reportPrivateUsage] - "Warehouse", "Bootstrap/1.10.0" + "Warehouse", "Bootstrap/1.11.0" ) - == "/entity/Bootstrap/1.10.0/Warehouse" + == "/entity/Bootstrap/1.11.0/Warehouse" ) diff --git a/tests/test_extract.py b/tests/test_extract.py index 15182ed..ff61969 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1239,12 +1239,12 @@ def test_b9_fallback_selects_keys_then_key_urls( for r in server.requests ] assert currency_requests == [ - ("Bootstrap/1.10.0/Currency", {"$filter": "IsFinancial eq true"}), + ("Bootstrap/1.11.0/Currency", {"$filter": "IsFinancial eq true"}), ( - "Bootstrap/1.10.0/Currency", + "Bootstrap/1.11.0/Currency", {"$select": "CuryID", "$filter": "IsFinancial eq true"}, ), - ("Bootstrap/1.10.0/Currency/EUR", {}), + ("Bootstrap/1.11.0/Currency/EUR", {}), ] text = extract._render(spec, records) # pyright: ignore[reportPrivateUsage] assert "RealGainAcctID" in text diff --git a/tests/test_seed.py b/tests/test_seed.py index f8c1fa7..7483a20 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -68,14 +68,14 @@ def test_load_baseline_rejects_record_without_key(tmp_path: Path) -> None: def test_load_baseline_parses_endpoint_override(tmp_path: Path) -> None: - text = BASELINE + "endpoint: Bootstrap/1.10.0\n" - assert seed.load_baseline(_write(tmp_path, text)).endpoint == "Bootstrap/1.10.0" + text = BASELINE + "endpoint: Bootstrap/1.11.0\n" + assert seed.load_baseline(_write(tmp_path, text)).endpoint == "Bootstrap/1.11.0" LEDGER_LINK_YAML = """\ entity: LedgerCompany key: [LedgerCD, OrganizationID] -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 records: - LedgerCD: ACTUAL OrganizationID: PRODUCTS @@ -127,7 +127,7 @@ def test_bootstrap_entities_parsed_from_packaged_template() -> None: # V2/T81: the ambiguous set comes from the active contract (packaged # full company fallback), never a hand-list - parity pinned so a # template edit surfaces offline. - assert seed.BOOTSTRAP_ENDPOINT == "Bootstrap/1.10.0" + assert seed.BOOTSTRAP_ENDPOINT == "Bootstrap/1.11.0" assert { "Company", "CreditTerms", @@ -170,7 +170,7 @@ def test_load_baseline_rejects_bootstrap_entity_without_endpoint( """ with pytest.raises( SystemExit, - match=r"endpoint: default.*Bootstrap/1\.10\.0.*'bootstrap' \| 'default'", + match=r"endpoint: default.*Bootstrap/1\.11\.0.*'bootstrap' \| 'default'", ): seed.load_baseline(_write(tmp_path, AMBIGUOUS_YAML)) @@ -179,7 +179,7 @@ def test_load_baseline_bootstrap_entity_explicit_endpoint_passes( tmp_path: Path, ) -> None: # V20: explicit endpoint: disambiguates - either target is legitimate - for endpoint in ("Bootstrap/1.10.0", "Default/25.200.001", "default"): + for endpoint in ("Bootstrap/1.11.0", "Default/25.200.001", "default"): text = AMBIGUOUS_YAML + f"endpoint: {endpoint}\n" assert seed.load_baseline(_write(tmp_path, text)).endpoint == endpoint @@ -187,7 +187,7 @@ def test_load_baseline_bootstrap_entity_explicit_endpoint_passes( def test_load_baseline_resolves_symbolic_bootstrap(tmp_path: Path) -> None: """Symbolic endpoint: bootstrap resolves to the active package version.""" text = AMBIGUOUS_YAML + "endpoint: bootstrap\n" - assert seed.load_baseline(_write(tmp_path, text)).endpoint == "Bootstrap/1.10.0" + assert seed.load_baseline(_write(tmp_path, text)).endpoint == "Bootstrap/1.11.0" def test_load_baseline_keeps_symbolic_default(tmp_path: Path) -> None: @@ -252,19 +252,19 @@ def test_active_bootstrap_package_only( (tmp_path / ".env").write_text("ACU_BASE_URL=https://example.com\n") monkeypatch.chdir(tmp_path) name, entities = seed.active_bootstrap() - assert name == "Bootstrap/1.10.0" + assert name == "Bootstrap/1.11.0" assert entities == seed.BOOTSTRAP_ENTITIES text = ( "entity: Company\nkey: AcctCD\nendpoint: bootstrap\n" "records:\n - AcctCD: MAIN\n" ) - assert seed.load_baseline(_write(tmp_path, text)).endpoint == "Bootstrap/1.10.0" + assert seed.load_baseline(_write(tmp_path, text)).endpoint == "Bootstrap/1.11.0" def test_apply_and_diff_target_endpoint_override( tmp_path: Path, instance: Instance ) -> None: - text = BASELINE + "endpoint: Bootstrap/1.10.0\n" + text = BASELINE + "endpoint: Bootstrap/1.11.0\n" baseline = seed.load_baseline(_write(tmp_path, text)) recorder = Recorder({"/UnitsOfMeasure": _live({"UOM": "KG"})}) @@ -272,7 +272,7 @@ def test_apply_and_diff_target_endpoint_override( seed.diff(_client(instance, recorder), baseline) paths = {r.url.path for r in recorder.requests} - assert paths == {"/AcumaticaERP/entity/Bootstrap/1.10.0/UnitsOfMeasure"} + assert paths == {"/AcumaticaERP/entity/Bootstrap/1.11.0/UnitsOfMeasure"} def test_norm_folds_booleans_and_strips() -> None: @@ -629,7 +629,7 @@ def _entity(self, request: httpx.Request) -> httpx.Response: COMPANY_YAML = """\ entity: Company key: AcctCD -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 records: - AcctCD: LAB5 OrganizationName: Lab Five @@ -652,11 +652,11 @@ def test_apply_company_relogins_once_per_session( assert methods_paths == [ ("POST", "/AcumaticaERP/entity/auth/login"), ("GET", "/AcumaticaERP/Frames/Login.aspx"), - ("PUT", "/AcumaticaERP/entity/Bootstrap/1.10.0/Company"), + ("PUT", "/AcumaticaERP/entity/Bootstrap/1.11.0/Company"), ("POST", "/AcumaticaERP/entity/auth/logout"), ("POST", "/AcumaticaERP/entity/auth/login"), ("GET", "/AcumaticaERP/Frames/Login.aspx"), - ("PUT", "/AcumaticaERP/entity/Bootstrap/1.10.0/Company"), + ("PUT", "/AcumaticaERP/entity/Bootstrap/1.11.0/Company"), ("POST", "/AcumaticaERP/entity/auth/logout"), ] @@ -679,7 +679,7 @@ def test_apply_retries_once_on_branch_empty( text = """\ entity: INPreferences key: HoldEntry -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 records: - HoldEntry: false TransitBranchID: LAB5 @@ -1478,7 +1478,7 @@ def test_package_company_apply_body_includes_decplqty_and_persist_uoms( assert body["DecPlQty"] == {"value": 3} assert body["WeightUOM"] == {"value": "KG"} assert body["VolumeUOM"] == {"value": "LITER"} - assert puts[0].url.path.endswith("/Bootstrap/1.10.0/Company") + assert puts[0].url.path.endswith("/Bootstrap/1.11.0/Company") def test_company_packaging_round_trip(instance: Instance) -> None: @@ -1706,7 +1706,7 @@ def test_diff_multi_key_single_org_no_phantom_drift( text = """\ entity: LedgerCompany key: [LedgerCD, OrganizationID] -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 records: - LedgerCD: ACTUAL OrganizationID: COMPANY @@ -1742,7 +1742,7 @@ def test_diff_multi_key_single_org_no_phantom_drift( CURRENCY_YAML = """\ entity: Currency key: CuryID -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 records: - CuryID: EUR Description: Euro @@ -1770,8 +1770,8 @@ def test_diff_falls_back_to_key_url_on_optimization_500( assert seed.diff(_client(instance, recorder), baseline) == [] paths = [r.url.path for r in recorder.requests] assert [p.split("/entity/", 1)[1] for p in paths] == [ - "Bootstrap/1.10.0/Currency", - "Bootstrap/1.10.0/Currency/EUR", + "Bootstrap/1.11.0/Currency", + "Bootstrap/1.11.0/Currency/EUR", ] @@ -1801,7 +1801,7 @@ def test_diff_non_optimization_500_still_raises( ACTION_YAML = """\ action: GenerateCalendar entity: MasterCalendar -endpoint: Bootstrap/1.10.0 +endpoint: Bootstrap/1.11.0 record: FinancialYear: 2026 parameters: @@ -1875,8 +1875,8 @@ def test_apply_action_invokes_on_204_never_following_location( assert [ (r.method, r.url.path.split("/entity/", 1)[1]) for r in recorder.requests ] == [ - ("GET", "Bootstrap/1.10.0/MasterCalendar"), - ("POST", "Bootstrap/1.10.0/MasterCalendar/GenerateCalendar"), + ("GET", "Bootstrap/1.11.0/MasterCalendar"), + ("POST", "Bootstrap/1.11.0/MasterCalendar/GenerateCalendar"), ] assert "invoke GenerateCalendar [MasterCalendar]" in capsys.readouterr().out @@ -1905,7 +1905,7 @@ def test_apply_action_polls_202_location_to_completion( """202 = long-running: poll the Location status URL until it answers 204.""" action = _action(tmp_path) status_path = ( - "/AcumaticaERP/entity/Bootstrap/1.10.0/MasterCalendar" + "/AcumaticaERP/entity/Bootstrap/1.11.0/MasterCalendar" "/GenerateCalendar/status/abc" ) polls: list[str] = [] @@ -1974,7 +1974,7 @@ def test_probe_routes_filter_and_defaults(tmp_path: Path, instance: Instance) -> seed.diff(_client(instance, recorder), action) (request,) = recorder.requests - assert request.url.path.endswith("/Bootstrap/1.10.0/MasterCalendar") + assert request.url.path.endswith("/Bootstrap/1.11.0/MasterCalendar") assert request.url.params["$filter"] == "FinancialYear eq '2026'" From 1fdb313ddc1da81b4526a2f9035ac22a2d78938d Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 15:22:48 -0400 Subject: [PATCH 03/25] T252: seed_catalog include NewSymbol; package seed NewSymbol: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V34: catalog include lists NewSymbol now that package seed has it. V40: LastNbr stays runtime (NUMBERING_RUNTIME_FIELDS); NewSymbol is seed. V56/V58: GET omits NewSymbol → diff-ignore, not not-returned; apply still PUTs; extract emits when GET returns (not LastNbr strip). --- SPEC.md | 2 +- src/acumatica_cli/extract.py | 1 + src/acumatica_cli/seed.py | 22 +++++++++++-------- src/acumatica_cli/seed_catalog.yaml | 9 ++++---- .../config/master/05-numbering-sequences.yaml | 13 +++++++++++ tests/test_extract.py | 8 +++++-- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/SPEC.md b/SPEC.md index c16eae2..26a028b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -234,7 +234,7 @@ T248|x|offline tests: empty mapped Users/Roles ! drift after AssignUser-shaped s T249|x|live/e2e: after `92-role-users.yaml` AssignUser, `acu diff` exit 0 on Role.Users / User.Roles (SQL-proven UsersInRoles)|V4,V13,V57,B37,T247 T250|x|docs/CHANGELOG Unreleased — membership GET empty ! diff fail; extract skip stands (gh #39)|V12,V19,V57,T247 T251|x|bootstrap contract NumberingSequence map Header NewSymbol (+ UserNumbering if screen requires pair); version bump 1.10.0→1.11.0|V21,V58,I.data,B38 -T252|.|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract emit when GET returns (not LastNbr strip); apply insert new NumberingID + re-apply existing|V34,V40,V56,V58,I.data,I.cmd,T251 +T252|x|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract emit when GET returns (not LastNbr strip); apply insert new NumberingID + re-apply existing|V34,V40,V56,V58,I.data,I.cmd,T251 T253|.|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 T254|.|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 T255|.|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 diff --git a/src/acumatica_cli/extract.py b/src/acumatica_cli/extract.py index 2faa338..05f63b3 100644 --- a/src/acumatica_cli/extract.py +++ b/src/acumatica_cli/extract.py @@ -328,6 +328,7 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]: continue # V39/V40: password hashes + numbering runtime counters never enter # seed (hard strip, even if catalog include mistakenly listed them). + # NewSymbol is seed (V58): emit when GET returns; do not LastNbr-strip. if field in PASSWORD_FIELDS or field in NUMBERING_RUNTIME_FIELDS: continue if spec.include: diff --git a/src/acumatica_cli/seed.py b/src/acumatica_cli/seed.py index 86a929a..b6ea019 100644 --- a/src/acumatica_cli/seed.py +++ b/src/acumatica_cli/seed.py @@ -83,15 +83,19 @@ # YAML; extract always strips; diff never compares (GET returns hashes # or nothing — never a re-seedable value). PASSWORD_FIELDS = frozenset({"Password", "b64__Password"}) -# NumberingSequence runtime counters (V40/gh #25): bounds-only seed; -# LastNbr (+ advanced counter if ever exposed) = issued progress, not -# desired config. Extract hard-strips; diff never compares; apply never -# PUTs them (re-apply must not reset live counters). +# NumberingSequence runtime counters (V40/gh #25): LastNbr (+ advanced +# counter if ever exposed) = issued progress, not desired config. Extract +# hard-strips; diff never compares; apply never PUTs them (re-apply must +# not reset live counters). NewSymbol is seed (V58), not this class. NUMBERING_RUNTIME_FIELDS = frozenset({"LastNbr"}) -# Fields omitted from source↔live compare (V39 write-only + V40 runtime). -# Field-name deny at every nesting level. Do not add bare ``Value`` here — -# that would hide real drift on unrelated fields (V56). -_DIFF_IGNORE_FIELDS = PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS +# Fields omitted from source↔live compare (V39 write-only + V40 runtime + +# V56/V58 GET-omit). Field-name deny at every nesting level. Do not add +# bare ``Value`` here — that would hide real drift on unrelated fields (V56). +# NewSymbol is insert-required seed; GET omits it. Apply still PUTs; +# extract emits if GET returns (not LastNbr strip). +_DIFF_IGNORE_FIELDS = ( + PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS | frozenset({"NewSymbol"}) +) # Path-qualified write-only GET-omit (V56): (entity, detail, field). # Diff ignores; extract strips; apply still PUTs when present in YAML. _DIFF_IGNORE_PATHS = frozenset({("LotSerialClass", "Segments", "Value")}) @@ -102,7 +106,7 @@ def _diff_ignored(entity: str, *path: str) -> bool: - """True when source↔live compare skips this field (V39/V40/V56/V57).""" + """True when source↔live compare skips this field (V39/V40/V56/V57/V58).""" if path and path[-1] in _DIFF_IGNORE_FIELDS: return True if len(path) == 1 and (entity, path[0]) in _DIFF_SKIP_DETAILS: diff --git a/src/acumatica_cli/seed_catalog.yaml b/src/acumatica_cli/seed_catalog.yaml index 9f05e11..34e8e7d 100644 --- a/src/acumatica_cli/seed_catalog.yaml +++ b/src/acumatica_cli/seed_catalog.yaml @@ -181,10 +181,10 @@ entities: # include field-partition (same live warehouse row, three apply phases). # Multi-file StockItem uses OData filter-split on ItemClass. # -# NumberingSequence (T151/gh #25): V22 prefix 05- sorts before every prefs -# file that may reference *NumberingID (IN/SO/PO/AR/AP/CA/GL setup). Bootstrap -# only — Default has no numbering surface. Bounds-only include (no LastNbr; -# runtime counter is V40/T152). +# NumberingSequence (T151/gh #25, T252/gh #44): V22 prefix 05- sorts before +# every prefs file that may reference *NumberingID (IN/SO/PO/AR/AP/CA/GL +# setup). Bootstrap only — Default has no numbering surface. Include bounds +# + NewSymbol (insert-required, V58); no LastNbr (runtime counter is V40). - entity: NumberingSequence endpoint: bootstrap @@ -192,6 +192,7 @@ entities: file: config/master/05-numbering-sequences.yaml include: - Descr + - NewSymbol - StartNbr - EndNbr - WarnNbr diff --git a/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml b/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml index 55f56c4..08e92ce 100644 --- a/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml +++ b/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml @@ -3,78 +3,91 @@ key: NumberingID endpoint: bootstrap records: - NumberingID: APBILL + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: APPAYMENT + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: ARINVOICE + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: ARPAYMENT + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: BATCH + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: INADJUST + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: INISSUE + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: INKITASSY + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: INRECEIPT + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: POORDER + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: PORECEIPT + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: SOORDER + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: SOSHIPMENT + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' diff --git a/tests/test_extract.py b/tests/test_extract.py index ff61969..9076ee6 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1580,7 +1580,7 @@ def test_extract_force_round_trips_segmented_key_lengths( def test_catalog_numbering_sequence_row() -> None: - """T151: NumberingSequence catalog bounds-only; V22 before prefs.""" + """T151/T252: NumberingSequence catalog bounds + NewSymbol; V22 before prefs.""" manifest = extract.load_manifest() by_file = {s.file: s for s in manifest.entities} num = by_file["config/master/05-numbering-sequences.yaml"] @@ -1589,6 +1589,7 @@ def test_catalog_numbering_sequence_row() -> None: assert num.endpoint == "bootstrap" assert set(num.include) == { "Descr", + "NewSymbol", "StartNbr", "EndNbr", "WarnNbr", @@ -1597,6 +1598,8 @@ def test_catalog_numbering_sequence_row() -> None: } # V40: LastNbr is runtime — not in include (T152 hardens strip/diff) assert "LastNbr" not in num.include + # V58: NewSymbol is seed (insert-required), not LastNbr-class runtime + assert "NewSymbol" in num.include files = [s.file for s in manifest.entities] # V22: within master/, numbering sorts before prefs that may *NumberingID. # Umbrella SEED_DIRS order is bootstrap→baseline→setup→master, so @@ -1706,7 +1709,7 @@ def test_catalog_role_user_membership_rows() -> None: def test_package_numbering_sequence_template() -> None: - """T151: package numbering = LAB5-class module sequences; bounds only.""" + """T151/T252: package numbering = LAB5-class sequences; bounds + NewSymbol.""" root = Path(__file__).resolve().parents[1] / "src" / "acumatica_cli" / "templates" path = root / "config/master/05-numbering-sequences.yaml" numbering = seed.load_baseline(path) @@ -1733,6 +1736,7 @@ def test_package_numbering_sequence_template() -> None: assert set(ids) == expected for rec in numbering.records: assert "LastNbr" not in rec + assert rec["NewSymbol"] == "" assert rec["StartNbr"] == "000000" assert rec["EndNbr"] == "999999" assert rec["WarnNbr"] == "999990" From 9ab6a11f683c450c5ff22bf4d6045370bb6bf98a Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 15:24:46 -0400 Subject: [PATCH 04/25] T253: offline tests for NumberingSequence NewSymbol insert V13: gmake check. V40: LastNbr extract/diff/apply strip stands. V56/V58: apply PUTs NewSymbol; GET-omit is not not-returned drift; extract emits NewSymbol when GET returns. Contract maps Header. --- SPEC.md | 2 +- tests/test_bootstrap.py | 26 +++++++++ tests/test_extract.py | 56 ++++++++++++++++++++ tests/test_seed.py | 113 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 26a028b..83128f5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -235,7 +235,7 @@ T249|x|live/e2e: after `92-role-users.yaml` AssignUser, `acu diff` exit 0 on Rol T250|x|docs/CHANGELOG Unreleased — membership GET empty ! diff fail; extract skip stands (gh #39)|V12,V19,V57,T247 T251|x|bootstrap contract NumberingSequence map Header NewSymbol (+ UserNumbering if screen requires pair); version bump 1.10.0→1.11.0|V21,V58,I.data,B38 T252|x|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract emit when GET returns (not LastNbr strip); apply insert new NumberingID + re-apply existing|V34,V40,V56,V58,I.data,I.cmd,T251 -T253|.|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 +T253|x|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 T254|.|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 T255|.|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 821f525..00758fc 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -525,6 +525,32 @@ def test_plugin_source_assigns_users_in_roles() -> None: assert "LastModifiedDateTime" in update_block +def test_numbering_newsymbol_maps_to_header() -> None: + """T251/T253/V58: CS201010 NewSymbol maps Header, not Sequence. + + Unmapped PUT drops NewSymbol and insert 422s (B38). Sequence map is + T159-class (every PUT 422 on NewSymbol mask). UserNumbering is the OR + alternative, not a required pair. + """ + ns, endpoint, entities = _packaged_endpoint() + assert endpoint.get("version") == "1.11.0" + entity = entities["NumberingSequence"] + fields = { + f.get("name"): f.get("type") for f in entity.findall(f"{ns}Fields/{ns}Field") + } + assert fields["NewSymbol"] == "StringValue" + assert "UserNumbering" not in fields + mappings = { + m.get("field"): m.find(f"{ns}To") + for m in entity.findall(f"{ns}Mappings/{ns}Mapping") + } + assert mappings["NewSymbol"] is not None + assert mappings["NewSymbol"].get("object") == "Header" + assert mappings["NewSymbol"].get("field") == "NewSymbol" + assert mappings["StartNbr"] is not None + assert mappings["StartNbr"].get("object") == "Sequence" + + def test_segmented_key_maps_length_to_detail_view() -> None: """T207/B28: CS202000 aspx DataMember is Detail, not Details. diff --git a/tests/test_extract.py b/tests/test_extract.py index 9076ee6..697087e 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -941,6 +941,62 @@ def test_shape_hard_strips_lastnbr_runtime_fields() -> None: assert shaped2[0]["StartNbr"] == "000000" +def test_shape_emits_newsymbol_when_get_returns() -> None: + """V58/T253: NewSymbol is seed — extract emits if GET returns (not LastNbr).""" + spec = extract.EntitySpec( + entity="NumberingSequence", + keys=["NumberingID"], + file="config/master/05-numbering-sequences.yaml", + include=["Descr", "NewSymbol", "StartNbr", "EndNbr", "LastNbr"], + ) + live = [ + wrap( + { + "NumberingID": "BATCH", + "Descr": "GL batches", + "NewSymbol": "", + "StartNbr": "000000", + "EndNbr": "999999", + "LastNbr": "000025", + } + ) + ] + shaped = extract._shape(spec, live) # pyright: ignore[reportPrivateUsage] + assert shaped == [ + { + "NumberingID": "BATCH", + "Descr": "GL batches", + "EndNbr": "999999", + "NewSymbol": "", + "StartNbr": "000000", + } + ] + assert "LastNbr" not in shaped[0] + + +def test_shape_omits_newsymbol_when_get_omits() -> None: + """V58/T253: GET-omit NewSymbol is absent from extract, not LastNbr-stripped.""" + spec = extract.EntitySpec( + entity="NumberingSequence", + keys=["NumberingID"], + file="config/master/05-numbering-sequences.yaml", + include=["Descr", "NewSymbol", "StartNbr", "EndNbr"], + ) + live = [ + wrap( + { + "NumberingID": "BATCH", + "Descr": "GL batches", + "StartNbr": "000000", + "EndNbr": "999999", + } + ) + ] + shaped = extract._shape(spec, live) # pyright: ignore[reportPrivateUsage] + assert "NewSymbol" not in shaped[0] + assert shaped[0]["StartNbr"] == "000000" + + def test_shape_missing_key_field_is_a_hard_error() -> None: with pytest.raises(RuntimeError, match="missing key field"): extract._shape( # pyright: ignore[reportPrivateUsage] diff --git a/tests/test_seed.py b/tests/test_seed.py index 7483a20..c449545 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -1216,6 +1216,42 @@ def test_diff_still_flags_non_password_user_fields( LastNbr: '000025' """ +NUMBERING_WITH_NEWSYMBOL_YAML = """\ +entity: NumberingSequence +key: NumberingID +endpoint: bootstrap +records: + - NumberingID: BATCH + NewSymbol: '' + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +""" + +NUMBERING_INSERT_AND_REAPPLY_YAML = """\ +entity: NumberingSequence +key: NumberingID +endpoint: bootstrap +records: + - NumberingID: T254NS + NewSymbol: '' + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' + - NumberingID: BATCH + NewSymbol: '' + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' + LastNbr: '000025' +""" + def test_apply_bounds_without_lastnbr(tmp_path: Path, instance: Instance) -> None: """V40/T152: apply of bounds-only seed never requires LastNbr.""" @@ -1352,6 +1388,83 @@ def test_diff_startdate_different_day_is_drift( ] +def test_apply_puts_newsymbol_and_strips_lastnbr( + tmp_path: Path, instance: Instance +) -> None: + """V40/V58/T253: apply PUTs NewSymbol; LastNbr never rides the body.""" + baseline = seed.load_baseline(_write(tmp_path, NUMBERING_INSERT_AND_REAPPLY_YAML)) + recorder = Recorder({"/NumberingSequence": httpx.Response(200, json=[])}) + seed.apply(_client(instance, recorder), baseline) + puts = [r for r in recorder.requests if r.method == "PUT"] + bodies = [json.loads(p.content) for p in puts] + by_id = {b["NumberingID"]["value"]: b for b in bodies} + assert set(by_id) == {"T254NS", "BATCH"} + for body in by_id.values(): + assert body["NewSymbol"] == {"value": ""} + assert "LastNbr" not in body + assert body["StartNbr"] == {"value": "000000"} + + +def test_apply_package_numbering_puts_newsymbol(instance: Instance) -> None: + """V58/T253: package BATCH re-apply body includes NewSymbol: .""" + baseline = seed.load_baseline( + _package_template("config/master/05-numbering-sequences.yaml") + ) + assert isinstance(baseline, seed.BaselineFile) + recorder = Recorder({"/NumberingSequence": httpx.Response(200, json=[])}) + seed.apply(_client(instance, recorder), baseline) + puts = [r for r in recorder.requests if r.method == "PUT"] + bodies = [json.loads(p.content) for p in puts] + batch = next(b for b in bodies if b["NumberingID"]["value"] == "BATCH") + assert batch["NewSymbol"] == {"value": ""} + assert "LastNbr" not in batch + + +def test_diff_ignores_newsymbol_get_omit(tmp_path: Path, instance: Instance) -> None: + """V56/V58/T253: seed NewSymbol vs GET-omit is not not-returned drift.""" + baseline = seed.load_baseline(_write(tmp_path, NUMBERING_WITH_NEWSYMBOL_YAML)) + recorder = Recorder( + { + "/NumberingSequence": _live( + { + "NumberingID": "BATCH", + "StartNbr": "000000", + "EndNbr": "999999", + "WarnNbr": "999990", + "NbrStep": 1, + "StartDate": "1900-01-01", + "LastNbr": "000042", + } + ) + } + ) + assert seed.diff(_client(instance, recorder), baseline) == [] + + +def test_diff_newsymbol_omit_still_flags_bounds_not_returned( + tmp_path: Path, instance: Instance +) -> None: + """V56/T253: other GET-omit fields still flag not-returned (V50 stands).""" + baseline = seed.load_baseline(_write(tmp_path, NUMBERING_WITH_NEWSYMBOL_YAML)) + recorder = Recorder( + { + "/NumberingSequence": _live( + { + "NumberingID": "BATCH", + "EndNbr": "999999", + "WarnNbr": "999990", + "NbrStep": 1, + "StartDate": "1900-01-01", + } + ) + } + ) + drifts = seed.diff(_client(instance, recorder), baseline) + assert drifts == ["NumberingSequence [BATCH].StartNbr: not returned by endpoint"] + assert not any("NewSymbol" in d for d in drifts) + assert not any("LastNbr" in d for d in drifts) + + LOT_SERIAL_YAML = """\ entity: LotSerialClass key: ClassID From 5eb3acef486315c7ee3a23bc886a0929693a23d4 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 15:40:49 -0400 Subject: [PATCH 05/25] T254: live e2e insert NumberingID with NewSymbol; re-apply BATCH V4/V58/B38: throwaway T254NS insert with NewSymbol: and Descr (QORD-class) does not 422. Re-apply of existing BATCH and package sequences succeeds (no NewSymbol mask 422 on update). --- SPEC.md | 2 +- tests/e2e/test_numbering_new_symbol.py | 127 +++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/test_numbering_new_symbol.py diff --git a/SPEC.md b/SPEC.md index 83128f5..2c9b979 100644 --- a/SPEC.md +++ b/SPEC.md @@ -236,7 +236,7 @@ T250|x|docs/CHANGELOG Unreleased — membership GET empty ! diff fail; extract s T251|x|bootstrap contract NumberingSequence map Header NewSymbol (+ UserNumbering if screen requires pair); version bump 1.10.0→1.11.0|V21,V58,I.data,B38 T252|x|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract emit when GET returns (not LastNbr strip); apply insert new NumberingID + re-apply existing|V34,V40,V56,V58,I.data,I.cmd,T251 T253|x|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 -T254|.|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 +T254|x|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 T255|.|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 ## §B BUGS diff --git a/tests/e2e/test_numbering_new_symbol.py b/tests/e2e/test_numbering_new_symbol.py new file mode 100644 index 0000000..581f393 --- /dev/null +++ b/tests/e2e/test_numbering_new_symbol.py @@ -0,0 +1,127 @@ +"""Live NumberingSequence insert with Header NewSymbol (T254/V58/B38). + +Insert of a new NumberingID 422s when NewSymbol is unmapped. Re-apply of +tenant-native BATCH must still succeed (no NewSymbol mask 422 on update). +Throwaway id is CLI-test only — not QORD/QNCR (QMS product sequences). +""" + +import subprocess +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import NamedTuple + +import pytest + +from acumatica_cli.client import AcumaticaClient, unwrap +from acumatica_cli.config import Instance +from acumatica_cli.seed import BOOTSTRAP_ENDPOINT +from acumatica_cli.tenant import TenantManager + +pytestmark = pytest.mark.e2e + +SCRATCH_LOGIN = "E2ENUM" +INSERT_ID = "T254NS" +NUMBERING_YAML = f"""\ +entity: NumberingSequence +key: NumberingID +endpoint: bootstrap +records: +- NumberingID: {INSERT_ID} + Descr: CLI e2e throwaway numbering + NewSymbol: '' + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: BATCH + NewSymbol: '' + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +""" + +RunAcu = Callable[..., subprocess.CompletedProcess[str]] +DeleteTenant = Callable[[str], None] + + +class ScratchTenant(NamedTuple): + login: str + company_id: int + + +def _combined(proc: subprocess.CompletedProcess[str]) -> str: + return proc.stdout + proc.stderr + + +@pytest.fixture(scope="module") +def scratch_tenant( + tenant_manager: TenantManager, delete_tenant: DeleteTenant +) -> Iterator[ScratchTenant]: + delete_tenant(SCRATCH_LOGIN) + company_id = max(t.company_id for t in tenant_manager.list()) + 1 + yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) + delete_tenant(SCRATCH_LOGIN) + + +def test_tenant_create_bootstraps(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: + proc = acu( + "tenant", + "create", + "--id", + str(scratch_tenant.company_id), + "--login", + scratch_tenant.login, + ) + assert proc.returncode == 0, _combined(proc) + assert f"tenant {scratch_tenant.login} is ready" in _combined(proc) + assert "AcuBootstrap published" in _combined(proc) + + +def test_insert_newsymbol_and_reapply_batch( + acu: RunAcu, + live_instance: Instance, + scratch_tenant: ScratchTenant, + data_repo: Path, +) -> None: + """T254/V58: insert T254NS with NewSymbol; re-apply BATCH; no 422.""" + path = data_repo / "config" / "master" / "05-e2e-numbering-newsymbol.yaml" + path.write_text(NUMBERING_YAML) + + proc = acu("--tenant", scratch_tenant.login, "apply", str(path)) + out = _combined(proc) + assert proc.returncode == 0, out + assert "422" not in out + assert "Either New Number Symbol or Manual Numbering" not in out + assert f"PUT NumberingSequence [{INSERT_ID}]" in out + assert "PUT NumberingSequence [BATCH]" in out + + inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) + with AcumaticaClient(inst) as client: + rec = client.get_record("NumberingSequence", [INSERT_ID], BOOTSTRAP_ENDPOINT) + assert rec is not None, INSERT_ID + plain = unwrap(rec) + assert plain["NumberingID"] == INSERT_ID + assert plain["Descr"] == "CLI e2e throwaway numbering" + assert plain["StartNbr"] == "000000" + assert plain["EndNbr"] == "999999" + + proc = acu( + "--tenant", + scratch_tenant.login, + "apply", + str(data_repo / "config/master/05-numbering-sequences.yaml"), + ) + out = _combined(proc) + assert proc.returncode == 0, out + assert "422" not in out + assert "PUT NumberingSequence [BATCH]" in out + + proc = acu("--tenant", scratch_tenant.login, "apply", str(path)) + out = _combined(proc) + assert proc.returncode == 0, out + assert "422" not in out + assert f"PUT NumberingSequence [{INSERT_ID}]" in out + assert "PUT NumberingSequence [BATCH]" in out From 0fff15b5c3474a69f86b3ef5ab765abc6b0e609a Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 15:42:28 -0400 Subject: [PATCH 06/25] T255: docs and CHANGELOG for NumberingSequence NewSymbol insert V12/V19/V58: demo-seed bounds vs runtime names NewSymbol as seed (insert-required) and LastNbr as runtime. CHANGELOG Unreleased records gh #44 Bootstrap 1.11.0 Header map. --- CHANGELOG.md | 8 ++++++++ SPEC.md | 2 +- docs/demo-seed.md | 39 ++++++++++++++++++++++++--------------- tests/test_docs.py | 15 +++++++++++++++ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eadb3e1..d065120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Fixed + +- **NumberingSequence NewSymbol insert (gh #44):** Bootstrap `1.11.0` maps + Header `NewSymbol`. Insert of a new NumberingID with `NewSymbol: ` + no longer 422s. Re-apply of existing sequences still succeeds. `LastNbr` + stays runtime (extract strips, diff ignores, apply never PUTs). + `NewSymbol` is seed; GET-omit is not `not returned by endpoint` drift. + ## [v0.33.1] - 2026-09-08 ### Fixed diff --git a/SPEC.md b/SPEC.md index 2c9b979..78081af 100644 --- a/SPEC.md +++ b/SPEC.md @@ -237,7 +237,7 @@ T251|x|bootstrap contract NumberingSequence map Header NewSymbol (+ UserNumberin T252|x|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract emit when GET returns (not LastNbr strip); apply insert new NumberingID + re-apply existing|V34,V40,V56,V58,I.data,I.cmd,T251 T253|x|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 T254|x|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 -T255|.|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 +T255|x|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 ## §B BUGS diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 48bda5b..66a6009 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -228,7 +228,7 @@ inventory transport — inventory is always offline XML). | `Vendor` | Vendor | default | identity; CD on `BAccount` (v1 no multi-table join) | | `Customer` | Customer | default | identity; CD on `BAccount` (v1 no multi-table join) | | `Roles` / `Users` / `UsersInRoles` | Role / User | bootstrap | membership via `AssignUser` | -| `NumberingSequence` / `Numbering` | NumberingSequence | bootstrap | bounds only (V40) | +| `NumberingSequence` / `Numbering` | NumberingSequence | bootstrap | bounds + NewSymbol (V40/V58) | | `Company` / `Ledger` / `TaxCategory` | (identity) | bootstrap or default | table name = entity; no map row required | #### Intentionally unmapped (findings noise, not CaC gaps) @@ -358,7 +358,7 @@ Marking those two as IN control makes the INPreferences PUT return 500 on 26r1 a ## Role, User, and password seed Default contract has **no** Role or User surface. -Both live on the **Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, currently `Bootstrap/1.10.0`). +Both live on the **Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, currently `Bootstrap/1.11.0`). Screens: Role = SM201005, User = SM201010. @@ -402,7 +402,7 @@ Mapped GET of `Role.Users` / `User.Roles` may stay `[]` after AssignUser. Extract still skips identity-only `92-role-users.yaml` when Users GET is empty. -Republish AcuBootstrap after upgrade so Bootstrap 1.10.0 (`AssignUser`) is live. +Republish AcuBootstrap after upgrade so Bootstrap 1.11.0 (`AssignUser` + NumberingSequence `NewSymbol`) is live. User seeds that still send `Selected` will 422. | Path | Practical rule | @@ -468,10 +468,14 @@ Never commit password hashes from SM203520 / inventory dumps. Default contract has **no** Numbering Sequences surface. Sequences live on the **Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, -currently `Bootstrap/1.4.0`). +currently `Bootstrap/1.11.0`). Screen: CS201010 (Numbering Sequences). +Header view `Header` (DAC `Numbering`) holds `NumberingID`, `Descr`, and +`NewSymbol` (New Number Symbol). +Detail view `Sequence` holds bounds. + ### Apply order (V22) Numbered prefix under `config/master/` places sequences **before** every module @@ -480,30 +484,34 @@ setup and kin): | File | Entity | Notes | | ---- | ------ | ----- | -| `config/master/05-numbering-sequences.yaml` | NumberingSequence | key `NumberingID`; bounds only | +| `config/master/05-numbering-sequences.yaml` | NumberingSequence | key `NumberingID`; bounds + `NewSymbol` | | later `20-in-preferences.yaml`, `56-so-preferences.yaml`, … | module prefs | may point `*NumberingID` at a sequence id | Package demo pins LAB5-class module sequences (`APBILL`, `APPAYMENT`, `ARINVOICE`, `ARPAYMENT`, `BATCH`, `INADJUST`, `INISSUE`, `INKITASSY`, -`INRECEIPT`, `POORDER`, `PORECEIPT`, `SOORDER`, `SOSHIPMENT`) as bounds-only -rows. A custom sequence id used by a prefs seed must exist (tenant-native or -earlier NumberingSequence seed) before that prefs PUT. +`INRECEIPT`, `POORDER`, `PORECEIPT`, `SOORDER`, `SOSHIPMENT`) as bounds + +`NewSymbol: ` rows. A custom sequence id used by a prefs seed must exist +(tenant-native or earlier NumberingSequence seed) before that prefs PUT. + +Insert of a new NumberingID needs Header `NewSymbol: ` (auto-numbering) +and `Descr`. Re-apply of an existing id still succeeds (gh #44 / V58). -### Bounds vs runtime (V40) +### Bounds vs runtime (V40 / V58) -Seed is **bounds only**. Issued progress is runtime state and never desired -config: +Seed is **bounds + NewSymbol**. Issued progress is runtime state and never +desired config: | Field class | Fields | Seed? | | ----------- | ------ | ----- | | Identity + bounds | `NumberingID`, `StartNbr`, `EndNbr`, `WarnNbr`, `NbrStep`, `StartDate`?, `Descr`? | yes | +| Insert-required seed | `NewSymbol` (auto-numbering ``) | yes | | Runtime counter | `LastNbr` (+ advanced counter if ever exposed) | **never** | | Path | Behavior | | ---- | -------- | -| **apply** | PUT bounds only. `LastNbr` is never sent — even if hand-authored seed includes it — so re-apply does not reset live counters. | -| **extract** | Always strips `LastNbr` (catalog include is bounds-only; hard strip even if include mistakenly lists it). | -| **diff** | Ignores `LastNbr` — seed bounds vs live counter is never drift. | +| **apply** | PUT bounds + `NewSymbol`. `LastNbr` is never sent — even if hand-authored seed includes it — so re-apply does not reset live counters. | +| **extract** | Always strips `LastNbr` (hard strip even if include mistakenly lists it). Emits `NewSymbol` when GET returns it. | +| **diff** | Ignores `LastNbr`. `NewSymbol` GET-omit is not `not returned by endpoint` drift. | ```yaml # config/master/05-numbering-sequences.yaml (shape) @@ -512,6 +520,7 @@ key: NumberingID endpoint: bootstrap records: - NumberingID: BATCH + NewSymbol: '' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' @@ -530,7 +539,7 @@ same calendar day is not drift; a different day still is. ## Segmented keys Default contract has **no** Segmented Keys surface. -Keys live on the **Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, currently `Bootstrap/1.10.0`). +Keys live on the **Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, currently `Bootstrap/1.11.0`). Screen: CS202000 (`DimensionMaint`). Header view `Header` (DAC `Dimension`), key `DimensionID`; detail view `Detail` (DAC `Segment`) fields `SegmentID` + `Length`. diff --git a/tests/test_docs.py b/tests/test_docs.py index 94c7608..454296c 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -38,6 +38,21 @@ def test_docs_user_role_membership_persist() -> None: assert "92-role-users" in templates +def test_docs_numbering_newsymbol_insert() -> None: + """T255/V12/V19/V58: Unreleased + demo-seed NumberingSequence NewSymbol.""" + demo = (REPO / "docs" / "demo-seed.md").read_text() + changelog = (REPO / "CHANGELOG.md").read_text() + unreleased = _unreleased(changelog) + assert "gh #44" in unreleased + assert "NewSymbol" in unreleased + assert "Bootstrap `1.11.0`" in unreleased + assert "not returned by endpoint" in unreleased + assert "NewSymbol: ''" in demo + assert "LastNbr" in demo + assert "not returned by endpoint" in demo + assert "Bootstrap/1.11.0" in demo + + def test_docs_membership_diff_empty() -> None: """T250/V12/V19/V57: Unreleased + demo-seed skip empty membership GET.""" demo = (REPO / "docs" / "demo-seed.md").read_text() From 9f97721a1ba962d87a924ba528528473389da428 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 16:02:47 -0400 Subject: [PATCH 07/25] review: lock NewSymbol mapped GET; drop diff ignore Mapped GET after Bootstrap 1.11.0 returns NewSymbol. Diff now compares it so value drift flags. E2e asserts the GET shape. Drop narrative comments on the LastNbr set and extract strip. --- CHANGELOG.md | 3 +- docs/demo-seed.md | 2 +- src/acumatica_cli/extract.py | 1 - src/acumatica_cli/seed.py | 12 ++---- tests/e2e/test_numbering_new_symbol.py | 1 + tests/test_seed.py | 55 ++++++++++++++++++++++++-- 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d065120..3a598ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ Header `NewSymbol`. Insert of a new NumberingID with `NewSymbol: ` no longer 422s. Re-apply of existing sequences still succeeds. `LastNbr` stays runtime (extract strips, diff ignores, apply never PUTs). - `NewSymbol` is seed; GET-omit is not `not returned by endpoint` drift. + `NewSymbol` is seed; mapped GET returns it. GET-omit is `not returned by endpoint` + drift. ## [v0.33.1] - 2026-09-08 diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 66a6009..314a5b3 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -511,7 +511,7 @@ desired config: | ---- | -------- | | **apply** | PUT bounds + `NewSymbol`. `LastNbr` is never sent — even if hand-authored seed includes it — so re-apply does not reset live counters. | | **extract** | Always strips `LastNbr` (hard strip even if include mistakenly lists it). Emits `NewSymbol` when GET returns it. | -| **diff** | Ignores `LastNbr`. `NewSymbol` GET-omit is not `not returned by endpoint` drift. | +| **diff** | Ignores `LastNbr`. `NewSymbol` is compared; GET-omit is `not returned by endpoint` drift. | ```yaml # config/master/05-numbering-sequences.yaml (shape) diff --git a/src/acumatica_cli/extract.py b/src/acumatica_cli/extract.py index 05f63b3..2faa338 100644 --- a/src/acumatica_cli/extract.py +++ b/src/acumatica_cli/extract.py @@ -328,7 +328,6 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]: continue # V39/V40: password hashes + numbering runtime counters never enter # seed (hard strip, even if catalog include mistakenly listed them). - # NewSymbol is seed (V58): emit when GET returns; do not LastNbr-strip. if field in PASSWORD_FIELDS or field in NUMBERING_RUNTIME_FIELDS: continue if spec.include: diff --git a/src/acumatica_cli/seed.py b/src/acumatica_cli/seed.py index b6ea019..30fabb6 100644 --- a/src/acumatica_cli/seed.py +++ b/src/acumatica_cli/seed.py @@ -86,16 +86,12 @@ # NumberingSequence runtime counters (V40/gh #25): LastNbr (+ advanced # counter if ever exposed) = issued progress, not desired config. Extract # hard-strips; diff never compares; apply never PUTs them (re-apply must -# not reset live counters). NewSymbol is seed (V58), not this class. +# not reset live counters). NUMBERING_RUNTIME_FIELDS = frozenset({"LastNbr"}) # Fields omitted from source↔live compare (V39 write-only + V40 runtime + -# V56/V58 GET-omit). Field-name deny at every nesting level. Do not add +# V56 GET-omit). Field-name deny at every nesting level. Do not add # bare ``Value`` here — that would hide real drift on unrelated fields (V56). -# NewSymbol is insert-required seed; GET omits it. Apply still PUTs; -# extract emits if GET returns (not LastNbr strip). -_DIFF_IGNORE_FIELDS = ( - PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS | frozenset({"NewSymbol"}) -) +_DIFF_IGNORE_FIELDS = PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS # Path-qualified write-only GET-omit (V56): (entity, detail, field). # Diff ignores; extract strips; apply still PUTs when present in YAML. _DIFF_IGNORE_PATHS = frozenset({("LotSerialClass", "Segments", "Value")}) @@ -106,7 +102,7 @@ def _diff_ignored(entity: str, *path: str) -> bool: - """True when source↔live compare skips this field (V39/V40/V56/V57/V58).""" + """True when source↔live compare skips this field (V39/V40/V56/V57).""" if path and path[-1] in _DIFF_IGNORE_FIELDS: return True if len(path) == 1 and (entity, path[0]) in _DIFF_SKIP_DETAILS: diff --git a/tests/e2e/test_numbering_new_symbol.py b/tests/e2e/test_numbering_new_symbol.py index 581f393..fe2b1b7 100644 --- a/tests/e2e/test_numbering_new_symbol.py +++ b/tests/e2e/test_numbering_new_symbol.py @@ -107,6 +107,7 @@ def test_insert_newsymbol_and_reapply_batch( assert plain["Descr"] == "CLI e2e throwaway numbering" assert plain["StartNbr"] == "000000" assert plain["EndNbr"] == "999999" + assert plain["NewSymbol"] == "" proc = acu( "--tenant", diff --git a/tests/test_seed.py b/tests/test_seed.py index c449545..80c78fd 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -1420,8 +1420,8 @@ def test_apply_package_numbering_puts_newsymbol(instance: Instance) -> None: assert "LastNbr" not in batch -def test_diff_ignores_newsymbol_get_omit(tmp_path: Path, instance: Instance) -> None: - """V56/V58/T253: seed NewSymbol vs GET-omit is not not-returned drift.""" +def test_diff_flags_newsymbol_get_omit(tmp_path: Path, instance: Instance) -> None: + """V58: mapped GET returns NewSymbol; omit is not-returned drift.""" baseline = seed.load_baseline(_write(tmp_path, NUMBERING_WITH_NEWSYMBOL_YAML)) recorder = Recorder( { @@ -1438,9 +1438,56 @@ def test_diff_ignores_newsymbol_get_omit(tmp_path: Path, instance: Instance) -> ) } ) + drifts = seed.diff(_client(instance, recorder), baseline) + assert drifts == ["NumberingSequence [BATCH].NewSymbol: not returned by endpoint"] + assert not any("LastNbr" in d for d in drifts) + + +def test_diff_newsymbol_match_no_drift(tmp_path: Path, instance: Instance) -> None: + """V58: seed NewSymbol vs matching mapped GET is not drift.""" + baseline = seed.load_baseline(_write(tmp_path, NUMBERING_WITH_NEWSYMBOL_YAML)) + recorder = Recorder( + { + "/NumberingSequence": _live( + { + "NumberingID": "BATCH", + "NewSymbol": "", + "StartNbr": "000000", + "EndNbr": "999999", + "WarnNbr": "999990", + "NbrStep": 1, + "StartDate": "1900-01-01", + "LastNbr": "000042", + } + ) + } + ) assert seed.diff(_client(instance, recorder), baseline) == [] +def test_diff_flags_newsymbol_value_drift(tmp_path: Path, instance: Instance) -> None: + """V58: NewSymbol value mismatch is drift (mapped GET returns it).""" + baseline = seed.load_baseline(_write(tmp_path, NUMBERING_WITH_NEWSYMBOL_YAML)) + recorder = Recorder( + { + "/NumberingSequence": _live( + { + "NumberingID": "BATCH", + "NewSymbol": "OTHER", + "StartNbr": "000000", + "EndNbr": "999999", + "WarnNbr": "999990", + "NbrStep": 1, + "StartDate": "1900-01-01", + } + ) + } + ) + assert seed.diff(_client(instance, recorder), baseline) == [ + "NumberingSequence [BATCH].NewSymbol: source='' live='OTHER'" + ] + + def test_diff_newsymbol_omit_still_flags_bounds_not_returned( tmp_path: Path, instance: Instance ) -> None: @@ -1460,8 +1507,8 @@ def test_diff_newsymbol_omit_still_flags_bounds_not_returned( } ) drifts = seed.diff(_client(instance, recorder), baseline) - assert drifts == ["NumberingSequence [BATCH].StartNbr: not returned by endpoint"] - assert not any("NewSymbol" in d for d in drifts) + assert "NumberingSequence [BATCH].StartNbr: not returned by endpoint" in drifts + assert "NumberingSequence [BATCH].NewSymbol: not returned by endpoint" in drifts assert not any("LastNbr" in d for d in drifts) From fce5bc8e2cf148923a7d2d7494230c8b74ce31d3 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 18:43:30 -0400 Subject: [PATCH 08/25] fix(extract): project AcctCD on Company GET during open-periods synth Select only `AcctCD` in the Company list query for open-periods synthesis to avoid 500 errors caused by unprojected BQL-delegate fields (`DecPlQty`, `WeightUOM`, `VolumeUOM`). --- CHANGELOG.md | 5 +++++ src/acumatica_cli/extract.py | 7 +++++-- tests/e2e/test_scenario_lifecycle.py | 11 ++++++++++- tests/test_extract.py | 15 +++++++++++++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a598ce..9fbafd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ stays runtime (extract strips, diff ignores, apply never PUTs). `NewSymbol` is seed; mapped GET returns it. GET-omit is `not returned by endpoint` drift. +- **extract open-periods Company GET:** `30-open-periods.yaml` synthesis + `$select`s `AcctCD` so the Company list GET does not 500 on + `DecPlQty`/`WeightUOM`/`VolumeUOM` (CS101500 `commonsetup` BQL delegate). + Unprojected list GET left extract exit 1 and a replayed tenant with + inactive periods. ## [v0.33.1] - 2026-09-08 diff --git a/src/acumatica_cli/extract.py b/src/acumatica_cli/extract.py index 2faa338..20744e4 100644 --- a/src/acumatica_cli/extract.py +++ b/src/acumatica_cli/extract.py @@ -465,8 +465,11 @@ def _synth_open_periods(client: AcumaticaClient) -> dict[str, Any] | None: years = _years(live) # OrganizationID = the extracted Company's AcctCD: the reference # resolves inside the emitted set (V22 - config/bootstrap/company.yaml - # creates the organization the action names) - companies = client.get_list("Company", endpoint=ep) + # creates the organization the action names). + # Company list GET 500s once DecPlQty/WeightUOM/VolumeUOM map + # commonsetup (V52; BQL-delegate view, B9). $select keeps AcctCD + # only so the synth does not pull those fields. + companies = client.get_list("Company", params={"$select": "AcctCD"}, endpoint=ep) if not companies: raise RuntimeError("open-periods: no Company on tenant") org = sorted(str(unwrap(c)["AcctCD"]) for c in companies)[0] diff --git a/tests/e2e/test_scenario_lifecycle.py b/tests/e2e/test_scenario_lifecycle.py index 45695e6..8304312 100644 --- a/tests/e2e/test_scenario_lifecycle.py +++ b/tests/e2e/test_scenario_lifecycle.py @@ -14,12 +14,15 @@ import sys import threading from collections.abc import Callable, Iterator +from datetime import date from pathlib import Path from typing import IO, NamedTuple import pytest +import yaml from acumatica_cli.config import scaffold +from acumatica_cli.run import period_mmYYYY from acumatica_cli.tenant import TenantManager pytestmark = pytest.mark.e2e @@ -197,7 +200,13 @@ def test_scenario_state_write( """ import re - import yaml + # Package TB view pins Period 072026 for mock alignment (V33/V43). + # Scenario posts to ${current_period}; rewrite the scaffold view so + # state/ captures the month the JE actually landed. + view_path = scenario_repo / "config" / "views" / "10-trial-balance.yaml" + view = yaml.safe_load(view_path.read_text()) + view["source"]["params"]["Period"] = period_mmYYYY(date.today()) + view_path.write_text(yaml.safe_dump(view, sort_keys=False)) proc = scenario_acu("--tenant", scenario_tenant.login, "state") assert proc.returncode == 0, _combined(proc) diff --git a/tests/test_extract.py b/tests/test_extract.py index 697087e..4705fe5 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -654,7 +654,10 @@ def _client(instance: Instance, server: FakeServer) -> AcumaticaClient: # Empty tables for every catalog entity without canned live state → skip clean for row in extract.load_manifest().entities: TABLES.setdefault(row.entity, []) -DELEGATE_VIEW = frozenset({"Currency"}) +# Currency = original B9 fixture (CuryRecords). Company = live V52 +# commonsetup DecPlQty/WeightUOM/VolumeUOM (BQL delegate); open-periods +# synth and Company entity extract must survive the unprojected 500. +DELEGATE_VIEW = frozenset({"Currency", "Company"}) # Catalog row counts (V34 path set): entity + setup (+ features outside catalog). _CATALOG_ENTITY_ROWS = len(extract.load_manifest().entities) @@ -1978,8 +1981,16 @@ def test_synthesized_master_calendar_spans_year_range( def test_synthesized_open_periods_sources_company_org( instance: Instance, server: FakeServer, tmp_path: Path ) -> None: - """OrganizationID = the extracted Company AcctCD (V22 in-set closure).""" + """OrganizationID = the extracted Company AcctCD (V22 in-set closure). + + Company is in DELEGATE_VIEW (V52 commonsetup). The synth $selects + AcctCD so the unprojected list GET 500 never fires (B9). + """ _run(instance, server, tmp_path, only=frozenset({"open-periods"})) + company_lists = [ + dict(r.url.params) for r in server.requests if r.url.path.endswith("/Company") + ] + assert any(p.get("$select") == "AcctCD" for p in company_lists) doc = yaml.safe_load( (tmp_path / "config" / "setup" / "30-open-periods.yaml").read_text() ) From 4864e8ff72ef4b1b5e557ced01ff872cc9c1230f Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 18:53:22 -0400 Subject: [PATCH 09/25] =?UTF-8?q?amend=20=C2=A7C.(+)=20and=20=C2=A7T.256(+?= =?UTF-8?q?):=20e2e=203=20pipeline=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live e2e is provision, scenario, and extract. Per-bug probes fold onto those tenants. A new e2e file is only for a different tenant shape. --- SPEC.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 78081af..878ff32 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,7 +8,7 @@ Configure Acumatica ERP purely from source — no UI, no Configuration Wizard. I - Repo = `acu` CLI only. Data (`baseline/`, `.env.gpg`) lives in separate data repos (sibling `acumatica-devops`); infra = sibling `acumatica-infra`; blog = sibling `acumatica-blog`. C# customization projects out of scope (exclusion: bootstrap package ships one C# CustomizationPlugin package — §T.11; endpoint item returns when §T.12 lands). - Python ≥ 3.12; click, httpx, pydantic, pydantic-settings, rich, pyyaml; uv build; module `acumatica_cli`; entry `acu`. -- Default suite fully offline: SSH = monkeypatched `subprocess.run`, REST = `httpx.MockTransport`; no live instance needed; `make check` gate offline-only. Opt-in live tier: pytest marker `e2e`, deselected via addopts, runs only via `make e2e`; e2e self-contained — single-org synthetic company scaffolded from packaged `config init` templates into tmp dirs, no repo-root data symlinks, no dataset tenants (`SalesDemo|T100|U100` stay CLI surface, never test fixtures); multi-org out of demo scope (paid engagement). +- Default suite fully offline: SSH = monkeypatched `subprocess.run`, REST = `httpx.MockTransport`; no live instance needed; `make check` gate offline-only. Opt-in live tier: pytest marker `e2e`, deselected via addopts, runs only via `make e2e`; e2e self-contained — single-org synthetic company scaffolded from packaged `config init` templates into tmp dirs, no repo-root data symlinks, no dataset tenants (`SalesDemo|T100|U100` stay CLI surface, never test fixtures); live e2e = 3 pipeline files (`test_provision_lifecycle` apply/diff, `test_scenario_lifecycle` run/state, `test_extract_roundtrip` two-tenant); per-bug probes fold onto those tenants; new e2e file only when proof needs different tenant shape; multi-org out of demo scope (paid engagement). - Every cmd talks to live instance unless offline path: `--dry-run`, `bootstrap --export`, `survey inventory`/`survey reconcile` (artifact-only; no REST/SSH/password). Final verification live vs acu-dev1 (`acu-dev1.vm.internal`; needs tailnet + GPG key): `cd ~/github/acumatica-devops && make decrypt && make diff` or `make e2e` here (self-contained per the e2e constraint; `.env`/`.env.gpg` real files @ repo root). - Unconfigured tenant fails most entities (500 `PXSetupNotEnteredException` or 403 feature-gated) until bootstrap (features + company/branch); built-in endpoints can't bootstrap; `PUT CompaniesStructure` dead; payment terms have no Default-endpoint entity; financial currency (CM202000) has none either — REST `Currency` = CM201000 currency list only. - Dual readers single writer (V35): vocab — REST = live data-plane transport; entity = contract identity (e.g. Contact); seed = CaC YAML under `config/` SEED_DIRS; tenant-snapshot artifact = SM203520 Settings XML ZIP or `ac.exe export xml` table-XML folder (ingress only); table/DAC = artifact identity; inventory/ = offline IR product; map join = table↔entity (`snapshot_map.yaml` table→entity or identity match), never seed-name↔inventory-name; REST extract = seed reader; offline inventory = artifact reader; sole tenant mutator = REST `apply`; CLI never creates/restores SM203520; `ac.exe` has no snapshot create — consume artifact only; derived-state = `acu state` (I.cmd), not inventory not tenant-snapshot; bare "snapshot" ! synonym for `state` (V32 rename) @@ -238,6 +238,7 @@ T252|x|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract T253|x|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 T254|x|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 T255|x|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 +T256|.|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/numbering/segmented-key/roles onto those tenants; shared conftest `bracket_tenant` + `joined_output`; drop per-bug files|V4,V13 ## §B BUGS From 7277bc5f556e63756537f0b2f9a9459f59e03d0c Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 18:53:31 -0400 Subject: [PATCH 10/25] T256: e2e 3 pipeline files (provision, scenario, extract) Share bracket_tenant and joined_output in conftest. Fold DecPlQty, roles SQL, NewSymbol insert, and 26-char InventoryID into provision. Fold KitAssembly alloc into scenario after buy/build. Drop five per-bug files. Offline test locks the three-file set. --- README.md | 11 +- SPEC.md | 2 +- tests/__init__.py | 0 tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 56 ++++- tests/e2e/test_company_qty_precision.py | 90 ------- tests/e2e/test_extract_roundtrip.py | 72 +++--- tests/e2e/test_kitassembly_alloc.py | 155 ------------ tests/e2e/test_numbering_new_symbol.py | 128 ---------- tests/e2e/test_provision_lifecycle.py | 230 ++++++++++++++---- tests/e2e/test_scenario_lifecycle.py | 298 ++++++++++++------------ tests/e2e/test_segmented_key_length.py | 166 ------------- tests/e2e/test_user_role_membership.py | 202 ---------------- tests/test_docs.py | 13 ++ 14 files changed, 441 insertions(+), 982 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/e2e/__init__.py delete mode 100644 tests/e2e/test_company_qty_precision.py delete mode 100644 tests/e2e/test_kitassembly_alloc.py delete mode 100644 tests/e2e/test_numbering_new_symbol.py delete mode 100644 tests/e2e/test_segmented_key_length.py delete mode 100644 tests/e2e/test_user_role_membership.py diff --git a/README.md b/README.md index aaa53fa..e885482 100644 --- a/README.md +++ b/README.md @@ -373,16 +373,19 @@ GitHub Actions on tag `v*` re-runs CI, builds sdist+wheel, publishes to PyPI via Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_TENANT`, `ACU_PASSWORD` (and optional `ACU_SSH`; omitted defaults to `Administrator@` + base-url host). `gmake e2e` refuses to start without it. -The tier is self-contained. +The tier is three pipeline files: provision (apply/diff), scenario (run/state), and extract round-trip. +Per-bug contract probes fold onto those tenants. + 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. Scratch tenants (`E2E`, `E2EA`, `E2EB`, `E2ESCEN`) are created on the way in and always deleted on the way out, so nothing persists. The packaged full `config init` seed (under `config/`) is the only scaffold. ```sh -gmake e2e # whole tier, about 20 minutes -gmake e2e FILE=test_provision_lifecycle # apply/diff focus -gmake e2e FILE=test_scenario_lifecycle # scenario + state focus +gmake e2e # whole tier, three files +gmake e2e FILE=test_provision_lifecycle # apply/diff + folded probes +gmake e2e FILE=test_scenario_lifecycle # scenario + state + kit alloc +gmake e2e FILE=test_extract_roundtrip # extract inverse ``` ## License diff --git a/SPEC.md b/SPEC.md index 878ff32..5b26172 100644 --- a/SPEC.md +++ b/SPEC.md @@ -238,7 +238,7 @@ T252|x|seed_catalog include NewSymbol; package seed `NewSymbol: `; extract T253|x|offline tests: contract field+mapping; apply body includes NewSymbol; extract/diff LastNbr strip stands; NewSymbol GET-omit ! not-returned drift; existing numbering tests green|V13,V40,V56,V58,T251,T252 T254|x|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 T255|x|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 -T256|.|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/numbering/segmented-key/roles onto those tenants; shared conftest `bracket_tenant` + `joined_output`; drop per-bug files|V4,V13 +T256|x|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/numbering/segmented-key/roles onto those tenants; shared conftest `bracket_tenant` + `joined_output`; drop per-bug files|V4,V13 ## §B BUGS diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 459a493..e6409af 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -8,6 +8,10 @@ data symlinks, no dataset tenants (SalesDemo|T100|U100 stay CLI surface, never test fixtures). +Live e2e is three pipeline files (T256): provision apply/diff, scenario +run/state, extract round-trip. Per-bug probes fold onto those tenants. +New e2e file only when the proof needs a different tenant shape. + Every e2e file drives the installed `acu` binary through subprocess (the V9 contract as scripts see it) against the live instance, and every file cleans up its own scratch tenants; the fixtures here are the one @@ -15,14 +19,16 @@ and stateful by design. """ +from __future__ import annotations + import contextlib import shutil import subprocess import sys import threading -from collections.abc import Callable +from collections.abc import Callable, Iterator from pathlib import Path -from typing import IO +from typing import IO, NamedTuple import pytest @@ -35,6 +41,50 @@ DeleteTenant = Callable[[str], None] +class ScratchTenant(NamedTuple): + """One disposable tenant slot a pipeline file runs against.""" + + login: str + company_id: int + + +class ScratchPair(NamedTuple): + """Two disposable tenant slots the extract round-trip runs against.""" + + id_a: int + id_b: int + + +def joined_output(proc: subprocess.CompletedProcess[str]) -> str: + """Stdout plus stderr - status lines (success/error) go to stderr.""" + return proc.stdout + proc.stderr + + +def bracket_tenant( + login: str, tenant_manager: TenantManager, delete_tenant: DeleteTenant +) -> Iterator[ScratchTenant]: + """Clear leftovers, yield the next free CompanyID, always delete on exit.""" + delete_tenant(login) + company_id = max(t.company_id for t in tenant_manager.list()) + 1 + yield ScratchTenant(login=login, company_id=company_id) + delete_tenant(login) + + +def bracket_pair( + login_a: str, + login_b: str, + tenant_manager: TenantManager, + delete_tenant: DeleteTenant, +) -> Iterator[ScratchPair]: + """Clear leftovers, reserve the next two CompanyIDs, always delete both.""" + for login in (login_a, login_b): + delete_tenant(login) + base = max(t.company_id for t in tenant_manager.list()) + yield ScratchPair(id_a=base + 1, id_b=base + 2) + for login in (login_a, login_b): + delete_tenant(login) + + def _pump(pipe: IO[str], lines: list[str], sink: IO[str]) -> None: """Copy one pipe to a live sink line by line, keeping every line.""" for line in pipe: @@ -99,7 +149,7 @@ def run(*args: str) -> subprocess.CompletedProcess[str]: for reader in readers: reader.start() try: - returncode = proc.wait(timeout=1800) + returncode = proc.wait(timeout=3600) except subprocess.TimeoutExpired: proc.kill() raise diff --git a/tests/e2e/test_company_qty_precision.py b/tests/e2e/test_company_qty_precision.py deleted file mode 100644 index f1e369e..0000000 --- a/tests/e2e/test_company_qty_precision.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Live Company DecPlQty after UOMs then packaging PUT (T223/V52). - -Kit milligram BOMs stay out of the LAB5 demo seed. Live kit 0.012 is -``tests/e2e/test_provision_lifecycle.py`` after full apply. -""" - -import subprocess -from collections.abc import Callable, Iterator -from pathlib import Path -from typing import NamedTuple - -import pytest - -from acumatica_cli import firstlogin -from acumatica_cli.client import AcumaticaClient, unwrap -from acumatica_cli.config import Instance -from acumatica_cli.seed import BOOTSTRAP_ENDPOINT -from acumatica_cli.tenant import TenantManager - -pytestmark = pytest.mark.e2e - -SCRATCH_LOGIN = "E2EQTY" - -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchTenant(NamedTuple): - login: str - company_id: int - - -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - return proc.stdout + proc.stderr - - -@pytest.fixture(scope="module") -def scratch_tenant( - tenant_manager: TenantManager, delete_tenant: DeleteTenant -) -> Iterator[ScratchTenant]: - delete_tenant(SCRATCH_LOGIN) - company_id = max(t.company_id for t in tenant_manager.list()) + 1 - yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) - delete_tenant(SCRATCH_LOGIN) - - -def test_tenant_create_bootstraps(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: - proc = acu( - "tenant", - "create", - "--id", - str(scratch_tenant.company_id), - "--login", - scratch_tenant.login, - ) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {scratch_tenant.login} is ready" in _combined(proc) - assert "AcuBootstrap published" in _combined(proc) - - -def test_company_get_returns_decplqty_after_put( - acu: RunAcu, - live_instance: Instance, - scratch_tenant: ScratchTenant, - data_repo: Path, -) -> None: - """T223/V52: key-URL GET returns DecPlQty 3 after packaging Company PUT.""" - proc = acu("--tenant", scratch_tenant.login, "apply", "config/bootstrap") - assert proc.returncode == 0, _combined(proc) - assert "PUT Company [LAB5]" in _combined(proc) - - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - str(data_repo / "config/baseline/90-uoms.yaml"), - str(data_repo / "config/baseline/91-company-packaging.yaml"), - ) - assert proc.returncode == 0, _combined(proc) - assert "PUT Company [LAB5]" in _combined(proc) - - inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) - firstlogin.initialize_admin_password(inst, tenant=scratch_tenant.login) - with AcumaticaClient(inst) as client: - rec = client.get_record("Company", ["LAB5"], BOOTSTRAP_ENDPOINT) - assert rec is not None - plain = unwrap(rec) - assert int(plain["DecPlQty"]) == 3, plain - assert plain["WeightUOM"] == "KG", plain - assert plain["VolumeUOM"] == "LITER", plain diff --git a/tests/e2e/test_extract_roundtrip.py b/tests/e2e/test_extract_roundtrip.py index 48b4c18..358c2c9 100644 --- a/tests/e2e/test_extract_roundtrip.py +++ b/tests/e2e/test_extract_roundtrip.py @@ -33,11 +33,9 @@ """ import difflib -import subprocess import time -from collections.abc import Callable, Iterator +from collections.abc import Iterator from pathlib import Path -from typing import NamedTuple import pytest @@ -46,39 +44,26 @@ from acumatica_cli.extract import FEATURES_FILE, load_manifest from acumatica_cli.seed import load_baseline from acumatica_cli.tenant import TenantManager +from tests.e2e.conftest import ( + DeleteTenant, + RunAcu, + ScratchPair, + bracket_pair, + joined_output, +) pytestmark = pytest.mark.e2e LOGIN_A = "E2EA" LOGIN_B = "E2EB" -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchPair(NamedTuple): - """The two disposable tenant slots the round-trip runs against.""" - - id_a: int - id_b: int - @pytest.fixture(scope="session") def scratch_pair( tenant_manager: TenantManager, delete_tenant: DeleteTenant ) -> Iterator[ScratchPair]: - """Bracket the session with two clean scratch-tenant slots. - - Setup clears leftovers from a crashed run and reserves the next two - free CompanyIDs (A is created before B, so B's slot is free when its - create runs); teardown always deletes both and recycles (V5). - """ - for login in (LOGIN_A, LOGIN_B): - delete_tenant(login) - base = max(t.company_id for t in tenant_manager.list()) - yield ScratchPair(id_a=base + 1, id_b=base + 2) - for login in (LOGIN_A, LOGIN_B): - delete_tenant(login) + """Bracket the session with two clean scratch-tenant slots.""" + yield from bracket_pair(LOGIN_A, LOGIN_B, tenant_manager, delete_tenant) @pytest.fixture(scope="session") @@ -88,11 +73,6 @@ def out_dirs(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: return root / "a", root / "b" -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - """Stdout plus stderr - status lines (success/error) go to stderr.""" - return proc.stdout + proc.stderr - - def _yaml_set(root: Path) -> set[str]: """Every emitted YAML file under an extract dir, repo-relative.""" return {str(p.relative_to(root)) for p in root.rglob("*.yaml")} @@ -111,9 +91,9 @@ def _catalog_expected() -> set[str]: def test_tenant_a_bootstraps(acu: RunAcu, scratch_pair: ScratchPair) -> None: """Tenant A: created from the data repo, bootstrap chained at birth.""" proc = acu("tenant", "create", "--id", str(scratch_pair.id_a), "--login", LOGIN_A) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {LOGIN_A} is ready" in _combined(proc) - assert "AcuBootstrap published" in _combined(proc) + assert proc.returncode == 0, joined_output(proc) + assert f"tenant {LOGIN_A} is ready" in joined_output(proc) + assert "AcuBootstrap published" in joined_output(proc) def test_apply_configures_tenant_a(acu: RunAcu, scratch_pair: ScratchPair) -> None: @@ -123,8 +103,8 @@ def test_apply_configures_tenant_a(acu: RunAcu, scratch_pair: ScratchPair) -> No meaningful when A carries the whole packaged surface. """ proc = acu("--tenant", LOGIN_A, "apply") - assert proc.returncode == 0, _combined(proc) - combined = _combined(proc) + assert proc.returncode == 0, joined_output(proc) + combined = joined_output(proc) # umbrella / default_seed_dirs order: bootstrap → baseline → setup → master assert "config/master/" in combined or "master/" in combined, combined @@ -147,10 +127,10 @@ def test_extract_dumps_tenant_a( assert master_expected, "catalog must include config/master/ rows (T117/T119)" proc = acu("--tenant", LOGIN_A, "survey", "extract", "--out", str(dir_a)) - assert proc.returncode == 0, _combined(proc) + assert proc.returncode == 0, joined_output(proc) skips = [ln for ln in proc.stdout.splitlines() if ln.startswith("skip ")] assert not any("entity not in active Bootstrap contract" in ln for ln in skips), ( - _combined(proc) + joined_output(proc) ) # master is non-optional after full apply (T119) master_skips = [ @@ -160,11 +140,11 @@ def test_extract_dumps_tenant_a( "full apply left master empty — extract inverse incomplete:\n" + "\n".join(master_skips) + "\n" - + _combined(proc) + + joined_output(proc) ) # allow clean (no records)/(exists) skips only for non-master rows for ln in skips: - assert "(no records)" in ln or "(exists)" in ln, _combined(proc) + assert "(no records)" in ln or "(exists)" in ln, joined_output(proc) for rel in list(expected): if rel in ln: expected.discard(rel) @@ -182,8 +162,8 @@ def test_extract_dumps_tenant_a( def test_tenant_b_bootstraps(acu: RunAcu, scratch_pair: ScratchPair) -> None: """Tenant B: the fresh replay target, bootstrap chained at birth.""" proc = acu("tenant", "create", "--id", str(scratch_pair.id_b), "--login", LOGIN_B) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {LOGIN_B} is ready" in _combined(proc) + assert proc.returncode == 0, joined_output(proc) + assert f"tenant {LOGIN_B} is ready" in joined_output(proc) def test_replay_extract_onto_tenant_b( @@ -192,8 +172,8 @@ def test_replay_extract_onto_tenant_b( """B is configured from a/ alone, umbrella expand of config/ (V30).""" dir_a, _ = out_dirs proc = acu("--tenant", LOGIN_B, "apply", str(dir_a / "config")) - assert proc.returncode == 0, _combined(proc) - combined = _combined(proc) + assert proc.returncode == 0, joined_output(proc) + combined = joined_output(proc) assert "config/master/" in combined or "master/" in combined, combined @@ -206,8 +186,8 @@ def test_diff_over_extract_is_clean_on_b( """ dir_a, _ = out_dirs proc = acu("--tenant", LOGIN_B, "diff", str(dir_a / "config")) - assert proc.returncode == 0, _combined(proc) - assert "no drift" in _combined(proc) + assert proc.returncode == 0, joined_output(proc) + assert "no drift" in joined_output(proc) def test_reextract_is_byte_identical( @@ -223,7 +203,7 @@ def test_reextract_is_byte_identical( """ dir_a, dir_b = out_dirs proc = acu("--tenant", LOGIN_B, "survey", "extract", "--out", str(dir_b)) - assert proc.returncode == 0, _combined(proc) + assert proc.returncode == 0, joined_output(proc) set_a, set_b = _yaml_set(dir_a), _yaml_set(dir_b) assert set_b == set_a master = {p for p in set_a if p.startswith("config/master/")} diff --git a/tests/e2e/test_kitassembly_alloc.py b/tests/e2e/test_kitassembly_alloc.py deleted file mode 100644 index de8fbd1..0000000 --- a/tests/e2e/test_kitassembly_alloc.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Live KitAssembly StockComponents id round-trip (T240/V54/B34). - -GET $expand=StockComponents keeps row id through unwrap. PUT of that id -plus LineNbr/Allocations updates the existing Components line instead of -inserting a second row (500 Components commit). -""" - -import subprocess -from collections.abc import Callable, Iterator -from typing import NamedTuple - -import pytest - -from acumatica_cli import firstlogin -from acumatica_cli.client import AcumaticaClient, unwrap -from acumatica_cli.config import Instance -from acumatica_cli.tenant import TenantManager - -pytestmark = pytest.mark.e2e - -SCRATCH_LOGIN = "E2EKIT" -KIT_INVENTORY_ID = "GW-EDGE" -KIT_REVISION = "V1" - -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchTenant(NamedTuple): - login: str - company_id: int - - -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - return proc.stdout + proc.stderr - - -def _kit_assembly_type(instance: Instance) -> str: - """25r1 Default 24.200.001 uses Assembly; trunk 25.200.001 uses Production.""" - return "Assembly" if instance.api_version.startswith("24.") else "Production" - - -@pytest.fixture(scope="module") -def scratch_tenant( - tenant_manager: TenantManager, delete_tenant: DeleteTenant -) -> Iterator[ScratchTenant]: - delete_tenant(SCRATCH_LOGIN) - company_id = max(t.company_id for t in tenant_manager.list()) + 1 - yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) - delete_tenant(SCRATCH_LOGIN) - - -def test_tenant_create_bootstraps(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: - proc = acu( - "tenant", - "create", - "--id", - str(scratch_tenant.company_id), - "--login", - scratch_tenant.login, - ) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {scratch_tenant.login} is ready" in _combined(proc) - assert "AcuBootstrap published" in _combined(proc) - - -def test_apply_configures_inventory(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: - proc = acu("--tenant", scratch_tenant.login, "apply") - assert proc.returncode == 0, _combined(proc) - assert "Warehouse" in _combined(proc) or "KitSpecification" in _combined(proc) - - -def test_buy_receipts_component_stock( - acu: RunAcu, scratch_tenant: ScratchTenant -) -> None: - """On-hand for GW-EDGE components so KitAssembly persist is not qty-blocked.""" - proc = acu( - "--tenant", - scratch_tenant.login, - "run", - "scenario/10-seed-capital.yaml", - "scenario/20-buy.yaml", - ) - assert proc.returncode == 0, _combined(proc) - - -def test_kitassembly_alloc_put_updates_existing_line( - live_instance: Instance, - scratch_tenant: ScratchTenant, -) -> None: - """T240/V54/B34: captured StockComponents[0].id PUT updates, no second row.""" - inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) - firstlogin.initialize_admin_password(inst, tenant=scratch_tenant.login) - kit_type = _kit_assembly_type(inst) - with AcumaticaClient(inst) as client: - created = unwrap( - client.put( - "KitAssembly", - { - "Type": kit_type, - "KitInventoryID": KIT_INVENTORY_ID, - "Revision": KIT_REVISION, - "Qty": 1, - "WarehouseID": "WH01", - "LocationID": "MAIN", - "ReasonCode": "INASSEMBLY", - "Hold": True, - }, - ) - ) - ref = created["ReferenceNbr"] - rec = client.get_record( - "KitAssembly", - [kit_type, ref], - params={"$expand": "StockComponents/Allocations"}, - ) - assert rec is not None - plain = unwrap(rec) - rows = plain["StockComponents"] - assert rows, plain - row0 = rows[0] - assert "id" in row0, row0 - n_before = len(rows) - ids_before = [row["id"] for row in rows] - allocs = row0.get("Allocations") or [ - { - "Qty": row0.get("Qty") or row0.get("ComponentQty") or 1, - "LocationID": row0.get("LocationID") or "MAIN", - } - ] - client.put( - "KitAssembly", - { - "Type": kit_type, - "ReferenceNbr": ref, - "StockComponents": [ - { - "id": row0["id"], - "LineNbr": row0["LineNbr"], - "Allocations": allocs, - } - ], - }, - ) - after = client.get_record( - "KitAssembly", - [kit_type, ref], - params={"$expand": "StockComponents"}, - ) - assert after is not None - after_plain = unwrap(after) - after_rows = after_plain["StockComponents"] - assert len(after_rows) == n_before, after_plain - assert [row["id"] for row in after_rows] == ids_before - assert after_rows[0]["id"] == row0["id"] diff --git a/tests/e2e/test_numbering_new_symbol.py b/tests/e2e/test_numbering_new_symbol.py deleted file mode 100644 index fe2b1b7..0000000 --- a/tests/e2e/test_numbering_new_symbol.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Live NumberingSequence insert with Header NewSymbol (T254/V58/B38). - -Insert of a new NumberingID 422s when NewSymbol is unmapped. Re-apply of -tenant-native BATCH must still succeed (no NewSymbol mask 422 on update). -Throwaway id is CLI-test only — not QORD/QNCR (QMS product sequences). -""" - -import subprocess -from collections.abc import Callable, Iterator -from pathlib import Path -from typing import NamedTuple - -import pytest - -from acumatica_cli.client import AcumaticaClient, unwrap -from acumatica_cli.config import Instance -from acumatica_cli.seed import BOOTSTRAP_ENDPOINT -from acumatica_cli.tenant import TenantManager - -pytestmark = pytest.mark.e2e - -SCRATCH_LOGIN = "E2ENUM" -INSERT_ID = "T254NS" -NUMBERING_YAML = f"""\ -entity: NumberingSequence -key: NumberingID -endpoint: bootstrap -records: -- NumberingID: {INSERT_ID} - Descr: CLI e2e throwaway numbering - NewSymbol: '' - StartNbr: '000000' - EndNbr: '999999' - WarnNbr: '999990' - NbrStep: 1 - StartDate: '1900-01-01' -- NumberingID: BATCH - NewSymbol: '' - StartNbr: '000000' - EndNbr: '999999' - WarnNbr: '999990' - NbrStep: 1 - StartDate: '1900-01-01' -""" - -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchTenant(NamedTuple): - login: str - company_id: int - - -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - return proc.stdout + proc.stderr - - -@pytest.fixture(scope="module") -def scratch_tenant( - tenant_manager: TenantManager, delete_tenant: DeleteTenant -) -> Iterator[ScratchTenant]: - delete_tenant(SCRATCH_LOGIN) - company_id = max(t.company_id for t in tenant_manager.list()) + 1 - yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) - delete_tenant(SCRATCH_LOGIN) - - -def test_tenant_create_bootstraps(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: - proc = acu( - "tenant", - "create", - "--id", - str(scratch_tenant.company_id), - "--login", - scratch_tenant.login, - ) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {scratch_tenant.login} is ready" in _combined(proc) - assert "AcuBootstrap published" in _combined(proc) - - -def test_insert_newsymbol_and_reapply_batch( - acu: RunAcu, - live_instance: Instance, - scratch_tenant: ScratchTenant, - data_repo: Path, -) -> None: - """T254/V58: insert T254NS with NewSymbol; re-apply BATCH; no 422.""" - path = data_repo / "config" / "master" / "05-e2e-numbering-newsymbol.yaml" - path.write_text(NUMBERING_YAML) - - proc = acu("--tenant", scratch_tenant.login, "apply", str(path)) - out = _combined(proc) - assert proc.returncode == 0, out - assert "422" not in out - assert "Either New Number Symbol or Manual Numbering" not in out - assert f"PUT NumberingSequence [{INSERT_ID}]" in out - assert "PUT NumberingSequence [BATCH]" in out - - inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) - with AcumaticaClient(inst) as client: - rec = client.get_record("NumberingSequence", [INSERT_ID], BOOTSTRAP_ENDPOINT) - assert rec is not None, INSERT_ID - plain = unwrap(rec) - assert plain["NumberingID"] == INSERT_ID - assert plain["Descr"] == "CLI e2e throwaway numbering" - assert plain["StartNbr"] == "000000" - assert plain["EndNbr"] == "999999" - assert plain["NewSymbol"] == "" - - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - str(data_repo / "config/master/05-numbering-sequences.yaml"), - ) - out = _combined(proc) - assert proc.returncode == 0, out - assert "422" not in out - assert "PUT NumberingSequence [BATCH]" in out - - proc = acu("--tenant", scratch_tenant.login, "apply", str(path)) - out = _combined(proc) - assert proc.returncode == 0, out - assert "422" not in out - assert f"PUT NumberingSequence [{INSERT_ID}]" in out - assert "PUT NumberingSequence [BATCH]" in out diff --git a/tests/e2e/test_provision_lifecycle.py b/tests/e2e/test_provision_lifecycle.py index bede1ab..e82f794 100644 --- a/tests/e2e/test_provision_lifecycle.py +++ b/tests/e2e/test_provision_lifecycle.py @@ -8,6 +8,11 @@ the default-dirs path (I.cmd/V30): the scaffolded ``config/`` SEED_DIRS (bootstrap/baseline/setup/master) are preferred over root. +Folded probes after full apply (T256): Role AssignUser SQL + diff, +NumberingSequence NewSymbol insert, 26-char InventoryID after +SegmentedKey, Company DecPlQty / kit 0.012. Throwaway YAML is applied +from tmp_path so it never lands in the shared scaffold master/. + Opt-in tier: every test carries the `e2e` marker, which the default suite deselects (`make check` stays offline, V13). Run via `make e2e` from the repo root; the only repo-root file involved is the decrypted @@ -24,54 +29,83 @@ instance. """ -import subprocess -from collections.abc import Callable, Iterator +from collections.abc import Iterator from pathlib import Path -from typing import Any, NamedTuple +from typing import Any import pytest import yaml from acumatica_cli import firstlogin from acumatica_cli.client import AcumaticaClient, unwrap -from acumatica_cli.config import Instance +from acumatica_cli.config import DB_NAME, Instance from acumatica_cli.seed import BOOTSTRAP_ENDPOINT from acumatica_cli.tenant import TenantManager +from tests.e2e.conftest import ( + DeleteTenant, + RunAcu, + ScratchTenant, + bracket_tenant, + joined_output, +) pytestmark = pytest.mark.e2e SCRATCH_LOGIN = "E2E" - -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchTenant(NamedTuple): - """The disposable tenant slot the lifecycle runs against.""" - - login: str - company_id: int +INSERT_ID = "T254NS" +LONG_INVENTORY_ID = "FG-CARDIO-OMEGA-COQ10-60SG" +NUMBERING_YAML = f"""\ +entity: NumberingSequence +key: NumberingID +endpoint: bootstrap +records: +- NumberingID: {INSERT_ID} + Descr: CLI e2e throwaway numbering + NewSymbol: '' + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: BATCH + NewSymbol: '' + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +""" @pytest.fixture(scope="session") def scratch_tenant( tenant_manager: TenantManager, delete_tenant: DeleteTenant ) -> Iterator[ScratchTenant]: - """Bracket the session with a clean scratch-tenant slot. - - Setup clears a leftover tenant from a crashed run and picks the next - free CompanyID; teardown always deletes the tenant (TenantManager.delete - has no confirmation prompt, unlike the CLI command). - """ - delete_tenant(SCRATCH_LOGIN) - company_id = max(t.company_id for t in tenant_manager.list()) + 1 - yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) - delete_tenant(SCRATCH_LOGIN) + """Bracket the session with a clean scratch-tenant slot.""" + yield from bracket_tenant(SCRATCH_LOGIN, tenant_manager, delete_tenant) + + +def _users_in_roles( + tenant_manager: TenantManager, company_id: int, username: str +) -> str: + """Sqlcmd dump of UsersInRoles for one user in the session tenant.""" + query = ( + "SET NOCOUNT ON; " + "SELECT Username, Rolename, CompanyID " + f"FROM {DB_NAME}.dbo.UsersInRoles " + f"WHERE CompanyID = {company_id} AND Username = '{username}'" + ) + return tenant_manager._ssh( # pyright: ignore[reportPrivateUsage] + f'sqlcmd -S "(local)" -E -C -W -h -1 -s "|" -Q "{query}"' + ) -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - """Stdout plus stderr - status lines (success/error) go to stderr.""" - return proc.stdout + proc.stderr +def _has_role(dump: str, rolename: str) -> bool: + for line in dump.splitlines(): + parts = [p.strip() for p in line.split("|")] + if len(parts) == 3 and parts[1] == rolename and parts[2].isdigit(): + return True + return False def test_tenant_create_bootstraps_at_birth( @@ -86,9 +120,9 @@ def test_tenant_create_bootstraps_at_birth( "--login", scratch_tenant.login, ) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {scratch_tenant.login} is ready" in _combined(proc) - assert "AcuBootstrap published" in _combined(proc) + assert proc.returncode == 0, joined_output(proc) + assert f"tenant {scratch_tenant.login} is ready" in joined_output(proc) + assert "AcuBootstrap published" in joined_output(proc) def test_apply_configures_the_fresh_tenant( @@ -96,8 +130,8 @@ def test_apply_configures_the_fresh_tenant( ) -> None: """Bare apply sweeps default config/ SEED_DIRS (T44/V30).""" proc = acu("--tenant", scratch_tenant.login, "apply") - assert proc.returncode == 0, _combined(proc) - assert "config/" in proc.stdout or "Warehouse" in _combined(proc) + assert proc.returncode == 0, joined_output(proc) + assert "config/" in proc.stdout or "Warehouse" in joined_output(proc) def test_diff_is_clean_on_configured_tenant( @@ -110,17 +144,17 @@ def test_diff_is_clean_on_configured_tenant( row and the 2026 company periods exist on the tenant. """ proc = acu("--tenant", scratch_tenant.login, "diff") - assert proc.returncode == 0, _combined(proc) - assert "no drift" in _combined(proc) + assert proc.returncode == 0, joined_output(proc) + assert "no drift" in joined_output(proc) def test_apply_is_idempotent(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: proc = acu("--tenant", scratch_tenant.login, "apply") - assert proc.returncode == 0, _combined(proc) + assert proc.returncode == 0, joined_output(proc) # every setup/ action re-verifies through its done_when probe and # skips - the T36 re-run leg: no second invoke, zero mutations - assert "skip GeneratePeriods (already done)" in _combined(proc) - assert "skip GenerateCalendar (already done)" in _combined(proc) + assert "skip GeneratePeriods (already done)" in joined_output(proc) + assert "skip GenerateCalendar (already done)" in joined_output(proc) def test_diff_detects_injected_drift( @@ -136,8 +170,8 @@ def test_diff_detects_injected_drift( mutated.write_text(yaml.safe_dump(doc, sort_keys=False)) proc = acu("--tenant", scratch_tenant.login, "diff", str(mutated)) - assert proc.returncode == 2, _combined(proc) - assert "DRIFT" in _combined(proc) + assert proc.returncode == 2, joined_output(proc) + assert "DRIFT" in joined_output(proc) def test_kit_componentqty_round_trips_after_decplqty( @@ -146,13 +180,17 @@ def test_kit_componentqty_round_trips_after_decplqty( """T223/V52/B30: live kit ComponentQty 0.012 after Company DecPlQty 3. Scratch revision is not in the demo seed, so config/ diff stays clean. + WeightUOM/VolumeUOM come from the packaging Company PUT (folded T223). """ inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) firstlogin.initialize_admin_password(inst, tenant=scratch_tenant.login) with AcumaticaClient(inst) as client: company = client.get_record("Company", ["LAB5"], BOOTSTRAP_ENDPOINT) assert company is not None - assert int(unwrap(company)["DecPlQty"]) == 3, unwrap(company) + plain = unwrap(company) + assert int(plain["DecPlQty"]) == 3, plain + assert plain["WeightUOM"] == "KG", plain + assert plain["VolumeUOM"] == "LITER", plain client.put( "KitSpecification", { @@ -180,6 +218,116 @@ def test_kit_componentqty_round_trips_after_decplqty( assert float(qty) != 0.01 +def test_role_users_sql_and_diff_clean( + acu: RunAcu, + tenant_manager: TenantManager, + scratch_tenant: ScratchTenant, +) -> None: + """T249/V57/B37: after 92-role-users AssignUser, SQL + acu diff exit 0.""" + dump = _users_in_roles(tenant_manager, scratch_tenant.company_id, "soadmin") + assert _has_role(dump, "SO Admin"), dump + + proc = acu( + "--tenant", + scratch_tenant.login, + "diff", + "config/master/90-roles.yaml", + "config/master/91-users.yaml", + "config/master/92-role-users.yaml", + ) + assert proc.returncode == 0, joined_output(proc) + out = joined_output(proc) + assert "Users[" not in out + assert ".Roles[" not in out + + +def test_insert_newsymbol_and_reapply_batch( + acu: RunAcu, + live_instance: Instance, + scratch_tenant: ScratchTenant, + data_repo: Path, + tmp_path: Path, +) -> None: + """T254/V58: insert T254NS with NewSymbol; re-apply BATCH; no 422.""" + path = tmp_path / "05-e2e-numbering-newsymbol.yaml" + path.write_text(NUMBERING_YAML) + + proc = acu("--tenant", scratch_tenant.login, "apply", str(path)) + out = joined_output(proc) + assert proc.returncode == 0, out + assert "422" not in out + assert "Either New Number Symbol or Manual Numbering" not in out + assert f"PUT NumberingSequence [{INSERT_ID}]" in out + assert "PUT NumberingSequence [BATCH]" in out + + inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) + with AcumaticaClient(inst) as client: + rec = client.get_record("NumberingSequence", [INSERT_ID], BOOTSTRAP_ENDPOINT) + assert rec is not None, INSERT_ID + plain = unwrap(rec) + assert plain["NumberingID"] == INSERT_ID + assert plain["Descr"] == "CLI e2e throwaway numbering" + assert plain["StartNbr"] == "000000" + assert plain["EndNbr"] == "999999" + assert plain["NewSymbol"] == "" + + proc = acu( + "--tenant", + scratch_tenant.login, + "apply", + str(data_repo / "config/master/05-numbering-sequences.yaml"), + ) + out = joined_output(proc) + assert proc.returncode == 0, out + assert "422" not in out + assert "PUT NumberingSequence [BATCH]" in out + + proc = acu("--tenant", scratch_tenant.login, "apply", str(path)) + out = joined_output(proc) + assert proc.returncode == 0, out + assert "422" not in out + assert f"PUT NumberingSequence [{INSERT_ID}]" in out + assert "PUT NumberingSequence [BATCH]" in out + + +def test_long_inventory_id_put_after_segmented_key( + live_instance: Instance, + tenant_manager: TenantManager, + scratch_tenant: ScratchTenant, +) -> None: + """T207/V50: 26-char InventoryID PUT succeeds after SegmentedKey apply. + + Recycle so CS202000 mask cache picks up Length 30 (app-start compile). + Last among tenant-using probes because recycle drops live sessions. + """ + assert len(LONG_INVENTORY_ID) == 26 + inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) + tenant_manager.recycle_app_pool() + firstlogin.initialize_admin_password(inst, tenant=scratch_tenant.login) + + with AcumaticaClient(inst) as client: + for dim, want in (("INVENTORY", 30), ("BIZACCT", 30), ("ACCOUNT", 10)): + rec = client.get_record("SegmentedKey", [dim], BOOTSTRAP_ENDPOINT) + assert rec is not None, dim + plain = unwrap(rec) + assert int(plain["SegmentID"]) == 1, plain + assert int(plain["Length"]) == want, plain + result = client.put( + "StockItem", + { + "InventoryID": LONG_INVENTORY_ID, + "Description": "T207 long InventoryID probe", + "ItemClass": "PARTS", + "ItemStatus": "Active", + "DefaultWarehouseID": "WH01", + }, + ) + assert str(unwrap(result)["InventoryID"]).rstrip() == LONG_INVENTORY_ID + record = client.get_record("StockItem", [LONG_INVENTORY_ID]) + assert record is not None + assert str(unwrap(record)["InventoryID"]).rstrip() == LONG_INVENTORY_ID + + def test_diff_against_nonexistent_tenant_exits_one(acu: RunAcu) -> None: """B5 regression (T21): an unknown tenant name must fail loudly, exit 1. @@ -190,4 +338,4 @@ def test_diff_against_nonexistent_tenant_exits_one(acu: RunAcu) -> None: guard in AcumaticaClient stands between that and a false-green diff. """ proc = acu("--tenant", "NoSuchTenantB5", "diff", "config/baseline") - assert proc.returncode == 1, _combined(proc) + assert proc.returncode == 1, joined_output(proc) diff --git a/tests/e2e/test_scenario_lifecycle.py b/tests/e2e/test_scenario_lifecycle.py index 8304312..1b866df 100644 --- a/tests/e2e/test_scenario_lifecycle.py +++ b/tests/e2e/test_scenario_lifecycle.py @@ -1,196 +1,204 @@ """Live virgin-tenant scenario + state lifecycle (SPEC T80/T98/T110/T114). -Self-contained: scaffolds the packaged full ``config init`` seed into a tmp -data repo, creates a scratch tenant, then apply → run scenario/ → warm -once-skip → diff clean → state write → assert-unchanged. Parallel to +Self-contained: uses the session-scaffolded packaged ``config init`` seed, +creates a scratch tenant, then apply → run scenario/ → kit-alloc probe → +warm once-skip → diff clean → state write → assert-unchanged. Parallel to ``test_provision_lifecycle`` (apply/diff focus) on a separate tenant login so the two modules do not share session tenant state. +KitAssembly alloc (T240) folds here: leftover component stock after +20-buy / 30-build is enough for a Hold Qty-1 assembly; Hold does not +move TB so later state asserts stay valid. + Opt-in via ``make e2e FILE=test_scenario_lifecycle``. Default offline suite stays green without this file (``not e2e``). """ -import subprocess -import sys -import threading -from collections.abc import Callable, Iterator +from collections.abc import Iterator from datetime import date from pathlib import Path -from typing import IO, NamedTuple import pytest import yaml -from acumatica_cli.config import scaffold +from acumatica_cli import firstlogin +from acumatica_cli.client import AcumaticaClient, unwrap +from acumatica_cli.config import Instance from acumatica_cli.run import period_mmYYYY from acumatica_cli.tenant import TenantManager +from tests.e2e.conftest import ( + DeleteTenant, + RunAcu, + ScratchTenant, + bracket_tenant, + joined_output, +) pytestmark = pytest.mark.e2e -REPO_ROOT = Path(__file__).resolve().parents[2] SCRATCH_LOGIN = "E2ESCEN" +KIT_INVENTORY_ID = "GW-EDGE" +KIT_REVISION = "V1" -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchTenant(NamedTuple): - login: str - company_id: int - - -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - return proc.stdout + proc.stderr - - -@pytest.fixture(scope="module") -def scenario_repo(tmp_path_factory: pytest.TempPathFactory) -> Path: - """Full-scaffold data repo with live credentials from repo-root .env.""" - import shutil - - root = tmp_path_factory.mktemp("scenario-data-repo") - for _ in scaffold(root): - pass - real_env = REPO_ROOT / ".env" - if real_env.exists(): - shutil.copyfile(real_env, root / ".env") - else: - (root / ".env").unlink(missing_ok=True) - return root - - -@pytest.fixture(scope="module") -def scenario_acu(scenario_repo: Path) -> RunAcu: - """Run installed ``acu`` from the scenario scaffold (stream like conftest).""" - - def _pump(pipe: IO[str], lines: list[str], sink: IO[str]) -> None: - for line in pipe: - lines.append(line) - sink.write(line) - sink.flush() - - def run(*args: str) -> subprocess.CompletedProcess[str]: - sys.stderr.write(f"$ acu {' '.join(args)}\n") - sys.stderr.flush() - with subprocess.Popen( - ["acu", *args], - cwd=scenario_repo, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) as proc: - assert proc.stdout is not None - assert proc.stderr is not None - out: list[str] = [] - err: list[str] = [] - readers = [ - threading.Thread(target=_pump, args=(proc.stdout, out, sys.stdout)), - threading.Thread(target=_pump, args=(proc.stderr, err, sys.stderr)), - ] - for reader in readers: - reader.start() - try: - returncode = proc.wait(timeout=3600) - except subprocess.TimeoutExpired: - proc.kill() - raise - finally: - for reader in readers: - reader.join() - return subprocess.CompletedProcess( - ["acu", *args], returncode, "".join(out), "".join(err) - ) - - return run - -@pytest.fixture(scope="module") -def scenario_tenant( +@pytest.fixture(scope="session") +def scratch_tenant( tenant_manager: TenantManager, delete_tenant: DeleteTenant ) -> Iterator[ScratchTenant]: - delete_tenant(SCRATCH_LOGIN) - company_id = max(t.company_id for t in tenant_manager.list()) + 1 - yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) - delete_tenant(SCRATCH_LOGIN) + yield from bracket_tenant(SCRATCH_LOGIN, tenant_manager, delete_tenant) + +def _kit_assembly_type(instance: Instance) -> str: + """25r1 Default 24.200.001 uses Assembly; trunk 25.200.001 uses Production.""" + return "Assembly" if instance.api_version.startswith("24.") else "Production" -def test_full_scaffold_layout(scenario_repo: Path) -> None: + +def test_full_scaffold_layout(data_repo: Path) -> None: """T80/T110/T113/T179/V28: config/ umbrella + lifecycle + TB views + README.""" # Bootstrap contract is package SoT — never scaffolded (T178/T179) - assert not (scenario_repo / "config" / "bootstrap" / "project.xml").exists() - assert (scenario_repo / "config" / "bootstrap" / "features.yaml").is_file() - assert (scenario_repo / "config" / "master").is_dir() - assert (scenario_repo / "scenario" / "10-seed-capital.yaml").is_file() - assert (scenario_repo / "scenario" / "20-buy.yaml").is_file() - assert (scenario_repo / "scenario" / "30-build.yaml").is_file() - assert (scenario_repo / "scenario" / "40-sell.yaml").is_file() - assert not (scenario_repo / "scenario" / "buy-sell.yaml").exists() - assert (scenario_repo / "overlays" / "README.md").is_file() + assert not (data_repo / "config" / "bootstrap" / "project.xml").exists() + assert (data_repo / "config" / "bootstrap" / "features.yaml").is_file() + assert (data_repo / "config" / "master").is_dir() + assert (data_repo / "scenario" / "10-seed-capital.yaml").is_file() + assert (data_repo / "scenario" / "20-buy.yaml").is_file() + assert (data_repo / "scenario" / "30-build.yaml").is_file() + assert (data_repo / "scenario" / "40-sell.yaml").is_file() + assert not (data_repo / "scenario" / "buy-sell.yaml").exists() + assert (data_repo / "overlays" / "README.md").is_file() assert ( - scenario_repo / "overlays" / "default-24.200.001" / "scenario" / "30-build.yaml" + data_repo / "overlays" / "default-24.200.001" / "scenario" / "30-build.yaml" ).is_file() - assert (scenario_repo / "config" / "views" / "10-trial-balance.yaml").is_file() + assert (data_repo / "config" / "views" / "10-trial-balance.yaml").is_file() # T107/V28/V33: golden state/ = trial-balance only (B25) - assert not ( - scenario_repo / "config" / "views" / "20-inventory-summary.yaml" - ).exists() - assert not (scenario_repo / "config" / "snapshot").exists() - assert (scenario_repo / "README.md").is_file() - assert list((scenario_repo / "config" / "master").glob("*.yaml")) + assert not (data_repo / "config" / "views" / "20-inventory-summary.yaml").exists() + assert not (data_repo / "config" / "snapshot").exists() + assert (data_repo / "README.md").is_file() + assert list((data_repo / "config" / "master").glob("*.yaml")) -def test_scenario_tenant_create( - scenario_acu: RunAcu, scenario_tenant: ScratchTenant -) -> None: - proc = scenario_acu( +def test_scenario_tenant_create(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: + proc = acu( "tenant", "create", "--id", - str(scenario_tenant.company_id), + str(scratch_tenant.company_id), "--login", - scenario_tenant.login, + scratch_tenant.login, ) - assert proc.returncode == 0, _combined(proc) - assert "AcuBootstrap published" in _combined(proc) + assert proc.returncode == 0, joined_output(proc) + assert "AcuBootstrap published" in joined_output(proc) -def test_scenario_apply(scenario_acu: RunAcu, scenario_tenant: ScratchTenant) -> None: +def test_scenario_apply(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: """Bare apply prefers config/ and includes master after setup (T77/T84).""" - proc = scenario_acu("--tenant", scenario_tenant.login, "apply") - assert proc.returncode == 0, _combined(proc) - assert "config/master/" in proc.stdout or "Warehouse" in _combined(proc) + proc = acu("--tenant", scratch_tenant.login, "apply") + assert proc.returncode == 0, joined_output(proc) + assert "config/master/" in proc.stdout or "Warehouse" in joined_output(proc) + + +def test_scenario_run(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: + proc = acu("--tenant", scratch_tenant.login, "run", "scenario/") + assert proc.returncode == 0, joined_output(proc) -def test_scenario_run(scenario_acu: RunAcu, scenario_tenant: ScratchTenant) -> None: - proc = scenario_acu("--tenant", scenario_tenant.login, "run", "scenario/") - assert proc.returncode == 0, _combined(proc) +def test_kitassembly_alloc_put_updates_existing_line( + live_instance: Instance, + scratch_tenant: ScratchTenant, +) -> None: + """T240/V54/B34: captured StockComponents[0].id PUT updates, no second row. + + Runs after scenario buy/build so component on-hand exists. Hold Qty-1 + does not release, so later TB asserts stay valid. + """ + inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) + firstlogin.initialize_admin_password(inst, tenant=scratch_tenant.login) + kit_type = _kit_assembly_type(inst) + with AcumaticaClient(inst) as client: + created = unwrap( + client.put( + "KitAssembly", + { + "Type": kit_type, + "KitInventoryID": KIT_INVENTORY_ID, + "Revision": KIT_REVISION, + "Qty": 1, + "WarehouseID": "WH01", + "LocationID": "MAIN", + "ReasonCode": "INASSEMBLY", + "Hold": True, + }, + ) + ) + ref = created["ReferenceNbr"] + rec = client.get_record( + "KitAssembly", + [kit_type, ref], + params={"$expand": "StockComponents/Allocations"}, + ) + assert rec is not None + plain = unwrap(rec) + rows = plain["StockComponents"] + assert rows, plain + row0 = rows[0] + assert "id" in row0, row0 + n_before = len(rows) + ids_before = [row["id"] for row in rows] + allocs = row0.get("Allocations") or [ + { + "Qty": row0.get("Qty") or row0.get("ComponentQty") or 1, + "LocationID": row0.get("LocationID") or "MAIN", + } + ] + client.put( + "KitAssembly", + { + "Type": kit_type, + "ReferenceNbr": ref, + "StockComponents": [ + { + "id": row0["id"], + "LineNbr": row0["LineNbr"], + "Allocations": allocs, + } + ], + }, + ) + after = client.get_record( + "KitAssembly", + [kit_type, ref], + params={"$expand": "StockComponents"}, + ) + assert after is not None + after_plain = unwrap(after) + after_rows = after_plain["StockComponents"] + assert len(after_rows) == n_before, after_plain + assert [row["id"] for row in after_rows] == ids_before + assert after_rows[0]["id"] == row0["id"] def test_scenario_warm_capital_once_skip( - scenario_acu: RunAcu, scenario_tenant: ScratchTenant + acu: RunAcu, scratch_tenant: ScratchTenant ) -> None: """T89/V4: second run scenario/ skips once capital (Owner Capital non-stack). Cold path ran in test_scenario_run. Warm re-run must print the once skip line for 10-seed-capital and still exit 0 for additive legs. """ - proc = scenario_acu("--tenant", scenario_tenant.login, "run", "scenario/") - combined = _combined(proc) + proc = acu("--tenant", scratch_tenant.login, "run", "scenario/") + combined = joined_output(proc) assert proc.returncode == 0, combined assert "once: already present" in combined assert "10-seed-capital" in combined -def test_scenario_diff_clean( - scenario_acu: RunAcu, scenario_tenant: ScratchTenant -) -> None: - proc = scenario_acu("--tenant", scenario_tenant.login, "diff") - assert proc.returncode == 0, _combined(proc) - assert "no drift" in _combined(proc) +def test_scenario_diff_clean(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: + proc = acu("--tenant", scratch_tenant.login, "diff") + assert proc.returncode == 0, joined_output(proc) + assert "no drift" in joined_output(proc) def test_scenario_state_write( - scenario_acu: RunAcu, scenario_tenant: ScratchTenant, scenario_repo: Path + acu: RunAcu, scratch_tenant: ScratchTenant, data_repo: Path ) -> None: """T98/T105/T107/T112/V32/V33: after scenario, state/ TB is numeric fixed-point. @@ -203,19 +211,19 @@ def test_scenario_state_write( # Package TB view pins Period 072026 for mock alignment (V33/V43). # Scenario posts to ${current_period}; rewrite the scaffold view so # state/ captures the month the JE actually landed. - view_path = scenario_repo / "config" / "views" / "10-trial-balance.yaml" + view_path = data_repo / "config" / "views" / "10-trial-balance.yaml" view = yaml.safe_load(view_path.read_text()) view["source"]["params"]["Period"] = period_mmYYYY(date.today()) view_path.write_text(yaml.safe_dump(view, sort_keys=False)) - proc = scenario_acu("--tenant", scenario_tenant.login, "state") - assert proc.returncode == 0, _combined(proc) - combined = _combined(proc) + proc = acu("--tenant", scratch_tenant.login, "state") + assert proc.returncode == 0, joined_output(proc) + combined = joined_output(proc) assert "trial-balance" in combined or "wrote" in combined - tb_path = scenario_repo / "state" / "trial-balance.yaml" + tb_path = data_repo / "state" / "trial-balance.yaml" assert tb_path.is_file() - assert not (scenario_repo / "state" / "inventory-summary.yaml").exists() + assert not (data_repo / "state" / "inventory-summary.yaml").exists() tb = yaml.safe_load(tb_path.read_text()) assert tb["view"] == "trial-balance" @@ -232,7 +240,7 @@ def test_scenario_state_write( def test_scenario_state_assert_unchanged( - scenario_acu: RunAcu, scenario_tenant: ScratchTenant + acu: RunAcu, scratch_tenant: ScratchTenant ) -> None: """T98/T105/T112/V4/V32: warm once-capital + state --assert-unchanged exits 0. @@ -240,16 +248,14 @@ def test_scenario_state_assert_unchanged( (skip path) so EndingBalance stays byte-stable — additive buy/sell legs would move TB cash/inventory observations. """ - run = scenario_acu( + run = acu( "--tenant", - scenario_tenant.login, + scratch_tenant.login, "run", "scenario/10-seed-capital.yaml", ) - combined = _combined(run) + combined = joined_output(run) assert run.returncode == 0, combined assert "once: already present" in combined - proc = scenario_acu( - "--tenant", scenario_tenant.login, "state", "--assert-unchanged" - ) - assert proc.returncode == 0, _combined(proc) + proc = acu("--tenant", scratch_tenant.login, "state", "--assert-unchanged") + assert proc.returncode == 0, joined_output(proc) diff --git a/tests/e2e/test_segmented_key_length.py b/tests/e2e/test_segmented_key_length.py deleted file mode 100644 index 190863e..0000000 --- a/tests/e2e/test_segmented_key_length.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Live SegmentedKey length: 26-char StockItem PUT after apply (T207/B28). - -Contract maps Length to CS202000 DataMember Detail (not Details). Order: -bootstrap SegmentedKey, recycle for the mask cache, then accounts plus -package INPreferences, then StockItem. -""" - -import subprocess -from collections.abc import Callable, Iterator -from pathlib import Path -from typing import NamedTuple - -import pytest - -from acumatica_cli import firstlogin -from acumatica_cli.client import AcumaticaClient, unwrap -from acumatica_cli.config import Instance -from acumatica_cli.seed import BOOTSTRAP_ENDPOINT -from acumatica_cli.tenant import TenantManager - -pytestmark = pytest.mark.e2e - -SCRATCH_LOGIN = "E2ESK" -LONG_INVENTORY_ID = "FG-CARDIO-OMEGA-COQ10-60SG" - -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchTenant(NamedTuple): - login: str - company_id: int - - -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - return proc.stdout + proc.stderr - - -@pytest.fixture(scope="module") -def scratch_tenant( - tenant_manager: TenantManager, delete_tenant: DeleteTenant -) -> Iterator[ScratchTenant]: - delete_tenant(SCRATCH_LOGIN) - company_id = max(t.company_id for t in tenant_manager.list()) + 1 - yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) - delete_tenant(SCRATCH_LOGIN) - - -def test_tenant_create_bootstraps(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: - proc = acu( - "tenant", - "create", - "--id", - str(scratch_tenant.company_id), - "--login", - scratch_tenant.login, - ) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {scratch_tenant.login} is ready" in _combined(proc) - assert "AcuBootstrap published" in _combined(proc) - - -def test_apply_bootstrap_widens_segmented_keys( - acu: RunAcu, scratch_tenant: ScratchTenant -) -> None: - """SegmentedKey apply is bootstrap-only; V22 before master StockItem.""" - proc = acu("--tenant", scratch_tenant.login, "apply", "config/bootstrap") - assert proc.returncode == 0, _combined(proc) - assert "PUT SegmentedKey [BIZACCT]" in _combined(proc) - assert "PUT SegmentedKey [INVENTORY]" in _combined(proc) - - -def test_long_inventory_id_put_after_segmented_key( - acu: RunAcu, - live_instance: Instance, - tenant_manager: TenantManager, - scratch_tenant: ScratchTenant, - data_repo: Path, -) -> None: - """T207/V50: 26-char InventoryID PUT succeeds after SegmentedKey apply.""" - assert len(LONG_INVENTORY_ID) == 26 - inst = live_instance.model_copy(update={"tenant": scratch_tenant.login}) - - # Recycle so CS202000 mask cache picks up Length 30 (app-start compile). - tenant_manager.recycle_app_pool() - firstlogin.initialize_admin_password(inst, tenant=scratch_tenant.login) - - with AcumaticaClient(inst) as client: - # Key-URL GET: list GET may omit detail Length (B9-class projection). - for dim, want in (("INVENTORY", 30), ("BIZACCT", 30), ("ACCOUNT", 10)): - rec = client.get_record("SegmentedKey", [dim], BOOTSTRAP_ENDPOINT) - assert rec is not None, dim - plain = unwrap(rec) - assert int(plain["SegmentID"]) == 1, plain - assert int(plain["Length"]) == want, plain - - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - "config/baseline", - "config/setup", - str(data_repo / "config/master/05-numbering-sequences.yaml"), - str(data_repo / "config/master/10-reason-codes.yaml"), - ) - assert proc.returncode == 0, _combined(proc) - - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - str(data_repo / "config/master/20-in-preferences.yaml"), - ) - assert proc.returncode == 0, _combined(proc) - assert "PUT INPreferences" in _combined(proc) - - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - str(data_repo / "config/master/30-availability-rules.yaml"), - str(data_repo / "config/master/40-posting-classes.yaml"), - str(data_repo / "config/master/53-tax-categories.yaml"), - ) - assert proc.returncode == 0, _combined(proc) - - with AcumaticaClient(inst) as client: - client.put( - "Warehouse", - { - "SiteCD": "WH01", - "Descr": "Main warehouse", - "Active": True, - "CountryID": "US", - }, - endpoint=BOOTSTRAP_ENDPOINT, - ) - client.put( - "ItemClass", - { - "ClassID": "PARTS", - "Description": "Purchased components", - "StockItem": True, - "ItemType": "Finished Good", - "ValuationMethod": "Average", - "BaseUOM": "EA", - "AvailabilityCalculationRule": "DEFAULT", - "PostingClass": "PARTS", - "TaxCategoryID": "EXEMPT", - "DefaultWarehouseID": "WH01", - }, - ) - result = client.put( - "StockItem", - { - "InventoryID": LONG_INVENTORY_ID, - "Description": "T207 long InventoryID probe", - "ItemClass": "PARTS", - "ItemStatus": "Active", - "DefaultWarehouseID": "WH01", - }, - ) - assert str(unwrap(result)["InventoryID"]).rstrip() == LONG_INVENTORY_ID - record = client.get_record("StockItem", [LONG_INVENTORY_ID]) - assert record is not None - assert str(unwrap(record)["InventoryID"]).rstrip() == LONG_INVENTORY_ID diff --git a/tests/e2e/test_user_role_membership.py b/tests/e2e/test_user_role_membership.py deleted file mode 100644 index e66813b..0000000 --- a/tests/e2e/test_user_role_membership.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Live Role AssignUser inserts UsersInRoles (T228/V53/B31/B32/B33). - -Mapped-detail PUT (User.Roles / Role.Users) never writes UsersInRoles -(silent 200). Persist is Role action AssignUser -> PXDatabase UsersInRoles. -SQL UsersInRoles on the session tenant CompanyID is the proof. -Diff skips Role.Users / User.Roles (V57): empty mapped GET is not exit 2. -""" - -import subprocess -from collections.abc import Callable, Iterator -from pathlib import Path -from typing import NamedTuple - -import pytest - -from acumatica_cli.config import DB_NAME -from acumatica_cli.tenant import TenantManager - -pytestmark = pytest.mark.e2e - -SCRATCH_LOGIN = "E2EROLES" -IN_MANAGER_USER = "e2eroles" -IN_MANAGER_ROLE_YAML = """\ -entity: Role -key: Rolename -endpoint: bootstrap -records: -- Rolename: IN Manager - Descr: Full access to IN functions -""" -IN_MANAGER_USER_YAML = """\ -entity: User -key: Username -endpoint: bootstrap -records: -- Username: e2eroles - FirstName: E2E - LastName: Roles - Email: e2eroles@example.com - IsApproved: true - PasswordNeverExpires: true - PasswordChangeOnNextLogin: false -""" -IN_MANAGER_MEMBERSHIP_YAML = """\ -entity: Role -key: Rolename -endpoint: bootstrap -detail_keys: - Users: Username -records: -- Rolename: IN Manager - Users: - - Username: e2eroles -""" - -RunAcu = Callable[..., subprocess.CompletedProcess[str]] -DeleteTenant = Callable[[str], None] - - -class ScratchTenant(NamedTuple): - login: str - company_id: int - - -def _combined(proc: subprocess.CompletedProcess[str]) -> str: - return proc.stdout + proc.stderr - - -def _users_in_roles( - tenant_manager: TenantManager, company_id: int, username: str -) -> str: - """Sqlcmd dump of UsersInRoles for one user in the session tenant.""" - query = ( - "SET NOCOUNT ON; " - "SELECT Username, Rolename, CompanyID " - f"FROM {DB_NAME}.dbo.UsersInRoles " - f"WHERE CompanyID = {company_id} AND Username = '{username}'" - ) - return tenant_manager._ssh( # pyright: ignore[reportPrivateUsage] - f'sqlcmd -S "(local)" -E -C -W -h -1 -s "|" -Q "{query}"' - ) - - -def _has_role(dump: str, rolename: str) -> bool: - for line in dump.splitlines(): - parts = [p.strip() for p in line.split("|")] - if len(parts) == 3 and parts[1] == rolename and parts[2].isdigit(): - return True - return False - - -@pytest.fixture(scope="module") -def scratch_tenant( - tenant_manager: TenantManager, delete_tenant: DeleteTenant -) -> Iterator[ScratchTenant]: - delete_tenant(SCRATCH_LOGIN) - company_id = max(t.company_id for t in tenant_manager.list()) + 1 - yield ScratchTenant(login=SCRATCH_LOGIN, company_id=company_id) - delete_tenant(SCRATCH_LOGIN) - - -def test_tenant_create_bootstraps(acu: RunAcu, scratch_tenant: ScratchTenant) -> None: - proc = acu( - "tenant", - "create", - "--id", - str(scratch_tenant.company_id), - "--login", - scratch_tenant.login, - ) - assert proc.returncode == 0, _combined(proc) - assert f"tenant {scratch_tenant.login} is ready" in _combined(proc) - assert "AcuBootstrap published" in _combined(proc) - - -def test_put_role_users_inserts_users_in_roles( - acu: RunAcu, - tenant_manager: TenantManager, - scratch_tenant: ScratchTenant, - data_repo: Path, -) -> None: - """T228/V53: apply Role Users invokes AssignUser; SQL UsersInRoles.""" - role_path = data_repo / "config" / "master" / "92-e2e-in-manager-role.yaml" - user_path = data_repo / "config" / "master" / "93-e2e-in-manager-user.yaml" - membership_path = data_repo / "config" / "master" / "94-e2e-in-manager-users.yaml" - role_path.write_text(IN_MANAGER_ROLE_YAML) - user_path.write_text(IN_MANAGER_USER_YAML) - membership_path.write_text(IN_MANAGER_MEMBERSHIP_YAML) - - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - str(role_path), - str(user_path), - str(membership_path), - ) - assert proc.returncode == 0, _combined(proc) - assert "PUT Role [IN Manager]" in _combined(proc) - assert f"PUT User [{IN_MANAGER_USER}]" in _combined(proc) - - dump = _users_in_roles(tenant_manager, scratch_tenant.company_id, IN_MANAGER_USER) - assert _has_role(dump, "IN Manager"), dump - - -def test_republish_apply_assigns_new_user_membership( - acu: RunAcu, - tenant_manager: TenantManager, - scratch_tenant: ScratchTenant, -) -> None: - """T228: republish + apply 90-roles, 91-users, 92-role-users assigns soadmin.""" - proc = acu("--tenant", scratch_tenant.login, "bootstrap") - assert proc.returncode == 0, _combined(proc) - assert "AcuBootstrap" in _combined(proc) - - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - "config/master/90-roles.yaml", - "config/master/91-users.yaml", - "config/master/92-role-users.yaml", - ) - assert proc.returncode == 0, _combined(proc) - assert "PUT User [soadmin]" in _combined(proc) - assert "PUT Role [SO Admin]" in _combined(proc) - - dump = _users_in_roles(tenant_manager, scratch_tenant.company_id, "soadmin") - assert _has_role(dump, "SO Admin"), dump - - -def test_diff_membership_clean_after_assignuser( - acu: RunAcu, - tenant_manager: TenantManager, - scratch_tenant: ScratchTenant, -) -> None: - """T249/V57/B37: after 92-role-users AssignUser, acu diff exit 0.""" - proc = acu( - "--tenant", - scratch_tenant.login, - "apply", - "config/master/90-roles.yaml", - "config/master/91-users.yaml", - "config/master/92-role-users.yaml", - ) - assert proc.returncode == 0, _combined(proc) - - dump = _users_in_roles(tenant_manager, scratch_tenant.company_id, "soadmin") - assert _has_role(dump, "SO Admin"), dump - - proc = acu( - "--tenant", - scratch_tenant.login, - "diff", - "config/master/90-roles.yaml", - "config/master/91-users.yaml", - "config/master/92-role-users.yaml", - ) - assert proc.returncode == 0, _combined(proc) - out = _combined(proc) - assert "Users[" not in out - assert ".Roles[" not in out diff --git a/tests/test_docs.py b/tests/test_docs.py index 454296c..fa2dcf8 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -19,6 +19,19 @@ def _unreleased(changelog: str) -> str: return rest if nxt < 0 else rest[:nxt] +def test_e2e_pipeline_files_only() -> None: + """T256: live e2e is three pipeline files; per-bug probes fold onto them.""" + names = sorted(p.name for p in (REPO / "tests" / "e2e").glob("test_*.py")) + assert names == [ + "test_extract_roundtrip.py", + "test_provision_lifecycle.py", + "test_scenario_lifecycle.py", + ] + readme = (REPO / "README.md").read_text() + assert "three pipeline files" in readme + assert "test_extract_roundtrip" in readme + + def test_docs_user_role_membership_persist() -> None: """T229/V12/V19/V53: docs name AssignUser persist; drop T189 limit + Selected.""" demo = (REPO / "docs" / "demo-seed.md").read_text() From 003d1a0b6e3c04b620fe637c365c84aa75128acf Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 20:14:07 -0400 Subject: [PATCH 11/25] =?UTF-8?q?amend=20=C2=A7C.(+)=20and=20=C2=A7V.2(+)?= =?UTF-8?q?=20and=20=C2=A7T.257(+):=20CLI=20test=20seed=20at=20repo=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This CLI repo now owns the ACUCLI test seed under repo-root config/ and scenario/. Product GitOps stays in the sibling data repo. Soak runs from this checkout with --tenant ACUCLI. Packaged templates still feed gmake e2e. Dropped V2 clause "both live in data repos, not here". --- SPEC.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5b26172..2648896 100644 --- a/SPEC.md +++ b/SPEC.md @@ -6,7 +6,8 @@ Configure Acumatica ERP purely from source — no UI, no Configuration Wizard. I ## §C CONSTRAINTS -- Repo = `acu` CLI only. Data (`baseline/`, `.env.gpg`) lives in separate data repos (sibling `acumatica-devops`); infra = sibling `acumatica-infra`; blog = sibling `acumatica-blog`. C# customization projects out of scope (exclusion: bootstrap package ships one C# CustomizationPlugin package — §T.11; endpoint item returns when §T.12 lands). +- Repo = `acu` CLI + CLI test seed (`config/` + `scenario/` @ repo root). Product data (`.env.gpg`, QMS GitOps) lives in sibling data repos; infra = sibling `acumatica-infra`; blog = sibling `acumatica-blog`. C# customization projects out of scope (exclusion: bootstrap package ships one C# CustomizationPlugin package — §T.11; endpoint item returns when §T.12 lands). +- CLI test seed = repo-root `config/` (SEED_DIRS + `views/`; never `qms/`) + `scenario/`; copy from sibling GitOps (`~/github/acu-gitops-qms`) prune `qms/`, live `.env`, `state/`, customization, Makefile, schemas; soak from this checkout: `acu --tenant ACUCLI apply|diff|run|state` (`.env` already @ root; flag wins); never tenant `CNBN`; never `cd` sibling for CLI soak; issue-driven seed patches edit this tree; packaged `config init` templates stay demo scaffold (`gmake e2e` still 3 pipeline files on templates); `config init` never overwrite committed test seed - Python ≥ 3.12; click, httpx, pydantic, pydantic-settings, rich, pyyaml; uv build; module `acumatica_cli`; entry `acu`. - Default suite fully offline: SSH = monkeypatched `subprocess.run`, REST = `httpx.MockTransport`; no live instance needed; `make check` gate offline-only. Opt-in live tier: pytest marker `e2e`, deselected via addopts, runs only via `make e2e`; e2e self-contained — single-org synthetic company scaffolded from packaged `config init` templates into tmp dirs, no repo-root data symlinks, no dataset tenants (`SalesDemo|T100|U100` stay CLI surface, never test fixtures); live e2e = 3 pipeline files (`test_provision_lifecycle` apply/diff, `test_scenario_lifecycle` run/state, `test_extract_roundtrip` two-tenant); per-bug probes fold onto those tenants; new e2e file only when proof needs different tenant shape; multi-org out of demo scope (paid engagement). - Every cmd talks to live instance unless offline path: `--dry-run`, `bootstrap --export`, `survey inventory`/`survey reconcile` (artifact-only; no REST/SSH/password). Final verification live vs acu-dev1 (`acu-dev1.vm.internal`; needs tailnet + GPG key): `cd ~/github/acumatica-devops && make decrypt && make diff` or `make e2e` here (self-contained per the e2e constraint; `.env`/`.env.gpg` real files @ repo root). @@ -53,7 +54,7 @@ Configure Acumatica ERP purely from source — no UI, no Configuration Wizard. I ## §V INVARIANTS V1: two-plane split — control plane = SSH (`tenant.py`, tenant CRUD only); data plane = REST (`client.py`); never mixed -V2: two source kinds never mixed — seed YAML = what, `.env` = secrets + where (`ACU_BASE_URL`) + pin (`ACU_API_VERSION`); never `matrix.yaml`; both live in data repos, not here; never hardcode company surface in plugin source; bootstrap source closure → `.spec/check-extras.md` §V.2 +V2: two source kinds never mixed — seed YAML = what, `.env` = secrets + where (`ACU_BASE_URL`) + pin (`ACU_API_VERSION`); never `matrix.yaml`; CLI test seed lives here (`config/` SEED_DIRS + `scenario/` + `config/views/`); product GitOps stays sibling data repos; never hardcode company surface in plugin source; bootstrap source closure → `.spec/check-extras.md` §V.2 V3: discovery — walk up from cwd to first dir containing `.env`; found → loads; absent → resolution on globals + process environment; hard error only when required value unresolved post-merge, error names missing key(s); resolution matrix → `.spec/check-extras.md` §V.3 V4: idempotence — `PUT` keyed upsert primitive; `diff` source-authoritative (extra live not flagged); drift → exit 2; resume/skip ! desired-state never marker; `$filter` key types by YAML scalar; full audit recipe → `.spec/check-extras.md` §V.4 V5: tenant-map — tenant create ! `AcumaticaERP` app-pool recycle after `ac.exe`; always send explicit valid `tenant`; data-plane session ! post-login landed-tenant verify, refuse on mismatch; apply that creates Company mid-session ! re-login same client before later PUTs resolving branch selectors (virgin-tenant TransitBranchID class; warm Company re-PUT re-login-safe) (closes §B.24); symptom recipe → `.spec/check-extras.md` §V.5 @@ -239,6 +240,9 @@ T253|x|offline tests: contract field+mapping; apply body includes NewSymbol; ext T254|x|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 T255|x|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 T256|x|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/numbering/segmented-key/roles onto those tenants; shared conftest `bracket_tenant` + `joined_output`; drop per-bug files|V4,V13 +T257|.|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `scenario/` → repo root (exclude `qms/`, live `.env`, `state/`, customization, Makefile, schemas)|V2,I.data +T258|.|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 +T259|.|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 ## §B BUGS From e30019a069ae8abb99aff4cfc823a2c575140e22 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 20:17:48 -0400 Subject: [PATCH 12/25] T257: copy sibling GitOps config/{bootstrap,baseline,setup,master,views} + scenario/ to repo root (exclude qms/, live .env, state/, customization, Makefile, schemas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy the CLI test seed from sibling GitOps into this checkout. Prune qms/, live .env, state/, customization, Makefile, and schemas. §V.2 --- SPEC.md | 2 +- config/baseline/10-subaccounts.yaml | 7 + config/baseline/20-accounts.yaml | 239 ++++++++++++++++ config/baseline/40-ledger.yaml | 6 + config/baseline/50-gl-preferences.yaml | 10 + config/baseline/60-ledger-company.yaml | 6 + config/baseline/90-uoms.yaml | 21 ++ config/baseline/91-company-packaging.yaml | 8 + config/bootstrap/company.yaml | 9 + config/bootstrap/credit-terms.yaml | 25 ++ config/bootstrap/features.yaml | 11 + config/bootstrap/segmented-key.yaml | 10 + config/master/05-numbering-sequences.yaml | 76 ++++++ config/master/10-reason-codes.yaml | 34 +++ config/master/20-in-preferences.yaml | 23 ++ config/master/30-availability-rules.yaml | 6 + config/master/40-posting-classes.yaml | 32 +++ config/master/50-warehouse.yaml | 8 + config/master/51-warehouse-locations.yaml | 39 +++ config/master/52-warehouse-defaults.yaml | 8 + config/master/53-tax-categories.yaml | 5 + config/master/54-item-classes.yaml | 23 ++ config/master/55-lot-serial-classes.yaml | 19 ++ config/master/56-so-preferences.yaml | 10 + config/master/57-po-preferences.yaml | 10 + config/master/58-order-types.yaml | 8 + config/master/60-ar-preferences.yaml | 12 + config/master/61-ap-preferences.yaml | 12 + config/master/62-ca-preferences.yaml | 11 + config/master/63-cash-account.yaml | 10 + config/master/64-payment-methods.yaml | 17 ++ config/master/65-statement-cycles.yaml | 11 + config/master/70-vendor-classes.yaml | 15 ++ config/master/71-customer-classes.yaml | 14 + config/master/75-vendors.yaml | 39 +++ config/master/76-customers.yaml | 21 ++ config/master/80-stock-items-parts.yaml | 39 +++ config/master/82-stock-items-kits.yaml | 19 ++ config/master/90-roles.yaml | 216 +++++++++++++++ config/master/91-users.yaml | 54 ++++ config/master/92-role-users.yaml | 30 +++ config/setup/10-financial-year.yaml | 8 + config/setup/20-master-calendar.yaml | 11 + config/setup/30-open-periods.yaml | 11 + config/views/10-trial-balance.yaml | 15 ++ scenario/10-seed-capital.yaml | 41 +++ scenario/20-buy.yaml | 314 ++++++++++++++++++++++ 47 files changed, 1574 insertions(+), 1 deletion(-) create mode 100644 config/baseline/10-subaccounts.yaml create mode 100644 config/baseline/20-accounts.yaml create mode 100644 config/baseline/40-ledger.yaml create mode 100644 config/baseline/50-gl-preferences.yaml create mode 100644 config/baseline/60-ledger-company.yaml create mode 100644 config/baseline/90-uoms.yaml create mode 100644 config/baseline/91-company-packaging.yaml create mode 100644 config/bootstrap/company.yaml create mode 100644 config/bootstrap/credit-terms.yaml create mode 100644 config/bootstrap/features.yaml create mode 100644 config/bootstrap/segmented-key.yaml create mode 100644 config/master/05-numbering-sequences.yaml create mode 100644 config/master/10-reason-codes.yaml create mode 100644 config/master/20-in-preferences.yaml create mode 100644 config/master/30-availability-rules.yaml create mode 100644 config/master/40-posting-classes.yaml create mode 100644 config/master/50-warehouse.yaml create mode 100644 config/master/51-warehouse-locations.yaml create mode 100644 config/master/52-warehouse-defaults.yaml create mode 100644 config/master/53-tax-categories.yaml create mode 100644 config/master/54-item-classes.yaml create mode 100644 config/master/55-lot-serial-classes.yaml create mode 100644 config/master/56-so-preferences.yaml create mode 100644 config/master/57-po-preferences.yaml create mode 100644 config/master/58-order-types.yaml create mode 100644 config/master/60-ar-preferences.yaml create mode 100644 config/master/61-ap-preferences.yaml create mode 100644 config/master/62-ca-preferences.yaml create mode 100644 config/master/63-cash-account.yaml create mode 100644 config/master/64-payment-methods.yaml create mode 100644 config/master/65-statement-cycles.yaml create mode 100644 config/master/70-vendor-classes.yaml create mode 100644 config/master/71-customer-classes.yaml create mode 100644 config/master/75-vendors.yaml create mode 100644 config/master/76-customers.yaml create mode 100644 config/master/80-stock-items-parts.yaml create mode 100644 config/master/82-stock-items-kits.yaml create mode 100644 config/master/90-roles.yaml create mode 100644 config/master/91-users.yaml create mode 100644 config/master/92-role-users.yaml create mode 100644 config/setup/10-financial-year.yaml create mode 100644 config/setup/20-master-calendar.yaml create mode 100644 config/setup/30-open-periods.yaml create mode 100644 config/views/10-trial-balance.yaml create mode 100644 scenario/10-seed-capital.yaml create mode 100644 scenario/20-buy.yaml diff --git a/SPEC.md b/SPEC.md index 2648896..3a12305 100644 --- a/SPEC.md +++ b/SPEC.md @@ -240,7 +240,7 @@ T253|x|offline tests: contract field+mapping; apply body includes NewSymbol; ext T254|x|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply existing BATCH; no 422|V4,V13,V58,B38,T252 T255|x|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 T256|x|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/numbering/segmented-key/roles onto those tenants; shared conftest `bracket_tenant` + `joined_output`; drop per-bug files|V4,V13 -T257|.|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `scenario/` → repo root (exclude `qms/`, live `.env`, `state/`, customization, Makefile, schemas)|V2,I.data +T257|x|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `scenario/` → repo root (exclude `qms/`, live `.env`, `state/`, customization, Makefile, schemas)|V2,I.data T258|.|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 T259|.|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 diff --git a/config/baseline/10-subaccounts.yaml b/config/baseline/10-subaccounts.yaml new file mode 100644 index 0000000..7cab1d8 --- /dev/null +++ b/config/baseline/10-subaccounts.yaml @@ -0,0 +1,7 @@ +entity: Subaccount +key: SubaccountCD +records: +- Active: true + Description: Default + Secured: false + SubaccountCD: '000000' diff --git a/config/baseline/20-accounts.yaml b/config/baseline/20-accounts.yaml new file mode 100644 index 0000000..1a19d5e --- /dev/null +++ b/config/baseline/20-accounts.yaml @@ -0,0 +1,239 @@ +entity: Account +key: AccountCD +records: +- AccountCD: '10100' + Active: true + CurrencyID: CAD + Description: Operating Chequing + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false +- AccountCD: '10200' + Active: true + Description: Cash In Transit + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false +- AccountCD: '11000' + Active: true + Description: Accounts Receivable + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false + custom: + AccountRecords: + ControlAccountModule: AR +- AccountCD: '12100' + Active: true + Description: Inventory - Raw Materials + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false + custom: + AccountRecords: + ControlAccountModule: IN +- AccountCD: '12200' + Active: true + Description: Inventory - Finished Goods + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false + custom: + AccountRecords: + ControlAccountModule: IN +# IN Setup WIP/transit (INProgressAcctID / INTransitAcctID) cannot be IN control accounts. +- AccountCD: '12300' + Active: true + Description: Work In Progress + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false +- AccountCD: '12400' + Active: true + Description: Inventory In Transit + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false +- AccountCD: '15000' + Active: true + Description: Prepaid Expenses + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false +- AccountCD: '16000' + Active: true + Description: Office Equipment + PostOption: Detail + RequireUnits: false + Secured: false + Type: Asset + UseDefaultSubaccount: false +- AccountCD: '20100' + Active: true + Description: Accounts Payable + PostOption: Summary + RequireUnits: false + Secured: false + Type: Liability + UseDefaultSubaccount: false + custom: + AccountRecords: + ControlAccountModule: AP +- AccountCD: '20200' + Active: true + Description: PO Accrual + PostOption: Detail + RequireUnits: false + Secured: false + Type: Liability + UseDefaultSubaccount: false + custom: + AccountRecords: + ControlAccountModule: PO +- AccountCD: '22000' + Active: true + Description: Accrued Expenses + PostOption: Summary + RequireUnits: false + Secured: false + Type: Liability + UseDefaultSubaccount: false +- AccountCD: '24000' + Active: true + Description: Sales Tax Payable + PostOption: Summary + RequireUnits: false + Secured: false + Type: Liability + UseDefaultSubaccount: false + custom: + AccountRecords: + ControlAccountModule: TX +- AccountCD: '30000' + Active: true + Description: Owner Capital + PostOption: Summary + RequireUnits: false + Secured: false + Type: Liability + UseDefaultSubaccount: false +- AccountCD: '32000' + Active: true + Description: Retained Earnings + PostOption: Summary + RequireUnits: false + Secured: false + Type: Liability + UseDefaultSubaccount: false +- AccountCD: '33000' + Active: true + Description: Net Income + PostOption: Summary + RequireUnits: false + Secured: false + Type: Liability + UseDefaultSubaccount: false +- AccountCD: '40000' + Active: true + Description: Sales - Finished Goods + PostOption: Summary + RequireUnits: false + Secured: false + Type: Income + UseDefaultSubaccount: false +- AccountCD: '40100' + Active: true + Description: Freight and CDMO Services + PostOption: Summary + RequireUnits: false + Secured: false + Type: Income + UseDefaultSubaccount: false +- AccountCD: '49000' + Active: true + Description: Sales Discounts + PostOption: Summary + RequireUnits: false + Secured: false + Type: Income + UseDefaultSubaccount: false +- AccountCD: '49100' + Active: true + Description: Purchase Discounts Taken + PostOption: Summary + RequireUnits: false + Secured: false + Type: Income + UseDefaultSubaccount: false +- AccountCD: '50000' + Active: true + Description: COGS - Finished Goods + PostOption: Summary + RequireUnits: false + Secured: false + Type: Expense + UseDefaultSubaccount: false +- AccountCD: '50100' + Active: true + Description: Purchase Price Variance + PostOption: Summary + RequireUnits: false + Secured: false + Type: Expense + UseDefaultSubaccount: false +- AccountCD: '50200' + Active: true + Description: Landed Cost Variance + PostOption: Summary + RequireUnits: false + Secured: false + Type: Expense + UseDefaultSubaccount: false +- AccountCD: '50300' + Active: true + Description: Inventory Adjustments + PostOption: Summary + RequireUnits: false + Secured: false + Type: Expense + UseDefaultSubaccount: false +- AccountCD: '60000' + Active: true + Description: Freight Expense + PostOption: Summary + RequireUnits: false + Secured: false + Type: Expense + UseDefaultSubaccount: false +- AccountCD: '61000' + Active: true + Description: Office Expense + PostOption: Summary + RequireUnits: false + Secured: false + Type: Expense + UseDefaultSubaccount: false +- AccountCD: '62000' + Active: true + Description: Bank Charges + PostOption: Summary + RequireUnits: false + Secured: false + Type: Expense + UseDefaultSubaccount: false diff --git a/config/baseline/40-ledger.yaml b/config/baseline/40-ledger.yaml new file mode 100644 index 0000000..0bb72fc --- /dev/null +++ b/config/baseline/40-ledger.yaml @@ -0,0 +1,6 @@ +entity: Ledger +key: LedgerID +records: +- LedgerID: ACTUAL + Description: Actual Ledger + Type: Actual diff --git a/config/baseline/50-gl-preferences.yaml b/config/baseline/50-gl-preferences.yaml new file mode 100644 index 0000000..2b64acc --- /dev/null +++ b/config/baseline/50-gl-preferences.yaml @@ -0,0 +1,10 @@ +entity: GLPreferences +key: RetEarnAccountID +endpoint: bootstrap +records: +- RetEarnAccountID: '32000' + YtdNetIncAccountID: '33000' + BatchNumberingID: BATCH + AutoPostOption: true + HoldEntry: true + RequireControlTotal: false diff --git a/config/baseline/60-ledger-company.yaml b/config/baseline/60-ledger-company.yaml new file mode 100644 index 0000000..86fdb56 --- /dev/null +++ b/config/baseline/60-ledger-company.yaml @@ -0,0 +1,6 @@ +entity: LedgerCompany +key: [LedgerCD, OrganizationID] +endpoint: bootstrap +records: +- LedgerCD: ACTUAL + OrganizationID: CNBN diff --git a/config/baseline/90-uoms.yaml b/config/baseline/90-uoms.yaml new file mode 100644 index 0000000..dc7d6e7 --- /dev/null +++ b/config/baseline/90-uoms.yaml @@ -0,0 +1,21 @@ +entity: UnitsOfMeasure +key: UnitID +records: +- UnitID: EA + Description: Each + L3Code: EA +- UnitID: BOX + Description: Box + L3Code: EA +- UnitID: PIECE + Description: Piece + L3Code: PCB +- UnitID: HOUR + Description: Hour + L3Code: HUR +- UnitID: KG + Description: Kg + L3Code: KGM +- UnitID: LITER + Description: Liter + L3Code: LTR diff --git a/config/baseline/91-company-packaging.yaml b/config/baseline/91-company-packaging.yaml new file mode 100644 index 0000000..d53055d --- /dev/null +++ b/config/baseline/91-company-packaging.yaml @@ -0,0 +1,8 @@ +entity: Company +key: AcctCD +endpoint: bootstrap +records: +- AcctCD: CNBN + DecPlQty: 3 + WeightUOM: KG + VolumeUOM: LITER diff --git a/config/bootstrap/company.yaml b/config/bootstrap/company.yaml new file mode 100644 index 0000000..2d698b9 --- /dev/null +++ b/config/bootstrap/company.yaml @@ -0,0 +1,9 @@ +entity: Company +key: AcctCD +endpoint: bootstrap +records: +- AcctCD: CNBN + AcctName: CanNordic BioNutra Inc. + OrganizationType: Without Branches + BaseCuryID: CAD + CountryID: CA diff --git a/config/bootstrap/credit-terms.yaml b/config/bootstrap/credit-terms.yaml new file mode 100644 index 0000000..9543c94 --- /dev/null +++ b/config/bootstrap/credit-terms.yaml @@ -0,0 +1,25 @@ +entity: CreditTerms +key: TermsID +endpoint: bootstrap +records: +- TermsID: NET30 + Descr: Net 30 Days + VisibleTo: All + DueType: Fixed Number of Days + DayDue00: 30 + DiscType: Fixed Number of Days + DiscPercent: 0 +- TermsID: NET45 + Descr: Net 45 Days + VisibleTo: All + DueType: Fixed Number of Days + DayDue00: 45 + DiscType: Fixed Number of Days + DiscPercent: 0 +- TermsID: NET60 + Descr: Net 60 Days + VisibleTo: All + DueType: Fixed Number of Days + DayDue00: 60 + DiscType: Fixed Number of Days + DiscPercent: 0 diff --git a/config/bootstrap/features.yaml b/config/bootstrap/features.yaml new file mode 100644 index 0000000..6a8e22a --- /dev/null +++ b/config/bootstrap/features.yaml @@ -0,0 +1,11 @@ +- FinancialModule +- FinancialStandard +- DistributionModule +- Inventory +- LotSerialTracking +- Branch +- MultiCompany +- SubAccount +- KitAssemblies +- Warehouse +- WarehouseLocation diff --git a/config/bootstrap/segmented-key.yaml b/config/bootstrap/segmented-key.yaml new file mode 100644 index 0000000..77d60dc --- /dev/null +++ b/config/bootstrap/segmented-key.yaml @@ -0,0 +1,10 @@ +entity: SegmentedKey +key: DimensionID +endpoint: bootstrap +records: +- DimensionID: BIZACCT + SegmentID: 1 + Length: 30 +- DimensionID: INVENTORY + SegmentID: 1 + Length: 30 diff --git a/config/master/05-numbering-sequences.yaml b/config/master/05-numbering-sequences.yaml new file mode 100644 index 0000000..33ee4cc --- /dev/null +++ b/config/master/05-numbering-sequences.yaml @@ -0,0 +1,76 @@ +entity: NumberingSequence +key: NumberingID +endpoint: bootstrap +records: +- NumberingID: APBILL + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: APPAYMENT + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: ARINVOICE + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: ARPAYMENT + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: BATCH + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: INADJUST + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: INISSUE + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: INRECEIPT + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: POORDER + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: PORECEIPT + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: SOORDER + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' +- NumberingID: SOSHIPMENT + StartNbr: '000000' + EndNbr: '999999' + WarnNbr: '999990' + NbrStep: 1 + StartDate: '1900-01-01' diff --git a/config/master/10-reason-codes.yaml b/config/master/10-reason-codes.yaml new file mode 100644 index 0000000..e815939 --- /dev/null +++ b/config/master/10-reason-codes.yaml @@ -0,0 +1,34 @@ +entity: ReasonCode +key: ReasonCodeID +endpoint: bootstrap +records: +- ReasonCodeID: INISSUE + Descr: Inventory issue + Usage: Issue + AccountID: '50300' + SubID: '000000' +- ReasonCodeID: INRECEIPT + Descr: Inventory receipt + Usage: Receipt + AccountID: '50300' + SubID: '000000' +- ReasonCodeID: INADJUST + Descr: Inventory adjustment + Usage: Adjustment + AccountID: '50300' + SubID: '000000' +- ReasonCodeID: INPI + Descr: Physical inventory variance + Usage: Adjustment + AccountID: '50300' + SubID: '000000' +- ReasonCodeID: INASSEMBLY + Descr: Kit assembly + Usage: Assembly/Disassembly + AccountID: '50300' + SubID: '000000' +- ReasonCodeID: PORETURN + Descr: Vendor return + Usage: Vendor Return + AccountID: '50300' + SubID: '000000' diff --git a/config/master/20-in-preferences.yaml b/config/master/20-in-preferences.yaml new file mode 100644 index 0000000..7089e3b --- /dev/null +++ b/config/master/20-in-preferences.yaml @@ -0,0 +1,23 @@ +entity: INPreferences +key: HoldEntry +endpoint: bootstrap +records: +- HoldEntry: false + INProgressAcctID: '12300' + INProgressSubID: '000000' + INTransitAcctID: '12400' + INTransitSubID: '000000' + TransitBranchID: CNBN + UpdateGL: true + IssuesReasonCode: INISSUE + ReceiptReasonCode: INRECEIPT + AdjustmentReasonCode: INADJUST + PIReasonCode: INPI + BatchNumberingID: BATCH + ReceiptNumberingID: INRECEIPT + IssueNumberingID: INISSUE + AdjustmentNumberingID: INADJUST + AutoPost: true + SummPost: false + NegQty: false + RequireControlTotal: true diff --git a/config/master/30-availability-rules.yaml b/config/master/30-availability-rules.yaml new file mode 100644 index 0000000..3dfae82 --- /dev/null +++ b/config/master/30-availability-rules.yaml @@ -0,0 +1,6 @@ +entity: AvailabilityCalculationRule +key: AvailabilitySchemeID +endpoint: bootstrap +records: +- AvailabilitySchemeID: DEFAULT + Description: Default availability calculation rule diff --git a/config/master/40-posting-classes.yaml b/config/master/40-posting-classes.yaml new file mode 100644 index 0000000..402461d --- /dev/null +++ b/config/master/40-posting-classes.yaml @@ -0,0 +1,32 @@ +entity: PostingClass +key: PostClassID +endpoint: bootstrap +records: +- PostClassID: PARTS + Descr: Purchased raw materials + InvtAcctID: '12100' + InvtSubID: '000000' + SalesAcctID: '40000' + SalesSubID: '000000' + COGSAcctID: '50000' + COGSSubID: '000000' + POAccrualAcctID: '20200' + POAccrualSubID: '000000' + PPVAcctID: '50100' + PPVSubID: '000000' + LCVarianceAcctID: '50200' + LCVarianceSubID: '000000' +- PostClassID: KITS + Descr: Manufactured nutraceuticals + InvtAcctID: '12200' + InvtSubID: '000000' + SalesAcctID: '40000' + SalesSubID: '000000' + COGSAcctID: '50000' + COGSSubID: '000000' + POAccrualAcctID: '20200' + POAccrualSubID: '000000' + PPVAcctID: '50100' + PPVSubID: '000000' + LCVarianceAcctID: '50200' + LCVarianceSubID: '000000' diff --git a/config/master/50-warehouse.yaml b/config/master/50-warehouse.yaml new file mode 100644 index 0000000..93fc2c9 --- /dev/null +++ b/config/master/50-warehouse.yaml @@ -0,0 +1,8 @@ +entity: Warehouse +key: SiteCD +endpoint: bootstrap +records: +- SiteCD: WH-MISS-01 + Descr: Mississauga plant and QC + Active: true + CountryID: CA diff --git a/config/master/51-warehouse-locations.yaml b/config/master/51-warehouse-locations.yaml new file mode 100644 index 0000000..23455fd --- /dev/null +++ b/config/master/51-warehouse-locations.yaml @@ -0,0 +1,39 @@ +entity: Warehouse +key: WarehouseID +endpoint: default +detail_keys: + Locations: LocationID +records: +- WarehouseID: WH-MISS-01 + ReceivingLocationID: QCHOLD + ShippingLocationID: READY + RMALocationID: QCHOLD + Locations: + - LocationID: MAIN + Description: Main storage + Active: true + ReceiptsAllowed: true + SalesAllowed: true + TransfersAllowed: true + AssemblyAllowed: true + - LocationID: QCHOLD + Description: QC Hold + Active: true + ReceiptsAllowed: true + SalesAllowed: false + TransfersAllowed: true + AssemblyAllowed: false + - LocationID: READY + Description: Ready to use + Active: true + ReceiptsAllowed: false + SalesAllowed: true + TransfersAllowed: true + AssemblyAllowed: true + - LocationID: QUARANTINE + Description: Quarantine + Active: true + ReceiptsAllowed: false + SalesAllowed: false + TransfersAllowed: true + AssemblyAllowed: false diff --git a/config/master/52-warehouse-defaults.yaml b/config/master/52-warehouse-defaults.yaml new file mode 100644 index 0000000..aec3dc0 --- /dev/null +++ b/config/master/52-warehouse-defaults.yaml @@ -0,0 +1,8 @@ +entity: Warehouse +key: WarehouseID +endpoint: default +records: +- WarehouseID: WH-MISS-01 + ReceivingLocationID: QCHOLD + ShippingLocationID: READY + RMALocationID: QCHOLD diff --git a/config/master/53-tax-categories.yaml b/config/master/53-tax-categories.yaml new file mode 100644 index 0000000..b5b8cdc --- /dev/null +++ b/config/master/53-tax-categories.yaml @@ -0,0 +1,5 @@ +entity: TaxCategory +key: TaxCategoryID +records: +- TaxCategoryID: EXEMPT + Description: Tax exempt diff --git a/config/master/54-item-classes.yaml b/config/master/54-item-classes.yaml new file mode 100644 index 0000000..f5d1014 --- /dev/null +++ b/config/master/54-item-classes.yaml @@ -0,0 +1,23 @@ +entity: ItemClass +key: ClassID +records: +- ClassID: PARTS + Description: Purchased raw materials + StockItem: true + ItemType: Finished Good + ValuationMethod: Average + BaseUOM: KG + AvailabilityCalculationRule: DEFAULT + PostingClass: PARTS + TaxCategoryID: EXEMPT + DefaultWarehouseID: WH-MISS-01 +- ClassID: KITS + Description: Manufactured nutraceuticals + StockItem: true + ItemType: Finished Good + ValuationMethod: Average + BaseUOM: EA + AvailabilityCalculationRule: DEFAULT + PostingClass: KITS + TaxCategoryID: EXEMPT + DefaultWarehouseID: WH-MISS-01 diff --git a/config/master/55-lot-serial-classes.yaml b/config/master/55-lot-serial-classes.yaml new file mode 100644 index 0000000..b305794 --- /dev/null +++ b/config/master/55-lot-serial-classes.yaml @@ -0,0 +1,19 @@ +entity: LotSerialClass +key: ClassID +endpoint: default +detail_keys: + Segments: SegmentNbr +records: +- ClassID: LOTRAW + Description: Incoming raw-material lots + TrackingMethod: Track Lot Numbers + AssignmentMethod: When Received + IssueMethod: User-Enterable + TrackExpirationDate: true + Segments: + - SegmentNbr: 1 + Type: Auto-Incremental Value + Value: '000001' +- ClassID: NOTRACK + Description: Finished goods not lot-tracked + TrackingMethod: Not Tracked diff --git a/config/master/56-so-preferences.yaml b/config/master/56-so-preferences.yaml new file mode 100644 index 0000000..dfe7df3 --- /dev/null +++ b/config/master/56-so-preferences.yaml @@ -0,0 +1,10 @@ +entity: SOPreferences +key: DefaultOrderType +endpoint: bootstrap +records: +- DefaultOrderType: SO + HoldShipments: false + RequireShipmentTotal: false + AutoReleaseIN: true + ShipmentNumberingID: SOSHIPMENT + CreditCheckError: true diff --git a/config/master/57-po-preferences.yaml b/config/master/57-po-preferences.yaml new file mode 100644 index 0000000..7501df8 --- /dev/null +++ b/config/master/57-po-preferences.yaml @@ -0,0 +1,10 @@ +entity: POPreferences +key: HoldReceipts +endpoint: bootstrap +records: +- HoldReceipts: false + RCReturnReasonCodeID: PORETURN + AutoReleaseIN: true + RegularPONumberingID: POORDER + ReceiptNumberingID: PORECEIPT + AutoReleaseAP: false diff --git a/config/master/58-order-types.yaml b/config/master/58-order-types.yaml new file mode 100644 index 0000000..f929168 --- /dev/null +++ b/config/master/58-order-types.yaml @@ -0,0 +1,8 @@ +entity: OrderType +key: OrderType +endpoint: bootstrap +records: +- OrderType: SO + Active: true + FreightAcctID: '40100' + FreightSubID: '000000' diff --git a/config/master/60-ar-preferences.yaml b/config/master/60-ar-preferences.yaml new file mode 100644 index 0000000..8739297 --- /dev/null +++ b/config/master/60-ar-preferences.yaml @@ -0,0 +1,12 @@ +entity: ARPreferences +key: HoldEntry +endpoint: bootstrap +records: +- HoldEntry: false + BatchNumberingID: BATCH + InvoiceNumberingID: ARINVOICE + PaymentNumberingID: ARPAYMENT + AutoPost: true + RequireControlTotal: true + RequireExtRef: true + CreditCheckError: true diff --git a/config/master/61-ap-preferences.yaml b/config/master/61-ap-preferences.yaml new file mode 100644 index 0000000..9670253 --- /dev/null +++ b/config/master/61-ap-preferences.yaml @@ -0,0 +1,12 @@ +entity: APPreferences +key: HoldEntry +endpoint: bootstrap +records: +- HoldEntry: false + BatchNumberingID: BATCH + InvoiceNumberingID: APBILL + CheckNumberingID: APPAYMENT + AutoPost: true + RequireControlTotal: true + RequireVendorRef: true + RequireApprovePayments: true diff --git a/config/master/62-ca-preferences.yaml b/config/master/62-ca-preferences.yaml new file mode 100644 index 0000000..d8118e6 --- /dev/null +++ b/config/master/62-ca-preferences.yaml @@ -0,0 +1,11 @@ +entity: CAPreferences +key: TransitAcctId +endpoint: bootstrap +records: +- TransitAcctId: '10200' + TransitSubID: '000000' + HoldEntry: false + BatchNumberingID: BATCH + AutoPostOption: true + ReleaseAP: true + ReleaseAR: true diff --git a/config/master/63-cash-account.yaml b/config/master/63-cash-account.yaml new file mode 100644 index 0000000..c68ed51 --- /dev/null +++ b/config/master/63-cash-account.yaml @@ -0,0 +1,10 @@ +entity: CashAccount +key: CashAccountCD +endpoint: bootstrap +records: +- CashAccountCD: '10100' + Descr: Operating Chequing + AccountID: '10100' + SubID: '000000' + BranchID: CNBN + Active: true diff --git a/config/master/64-payment-methods.yaml b/config/master/64-payment-methods.yaml new file mode 100644 index 0000000..d090600 --- /dev/null +++ b/config/master/64-payment-methods.yaml @@ -0,0 +1,17 @@ +entity: PaymentMethod +key: PaymentMethodID +detail_keys: + AllowedCashAccounts: CashAccount +records: +- PaymentMethodID: WIRE + Description: Wire transfer + Active: true + MeansOfPayment: Cash/Check + UseInAP: true + UseInAR: true + AllowedCashAccounts: + - CashAccount: '10100' + UseInAP: true + UseInAR: true + APDefault: true + ARDefault: true diff --git a/config/master/65-statement-cycles.yaml b/config/master/65-statement-cycles.yaml new file mode 100644 index 0000000..840e8ae --- /dev/null +++ b/config/master/65-statement-cycles.yaml @@ -0,0 +1,11 @@ +entity: StatementCycle +key: StatementCycleId +endpoint: bootstrap +records: +- StatementCycleId: MONTHLY + Descr: Monthly statements + PrepareOn: Fixed Day of Month + Day00: 1 + AgeDays00: 30 + AgeDays01: 60 + AgeDays02: 90 diff --git a/config/master/70-vendor-classes.yaml b/config/master/70-vendor-classes.yaml new file mode 100644 index 0000000..7b22b76 --- /dev/null +++ b/config/master/70-vendor-classes.yaml @@ -0,0 +1,15 @@ +entity: VendorClass +key: VendorClassID +endpoint: bootstrap +records: +- VendorClassID: SUPPLIER + Descr: Ingredient suppliers + TermsID: NET30 + APAcctID: '20100' + APSubID: '000000' + DiscTakenAcctID: '49100' + DiscTakenSubID: '000000' + ExpenseAcctID: '50000' + ExpenseSubID: '000000' + POAccrualAcctID: '20200' + POAccrualSubID: '000000' diff --git a/config/master/71-customer-classes.yaml b/config/master/71-customer-classes.yaml new file mode 100644 index 0000000..77b8edc --- /dev/null +++ b/config/master/71-customer-classes.yaml @@ -0,0 +1,14 @@ +entity: CustomerClass +key: ClassID +records: +- ClassID: B2B + Description: Brand and CDMO customers + Terms: NET30 + CurrencyID: CAD + ARAccount: '11000' + ARSubaccount: '000000' + SalesAccount: '40000' + SalesSubaccount: '000000' + CashDiscountAccount: '49000' + CashDiscountSubaccount: '000000' + StatementCycleID: MONTHLY diff --git a/config/master/75-vendors.yaml b/config/master/75-vendors.yaml new file mode 100644 index 0000000..4b2e2a4 --- /dev/null +++ b/config/master/75-vendors.yaml @@ -0,0 +1,39 @@ +entity: Vendor +key: VendorID +records: +- VendorID: VEND-NORTH-BIO + VendorName: Northern BioNutra Imports Corp. + VendorClass: SUPPLIER + Terms: NET30 + PrintOrders: false + SendOrdersbyEmail: false + MainContact: + Address: + Country: CA +- VendorID: VEND-ALPINE-EXT + VendorName: Alpine Botanical Extracts GmbH + VendorClass: SUPPLIER + Terms: NET60 + PrintOrders: false + SendOrdersbyEmail: false + MainContact: + Address: + Country: DE +- VendorID: VEND-NIPPON-PHARMA + VendorName: Nippon Pure Bioactives Inc. + VendorClass: SUPPLIER + Terms: NET45 + PrintOrders: false + SendOrdersbyEmail: false + MainContact: + Address: + Country: JP +- VendorID: VEND-NORDIC-MAR + VendorName: Nordic Marine Extracts AS + VendorClass: SUPPLIER + Terms: NET30 + PrintOrders: false + SendOrdersbyEmail: false + MainContact: + Address: + Country: "NO" # YAML 1.1: unquoted NO is boolean false diff --git a/config/master/76-customers.yaml b/config/master/76-customers.yaml new file mode 100644 index 0000000..8c150b5 --- /dev/null +++ b/config/master/76-customers.yaml @@ -0,0 +1,21 @@ +entity: Customer +key: CustomerID +records: +- CustomerID: VITALPLUS + CustomerName: VitalPlus Naturals Inc. + CustomerClass: B2B + MainContact: + Address: + Country: CA +- CustomerID: HEARTLAB + CustomerName: HeartLab Wellness Brands + CustomerClass: B2B + MainContact: + Address: + Country: CA +- CustomerID: WELLCAN + CustomerName: WellCan Consumer Health + CustomerClass: B2B + MainContact: + Address: + Country: CA diff --git a/config/master/80-stock-items-parts.yaml b/config/master/80-stock-items-parts.yaml new file mode 100644 index 0000000..c1c1dfd --- /dev/null +++ b/config/master/80-stock-items-parts.yaml @@ -0,0 +1,39 @@ +entity: StockItem +key: InventoryID +records: +- InventoryID: RAW-ECH-EXT4 + Description: Organic Echinacea extract 4% polyphenols + ItemClass: PARTS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: LOTRAW +- InventoryID: RAW-ELD-EXT10 + Description: Organic elderberry extract 10% anthocyanins + ItemClass: PARTS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: LOTRAW +- InventoryID: RAW-ASH-EXT5 + Description: Organic ashwagandha extract 5% withanolides + ItemClass: PARTS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: LOTRAW +- InventoryID: RAW-COQ10-99 + Description: Coenzyme Q10 USP 99% + ItemClass: PARTS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: LOTRAW +- InventoryID: RAW-OMEGA3-70 + Description: Marine omega-3 TG oil 70% EPA/DHA + ItemClass: PARTS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: LOTRAW +- InventoryID: RAW-ASTA-10 + Description: Natural astaxanthin oleoresin 10% + ItemClass: PARTS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: LOTRAW diff --git a/config/master/82-stock-items-kits.yaml b/config/master/82-stock-items-kits.yaml new file mode 100644 index 0000000..79fdc69 --- /dev/null +++ b/config/master/82-stock-items-kits.yaml @@ -0,0 +1,19 @@ +entity: StockItem +key: InventoryID +records: +- InventoryID: FG-IMMUNE-DEFENSE-60C + Description: ImmunoShield Botanical Active Plus 60 veg caps + ItemClass: KITS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: NOTRACK + IsAKit: true + DefaultPrice: 24.00 +- InventoryID: FG-CARDIO-OMEGA-COQ10-60SG + Description: CardioPure Ultra Omega-3 + CoQ10 60 softgels + ItemClass: KITS + ItemStatus: Active + DefaultWarehouseID: WH-MISS-01 + LotSerialClass: NOTRACK + IsAKit: true + DefaultPrice: 36.00 diff --git a/config/master/90-roles.yaml b/config/master/90-roles.yaml new file mode 100644 index 0000000..f06646c --- /dev/null +++ b/config/master/90-roles.yaml @@ -0,0 +1,216 @@ +entity: Role +key: Rolename +endpoint: bootstrap +records: +- Rolename: AI Assistant User + Descr: A user that can access the AI Assistant Service +- Rolename: AP Admin + Descr: Access to AP functions and settings +- Rolename: AP Clerk + Descr: Access to AP functions +- Rolename: AP Viewer + Descr: Read-only access to AP functions +- Rolename: AR Admin + Descr: Access to AR functions and settings +- Rolename: AR Clerk + Descr: Access to AR functions +- Rolename: AR Viewer + Descr: Read-only access to AR functions +- Rolename: AcumaticaSupport + Descr: Role for Acumatica Support. Access similar to Administrator except for User Management, Security Settings and Access Management. +- Rolename: Administrator + Descr: System Administrator +- Rolename: Anonymous + Descr: Anonymous +- Rolename: Archivist + Descr: Archive Manager +- Rolename: BI + Descr: Access to Business Intelligence Views +- Rolename: BusinessDateOverride + Descr: Business Date Override +- Rolename: CA Admin + Descr: Access to CA functions and settings +- Rolename: CA Clerk + Descr: Access to CA functions +- Rolename: CA Viewer + Descr: Read-only access to CA functions +- Rolename: CM Admin + Descr: Access to CM functions and settings +- Rolename: CM Viewer + Descr: Read-only access to CM functions +- Rolename: CR Marketing Manager + Descr: Access to Marketing functions and settings +- Rolename: CR Sales & Marketing Admin + Descr: Full access to Sales and Marketing functions and settings +- Rolename: CR Sales Representative + Descr: Access to Sales functions and settings +- Rolename: CR Support Admin + Descr: Full access to Support functionality and settings +- Rolename: CR Support Representative + Descr: Access to Support functionality and settings +- Rolename: CR Viewer + Descr: Read-only access to marketing, sales and support forms +- Rolename: CS Admin + Descr: Access to system functions and configuration items +- Rolename: Customer Data Manager + Descr: Full access to data entry forms related to customers +- Rolename: Customer Portal Case Manager + Descr: Access to support cases +- Rolename: Customer Portal Company Manager + Descr: Access to manage company-level settings through the Company Profile screen, including company details and payment methods +- Rolename: Customer Portal Contact Manager + Descr: Access to company contacts and their portal access through the Company Profile screen, including adding, updating, deactivating contacts and managing portal roles +- Rolename: Customer Portal Financial Manager + Descr: Access to financial information, including invoices, balances, statements, and payments +- Rolename: Customer Portal Manager + Descr: Full access for managing orders, cases, and financial documents +- Rolename: Customer Portal Order Manager + Descr: Access to the cart, catalog, and orders +- Rolename: Customizer + Descr: Customizer +- Rolename: DR Admin + Descr: Access to DR functions and settings +- Rolename: DR Viewer + Descr: Read-only access to DR functions +- Rolename: DashboardDesigner + Descr: Dashboard Designer +- Rolename: Data Privacy Controller + Descr: Data Privacy Controller +- Rolename: ESign + Descr: Access to eSign Central and eSign Sync +- Rolename: FA Admin + Descr: Access to FA functions and settings +- Rolename: FA Clerk + Descr: Access to FA functions +- Rolename: FA Viewer + Descr: Read-only access to FA functions +- Rolename: Field-Level Audit + Descr: Role that can access Field-Level Audit +- Rolename: Financial Supervisor + Descr: Financial Supervisor +- Rolename: GL Admin + Descr: Access to GL functions and settings +- Rolename: GL Clerk + Descr: Access to GL functions +- Rolename: GL Viewer + Descr: Read-only access to GL functions +- Rolename: Guest + Descr: External Guest Role +- Rolename: IN Admin + Descr: Full access to IN functions and settings +- Rolename: IN Clerk + Descr: Limited access to IN functions related to warehouse stock management +- Rolename: IN Manager + Descr: Full access to IN functions +- Rolename: IN Receiver + Descr: Access to IN functions related to receiving goods +- Rolename: IN Shipper + Descr: Access to IN functions related to shipping goods +- Rolename: IN Viewer + Descr: Read-only access to IN functions +- Rolename: Internal Employee + Descr: Access to tasks and events, time card, expense receipts and claims, personal settings, and Help +- Rolename: Internal User + Descr: Allows the user to change personal settings, and view Help +- Rolename: Inventory Data Manager + Descr: Full access to data entry forms related to inventory items +- Rolename: LLM Agent + Descr: QM documents (inspection orders, CoA files, NCR) +- Rolename: LLM Prompt Engineer + Descr: Authorization to create LLM Prompts +- Rolename: LLM Security Expert + Descr: Authorization to create LLM System Instructions +- Rolename: MFG Admin + Descr: Access to all Manufacturing Module Settings +- Rolename: MFG Cost Accountant + Descr: Manufacturing Cost Accountant +- Rolename: MFG Engineer + Descr: Manufacturing Engineer +- Rolename: MFG Engineering MGR + Descr: Manufacturing Engineering Manager +- Rolename: MFG Planner + Descr: Manufacturing Planner +- Rolename: MFG Planning MGR + Descr: Manufacturing Planning Manager +- Rolename: MFG Production MGR + Descr: Manufacturing Production Manager +- Rolename: MFG Sales Engineer + Descr: Manufacturing Sales Engineer +- Rolename: MFG Scheduler + Descr: Manufacturing Scheduler +- Rolename: MFG Scheduling MGR + Descr: Manufacturing Scheduling Manager +- Rolename: MFG Shop Floor + Descr: Manufacturing Shop floor employee +- Rolename: MFG Viewer + Descr: View Production Order Information Only +- Rolename: MFG Warehouse + Descr: Manufacturing Warehouse +- Rolename: ODatav4 User + Descr: Access to data exposed via DAC-based OData +- Rolename: PDF Annotator + Descr: Access to PDF annotation +- Rolename: PJ Admin + Descr: Access to construction management settings +- Rolename: PM Admin + Descr: Access to project management settings +- Rolename: PM Financial Controller + Descr: Access to project-related financial analytics +- Rolename: PM Operations Manager + Descr: Access to project management analytics +- Rolename: PM Project Manager + Descr: Access to project management functions and analytics +- Rolename: PM Project Manager Assistant + Descr: Access to project administration functions and analytics +- Rolename: PM Superintendent + Descr: Access to project site-related functions +- Rolename: PM Viewer + Descr: Read-only access to projects +- Rolename: PO Admin + Descr: Full access to PO functions and settings +- Rolename: PO Buyer + Descr: Full access to PO functions related to orders and replenishment +- Rolename: PO Clerk + Descr: Limited access to PO functions related to orders +- Rolename: PO Manager + Descr: Full access to PO functions +- Rolename: PO Viewer + Descr: Read-only access to PO functions +- Rolename: PR Admin + Descr: Access to all PR functions and settings +- Rolename: PR Clerk + Descr: Access to data entry PR functions and read-only access to PR settings +- Rolename: PR Manager + Descr: Access to all PR functions and read-only access to PR settings +- Rolename: PR Viewer + Descr: Read-Only access to all PR functions +- Rolename: Portal Admin + Descr: Access to portal configuration +- Rolename: Portal User + Descr: Portal user +- Rolename: Project Accountant + Descr: Project Accountant +- Rolename: ReportDesigner + Descr: Report Designer +- Rolename: SFK App Access + Descr: Shop Floor Kiosk App Access +- Rolename: SFK User + Descr: Shop Floor Kiosk User +- Rolename: SO Admin + Descr: Full access to SO functions and settings +- Rolename: SO Clerk + Descr: Limited access to SO functions +- Rolename: SO Manager + Descr: Full access to SO functions +- Rolename: SO Viewer + Descr: Read-only access to SO functions +- Rolename: TX Admin + Descr: Access to TX functions and settings +- Rolename: TX Viewer + Descr: Read-only access to TX functions +- Rolename: Vendor Data Manager + Descr: Full access to data entry forms related to vendors +- Rolename: Wiki Admin + Descr: Wiki Administrator to set other users access rights to Wiki +- Rolename: Wiki Author + Descr: Role provides access rights for creating Wiki articles diff --git a/config/master/91-users.yaml b/config/master/91-users.yaml new file mode 100644 index 0000000..bceae07 --- /dev/null +++ b/config/master/91-users.yaml @@ -0,0 +1,54 @@ +entity: User +key: Username +endpoint: bootstrap +detail_keys: + Roles: Rolename +records: +- Username: qa-director + FirstName: Elodie + LastName: Tremblay + Email: qa-director@cannordic.ca + IsApproved: true + PasswordNeverExpires: true + PasswordChangeOnNextLogin: false + Roles: + - Rolename: IN Manager + - Rolename: PO Viewer +- Username: vp-supply-chain + FirstName: Marcus + LastName: Vance + Email: vp-supply-chain@cannordic.ca + IsApproved: true + PasswordNeverExpires: true + PasswordChangeOnNextLogin: false + Roles: + - Rolename: PO Admin + - Rolename: SO Admin +- Username: receiving-supervisor + FirstName: Devon + LastName: Singh + Email: receiving-supervisor@cannordic.ca + IsApproved: true + PasswordNeverExpires: true + PasswordChangeOnNextLogin: false + Roles: + - Rolename: IN Receiver + - Rolename: PO Clerk +- Username: erp-architect + FirstName: Sophie + LastName: Archambault + Email: erp-architect@cannordic.ca + IsApproved: true + PasswordNeverExpires: true + PasswordChangeOnNextLogin: false + Roles: + - Rolename: Administrator +- Username: llm-agent + FirstName: LLM + LastName: Agent + Email: llm-agent@cannordic.ca + IsApproved: true + PasswordNeverExpires: true + PasswordChangeOnNextLogin: false + Roles: + - Rolename: LLM Agent diff --git a/config/master/92-role-users.yaml b/config/master/92-role-users.yaml new file mode 100644 index 0000000..9b28481 --- /dev/null +++ b/config/master/92-role-users.yaml @@ -0,0 +1,30 @@ +entity: Role +key: Rolename +endpoint: bootstrap +detail_keys: + Users: Username +records: +- Rolename: IN Manager + Users: + - Username: qa-director +- Rolename: PO Viewer + Users: + - Username: qa-director +- Rolename: PO Admin + Users: + - Username: vp-supply-chain +- Rolename: SO Admin + Users: + - Username: vp-supply-chain +- Rolename: IN Receiver + Users: + - Username: receiving-supervisor +- Rolename: PO Clerk + Users: + - Username: receiving-supervisor +- Rolename: Administrator + Users: + - Username: erp-architect +- Rolename: LLM Agent + Users: + - Username: llm-agent diff --git a/config/setup/10-financial-year.yaml b/config/setup/10-financial-year.yaml new file mode 100644 index 0000000..ccb1933 --- /dev/null +++ b/config/setup/10-financial-year.yaml @@ -0,0 +1,8 @@ +action: GeneratePeriods +entity: FinancialYearSettings +endpoint: bootstrap +record: + BegFinYear: "2026-01-01" + FinPeriods: 12 + PeriodType: Month +done_when: {} diff --git a/config/setup/20-master-calendar.yaml b/config/setup/20-master-calendar.yaml new file mode 100644 index 0000000..22b6216 --- /dev/null +++ b/config/setup/20-master-calendar.yaml @@ -0,0 +1,11 @@ +action: GenerateCalendar +entity: MasterCalendar +endpoint: bootstrap +record: + FinancialYear: "2026" +parameters: + FromYear: "2026" + ToYear: "2026" +done_when: + entity: CompanyCalendar + filter: FinancialYear eq '2026' diff --git a/config/setup/30-open-periods.yaml b/config/setup/30-open-periods.yaml new file mode 100644 index 0000000..cddb522 --- /dev/null +++ b/config/setup/30-open-periods.yaml @@ -0,0 +1,11 @@ +action: ProcessAll +entity: ManagePeriods +endpoint: bootstrap +record: + Action: Open + FromYear: "2026" + ToYear: "2026" + OrganizationID: CNBN +done_when: + entity: CompanyPeriod + filter: FinancialYear eq '2026' and Status eq 'Open' diff --git a/config/views/10-trial-balance.yaml b/config/views/10-trial-balance.yaml new file mode 100644 index 0000000..f8b7f05 --- /dev/null +++ b/config/views/10-trial-balance.yaml @@ -0,0 +1,15 @@ +name: trial-balance +source: + inquire: AccountSummaryInquiry + params: + Ledger: ACTUAL + Period: "072026" +key: + - Account +capture: + - Description + - BegBalance + - DebitTotal + - CreditTotal + - EndingBalance +decimals: 2 diff --git a/scenario/10-seed-capital.yaml b/scenario/10-seed-capital.yaml new file mode 100644 index 0000000..533fe58 --- /dev/null +++ b/scenario/10-seed-capital.yaml @@ -0,0 +1,41 @@ +scenario: seed-capital +description: Once-class owner capital JE (fund Operating Chequing 10100 for AP WIRE) +once: true +present: + inquire: AccountSummaryInquiry + parameters: {Ledger: ACTUAL, Period: "${current_period}"} + match: {Account: '30000'} + when: + EndingBalance: {gte: 100000} +steps: +- id: seed-capital + put: JournalTransaction + record: + Module: GL + Description: Seed capital - owner investment + Hold: false + Details: + - {Account: '10100', Subaccount: '000000', DebitAmount: 100000.00} + - {Account: '30000', Subaccount: '000000', CreditAmount: 100000.00} + capture: {BatchNbr: je_seed} +- id: release-seed-capital + action: {entity: JournalTransaction, name: ReleaseJournalTransaction} + record: {Module: GL, BatchNbr: "${je_seed}"} + wait: + entity: JournalTransaction + keys: [GL, "${je_seed}"] + until: {Status: Posted} +expect: +- get: + entity: JournalTransaction + keys: [GL, "${je_seed}"] + fields: + Status: Posted +- inquire: AccountSummaryInquiry + parameters: {Ledger: ACTUAL, Period: "${current_period}"} + match: {Account: '10100'} + delta: {EndingBalance: 100000.00} +- inquire: AccountSummaryInquiry + parameters: {Ledger: ACTUAL, Period: "${current_period}"} + match: {Account: '30000'} + delta: {EndingBalance: 100000.00} diff --git a/scenario/20-buy.yaml b/scenario/20-buy.yaml new file mode 100644 index 0000000..9066f11 --- /dev/null +++ b/scenario/20-buy.yaml @@ -0,0 +1,314 @@ +scenario: buy +description: Buy kit-BOM raw materials (PO receipt bill+pay) +steps: +- id: po-northbio + put: PurchaseOrder + record: + VendorID: VEND-NORTH-BIO + Hold: false + Details: + - {InventoryID: RAW-ECH-EXT4, OrderQty: 5, UnitCost: 180.00} + - {InventoryID: RAW-ELD-EXT10, OrderQty: 5, UnitCost: 220.00} + capture: {OrderNbr: po_northbio} +- id: rcpt-northbio + put: PurchaseReceipt + record: + Type: Receipt + VendorID: VEND-NORTH-BIO + Hold: false + CreateBill: true + VendorRef: "PO-${po_northbio}" + Details: + - {POOrderType: Normal, POOrderNbr: "${po_northbio}", POLineNbr: 1, ReceiptQty: 5, Location: QCHOLD, LotSerialNbr: NB-ECH-25001, ExpirationDate: '2028-12-31'} + - {POOrderType: Normal, POOrderNbr: "${po_northbio}", POLineNbr: 2, ReceiptQty: 5, Location: QCHOLD, LotSerialNbr: NB-ELD-25001, ExpirationDate: '2028-12-31'} + capture: {ReceiptNbr: rcpt_northbio} +- id: release-rcpt-northbio + action: {entity: PurchaseReceipt, name: ReleasePurchaseReceipt} + record: {Type: Receipt, ReceiptNbr: "${rcpt_northbio}"} + wait: + entity: PurchaseReceipt + keys: [Receipt, "${rcpt_northbio}"] + until: {Status: Released} +- id: billnbr-northbio + get: + entity: Bill + filter: "VendorRef eq 'PO-${po_northbio}'" + top: 1 + capture: {ReferenceNbr: bill_northbio} +- id: release-bill-northbio + action: {entity: Bill, name: ReleaseBill} + record: {Type: Bill, ReferenceNbr: "${bill_northbio}"} + wait: + entity: Bill + keys: [Bill, "${bill_northbio}"] + until: {Status: Open} +- id: pay-northbio + put: Check + record: + Type: Payment + Vendor: VEND-NORTH-BIO + PaymentMethod: WIRE + CashAccount: '10100' + PaymentAmount: 2000.00 + PaymentRef: "WIRE-AP-${bill_northbio}" + Hold: false + Details: + - {DocType: Bill, ReferenceNbr: "${bill_northbio}", AmountPaid: 2000.00} + capture: {ReferenceNbr: chk_northbio} +- id: release-pay-northbio + action: {entity: Check, name: ReleaseCheck} + record: {Type: Payment, ReferenceNbr: "${chk_northbio}"} + wait: + entity: Check + keys: [Payment, "${chk_northbio}"] + until: {Status: Closed} +- id: po-alpine + put: PurchaseOrder + record: + VendorID: VEND-ALPINE-EXT + Hold: false + Details: + - {InventoryID: RAW-ASH-EXT5, OrderQty: 5, UnitCost: 90.00} + capture: {OrderNbr: po_alpine} +- id: rcpt-alpine + put: PurchaseReceipt + record: + Type: Receipt + VendorID: VEND-ALPINE-EXT + Hold: false + CreateBill: true + VendorRef: "PO-${po_alpine}" + Details: + - {POOrderType: Normal, POOrderNbr: "${po_alpine}", POLineNbr: 1, ReceiptQty: 5, Location: QCHOLD, LotSerialNbr: AB-ASH-25001, ExpirationDate: '2028-12-31'} + capture: {ReceiptNbr: rcpt_alpine} +- id: release-rcpt-alpine + action: {entity: PurchaseReceipt, name: ReleasePurchaseReceipt} + record: {Type: Receipt, ReceiptNbr: "${rcpt_alpine}"} + wait: + entity: PurchaseReceipt + keys: [Receipt, "${rcpt_alpine}"] + until: {Status: Released} +- id: billnbr-alpine + get: + entity: Bill + filter: "VendorRef eq 'PO-${po_alpine}'" + top: 1 + capture: {ReferenceNbr: bill_alpine} +- id: release-bill-alpine + action: {entity: Bill, name: ReleaseBill} + record: {Type: Bill, ReferenceNbr: "${bill_alpine}"} + wait: + entity: Bill + keys: [Bill, "${bill_alpine}"] + until: {Status: Open} +- id: pay-alpine + put: Check + record: + Type: Payment + Vendor: VEND-ALPINE-EXT + PaymentMethod: WIRE + CashAccount: '10100' + PaymentAmount: 450.00 + PaymentRef: "WIRE-AP-${bill_alpine}" + Hold: false + Details: + - {DocType: Bill, ReferenceNbr: "${bill_alpine}", AmountPaid: 450.00} + capture: {ReferenceNbr: chk_alpine} +- id: release-pay-alpine + action: {entity: Check, name: ReleaseCheck} + record: {Type: Payment, ReferenceNbr: "${chk_alpine}"} + wait: + entity: Check + keys: [Payment, "${chk_alpine}"] + until: {Status: Closed} +- id: po-nippon + put: PurchaseOrder + record: + VendorID: VEND-NIPPON-PHARMA + Hold: false + Details: + - {InventoryID: RAW-COQ10-99, OrderQty: 2, UnitCost: 320.00} + capture: {OrderNbr: po_nippon} +- id: rcpt-nippon + put: PurchaseReceipt + record: + Type: Receipt + VendorID: VEND-NIPPON-PHARMA + Hold: false + CreateBill: true + VendorRef: "PO-${po_nippon}" + Details: + - {POOrderType: Normal, POOrderNbr: "${po_nippon}", POLineNbr: 1, ReceiptQty: 2, Location: QCHOLD, LotSerialNbr: NP-COQ-25001, ExpirationDate: '2028-12-31'} + capture: {ReceiptNbr: rcpt_nippon} +- id: release-rcpt-nippon + action: {entity: PurchaseReceipt, name: ReleasePurchaseReceipt} + record: {Type: Receipt, ReceiptNbr: "${rcpt_nippon}"} + wait: + entity: PurchaseReceipt + keys: [Receipt, "${rcpt_nippon}"] + until: {Status: Released} +- id: billnbr-nippon + get: + entity: Bill + filter: "VendorRef eq 'PO-${po_nippon}'" + top: 1 + capture: {ReferenceNbr: bill_nippon} +- id: release-bill-nippon + action: {entity: Bill, name: ReleaseBill} + record: {Type: Bill, ReferenceNbr: "${bill_nippon}"} + wait: + entity: Bill + keys: [Bill, "${bill_nippon}"] + until: {Status: Open} +- id: pay-nippon + put: Check + record: + Type: Payment + Vendor: VEND-NIPPON-PHARMA + PaymentMethod: WIRE + CashAccount: '10100' + PaymentAmount: 640.00 + PaymentRef: "WIRE-AP-${bill_nippon}" + Hold: false + Details: + - {DocType: Bill, ReferenceNbr: "${bill_nippon}", AmountPaid: 640.00} + capture: {ReferenceNbr: chk_nippon} +- id: release-pay-nippon + action: {entity: Check, name: ReleaseCheck} + record: {Type: Payment, ReferenceNbr: "${chk_nippon}"} + wait: + entity: Check + keys: [Payment, "${chk_nippon}"] + until: {Status: Closed} +- id: po-nordic + put: PurchaseOrder + record: + VendorID: VEND-NORDIC-MAR + Hold: false + Details: + - {InventoryID: RAW-OMEGA3-70, OrderQty: 10, UnitCost: 55.00} + - {InventoryID: RAW-ASTA-10, OrderQty: 1, UnitCost: 900.00} + capture: {OrderNbr: po_nordic} +- id: rcpt-nordic + put: PurchaseReceipt + record: + Type: Receipt + VendorID: VEND-NORDIC-MAR + Hold: false + CreateBill: true + VendorRef: "PO-${po_nordic}" + Details: + - {POOrderType: Normal, POOrderNbr: "${po_nordic}", POLineNbr: 1, ReceiptQty: 10, Location: QCHOLD, LotSerialNbr: NM-OM3-25001, ExpirationDate: '2028-12-31'} + - {POOrderType: Normal, POOrderNbr: "${po_nordic}", POLineNbr: 2, ReceiptQty: 1, Location: QCHOLD, LotSerialNbr: NM-AST-25001, ExpirationDate: '2028-12-31'} + capture: {ReceiptNbr: rcpt_nordic} +- id: release-rcpt-nordic + action: {entity: PurchaseReceipt, name: ReleasePurchaseReceipt} + record: {Type: Receipt, ReceiptNbr: "${rcpt_nordic}"} + wait: + entity: PurchaseReceipt + keys: [Receipt, "${rcpt_nordic}"] + until: {Status: Released} +- id: billnbr-nordic + get: + entity: Bill + filter: "VendorRef eq 'PO-${po_nordic}'" + top: 1 + capture: {ReferenceNbr: bill_nordic} +- id: release-bill-nordic + action: {entity: Bill, name: ReleaseBill} + record: {Type: Bill, ReferenceNbr: "${bill_nordic}"} + wait: + entity: Bill + keys: [Bill, "${bill_nordic}"] + until: {Status: Open} +- id: pay-nordic + put: Check + record: + Type: Payment + Vendor: VEND-NORDIC-MAR + PaymentMethod: WIRE + CashAccount: '10100' + PaymentAmount: 1450.00 + PaymentRef: "WIRE-AP-${bill_nordic}" + Hold: false + Details: + - {DocType: Bill, ReferenceNbr: "${bill_nordic}", AmountPaid: 1450.00} + capture: {ReferenceNbr: chk_nordic} +- id: release-pay-nordic + action: {entity: Check, name: ReleaseCheck} + record: {Type: Payment, ReferenceNbr: "${chk_nordic}"} + wait: + entity: Check + keys: [Payment, "${chk_nordic}"] + until: {Status: Closed} +expect: +- get: + entity: Bill + keys: [Bill, "${bill_northbio}"] + fields: + Status: Closed +- get: + entity: Check + keys: [Payment, "${chk_northbio}"] + fields: + Status: Closed +- get: + entity: Bill + keys: [Bill, "${bill_alpine}"] + fields: + Status: Closed +- get: + entity: Check + keys: [Payment, "${chk_alpine}"] + fields: + Status: Closed +- get: + entity: Bill + keys: [Bill, "${bill_nippon}"] + fields: + Status: Closed +- get: + entity: Check + keys: [Payment, "${chk_nippon}"] + fields: + Status: Closed +- get: + entity: Bill + keys: [Bill, "${bill_nordic}"] + fields: + Status: Closed +- get: + entity: Check + keys: [Payment, "${chk_nordic}"] + fields: + Status: Closed +- inquire: InventorySummaryInquiry + parameters: {InventoryID: RAW-ECH-EXT4, WarehouseID: WH-MISS-01} + delta: {QtyOnHand: 5.0} +- inquire: InventorySummaryInquiry + parameters: {InventoryID: RAW-ELD-EXT10, WarehouseID: WH-MISS-01} + delta: {QtyOnHand: 5.0} +- inquire: InventorySummaryInquiry + parameters: {InventoryID: RAW-ASH-EXT5, WarehouseID: WH-MISS-01} + delta: {QtyOnHand: 5.0} +- inquire: InventorySummaryInquiry + parameters: {InventoryID: RAW-COQ10-99, WarehouseID: WH-MISS-01} + delta: {QtyOnHand: 2.0} +- inquire: InventorySummaryInquiry + parameters: {InventoryID: RAW-OMEGA3-70, WarehouseID: WH-MISS-01} + delta: {QtyOnHand: 10.0} +- inquire: InventorySummaryInquiry + parameters: {InventoryID: RAW-ASTA-10, WarehouseID: WH-MISS-01} + delta: {QtyOnHand: 1.0} +- inquire: AccountSummaryInquiry + parameters: {Ledger: ACTUAL, Period: "${current_period}"} + match: {Account: '20200'} + delta: {EndingBalance: 0.00} +- inquire: AccountSummaryInquiry + parameters: {Ledger: ACTUAL, Period: "${current_period}"} + match: {Account: '20100'} + delta: {EndingBalance: 0.00} +- inquire: AccountSummaryInquiry + parameters: {Ledger: ACTUAL, Period: "${current_period}"} + match: {Account: '10100'} + delta: {EndingBalance: -4540.00} From e8ce9cf66d19dd254234b03a09010057292094d0 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 20:18:55 -0400 Subject: [PATCH 13/25] T258: AGENTS.md + README: ACUCLI soak from this checkout --tenant ACUCLI; never CNBN; never sibling cd for CLI soak; e2e still packaged templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soak the CLI test tenant from this checkout. Do not cd the sibling GitOps repo. gmake e2e still uses packaged config init templates. §V.2 §V.12 --- AGENTS.md | 46 +++++++++++++++++++++------------------------- README.md | 17 +++++++++++++++++ SPEC.md | 2 +- tests/test_docs.py | 17 +++++++++++++++++ 4 files changed, 56 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 332a510..2489bdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,25 @@ # AGENTS.md -This repository is the `acu` CLI (`acumatica-cli`). It has no GitOps seed. -Do not `acu apply` / `diff` / `run` from here. +This repository is the `acu` CLI (`acumatica-cli`) plus the CLI test seed +(`config/` SEED_DIRS + `config/views/` + `scenario/` at repo root). -Live CLI tests use tenant login `ACUCLI` and sibling GitOps repo -`~/github/acu-gitops-qms` as the YAML source. - -Never print `.env` secrets. +Product GitOps (QMS) stays in the sibling data repo. Never print `.env` secrets. ## Live test target | | | | --- | --- | | Tenant login | `ACUCLI` (`ACU_TENANT=ACUCLI`) | -| GitOps source | `~/github/acu-gitops-qms` | +| CLI test seed | this checkout `config/` + `scenario/` | | Instance | this repo's `.env` (`ACU_BASE_URL`) | -The GitOps repo `.env` names tenant `CNBN`. That is the QMS product tenant. +The sibling GitOps `.env` names tenant `CNBN`. That is the QMS product tenant. Do not apply, delete, or rebuild `CNBN` while testing this CLI. Do not change `~/github/acu-gitops-qms/.env`. Do not run `gmake rebuild` there (it uses `.env` `ACU_TENANT`). +Do not `cd` the sibling for CLI soak. -Pass `--tenant ACUCLI` on every live command (flag wins over the GitOps `.env`). +Pass `--tenant ACUCLI` on every live command (flag wins over `.env` `ACU_TENANT`). ## Local CLI @@ -38,30 +36,28 @@ uv run acu --version # 0.x.y+dev () Offline unit tests stay here: `gmake check`. `gmake e2e` is a separate path: scratch tenants from packaged `config init` templates. -It is not the GitOps soak. +It is not the CLI test-seed soak. -## GitOps soak (from the data repo) +## CLI test-seed soak (this checkout) -Run from the GitOps tree so cwd walk-up finds `config/` and `.env`. -Set PATH to this CLI checkout's `.venv/bin` first, not the GitOps tree. +Run from this tree so cwd walk-up finds `config/` and `.env`. +Use this checkout's `.venv/bin/acu`. ```sh -PATH="$HOME/github/acumatica-cli/.venv/bin:$PATH" -cd ~/github/acu-gitops-qms - -acu --tenant ACUCLI config check -acu --tenant ACUCLI tenant list +gmake install +uv run acu --tenant ACUCLI config check +uv run acu --tenant ACUCLI tenant list # cold rebuild of the CLI test tenant only -acu --tenant ACUCLI tenant delete --login ACUCLI --yes -acu --tenant ACUCLI tenant create --login ACUCLI -acu --tenant ACUCLI apply -acu --tenant ACUCLI run -acu --tenant ACUCLI diff -acu --tenant ACUCLI state +uv run acu --tenant ACUCLI tenant delete --login ACUCLI --yes +uv run acu --tenant ACUCLI tenant create --login ACUCLI +uv run acu --tenant ACUCLI apply +uv run acu --tenant ACUCLI run +uv run acu --tenant ACUCLI diff +uv run acu --tenant ACUCLI state ``` -`config/qms/` needs Lab5.QMS published. Skip it unless the change under test is QMS apply. +This seed has no `config/qms/`. QMS apply stays in the sibling GitOps repo. ## Related repos diff --git a/README.md b/README.md index e885482..e3d1b30 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,23 @@ gmake e2e FILE=test_scenario_lifecycle # scenario + state + kit alloc gmake e2e FILE=test_extract_roundtrip # extract inverse ``` +### CLI test-seed soak + +This checkout carries a CLI test seed at repo-root `config/` and `scenario/` (no `config/qms/`). +Soak the live CLI tenant from here with `--tenant ACUCLI`. + +Never use tenant `CNBN`. Never `cd` the sibling GitOps repo for CLI soak. + +`gmake e2e` still scaffolds from packaged `config init` templates into a tmp dir. +That path is not the soak. + +```sh +uv run acu --tenant ACUCLI apply +uv run acu --tenant ACUCLI run +uv run acu --tenant ACUCLI diff +uv run acu --tenant ACUCLI state +``` + ## License This project is licensed under the PolyForm Noncommercial License 1.0.0. diff --git a/SPEC.md b/SPEC.md index 3a12305..28dcec2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -241,7 +241,7 @@ T254|x|live/e2e: insert new NumberingID (QORD-class) w/ NewSymbol; re-apply exis T255|x|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol insert (gh #44)|V12,V19,V58,T251,T252 T256|x|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/numbering/segmented-key/roles onto those tenants; shared conftest `bracket_tenant` + `joined_output`; drop per-bug files|V4,V13 T257|x|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `scenario/` → repo root (exclude `qms/`, live `.env`, `state/`, customization, Makefile, schemas)|V2,I.data -T258|.|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 +T258|x|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 T259|.|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 ## §B BUGS diff --git a/tests/test_docs.py b/tests/test_docs.py index fa2dcf8..31c4fba 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -19,6 +19,23 @@ def _unreleased(changelog: str) -> str: return rest if nxt < 0 else rest[:nxt] +def test_docs_cli_test_seed_soak() -> None: + """T258/V12/V2: soak from this checkout; never CNBN; e2e still templates.""" + agents = (REPO / "AGENTS.md").read_text() + readme = (REPO / "README.md").read_text() + assert "has no GitOps seed" not in agents + assert "Do not `acu apply`" not in agents + assert "cd ~/github/acu-gitops-qms" not in agents + assert "--tenant ACUCLI" in agents + assert "Do not apply, delete, or rebuild `CNBN`" in agents + assert "Do not `cd` the sibling for CLI soak" in agents + assert "packaged `config init` templates" in agents + assert "--tenant ACUCLI" in readme + assert "Never use tenant `CNBN`" in readme + assert "Never `cd` the sibling GitOps repo for CLI soak" in readme + assert "packaged `config init` templates" in readme + + def test_e2e_pipeline_files_only() -> None: """T256: live e2e is three pipeline files; per-bug probes fold onto them.""" names = sorted(p.name for p in (REPO / "tests" / "e2e").glob("test_*.py")) From 918c7fd273cfd2c297cdd2d3027c5f35661a06a1 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 20:19:39 -0400 Subject: [PATCH 14/25] T259: offline test: repo-root seed trees present; no config/qms/; e2e still 3 pipeline files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert CLI test seed dirs exist, config/qms is absent, and live e2e stays three pipeline files. §V.2 §V.13 --- SPEC.md | 2 +- tests/test_docs.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 28dcec2..13f8ab2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -242,7 +242,7 @@ T255|x|docs/demo-seed + CHANGELOG Unreleased — NumberingSequence NewSymbol ins T256|x|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/numbering/segmented-key/roles onto those tenants; shared conftest `bracket_tenant` + `joined_output`; drop per-bug files|V4,V13 T257|x|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `scenario/` → repo root (exclude `qms/`, live `.env`, `state/`, customization, Makefile, schemas)|V2,I.data T258|x|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 -T259|.|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 +T259|x|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 ## §B BUGS diff --git a/tests/test_docs.py b/tests/test_docs.py index 31c4fba..4293f06 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -36,6 +36,25 @@ def test_docs_cli_test_seed_soak() -> None: assert "packaged `config init` templates" in readme +def test_cli_test_seed_trees_present() -> None: + """T259/V2/V13: repo-root seed trees; no config/qms; e2e still 3 files.""" + config = REPO / "config" + for name in ("bootstrap", "baseline", "setup", "master", "views"): + d = config / name + assert d.is_dir(), d + assert list(d.glob("*.yaml")), d + assert not (config / "qms").exists() + scenario = REPO / "scenario" + assert scenario.is_dir() + assert list(scenario.glob("*.yaml")) + names = sorted(p.name for p in (REPO / "tests" / "e2e").glob("test_*.py")) + assert names == [ + "test_extract_roundtrip.py", + "test_provision_lifecycle.py", + "test_scenario_lifecycle.py", + ] + + def test_e2e_pipeline_files_only() -> None: """T256: live e2e is three pipeline files; per-bug probes fold onto them.""" names = sorted(p.name for p in (REPO / "tests" / "e2e").glob("test_*.py")) From 18e8c1056a8c18a0cce84fe86752fb306360e6e1 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 20:28:52 -0400 Subject: [PATCH 15/25] review: point e2e decrypt at repo-root .env; drop stale NewSymbol GET-omit comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing live config now tells operators to decrypt .env.gpg here. Diff ignore-field comments keep V56 on path-qualified GET-omit only. Contract comment matches mapped GET returning NewSymbol. §V.2 §V.56 --- src/acumatica_cli/bootstrap_project.xml | 2 +- src/acumatica_cli/seed.py | 7 ++++--- tests/e2e/conftest.py | 3 +-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/acumatica_cli/bootstrap_project.xml b/src/acumatica_cli/bootstrap_project.xml index 0598afb..1f3667e 100644 --- a/src/acumatica_cli/bootstrap_project.xml +++ b/src/acumatica_cli/bootstrap_project.xml @@ -70,7 +70,7 @@ - PrimaryView Header (DAC Numbering): key NumberingID + Descr + NewSymbol (T251/V58; CS201010 "New Number Symbol"). UserNumbering is the OR alternative (manual numbering), not a required pair. - GET omits NewSymbol — write-only (V56), not LastNbr-class runtime. + Mapped GET returns NewSymbol (seed; GET-omit is mapping-miss drift). - Detail view Sequence (DAC NumberingSequence): bounds StartNbr, EndNbr, WarnNbr, NbrStep, StartDate. LastNbr is runtime counter — omitted from contract (V40/T152 strip/ignore if ever present). diff --git a/src/acumatica_cli/seed.py b/src/acumatica_cli/seed.py index 30fabb6..f29e779 100644 --- a/src/acumatica_cli/seed.py +++ b/src/acumatica_cli/seed.py @@ -88,12 +88,13 @@ # hard-strips; diff never compares; apply never PUTs them (re-apply must # not reset live counters). NUMBERING_RUNTIME_FIELDS = frozenset({"LastNbr"}) -# Fields omitted from source↔live compare (V39 write-only + V40 runtime + -# V56 GET-omit). Field-name deny at every nesting level. Do not add -# bare ``Value`` here — that would hide real drift on unrelated fields (V56). +# Fields omitted from source↔live compare (V39 write-only + V40 runtime). +# Field-name deny at every nesting level. _DIFF_IGNORE_FIELDS = PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS # Path-qualified write-only GET-omit (V56): (entity, detail, field). # Diff ignores; extract strips; apply still PUTs when present in YAML. +# Do not add bare ``Value`` to _DIFF_IGNORE_FIELDS — that would hide +# real drift on unrelated fields. _DIFF_IGNORE_PATHS = frozenset({("LotSerialClass", "Segments", "Value")}) # Whole-detail skip (V57/B37): mapped GET Role.Users / User.Roles may be # empty while UsersInRoles has session-company rows. Extra live members diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index e6409af..054978f 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -171,8 +171,7 @@ def live_instance() -> Instance: return load_instance() except SystemExit as exc: pytest.exit( - f"live config missing ({exc}) - " - "run 'make decrypt' in the sibling data repo", + f"live config missing ({exc}) - decrypt .env.gpg at the repo root", returncode=1, ) From 4438305d9ba0a3b456d7169f44bda271046655dd Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Wed, 9 Sep 2026 20:37:21 -0400 Subject: [PATCH 16/25] chore(env): update encrypted environment variables --- .env.gpg | Bin 287 -> 288 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.env.gpg b/.env.gpg index c6f7393d00b3eb622967d3acf20c8b4743088e37..b14db77c218de81d68ddad80ac29ddd79a5a0981 100644 GIT binary patch literal 288 zcmV+*0pI?FUIPn3l2t*I(9seB2S7UZ8vY6@rguTvuGa)Xl+78#*b_5#k<~YudquhGKb0|T7|4#@qD`bgsJ_-IP`D)2 zz5xjW5G#xxMza*uGUy0gT1q4b5|w5JD}krP<;(9WS7#Ns6c8A&K*EN!*63ffL9_|q zXJ-;aLaG=Du@b#%aIhM3f%z`pl{odIK>uOasNf{L2eG}3{iw6Iq!3a1DYGQxcI^$u z64;l%Skeia-eu;z=;l!}-{hi^RHR+8S5;T>eZ4rZe^q0#5)a`=%O+uGx`{#n literal 287 zcmV+)0pR|GUIPn3l2t*I(9seB2S6TnQNVmkUgtRYW2mVwq7sJ+)ghsgi8(1n@&_j? zZAUQkI7`9ygu&S_WKnUHPhPx#>EQB<^ods*DP0oUlhGu3oDu-}l6128P@3ei7|V;) zy#WaV5ae4z`Yv~Z5^TCqSH4C~TM}J9s58NWWh=G=$pDxco?f+Tr!suW0R{kYOd2Pd zUNQoH>KFk7{}1w!d3OSEmO%gZ5p-!?I!uMczqx;T+be-;+Vd?=|5KfE1;KclRsWNN z3kNsg)%Fhna~jrqAI|*PX8JUoeUW-+nz70jd^)vl_1xw Date: Wed, 9 Sep 2026 20:40:08 -0400 Subject: [PATCH 17/25] =?UTF-8?q?amend=20=C2=A7V.58(+):=20mapped=20GET=20r?= =?UTF-8?q?eturns=20NewSymbol;=20GET-omit=20is=20not-returned=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pruned GET-omit write-only clause. That was the unmapped 1.10.0 observation. After Header map, GET returning NewSymbol is the mapping-miss detector. LastNbr stays the only numbering runtime strip. --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 13f8ab2..f7bf659 100644 --- a/SPEC.md +++ b/SPEC.md @@ -109,7 +109,7 @@ V54: unwrap-row-id — unwrap keeps `id` + `delete` on records and detail rows t V55: datetime-calendar-day — `_norm` date-only `YYYY-MM-DD` matches live DateTimeValue same calendar day (strip `T…offset`); different day still drift; extract date-strips DateTimeValue seed emit (BegFinYear synth class) (closes §B.35) (gh #40) V56: write-only-get-omit — apply-needed field GET never returns ! diff-ignore (join `_DIFF_IGNORE_FIELDS`) not `not returned by endpoint`; ! general GET-omit=ok (mapping-miss per §V.50 stands); LotSerialClass Auto-Incremental `Segments.Value` first (closes §B.36) (gh #40) V57: membership-diff-empty — mapped GET Role.Users / User.Roles ? empty while UsersInRoles has session-company rows; diff ! flag Role.Users / User.Roles missing; skip those details in diff or read live source that returns rows; AssignUser apply + SQL-proven membership → `acu diff` exit 0 on Role.Users / User.Roles (closes §B.37) (gh #39) -V58: numbering-new-symbol — NumberingSequence insert ! Header NewSymbol (or UserNumbering if screen requires pair); Bootstrap contract maps it; apply PUT new NumberingID w/ `NewSymbol: ` ! 422; re-apply existing id succeeds (no NewSymbol mask 422 on update); GET omits NewSymbol → seed write-only not runtime (LastNbr strip per numbering-runtime invariant; GET-omit per write-only-get-omit invariant) (closes §B.38) (gh #44) +V58: numbering-new-symbol — NumberingSequence insert ! Header NewSymbol (or UserNumbering if screen requires pair); Bootstrap contract maps it; apply PUT new NumberingID w/ `NewSymbol: ` ! 422; re-apply existing id succeeds (no NewSymbol mask 422 on update); NewSymbol seed; mapped GET returns it; GET-omit → `not returned by endpoint` drift (mapping-miss per segmented-key-seed invariant; not write-only-get-omit ignore); LastNbr strip per numbering-runtime invariant (closes §B.38) (gh #44) ## §T TASKS From 4eff9b75281c899fc4106996cd4bc10d2c7f6302 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Thu, 10 Sep 2026 09:13:43 -0400 Subject: [PATCH 18/25] docs: remove Descr requirement for NumberingID inserts in demo-seed --- docs/demo-seed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 314a5b3..bb4714f 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -493,8 +493,8 @@ Package demo pins LAB5-class module sequences (`APBILL`, `APPAYMENT`, `NewSymbol: ` rows. A custom sequence id used by a prefs seed must exist (tenant-native or earlier NumberingSequence seed) before that prefs PUT. -Insert of a new NumberingID needs Header `NewSymbol: ` (auto-numbering) -and `Descr`. Re-apply of an existing id still succeeds (gh #44 / V58). +Insert of a new NumberingID needs Header `NewSymbol: ` (auto-numbering). +Re-apply of an existing id still succeeds (gh #44 / V58). ### Bounds vs runtime (V40 / V58) From cebc50b2a3450a54f18c9e1aa00a5f2220eff824 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Thu, 10 Sep 2026 09:23:02 -0400 Subject: [PATCH 19/25] =?UTF-8?q?amend=20=C2=A7C.(+)=20=C2=A7I.(+)=20?= =?UTF-8?q?=C2=A7V.12(+)=20=C2=A7V.42(+)=20=C2=A7V.49(+)=20=C2=A7T.260(+):?= =?UTF-8?q?=20drop=20docs/=20SoT;=20.env-only=20live=20instance;=20drop=20?= =?UTF-8?q?retired=20sibling/host=20literals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the docs/ tree as a live source of truth. Live instance URL comes from .env only. Drop retired sibling and host names: acumatica-infra, acumatica-blog, acumatica-devops, acu-dev1.vm.internal. --- SPEC.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/SPEC.md b/SPEC.md index f7bf659..ba80669 100644 --- a/SPEC.md +++ b/SPEC.md @@ -6,14 +6,14 @@ Configure Acumatica ERP purely from source — no UI, no Configuration Wizard. I ## §C CONSTRAINTS -- Repo = `acu` CLI + CLI test seed (`config/` + `scenario/` @ repo root). Product data (`.env.gpg`, QMS GitOps) lives in sibling data repos; infra = sibling `acumatica-infra`; blog = sibling `acumatica-blog`. C# customization projects out of scope (exclusion: bootstrap package ships one C# CustomizationPlugin package — §T.11; endpoint item returns when §T.12 lands). -- CLI test seed = repo-root `config/` (SEED_DIRS + `views/`; never `qms/`) + `scenario/`; copy from sibling GitOps (`~/github/acu-gitops-qms`) prune `qms/`, live `.env`, `state/`, customization, Makefile, schemas; soak from this checkout: `acu --tenant ACUCLI apply|diff|run|state` (`.env` already @ root; flag wins); never tenant `CNBN`; never `cd` sibling for CLI soak; issue-driven seed patches edit this tree; packaged `config init` templates stay demo scaffold (`gmake e2e` still 3 pipeline files on templates); `config init` never overwrite committed test seed +- Repo = `acu` CLI + CLI test seed (`config/` + `scenario/` @ repo root). Product QMS GitOps stays sibling data repo (never soak/apply that tenant from this tree). C# customization projects out of scope (exclusion: bootstrap package ships one C# CustomizationPlugin package — §T.11; endpoint item returns when §T.12 lands). +- CLI test seed = repo-root `config/` (SEED_DIRS + `views/`; never `qms/`) + `scenario/`; origin = sibling GitOps prune `qms/`, live `.env`, `state/`, customization, Makefile, schemas; soak from this checkout: `acu --tenant ACUCLI apply|diff|run|state` (`.env` @ root; flag wins); never tenant `CNBN`; never `cd` sibling for CLI soak; issue-driven seed patches edit this tree; packaged `config init` templates stay demo scaffold (`gmake e2e` still 3 pipeline files on templates); `config init` never overwrite committed test seed - Python ≥ 3.12; click, httpx, pydantic, pydantic-settings, rich, pyyaml; uv build; module `acumatica_cli`; entry `acu`. - Default suite fully offline: SSH = monkeypatched `subprocess.run`, REST = `httpx.MockTransport`; no live instance needed; `make check` gate offline-only. Opt-in live tier: pytest marker `e2e`, deselected via addopts, runs only via `make e2e`; e2e self-contained — single-org synthetic company scaffolded from packaged `config init` templates into tmp dirs, no repo-root data symlinks, no dataset tenants (`SalesDemo|T100|U100` stay CLI surface, never test fixtures); live e2e = 3 pipeline files (`test_provision_lifecycle` apply/diff, `test_scenario_lifecycle` run/state, `test_extract_roundtrip` two-tenant); per-bug probes fold onto those tenants; new e2e file only when proof needs different tenant shape; multi-org out of demo scope (paid engagement). -- Every cmd talks to live instance unless offline path: `--dry-run`, `bootstrap --export`, `survey inventory`/`survey reconcile` (artifact-only; no REST/SSH/password). Final verification live vs acu-dev1 (`acu-dev1.vm.internal`; needs tailnet + GPG key): `cd ~/github/acumatica-devops && make decrypt && make diff` or `make e2e` here (self-contained per the e2e constraint; `.env`/`.env.gpg` real files @ repo root). +- Every cmd talks to live instance unless offline path: `--dry-run`, `bootstrap --export`, `survey inventory`/`survey reconcile` (artifact-only; no REST/SSH/password). Live verify = this checkout `.env` (`ACU_BASE_URL` + `ACU_TENANT`); never host literals; never `cd` sibling; `make e2e` here (self-contained; `.env`/`.env.gpg` @ repo root). - Unconfigured tenant fails most entities (500 `PXSetupNotEnteredException` or 403 feature-gated) until bootstrap (features + company/branch); built-in endpoints can't bootstrap; `PUT CompaniesStructure` dead; payment terms have no Default-endpoint entity; financial currency (CM202000) has none either — REST `Currency` = CM201000 currency list only. - Dual readers single writer (V35): vocab — REST = live data-plane transport; entity = contract identity (e.g. Contact); seed = CaC YAML under `config/` SEED_DIRS; tenant-snapshot artifact = SM203520 Settings XML ZIP or `ac.exe export xml` table-XML folder (ingress only); table/DAC = artifact identity; inventory/ = offline IR product; map join = table↔entity (`snapshot_map.yaml` table→entity or identity match), never seed-name↔inventory-name; REST extract = seed reader; offline inventory = artifact reader; sole tenant mutator = REST `apply`; CLI never creates/restores SM203520; `ac.exe` has no snapshot create — consume artifact only; derived-state = `acu state` (I.cmd), not inventory not tenant-snapshot; bare "snapshot" ! synonym for `state` (V32 rename) -- `config init` = single full seed scaffold (LAB5/gitops-class under `config/` + lifecycle `scenario/` + observer `config/views/`); package templates derive from sibling `acumatica-gitops` seed trees (prune extras as needed — `demo/`, Makefile, live `.env`, committed `state/` never packaged). Non-goals: multi-org, multicurrency, full tax engine; not production cutover. Offline e2e still green on same scaffold (mock transport; live e2e opt-in). +- `config init` = single full seed scaffold (LAB5/gitops-class under `config/` + lifecycle `scenario/` + observer `config/views/`); package templates derive from sibling GitOps seed trees (prune extras as needed — `demo/`, Makefile, live `.env`, committed `state/` never packaged). Non-goals: multi-org, multicurrency, full tax engine; not production cutover. Offline e2e still green on same scaffold (mock transport; live e2e opt-in). - Config = `.env` `ACU_*` (secrets + where `ACU_BASE_URL` + pin `ACU_API_VERSION`); optional Default-half overlays keyed by resolved `api_version`; never `matrix.yaml`; never long-running product branches for version fan-out; never commit multi-version OpenAPI trees in package (§V.44) ## §I INTERFACES @@ -30,7 +30,7 @@ Configure Acumatica ERP purely from source — no UI, no Configuration Wizard. I - cmd: `acu state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]` → capture live **derived state** (balances, quantities, totals) → committed observation files under `state/`; args ? → `config/views/` hard-cut (no `config/snapshot/` fallback); dir expands `*.yaml` alpha; explicit paths win; default write `--out` (default `state/`); `--diff` compare live vs disk write nothing exit 0 either way; `--assert-unchanged` as `--diff` + exit 2 if moved; `--dry-run` resolve views + validate sources no HTTP; exit 0 ok (change fine bare), 1 op fail, 2 only under `--assert-unchanged`; view `params` pinned literals — no `${current_period}` expand (V43; operator hand-sets Period for live current-month window); ! seed inverse — never writes SEED_DIRS/seed shape, never `endpoint:` symbols, never apply path; views live under `config/views/` but ! SEED_DIRS; ! SM203520 tenant snapshot; ! inventory/findings paths (V35); ! `survey extract --only …`; `run` `expect:delta` stays aggregate field totals — row engine new (share client/auth/unwrap only); auth/session/target gate same as other data-plane cmds - cmd: `acu schema [--out ]` → OpenAPI dump → `schemas/` (gitignored ~3 MB; regenerate, never version) - cmd: `acu config show` → emit resolved `.env`-format doc: header comment + `ACU_*` keys w/ resolved values (defaults merged incl derived `ACU_SSH` + `ACU_API_VERSION`, flag overrides reflected); `ACU_PASSWORD` never emitted any form → output round-trips (`acu config show > .env` reloads identical, password excluded); ! same `Instance` resolution path as live cmds — no parallel resolution -- cmd: `acu config init [--host ] []` → scaffold data repo: `.env` `ACU_*` placeholders include `ACU_BASE_URL` (`--host` seeds host, placeholder `erp.example.com`) + `ACU_API_VERSION` (placeholder code default `25.200.001`) + `ACU_TENANT` + `ACU_USER=admin` + `ACU_PASSWORD=` blank; omit `ACU_SSH` (key absent → default `Administrator@`+resolved host; hosted opt-out = present blank `ACU_SSH=`); never real secrets; header comment → `acu config show`; env comments: secrets + ssh default note + API pin; no `matrix.yaml`; no `target.yaml`; no `--flavor` — single full template under `config/{bootstrap,baseline,setup,master}/` + `scenario/` + `config/views/10-trial-balance.yaml` + short README; package content ! derive from sibling `acumatica-gitops` seed trees (prune non-seed extras: `demo/`, Makefile, live `.env`, committed `state/`); bootstrap company + features + credit-terms only (never scaffolds `project.xml` — package SoT per V2/V21); baseline GL chain (+ packaging rows when present in source); setup fin-year/master-calendar/open-periods; master inventory/SO/PO/AR/AP/CA surface (extended features Inventory/DistributionModule/Warehouse/WarehouseLocation); scenario lifecycle alphabetical: `10-seed-capital.yaml` (`once: true` + authored `present`) + `20-buy.yaml` + `30-build.yaml` + `40-sell.yaml` (gitops names; monoscenario `buy-sell` forbidden); state TB EndingBalance-class numeric money capture per V33 (inventory-summary ! golden); post-scaffold next-step cmds prefer `bootstrap`, `apply config/`, `run scenario/`, `diff config/`, `state`; `` ? cwd, created if absent; per-file skip-if-exists → `skip (exists)`, exit 0, never overwrites; templates = package data via `importlib.resources`; no `git init`, no gpg +- cmd: `acu config init [--host ] []` → scaffold data repo: `.env` `ACU_*` placeholders include `ACU_BASE_URL` (`--host` seeds host, placeholder `erp.example.com`) + `ACU_API_VERSION` (placeholder code default `25.200.001`) + `ACU_TENANT` + `ACU_USER=admin` + `ACU_PASSWORD=` blank; omit `ACU_SSH` (key absent → default `Administrator@`+resolved host; hosted opt-out = present blank `ACU_SSH=`); never real secrets; header comment → `acu config show`; env comments: secrets + ssh default note + API pin; no `matrix.yaml`; no `target.yaml`; no `--flavor` — single full template under `config/{bootstrap,baseline,setup,master}/` + `scenario/` + `config/views/10-trial-balance.yaml` + short README; package content ! derive from sibling GitOps seed trees (prune non-seed extras: `demo/`, Makefile, live `.env`, committed `state/`); bootstrap company + features + credit-terms only (never scaffolds `project.xml` — package SoT per V2/V21); baseline GL chain (+ packaging rows when present in source); setup fin-year/master-calendar/open-periods; master inventory/SO/PO/AR/AP/CA surface (extended features Inventory/DistributionModule/Warehouse/WarehouseLocation); scenario lifecycle alphabetical: `10-seed-capital.yaml` (`once: true` + authored `present`) + `20-buy.yaml` + `30-build.yaml` + `40-sell.yaml` (gitops names; monoscenario `buy-sell` forbidden); state TB EndingBalance-class numeric money capture per V33 (inventory-summary ! golden); post-scaffold next-step cmds prefer `bootstrap`, `apply config/`, `run scenario/`, `diff config/`, `state`; `` ? cwd, created if absent; per-file skip-if-exists → `skip (exists)`, exit 0, never overwrites; templates = package data via `importlib.resources`; no `git init`, no gpg - cmd: `acu config check` → read-only preflight, dependency order, one `ok`/`fail`/`warn`/`skip` line each (stdout via `output.data`): discovery (walk-up finds `.env` + parse) → secrets (`ACU_PASSWORD` resolved) → REST (login + landed-tenant verify + logout) [→ endpoints Default presence via dual-shape `GET /entity` parse per V31] ∥ ssh (non-empty post-default → trivial remote via `_ssh`; empty → `skip ssh (ACU_SSH not set)`, never fail); REST + ssh probe independently when ssh non-empty; discovery/secrets fail stops; no PUTs, no tenant CRUD; never matrix probe; exit 0 when no `fail` (warns allowed); exit 1 any fail; 2 stays drift - cfg: `Instance` = pydantic-settings `BaseSettings`, env prefix `ACU_`, keys: `ACU_BASE_URL` (REST root: scheme + host + site path; flag/env when set win; else hard error naming sources), `ACU_SSH` (control plane `user@host`: flag/env when key present wins incl blank; key absent + base_url host → code default `Administrator@`+hostname; key present blank → empty = hosted/data-plane only; tenant cmds hard-error when empty post-default naming key), `ACU_TENANT`, `ACU_USER` (default `admin`), `ACU_PASSWORD`, `ACU_API_VERSION` (version half only — `/` or `Default/` prefix → hard error; name half hardcoded `Default` — custom endpoints = per-call seed `endpoint:` overrides; `--api-version` flag ? → else env → else code default `25.200.001`); other flags override env per key; unknown `ACU_*` ignored; install-layout values = `config.py` constants (`AcumaticaERP` — IIS pool coupling documented @ `recycle_app_pool` call site, `C:\Acumatica\AcumaticaERP`, `ac.exe` path, `AcumaticaDB`), not config surface - env: `.env` ? — secrets + where + pin as `ACU_*` vars (`ACU_BASE_URL`, `ACU_API_VERSION`, creds); discovery sentinel, walk-up per V3; encrypted at rest as `.env.gpg`; absent → resolution on flags + process environment; password ! resolve via `--password` or `ACU_PASSWORD`; username ? via `--username` or `ACU_USER`, default `admin`; holds Default API pin `ACU_API_VERSION` (V2/V27); never `matrix.yaml` @@ -40,7 +40,7 @@ Configure Acumatica ERP purely from source — no UI, no Configuration Wizard. I - data: `bootstrap/features.yaml` or `config/bootstrap/features.yaml` → ? list of `FeaturesSet` property names (DAC bit flags) → plugin `Enabled` set @ package build; resolve prefer `config/bootstrap/` then root `bootstrap/`; absent → built-in six (FinancialModule, FinancialStandard, DistributionModule, Inventory, Branch, MultiCompany) - data: packaged `bootstrap_project.xml` → sole Bootstrap endpoint contract SoT (entities, fields, name+version identity; full company surface: company (CS101500 commonsetup DecPlQty+WeightUOM+VolumeUOM) + credit terms + financial currency + GL chain + module prefs + vendor class + statement cycle + warehouse + order type + cash account + Role + User (identity + Roles/Users details; mapped-detail PUT ! UsersInRoles persist) + NumberingSequence + SegmentedKey + …); package_zip + symbolic `bootstrap` always load package; data-repo `config/bootstrap/project.xml` or `bootstrap/project.xml` ! seed — present → hard error on bootstrap/publish/load_contract naming package SoT; `config init` never scaffolds; shape change ! version bump per V21 - data: `config/{bootstrap,baseline,setup,master}/` → default init seed layout (apply/diff umbrella + extract hard-cut emit); V30 still honors legacy root SEED_DIRS for apply/diff read only; extract never emits root SEED_DIRS; `config/views/` co-located under `config/` but ! SEED_DIRS (! apply/diff/extract seed) -- data: `seed_catalog.yaml` → packaged extract registry (rename from `extract_manifest.yaml`); sole place naming extractable seed rows: entity, endpoint, keys, file, filter?, strip|include?, features?, kind?, phase?; apply/diff never load @ runtime — catalog = extract + CI completeness + docs; completeness per V34 +- data: `seed_catalog.yaml` → packaged extract registry (rename from `extract_manifest.yaml`); sole place naming extractable seed rows: entity, endpoint, keys, file, filter?, strip|include?, features?, kind?, phase?; apply/diff never load @ runtime — catalog = extract + CI completeness; never docs/ tree; completeness per V34 - data: `scenario/*.yaml` → scenario file: `scenario` / `description` ? / `steps` (each `id` + one op key of `put|action|wait|get` + `record` ? + `capture` ? + `wait` ?; empty steps ok — stub no-op) + `expect` ? + `once` ? (`true` ! `present`: `inquire` + `parameters` author-owned incl Period + `match` ? + `when` per-field `eq|gte` absolute gate; no filename convention; no CLI capital default; present probe pre-steps — capture `${var}` ! allowed; built-in `${current_period}` ok per V43; package templates use `${current_period}` where period tracks calendar) ; transactions never keyed upserts — `run` owns them, `apply`/`diff` never read `scenario/`; golden set = `10-seed-capital` + `20-buy` + `30-build` + `40-sell` (gitops names); parsed by `run.py` - data: `config/views/*.yaml` → view def (observer config, ! seed): `name` (output stem under `state/`) + `source` (exactly one of `gi:`|`entity:`|`inquire:` + `params` ? pinned literals in YAML — no `${current_period}` expand on state path per V43; package template keeps fixed Period for e2e/mock alignment; `inquire:` = contract inquiry entity name) + `key` (sort + identity cols, unique across rows) + `capture` allowlist + `decimals` ? default 2; stem `trial-balance` ! ≥1 numeric money field in capture (V33); inventory-summary ! golden this pass; alphabetical prefix order like `scenario/`; bare default hard-cut this tree (no `config/snapshot/` fallback); `apply`/`diff` never read `config/views/`; parsed by state engine - data: `state/*.yaml` → observation file (committed derived-state evidence, ! seed): `view` + `erp` (live build provenance) + `rows` flow-style one mapping per line (key-sorted; money = fixed-point strings @ view `decimals`); never under SEED_DIRS; never `endpoint:`; never apply path; series history = `git log -p state/` — no in-tool baseline/chart; hard-cut default `--out` `state/` @@ -63,7 +63,7 @@ V7: `CompanyConfig` ! `-h` beside `-iname` + `-dbnew:"False"`; delete uses `Dele V9: output — everything through `output.py`; stdout=data stderr=process; ASCII-only; exit 0/1/2; no bare `print()`; full audit recipe → `.spec/check-extras.md` §V.9 V10: every model inherits `models.Model` (pydantic frozen, `extra="forbid"`) — validate at boundary, unknown fields error; mechanical form: `^class .*BaseModel` outside `models.py` = violation (`.spec/scripts/check-extras.sh` scan); class w/o pydantic base = not a model, exempt V11: REST targets versioned path only (`Default/25.200.001`), never unversioned alias -V12: `docs/ac-exe.md` + `docs/rest-api.md` verified vs live 26.101.0225 — trust over training data, re-verify on upgrade; dumped schema (`acu schema`) = authoritative field reference +V12: live-verify — behavior claims ! vs live instance from `.env` (`ACU_BASE_URL`); dumped schema (`acu schema`) = field reference; trust live over training data; re-verify on ERP upgrade; never `docs/` tree as SoT V13: `make check` (ruff + basedpyright strict + offline pytest) before every commit V14: friction record — major Acumatica API friction (vendor docs wrong/missing, silent no-op write, read 500/empty w/o cause, feature gate) → gh issue on this repo: symptom, then live evidence, then workaround/fix; dead ends stay recorded (findings, not noise) V15: cmd grammar — exactly two forms: `acu [globals] [options]` = resource ops; `acu [globals] [options] [args]` = data plane; no third form; surface encodes V1 split; `survey` noun owns extract|inventory|reconcile (hard-cut L1, no aliases); extras-hook greps `src/` `@cli.command("extract"|"inventory"|"reconcile")`; verb map → `.spec/check-extras.md` §V.15 @@ -93,14 +93,14 @@ V38: reconcile-normalize — pad-trim both sides; snapshot_map aliases + FK CD V39: password-seed — User seed Password write-only @ apply when present in YAML; extract strips `Password` + `b64__Password` (never seed hashes); diff ignores password fields V40: numbering-runtime — NumberingSequence seed ! bounds + NewSymbol (`NumberingID`, `StartNbr`, `EndNbr`, `WarnNbr`, `NbrStep` + StartDate? if screen requires; NewSymbol insert-required per numbering-new-symbol invariant); `LastNbr` (+ advanced counter) = runtime state — extract strips; diff ignores; apply never requires; ! reset live counters every apply (gh #25) V41: prefs-field-depth — Bootstrap *Preferences field lists ! curated subset (demo seed need or ERP-default rebuild risk); ! full DAC mirror; each field ! seed/catalog reason; server-derived/runtime ! seed (B11 class); contract shape change ! version bump (V21) (gh #26) -V42: inventory-map-coverage — dual-reader masters ! snapshot_map table→entity so findings = real gaps; intentional unmapped ! docs table; full audit recipe → `.spec/check-extras.md` §V.42 +V42: inventory-map-coverage — dual-reader masters ! snapshot_map table→entity so findings = real gaps; intentional unmapped ! named in snapshot_map or findings, never silent; never docs/ table; full audit recipe → `.spec/check-extras.md` §V.42 V43: period-token — `acu run` `${current_period}` → host-local `MMyyyy` @ process start every `${var}` site; views/state ! expand; full audit recipe → `.spec/check-extras.md` §V.43 V44: pin-overlay — optional Default-half overlays under `overlays/default-/` keyed by resolved `api_version` (flag or `ACU_API_VERSION` or code default); config init scaffolds layout + known rewrites; bare apply/diff/run auto-compose pin overlay when path args omitted (scenario basenames replace; config SEED_DIRS append); explicit paths disable auto; never `matrix.yaml`; never `--cell`; CLI never requires long-running product branches; never commit multi-version OpenAPI trees as SoT (`acu schema` live dump/gitignored); full seed version matrices live in data repos not CLI (extends V27) V45: apply-fail-report — apply failure ! silent partial tree: either per-record continue + multi-error summary exit 1, or hard-stop w/ stopped-at file/record + remaining not-applied list exit 1; exit 2 stays drift (sibling V24 extract isolation) V46: contract-field-error — PUT/action HTTP error ! surface nested `Field.error` + kin (esp 422) and `innerException.exceptionMessage` when present (append, not only when top empty); not status-only or top exceptionMessage alone; curl not required V47: no-lifecycle-cmd — no `acu check` verb; cold rebuild = compose `tenant create` + `apply` + `run` + `diff`; `acu config check` stays preflight; never wrap pipeline as one cmd; extras-hook greps `src/` `check_cmd|_run_lifecycle|_lifecycle_|_resolve_check_tenant` → `.spec/check-extras.md` §V.47 V48: agent-help — root `acu --help` ! MENTAL MODEL (data-repo layout, REST vs SSH planes, sole writer=`apply`, drift=`diff` exit 2, txns=`run`) + TYPICAL WORKFLOW (SSH box) + HOSTED path + CONFIG RESOLUTION + COMMAND MAP by intent + EXIT CODES + DEFAULT PATHS so LLM agents learn tool from `--help` alone (README points agents @ root help); every subcommand help ! prerequisites + concrete examples + exit codes + related cmds; accepts `-h` + `--help`; help layout wide (`max_content_width` ≥ 100) -V49: md-prose-density — human-facing Markdown (`README.md`, `docs/*.md`, packaged template README) prose paragraph ≤ 2 sentences, sparse; list / table / fence exempt; SPEC.md / SPEC.archive.md / CHANGELOG.md / `.spec/*.md` out; mechanical: extras-hook `.spec/scripts/check-md-prose` via `.spec/scripts/check-extras.sh`; ≥3-sentence prose paragraph = VIOLATE +V49: md-prose-density — human-facing Markdown (`README.md`, packaged template README) prose paragraph ≤ 2 sentences, sparse; list / table / fence exempt; SPEC.md / SPEC.archive.md / CHANGELOG.md / `.spec/*.md` out; mechanical: extras-hook `.spec/scripts/check-md-prose` via `.spec/scripts/check-extras.sh`; ≥3-sentence prose paragraph = VIOLATE V50: segmented-key-seed — SegmentedKey seed ! update existing `DimensionID` only (never insert new); package seeds `INVENTORY` + `BIZACCT` one alphanumeric segment `Length` 30 (DAC max); `ACCOUNT` + `INSITE` stay `Length` 10 (`SiteCD` NVarChar(10)); never shrink `Length` after data exists; key-URL GET after PUT ! return `SegmentID` + `Length`; PUT `Length` ! persist so live InventoryID mask accepts 30; silent HTTP 200 w/ omitted detail fields = mapping miss (closes §B.28) (gh #30) V51: in-prefs-control-account — INPreferences `INProgressAcctID`/`INTransitAcctID` ! accounts w/ `ControlAccountModule: IN`; package 12300/12400 drop it; 12100/12200 keep IN control (posting-class InvtAcctID); else 26r1 PUT 500 + IN Setup cascade (closes §B.29) (gh #32) V52: company-qty-precision — Company CS101500 maps `DecPlQty` (`ShortValue`) + `WeightUOM`/`VolumeUOM` to view `commonsetup` (aspx DataMember; graph `Commonsetup`); unmapped PUT 200 ignores (B28 class); GET ! return mapped fields so extract/diff round-trip; package seed `DecPlQty: 3` (kit BOM milligram-scale KG; DAC default 2); DistributionModule on → PUT ! send WeightUOM+VolumeUOM (`CommonSetup_RowPersisting`) even when extract strips GET-omit siblings (B26 StockItem class); contract shape change ! version bump (V21) (closes §B.30) (gh #34) @@ -243,6 +243,9 @@ T256|x|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/ T257|x|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `scenario/` → repo root (exclude `qms/`, live `.env`, `state/`, customization, Makefile, schemas)|V2,I.data T258|x|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 T259|x|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 +T260|.|drop `docs/` tree; sweep remaining `docs/` cites (scope: `docs/`)|V12,V49 +T261|.|sweep host + retired-sibling literals (scope: `acu-dev1|vm\.internal|acumatica-infra|acumatica-blog|acumatica-devops`); live instance from `.env` only|V12,V27 +T262|.|extras-hook V49 + check-md-prose drop `docs/*.md` glob; README/help/CHANGELOG Unreleased no `docs/` links; suite green|V49,V12,V19 ## §B BUGS From 16a26ac6c98e6dfee5f91495c09d873e9b2541b3 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik Date: Thu, 10 Sep 2026 09:29:47 -0400 Subject: [PATCH 20/25] T260: drop docs/ tree; sweep remaining docs/ cites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the docs/ tree. Live verify stays on .env and dumped schema, not a docs/ source of truth. Sweep remaining docs/*.md cites from src/ and tests. Human-facing README and CHANGELOG Unreleased links wait for T262. §V.12 §V.49 --- SPEC.md | 2 +- docs/ac-exe.md | 266 ---------- docs/demo-seed.md | 734 -------------------------- docs/rest-api.md | 529 ------------------- src/acumatica_cli/bootstrap.py | 4 +- src/acumatica_cli/bootstrap_plugin.cs | 2 +- src/acumatica_cli/cli.py | 6 +- src/acumatica_cli/client.py | 10 +- src/acumatica_cli/firstlogin.py | 4 +- src/acumatica_cli/inventory.py | 2 +- src/acumatica_cli/tenant.py | 16 +- tests/test_cli.py | 4 +- tests/test_docs.py | 60 +-- tests/test_inventory.py | 2 +- tests/test_tenant.py | 8 +- 15 files changed, 59 insertions(+), 1590 deletions(-) delete mode 100644 docs/ac-exe.md delete mode 100644 docs/demo-seed.md delete mode 100644 docs/rest-api.md diff --git a/SPEC.md b/SPEC.md index ba80669..e32ba92 100644 --- a/SPEC.md +++ b/SPEC.md @@ -243,7 +243,7 @@ T256|x|e2e: 3 pipeline files (provision, scenario, extract); fold qty/kit-alloc/ T257|x|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `scenario/` → repo root (exclude `qms/`, live `.env`, `state/`, customization, Makefile, schemas)|V2,I.data T258|x|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 T259|x|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 -T260|.|drop `docs/` tree; sweep remaining `docs/` cites (scope: `docs/`)|V12,V49 +T260|x|drop `docs/` tree; sweep remaining `docs/` cites (scope: `docs/`)|V12,V49 T261|.|sweep host + retired-sibling literals (scope: `acu-dev1|vm\.internal|acumatica-infra|acumatica-blog|acumatica-devops`); live instance from `.env` only|V12,V27 T262|.|extras-hook V49 + check-md-prose drop `docs/*.md` glob; README/help/CHANGELOG Unreleased no `docs/` links; suite green|V49,V12,V19 diff --git a/docs/ac-exe.md b/docs/ac-exe.md deleted file mode 100644 index a2de488..0000000 --- a/docs/ac-exe.md +++ /dev/null @@ -1,266 +0,0 @@ -# ac.exe — verified CLI reference - -Distilled notes on the Acumatica Configuration Wizard CLI, covering only what -this repo uses. -**Verified against the 26.101.0225 binary on acu-dev1** -(`ac.exe -?` output) and the official docs, 2026-07-07. - -Re-verify on version upgrades — run `ac.exe -?` on the box; the binary is the ground truth. - -Sources: -- [Command-Line Tool: Possible Parameters and Values](https://help.acumatica.com/(W(8))/Wiki/Print.aspx?wikiname=HelpRoot_Install&PageID=9e64f453-3f47-4b2b-979b-3b5c22ae1f8b) -- [Command-Line Tool: General Information](https://help.acumatica.com/Help?ScreenId=ShowWiki&pageid=4f9af995-3f23-4da6-a6c0-f6435979c4c4) -- Working `NewInstance` invocation: `acumatica-infra` - `ansible/roles/acumatica/tasks/instance.yml` - -## Basics - -- Location: `C:\Program Files\Acumatica ERP\Data\ac.exe` (on the Windows - guest). Runs elevated; the infra repo runs it as Administrator over SSH. -- `-configmode:` (`-cm`) selects the operation. Modes in 26.101: - `NewInstance`, `CompanyConfig`, `DBMaint`, `DBConection`, `DeleteSite`, - `RenameSite`, `SiteMaint`, `UpgradeSite`, `NewDBLessInstance`, - `NewTrainingInstance`. -- Booleans accept `True/False` or `Yes/No`. -- `-output:"Forced"` (`-op`) makes it log to stdout — use it in automation. -- Exit code is meaningful; propagate `$LASTEXITCODE` in PowerShell wrappers. -- `-file:""` (`-f`) points at an XML configuration file exported from - the Configuration Wizard — the alternative to spelling out flags. Format - verified 2026-07-09 (decompiled `AcumaticaConfig.ConsoleOptionExtensions` - in the 26.101 binary, then generated a real file by calling the wizard's - own `SaveToFile` over PowerShell reflection): root element is literally - ``; one child element per option, named by the option's *long* CLI - name, value in a `Value` attribute (never element text), a decorative - `Description` attribute the loader ignores. Null-valued options are - omitted and fall back to built-in defaults on load; unknown elements are - silently ignored. Tenants ride in a `` element holding one - `` per tenant; RabbitMQ parameters in - ``. Two trailing write-only elements - (`ShortConsoleCommand`, `LongConsoleCommand`) carry the equivalent - command line as self-documentation. Passwords, if set, are written in - clear text. The console binary has no save flag — only the GUI wizard - writes this file. Note this file is **installer parameters only** (DB, - IIS, instance, tenant list); it contains no in-tenant ERP data. - -## Tenant management — `-cm:CompanyConfig` - -Adds new tenants or deletes existing ones in an existing instance's database. -Combine with the DB connection flags (`-dbsrvname`, `-dbname`, -`-dbsrvwinauth` / `-dbsrvuser` + `-dbsrvpass`) and one `-company` parameter -per tenant. - -**Also pass `-dbnew:"False"`** — the flag defaults to `True` -(create database), which is wrong for operating on an existing DB. - -**Two undocumented flags are both required** (verified 2026-07-08 on 26.101); -the official CompanyConfig example omits both: - -- **`-iname:""`** — the tenant scenario resolves the registered - site by name and dies with `Site with name '' doesn't exist in the system` - without it. -- **`-h:""`** (a.k.a. `-ipath`, the instance's physical folder, - e.g. `C:\Acumatica\AcumaticaERP`) — without it the *web.config* stage of the - run throws `System.ArgumentNullException: … path1` inside - `AcumaticaConfig.Installation.WebCompanyConfig()` and rolls the whole - operation back. `-iname` alone is not enough. Creating a clean tenant takes - ~40–60 s. - -More verified quirks: - -- **`LoginName` lands in `dbo.Company.CompanyKey`, not `CompanyCD`** (verified - 2026-07-08; the 2026-07-07 "it didn't take" note was wrong — I only checked - CompanyCD). `CompanyCD` is an auto-generated internal code (`Company2`, - `Company3`, …); `CompanyKey` holds the name you passed (`Scratch`) and **is** - the value the sign-in page shows and the REST login's `tenant` field - matches. So `acu tenant list` reads CompanyKey as the login name. - **`acu tenant create` aligns `CompanyCD` to the login after CompanyConfig** - (sqlcmd `UPDATE dbo.Company SET CompanyCD = …`) so list Login and CD match; - re-running create is idempotent when they already match. Collision with - another tenant's CD is a hard error. -- **The delete sub-key is `Deleted`, not the documented `Delete`** (verified - 2026-07-08). `Delete=Yes` is silently ignored — worse, the run then treats - the tenant as a data-insert target and *overwrites it*. And a delete must - carry the **full spec** (`ParentID` + `CompanyType`): with only - `CompanyID=N;Deleted=Yes;` the preflight (`dataCpnTest` → `CompanyMaint` - `.CheckAndUpdate`) reads the empty CompanyType as the system tenant and - aborts with `The system company cannot be removed.` -- **`-aun`/`-aup`/`-auc` preset the tenant's admin login/password.** Passing - `-aun:"admin" -aup:"" -auc:"False"` on a create makes the new - tenant's admin usable immediately (no `PasswordChangeOnNextLogin`), so REST - login works right after the app-pool recycle — no screen-flow first-login - dance needed. This is what `acu tenant create` does; the `Login.aspx` flow - (below) is kept only as a fallback for tenants created without these flags. -- Every run logs a harmless `InitializeOEMVersion error` / - `Could not find assembly 'mscorlib'` line at startup — it appears on exit-0 - runs too; ignore it. -- The running site does **not** see a newly created tenant (sign-in page and - REST tenant selection both miss it) — the tenant map loads at app start. An - `AcumaticaERP` app-pool recycle after CompanyConfig fixes it (**verified - 2026-07-08**: `Restart-WebAppPool -Name AcumaticaERP` made tenant 3 appear - on the sign-in page and route over REST). `acu tenant create` must run this - recycle itself after `ac.exe` returns. Operators can also run - `acu tenant recycle` alone (same SSH pool restart; no tenant `--id`). See - `rest-api.md` for the tenant-selection foot-gun that skipping the recycle - causes. -- Without the `-aup` admin-preset flags above, a newly created clean tenant - seeds `admin` with password `setup` and `PasswordChangeOnNextLogin = 1`, - which the contract REST endpoint can't clear (**verified 2026-07-08** — see - `rest-api.md`); first login then has to go through the `Login.aspx` form - flow. Presetting the password with `-aup` is the simpler route and what - `acu tenant create` uses. - -`-company:"key=value;key=value;"` sub-keys (official list): - -| Key | Meaning | -|---|---| -| `CompanyID` | Integer tenant ID. ID 1 is the hidden system/parent tenant; real tenants start at 2. Reference an existing ID to modify it. | -| `ParentID` | Parent tenant ID — `1` for normal tenants. Pass it on delete too (see below). | -| `Visible` | `Yes` → tenant appears on the sign-in page. | -| `LoginName` | Tenant name — lands in `CompanyKey`, shown on the sign-in page and matched by the REST login `tenant` field. Domain-style names (dots and hyphens, e.g. `lab5.ca-dev1`) are accepted end to end — verified 2026-07-08. Keep to alphanumerics + `.` + `-`; never `;` or `=` (the `-company` string's own delimiters). | -| `CompanyType` | Data set inserted at creation: empty = clean tenant, `SalesDemo` = demo data. (Official doc text mislabels this as a boolean; the working values are dataset names — verified by the infra deployment.) Also required on delete. | -| `Deleted` | `Yes` → delete this tenant. **Note the `d`** — the officially documented `Delete` is silently ignored (and turns the run destructive). | - -Example — add a visible clean tenant with ID 3, admin ready to use, then -delete it (both verified 2026-07-08): - -```powershell -# create — -h and the -aup admin preset are what make it automation-ready -& 'C:\Program Files\Acumatica ERP\Data\ac.exe' -configmode:"CompanyConfig" -output:"Forced" ` - -iname:"AcumaticaERP" -h:"C:\Acumatica\AcumaticaERP" ` - -dbsrvname:"(local)" -dbsrvwinauth:"True" -dbname:"AcumaticaDB" -dbnew:"False" ` - -company:"CompanyID=3;ParentID=1;Visible=Yes;LoginName=Tenant2;" ` - -aun:"admin" -aup:"" -auc:"False" - -# delete — full spec (ParentID + CompanyType), sub-key is Deleted -& 'C:\Program Files\Acumatica ERP\Data\ac.exe' -configmode:"CompanyConfig" -output:"Forced" ` - -iname:"AcumaticaERP" -h:"C:\Acumatica\AcumaticaERP" ` - -dbsrvname:"(local)" -dbsrvwinauth:"True" -dbname:"AcumaticaDB" -dbnew:"False" ` - -company:"CompanyID=3;ParentID=1;CompanyType=Custom;Deleted=Yes;" -``` - -### Available `CompanyType` datasets (verified on the box) - -The insert-data sets are the folders under -`C:\Program Files\Acumatica ERP\Database\Data`: `SalesDemo`, `System`, `T100`, -`U100`. -T100/U100 are Acumatica University training datasets (579+ table XMLs -including AP invoices and payments — transactional data, not clean baselines). - -None of them is a bootstrap shortcut for a clean client baseline. - -## Instance creation — `-cm:NewInstance` - -Owned by `acumatica-infra` (see `instance.yml` for the full working command: -DB + tenants + IIS site in one shot). Kept out of scope here; this repo -assumes the instance exists. - -## Hidden data verbs: `export` / `import` — verified 2026-07-09 - -`ac.exe -?` does not list them, but the 26.101 binary dispatches a second -command family when the first argument is a bare verb instead of a flag -(found by decompiling `ConfigStart.opcodeHandlers`): `database`, `export`, -`import`, `xmlentity`, `delta`, `util`, `mobilesitemap`, and others. The -interesting pair for config-as-code: - -```text -ac.exe export # dump one tenant's data -ac.exe import # load it back -``` - -The datasource URL is `://?` where proto -is `mssql` (also `sql`, `mysql`, `pgsql`, `file`) and params include -`companyid=N` (or `companycd=`, `parentid=`, `schema=`, `timeout=`). -Live-verified export on acu-dev1: - -```powershell -& 'C:\Program Files\Acumatica ERP\Data\ac.exe' export xml ` - 'mssql://Server=(local);Database=AcumaticaDB;Integrated Security=SSPI?companyid=2' ` - "$env:TEMP\company2-export" # exit 0 -``` - -Output: one XML file per table that has rows for that tenant (a near-empty -tenant produced 10 files; a `SalesDemo` tenant would produce hundreds). -The format is exactly the shipped-dataset format under -`C:\Program Files\Acumatica ERP\Database\Data\\` — a `` -document with a typed `
` schema header followed by -``. - -In other words, `SalesDemo` *is* an -`ac.exe export xml` dump, and `CompanyType` at tenant creation is the -import path for it. - -Caveats before treating this as an editing surface: rows are raw table -data — internal integer IDs cross-reference across files, and imports -bypass all business logic (no defaults, validations, or side effects run, -unlike REST). -The schema header is version-coupled to the build. - -`import` is **unverified** (it mutates; test on a scratch tenant first). - -### Offline dual-reader: `acu survey inventory` consumes this folder - -The `export xml` **folder** is a first-class input to `acu survey inventory` -(V35/V37 dual-reader). -The CLI normalizes it to one IR and writes -`inventory/summary.yaml` + `inventory/tables/.yaml` — **not** seed -under `config/`, never an apply path. - -Binary `export adb` / `.adb` files -are **rejected** (fail-closed named error). -Example: - -```sh -# on the Windows box (or after scp of the folder): -acu survey inventory "$env:TEMP\company2-export" -acu survey inventory --dry-run ./company2-export -acu survey reconcile # optional: inventory/ + config/ → findings/ only -# Optional data-repo snapshot_map.yaml (or package defaults): table→entity, -# key/field aliases, Account/Sub FK CD resolve — see docs/demo-seed.md -``` - -See also SM203520 Settings **XML** ZIP below (same IR after normalize). - -## Snapshots: NOT created/restored by ac.exe — verified finding - -Neither the 26.101 binary help nor the current parameter docs expose any -snapshot save/restore operation. -The Part 1 spec's assumption ("ac.exe -creates the tenant, then load a snapshot" with a CLI flag) **does not hold**. - -`acu` never creates, restores, or imports SM203520 snapshots or `.adb` -binaries (V35 sole mutator = REST `apply`). - -Options, in order of preference for this repo: - -1. **Reference data as code (primary)** — seed config through the REST API - (see [rest-api.md](rest-api.md)); fully scriptable, diffable, no snapshot - needed for the baseline. Live inverse = `acu survey extract` → `config/**`. -2. **Offline table dump for audit / gap analysis** — either: - - **`ac.exe export xml` folder** (above) — preferred automation-friendly - artifact when you have SSH to the box; feed `acu survey inventory`. - - **SM203520 Tenants screen → Settings export as XML ZIP** — UI path: - export a snapshot in **XML** (not binary `.adb`). The zip carries - `manifest.xml` plus per-table `*.xml` in the same table-XML shape as - `export xml`. `acu survey inventory path/to/settings.xml.zip` normalizes it - to the same IR. Restore/import of that snapshot remains UI-only and - is **out of scope** for this CLI (never `ac.exe import`, never - SM203520 restore). -3. **Snapshot via SM203520 as backup/restore convenience** — create/restore - stay UI-driven. Community attempts to automate screen actions through - extended endpoints are reported unreliable - ([community thread](https://community.acumatica.com/develop-customizations-288/snapshot-automation-32047)). - Do not treat restore as a GitOps building block. - -| Artifact | How obtained | `acu survey inventory` | Mutates tenant? | -| -------- | ------------ | --------------- | --------------- | -| `ac.exe export xml` folder | SSH / `ac.exe` on box | yes (folder path) | no (read-only export) | -| SM203520 Settings **XML** ZIP | UI export XML | yes (zip path) | no when only exporting | -| SM203520 / `.adb` binary | UI export binary | **no** — rejected | restore is UI-only, out of scope | - -The deprecated `PXInstanceHelper` API (`CreateTenant`/`LoadSnapshot`) is gone -in current builds — do not reference it. - -## Prior art - -- [Acumatica/AcuCustomizationUtil](https://github.com/Acumatica/ACUCustomizationUtil) — `acu` CLI (customization-package centric) diff --git a/docs/demo-seed.md b/docs/demo-seed.md deleted file mode 100644 index bb4714f..0000000 --- a/docs/demo-seed.md +++ /dev/null @@ -1,734 +0,0 @@ -# Demo seed (`acu config init`) - -Single full virgin-tenant demo seed for finance + inventory/distribution -(lab5 Demo Tenant Factory class). No `--flavor` — one packaged template set -under `config/` + lifecycle `scenario/`. - -## Rebuild order - -Start from a **brand-new empty tenant**. - -Do not apply onto a half-configured company. - -```sh -acu config init --host erp.example.com my-erp -cd my-erp -# edit .env: ACU_PASSWORD, ACU_TENANT, ACU_API_VERSION - -acu config check -acu bootstrap # publishes Bootstrap + features from config/bootstrap/ -acu apply config/ # config/{bootstrap,baseline,setup,master}/ fixed order -acu run scenario/ # 10-seed-capital → 20-buy → 30-build → 40-sell -acu diff config/ # expect exit 0 (no drift) -acu state # write state/ from config/views/ (EndingBalance TB) -# warm gate: once-capital only — additive buy/sell would move numeric observations -acu run scenario/10-seed-capital.yaml && acu state --assert-unchanged -``` - -Bare `apply` / `diff` (no path args) also prefer `config//` when those trees exist (V30). - -`apply` continues after a failed record (V45): later records and files still -run; exit 1 with a multi-error summary when any PUT failed (never silent -partial; exit 2 stays with `diff`). Field-level 422 detail surfaces in the -error text (V46). - -### Multi-host trunk + overlays (V44) - -Keep one trunk seed; pin each host with `.env` (`ACU_BASE_URL` + -`ACU_API_VERSION`); put version-specific rewrites under a data-repo overlay -directory and pass it after trunk: - -```sh -acu apply config/ overlays/default-24.200.001/ -``` - -No long-running release branches for version fan-out. -No `apply --overlay` flag (data-repo path args are enough). - -Cross-link: -[acumatica-gitops#2](https://github.com/kborovik/acumatica-gitops/issues/2). - -Legacy data repos may still use root `bootstrap/`…`master/`; init no longer scaffolds that layout. -`acu survey extract` never writes that root layout — emit is hard-cut to `config/` only (see [Extract](#extract)). - -## Extract - -`acu survey extract` is the **inverse of apply** for seed trees: read the live tenant, write seed YAML under `config/{bootstrap,baseline,setup,master}/` that `apply` / `diff` consume unchanged. - -| Concern | Rule | -| ------- | ---- | -| Registry | Packaged `seed_catalog.yaml` (not operator-authored); every catalog `file:` path is under `config/` | -| Features | Synthesized `config/bootstrap/features.yaml` from catalog `features:` gates (not a catalog entity row) | -| Setup | Catalog `kind:` synthesizers rebuild action files (not raw record dumps) | -| Multi-file same entity | Filter-split or include-partition (one catalog row per numbered file) | -| Skip / fail | Exists: skip unless `--force`; zero records: skip; row fail: report and continue; exit 1 if any fail | -| Not seed | `scenario/` and `config/views/` are never extract targets | - -```sh -acu survey extract --out . --force # full catalog → config/** -acu survey extract --only StockItem --force # catalog rows matching entity or file stem -acu apply config/ && acu diff config/ # round-trip: expect clean diff after apply -``` - -**Migration (root emit to `config/`):** older extract wrote `bootstrap/`…`master/` at the data-repo root. -That path is gone (no `--layout`). - -Re-extract into a `config/` data repo, or move root trees under `config/` before the next apply. -Package data renamed `extract_manifest.yaml` becomes `seed_catalog.yaml`. - -## State observations - -`acu state` captures **derived state** (not seed config): - -| Artifact | Path | Role | -| -------- | ---- | ---- | -| View defs | `config/views/*.yaml` | Observer config (`inquire:` / `entity:` / `gi:`, keys, capture allowlist; not SEED_DIRS) | -| Observations | `state/*.yaml` | Committed evidence; flow-style one row per line; money/qty = fixed-point strings | - -Unlike `extract` / `diff`, `state` never writes seed trees, never carries `endpoint:` symbols, and never participates in apply. Exit codes invert `diff`'s default: bare capture treats change as normal (exit 0); exit 2 only under `--assert-unchanged` when state moved. - -**Migration (T112–T114):** CLI verb is `state` only (no `snapshot` alias). -Bare defaults hard-cut to `config/views/` and `state/`. - -Prior `config/snapshot/` is not a fallback — move views or pass explicit paths. - -### Period token (`${current_period}`) vs pinned views - -| Surface | Period handling | -| ------- | --------------- | -| `acu run` scenario YAML | Built-in `${current_period}` → host-local `MMyyyy` at process start (V43). Expands on steps, `expect` params, and `once.present` params. Package lifecycle scenarios use the token so month roll does not require hand-editing inquire Period. | -| `acu state` / `config/views/` | **Pinned literals only.** No `${current_period}` expand (V33). Package TB view keeps a fixed Period for e2e/mock alignment and reproducible committed `state/` rows. Operators hand-set Period when capturing the live current-month window. | - -Why the split: scenario expects measure the period transactions just posted (calendar-relative). Committed observations under `state/` are git evidence — expanding a token at capture time would rewrite files every month without a real ERP change. - -**ERP business-date skew:** the token uses the **host** local date, not an ERP business-date probe and not a `--as-of` / `--period` flag (v1). If the ERP business date differs from the host calendar, set Period explicitly in the scenario or align the host date. - -```yaml -# scenario expect / once.present (calendar-relative) -parameters: {Ledger: ACTUAL, Period: "${current_period}"} - -# config/views (pinned for reproducible state/) -params: {Ledger: ACTUAL, Period: "082026"} -``` - -## Dual readers: inventory vs extract vs state - -Three different products — same tenant, different jobs (V35 dual-reader single-writer): - -| | `extract` | `inventory` | `state` | -| - | --------- | ----------- | ------- | -| Needs live REST | yes | **no** (offline) | yes | -| Input | contract entities (catalog) | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `config/views/*.yaml` | -| Output | seed under `config/**` | `inventory/summary.yaml` + `tables/
.yaml` | `state/.yaml` | -| Shape | seed (`entity` / `key` / `records` / `endpoint:`) | raw DAC tables (columns + rows) | observations (keys + capture cols) | -| Apply path? | yes — inverse of `apply` | **never** | **never** | -| Who mutates tenant | — | — | — | - -Sole tenant mutator remains `apply` (keyed PUT). Snapshot restore / `ac.exe import` / binary `.adb` are out of scope. - -```sh -# Live seed inverse (REST) -acu --tenant DEV survey extract --out . --force - -# Offline full-table read (artifact from SM203520 Settings export XML or ac.exe) -acu survey inventory ./tenant-export.xml.zip # or a folder from: ac.exe export xml … -acu survey inventory --dry-run ./company2-export/ # would-write only -acu survey reconcile # inventory/ + config/ → findings/ -acu survey reconcile --inventory inv/ --config config/ --out findings/ - -# Derived balances (REST observers; not inventory) -acu --tenant DEV state -``` - -`reconcile` never writes `config/` (V36): unmapped tables, REST gaps, rest-vs-snapshot field deltas, and custom `Usr*` columns land under `findings/` only. -`inventory` records artifact `erp` in `summary.yaml` when the source exposes it; it never erp-matches a config pin (V37). - -`config init` does not scaffold `inventory/` or `findings/` — engagement-generated (gitignored by the scaffolded `.gitignore`). - -### `snapshot_map.yaml` (table→entity + normalize) - -Join is always **table↔entity** (never seed filename↔inventory filename). Load -order: data-repo root `snapshot_map.yaml` if present, else package defaults, -else empty → identity match on catalog entity name. v1 rows stay -`{table, entity}` only. - -Beyond renames, the map (and package defaults) support V38 normalize: - -| Layer | YAML | Effect | -| ----- | ---- | ------ | -| Pad-trim | (always on) | string keys + fields strip both sides so NVarChar padding joins | -| Key/field aliases | per-row `keys:` / `fields:` | seed name → inventory column (e.g. `SubaccountCD: SubCD`, `UnitID: Unit`, `Description: Descr`) | -| FK CD resolve | global `resolvers:` + per-row `resolves:` | inv int id → CD via inventory index (prefer compare in seed CD space); package ships Account+Sub (+ Branch) for ReasonCode/VendorClass/PostingClass/CashAccount/OrderType freight | -| Enum labels | global `enums:` + per-row `enums:` | REST label → DAC code (e.g. `Usage: reason_usage`, `Active: bool_bit`, Account `Type`/`PostOption`) | - -```yaml -# snapshot_map.yaml (excerpt — package ships defaults) -resolvers: - account_cd: { table: Account, id: AccountID, cd: AccountCD } - sub_cd: { table: Sub, id: SubID, cd: SubCD } - -enums: - reason_usage: { Adjustment: A, Issue: I, Receipt: R } - bool_bit: { "true": "1", "false": "0" } - -tables: - - table: Sub - entity: Subaccount - keys: { SubaccountCD: SubCD } - enums: { Active: bool_bit } - - table: UnitOfMeasure - entity: UnitsOfMeasure - keys: { UnitID: Unit } - fields: { Description: Descr } - - table: ReasonCode - entity: ReasonCode - resolves: { AccountID: account_cd, SubID: sub_cd } - enums: { Usage: reason_usage } -``` - -Without aliases, renamed DACs clear **unmapped** but skip row compare (key -names miss). -Without resolvers, ReasonCode/VendorClass report false -CD-vs-int deltas. - -Without enums, Usage/Account Type/Active report false -label-vs-code deltas. -Findings report seed-side field names and resolved -inventory values (codes after enum fold). - -### Inventory table → entity → endpoint (V42) - -Package `snapshot_map.yaml` (and identity match when table name equals catalog -entity). Endpoint column = catalog symbolic contract for the entity (not the -inventory transport — inventory is always offline XML). - -| Inventory table | Entity | Endpoint | Notes | -| --------------- | ------ | -------- | ----- | -| `Account` | Account | default | Type/PostOption/Active enums | -| `Sub` | Subaccount | default | `SubaccountCD`→`SubCD` | -| `Terms` | CreditTerms | bootstrap | Due/Disc/Visible enums | -| `UnitOfMeasure` | UnitsOfMeasure | default | `UnitID`→`Unit` | -| `OrganizationLedgerLink` | LedgerCompany | bootstrap | | -| `GLSetup` / `INSetup` / `APSetup` / `ARSetup` / `SOSetup` / `POSetup` / `CASetup` | module Preferences | bootstrap | bool_bit policy bits | -| `ReasonCode` | ReasonCode | bootstrap | Usage enum + Account/Sub FK | -| `INPostClass` | PostingClass | bootstrap | *Acct/*Sub FK resolve | -| `INAvailabilityScheme` | AvailabilityCalculationRule | bootstrap | | -| `INItemClass` | ItemClass | default | `ClassID`→`ItemClassCD`; ItemType/ValMethod | -| `INSite` | Warehouse | bootstrap \| default | `WarehouseID`→`SiteCD` | -| `INLocation` | Warehouse | bootstrap \| default | bin rows; detail lists not field-compared | -| `InventoryItem` | StockItem | default | `InventoryID`→`InventoryCD`; ItemStatus | -| `INKitSpecHdr` | KitSpecification | default | | -| `SOOrderType` | OrderType | bootstrap | Freight FK | -| `ARStatementCycle` | StatementCycle | bootstrap | PrepareOn | -| `CashAccount` | CashAccount | bootstrap | Account/Sub/Branch FK | -| `PaymentMethod` | PaymentMethod | default | Descr/IsActive/UseFor*/PaymentType | -| `PaymentMethodAccount` | PaymentMethod | default | detail cash accounts | -| `VendorClass` | VendorClass | bootstrap | *Acct/*Sub FK | -| `CustomerClass` | CustomerClass | default | `ClassID`→`CustomerClassID` + AR/Sales FK | -| `Vendor` | Vendor | default | identity; CD on `BAccount` (v1 no multi-table join) | -| `Customer` | Customer | default | identity; CD on `BAccount` (v1 no multi-table join) | -| `Roles` / `Users` / `UsersInRoles` | Role / User | bootstrap | membership via `AssignUser` | -| `NumberingSequence` / `Numbering` | NumberingSequence | bootstrap | bounds + NewSymbol (V40/V58) | -| `Company` / `Ledger` / `TaxCategory` | (identity) | bootstrap or default | table name = entity; no map row required | - -#### Intentionally unmapped (findings noise, not CaC gaps) - -| Class | Examples | -| ----- | -------- | -| Txn / history | `INTran*`, `GLTran`, `APInvoice`, `ARPayment`, `Batch`, `PO*`, `SO*`, cost/status hist | -| System / audit | `Note`, `LoginTrace`, `ScreenPreferences` (when present) | -| Features via plugin | `FeaturesSet` (bits applied by Bootstrap plugin, not seed table) | -| Party CD host | `BAccount` — natural CD for Vendor/Customer; v1 maps extension tables only | -| Party address/contact | `Location`, `Contact`, `Address` (linked entities, not flat seed headers) | -| Lot/serial | `INLotSerClass` — demo non-goal (see [LotSerialClass](#lotserialclass--non-goal-gh-27--v42)) | -| Kit/detail noise | `INKitSpecStkDet`, `INUnit`, site-status / cost tables | - -Artifact notes and the SM203520 / `ac.exe export xml` distinction: [ac-exe.md](ac-exe.md). - -Default scaffold golden is **trial-balance only** (V28/V33): - -| Stem | Source | Capture (must be numeric money) | -| ---- | ------ | ------------------------------- | -| `trial-balance` | `inquire: AccountSummaryInquiry` | `EndingBalance` (+ balance columns) | - -Roster-only `entity: Account` is forbidden for that stem. -`inventory-summary` / `QtyOnHand` is not packaged this pass (B25: -`InventorySummaryInquiry` warehouse-only yields empty Results). - -Custom views via `inquire:` or `gi:` remain supported when a V12-verified path is known. -`gi:` stays optional when a GenericInquiry is V12-verified: enable **Expose via -OData** on SM208000, seed the GI under `config/master/`, point `source.gi:` with -params pinned in YAML (`$metadata` fail-closed). - -See [rest-api.md](rest-api.md) state view sources. - -Warm `--assert-unchanged` after a full `run scenario/` fails on purpose when -buy/sell re-stack cash on the TB — re-run only once-class capital (skip path) -or re-capture without mutation. - -## Once vs additive scenarios - -| File | Class | Behavior on warm re-run | -| ---- | ----- | ----------------------- | -| `scenario/10-seed-capital.yaml` | once | `once: true` + `present` inquire on Owner Capital 30000 `EndingBalance gte 50000`; skip when already present | -| `scenario/20-buy.yaml` | additive | re-runs; per-leg delta expects | -| `scenario/30-build.yaml` | additive | kit assembly; re-runs | -| `scenario/40-sell.yaml` | additive | re-runs; per-leg delta expects | - -Warm capital non-stack proof (live or e2e): - -```sh -acu --tenant DEV run scenario/ # cold: capital posts 50k -acu --tenant DEV run scenario/ # warm: skip 10-seed-capital; capital stays 50000 -# optional absolute check after second run: -# AccountSummaryInquiry Account=30000 EndingBalance must remain 50000 (not 100000) -``` - -Offline unit tests cover the once skip path (`tests/test_run.py::test_once_skip_when_present`). - -Live virgin-tenant scenario + state chain: `make e2e FILE=test_scenario_lifecycle`. - -## Apply-order and dependency notes - -Numbered file prefixes encode order within each directory (alphabetical expansion). - -Cross-directory order is fixed: bootstrap, then baseline, then setup, then master (umbrella expand under `config/`). - -| Phase | Must exist before later files | -| ----- | ----------------------------- | -| Features | `config/bootstrap/features.yaml` enables Inventory, DistributionModule, Warehouse, WarehouseLocation, KitAssemblies, SubAccount, … | -| Segmented keys | `config/bootstrap/segmented-key.yaml` raises INVENTORY + BIZACCT Length 30 before master StockItem / Vendor / Customer — see [Segmented keys](#segmented-keys) | -| Company | Org CD **LAB5**; `DecPlQty: 3` plus persist UOMs — see [Company quantity precision](#company-quantity-precision) | -| COA / GL | Expanded accounts for inventory, in-transit, PO accrual, PPV/LCV, freight, discounts | -| Setup calendar | Financial year, then master calendar, then open periods for LAB5 | -| Numbering | `05-numbering-sequences.yaml` before any prefs that may set `*NumberingID` — see [Numbering sequences](#numbering-sequences) | -| Master prefs | Reason codes and IN prefs before warehouse; warehouse before item classes and stock items | -| Parties | Vendor/customer classes before vendors/customers | -| Roles / users | `90-roles.yaml` then `91-users.yaml` then `92-role-users.yaml` (Role then User then Role.Users; persist `AssignUser`) — see [Role, User, and password seed](#role-user-and-password-seed) | -| Scenario | Runs only after master apply; capital once-guard then additive buy/build/sell | - -Feature closure: every feature-gated form used by a seed file must appear in `config/bootstrap/features.yaml`. - -Reference closure: every foreign key must resolve to a tenant-native row or an earlier-sorting seed file. - -## Company quantity precision - -Quantity decimal places live on CS101500 `CommonSetup.DecPlQty` (aspx DataMember `commonsetup`). -Bootstrap Company maps `DecPlQty` (`ShortValue`) plus persist `WeightUOM` and `VolumeUOM`. - -Identity fields stay on `config/bootstrap/company.yaml`. -Qty and UOMs ride `config/baseline/91-company-packaging.yaml` after `90-uoms.yaml` so KG and LITER exist before `CommonSetup_RowPersisting`. - -Package seed sets `DecPlQty: 3` so kit BOM quantities such as `0.012` KG store without rounding to `0.01`. -The DAC default is 2. - -DistributionModule on requires `WeightUOM` and `VolumeUOM` on the same packaging Company PUT. -Extract keeps those fields on the packaging file when GET returns them; StockItem still omits GET-omit UOMs (B26). - -Republish AcuBootstrap after a contract upgrade so the 1.7.0 maps are live. - -```yaml -# config/baseline/91-company-packaging.yaml (shape) -entity: Company -key: AcctCD -endpoint: bootstrap -records: -- AcctCD: LAB5 - DecPlQty: 3 - WeightUOM: KG - VolumeUOM: LITER -``` - -Unmapped `DecPlQty` PUT answers 200 and ignores the field (B30). - -## IN control accounts (V51) - -Posting-class inventory assets (`12100`, `12200`) stay `ControlAccountModule: IN`. -WIP (`12300`) and in-transit (`12400`) are not IN control so IN Setup can use them as `INProgressAcctID` / `INTransitAcctID`. - -Marking those two as IN control makes the INPreferences PUT return 500 on 26r1 and blocks warehouse, item, and kit apply (B29). - -| AccountCD | Description | ControlAccountModule | Used as | -| --------- | ----------- | -------------------- | ------- | -| `12100` | Inventory - Components | IN | posting-class PARTS `InvtAcctID` | -| `12200` | Inventory - Finished Goods | IN | posting-class KITS `InvtAcctID` | -| `12300` | Work In Progress | (none) | INPreferences `INProgressAcctID` | -| `12400` | Inventory In Transit | (none) | INPreferences `INTransitAcctID` | - -## Role, User, and password seed - -Default contract has **no** Role or User surface. -Both live on the **Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, currently `Bootstrap/1.11.0`). - -Screens: Role = SM201005, User = SM201010. - -### Apply order (V22) - -Numbered prefixes under `config/master/` enforce **Role → User → Role.Users**: - -| File | Entity | Notes | -| ---- | ------ | ----- | -| `config/master/90-roles.yaml` | Role | key `Rolename`; fields `Rolename`, `Descr` | -| `config/master/91-users.yaml` | User | key `Username`; identity fields; optional GET/extract detail `Roles` (diff skips) | -| `config/master/92-role-users.yaml` | Role | key `Rolename`; detail `Users` (`detail_keys: { Users: Username }`) | - -Membership is the Role detail list `Users`, each row `Username` only (no `Selected`). -User detail `Roles` is GET/extract shape; a User PUT of that list does not write `UsersInRoles`. - -A role must exist (tenant-native or earlier Role seed) before `AssignUser` names it. -A user must exist before `92-role-users.yaml` names that `Username`. - -Package demo: full pre-build role dump in `90-roles.yaml`, demo user `soadmin` identity in `91-users.yaml`, and `SO Admin` to `soadmin` in `92-role-users.yaml`. -Built-in system roles (Administrator, Customizer, …) stay as identity/header rows for reference closure; access-rights matrix beyond role header is out of scope. - -### User role membership persist (V53) - -Mapped-detail PUT never writes `UsersInRoles`. -`User.Roles` (`RolesByUser`) and `Role.Users` (`UsersByRole`) both return 200 and leave SQL empty (B31, B32, B33). - -T189 recorded empty GET as a docs limit. -The write is a no-op on every mapped detail tried (AllowedRoles, RolesByUser, RoleList, UsersByRole). - -Apply persists membership by invoking Role action `AssignUser` after the Role PUT of `92-role-users.yaml`. -The plugin inserts `UsersInRoles` via PXDatabase on the session tenant CompanyID. - -Persist is additive-only. -Rows with `delete: true` are skipped; extra live members are not removed. - -SQL `UsersInRoles` on the session CompanyID is the proof. - -Mapped GET of `Role.Users` / `User.Roles` may stay `[]` after AssignUser. -`acu diff` skips those details so empty GET is not missing-on-tenant. - -Extract still skips identity-only `92-role-users.yaml` when Users GET is empty. - -Republish AcuBootstrap after upgrade so Bootstrap 1.11.0 (`AssignUser` + NumberingSequence `NewSymbol`) is live. -User seeds that still send `Selected` will 422. - -| Path | Practical rule | -| ---- | -------------- | -| **apply** | Role then User then Role.Users. Membership writes via `AssignUser`, not User.Roles PUT. | -| **diff** | Identity User/Role fields round-trip. Diff skips `Users` / `Roles` details (V57); empty mapped GET is not exit 2. | -| **Data repos** | Seed identity on `91-users.yaml`. Seed membership on `92-role-users.yaml` (`Users: [{Username}]`). No `Selected`. | -| **Package template** | `soadmin` identity in `91-users.yaml`; `SO Admin` to `soadmin` in `92-role-users.yaml`. | - -Not a password issue (V39). -Not fixed by remapping `User.Roles` to `RolesByUser` alone. - -### Password rule (V39) - -| Path | Behavior | -| ---- | -------- | -| **apply** | `Password` is **write-only** when present in seed YAML. Virgin create (no live User) sends it; **warm** re-apply (User already live) **strips** password fields so identity re-PUT does not reset passwords. Package User seed needs no password. | -| **extract** | Always strips `Password` and `b64__Password` — hashes never enter seed (even if a catalog `include` mistakenly listed them). | -| **diff** | Ignores password fields — seed with or without `Password` vs live hash is never drift. | - -```yaml -# config/master/91-users.yaml (shape) -entity: User -key: Username -endpoint: bootstrap -detail_keys: - Roles: Rolename -records: -- Username: soadmin - FirstName: SO - LastName: Admin - Email: soadmin@example.com - IsApproved: true - PasswordNeverExpires: true - PasswordChangeOnNextLogin: false - # Password: optional; write-only on virgin create only - Roles: - - Rolename: SO Admin -``` - -```yaml -# config/master/92-role-users.yaml (shape) — persist path -entity: Role -key: Rolename -endpoint: bootstrap -detail_keys: - Users: Username -records: -- Rolename: SO Admin - Users: - - Username: soadmin -``` - -Authoring a first-login password: put `Password` on the User record for the -initial apply only. -After that, omit it — warm re-apply and extract stay -identity-only. - -Never commit password hashes from SM203520 / inventory dumps. - -## Numbering sequences - -Default contract has **no** Numbering Sequences surface. -Sequences live on the -**Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, -currently `Bootstrap/1.11.0`). - -Screen: CS201010 (Numbering Sequences). - -Header view `Header` (DAC `Numbering`) holds `NumberingID`, `Descr`, and -`NewSymbol` (New Number Symbol). -Detail view `Sequence` holds bounds. - -### Apply order (V22) - -Numbered prefix under `config/master/` places sequences **before** every module -prefs file that may reference a sequence id via `*NumberingID` (IN/SO/PO/AR/AP/CA -setup and kin): - -| File | Entity | Notes | -| ---- | ------ | ----- | -| `config/master/05-numbering-sequences.yaml` | NumberingSequence | key `NumberingID`; bounds + `NewSymbol` | -| later `20-in-preferences.yaml`, `56-so-preferences.yaml`, … | module prefs | may point `*NumberingID` at a sequence id | - -Package demo pins LAB5-class module sequences (`APBILL`, `APPAYMENT`, -`ARINVOICE`, `ARPAYMENT`, `BATCH`, `INADJUST`, `INISSUE`, `INKITASSY`, -`INRECEIPT`, `POORDER`, `PORECEIPT`, `SOORDER`, `SOSHIPMENT`) as bounds + -`NewSymbol: ` rows. A custom sequence id used by a prefs seed must exist -(tenant-native or earlier NumberingSequence seed) before that prefs PUT. - -Insert of a new NumberingID needs Header `NewSymbol: ` (auto-numbering). -Re-apply of an existing id still succeeds (gh #44 / V58). - -### Bounds vs runtime (V40 / V58) - -Seed is **bounds + NewSymbol**. Issued progress is runtime state and never -desired config: - -| Field class | Fields | Seed? | -| ----------- | ------ | ----- | -| Identity + bounds | `NumberingID`, `StartNbr`, `EndNbr`, `WarnNbr`, `NbrStep`, `StartDate`?, `Descr`? | yes | -| Insert-required seed | `NewSymbol` (auto-numbering ``) | yes | -| Runtime counter | `LastNbr` (+ advanced counter if ever exposed) | **never** | - -| Path | Behavior | -| ---- | -------- | -| **apply** | PUT bounds + `NewSymbol`. `LastNbr` is never sent — even if hand-authored seed includes it — so re-apply does not reset live counters. | -| **extract** | Always strips `LastNbr` (hard strip even if include mistakenly lists it). Emits `NewSymbol` when GET returns it. | -| **diff** | Ignores `LastNbr`. `NewSymbol` is compared; GET-omit is `not returned by endpoint` drift. | - -```yaml -# config/master/05-numbering-sequences.yaml (shape) -entity: NumberingSequence -key: NumberingID -endpoint: bootstrap -records: -- NumberingID: BATCH - NewSymbol: '' - StartNbr: '000000' - EndNbr: '999999' - WarnNbr: '999990' - NbrStep: 1 - StartDate: '1900-01-01' - # LastNbr: never seed — runtime issued progress (V40) -``` - -Do not author `LastNbr` in seed. Scenario documents that advance counters are -out of scope for apply; warm re-apply of bounds must leave live `LastNbr` -alone. - -Author `StartDate` as date-only (`YYYY-MM-DD`). Live DateTimeValue on the -same calendar day is not drift; a different day still is. - -## Segmented keys - -Default contract has **no** Segmented Keys surface. -Keys live on the **Bootstrap** endpoint only (`endpoint: bootstrap` leads to the active package version, currently `Bootstrap/1.11.0`). - -Screen: CS202000 (`DimensionMaint`). -Header view `Header` (DAC `Dimension`), key `DimensionID`; detail view `Detail` (DAC `Segment`) fields `SegmentID` + `Length`. - -Seed **updates existing** `DimensionID` rows only. -Never insert a new dimension. - -### Apply order (V22) - -The file sits under `config/bootstrap/` so it applies before master StockItem, Vendor, and Customer: - -| File | Entity | Notes | -| ---- | ------ | ----- | -| `config/bootstrap/segmented-key.yaml` | SegmentedKey | key `DimensionID`; `Length` on segment 1 | - -Package demo raises `INVENTORY` and `BIZACCT` segment 1 to `Length` 30 (DAC max). -`ACCOUNT` and `INSITE` stay `Length` 10 (`SiteCD` is `NVarChar(10)`) and are not in the seed file. - -### Length rules (V50) - -| Rule | Detail | -| ---- | ------ | -| Update existing only | Never insert a new `DimensionID` | -| DAC max | One alphanumeric segment, `Length` 30 for `INVENTORY` + `BIZACCT` | -| Leave alone | `ACCOUNT` and `INSITE` stay 10 | -| Never shrink | Do not lower `Length` after data exists | - -```yaml -# config/bootstrap/segmented-key.yaml (shape) -entity: SegmentedKey -key: DimensionID -endpoint: bootstrap -records: -- DimensionID: BIZACCT - SegmentID: 1 - Length: 30 -- DimensionID: INVENTORY - SegmentID: 1 - Length: 30 -``` - -A 26-character `InventoryID` (for example `FG-CARDIO-OMEGA-COQ10-60SG`) PUTs only after this seed is applied. -The InventoryID mask may stay cached until an app-pool recycle (`acu tenant recycle`). - -Silent HTTP 200 that omits `SegmentID` / `Length` on the later key-URL GET is a contract mapping miss (B28). - -### Non-goals (gh #30 / V50) - -| Skip | Why | -| ---- | --- | -| Shrink `Length` | Existing IDs can exceed the new (smaller) mask | -| Widen `ACCOUNT` | Chart of accounts segment stays 10 | -| Widen `INSITE` | `SiteCD` is `NVarChar(10)` | - -## Module preferences field depth (V41) - -Bootstrap `*Preferences` entities are a **curated subset** of inventory -`*Setup` tables — not a full DAC mirror (gh #26). -Each seed/catalog field has -a reason: demo need or rebuild risk when virgin ERP defaults drift across -builds. - -Server-derived and runtime fields stay out (B11 class). - -Active package: `Bootstrap/1.4.0` (shape change = version bump, V21). - -### Added fields (beyond pre-1.3.0 surface) - -| Entity | Screen | Added fields | Reason | -| ------ | ------ | ------------ | ------ | -| GLPreferences | GL102000 | `BatchNumberingID`, `AutoPostOption`, `HoldEntry`, `RequireControlTotal` | pin BATCH + post/hold policy vs virgin defaults | -| INPreferences | IN101000 | `BatchNumberingID`, `ReceiptNumberingID`, `IssueNumberingID`, `AdjustmentNumberingID`, `KitAssemblyNumberingID`, `AutoPost`, `SummPost`, `NegQty`, `RequireControlTotal` | LAB5 numbering + post/qty/control policy | -| APPreferences | AP101000 | `BatchNumberingID`, `InvoiceNumberingID`, `CheckNumberingID`, `AutoPost`, `RequireControlTotal`, `RequireVendorRef`, `RequireApprovePayments` | bill/payment sequences + vendor-ref scenario needs | -| ARPreferences | AR101000 | `BatchNumberingID`, `InvoiceNumberingID`, `PaymentNumberingID`, `AutoPost`, `RequireControlTotal`, `RequireExtRef`, `CreditCheckError` | invoice/payment sequences + credit policy | -| SOPreferences | SO101000 | `ShipmentNumberingID`, `CreditCheckError` | SOSHIPMENT pin + credit check | -| POPreferences | PO101000 | `RegularPONumberingID`, `ReceiptNumberingID`, `AutoReleaseAP` | POORDER/PORECEIPT pin; AP auto-release off | -| CAPreferences | CA101000 | `BatchNumberingID`, `AutoPostOption`, `ReleaseAP`, `ReleaseAR` | batch + cash release policy | - -Pre-existing surfaces kept: GL accounts; IN transit/progress/reason codes / -`UpdateGL` / `HoldEntry`; SO default order type / hold-shipment / auto-release -IN; PO hold receipts / return reason / auto-release IN; CA transit account/sub -/ `HoldEntry`. - -### Explicitly not seeded (V41) - -| Skip class | Examples | Why | -| ---------- | -------- | --- | -| Apply-order FKs | IN `DfltLotSerClassID`, `TransitSiteID`, `DfltPostClassID`; AP/AR default class | Prefer files sort **before** warehouse / lot class / posting class / vendor-customer class | -| Unsequenced numbering | CA `TransferNumberingID` / `RegisterNumberingID`; IN `PINumberingID` | Sequence ids not in package `05-numbering-sequences.yaml` | -| Full-DAC noise | aging buckets, retainage, print flags, assignment maps | Not demo-critical; rebuild risk low vs maintenance cost | - -### Apply order vs numbering / warehouse / lot class - -| Step | Must exist first | -| ---- | ---------------- | -| `05-numbering-sequences.yaml` | every prefs `*NumberingID` claim | -| `10-reason-codes.yaml` | IN/PO reason-code FKs | -| `20-in-preferences.yaml` | before warehouse (IN prefs do **not** claim site/lot/posting defaults) | -| warehouse / posting class / item class | stock items — not prefs FKs this pass | - -If you later seed `DfltLotSerClassID` or `TransitSiteID`, renumber master -prefixes so lot class / warehouse sort **before** IN preferences. - -### LotSerialClass — non-goal (gh #27 / V42) - -Demo does **not** seed lot/serial classes and does **not** claim -`DfltLotSerClassID` on IN preferences (V41 apply-order skip stands). - -| Surface | Status | -| ------- | ------ | -| `seed_catalog.yaml` row for Default `LotSerialClass` | **not packaged** | -| Package template under `config/master/` | **none** | -| Inventory table `INLotSerClass` | intentionally **unmapped** in `snapshot_map` (system `DEFAULT` only on LAB5-class) | -| Bootstrap bump for lot/serial | **out of scope** this pass | - -When a data repo later seeds lot/serial classes, add a catalog row + numbered -master template **before** any IN prefs FK claim, map `INLotSerClass` → -`LotSerialClass`, and re-check V34 completeness. - -Auto-Incremental `Segments.Value` is write-only GET-omit. Apply PUTs it when -present; `acu diff` does not treat a missing live `Value` as -`not returned by endpoint`. - -## Entity map - -**Catalog mirror:** seed-file paths and entity/endpoint pairs below match packaged -`seed_catalog.yaml` (templates ↔ catalog ↔ extract emit; V34). -Screen IDs are operator notes only (not catalog fields). - -`scenario/` rows are `acu run` only — not in the catalog, not extracted. - -| Seed file | Entity / action | Endpoint | Screen | Extract | -| -------- | --------------- | -------- | ------ | ------- | -| `config/bootstrap/company.yaml` | Company | bootstrap | CS101500 | catalog | -| `config/bootstrap/credit-terms.yaml` | CreditTerms | bootstrap | CS206500 | catalog | -| `config/bootstrap/features.yaml` | FeaturesSet gates (synthesized) | — | CS100000 | synth | -| `config/bootstrap/segmented-key.yaml` | SegmentedKey | bootstrap | CS202000 | catalog | -| `config/baseline/10-subaccounts.yaml` | Subaccount | default | GL203000 | catalog | -| `config/baseline/20-accounts.yaml` | Account | default | GL202500 | catalog | -| `config/baseline/40-ledger.yaml` | Ledger | default | GL201500 | catalog | -| `config/baseline/50-gl-preferences.yaml` | GLPreferences | bootstrap | GL102000 | catalog | -| `config/baseline/60-ledger-company.yaml` | LedgerCompany | bootstrap | GL201500 | catalog | -| `config/baseline/90-uoms.yaml` | UnitsOfMeasure | default | CS203500 | catalog | -| `config/baseline/91-company-packaging.yaml` | Company (DecPlQty + persist UOMs) | bootstrap | CS101500 | catalog | -| `config/setup/10-financial-year.yaml` | FinancialYearSettings / GeneratePeriods | bootstrap | GL101000 | setup synth | -| `config/setup/20-master-calendar.yaml` | MasterCalendar / GenerateCalendar | bootstrap | GL201000 | setup synth | -| `config/setup/30-open-periods.yaml` | ManagePeriods / ProcessAll | bootstrap | GL503000 | setup synth | -| `config/master/05-numbering-sequences.yaml` | NumberingSequence | bootstrap | CS201010 | catalog | -| `config/master/10-reason-codes.yaml` | ReasonCode | bootstrap | CS211000 | catalog | -| `config/master/20-in-preferences.yaml` | INPreferences | bootstrap | IN101000 | catalog | -| `config/master/30-availability-rules.yaml` | AvailabilityCalculationRule | bootstrap | IN201500 | catalog | -| `config/master/40-posting-classes.yaml` | PostingClass | bootstrap | IN206000 | catalog | -| `config/master/50-warehouse.yaml` | Warehouse | bootstrap | IN204000 | catalog | -| `config/master/51-warehouse-locations.yaml` | Warehouse (locations) | default | IN204000 | catalog | -| `config/master/52-warehouse-defaults.yaml` | Warehouse (receive/ship/RMA bins) | default | IN204000 | catalog | -| `config/master/53-tax-categories.yaml` | TaxCategory | default | TX205500 | catalog | -| `config/master/54-item-classes.yaml` | ItemClass | default | IN201000 | catalog | -| `config/master/56-so-preferences.yaml` | SOPreferences | bootstrap | SO101000 | catalog | -| `config/master/57-po-preferences.yaml` | POPreferences | bootstrap | PO101000 | catalog | -| `config/master/58-order-types.yaml` | OrderType | bootstrap | SO201000 | catalog | -| `config/master/60-ar-preferences.yaml` | ARPreferences | bootstrap | AR101000 | catalog | -| `config/master/61-ap-preferences.yaml` | APPreferences | bootstrap | AP101000 | catalog | -| `config/master/62-ca-preferences.yaml` | CAPreferences | bootstrap | CA101000 | catalog | -| `config/master/63-cash-account.yaml` | CashAccount | bootstrap | CA202000 | catalog | -| `config/master/64-payment-methods.yaml` | PaymentMethod | default | CA204000 | catalog | -| `config/master/65-statement-cycles.yaml` | StatementCycle | bootstrap | AR202800 | catalog | -| `config/master/70-vendor-classes.yaml` | VendorClass | bootstrap | AP201000 | catalog | -| `config/master/71-customer-classes.yaml` | CustomerClass | default | AR201000 | catalog | -| `config/master/75-vendors.yaml` | Vendor | default | AP303000 | catalog | -| `config/master/76-customers.yaml` | Customer | default | AR303000 | catalog | -| `config/master/80-stock-items-parts.yaml` | StockItem | default | IN202500 | catalog | -| `config/master/82-stock-items-kits.yaml` | StockItem | default | IN202500 | catalog | -| `config/master/85-kit-specifications.yaml` | KitSpecification | default | IN209500 | catalog | -| `config/master/90-roles.yaml` | Role | bootstrap | SM201005 | catalog | -| `config/master/91-users.yaml` | User (identity; Roles GET/extract; diff skips) | bootstrap | SM201010 | catalog | -| `config/master/92-role-users.yaml` | Role (+ Users; persist `AssignUser`) | bootstrap | SM201005 | catalog | -| `scenario/10-seed-capital.yaml` | JournalTransaction (once) | default | GL301000 | run only | -| `scenario/20-buy.yaml` | PO / receipt / AP bill+pay | default | PO/IN/AP | run only | -| `scenario/30-build.yaml` | KitAssembly | default | IN307000 | run only | -| `scenario/40-sell.yaml` | SO / ship / invoice / AR pay | default | SO/IN/AR | run only | - -`endpoint: bootstrap` resolves to the active Bootstrap package version from the packaged CLI contract (`bootstrap_project.xml` — package SoT). Data-repo `project.xml` is not a seed; remove it if present. - -`endpoint: default` (or omitted on Default-only entities) resolves to `Default/` where `api_version` comes from `--api-version`, else `ACU_API_VERSION`, else the code default. - -## Non-goals - -- Multi-org, multicurrency, full tax engine -- Production cutover or opening balances from a legacy system -- Replacing external data repos for production cutovers -- **LotSerialClass** seed/catalog (demo never claims `DfltLotSerClassID`; see [LotSerialClass — non-goal](#lotserialclass--non-goal-gh-27--v42)) -- **SegmentedKey** shrink after data exists; no `ACCOUNT` / `INSITE` widen (`SiteCD` is `NVarChar(10)`; see [Segmented keys](#segmented-keys)) diff --git a/docs/rest-api.md b/docs/rest-api.md deleted file mode 100644 index a43398b..0000000 --- a/docs/rest-api.md +++ /dev/null @@ -1,529 +0,0 @@ -# Contract-based REST API — verified notes - -Working notes for the reference-data-as-code layer. **Verified against -acu-dev1 (Acumatica 26.101.0225) on 2026-07-07.** - -## Endpoint - -Base: `http://acu-dev1.vm.internal/AcumaticaERP` - -Newest Default contract endpoint on this build (from `GET /entity`): - -``` -/AcumaticaERP/entity/Default/25.200.001/ -``` - -### State view sources (`acu state`) - -Each `config/views/*.yaml` view picks **exactly one** source kind (V33): - -| Kind | HTTP | Notes | -| ---- | ---- | ----- | -| `inquire:` | `PUT /entity/Default//?$expand=Results` | Same contract-inquiry idiom as `run` `expect` / `present`. View `params` are the PUT body (**pinned** in YAML — no `${current_period}` expand; V33/V43). Optional `match:` filters Results rows. Scenario `run` may use `${current_period}` → host-local `MMyyyy` on inquire params; `state` never does. | -| `entity:` | `GET /entity/Default//` | Contract REST list. No per-view `endpoint:` pin in v1. | -| `gi:` | OData GI (below) | Optional when a GI is V12-verified and **Expose via OData** is on. | - -Golden stem `trial-balance` must capture **≥1 numeric money field** -(fixed-point strings at view `decimals`, default 2) — roster-only -`entity: Account` is forbidden for that stem (V33). - -Packaged default: - -- demo seed TB → `inquire: AccountSummaryInquiry` with `EndingBalance` - -`inventory-summary` / `QtyOnHand` is **not** a packaged golden this pass: -live `InventorySummaryInquiry` with warehouse-only params returns empty -Results (B25). Operators may still author a custom view (`inquire:` or -`gi:`) when a V12-verified path is known. - -`gi:` remains optional for production-grade LAB5-style inquiries when the -GenericInquiry surface is V12-verified; seed GI defs under `config/master/` and -point `source.gi:` at the name. Observations land under `state/.yaml` -(flow-style rows; money/qty always fixed-point strings, never bare float). - -### OData Generic Inquiry (`acu state` `gi:` source) - -24R2+ service root (verified path form): - -``` -/AcumaticaERP/t//api/odata/gi/ -/AcumaticaERP/t//api/odata/gi/$metadata -``` - -The GI must have **Expose via OData** enabled on SM208000. -View `params` -are validated against `$metadata` at capture time (fail-closed): unknown -parameter names hard-error rather than silently returning an unfiltered -result set. - -Cookie session from contract login is reused (same -`AcumaticaClient`). - -Legacy `/OData//` remains for older builds; this CLI uses -the 24R2+ form. - -### `GET /entity` — endpoint list (config check probe) - -Authenticated `GET /entity` returns the published contract endpoints. - -**26.x shape (verified 26.101.0225).** -The body is a wrapper object. - -Rows live under `endpoints`. -A `version` object may carry the live ERP build id -(`acumaticaBuildVersion`, `databaseVersion`): - -```json -{ - "version": { - "acumaticaBuildVersion": "26.101.0225", - "databaseVersion": "26.101.0225" - }, - "endpoints": [ - { - "name": "Default", - "version": "25.200.001", - "href": "http://…/entity/Default/25.200.001/" - }, - { - "name": "Bootstrap", - "version": "1.0.0", - "href": "http://…/entity/Bootstrap/1.4.0/" - } - ] -} -``` - -**Legacy array shape.** -Older builds and some offline fixtures still return a bare top-level -array of the same row objects: - -```json -[ - { - "name": "Default", - "version": "25.200.001", - "href": "http://…/entity/Default/25.200.001/" - } -] -``` - -`parse_entity_list` accepts both shapes (V31). -Each row needs string `name` and `version`. - -Unparseable body fails closed with status, content-type, and a short -raw hint — never silent skip (V12). - -`acu config check` requires a `Default` row whose `version` equals -`Instance.api_version` (exact string). - -### `Instance.api_version` resolution (V27) - -The Default contract version half is an env pin. Resolution: - -1. `--api-version` global flag (ad-hoc override; version half only) -2. else `ACU_API_VERSION` from `.env` or the process environment -3. else code default `25.200.001` - -`acu config show` emits `ACU_API_VERSION` with the resolved pin. -Leftover `matrix.yaml` is never loaded. - -### Multi-host overlays (V44) - -Data repos pin hosts with per-checkout `.env` (`ACU_BASE_URL` + -`ACU_API_VERSION`) on a single trunk seed. -Optional Default-half overlays live under -`overlays/default-/` (scaffolded by `config init`). - -Bare `apply` / `diff` / `run` auto-compose the pin overlay when path args are -omitted; explicit path args stay manual. -The CLI has no `--overlay` flag. - -Never commit multi-version OpenAPI trees; use `acu schema` live dumps. -See README "Multi-host overlays" and -[acumatica-gitops#2](https://github.com/kborovik/acumatica-gitops/issues/2). - -### HTTP error detail (V46) - -PUT and action POST failures raise `RuntimeError` with status plus -`exceptionMessage`, nested field errors (`Field: {value, error}` under -the body or `entity`, including detail rows), and -`innerException.exceptionMessage` when that inner text is present -(appended, not only when the top message is empty). -Status-only or top-level message alone is insufficient — operators -should not need curl to see which field or inner row the contract -rejected. - -### Live ERP build probe - -When `GET /entity` returns the 26.x wrapper, -`version.acumaticaBuildVersion` is the live ERP build id. -`acu state` writes that id into observation `erp:` headers (V32). - -A bare array has no build id; `state` then records `erp: unknown`. -`config check` does not erp-match — it only requires a `Default` row -whose version equals `Instance.api_version`. - -Do not add an SSH/sqlcmd path for this — control plane stays tenant CRUD -only (V1). - -The instance's own OpenAPI 3.0.1 schema is the authoritative field-level -reference — dump it with `acu schema` -into `schemas/` (gitignored; ~3 MB, regenerate rather than version). - -## Authentication (cookie session) - -``` -POST /entity/auth/login {"name":"admin","password":"...","tenant":"Company"} → 204 + cookies -...work with the cookie jar... -POST /entity/auth/logout (empty body) → 204 -``` - -Verified quirks: - -- **Tenant routing validates the name only on a multi-tenant instance with a - current tenant map (verified 2026-07-08, revised 2026-07-09).** Send an - explicit, valid tenant name; do not omit it or pass an empty string. The - full behavior matrix (all cells verified live on 26.101.0225): - - multi-tenant, fresh map, unknown non-empty name (e.g. - `DefinitelyBogus`) → **500 `PXUndefinedCompanyException`** ("A proper - company ID cannot be determined"). No silent fallback. - - multi-tenant, fresh map, valid name → routes to *that* tenant and - enforces *that tenant's* credentials. - - omitted / `""` → 204 on the **default tenant** (expected Acumatica - semantics — never rely on it). - - **single login-able tenant in the DB → ANY tenant value returns 204** - and lands on that tenant. No validation at all (verified 2026-07-09: - after deleting the second tenant and recycling, its old name and pure - nonsense both logged in cleanly). The "strict" behavior above only - exists when there are two or more tenants to choose between. - - **stale map (tenant created or deleted without a recycle) → named - logins silently land on the default tenant** — the B5 false-green - `diff`. On 2026-07-07 marker UOM rows written under four names all hit - CompanyID 2 this way. - Lesson: a 204 from login proves nothing about *where* the session landed. - Wrong-tenant writes and false-green diffs are only prevented by verifying - the landed tenant after login (next bullet). -- **Landed-tenant probe (T21, verified 2026-07-09).** The contract API - surface exposes nothing tenant-identifying (no cookie carries the tenant; - `GET /entity` and `/CustomizationApi/getPublished` are landing-invariant; - the `/Frames` page `` is the tenant's internal `CompanyCD`, not its - login name; `/Main`'s company markup and the `screenLink` `CompanyID` - query param both disappear on a single-tenant instance). What works: an - **authenticated `GET /Frames/Login.aspx`** renders a hidden input - `id="txtSingleCompany"` whose value is the **landed tenant's login name in - every observed state** — multi-tenant fresh map, single-tenant, and - mid-reroute under a stale map — and the GET does not disturb the session. - `AcumaticaClient.__enter__` parses it and refuses the session (after - logging out) when it does not match the requested tenant. Re-verify the - page shape on upgrade: the guard fails closed if the input disappears. -- **`Restart-WebAppPool` returns before the new worker serves (overlapped - recycle, observed 2026-07-09).** Requests fired immediately after the - recycle can still hit the old worker and its old tenant map for tens of - seconds. `acu tenant create`'s login-verification loop absorbs this; - hand-rolled probes right after a recycle must wait or retry. -- **Newly created tenants are invisible until an app-pool recycle (verified - 2026-07-08).** After `ac.exe` added tenant 3 the sign-in page did not list - it and no tenant value could select it — the app's tenant map loads at - startup. Recycling the `AcumaticaERP` app pool - (`Restart-WebAppPool -Name AcumaticaERP`) fixed it: the tenant then appears - on the sign-in page and routes over REST. `acu tenant create` must perform - this recycle itself after `ac.exe` returns. Standalone operator path: - `acu tenant recycle` (site-wide; kills all sessions and frees concurrent - API-user license slots as a side effect). -- **First-login password change is a bootstrap step the contract API can't do - (verified 2026-07-08).** A freshly created clean tenant seeds `admin` / - **`setup`** with `PasswordChangeOnNextLogin = 1` (in `dbo.Users`). The - contract login authenticates but refuses to clear the flag: passing - `newPassword` with `setup` still returns *"You need to change your password. - Sign in by using your browser to set a new password."* Repeated wrong - passwords stamp `LockedOutDate` and lock the account. ("Login is not a - blocker" from 2026-07-07 applied only because those probes hit tenant 2, - which is past first login.) -- **…but the create path sidesteps it, and the screen flow is a fallback - (verified 2026-07-08).** `acu tenant create` presets the admin password via - ac.exe's `-aun`/`-aup`/`-auc` flags (see `ac-exe.md`), so the tenant is - REST-loginable straight after the recycle with no first-login step at all. - The `Login.aspx` WebForms dance below is only needed for a tenant that was - created without those flags — it works from plain httpx, no browser: - 1. `GET /Frames/Login.aspx` — collect the hidden fields (`__VIEWSTATE`, - `__VIEWSTATEGENERATOR`, …) and the session cookie. - 2. `POST` the same URL with those fields plus - `ctl00$phUser$cmbCompany=<tenant>`, `ctl00$phUser$txtUser=admin`, - `ctl00$phUser$txtPass=setup`, `ctl00$phUser$btnLogin=Sign In` — the - response re-renders the page with `ctl00$phUser$txtNewPassword` and - `ctl00$phUser$txtConfirmPassword` visible. - 3. `POST` again with old password + both new-password fields filled — - redirects to `/Main` (signed in, flag cleared). REST login with the - new password then returns 204. - Traps: submit `ctl00$phUser$btnLogin` (the `mfLoginButton` on the page is - the multi-factor flow); the `isReal` login-JS flag is client-side only, a - plain postback is accepted; and the page always contains hidden error divs - ("Last update was unsuccessful", "customization failed to apply") — they - are static markup, not a response to your POST. This is the - `acu tenant create` route: recycle app pool → screen-flow password change → - tenant is automation-ready. -- Logout with curl needs an explicit `Content-Length: 0` header or IIS - answers `411 Length Required`. -- **Always log out.** Sessions count against the license's concurrent API - user limit; leaked sessions exhaust it (trial instances have a small cap). -- Credentials live outside the repo (env vars `ACU_URL`, `ACU_USER`, - `ACU_PASSWORD`, `ACU_TENANT`). - -## Entity coverage for the Part 1 baseline - -Checked against the dumped schema — every entity below supports -`GET`/`PUT`/`PATCH` (PUT is upsert: keyed by the entity's key fields, so -re-runs are naturally idempotent): - -| Config object (spec) | Default 25.200.001 entity | -|---|---| -| Chart of accounts | `Account` (+ `Subaccount`, `Ledger`) | -| Customer classes | `CustomerClass` | -| Vendor classes | `VendorClass` | -| Tax zones / categories | `TaxZone`, `TaxCategory`, `Tax` | -| Item classes | `ItemClass` | -| UOMs | `UnitsOfMeasure` | -| Currencies | `Currency` | -| Branches / company structure | `CompaniesStructure` | -| Financial calendar | `FinancialYear`, `FinancialPeriod` | - -**Verified gap — payment terms.** The Default endpoint has no Credit Terms -(CS206500) entity (only `ShippingTerm`, which is different). Decision -(2026-07-07): the bootstrap customization package (below) will carry a custom -endpoint that exposes Credit Terms alongside the bootstrap screens — one -package closes both gaps. - -## Verified: no tenant works before bootstrap — and the API can't bootstrap alone - -Scratch-tenant experiment, 2026-07-07. -Attribution note: because tenant 3 was -not yet loaded into the app (the recycle hadn't run), every probe in this -experiment actually ran against **tenant 2 (`Company`)** — which turned out to -be just as unconfigured as the freshly created tenant 3 (`FeaturesSet` empty -for all tenants; explicit `tenant: Company` probe confirmed identical -failures). - -The instance has never been through initial setup, so bootstrap is -required for *every* tenant, not just new ones. -The uoms.yaml proof worked -only because CS203500 has no setup dependency. - -**Re-run done (2026-07-08):** -after the recycle + screen-flow password change, the probe against the real -tenant 3 returned an identical map — same three classes, `CompaniesStructure` -still dead, CustomizationApi 200 ("no published projects"). -The findings -below stand on a genuinely virgin tenant. - -Entity access on an unconfigured tenant falls into three classes: - -| Class | Observed entities | Error | -|---|---|---| -| Works | `UnitsOfMeasure` (ships with defaults), `FinancialYear` (0 records) | — | -| Setup chain missing | `Account`, `Currency` (Company Branches); `Ledger` (Companies); `FinancialPeriod` (Financial Year); `TaxCategory`, `TaxZone` (Branch); `PaymentMethod` (GL Preferences) | 500 `PXSetupNotEnteredException`, names the missing form | -| Feature-gated | `CustomerClass` (AR201000), `VendorClass` (AP201000), `ItemClass` (IN201000), `Subaccount` (GL203000) | 403 insufficient rights | - -Bootstrap findings, all verified against the live instance: - -- **`PUT CompaniesStructure` is dead.** 500 `Operation failed` - (`PXInvalidOperationException` in `EntityService.CreateOrUpdate`) for every - body variant tried; GET fails too (`BqlDelegate in view Filter`). The - entity cannot create a company or branch. -- **Login IS a blocker on a genuinely virgin tenant (corrected 2026-07-08).** - The original "no first-login dance" note was measured against tenant 2, - which is past first login. A freshly created tenant seeds `admin` / `setup` - with `PasswordChangeOnNextLogin = 1`, and the contract endpoint cannot clear - it (see the auth section). First login must go through the screen or a SQL - reset before REST seeding can run. -- **The CustomizationApi works on a virgin tenant.** - `POST /CustomizationApi/getPublished` → 200. This is the bootstrap route: - publish a small package whose custom endpoint exposes Companies (CS101500), - Enable/Disable Features (CS100000), and Credit Terms (CS206500). -- DB facts (read-only sqlcmd): `EntityEndpoint` is empty — built-in endpoints - are code-defined; custom endpoints are tenant-scoped rows, so the package - must be published per tenant. `FeaturesSet` is empty for **all** tenants — - features were never enabled anywhere, which explains the 403 class. -- Insert datasets are no shortcut — see `ac-exe.md` (T100/U100 are training - data, not clean baselines). - -Consequence — the ordered bootstrap pipeline: - -1. `acu tenant create` (ac.exe over SSH) -2. publish the bootstrap package (`/CustomizationApi`) -3. enable features + create company/branch (custom endpoint) -4. seed `baseline/` (Default endpoint) - -~~Open risk: whether CS100000 accepts writes through a custom endpoint~~ -**Resolved 2026-07-08 — CS100000 does NOT accept writes through a custom -endpoint.** See the custom-endpoint section below. The fallback is now the -route: a `CustomizationPlugin` flipping `FeaturesSet` on publish (ships C# -in the package). - -## Custom endpoints under the hood — verified 2026-07-08 - -Everything below was verified live on a scratch tenant (BootTest, CompanyID -4, deleted after) by installing a custom endpoint as raw DB rows and probing -the API; constants were pulled from the server's own DLLs via PowerShell -reflection (`PX.Api.ContractBased[.Common].dll`). - -**A custom endpoint is nothing but tenant-scoped rows in five tables** — -no code, no files: - -| Table | Row shape (key columns) | -|---|---| -| `EntityEndpoint` | `GateVersion` ('1.0.0'), `InterfaceName` ('Bootstrap'), `ExtendsVersion/Name` (null unless extending), `SystemContractVersion` = **4** on this build (`SystemContracts.V4` is the only implementation, attribute `IsCurrent`; the DAC default 1 is legacy) | -| `EntityDescription` | `EntityId` (globally unique int), `GateVersion`+`InterfaceName`, `ObjectName` ('Features'), `Active`, `ObjectType` — `T`opLevel / `L`inked / `D`etail / `R`eport, `ScreenID` ('CS100000') | -| `EntityFieldDescription` | `EntityId`, `EntityFieldId`, `FieldName`, `FieldType` — the wrapper names: `StringValue`, `BooleanValue`, `IntValue`, `DecimalValue`, `GuidValue`, `DateTimeValue`, `ShortValue`, `LongValue`, `DoubleValue`, `ByteValue`, `DateOnlyValue`; `PopulateByDefault` = 0 | -| `EntityMapping` | `MappingKey` `E/<entityId>/<fieldId>` → `MappedObject` (graph **view** name) + `MappedField` (DAC field). No entity-level `E/<id>` rows exist — the primary view resolves from the screen. | -| `EntityActionDescription` | `EntityId`, `ActionId`, `ActionName` (contract name), `MappedAction` (graph action member), `Active`; action-parameter mappings key as `E/<eid>/A<aid>/<pid>` | - -Facts that matter for tooling: - -- **Built-in endpoints materialize into the same tables under CompanyID 1** - (~2.7k `EntityDescription`, ~34k field rows appeared after an app-pool - recycle). `EntityId`/`ActionId` are allocated globally - (`SELECT MAX(...)+1`) — a custom entity reusing a taken id kills the - whole contract API for that tenant with "An item with the same key has - already been added". The earlier "EntityEndpoint is empty" observation - was a pre-materialization artifact. -- **Endpoint metadata is cached per app domain** (`MetadataProvider.*` - slots). A new `EntityEndpoint` row shows in `GET /entity` immediately, - but entities/fields/actions need an app-pool recycle to appear. -- Tenant scoping is real: rows with CompanyID 4 are invisible to sessions - on other tenants (404), exactly as the publish-per-tenant model implies. -- Broken metadata rows don't 404 — they 500/302 **every** `/entity/…` - request on that tenant, including the built-in Default endpoint. A - malformed row poisons the tenant's whole contract API. - -**The CS100000 verdict (T3).** With a correct custom endpoint mapping -`Features` → FeaturesMaint view `Features`: - -- `PUT …/Features {"MultiCompany": {"value": true}}` → **200, but nothing - persists** — `FeaturesSet` stays empty, and the echoed record omits all - value fields. The primary view is a keyless BqlDelegate view; the - contract-API update targets the delegate's transient row and the graph - save is a silent no-op. -- `GET …/Features` → 500 `CannotOptimizeException` ("There is a BqlDelegate - in view Features") — same disease as `PUT CompaniesStructure`. -- Exposing the graph's custom `Insert` action (`EntityActionDescription` → - `MappedAction: Insert`) and invoking it → 500 `PXInvalidOperationException` - "Operation failed". - -So features cannot be enabled through the contract API surface at all, no -matter what endpoint fronts CS100000 — the fallback (C# CustomizationPlugin -writing `FeaturesSet` on publish) ships in the bootstrap package. - -**The fallback is verified working (T11, 2026-07-08):** after publish + recycle, -`CustomerClass`/`ItemClass` (the 403 feature-gated class) answer 200 on a -freshly provisioned tenant; `VendorClass` moves to the setup-chain class -(GL Preferences), which baseline seeding owns. - -See the CustomizationApi -section for the plugin-side landmines. - -**Where to read server errors when the API only 302s to `/ui/error`:** -flip `EnableFirstChanceExceptionsLogging` to `True` in web.config (default -False; restarts the app) and read -`App_Data\firstchanceexceptions.log`. The `/ui/error` SPA itself reads -`apiweb/error-page`, which only carries the generic message. - -## CustomizationApi — verified quirks (2026-07-08, extended same day) - -- **Failures are in-band.** Every `/CustomizationApi/*` call answers 200; - errors appear only as `log` entries with `"logType": "error"` (a rejected - import still returns 200 + "The project is not found: …" in the log). - `getPublished` with nothing published returns only a `log` — no - `projects` key. -- **The import content field is `projectContentBase64`** — the live binder's - property (`ImportParamsData`, verified by reflection on - `PX.Web.Customization.dll`). The widely documented `projectContents` - binds NOTHING on this build: the server deletes the existing project - (`isReplaceIfExists`) and then errors "The project is not found" — the - original mystery import failure. Full binder surface: `ProjectLevel`, - `ProjectName`, `ProjectDescription`, `ProjectContentBase64`, - `IsReplaceIfExists`, `Content`. -- **Project names are alphanumeric only.** - `CstDbStorage.ValidatePackageName` rejects `-` and `_` outright - ("Invalid project name", import 500s) — `acu-bootstrap` was invalid from - birth; the package is now `AcuBootstrap`. -- `publishBegin` accepts `projectNames`, `isMergeWithExistingPackages`, - `isOnlyValidation`, `isOnlyDbUpdates`, - `isReplayPreviouslyExecutedScripts`, `tenantMode: "Current"`; poll - `publishEnd` for `{isCompleted, isFailed, log}`. There is also - `getProject {projectName}` (returns `projectContentBase64`) and - `delete {projectName}`. -- **`getPublished` is a false idempotence proxy after tenant recreate.** - Deleting a tenant and recreating it under the same CompanyID keeps the - stale publication row (the package stays listed) while the project - content and everything the publish wrote are gone; an - `isOnlyDbUpdates` replay then fails with "The previously published - project cannot be found in the database". `acu`'s publish skip therefore - requires getPublished AND getProject to agree. -- **Package format (verified for code items).** The importable zip carries - `project.xml` whose root is - `<Customization level="" description="…" product-version="26.101">` — - verified against the training packages shipped on the box - (`HelpAndTraining/T*/PhoneRepairShop.zip`, same build). The project - *name* comes from the import call, not the XML. - - **C# source items are `<Graph ClassName="X" FileType="NewFile" - Source="…escaped source…"/>`** — the source rides in the `Source` - ATTRIBUTE (`Customization.CstCodeFile`, `Tag = Graph`; shape - confirmed by invoking its own `Save()` by reflection and by live - import round-trip). Inline CDATA children and zip-file variants are - silently dropped on import. - - **Endpoint items are inline XML, no `.endpoint` file (T12, - verified by import round-trip 2026-07-08).** The `<EntityEndpoint>` - project item wraps an `<Endpoint>` child that is the XmlSerializer - form of `PX.Api.ContractBased.Common.Model.Endpoint` — namespace - **`http://www.acumatica.com/entity/maintenance/5.31`** (the - "Unknown root node …data-model:Endpoint" rejection was the wrong - namespace, not a missing file; the `*.endpoint` globs in - `PX.Api.ContractBased.Common.dll` belong to the source-control - folder layout). Shape (attributes throughout): - `<Endpoint name version systemContractVersion=4>` → - `<TopLevelEntity name screen>` (also `Detail`/`LinkedEntity`/ - `Report`) → `<Fields><Field name type/></Fields>` + - `<Mappings><Mapping field><To object="<view>" field="<DAC prop>"/> - </Mapping></Mappings>` + `<Actions><Action name mappedTo/>`. - `type` = the wrapper names (`StringValue`, `ShortValue`, …); - `systemContractVersion` 4 = `SystemContracts.V4`, the build's only - `IsCurrent` implementation. The server's own getProject - re-serialization carries no attributes on `<EntityEndpoint>` — - identity comes from the child's name/version. Live screen chain for - the bootstrap payload: CS101500 → `OrganizationMaint`, PrimaryView - `BAccount` (DAC `PX.Objects.CS.DAC.OrganizationBAccount`; fields - `AcctCD`/`AcctName`/`OrganizationType`/`BaseCuryID` — no - `OrganizationCD`, no `CountryID` on this build); CS206500 → - `TermsMaint`, PrimaryView `TermsDef` (DAC `PX.Objects.CS.Terms`; - `DayDue00` is `Nullable<Int16>` → `ShortValue`). -- **`CustomizationPlugin` is the working features route (T11, verified).** - `UpdateDatabase` runs on publish (plus a second invocation around site - start). Writing through `FeaturesMaint` + `Save.Press()` collides with - the concurrent invocation ("Another process has added the 'FeaturesSet' - record. Your changes will be lost." — a warning, publish still reports - success, nothing persists). Writing through - `PXDatabase.Update/Insert<FeaturesSet>` works; all 205 NOT NULL bit - columns must be assigned (only ~136 have DB defaults) — fill them - reflectively. `Status = 0` means Validated. -- **The feature slot outlives the publish restart.** The publish restarts - the site BEFORE its DB transaction commits, so the restarted domain - caches the pre-plugin feature set — feature-gated screens keep answering - 403 until one more app-pool recycle. `acu tenant create` recycles - unconditionally after its publish step. - -## Conventions for the seeding layer - -- Target the versioned path (`Default/25.200.001`), never an unversioned - alias — deterministic contract per instance build. -- Field values go wrapped: `{"AccountCD": {"value": "10100"}}`. -- Contract `id` and `delete` travel bare (never `{value}`). - `unwrap` keeps them on value-field records and detail rows; files-style - id-only rows stay elided so GET `$expand` row GUIDs round-trip onto a - later PUT (V54). -- Extract still strips `id`/`delete` from seed YAML so GUIDs stay off - the portable seed (B11). -- `PUT /<Entity>` with the key fields present updates-or-creates — the - idempotence primitive. Confirm per entity with a re-run diff. -- `GET /<Entity>` with `$select`/`$filter`/`$expand` is the read side the - drift-diff and Part 2 validation build on. diff --git a/src/acumatica_cli/bootstrap.py b/src/acumatica_cli/bootstrap.py index 38f4898..5296541 100644 --- a/src/acumatica_cli/bootstrap.py +++ b/src/acumatica_cli/bootstrap.py @@ -1,8 +1,8 @@ """Bootstrap customization package: build the zip, publish via /CustomizationApi. An unconfigured tenant cannot be configured through the Default endpoint -(features are off, no company/branch exists, credit terms have no entity — -docs/rest-api.md). The CustomizationApi is the one door that works on a +(features are off, no company/branch exists, credit terms have no entity). +The CustomizationApi is the one door that works on a virgin tenant, so bootstrap = publish a package whose CustomizationPlugin (`bootstrap_plugin.cs`) enables features on publish — the contract API cannot write CS100000 at all (T3 verdict) — and whose Bootstrap contract diff --git a/src/acumatica_cli/bootstrap_plugin.cs b/src/acumatica_cli/bootstrap_plugin.cs index bfd960d..0ee342e 100644 --- a/src/acumatica_cli/bootstrap_plugin.cs +++ b/src/acumatica_cli/bootstrap_plugin.cs @@ -2,7 +2,7 @@ // // Runs in-process on customization publish - the one write path to // FeaturesSet that works: the contract API cannot persist features no -// matter what endpoint fronts CS100000 (T3 verdict, docs/rest-api.md). +// matter what endpoint fronts CS100000 (T3 verdict). // // Deliberately writes through PXDatabase, not FeaturesMaint: the graph // save collides with the publish pipeline's concurrent plugin invocations diff --git a/src/acumatica_cli/cli.py b/src/acumatica_cli/cli.py index 9367fb6..ec78ac0 100644 --- a/src/acumatica_cli/cli.py +++ b/src/acumatica_cli/cli.py @@ -76,7 +76,7 @@ survey extract -> always writes config/{bootstrap,baseline,setup,master}/ Run `acu <command> --help` for flags, examples, and prerequisites. -See package README and docs/demo-seed.md for seed YAML shape. +See package README for seed YAML shape. """ import functools @@ -382,7 +382,7 @@ def tenant_list(inst: Instance) -> None: @click.option( "--type", "company_type", - # the V12-verified dataset folders on the box (docs/ac-exe.md); System + # the V12-verified dataset folders on the box; System # is the system-tenant dataset, deliberately not offered type=click.Choice(["SalesDemo", "T100", "U100"]), default=None, @@ -466,7 +466,7 @@ def tenant_create( output.data(raw.splitlines()[-1] if raw.strip() else "created") # ac.exe only sets CompanyKey from LoginName; CompanyCD stays auto-generated # (Company2/Company3/…). Align CD → login so list shows matching columns. - # Idempotent on re-run / exists-skip (docs/ac-exe.md). + # Idempotent on re-run / exists-skip. with output.step(f"aligning CompanyCD to login ({login_name})"): if mgr.set_company_cd(company_id, login_name): output.data(f"CompanyCD set to {login_name}") diff --git a/src/acumatica_cli/client.py b/src/acumatica_cli/client.py index b2e7b65..8f07d0a 100644 --- a/src/acumatica_cli/client.py +++ b/src/acumatica_cli/client.py @@ -1,4 +1,4 @@ -"""Contract-based REST API session (see docs/rest-api.md for verified quirks).""" +"""Contract-based REST API session.""" import base64 import html @@ -162,7 +162,7 @@ def _entity_build_version(body: Any) -> str | None: # tenant answers PXSetupNotEnteredException on most entities until bootstrap # lands, and the exceptionMessage names the missing form — "The required # configuration data is not entered on the <Form> form." (verified vs -# 26.101.0225, docs/rest-api.md). +# 26.101.0225). SETUP_NOT_ENTERED_500 = "configuration data is not entered" @@ -188,7 +188,7 @@ def __init__( self._refreshed_after_company = False def __enter__(self) -> AcumaticaClient: - # tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is + # tenant guard (V5): an omitted or empty tenant is # the one login the server still routes silently — to the default # tenant. Defense-in-depth vs wrong-tenant writes: every data-plane # session must name its tenant, so refuse before any HTTP happens. @@ -219,7 +219,7 @@ def _login(self) -> None: Login accepting the name proves nothing - a stale tenant map reroutes named logins to the default tenant, and a single-tenant - instance accepts ANY name (both verified live, docs/rest-api.md). + instance accepts ANY name (both verified live). """ creds: dict[str, str] = { "name": self.instance.user, @@ -261,7 +261,7 @@ def refresh_after_company(self) -> None: def _landed_tenant(self) -> str: """The tenant this session actually landed on (login name). - Verified vs 26.101.0225 (docs/rest-api.md): the contract API exposes + Verified vs 26.101.0225: the contract API exposes nothing tenant-identifying, but an authenticated GET of the sign-in page renders a hidden ``txtSingleCompany`` input whose value is the session tenant's login name in every observed state - multi-tenant, diff --git a/src/acumatica_cli/firstlogin.py b/src/acumatica_cli/firstlogin.py index 96b4918..d47f1f4 100644 --- a/src/acumatica_cli/firstlogin.py +++ b/src/acumatica_cli/firstlogin.py @@ -1,8 +1,8 @@ """First-login admin password initialization via the Login.aspx screen flow. A freshly created tenant seeds ``admin``/``setup`` with a -must-change-on-first-login flag the contract REST API cannot clear (verified — -see docs/rest-api.md). The sign-in screen's WebForms flow can, with plain +must-change-on-first-login flag the contract REST API cannot clear (verified +live). The sign-in screen's WebForms flow can, with plain HTTP: GET the page for the hidden fields, POST the seed credentials, then POST again with the new password into the change view the server renders. """ diff --git a/src/acumatica_cli/inventory.py b/src/acumatica_cli/inventory.py index ff23a0a..20cd148 100644 --- a/src/acumatica_cli/inventory.py +++ b/src/acumatica_cli/inventory.py @@ -13,7 +13,7 @@ Rejected: binary ``.adb`` (and ZIPs that only carry ``.adb``) — named error, fail-closed. -Table XML shape (verified ac-exe export / dataset format, docs/ac-exe.md): +Table XML shape (verified ac-exe export / dataset format): <data> <table name="Account"> diff --git a/src/acumatica_cli/tenant.py b/src/acumatica_cli/tenant.py index f6844a6..35feeea 100644 --- a/src/acumatica_cli/tenant.py +++ b/src/acumatica_cli/tenant.py @@ -1,7 +1,7 @@ """Tenant CRUD on the instance. -Thin SSH wrapper around ac.exe CompanyConfig (the only supported CLI route, -see docs/ac-exe.md) plus a sqlcmd read side. +Thin SSH wrapper around ac.exe CompanyConfig (the only supported CLI route) +plus a sqlcmd read side. """ import subprocess @@ -15,8 +15,8 @@ class Tenant(Model): login_name is dbo.Company.CompanyKey — the name on the sign-in page and the REST login's tenant value. company_cd is dbo.Company.CompanyCD; ac.exe - auto-generates it (Company2, Company3, …) and ignores LoginName for it - (docs/ac-exe.md). ``acu tenant create`` aligns CompanyCD to the login + auto-generates it (Company2, Company3, …) and ignores LoginName for it. + ``acu tenant create`` aligns CompanyCD to the login after CompanyConfig so list Login and CD match. """ @@ -97,7 +97,7 @@ def recycle_app_pool(self) -> None: The tenant map loads at app start; without this, tenants created or deleted by CompanyConfig stay invisible to the sign-in page and REST - routing silently falls back to the default tenant (docs/rest-api.md). + routing silently falls back to the default tenant. The pool is named after the instance (how acumatica-infra builds it). """ self._ssh( @@ -108,7 +108,7 @@ def recycle_app_pool(self) -> None: def _company_config(self, company: str, extra: str = "") -> str: # -iname AND -h are both required: without -iname CompanyConfig can't # find the site; without -h its web.config step dies on a null path - # (verified 2026-07-08, docs/ac-exe.md). + # (verified 2026-07-08). return self._ssh( f"& '{AC_EXE}' " f'-configmode:"CompanyConfig" -output:"Forced" ' @@ -132,7 +132,7 @@ def create( company_type: '' = clean tenant, 'SalesDemo' = demo data. The admin flags preset the tenant's admin to the instance credentials with no must-change flag, so the tenant is REST-loginable right after the - app-pool recycle — no first-login dance (verified, docs/ac-exe.md). + app-pool recycle — no first-login dance (verified live). Does not set CompanyCD (ac.exe auto-generates it). Call :meth:`set_company_cd` after create to align CD with the login. @@ -199,7 +199,7 @@ def delete( Identify by ``company_id`` or ``login_name`` (exactly one). Login is the sign-in name / REST tenant value (``CompanyKey``). - Two verified gotchas (docs/ac-exe.md): + Two verified gotchas: - The sub-key is ``Deleted``, not the officially documented ``Delete`` — an unknown key is silently ignored and the run degrades diff --git a/tests/test_cli.py b/tests/test_cli.py index f03073d..e36be65 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -369,7 +369,7 @@ def test_tenant_create_chains_init_and_bootstrap(create_env: list[str]) -> None: ) assert result.exit_code == 0 - # the ordered pipeline from docs/ac-exe.md + docs/rest-api.md (T45): + # the ordered pipeline (T45): # create over SSH, recycle + login check (V5), then the bootstrap # publish into the NEW tenant (never the config default) followed by # the post-publish recycle - the publish's own restart caches the @@ -574,7 +574,7 @@ def test_tenant_create_exists_skip_without_id_adopts_live_id( def test_tenant_create_type_rejects_unknown_dataset(create_env: list[str]) -> None: # T56/V9/V12: --type validates client-side against the box-verified - # dataset set (docs/ac-exe.md) - a non-member errors naming the allowed + # dataset set - a non-member errors naming the allowed # set, and nothing runs (no SSH, no chain); System stays excluded result = CliRunner().invoke( cli.cli, diff --git a/tests/test_docs.py b/tests/test_docs.py index 4293f06..00017d1 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -68,18 +68,34 @@ def test_e2e_pipeline_files_only() -> None: assert "test_extract_roundtrip" in readme +def test_docs_tree_dropped() -> None: + """T260/V12: never docs/ tree as SoT.""" + docs = REPO / "docs" + assert not docs.exists() or not list(docs.glob("*.md")) + + +def test_src_tests_drop_docs_path_cites() -> None: + """T260/V12: remaining docs/*.md cites swept from src/ and tests/.""" + stems = "ac-exe", "demo-seed", "rest-api" + pat = re.compile("docs/" + "(?:" + "|".join(stems) + r")\.md") + hits: list[str] = [] + for root in (REPO / "src", REPO / "tests"): + for path in root.rglob("*"): + if not path.is_file() or path.suffix not in {".py", ".cs", ".md"}: + continue + text = path.read_text() + for i, line in enumerate(text.splitlines(), 1): + if pat.search(line): + rel = path.relative_to(REPO).as_posix() + hits.append(f"{rel}:{i}: {line.strip()}") + assert hits == [], "docs/ path cites:\n" + "\n".join(hits) + + def test_docs_user_role_membership_persist() -> None: - """T229/V12/V19/V53: docs name AssignUser persist; drop T189 limit + Selected.""" - demo = (REPO / "docs" / "demo-seed.md").read_text() + """T229/V12/V19/V53: human-facing notes name AssignUser persist.""" changelog = (REPO / "CHANGELOG.md").read_text() readme = (REPO / "README.md").read_text() templates = (REPO / "src" / "acumatica_cli" / "templates" / "README.md").read_text() - assert "membership limit (T189)" not in demo - assert "Selected: true" not in demo - assert "AssignUser" in demo - assert "92-role-users.yaml" in demo - assert "UsersInRoles" in demo - assert "RolesByUser" in demo # V19 promote empties Unreleased; persist notes live in the versioned section. assert "gh #35" in changelog assert "AssignUser" in changelog @@ -88,33 +104,22 @@ def test_docs_user_role_membership_persist() -> None: def test_docs_numbering_newsymbol_insert() -> None: - """T255/V12/V19/V58: Unreleased + demo-seed NumberingSequence NewSymbol.""" - demo = (REPO / "docs" / "demo-seed.md").read_text() + """T255/V12/V19/V58: Unreleased NumberingSequence NewSymbol.""" changelog = (REPO / "CHANGELOG.md").read_text() unreleased = _unreleased(changelog) assert "gh #44" in unreleased assert "NewSymbol" in unreleased assert "Bootstrap `1.11.0`" in unreleased assert "not returned by endpoint" in unreleased - assert "NewSymbol: '<NEW>'" in demo - assert "LastNbr" in demo - assert "not returned by endpoint" in demo - assert "Bootstrap/1.11.0" in demo def test_docs_membership_diff_empty() -> None: - """T250/V12/V19/V57: Unreleased + demo-seed skip empty membership GET.""" - demo = (REPO / "docs" / "demo-seed.md").read_text() + """T250/V12/V19/V57: changelog skip empty membership GET.""" changelog = (REPO / "CHANGELOG.md").read_text() # V19 promote empties Unreleased; persist notes live in the versioned section. assert "gh #39" in changelog assert "acu diff` skips those details" in changelog assert "Users GET is empty" in changelog - assert "read path is confirmed" not in demo - assert "`acu diff` skips those details" in demo - assert "Users GET is empty" in demo - assert "GET/diff detail `Roles`" not in demo - assert "Roles GET/diff shape" not in demo def test_docs_drop_root_check() -> None: @@ -136,25 +141,18 @@ def test_docs_drop_root_check() -> None: def test_docs_env_sole_config() -> None: """T219/V12/V27: human-facing docs pin via .env, not matrix.yaml.""" readme = (REPO / "README.md").read_text() - rest = (REPO / "docs" / "rest-api.md").read_text() - demo = (REPO / "docs" / "demo-seed.md").read_text() - for text in (readme, rest, demo): - assert "[--cell" not in text - assert "check --all" not in text - assert "ACU_API_VERSION" in text + assert "[--cell" not in readme + assert "check --all" not in readme + assert "ACU_API_VERSION" in readme assert "| `matrix.yaml` |" not in readme assert "check [--all]" not in readme assert "--strict" not in readme assert "default_api" not in readme - assert "There is no `ACU_API_VERSION`" not in rest - assert "matrix.yaml `default_api`" not in demo - assert "matrix.yaml default_api" not in demo def test_docs_survey_group() -> None: """T235/V15/V48/V19: docs nest extract/inventory/reconcile under survey.""" scoped = [REPO / "README.md"] - scoped.extend(sorted((REPO / "docs").glob("*.md"))) scoped.extend( sorted((REPO / "src" / "acumatica_cli" / "templates").rglob("README.md")) ) diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 684183f..1537590 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -12,7 +12,7 @@ from acumatica_cli import cli, inventory -# Minimal ac.exe / SM203520 table XML (docs/ac-exe.md shape). +# Minimal ac.exe / SM203520 table XML shape. ACCOUNT_XML = """\ <?xml version="1.0" encoding="utf-8"?> <data> diff --git a/tests/test_tenant.py b/tests/test_tenant.py index 913b823..8a79f01 100644 --- a/tests/test_tenant.py +++ b/tests/test_tenant.py @@ -1,8 +1,8 @@ """TenantManager: the exact ac.exe/sqlcmd command lines sent over SSH. -subprocess.run is monkeypatched — these tests pin the verified landmines -(docs/ac-exe.md): -iname AND -h, the Deleted sub-key with the full spec, -the -aup admin preset, and $LASTEXITCODE propagation. +subprocess.run is monkeypatched — these tests pin the verified landmines: +-iname AND -h, the Deleted sub-key with the full spec, the -aup admin +preset, and $LASTEXITCODE propagation. """ import subprocess @@ -90,7 +90,7 @@ def test_create_builds_full_company_spec(instance: Instance, run: FakeRun) -> No '-company:"CompanyID=5;ParentID=1;Visible=Yes;' 'CompanyType=;LoginName=lab5.ca-dev1;"' in command ) - # both are required or CompanyConfig dies mid-run (docs/ac-exe.md) + # both are required or CompanyConfig dies mid-run assert '-iname:"AcumaticaERP"' in command assert '-h:"C:\\Acumatica\\AcumaticaERP"' in command # admin preset makes the tenant REST-loginable without the first-login dance From 13abbb9aeb4034373708f0b907b9bb3e6b69b582 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik <github@lab5.ca> Date: Thu, 10 Sep 2026 09:30:47 -0400 Subject: [PATCH 21/25] T261: sweep host + retired-sibling literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the README example ACU_BASE_URL with the erp.example.com placeholder. Live instance stays in .env only. Drop the retired acumatica-infra sibling name from the app-pool docstring. §V.12 §V.27 --- README.md | 2 +- SPEC.md | 2 +- src/acumatica_cli/tenant.py | 2 +- tests/test_docs.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e3d1b30..a66e422 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ Secrets, REST where, and the Default contract pin live in one `.env` file (`ACU_ Leftover `matrix.yaml` is ignored and never loaded. ```sh -ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # REST where (required) +ACU_BASE_URL=http://erp.example.com/AcumaticaERP # REST where (required) ACU_API_VERSION=25.200.001 # Default contract half ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use # ACU_SSH omitted → defaults to Administrator@ + resolved base_url host diff --git a/SPEC.md b/SPEC.md index e32ba92..20dbf6d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -244,7 +244,7 @@ T257|x|copy sibling GitOps `config/{bootstrap,baseline,setup,master,views}` + `s T258|x|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; never CNBN; never sibling `cd` for CLI soak; e2e still packaged templates|V2,V12 T259|x|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 T260|x|drop `docs/` tree; sweep remaining `docs/` cites (scope: `docs/`)|V12,V49 -T261|.|sweep host + retired-sibling literals (scope: `acu-dev1|vm\.internal|acumatica-infra|acumatica-blog|acumatica-devops`); live instance from `.env` only|V12,V27 +T261|x|sweep host + retired-sibling literals (scope: `acu-dev1|vm\.internal|acumatica-infra|acumatica-blog|acumatica-devops`); live instance from `.env` only|V12,V27 T262|.|extras-hook V49 + check-md-prose drop `docs/*.md` glob; README/help/CHANGELOG Unreleased no `docs/` links; suite green|V49,V12,V19 ## §B BUGS diff --git a/src/acumatica_cli/tenant.py b/src/acumatica_cli/tenant.py index 35feeea..0d3d7bf 100644 --- a/src/acumatica_cli/tenant.py +++ b/src/acumatica_cli/tenant.py @@ -98,7 +98,7 @@ def recycle_app_pool(self) -> None: The tenant map loads at app start; without this, tenants created or deleted by CompanyConfig stay invisible to the sign-in page and REST routing silently falls back to the default tenant. - The pool is named after the instance (how acumatica-infra builds it). + The pool is named after the instance. """ self._ssh( "Import-Module WebAdministration; " diff --git a/tests/test_docs.py b/tests/test_docs.py index 00017d1..960e7c6 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -19,6 +19,35 @@ def _unreleased(changelog: str) -> str: return rest if nxt < 0 else rest[:nxt] +def test_no_retired_host_literals() -> None: + """T261/V12/V27: live instance from .env; no host/sibling literals.""" + bits = ( + "acu" + "-dev1", + "vm" + ".internal", + "acumatica" + "-infra", + "acumatica" + "-blog", + "acumatica" + "-devops", + ) + pat = re.compile("|".join(re.escape(b) for b in bits)) + hits: list[str] = [] + roots = [REPO / "README.md", REPO / "src", REPO / "tests"] + files: list[Path] = [] + for root in roots: + if root.is_file(): + files.append(root) + else: + files.extend(p for p in root.rglob("*") if p.is_file()) + for path in files: + if path.suffix not in {".py", ".cs", ".md", ".yaml", ".yml"}: + continue + text = path.read_text() + for i, line in enumerate(text.splitlines(), 1): + if pat.search(line): + rel = path.relative_to(REPO).as_posix() + hits.append(f"{rel}:{i}: {line.strip()}") + assert hits == [], "retired host/sibling literals:\n" + "\n".join(hits) + + def test_docs_cli_test_seed_soak() -> None: """T258/V12/V2: soak from this checkout; never CNBN; e2e still templates.""" agents = (REPO / "AGENTS.md").read_text() From dcad7040c7e66ab68d7edf652d009759833297ba Mon Sep 17 00:00:00 2001 From: Konstantin Borovik <github@lab5.ca> Date: Thu, 10 Sep 2026 09:32:20 -0400 Subject: [PATCH 22/25] T262: extras-hook V49 drop docs/*.md glob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-md-prose and the V49 extras recipe scan README plus packaged template README only. Drop leftover docs/*.md links from README. Unreleased records the docs/ tree removal. Root help already points at the package README. §V.49 §V.12 §V.19 --- .spec/check-extras.md | 2 +- .spec/scripts/check-md-prose | 5 +---- CHANGELOG.md | 5 +++++ README.md | 8 +------- SPEC.md | 2 +- tests/test_check_extras.py | 11 +++++++++++ tests/test_docs.py | 19 +++++++++++++++++++ 7 files changed, 39 insertions(+), 13 deletions(-) diff --git a/.spec/check-extras.md b/.spec/check-extras.md index 961da84..04af5ba 100644 --- a/.spec/check-extras.md +++ b/.spec/check-extras.md @@ -29,7 +29,7 @@ for granular /sdd:check runs. ## §V.49 — md-prose-density (mechanized §T.203) - cmd: `.spec/scripts/check-md-prose` (also via extras-hook above) -- scope: `README.md`, `docs/*.md`, `src/acumatica_cli/templates/**/README.md` +- scope: `README.md`, `src/acumatica_cli/templates/**/README.md` - prose paragraph = blank-line block; list / table / blockquote / fence / heading exempt - surviving match (`V49|VIOLATE|file:line: N sentences`, exit 1) → bail: `prose paragraph ≥3 sentences in human-facing Markdown per §V.49 — split the paragraph` diff --git a/.spec/scripts/check-md-prose b/.spec/scripts/check-md-prose index 4188d65..e74c78d 100755 --- a/.spec/scripts/check-md-prose +++ b/.spec/scripts/check-md-prose @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """check-md-prose - V49 human-facing Markdown paragraph density. -Scope: README.md, docs/*.md, packaged template README.md under +Scope: README.md, packaged template README.md under src/acumatica_cli/templates/. A prose paragraph (blank-line block) may have at most 2 sentences. List items, table rows, blockquotes, fenced code, and headings are exempt. SPEC.md / CHANGELOG / .spec stay out. @@ -31,9 +31,6 @@ def scoped_files(root: Path) -> list[Path]: readme = root / "README.md" if readme.is_file(): files.append(readme) - docs = root / "docs" - if docs.is_dir(): - files.extend(sorted(p for p in docs.glob("*.md") if p.is_file())) templates = root / "src" / "acumatica_cli" / "templates" if templates.is_dir(): files.extend(sorted(templates.rglob("README.md"))) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fbafd5..686d2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Removed + +- **docs/ tree:** dropped. Live verify is `.env` plus `acu schema`. README + and `acu --help` stay the human-facing surface. + ### Fixed - **NumberingSequence NewSymbol insert (gh #44):** Bootstrap `1.11.0` maps diff --git a/README.md b/README.md index a66e422..f69320b 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ acu --tenant DEV state # capture state/ trial-balance ``` Bare `apply` / `diff` (no path args) also prefer `config/` when those trees exist. -See [docs/demo-seed.md](docs/demo-seed.md) for the entity map, once-guard, apply-order notes, NumberingSequence vs prefs `*NumberingID`, curated *Preferences field depth (V41), and Role/User + password seed rules. **Hosted Acumatica (no SSH):** the tenant already exists; set a blank `ACU_SSH=` in `.env`. The scaffold omits the key — without it, acu defaults to `Administrator@<ACU_BASE_URL host>` for SSH boxes. @@ -122,7 +121,7 @@ When you omit FILES: - `state` defaults to `config/views/`; writes go to `state/` (`--out`). Scenario YAML may use `${current_period}` (host-local `MMyyyy`) on steps, expect params, and `once.present` params. -`config/views` and `state` keep Period pinned — see [docs/demo-seed.md](docs/demo-seed.md#period-token-current_period-vs-pinned-views). +`config/views` and `state` keep Period pinned. `survey extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS). @@ -135,8 +134,6 @@ It never writes seed. Optional `snapshot_map.yaml` (data-repo root, or package defaults) maps DAC tables to catalog entities. It normalizes the join: pad-trim, key/field aliases, Account/Sub FK CD resolve, enum label to code. -See [docs/demo-seed.md](docs/demo-seed.md). - `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile. Run `acu --help` for the full mental model (workflow, planes, exit codes, command map). @@ -160,8 +157,6 @@ Do not confuse them with each other or with `state`: `inventory/` and `findings/` are engagement outputs: not SEED_DIRS, never loaded by `apply`/`diff`, not scaffolded by `config init`. Binary `.adb` snapshots are rejected (XML only). -See [docs/ac-exe.md](docs/ac-exe.md) for export / SM203520 notes and [docs/demo-seed.md](docs/demo-seed.md) for the extract/state/inventory map. - ## The data repo Your configuration lives in its own git repo. @@ -194,7 +189,6 @@ Seed YAML is state: `apply` upserts it, `diff` proves it. `acu survey extract` is the inverse of `apply`: GET live tenant rows into seed YAML under `config/{bootstrap,baseline,setup,master}/` (hard-cut). Packaged `seed_catalog.yaml` is the sole extract registry (entity, endpoint, keys, file, strip/include, filter-split). -The demo entity map in [docs/demo-seed.md](docs/demo-seed.md) mirrors those catalog paths. Features synthesize to `config/bootstrap/features.yaml`. Existing files skip unless `--force`; empty live sets skip. diff --git a/SPEC.md b/SPEC.md index 20dbf6d..f7fc878 100644 --- a/SPEC.md +++ b/SPEC.md @@ -245,7 +245,7 @@ T258|x|AGENTS.md + README: ACUCLI soak from this checkout `--tenant ACUCLI`; nev T259|x|offline test: repo-root seed trees present; no `config/qms/`; e2e still 3 pipeline files|V2,V13 T260|x|drop `docs/` tree; sweep remaining `docs/` cites (scope: `docs/`)|V12,V49 T261|x|sweep host + retired-sibling literals (scope: `acu-dev1|vm\.internal|acumatica-infra|acumatica-blog|acumatica-devops`); live instance from `.env` only|V12,V27 -T262|.|extras-hook V49 + check-md-prose drop `docs/*.md` glob; README/help/CHANGELOG Unreleased no `docs/` links; suite green|V49,V12,V19 +T262|x|extras-hook V49 + check-md-prose drop `docs/*.md` glob; README/help/CHANGELOG Unreleased no `docs/` links; suite green|V49,V12,V19 ## §B BUGS diff --git a/tests/test_check_extras.py b/tests/test_check_extras.py index 9d433a3..36aff16 100644 --- a/tests/test_check_extras.py +++ b/tests/test_check_extras.py @@ -295,6 +295,17 @@ def test_v47_config_check_holds(tmp_path: Path) -> None: assert rows(r)["V47"][0] == "HOLD" +def test_v49_docs_md_out_of_scope(tmp_path: Path) -> None: + """T262/V49: check-md-prose no longer globs docs/*.md.""" + script = make_repo(tmp_path) + docs = tmp_path / "docs" + docs.mkdir() + (docs / "rest-api.md").write_text("One sentence. Two sentences. Three sentences.\n") + r = run_hook(script) + assert r.returncode == 0, r.stdout + r.stderr + assert rows(r)["V49"][0] == "HOLD" + + def test_v49_three_sentence_prose_violates(tmp_path: Path) -> None: script = make_repo(tmp_path) (tmp_path / "README.md").write_text( diff --git a/tests/test_docs.py b/tests/test_docs.py index 960e7c6..21f3470 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -97,6 +97,25 @@ def test_e2e_pipeline_files_only() -> None: assert "test_extract_roundtrip" in readme +def test_readme_help_unreleased_no_docs_links() -> None: + """T262/V12/V19/V49: README, root help, Unreleased have no docs/*.md links.""" + pat = re.compile(r"docs/" + r"[\w.-]+\.md") + readme = (REPO / "README.md").read_text() + help_text = (REPO / "src" / "acumatica_cli" / "cli.py").read_text() + unreleased = _unreleased((REPO / "CHANGELOG.md").read_text()) + hits: list[str] = [] + for label, text in ( + ("README.md", readme), + ("src/acumatica_cli/cli.py", help_text), + ("CHANGELOG.md Unreleased", unreleased), + ): + for i, line in enumerate(text.splitlines(), 1): + if pat.search(line): + hits.append(f"{label}:{i}: {line.strip()}") + assert hits == [], "docs/*.md links:\n" + "\n".join(hits) + assert "docs/ tree" in unreleased + + def test_docs_tree_dropped() -> None: """T260/V12: never docs/ tree as SoT.""" docs = REPO / "docs" From 17b43cda00496ff0edb329e285afdad340b16433 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik <github@lab5.ca> Date: Thu, 10 Sep 2026 09:49:15 -0400 Subject: [PATCH 23/25] review: soak numbering NewSymbol; drop stale project.xml fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI test-seed numbering sequences now carry NewSymbol: <NEW>, matching the packaged template so a cold insert does not 422. The bootstrap contract header no longer claims data-repo project.xml is a fallback. Packaged bootstrap_project.xml is the only contract. §V.58 §V.21 --- config/master/05-numbering-sequences.yaml | 12 ++++++++++++ src/acumatica_cli/bootstrap_project.xml | 6 +++--- tests/test_docs.py | 3 +++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/config/master/05-numbering-sequences.yaml b/config/master/05-numbering-sequences.yaml index 33ee4cc..746ef18 100644 --- a/config/master/05-numbering-sequences.yaml +++ b/config/master/05-numbering-sequences.yaml @@ -3,72 +3,84 @@ key: NumberingID endpoint: bootstrap records: - NumberingID: APBILL + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: APPAYMENT + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: ARINVOICE + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: ARPAYMENT + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: BATCH + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: INADJUST + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: INISSUE + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: INRECEIPT + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: POORDER + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: PORECEIPT + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: SOORDER + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' NbrStep: 1 StartDate: '1900-01-01' - NumberingID: SOSHIPMENT + NewSymbol: '<NEW>' StartNbr: '000000' EndNbr: '999999' WarnNbr: '999990' diff --git a/src/acumatica_cli/bootstrap_project.xml b/src/acumatica_cli/bootstrap_project.xml index 1f3667e..13c7c7a 100644 --- a/src/acumatica_cli/bootstrap_project.xml +++ b/src/acumatica_cli/bootstrap_project.xml @@ -18,9 +18,9 @@ (CS201010 insert requires New Number Symbol or Manual Numbering; gh #44 / V58). - package_zip() (bootstrap.py) prefers data-repo bootstrap/project.xml when - present; otherwise this packaged template is the fallback so config init, - offline tests, and package-only installs still bootstrap the full surface. + package_zip() always loads this packaged contract. Data-repo + config/bootstrap/project.xml or bootstrap/project.xml is forbidden (V21); + present -> hard error naming package SoT. package_zip() injects a <Graph ClassName="AcuBootstrapPlugin" FileType="NewFile" Source="..."/> diff --git a/tests/test_docs.py b/tests/test_docs.py index 21f3470..8919fd5 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -82,6 +82,9 @@ def test_cli_test_seed_trees_present() -> None: "test_provision_lifecycle.py", "test_scenario_lifecycle.py", ] + numbering = (config / "master" / "05-numbering-sequences.yaml").read_text() + assert "NewSymbol: '<NEW>'" in numbering + assert numbering.count("NumberingID:") == numbering.count("NewSymbol:") def test_e2e_pipeline_files_only() -> None: From 1ef891fa7217f174d4d0cb24b703ac31be96fcc1 Mon Sep 17 00:00:00 2001 From: Konstantin Borovik <github@lab5.ca> Date: Thu, 10 Sep 2026 11:18:34 -0400 Subject: [PATCH 24/25] =?UTF-8?q?condense=20SPEC.md:=20prongs=20{3,=206}?= =?UTF-8?q?=20(~19.5k=20=E2=86=92=20~15.6k=20tokens)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archive closed tasks T133 through T212 into SPEC.archive.md. Keep the newest 50 live (T213 through T262). Extract heavy invariant audit recipes into .spec/check-extras.md. --- .spec/check-extras.md | 111 ++++++++++++++++++++++++++++++++++++++++++ SPEC.archive.md | 80 ++++++++++++++++++++++++++++++ SPEC.md | 110 ++++++----------------------------------- 3 files changed, 206 insertions(+), 95 deletions(-) diff --git a/.spec/check-extras.md b/.spec/check-extras.md index 04af5ba..dcc884e 100644 --- a/.spec/check-extras.md +++ b/.spec/check-extras.md @@ -266,3 +266,114 @@ for granular /sdd:check runs. - `config/views` / `acu state` ! expand token (params pinned literals) - unknown `${…}` hard fail - no ERP business-date probe; no `--as-of`/`--period` CLI v1 (gh #28) + +## §V.25 — seed-file key identity (extracted from SPEC.md §V.25) + +- extract dup key tuple → row failure per V24, file never emitted +- `load_baseline` dup key tuple → hard error naming entity + first dup tuple +- catches hand-authored files (§B.14 workaround class; closes §B.21) + +## §V.30 — seed-layout dual (extracted from SPEC.md §V.30) + +- bare apply/diff defaults prefer data-repo `config/` SEED_DIRS when any child present, else root SEED_DIRS +- never merge dual trees; explicit path args still honored +- bootstrap features dual-resolve `config/bootstrap/` then `bootstrap/` (gh #19) +- `project.xml` ! dual-resolve (package SoT per V2/V21) +- extract emit hard-cut `config/` SEED_DIRS only (no root emit, no `--layout`) + +## §V.36 — seed authority (extracted from SPEC.md §V.36) + +- v1 writers into `config/` = REST extract + human only +- REST extract success for entity → seed/entity always wins vs inventory/table +- field/key conflict → `findings/` never silent overwrite seed +- post-v1 SnapshotMap promote only on REST gap (not prefer-artifact flag) +- unmapped tables never enter `config/` + +## §V.37 — tenant-snapshot artifact (extracted from SPEC.md §V.37) + +- accept SM203520 XML ZIP (`manifest.xml` + `*.xml`) or `ac.exe export xml` table-XML folder +- both normalize one IR → inventory/ +- binary `.adb` fail-closed named error +- never matrix `erp` match +- format tag "XML dump" ? for ingress only — never identity side name (table owns identity) + +## §V.40 — numbering-runtime (extracted from SPEC.md §V.40) + +- seed bounds + NewSymbol fields: `NumberingID`, `StartNbr`, `EndNbr`, `WarnNbr`, `NbrStep` + StartDate? if screen requires +- NewSymbol insert-required per numbering-new-symbol invariant +- `LastNbr` (+ advanced counter) = runtime state — extract strips; diff ignores; apply never requires +- ! reset live counters every apply (gh #25) + +## §V.44 — pin-overlay (extracted from SPEC.md §V.44) + +- optional Default-half overlays under `overlays/default-<half>/` keyed by resolved `api_version` (flag or `ACU_API_VERSION` or code default) +- config init scaffolds layout + known rewrites +- bare apply/diff/run auto-compose pin overlay when path args omitted (scenario basenames replace; config SEED_DIRS append) +- explicit paths disable auto +- never `matrix.yaml`; never `--cell` +- CLI never requires long-running product branches +- never commit multi-version OpenAPI trees as SoT (`acu schema` live dump/gitignored) +- full seed version matrices live in data repos not CLI (extends V27) + +## §V.48 — agent-help (extracted from SPEC.md §V.48) + +- root `acu --help` ! MENTAL MODEL (data-repo layout, REST vs SSH planes, sole writer=`apply`, drift=`diff` exit 2, txns=`run`) + TYPICAL WORKFLOW (SSH box) + HOSTED path + CONFIG RESOLUTION + COMMAND MAP by intent + EXIT CODES + DEFAULT PATHS +- LLM agents learn tool from `--help` alone (README points agents @ root help) +- every subcommand help ! prerequisites + concrete examples + exit codes + related cmds +- accepts `-h` + `--help` +- help layout wide (`max_content_width` ≥ 100) + +## §V.50 — segmented-key-seed (extracted from SPEC.md §V.50) + +- SegmentedKey seed ! update existing `DimensionID` only (never insert new) +- package seeds `INVENTORY` + `BIZACCT` one alphanumeric segment `Length` 30 (DAC max) +- `ACCOUNT` + `INSITE` stay `Length` 10 (`SiteCD` NVarChar(10)) +- never shrink `Length` after data exists +- key-URL GET after PUT ! return `SegmentID` + `Length` +- PUT `Length` ! persist so live InventoryID mask accepts 30 +- silent HTTP 200 w/ omitted detail fields = mapping miss (closes §B.28) (gh #30) + +## §V.52 — company-qty-precision (extracted from SPEC.md §V.52) + +- Company CS101500 maps `DecPlQty` (`ShortValue`) + `WeightUOM`/`VolumeUOM` to view `commonsetup` (aspx DataMember; graph `Commonsetup`) +- unmapped PUT 200 ignores (B28 class) +- GET ! return mapped fields so extract/diff round-trip +- package seed `DecPlQty: 3` (kit BOM milligram-scale KG; DAC default 2) +- DistributionModule on → PUT ! send WeightUOM+VolumeUOM (`CommonSetup_RowPersisting`) even when extract strips GET-omit siblings (B26 StockItem class) +- contract shape change ! version bump (V21) (closes §B.30) (gh #34) + +## §V.53 — user-role-membership (extracted from SPEC.md §V.53) + +- SM201010 User.Roles and SM201005 Role.Users contract-detail PUT never write UsersInRoles (silent 200) on AllowedRoles, RolesByUser, RoleList, and UsersByRole +- persist path ! mapped contract detail +- prove via SQL UsersInRoles (session CompanyID) +- GET/diff membership per membership-diff-empty invariant +- UserRole ! Selected; never AllowedRoles (EPLoginTypeAllowsRole) +- closes §B.31, §B.32, §B.33 (gh #35) + +## §V.54 — unwrap-row-id (extracted from SPEC.md §V.54) + +- unwrap keeps `id` + `delete` on records and detail rows that also have value fields (wrap already leaves them bare) +- files-style descriptors that unwrap to only `id` stay elided +- GET `$expand` detail `id` ! round-trip onto later PUT so contract updates existing line +- missing id → insert + 500 Components commit (KitAssembly StockComponents class) +- closes §B.34 (gh #38) + +## §V.57 — membership-diff-empty (extracted from SPEC.md §V.57) + +- mapped GET Role.Users / User.Roles ? empty while UsersInRoles has session-company rows +- diff ! flag Role.Users / User.Roles missing +- skip those details in diff or read live source that returns rows +- AssignUser apply + SQL-proven membership → `acu diff` exit 0 on Role.Users / User.Roles +- closes §B.37 (gh #39) + +## §V.58 — numbering-new-symbol (extracted from SPEC.md §V.58) + +- NumberingSequence insert ! Header NewSymbol (or UserNumbering if screen requires pair) +- Bootstrap contract maps it +- apply PUT new NumberingID w/ `NewSymbol: <NEW>` ! 422 +- re-apply existing id succeeds (no NewSymbol mask 422 on update) +- NewSymbol seed; mapped GET returns it +- GET-omit → `not returned by endpoint` drift (mapping-miss per segmented-key-seed invariant; not write-only-get-omit ignore) +- LastNbr strip per numbering-runtime invariant +- closes §B.38 (gh #44) diff --git a/SPEC.archive.md b/SPEC.archive.md index 1a1edb5..7f1cea2 100644 --- a/SPEC.archive.md +++ b/SPEC.archive.md @@ -135,6 +135,86 @@ T129|x|`acu reconcile` CLI — inventory + optional config/ → findings/ only; T130|x|offline tests: parse fixtures ZIP+folder, inventory byte-stable, binary reject, version pin vs target.yaml, reconcile findings, apply/diff ignore inventory/findings paths|V13,V35,V36,V37,T127,T128,T129 T131|x|docs + help + changelog: inventory vs extract vs state; dual-reader layout; ac-exe export + SM203520 Settings XML note; README CLI map|V12,V35,I.cmd,T128,T129 T132|x|M2 pad-trim string keys+fields on reconcile join/compare both sides|V38,I.cmd,T129 +T133|x|M3 declarative seed→inv key/field aliases in snapshot_map; Subaccount+UnitsOfMeasure join inventory; v1 map loads|V38,I.data,T132 +T134|x|M4 FK CD↔ID resolvers from inventory (Account+Sub first); ReasonCode+VendorClass *AcctID/*SubID false deltas → 0 when live matches seed|V38,I.data,T133 +T135|x|offline unit tests trim+alias join+Account/Sub resolve; LAB5-style fixture ReasonCode+VendorClass CD/ID pairs → 0 when match; existing reconcile tests green|V13,V38,T132,T133,T134 +T136|x|docs/changelog snapshot_map beyond table→entity (aliases+resolvers)|V12,I.data,T133,T134 +T137|x|M5 declarative enums: global enums: + per-row field→enum; ReasonCode.Usage + Account Type/PostOption/Active/RequireUnits + CreditTerms/StatementCycle/bool Active package defaults; label→code before compare|V38,I.data,T136 +T138|x|M4 extend PostingClass *AcctID/*SubID + CashAccount Account/Sub/Branch + OrderType Freight resolves in package map|V38,I.data,T134,T137 +T139|x|offline unit tests: enum Usage/bool/Type + PostingClass FK resolve + decimal trailing-zero norm; package map asserts; existing reconcile tests green|V13,V38,T137,T138 +T140|x|docs/changelog/help: enums layer on snapshot_map; package defaults cover LAB5 high-churn enums + PostingClass|V12,I.data,T137,T138 +T141|x|tenant list banner hostname-only + offline assert (no full URL in title)|V9,I.cmd,B27 +T142|x|`acu tenant recycle` CLI — wire TenantManager.recycle_app_pool; confirm + --yes; V15 verb map + help; stdout progress; exit 0/1|V1,V5,V6,V9,V15,V16,I.cmd +T143|x|offline tests: recycle invokes Restart-WebAppPool; empty ACU_SSH hard error; confirm/--yes; no REST|V13,V1,T142 +T144|x|docs + client mismatch hint → `acu tenant recycle`; changelog|V12,T142 +T145|x|bootstrap contract Role (SM201005 key Rolename ≥ Rolename+Descr) + User (SM201010 key Username identity fields) + membership (User detail roles); version bump|V21,I.data +T146|x|seed_catalog + snapshot_map rows Roles/Users/membership; optional package template master seed; ! built-in system roles as mutable seed|V34,V35,I.data,T145 +T147|x|password-seed pipeline: apply write-only when present; extract strip Password+b64__Password; diff ignore password; offline unit tests|V39,I.cmd,T145 +T148|x|apply order Role then User then membership (V22 prefixes); virgin+warm idempotent PUT; re-apply no password when user unchanged|V4,V22,V39,T145,T147 +T149|x|docs demo-seed or README CLI map — role/user seed order + password rule (gh #24)|V12,T145,T147 +T150|x|bootstrap contract NumberingSequence (CS201010 or V12-verified screen; key NumberingID ≥ bounds fields); version bump|V21,I.data +T151|x|seed_catalog + snapshot_map NumberingSequence; optional package template master/baseline; V22 order before prefs *NumberingID refs|V22,V34,V35,I.data,T150 +T152|x|LastNbr pipeline: extract strip LastNbr(+advanced counter); apply bounds without LastNbr; diff ignore; offline unit tests|V40,I.cmd,T150 +T153|x|docs demo-seed — numbering seed vs prefs *NumberingID apply order (gh #25)|V12,T150,T151 +T154|x|inventory+demo field pick list per prefs entity (IN/AP/AR/GL/SO/PO/CA as chosen); reason per field; ! full DAC|V41,I.data +T155|x|extend bootstrap_project.xml *Preferences for chosen fields; version bump|V21,V41,I.data,T154 +T156|x|widen seed_catalog include + package templates only where demo claims field; snapshot_map enums/resolves if new FKs (numbering IDs depend V40)|V34,V38,V40,V41,T155 +T157|x|offline tests: apply body + extract include + no permanent drift from server-derived/runtime; bootstrap field asserts|V13,V34,V41,T155,T156 +T158|x|docs demo-seed — added fields list + apply order vs warehouse/lot class/numbering if any; changelog (gh #26)|V12,T154,T156 +T159|x|LotSerialClass: catalog+template if demo claims; else docs non-goal (IN prefs skip DfltLotSerClassID stands)|V34,V42,I.data +T160|x|snapshot_map polish inventory masters reconcile should compare (PaymentMethod, CustomerClass, Vendor/Customer?, INLocation↔Warehouse locations, LotSerial if catalog); aliases/resolves/enums; ! Bootstrap bump|V35,V38,V42,I.data,T159 +T161|x|offline tests LAB5-class inventory+config → findings only real gaps not join-alias noise for mapped masters; existing reconcile green|V13,V38,V42,T160 +T162|x|docs table inventory table → entity → bootstrap\|default\|unmapped; changelog (gh #27)|V12,V42,T159,T160 +T163|x|`make release` promote Unreleased → `## [vX.Y.Z] - date`; empty Unreleased hard-fail; commit CHANGELOG w/ pyproject|V19,I.pkg +T164|x|`release.yml` GH release notes from CHANGELOG version section for tag; not sole `--generate-notes`|V19,I.pkg,T163 +T165|x|docs README release path + CHANGELOG Unreleased duty for user-facing work|V12,V19,T163 +T166|x|tenant create --id optional; omit → next free CompanyID max+1 from list; exists-skip adopts existing id when --id omit|V16,I.cmd +T167|x|offline tests: create login-only allocates; exists-skip without --id; id mismatch still hard-error; delete --login green|V13,T166 +T168|x|README CLI map + help + CHANGELOG Unreleased — create --login-only|V12,V19,T166 +T169|x|tenant delete progress — wrap ac.exe delete in output.step matching create; keep recycle step; offline assert step path; CHANGELOG Unreleased|V9,V19,I.cmd +T170|x|inventory progress — output.step around artifact parse; keep banner + write/skip emit; offline assert|V9,I.cmd +T171|x|reconcile progress — output.step around load+compare; keep emit write/skip; offline assert|V9,I.cmd +T172|x|CHANGELOG Unreleased — CLI progress (delete/inventory/reconcile); suite green|V19,T169,T170,T171 +T173|x|period helper + run interpolator built-in `${current_period}` (host-local MMyyyy); expand steps+expect+once present; unknown token fail|V43,I.cmd +T174|x|package scenario templates Period/FinPeriod → `${current_period}` where calendar-relative|V43,I.data,T173 +T175|x|offline tests: period_mmYYYY + freezegun month boundary + interpolator expect/once/step; view path no expand|V13,V33,V43,T173 +T176|x|docs + help: period token vs pinned views; ERP business-date skew; CHANGELOG Unreleased (gh #28)|V12,V19,V33,V43,T173,T174 +T177|x|e2e or offline cold seed-capital run green w/ token after mock month change; suite green|V4,V13,V43,T174,T175 +T178|x|drop data-repo project.xml resolve; load_contract always packaged bootstrap_project.xml; present data-repo file → hard error on bootstrap/publish path|V2,V21,I.data,I.cmd +T179|x|config init never scaffolds project.xml; drop INIT_TEMPLATES sentinel + special-case package copy|V28,I.cmd,T178 +T180|x|offline tests: package-only contract; present override → hard error; flip hybrid prefer/override cases|V13,V2,V21,T178,T179 +T181|x|docs README + demo-seed + templates README + seed_catalog comments + CHANGELOG Unreleased — package SoT; data repos must not keep project.xml|V12,V19,T178,T179 +T182|x|suite green make check after package-SoT bootstrap contract|V13,T178,T179,T180,T181 +T183|x|docs multi-host matrix: trunk + target.yaml + optional overlay; no long-running branches; cross-link gitops#2|V44,V12,V27 +T184|x|apply fail report: continue multi-error summary or stopped-at + remaining list; exit 1; never silent partial|V45,I.cmd,V4 +T185|x|offline tests apply fail-report paths (first-record fail + multi-error)|V13,V45,T184 +T186|x|surface 422 field-level errors (Type.error class) in put/action RuntimeError detail|V46,I.cmd +T187|x|offline tests field-error parse from fixture 422 bodies|V13,V46,T186 +T188|x|decide overlay: data-repo-only vs apply --overlay; docs; implement CLI only if gitops needs hook|V44,I.cmd,I.data +T189|x|investigate User Roles membership apply/diff for built-in soadmin/apadmin/aradmin; fix or docs limit|V4,V39,I.data +T190|x|CHANGELOG Unreleased + suite green after matrix/apply-error work|V19,V13,T183,T184,T186 +T191|x|optional later: curated compat profile by Default half for proven rewrites; no full swagger trees|V44,V11 +T192|x|matrix.yaml load+models (cells id/erp/default_api/base_url); unique ids; ordered; drop target.yaml loader|V27,I.data +T193|x|global --cell; default first cell; resolve base_url+api_version+erp from active cell|V27,I.cmd +T194|x|config init/show/check: matrix surface; init one-cell scaffold; retire target template|V27,V28,I.cmd +T195|x|acu check lifecycle create→apply→run→diff→delete; --yes; tenant+SSH gates|V47,I.cmd +T196|x|acu check --all continue+aggregate exit; per-cell progress (V9)|V47,V9,I.cmd +T197|x|offline tests: matrix load/select; resolve order; check dry/mock paths; no target.yaml|V13,V27,T192,T193,T195 +T198|x|docs README+templates+CHANGELOG matrix.yaml + acu check; suite green|V12,V19,V44,T192,T195 +T199|x|drop acu check post-clean delete; leave tenant after green (fail path leave too)|V47,I.cmd +T200|x|offline test green leaves tenant; README+CHANGELOG lifecycle leave-tenant; suite green|V13,V12,V19,V47,T199 +T201|x|agent-oriented root+subcommand --help (mental model, workflow, map, exit codes, -h, wide layout); offline tests; README agent pointer|V48,I.cmd +T202|x|sweep human-facing md prose → ≤2 sentences/paragraph, sparse (scope: `README.md` `docs/*.md` `src/acumatica_cli/templates/**/*.md`)|V49 +T203|x|add V49 extras-hook `.spec/scripts/check-md-prose` + recipe; offline tests; suite green|V49,V13 +T204|x|bootstrap contract SegmentedKey (CS202000 DimensionMaint; Header Dimension key DimensionID; detail Segment SegmentID+Length); version bump|V21,I.data +T205|x|seed_catalog + package template `config/bootstrap/` SegmentedKey; keys `[DimensionID]`; INVENTORY+BIZACCT segment 1 Length 30; ACCOUNT+INSITE unchanged; V22 bootstrap-before-master; V34 one catalog row|V22,V34,V50,I.data,T204 +T206|x|extract --force round-trips INVENTORY/BIZACCT Length 30; offline tests apply body + extract include + catalog row|V13,V34,V50,I.cmd,T205 +T207|x|live/e2e: SegmentedKey GET key-URL returns Length 30 after apply; PUT StockItem 26-char InventoryID succeeds; contract mapping fix + version bump if shape change|V4,V13,V21,V50,B28,T205 +T208|x|docs/demo-seed — SegmentedKey entity, CS202000, DAC max 30, non-goals (no shrink; no INSITE/ACCOUNT widen); CHANGELOG Unreleased (gh #30)|V12,V19,V50,T204,T205 +T209|x|drop ControlAccountModule IN on package `config/baseline/20-accounts.yaml` 12300/12400; keep 12100/12200|V51,I.data,B29 +T210|x|offline tests: 12300/12400 no ControlAccountModule IN; 12100/12200 still IN; INPreferences still 12300/12400|V13,V51,T209 +T211|x|e2e: package INPreferences apply succeeds after accounts; drop 15000 workaround in `test_segmented_key_length.py`|V4,V13,V51,B29,T209 +T212|x|docs/demo-seed — IN control on posting-class inventory assets only; WIP/transit not control; CHANGELOG Unreleased (gh #32)|V12,V19,V51,T209 ## §B BUGS diff --git a/SPEC.md b/SPEC.md index f7fc878..a743a3e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -76,126 +76,46 @@ V21: endpoint contract identity = name+version — entity or field shape change V22: baseline reference closure — referenced entity ! exist @ PUT (tenant-native or earlier-sorting file); feature-gated file ! FeaturesSet enabled; full audit recipe → `.spec/check-extras.md` §V.22 V23: completion path local-only — `--completion` script emit + dynamic value completion read package data + data-repo files (cwd walk-up) + `.env` only; never REST, never SSH, never live instance (fires per keystroke) V24: extract per-row failure isolation — row failure → continue + report; empty-state class ! skip not fail; virgin tenant full-manifest exit 0 (closes §B.19); exit/msg matrix → `.spec/check-extras.md` §V.24 -V25: seed-file key identity — declared key tuple ! uniquely identify records; extract dup key tuple → row failure per V24, file never emitted; `load_baseline` dup key tuple → hard error naming entity + first dup tuple — catches hand-authored files (§B.14 workaround class; closes §B.21) +V25: seed-file key identity — declared key tuple ! uniquely identify records; extract dup → row failure per V24, file never emitted; `load_baseline` dup → hard error (closes §B.21); audit recipe → `.spec/check-extras.md` §V.25 V26: org-scoped view read ! explicit org context — GL201100-class entity answers 200 [] multi-org w/o org param; probe/synth ! org-parameterized or per-org iterate; single-org green ≠ multi-org proof (closes §B.22); audit recipe → `.spec/check-extras.md` §V.26 V27: env-sole-config — `.env` `ACU_*` sole pin+where: `ACU_BASE_URL` + `ACU_API_VERSION`; `--api-version` flag ? → else env → else code default `25.200.001`; `--url` flag ? → else env → else hard error; never `matrix.yaml`; never `--cell`; never `target.yaml`; leftover `matrix.yaml` ignored (never loaded); gate allowlist → `.spec/check-extras.md` §V.27 V28: init-template — `config init` single full seed (no `--flavor`); config SEED_DIRS + scenario lifecycle + views TB + `.env` w/ `ACU_BASE_URL` + `ACU_API_VERSION` (not `matrix.yaml` not `target.yaml`); templates gitops-prune; no project.xml scaffold; full audit recipe → `.spec/check-extras.md` §V.28 V29: init-org-cd — init scaffold ! single org-CD placeholder token across company `AcctCD`, ledger-company, open-periods `OrganizationID`, INPreferences `TransitBranchID`, cash-account `BranchID` -V30: seed-layout dual — bare apply/diff defaults prefer data-repo `config/` SEED_DIRS when any child present, else root SEED_DIRS; never merge dual trees; explicit path args still honored; bootstrap features dual-resolve `config/bootstrap/` then `bootstrap/` (gh #19); `project.xml` ! dual-resolve (package SoT per V2/V21); extract emit hard-cut `config/` SEED_DIRS only (no root emit, no `--layout`) +V30: seed-layout dual — bare apply/diff defaults prefer `config/` SEED_DIRS when any child present, else root; never merge dual trees; extract emit hard-cut `config/` only; layout recipe → `.spec/check-extras.md` §V.30 V31: entity-list dual-shape — `parse_entity_list` accepts top-level JSON array or object w/ `endpoints` array (same name/version string row rules); empty/unparseable → fail-closed (gh #20; T74 array-only broken on 26.x wrapper) V32: derived-state-observation — views `config/views/`, captures `state/`; never seed/apply path; money fixed-point; exit 2 only `--assert-unchanged`; full audit recipe → `.spec/check-extras.md` §V.32 V33: observation-source — view exactly one of `gi:`|`entity:`|`inquire:`; params pinned literals (no `${current_period}`); TB ! ≥1 numeric money; full audit recipe → `.spec/check-extras.md` §V.33 V34: seed-catalog completeness — every packaged `templates/config/**` seed yaml ! exactly one `seed_catalog.yaml` row; template=catalog=extract paths; no GET-never-returned claims; full audit recipe → `.spec/check-extras.md` §V.34 V35: dual-reader single-writer + vocab — REST `extract` + offline `inventory` readers (cmds under `survey`); sole mutator `apply` PUT; never SM203520 restore; vocab + layout → `.spec/check-extras.md` §V.35 -V36: seed authority — v1 writers into `config/` = REST extract + human only; when REST extract succeeds for entity, seed/entity always wins vs inventory/table; field/key conflict → `findings/` never silent overwrite seed; post-v1 SnapshotMap promote only on REST gap (not prefer-artifact flag); unmapped tables never enter `config/` -V37: tenant-snapshot artifact — accept SM203520 XML ZIP (`manifest.xml` + `*.xml`) or `ac.exe export xml` table-XML folder; both normalize one IR → inventory/; binary `.adb` fail-closed named error; never matrix `erp` match; format tag "XML dump" ? for ingress only — never identity side name (table owns identity) +V36: seed authority — v1 writers into `config/` = REST extract + human only; REST extract success → seed/entity wins vs inventory/table; conflict → `findings/` never silent overwrite; audit recipe → `.spec/check-extras.md` §V.36 +V37: tenant-snapshot artifact — accept SM203520 XML ZIP or `ac.exe export xml` folder; both normalize one IR → inventory/; binary `.adb` fail-closed; format recipe → `.spec/check-extras.md` §V.37 V38: reconcile-normalize — pad-trim both sides; snapshot_map aliases + FK CD↔ID + enum label→code; never silent promote config/ (V36); full audit recipe → `.spec/check-extras.md` §V.38 V39: password-seed — User seed Password write-only @ apply when present in YAML; extract strips `Password` + `b64__Password` (never seed hashes); diff ignores password fields -V40: numbering-runtime — NumberingSequence seed ! bounds + NewSymbol (`NumberingID`, `StartNbr`, `EndNbr`, `WarnNbr`, `NbrStep` + StartDate? if screen requires; NewSymbol insert-required per numbering-new-symbol invariant); `LastNbr` (+ advanced counter) = runtime state — extract strips; diff ignores; apply never requires; ! reset live counters every apply (gh #25) +V40: numbering-runtime — NumberingSequence seed ! bounds + NewSymbol; `LastNbr` (+ advanced counter) = runtime state — extract strips; diff ignores; apply never requires; ! reset live counters every apply; audit recipe → `.spec/check-extras.md` §V.40 V41: prefs-field-depth — Bootstrap *Preferences field lists ! curated subset (demo seed need or ERP-default rebuild risk); ! full DAC mirror; each field ! seed/catalog reason; server-derived/runtime ! seed (B11 class); contract shape change ! version bump (V21) (gh #26) V42: inventory-map-coverage — dual-reader masters ! snapshot_map table→entity so findings = real gaps; intentional unmapped ! named in snapshot_map or findings, never silent; never docs/ table; full audit recipe → `.spec/check-extras.md` §V.42 V43: period-token — `acu run` `${current_period}` → host-local `MMyyyy` @ process start every `${var}` site; views/state ! expand; full audit recipe → `.spec/check-extras.md` §V.43 -V44: pin-overlay — optional Default-half overlays under `overlays/default-<half>/` keyed by resolved `api_version` (flag or `ACU_API_VERSION` or code default); config init scaffolds layout + known rewrites; bare apply/diff/run auto-compose pin overlay when path args omitted (scenario basenames replace; config SEED_DIRS append); explicit paths disable auto; never `matrix.yaml`; never `--cell`; CLI never requires long-running product branches; never commit multi-version OpenAPI trees as SoT (`acu schema` live dump/gitignored); full seed version matrices live in data repos not CLI (extends V27) +V44: pin-overlay — optional Default-half overlays keyed by resolved `api_version`; never `matrix.yaml`; never `--cell`; never commit multi-version OpenAPI trees as SoT; extends V27; compose recipe → `.spec/check-extras.md` §V.44 V45: apply-fail-report — apply failure ! silent partial tree: either per-record continue + multi-error summary exit 1, or hard-stop w/ stopped-at file/record + remaining not-applied list exit 1; exit 2 stays drift (sibling V24 extract isolation) V46: contract-field-error — PUT/action HTTP error ! surface nested `Field.error` + kin (esp 422) and `innerException.exceptionMessage` when present (append, not only when top empty); not status-only or top exceptionMessage alone; curl not required V47: no-lifecycle-cmd — no `acu check` verb; cold rebuild = compose `tenant create` + `apply` + `run` + `diff`; `acu config check` stays preflight; never wrap pipeline as one cmd; extras-hook greps `src/` `check_cmd|_run_lifecycle|_lifecycle_|_resolve_check_tenant` → `.spec/check-extras.md` §V.47 -V48: agent-help — root `acu --help` ! MENTAL MODEL (data-repo layout, REST vs SSH planes, sole writer=`apply`, drift=`diff` exit 2, txns=`run`) + TYPICAL WORKFLOW (SSH box) + HOSTED path + CONFIG RESOLUTION + COMMAND MAP by intent + EXIT CODES + DEFAULT PATHS so LLM agents learn tool from `--help` alone (README points agents @ root help); every subcommand help ! prerequisites + concrete examples + exit codes + related cmds; accepts `-h` + `--help`; help layout wide (`max_content_width` ≥ 100) -V49: md-prose-density — human-facing Markdown (`README.md`, packaged template README) prose paragraph ≤ 2 sentences, sparse; list / table / fence exempt; SPEC.md / SPEC.archive.md / CHANGELOG.md / `.spec/*.md` out; mechanical: extras-hook `.spec/scripts/check-md-prose` via `.spec/scripts/check-extras.sh`; ≥3-sentence prose paragraph = VIOLATE -V50: segmented-key-seed — SegmentedKey seed ! update existing `DimensionID` only (never insert new); package seeds `INVENTORY` + `BIZACCT` one alphanumeric segment `Length` 30 (DAC max); `ACCOUNT` + `INSITE` stay `Length` 10 (`SiteCD` NVarChar(10)); never shrink `Length` after data exists; key-URL GET after PUT ! return `SegmentID` + `Length`; PUT `Length` ! persist so live InventoryID mask accepts 30; silent HTTP 200 w/ omitted detail fields = mapping miss (closes §B.28) (gh #30) +V48: agent-help — root `acu --help` ! MENTAL MODEL + WORKFLOW + COMMAND MAP + EXIT + PATHS so agents learn from `--help` alone; subcommand help ! prereqs + examples + exit + related; accepts `-h` + `--help`; layout recipe → `.spec/check-extras.md` §V.48 +V49: md-prose-density — human-facing Markdown (`README.md`, packaged template README) prose paragraph ≤ 2 sentences, sparse; list / table / fence exempt; SPEC.md / SPEC.archive.md / CHANGELOG.md / `.spec/*.md` out; ≥3-sentence prose paragraph = VIOLATE; mechanical → `.spec/check-extras.md` §V.49 +V50: segmented-key-seed — SegmentedKey seed ! update existing `DimensionID` only (never insert new); package `INVENTORY`+`BIZACCT` Length 30; `ACCOUNT`+`INSITE` Length 10; never shrink `Length` after data exists; silent HTTP 200 omitted detail = mapping miss (closes §B.28); mapping recipe → `.spec/check-extras.md` §V.50 V51: in-prefs-control-account — INPreferences `INProgressAcctID`/`INTransitAcctID` ! accounts w/ `ControlAccountModule: IN`; package 12300/12400 drop it; 12100/12200 keep IN control (posting-class InvtAcctID); else 26r1 PUT 500 + IN Setup cascade (closes §B.29) (gh #32) -V52: company-qty-precision — Company CS101500 maps `DecPlQty` (`ShortValue`) + `WeightUOM`/`VolumeUOM` to view `commonsetup` (aspx DataMember; graph `Commonsetup`); unmapped PUT 200 ignores (B28 class); GET ! return mapped fields so extract/diff round-trip; package seed `DecPlQty: 3` (kit BOM milligram-scale KG; DAC default 2); DistributionModule on → PUT ! send WeightUOM+VolumeUOM (`CommonSetup_RowPersisting`) even when extract strips GET-omit siblings (B26 StockItem class); contract shape change ! version bump (V21) (closes §B.30) (gh #34) -V53: user-role-membership — SM201010 User.Roles and SM201005 Role.Users contract-detail PUT never write UsersInRoles (silent 200) on AllowedRoles, RolesByUser, RoleList, and UsersByRole; persist path ! mapped contract detail; prove via SQL UsersInRoles (session CompanyID); GET/diff membership per membership-diff-empty invariant; UserRole ! Selected; never AllowedRoles (EPLoginTypeAllowsRole) (closes §B.31, §B.32, §B.33) (gh #35) -V54: unwrap-row-id — unwrap keeps `id` + `delete` on records and detail rows that also have value fields (wrap already leaves them bare); files-style descriptors that unwrap to only `id` stay elided; GET `$expand` detail `id` ! round-trip onto later PUT so contract updates existing line — missing id → insert + 500 Components commit (KitAssembly StockComponents class) (closes §B.34) (gh #38) +V52: company-qty-precision — Company CS101500 maps `DecPlQty` + `WeightUOM`/`VolumeUOM` to `commonsetup`; unmapped PUT 200 ignores; package seed `DecPlQty: 3`; DistributionModule on → PUT ! send WeightUOM+VolumeUOM (closes §B.30); mapping recipe → `.spec/check-extras.md` §V.52 +V53: user-role-membership — SM201010 User.Roles and SM201005 Role.Users contract-detail PUT never write UsersInRoles; persist path ! mapped contract detail; UserRole ! Selected; never AllowedRoles (closes §B.31, §B.32, §B.33); persist recipe → `.spec/check-extras.md` §V.53 +V54: unwrap-row-id — unwrap keeps `id` + `delete` on value-field rows; files-style id-only stay elided; GET `$expand` detail `id` ! round-trip onto later PUT; missing id → insert + 500 Components commit (closes §B.34); unwrap recipe → `.spec/check-extras.md` §V.54 V55: datetime-calendar-day — `_norm` date-only `YYYY-MM-DD` matches live DateTimeValue same calendar day (strip `T…offset`); different day still drift; extract date-strips DateTimeValue seed emit (BegFinYear synth class) (closes §B.35) (gh #40) V56: write-only-get-omit — apply-needed field GET never returns ! diff-ignore (join `_DIFF_IGNORE_FIELDS`) not `not returned by endpoint`; ! general GET-omit=ok (mapping-miss per §V.50 stands); LotSerialClass Auto-Incremental `Segments.Value` first (closes §B.36) (gh #40) -V57: membership-diff-empty — mapped GET Role.Users / User.Roles ? empty while UsersInRoles has session-company rows; diff ! flag Role.Users / User.Roles missing; skip those details in diff or read live source that returns rows; AssignUser apply + SQL-proven membership → `acu diff` exit 0 on Role.Users / User.Roles (closes §B.37) (gh #39) -V58: numbering-new-symbol — NumberingSequence insert ! Header NewSymbol (or UserNumbering if screen requires pair); Bootstrap contract maps it; apply PUT new NumberingID w/ `NewSymbol: <NEW>` ! 422; re-apply existing id succeeds (no NewSymbol mask 422 on update); NewSymbol seed; mapped GET returns it; GET-omit → `not returned by endpoint` drift (mapping-miss per segmented-key-seed invariant; not write-only-get-omit ignore); LastNbr strip per numbering-runtime invariant (closes §B.38) (gh #44) +V57: membership-diff-empty — mapped GET Role.Users / User.Roles ? empty while UsersInRoles has session-company rows; diff ! flag missing (closes §B.37); compare recipe → `.spec/check-extras.md` §V.57 +V58: numbering-new-symbol — NumberingSequence insert ! Header NewSymbol (or UserNumbering if screen requires pair); apply PUT new NumberingID w/ `NewSymbol: <NEW>` ! 422; GET-omit → `not returned by endpoint` drift not write-only-get-omit ignore (closes §B.38); mapping recipe → `.spec/check-extras.md` §V.58 ## §T TASKS -## archived: §T.1..§T.132 → SPEC.archive.md (132 rows) +## archived: §T.1..§T.212 → SPEC.archive.md (212 rows) id|status|task|cites -T133|x|M3 declarative seed→inv key/field aliases in snapshot_map; Subaccount+UnitsOfMeasure join inventory; v1 map loads|V38,I.data,T132 -T134|x|M4 FK CD↔ID resolvers from inventory (Account+Sub first); ReasonCode+VendorClass *AcctID/*SubID false deltas → 0 when live matches seed|V38,I.data,T133 -T135|x|offline unit tests trim+alias join+Account/Sub resolve; LAB5-style fixture ReasonCode+VendorClass CD/ID pairs → 0 when match; existing reconcile tests green|V13,V38,T132,T133,T134 -T136|x|docs/changelog snapshot_map beyond table→entity (aliases+resolvers)|V12,I.data,T133,T134 -T137|x|M5 declarative enums: global enums: + per-row field→enum; ReasonCode.Usage + Account Type/PostOption/Active/RequireUnits + CreditTerms/StatementCycle/bool Active package defaults; label→code before compare|V38,I.data,T136 -T138|x|M4 extend PostingClass *AcctID/*SubID + CashAccount Account/Sub/Branch + OrderType Freight resolves in package map|V38,I.data,T134,T137 -T139|x|offline unit tests: enum Usage/bool/Type + PostingClass FK resolve + decimal trailing-zero norm; package map asserts; existing reconcile tests green|V13,V38,T137,T138 -T140|x|docs/changelog/help: enums layer on snapshot_map; package defaults cover LAB5 high-churn enums + PostingClass|V12,I.data,T137,T138 -T141|x|tenant list banner hostname-only + offline assert (no full URL in title)|V9,I.cmd,B27 -T142|x|`acu tenant recycle` CLI — wire TenantManager.recycle_app_pool; confirm + --yes; V15 verb map + help; stdout progress; exit 0/1|V1,V5,V6,V9,V15,V16,I.cmd -T143|x|offline tests: recycle invokes Restart-WebAppPool; empty ACU_SSH hard error; confirm/--yes; no REST|V13,V1,T142 -T144|x|docs + client mismatch hint → `acu tenant recycle`; changelog|V12,T142 -T145|x|bootstrap contract Role (SM201005 key Rolename ≥ Rolename+Descr) + User (SM201010 key Username identity fields) + membership (User detail roles); version bump|V21,I.data -T146|x|seed_catalog + snapshot_map rows Roles/Users/membership; optional package template master seed; ! built-in system roles as mutable seed|V34,V35,I.data,T145 -T147|x|password-seed pipeline: apply write-only when present; extract strip Password+b64__Password; diff ignore password; offline unit tests|V39,I.cmd,T145 -T148|x|apply order Role then User then membership (V22 prefixes); virgin+warm idempotent PUT; re-apply no password when user unchanged|V4,V22,V39,T145,T147 -T149|x|docs demo-seed or README CLI map — role/user seed order + password rule (gh #24)|V12,T145,T147 -T150|x|bootstrap contract NumberingSequence (CS201010 or V12-verified screen; key NumberingID ≥ bounds fields); version bump|V21,I.data -T151|x|seed_catalog + snapshot_map NumberingSequence; optional package template master/baseline; V22 order before prefs *NumberingID refs|V22,V34,V35,I.data,T150 -T152|x|LastNbr pipeline: extract strip LastNbr(+advanced counter); apply bounds without LastNbr; diff ignore; offline unit tests|V40,I.cmd,T150 -T153|x|docs demo-seed — numbering seed vs prefs *NumberingID apply order (gh #25)|V12,T150,T151 -T154|x|inventory+demo field pick list per prefs entity (IN/AP/AR/GL/SO/PO/CA as chosen); reason per field; ! full DAC|V41,I.data -T155|x|extend bootstrap_project.xml *Preferences for chosen fields; version bump|V21,V41,I.data,T154 -T156|x|widen seed_catalog include + package templates only where demo claims field; snapshot_map enums/resolves if new FKs (numbering IDs depend V40)|V34,V38,V40,V41,T155 -T157|x|offline tests: apply body + extract include + no permanent drift from server-derived/runtime; bootstrap field asserts|V13,V34,V41,T155,T156 -T158|x|docs demo-seed — added fields list + apply order vs warehouse/lot class/numbering if any; changelog (gh #26)|V12,T154,T156 -T159|x|LotSerialClass: catalog+template if demo claims; else docs non-goal (IN prefs skip DfltLotSerClassID stands)|V34,V42,I.data -T160|x|snapshot_map polish inventory masters reconcile should compare (PaymentMethod, CustomerClass, Vendor/Customer?, INLocation↔Warehouse locations, LotSerial if catalog); aliases/resolves/enums; ! Bootstrap bump|V35,V38,V42,I.data,T159 -T161|x|offline tests LAB5-class inventory+config → findings only real gaps not join-alias noise for mapped masters; existing reconcile green|V13,V38,V42,T160 -T162|x|docs table inventory table → entity → bootstrap\|default\|unmapped; changelog (gh #27)|V12,V42,T159,T160 -T163|x|`make release` promote Unreleased → `## [vX.Y.Z] - date`; empty Unreleased hard-fail; commit CHANGELOG w/ pyproject|V19,I.pkg -T164|x|`release.yml` GH release notes from CHANGELOG version section for tag; not sole `--generate-notes`|V19,I.pkg,T163 -T165|x|docs README release path + CHANGELOG Unreleased duty for user-facing work|V12,V19,T163 -T166|x|tenant create --id optional; omit → next free CompanyID max+1 from list; exists-skip adopts existing id when --id omit|V16,I.cmd -T167|x|offline tests: create login-only allocates; exists-skip without --id; id mismatch still hard-error; delete --login green|V13,T166 -T168|x|README CLI map + help + CHANGELOG Unreleased — create --login-only|V12,V19,T166 -T169|x|tenant delete progress — wrap ac.exe delete in output.step matching create; keep recycle step; offline assert step path; CHANGELOG Unreleased|V9,V19,I.cmd -T170|x|inventory progress — output.step around artifact parse; keep banner + write/skip emit; offline assert|V9,I.cmd -T171|x|reconcile progress — output.step around load+compare; keep emit write/skip; offline assert|V9,I.cmd -T172|x|CHANGELOG Unreleased — CLI progress (delete/inventory/reconcile); suite green|V19,T169,T170,T171 -T173|x|period helper + run interpolator built-in `${current_period}` (host-local MMyyyy); expand steps+expect+once present; unknown token fail|V43,I.cmd -T174|x|package scenario templates Period/FinPeriod → `${current_period}` where calendar-relative|V43,I.data,T173 -T175|x|offline tests: period_mmYYYY + freezegun month boundary + interpolator expect/once/step; view path no expand|V13,V33,V43,T173 -T176|x|docs + help: period token vs pinned views; ERP business-date skew; CHANGELOG Unreleased (gh #28)|V12,V19,V33,V43,T173,T174 -T177|x|e2e or offline cold seed-capital run green w/ token after mock month change; suite green|V4,V13,V43,T174,T175 -T178|x|drop data-repo project.xml resolve; load_contract always packaged bootstrap_project.xml; present data-repo file → hard error on bootstrap/publish path|V2,V21,I.data,I.cmd -T179|x|config init never scaffolds project.xml; drop INIT_TEMPLATES sentinel + special-case package copy|V28,I.cmd,T178 -T180|x|offline tests: package-only contract; present override → hard error; flip hybrid prefer/override cases|V13,V2,V21,T178,T179 -T181|x|docs README + demo-seed + templates README + seed_catalog comments + CHANGELOG Unreleased — package SoT; data repos must not keep project.xml|V12,V19,T178,T179 -T182|x|suite green make check after package-SoT bootstrap contract|V13,T178,T179,T180,T181 -T183|x|docs multi-host matrix: trunk + target.yaml + optional overlay; no long-running branches; cross-link gitops#2|V44,V12,V27 -T184|x|apply fail report: continue multi-error summary or stopped-at + remaining list; exit 1; never silent partial|V45,I.cmd,V4 -T185|x|offline tests apply fail-report paths (first-record fail + multi-error)|V13,V45,T184 -T186|x|surface 422 field-level errors (Type.error class) in put/action RuntimeError detail|V46,I.cmd -T187|x|offline tests field-error parse from fixture 422 bodies|V13,V46,T186 -T188|x|decide overlay: data-repo-only vs apply --overlay; docs; implement CLI only if gitops needs hook|V44,I.cmd,I.data -T189|x|investigate User Roles membership apply/diff for built-in soadmin/apadmin/aradmin; fix or docs limit|V4,V39,I.data -T190|x|CHANGELOG Unreleased + suite green after matrix/apply-error work|V19,V13,T183,T184,T186 -T191|x|optional later: curated compat profile by Default half for proven rewrites; no full swagger trees|V44,V11 -T192|x|matrix.yaml load+models (cells id/erp/default_api/base_url); unique ids; ordered; drop target.yaml loader|V27,I.data -T193|x|global --cell; default first cell; resolve base_url+api_version+erp from active cell|V27,I.cmd -T194|x|config init/show/check: matrix surface; init one-cell scaffold; retire target template|V27,V28,I.cmd -T195|x|acu check lifecycle create→apply→run→diff→delete; --yes; tenant+SSH gates|V47,I.cmd -T196|x|acu check --all continue+aggregate exit; per-cell progress (V9)|V47,V9,I.cmd -T197|x|offline tests: matrix load/select; resolve order; check dry/mock paths; no target.yaml|V13,V27,T192,T193,T195 -T198|x|docs README+templates+CHANGELOG matrix.yaml + acu check; suite green|V12,V19,V44,T192,T195 -T199|x|drop acu check post-clean delete; leave tenant after green (fail path leave too)|V47,I.cmd -T200|x|offline test green leaves tenant; README+CHANGELOG lifecycle leave-tenant; suite green|V13,V12,V19,V47,T199 -T201|x|agent-oriented root+subcommand --help (mental model, workflow, map, exit codes, -h, wide layout); offline tests; README agent pointer|V48,I.cmd -T202|x|sweep human-facing md prose → ≤2 sentences/paragraph, sparse (scope: `README.md` `docs/*.md` `src/acumatica_cli/templates/**/*.md`)|V49 -T203|x|add V49 extras-hook `.spec/scripts/check-md-prose` + recipe; offline tests; suite green|V49,V13 -T204|x|bootstrap contract SegmentedKey (CS202000 DimensionMaint; Header Dimension key DimensionID; detail Segment SegmentID+Length); version bump|V21,I.data -T205|x|seed_catalog + package template `config/bootstrap/` SegmentedKey; keys `[DimensionID]`; INVENTORY+BIZACCT segment 1 Length 30; ACCOUNT+INSITE unchanged; V22 bootstrap-before-master; V34 one catalog row|V22,V34,V50,I.data,T204 -T206|x|extract --force round-trips INVENTORY/BIZACCT Length 30; offline tests apply body + extract include + catalog row|V13,V34,V50,I.cmd,T205 -T207|x|live/e2e: SegmentedKey GET key-URL returns Length 30 after apply; PUT StockItem 26-char InventoryID succeeds; contract mapping fix + version bump if shape change|V4,V13,V21,V50,B28,T205 -T208|x|docs/demo-seed — SegmentedKey entity, CS202000, DAC max 30, non-goals (no shrink; no INSITE/ACCOUNT widen); CHANGELOG Unreleased (gh #30)|V12,V19,V50,T204,T205 -T209|x|drop ControlAccountModule IN on package `config/baseline/20-accounts.yaml` 12300/12400; keep 12100/12200|V51,I.data,B29 -T210|x|offline tests: 12300/12400 no ControlAccountModule IN; 12100/12200 still IN; INPreferences still 12300/12400|V13,V51,T209 -T211|x|e2e: package INPreferences apply succeeds after accounts; drop 15000 workaround in `test_segmented_key_length.py`|V4,V13,V51,B29,T209 -T212|x|docs/demo-seed — IN control on posting-class inventory assets only; WIP/transit not control; CHANGELOG Unreleased (gh #32)|V12,V19,V51,T209 T213|x|drop `matrix.py` + `--cell`; Instance resolve `api_version` from `ACU_API_VERSION` env (flag > env > code default)|V27,I.cfg,I.env T214|x|config init/show/check: no matrix surface; `.env` scaffolds `ACU_BASE_URL` + `ACU_API_VERSION`; drop `--strict`|V27,V28,I.cmd T215|x|`acu check` single-instance from `.env`; drop `--all` cell walk|V47,I.cmd From d276ffc5aa4081166f2dd455daf29207ddc50b9a Mon Sep 17 00:00:00 2001 From: Konstantin Borovik <github@lab5.ca> Date: Thu, 10 Sep 2026 15:28:21 -0400 Subject: [PATCH 25/25] test(e2e): isolate harness from ambient ACU_* and support role skip Ensure e2e tests resolve the repository's venv binary and `.env` file instead of ambient process environment variables. Allow clean extract skips for empty role membership GETs in roundtrip extraction tests. --- tests/e2e/conftest.py | 61 +++++++++++++++++++++++------ tests/e2e/test_extract_roundtrip.py | 34 +++++++++++----- tests/test_e2e_conftest.py | 39 ++++++++++++++++++ 3 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 tests/test_e2e_conftest.py diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 054978f..359ea91 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -22,11 +22,12 @@ from __future__ import annotations import contextlib +import os import shutil import subprocess import sys import threading -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping from pathlib import Path from typing import IO, NamedTuple @@ -60,6 +61,39 @@ def joined_output(proc: subprocess.CompletedProcess[str]) -> str: return proc.stdout + proc.stderr +def env_without_acu(src: Mapping[str, str] | None = None) -> dict[str, str]: + """Process environment with ambient ACU_* stripped so a found .env wins.""" + return {k: v for k, v in (src or os.environ).items() if not k.startswith("ACU_")} + + +def acu_binary() -> Path: + """This checkout's ``.venv/bin/acu``, never the global PyPI tool. + + ``sys.executable.resolve()`` follows the venv symlink into the + Homebrew framework ``bin/``, which has no ``acu``. ``sys.prefix`` is + the venv root under ``uv run`` / ``gmake e2e``. + """ + path = Path(sys.prefix) / "bin" / "acu" + if not path.is_file(): + raise FileNotFoundError(f"checkout acu not found at {path}") + return path + + +def load_instance_from_dotenv(root: Path) -> Instance: + """Resolve Instance from ``root/.env`` only — ambient ACU_* do not override. + + Live e2e must follow the repo-root (or scaffold-copied) .env as the + deployment target. pydantic-settings prefers process env over the + file; stripping ACU_* for the load makes the file the sole source. + """ + saved = {k: os.environ.pop(k) for k in list(os.environ) if k.startswith("ACU_")} + try: + with contextlib.chdir(root): + return load_instance() + finally: + os.environ.update(saved) + + def bracket_tenant( login: str, tenant_manager: TenantManager, delete_tenant: DeleteTenant ) -> Iterator[ScratchTenant]: @@ -120,20 +154,26 @@ def data_repo(tmp_path_factory: pytest.TempPathFactory) -> Path: @pytest.fixture(scope="session") def acu(data_repo: Path) -> RunAcu: - """Run the real acu binary from the scaffolded data repo. - - Output is streamed through to the terminal as it arrives (acu's own - step lines are the progress indicator for the minutes-long create + - apply) while still being buffered for the assertions. `make e2e` - passes -s so pytest does not swallow the stream. + """Run this checkout's acu binary from the scaffolded data repo. + + The sibling of ``sys.executable`` is ``.venv/bin/acu`` under + ``uv run`` / ``gmake e2e`` — never the global PyPI ``acu``. Ambient + ACU_* are stripped so the scaffold-copied repo-root .env is the + deployment target (V3: process env would otherwise win). Output is + streamed through to the terminal as it arrives (acu's own step lines + are the progress indicator for the minutes-long create + apply) + while still being buffered for the assertions. `make e2e` passes -s + so pytest does not swallow the stream. """ + acu_bin = str(acu_binary()) def run(*args: str) -> subprocess.CompletedProcess[str]: sys.stderr.write(f"$ acu {' '.join(args)}\n") sys.stderr.flush() with subprocess.Popen( - ["acu", *args], + [acu_bin, *args], cwd=data_repo, + env=env_without_acu(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -165,10 +205,9 @@ def run(*args: str) -> subprocess.CompletedProcess[str]: @pytest.fixture(scope="session") def live_instance() -> Instance: - """The real target, resolved exactly as every live command resolves it.""" + """The real target from repo-root .env (ambient ACU_* ignored).""" try: - with contextlib.chdir(REPO_ROOT): - return load_instance() + return load_instance_from_dotenv(REPO_ROOT) except SystemExit as exc: pytest.exit( f"live config missing ({exc}) - decrypt .env.gpg at the repo root", diff --git a/tests/e2e/test_extract_roundtrip.py b/tests/e2e/test_extract_roundtrip.py index 358c2c9..7b48f11 100644 --- a/tests/e2e/test_extract_roundtrip.py +++ b/tests/e2e/test_extract_roundtrip.py @@ -16,9 +16,11 @@ Master is non-optional: after full bare apply, no ``config/master/`` row may skip as ``(no records)`` — that would mean A's configuration is -incomplete before the inverse is even exercised. Multi-file filter-split -(Warehouse / StockItem) quality is T117; packaging UOMs are not claimed -(T118 / B26). +incomplete before the inverse is even exercised. ``92-role-users.yaml`` +is the V53 exception: mapped Role.Users GET stays empty after AssignUser, +so extract skips ``(Users GET empty)`` rather than emit identity-only YAML +(B33/B37). Multi-file filter-split (Warehouse / StockItem) quality is +T117; packaging UOMs are not claimed (T118 / B26). The GL batch leg proves the replayed setup chain is complete end to end (B16 class): a hand-built batch PUT releases to Posted on B. @@ -56,6 +58,9 @@ LOGIN_A = "E2EA" LOGIN_B = "E2EB" +# V53: mapped Role.Users GET is empty; extract skips rather than emit. +ROLE_USERS_FILE = "config/master/92-role-users.yaml" +USERS_GET_EMPTY = "Users GET empty" @pytest.fixture(scope="session") @@ -116,10 +121,12 @@ def test_extract_dumps_tenant_a( Every in-contract catalog row must produce a file under ``config/`` (V30 hard-cut). A ``(no records)`` skip on master means A's apply was - incomplete — fail loud before the byte-compare. Every entity/action - file must parse through ``load_baseline`` (emitted files are seed - files by construction). V34: catalog file set equals template seed - set (no multicurrency Currency row under LAB5). + incomplete — fail loud before the byte-compare. V53: Role.Users GET + empty is a clean skip for ``92-role-users.yaml`` (apply-only + membership), not an incomplete apply. Every entity/action file must + parse through ``load_baseline`` (emitted files are seed files by + construction). V34: catalog file set equals template seed set (no + multicurrency Currency row under LAB5). """ dir_a, _ = out_dirs expected = _catalog_expected() @@ -142,9 +149,18 @@ def test_extract_dumps_tenant_a( + "\n" + joined_output(proc) ) - # allow clean (no records)/(exists) skips only for non-master rows + # V53: mapped GET Role.Users stays empty; skip not emit identity-only. + membership_skip = any( + ROLE_USERS_FILE in ln and USERS_GET_EMPTY in ln for ln in skips + ) + if membership_skip: + expected.discard(ROLE_USERS_FILE) + master_expected.discard(ROLE_USERS_FILE) + # allow clean (no records)/(exists) skips; V53 Users GET empty on membership for ln in skips: - assert "(no records)" in ln or "(exists)" in ln, joined_output(proc) + assert "(no records)" in ln or "(exists)" in ln or USERS_GET_EMPTY in ln, ( + joined_output(proc) + ) for rel in list(expected): if rel in ln: expected.discard(rel) diff --git a/tests/test_e2e_conftest.py b/tests/test_e2e_conftest.py new file mode 100644 index 0000000..0a2cb4c --- /dev/null +++ b/tests/test_e2e_conftest.py @@ -0,0 +1,39 @@ +"""E2E harness: live target is repo-root .env, not ambient ACU_*.""" + +from pathlib import Path + +import pytest + +from tests.e2e.conftest import acu_binary, env_without_acu, load_instance_from_dotenv + + +def test_acu_binary_is_venv_not_framework() -> None: + path = acu_binary() + assert path.is_file() + assert path.name == "acu" + assert "Cellar" not in path.parts + assert "Frameworks" not in path.parts + + +def test_env_without_acu_drops_acu_keys() -> None: + src = {"PATH": "/bin", "ACU_BASE_URL": "http://ambient.example/X", "HOME": "/tmp"} + assert env_without_acu(src) == {"PATH": "/bin", "HOME": "/tmp"} + + +def test_load_instance_from_dotenv_ignores_ambient( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Gmake e2e follows the copied .env even if the shell has ACU_* set.""" + monkeypatch.setenv("ACU_BASE_URL", "http://ambient.example/AcumaticaERP") + monkeypatch.setenv("ACU_PASSWORD", "ambient") + monkeypatch.setenv("ACU_TENANT", "AMBIENT") + (tmp_path / ".env").write_text( + "ACU_BASE_URL=http://from-file.example/AcumaticaERP\n" + "ACU_PASSWORD=filepw\n" + "ACU_TENANT=FILETENANT\n", + encoding="utf-8", + ) + inst = load_instance_from_dotenv(tmp_path) + assert inst.base_url == "http://from-file.example/AcumaticaERP" + assert inst.password == "filepw" + assert inst.tenant == "FILETENANT"