acumatica-cli 0.12.1__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/PKG-INFO +10 -1
  2. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/README.md +9 -0
  3. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/cli.py +77 -1
  5. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/client.py +24 -0
  6. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/config.py +24 -9
  7. acumatica_cli-0.13.0/src/acumatica_cli/snapshot.py +573 -0
  8. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/README.md +29 -1
  9. acumatica_cli-0.13.0/src/acumatica_cli/templates/distribution/snapshot/10-trial-balance.yaml +20 -0
  10. acumatica_cli-0.13.0/src/acumatica_cli/templates/finance/snapshot/10-trial-balance.yaml +20 -0
  11. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/__init__.py +0 -0
  12. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap.py +0 -0
  13. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  14. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  15. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/extract.py +0 -0
  16. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
  17. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/firstlogin.py +0 -0
  18. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/models.py +0 -0
  19. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/output.py +0 -0
  20. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/run.py +0 -0
  21. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/seed.py +0 -0
  22. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/target.py +0 -0
  23. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/20-accounts.yaml +0 -0
  24. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/60-ledger-company.yaml +0 -0
  25. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/90-uoms.yaml +0 -0
  26. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/bootstrap/company.yaml +0 -0
  27. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/bootstrap/features.yaml +0 -0
  28. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/10-reason-codes.yaml +0 -0
  29. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/20-in-preferences.yaml +0 -0
  30. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/30-availability-rules.yaml +0 -0
  31. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/40-posting-classes.yaml +0 -0
  32. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/50-warehouse.yaml +0 -0
  33. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/51-warehouse-locations.yaml +0 -0
  34. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/52-warehouse-defaults.yaml +0 -0
  35. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/53-tax-categories.yaml +0 -0
  36. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/54-item-classes.yaml +0 -0
  37. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/56-so-preferences.yaml +0 -0
  38. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/57-po-preferences.yaml +0 -0
  39. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/58-order-types.yaml +0 -0
  40. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/60-ar-preferences.yaml +0 -0
  41. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/61-ap-preferences.yaml +0 -0
  42. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/62-ca-preferences.yaml +0 -0
  43. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/63-cash-account.yaml +0 -0
  44. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/64-payment-methods.yaml +0 -0
  45. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/65-statement-cycles.yaml +0 -0
  46. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/70-vendor-classes.yaml +0 -0
  47. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/71-customer-classes.yaml +0 -0
  48. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/75-vendors.yaml +0 -0
  49. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/76-customers.yaml +0 -0
  50. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/80-stock-items-parts.yaml +0 -0
  51. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/82-stock-items-kits.yaml +0 -0
  52. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/85-kit-specifications.yaml +0 -0
  53. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/10-seed-capital.yaml +0 -0
  54. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/20-buy-gateways.yaml +0 -0
  55. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/30-build.yaml +0 -0
  56. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/40-sell.yaml +0 -0
  57. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/setup/30-open-periods.yaml +0 -0
  58. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/10-subaccounts.yaml +0 -0
  59. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/20-accounts.yaml +0 -0
  60. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/40-ledger.yaml +0 -0
  61. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/50-gl-preferences.yaml +0 -0
  62. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/60-ledger-company.yaml +0 -0
  63. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/90-uoms.yaml +0 -0
  64. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/company.yaml +0 -0
  65. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/credit-terms.yaml +0 -0
  66. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/features.yaml +0 -0
  67. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/env +0 -0
  68. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/gitignore +0 -0
  69. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/10-financial-year.yaml +0 -0
  70. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/20-master-calendar.yaml +0 -0
  71. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/30-open-periods.yaml +0 -0
  72. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/target +0 -0
  73. {acumatica_cli-0.12.1 → acumatica_cli-0.13.0}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acumatica-cli
3
- Version: 0.12.1
3
+ Version: 0.13.0
4
4
  Summary: Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data
5
5
  Author: Konstantin Borovik
6
6
  Author-email: Konstantin Borovik <kb@lab5.ca>
@@ -89,6 +89,8 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
89
89
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
90
90
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
91
91
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
92
+ ├── snapshot [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
93
+ │ capture derived state into state/ (not seed)
92
94
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
93
95
  │ dump live tenant state as seed YAML (inverse of apply)
94
96
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
@@ -103,6 +105,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
103
105
  `apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
104
106
  A path like `config/` expands nested seed dirs in that fixed order.
105
107
  `run` without FILES defaults to `scenario/`.
108
+ `snapshot` without FILES defaults to `config/snapshot/`; writes go to `state/` (`--out`).
106
109
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
107
110
  Run `acu <command> --help` for details on any command.
108
111
 
@@ -119,6 +122,8 @@ Your configuration lives in its own git repo.
119
122
  | `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
120
123
  | `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
121
124
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
125
+ | `config/snapshot/` | observer views for `acu snapshot` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
126
+ | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
122
127
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
123
128
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
124
129
 
@@ -131,6 +136,10 @@ Scenario YAML is different — it describes transactions that flow forward.
131
136
  `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
132
137
  `once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
133
138
 
139
+ `acu snapshot` is the third observation path: it captures live derived state (balances) into `state/` for git review. It is not `extract` (config seed) and not `diff` (desired vs actual config). Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`). After a cold `acu run scenario/ && acu snapshot`, warm `acu run scenario/10-seed-capital.yaml && acu snapshot --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
140
+
141
+ **Migration (path hard-cut):** bare defaults are `config/snapshot/` (views) and `state/` (observations). Root `snapshot/` and `snapshots/` are no longer defaulted — move files or pass explicit path args.
142
+
134
143
  ### Seed `endpoint:` symbols
135
144
 
136
145
  Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
@@ -71,6 +71,8 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
71
71
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
72
72
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
73
73
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
74
+ ├── snapshot [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
75
+ │ capture derived state into state/ (not seed)
74
76
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
75
77
  │ dump live tenant state as seed YAML (inverse of apply)
76
78
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
@@ -85,6 +87,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
85
87
  `apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
