icedcoffee-sync 0.1.0.dev11__tar.gz → 0.1.0.dev13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/PKG-INFO +27 -1
  2. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/README.md +26 -0
  3. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/pyproject.toml +1 -1
  4. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/comment.py +3 -5
  5. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/documents.py +40 -42
  6. icedcoffee_sync-0.1.0.dev13/src/icedcoffee_sync/erpnext/namespace.py +64 -0
  7. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/purchase.py +20 -12
  8. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/webhook.py +21 -19
  9. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/README.md +3 -1
  10. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_comment.py +5 -3
  11. icedcoffee_sync-0.1.0.dev13/tests/test_erpnext_documents.py +68 -0
  12. icedcoffee_sync-0.1.0.dev13/tests/test_erpnext_namespace.py +28 -0
  13. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_purchase.py +13 -3
  14. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_webhook.py +33 -16
  15. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_live_frappe.py +10 -4
  16. icedcoffee_sync-0.1.0.dev11/tests/test_erpnext_documents.py +0 -42
  17. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/.github/workflows/ci.yml +0 -0
  18. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/.gitignore +0 -0
  19. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/.kata.toml +0 -0
  20. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/.python-version +0 -0
  21. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/LICENSE +0 -0
  22. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/justfile +0 -0
  23. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/__init__.py +0 -0
  24. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/__init__.py +0 -0
  25. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/diff.py +0 -0
  26. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/execute.py +0 -0
  27. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/model.py +0 -0
  28. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/order.py +0 -0
  29. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/park.py +0 -0
  30. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/prune.py +0 -0
  31. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/__init__.py +0 -0
  32. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/accounts.py +0 -0
  33. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/company.py +0 -0
  34. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/ensure.py +0 -0
  35. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/__init__.py +0 -0
  36. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/access.py +0 -0
  37. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/client.py +0 -0
  38. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/errors.py +0 -0
  39. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/lifecycle.py +0 -0
  40. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/request.py +0 -0
  41. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/response.py +0 -0
  42. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/py.typed +0 -0
  43. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/site.py +0 -0
  44. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/__init__.py +0 -0
  45. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/capture_fixtures.py +0 -0
  46. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/duplicate_not_provoked.json +0 -0
  47. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/error_not_found.json +0 -0
  48. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/error_validation.json +0 -0
  49. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/get_ok.json +0 -0
  50. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/list_ok.json +0 -0
  51. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/permission_not_provoked.json +0 -0
  52. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/purchase_order_submitted.json +0 -0
  53. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_diff.py +0 -0
  54. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_execute.py +0 -0
  55. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_order.py +0 -0
  56. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_park.py +0 -0
  57. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_prune.py +0 -0
  58. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_client.py +0 -0
  59. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_download_file.py +0 -0
  60. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_accounts.py +0 -0
  61. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_accounts_reads.py +0 -0
  62. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_company.py +0 -0
  63. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_ensure.py +0 -0
  64. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_errors.py +0 -0
  65. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_frappe_access.py +0 -0
  66. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_lifecycle.py +0 -0
  67. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_package.py +0 -0
  68. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_pagination.py +0 -0
  69. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_request.py +0 -0
  70. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_response.py +0 -0
  71. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/tests/test_site.py +0 -0
  72. {icedcoffee_sync-0.1.0.dev11 → icedcoffee_sync-0.1.0.dev13}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: icedcoffee-sync
3
- Version: 0.1.0.dev11
3
+ Version: 0.1.0.dev13
4
4
  Summary: Typed sync and async client for the Frappe and ERPNext REST API
5
5
  Author-email: Gavin Carothers <gavin@carothers.name>
6
6
  License-Expression: Apache-2.0
@@ -109,10 +109,36 @@ with Client(config) as client:
109
109
  print(created)
110
110
  ```
111
111
 
112
+ - `icedcoffee_sync.erpnext.documents`: Supplier, Item and Purchase Order documents, and the Custom Fields that mark them as yours.
113
+ - `icedcoffee_sync.erpnext.webhook`: provision Purchase Order Webhooks pointed at your receiver.
112
114
  - `icedcoffee_sync.erpnext.purchase`: typed Purchase Order reads and draft Purchase Receipt documents.
113
115
  - `icedcoffee_sync.erpnext.company`: validate, set and clear Company default-account links.
114
116
  - `icedcoffee_sync.erpnext.comment`: add and list comments on documents.
115
117
 
118
+ ### Your namespace
119
+
120
+ The documents, Webhooks and Purchase Order reads take a `Namespace`: the names
121
+ your integration leaves on a site. `name` prefixes the Custom Fields
122
+ (`<name>_source`, `<name>_ref`, `<name>_order_no`) and the Webhook names, and
123
+ is the marker stamped on every document you write. `label` prefixes the field
124
+ labels a person sees on the form. `site_header` carries your site id on each
125
+ Webhook delivery, so one receiving URL can tell sites apart.
126
+
127
+ ```python
128
+ from icedcoffee_sync.erpnext.documents import DesiredSupplier, custom_fields
129
+ from icedcoffee_sync.erpnext.namespace import Namespace
130
+
131
+ ns = Namespace(name="acme", label="Acme", site_header="X-Acme-Site")
132
+ fields = custom_fields(ns) # ensure these before writing documents
133
+ supplier = DesiredSupplier(vendor_id="v-123", supplier_name="Example Supplier")
134
+ print(supplier.as_document(namespace=ns))
135
+ # {'supplier_name': 'Example Supplier', 'disabled': 0, 'acme_source': 'acme', 'acme_ref': 'v-123'}
136
+ ```
137
+
138
+ Pick the namespace once per site. Documents written under one name are
139
+ invisible to readers under another, and the old fields and Webhooks stay on
140
+ the site.
141
+
116
142
  ## Reconciliation
117
143
 
118
144
  `icedcoffee_sync.apply` reconciles charts through the synchronous client.
@@ -98,10 +98,36 @@ with Client(config) as client:
98
98
  print(created)
99
99
  ```
100
100
 
