acumatica-cli 0.8.3__tar.gz → 0.9.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.
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/PKG-INFO +1 -1
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/pyproject.toml +1 -1
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/bootstrap.py +73 -16
- acumatica_cli-0.9.0/src/acumatica_cli/bootstrap_project.xml +238 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/extract.py +35 -25
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/extract_manifest.yaml +4 -3
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/seed.py +43 -32
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +4 -4
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +3 -3
- acumatica_cli-0.9.0/src/acumatica_cli/templates/bootstrap/company.yaml +15 -0
- acumatica_cli-0.9.0/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +17 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +1 -1
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +1 -1
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +1 -1
- acumatica_cli-0.8.3/src/acumatica_cli/bootstrap_project.xml +0 -997
- acumatica_cli-0.8.3/src/acumatica_cli/templates/bootstrap/company.yaml +0 -14
- acumatica_cli-0.8.3/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -16
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/README.md +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/cli.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/client.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/config.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/run.py +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/env +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/templates/gitignore +0 -0
- {acumatica_cli-0.8.3 → acumatica_cli-0.9.0}/src/acumatica_cli/tenant.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acumatica-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.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
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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>
|
|
@@ -40,10 +40,11 @@ from .client import (
|
|
|
40
40
|
unwrap,
|
|
41
41
|
)
|
|
42
42
|
from .models import Model, validation_summary
|
|
43
|
-
from .seed import
|
|
43
|
+
from .seed import active_bootstrap, resolve_endpoint
|
|
44
44
|
|
|
45
45
|
# The one non-manifest destination: the feature-closure file (V22/B15).
|
|
46
46
|
FEATURES_FILE = "bootstrap/features.yaml"
|
|
47
|
+
_SYMBOLIC_BOOTSTRAP = "bootstrap"
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
class EntitySpec(Model):
|
|
@@ -52,19 +53,15 @@ class EntitySpec(Model):
|
|
|
52
53
|
entity: str
|
|
53
54
|
keys: list[str] = Field(min_length=1)
|
|
54
55
|
file: str
|
|
56
|
+
# Symbolic `bootstrap` stays symbolic until HTTP time (V20/V21): the
|
|
57
|
+
# emitted file carries the symbol so a version bump never rewrites
|
|
58
|
+
# extract output; resolve_endpoint maps it to Bootstrap/<ver>.
|
|
55
59
|
endpoint: str | None = None
|
|
56
60
|
filter: str | None = None
|
|
57
61
|
strip: list[str] = Field(default_factory=list)
|
|
58
62
|
include: list[str] = Field(default_factory=list)
|
|
59
63
|
features: list[str] = Field(default_factory=list)
|
|
60
64
|
|
|
61
|
-
@field_validator("endpoint")
|
|
62
|
-
@classmethod
|
|
63
|
-
def _resolve_symbolic(cls, v: str | None) -> str | None:
|
|
64
|
-
# `bootstrap` resolves to the packaged endpoint at load, so the
|
|
65
|
-
# manifest never carries a literal version to go stale (V21)
|
|
66
|
-
return BOOTSTRAP_ENDPOINT if v == "bootstrap" else v
|
|
67
|
-
|
|
68
65
|
@model_validator(mode="after")
|
|
69
66
|
def _strip_include_exclusive(self) -> EntitySpec:
|
|
70
67
|
if self.strip and self.include:
|
|
@@ -97,13 +94,15 @@ class Manifest(Model):
|
|
|
97
94
|
|
|
98
95
|
@model_validator(mode="after")
|
|
99
96
|
def _self_consistent(self) -> Manifest:
|
|
100
|
-
# V20 by construction: an emitted file for an entity
|
|
101
|
-
#
|
|
97
|
+
# V20 by construction: an emitted file for an entity the active
|
|
98
|
+
# Bootstrap contract serves must carry an endpoint (literal or
|
|
99
|
+
# symbolic bootstrap), or load_baseline rejects it
|
|
100
|
+
_name, entities = active_bootstrap()
|
|
102
101
|
for spec in self.entities:
|
|
103
|
-
if spec.entity in
|
|
102
|
+
if spec.entity in entities and spec.endpoint is None:
|
|
104
103
|
raise ValueError(
|
|
105
|
-
f"entity '{spec.entity}' is served by the
|
|
106
|
-
f"{
|
|
104
|
+
f"entity '{spec.entity}' is served by the active "
|
|
105
|
+
f"{_name} and must carry an endpoint"
|
|
107
106
|
)
|
|
108
107
|
# FEATURES_FILE is claimed by the feature-closure render, never a row
|
|
109
108
|
files = (
|
|
@@ -142,24 +141,23 @@ def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
|
|
|
142
141
|
A manifest filter rides both list reads, so the two paths serve the
|
|
143
142
|
same record set and the per-key walk only visits filtered keys.
|
|
144
143
|
"""
|
|
144
|
+
endpoint = resolve_endpoint(spec.endpoint)
|
|
145
145
|
narrowed = {"$filter": spec.filter} if spec.filter else {}
|
|
146
146
|
try:
|
|
147
|
-
return client.get_list(
|
|
148
|
-
spec.entity, params=narrowed or None, endpoint=spec.endpoint
|
|
149
|
-
)
|
|
147
|
+
return client.get_list(spec.entity, params=narrowed or None, endpoint=endpoint)
|
|
150
148
|
except RuntimeError as err:
|
|
151
149
|
if OPTIMIZATION_500 not in str(err):
|
|
152
150
|
raise
|
|
153
151
|
key_rows = client.get_list(
|
|
154
152
|
spec.entity,
|
|
155
153
|
params={"$select": ",".join(spec.keys)} | narrowed,
|
|
156
|
-
endpoint=
|
|
154
|
+
endpoint=endpoint,
|
|
157
155
|
)
|
|
158
156
|
records: list[dict[str, Any]] = []
|
|
159
157
|
for row in key_rows:
|
|
160
158
|
values = unwrap(row)
|
|
161
159
|
record = client.get_record(
|
|
162
|
-
spec.entity, [values[k] for k in spec.keys],
|
|
160
|
+
spec.entity, [values[k] for k in spec.keys], endpoint
|
|
163
161
|
)
|
|
164
162
|
if record is not None:
|
|
165
163
|
records.append(record)
|
|
@@ -234,14 +232,15 @@ def _years(rows: list[dict[str, Any]]) -> list[str]:
|
|
|
234
232
|
|
|
235
233
|
def _synth_financial_year(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
236
234
|
"""The FinYearSetup singleton back as its GeneratePeriods action file."""
|
|
237
|
-
|
|
235
|
+
ep = resolve_endpoint(_SYMBOLIC_BOOTSTRAP)
|
|
236
|
+
live = client.get_list("FinancialYearSettings", endpoint=ep)
|
|
238
237
|
if not live:
|
|
239
238
|
return None
|
|
240
239
|
settings = unwrap(live[0])
|
|
241
240
|
return {
|
|
242
241
|
"action": "GeneratePeriods",
|
|
243
242
|
"entity": "FinancialYearSettings",
|
|
244
|
-
"endpoint":
|
|
243
|
+
"endpoint": _SYMBOLIC_BOOTSTRAP,
|
|
245
244
|
"record": {
|
|
246
245
|
# DateTimeValue comes back as a full ISO datetime; the action
|
|
247
246
|
# record wants the date, quoted (the seed pipeline ships YAML
|
|
@@ -257,14 +256,15 @@ def _synth_financial_year(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
|
257
256
|
|
|
258
257
|
def _synth_master_calendar(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
259
258
|
"""The master-calendar year range back as its GenerateCalendar action file."""
|
|
260
|
-
|
|
259
|
+
ep = resolve_endpoint(_SYMBOLIC_BOOTSTRAP)
|
|
260
|
+
live = client.get_list("MasterCalendar", endpoint=ep)
|
|
261
261
|
if not live:
|
|
262
262
|
return None
|
|
263
263
|
years = _years(live)
|
|
264
264
|
return {
|
|
265
265
|
"action": "GenerateCalendar",
|
|
266
266
|
"entity": "MasterCalendar",
|
|
267
|
-
"endpoint":
|
|
267
|
+
"endpoint": _SYMBOLIC_BOOTSTRAP,
|
|
268
268
|
"record": {"FinancialYear": years[0]},
|
|
269
269
|
"parameters": {"FromYear": years[0], "ToYear": years[-1]},
|
|
270
270
|
# the company calendar derives from the master, so it is the
|
|
@@ -279,10 +279,11 @@ def _synth_master_calendar(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
|
279
279
|
|
|
280
280
|
def _synth_open_periods(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
281
281
|
"""The open-period range back as its GL503000 ProcessAll action file."""
|
|
282
|
+
ep = resolve_endpoint(_SYMBOLIC_BOOTSTRAP)
|
|
282
283
|
live = client.get_list(
|
|
283
284
|
"CompanyPeriod",
|
|
284
285
|
params={"$filter": "Status eq 'Open'"},
|
|
285
|
-
endpoint=
|
|
286
|
+
endpoint=ep,
|
|
286
287
|
)
|
|
287
288
|
if not live:
|
|
288
289
|
return None
|
|
@@ -290,14 +291,14 @@ def _synth_open_periods(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
|
290
291
|
# OrganizationID = the extracted Company's AcctCD: the reference
|
|
291
292
|
# resolves inside the emitted set (V22 - bootstrap/company.yaml
|
|
292
293
|
# creates the organization the action names)
|
|
293
|
-
companies = client.get_list("Company", endpoint=
|
|
294
|
+
companies = client.get_list("Company", endpoint=ep)
|
|
294
295
|
if not companies:
|
|
295
296
|
raise RuntimeError("open-periods: no Company on tenant")
|
|
296
297
|
org = sorted(str(unwrap(c)["AcctCD"]) for c in companies)[0]
|
|
297
298
|
return {
|
|
298
299
|
"action": "ProcessAll",
|
|
299
300
|
"entity": "ManagePeriods",
|
|
300
|
-
"endpoint":
|
|
301
|
+
"endpoint": _SYMBOLIC_BOOTSTRAP,
|
|
301
302
|
"record": {
|
|
302
303
|
"Action": "Open",
|
|
303
304
|
"FromYear": years[0],
|
|
@@ -406,12 +407,21 @@ class _Extraction:
|
|
|
406
407
|
def entities(self) -> set[str]:
|
|
407
408
|
"""The entity pass; returns destination files written (or would be)."""
|
|
408
409
|
produced: set[str] = set()
|
|
410
|
+
active_name, active_entities = active_bootstrap()
|
|
409
411
|
for spec in self.manifest.entities:
|
|
410
412
|
if not self._selected(spec.entity, spec.file):
|
|
411
413
|
continue
|
|
412
414
|
target = self.out_dir / spec.file
|
|
413
415
|
if self._skip_existing(target):
|
|
414
416
|
continue
|
|
417
|
+
# Hybrid contract (T69): a bootstrap-endpoint row for an entity
|
|
418
|
+
# the active package does not serve cannot be read - skip clean
|
|
419
|
+
# rather than 404 (full company surface lives in the data-repo
|
|
420
|
+
# contract; minimal packaged fallback has config-init only).
|
|
421
|
+
resolved = resolve_endpoint(spec.endpoint)
|
|
422
|
+
if resolved == active_name and spec.entity not in active_entities:
|
|
423
|
+
self._skip(target, "entity not in active Bootstrap contract")
|
|
424
|
+
continue
|
|
415
425
|
try:
|
|
416
426
|
live = _fetch(self.client, spec)
|
|
417
427
|
records = _shape(spec, live)
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
# Per entity row:
|
|
5
5
|
# entity contract entity name (the GET source and the emitted `entity:`)
|
|
6
6
|
# endpoint omitted = the instance Default endpoint; the symbolic name
|
|
7
|
-
# `bootstrap`
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# `bootstrap` stays symbolic in emitted files and resolves to
|
|
8
|
+
# the active Bootstrap package version at HTTP/load time
|
|
9
|
+
# (data-repo bootstrap/project.xml when present, else
|
|
10
|
+
# packaged minimal - V20/V21)
|
|
10
11
|
# keys key fields: the emitted `key:`, the record sort order, and
|
|
11
12
|
# the B9 fallback's $select projection; the tuple must
|
|
12
13
|
# identify each record (V25 - a dup tuple is a row failure)
|