acumatica-cli 0.15.4__tar.gz → 0.17.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 (70) hide show
  1. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/PKG-INFO +28 -1
  2. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/README.md +27 -0
  3. acumatica_cli-0.17.0/pyproject.toml +109 -0
  4. acumatica_cli-0.15.4/pyproject.toml → acumatica_cli-0.17.0/pyproject.toml.orig +1 -1
  5. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/cli.py +131 -1
  6. acumatica_cli-0.17.0/src/acumatica_cli/inventory.py +548 -0
  7. acumatica_cli-0.17.0/src/acumatica_cli/reconcile.py +910 -0
  8. acumatica_cli-0.17.0/src/acumatica_cli/snapshot_map.yaml +86 -0
  9. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/__init__.py +0 -0
  10. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap.py +0 -0
  11. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  12. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  13. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/client.py +0 -0
  14. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/config.py +0 -0
  15. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/extract.py +0 -0
  16. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/firstlogin.py +0 -0
  17. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/models.py +0 -0
  18. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/output.py +0 -0
  19. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/run.py +0 -0
  20. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/seed.py +0 -0
  21. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/seed_catalog.yaml +0 -0
  22. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/state.py +0 -0
  23. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/target.py +0 -0
  24. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/README.md +0 -0
  25. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  26. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  27. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  28. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  29. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  30. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  31. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  32. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  33. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  34. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  35. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  36. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  37. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  38. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  39. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  40. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  41. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  42. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  43. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  44. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  45. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  46. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  47. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  48. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  49. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  50. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  51. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  52. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  53. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  54. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  55. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  56. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  57. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  58. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  59. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  60. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  61. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  62. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  63. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/env +0 -0
  64. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/gitignore +0 -0
  65. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  66. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  67. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  68. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  69. {acumatica_cli-0.15.4 → acumatica_cli-0.17.0}/src/acumatica_cli/templates/target +0 -0
  70. {acumatica_cli-0.15.4 → acumatica_cli-0.17.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.15.4
3
+ Version: 0.17.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>
@@ -88,6 +88,10 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
88
88
  │ capture derived state into state/ (not seed)
89
89
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
90
90
  │ inverse of apply into config/{bootstrap,baseline,setup,master}/
91
+ ├── inventory [--out DIR] [--force] [--dry-run] ARTIFACT
92
+ │ offline snapshot artifact → inventory/ (not seed)
93
+ ├── reconcile [--inventory DIR] [--config DIR] [--out DIR] [--force] [--dry-run]
94
+ │ inventory/ + optional config/ → findings/ only
91
95
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
92
96
  │
93
97
  └── config configuration ops
@@ -101,9 +105,30 @@ A path like `config/` expands nested seed dirs in that fixed order.
101
105
  `run` without FILES defaults to `scenario/`.
102
106
  `state` without FILES defaults to `config/views/`; writes go to `state/` (`--out`).
103
107
  `extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
108
+ `inventory` is offline (no REST/SSH/password): SM203520 Settings XML ZIP or `ac.exe export xml` folder writes to `inventory/`.
109
+ `reconcile` is offline: compare `inventory/` to optional `config/` and write `findings/` only (never writes seed).
110
+ Optional `snapshot_map.yaml` (data-repo root or package defaults) maps DAC tables to catalog entities and normalizes join (pad-trim, key/field aliases, Account/Sub FK CD resolve).
111
+ See [docs/demo-seed.md](docs/demo-seed.md).
104
112
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
105
113
  Run `acu <command> --help` for details on any command.
106
114
 
115
+ ### Dual readers, one writer
116
+
117
+ Two read paths, one mutator (V35).
118
+ Do not confuse them with each other or with `state`:
119
+
120
+ | Command | Plane | Input | Writes | Role |
121
+ | ------- | ----- | ----- | ------ | ---- |
122
+ | `extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
123
+ | `inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
124
+ | `reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
125
+ | `state` | REST (live) | `config/views/` | `state/` | Derived balances/totals — not seed, not inventory |
126
+ | `apply` | REST (live) | seed YAML under `config/` | tenant | **Sole** tenant writer (keyed PUT) |
127
+
128
+ `inventory/` and `findings/` are engagement outputs: not SEED_DIRS, never loaded by `apply`/`diff`, not scaffolded by `config init`.
129
+ Binary `.adb` snapshots are rejected (XML only).
130
+ 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.
131
+
107
132
  ## The data repo
108
133
 
109
134
  Your configuration lives in its own git repo.
@@ -119,6 +144,8 @@ There is no `--flavor`.
119
144
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
120
145
  | `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
121
146
  | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
147
+ | `inventory/` | engagement: offline snapshot tables from `acu inventory` (not seed; not SEED_DIRS) |
148
+ | `findings/` | engagement: `acu reconcile` cross-check output (never apply path) |
122
149
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
123
150
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
124
151
 
@@ -70,6 +70,10 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
70
70
  │ capture derived state into state/ (not seed)
71
71
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
72
72
  │ inverse of apply into config/{bootstrap,baseline,setup,master}/
73
+ ├── inventory [--out DIR] [--force] [--dry-run] ARTIFACT
74
+ │ offline snapshot artifact → inventory/ (not seed)
75
+ ├── reconcile [--inventory DIR] [--config DIR] [--out DIR] [--force] [--dry-run]
76
+ │ inventory/ + optional config/ → findings/ only
73
77
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
74
78
  │
75
79
  └── config configuration ops
@@ -83,9 +87,30 @@ A path like `config/` expands nested seed dirs in that fixed order.
83
87
  `run` without FILES defaults to `scenario/`.
84
88
  `state` without FILES defaults to `config/views/`; writes go to `state/` (`--out`).
85
89
  `extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
90
+ `inventory` is offline (no REST/SSH/password): SM203520 Settings XML ZIP or `ac.exe export xml` folder writes to `inventory/`.
91
+ `reconcile` is offline: compare `inventory/` to optional `config/` and write `findings/` only (never writes seed).
92
+ Optional `snapshot_map.yaml` (data-repo root or package defaults) maps DAC tables to catalog entities and normalizes join (pad-trim, key/field aliases, Account/Sub FK CD resolve).
93
+ See [docs/demo-seed.md](docs/demo-seed.md).
86
94
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
87
95
  Run `acu <command> --help` for details on any command.
88
96
 
97
+ ### Dual readers, one writer
98
+
99
+ Two read paths, one mutator (V35).
100
+ Do not confuse them with each other or with `state`:
101
+
102
+ | Command | Plane | Input | Writes | Role |
103
+ | ------- | ----- | ----- | ------ | ---- |
104
+ | `extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
105
+ | `inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
106
+ | `reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
107
+ | `state` | REST (live) | `config/views/` | `state/` | Derived balances/totals — not seed, not inventory |
108
+ | `apply` | REST (live) | seed YAML under `config/` | tenant | **Sole** tenant writer (keyed PUT) |
109
+
110
+ `inventory/` and `findings/` are engagement outputs: not SEED_DIRS, never loaded by `apply`/`diff`, not scaffolded by `config init`.
111
+ Binary `.adb` snapshots are rejected (XML only).
112
+ 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.
113
+
89
114
  ## The data repo
90
115
 
91
116
  Your configuration lives in its own git repo.
@@ -101,6 +126,8 @@ There is no `--flavor`.
101
126
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
102
127
  | `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
103
128
  | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
129
+ | `inventory/` | engagement: offline snapshot tables from `acu inventory` (not seed; not SEED_DIRS) |
130
+ | `findings/` | engagement: `acu reconcile` cross-check output (never apply path) |
104
131
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
105
132
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
106
133
 
@@ -0,0 +1,109 @@
1
+ [project]
2
+ name = "acumatica-cli"
3
+ version = "0.17.0"
4
+ description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
5
+ license = "PolyForm-Noncommercial-1.0.0"
6
+ readme = "README.md"
7
+ requires-python = ">=3.14"
8
+ dependencies = [
9
+ "click>=8.1",
10
+ "httpx>=0.27",
11
+ "pydantic>=2.7",
12
+ "pydantic-settings>=2.5",
13
+ "pyyaml>=6.0",
14
+ "rich>=13",
15
+ ]
16
+
17
+ [[project.authors]]
18
+ name = "Konstantin Borovik"
19
+ email = "kb@lab5.ca"
20
+
21
+ [project.urls]
22
+ Homepage = "https://lab5.ca"
23
+ Repository = "https://github.com/kborovik/acumatica-cli"
24
+
25
+ [project.scripts]
26
+ acu = "acumatica_cli.cli:main"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.11,<0.12"]
30
+ build-backend = "uv_build"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "basedpyright>=1.37.0",
35
+ "pytest>=8",
36
+ "ruff>=0.13.0",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ addopts = '-q -m "not e2e"'
42
+ markers = ["e2e: live end-to-end tests against a real Acumatica instance (make e2e)"]
43
+
44
+ [tool.basedpyright]
45
+ typeCheckingMode = "strict"
46
+ pythonVersion = "3.14"
47
+ include = [
48
+ "src/acumatica_cli/",
49
+ "tests/",
50
+ ]
51
+ reportMissingTypeStubs = false
52
+ reportUnknownVariableType = false
53
+ reportUnknownMemberType = false
54
+ reportUnknownArgumentType = false
55
+ reportUnknownParameterType = false
56
+ reportUnknownLambdaType = false
57
+ reportUnusedFunction = "warning"
58
+
59
+ [tool.ruff]
60
+ target-version = "py314"
61
+ src = ["src"]
62
+
63
+ [tool.ruff.format]
64
+ docstring-code-format = true
65
+
66
+ [tool.ruff.lint]
67
+ fixable = ["ALL"]
68
+ select = [
69
+ "B",
70
+ "C4",
71
+ "C90",
72
+ "COM",
73
+ "D",
74
+ "E",
75
+ "F",
76
+ "I",
77
+ "N",
78
+ "PL",
79
+ "PT",
80
+ "RUF",
81
+ "SIM",
82
+ "T20",
83
+ "TID",
84
+ "UP",
85
+ "W",
86
+ ]
87
+ ignore = [
88
+ "PLR2004",
89
+ "PLC0415",
90
+ "D100",
91
+ "D104",
92
+ "D105",
93
+ "D107",
94
+ "COM812",
95
+ ]
96
+
97
+ [tool.ruff.lint.pydocstyle]
98
+ convention = "google"
99
+
100
+ [tool.ruff.lint.isort]
101
+ known-first-party = ["acumatica_cli"]
102
+
103
+ [tool.ruff.lint.per-file-ignores]
104
+ "src/acumatica_cli/cli.py" = ["PLR0913"]
105
+ "tests/*" = [
106
+ "D101",
107
+ "D102",
108
+ "D103",
109
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.15.4"
3
+ version = "0.17.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,17 @@ 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, state
15
+ from . import (
16
+ bootstrap,
17
+ extract,
18
+ firstlogin,
19
+ inventory,
20
+ output,
21
+ reconcile,
22
+ run,
23
+ seed,
24
+ state,
25
+ )
16
26
  from .client import AcumaticaClient
17
27
  from .config import (
18
28
  Instance,
@@ -1000,6 +1010,126 @@ def _exit_on_drift(inst: Instance, drifts: list[str], files: int) -> None:
1000
1010
  output.success(f"no drift on {inst.tenant} ({inst.base_url}, {files} file(s))")
1001
1011
 
1002
1012
 
1013
+ @cli.command("inventory")
1014
+ @click.argument(
1015
+ "artifact",
1016
+ type=click.Path(exists=True, path_type=Path),
1017
+ )
1018
+ @click.option(
1019
+ "--out",
1020
+ "out_dir",
1021
+ type=click.Path(file_okay=False, path_type=Path),
1022
+ default=None,
1023
+ help="Output directory (default: inventory/)",
1024
+ )
1025
+ @click.option("--force", is_flag=True, help="Overwrite existing files")
1026
+ @click.option(
1027
+ "--dry-run",
1028
+ is_flag=True,
1029
+ help="Show what would be written without writing",
1030
+ )
1031
+ def inventory_cmd(
1032
+ artifact: Path,
1033
+ out_dir: Path | None,
1034
+ force: bool,
1035
+ dry_run: bool,
1036
+ ) -> None:
1037
+ """Parse a snapshot artifact into inventory/ (offline dual-reader).
1038
+
1039
+ ARTIFACT is an SM203520 Settings XML ZIP (manifest.xml + table XML) or
1040
+ an ac.exe export xml folder. Both normalize to one IR (V37). Binary
1041
+ .adb is rejected. No REST, no SSH, no password. Writes summary.yaml
1042
+ and tables/<Table>.yaml under --out (default inventory/). Existing
1043
+ files are skipped unless --force. When data-repo target.yaml is
1044
+ present and the artifact reports a build, erp must match or the run
1045
+ fails. Exit 0 clean, 1 parse/format/version fail; never 2 (drift is
1046
+ not this command). Not extract (REST seed into config/), not state
1047
+ (derived balances) — never writes config/ or state/ (V35).
1048
+ """
1049
+ art = inventory.parse_artifact(artifact)
1050
+ target = load_target()
1051
+ if target is not None:
1052
+ inventory.assert_erp_matches(art, target.erp)
1053
+ dest = out_dir if out_dir is not None else Path(inventory.DEFAULT_OUT)
1054
+ output.data(
1055
+ f"{artifact} -> {dest} ({len(art.tables)} table(s)"
1056
+ f"{f', erp={art.erp}' if art.erp else ''})"
1057
+ )
1058
+ inventory.emit(art, dest, force=force, dry_run=dry_run)
1059
+
1060
+
1061
+ @cli.command("reconcile")
1062
+ @click.option(
1063
+ "--inventory",
1064
+ "inventory_dir",
1065
+ type=click.Path(file_okay=False, path_type=Path),
1066
+ default=None,
1067
+ help="Inventory tree directory (default: inventory/)",
1068
+ )
1069
+ @click.option(
1070
+ "--config",
1071
+ "config_dir",
1072
+ type=click.Path(file_okay=False, path_type=Path),
1073
+ default=None,
1074
+ help="Optional config/ seed tree (default: config/ when present)",
1075
+ )
1076
+ @click.option(
1077
+ "--out",
1078
+ "out_dir",
1079
+ type=click.Path(file_okay=False, path_type=Path),
1080
+ default=None,
1081
+ help="Findings output directory (default: findings/)",
1082
+ )
1083
+ @click.option("--force", is_flag=True, help="Overwrite existing findings files")
1084
+ @click.option(
1085
+ "--dry-run",
1086
+ is_flag=True,
1087
+ help="Show what would be written without writing",
1088
+ )
1089
+ def reconcile_cmd(
1090
+ inventory_dir: Path | None,
1091
+ config_dir: Path | None,
1092
+ out_dir: Path | None,
1093
+ force: bool,
1094
+ dry_run: bool,
1095
+ ) -> None:
1096
+ """Compare inventory/ to optional config/ and write findings/ (offline).
1097
+
1098
+ Dual-reader cross-check (V35/V36): reads an inventory tree from
1099
+ `acu inventory` plus an optional prior-extract config/ seed tree;
1100
+ emits FindingsBundle under --out (default findings/). Never writes
1101
+ config/ (not extract promote) and never captures state/ — unmapped
1102
+ tables, REST gaps, rest-vs-snapshot field deltas, and Usr* custom
1103
+ columns land in findings files only. No REST, no SSH, no password.
1104
+ Exit 0 clean, 1 IO/parse fail; never 2 (conflicts are findings, not
1105
+ drift). Optional snapshot_map.yaml maps DAC tables to catalog
1106
+ entities (absent → identity match on entity name) and may declare
1107
+ keys:/fields: seed→inv aliases plus resolvers:/resolves: for FK
1108
+ int→CD compare (package defaults cover Sub, UnitOfMeasure,
1109
+ ReasonCode/VendorClass Account+Sub). Compare always pad-trims strings.
1110
+ """
1111
+ inv = (
1112
+ inventory_dir
1113
+ if inventory_dir is not None
1114
+ else Path(reconcile.DEFAULT_INVENTORY)
1115
+ )
1116
+ if config_dir is not None:
1117
+ cfg: Path | None = config_dir
1118
+ else:
1119
+ default_cfg = Path(reconcile.DEFAULT_CONFIG)
1120
+ cfg = default_cfg if default_cfg.is_dir() else None
1121
+ dest = out_dir if out_dir is not None else Path(reconcile.DEFAULT_OUT)
1122
+ cfg_label = str(cfg) if cfg is not None else "(none)"
1123
+ output.data(f"{inv} + {cfg_label} -> {dest}")
1124
+ reconcile.run(
1125
+ inventory_dir=inv,
1126
+ config_dir=cfg,
1127
+ out_dir=dest,
1128
+ force=force,
1129
+ dry_run=dry_run,
1130
+ )
1131
+
1132
+
1003
1133
  @cli.command("state")
1004
1134
  @click.argument(
1005
1135
  "files", nargs=-1, required=False, type=click.Path(exists=True, path_type=Path)