86
88
  A path like `config/` expands nested seed dirs in that fixed order.
87
89
  `run` without FILES defaults to `scenario/`.
90
+ `snapshot` without FILES defaults to `config/snapshot/`; writes go to `state/` (`--out`).
88
91
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
89
92
  Run `acu <command> --help` for details on any command.
90
93
 
@@ -101,6 +104,8 @@ Your configuration lives in its own git repo.
101
104
  | `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
102
105
  | `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
103
106
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
107
+ | `config/snapshot/` | observer views for `acu snapshot` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
108
+ | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
104
109
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
105
110
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
106
111
 
@@ -113,6 +118,10 @@ Scenario YAML is different — it describes transactions that flow forward.
113
118
  `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
114
119
  `once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
115
120
 
121
+ `acu snapshot` is the third observation path: it captures live derived state (balances) into `state/` for git review. It is not `extract` (config seed) and not `diff` (desired vs actual config). Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`). After a cold `acu run scenario/ && acu snapshot`, warm `acu run scenario/10-seed-capital.yaml && acu snapshot --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
122
+
123
+ **Migration (path hard-cut):** bare defaults are `config/snapshot/` (views) and `state/` (observations). Root `snapshot/` and `snapshots/` are no longer defaulted — move files or pass explicit path args.
124
+
116
125
  ### Seed `endpoint:` symbols
117
126
 
118
127
  Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.12.1"
3
+ version = "0.13.0"
4
4
  description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
5
5
  authors = [{ name = "Konstantin Borovik", email = "kb@lab5.ca" }]
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -12,7 +12,7 @@ import click
12
12
  import httpx
13
13
  from click.shell_completion import get_completion_class
14
14
 
15
- from . import bootstrap, extract, firstlogin, output, run, seed
15
+ from . import bootstrap, extract, firstlogin, output, run, seed, snapshot
16
16
  from .client import AcumaticaClient
17
17
  from .config import (
18
18
  INIT_FLAVORS,
@@ -946,3 +946,79 @@ def _exit_on_drift(inst: Instance, drifts: list[str], files: int) -> None:
946
946
  output.data(f" {line}")
947
947
  raise SystemExit(2)
948
948
  output.success(f"no drift on {inst.tenant} ({inst.base_url}, {files} file(s))")
949
+
950
+
951
+ @cli.command("snapshot")
952
+ @click.argument(
953
+ "files", nargs=-1, required=False, type=click.Path(exists=True, path_type=Path)
954
+ )
955
+ @click.option(
956
+ "--out",
957
+ "out_dir",
958
+ type=click.Path(file_okay=False, path_type=Path),
959
+ default=None,
960
+ help="Observation output directory (default: state/)",
961
+ )
962
+ @click.option(
963
+ "--diff",
964
+ "do_diff",
965
+ is_flag=True,
966
+ help="Compare live vs disk; write nothing (exit 0 either way)",
967
+ )
968
+ @click.option(
969
+ "--assert-unchanged",
970
+ is_flag=True,
971
+ help="Like --diff, but exit 2 when state moved (idempotence gate)",
972
+ )
973
+ @click.option(
974
+ "--dry-run",
975
+ is_flag=True,
976
+ help="Resolve views and validate sources without HTTP",
977
+ )
978
+ @pass_instance
979
+ def snapshot_cmd(
980
+ inst: Instance,
981
+ files: tuple[Path, ...],
982
+ out_dir: Path | None,
983
+ do_diff: bool,
984
+ assert_unchanged: bool,
985
+ dry_run: bool,
986
+ ) -> None:
987
+ """Capture live derived state into committed observation files.
988
+
989
+ FILES are snapshot view YAML files or directories. Omitted, they default
990
+ to the data repo's config/snapshot/ directory (hard-cut; no root
991
+ snapshot/ fallback). Default write target is state/ (--out; no
992
+ snapshots/ fallback). Bare capture writes observations (change is
993
+ fine). --diff compares live to disk without writing.
994
+ --assert-unchanged is the warm-run idempotence gate (exit 2 when
995
+ moved). Never writes seed trees or endpoint: symbols (V32). Exit 0
996
+ ok, 1 op fail, 2 only under --assert-unchanged when state moved.
997
+ """
998
+ assert_target_compatible(inst)
999
+ if not files:
1000
+ default = data_root() / "config" / "snapshot"
1001
+ if not default.is_dir():
1002
+ raise SystemExit(f"{default}: snapshot directory does not exist")
1003
+ files = (Path(os.path.relpath(default)),)
1004
+ paths = snapshot.expand_view_files(files)
1005
+ views = [snapshot.load_view(path) for path in paths]
1006
+ dest = out_dir if out_dir is not None else Path("state")
1007
+ if dry_run:
1008
+ code = snapshot.run_views(None, views, out_dir=dest, mode="dry")
1009
+ else:
1010
+ mode = "assert" if assert_unchanged else "diff" if do_diff else "write"
1011
+ with AcumaticaClient(inst) as client:
1012
+ code = snapshot.run_views(client, views, out_dir=dest, mode=mode)
1013
+ if code:
1014
+ raise SystemExit(code)
1015
+ if dry_run:
1016
+ return
1017
+ if assert_unchanged:
1018
+ output.success(f"{len(views)} snapshot(s) unchanged on {inst.tenant}")
1019
+ elif do_diff:
1020
+ output.success(f"{len(views)} snapshot(s) compared on {inst.tenant}")
1021
+ else:
1022
+ output.success(
1023
+ f"{len(views)} snapshot(s) written under {dest} on {inst.tenant}"
1024
+ )
@@ -538,3 +538,27 @@ class AcumaticaClient:
538
538
  return self._checked(
539
539
  self._http.post("/CustomizationApi/publishEnd", json={})
540
540
  ).json()
541
+
542
+ # -- OData Generic Inquiry (snapshot gi: source; V33) --
543
+
544
+ def _odata_gi_root(self) -> str:
545
+ """24R2+ OData GI service root ``/t/<tenant>/api/odata/gi`` (V33)."""
546
+ tenant = quote(self.instance.tenant, safe="")
547
+ return f"/t/{tenant}/api/odata/gi"
548
+
549
+ def odata_gi_metadata(self, name: str) -> str:
550
+ """GET OData GI service ``$metadata`` XML (V33 param validation).
551
+
552
+ ``name`` is reserved for future per-GI metadata URLs; the 24R2
553
+ service exposes one EDMX document at the service root.
554
+ """
555
+ del name # service-level metadata; per-GI filter is validate_gi_params
556
+ r = self._checked(self._http.get(f"{self._odata_gi_root()}/$metadata"))
557
+ return r.text
558
+
559
+ def odata_gi(self, name: str, params: dict[str, str] | None = None) -> Any:
560
+ """GET OData GI rows as JSON (requires Expose via OData on the GI)."""
561
+ gi = quote(name, safe="")
562
+ return self._checked(
563
+ self._http.get(f"{self._odata_gi_root()}/{gi}", params=params)
564
+ ).json()
@@ -58,6 +58,11 @@ INIT_TEMPLATES = (
58
58
  ("finance/setup/10-financial-year.yaml", "setup/10-financial-year.yaml"),
59
59
  ("finance/setup/20-master-calendar.yaml", "setup/20-master-calendar.yaml"),
60
60
  ("finance/setup/30-open-periods.yaml", "setup/30-open-periods.yaml"),
61
+ # V28/V32: observer views under config/snapshot/ (not SEED_DIRS; lone config/ ok)
62
+ (
63
+ "finance/snapshot/10-trial-balance.yaml",
64
+ "config/snapshot/10-trial-balance.yaml",
65
+ ),
61
66
  )
62
67
 
63
68
  # Opt-in `--flavor distribution` overlays + extras (V28/V29/T87). Resource
@@ -168,6 +173,10 @@ DISTRIBUTION_TEMPLATES = (
168
173
  ),
169
174
  ("distribution/scenario/30-build.yaml", "scenario/30-build.yaml"),
170
175
  ("distribution/scenario/40-sell.yaml", "scenario/40-sell.yaml"),
176
+ (
177
+ "distribution/snapshot/10-trial-balance.yaml",
178
+ "config/snapshot/10-trial-balance.yaml",
179
+ ),
171
180
  ("distribution/README.md", "README.md"),
172
181
  )
