acumatica-cli 0.28.0__tar.gz → 0.30.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 (78) hide show
  1. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/PKG-INFO +7 -11
  2. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/README.md +6 -10
  3. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/pyproject.toml.orig +1 -1
  5. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/bootstrap.py +2 -2
  6. acumatica_cli-0.30.0/src/acumatica_cli/bootstrap_plugin.cs +147 -0
  7. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/bootstrap_project.xml +42 -11
  8. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/cli.py +4 -192
  9. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/config.py +5 -0
  10. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/extract.py +7 -0
  11. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/seed.py +59 -1
  12. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/seed_catalog.yaml +37 -12
  13. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/README.md +2 -3
  14. acumatica_cli-0.30.0/src/acumatica_cli/templates/config/baseline/91-company-packaging.yaml +8 -0
  15. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/91-users.yaml +0 -1
  16. acumatica_cli-0.30.0/src/acumatica_cli/templates/config/master/92-role-users.yaml +9 -0
  17. acumatica_cli-0.28.0/src/acumatica_cli/bootstrap_plugin.cs +0 -74
  18. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/__init__.py +0 -0
  19. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/client.py +0 -0
  20. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/firstlogin.py +0 -0
  21. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/inventory.py +0 -0
  22. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/models.py +0 -0
  23. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/output.py +0 -0
  24. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/reconcile.py +0 -0
  25. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/run.py +0 -0
  26. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/snapshot_map.yaml +0 -0
  27. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/state.py +0 -0
  28. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  29. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  30. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  31. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  32. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  33. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  34. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  35. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  36. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  37. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/bootstrap/segmented-key.yaml +0 -0
  38. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml +0 -0
  39. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  40. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  41. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  42. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  43. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  44. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  45. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  46. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  47. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  48. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  49. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  50. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  51. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  52. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  53. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  54. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  55. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  56. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  57. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  58. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  59. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  60. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  61. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  62. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  63. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  64. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/master/90-roles.yaml +0 -0
  65. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  66. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  67. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  68. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  69. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/env +0 -0
  70. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/gitignore +0 -0
  71. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/overlays/README.md +0 -0
  72. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/overlays/default-24.200.001/README.md +0 -0
  73. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/overlays/default-24.200.001/scenario/30-build.yaml +0 -0
  74. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  75. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  76. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  77. {acumatica_cli-0.28.0 → acumatica_cli-0.30.0}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  78. {acumatica_cli-0.28.0 → acumatica_cli-0.30.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.28.0
3
+ Version: 0.30.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>
@@ -45,7 +45,7 @@ YAML in git is the source of truth; the live tenant is the target. `apply` is th
45
45
  | Capture derived balances | `state` | Inquire trial-balance etc. into `state/` (not seed) |
46
46
  | Create and destroy tenants | `tenant` | SSH control plane: list / create / delete / recycle |
47
47
  | Publish the Bootstrap contract | `bootstrap` | REST publish of AcuBootstrap (or `--export` zip for the UI) |
48
- | Prove a cold rebuild | `check` | create, then apply, then run, then diff on a fresh tenant |
48
+ | Prove a cold rebuild | `tenant create` then `apply` then `run` then `diff` | Compose on a fresh tenant. No wrap command. |
49
49
  | Snapshot a site offline | `inventory` | SM203520 XML ZIP or `ac.exe export xml` writes `inventory/` |
50
50
  | Cross-check snapshot vs seed | `reconcile` | `inventory/` plus optional `config/` writes `findings/` only |
51
51
  | Dump the contract schema | `schema` | OpenAPI `swagger.json` for the pinned endpoint |
@@ -76,7 +76,6 @@ acu --tenant DEV apply config/ # seed config/{bootstrap,baseline,setup
76
76
  acu --tenant DEV run scenario/ # once capital → buy → build → sell
77
77
  acu --tenant DEV diff config/ # prove zero drift (exit 2 on drift)
78
78
  acu --tenant DEV state # capture state/ trial-balance
79
- acu check --yes --tenant DEV # cold lifecycle create→apply→run→diff (leave tenant)
80
79
  ```
81
80
 
82
81
  Bare `apply` / `diff` (no path args) also prefer `config/` when those trees exist.
@@ -115,7 +114,6 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
115
114
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
116
115
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
117
116
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
118
- ├── check [--yes] [--tenant L] cold lifecycle create→apply→run→diff; leave tenant (V47)
119
117
  ├── state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
120
118
  │ capture derived state into state/ (not seed)
121
119
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
@@ -196,7 +194,7 @@ There is no `--flavor`.
196
194
  | `config/bootstrap/` | virgin-tenant config: features, company, credit terms (no `project.xml`) |
197
195
  | `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs |
198
196
  | `config/setup/` | one-time actions: financial year, master calendar, open periods |
199
- | `config/master/` | inventory/distribution masters: numbering (`05-…`) before prefs, warehouse, items, parties + Role/User (`90-roles` then `91-users`) |
197
+ | `config/master/` | inventory/distribution masters: numbering (`05-…`) before prefs, warehouse, items, parties + Role/User (`90-roles` then `91-users` then `92-role-users`) |
200
198
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
201
199
  | `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
202
200
  | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
@@ -285,7 +283,6 @@ Ad-hoc override: `acu --api-version 24.200.001 …` (version half only, never
285
283
  `base_url` resolves as `--url`, else `ACU_BASE_URL`, else a hard error naming sources.
286
284
 
287
285
  `acu config show` prints the resolved `.env` (password excluded; includes `ACU_API_VERSION`).
288
- `acu check` (lifecycle) uses the same resolved instance.
289
286
 
290
287
  ### Multi-host overlays (V44)
291
288
 
@@ -317,9 +314,6 @@ acu diff
317
314
  # explicit path args (no auto) — later path wins same keys
318
315
  acu apply config/ overlays/default-24.200.001/
319
316
  acu diff config/ overlays/default-24.200.001/
320
-
321
- # cold lifecycle vs the resolved .env instance (SSH + tenant required)
322
- acu check --yes --tenant LAB5
323
317
  ```
324
318
 
325
319
  Add a future half by creating `overlays/default-<new-half>/` with the
@@ -335,7 +329,7 @@ Worth knowing:
335
329
  - Without a `.env`, global flags plus the process environment supply the configuration.
336
330
  - When `ACU_SSH` is **absent**, acu defaults to `Administrator@` + the resolved base_url hostname.
337
331
  A **present blank** `ACU_SSH=` is the hosted opt-out.
338
- Only `acu tenant` / `acu check` require a non-empty value post-default.
332
+ Only `acu tenant` requires a non-empty value post-default.
339
333
  - `acu config show` prints the resolved `.env` (password excluded).
340
334
  - Redirect it to turn resolved state into a working config: `acu config show > .env`.
341
335
 
@@ -383,7 +377,9 @@ gmake release patch # or minor | major
383
377
  2. Fail if `## Unreleased` has no bullets
384
378
  3. Bump `pyproject.toml` version (`major` | `minor` | `patch`)
385
379
  4. Promote Unreleased body to `## [vX.Y.Z] - YYYY-MM-DD`, leave an empty `## Unreleased`
386
- 5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together, tag `vX.Y.Z`, push
380
+ 5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together, tag `vX.Y.Z`
381
+ 6. `gmake check` again on the promoted tree
382
+ 7. Push tag + branch — GitHub Actions publishes
387
383
 
388
384
  GitHub Actions on tag `v*` re-runs CI, builds sdist+wheel, publishes to PyPI via OIDC trusted publishing, and creates a GitHub Release whose notes are the promoted CHANGELOG section for that tag (plus the artifacts).
389
385
 
@@ -27,7 +27,7 @@ YAML in git is the source of truth; the live tenant is the target. `apply` is th
27
27
  | Capture derived balances | `state` | Inquire trial-balance etc. into `state/` (not seed) |
28
28
  | Create and destroy tenants | `tenant` | SSH control plane: list / create / delete / recycle |
29
29
  | Publish the Bootstrap contract | `bootstrap` | REST publish of AcuBootstrap (or `--export` zip for the UI) |
30
- | Prove a cold rebuild | `check` | create, then apply, then run, then diff on a fresh tenant |
30
+ | Prove a cold rebuild | `tenant create` then `apply` then `run` then `diff` | Compose on a fresh tenant. No wrap command. |
31
31
  | Snapshot a site offline | `inventory` | SM203520 XML ZIP or `ac.exe export xml` writes `inventory/` |
32
32
  | Cross-check snapshot vs seed | `reconcile` | `inventory/` plus optional `config/` writes `findings/` only |
33
33
  | Dump the contract schema | `schema` | OpenAPI `swagger.json` for the pinned endpoint |
@@ -58,7 +58,6 @@ acu --tenant DEV apply config/ # seed config/{bootstrap,baseline,setup
58
58
  acu --tenant DEV run scenario/ # once capital → buy → build → sell
59
59
  acu --tenant DEV diff config/ # prove zero drift (exit 2 on drift)
60
60
  acu --tenant DEV state # capture state/ trial-balance
61
- acu check --yes --tenant DEV # cold lifecycle create→apply→run→diff (leave tenant)
62
61
  ```
63
62
 
64
63
  Bare `apply` / `diff` (no path args) also prefer `config/` when those trees exist.
@@ -97,7 +96,6 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
97
96
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
98
97
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
99
98
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
100
- ├── check [--yes] [--tenant L] cold lifecycle create→apply→run→diff; leave tenant (V47)
101
99
  ├── state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
102
100
  │ capture derived state into state/ (not seed)
103
101
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
@@ -178,7 +176,7 @@ There is no `--flavor`.
178
176
  | `config/bootstrap/` | virgin-tenant config: features, company, credit terms (no `project.xml`) |
179
177
  | `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs |
180
178
  | `config/setup/` | one-time actions: financial year, master calendar, open periods |
181
- | `config/master/` | inventory/distribution masters: numbering (`05-…`) before prefs, warehouse, items, parties + Role/User (`90-roles` then `91-users`) |
179
+ | `config/master/` | inventory/distribution masters: numbering (`05-…`) before prefs, warehouse, items, parties + Role/User (`90-roles` then `91-users` then `92-role-users`) |
182
180
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
183
181
  | `config/views/` | observer views for `acu state` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
184
182
  | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
@@ -267,7 +265,6 @@ Ad-hoc override: `acu --api-version 24.200.001 …` (version half only, never
267
265
  `base_url` resolves as `--url`, else `ACU_BASE_URL`, else a hard error naming sources.
268
266
 
269
267
  `acu config show` prints the resolved `.env` (password excluded; includes `ACU_API_VERSION`).
270
- `acu check` (lifecycle) uses the same resolved instance.
271
268
 
272
269
  ### Multi-host overlays (V44)
273
270
 
@@ -299,9 +296,6 @@ acu diff
299
296
  # explicit path args (no auto) — later path wins same keys
300
297
  acu apply config/ overlays/default-24.200.001/
301
298
  acu diff config/ overlays/default-24.200.001/
302
-
303
- # cold lifecycle vs the resolved .env instance (SSH + tenant required)
304
- acu check --yes --tenant LAB5
305
299
  ```
306
300
 
307
301
  Add a future half by creating `overlays/default-<new-half>/` with the
@@ -317,7 +311,7 @@ Worth knowing:
317
311
  - Without a `.env`, global flags plus the process environment supply the configuration.
318
312
  - When `ACU_SSH` is **absent**, acu defaults to `Administrator@` + the resolved base_url hostname.
319
313
  A **present blank** `ACU_SSH=` is the hosted opt-out.
320
- Only `acu tenant` / `acu check` require a non-empty value post-default.
314
+ Only `acu tenant` requires a non-empty value post-default.
321
315
  - `acu config show` prints the resolved `.env` (password excluded).
322
316
  - Redirect it to turn resolved state into a working config: `acu config show > .env`.
323
317
 
@@ -365,7 +359,9 @@ gmake release patch # or minor | major
365
359
  2. Fail if `## Unreleased` has no bullets
366
360
  3. Bump `pyproject.toml` version (`major` | `minor` | `patch`)
367
361
  4. Promote Unreleased body to `## [vX.Y.Z] - YYYY-MM-DD`, leave an empty `## Unreleased`
368
- 5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together, tag `vX.Y.Z`, push
362
+ 5. Commit `CHANGELOG.md` + `pyproject.toml` (+ lock if bumped) together, tag `vX.Y.Z`
363
+ 6. `gmake check` again on the promoted tree
364
+ 7. Push tag + branch — GitHub Actions publishes
369
365
 
370
366
  GitHub Actions on tag `v*` re-runs CI, builds sdist+wheel, publishes to PyPI via OIDC trusted publishing, and creates a GitHub Release whose notes are the promoted CHANGELOG section for that tag (plus the artifacts).
371
367
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.28.0"
3
+ version = "0.30.0"
4
4
  description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
5
5
  license = "PolyForm-Noncommercial-1.0.0"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.28.0"
3
+ version = "0.30.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"
@@ -9,7 +9,7 @@ cannot write CS100000 at all (T3 verdict) — and whose Bootstrap contract
9
9
  endpoint exposes the seeding surface (serialization verified T12).
10
10
 
11
11
  Bootstrap endpoint contract is package SoT (V2/V21/T178): always the
12
- packaged full company ``bootstrap_project.xml`` (``Bootstrap/1.7.0``).
12
+ packaged full company ``bootstrap_project.xml`` (``Bootstrap/1.10.0``).
13
13
  Data-repo ``config/bootstrap/project.xml`` or ``bootstrap/project.xml`` is
14
14
  not a seed — present → hard error naming package SoT (no dual contract line).
15
15
 
@@ -68,7 +68,7 @@ def _bootstrap_file(root: Path, name: str) -> Path | None:
68
68
 
69
69
 
70
70
  def packaged_contract_xml() -> bytes:
71
- """The CLI-shipped full company Bootstrap contract (``Bootstrap/1.7.0``)."""
71
+ """The CLI-shipped full company Bootstrap contract (``Bootstrap/1.10.0``)."""
72
72
  return (resources.files("acumatica_cli") / "bootstrap_project.xml").read_bytes()
73
73
 
74
74
 
@@ -0,0 +1,147 @@
1
+ // acu bootstrap CustomizationPlugin (SPEC T11).
2
+ //
3
+ // Runs in-process on customization publish - the one write path to
4
+ // FeaturesSet that works: the contract API cannot persist features no
5
+ // matter what endpoint fronts CS100000 (T3 verdict, docs/rest-api.md).
6
+ //
7
+ // Deliberately writes through PXDatabase, not FeaturesMaint: the graph
8
+ // save collides with the publish pipeline's concurrent plugin invocations
9
+ // ("Another process has added the 'FeaturesSet' record" - observed live,
10
+ // 26.101.0225) and nothing persists. A keyed row write is deterministic
11
+ // and idempotent: update the existing row, insert when absent. All 205
12
+ // NOT NULL bit columns are filled reflectively (only ~136 carry DB
13
+ // defaults); Status = 0 means Validated (PXIntList, verified vs live).
14
+ //
15
+ // The Enabled set is NOT authored here (SPEC V2: feature flags are config
16
+ // "what", never tool source - B6): package_zip() substitutes the
17
+ // ACU_FEATURES sentinel below with the data repo's bootstrap/features.yaml
18
+ // list (built-in six when the file is absent) at package-build time.
19
+ using System;
20
+ using System.Collections;
21
+ using System.Collections.Generic;
22
+ using System.Reflection;
23
+ using PX.Data;
24
+ using PX.Objects.CS;
25
+ using PX.SM;
26
+
27
+ namespace AcuBootstrap
28
+ {
29
+ public class AcuBootstrapPlugin : Customization.CustomizationPlugin
30
+ {
31
+ private static readonly HashSet<string> Enabled = new HashSet<string>
32
+ {
33
+ /*ACU_FEATURES*/
34
+ };
35
+
36
+ public override void UpdateDatabase()
37
+ {
38
+ var flags = new List<PXDataFieldAssign>();
39
+ var known = new HashSet<string>();
40
+ foreach (PropertyInfo prop in typeof(FeaturesSet).GetProperties())
41
+ {
42
+ if (prop.PropertyType == typeof(bool?))
43
+ {
44
+ known.Add(prop.Name);
45
+ flags.Add(new PXDataFieldAssign(
46
+ prop.Name, PXDbType.Bit, Enabled.Contains(prop.Name)));
47
+ }
48
+ }
49
+ foreach (string name in Enabled)
50
+ {
51
+ if (!known.Contains(name))
52
+ {
53
+ // silent-typo guard (T24): a misspelled features.yaml
54
+ // entry enables nothing - say so in the publish log
55
+ WriteLog("AcuBootstrap: unknown feature name '" + name
56
+ + "' - no FeaturesSet property, nothing enabled");
57
+ }
58
+ }
59
+ flags.Add(new PXDataFieldAssign("Status", PXDbType.Int, 0)); // Validated
60
+
61
+ using (var tx = new PXTransactionScope())
62
+ {
63
+ bool updated = PXDatabase.Update<FeaturesSet>(flags.ToArray());
64
+ if (!updated)
65
+ {
66
+ PXDatabase.Insert<FeaturesSet>(flags.ToArray());
67
+ WriteLog("AcuBootstrap: FeaturesSet row inserted (features enabled)");
68
+ }
69
+ else
70
+ {
71
+ WriteLog("AcuBootstrap: FeaturesSet row updated (features enabled)");
72
+ }
73
+ tx.Complete();
74
+ }
75
+ }
76
+
77
+ // V53/B33: mapped Role.Users / User.Roles PUT never writes
78
+ // UsersInRoles. Persist is a keyed PXDatabase write (same
79
+ // FeaturesSet idiom): ApplicationName='/' (ASP.NET membership).
80
+ public static void AssignUserToRole(string username, string rolename)
81
+ {
82
+ if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(rolename))
83
+ {
84
+ throw new PXException(
85
+ "AssignUser requires Username and Rolename");
86
+ }
87
+ Guid userId = PXAccess.GetUserID();
88
+ DateTime now = DateTime.Now;
89
+ var update = new List<PXDataFieldParam>
90
+ {
91
+ new PXDataFieldRestrict("Username", PXDbType.NVarChar, username),
92
+ new PXDataFieldRestrict("Rolename", PXDbType.NVarChar, rolename),
93
+ new PXDataFieldRestrict("ApplicationName", PXDbType.VarChar, "/"),
94
+ new PXDataFieldAssign("LastModifiedByID", PXDbType.UniqueIdentifier, userId),
95
+ new PXDataFieldAssign("LastModifiedByScreenID", PXDbType.Char, "SM201005"),
96
+ new PXDataFieldAssign("LastModifiedDateTime", PXDbType.DateTime, now),
97
+ };
98
+ bool updated = PXDatabase.Update<UsersInRoles>(update.ToArray());
99
+ if (!updated)
100
+ {
101
+ var insert = new List<PXDataFieldAssign>
102
+ {
103
+ new PXDataFieldAssign("Username", PXDbType.NVarChar, username),
104
+ new PXDataFieldAssign("Rolename", PXDbType.NVarChar, rolename),
105
+ new PXDataFieldAssign("ApplicationName", PXDbType.VarChar, "/"),
106
+ new PXDataFieldAssign("CreatedByID", PXDbType.UniqueIdentifier, userId),
107
+ new PXDataFieldAssign("CreatedByScreenID", PXDbType.Char, "SM201005"),
108
+ new PXDataFieldAssign("CreatedDateTime", PXDbType.DateTime, now),
109
+ new PXDataFieldAssign("LastModifiedByID", PXDbType.UniqueIdentifier, userId),
110
+ new PXDataFieldAssign("LastModifiedByScreenID", PXDbType.Char, "SM201005"),
111
+ new PXDataFieldAssign("LastModifiedDateTime", PXDbType.DateTime, now),
112
+ };
113
+ PXDatabase.Insert<UsersInRoles>(insert.ToArray());
114
+ }
115
+ }
116
+ }
117
+
118
+ public class AssignUserFilter : PXBqlTable, IBqlTable
119
+ {
120
+ [PXString(256, IsUnicode = true, InputMask = "")]
121
+ [PXUIField(DisplayName = "Username")]
122
+ public virtual string Username { get; set; }
123
+ public abstract class username : PX.Data.BQL.BqlString.Field<username> { }
124
+ }
125
+
126
+ // Action lives on RoleAccess so Base.Roles.Current is the SM201005 header.
127
+ public class AcuRoleAccessExt : PXGraphExtension<RoleAccess>
128
+ {
129
+ public static bool IsActive() => true;
130
+
131
+ public PXFilter<AssignUserFilter> AssignUserParams;
132
+
133
+ public PXAction<Roles> assignUser;
134
+
135
+ [PXButton(CommitChanges = true)]
136
+ [PXUIField(DisplayName = "Assign User", Visible = false)]
137
+ protected virtual IEnumerable AssignUser(PXAdapter adapter)
138
+ {
139
+ string username = AssignUserParams.Current != null
140
+ ? AssignUserParams.Current.Username : null;
141
+ Roles role = Base.Roles.Current;
142
+ string rolename = role != null ? role.Rolename : null;
143
+ AcuBootstrapPlugin.AssignUserToRole(username, rolename);
144
+ return adapter.Get();
145
+ }
146
+ }
147
+ }
@@ -8,7 +8,12 @@
8
8
  T204 bumps 1.4.0 -> 1.5.0 for SegmentedKey, gh #30 / V50;
9
9
  T207 bumps 1.5.0 -> 1.6.0 for SegmentedKey Detail view (B28);
10
10
  gh #34 bumps 1.6.0 -> 1.7.0 for Company DecPlQty + WeightUOM/VolumeUOM
11
- (CS101500 commonsetup; kit BOM milligram-scale KG needs qty precision 3).
11
+ (CS101500 commonsetup; kit BOM milligram-scale KG needs qty precision 3);
12
+ gh #35 bumps 1.7.0 -> 1.8.0 for User.Roles -> RolesByUser (UsersInRoles)
13
+ and drops Selected (AllowedRoles was EPLoginTypeAllowsRole / EP202500);
14
+ T228 bumps 1.8.0 -> 1.9.0 for Role.Users -> UsersByRole (SM201005
15
+ Users tab; mapped-detail PUT is a no-op — B33) and 1.9.0 -> 1.10.0
16
+ for Role AssignUser action (PXDatabase UsersInRoles persist).
12
17
 
13
18
  package_zip() (bootstrap.py) prefers data-repo bootstrap/project.xml when
14
19
  present; otherwise this packaged template is the fallback so config init,
@@ -48,9 +53,13 @@
48
53
  Username; identity FirstName/LastName/Email + IsApproved +
49
54
  PasswordNeverExpires + PasswordChangeOnNextLogin; Password write-only
50
55
  (extract/diff policy = V39/T147).
51
- - membership = User detail Roles -> AllowedRoles (UsersInRoles/Roles
52
- projection): Rolename + Selected (assign when true). Apply order
53
- Role then User (V22 prefixes; T148).
56
+ - User detail Roles -> RolesByUser (GET/diff shape; PUT never
57
+ writes UsersInRoles — B31/B32/B33). Persist = Role action
58
+ AssignUser -> PXDatabase UsersInRoles (ApplicationName='/').
59
+ Role.Users -> UsersByRole is the mapped-detail fixture (silent
60
+ 200). Apply order Role then User then Role.Users
61
+ (V22 prefixes 90/91/92). Never AllowedRoles
62
+ (EPLoginTypeAllowsRole / EP202500 allow-list; gh #35 / V53).
54
63
 
55
64
  T150 NumberingSequence (gh #25) - Default endpoint has no numbering
56
65
  surface; CS201010 Numbering Sequences (NumberingMaint — NOT the
@@ -112,7 +121,7 @@
112
121
  -->
113
122
  <Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes full company surface (company + credit terms + financial currency + GL chain + module preferences + vendor class + statement cycle + warehouse + order type + cash account + role + user + membership + numbering sequence + segmented key)" product-version="26.101">
114
123
  <EntityEndpoint>
115
- <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.7.0" systemContractVersion="4">
124
+ <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.10.0" systemContractVersion="4">
116
125
  <TopLevelEntity name="Company" screen="CS101500">
117
126
  <Fields>
118
127
  <Field name="AcctCD" type="StringValue" />
@@ -1019,6 +1028,7 @@
1019
1028
  <Fields>
1020
1029
  <Field name="Rolename" type="StringValue" />
1021
1030
  <Field name="Descr" type="StringValue" />
1031
+ <Field name="Users" type="RoleUser[]" />
1022
1032
  </Fields>
1023
1033
  <Mappings>
1024
1034
  <Mapping field="Rolename">
@@ -1027,8 +1037,33 @@
1027
1037
  <Mapping field="Descr">
1028
1038
  <To object="Roles" field="Descr" />
1029
1039
  </Mapping>
1040
+ <Mapping field="Users">
1041
+ <To object="UsersByRole" field="" />
1042
+ </Mapping>
1030
1043
  </Mappings>
1044
+ <Actions>
1045
+ <Action name="AssignUser" mappedTo="assignUser" isDefault="false">
1046
+ <Fields>
1047
+ <Field name="Username" type="StringValue" />
1048
+ </Fields>
1049
+ <Mappings>
1050
+ <Mapping field="Username">
1051
+ <To object="AssignUserParams" field="Username" />
1052
+ </Mapping>
1053
+ </Mappings>
1054
+ </Action>
1055
+ </Actions>
1031
1056
  </TopLevelEntity>
1057
+ <Detail name="RoleUser">
1058
+ <Fields>
1059
+ <Field name="Username" type="StringValue" />
1060
+ </Fields>
1061
+ <Mappings>
1062
+ <Mapping field="Username">
1063
+ <To object="UsersByRole" field="Username" />
1064
+ </Mapping>
1065
+ </Mappings>
1066
+ </Detail>
1032
1067
  <TopLevelEntity name="User" screen="SM201010">
1033
1068
  <Fields>
1034
1069
  <Field name="Username" type="StringValue" />
@@ -1067,21 +1102,17 @@
1067
1102
  <To object="UserList" field="Password" />
1068
1103
  </Mapping>
1069
1104
  <Mapping field="Roles">
1070
- <To object="AllowedRoles" field="" />
1105
+ <To object="RolesByUser" field="" />
1071
1106
  </Mapping>
1072
1107
  </Mappings>
1073
1108
  </TopLevelEntity>
1074
1109
  <Detail name="UserRole">
1075
1110
  <Fields>
1076
1111
  <Field name="Rolename" type="StringValue" />
1077
- <Field name="Selected" type="BooleanValue" />
1078
1112
  </Fields>
1079
1113
  <Mappings>
1080
1114
  <Mapping field="Rolename">
1081
- <To object="AllowedRoles" field="Rolename" />
1082
- </Mapping>
1083
- <Mapping field="Selected">
1084
- <To object="AllowedRoles" field="Selected" />
1115
+ <To object="RolesByUser" field="Rolename" />
1085
1116
  </Mapping>
1086
1117
  </Mappings>
1087
1118
  </Detail>
@@ -55,7 +55,7 @@ COMMAND MAP (pick by intent)
55
55
  seed write apply [--dry-run] [FILES...] (sole mutator)
56
56
  seed check diff [FILES...] (exit 2 on drift)
57
57
  txns run [--dry-run] [FILES...]
58
- cold CI check [--tenant LOGIN] (!= config check)
58
+ cold rebuild tenant create then apply then run then diff (no wrap cmd)
59
59
  pull seed extract [--only ENTITY]... (inverse of apply)
60
60
  observe state [--diff|--assert-unchanged]
61
61
  offline inventory ARTIFACT | reconcile
@@ -66,7 +66,6 @@ EXIT CODES (common)
66
66
  0 success
67
67
  1 operational / parse / expectation failure
68
68
  2 seed drift (diff) or state moved (state --assert-unchanged)
69
- check (lifecycle) never exits 2 — drift fails the run as exit 1
70
69
 
71
70
  \b
72
71
  DEFAULT PATHS (when FILES omitted)
@@ -674,8 +673,8 @@ def config_group() -> None:
674
673
  show print resolved config as a complete .env (password redacted)
675
674
  check read-only live preflight (REST + optional SSH)
676
675
 
677
- Not the same as `acu check` (cold lifecycle rebuild). Start here on a
678
- new machine: init → edit .env → config check → tenant create / bootstrap.
676
+ Start here on a new machine: init → edit .env → config check →
677
+ tenant create / bootstrap.
679
678
  """
680
679
 
681
680
 
@@ -721,7 +720,6 @@ def config_init(host: str | None, directory: Path | None) -> None:
721
720
  output.data(" 5. acu run scenario/")
722
721
  output.data(" 6. acu diff config/")
723
722
  output.data(" 7. acu state")
724
- output.data(" 8. acu check # cold lifecycle (SSH + tenant)")
725
723
 
726
724
 
727
725
  @config_group.command("show")
@@ -751,7 +749,7 @@ def config_check(ctx: click.Context) -> None:
751
749
  """Read-only preflight of the resolved target (ok/fail/warn/skip lines).
752
750
 
753
751
  Proves connectivity before apply/run. Writes nothing (no PUT, no tenant
754
- CRUD). Not `acu check` (that is a destructive cold lifecycle rebuild).
752
+ CRUD).
755
753
 
756
754
  \b
757
755
  Probe order
@@ -875,192 +873,6 @@ def _probe_endpoints(endpoints: list[tuple[str, str]], inst: Instance) -> bool:
875
873
  return False
876
874
 
877
875
 
878
- @cli.command("check")
879
- @click.option(
880
- "--yes",
881
- is_flag=True,
882
- help="Accepted for muscle-memory (pre-clean delete is always unattended)",
883
- )
884
- @click.option(
885
- "--tenant",
886
- "tenant_login",
887
- default=None,
888
- help="Tenant login for pre-clean create (else ACU_TENANT / global --tenant)",
889
- )
890
- @click.pass_context
891
- def check_cmd(ctx: click.Context, yes: bool, tenant_login: str | None) -> None:
892
- """Cold lifecycle: delete → create → apply → run → diff (leave tenant).
893
-
894
- Destructive rebuild for CI against the resolved .env instance. Not
895
- `acu config check` (read-only preflight). Requires non-empty ACU_SSH
896
- and a tenant login (--tenant / ACU_TENANT). Pre-clean delete is
897
- always unattended; rebuilt tenant is left on the host for inspect /
898
- state. --yes is accepted for muscle-memory only.
899
-
900
- \b
901
- Flow
902
- delete tenant (if present) → create+bootstrap → apply → run → diff
903
- Diff drift fails the run as exit 1 (never exit 2 from this command).
904
-
905
- \b
906
- Examples
907
- acu check --tenant DEV
908
- acu check --yes --tenant DEV
909
-
910
- Prefer `config check` for "can I connect?" Prefer this for "rebuild
911
- green from empty."
912
- """
913
- _ = yes
914
- overrides: dict[str, str] = dict(ctx.obj or {})
915
- tenant = _resolve_check_tenant(tenant_login, overrides)
916
- try:
917
- inst = load_instance({**overrides, "tenant": tenant})
918
- except SystemExit as exc:
919
- raise SystemExit(str(exc)) from exc
920
- if not inst.ssh:
921
- raise SystemExit(
922
- "ACU_SSH not set (pass --ssh, or put ACU_SSH in .env or the environment)"
923
- )
924
- output.data(f"check {inst.base_url} (api={inst.api_version})")
925
- if _run_lifecycle(inst, tenant) != 0:
926
- raise SystemExit(1)
927
- output.success("check: green")
928
-
929
-
930
- def _resolve_check_tenant(tenant_login: str | None, overrides: dict[str, str]) -> str:
931
- """Tenant login from flag, global, env, or .env; hard-error if unset."""
932
- tenant = tenant_login or overrides.get("tenant") or os.environ.get("ACU_TENANT")
933
- if not tenant and (root := find_data_root()) is not None:
934
- tenant = read_env_values(root / ".env").get("tenant") or None
935
- if not tenant:
936
- raise SystemExit(
937
- "tenant not set (pass --tenant, "
938
- "or put ACU_TENANT in .env or the environment)"
939
- )
940
- return tenant
941
-
942
-
943
- def _run_lifecycle(inst: Instance, tenant: str) -> int:
944
- """pre-clean→create→apply→run→diff; leave tenant (V47); return 0 or 1."""
945
- mgr = TenantManager(inst)
946
- _lifecycle_delete_best_effort(mgr, tenant, label="pre-clean")
947
- try:
948
- _lifecycle_create_and_bootstrap(inst, mgr, tenant)
949
- data_inst = inst.model_copy(update={"tenant": tenant})
950
- _lifecycle_apply(data_inst)
951
- _lifecycle_run(data_inst)
952
- _lifecycle_diff(data_inst)
953
- except SystemExit as exc:
954
- code = exc.code if isinstance(exc.code, int) else 1
955
- if code == 2:
956
- code = 1 # V47: check never exits 2
957
- output.error(f"lifecycle: {exc}")
958
- # Leave tenant for manual inspect (no post-clean — V47).
959
- return 1 if code != 0 else 0
960
- except (RuntimeError, httpx.HTTPError) as exc:
961
- output.error(f"lifecycle: {_format_failure(exc, target=inst.base_url)}")
962
- # Leave tenant for manual inspect (no post-clean — V47).
963
- return 1
964
- output.success(
965
- f"lifecycle green on {inst.base_url} tenant {tenant} (left for inspection)"
966
- )
967
- return 0
968
-
969
-
970
- def _lifecycle_create_and_bootstrap(
971
- inst: Instance, mgr: TenantManager, tenant: str
972
- ) -> None:
973
- """Create (or exists-skip) tenant + init + bootstrap chain."""
974
- tenants = mgr.list()
975
- existing = next((t for t in tenants if t.login_name == tenant), None)
976
- if existing is not None:
977
- company_id = existing.company_id
978
- output.data(f"skip create: tenant {tenant} exists (id {company_id})")
979
- else:
980
- company_id = max((t.company_id for t in tenants), default=0) + 1
981
- with output.step(f"creating tenant {company_id} ({tenant}) on {inst.base_url}"):
982
- mgr.create(company_id, tenant, 1, True, "")
983
- output.data("created")
984
- with output.step(f"aligning CompanyCD to login ({tenant})"):
985
- if mgr.set_company_cd(company_id, tenant):
986
- output.data(f"CompanyCD set to {tenant}")
987
- else:
988
- output.data(f"CompanyCD already {tenant}")
989
- _init_tenant(inst, mgr, tenant)
990
- _bootstrap_tenant(inst, mgr, tenant)
991
-
992
-
993
- def _lifecycle_delete_best_effort(
994
- mgr: TenantManager, tenant: str, *, label: str
995
- ) -> None:
996
- try:
997
- tenants = mgr.list()
998
- if any(t.login_name == tenant for t in tenants):
999
- with output.step(f"{label} delete tenant {tenant}"):
1000
- mgr.delete(login_name=tenant)
1001
- with output.step(f"recycling app pool after {label}"):
1002
- mgr.recycle_app_pool()
1003
- output.data(f"{label}: deleted {tenant}")
1004
- else:
1005
- output.data(f"{label}: tenant {tenant} absent")
1006
- except (RuntimeError, SystemExit) as exc:
1007
- output.warn(f"{label}: {exc}")
1008
-
1009
-
1010
- def _lifecycle_apply(inst: Instance) -> None:
1011
- """Bare apply (pin overlay auto-compose); SystemExit 1 on failure."""
1012
- total_ok = 0
1013
- all_errors: list[str] = []
1014
- with AcumaticaClient(inst) as client:
1015
- for path in expand_files(default_apply_dirs(inst)):
1016
- baseline = seed.load_baseline(path)
1017
- output.data(
1018
- f"{path} -> {inst.tenant} on {inst.base_url} ({baseline.entity})"
1019
- )
1020
- n, errors = seed.apply(client, baseline, dry_run=False)
1021
- total_ok += n
1022
- all_errors.extend(f"{path}: {e}" for e in errors)
1023
- output.data(f" {n} record(s)")
1024
- if all_errors:
1025
- output.error(f"apply: {len(all_errors)} error(s), {total_ok} record(s) applied")
1026
- for err in all_errors:
1027
- output.error(err)
1028
- raise SystemExit(1)
1029
-
1030
-
1031
- def _lifecycle_run(inst: Instance) -> None:
1032
- """Bare run (pin overlay); SystemExit 1 on failure."""
1033
- paths = list(default_scenario_files(inst))
1034
- scenarios = [run.load_scenario(path) for path in paths]
1035
- ok = True
1036
- with AcumaticaClient(inst) as client:
1037
- for scenario in scenarios:
1038
- ok = run.run(client, scenario) and ok
1039
- if not ok:
1040
- raise SystemExit(1)
1041
- output.success(f"{len(scenarios)} scenario(s) passed on {inst.tenant}")
1042
-
1043
-
1044
- def _lifecycle_diff(inst: Instance) -> None:
1045
- """Bare diff; SystemExit 2 on drift (caller maps to 1 for check)."""
1046
- paths = expand_files(default_apply_dirs(inst))
1047
- drifts: list[str] = []
1048
- with AcumaticaClient(inst) as client:
1049
- for path in paths:
1050
- baseline = seed.load_baseline(path)
1051
- output.data(
1052
- f"{path} -> {inst.tenant} on {inst.base_url} ({baseline.entity})"
1053
- )
1054
- file_drifts = seed.diff(client, baseline)
1055
- drifts += file_drifts
1056
- n = 1 if isinstance(baseline, seed.ActionFile) else len(baseline.records)
1057
- if file_drifts:
1058
- output.data(f" {n} record(s), {len(file_drifts)} drift(s)")
1059
- else:
1060
- output.data(f" {n} record(s) ok")
1061
- _exit_on_drift(inst, drifts, len(paths))
1062
-
1063
-
1064
876
  SEED_DIRS = ("bootstrap", "baseline", "setup", "master")
1065
877
 
1066
878
 
@@ -70,6 +70,10 @@ INIT_TEMPLATES = (
70
70
  "config/baseline/60-ledger-company.yaml",
71
71
  ),
72
72
  ("config/baseline/90-uoms.yaml", "config/baseline/90-uoms.yaml"),
73
+ (
74
+ "config/baseline/91-company-packaging.yaml",
75
+ "config/baseline/91-company-packaging.yaml",
76
+ ),
73
77
  ("config/setup/10-financial-year.yaml", "config/setup/10-financial-year.yaml"),
74
78
  ("config/setup/20-master-calendar.yaml", "config/setup/20-master-calendar.yaml"),
75
79
  ("config/setup/30-open-periods.yaml", "config/setup/30-open-periods.yaml"),
@@ -128,6 +132,7 @@ INIT_TEMPLATES = (
128
132
  ),
129
133
  ("config/master/90-roles.yaml", "config/master/90-roles.yaml"),
130
134
  ("config/master/91-users.yaml", "config/master/91-users.yaml"),
135
+ ("config/master/92-role-users.yaml", "config/master/92-role-users.yaml"),
131
136
  (
132
137
  "config/views/10-trial-balance.yaml",
133
138
  "config/views/10-trial-balance.yaml",
@@ -590,6 +590,13 @@ class _Extraction:
590
590
  if not live:
591
591
  self._skip(target, "no records")
592
592
  continue
593
+ # V53: Role.Users GET may stay empty; do not emit identity-only
594
+ # 92-role-users.yaml that would drop authored AssignUser rows.
595
+ if spec.include == ["Users"] and not any(
596
+ isinstance(r.get("Users"), list) and r["Users"] for r in records
597
+ ):
598
+ self._skip(target, "Users GET empty")
599
+ continue
593
600
  self._emit(target, _render(spec, records), len(records))
594
601
  produced.add(spec.file)
595
602
  return produced
@@ -397,6 +397,10 @@ def apply(
397
397
  records in the same file. Returns ``(ok_count, error_messages)`` —
398
398
  never silent partial; caller exits 1 when any errors remain. Exit 2
399
399
  stays drift (``diff`` only).
400
+
401
+ Role.Users mapped-detail PUT is a no-op (V53/B33). After the Role PUT,
402
+ apply invokes contract action ``AssignUser`` per Username so persist
403
+ is PXDatabase UsersInRoles, not the mapped detail.
400
404
  """
401
405
  if isinstance(baseline, ActionFile):
402
406
  return _apply_action(client, baseline, dry_run)
@@ -406,14 +410,23 @@ def apply(
406
410
  label = ", ".join(str(record[k]) for k in baseline.keys)
407
411
  if dry_run:
408
412
  output.data(f" would PUT {baseline.entity} [{label}]")
409
- ok += 1
413
+ try:
414
+ _assign_role_users(client, baseline, record, dry_run=True)
415
+ ok += 1
416
+ except RuntimeError as err:
417
+ msg = f"{baseline.entity} [{label}]: {err}"
418
+ output.error(msg)
419
+ errors.append(msg)
410
420
  continue
411
421
  try:
412
422
  body = _put_body(client, baseline, record)
423
+ if baseline.entity == "Role":
424
+ body.pop("Users", None)
413
425
  _put(client, baseline.entity, body, baseline.endpoint)
414
426
  if baseline.entity == "Company":
415
427
  client.refresh_after_company()
416
428
  output.data(f" PUT {baseline.entity} [{label}]")
429
+ _assign_role_users(client, baseline, record, dry_run=False)
417
430
  ok += 1
418
431
  except RuntimeError as err:
419
432
  msg = f"{baseline.entity} [{label}]: {err}"
@@ -422,6 +435,51 @@ def apply(
422
435
  return ok, errors
423
436
 
424
437
 
438
+ def _assign_role_users(
439
+ client: AcumaticaClient,
440
+ baseline: BaselineFile,
441
+ record: dict[str, Any],
442
+ *,
443
+ dry_run: bool,
444
+ ) -> None:
445
+ """V53/B33: persist Role.Users via AssignUser, not mapped-detail PUT.
446
+
447
+ Additive-only: ``delete: true`` merge rows are skipped. Mapped GET of
448
+ Users may be empty, so extra live members are not a persist delete.
449
+ Role PUT strips ``Users`` so additive-only does not depend on B33.
450
+ """
451
+ if baseline.entity != "Role":
452
+ return
453
+ users = record.get("Users")
454
+ if not isinstance(users, list):
455
+ return
456
+ rolename = record.get("Rolename")
457
+ if not isinstance(rolename, str) or not rolename:
458
+ return
459
+ to_assign: list[str] = []
460
+ for i, row in enumerate(users):
461
+ if isinstance(row, dict) and row.get("delete") is True:
462
+ continue
463
+ if not isinstance(row, dict):
464
+ raise RuntimeError(f"Users[{i}] is not a mapping")
465
+ username = row.get("Username")
466
+ if not isinstance(username, str) or not username:
467
+ raise RuntimeError(f"Users[{i}] missing Username")
468
+ to_assign.append(username)
469
+ for username in to_assign:
470
+ if dry_run:
471
+ output.data(f" would invoke AssignUser [{rolename}/{username}]")
472
+ continue
473
+ client.invoke(
474
+ "Role",
475
+ "AssignUser",
476
+ {"Rolename": rolename},
477
+ {"Username": username},
478
+ baseline.endpoint,
479
+ )
480
+ output.data(f" invoke AssignUser [{rolename}/{username}]")
481
+
482
+
425
483
  def _put(
426
484
  client: AcumaticaClient,
427
485
  entity: str,
@@ -6,7 +6,7 @@
6
6
  # entity contract entity name (the GET source and the emitted `entity:`)
7
7
  # endpoint omitted = the instance Default endpoint; the symbolic name
8
8
  # `bootstrap` stays symbolic in emitted files and resolves to
9
- # the packaged full company Bootstrap/1.7.0 at HTTP/load time
9
+ # the packaged full company Bootstrap/1.10.0 at HTTP/load time
10
10
  # (package SoT - V20/V21/T178; data-repo project.xml forbidden)
11
11
  # keys key fields: the emitted `key:`, the record sort order, and
12
12
  # the B9 fallback's $select projection; the tuple must
@@ -44,10 +44,9 @@ entities:
44
44
 
45
45
  # config/bootstrap/ - the virgin-tenant phase via the custom Bootstrap
46
46
  # endpoint: built-in endpoints cannot create the first organization (C).
47
- # Company packaging: DecPlQty + WeightUOM/VolumeUOM on CS101500 commonsetup
48
- # (gh #34). WeightUOM/VolumeUOM required to persist CommonSetup when
49
- # DistributionModule is on. B26 dropped unmapped packaging claims; mapped
50
- # fields that GET returns stay in include.
47
+ # Company identity first (bootstrap). Packaging qty/UOMs are a second
48
+ # Company PUT after 90-uoms.yaml (see that row). StockItem WeightUOM
49
+ # /VolumeUOM stay GET-omit (B26).
51
50
 
52
51
  - entity: Company
53
52
  endpoint: bootstrap
@@ -58,9 +57,6 @@ entities:
58
57
  - OrganizationType
59
58
  - BaseCuryID
60
59
  - CountryID
61
- - DecPlQty
62
- - WeightUOM
63
- - VolumeUOM
64
60
 
65
61
  - entity: CreditTerms
66
62
  endpoint: bootstrap
@@ -167,6 +163,20 @@ entities:
167
163
  - Description
168
164
  - L3Code
169
165
 
166
+ # Company packaging (gh #34 / V52): second PUT after UOMs so KG/LITER
167
+ # exist for CommonSetup_RowPersisting when DistributionModule is on.
168
+ # Include-partition of the same live Company row (warehouse-phase idiom).
169
+ # Mapped fields GET-return stay in include.
170
+
171
+ - entity: Company
172
+ endpoint: bootstrap
173
+ keys: [AcctCD]
174
+ file: config/baseline/91-company-packaging.yaml
175
+ include:
176
+ - DecPlQty
177
+ - WeightUOM
178
+ - VolumeUOM
179
+
170
180
  # config/master/ - full packaged seed (T117). Multi-file Warehouse uses
171
181
  # include field-partition (same live warehouse row, three apply phases).
172
182
  # Multi-file StockItem uses OData filter-split on ItemClass.
@@ -522,10 +532,12 @@ entities:
522
532
  - Active
523
533
  - StockComponents
524
534
 
525
- # config/master/ Role then User (V22 prefixes; T145/T146/T148). Bootstrap
526
- # only — Default has no Role/User surface. Membership = User detail Roles
527
- # (UserRole/AllowedRoles). Package 90-roles = full pre-build CompanyID=1
528
- # Roles dump (system + module roles; live 26.x); 91-users demo soadmin.
535
+ # config/master/ Role then User then Role.Users (V22 prefixes 90/91/92;
536
+ # T145/T146/T148/T228). Bootstrap only — Default has no Role/User surface.
537
+ # Persist = Role action AssignUser (PXDatabase UsersInRoles; T228/B33).
538
+ # User detail Roles is GET/diff shape (PUT never writes UsersInRoles —
539
+ # B31/B32/B33). Package 90-roles = full pre-build CompanyID=1
540
+ # Roles dump; 91-users demo soadmin identity; 92-role-users membership.
529
541
  # Password is write-only on apply (V39/T147): include omits Password so
530
542
  # extract never emits hashes (b64__Password also out of include).
531
543
 
@@ -551,6 +563,19 @@ entities:
551
563
  - PasswordChangeOnNextLogin
552
564
  - Roles
553
565
 
566
+ # Persist membership (T228/V53): apply Role.Users invokes AssignUser.
567
+ # Filter-split from 90-roles (Descr-only identity dump). Mapped-detail
568
+ # PUT never writes UsersInRoles (B31/B32/B33).
569
+ - entity: Role
570
+ endpoint: bootstrap
571
+ keys: [Rolename]
572
+ file: config/master/92-role-users.yaml
573
+ filter: Rolename eq 'SO Admin'
574
+ detail_keys:
575
+ Users: Username
576
+ include:
577
+ - Users
578
+
554
579
  # config/setup/ - the GL action chain (T36/T37): financial-year setup,
555
580
  # then calendar generation, then period activation; numbered prefixes
556
581
  # mirror the verified apply order.
@@ -30,8 +30,7 @@ acu state
30
30
  # warm gate: once-capital only — additive buy/sell moves numeric observations
31
31
  acu run scenario/10-seed-capital.yaml && acu state --assert-unchanged
32
32
 
33
- # 7. Cold lifecycle (SSH + tenant)
34
- # acu check --yes
33
+ # SSH cold rebuild: acu tenant create --login NAME, then apply, run, diff
35
34
 
36
35
  # Optional: re-seed from live (inverse of apply; always under config/)
37
36
  # acu extract --out . --force
@@ -48,7 +47,7 @@ Bare `acu apply` / `acu diff` also prefer `config/` when those trees exist.
48
47
  | `config/bootstrap/` | Company, features, credit terms (Bootstrap contract is package SoT — never scaffolded) |
49
48
  | `config/baseline/` | GL foundation (COA, ledger, subaccounts, UOMs) |
50
49
  | `config/setup/` | Financial year, master calendar, open periods |
51
- | `config/master/` | Numbering (`05-numbering-sequences`) before module prefs; inventory, warehouse, items, vendors, customers; roles/users (`90-roles` then `91-users`) |
50
+ | `config/master/` | Numbering (`05-numbering-sequences`) before module prefs; inventory, warehouse, items, vendors, customers; roles/users (`90-roles` then `91-users` then `92-role-users`) |
52
51
  | `scenario/10-seed-capital.yaml` | Once-class owner capital JE (skip-if-present when present); Period = `${current_period}` |
53
52
  | `scenario/20-buy.yaml` | Additive component PO → receipt → bill → AP pay |
54
53
  | `scenario/30-build.yaml` | Additive kit assembly |
@@ -0,0 +1,8 @@
1
+ entity: Company
2
+ key: AcctCD
3
+ endpoint: bootstrap
4
+ records:
5
+ - AcctCD: LAB5
6
+ DecPlQty: 3
7
+ WeightUOM: KG
8
+ VolumeUOM: LITER
@@ -13,4 +13,3 @@ records:
13
13
  PasswordChangeOnNextLogin: false
14
14
  Roles:
15
15
  - Rolename: SO Admin
16
- Selected: true
@@ -0,0 +1,9 @@
1
+ entity: Role
2
+ key: Rolename
3
+ endpoint: bootstrap
4
+ detail_keys:
5
+ Users: Username
6
+ records:
7
+ - Rolename: SO Admin
8
+ Users:
9
+ - Username: soadmin
@@ -1,74 +0,0 @@
1
- // acu bootstrap CustomizationPlugin (SPEC T11).
2
- //
3
- // Runs in-process on customization publish - the one write path to
4
- // FeaturesSet that works: the contract API cannot persist features no
5
- // matter what endpoint fronts CS100000 (T3 verdict, docs/rest-api.md).
6
- //
7
- // Deliberately writes through PXDatabase, not FeaturesMaint: the graph
8
- // save collides with the publish pipeline's concurrent plugin invocations
9
- // ("Another process has added the 'FeaturesSet' record" - observed live,
10
- // 26.101.0225) and nothing persists. A keyed row write is deterministic
11
- // and idempotent: update the existing row, insert when absent. All 205
12
- // NOT NULL bit columns are filled reflectively (only ~136 carry DB
13
- // defaults); Status = 0 means Validated (PXIntList, verified vs live).
14
- //
15
- // The Enabled set is NOT authored here (SPEC V2: feature flags are config
16
- // "what", never tool source - B6): package_zip() substitutes the
17
- // ACU_FEATURES sentinel below with the data repo's bootstrap/features.yaml
18
- // list (built-in six when the file is absent) at package-build time.
19
- using System.Collections.Generic;
20
- using System.Reflection;
21
- using PX.Data;
22
- using PX.Objects.CS;
23
-
24
- namespace AcuBootstrap
25
- {
26
- public class AcuBootstrapPlugin : Customization.CustomizationPlugin
27
- {
28
- private static readonly HashSet<string> Enabled = new HashSet<string>
29
- {
30
- /*ACU_FEATURES*/
31
- };
32
-
33
- public override void UpdateDatabase()
34
- {
35
- var flags = new List<PXDataFieldAssign>();
36
- var known = new HashSet<string>();
37
- foreach (PropertyInfo prop in typeof(FeaturesSet).GetProperties())
38
- {
39
- if (prop.PropertyType == typeof(bool?))
40
- {
41
- known.Add(prop.Name);
42
- flags.Add(new PXDataFieldAssign(
43
- prop.Name, PXDbType.Bit, Enabled.Contains(prop.Name)));
44
- }
45
- }
46
- foreach (string name in Enabled)
47
- {
48
- if (!known.Contains(name))
49
- {
50
- // silent-typo guard (T24): a misspelled features.yaml
51
- // entry enables nothing - say so in the publish log
52
- WriteLog("AcuBootstrap: unknown feature name '" + name
53
- + "' - no FeaturesSet property, nothing enabled");
54
- }
55
- }
56
- flags.Add(new PXDataFieldAssign("Status", PXDbType.Int, 0)); // Validated
57
-
58
- using (var tx = new PXTransactionScope())
59
- {
60
- bool updated = PXDatabase.Update<FeaturesSet>(flags.ToArray());
61
- if (!updated)
62
- {
63
- PXDatabase.Insert<FeaturesSet>(flags.ToArray());
64
- WriteLog("AcuBootstrap: FeaturesSet row inserted (features enabled)");
65
- }
66
- else
67
- {
68
- WriteLog("AcuBootstrap: FeaturesSet row updated (features enabled)");
69
- }
70
- tx.Complete();
71
- }
72
- }
73
- }
74
- }