icedcoffee-sync 0.1.0.dev5__py3-none-any.whl
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.
- icedcoffee_sync/__init__.py +3 -0
- icedcoffee_sync/apply/__init__.py +6 -0
- icedcoffee_sync/apply/diff.py +522 -0
- icedcoffee_sync/apply/execute.py +180 -0
- icedcoffee_sync/apply/model.py +45 -0
- icedcoffee_sync/apply/order.py +129 -0
- icedcoffee_sync/apply/park.py +107 -0
- icedcoffee_sync/apply/prune.py +215 -0
- icedcoffee_sync/erpnext/__init__.py +1 -0
- icedcoffee_sync/erpnext/accounts.py +183 -0
- icedcoffee_sync/erpnext/comment.py +62 -0
- icedcoffee_sync/erpnext/company.py +102 -0
- icedcoffee_sync/erpnext/documents.py +179 -0
- icedcoffee_sync/erpnext/ensure.py +34 -0
- icedcoffee_sync/erpnext/purchase.py +260 -0
- icedcoffee_sync/erpnext/webhook.py +137 -0
- icedcoffee_sync/frappe/__init__.py +0 -0
- icedcoffee_sync/frappe/access.py +379 -0
- icedcoffee_sync/frappe/client.py +539 -0
- icedcoffee_sync/frappe/errors.py +201 -0
- icedcoffee_sync/frappe/lifecycle.py +30 -0
- icedcoffee_sync/frappe/request.py +145 -0
- icedcoffee_sync/frappe/response.py +76 -0
- icedcoffee_sync/py.typed +0 -0
- icedcoffee_sync/site.py +65 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/METADATA +9 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/RECORD +29 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/WHEEL +4 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""ERPNext Account facts: how accounts are named, and what types exist.
|
|
2
|
+
|
|
3
|
+
The constants here are a snapshot of ERPNext 16's Select options, measured on
|
|
4
|
+
2026-08-25. They exist so a chart can be validated offline, before any request
|
|
5
|
+
is sent. `icedcoffee_sync.erpnext.accounts.live_account_types` reads the same list from
|
|
6
|
+
a running site, and a live test asserts the two agree -- that is what catches an
|
|
7
|
+
ERPNext upgrade adding or removing a type.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from icedcoffee_sync.apply.model import LiveAccount
|
|
16
|
+
|
|
17
|
+
ROOT_TYPES: frozenset[str] = frozenset({"Asset", "Liability", "Income", "Expense", "Equity"})
|
|
18
|
+
|
|
19
|
+
ACCOUNT_TYPES: frozenset[str] = frozenset({
|
|
20
|
+
"Accumulated Depreciation",
|
|
21
|
+
"Asset Received But Not Billed",
|
|
22
|
+
"Bank",
|
|
23
|
+
"Capital Work in Progress",
|
|
24
|
+
"Cash",
|
|
25
|
+
"Chargeable",
|
|
26
|
+
"Cost of Goods Sold",
|
|
27
|
+
"Current Asset",
|
|
28
|
+
"Current Liability",
|
|
29
|
+
"Depreciation",
|
|
30
|
+
"Direct Expense",
|
|
31
|
+
"Direct Income",
|
|
32
|
+
"Equity",
|
|
33
|
+
"Expense Account",
|
|
34
|
+
"Expenses Included In Asset Valuation",
|
|
35
|
+
"Expenses Included In Valuation",
|
|
36
|
+
"Fixed Asset",
|
|
37
|
+
"Income Account",
|
|
38
|
+
"Indirect Expense",
|
|
39
|
+
"Indirect Income",
|
|
40
|
+
"Liability",
|
|
41
|
+
"Payable",
|
|
42
|
+
"Receivable",
|
|
43
|
+
"Round Off",
|
|
44
|
+
"Round Off for Opening",
|
|
45
|
+
"Service Received But Not Billed",
|
|
46
|
+
"Stock",
|
|
47
|
+
"Stock Adjustment",
|
|
48
|
+
"Stock Received But Not Billed",
|
|
49
|
+
"Tax",
|
|
50
|
+
"Temporary",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UnknownAccountTypeError(ValueError):
|
|
55
|
+
"""An account_type outside ERPNext's Select options.
|
|
56
|
+
|
|
57
|
+
Raised locally so a bad chart fails before a request is built, rather than
|
|
58
|
+
surfacing as a server-side ValidationError halfway through an apply.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_account_type(value: str | None) -> None:
|
|
63
|
+
"""Refuse an account_type ERPNext would reject. Absent is allowed."""
|
|
64
|
+
if not value:
|
|
65
|
+
return
|
|
66
|
+
if value not in ACCOUNT_TYPES:
|
|
67
|
+
allowed = ", ".join(sorted(ACCOUNT_TYPES))
|
|
68
|
+
raise UnknownAccountTypeError(f"unknown account_type {value!r}; ERPNext allows: {allowed}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def document_name(account_name: str, abbr: str, account_number: str | None = None) -> str:
|
|
72
|
+
"""Derive an Account's document name the way ERPNext does.
|
|
73
|
+
|
|
74
|
+
Numbered accounts are `{number} - {name} - {abbr}`; unnumbered ones drop
|
|
75
|
+
the number and its separator. Frappe returns "" for an unset number, so an
|
|
76
|
+
empty string counts as absent -- treating it as present would produce a
|
|
77
|
+
leading " - " that matches no document.
|
|
78
|
+
"""
|
|
79
|
+
if not account_name:
|
|
80
|
+
raise ValueError("account_name must not be empty")
|
|
81
|
+
if not abbr:
|
|
82
|
+
raise ValueError("abbr must not be empty")
|
|
83
|
+
if account_number:
|
|
84
|
+
return f"{account_number} - {account_name} - {abbr}"
|
|
85
|
+
return f"{account_name} - {abbr}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_CHART_FIELDS = [
|
|
89
|
+
"name",
|
|
90
|
+
"account_number",
|
|
91
|
+
"account_name",
|
|
92
|
+
"parent_account",
|
|
93
|
+
"is_group",
|
|
94
|
+
"root_type",
|
|
95
|
+
"account_type",
|
|
96
|
+
"disabled",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Reader(Protocol):
|
|
101
|
+
"""The slice of Client these reads need. Keeps the test doubles honest."""
|
|
102
|
+
|
|
103
|
+
def list(self, doctype: str, **kwargs: Any) -> Any: ...
|
|
104
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
105
|
+
def iter_all(self, doctype: str, **kwargs: Any) -> Iterator[dict[str, Any]]: ...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def company_abbr(client: _Reader, company: str) -> str:
|
|
109
|
+
"""The company's abbreviation, which every account's document name ends in."""
|
|
110
|
+
rows = client.list("Company", fields=["name", "abbr"], filters=[["name", "=", company]], limit=1)
|
|
111
|
+
if not rows:
|
|
112
|
+
raise LookupError(f"no Company named {company!r} on this site")
|
|
113
|
+
abbr = rows[0].get("abbr")
|
|
114
|
+
if not abbr:
|
|
115
|
+
raise LookupError(f"Company {company!r} has no abbreviation set")
|
|
116
|
+
return str(abbr)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_chart(client: _Reader, company: str) -> list[LiveAccount]:
|
|
120
|
+
"""Every account on the site for one company.
|
|
121
|
+
|
|
122
|
+
Frappe returns "" rather than null for unset links and selects, normalised
|
|
123
|
+
to None here -- leaving "" in place would make an unparented root look
|
|
124
|
+
like a child of an account named "". Paged through `iter_all`, not a
|
|
125
|
+
single capped `list` call: a silently truncated read would make every
|
|
126
|
+
account past the cutoff look absent and get it created again as a duplicate.
|
|
127
|
+
"""
|
|
128
|
+
rows = client.iter_all(
|
|
129
|
+
"Account",
|
|
130
|
+
fields=_CHART_FIELDS,
|
|
131
|
+
filters=[["company", "=", company]],
|
|
132
|
+
order_by="name asc",
|
|
133
|
+
)
|
|
134
|
+
return [
|
|
135
|
+
LiveAccount(
|
|
136
|
+
document_name=row["name"],
|
|
137
|
+
number=row.get("account_number") or "",
|
|
138
|
+
name=row["account_name"],
|
|
139
|
+
parent_document_name=row.get("parent_account") or None,
|
|
140
|
+
is_group=bool(row.get("is_group")),
|
|
141
|
+
root_type=row.get("root_type") or "",
|
|
142
|
+
account_type=row.get("account_type") or None,
|
|
143
|
+
disabled=bool(row.get("disabled")),
|
|
144
|
+
)
|
|
145
|
+
for row in rows
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def accounts_with_entries(client: _Reader, document_names: list[str]) -> set[str]:
|
|
150
|
+
"""Which of these accounts already carry ledger entries.
|
|
151
|
+
|
|
152
|
+
One `limit=1` presence probe per account, not a single batched `IN`
|
|
153
|
+
query: a shared `limit` over an `IN` filter caps total ROWS, not
|
|
154
|
+
accounts, so one account with heavy history could starve the presence
|
|
155
|
+
signal for every other name in the batch and report them as entry-free
|
|
156
|
+
when they are not -- exactly what this guard exists to prevent. The
|
|
157
|
+
honest cost: one request per account, acceptable for a tool a human
|
|
158
|
+
runs and reads, not a hot path.
|
|
159
|
+
"""
|
|
160
|
+
found: set[str] = set()
|
|
161
|
+
for name in document_names:
|
|
162
|
+
rows = client.list(
|
|
163
|
+
"GL Entry",
|
|
164
|
+
fields=["account"],
|
|
165
|
+
filters=[["account", "=", name]],
|
|
166
|
+
limit=1,
|
|
167
|
+
)
|
|
168
|
+
if rows:
|
|
169
|
+
found.add(name)
|
|
170
|
+
return found
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def live_account_types(client: _Reader) -> frozenset[str]:
|
|
174
|
+
"""The account_type Select options this site actually offers.
|
|
175
|
+
|
|
176
|
+
Used by a live test to catch an ERPNext upgrade drifting from the checked-in
|
|
177
|
+
ACCOUNT_TYPES snapshot.
|
|
178
|
+
"""
|
|
179
|
+
meta = client.get("DocType", "Account")
|
|
180
|
+
for field in meta.get("fields", []):
|
|
181
|
+
if field.get("fieldname") == "account_type":
|
|
182
|
+
return frozenset(o for o in (field.get("options") or "").split("\n") if o)
|
|
183
|
+
raise LookupError("Account doctype has no account_type field")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Comments on a Frappe document.
|
|
2
|
+
|
|
3
|
+
Frappe exposes `frappe.desk.form.utils.add_comment(reference_doctype,
|
|
4
|
+
reference_name, content, comment_email, comment_by)` (verified on
|
|
5
|
+
version-16-hotfix), which checks read permission on the document and creates a
|
|
6
|
+
`Comment` of type "Comment". It is not a committing method, so a client
|
|
7
|
+
without submit capability may call it. Listing goes through the `Comment`
|
|
8
|
+
doctype directly, filtered to the document, which is how idempotency-by-content
|
|
9
|
+
is checked before adding one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
ADD_COMMENT = "frappe.desk.form.utils.add_comment"
|
|
18
|
+
DOCTYPE_COMMENT = "Comment"
|
|
19
|
+
DEFAULT_EMAIL = "integration@example.com"
|
|
20
|
+
DEFAULT_BY = "Transpire"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Caller(Protocol):
|
|
24
|
+
def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
|
|
25
|
+
def iter_all(self, doctype: str, **kwargs: Any) -> Iterable[dict[str, Any]]: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def add_comment(
|
|
29
|
+
client: _Caller,
|
|
30
|
+
doctype: str,
|
|
31
|
+
name: str,
|
|
32
|
+
content: str,
|
|
33
|
+
*,
|
|
34
|
+
email: str = DEFAULT_EMAIL,
|
|
35
|
+
by: str = DEFAULT_BY,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Add a Comment to `doctype`/`name`. Returns the created Comment document."""
|
|
38
|
+
return client.call(
|
|
39
|
+
ADD_COMMENT,
|
|
40
|
+
{
|
|
41
|
+
"reference_doctype": doctype,
|
|
42
|
+
"reference_name": name,
|
|
43
|
+
"content": content,
|
|
44
|
+
"comment_email": email,
|
|
45
|
+
"comment_by": by,
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def list_comments(client: _Caller, doctype: str, name: str) -> list[dict[str, Any]]:
|
|
51
|
+
"""Every "Comment"-type Comment on the document, with its content.
|
|
52
|
+
|
|
53
|
+
Pages to the end: a busy document's comment history outgrows any one page,
|
|
54
|
+
and a truncated read is what makes an already-posted comment look absent.
|
|
55
|
+
"""
|
|
56
|
+
return list(
|
|
57
|
+
client.iter_all(
|
|
58
|
+
DOCTYPE_COMMENT,
|
|
59
|
+
fields=["name", "content", "comment_email", "creation"],
|
|
60
|
+
filters={"reference_doctype": doctype, "reference_name": name, "comment_type": "Comment"},
|
|
61
|
+
)
|
|
62
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Company's default-account links.
|
|
2
|
+
|
|
3
|
+
ERPNext posts automatically to whichever account Company names, and Frappe
|
|
4
|
+
silently drops an update naming a field that doesn't exist. Validated by
|
|
5
|
+
reading Company itself, not `DocType` meta -- a role permission there
|
|
6
|
+
doesn't take effect the way it does elsewhere (measured live).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
# Company Link-to-Account fields without "account" in the name -- measured live.
|
|
15
|
+
_ACCOUNT_FIELD_NAME_EXCEPTIONS: frozenset[str] = frozenset({
|
|
16
|
+
"round_off_for_opening",
|
|
17
|
+
"stock_received_but_not_billed",
|
|
18
|
+
"asset_received_but_not_billed",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _plausibly_an_account_field(field: str) -> bool:
|
|
23
|
+
"""Whether `field` is plausibly a Link-to-Account field, by name (e.g. not `default_warehouse`)."""
|
|
24
|
+
return "account" in field or field in _ACCOUNT_FIELD_NAME_EXCEPTIONS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnknownCompanyFieldError(ValueError):
|
|
28
|
+
"""A default-account field this ERPNext build does not have."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DefaultNotAppliedError(ValueError):
|
|
32
|
+
"""A default-account field exists but did not read back as requested."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _CompanyClient(Protocol):
|
|
36
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
37
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clear_defaults_pointing_at(client: _CompanyClient, company: str, account_names: frozenset[str]) -> list[str]:
|
|
41
|
+
"""Null out every Company default pointing at one of `account_names`, for `--allow-unapplied` prune.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The cleared field names, sorted. No matches makes no request.
|
|
45
|
+
"""
|
|
46
|
+
doc = client.get("Company", company)
|
|
47
|
+
cleared = sorted(
|
|
48
|
+
field
|
|
49
|
+
for field, value in doc.items()
|
|
50
|
+
if field != "name"
|
|
51
|
+
and not field.startswith("_")
|
|
52
|
+
and isinstance(value, str)
|
|
53
|
+
and value in account_names
|
|
54
|
+
and _plausibly_an_account_field(field)
|
|
55
|
+
)
|
|
56
|
+
if not cleared:
|
|
57
|
+
return []
|
|
58
|
+
client.update("Company", company, dict.fromkeys(cleared))
|
|
59
|
+
return cleared
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_default_fields(client: _CompanyClient, company: str, fields: Iterable[str]) -> None:
|
|
63
|
+
"""Raise `UnknownCompanyFieldError` naming any of `fields` absent from `company`. A pre-flight; writes nothing."""
|
|
64
|
+
doc = client.get("Company", company)
|
|
65
|
+
missing = sorted(field for field in fields if field not in doc)
|
|
66
|
+
if missing:
|
|
67
|
+
raise UnknownCompanyFieldError(
|
|
68
|
+
f"Company {company!r} has no field(s): {', '.join(missing)} -- "
|
|
69
|
+
f"absent from the document read before applying anything"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_default_accounts(client: _CompanyClient, company: str, defaults: dict[str, str]) -> list[str]:
|
|
74
|
+
"""Point Company's defaults at specific accounts, then confirm each one landed.
|
|
75
|
+
|
|
76
|
+
Rejects any field absent from Company before writing, and reads back
|
|
77
|
+
after to confirm each value actually stuck.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The field names written, sorted. Empty input makes no request.
|
|
81
|
+
"""
|
|
82
|
+
if not defaults:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
pre = client.get("Company", company)
|
|
86
|
+
missing = sorted(field for field in defaults if field not in pre)
|
|
87
|
+
if missing:
|
|
88
|
+
raise UnknownCompanyFieldError(
|
|
89
|
+
f"Company {company!r} has no field(s): {', '.join(missing)} -- "
|
|
90
|
+
f"absent from the document read before writing anything"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
client.update("Company", company, dict(defaults))
|
|
94
|
+
doc = client.get("Company", company)
|
|
95
|
+
|
|
96
|
+
for field in sorted(defaults):
|
|
97
|
+
wanted = defaults[field]
|
|
98
|
+
got = doc[field]
|
|
99
|
+
if got != wanted:
|
|
100
|
+
raise DefaultNotAppliedError(f"{field}: wanted {wanted!r}, got {got!r} on Company {company!r}")
|
|
101
|
+
|
|
102
|
+
return sorted(defaults)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The ERPNext documents Transpire writes, as dataclasses with `as_document()`.
|
|
2
|
+
|
|
3
|
+
Shared by `graft` (which pushes a tenant's curated data onto a demo site) and
|
|
4
|
+
phloem's connector (which stamps identity markers onto a customer's site and
|
|
5
|
+
drafts documents there). One definition, so the two never drift on a field
|
|
6
|
+
name or a marker value.
|
|
7
|
+
|
|
8
|
+
Every document carries `transpire_source = transpire`, which is what keeps a
|
|
9
|
+
row we wrote distinguishable from a site's native data and from anything a
|
|
10
|
+
person types later.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# What every document this connector writes is stamped with, so a pushed row
|
|
19
|
+
# stays distinguishable from the site's own ERPNext demo data and from
|
|
20
|
+
# anything a person types in later.
|
|
21
|
+
SOURCE_MARKER = "transpire"
|
|
22
|
+
|
|
23
|
+
FIELD_SOURCE = "transpire_source"
|
|
24
|
+
FIELD_REF = "transpire_ref"
|
|
25
|
+
FIELD_ORDER_NO = "transpire_order_no"
|
|
26
|
+
|
|
27
|
+
# Present on every ERPNext site out of the box, so the connector needs no
|
|
28
|
+
# site configuration of its own.
|
|
29
|
+
DEFAULT_ITEM_GROUP = "All Item Groups"
|
|
30
|
+
DEFAULT_UOM = "Nos"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclasses.dataclass(frozen=True)
|
|
34
|
+
class DesiredCustomField:
|
|
35
|
+
"""One Custom Field the connector needs before it can key anything.
|
|
36
|
+
|
|
37
|
+
`document_name` is deterministic (`<dt>-<fieldname>`), which is what makes
|
|
38
|
+
ensuring these idempotent without a query on anything but the name.
|
|
39
|
+
|
|
40
|
+
Deliberately NOT `unique`: a unique index over an existing doctype whose
|
|
41
|
+
rows all carry an empty string for the new column is rejected by MariaDB
|
|
42
|
+
on the second empty row, so marking the marker unique would fail on any
|
|
43
|
+
site that already holds suppliers -- which is every site.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
dt: str
|
|
47
|
+
fieldname: str
|
|
48
|
+
label: str
|
|
49
|
+
insert_after: str
|
|
50
|
+
fieldtype: str = "Data"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def document_name(self) -> str:
|
|
54
|
+
return f"{self.dt}-{self.fieldname}"
|
|
55
|
+
|
|
56
|
+
def as_document(self) -> dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"dt": self.dt,
|
|
59
|
+
"fieldname": self.fieldname,
|
|
60
|
+
"label": self.label,
|
|
61
|
+
"fieldtype": self.fieldtype,
|
|
62
|
+
"insert_after": self.insert_after,
|
|
63
|
+
# Machine-owned: editable through the API, not through the form.
|
|
64
|
+
"read_only": 1,
|
|
65
|
+
# An amended document starts as a copy; carrying the marker onto
|
|
66
|
+
# it would claim Transpire wrote a row it has never seen.
|
|
67
|
+
"no_copy": 1,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
CUSTOM_FIELDS: tuple[DesiredCustomField, ...] = (
|
|
72
|
+
DesiredCustomField("Supplier", FIELD_SOURCE, "Transpire Source", insert_after="supplier_name"),
|
|
73
|
+
DesiredCustomField("Supplier", FIELD_REF, "Transpire Vendor ID", insert_after=FIELD_SOURCE),
|
|
74
|
+
# An Item needs no `transpire_ref`: its `item_code` IS the reference, and
|
|
75
|
+
# Frappe names the document from it. The source marker is what a site's own
|
|
76
|
+
# demo Items lack, which is what makes a reused one recognisable.
|
|
77
|
+
DesiredCustomField("Item", FIELD_SOURCE, "Transpire Source", insert_after="item_name"),
|
|
78
|
+
DesiredCustomField("Purchase Order", FIELD_SOURCE, "Transpire Source", insert_after="supplier"),
|
|
79
|
+
DesiredCustomField("Purchase Order", FIELD_REF, "Transpire Order ID", insert_after=FIELD_SOURCE),
|
|
80
|
+
DesiredCustomField("Purchase Order", FIELD_ORDER_NO, "Vendor Order Number", insert_after=FIELD_REF),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# The one marker phloem's onboarding stamps onto a customer's Suppliers.
|
|
84
|
+
SUPPLIER_REF_FIELD = next(f for f in CUSTOM_FIELDS if f.dt == "Supplier" and f.fieldname == FIELD_REF)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
88
|
+
class DesiredSupplier:
|
|
89
|
+
"""One Supplier, keyed by the tenant's vendor id rather than by its name."""
|
|
90
|
+
|
|
91
|
+
vendor_id: str
|
|
92
|
+
supplier_name: str
|
|
93
|
+
disabled: bool = False
|
|
94
|
+
|
|
95
|
+
def as_document(self) -> dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"supplier_name": self.supplier_name,
|
|
98
|
+
"disabled": 1 if self.disabled else 0,
|
|
99
|
+
FIELD_SOURCE: SOURCE_MARKER,
|
|
100
|
+
FIELD_REF: self.vendor_id,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclasses.dataclass(frozen=True)
|
|
105
|
+
class DesiredItem:
|
|
106
|
+
"""One Item, named by a deterministic slug so creating it is idempotent.
|
|
107
|
+
|
|
108
|
+
Stock-maintained: these are parts a shop holds in inventory, and ERPNext
|
|
109
|
+
writes no stock ledger entry for an item with Maintain Stock off -- so a
|
|
110
|
+
Purchase Receipt against a non-stock item moves nothing, which is the half
|
|
111
|
+
of the demo that matters. The warehouse and valuation rate this drags in
|
|
112
|
+
are answered by the site's company defaults and by the line's own rate.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
item_code: str
|
|
116
|
+
item_name: str
|
|
117
|
+
description: str | None
|
|
118
|
+
|
|
119
|
+
def as_document(self) -> dict[str, Any]:
|
|
120
|
+
return {
|
|
121
|
+
"item_code": self.item_code,
|
|
122
|
+
"item_name": self.item_name,
|
|
123
|
+
"description": self.description or self.item_name,
|
|
124
|
+
"item_group": DEFAULT_ITEM_GROUP,
|
|
125
|
+
"stock_uom": DEFAULT_UOM,
|
|
126
|
+
"is_stock_item": 1,
|
|
127
|
+
FIELD_SOURCE: SOURCE_MARKER,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
132
|
+
class DesiredPurchaseOrderItem:
|
|
133
|
+
"""One Purchase Order line.
|
|
134
|
+
|
|
135
|
+
`rate` is the unit price in the site's company currency, or 0 when the
|
|
136
|
+
caller has no price it can vouch for: the connector never invents one.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
item_code: str
|
|
140
|
+
item_name: str = ""
|
|
141
|
+
description: str | None = None
|
|
142
|
+
qty: int
|
|
143
|
+
schedule_date: str
|
|
144
|
+
rate: float = 0
|
|
145
|
+
|
|
146
|
+
def as_document(self) -> dict[str, Any]:
|
|
147
|
+
return {
|
|
148
|
+
"item_code": self.item_code,
|
|
149
|
+
"item_name": self.item_name,
|
|
150
|
+
"description": self.description or self.item_name,
|
|
151
|
+
"qty": self.qty,
|
|
152
|
+
"rate": self.rate,
|
|
153
|
+
"uom": DEFAULT_UOM,
|
|
154
|
+
"conversion_factor": 1,
|
|
155
|
+
"schedule_date": self.schedule_date,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclasses.dataclass(frozen=True)
|
|
160
|
+
class DesiredPurchaseOrder:
|
|
161
|
+
"""One Purchase Order, minus the Supplier name only the site can assign."""
|
|
162
|
+
|
|
163
|
+
order_id: str
|
|
164
|
+
vendor_id: str
|
|
165
|
+
order_number: str | None
|
|
166
|
+
transaction_date: str
|
|
167
|
+
schedule_date: str
|
|
168
|
+
items: tuple[DesiredPurchaseOrderItem, ...]
|
|
169
|
+
|
|
170
|
+
def as_document(self, supplier: str) -> dict[str, Any]:
|
|
171
|
+
return {
|
|
172
|
+
"supplier": supplier,
|
|
173
|
+
"transaction_date": self.transaction_date,
|
|
174
|
+
"schedule_date": self.schedule_date,
|
|
175
|
+
"items": [item.as_document() for item in self.items],
|
|
176
|
+
FIELD_SOURCE: SOURCE_MARKER,
|
|
177
|
+
FIELD_REF: self.order_id,
|
|
178
|
+
FIELD_ORDER_NO: self.order_number or "",
|
|
179
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Get-or-insert by document name.
|
|
2
|
+
|
|
3
|
+
Frappe addresses a document by its name, and for the doctypes we provision
|
|
4
|
+
(Custom Field, Webhook) the name is deterministic -- either Frappe derives it
|
|
5
|
+
from the content or the caller sets it. So "make sure this exists" is one
|
|
6
|
+
`get` that is allowed to miss, then one `insert`. An existing document is
|
|
7
|
+
left exactly as it is: re-running setup must never rewrite what a person
|
|
8
|
+
changed on the site.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from icedcoffee_sync.frappe.errors import DoesNotExistError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Writer(Protocol):
|
|
19
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
20
|
+
def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ensure_by_name(client: _Writer, doctype: str, name: str, doc: dict[str, Any]) -> bool:
|
|
24
|
+
"""Insert `doc` as `doctype` unless a document named `name` already exists.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
True when this call inserted it; False when it was already there.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
client.get(doctype, name)
|
|
31
|
+
except DoesNotExistError:
|
|
32
|
+
client.insert(doctype, doc)
|
|
33
|
+
return True
|
|
34
|
+
return False
|