173
182
 
@@ -237,10 +246,13 @@ class Instance(BaseSettings):
237
246
  def templates_for(flavor: str | None) -> tuple[tuple[str, str], ...]:
238
247
  """Resolve (resource, dest) pairs for ``config init`` (V28/T87).
239
248
 
240
- Absent flavor → finance-minimal ``INIT_TEMPLATES`` at root. ``distribution``
241
- keeps root meta (``.env``/``.gitignore``/``target.yaml``), rehomes finance
242
- seed under ``config/``, then overlays ``DISTRIBUTION_TEMPLATES`` (config/
243
- seeds + lifecycle ``scenario/`` + README). Never dual root+config trees.
249
+ Absent flavor → finance-minimal ``INIT_TEMPLATES`` at root (+ observer
250
+ ``config/snapshot/``). ``distribution`` keeps root meta
251
+ (``.env``/``.gitignore``/``target.yaml``), rehomes finance seed under
252
+ ``config/``, then overlays ``DISTRIBUTION_TEMPLATES`` (config/ seeds +
253
+ lifecycle ``scenario/`` + ``config/snapshot/`` + README). Never dual
254
+ root+config seed trees. Paths already under ``config/`` (snapshot
255
+ views) pass through unchanged.
244
256
  """
245
257
  if flavor is None:
246
258
  return INIT_TEMPLATES
@@ -256,11 +268,14 @@ def templates_for(flavor: str | None) -> tuple[tuple[str, str], ...]:
256
268
  by_dest[dest] = res
257
269
  order.append(dest)
258
270
  continue
259
- target = (
260
- f"config/{dest}"
261
- if any(dest.startswith(p) for p in _SEED_PREFIXES)
262
- else dest
263
- )
271
+ if dest.startswith("config/"):
272
+ target = dest
273
+ else:
274
+ target = (
275
+ f"config/{dest}"
276
+ if any(dest.startswith(p) for p in _SEED_PREFIXES)
277
+ else dest
278
+ )
264
279
  if target not in by_dest:
265
280
  order.append(target)
266
281
  by_dest[target] = res