101
+ - `icedcoffee_sync.erpnext.documents`: Supplier, Item and Purchase Order documents, and the Custom Fields that mark them as yours.
102
+ - `icedcoffee_sync.erpnext.webhook`: provision Purchase Order Webhooks pointed at your receiver.
101
103
  - `icedcoffee_sync.erpnext.purchase`: typed Purchase Order reads and draft Purchase Receipt documents.
102
104
  - `icedcoffee_sync.erpnext.company`: validate, set and clear Company default-account links.
103
105
  - `icedcoffee_sync.erpnext.comment`: add and list comments on documents.
104
106
 
107
+ ### Your namespace
108
+
109
+ The documents, Webhooks and Purchase Order reads take a `Namespace`: the names
110
+ your integration leaves on a site. `name` prefixes the Custom Fields
111
+ (`<name>_source`, `<name>_ref`, `<name>_order_no`) and the Webhook names, and
112
+ is the marker stamped on every document you write. `label` prefixes the field
113
+ labels a person sees on the form. `site_header` carries your site id on each
114
+ Webhook delivery, so one receiving URL can tell sites apart.
115
+
116
+ ```python
117
+ from icedcoffee_sync.erpnext.documents import DesiredSupplier, custom_fields
118
+ from icedcoffee_sync.erpnext.namespace import Namespace
119
+
120
+ ns = Namespace(name="acme", label="Acme", site_header="X-Acme-Site")
121
+ fields = custom_fields(ns) # ensure these before writing documents
122
+ supplier = DesiredSupplier(vendor_id="v-123", supplier_name="Example Supplier")
123
+ print(supplier.as_document(namespace=ns))
124
+ # {'supplier_name': 'Example Supplier', 'disabled': 0, 'acme_source': 'acme', 'acme_ref': 'v-123'}
125
+ ```
126
+
127
+ Pick the namespace once per site. Documents written under one name are
128
+ invisible to readers under another, and the old fields and Webhooks stay on
129
+ the site.
130
+
105
131
  ## Reconciliation
106
132
 
107
133
  `icedcoffee_sync.apply` reconciles charts through the synchronous client.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "icedcoffee-sync"
3
- version = "0.1.0.dev11"
3
+ version = "0.1.0.dev13"
4
4
  description = "Typed sync and async client for the Frappe and ERPNext REST API"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Gavin Carothers", email = "gavin@carothers.name" }]
@@ -16,8 +16,6 @@ from typing import Any, Protocol
16
16
 
17
17
  ADD_COMMENT = "frappe.desk.form.utils.add_comment"
18
18
  DOCTYPE_COMMENT = "Comment"
19
- DEFAULT_EMAIL = "integration@example.com"
20
- DEFAULT_BY = "Transpire"
21
19
 
22
20
 
23
21
  class _Caller(Protocol):
@@ -31,10 +29,10 @@ def add_comment(
31
29
  name: str,
32
30
  content: str,
33
31
  *,
34
- email: str = DEFAULT_EMAIL,
35
- by: str = DEFAULT_BY,
32
+ email: str,
33
+ by: str,
36
34
  ) -> dict[str, Any]:
37
- """Add a Comment to `doctype`/`name`. Returns the created Comment document."""
35
+ """Add a Comment to `doctype`/`name`, signed `by` <`email`>. Returns the created Comment document."""
38
36
  return client.call(
39
37
  ADD_COMMENT,
40
38
  {
@@ -1,13 +1,11 @@
1
- """The ERPNext documents Transpire writes, as dataclasses with `as_document()`.
1
+ """The ERPNext documents an integration writes, as dataclasses with `as_document()`.
2
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.
3
+ One definition for every writer, so two programs writing to the same site
4
+ under one `Namespace` never drift on a field name or a marker value.
7
5
 
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.
6
+ Every document carries `<namespace>_source = <namespace>`, which is what keeps
7
+ a row the integration wrote distinguishable from a site's native data and from
8
+ anything a person types later.
11
9
  """
12
10
 
13
11
  from __future__ import annotations
@@ -15,16 +13,9 @@ from __future__ import annotations
15
13
  import dataclasses
16
14
  from typing import Any
17
15
 
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"
16
+ from icedcoffee_sync.erpnext.namespace import Namespace
22
17
 
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
18
+ # Present on every ERPNext site out of the box, so the integration needs no
28
19
  # site configuration of its own.
29
20
  DEFAULT_ITEM_GROUP = "All Item Groups"
30
21
  DEFAULT_UOM = "Nos"
@@ -32,7 +23,7 @@ DEFAULT_UOM = "Nos"
32
23
 
33
24
  @dataclasses.dataclass(frozen=True)
34
25
  class DesiredCustomField:
35
- """One Custom Field the connector needs before it can key anything.
26
+ """One Custom Field the integration needs before it can key anything.
36
27
 
37
28
  `document_name` is deterministic (`<dt>-<fieldname>`), which is what makes
38
29
  ensuring these idempotent without a query on anything but the name.
@@ -63,25 +54,32 @@ class DesiredCustomField:
63
54
  # Machine-owned: editable through the API, not through the form.
64
55
  "read_only": 1,
65
56
  # An amended document starts as a copy; carrying the marker onto
66
- # it would claim Transpire wrote a row it has never seen.
57
+ # it would claim the integration wrote a row it has never seen.
67
58
  "no_copy": 1,
68
59
  }
69
60
 
70
61
 
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
- )
62
+ def custom_fields(namespace: Namespace) -> tuple[DesiredCustomField, ...]:
63
+ """Every Custom Field the documents below write into, named for `namespace`."""
64
+ ns = namespace
65
+ return (
66
+ DesiredCustomField("Supplier", ns.source_field, f"{ns.label} Source", insert_after="supplier_name"),
67
+ supplier_ref_field(ns),
68
+ # An Item needs no ref field: its `item_code` IS the reference, and
69
+ # Frappe names the document from it. The source marker is what a site's
70
+ # own demo Items lack, which is what makes a reused one recognisable.
71
+ DesiredCustomField("Item", ns.source_field, f"{ns.label} Source", insert_after="item_name"),
72
+ DesiredCustomField("Purchase Order", ns.source_field, f"{ns.label} Source", insert_after="supplier"),
73
+ DesiredCustomField("Purchase Order", ns.ref_field, f"{ns.label} Order ID", insert_after=ns.source_field),
74
+ DesiredCustomField("Purchase Order", ns.order_no_field, "Vendor Order Number", insert_after=ns.ref_field),
75
+ )
76
+
82
77
 
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)
78
+ def supplier_ref_field(namespace: Namespace) -> DesiredCustomField:
79
+ """The Supplier's ref field on its own: what links a site's existing Supplier to the caller's vendor id."""
80
+ return DesiredCustomField(
81
+ "Supplier", namespace.ref_field, f"{namespace.label} Vendor ID", insert_after=namespace.source_field
82
+ )
85
83
 
86
84
 
87
85
  @dataclasses.dataclass(frozen=True, kw_only=True)
@@ -92,12 +90,12 @@ class DesiredSupplier:
92
90
  supplier_name: str
93
91
  disabled: bool = False
94
92
 
95
- def as_document(self) -> dict[str, Any]:
93
+ def as_document(self, *, namespace: Namespace) -> dict[str, Any]:
96
94
  return {
97
95
  "supplier_name": self.supplier_name,
98
96
  "disabled": 1 if self.disabled else 0,
99
- FIELD_SOURCE: SOURCE_MARKER,
100
- FIELD_REF: self.vendor_id,
97
+ namespace.source_field: namespace.marker,
98
+ namespace.ref_field: self.vendor_id,
101
99
  }
102
100
 
103
101
 
@@ -116,7 +114,7 @@ class DesiredItem:
116
114
  item_name: str
117
115
  description: str | None
118
116
 
119
- def as_document(self) -> dict[str, Any]:
117
+ def as_document(self, *, namespace: Namespace) -> dict[str, Any]:
120
118
  return {
121
119
  "item_code": self.item_code,
122
120
  "item_name": self.item_name,
@@ -124,7 +122,7 @@ class DesiredItem:
124
122
  "item_group": DEFAULT_ITEM_GROUP,
125
123
  "stock_uom": DEFAULT_UOM,
126
124
  "is_stock_item": 1,
127
- FIELD_SOURCE: SOURCE_MARKER,
125
+ namespace.source_field: namespace.marker,
128
126
  }
129
127
 
130
128
 
@@ -133,7 +131,7 @@ class DesiredPurchaseOrderItem:
133
131
  """One Purchase Order line.
134
132
 
135
133
  `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.
134
+ caller has no price it can vouch for: the integration never invents one.
137
135
  """
138
136
 
139
137
  item_code: str
@@ -167,13 +165,13 @@ class DesiredPurchaseOrder:
167
165
  schedule_date: str
168
166
  items: tuple[DesiredPurchaseOrderItem, ...]
169
167
 
170
- def as_document(self, supplier: str) -> dict[str, Any]:
168
+ def as_document(self, supplier: str, *, namespace: Namespace) -> dict[str, Any]:
171
169
  return {
172
170
  "supplier": supplier,
173
171
  "transaction_date": self.transaction_date,
174
172
  "schedule_date": self.schedule_date,
175
173
  "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 "",
174
+ namespace.source_field: namespace.marker,
175
+ namespace.ref_field: self.order_id,
176
+ namespace.order_no_field: self.order_number or "",
179
177
  }
@@ -0,0 +1,64 @@
1
+ """The names an integration writes onto a site under, supplied by the caller.
2
+
3
+ An integration that writes to an ERPNext site leaves its own name there: the
4
+ Custom Fields that mark the rows it wrote, the marker value in them, the names
5
+ of the Webhooks it provisions, and the header that tells its receiver which
6
+ site a delivery came from. None of those belong to this library, so every
7
+ function that writes or reads them takes a `Namespace`.
8
+
9
+ Changing a namespace for a site that already has documents written under it
10
+ orphans them: the old Custom Fields and Webhooks stay on the site under the old
11
+ names, and nothing here recognises them any more.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import re
18
+
19
+ # A Frappe fieldname: lowercase, starting with a letter. The namespace name
20
+ # prefixes every Custom Field, so it has to be one too.
21
+ _FIELDNAME = re.compile(r"[a-z][a-z0-9_]*")
22
+ # An HTTP header name (RFC 9110 token), narrowed to what a person would type.
23
+ _HEADER = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]*")
24
+
25
+
26
+ @dataclasses.dataclass(frozen=True)
27
+ class Namespace:
28
+ """Who is writing to the site.
29
+
30
+ `name` prefixes every Custom Field (`<name>_source`, `<name>_ref`,
31
+ `<name>_order_no`) and every Webhook name, and is the marker value stamped
32
+ into `<name>_source`. `label` prefixes the Custom Fields' labels as a
33
+ person sees them on the form. `site_header` is the request header each
34
+ Webhook carries the caller's site id in.
35
+ """
36
+
37
+ name: str
38
+ label: str
39
+ site_header: str
40
+
41
+ def __post_init__(self) -> None:
42
+ if not _FIELDNAME.fullmatch(self.name):
43
+ raise ValueError(f"namespace name {self.name!r} is not a Frappe fieldname (lowercase, letters first)")
44
+ if not self.label.strip():
45
+ raise ValueError("namespace label is empty")
46
+ if not _HEADER.fullmatch(self.site_header):
47
+ raise ValueError(f"site header {self.site_header!r} is not an HTTP header name")
48
+
49
+ @property
50
+ def marker(self) -> str:
51
+ """The value stamped into `source_field` on every document written."""
52
+ return self.name
53
+
54
+ @property
55
+ def source_field(self) -> str:
56
+ return f"{self.name}_source"
57
+
58
+ @property
59
+ def ref_field(self) -> str:
60
+ return f"{self.name}_ref"
61
+
62
+ @property
63
+ def order_no_field(self) -> str:
64
+ return f"{self.name}_order_no"
@@ -12,6 +12,8 @@ import dataclasses
12
12
  from collections.abc import Iterator
13
13
  from typing import Any, Protocol
14
14
 
15
+ from icedcoffee_sync.erpnext.namespace import Namespace
16
+
15
17
  DOCTYPE_PURCHASE_ORDER = "Purchase Order"
16
18
  DOCTYPE_PURCHASE_RECEIPT = "Purchase Receipt"
17
19
  DEFAULT_UOM = "Nos"
@@ -57,7 +59,13 @@ class PurchaseOrderItem:
57
59
 
58
60
  @dataclasses.dataclass(frozen=True)
59
61
  class PurchaseOrder:
60
- """A Purchase Order as read from the site."""
62
+ """A Purchase Order as read from the site.
63
+
64
+ The `source_*` fields are the reader's namespace fields: `source_marker`
65
+ is the namespace's marker when the reader's integration wrote the order,
66
+ `source_ref` the order id it wrote it under, and `source_order_no` the
67
+ vendor's order number it carried.
68
+ """
61
69
 
62
70
  name: str
63
71
  supplier: str
@@ -73,9 +81,9 @@ class PurchaseOrder:
73
81
  order_confirmation_date: str | None
74
82
  amended_from: str | None
75
83
  modified: str
76
- transpire_source: str | None
77
- transpire_ref: str | None
78
- transpire_order_no: str | None
84
+ source_marker: str | None
85
+ source_ref: str | None
86
+ source_order_no: str | None
79
87
  items: tuple[PurchaseOrderItem, ...]
80
88
 
81
89
  @property
@@ -87,8 +95,8 @@ class PurchaseOrder:
87
95
  return self.docstatus == 2
88
96
 
89
97
 
90
- def purchase_order_from_document(doc: dict[str, Any]) -> PurchaseOrder:
91
- """Type a raw `Purchase Order` document. Pure."""
98
+ def purchase_order_from_document(doc: dict[str, Any], namespace: Namespace) -> PurchaseOrder:
99
+ """Type a raw `Purchase Order` document, reading `namespace`'s fields. Pure."""
92
100
  items = tuple(
93
101
  PurchaseOrderItem(
94
102
  name=str(row.get("name", "")),
@@ -119,16 +127,16 @@ def purchase_order_from_document(doc: dict[str, Any]) -> PurchaseOrder:
119
127
  order_confirmation_date=_text(doc.get("order_confirmation_date")),
120
128
  amended_from=_text(doc.get("amended_from")),
121
129
  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")),
130
+ source_marker=_text(doc.get(namespace.source_field)),
131
+ source_ref=_text(doc.get(namespace.ref_field)),
132
+ source_order_no=_text(doc.get(namespace.order_no_field)),
125
133
  items=items,
126
134
  )
127
135
 
128
136
 
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))
137
+ def read_purchase_order(client: _Getter, name: str, namespace: Namespace) -> PurchaseOrder:
138
+ """Fetch and type one Purchase Order, reading `namespace`'s fields."""
139
+ return purchase_order_from_document(client.get(DOCTYPE_PURCHASE_ORDER, name), namespace)
132
140
 
133
141
 
134
142
  def list_submitted_purchase_orders(
@@ -2,9 +2,9 @@
2
2
 
3
3
  A Webhook fires from `Document.run_method`, so a Purchase Order submitted from
4
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.
5
+ two-field template naming the document; the receiver fetches the rest. The
6
+ caller's site id rides in the namespace's site header, so one receiving URL can
7
+ tell which site's secret signs the request without a per-site route.
8
8
 
9
9
  Field names are ERPNext v16's (`frappe/integrations/doctype/webhook/webhook.json`
10
10
  on version-16-hotfix). Frappe names a Webhook by prompt, so the caller sets
@@ -18,12 +18,12 @@ from collections.abc import Iterable
18
18
  from typing import Any, Protocol
19
19
 
20
20
  from icedcoffee_sync.erpnext.ensure import ensure_by_name
21
+ from icedcoffee_sync.erpnext.namespace import Namespace
21
22
 
22
23
  DOCTYPE_WEBHOOK = "Webhook"
23
24
  PURCHASE_ORDER = "Purchase Order"
24
25
  PURCHASE_ORDER_EVENTS: tuple[str, ...] = ("on_submit", "on_update_after_submit", "on_cancel")
25
26
 
26
- SITE_HEADER = "X-Transpire-Site"
27
27
  TIMEOUT_SECONDS = 10
28
28
 
29
29
  # A doorbell, not the data. Frappe renders this Jinja against the document.
@@ -36,20 +36,21 @@ class _Writer(Protocol):
36
36
  def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
37
37
 
38
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."""
39
+ def webhook_name(namespace: Namespace, site_id: str, doctype: str, event: str) -> str:
40
+ """`<namespace>-<site>-<doctype slug>-<event>`; deterministic, so ensure is get-then-insert."""
41
41
  slug = doctype.lower().replace(" ", "-")
42
- return f"transpire-{site_id}-{slug}-{event}"
42
+ return f"{namespace.name}-{site_id}-{slug}-{event}"
43
43
 
44
44
 
45
45
  @dataclasses.dataclass(frozen=True)
46
46
  class DesiredWebhook:
47
- """One Webhook document as Transpire wants it on the site."""
47
+ """One Webhook document as the integration wants it on the site."""
48
48
 
49
49
  name: str
50
50
  doctype: str
51
51
  event: str
52
52
  request_url: str
53
+ site_header: str
53
54
  site_id: str
54
55
  secret: str = dataclasses.field(repr=False)
55
56
  # None means "do not touch the switch": a Webhook this creates starts off
@@ -70,7 +71,7 @@ class DesiredWebhook:
70
71
  "webhook_json": DOORBELL_TEMPLATE,
71
72
  "webhook_headers": [
72
73
  {"key": "Content-Type", "value": "application/json"},
73
- {"key": SITE_HEADER, "value": self.site_id},
74
+ {"key": self.site_header, "value": self.site_id},
74
75
  ],
75
76
  "enable_security": 1,
76
77
  "webhook_secret": self.secret,
@@ -80,9 +81,9 @@ class DesiredWebhook:
80
81
 
81
82
 
82
83
  def purchase_order_webhooks(
83
- *, site_id: str, request_url: str, secret: str, enabled: bool | None = None
84
+ *, namespace: Namespace, site_id: str, request_url: str, secret: str, enabled: bool | None = None
84
85
  ) -> tuple[DesiredWebhook, ...]:
85
- """The three Purchase Order Webhooks the connector listens to.
86
+ """The three Purchase Order Webhooks a receiver listens to, named and headed for `namespace`.
86
87
 
87
88
  `enabled` left None creates them off and leaves existing ones as they are;
88
89
  re-running provisioning with it True, once the inbound route and its topic
@@ -90,10 +91,11 @@ def purchase_order_webhooks(
90
91
  """
91
92
  return tuple(
92
93
  DesiredWebhook(
93
- name=webhook_name(site_id, PURCHASE_ORDER, event),
94
+ name=webhook_name(namespace, site_id, PURCHASE_ORDER, event),
94
95
  doctype=PURCHASE_ORDER,
95
96
  event=event,
96
97
  request_url=request_url,
98
+ site_header=namespace.site_header,
97
99
  site_id=site_id,
98
100
  secret=secret,
99
101
  enabled=enabled,
@@ -113,13 +115,13 @@ class WebhookReport:
113
115
  def ensure_webhooks(client: _Writer, webhooks: Iterable[DesiredWebhook]) -> WebhookReport:
114
116
  """Create every Webhook the site lacks; re-assert the machine-owned fields on the rest.
115
117
 
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`).
118
+ A Webhook named under the caller's namespace is the caller's, so an
119
+ existing one is brought back to the desired document -- URL, headers,
120
+ template, secret, timeout -- rather than left alone. That is what lets a
121
+ re-run apply a changed receiver URL or a re-derived secret after the master
122
+ secret rotates; the secret is a Password field Frappe never reads back, so
123
+ it is always written. The `enabled` switch is re-asserted only when the
124
+ desired document says which way (see `DesiredWebhook.enabled`).
123
125
 
124
126
  Returns:
125
127
  The names created and the names updated, each in input order.
@@ -245,7 +245,9 @@ detection.
245
245
  Captured 2026-09-03 by a one-off script against `example-demo-sites.invalid`
246
246
  (Acmebike's demo ERPNext site): `GET /api/v2/document/Purchase Order/<name>` for
247
247
  the Purchase Order carrying `transpire_order_no` `0001ABCD`, written raw and
248
- unstripped, as `read_purchase_order` needs to parse the real shape.
248
+ unstripped, as `read_purchase_order` needs to parse the real shape. The order
249
+ was written under the namespace `transpire`, so its `transpire_*` fields are
250
+ what a reader in that namespace sees and a reader in any other sees as absent.
249
251
 
250
252
  Two fields were edited by hand after capture: **`docstatus`** (`0` → `1`) and
251
253
  **`status`** (`"Draft"` → `"To Receive and Bill"`). The captured PO is still a
@@ -21,7 +21,9 @@ class Recorder:
21
21
 
22
22
  def test_add_comment_goes_through_add_comment_not_a_raw_insert() -> None:
23
23
  site = Recorder()
24
- out = add_comment(site, "Purchase Order", "PUR-ORD-1", "Tracking 1Z999 via UPS")
24
+ out = add_comment(
25
+ site, "Purchase Order", "PUR-ORD-1", "Tracking 1Z999 via UPS", email="sync@acme.example", by="Acme"
26
+ )
25
27
  assert out["name"] == "cmt-1"
26
28
  path, params = site.calls[0]
27
29
  assert path == "frappe.desk.form.utils.add_comment"
@@ -29,8 +31,8 @@ def test_add_comment_goes_through_add_comment_not_a_raw_insert() -> None:
29
31
  "reference_doctype": "Purchase Order",
30
32
  "reference_name": "PUR-ORD-1",
31
33
  "content": "Tracking 1Z999 via UPS",
32
- "comment_email": "integration@example.com",
33
- "comment_by": "Transpire",
34
+ "comment_email": "sync@acme.example",
35
+ "comment_by": "Acme",
34
36
  }
35
37
 
36
38
 
@@ -0,0 +1,68 @@
1
+ """The ERPNext document shapes an integration writes, under the caller's namespace."""
2
+
3
+ from icedcoffee_sync.erpnext import documents as d
4
+ from icedcoffee_sync.erpnext.namespace import Namespace
5
+
6
+ ACME = Namespace(name="acme", label="Acme", site_header="X-Acme-Site")
7
+
8
+
9
+ def test_custom_field_is_named_the_way_frappe_names_it() -> None:
10
+ field = d.DesiredCustomField(dt="Supplier", fieldname="acme_ref", label="Acme Ref", insert_after="supplier_name")
11
+ assert field.document_name == "Supplier-acme_ref"
12
+ doc = field.as_document()
13
+ assert doc["dt"] == "Supplier" and doc["fieldname"] == "acme_ref"
14
+ assert doc["read_only"] == 1 and doc["no_copy"] == 1
15
+ assert "unique" not in doc
16
+
17
+
18
+ def test_every_pushed_document_carries_the_source_marker() -> None:
19
+ supplier = d.DesiredSupplier(vendor_id="v1", supplier_name="Shimano")
20
+ item = d.DesiredItem(item_code="CN-1", item_name="CHAIN", description="CHAIN")
21
+ line = d.DesiredPurchaseOrderItem(item_code="CN-1", qty=2, schedule_date="2026-04-17")
22
+ order = d.DesiredPurchaseOrder(
23
+ order_id="o1",
24
+ vendor_id="v1",
25
+ order_number="12672682",
26
+ transaction_date="2026-04-17",
27
+ schedule_date="2026-04-17",
28
+ items=(line,),
29
+ )
30
+ for doc in (
31
+ supplier.as_document(namespace=ACME),
32
+ item.as_document(namespace=ACME),
33
+ order.as_document("Shimano", namespace=ACME),
34
+ ):
35
+ assert doc["acme_source"] == "acme"
36
+ assert supplier.as_document(namespace=ACME)["acme_ref"] == "v1"
37
+ assert order.as_document("Shimano", namespace=ACME)["acme_ref"] == "o1"
38
+ assert order.as_document("Shimano", namespace=ACME)["acme_order_no"] == "12672682"
39
+
40
+
41
+ def test_the_custom_fields_are_named_and_labelled_for_the_namespace() -> None:
42
+ fields = d.custom_fields(ACME)
43
+ assert [(f.dt, f.fieldname, f.label) for f in fields] == [
44
+ ("Supplier", "acme_source", "Acme Source"),
45
+ ("Supplier", "acme_ref", "Acme Vendor ID"),
46
+ ("Item", "acme_source", "Acme Source"),
47
+ ("Purchase Order", "acme_source", "Acme Source"),
48
+ ("Purchase Order", "acme_ref", "Acme Order ID"),
49
+ ("Purchase Order", "acme_order_no", "Vendor Order Number"),
50
+ ]
51
+ # Each field the documents write into is one the site is given.
52
+ written = {
53
+ *d.DesiredSupplier(vendor_id="v1", supplier_name="Shimano").as_document(namespace=ACME),
54
+ } - {"supplier_name", "disabled"}
55
+ assert written <= {f.fieldname for f in fields if f.dt == "Supplier"}
56
+
57
+
58
+ def test_supplier_ref_field_is_one_of_the_custom_fields() -> None:
59
+ assert d.supplier_ref_field(ACME) in d.custom_fields(ACME)
60
+ assert d.supplier_ref_field(ACME).document_name == "Supplier-acme_ref"
61
+
62
+
63
+ def test_an_item_is_stock_maintained() -> None:
64
+ # These are bicycle parts a shop holds in inventory, not services. ERPNext
65
+ # writes no stock ledger entry for an item with Maintain Stock off, so a
66
+ # Purchase Receipt against one moves nothing.
67
+ item = d.DesiredItem(item_code="CN-1", item_name="CHAIN", description="CHAIN")
68
+ assert item.as_document(namespace=ACME)["is_stock_item"] == 1
@@ -0,0 +1,28 @@
1
+ """The caller's namespace: every name an integration leaves on a site."""
2
+
3
+ import pytest
4
+
5
+ from icedcoffee_sync.erpnext.namespace import Namespace
6
+
7
+
8
+ def test_the_field_names_and_marker_come_from_the_name() -> None:
9
+ ns = Namespace(name="acme", label="Acme", site_header="X-Acme-Site")
10
+ assert ns.marker == "acme"
11
+ assert (ns.source_field, ns.ref_field, ns.order_no_field) == ("acme_source", "acme_ref", "acme_order_no")
12
+
13
+
14
+ @pytest.mark.parametrize("name", ["", "Acme", "1acme", "acme-sync", "acme sync", "_acme"])
15
+ def test_a_name_that_is_not_a_frappe_fieldname_is_refused(name: str) -> None:
16
+ with pytest.raises(ValueError, match="not a Frappe fieldname"):
17
+ Namespace(name=name, label="Acme", site_header="X-Acme-Site")
18
+
19
+
20
+ def test_an_empty_label_is_refused() -> None:
21
+ with pytest.raises(ValueError, match="label is empty"):
22
+ Namespace(name="acme", label=" ", site_header="X-Acme-Site")
23
+
24
+
25
+ @pytest.mark.parametrize("header", ["", "X Acme Site", "X-Acme-Site:", "-X-Acme", "X_Acme"])
26
+ def test_a_site_header_that_is_not_a_header_name_is_refused(header: str) -> None:
27
+ with pytest.raises(ValueError, match="not an HTTP header name"):
28
+ Namespace(name="acme", label="Acme", site_header=header)
@@ -6,8 +6,11 @@ from collections.abc import Sequence
6
6
  from typing import Any
7
7
 
8
8
  from icedcoffee_sync.erpnext import purchase as p
9
+ from icedcoffee_sync.erpnext.namespace import Namespace
9
10
 
10
11
  FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "purchase_order_submitted.json"
12
+ # The namespace the fixture's order was written under (see fixtures/README.md).
13
+ WRITER = Namespace(name="transpire", label="Transpire", site_header="X-Transpire-Site")
11
14
 
12
15
 
13
16
  def _doc() -> dict[str, Any]:
@@ -15,21 +18,28 @@ def _doc() -> dict[str, Any]:
15
18
 
16
19
 
17
20
  def test_a_submitted_purchase_order_reads_into_a_typed_record() -> None:
18
- po = p.purchase_order_from_document(_doc())
21
+ po = p.purchase_order_from_document(_doc(), WRITER)
19
22
  assert po.name.startswith("PUR-ORD-")
20
23
  assert po.supplier == "SUP"
21
24
  assert po.company == "Acmebike Cycles" and po.currency == "USD"
22
25
  assert po.docstatus == 1 and po.is_submitted and not po.is_cancelled
23
- assert po.transpire_source == "transpire" and po.transpire_order_no == "0001ABCD"
26
+ assert po.source_marker == "transpire" and po.source_order_no == "0001ABCD"
27
+ assert po.source_ref == "ord_0123456789abcdef"
24
28
  assert po.order_confirmation_no is None
25
29
  assert po.amended_from is None
26
30
  assert po.items and po.items[0].item_code == "IT0001" and po.items[0].qty == 1.0 and po.items[0].uom == "Nos"
27
31
 
28
32
 
33
+ def test_another_namespace_reads_none_of_the_writers_fields() -> None:
34
+ other = Namespace(name="acme", label="Acme", site_header="X-Acme-Site")
35
+ po = p.purchase_order_from_document(_doc(), other)
36
+ assert (po.source_marker, po.source_ref, po.source_order_no) == (None, None, None)
37
+
38
+
29
39
  def test_empty_strings_read_as_none() -> None:
30
40
  doc = _doc()
31
41
  doc["order_confirmation_no"] = ""
32
- assert p.purchase_order_from_document(doc).order_confirmation_no is None
42
+ assert p.purchase_order_from_document(doc, WRITER).order_confirmation_no is None
33
43
 
34
44
 
35
45
  def test_set_order_confirmation_updates_exactly_two_fields() -> None:
@@ -1,23 +1,30 @@
1
- """The Frappe Webhook documents that ring phloem's doorbell."""
1
+ """The Frappe Webhook documents that ring a receiver's doorbell."""
2
2
 
3
3
  import json
4
4
 
5
5
  from icedcoffee_sync.erpnext import webhook as w
6
+ from icedcoffee_sync.erpnext.namespace import Namespace
7
+
8
+ ACME = Namespace(name="acme", label="Acme", site_header="X-Acme-Site")
6
9
 
7
10
  SECRET = "7dc9c9544e17e1867efde0506bca3dacaaa5d11f661560591bc1acf0924e8c86"
8
11
 
9
12
 
10
13
  def test_three_purchase_order_webhooks_named_deterministically() -> None:
11
- hooks = w.purchase_order_webhooks(site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET)
14
+ hooks = w.purchase_order_webhooks(
15
+ namespace=ACME, site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET
16
+ )
12
17
  assert [h.name for h in hooks] == [
13
- "transpire-site-1-purchase-order-on_submit",
14
- "transpire-site-1-purchase-order-on_update_after_submit",
15
- "transpire-site-1-purchase-order-on_cancel",
18
+ "acme-site-1-purchase-order-on_submit",
19
+ "acme-site-1-purchase-order-on_update_after_submit",
20
+ "acme-site-1-purchase-order-on_cancel",
16
21
  ]
17
22
 
18
23
 
19
24
  def test_the_document_uses_the_v16_field_names_and_a_two_field_json_template() -> None:
20
- hook = w.purchase_order_webhooks(site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET)[0]
25
+ hook = w.purchase_order_webhooks(
26
+ namespace=ACME, site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET
27
+ )[0]
21
28
  doc = hook.as_document()
22
29
  assert doc["name"] == hook.name
23
30
  assert doc["webhook_doctype"] == "Purchase Order"
@@ -29,12 +36,12 @@ def test_the_document_uses_the_v16_field_names_and_a_two_field_json_template() -
29
36
  # Disabled by default: deliveries start only once the inbound route exists.
30
37
  assert doc["enabled"] == 0 and doc["timeout"] == 10
31
38
  live = w.purchase_order_webhooks(
32
- site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET, enabled=True
39
+ namespace=ACME, site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET, enabled=True
33
40
  )
34
41
  assert live[0].as_document()["enabled"] == 1
35
42
  assert {h["key"]: h["value"] for h in doc["webhook_headers"]} == {
36
43
  "Content-Type": "application/json",
37
- "X-Transpire-Site": "site-1",
44
+ "X-Acme-Site": "site-1",
38
45
  }
39
46
  # The template is a doorbell: the document's identity and nothing else.
40
47
  rendered = doc["webhook_json"].replace("{{ doc.doctype }}", "Purchase Order").replace("{{ doc.name }}", "PUR-ORD-1")
@@ -42,7 +49,9 @@ def test_the_document_uses_the_v16_field_names_and_a_two_field_json_template() -
42
49
 
43
50
 
44
51
  def test_the_secret_never_appears_in_the_repr() -> None:
45
- hook = w.purchase_order_webhooks(site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET)[0]
52
+ hook = w.purchase_order_webhooks(
53
+ namespace=ACME, site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET
54
+ )[0]
46
55
  assert SECRET not in repr(hook)
47
56
 
48
57
 
@@ -51,7 +60,7 @@ def test_ensure_creates_the_missing_ones_and_reasserts_the_rest() -> None:
51
60
 
52
61
  class Site:
53
62
  def __init__(self) -> None:
54
- self.have = {"transpire-site-1-purchase-order-on_submit"}
63
+ self.have = {"acme-site-1-purchase-order-on_submit"}
55
64
  self.inserted: list[str] = []
56
65
  self.updated: list[tuple[str, dict]] = []
57
66
 
@@ -70,13 +79,15 @@ def test_ensure_creates_the_missing_ones_and_reasserts_the_rest() -> None:
70
79
  return {"name": name, **doc}
71
80
 
72
81
  site = Site()
73
- hooks = w.purchase_order_webhooks(site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET)
82
+ hooks = w.purchase_order_webhooks(
83
+ namespace=ACME, site_id="site-1", request_url="https://gw.example/frappe", secret=SECRET
84
+ )
74
85
  first = w.ensure_webhooks(site, hooks)
75
86
  assert first.created == [
76
- "transpire-site-1-purchase-order-on_update_after_submit",
77
- "transpire-site-1-purchase-order-on_cancel",
87
+ "acme-site-1-purchase-order-on_update_after_submit",
88
+ "acme-site-1-purchase-order-on_cancel",
78
89
  ]
79
- assert first.updated == ["transpire-site-1-purchase-order-on_submit"]
90
+ assert first.updated == ["acme-site-1-purchase-order-on_submit"]
80
91
  # The existing one was brought back to the desired document, secret included,
81
92
  # and its name was not sent as a field to change.
82
93
  _name, doc = site.updated[0]
@@ -85,7 +96,9 @@ def test_ensure_creates_the_missing_ones_and_reasserts_the_rest() -> None:
85
96
  # Nothing said which way the switch should go, so the existing Webhook keeps its own.
86
97
  assert "enabled" not in doc
87
98
 
88
- rotated = w.purchase_order_webhooks(site_id="site-1", request_url="https://gw2.example/frappe", secret="y" * 64)
99
+ rotated = w.purchase_order_webhooks(
100
+ namespace=ACME, site_id="site-1", request_url="https://gw2.example/frappe", secret="y" * 64
101
+ )
89
102
  second = w.ensure_webhooks(site, rotated)
90
103
  assert second.created == [] and len(second.updated) == 3
91
104
  assert all(doc["request_url"] == "https://gw2.example/frappe" for _, doc in site.updated[1:])
@@ -96,7 +109,11 @@ def test_ensure_creates_the_missing_ones_and_reasserts_the_rest() -> None:
96
109
  w.ensure_webhooks(
97
110
  site,
98
111
  w.purchase_order_webhooks(
99
- site_id="site-1", request_url="https://gw2.example/frappe", secret=SECRET, enabled=enabled
112
+ namespace=ACME,
113
+ site_id="site-1",
114
+ request_url="https://gw2.example/frappe",
115
+ secret=SECRET,
116
+ enabled=enabled,
100
117
  ),
101
118
  )
102
119
  assert {doc["enabled"] for _, doc in site.updated} == {expected}
@@ -236,9 +236,13 @@ def test_upload_file_attaches_a_private_file_to_a_document(client: Client) -> No
236
236
 
237
237
  def test_a_webhook_provisions_idempotently_and_is_deletable(client: Client) -> None:
238
238
  from icedcoffee_sync.erpnext import webhook as w
239
+ from icedcoffee_sync.erpnext.namespace import Namespace
239
240
 
241
+ ns = Namespace(name="icedcoffee_live", label="icedcoffee live", site_header="X-Icedcoffee-Live-Site")
240
242
  site_id = f"live-{uuid.uuid4().hex[:8]}"
241
- hooks = w.purchase_order_webhooks(site_id=site_id, request_url="https://example.invalid/frappe", secret="x" * 64)
243
+ hooks = w.purchase_order_webhooks(
244
+ namespace=ns, site_id=site_id, request_url="https://example.invalid/frappe", secret="x" * 64
245
+ )
242
246
  try:
243
247
  first = w.ensure_webhooks(client, hooks)
244
248
  assert first.created == [h.name for h in hooks]
@@ -246,7 +250,7 @@ def test_a_webhook_provisions_idempotently_and_is_deletable(client: Client) -> N
246
250
  assert second.created == [] and second.updated == [h.name for h in hooks]
247
251
  got = client.get(w.DOCTYPE_WEBHOOK, hooks[0].name)
248
252
  assert got["webhook_docevent"] == "on_submit"
249
- assert {h["key"] for h in got["webhook_headers"]} == {"Content-Type", w.SITE_HEADER}
253
+ assert {h["key"] for h in got["webhook_headers"]} == {"Content-Type", ns.site_header}
250
254
  finally:
251
255
  for hook in hooks:
252
256
  with contextlib.suppress(DoesNotExistError):
@@ -255,11 +259,13 @@ def test_a_webhook_provisions_idempotently_and_is_deletable(client: Client) -> N
255
259
 
256
260
  def test_submitted_purchase_orders_list_and_read(client: Client) -> None:
257
261
  from icedcoffee_sync.erpnext import purchase as p
262
+ from icedcoffee_sync.erpnext.namespace import Namespace
258
263
 
264
+ ns = Namespace(name="icedcoffee_live", label="icedcoffee live", site_header="X-Icedcoffee-Live-Site")
259
265
  rows = list(p.list_submitted_purchase_orders(client, modified_after=None, page_size=5))
260
266
  if not rows:
261
267
  pytest.skip("test site holds no submitted Purchase Orders")
262
- po = p.read_purchase_order(client, rows[0]["name"])
268
+ po = p.read_purchase_order(client, rows[0]["name"], ns)
263
269
  # The listing deliberately includes cancelled documents, so the first row
264
270
  # may be either; what matters is that a finalized document reads with lines.
265
271
  assert (po.is_submitted or po.is_cancelled) and po.items
@@ -270,6 +276,6 @@ def test_a_comment_lands_on_the_document_and_lists_back(note: tuple[Client, str,
270
276
 
271
277
  client, name, _title = note
272
278
  text = f"icedcoffee-sync live comment {uuid.uuid4()}"
273
- created = add_comment(client, "Note", name, text)
279
+ created = add_comment(client, "Note", name, text, email="integration@example.com", by="icedcoffee-sync")
274
280
  assert created["reference_name"] == name
275
281
  assert any(c["content"] == text for c in list_comments(client, "Note", name))
@@ -1,42 +0,0 @@
1
- """The ERPNext document shapes shared by graft and phloem."""
2
-
3
- from icedcoffee_sync.erpnext import documents as d
4
-
5
-
6
- def test_custom_field_is_named_the_way_frappe_names_it() -> None:
7
- field = d.DesiredCustomField(
8
- dt="Supplier", fieldname="transpire_ref", label="Transpire Ref", insert_after="supplier_name"
9
- )
10
- assert field.document_name == "Supplier-transpire_ref"
11
- doc = field.as_document()
12
- assert doc["dt"] == "Supplier" and doc["fieldname"] == "transpire_ref"
13
- assert doc["read_only"] == 1 and doc["no_copy"] == 1
14
- assert "unique" not in doc
15
-
16
-
17
- def test_every_pushed_document_carries_the_source_marker() -> None:
18
- supplier = d.DesiredSupplier(vendor_id="v1", supplier_name="Shimano")
19
- item = d.DesiredItem(item_code="CN-1", item_name="CHAIN", description="CHAIN")
20
- line = d.DesiredPurchaseOrderItem(item_code="CN-1", qty=2, schedule_date="2026-04-17")
21
- order = d.DesiredPurchaseOrder(
22
- order_id="o1",
23
- vendor_id="v1",
24
- order_number="12672682",
25
- transaction_date="2026-04-17",
26
- schedule_date="2026-04-17",
27
- items=(line,),
28
- )
29
- for doc in (supplier.as_document(), item.as_document(), order.as_document("Shimano")):
30
- assert doc[d.FIELD_SOURCE] == d.SOURCE_MARKER
31
-
32
-
33
- def test_supplier_ref_field_is_the_one_graft_creates() -> None:
34
- assert d.SUPPLIER_REF_FIELD in d.CUSTOM_FIELDS
35
- assert d.SUPPLIER_REF_FIELD.document_name == "Supplier-transpire_ref"
36
-
37
-
38
- def test_an_item_is_stock_maintained() -> None:
39
- # These are bicycle parts a shop holds in inventory, not services. ERPNext
40
- # writes no stock ledger entry for an item with Maintain Stock off, so a
41
- # Purchase Receipt against one moves nothing.
42
- assert d.DesiredItem(item_code="CN-1", item_name="CHAIN", description="CHAIN").as_document()["is_stock_item"] == 1