Skip to content

Commit ee956ef

Browse files
committed
refactor(sync): use ERPNext lifecycle for branch transactions; isolate per-branch masters
Branch transactions (Sales Invoice, Payment Entry, POS shifts) now ingest via real doc.insert() + doc.submit() instead of raw SQL — central runs the full ERPNext flow so SLE rows, GL entries, and Bin balances are generated locally. Stock Ledger Entry sync is removed; central regenerates SLEs on submit. - Branch-prefixed naming series surfaced on Sync Site Config
1 parent 358da5d commit ee956ef

12 files changed

Lines changed: 329 additions & 108 deletions

File tree

pos_next/hooks.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,18 @@
170170
doc_events = {
171171
"Item": {
172172
"validate": "pos_next.validations.validate_item",
173-
"on_update": "pos_next.sync.hooks.notify_branches_of_master_change",
174-
"on_trash": "pos_next.sync.hooks.notify_branches_of_master_change",
173+
"before_insert": [
174+
"pos_next.sync.hooks_uuid.set_sync_uuid_if_missing",
175+
"pos_next.sync.hooks_uuid.set_origin_branch_if_missing",
176+
],
177+
"on_update": [
178+
"pos_next.sync.hooks.notify_branches_of_master_change",
179+
"pos_next.sync.hooks_outbox.enqueue_to_outbox",
180+
],
181+
"on_trash": [
182+
"pos_next.sync.hooks.notify_branches_of_master_change",
183+
"pos_next.sync.hooks_outbox.enqueue_to_outbox",
184+
],
175185
},
176186
"Customer": {
177187
"before_insert": [
@@ -222,13 +232,6 @@
222232
"on_submit": "pos_next.sync.hooks_outbox.enqueue_to_outbox",
223233
"on_cancel": "pos_next.sync.hooks_outbox.enqueue_to_outbox",
224234
},
225-
"Stock Ledger Entry": {
226-
"before_insert": [
227-
"pos_next.sync.hooks_uuid.set_sync_uuid_if_missing",
228-
"pos_next.sync.hooks_uuid.set_origin_branch_if_missing",
229-
],
230-
"after_insert": "pos_next.sync.hooks_outbox.enqueue_to_outbox",
231-
},
232235
"POS Opening Shift": {
233236
"before_insert": [
234237
"pos_next.sync.hooks_uuid.set_sync_uuid_if_missing",

pos_next/pos_next/doctype/sync_doctype_rule/sync_doctype_rule.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"conflict_rule",
1111
"priority",
1212
"batch_size",
13-
"enabled"
13+
"enabled",
14+
"branch_filter_field"
1415
],
1516
"fields": [
1617
{
@@ -63,6 +64,12 @@
6364
"fieldtype": "Check",
6465
"in_list_view": 1,
6566
"label": "Enabled"
67+
},
68+
{
69+
"fieldname": "branch_filter_field",
70+
"fieldtype": "Data",
71+
"label": "Branch Filter Field",
72+
"description": "Optional. When set, central's changes_since restricts records returned to a branch to those whose <fieldname> equals the branch_code. Leave blank for global masters."
6673
}
6774
],
6875
"index_web_pages_for_search": 0,

pos_next/pos_next/doctype/sync_site_config/sync_site_config.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,46 @@
11
// Copyright (c) 2026, BrainWise and contributors
22
// For license information, please see license.txt
33

4+
const NAMING_SERIES_PREFIXES = [
5+
["sales_invoice_naming_series", "SINV"],
6+
["payment_entry_naming_series", "PE"],
7+
["pos_opening_shift_naming_series", "POS-OS"],
8+
["pos_closing_shift_naming_series", "POS-CS"],
9+
];
10+
11+
function compute_naming_defaults(branch_code) {
12+
const defaults = {};
13+
for (const [field, prefix] of NAMING_SERIES_PREFIXES) {
14+
defaults[field] = `${prefix}-${branch_code}-.YYYY.-.#####`;
15+
}
16+
return defaults;
17+
}
18+
19+
function autofill_naming_series(frm) {
20+
if (frm.doc.site_role !== "Branch") return;
21+
const code = (frm.doc.branch_code || "").trim();
22+
if (!code) return;
23+
24+
const new_defaults = compute_naming_defaults(code);
25+
// Track the last code we autofilled for so we can detect "still the
26+
// previous default" vs. "admin customized" on subsequent branch_code changes.
27+
const old_code = (frm._last_autofill_branch_code || "").trim();
28+
const old_defaults = old_code ? compute_naming_defaults(old_code) : {};
29+
30+
for (const [field, _prefix] of NAMING_SERIES_PREFIXES) {
31+
const current = frm.doc[field] || "";
32+
const is_blank = !current;
33+
const is_old_default = old_code && current === old_defaults[field];
34+
if (is_blank || is_old_default) {
35+
frm.set_value(field, new_defaults[field]);
36+
}
37+
}
38+
frm._last_autofill_branch_code = code;
39+
}
40+
441
frappe.ui.form.on("Sync Site Config", {
542
refresh(frm) {
43+
autofill_naming_series(frm);
644
if (frm.doc.site_role === "Branch" && !frm.is_new()) {
745
frm.add_custom_button(__("Test Sync Connection"), () => {
846
frappe.call({
@@ -30,6 +68,14 @@ frappe.ui.form.on("Sync Site Config", {
3068
}
3169
},
3270

71+
branch_code(frm) {
72+
autofill_naming_series(frm);
73+
},
74+
75+
site_role(frm) {
76+
autofill_naming_series(frm);
77+
},
78+
3379
load_sync_dashboard(frm) {
3480
frappe.call({
3581
method: "pos_next.sync.api.status.get_sync_status",

pos_next/pos_next/doctype/sync_site_config/sync_site_config.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
"push_interval_seconds",
1818
"pull_masters_interval_seconds",
1919
"pull_failover_interval_seconds",
20+
"section_break_naming_series",
21+
"sales_invoice_naming_series",
22+
"payment_entry_naming_series",
23+
"pos_opening_shift_naming_series",
24+
"pos_closing_shift_naming_series",
2025
"section_break_status",
2126
"last_push_at",
2227
"last_pull_masters_at",
@@ -110,6 +115,42 @@
110115
"fieldtype": "Int",
111116
"label": "Pull Failover Interval (seconds)"
112117
},
118+
{
119+
"collapsible": 1,
120+
"depends_on": "eval:doc.site_role==\"Branch\"",
121+
"fieldname": "section_break_naming_series",
122+
"fieldtype": "Section Break",
123+
"label": "Branch Naming Series",
124+
"description": "Branch-prefixed series installed onto each transactional DocType so names can't collide with central's local series. Edit the patterns to taste; blank means leave that DocType alone."
125+
},
126+
{
127+
"depends_on": "eval:doc.site_role==\"Branch\"",
128+
"fieldname": "sales_invoice_naming_series",
129+
"fieldtype": "Data",
130+
"label": "Sales Invoice Naming Series",
131+
"description": "e.g. SINV-CAI-.YYYY.-.##### — .YYYY. and .##### are Frappe naming-series tokens. Saved as the default option for Sales Invoice on this site."
132+
},
133+
{
134+
"depends_on": "eval:doc.site_role==\"Branch\"",
135+
"fieldname": "payment_entry_naming_series",
136+
"fieldtype": "Data",
137+
"label": "Payment Entry Naming Series",
138+
"description": "e.g. PE-CAI-.YYYY.-.#####"
139+
},
140+
{
141+
"depends_on": "eval:doc.site_role==\"Branch\"",
142+
"fieldname": "pos_opening_shift_naming_series",
143+
"fieldtype": "Data",
144+
"label": "POS Opening Shift Naming Series",
145+
"description": "e.g. POS-OS-CAI-.YYYY.-.#####"
146+
},
147+
{
148+
"depends_on": "eval:doc.site_role==\"Branch\"",
149+
"fieldname": "pos_closing_shift_naming_series",
150+
"fieldtype": "Data",
151+
"label": "POS Closing Shift Naming Series",
152+
"description": "e.g. POS-CS-CAI-.YYYY.-.#####"
153+
},
113154
{
114155
"collapsible": 1,
115156
"fieldname": "section_break_status",

pos_next/pos_next/doctype/sync_site_config/sync_site_config.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def validate(self):
2828
self._validate_cardinality()
2929
self._validate_https_url()
3030
self._validate_branch_code()
31+
self._autofill_naming_series_defaults()
3132

3233
def _validate_cardinality(self):
3334
"""A Branch-role record must be singleton; Central allows many."""
@@ -81,6 +82,103 @@ def after_insert(self):
8182
from pos_next.sync.seeds import apply_seeds_to_config
8283
apply_seeds_to_config(self)
8384

85+
def on_update(self):
86+
"""Push the naming-series fields onto the underlying DocTypes via Property Setter."""
87+
if self.site_role == "Branch":
88+
self._apply_branch_naming_series()
89+
90+
# Mapping between this doc's naming-series fields and the target DocType.
91+
# Edit/extend here to support new transactional doctypes.
92+
_NAMING_SERIES_FIELDS = (
93+
("sales_invoice_naming_series", "Sales Invoice"),
94+
("payment_entry_naming_series", "Payment Entry"),
95+
("pos_opening_shift_naming_series", "POS Opening Shift"),
96+
("pos_closing_shift_naming_series", "POS Closing Shift"),
97+
)
98+
99+
def _autofill_naming_series_defaults(self):
100+
"""
101+
Pre-fill blank naming-series fields with sensible branch-prefixed defaults.
102+
Runs on every validate so a freshly-created Branch row already shows the
103+
patterns the admin can review. Existing values are never overwritten.
104+
"""
105+
if self.site_role != "Branch" or not self.branch_code:
106+
return
107+
108+
defaults = {
109+
"sales_invoice_naming_series": f"SINV-{self.branch_code}-.YYYY.-.#####",
110+
"payment_entry_naming_series": f"PE-{self.branch_code}-.YYYY.-.#####",
111+
"pos_opening_shift_naming_series": f"POS-OS-{self.branch_code}-.YYYY.-.#####",
112+
"pos_closing_shift_naming_series": f"POS-CS-{self.branch_code}-.YYYY.-.#####",
113+
}
114+
for field, default in defaults.items():
115+
if not self.get(field):
116+
self.set(field, default)
117+
118+
def _apply_branch_naming_series(self):
119+
"""
120+
Install each non-blank naming-series field onto the matching DocType.
121+
Uses Frappe's standard Property Setter mechanism (same machinery that
122+
Customize Form uses), so the pattern shows up in the Sales Invoice etc.
123+
`naming_series` dropdown and is set as that DocType's default.
124+
"""
125+
for field, doctype in self._NAMING_SERIES_FIELDS:
126+
pattern = (self.get(field) or "").strip()
127+
if not pattern:
128+
continue
129+
try:
130+
_install_naming_series(doctype, pattern, default=True)
131+
except Exception as e:
132+
frappe.log_error(
133+
"Sync Branch Naming Series",
134+
f"Failed to install '{pattern}' on {doctype}: {e}",
135+
)
136+
137+
138+
def _install_naming_series(doctype, series, default=True):
139+
"""
140+
Append `series` to the doctype's `naming_series` Select options (via
141+
Property Setter) and optionally mark it the default.
142+
Idempotent: if the series is already present, only the default flag is
143+
updated.
144+
"""
145+
property_name = "options"
146+
field = "naming_series"
147+
148+
ps_name = frappe.db.get_value(
149+
"Property Setter",
150+
{"doc_type": doctype, "field_name": field, "property": property_name},
151+
"name",
152+
)
153+
154+
if ps_name:
155+
ps = frappe.get_doc("Property Setter", ps_name)
156+
current = (ps.value or "").splitlines()
157+
if series not in current:
158+
current.append(series)
159+
ps.value = "\n".join([s for s in current if s])
160+
ps.save(ignore_permissions=True)
161+
else:
162+
# Read the default options from the meta and prepend our series.
163+
meta = frappe.get_meta(doctype)
164+
df = meta.get_field(field)
165+
default_options = (df.options or "") if df else ""
166+
current = default_options.splitlines()
167+
if series not in current:
168+
current.append(series)
169+
frappe.get_doc({
170+
"doctype": "Property Setter",
171+
"doctype_or_field": "DocField",
172+
"doc_type": doctype,
173+
"field_name": field,
174+
"property": property_name,
175+
"property_type": "Text",
176+
"value": "\n".join([s for s in current if s]),
177+
}).insert(ignore_permissions=True)
178+
179+
if default:
180+
frappe.db.set_default(f"{field}:{doctype}", series)
181+
84182
@frappe.whitelist()
85183
def test_connection(self):
86184
"""
Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
# Copyright (c) 2026, BrainWise and contributors
22
# For license information, please see license.txt
33

4-
"""Adapter for Payment Entry."""
4+
"""Adapter for Payment Entry — currently uses the base submittable behavior."""
55

66
from pos_next.sync.adapters.submittable import SubmittableAdapter
7-
from pos_next.sync.payload import strip_meta
87
from pos_next.sync import registry
98

109

1110
class PaymentEntryAdapter(SubmittableAdapter):
1211
doctype = "Payment Entry"
1312

14-
def pre_apply_transform(self, payload):
15-
cleaned = strip_meta(payload)
16-
for key, val in cleaned.items():
17-
if isinstance(val, list):
18-
cleaned[key] = [strip_meta(row) if isinstance(row, dict) else row for row in val]
19-
return cleaned
20-
2113

2214
registry.register(PaymentEntryAdapter)
Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,14 @@
11
# Copyright (c) 2026, BrainWise and contributors
22
# For license information, please see license.txt
33

4-
"""Adapter for Sales Invoice — naming series validation, child tables."""
4+
"""Adapter for Sales Invoice — currently uses the base submittable behavior."""
55

6-
import frappe
76
from pos_next.sync.adapters.submittable import SubmittableAdapter
8-
from pos_next.sync.payload import strip_meta
9-
from pos_next.sync.exceptions import SyncValidationError
107
from pos_next.sync import registry
118

129

1310
class SalesInvoiceAdapter(SubmittableAdapter):
1411
doctype = "Sales Invoice"
1512

16-
def validate_incoming(self, payload):
17-
if not payload.get("origin_branch"):
18-
raise SyncValidationError(
19-
f"Sales Invoice {payload.get('name')} missing origin_branch — "
20-
"cannot accept invoice with unknown source branch"
21-
)
22-
23-
def pre_apply_transform(self, payload):
24-
cleaned = strip_meta(payload)
25-
for key, val in cleaned.items():
26-
if isinstance(val, list):
27-
cleaned[key] = [strip_meta(row) if isinstance(row, dict) else row for row in val]
28-
return cleaned
29-
3013

3114
registry.register(SalesInvoiceAdapter)

pos_next/sync/adapters/stock_ledger_entry.py

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)