acumatica-cli 0.31.0__tar.gz → 0.33.1__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 (77) hide show
  1. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/PKG-INFO +4 -4
  2. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/README.md +3 -3
  3. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/pyproject.toml +1 -1
  4. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/pyproject.toml.orig +1 -1
  5. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/client.py +25 -15
  6. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/extract.py +28 -3
  7. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/seed.py +62 -7
  8. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/seed_catalog.yaml +4 -3
  9. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/__init__.py +0 -0
  10. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/bootstrap.py +0 -0
  11. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  12. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/bootstrap_project.xml +0 -0
  13. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/cli.py +0 -0
  14. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/config.py +0 -0
  15. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/firstlogin.py +0 -0
  16. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/inventory.py +0 -0
  17. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/models.py +0 -0
  18. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/output.py +0 -0
  19. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/reconcile.py +0 -0
  20. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/run.py +0 -0
  21. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/snapshot_map.yaml +0 -0
  22. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/state.py +0 -0
  23. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/README.md +0 -0
  24. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  25. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  26. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  27. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  28. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  29. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  30. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/91-company-packaging.yaml +0 -0
  31. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  32. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  33. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  34. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/segmented-key.yaml +0 -0
  35. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml +0 -0
  36. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  37. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  38. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  39. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  40. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  41. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  42. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  43. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  44. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  45. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  46. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  47. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  48. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  49. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  50. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  51. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  52. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  53. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  54. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  55. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  56. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  57. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  58. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
  59. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
  60. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  61. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/90-roles.yaml +0 -0
  62. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/91-users.yaml +0 -0
  63. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/92-role-users.yaml +0 -0
  64. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  65. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  66. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  67. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  68. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/env +0 -0
  69. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/gitignore +0 -0
  70. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/overlays/README.md +0 -0
  71. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/overlays/default-24.200.001/README.md +0 -0
  72. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/overlays/default-24.200.001/scenario/30-build.yaml +0 -0
  73. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  74. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  75. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  76. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  77. {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/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.31.0
3
+ Version: 0.33.1
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>
@@ -257,10 +257,10 @@ Or clone and install editable for development:
257
257
  ```sh
258
258
  git clone https://github.com/kborovik/acumatica-cli.git
259
259
  cd acumatica-cli
260
- gmake install # editable install as a global uv tool
260
+ gmake install # uv sync; does not replace the PyPI tool
261
261
  ```
262
262
 
263
- Verify with `acu --version`.
263
+ Verify with `uv run acu --version` (`+dev`).
264
264
 
265
265
  ## Configuration
266
266
 
@@ -351,7 +351,7 @@ Elsewhere plain `make` is fine when it is GNU Make.
351
351
  ```sh
352
352
  git clone https://github.com/kborovik/acumatica-cli.git
353
353
  cd acumatica-cli
354
- gmake install # editable install as a global uv tool
354
+ gmake install # uv sync; does not replace the PyPI tool
355
355
  gmake check # offline gate: ruff, basedpyright strict, pytest
356
356
  ```
357
357
 
@@ -239,10 +239,10 @@ Or clone and install editable for development:
239
239
  ```sh
240
240
  git clone https://github.com/kborovik/acumatica-cli.git
241
241
  cd acumatica-cli
242
- gmake install # editable install as a global uv tool
242
+ gmake install # uv sync; does not replace the PyPI tool
243
243
  ```
244
244
 
245
- Verify with `acu --version`.
245
+ Verify with `uv run acu --version` (`+dev`).
246
246
 
247
247
  ## Configuration
248
248
 
@@ -333,7 +333,7 @@ Elsewhere plain `make` is fine when it is GNU Make.
333
333
  ```sh
334
334
  git clone https://github.com/kborovik/acumatica-cli.git
335
335
  cd acumatica-cli
336
- gmake install # editable install as a global uv tool
336
+ gmake install # uv sync; does not replace the PyPI tool
337
337
  gmake check # offline gate: ruff, basedpyright strict, pytest
338
338
  ```
339
339
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.31.0"
3
+ version = "0.33.1"
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.31.0"
3
+ version = "0.33.1"
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,6 +12,11 @@ import httpx
12
12
 
13
13
  from .config import Instance
14
14
 
15
+ # Contract control fields travel bare on wrap and stay on unwrap of
16
+ # value-field rows (V54). Files-style rows that unwrap to only these
17
+ # keys are elided so expanded `files` descriptors stay noise.
18
+ _BARE_FIELDS = frozenset({"id", "delete"})
19
+
15
20
 
16
21
  def wrap(record: dict[str, Any]) -> dict[str, Any]:
17
22
  """Plain dict -> contract-API body: {"Field": {"value": ...}}.
@@ -33,31 +38,35 @@ def wrap(record: dict[str, Any]) -> dict[str, Any]:
33
38
  return wrap(v)
34
39
  return {"value": v}
35
40
 
36
- return {k: v if k in ("id", "delete") else _value(v) for k, v in record.items()}
41
+ return {k: v if k in _BARE_FIELDS else _value(v) for k, v in record.items()}
37
42
 
38
43
 
39
44
  def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
40
45
  """Contract-API entity -> plain dict (value fields + detail arrays).
41
46
 
42
- Inverse of wrap (T60/T65): a list of dicts unwraps row by row; lists
43
- that are empty or whose rows carry no value fields are elided (a
44
- detail array the source never claimed must not surface, and expanded
45
- `files` descriptors are plain dicts that would unwrap to noise). A
46
- non-value dict is a linked entity — unwrapped recursively, kept only
47
- when something survives, so bookkeeping dicts (custom, _links, note)
48
- stay excluded exactly as before.
47
+ Inverse of wrap (T60/T65/V54): a list of dicts unwraps row by row;
48
+ lists that are empty or whose rows carry no value fields are elided
49
+ (a detail array the source never claimed must not surface, and
50
+ expanded `files` descriptors are plain dicts that would unwrap to
51
+ noise). A non-value dict is a linked entity — unwrapped recursively,
52
+ kept only when something survives, so bookkeeping dicts (custom,
53
+ _links, note) stay excluded exactly as before. `id` and `delete`
54
+ stay on records and on detail rows that also have value fields so a
55
+ later PUT updates the existing line (V54).
49
56
  """
50
57
  out: dict[str, Any] = {}
51
58
  for k, v in entity.items():
52
- if isinstance(v, dict) and "value" in v:
59
+ if k in _BARE_FIELDS and not isinstance(v, (dict, list)):
60
+ out[k] = v
61
+ elif isinstance(v, dict) and "value" in v:
53
62
  out[k] = v["value"]
54
63
  elif isinstance(v, dict):
55
64
  nested = unwrap(v)
56
65
  if nested:
57
66
  out[k] = nested
58
67
  elif isinstance(v, list) and v and all(isinstance(row, dict) for row in v):
59
- rows = [unwrap(row) for row in v]
60
- if any(rows):
68
+ rows = [row for row in (unwrap(r) for r in v) if set(row) - _BARE_FIELDS]
69
+ if rows:
61
70
  out[k] = rows
62
71
  return out
63
72
 
@@ -326,7 +335,7 @@ class AcumaticaClient:
326
335
 
327
336
  @staticmethod
328
337
  def _checked(r: httpx.Response) -> httpx.Response:
329
- """Surface exceptionMessage + nested Field.error (V46), not status alone."""
338
+ """Surface exceptionMessage + innerException + Field.error (V46)."""
330
339
  if r.is_error:
331
340
  detail = ""
332
341
  try:
@@ -338,12 +347,13 @@ class AcumaticaClient:
338
347
  or body.get("error")
339
348
  or ""
340
349
  )
341
- # nested contract-API validation often rides here
342
- if not detail and isinstance(body.get("innerException"), dict):
350
+ if isinstance(body.get("innerException"), dict):
343
351
  inner = body["innerException"]
344
- detail = (
352
+ inner_msg = (
345
353
  inner.get("exceptionMessage") or inner.get("message") or ""
346
354
  )
355
+ if inner_msg:
356
+ detail = f"{detail}; {inner_msg}" if detail else inner_msg
347
357
  field_errs = AcumaticaClient._field_errors(body)
348
358
  if field_errs:
349
359
  joined = "; ".join(field_errs)
@@ -46,6 +46,8 @@ from .models import Model, validation_summary
46
46
  from .seed import (
47
47
  NUMBERING_RUNTIME_FIELDS,
48
48
  PASSWORD_FIELDS,
49
+ _calendar_day, # pyright: ignore[reportPrivateUsage]
50
+ _drop_path_ignored, # pyright: ignore[reportPrivateUsage]
49
51
  active_bootstrap,
50
52
  resolve_endpoint,
51
53
  )
@@ -263,14 +265,16 @@ def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
263
265
 
264
266
  # Server-assigned / audit fields that must never enter seed (B11 class).
265
267
  # Applied recursively under linked entities and detail rows after expand
266
- # (T65/T119): MainContact.ContactID and AllowedCashAccounts.LastModified*
267
- # would otherwise permanent-red-diff after re-apply on a fresh tenant.
268
+ # (T65/T119). `id`/`delete` stay in unwrap for run-capture PUT (V54);
269
+ # extract still strips them so seed is tenant-portable.
268
270
  _SERVER_DERIVED = frozenset(
269
271
  {
270
272
  "ContactID",
271
273
  "CreatedDateTime",
272
274
  "LastModifiedDateTime",
273
275
  "NoteID",
276
+ "delete",
277
+ "id",
274
278
  "tstamp",
275
279
  }
276
280
  )
@@ -293,6 +297,26 @@ def _elide_server_derived(value: Any) -> Any:
293
297
  return value
294
298
 
295
299
 
300
+ def _date_strip(value: Any) -> Any:
301
+ """DateTimeValue seed emit: keep YYYY-MM-DD prefix only (V55)."""
302
+ if isinstance(value, dict):
303
+ return {k: _date_strip(v) for k, v in value.items()}
304
+ if isinstance(value, list):
305
+ return [_date_strip(v) for v in value]
306
+ if isinstance(value, str):
307
+ day = _calendar_day(value)
308
+ if day is not None:
309
+ return day
310
+ return value
311
+
312
+
313
+ def _strip_write_only_get_omit(entity: str, field: str, value: Any) -> Any:
314
+ """Drop path-qualified write-only GET-omit fields from detail rows (V56)."""
315
+ if not isinstance(value, list):
316
+ return value
317
+ return _drop_path_ignored(entity, field, value)
318
+
319
+
296
320
  def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
297
321
  """Apply include/strip + nested server-derived elision to one unwrapped row."""
298
322
  keep: dict[str, Any] = {}
@@ -313,6 +337,7 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
313
337
  continue
314
338
  # Nested ContactID / LastModifiedDateTime under expand (T65/T119).
315
339
  cleaned = _elide_server_derived(value)
340
+ cleaned = _strip_write_only_get_omit(spec.entity, field, cleaned)
316
341
  if cleaned is None or cleaned == "":
317
342
  continue
318
343
  if isinstance(cleaned, (dict, list)) and not cleaned:
@@ -320,7 +345,7 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
320
345
  keep[field] = cleaned
321
346
  ordered = {k: keep[k] for k in spec.keys if k in keep}
322
347
  ordered |= {k: keep[k] for k in sorted(keep.keys() - set(spec.keys))}
323
- return ordered
348
+ return _date_strip(ordered)
324
349
 
325
350
 
326
351
  def _shape(spec: EntitySpec, live: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -52,6 +52,7 @@ Default Currency = CM201000 list), so an ambiguous file is a hard error,
52
52
  never a silent Default-endpoint PUT.
53
53
  """
54
54
 
55
+ import re
55
56
  from pathlib import Path
56
57
  from typing import Any
57
58
 
@@ -88,7 +89,40 @@ PASSWORD_FIELDS = frozenset({"Password", "b64__Password"})
88
89
  # PUTs them (re-apply must not reset live counters).
89
90
  NUMBERING_RUNTIME_FIELDS = frozenset({"LastNbr"})
90
91
  # Fields omitted from source↔live compare (V39 write-only + V40 runtime).
92
+ # Field-name deny at every nesting level. Do not add bare ``Value`` here —
93
+ # that would hide real drift on unrelated fields (V56).
91
94
  _DIFF_IGNORE_FIELDS = PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS
95
+ # Path-qualified write-only GET-omit (V56): (entity, detail, field).
96
+ # Diff ignores; extract strips; apply still PUTs when present in YAML.
97
+ _DIFF_IGNORE_PATHS = frozenset({("LotSerialClass", "Segments", "Value")})
98
+ # Whole-detail skip (V57/B37): mapped GET Role.Users / User.Roles may be
99
+ # empty while UsersInRoles has session-company rows. Extra live members
100
+ # are not drift (AssignUser is additive-only).
101
+ _DIFF_SKIP_DETAILS = frozenset({("Role", "Users"), ("User", "Roles")})
102
+
103
+
104
+ def _diff_ignored(entity: str, *path: str) -> bool:
105
+ """True when source↔live compare skips this field (V39/V40/V56/V57)."""
106
+ if path and path[-1] in _DIFF_IGNORE_FIELDS:
107
+ return True
108
+ if len(path) == 1 and (entity, path[0]) in _DIFF_SKIP_DETAILS:
109
+ return True
110
+ return (entity, *path) in _DIFF_IGNORE_PATHS
111
+
112
+
113
+ def _drop_path_ignored(entity: str, field: str, rows: list[Any]) -> list[Any]:
114
+ """Strip path-qualified GET-omit fields from detail rows (V56)."""
115
+ drop = {
116
+ sub for (ent, det, sub) in _DIFF_IGNORE_PATHS if ent == entity and det == field
117
+ }
118
+ if not drop:
119
+ return rows
120
+ return [
121
+ {k: v for k, v in row.items() if k not in drop}
122
+ if isinstance(row, dict)
123
+ else row
124
+ for row in rows
125
+ ]
92
126
 
93
127
 
94
128
  def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
@@ -251,18 +285,33 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
251
285
  return parsed
252
286
 
253
287
 
288
+ # ISO date or DateTimeValue: calendar-day prefix only (V55). Random strings
289
+ # that merely contain T stay untouched.
290
+ _ISO_CALENDAR_DAY = re.compile(r"(\d{4}-\d{2}-\d{2})(T.*)?")
291
+
292
+
293
+ def _calendar_day(text: str) -> str | None:
294
+ """YYYY-MM-DD when ``text`` is ISO date/datetime; else None. No TZ convert."""
295
+ m = _ISO_CALENDAR_DAY.fullmatch(text)
296
+ return None if m is None else m.group(1)
297
+
298
+
254
299
  def _norm(value: Any) -> str:
255
- """Comparable form: bools case-folded, numbers by value, rest stringified.
300
+ """Comparable form: bools case-folded, numbers by value, dates by day.
256
301
 
257
302
  Numbers compare by value, not spelling - a YAML `0` against the
258
303
  endpoint's `0.0` (DecimalValue fields come back as floats) is not
259
- drift (T13).
304
+ drift (T13). Date-only ``YYYY-MM-DD`` matches a live DateTimeValue on
305
+ the same calendar day (strip ``T...offset``); a different day still
306
+ drifts (V55). No timezone conversion.
260
307
  """
261
308
  if isinstance(value, bool):
262
309
  return str(value).lower()
263
310
  if isinstance(value, int | float):
264
311
  return repr(float(value))
265
- return str(value).strip()
312
+ text = str(value).strip()
313
+ day = _calendar_day(text)
314
+ return text if day is None else day
266
315
 
267
316
 
268
317
  def _filter_for(record: dict[str, Any], keys: list[str]) -> str:
@@ -636,13 +685,19 @@ def diff(client: AcumaticaClient, baseline: BaselineFile | ActionFile) -> list[s
636
685
  drifts.append(f"{label}: missing on tenant")
637
686
  continue
638
687
  actual = unwrap(live)
639
- # V39/V40: never compare write-only password or runtime numbering counters
640
- fields = {k: v for k, v in record.items() if k not in _DIFF_IGNORE_FIELDS}
688
+ # V39/V40/V56/V57: never compare write-only, runtime, GET-omit,
689
+ # or membership details whose mapped GET may stay empty.
690
+ fields = {
691
+ k: v for k, v in record.items() if not _diff_ignored(baseline.entity, k)
692
+ }
641
693
  for field, expected in fields.items():
642
694
  if isinstance(expected, list):
643
695
  key = (baseline.detail_keys or {})[field] # load-validated
644
- live_rows = actual.get(field, [])
645
- drifts.extend(_diff_details(label, field, key, expected, live_rows))
696
+ want_rows = _drop_path_ignored(baseline.entity, field, expected)
697
+ live_rows = _drop_path_ignored(
698
+ baseline.entity, field, actual.get(field, [])
699
+ )
700
+ drifts.extend(_diff_details(label, field, key, want_rows, live_rows))
646
701
  elif isinstance(expected, dict):
647
702
  drifts.extend(
648
703
  _diff_nested(f"{label}.{field}", expected, actual.get(field))
@@ -535,9 +535,10 @@ entities:
535
535
  # config/master/ Role then User then Role.Users (V22 prefixes 90/91/92;
536
536
  # T145/T146/T148/T228). Bootstrap only — Default has no Role/User surface.
537
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.
538
+ # User detail Roles = GET/extract; diff skips Users/Roles (V57).
539
+ # PUT never writes UsersInRoles (B31/B32/B33).
540
+ # Package 90-roles = full pre-build CompanyID=1 Roles dump;
541
+ # 91-users demo soadmin identity; 92-role-users membership.
541
542
  # Password is write-only on apply (V39/T147): include omits Password so
542
543
  # extract never emits hashes (b64__Password also out of include).
543
544