acumatica-cli 0.8.3__tar.gz → 0.9.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 (35) hide show
  1. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/PKG-INFO +1 -1
  2. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/pyproject.toml +1 -1
  3. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/bootstrap.py +73 -16
  4. acumatica_cli-0.9.1/src/acumatica_cli/bootstrap_project.xml +238 -0
  5. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/client.py +50 -20
  6. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/extract.py +35 -25
  7. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/extract_manifest.yaml +4 -3
  8. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/seed.py +71 -33
  9. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +4 -4
  10. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +3 -3
  11. acumatica_cli-0.9.1/src/acumatica_cli/templates/bootstrap/company.yaml +15 -0
  12. acumatica_cli-0.9.1/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +17 -0
  13. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/setup/10-financial-year.yaml +1 -1
  14. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +1 -1
  15. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/setup/30-open-periods.yaml +1 -1
  16. acumatica_cli-0.8.3/src/acumatica_cli/bootstrap_project.xml +0 -997
  17. acumatica_cli-0.8.3/src/acumatica_cli/templates/bootstrap/company.yaml +0 -14
  18. acumatica_cli-0.8.3/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -16
  19. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/README.md +0 -0
  20. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/__init__.py +0 -0
  21. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  22. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/cli.py +0 -0
  23. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/config.py +0 -0
  24. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/firstlogin.py +0 -0
  25. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/models.py +0 -0
  26. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/output.py +0 -0
  27. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/run.py +0 -0
  28. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  29. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  30. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  31. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  32. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  33. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/env +0 -0
  34. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/gitignore +0 -0
  35. {acumatica_cli-0.8.3 → acumatica_cli-0.9.1}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acumatica-cli
3
- Version: 0.8.3
3
+ Version: 0.9.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.8.3"
3
+ version = "0.9.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
  readme = "README.md"
@@ -6,10 +6,12 @@ docs/rest-api.md). The CustomizationApi is the one door that works on a
6
6
  virgin tenant, so bootstrap = publish a package whose CustomizationPlugin
7
7
  (`bootstrap_plugin.cs`) enables features on publish — the contract API
8
8
  cannot write CS100000 at all (T3 verdict) — and whose Bootstrap contract
9
- endpoint exposes CS101500 company + CS206500 credit terms + CM202000
10
- financial currency for seeding (`bootstrap_project.xml`, serialization
11
- verified T12; the Default endpoint's Currency entity is the CM201000 list
12
- only — B8).
9
+ endpoint exposes the seeding surface (serialization verified T12).
10
+
11
+ Contract ownership is hybrid (T69/V2): the data repo's
12
+ ``bootstrap/project.xml`` is preferred when present (full company surface);
13
+ else the packaged minimal ``bootstrap_project.xml`` covers config-init +
14
+ the GL chain so PyPI-only and offline virgin paths still bootstrap.
13
15
 
14
16
  Customization publishes are tenant-scoped, so the package must be published
15
17
  per tenant; publish() is idempotent on content — the skip gate compares the
@@ -38,13 +40,8 @@ from .client import AcumaticaClient
38
40
  # Alphanumeric only: CstDbStorage.ValidatePackageName rejects '-' and '_'
39
41
  # (verified vs 26.101.0225 — "Invalid project name")
40
42
  PACKAGE_NAME = "AcuBootstrap"
41
- # Authored once, in the template's root description attribute — read it
42
- # here rather than hand-syncing a second copy: the XML is digest input,
43
- # so a divergent XML-only edit would fire a spurious republish (V4).
44
- PACKAGE_DESCRIPTION = ET.fromstring(
45
- (resources.files("acumatica_cli") / "bootstrap_project.xml").read_bytes()
46
- ).get("description", "")
47
43
  PLUGIN_CLASS = "AcuBootstrapPlugin"
44
+ _ENDPOINT_NS = "{http://www.acumatica.com/entity/maintenance/5.31}"
48
45
 
49
46
  # Code default when the data repo carries no bootstrap/features.yaml
50
47
  # (SPEC I.data) — the minimum for company/branch + baseline seeding.
