acumatica-cli 0.15.4__tar.gz → 0.16.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 (69) hide show
  1. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/PKG-INFO +26 -1
  2. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/README.md +25 -0
  3. acumatica_cli-0.16.0/pyproject.toml +109 -0
  4. acumatica_cli-0.15.4/pyproject.toml → acumatica_cli-0.16.0/pyproject.toml.orig +1 -1
  5. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/cli.py +128 -1
  6. acumatica_cli-0.16.0/src/acumatica_cli/inventory.py +548 -0
  7. acumatica_cli-0.16.0/src/acumatica_cli/reconcile.py +760 -0
  8. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/__init__.py +0 -0
  9. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/bootstrap.py +0 -0
  10. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  11. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  12. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/client.py +0 -0
  13. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/config.py +0 -0
  14. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/extract.py +0 -0
  15. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/firstlogin.py +0 -0
  16. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/models.py +0 -0
  17. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/output.py +0 -0
  18. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/run.py +0 -0
  19. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/seed.py +0 -0
  20. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/seed_catalog.yaml +0 -0
  21. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/state.py +0 -0
  22. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/target.py +0 -0
  23. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/README.md +0 -0
  24. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  25. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  26. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  27. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  28. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  29. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  30. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  31. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  32. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  33. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  34. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  35. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  36. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  37. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  38. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  39. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  40. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  41. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  42. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  43. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  44. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  45. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  46. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  47. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  48. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  49. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  50. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  51. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  52. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  53. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  54. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  55. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  56. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  57. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  58. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  59. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  60. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  61. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  62. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/env +0 -0
  63. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/gitignore +0 -0
  64. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  65. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  66. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  67. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  68. {acumatica_cli-0.15.4 → acumatica_cli-0.16.0}/src/acumatica_cli/templates/target +0 -0
  69. {acumatica_cli-0.15.4 → acumatica_cli-0.16.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.16.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,28 @@ 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).
104
110
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
105
111
  Run `acu <command> --help` for details on any command.
106
112
 
113
+ ### Dual readers, one writer
114
+
115
+ Two read paths, one mutator (V35).
116
+ Do not confuse them with each other or with `state`:
117
+
118
+ | Command | Plane | Input | Writes | Role |
119
+ | ------- | ----- | ----- | ------ | ---- |
120
+ | `extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
121
+ | `inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
122
+ | `reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
123
+ | `state` | REST (live) | `config/views/` | `state/` | Derived balances/totals — not seed, not inventory |
124
+ | `apply` | REST (live) | seed YAML under `config/` | tenant | **Sole** tenant writer (keyed PUT) |
125
+
126
+ `inventory/` and `findings/` are engagement outputs: not SEED_DIRS, never loaded by `apply`/`diff`, not scaffolded by `config init`.
127
+ Binary `.adb` snapshots are rejected (XML only).
128
+ 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.
129
+
107
130
  ## The data repo
108
131
 
109
132
  Your configuration lives in its own git repo.
@@ -119,6 +142,8 @@ There is no `--flavor`.
119
142
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
120
143
  | `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
121
144
  | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
145
+ | `inventory/` | engagement: offline snapshot tables from `acu inventory` (not seed; not SEED_DIRS) |
146
+ | `findings/` | engagement: `acu reconcile` cross-check output (never apply path) |
122
147
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
123
148
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
124
149
 
@@ -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,28 @@ 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).
86
92
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
87
93
  Run `acu <command> --help` for details on any command.
88
94
 
95
+ ### Dual readers, one writer
96
+
97
+ Two read paths, one mutator (V35).
98
+ Do not confuse them with each other or with `state`:
99
+
100
+ | Command | Plane | Input | Writes | Role |
101
+ | ------- | ----- | ----- | ------ | ---- |
102
+ | `extract` | REST (live) | tenant via contract API | `config/{bootstrap,baseline,setup,master}/` | Inverse of `apply` — seed YAML |
103
+ | `inventory` | Offline | SM203520 Settings XML ZIP or `ac.exe export xml` folder | `inventory/` (`summary.yaml` + `tables/`) | Full-table snapshot IR — not seed |
104
+ | `reconcile` | Offline | `inventory/` + optional `config/` | `findings/` only | Cross-check gaps/deltas — never mutates seed or tenant |
105
+ | `state` | REST (live) | `config/views/` | `state/` | Derived balances/totals — not seed, not inventory |
106
+ | `apply` | REST (live) | seed YAML under `config/` | tenant | **Sole** tenant writer (keyed PUT) |
107
+
108
+ `inventory/` and `findings/` are engagement outputs: not SEED_DIRS, never loaded by `apply`/`diff`, not scaffolded by `config init`.
109
+ Binary `.adb` snapshots are rejected (XML only).
110
+ 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.
111
+
89
112
  ## The data repo
90
113
 
91
114
  Your configuration lives in its own git repo.
@@ -101,6 +124,8 @@ There is no `--flavor`.
101
124
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
102
125
  | `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
103
126
  | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
127
+ | `inventory/` | engagement: offline snapshot tables from `acu inventory` (not seed; not SEED_DIRS) |
128
+ | `findings/` | engagement: `acu reconcile` cross-check output (never apply path) |
104
129
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
105
130
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
106
131
 
@@ -0,0 +1,109 @@
1
+ [project]
2
+ name = "acumatica-cli"
3
+ version = "0.16.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.16.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,123 @@ 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.
1107
+ """
1108
+ inv = (
1109
+ inventory_dir
1110
+ if inventory_dir is not None
1111
+ else Path(reconcile.DEFAULT_INVENTORY)
1112
+ )
1113
+ if config_dir is not None:
1114
+ cfg: Path | None = config_dir
1115
+ else:
1116
+ default_cfg = Path(reconcile.DEFAULT_CONFIG)
1117
+ cfg = default_cfg if default_cfg.is_dir() else None
1118
+ dest = out_dir if out_dir is not None else Path(reconcile.DEFAULT_OUT)
1119
+ cfg_label = str(cfg) if cfg is not None else "(none)"
1120
+ output.data(f"{inv} + {cfg_label} -> {dest}")
1121
+ reconcile.run(
1122
+ inventory_dir=inv,
1123
+ config_dir=cfg,
1124
+ out_dir=dest,
1125
+ force=force,
1126
+ dry_run=dry_run,
1127
+ )
1128
+
1129
+
1003
1130
  @cli.command("state")
1004
1131
  @click.argument(
1005
1132
  "files", nargs=-1, required=False, type=click.Path(exists=True, path_type=Path)