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.
@@ -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