@@ -62,6 +59,43 @@ DEFAULT_FEATURES = (
62
59
  FEATURES_SENTINEL = "/*ACU_FEATURES*/"
63
60
 
64
61
 
62
+ def packaged_contract_xml() -> bytes:
63
+ """The CLI-shipped minimal Bootstrap contract (config-init surface)."""
64
+ return (resources.files("acumatica_cli") / "bootstrap_project.xml").read_bytes()
65
+
66
+
67
+ def load_contract_xml(root: Path | None = None) -> bytes:
68
+ """Active contract bytes: data-repo override when present, else packaged.
69
+
70
+ ``root`` is the data-repo discovery root (the dir holding ``.env``).
71
+ Absent root or absent ``bootstrap/project.xml`` falls back to the
72
+ packaged minimal contract (V2, same absence pattern as features.yaml).
73
+ """
74
+ if root is not None:
75
+ path = root / "bootstrap" / "project.xml"
76
+ if path.is_file():
77
+ return path.read_bytes()
78
+ return packaged_contract_xml()
79
+
80
+
81
+ def parse_endpoint(xml: bytes) -> tuple[str, frozenset[str]]:
82
+ """Endpoint ``Name/version`` + entity names from a contract project.xml."""
83
+ root = ET.fromstring(xml)
84
+ endpoint = root.find(f"EntityEndpoint/{_ENDPOINT_NS}Endpoint")
85
+ if endpoint is None:
86
+ raise RuntimeError("bootstrap contract: no EntityEndpoint/Endpoint item")
87
+ name = f"{endpoint.get('name')}/{endpoint.get('version')}"
88
+ entities = frozenset(
89
+ e.get("name", "") for e in endpoint.findall(f"{_ENDPOINT_NS}TopLevelEntity")
90
+ )
91
+ return name, entities
92
+
93
+
94
+ # Authored once on the packaged template's root description — kept for
95
+ # tests that pin a pre-digest stale description (V4 republish path).
96
+ PACKAGE_DESCRIPTION = ET.fromstring(packaged_contract_xml()).get("description", "")
97
+
98
+
65
99
  def load_features(root: Path) -> list[str]:
66
100
  """The FeaturesSet property names from <root>/bootstrap/features.yaml.
67
101
 
@@ -85,7 +119,12 @@ def load_features(root: Path) -> list[str]:
85
119
  return list(data)
86
120
 
87
121
 
88
- def package_zip(features: Sequence[str] | None = None) -> bytes:
122
+ def package_zip(
123
+ features: Sequence[str] | None = None,
124
+ *,
125
+ root: Path | None = None,
126
+ contract: bytes | None = None,
127
+ ) -> bytes:
89
128
  """Build the customization package: a zip holding project.xml.
90
129
 
91
130
  The C# plugin travels as a <Graph> item whose Source ATTRIBUTE holds the
@@ -96,9 +135,21 @@ def package_zip(features: Sequence[str] | None = None) -> bytes:
96
135
  ``features`` (default: the built-in six) is spliced into the plugin's
97
136
  ``Enabled`` set at the ACU_FEATURES sentinel — the one point where the
98
137
  data repo's feature list enters the package (V2).
138
+
139
+ Contract XML (V2/T69): ``contract`` when given; else
140
+ ``bootstrap/project.xml`` under ``root`` when present; else the
141
+ packaged minimal template.
99
142
  """
100
143
  pkg = resources.files("acumatica_cli")
101
- root = ET.fromstring((pkg / "bootstrap_project.xml").read_bytes())
144
+ if contract is None:
145
+ if root is None:
146
+ from .config import find_data_root
147
+
148
+ root = find_data_root()
149
+ xml = load_contract_xml(root)
150
+ else:
151
+ xml = contract
152
+ root_el = ET.fromstring(xml)
102
153
  source = (pkg / "bootstrap_plugin.cs").read_text(encoding="utf-8")
103
154
  if FEATURES_SENTINEL not in source:
104
155
  raise RuntimeError(f"bootstrap_plugin.cs: {FEATURES_SENTINEL} sentinel missing")
@@ -106,13 +157,13 @@ def package_zip(features: Sequence[str] | None = None) -> bytes:
106
157
  source = source.replace(
107
158
  FEATURES_SENTINEL, ", ".join(f'"{name}"' for name in enabled)
108
159
  )
109
- graph = ET.SubElement(root, "Graph")
160
+ graph = ET.SubElement(root_el, "Graph")
110
161
  graph.set("ClassName", PLUGIN_CLASS)
111
162
  graph.set("FileType", "NewFile")
112
163
  graph.set("Source", source)
113
164
  buf = io.BytesIO()
114
165
  with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
115
- zf.writestr("project.xml", ET.tostring(root, encoding="utf-8"))
166
+ zf.writestr("project.xml", ET.tostring(root_el, encoding="utf-8"))
116
167
  return buf.getvalue()
117
168
 
118
169
 
@@ -134,8 +185,12 @@ def package_description(zip_bytes: bytes) -> str:
134
185
  offers (verified live vs 26.101.0225): the import's projectDescription
135
186
  comes back only in the root description attribute of getProject's
136
187
  re-serialized project.xml — getPublished rows hold names alone.
188
+ Root description is read from the package's own project.xml so a
189
+ data-repo contract carries its own prose (V2).
137
190
  """
138
- return f"{PACKAGE_DESCRIPTION} [sha256:{content_digest(zip_bytes)}]"
191
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
192
+ desc = ET.fromstring(zf.read("project.xml")).get("description", "")
193
+ return f"{desc} [sha256:{content_digest(zip_bytes)}]"
139
194
 
140
195
 
141
196
  def _published_description(client: AcumaticaClient) -> str | None:
@@ -169,6 +224,8 @@ def _log_tail(status: dict[str, object], limit: int = 5) -> str:
169
224
  def publish(
170
225
  client: AcumaticaClient,
171
226
  features: Sequence[str] | None = None,
227
+ *,
228
+ root: Path | None = None,
172
229
  timeout: float = 600.0,
173
230
  poll: float = 5.0,
174
231
  ) -> str:
@@ -187,7 +244,7 @@ def publish(
187
244
  site may briefly drop connections mid-publish. Raises RuntimeError on a
188
245
  failed publish or on timeout.
189
246
  """
190
- zip_bytes = package_zip(features)
247
+ zip_bytes = package_zip(features, root=root)
191
248
  description = package_description(zip_bytes)
192
249
  if PACKAGE_NAME in client.customization_published() and (
193
250
  _published_description(client) == description
@@ -0,0 +1,238 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ acu bootstrap customization package template - minimal config-init surface
4
+ (SPEC T69 hybrid A).
5
+
6
+ package_zip() (bootstrap.py) prefers data-repo bootstrap/project.xml when
7
+ present; otherwise this packaged template is the fallback so config init,
8
+ offline tests, and PyPI-only installs still bootstrap.
9
+
10
+ package_zip() injects a
11
+ <Graph ClassName="AcuBootstrapPlugin" FileType="NewFile" Source="..."/>
12
+ item carrying bootstrap_plugin.cs - the CustomizationPlugin that enables
13
+ features on publish (CS100000 is un-writable through the contract API;
14
+ T3 verdict). Item shape verified vs 26.101.0225: Customization.CstCodeFile
15
+ Tag=Graph, source rides in the Source ATTRIBUTE (XML-escaped), not CDATA.
16
+
17
+ Root element shape verified vs the training packages shipped on the live
18
+ box (HelpAndTraining/T*/PhoneRepairShop.zip, same 26.101.0225 build):
19
+ project.xml's root is <Customization level description product-version>;
20
+ the project NAME comes from the import call, not the XML.
21
+
22
+ The <EntityEndpoint> item (T12, verified vs 26.101.0225 by import
23
+ round-trip 2026-07-08): the <Endpoint> child is the XmlSerializer form of
24
+ PX.Api.ContractBased.Common.Model.Endpoint - namespace
25
+ http://www.acumatica.com/entity/maintenance/5.31, systemContractVersion 4.
26
+
27
+ Minimal entity payload (config-init + GL chain only; full company surface
28
+ lives in the data repo's bootstrap/project.xml per T69):
29
+ - Company CS101500 -> OrganizationMaint, PrimaryView BAccount
30
+ - CreditTerms CS206500 -> TermsMaint, PrimaryView TermsDef
31
+ - LedgerCompany GL201500 -> GeneralLedgerMaint (org-ledger link, B12)
32
+ - FinancialYearSettings GL101000 + GeneratePeriods (AutoFill)
33
+ - MasterCalendar GL201000 + GenerateCalendar (GenerateYears)
34
+ - CompanyCalendar / CompanyPeriod GL201100 (done_when probes)
35
+ - ManagePeriods GL503000 + ProcessAll (period activation, B13)
36
+ - GLPreferences GL102000 (YtdNetIncAccountID + RetEarnAccountID)
37
+
38
+ Contract field names = DAC property names verbatim (no rename layer).
39
+ Features (CS100000) stays OUT of the endpoint - the plugin owns features.
40
+ -->
41
+ <Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes company + credit terms + GL preferences + ledger-company link + financial year + calendars + period activation (config-init surface; full company contract lives in data-repo bootstrap/project.xml)" product-version="26.101">
42
+ <EntityEndpoint>
43
+ <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.9.0" systemContractVersion="4">
44
+ <TopLevelEntity name="Company" screen="CS101500">
45
+ <Fields>
46
+ <Field name="AcctCD" type="StringValue" />
47
+ <Field name="AcctName" type="StringValue" />
48
+ <Field name="OrganizationType" type="StringValue" />
49
+ <Field name="BaseCuryID" type="StringValue" />
50
+ <Field name="CountryID" type="StringValue" />
51
+ </Fields>
52
+ <Mappings>
53
+ <Mapping field="AcctCD">
54
+ <To object="BAccount" field="AcctCD" />
55
+ </Mapping>
56
+ <Mapping field="AcctName">
57
+ <To object="BAccount" field="AcctName" />
58
+ </Mapping>
59
+ <Mapping field="OrganizationType">
60
+ <To object="OrganizationView" field="OrganizationType" />
61
+ </Mapping>
62
+ <Mapping field="BaseCuryID">
63
+ <To object="OrganizationView" field="BaseCuryID" />
64
+ </Mapping>
65
+ <Mapping field="CountryID">
66
+ <To object="AddressDummy" field="CountryID" />
67
+ </Mapping>
68
+ </Mappings>
69
+ </TopLevelEntity>
70
+ <TopLevelEntity name="CreditTerms" screen="CS206500">
71
+ <Fields>
72
+ <Field name="TermsID" type="StringValue" />
73
+ <Field name="Descr" type="StringValue" />
74
+ <Field name="VisibleTo" type="StringValue" />
75
+ <Field name="DueType" type="StringValue" />
76
+ <Field name="DayDue00" type="ShortValue" />
77
+ <Field name="DiscType" type="StringValue" />
78
+ <Field name="DiscPercent" type="DecimalValue" />
79
+ </Fields>
80
+ <Mappings>
81
+ <Mapping field="TermsID">
82
+ <To object="TermsDef" field="TermsID" />
83
+ </Mapping>
84
+ <Mapping field="Descr">
85
+ <To object="TermsDef" field="Descr" />
86
+ </Mapping>
87
+ <Mapping field="VisibleTo">
88
+ <To object="TermsDef" field="VisibleTo" />
89
+ </Mapping>
90
+ <Mapping field="DueType">
91
+ <To object="TermsDef" field="DueType" />
92
+ </Mapping>
93
+ <Mapping field="DayDue00">
94
+ <To object="TermsDef" field="DayDue00" />
95
+ </Mapping>
96
+ <Mapping field="DiscType">
97
+ <To object="TermsDef" field="DiscType" />
98
+ </Mapping>
99
+ <Mapping field="DiscPercent">
100
+ <To object="TermsDef" field="DiscPercent" />
101
+ </Mapping>
102
+ </Mappings>
103
+ </TopLevelEntity>
104
+ <TopLevelEntity name="LedgerCompany" screen="GL201500">
105
+ <Fields>
106
+ <Field name="LedgerCD" type="StringValue" />
107
+ <Field name="OrganizationID" type="StringValue" />
108
+ </Fields>
109
+ <Mappings>
110
+ <Mapping field="LedgerCD">
111
+ <To object="LedgerRecords" field="LedgerCD" />
112
+ </Mapping>
113
+ <Mapping field="OrganizationID">
114
+ <To object="OrganizationLedgerLinkWithOrganizationSelect" field="OrganizationID" />
115
+ </Mapping>
116
+ </Mappings>
117
+ </TopLevelEntity>
118
+ <TopLevelEntity name="FinancialYearSettings" screen="GL101000">
119
+ <Fields>
120
+ <Field name="BegFinYear" type="DateTimeValue" />
121
+ <Field name="FinPeriods" type="ShortValue" />
122
+ <Field name="PeriodType" type="StringValue" />
123
+ </Fields>
124
+ <Mappings>
125
+ <Mapping field="BegFinYear">
126
+ <To object="FiscalYearSetup" field="BegFinYear" />
127
+ </Mapping>
128
+ <Mapping field="FinPeriods">
129
+ <To object="FiscalYearSetup" field="FinPeriods" />
130
+ </Mapping>
131
+ <Mapping field="PeriodType">
132
+ <To object="FiscalYearSetup" field="PeriodType" />
133
+ </Mapping>
134
+ </Mappings>
135
+ <Actions>
136
+ <Action name="GeneratePeriods" mappedTo="AutoFill" isDefault="false" />
137
+ </Actions>
138
+ </TopLevelEntity>
139
+ <TopLevelEntity name="MasterCalendar" screen="GL201000">
140
+ <Fields>
141
+ <Field name="FinancialYear" type="StringValue" />
142
+ </Fields>
143
+ <Mappings>
144
+ <Mapping field="FinancialYear">
145
+ <To object="FiscalYear" field="Year" />
146
+ </Mapping>
147
+ </Mappings>
148
+ <Actions>
149
+ <Action name="GenerateCalendar" mappedTo="GenerateYears" isDefault="false">
150
+ <Fields>
151
+ <Field name="FromYear" type="StringValue" />
152
+ <Field name="ToYear" type="StringValue" />
153
+ </Fields>
154
+ <Mappings>
155
+ <Mapping field="FromYear">
156
+ <To object="GenerateParams" field="FromYear" />
157
+ </Mapping>
158
+ <Mapping field="ToYear">
159
+ <To object="GenerateParams" field="ToYear" />
160
+ </Mapping>
161
+ </Mappings>
162
+ </Action>
163
+ </Actions>
164
+ </TopLevelEntity>
165
+ <TopLevelEntity name="CompanyCalendar" screen="GL201100">
166
+ <Fields>
167
+ <Field name="FinancialYear" type="StringValue" />
168
+ <Field name="OrganizationID" type="StringValue" />
169
+ </Fields>
170
+ <Mappings>
171
+ <Mapping field="FinancialYear">
172
+ <To object="OrgFinYear" field="Year" />
173
+ </Mapping>
174
+ <Mapping field="OrganizationID">
175
+ <To object="OrgFinYear" field="OrganizationID" />
176
+ </Mapping>
177
+ </Mappings>
178
+ </TopLevelEntity>
179
+ <TopLevelEntity name="CompanyPeriod" screen="GL201100">
180
+ <Fields>
181
+ <Field name="FinancialYear" type="StringValue" />
182
+ <Field name="FinPeriodID" type="StringValue" />
183
+ <Field name="Status" type="StringValue" />
184
+ </Fields>
185
+ <Mappings>
186
+ <Mapping field="FinancialYear">
187
+ <To object="OrgFinPeriods" field="FinYear" />
188
+ </Mapping>
189
+ <Mapping field="FinPeriodID">
190
+ <To object="OrgFinPeriods" field="FinPeriodID" />
191
+ </Mapping>
192
+ <Mapping field="Status">
193
+ <To object="OrgFinPeriods" field="Status" />
194
+ </Mapping>
195
+ </Mappings>
196
+ </TopLevelEntity>
197
+ <TopLevelEntity name="ManagePeriods" screen="GL503000">
198
+ <Fields>
199
+ <Field name="Action" type="StringValue" />
200
+ <Field name="FromYear" type="StringValue" />
201
+ <Field name="ToYear" type="StringValue" />
202
+ <Field name="OrganizationID" type="StringValue" />
203
+ </Fields>
204
+ <Mappings>
205
+ <Mapping field="Action">
206
+ <To object="Filter" field="Action" />
207
+ </Mapping>
208
+ <Mapping field="FromYear">
209
+ <To object="Filter" field="FromYear" />
210
+ </Mapping>
211
+ <Mapping field="ToYear">
212
+ <To object="Filter" field="ToYear" />
213
+ </Mapping>
214
+ <Mapping field="OrganizationID">
215
+ <To object="Filter" field="OrganizationID" />
216
+ </Mapping>
217
+ </Mappings>
218
+ <Actions>
219
+ <Action name="ProcessAll" mappedTo="ProcessAll" isDefault="false" />
220
+ </Actions>
221
+ </TopLevelEntity>
222
+ <TopLevelEntity name="GLPreferences" screen="GL102000">
223
+ <Fields>
224
+ <Field name="YtdNetIncAccountID" type="StringValue" />
225
+ <Field name="RetEarnAccountID" type="StringValue" />
226
+ </Fields>
227
+ <Mappings>
228
+ <Mapping field="YtdNetIncAccountID">
229
+ <To object="GLSetupRecord" field="YtdNetIncAccountID" />
230
+ </Mapping>
231
+ <Mapping field="RetEarnAccountID">
232
+ <To object="GLSetupRecord" field="RetEarnAccountID" />
233
+ </Mapping>
234
+ </Mappings>
235
+ </TopLevelEntity>
236
+ </Endpoint>
237
+ </EntityEndpoint>
238
+ </Customization>
@@ -93,6 +93,9 @@ class AcumaticaClient:
93
93
  self._http = httpx.Client(
94
94
  base_url=instance.base_url, timeout=timeout, transport=transport
95
95
  )
96
+ # V5/B24: first successful Company PUT this session → one re-login
97
+ # so later branch selectors resolve; warm re-PUT is re-login-safe
98
+ self._refreshed_after_company = False
96
99
 
97
100
  def __enter__(self) -> AcumaticaClient:
98
101
  # tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is
@@ -105,27 +108,10 @@ class AcumaticaClient:
105
108
  "an explicit tenant silently lands on the default tenant; "
106
109
  "set ACU_TENANT in .env or pass --tenant"
107
110
  )
108
- creds: dict[str, str] = {
109
- "name": self.instance.user,
110
- "password": self.instance.password,
111
- "tenant": self.instance.tenant,
112
- }
113
- self._checked(self._http.post("/entity/auth/login", json=creds))
114
- # tenant guard, landed side (V5, B5): login accepting the name proves
115
- # nothing - a stale tenant map reroutes named logins to the default
116
- # tenant, and a single-tenant instance accepts ANY name (both verified
117
- # live, docs/rest-api.md). Verify where the session actually landed
118
- # and refuse on mismatch; logout first, since __exit__ never runs
119
- # when __enter__ raises (V6 - sessions count against the license).
111
+ # logout first on failure: __exit__ never runs when __enter__ raises
112
+ # (V6 - sessions count against the license)
120
113
  try:
121
- landed = self._landed_tenant()
122
- if landed.casefold() != self.instance.tenant.casefold():
123
- raise RuntimeError(
124
- f"tenant guard: asked for tenant {self.instance.tenant!r} "
125
- f"but the session landed on {landed!r} - the instance "
126
- "tenant map is stale or the tenant does not exist; check "
127
- "acu tenant list and recycle the app pool"
128
- )
114
+ self._login()
129
115
  except BaseException:
130
116
  self.__exit__()
131
117
  raise
@@ -138,6 +124,50 @@ class AcumaticaClient:
138
124
  finally:
139
125
  self._http.close()
140
126
 
127
+ def _login(self) -> None:
128
+ """POST login + refuse on landed-tenant mismatch (V5, B5).
129
+
130
+ Login accepting the name proves nothing - a stale tenant map
131
+ reroutes named logins to the default tenant, and a single-tenant
132
+ instance accepts ANY name (both verified live, docs/rest-api.md).
133
+ """
134
+ creds: dict[str, str] = {
135
+ "name": self.instance.user,
136
+ "password": self.instance.password,
137
+ "tenant": self.instance.tenant,
138
+ }
139
+ self._checked(self._http.post("/entity/auth/login", json=creds))
140
+ landed = self._landed_tenant()
141
+ if landed.casefold() != self.instance.tenant.casefold():
142
+ raise RuntimeError(
143
+ f"tenant guard: asked for tenant {self.instance.tenant!r} "
144
+ f"but the session landed on {landed!r} - the instance "
145
+ "tenant map is stale or the tenant does not exist; check "
146
+ "acu tenant list and recycle the app pool"
147
+ )
148
+
149
+ def relogin(self) -> None:
150
+ """Drop the cookie session and open a new one without closing transport.
151
+
152
+ Mid-session Company create leaves branch selectors empty until a
153
+ fresh login (V5, B24). Unlike ``__exit__``, the httpx client stays
154
+ open so the outer context manager still owns the session lifecycle.
155
+ """
156
+ # empty body → Content-Length: 0 (V6 / IIS 411)
157
+ self._http.post("/entity/auth/logout", content=b"")
158
+ self._login()
159
+
160
+ def refresh_after_company(self) -> None:
161
+ """Re-login once after the first successful Company PUT this session.
162
+
163
+ Warm-tenant Company re-PUT is re-login-safe (V5); further Company
164
+ records in the same run skip the second bounce.
165
+ """
166
+ if self._refreshed_after_company:
167
+ return
168
+ self.relogin()
169
+ self._refreshed_after_company = True
170
+
141
171
  def _landed_tenant(self) -> str:
142
172
  """The tenant this session actually landed on (login name).
143
173