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,260 @@
1
+ """Purchase Orders as ERPNext holds them, and the documents we write against one.
2
+
3
+ Reads are typed: the connector needs a stable shape to turn into observations,
4
+ and a dict from Frappe carries "" where we mean None. Writes are narrow: the
5
+ two `allow_on_submit` confirmation fields (verified on the v16 Purchase Order
6
+ doctype), and a draft Purchase Receipt. Nothing here submits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ from collections.abc import Iterator
13
+ from typing import Any, Protocol
14
+
15
+ DOCTYPE_PURCHASE_ORDER = "Purchase Order"
16
+ DOCTYPE_PURCHASE_RECEIPT = "Purchase Receipt"
17
+ DEFAULT_UOM = "Nos"
18
+
19
+
20
+ class _Getter(Protocol):
21
+ def get(self, doctype: str, name: str) -> Any: ...
22
+
23
+
24
+ class _Lister(Protocol):
25
+ def list_page(
26
+ self, doctype: str, limit: int, start: int, **kwargs: Any
27
+ ) -> tuple[list[dict[str, Any]], bool | None]: ...
28
+
29
+
30
+ class _Updater(Protocol):
31
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
32
+
33
+
34
+ def _text(value: object) -> str | None:
35
+ return value if isinstance(value, str) and value != "" else None
36
+
37
+
38
+ def _num(value: object) -> float:
39
+ return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0.0
40
+
41
+
42
+ @dataclasses.dataclass(frozen=True)
43
+ class PurchaseOrderItem:
44
+ """One row of a Purchase Order's `items` table."""
45
+
46
+ name: str
47
+ idx: int
48
+ item_code: str
49
+ item_name: str | None
50
+ description: str | None
51
+ qty: float
52
+ rate: float
53
+ amount: float
54
+ uom: str
55
+ schedule_date: str | None
56
+
57
+
58
+ @dataclasses.dataclass(frozen=True)
59
+ class PurchaseOrder:
60
+ """A Purchase Order as read from the site."""
61
+
62
+ name: str
63
+ supplier: str
64
+ supplier_name: str | None
65
+ company: str | None
66
+ currency: str | None
67
+ transaction_date: str | None
68
+ schedule_date: str | None
69
+ docstatus: int
70
+ status: str | None
71
+ grand_total: float
72
+ order_confirmation_no: str | None
73
+ order_confirmation_date: str | None
74
+ amended_from: str | None
75
+ modified: str
76
+ transpire_source: str | None
77
+ transpire_ref: str | None
78
+ transpire_order_no: str | None
79
+ items: tuple[PurchaseOrderItem, ...]
80
+
81
+ @property
82
+ def is_submitted(self) -> bool:
83
+ return self.docstatus == 1
84
+
85
+ @property
86
+ def is_cancelled(self) -> bool:
87
+ return self.docstatus == 2
88
+
89
+
90
+ def purchase_order_from_document(doc: dict[str, Any]) -> PurchaseOrder:
91
+ """Type a raw `Purchase Order` document. Pure."""
92
+ items = tuple(
93
+ PurchaseOrderItem(
94
+ name=str(row.get("name", "")),
95
+ idx=int(row.get("idx", 0)),
96
+ item_code=str(row["item_code"]),
97
+ item_name=_text(row.get("item_name")),
98
+ description=_text(row.get("description")),
99
+ qty=_num(row.get("qty")),
100
+ rate=_num(row.get("rate")),
101
+ amount=_num(row.get("amount")),
102
+ uom=_text(row.get("uom")) or DEFAULT_UOM,
103
+ schedule_date=_text(row.get("schedule_date")),
104
+ )
105
+ for row in doc.get("items") or []
106
+ )
107
+ return PurchaseOrder(
108
+ name=str(doc["name"]),
109
+ supplier=str(doc["supplier"]),
110
+ supplier_name=_text(doc.get("supplier_name")),
111
+ company=_text(doc.get("company")),
112
+ currency=_text(doc.get("currency")),
113
+ transaction_date=_text(doc.get("transaction_date")),
114
+ schedule_date=_text(doc.get("schedule_date")),
115
+ docstatus=int(doc.get("docstatus", 0)),
116
+ status=_text(doc.get("status")),
117
+ grand_total=_num(doc.get("grand_total")),
118
+ order_confirmation_no=_text(doc.get("order_confirmation_no")),
119
+ order_confirmation_date=_text(doc.get("order_confirmation_date")),
120
+ amended_from=_text(doc.get("amended_from")),
121
+ modified=str(doc.get("modified", "")),
122
+ transpire_source=_text(doc.get("transpire_source")),
123
+ transpire_ref=_text(doc.get("transpire_ref")),
124
+ transpire_order_no=_text(doc.get("transpire_order_no")),
125
+ items=items,
126
+ )
127
+
128
+
129
+ def read_purchase_order(client: _Getter, name: str) -> PurchaseOrder:
130
+ """Fetch and type one Purchase Order."""
131
+ return purchase_order_from_document(client.get(DOCTYPE_PURCHASE_ORDER, name))
132
+
133
+
134
+ def list_submitted_purchase_orders(
135
+ client: _Lister, *, modified_after: str | None, page_size: int = 100, company: str | None = None
136
+ ) -> Iterator[dict[str, Any]]:
137
+ """`{"name", "modified"}` for every submitted or cancelled PO past the cursor, oldest first.
138
+
139
+ `modified_after` is Frappe's own timestamp text (`YYYY-MM-DD HH:MM:SS[.ffffff]`),
140
+ compared server-side, so the cursor a caller stores is exactly what the
141
+ site last returned. The document at the cursor itself is listed again;
142
+ see the comment on the filter.
143
+
144
+ `company` narrows to one Frappe Company (a tenant's site may run several);
145
+ omitted, every company on the site is in scope.
146
+
147
+ Pages by key, never by offset. `modified` is what the listing is ordered
148
+ by, and it moves: a document edited between two pages sorts later than it
149
+ did, and with offsets the unread document that slid into its old slot
150
+ would be skipped, permanently, once the caller stored the cursor. Each
151
+ page instead continues from the last `(modified, name)` yielded, in two
152
+ steps because Frappe's list filters only AND together: first the rest of
153
+ the documents tied at that `modified`, by `name`, then everything after
154
+ it. An edit only ever moves a document later, so it can be listed twice
155
+ (every caller keys its source id on `modified`, so a repeat is
156
+ idempotent) but not lost.
157
+ """
158
+ cursor = modified_after
159
+ last_name: str | None = None
160
+ while True:
161
+ if last_name is not None:
162
+ # The rest of the tie at `cursor`. `name` is immutable, so this
163
+ # walk is stable however the documents are edited meanwhile.
164
+ while True:
165
+ tie = [["modified", "=", cursor], ["name", ">", last_name]]
166
+ rows, more = _page(client, tie, order_by="name asc", page_size=page_size, company=company)
167
+ if not rows:
168
+ break
169
+ yield from rows
170
+ last_name = rows[-1]["name"]
171
+ if _last_page(rows, more, page_size):
172
+ break
173
+ after: list[list[Any]] = []
174
+ if cursor is not None:
175
+ # Inclusive on the caller's own cursor, on purpose: several
176
+ # documents can share one `modified`, and a run that stopped
177
+ # between two of them would otherwise never see the rest.
178
+ # Re-reading the document at the cursor is idempotent for every
179
+ # caller. Strict once the tie has been walked above.
180
+ after = [["modified", ">" if last_name is not None else ">=", cursor]]
181
+ rows, more = _page(client, after, order_by="modified asc, name asc", page_size=page_size, company=company)
182
+ if not rows:
183
+ return
184
+ yield from rows
185
+ cursor, last_name = rows[-1]["modified"], rows[-1]["name"]
186
+ if _last_page(rows, more, page_size):
187
+ return
188
+
189
+
190
+ def _page(
191
+ client: _Lister, filters: list[list[Any]], *, order_by: str, page_size: int, company: str | None = None
192
+ ) -> tuple[list[dict[str, Any]], bool | None]:
193
+ # Submitted and cancelled, never drafts: a cancellation whose Webhook was
194
+ # lost is exactly what the reconcile poll exists to recover.
195
+ base: list[list[Any]] = [["docstatus", "in", [1, 2]]]
196
+ if company:
197
+ base.append(["company", "=", company])
198
+ return client.list_page(
199
+ DOCTYPE_PURCHASE_ORDER,
200
+ page_size,
201
+ 0,
202
+ fields=["name", "modified"],
203
+ filters=[*base, *filters],
204
+ order_by=order_by,
205
+ )
206
+
207
+
208
+ def _last_page(rows: list[dict[str, Any]], more: bool | None, page_size: int) -> bool:
209
+ """Trust the server's flag; without one (v1), a short page is the end."""
210
+ return more is False or (more is None and len(rows) < page_size)
211
+
212
+
213
+ def set_order_confirmation(client: _Updater, name: str, *, number: str, date: str) -> dict[str, Any]:
214
+ """Set the vendor's order number and date on a submitted PO. Both fields allow it."""
215
+ return client.update(
216
+ DOCTYPE_PURCHASE_ORDER, name, {"order_confirmation_no": number, "order_confirmation_date": date}
217
+ )
218
+
219
+
220
+ @dataclasses.dataclass(frozen=True)
221
+ class DesiredPurchaseReceiptItem:
222
+ """One received line, linked back to the PO row it fulfils."""
223
+
224
+ item_code: str
225
+ qty: float
226
+ purchase_order: str
227
+ purchase_order_item: str
228
+ rate: float
229
+ uom: str = DEFAULT_UOM
230
+
231
+ def as_document(self) -> dict[str, Any]:
232
+ return {
233
+ "item_code": self.item_code,
234
+ "qty": self.qty,
235
+ "uom": self.uom,
236
+ "rate": self.rate,
237
+ "purchase_order": self.purchase_order,
238
+ "purchase_order_item": self.purchase_order_item,
239
+ }
240
+
241
+
242
+ @dataclasses.dataclass(frozen=True)
243
+ class DesiredPurchaseReceipt:
244
+ """A draft Purchase Receipt against one PO. Never carries a docstatus."""
245
+
246
+ purchase_order: str
247
+ supplier: str
248
+ supplier_delivery_note: str
249
+ posting_date: str
250
+ remarks: str
251
+ items: tuple[DesiredPurchaseReceiptItem, ...]
252
+
253
+ def as_document(self) -> dict[str, Any]:
254
+ return {
255
+ "supplier": self.supplier,
256
+ "posting_date": self.posting_date,
257
+ "supplier_delivery_note": self.supplier_delivery_note,
258
+ "remarks": self.remarks,
259
+ "items": [item.as_document() for item in self.items],
260
+ }
@@ -0,0 +1,137 @@
1
+ """Frappe `Webhook` documents, provisioned so nobody transcribes a form.
2
+
3
+ A Webhook fires from `Document.run_method`, so a Purchase Order submitted from
4
+ the desk, the REST API, or a script all ring the same bell. The body is a
5
+ two-field template naming the document; phloem fetches the rest. The site id
6
+ rides in a header so the gateway can tell which site's secret signs the
7
+ request without a per-site route.
8
+
9
+ Field names are ERPNext v16's (`frappe/integrations/doctype/webhook/webhook.json`
10
+ on version-16-hotfix). Frappe names a Webhook by prompt, so the caller sets
11
+ `name`, which is what makes provisioning idempotent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ from collections.abc import Iterable
18
+ from typing import Any, Protocol
19
+
20
+ from icedcoffee_sync.erpnext.ensure import ensure_by_name
21
+
22
+ DOCTYPE_WEBHOOK = "Webhook"
23
+ PURCHASE_ORDER = "Purchase Order"
24
+ PURCHASE_ORDER_EVENTS: tuple[str, ...] = ("on_submit", "on_update_after_submit", "on_cancel")
25
+
26
+ SITE_HEADER = "X-Transpire-Site"
27
+ TIMEOUT_SECONDS = 10
28
+
29
+ # A doorbell, not the data. Frappe renders this Jinja against the document.
30
+ DOORBELL_TEMPLATE = '{"doctype": "{{ doc.doctype }}", "name": "{{ doc.name }}"}'
31
+
32
+
33
+ class _Writer(Protocol):
34
+ def get(self, doctype: str, name: str) -> Any: ...
35
+ def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
36
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
37
+
38
+
39
+ def webhook_name(site_id: str, doctype: str, event: str) -> str:
40
+ """`transpire-<site>-<doctype slug>-<event>`; deterministic, so ensure is get-then-insert."""
41
+ slug = doctype.lower().replace(" ", "-")
42
+ return f"transpire-{site_id}-{slug}-{event}"
43
+
44
+
45
+ @dataclasses.dataclass(frozen=True)
46
+ class DesiredWebhook:
47
+ """One Webhook document as Transpire wants it on the site."""
48
+
49
+ name: str
50
+ doctype: str
51
+ event: str
52
+ request_url: str
53
+ site_id: str
54
+ secret: str = dataclasses.field(repr=False)
55
+ # None means "do not touch the switch": a Webhook this creates starts off
56
+ # (an enabled Webhook pointed at a route that 404s burns Frappe's three
57
+ # attempts for nothing), and one that already exists keeps whatever it
58
+ # was, so a re-run for a key rotation cannot silently stop deliveries.
59
+ # True and False set it either way.
60
+ enabled: bool | None = None
61
+
62
+ def as_document(self) -> dict[str, Any]:
63
+ return {
64
+ "name": self.name,
65
+ "webhook_doctype": self.doctype,
66
+ "webhook_docevent": self.event,
67
+ "request_url": self.request_url,
68
+ "request_method": "POST",
69
+ "request_structure": "JSON",
70
+ "webhook_json": DOORBELL_TEMPLATE,
71
+ "webhook_headers": [
72
+ {"key": "Content-Type", "value": "application/json"},
73
+ {"key": SITE_HEADER, "value": self.site_id},
74
+ ],
75
+ "enable_security": 1,
76
+ "webhook_secret": self.secret,
77
+ "enabled": 1 if self.enabled else 0,
78
+ "timeout": TIMEOUT_SECONDS,
79
+ }
80
+
81
+
82
+ def purchase_order_webhooks(
83
+ *, site_id: str, request_url: str, secret: str, enabled: bool | None = None
84
+ ) -> tuple[DesiredWebhook, ...]:
85
+ """The three Purchase Order Webhooks the connector listens to.
86
+
87
+ `enabled` left None creates them off and leaves existing ones as they are;
88
+ re-running provisioning with it True, once the inbound route and its topic
89
+ are deployed, is what switches deliveries on. False switches them off.
90
+ """
91
+ return tuple(
92
+ DesiredWebhook(
93
+ name=webhook_name(site_id, PURCHASE_ORDER, event),
94
+ doctype=PURCHASE_ORDER,
95
+ event=event,
96
+ request_url=request_url,
97
+ site_id=site_id,
98
+ secret=secret,
99
+ enabled=enabled,
100
+ )
101
+ for event in PURCHASE_ORDER_EVENTS
102
+ )
103
+
104
+
105
+ @dataclasses.dataclass(frozen=True)
106
+ class WebhookReport:
107
+ """What `ensure_webhooks` did: the names it created and the names it re-asserted."""
108
+
109
+ created: list[str]
110
+ updated: list[str]
111
+
112
+
113
+ def ensure_webhooks(client: _Writer, webhooks: Iterable[DesiredWebhook]) -> WebhookReport:
114
+ """Create every Webhook the site lacks; re-assert the machine-owned fields on the rest.
115
+
116
+ A Transpire Webhook is ours by name, so an existing one is brought back to
117
+ the desired document -- URL, headers, template, secret, timeout -- rather
118
+ than left alone. That is what lets a re-run apply a changed gateway URL or
119
+ a re-derived secret after the master secret rotates; the secret is a
120
+ Password field Frappe never reads back, so it is always written. The
121
+ `enabled` switch is re-asserted only when the desired document says which
122
+ way (see `DesiredWebhook.enabled`).
123
+
124
+ Returns:
125
+ The names created and the names updated, each in input order.
126
+ """
127
+ created: list[str] = []
128
+ updated: list[str] = []
129
+ for hook in webhooks:
130
+ document = hook.as_document()
131
+ if ensure_by_name(client, DOCTYPE_WEBHOOK, hook.name, document):
132
+ created.append(hook.name)
133
+ continue
134
+ untouched = {"name"} | ({"enabled"} if hook.enabled is None else set())
135
+ client.update(DOCTYPE_WEBHOOK, hook.name, {k: v for k, v in document.items() if k not in untouched})
136
+ updated.append(hook.name)
137
+ return WebhookReport(created=created, updated=updated)
File without changes