icedcoffee-sync 0.1.0.dev9__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.
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/.gitignore +1 -0
- icedcoffee_sync-0.1.0.dev13/.kata.toml +4 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/PKG-INFO +27 -1
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/README.md +26 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/pyproject.toml +1 -1
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/comment.py +3 -5
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/documents.py +40 -42
- icedcoffee_sync-0.1.0.dev13/src/icedcoffee_sync/erpnext/namespace.py +64 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/purchase.py +20 -12
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/webhook.py +21 -19
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/README.md +3 -1
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_comment.py +5 -3
- icedcoffee_sync-0.1.0.dev13/tests/test_erpnext_documents.py +68 -0
- icedcoffee_sync-0.1.0.dev13/tests/test_erpnext_namespace.py +28 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_purchase.py +13 -3
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_webhook.py +33 -16
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_live_frappe.py +10 -4
- icedcoffee_sync-0.1.0.dev9/tests/test_erpnext_documents.py +0 -42
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/.github/workflows/ci.yml +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/.python-version +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/LICENSE +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/justfile +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/diff.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/execute.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/model.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/order.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/park.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/prune.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/accounts.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/company.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/ensure.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/access.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/client.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/errors.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/lifecycle.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/request.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/response.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/py.typed +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/site.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/capture_fixtures.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/duplicate_not_provoked.json +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/error_not_found.json +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/error_validation.json +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/get_ok.json +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/list_ok.json +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/permission_not_provoked.json +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/purchase_order_submitted.json +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_diff.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_execute.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_order.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_park.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_apply_prune.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_client.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_download_file.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_accounts.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_accounts_reads.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_company.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_ensure.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_errors.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_frappe_access.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_lifecycle.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_package.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_pagination.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_request.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_response.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_site.py +0 -0
- {icedcoffee_sync-0.1.0.dev9 → 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.
|
|
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.
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/comment.py
RENAMED
|
@@ -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
|
|
35
|
-
by: str
|
|
32
|
+
email: str,
|
|
33
|
+
by: str,
|
|
36
34
|
) -> dict[str, Any]:
|
|
37
|
-
"""Add a Comment to `doctype`/`name
|
|
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
|
{
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/documents.py
RENAMED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
"""The ERPNext documents
|
|
1
|
+
"""The ERPNext documents an integration writes, as dataclasses with `as_document()`.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
9
|
-
row
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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"
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/purchase.py
RENAMED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/webhook.py
RENAMED
|
@@ -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;
|
|
6
|
-
|
|
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
|
-
"""
|
|
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"
|
|
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
|
|
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":
|
|
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
|
|
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
|
|
117
|
-
the desired document -- URL, headers,
|
|
118
|
-
than left alone. That is what lets a
|
|
119
|
-
a re-derived secret after the master
|
|
120
|
-
Password field Frappe never reads back, so
|
|
121
|
-
`enabled` switch is re-asserted only when the
|
|
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(
|
|
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": "
|
|
33
|
-
"comment_by": "
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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(
|
|
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-
|
|
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(
|
|
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 = {"
|
|
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(
|
|
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
|
-
"
|
|
77
|
-
"
|
|
87
|
+
"acme-site-1-purchase-order-on_update_after_submit",
|
|
88
|
+
"acme-site-1-purchase-order-on_cancel",
|
|
78
89
|
]
|
|
79
|
-
assert first.updated == ["
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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",
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/__init__.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/diff.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/execute.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/model.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/order.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/park.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/apply/prune.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/__init__.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/accounts.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/company.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/erpnext/ensure.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/__init__.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/access.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/client.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/errors.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/lifecycle.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/request.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/src/icedcoffee_sync/frappe/response.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/error_not_found.json
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/fixtures/error_validation.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{icedcoffee_sync-0.1.0.dev9 → icedcoffee_sync-0.1.0.dev13}/tests/test_erpnext_accounts_reads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|