@@ -0,0 +1,573 @@
1
+ """Derived-state observations: config/snapshot/*.yaml views -> state/*.yaml.
2
+
3
+ `acu snapshot` captures live balances, quantities, and totals as committed
4
+ evidence (SPEC I.cmd / V32). Not a seed inverse: never writes seed trees,
5
+ never carries endpoint: symbols, never participates in apply/diff. Views
6
+ configure the observer under config/snapshot/ (not SEED_DIRS); observations
7
+ are git-diffable flow-style YAML under state/. Bare defaults hard-cut those
8
+ paths (no root snapshot/ or snapshots/ fallback).
9
+
10
+ View file format (I.data config/snapshot/*):
11
+
12
+ name: trial-balance
13
+ source:
14
+ inquire: AccountSummaryInquiry # or: gi: LAB5-… | entity: Account
15
+ params: { Ledger: ACTUAL, Period: "072026" } # pinned; not runtime-resolved
16
+ match: { Account: "30000" } # optional Results row filter (inquire only)
17
+ key: [Account]
18
+ capture: [Description, BegBalance, DebitTotal, CreditTotal, EndingBalance]
19
+ decimals: 2
20
+
21
+ Observation file format (I.data state/*):
22
+
23
+ view: trial-balance
24
+ erp: "26.101.0225"
25
+ rows:
26
+ - {Account: "10100", BegBalance: "0.00", ...}
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import xml.etree.ElementTree as ET
32
+ from decimal import Decimal, InvalidOperation
33
+ from pathlib import Path
34
+ from typing import Any, Literal
35
+
36
+ import yaml
37
+ from pydantic import Field, ValidationError, field_validator, model_validator
38
+
39
+ from . import output
40
+ from .client import AcumaticaClient, unwrap
41
+ from .models import Model, validation_summary
42
+ from .seed import _norm # pyright: ignore[reportPrivateUsage]
43
+
44
+ # Flow-style observation rows: one mapping per line under `rows:`.
45
+ _ROW_PREFIX = " - "
46
+
47
+
48
+ class SourceSpec(Model):
49
+ """Exactly one of gi: | entity: | inquire:; optional params pinned (V33).
50
+
51
+ ``match`` is an optional Results-row filter for ``inquire:`` only
52
+ (same idiom as ``run`` expect/present). Params for inquire become the
53
+ PUT body; ``$expand=Results`` is always added by the backend.
54
+ """
55
+
56
+ gi: str | None = None
57
+ entity: str | None = None
58
+ inquire: str | None = None
59
+ params: dict[str, Any] = Field(default_factory=dict)
60
+ match: dict[str, Any] | None = None
61
+
62
+ @model_validator(mode="after")
63
+ def _one_source(self) -> SourceSpec:
64
+ kinds = sum(1 for v in (self.gi, self.entity, self.inquire) if v is not None)
65
+ if kinds != 1:
66
+ raise ValueError("source: exactly one of gi, entity, inquire")
67
+ if self.match is not None and self.inquire is None:
68
+ raise ValueError("source.match requires inquire")
69
+ return self
70
+
71
+ @property
72
+ def kind(self) -> Literal["gi", "entity", "inquire"]:
73
+ """Discriminator: ``gi``, ``entity``, or ``inquire`` (exactly one set)."""
74
+ if self.gi is not None:
75
+ return "gi"
76
+ if self.entity is not None:
77
+ return "entity"
78
+ return "inquire"
79
+
80
+ @property
81
+ def name(self) -> str:
82
+ """The GI title, contract entity, or inquiry entity name for this source."""
83
+ if self.gi is not None:
84
+ return self.gi
85
+ if self.entity is not None:
86
+ return self.entity
87
+ assert self.inquire is not None
88
+ return self.inquire
89
+
90
+
91
+ class ViewDef(Model):
92
+ """Observer config for one capture (I.data config/snapshot/*; V32/V33)."""
93
+
94
+ name: str
95
+ source: SourceSpec
96
+ key: list[str]
97
+ capture: list[str]
98
+ decimals: int = 2
99
+ path: Path = Field(exclude=True, repr=False)
100
+
101
+ @field_validator("name")
102
+ @classmethod
103
+ def _name_stem(cls, v: str) -> str:
104
+ v = v.strip()
105
+ if not v or "/" in v or "\\" in v or v.endswith(".yaml"):
106
+ raise ValueError(
107
+ f"name must be a bare output stem (got {v!r}); writes state/<name>.yaml"
108
+ )
109
+ return v
110
+
111
+ @field_validator("key", "capture")
112
+ @classmethod
113
+ def _nonempty_fields(cls, v: list[str]) -> list[str]:
114
+ if not v:
115
+ raise ValueError("must be a non-empty list of field names")
116
+ return list(v)
117
+
118
+ @field_validator("decimals")
119
+ @classmethod
120
+ def _decimals_nonneg(cls, v: int) -> int:
121
+ if v < 0:
122
+ raise ValueError("decimals must be >= 0")
123
+ return v
124
+
125
+
126
+ class Observation(Model):
127
+ """Committed derived-state capture (I.data state/*; V32)."""
128
+
129
+ view: str
130
+ erp: str
131
+ rows: list[dict[str, Any]] = Field(default_factory=list)
132
+
133
+
134
+ def load_view(path: Path) -> ViewDef:
135
+ """Parse one snapshot view definition; hard error on invalid shape."""
136
+ try:
137
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
138
+ except yaml.YAMLError as exc:
139
+ raise SystemExit(f"{path}: invalid YAML: {exc}") from exc
140
+ if not isinstance(raw, dict):
141
+ raise SystemExit(f"{path}: expected a mapping at the root")
142
+ try:
143
+ return ViewDef.model_validate({**raw, "path": path})
144
+ except ValidationError as exc:
145
+ raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
146
+
147
+
148
+ def load_observation(path: Path) -> Observation:
149
+ """Parse a committed observation file."""
150
+ try:
151
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
152
+ except yaml.YAMLError as exc:
153
+ raise SystemExit(f"{path}: invalid YAML: {exc}") from exc
154
+ if not isinstance(raw, dict):
155
+ raise SystemExit(f"{path}: expected a mapping at the root")
156
+ try:
157
+ return Observation.model_validate(raw)
158
+ except ValidationError as exc:
159
+ raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
160
+
161
+
162
+ def _is_numeric(value: Any) -> bool:
163
+ """True when value should be fixed-point formatted (money/qty)."""
164
+ if isinstance(value, bool) or value is None:
165
+ return False
166
+ if isinstance(value, (int, float, Decimal)):
167
+ return True
168
+ if isinstance(value, str):
169
+ text = value.strip()
170
+ if not text:
171
+ return False
172
+ try:
173
+ Decimal(text)
174
+ except InvalidOperation:
175
+ return False
176
+ return True
177
+ return False
178
+
179
+
180
+ def format_key(value: Any) -> str | int | bool | None:
181
+ """Identity cell for key columns - no money fixed-point (V32).
182
+
183
+ Account codes like "10000" and ints stay as-is so sort identity
184
+ matches the live tenant; only capture columns get decimals formatting.
185
+ """
186
+ if value is None:
187
+ return None
188
+ if isinstance(value, bool):
189
+ return value
190
+ if isinstance(value, int):
191
+ return value
192
+ if isinstance(value, float):
193
+ # key from float is rare; keep lossless string without forcing scale
194
+ return str(value).rstrip("0").rstrip(".") if "." in str(value) else str(value)
195
+ if isinstance(value, str):
196
+ return value
197
+ return str(value)
198
+
199
+
200
+ def format_cell(value: Any, decimals: int) -> str | int | bool | None:
201
+ """Normalize one capture cell for observation output (V32).
202
+
203
+ Numerics become fixed-point strings at ``decimals`` (money/qty). Non-
204
+ numerics keep a YAML-safe scalar type. Nested values stringify.
205
+ """
206
+ if value is None:
207
+ return None
208
+ if isinstance(value, bool):
209
+ return value
210
+ if _is_numeric(value):
211
+ quant = Decimal(1).scaleb(-decimals) if decimals else Decimal(1)
212
+ d = Decimal(str(value)).quantize(quant)
213
+ # fixed-point string always — never float in the file
214
+ return f"{d:.{decimals}f}" if decimals else str(int(d))
215
+ if isinstance(value, (int, str)):
216
+ return value
217
+ return str(value)
218
+
219
+
220
+ def _key_tuple(row: dict[str, Any], keys: list[str]) -> tuple[Any, ...]:
221
+ return tuple(row.get(k) for k in keys)
222
+
223
+
224
+ def project_rows(
225
+ live_rows: list[dict[str, Any]], view: ViewDef
226
+ ) -> list[dict[str, Any]]:
227
+ """Allowlist + format + sort by key; key-tuple collision → SystemExit 1 path.
228
+
229
+ ``capture:`` is an allowlist (V32): unexpected live fields are dropped.
230
+ Sort is always by the key tuple so REST/OData order is not contractual.
231
+ Key columns use ``format_key`` (identity); other capture columns use
232
+ fixed-point ``format_cell`` so money never lands as float.
233
+ """
234
+ projected: list[dict[str, Any]] = []
235
+ seen: dict[tuple[Any, ...], int] = {}
236
+ capture_only = [f for f in view.capture if f not in view.key]
237
+ for i, raw in enumerate(live_rows):
238
+ row: dict[str, Any] = {}
239
+ for k in view.key:
240
+ row[k] = format_key(raw.get(k))
241
+ for f in capture_only:
242
+ if f in raw:
243
+ row[f] = format_cell(raw.get(f), view.decimals)
244
+ kt = _key_tuple(row, view.key)
245
+ if kt in seen:
246
+ raise SystemExit(
247
+ f"{view.path}: key collision on {dict(zip(view.key, kt, strict=True))} "
248
+ f"(rows {seen[kt]} and {i}); key must uniquely identify rows (V32)"
249
+ )
250
+ seen[kt] = i
251
+ projected.append(row)
252
+ projected.sort(key=lambda r: _key_tuple(r, view.key))
253
+ # observation row: key fields first (view order), then capture alpha
254
+ ordered: list[dict[str, Any]] = []
255
+ capture_rest = sorted(capture_only)
256
+ col_order = [*view.key, *capture_rest]
257
+ for row in projected:
258
+ ordered.append({c: row[c] for c in col_order if c in row})
259
+ return ordered
260
+
261
+
262
+ def _yaml_flow_scalar(value: Any) -> str:
263
+ """Render one scalar inside a flow mapping (ASCII, git-friendly).
264
+
265
+ Strings are always double-quoted so codes like ``10100`` and fixed-
266
+ point money stay strings on ``yaml.safe_load`` round-trip (bare
267
+ ``10100`` would reparse as int and break key identity).
268
+ """
269
+ if value is None:
270
+ return "null"
271
+ if isinstance(value, bool):
272
+ return "true" if value else "false"
273
+ if isinstance(value, int) and not isinstance(value, bool):
274
+ return str(value)
275
+ text = str(value)
276
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
277
+ return f'"{escaped}"'
278
+
279
+
280
+ def render_observation(obs: Observation) -> str:
281
+ """Byte-stable observation YAML: one flow-style row per line (V32)."""
282
+ lines = [
283
+ f"view: {obs.view}",
284
+ f"erp: {_yaml_flow_scalar(obs.erp)}",
285
+ "rows:",
286
+ ]
287
+ if not obs.rows:
288
+ lines.append(" []")
289
+ else:
290
+ for row in obs.rows:
291
+ # key-sorted within the flow map for stable diffs when col set grows
292
+ items = ", ".join(f"{k}: {_yaml_flow_scalar(row[k])}" for k in row)
293
+ lines.append(f"{_ROW_PREFIX}{{{items}}}")
294
+ return "\n".join(lines) + "\n"
295
+
296
+
297
+ def write_observation(path: Path, obs: Observation) -> None:
298
+ """Write observation file; parent dirs created as needed."""
299
+ path.parent.mkdir(parents=True, exist_ok=True)
300
+ path.write_text(render_observation(obs), encoding="utf-8")
301
+
302
+
303
+ def compare_observations(live: Observation, disk: Observation | None) -> list[str]:
304
+ """Human-readable drift lines (view/erp/rows); empty = identical rows+erp.
305
+
306
+ Row identity is the full projected row dict order as rendered. Used by
307
+ --diff and --assert-unchanged; never writes.
308
+ """
309
+ if disk is None:
310
+ return [f"missing on disk (live has {len(live.rows)} row(s))"]
311
+ drifts: list[str] = []
312
+ if live.view != disk.view:
313
+ drifts.append(f"view: live={live.view!r} disk={disk.view!r}")
314
+ if live.erp != disk.erp:
315
+ drifts.append(f"erp: live={live.erp!r} disk={disk.erp!r}")
316
+ live_text = render_observation(live)
317
+ disk_text = render_observation(disk)
318
+ if live_text == disk_text:
319
+ return drifts
320
+ if len(live.rows) != len(disk.rows):
321
+ drifts.append(f"rows: live has {len(live.rows)}, disk has {len(disk.rows)}")
322
+ elif not drifts:
323
+ for i, (a, b) in enumerate(zip(live.rows, disk.rows, strict=True)):
324
+ if a != b:
325
+ drifts.append(f"row[{i}]: live={a!r} disk={b!r}")
326
+ return drifts
327
+
328
+
329
+ def expand_view_files(files: tuple[Path, ...]) -> list[Path]:
330
+ """Expand dir args to sorted ``*.yaml``; explicit files preserved order."""
331
+ paths: list[Path] = []
332
+ for path in files:
333
+ if path.is_dir():
334
+ found = sorted(path.glob("*.yaml"))
335
+ if not found:
336
+ raise SystemExit(f"{path}: no snapshot *.yaml files in directory")
337
+ paths += found
338
+ else:
339
+ paths.append(path)
340
+ return paths
341
+
342
+
343
+ def live_erp_build(client: AcumaticaClient) -> str:
344
+ """Live ERP build id for observation ``erp:`` header (V32).
345
+
346
+ Prefers ``GET /entity`` wrapper ``version.acumaticaBuildVersion`` (T92);
347
+ falls back to claimed ``target.yaml`` erp is not available here — use
348
+ the configured default string when the wrapper omits build.
349
+ """
350
+ try:
351
+ _endpoints, build = client.entity_root()
352
+ except RuntimeError, OSError:
353
+ return "unknown"
354
+ return build or "unknown"
355
+
356
+
357
+ def fetch_live_rows(client: AcumaticaClient, view: ViewDef) -> list[dict[str, Any]]:
358
+ """Pull raw unwrapped rows for a view via entity: / gi: / inquire: (V33)."""
359
+ if view.source.kind == "entity":
360
+ return _fetch_entity_rows(client, view)
361
+ if view.source.kind == "gi":
362
+ return _fetch_gi_rows(client, view)
363
+ return _fetch_inquire_rows(client, view)
364
+
365
+
366
+ def _odata_params(params: dict[str, Any]) -> dict[str, str]:
367
+ """Stringify view params for OData/query use; keys pass through as-is."""
368
+ return {str(k): "" if v is None else str(v) for k, v in params.items()}
369
+
370
+
371
+ def _fetch_entity_rows(client: AcumaticaClient, view: ViewDef) -> list[dict[str, Any]]:
372
+ """Contract REST list GET on Default/<api_version> (V33; no per-view pin)."""
373
+ assert view.source.entity is not None
374
+ params = _odata_params(view.source.params)
375
+ # narrow select when no explicit $select — allowlist only
376
+ if "$select" not in params and "select" not in params:
377
+ fields = list(dict.fromkeys([*view.key, *view.capture]))
378
+ params["$select"] = ",".join(fields)
379
+ rows = client.get_list(view.source.entity, params=params or None)
380
+ return [unwrap(r) for r in rows]
381
+
382
+
383
+ def validate_gi_params(metadata_xml: str, gi_name: str, params: dict[str, Any]) -> None:
384
+ """Fail-closed: every param key must appear in the GI's $metadata (V33).
385
+
386
+ Acumatica OData metadata is EDMX. Parameter names may surface as
387
+ ``Parameter`` / ``Property`` / ``ParameterImport`` elements with a
388
+ ``Name`` attribute. Unknown param keys raise SystemExit — silent
389
+ ignore of Period (and kin) would produce phantom full-set captures.
390
+ """
391
+ if not params:
392
+ return
393
+ try:
394
+ root = ET.fromstring(metadata_xml)
395
+ except ET.ParseError as exc:
396
+ raise SystemExit(
397
+ f"gi {gi_name!r}: $metadata not parseable as XML: {exc}"
398
+ ) from exc
399
+ names: set[str] = set()
400
+ for el in root.iter():
401
+ tag = el.tag.rsplit("}", 1)[-1]
402
+ if tag in {
403
+ "Parameter",
404
+ "Property",
405
+ "ParameterImport",
406
+ "FunctionImport",
407
+ "EntityType",
408
+ "ComplexType",
409
+ }:
410
+ name = el.attrib.get("Name") or el.attrib.get("name")
411
+ if name:
412
+ names.add(name)
413
+ # also collect Annotation/Documentation free text sparingly — skip
414
+ # Always allow common OData system options
415
+ system = {"$filter", "$select", "$top", "$orderby", "$expand", "$skip", "$format"}
416
+ unknown = [k for k in params if k not in names and k not in system]
417
+ if unknown and not names:
418
+ # metadata had no Name attributes we recognize — fail closed
419
+ raise SystemExit(
420
+ f"gi {gi_name!r}: $metadata has no discoverable parameter names; "
421
+ f"cannot validate params {sorted(params)}; refuse rather than "
422
+ "silently ignore (V33)"
423
+ )
424
+ if unknown:
425
+ raise SystemExit(
426
+ f"gi {gi_name!r}: unknown params {unknown} not in $metadata "
427
+ f"(known: {', '.join(sorted(names)) or '(none)'}) (V33)"
428
+ )
429
+
430
+
431
+ def _fetch_gi_rows(client: AcumaticaClient, view: ViewDef) -> list[dict[str, Any]]:
432
+ """OData Generic Inquiry rows (V33); params validated against $metadata."""
433
+ assert view.source.gi is not None
434
+ gi = view.source.gi
435
+ params = dict(view.source.params)
436
+ meta = client.odata_gi_metadata(gi)
437
+ validate_gi_params(meta, gi, params)
438
+ raw = client.odata_gi(gi, params=_odata_params(params) or None)
439
+ return _odata_value_rows(raw)
440
+
441
+
442
+ def _fetch_inquire_rows(client: AcumaticaClient, view: ViewDef) -> list[dict[str, Any]]:
443
+ """Contract inquiry PUT ``$expand=Results`` (V33; same idiom as run).
444
+
445
+ View ``params`` are the PUT body (pinned in YAML). Optional ``match``
446
+ keeps only Results rows whose unwrapped fields equal the filter
447
+ (``seed._norm`` compare — number spelling tolerant). Rows are then
448
+ projected by key+capture in ``project_rows``.
449
+ """
450
+ assert view.source.inquire is not None
451
+ body = client.put(
452
+ view.source.inquire,
453
+ dict(view.source.params),
454
+ params={"$expand": "Results"},
455
+ )
456
+ match = view.source.match
457
+ rows: list[dict[str, Any]] = []
458
+ for row in body.get("Results") or []:
459
+ values = unwrap(row) if isinstance(row, dict) else {}
460
+ if match and any(
461
+ field not in values or _norm(values[field]) != _norm(want)
462
+ for field, want in match.items()
463
+ ):
464
+ continue
465
+ rows.append(values)
466
+ return rows
467
+
468
+
469
+ def _odata_value_rows(body: Any) -> list[dict[str, Any]]:
470
+ """Normalize OData JSON to a list of plain dict rows."""
471
+ if isinstance(body, list):
472
+ return [dict(r) for r in body if isinstance(r, dict)]
473
+ if isinstance(body, dict):
474
+ value = body.get("value")
475
+ if isinstance(value, list):
476
+ return [dict(r) for r in value if isinstance(r, dict)]
477
+ # single entity object
478
+ if body and not any(k.startswith("@odata") for k in body if k != "value"):
479
+ # might still be a wrapper with only odata keys + value missing
480
+ data = {k: v for k, v in body.items() if not str(k).startswith("@")}
481
+ if data and "value" not in data:
482
+ return [data]
483
+ raise RuntimeError(f"OData response not a row list: {type(body).__name__}")
484
+
485
+
486
+ def capture_view(
487
+ client: AcumaticaClient, view: ViewDef, erp: str | None = None
488
+ ) -> Observation:
489
+ """Fetch live rows, project, return Observation (no disk I/O)."""
490
+ rows = project_rows(fetch_live_rows(client, view), view)
491
+ return Observation(
492
+ view=view.name,
493
+ erp=erp if erp is not None else live_erp_build(client),
494
+ rows=rows,
495
+ )
496
+
497
+
498
+ def observation_path(out_dir: Path, view: ViewDef) -> Path:
499
+ """Default observation path for a view under ``out_dir``."""
500
+ return out_dir / f"{view.name}.yaml"
501
+
502
+
503
+ def _dry_run_views(views: list[ViewDef], out_dir: Path) -> int:
504
+ """List resolved views without HTTP (V32 --dry-run)."""
505
+ for view in views:
506
+ src = f"{view.source.kind}:{view.source.name}"
507
+ params = f" params={view.source.params}" if view.source.params else ""
508
+ output.data(
509
+ f"would capture {view.path} -> "
510
+ f"{observation_path(out_dir, view)} ({src}{params})"
511
+ )
512
+ output.data(f"{len(views)} view(s) (dry run)")
513
+ return 0
514
+
515
+
516
+ def _process_view(
517
+ client: AcumaticaClient,
518
+ view: ViewDef,
519
+ *,
520
+ out_dir: Path,
521
+ erp: str,
522
+ diff: bool,
523
+ ) -> bool:
524
+ """Capture one view; write or compare. Returns True if state moved."""
525
+ dest = observation_path(out_dir, view)
526
+ output.data(f"{view.path} -> {view.name} ({view.source.kind}:{view.source.name})")
527
+ live = capture_view(client, view, erp=erp)
528
+ if not diff:
529
+ write_observation(dest, live)
530
+ output.data(f" wrote {dest} ({len(live.rows)} row(s))")
531
+ return False
532
+ disk = load_observation(dest) if dest.is_file() else None
533
+ drifts = compare_observations(live, disk)
534
+ if drifts:
535
+ output.data(f" changed ({len(drifts)} difference(s))")
536
+ for line in drifts:
537
+ output.data(f" {line}")
538
+ return True
539
+ output.data(f" {len(live.rows)} row(s) unchanged")
540
+ return False
541
+
542
+
543
+ def run_views(
544
+ client: AcumaticaClient | None,
545
+ views: list[ViewDef],
546
+ *,
547
+ out_dir: Path,
548
+ mode: Literal["write", "diff", "assert", "dry"] = "write",
549
+ ) -> int:
550
+ """Execute snapshot for views; return process exit code (V32 exit matrix).
551
+
552
+ 0 = ok (write or compare; change fine unless assert)
553
+ 1 = operational failure
554
+ 2 = state moved under assert mode
555
+ """
556
+ if mode == "dry":
557
+ return _dry_run_views(views, out_dir)
558
+ assert client is not None
559
+ diff = mode in ("diff", "assert")
560
+ erp = live_erp_build(client)
561
+ moved = False
562
+ try:
563
+ for view in views:
564
+ if _process_view(client, view, out_dir=out_dir, erp=erp, diff=diff):
565
+ moved = True
566
+ except SystemExit:
567
+ raise
568
+ except (RuntimeError, OSError) as exc:
569
+ output.error(str(exc))
570
+ return 1
571
+ if mode == "assert" and moved:
572
+ return 2
573
+ return 0
@@ -21,6 +21,11 @@ acu run scenario/
21
21
 
22
22
  # 5. Prove no drift
23
23
  acu diff config/
24
+
25
+ # 6. Capture derived-state observations (EndingBalance trial-balance)
26
+ acu snapshot
27
+ # warm gate: once-capital only — additive buy/sell moves numeric observations
28
+ acu run scenario/10-seed-capital.yaml && acu snapshot --assert-unchanged
24
29
  ```
25
30
 
26
31
  Bare `acu apply` / `acu diff` also prefer `config/` when those trees exist.
@@ -34,6 +39,8 @@ Bare `acu apply` / `acu diff` also prefer `config/` when those trees exist.
34
39
  | `config/setup/` | Financial year, master calendar, open periods |
35
40
  | `config/master/` | Inventory, warehouse, items, vendors, customers, module prefs |
36
41
  | `scenario/` | Lifecycle: `10-seed-capital` (once) + `20-buy-gateways` + `30-build` (stub) + `40-sell` |
42
+ | `config/snapshot/` | Observer views (`inquire:` TB golden; optional custom `gi:` / `inquire:`) for `acu snapshot` (not SEED_DIRS) |
43
+ | `state/` | Committed observation files (written by `acu snapshot`, not seed; money fixed-point) |
37
44
  | `target.yaml` | Verified ERP / Default API matrix |
38
45
 
39
46
  Org CD is the single placeholder **LAB5** across company, ledger-company, open periods, inventory transit branch, and cash account.
@@ -44,15 +51,36 @@ Org CD is the single placeholder **LAB5** across company, ledger-company, open p
44
51
 
45
52
  A warm second `acu run scenario/` skips capital when the probe already holds, so Owner Capital stays 50000 (not 100000).
46
53
 
47
- Additive buy/sell legs re-run with per-leg delta expects.
54
+ Additive buy/sell legs re-run with per-leg delta expects (including `InventorySummaryInquiry` qty deltas on the scenario path — not snapshot golden).
48
55
 
49
56
  Monoscenario `buy-sell` is not part of this flavor.
50
57
 
58
+ ## Snapshot vs extract / diff
59
+
60
+ | Command | Reads | Writes |
61
+ | ------- | ----- | ------ |
62
+ | `extract` | live **config** | seed YAML under bootstrap/baseline/setup |
63
+ | `diff` | seed YAML + live config | nothing (exit 2 on drift) |
64
+ | `snapshot` | live **derived state** | `state/*.yaml` observations (not seed) |
65
+
66
+ Default scaffold golden is **trial-balance only** (V28/V33):
67
+
68
+ - `10-trial-balance.yaml` → `inquire: AccountSummaryInquiry`, capture includes `EndingBalance`
69
+
70
+ Roster-only `entity: Account` is not used for that stem.
71
+ `inventory-summary` / `QtyOnHand` is not packaged this pass (`InventorySummaryInquiry`
72
+ warehouse-only returns empty Results). Add a custom view when a verified path is known.
73
+ `gi:` remains optional when a GI is V12-verified and **Expose via OData** is on
74
+ (seed under `config/master/`, pin `source.gi:` + params).
75
+
76
+ **Migration:** bare `acu snapshot` reads `config/snapshot/` and writes `state/` (not root `snapshot/` / `snapshots/`).
77
+
51
78
  ## Non-goals
52
79
 
53
80
  - Multi-org, multicurrency, full tax engine
54
81
  - Production cutover / opening balances from a legacy system
55
82
  - Replacing external data repos for production cutovers
83
+ - Historical series inside the tool — use `git log -p state/`
56
84
 
57
85
  ## Default flavor
58
86
 
@@ -0,0 +1,20 @@
1
+ # Snapshot view: GL trial balance (EndingBalance-class, V33).
2
+ # inquire: AccountSummaryInquiry — same contract inquiry as run expect/present
3
+ # (Ledger + Period pinned; Results projected by Account). gi: LAB5-TrialBalance
4
+ # remains optional when GenericInquiry is V12-verified and Expose via OData is on.
5
+ # After scenario/, `acu snapshot --assert-unchanged` is the idempotence gate (V32).
6
+ name: trial-balance
7
+ source:
8
+ inquire: AccountSummaryInquiry
9
+ params:
10
+ Ledger: ACTUAL
11
+ Period: "072026"
12
+ key:
13
+ - Account
14
+ capture:
15
+ - Description
16
+ - BegBalance
17
+ - DebitTotal
18
+ - CreditTotal
19
+ - EndingBalance
20
+ decimals: 2
@@ -0,0 +1,20 @@
1
+ # Snapshot view: GL trial balance (EndingBalance-class, V33).
2
+ # inquire: AccountSummaryInquiry — same contract inquiry as run expect/present
3
+ # (Ledger + Period pinned; Results projected by Account). gi: LAB5-TrialBalance
4
+ # remains optional when GenericInquiry is V12-verified and Expose via OData is on.
5
+ # After scenario/, `acu snapshot --assert-unchanged` is the idempotence gate (V32).
6
+ name: trial-balance
7
+ source:
8
+ inquire: AccountSummaryInquiry
9
+ params:
10
+ Ledger: ACTUAL
11
+ Period: "072026"
12
+ key:
13
+ - Account
14
+ capture:
15
+ - Description
16
+ - BegBalance
17
+ - DebitTotal
18
+ - CreditTotal
19
+ - EndingBalance
20
+ decimals: 2