From 8f84bb3c7ab2ff7f896e07474d78e0832c06a047 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:54:17 +0700 Subject: [PATCH 01/17] feat: add Price Group ownership constants and managed state Ports the ownership model from the selling_additional app: a Price Group owns one generated Price List, the unscoped Item Price rows on it, and the POS Profiles matching its outlets. Owner fields are renamed to the custom_pos_next_* namespace since this is a different app. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/price_group_ownership.py | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 pos_next/price_group_ownership.py diff --git a/pos_next/price_group_ownership.py b/pos_next/price_group_ownership.py new file mode 100644 index 000000000..e20ee0845 --- /dev/null +++ b/pos_next/price_group_ownership.py @@ -0,0 +1,62 @@ +"""Ownership constants and field definitions for the Price Group feature. + +Ported from the `selling_additional` app. A Price Group owns exactly one generated +Price List (`PG-`), the unscoped Item Price rows on that list, and the POS +Profiles matching its outlets. Ownership is recorded in Custom Fields so a row can +always be traced back to the group that created it — and restored when the group is +disabled or deleted. +""" + +from dataclasses import dataclass +from types import MappingProxyType + +OWNER_FIELD = "custom_pos_next_price_group" +PRICE_LIST_OWNER_FIELD = OWNER_FIELD +ITEM_PRICE_OWNER_FIELD = OWNER_FIELD +PROFILE_OWNER_FIELD = OWNER_FIELD +PROFILE_PREVIOUS_PRICE_LIST_FIELD = "custom_pos_next_previous_price_list" +MANAGED_PRICE_LIST_PREFIX = "PG-" + +SCOPE_FIELDS = ("customer", "supplier", "batch_no", "valid_upto", "packing_unit") +"""Item Price fields that mark a row as SCOPED, and that can legitimately be empty. + +`valid_from` is deliberately NOT here even though `ItemPrice.check_duplicates` treats it as +a discriminator. It carries meta default `'Today'`, so every row Frappe inserts already has +a date and no managed row can ever match an empty-valued predicate on it: adding either +`["is", "not set"]` or `["in", [None, ""]]` selects zero rows, which would make every +managed identity look new and then fail in `check_duplicates`. A future-dated legacy row +that some other path marks is caught instead by the duplicate-identity throw in +`PriceGroup._sync_item_prices`. +""" + + +def managed_item_price_filters(price_group: str, price_list: str) -> dict: + """Filters selecting ONLY the unscoped Item Price rows this Price Group manages. + + Scoped rows (customer, supplier, batch, end date, packing unit) are never managed + even if some other path marked them, so every managed-row query must exclude them. + See SCOPE_FIELDS for why `valid_from` cannot be one of those predicates. + """ + filters = {"price_list": price_list, OWNER_FIELD: price_group} + for field in SCOPE_FIELDS: + filters[field] = ["in", [None, 0]] if field == "packing_unit" else ["is", "not set"] + return filters + + +@dataclass(frozen=True) +class ManagedState: + price_list_name: str + desired_profiles: tuple[str, ...] + currently_owned_profiles: tuple[str, ...] + all_profiles: tuple[str, ...] + managed_item_prices: tuple[str, ...] + outlet_profiles: MappingProxyType + + +def managed_price_list_name(price_group_name: str) -> str: + return f"{MANAGED_PRICE_LIST_PREFIX}{price_group_name}" + + +def owner_filters(price_group: str) -> dict: + """Filter dict selecting rows this Price Group owns via OWNER_FIELD.""" + return {OWNER_FIELD: price_group} From 2c48b2538099f762f5515f2a1c2f58ef3e52cc32 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:54:30 +0700 Subject: [PATCH 02/17] feat: add Price Group Item and Price Group Outlet child tables Both child tables exist only to be referenced by the Price Group parent, so they land together. Outlet carries read-only pos_profile and status columns the controller fills in on save. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../doctype/price_group_item/__init__.py | 0 .../price_group_item/price_group_item.json | 64 ++++++++++++++++++ .../price_group_item/price_group_item.py | 5 ++ .../doctype/price_group_outlet/__init__.py | 0 .../price_group_outlet.json | 65 +++++++++++++++++++ .../price_group_outlet/price_group_outlet.py | 5 ++ 6 files changed, 139 insertions(+) create mode 100644 pos_next/pos_next/doctype/price_group_item/__init__.py create mode 100644 pos_next/pos_next/doctype/price_group_item/price_group_item.json create mode 100644 pos_next/pos_next/doctype/price_group_item/price_group_item.py create mode 100644 pos_next/pos_next/doctype/price_group_outlet/__init__.py create mode 100644 pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.json create mode 100644 pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.py diff --git a/pos_next/pos_next/doctype/price_group_item/__init__.py b/pos_next/pos_next/doctype/price_group_item/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/price_group_item/price_group_item.json b/pos_next/pos_next/doctype/price_group_item/price_group_item.json new file mode 100644 index 000000000..212031b93 --- /dev/null +++ b/pos_next/pos_next/doctype/price_group_item/price_group_item.json @@ -0,0 +1,64 @@ +{ + "actions": [], + "creation": "2026-07-22 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "column_break_01", + "uom", + "rate" + ], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item", + "options": "Item", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "in_list_view": 0, + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "column_break_01", + "fieldtype": "Column Break" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "in_list_view": 0, + "label": "UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate", + "reqd": 1 + } + ], + "index_web_pages_for_search": 0, + "istable": 1, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Price Group Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/pos_next/pos_next/doctype/price_group_item/price_group_item.py b/pos_next/pos_next/doctype/price_group_item/price_group_item.py new file mode 100644 index 000000000..309c20481 --- /dev/null +++ b/pos_next/pos_next/doctype/price_group_item/price_group_item.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class PriceGroupItem(Document): + pass diff --git a/pos_next/pos_next/doctype/price_group_outlet/__init__.py b/pos_next/pos_next/doctype/price_group_outlet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.json b/pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.json new file mode 100644 index 000000000..a0efc39b7 --- /dev/null +++ b/pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "creation": "2026-07-22 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "warehouse", + "column_break_01", + "pos_profile", + "status" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse", + "reqd": 1 + }, + { + "fieldname": "column_break_01", + "fieldtype": "Column Break" + }, + { + "fieldname": "pos_profile", + "fieldtype": "Link", + "in_list_view": 0, + "label": "POS Profile", + "options": "POS Profile", + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "\nLinked\nNo POS Profile", + "read_only": 1 + } + ], + "index_web_pages_for_search": 0, + "istable": 1, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Price Group Outlet", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.py b/pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.py new file mode 100644 index 000000000..351e23fb2 --- /dev/null +++ b/pos_next/pos_next/doctype/price_group_outlet/price_group_outlet.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class PriceGroupOutlet(Document): + pass From 195e155776a9cce148752e8a3236ae8941416d9c Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:54:43 +0700 Subject: [PATCH 03/17] feat: add Price Group doctype with outlet-scoped price sync The controller owns one generated Price List (PG-), the unscoped Item Price rows on it, and the POS Profiles matching its outlets. All writes are targeted db.set_value rather than document saves: saving a Price List runs PriceList.on_update, which rewrites every Item Price on the list and can claim the global Selling default. Disabling keeps managed rows for re-enable; deleting restores every owned profile and retains the Price List disabled. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../pos_next/doctype/price_group/__init__.py | 0 .../doctype/price_group/price_group.js | 23 + .../doctype/price_group/price_group.json | 116 +++++ .../doctype/price_group/price_group.py | 467 ++++++++++++++++++ 4 files changed, 606 insertions(+) create mode 100644 pos_next/pos_next/doctype/price_group/__init__.py create mode 100644 pos_next/pos_next/doctype/price_group/price_group.js create mode 100644 pos_next/pos_next/doctype/price_group/price_group.json create mode 100644 pos_next/pos_next/doctype/price_group/price_group.py diff --git a/pos_next/pos_next/doctype/price_group/__init__.py b/pos_next/pos_next/doctype/price_group/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/price_group/price_group.js b/pos_next/pos_next/doctype/price_group/price_group.js new file mode 100644 index 000000000..f4583d7c8 --- /dev/null +++ b/pos_next/pos_next/doctype/price_group/price_group.js @@ -0,0 +1,23 @@ +frappe.ui.form.on("Price Group", { + refresh(frm) { + // Filter warehouse by company of each child row + frm.fields_dict.outlets.grid.get_field("warehouse").get_query = (doc, cdt, cdn) => { + const row = locals[cdt][cdn] + return { + filters: { + company: row.company || "", + is_group: 0, + }, + } + } + }, +}) + +frappe.ui.form.on("Price Group Outlet", { + company(frm, cdt, cdn) { + // Clear dependent fields when company changes + frappe.model.set_value(cdt, cdn, "warehouse", "") + frappe.model.set_value(cdt, cdn, "pos_profile", "") + frappe.model.set_value(cdt, cdn, "status", "") + }, +}) diff --git a/pos_next/pos_next/doctype/price_group/price_group.json b/pos_next/pos_next/doctype/price_group/price_group.json new file mode 100644 index 000000000..972f56eb9 --- /dev/null +++ b/pos_next/pos_next/doctype/price_group/price_group.json @@ -0,0 +1,116 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:price_group_name", + "creation": "2026-07-22 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "price_group_name", + "enabled", + "column_break_01", + "currency", + "price_list", + "section_break_items", + "items", + "section_break_outlets", + "outlets" + ], + "fields": [ + { + "fieldname": "price_group_name", + "fieldtype": "Data", + "label": "Price Group Name", + "reqd": 1, + "unique": 1 + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "column_break_01", + "fieldtype": "Column Break" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "reqd": 1 + }, + { + "fieldname": "price_list", + "fieldtype": "Link", + "label": "Price List", + "no_copy": 1, + "options": "Price List", + "read_only": 1 + }, + { + "fieldname": "section_break_items", + "fieldtype": "Section Break", + "label": "Items" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Price Group Item", + "reqd": 1 + }, + { + "fieldname": "section_break_outlets", + "fieldtype": "Section Break", + "label": "Outlets" + }, + { + "fieldname": "outlets", + "fieldtype": "Table", + "label": "Outlets", + "options": "Price Group Outlet" + } + ], + "index_web_pages_for_search": 0, + "is_submittable": 0, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Price Group", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Nexus POS Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "price_group_name", + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/price_group/price_group.py b/pos_next/pos_next/doctype/price_group/price_group.py new file mode 100644 index 000000000..b18542059 --- /dev/null +++ b/pos_next/pos_next/doctype/price_group/price_group.py @@ -0,0 +1,467 @@ +"""Price Group — outlet-scoped selling prices, ported from the `selling_additional` app. + +A Price Group owns one generated Price List (`PG-`), the unscoped Item Price rows +on that list, and every POS Profile matching its outlets. All writes are targeted +(`frappe.db.set_value`) rather than document saves: saving a Price List runs +`PriceList.on_update`, which rewrites every Item Price on the list and can claim the +global `Selling Settings.selling_price_list` default. +""" + +from types import MappingProxyType + +import frappe +from frappe import _ +from frappe.model.document import Document + +from pos_next.price_group_ownership import ( + ITEM_PRICE_OWNER_FIELD, + PRICE_LIST_OWNER_FIELD, + PROFILE_OWNER_FIELD, + PROFILE_PREVIOUS_PRICE_LIST_FIELD, + SCOPE_FIELDS, + ManagedState, + managed_item_price_filters, + managed_price_list_name, +) + + +class PriceGroup(Document): + def validate(self) -> None: + self._validate_items() + self._validate_outlets() + self._set_uom() + + def on_update(self) -> None: + state = self._lock_managed_state() + self._validate_managed_state(state) + self._validate_item_uom_conversions() + self._sync_price_list(state) + self._sync_item_prices(state) + self._sync_profiles(state) + + def on_trash(self) -> None: + state = self._lock_managed_state() + self._validate_managed_state(state, validate_desired=False) + self._cleanup(state) + + def _validate_items(self) -> None: + if not self.items: + frappe.throw(_("At least one item is required")) + + seen = set() + for row in self.items: + if not row.rate or row.rate <= 0: + frappe.throw(_("Row {0}: Rate must be greater than zero").format(row.idx)) + if row.item_code in seen: + frappe.throw(_("Row {0}: Duplicate item {1}").format(row.idx, row.item_code)) + seen.add(row.item_code) + + def _validate_outlets(self) -> None: + seen = set() + for row in self.outlets: + key = (row.company, row.warehouse) + if key in seen: + frappe.throw( + _("Row {0}: Duplicate outlet {1} / {2}").format(row.idx, row.company, row.warehouse) + ) + seen.add(key) + + # Verify warehouse belongs to company + wh_company = frappe.db.get_value("Warehouse", row.warehouse, "company") + if wh_company != row.company: + frappe.throw( + _("Row {0}: Warehouse {1} belongs to company {2}, not {3}").format( + row.idx, row.warehouse, wh_company, row.company + ) + ) + + def _set_uom(self) -> None: + for row in self.items: + if not frappe.db.exists("Item", row.item_code): + frappe.throw(_("Item {0} does not exist").format(row.item_code)) + # Managed identity is (item_code, uom) with uom DERIVED from the Item's current + # stock UOM on every save. Derive unconditionally, never fill-if-blank: the child + # field is read_only, and preserving a stale value is what prevents identity from + # moving after a stock-UOM change. + row.uom = frappe.db.get_value("Item", row.item_code, "stock_uom") + if not row.uom: + frappe.throw(_("Item {0} has no stock UOM").format(row.item_code)) + if not frappe.db.exists("UOM", row.uom): + frappe.throw(_("UOM {0} does not exist").format(row.uom)) + + def _currently_owned_profiles(self) -> list[str]: + if not self.name: + return [] + return sorted( + frappe.get_all( + "POS Profile", + filters={PROFILE_OWNER_FIELD: self.name}, + pluck="name", + ) + ) + + def _lock_managed_state(self) -> ManagedState: + # Lock order for BOTH on_update() and on_trash(): + # 1. Price Group row + if self.name and frappe.db.exists("Price Group", self.name): + frappe.db.get_value("Price Group", self.name, "modified", for_update=True) + + # 2. Resolve desired profile names and per-outlet mapping deterministically + desired_set: set[str] = set() + outlet_profiles_dict: dict[tuple[str, str], str | None] = {} + for row in self.outlets: + key = (row.company, row.warehouse) + if key not in outlet_profiles_dict: + matching = frappe.get_all( + "POS Profile", + filters={"company": row.company, "warehouse": row.warehouse}, + pluck="name", + order_by="name asc", + ) + chosen = matching[0] if matching else None + outlet_profiles_dict[key] = chosen + if chosen: + desired_set.add(chosen) + + desired = sorted(desired_set) + currently_owned = self._currently_owned_profiles() + all_profiles = sorted(set(desired) | set(currently_owned)) + + # 3. Lock the sorted union of those profile rows + for p_name in all_profiles: + frappe.db.get_value("POS Profile", p_name, "modified", for_update=True) + + # 4. Lock the managed Price List row when it exists + pl_name = managed_price_list_name(self.price_group_name) + if frappe.db.exists("Price List", pl_name): + frappe.db.get_value("Price List", pl_name, "modified", for_update=True) + + # 5. Query existing marked Item Price names using unscoped filters, sort them, lock each row + existing_marked_ips = [] + if self.name: + existing_marked_ips = sorted( + frappe.get_all( + "Item Price", + filters=managed_item_price_filters(self.name, pl_name), + pluck="name", + ) + ) + for ip_name in existing_marked_ips: + frappe.db.get_value("Item Price", ip_name, "modified", for_update=True) + + return ManagedState( + price_list_name=pl_name, + desired_profiles=tuple(desired), + currently_owned_profiles=tuple(currently_owned), + all_profiles=tuple(all_profiles), + managed_item_prices=tuple(existing_marked_ips), + outlet_profiles=MappingProxyType(outlet_profiles_dict), + ) + + def _validate_managed_state(self, state: ManagedState, *, validate_desired: bool = True) -> None: + # Read ownership values again from locked rows, validate collisions & invariants + pl_name = state.price_list_name + if frappe.db.exists("Price List", pl_name): + pl_owner = frappe.db.get_value("Price List", pl_name, PRICE_LIST_OWNER_FIELD) + if pl_owner and pl_owner != self.name: + frappe.throw(_("Price List {0} already exists and is owned by {1}").format(pl_name, pl_owner)) + if not pl_owner: + frappe.throw( + _("Price List {0} already exists and is not linked to this Price Group").format(pl_name) + ) + + # Desired-profile collisions block a save, but NOT a delete: delete is scoped to + # owned profiles, and a foreign-owned profile this group merely desires is untouched. + if validate_desired: + for p_name in state.desired_profiles: + p_owner = frappe.db.get_value("POS Profile", p_name, PROFILE_OWNER_FIELD) + if p_owner and p_owner != self.name: + frappe.throw( + _("POS Profile {0} is already claimed by Price Group {1}").format(p_name, p_owner) + ) + + for p_name in state.currently_owned_profiles: + p_owner = frappe.db.get_value("POS Profile", p_name, PROFILE_OWNER_FIELD) + if p_owner and p_owner != self.name: + frappe.throw( + _("POS Profile {0} is owned by {1}, expected {2}").format(p_name, p_owner, self.name) + ) + + def _validate_item_uom_conversions(self) -> None: + """Fail before any Item Price mutation when a resolved UOM has no conversion row. + + `ItemPrice.validate_item` requires a UOM Conversion Detail row for the Item and UOM + and throws MID-mutation, after the Price List already exists. Frappe normally + guarantees one for the stock UOM (`Item.add_default_uom_in_conversion_factor_table`), + so this only catches a hand-damaged Item — and names the Item and UOM when it does. + """ + for row in self.items: + if not row.uom: + continue + if not frappe.db.exists( + "UOM Conversion Detail", + {"parenttype": "Item", "parent": row.item_code, "uom": row.uom}, + ): + frappe.throw( + _("Conversion Factor for UOM {0} does not exist for Item {1}").format( + row.uom, row.item_code + ), + exc=frappe.ValidationError, + ) + + def _sync_price_list(self, state: ManagedState) -> None: + # Targeted writes avoid PriceList.on_update, which rewrites all Item Prices and may + # claim the global Selling default. + pl_name = state.price_list_name + if frappe.db.exists("Price List", pl_name): + frappe.db.set_value( + "Price List", + pl_name, + { + "enabled": 1 if self.enabled else 0, + "currency": self.currency, + PRICE_LIST_OWNER_FIELD: self.name, + }, + update_modified=False, + ) + frappe.cache.hdel("price_list_details", pl_name) + else: + # The row is usually ABSENT here (every restore below deletes it), so this + # FOR UPDATE takes a gap lock on (doctype, field) rather than a row lock. + # tabSingles has only a non-unique index and no primary key, and gap locks + # exist only under REPEATABLE READ. Under READ COMMITTED this degrades to an + # optimistic read-then-restore. + res = frappe.db.sql( + """select value from tabSingles + where doctype = 'Selling Settings' and field = 'selling_price_list' + for update""" + ) + prev_single = res[0][0] if res else None + + prev_default_rows = frappe.db.sql( + """select defvalue from tabDefaultValue + where parent = '__default' and defkey = 'selling_price_list'""" + ) + prev_default = prev_default_rows[0][0] if prev_default_rows else None + + pl = frappe.get_doc( + { + "doctype": "Price List", + "price_list_name": pl_name, + "selling": 1, + "buying": 0, + "currency": self.currency, + "enabled": 1 if self.enabled else 0, + PRICE_LIST_OWNER_FIELD: self.name, + } + ) + pl.insert(ignore_permissions=True) + + curr_single = frappe.db.get_single_value("Selling Settings", "selling_price_list") + if curr_single != prev_single and curr_single == pl_name: + if prev_single: + frappe.db.sql( + """update tabSingles set value = %s + where doctype = 'Selling Settings' and field = 'selling_price_list'""", + (prev_single,), + ) + else: + frappe.db.sql( + """delete from tabSingles + where doctype = 'Selling Settings' and field = 'selling_price_list'""" + ) + frappe.clear_document_cache("Selling Settings", "Selling Settings") + + curr_default_rows = frappe.db.sql( + """select defvalue from tabDefaultValue + where parent = '__default' and defkey = 'selling_price_list'""" + ) + curr_default = curr_default_rows[0][0] if curr_default_rows else None + if curr_default != prev_default and curr_default == pl_name: + if prev_default: + frappe.db.set_default("selling_price_list", prev_default) + else: + frappe.defaults.clear_default("selling_price_list", parent="__default") + + if self.price_list != pl_name: + self.db_set("price_list", pl_name, update_modified=False) + self.price_list = pl_name + + def _sync_item_prices(self, state: ManagedState) -> None: + if not self.enabled: + # Disabled: do not insert, update, or delete Item Prices. Keep all marked rows + # so a later re-enable restores the exact same prices. + return + + pl_name = state.price_list_name + desired_identities = {} + for row in self.items: + desired_identities[(row.item_code, row.uom)] = row.rate + + # Stale detection using ONLY already locked marked rows + existing_marked_by_ident = {} + for ip_name in state.managed_item_prices: + ident = frappe.db.get_value("Item Price", ip_name, ["item_code", "uom"], as_dict=True) + if ident: + if (ident.item_code, ident.uom) in existing_marked_by_ident: + frappe.throw( + _("Two managed Item Prices share identity ({0}, {1}) on {2}").format( + ident.item_code, ident.uom, pl_name + ) + ) + existing_marked_by_ident[(ident.item_code, ident.uom)] = ip_name + + # Precheck for colliding unmanaged unscoped Item Prices before mutating. + # Deliberately broader than ItemPrice.check_duplicates on valid_from: SCOPE_FIELDS omits + # it (see its docstring), so this matches an unmanaged row on ANY start date rather than + # only today's. That is the conservative direction — an open-ended unmanaged row makes + # price resolution on a managed list ambiguous whatever its start date. + # The uom predicate is normalization-aware: a legacy NULL/empty-uom row on this list + # normalizes to the Item's stock UOM — exactly the identity being inserted — and + # ItemPrice.check_duplicates treats NULL and '' as equivalent, so catching it here + # produces the named validation error instead of ERPNext's late duplicate exception. + for item_code, uom in desired_identities: + if (item_code, uom) in existing_marked_by_ident: + continue + colliding_filters = { + "price_list": pl_name, + "item_code": item_code, + "uom": ["in", [uom, None, ""]], + ITEM_PRICE_OWNER_FIELD: ["is", "not set"], + } + for field in SCOPE_FIELDS: + colliding_filters[field] = ["in", [None, 0]] if field == "packing_unit" else ["is", "not set"] + colliding = frappe.get_all("Item Price", filters=colliding_filters, pluck="name", limit=1) + if colliding: + frappe.throw( + _( + "An existing unmanaged Item Price {0} already covers Item {1} with UOM {2} " + "on Price List {3}. Resolve it before this Price Group can manage that item." + ).format(colliding[0], item_code, uom, pl_name) + ) + + # Update or insert desired marked rows + for (item_code, uom), rate in desired_identities.items(): + if (item_code, uom) in existing_marked_by_ident: + ip_name = existing_marked_by_ident[(item_code, uom)] + curr_rate = frappe.db.get_value("Item Price", ip_name, "price_list_rate") + if curr_rate != rate: + frappe.db.set_value( + "Item Price", + ip_name, + {"price_list_rate": rate, ITEM_PRICE_OWNER_FIELD: self.name}, + ) + else: + ip = frappe.get_doc( + { + "doctype": "Item Price", + "item_code": item_code, + "uom": uom, + "price_list": pl_name, + "price_list_rate": rate, + "currency": self.currency, + ITEM_PRICE_OWNER_FIELD: self.name, + } + ) + ip.insert(ignore_permissions=True) + + # Delete only marked rows not in desired identities + for (item_code, uom), ip_name in existing_marked_by_ident.items(): + if (item_code, uom) not in desired_identities: + frappe.delete_doc("Item Price", ip_name, ignore_permissions=True) + + def _restore_profile(self, p_name: str) -> None: + prev_pl = frappe.db.get_value("POS Profile", p_name, PROFILE_PREVIOUS_PRICE_LIST_FIELD) + frappe.db.set_value( + "POS Profile", + p_name, + { + "selling_price_list": prev_pl, + PROFILE_OWNER_FIELD: None, + PROFILE_PREVIOUS_PRICE_LIST_FIELD: None, + }, + update_modified=False, + ) + + def _sync_profiles(self, state: ManagedState) -> None: + # Targeted restore bypasses unrelated POS Profile validation while preserving the + # recorded prior list. + pl_name = state.price_list_name + desired_set = set(state.desired_profiles) if self.enabled else set() + + # Update outlet child table statuses & pos_profile links from the state snapshot + for row in self.outlets: + profile_name = state.outlet_profiles.get((row.company, row.warehouse)) + if not profile_name: + row.status = "No POS Profile" + row.pos_profile = None + else: + row.status = "Linked" + row.pos_profile = profile_name + + if row.name: + frappe.db.set_value( + "Price Group Outlet", + row.name, + {"status": row.status, "pos_profile": row.pos_profile}, + update_modified=False, + ) + + # Claim desired profiles + for p_name in desired_set: + p_owner = frappe.db.get_value("POS Profile", p_name, PROFILE_OWNER_FIELD) + current_pl = frappe.db.get_value("POS Profile", p_name, "selling_price_list") + if not p_owner: + # Empty owner: store current list once, set owner, assign managed list + frappe.db.set_value( + "POS Profile", + p_name, + { + PROFILE_PREVIOUS_PRICE_LIST_FIELD: current_pl, + PROFILE_OWNER_FIELD: self.name, + "selling_price_list": pl_name, + }, + update_modified=False, + ) + elif p_owner == self.name: + # Same owner: update only assigned list + if current_pl != pl_name: + frappe.db.set_value( + "POS Profile", + p_name, + {"selling_price_list": pl_name}, + update_modified=False, + ) + + # Restore currently owned profiles not desired (or all when disabled) + for p_name in state.currently_owned_profiles: + if p_name not in desired_set: + self._restore_profile(p_name) + + def _cleanup(self, state: ManagedState) -> None: + # Delete restores every owned profile, deletes only marked Item Prices, clears the + # Price List marker, then disables and RETAINS the Price List. + # Never force-delete and never delete the Price List. + + # 1. Restore every owned profile + for p_name in state.currently_owned_profiles: + self._restore_profile(p_name) + + # 2. Delete only marked Item Prices + for ip_name in state.managed_item_prices: + if frappe.db.exists("Item Price", ip_name): + frappe.delete_doc("Item Price", ip_name, ignore_permissions=True) + + # 3. Clear Price List marker, disable and retain Price List + pl_name = state.price_list_name + if frappe.db.exists("Price List", pl_name): + frappe.db.set_value( + "Price List", + pl_name, + { + "enabled": 0, + PRICE_LIST_OWNER_FIELD: None, + }, + update_modified=False, + ) + frappe.cache.hdel("price_list_details", pl_name) From fadd201782c9af6bff466e50dd19117b35060da8 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:54:56 +0700 Subject: [PATCH 04/17] feat: upsert Price Group ownership custom fields on install and migrate hooks.py:fixtures exports only Role and Custom DocPerm, so the four owner fields cannot ship as a fixture and are upserted idempotently instead. Validation is left on so CustomField.validate computes idx from insert_after and catches fieldname conflicts. uninstall removes the same four. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/install.py | 96 +++++++++++++++++++++++++++++++++++++++++++ pos_next/uninstall.py | 15 +++++++ 2 files changed, 111 insertions(+) diff --git a/pos_next/install.py b/pos_next/install.py index c749d4961..7ff6d0ee8 100644 --- a/pos_next/install.py +++ b/pos_next/install.py @@ -15,6 +15,13 @@ import frappe +from pos_next.price_group_ownership import ( + ITEM_PRICE_OWNER_FIELD, + PRICE_LIST_OWNER_FIELD, + PROFILE_OWNER_FIELD, + PROFILE_PREVIOUS_PRICE_LIST_FIELD, +) + # Configure logger logger = logging.getLogger(__name__) @@ -27,6 +34,8 @@ def after_install(): # Setup default print format for POS Profiles setup_default_print_format() + ensure_price_group_custom_fields() + # Clear cache to ensure changes take effect frappe.clear_cache() frappe.db.commit() @@ -51,6 +60,8 @@ def after_migrate(): # Setup default print format setup_default_print_format(quiet=True) + ensure_price_group_custom_fields() + # Clear cache frappe.clear_cache() frappe.db.commit() @@ -109,6 +120,91 @@ def setup_default_print_format(quiet=False): frappe.log_error(title="Default Print Format Setup Error", message=frappe.get_traceback()) +PRICE_GROUP_CUSTOM_FIELDS = { + "Price List": [ + { + "fieldname": PRICE_LIST_OWNER_FIELD, + "label": "Price Group", + "fieldtype": "Link", + "options": "Price Group", + "insert_after": "price_list_name", + "read_only": 1, + "no_copy": 1, + } + ], + "Item Price": [ + { + "fieldname": ITEM_PRICE_OWNER_FIELD, + "label": "Price Group", + "fieldtype": "Link", + "options": "Price Group", + "insert_after": "price_list", + "read_only": 1, + "no_copy": 1, + } + ], + "POS Profile": [ + { + "fieldname": PROFILE_OWNER_FIELD, + "label": "Price Group", + "fieldtype": "Link", + "options": "Price Group", + "insert_after": "selling_price_list", + "read_only": 1, + "no_copy": 1, + }, + { + "fieldname": PROFILE_PREVIOUS_PRICE_LIST_FIELD, + "label": "Previous Price List", + "fieldtype": "Link", + "options": "Price List", + "insert_after": PROFILE_OWNER_FIELD, + "read_only": 1, + "no_copy": 1, + }, + ], +} + + +def ensure_price_group_custom_fields(quiet=False): + """Create the Price Group ownership Custom Fields when missing. + + `hooks.py:fixtures` exports only Role and Custom DocPerm, so these fields cannot ship + as a fixture and must be upserted here on every install and migrate. + + Validation is NOT suppressed: `CustomField.validate` computes `idx` from `insert_after` + and runs `check_fieldname_conflicts`. Skipping it would leave every field at `idx = 0` + and hide a genuine fieldname collision. + """ + created = 0 + for dt, fields in PRICE_GROUP_CUSTOM_FIELDS.items(): + if not frappe.db.exists("DocType", dt): + log_message(f"DocType {dt} missing, skipping its Price Group fields", level="warning") + continue + for df in fields: + cf_name = f"{dt}-{df['fieldname']}" + if frappe.db.exists("Custom Field", cf_name): + continue + doc = frappe.get_doc( + { + "doctype": "Custom Field", + "dt": dt, + "permlevel": 0, + "hidden": 0, + "is_system_generated": 0, + "module": "POS Next", + **df, + } + ) + doc.insert(ignore_permissions=True) + created += 1 + if not quiet: + log_message(f"Created Custom Field: {cf_name}", level="info", indent=1) + + if created and not quiet: + log_message(f"Created {created} Price Group custom field(s)", level="success") + + def log_message(message, level="info", indent=0): """ Standardized logging function with consistent formatting. diff --git a/pos_next/uninstall.py b/pos_next/uninstall.py index cc8b078a4..4734aa3d9 100644 --- a/pos_next/uninstall.py +++ b/pos_next/uninstall.py @@ -6,6 +6,13 @@ import frappe +from pos_next.price_group_ownership import ( + ITEM_PRICE_OWNER_FIELD, + PRICE_LIST_OWNER_FIELD, + PROFILE_OWNER_FIELD, + PROFILE_PREVIOUS_PRICE_LIST_FIELD, +) + # Configure logger logger = logging.getLogger(__name__) @@ -51,6 +58,10 @@ def remove_custom_fields(): custom_fields = [ "Sales Invoice-posa_pos_opening_shift", "Sales Invoice-posa_is_printed", + f"Price List-{PRICE_LIST_OWNER_FIELD}", + f"Item Price-{ITEM_PRICE_OWNER_FIELD}", + f"POS Profile-{PROFILE_OWNER_FIELD}", + f"POS Profile-{PROFILE_PREVIOUS_PRICE_LIST_FIELD}", ] removed_count = 0 @@ -223,6 +234,10 @@ def get_custom_fields_for_cleanup(): custom_fields = [ "Sales Invoice-posa_pos_opening_shift", "Sales Invoice-posa_is_printed", + f"Price List-{PRICE_LIST_OWNER_FIELD}", + f"Item Price-{ITEM_PRICE_OWNER_FIELD}", + f"POS Profile-{PROFILE_OWNER_FIELD}", + f"POS Profile-{PROFILE_PREVIOUS_PRICE_LIST_FIELD}", ] return custom_fields From d77c621c9ad7c86d206e256121c4f3f24f98444b Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:55:10 +0700 Subject: [PATCH 05/17] chore: mirror Price Group ownership fields in custom review JSON custom/*.json is a review mirror only, never applied. The pos_profile.json entries are additive: the bench exporter drops custom_brands_table because its module is unset, so the two new fields were merged into the committed file by hand instead. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/pos_next/custom/item_price.json | 78 ++++++++++++ pos_next/pos_next/custom/pos_profile.json | 138 ++++++++++++++++++++++ pos_next/pos_next/custom/price_list.json | 78 ++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 pos_next/pos_next/custom/item_price.json create mode 100644 pos_next/pos_next/custom/price_list.json diff --git a/pos_next/pos_next/custom/item_price.json b/pos_next/pos_next/custom/item_price.json new file mode 100644 index 000000000..78ae0d6eb --- /dev/null +++ b/pos_next/pos_next/custom/item_price.json @@ -0,0 +1,78 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "alignment": "", + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-31 20:38:35.884261", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Item Price", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_pos_next_price_group", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 9, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "price_list", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Price Group", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "mask": 0, + "modified": "2026-08-31 20:38:35.884261", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Item Price-custom_pos_next_price_group", + "no_copy": 1, + "non_negative": 0, + "options": "Price Group", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Item Price", + "links": [], + "property_setters": [], + "sync_on_migrate": 0 +} diff --git a/pos_next/pos_next/custom/pos_profile.json b/pos_next/pos_next/custom/pos_profile.json index 2306f351d..945e97049 100644 --- a/pos_next/pos_next/custom/pos_profile.json +++ b/pos_next/pos_next/custom/pos_profile.json @@ -64,6 +64,144 @@ "unique": 0, "width": null }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "alignment": "", + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-31 20:38:36.004376", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "POS Profile", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_pos_next_previous_price_list", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 18, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_pos_next_price_group", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Previous Price List", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "mask": 0, + "modified": "2026-08-31 20:38:36.004376", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Profile-custom_pos_next_previous_price_list", + "no_copy": 1, + "non_negative": 0, + "options": "Price List", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "alignment": "", + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-31 20:38:35.935583", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "POS Profile", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_pos_next_price_group", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 15, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "selling_price_list", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Price Group", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "mask": 0, + "modified": "2026-08-31 20:38:35.935583", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Profile-custom_pos_next_price_group", + "no_copy": 1, + "non_negative": 0, + "options": "Price Group", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, { "_assign": null, "_comments": null, diff --git a/pos_next/pos_next/custom/price_list.json b/pos_next/pos_next/custom/price_list.json new file mode 100644 index 000000000..5081e6a75 --- /dev/null +++ b/pos_next/pos_next/custom/price_list.json @@ -0,0 +1,78 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "alignment": "", + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-31 20:38:35.799547", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Price List", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_pos_next_price_group", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 3, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "price_list_name", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Price Group", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "mask": 0, + "modified": "2026-08-31 20:38:35.799547", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Price List-custom_pos_next_price_group", + "no_copy": 1, + "non_negative": 0, + "options": "Price Group", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Price List", + "links": [], + "property_setters": [], + "sync_on_migrate": 0 +} From 267b97696b8351321c969b8266913972ef012d0f Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:55:24 +0700 Subject: [PATCH 06/17] test: add bootstrap test runner for pos_next python -m unittest imports test modules before frappe.init, and bench run-tests dies on ERPNext bootstrap, so neither can run this app's tests. This inits and connects to the site first. sys.path[0] is rewritten to the app root because the nested pos_next/pos_next package would otherwise shadow pos_next.pos_next. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/_pn_run_tests.py | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 pos_next/_pn_run_tests.py diff --git a/pos_next/_pn_run_tests.py b/pos_next/_pn_run_tests.py new file mode 100644 index 000000000..be1cc10e2 --- /dev/null +++ b/pos_next/_pn_run_tests.py @@ -0,0 +1,50 @@ +"""Bootstrap test runner for pos_next. + +`python -m unittest` imports test modules before `frappe.init`, and `bench run-tests` +dies on ERPNext bootstrap. This inits and connects to the site first, then loads the +modules named on the command line. + + ./env/bin/python apps/pos_next/pos_next/_pn_run_tests.py pos_next.api.test_items ... + +Run serially: parallel runs deadlock on Stock Settings / tabSingles (error 1213). +""" + +import os +import sys +import unittest +from pathlib import Path + +# sys.path[0] is this file's own directory, where `import pos_next` resolves to the +# nested pos_next/pos_next module dir and hides `pos_next.pos_next`. Point it at the +# app root so the top-level package wins. +_APP_ROOT = str(Path(__file__).resolve().parent.parent) +if sys.path and sys.path[0] != _APP_ROOT: + sys.path[0] = _APP_ROOT + +import frappe + +SITE = "erpnext16.localhost" +SITES_PATH = os.environ.get("SITES_PATH", "sites") + + +def main(module_names: list[str]) -> int: + if not module_names: + print(__doc__) + return 2 + + frappe.init(site=SITE, sites_path=SITES_PATH) + frappe.connect() + frappe.flags.in_test = True + + loader = unittest.TestLoader() + suite = unittest.TestSuite() + for name in module_names: + suite.addTests(loader.loadTestsFromName(name)) + + result = unittest.TextTestRunner(verbosity=2).run(suite) + frappe.destroy() + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From 8b64af72a0d74fb72bb142f250f899eaa5056338 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:55:34 +0700 Subject: [PATCH 07/17] test: add Price Group lifecycle and concurrency tests 31 tests ported from selling_additional covering enable/disable, ownership claim and restore, managed vs unmanaged Item Price rows, stock-UOM identity moves, lock ordering, and concurrent claims. They live outside the doctype folder on purpose: inside it frappe derives cls.doctype and generates test records, which imports ERPNext test utils and dies on a DuplicateEntryError for Standard Buying. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/tests/__init__.py | 0 pos_next/tests/price_group_helpers.py | 391 ++++++++ .../tests/test_price_group_concurrency.py | 217 ++++ pos_next/tests/test_price_group_lifecycle.py | 929 ++++++++++++++++++ 4 files changed, 1537 insertions(+) create mode 100644 pos_next/tests/__init__.py create mode 100644 pos_next/tests/price_group_helpers.py create mode 100644 pos_next/tests/test_price_group_concurrency.py create mode 100644 pos_next/tests/test_price_group_lifecycle.py diff --git a/pos_next/tests/__init__.py b/pos_next/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/tests/price_group_helpers.py b/pos_next/tests/price_group_helpers.py new file mode 100644 index 000000000..09a058b5e --- /dev/null +++ b/pos_next/tests/price_group_helpers.py @@ -0,0 +1,391 @@ +"""Shared test fixtures for the Price Group tests.""" + +import frappe + +from pos_next.price_group_ownership import ( + ITEM_PRICE_OWNER_FIELD, + MANAGED_PRICE_LIST_PREFIX, + OWNER_FIELD, + PRICE_LIST_OWNER_FIELD, + PROFILE_OWNER_FIELD, + PROFILE_PREVIOUS_PRICE_LIST_FIELD, +) + + +def get_default_company() -> str: + """Resolve an existing company with a stable sort or create a test company if none exists.""" + company = frappe.db.get_value("Company", {}, "name", order_by="creation asc") + if company: + return company + doc = frappe.get_doc( + { + "doctype": "Company", + "company_name": "_Test POS Next Company", + "default_currency": "IDR", + "country": "Indonesia", + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def get_second_company() -> str: + """Resolve or create a distinct second company.""" + primary = get_default_company() + companies = frappe.get_all( + "Company", filters={"name": ("!=", primary)}, pluck="name", order_by="creation asc" + ) + if companies: + return companies[0] + doc = frappe.get_doc( + { + "doctype": "Company", + "company_name": "_Test Second Company", + "default_currency": get_default_currency(primary), + "country": "Indonesia", + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def get_default_currency(company: str | None = None) -> str: + """Derive currency from the resolved company's default_currency.""" + comp = company or get_default_company() + return frappe.db.get_value("Company", comp, "default_currency") or "IDR" + + +def ensure_uom(uom_name: str) -> str: + """Return uom_name, creating the UOM record if missing.""" + if not frappe.db.exists("UOM", uom_name): + frappe.get_doc({"doctype": "UOM", "uom_name": uom_name}).insert(ignore_permissions=True) + return uom_name + + +def base_uom() -> str: + """Return a base UOM existing on site or create fallback.""" + existing = frappe.db.get_value("UOM", {"name": "Nos"}, "name") or frappe.db.get_value( + "UOM", {}, "name", order_by="creation asc" + ) + if existing: + return existing + return ensure_uom("_Test Base UOM") + + +def custom_uom() -> str: + """Return a secondary UOM distinct from base_uom.""" + base = base_uom() + existing = frappe.db.get_value("UOM", {"name": ("!=", base)}, "name", order_by="creation asc") + if existing: + uom = existing + else: + uom = ensure_uom("_Test Custom UOM") + assert uom != base, f"custom_uom ({uom}) must be distinct from base_uom ({base})" + return uom + + +def item_group() -> str: + """Return a leaf Item Group, creating one if the site has none.""" + existing = frappe.db.get_value("Item Group", {"is_group": 0}, "name", order_by="creation asc") + if existing: + return existing + + parent = frappe.db.get_value("Item Group", {"is_group": 1}, "name", order_by="creation asc") + if not parent: + root = frappe.get_doc( + {"doctype": "Item Group", "item_group_name": "_Test PN Root Group", "is_group": 1} + ) + root.flags.ignore_mandatory = True + root.insert(ignore_permissions=True) + parent = root.name + + group = frappe.get_doc( + { + "doctype": "Item Group", + "item_group_name": "_Test PN Leaf Group", + "is_group": 0, + "parent_item_group": parent, + } + ) + group.insert(ignore_permissions=True) + return group.name + + +def make_test_item( + suffix: str, stock_uom: str | None = None, *, has_batch_no: int = 0, is_stock_item: int = 0 +) -> str: + """Create a test Item with resolved leaf Item Group and stock UOM.""" + item_code = f"_Test Item {suffix}" + if frappe.db.exists("Item", item_code): + return item_code + + uom = stock_uom or base_uom() + ensure_uom(uom) + doc = frappe.get_doc( + { + "doctype": "Item", + "item_code": item_code, + "item_name": item_code, + "item_group": item_group(), + "stock_uom": uom, + "is_stock_item": is_stock_item, + "has_batch_no": has_batch_no, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def make_test_warehouse(suffix: str, company: str) -> str: + """Create a leaf Warehouse linked to the given company.""" + wh_name = f"_Test Warehouse {suffix} - {company[:10]}" + existing = frappe.db.get_value("Warehouse", {"warehouse_name": wh_name, "company": company}, "name") + if existing: + return existing + + parent_wh = frappe.db.get_value( + "Warehouse", {"is_group": 1, "company": company}, "name", order_by="creation asc" + ) + doc = frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": wh_name, + "company": company, + "is_group": 0, + "parent_warehouse": parent_wh, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def get_default_cost_center(company: str) -> str: + """Resolve a cost center for the company or create fallback.""" + existing = frappe.db.get_value( + "Cost Center", {"company": company, "is_group": 0}, "name", order_by="creation asc" + ) + if existing: + return existing + parent_cc = frappe.db.get_value( + "Cost Center", {"company": company, "is_group": 1}, "name", order_by="creation asc" + ) + doc = frappe.get_doc( + { + "doctype": "Cost Center", + "cost_center_name": f"_Test CC {company[:10]}", + "company": company, + "is_group": 0, + "parent_cost_center": parent_cc, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def get_default_account(company: str, account_type: str = "Expense") -> str: + """Resolve an account for the company or create fallback.""" + if account_type == "Expense": + existing = frappe.db.get_value( + "Account", + { + "company": company, + "account_type": ( + "in", + ["Expense", "Expense Account", "Indirect Expense", "Cost of Goods Sold"], + ), + "is_group": 0, + }, + "name", + order_by="creation asc", + ) or frappe.db.get_value( + "Account", + {"company": company, "root_type": "Expense", "is_group": 0}, + "name", + order_by="creation asc", + ) + else: + existing = frappe.db.get_value( + "Account", + {"company": company, "account_type": account_type, "is_group": 0}, + "name", + order_by="creation asc", + ) + + if not existing: + existing = frappe.db.get_value( + "Account", {"company": company, "is_group": 0}, "name", order_by="creation asc" + ) + return existing + + +def get_default_mode_of_payment(company: str) -> str: + """Resolve an enabled Mode of Payment with an account for company without mutating shared data.""" + mop_list = frappe.get_all( + "Mode of Payment", filters={"enabled": 1}, pluck="name", order_by="creation asc" + ) + for mop_name in mop_list: + has_account = frappe.db.exists("Mode of Payment Account", {"parent": mop_name, "company": company}) + if has_account: + return mop_name + + # Create a test-owned MOP with an account for this company + mop_name = f"_Test MOP {company[:10]}" + if frappe.db.exists("Mode of Payment", mop_name): + return mop_name + + default_account = frappe.db.get_value( + "Account", + {"company": company, "account_type": ("in", ["Cash", "Bank"]), "is_group": 0}, + "name", + order_by="creation asc", + ) or frappe.db.get_value("Account", {"company": company, "is_group": 0}, "name", order_by="creation asc") + + accounts = [] + if default_account: + accounts.append({"company": company, "default_account": default_account}) + + mop = frappe.get_doc( + { + "doctype": "Mode of Payment", + "mode_of_payment": mop_name, + "enabled": 1, + "type": "Cash", + "accounts": accounts, + } + ) + mop.insert(ignore_permissions=True) + return mop.name + + +def make_test_pos_profile(suffix: str, company: str, warehouse: str, *, payments=None) -> str: + """Create a POS Profile with mandatory payment methods populated.""" + profile_name = f"_Test POS Profile {suffix}" + if frappe.db.exists("POS Profile", profile_name): + return profile_name + + if payments is None: + mop = get_default_mode_of_payment(company) + payment_rows = [{"mode_of_payment": mop, "default": 1}] + else: + payment_rows = payments + + currency = get_default_currency(company) + write_off_account = get_default_account(company, "Expense") + write_off_cc = get_default_cost_center(company) + income_account = ( + frappe.db.get_value( + "Account", + {"company": company, "root_type": "Income", "is_group": 0}, + "name", + order_by="creation asc", + ) + or write_off_account + ) + expense_account = write_off_account + + doc = frappe.get_doc( + { + "doctype": "POS Profile", + "name": profile_name, + "company": company, + "warehouse": warehouse, + "currency": currency, + "payments": payment_rows, + "write_off_account": write_off_account, + "write_off_cost_center": write_off_cc, + "income_account": income_account, + "expense_account": expense_account, + "cost_center": write_off_cc, + "write_off_limit": 1.0, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def make_price_group(name: str, *, items, outlets=(), enabled=1, currency=None): + """Create and insert a Price Group document.""" + resolved_currency = currency or get_default_currency() + doc = frappe.get_doc( + { + "doctype": "Price Group", + "price_group_name": name, + "enabled": enabled, + "currency": resolved_currency, + "items": [ + { + "item_code": item.get("item_code"), + "rate": item.get("rate"), + **({"uom": item["uom"]} if "uom" in item else {}), + } + for item in items + ], + "outlets": [ + { + "company": outlet.get("company"), + "warehouse": outlet.get("warehouse"), + } + for outlet in outlets + ], + } + ) + doc.insert(ignore_permissions=True) + return doc + + +def make_test_customer(suffix: str) -> str: + """Resolve or create a test Customer.""" + cust_name = f"_Test Customer {suffix}" + if frappe.db.exists("Customer", cust_name): + return cust_name + existing = frappe.db.get_value("Customer", {}, "name", order_by="creation asc") + if existing: + return existing + cg = ( + frappe.db.get_value("Customer Group", {"is_group": 0}, "name", order_by="creation asc") + or "All Customer Groups" + ) + doc = frappe.get_doc( + { + "doctype": "Customer", + "customer_name": cust_name, + "customer_group": cg, + "territory": "All Territories", + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def make_test_batch(item_code: str, suffix: str) -> str: + """Create a Batch for item_code.""" + batch_id = f"_Test Batch {suffix}" + if frappe.db.exists("Batch", batch_id): + return batch_id + doc = frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item_code, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def manual_item_price(item_code: str, price_list: str, **overrides) -> str: + """Create a manual unmanaged Item Price on the given Price List.""" + stock_uom = frappe.db.get_value("Item", item_code, "stock_uom") or base_uom() + currency = frappe.db.get_value("Price List", price_list, "currency") or get_default_currency() + payload = { + "doctype": "Item Price", + "item_code": item_code, + "price_list": price_list, + "price_list_rate": 100.0, + "currency": currency, + "uom": stock_uom, + } + payload.update(overrides) + doc = frappe.get_doc(payload) + doc.insert(ignore_permissions=True) + return doc.name diff --git a/pos_next/tests/test_price_group_concurrency.py b/pos_next/tests/test_price_group_concurrency.py new file mode 100644 index 000000000..1f4dac763 --- /dev/null +++ b/pos_next/tests/test_price_group_concurrency.py @@ -0,0 +1,217 @@ +"""Lock order and concurrency tests for Price Group.""" + +import frappe +from frappe.tests import IntegrationTestCase + +from pos_next.install import ensure_price_group_custom_fields +from pos_next.tests import price_group_helpers as helpers + + +class TestPriceGroupConcurrency(IntegrationTestCase): + def setUp(self): + super().setUp() + ensure_price_group_custom_fields(quiet=True) + self.company = helpers.get_default_company() + self.uom = helpers.base_uom() + + def test_lock_order_covers_current_and_desired_profiles(self): + """Assert full lock sequence: Price Group, current+desired profiles, Price List, managed Item Prices.""" + item1 = helpers.make_test_item("conc1-1", self.uom) + item2 = helpers.make_test_item("conc1-2", self.uom) + wh1 = helpers.make_test_warehouse("conc1-1", self.company) + wh2 = helpers.make_test_warehouse("conc1-2", self.company) + pos1 = helpers.make_test_pos_profile("conc1-1", self.company, wh1) + pos2 = helpers.make_test_pos_profile("conc1-2", self.company, wh2) + + pg = helpers.make_price_group( + "PG-Conc-1", + items=[ + {"item_code": item1, "rate": 10000}, + {"item_code": item2, "rate": 20000}, + ], + outlets=[{"company": self.company, "warehouse": wh1}], + ) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + managed_ip_names = frappe.get_all( + "Item Price", + filters={"price_list": pl_name, helpers.OWNER_FIELD: pg.name}, + pluck="name", + ) + self.assertTrue( + managed_ip_names, + msg="Fixture produced no managed Item Prices, so the Item Price lock order below would be asserted against an empty list and could not fail", + ) + + # Transfer ownership from current pos1 to desired pos2. + pg.set("outlets", [{"company": self.company, "warehouse": wh2}]) + lock_log = [] + orig_get_value = frappe.db.get_value + + def logging_get_value(*args, **kwargs): + if kwargs.get("for_update"): + doctype = args[0] if args else kwargs.get("doctype") + row = args[1] if len(args) > 1 else kwargs.get("filters") + if isinstance(row, dict): + row = row.get("name") + lock_log.append((doctype, str(row))) + return orig_get_value(*args, **kwargs) + + with __import__("unittest").mock.patch("frappe.db.get_value", side_effect=logging_get_value): + pg.save() + + expected = ( + [("Price Group", pg.name)] + + sorted([("POS Profile", pos1), ("POS Profile", pos2)]) + + [("Price List", pl_name)] + + [("Item Price", name) for name in sorted(managed_ip_names)] + ) + self.assertEqual( + lock_log, + expected, + msg=f"Observed lock order differs from required full sequence. Probe only observes frappe.db.get_value locks; observed {lock_log}, expected {expected}", + ) + + def test_ownership_checked_after_all_locks(self): + """Assert each ownership read follows a lock on the exact same (doctype, name) row.""" + item = helpers.make_test_item("conc2", self.uom) + wh = helpers.make_test_warehouse("conc2", self.company) + helpers.make_test_pos_profile("conc2", self.company, wh) + + pg = helpers.make_price_group( + "PG-Conc-2", + items=[{"item_code": item, "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + events = [] + orig_get_value = frappe.db.get_value + owner_fields = {helpers.OWNER_FIELD, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD} + + def logging_get_value(*args, **kwargs): + doctype = args[0] if args else kwargs.get("doctype") + row = args[1] if len(args) > 1 else kwargs.get("filters") + if isinstance(row, dict): + row = row.get("name") + row = str(row) + fieldname = args[2] if len(args) > 2 else kwargs.get("fieldname") + fields = fieldname if isinstance(fieldname, list | tuple) else [fieldname] + if kwargs.get("for_update"): + events.append(("LOCK", (doctype, row))) + elif owner_fields.intersection(fields): + events.append(("READ_OWNER", (doctype, row))) + return orig_get_value(*args, **kwargs) + + with __import__("unittest").mock.patch("frappe.db.get_value", side_effect=logging_get_value): + pg.save() + + self.assertTrue( + any(kind == "LOCK" for kind, _ in events), + msg=f"Ownership ordering probe recorded no row locks: {events}", + ) + self.assertTrue( + any(kind == "READ_OWNER" for kind, _ in events), + msg=f"Ownership ordering probe recorded no ownership reads: {events}", + ) + read_doctypes = {dt for kind, (dt, _row) in events if kind == "READ_OWNER"} + self.assertIn("Price List", read_doctypes, msg=f"Price List ownership read missing: events={events}") + self.assertIn( + "POS Profile", read_doctypes, msg=f"POS Profile ownership read missing: events={events}" + ) + locked = set() + for kind, row in events: + if kind == "LOCK": + locked.add(row) + else: + self.assertIn( + row, + locked, + msg=f"Ownership row {row} was read before its exact row lock; events={events}", + ) + + def test_concurrent_claim_yields_one_owner(self): + """Primary lock serializes claims; secondary times out, then rejects committed competing owner.""" + suffix = frappe.generate_hash(length=8) + + # Roll back first so the commit below captures only this test's own rows. + # IntegrationTestCase has no per-test rollback, so a bare commit would also commit + # whatever uncommitted state earlier tests in this class left behind. + frappe.db.rollback() + + item = helpers.make_test_item(f"conc3-{suffix}", self.uom) + wh = helpers.make_test_warehouse(f"conc3-{suffix}", self.company) + pos = helpers.make_test_pos_profile(f"conc3-{suffix}", self.company, wh) + pg_a_name = f"PG-Conc-A-{suffix}" + pg_b_name = f"PG-Conc-B-{suffix}" + pl_a_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg_a_name}" + pl_b_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg_b_name}" + + frappe.db.commit() + + cleanup_rows = { + "Price Group": [pg_a_name, pg_b_name], + "Item Price": [], + "Price List": [pl_a_name, pl_b_name], + "POS Profile": [pos], + "Warehouse": [wh], + "Item": [item], + } + + def cleanup(): + with self.primary_connection(): + try: + frappe.db.rollback() + for name in list(cleanup_rows["Item Price"]): + try: + if frappe.db.exists("Item Price", name): + frappe.delete_doc("Item Price", name, ignore_permissions=True) + except Exception: + pass + for dt in ("Price List", "Price Group", "POS Profile", "Warehouse", "Item"): + for name in cleanup_rows[dt]: + try: + if frappe.db.exists(dt, name): + frappe.delete_doc(dt, name, ignore_permissions=True) + except Exception: + pass + finally: + frappe.db.commit() + + self.addCleanup(cleanup) + + # IntegrationTestCase.secondary_connection() captures frappe.local.db AFTER its + # first-use frappe.connect(), so its finally-block restores the secondary rather + # than the primary. Every operation below therefore names its connection + # explicitly instead of relying on the ambient one. + + # Primary holds the profile row lock and writes group A's marker, uncommitted. + with self.primary_connection(): + frappe.db.get_value("POS Profile", pos, "modified", for_update=True) + frappe.db.set_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD, pg_a_name) + + with self.secondary_connection(): + with self.assertRaises( + frappe.QueryTimeoutError, + msg="Secondary claim should time out while primary holds profile lock", + ): + frappe.db.get_value("POS Profile", pos, "modified", for_update=True, wait=False) + + with self.primary_connection(): + frappe.db.commit() + + with self.secondary_connection(): + # REPEATABLE-READ: end the transaction the failed lock attempt left open so the + # ownership read below sees group A's now-committed marker. + frappe.db.rollback() + with self.assertRaises( + frappe.ValidationError, + msg="Secondary Group B claim should be rejected after Group A commits ownership", + ): + helpers.make_price_group( + pg_b_name, + items=[{"item_code": item, "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + with self.primary_connection(): + owner = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD) + self.assertEqual(owner, pg_a_name, msg=f"Group A should remain sole owner of {pos}, got {owner}") diff --git a/pos_next/tests/test_price_group_lifecycle.py b/pos_next/tests/test_price_group_lifecycle.py new file mode 100644 index 000000000..58dd339df --- /dev/null +++ b/pos_next/tests/test_price_group_lifecycle.py @@ -0,0 +1,929 @@ +"""Lifecycle and contract tests for Price Group. + +Pins the enabled, disabled, ownership, and deletion contracts of the feature. +""" + +from pathlib import Path + +import frappe +from frappe.tests import IntegrationTestCase + +from pos_next.install import ensure_price_group_custom_fields +from pos_next.tests import price_group_helpers as helpers + + +class TestPriceGroupLifecycle(IntegrationTestCase): + def setUp(self): + super().setUp() + ensure_price_group_custom_fields(quiet=True) + self.company = helpers.get_default_company() + self.currency = helpers.get_default_currency(self.company) + self.uom = helpers.base_uom() + self.custom_uom = helpers.custom_uom() + + def test_enable_creates_marked_price_list(self): + """Save enabled Price Group: assert Price List PG- created with correct values and owner marker.""" + item = helpers.make_test_item("life1", self.uom) + pg = helpers.make_price_group("PG-Life-1", items=[{"item_code": item, "rate": 10000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + self.assertTrue(frappe.db.exists("Price List", pl_name), msg=f"Price List {pl_name} was not created") + pl = frappe.get_doc("Price List", pl_name) + self.assertEqual(pl.selling, 1, msg=f"Price List {pl_name} selling should be 1") + self.assertEqual(pl.buying, 0, msg=f"Price List {pl_name} buying should be 0") + self.assertEqual(pl.enabled, 1, msg=f"Price List {pl_name} enabled should be 1") + self.assertEqual( + pl.currency, + self.currency, + msg=f"Price List currency should match company currency {self.currency}", + ) + self.assertEqual( + getattr(pl, helpers.PRICE_LIST_OWNER_FIELD, None), + pg.name, + msg=f"Price List {pl_name} owner marker {helpers.PRICE_LIST_OWNER_FIELD} should equal {pg.name}", + ) + + def test_new_managed_price_list_does_not_become_global_default(self): + """Clear Selling Settings.selling_price_list in singles and defaults, create group, assert setting remains empty.""" + frappe.db.set_single_value("Selling Settings", "selling_price_list", None) + frappe.defaults.clear_default("selling_price_list", parent="__default") + + item = helpers.make_test_item("life2", self.uom) + pg = helpers.make_price_group("PG-Life-2", items=[{"item_code": item, "rate": 15000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + current_single = frappe.db.get_single_value("Selling Settings", "selling_price_list") + rows = frappe.db.sql( + """select defvalue from tabDefaultValue + where parent = '__default' and defkey = 'selling_price_list'""" + ) + current_default = rows[0][0] if rows else None + + self.assertFalse( + current_single, + msg=f"Selling Settings.selling_price_list should remain empty, but became {current_single!r}", + ) + self.assertNotEqual( + current_single, + pl_name, + msg=f"Selling Settings.selling_price_list was claimed by the managed list {pl_name}", + ) + self.assertFalse( + current_default, + msg=f"tabDefaultValue leaked: {current_default!r}", + ) + self.assertNotEqual( + current_default, + pl_name, + msg=f"tabDefaultValue was claimed by the managed list {pl_name}", + ) + + def test_existing_selling_default_survives_managed_price_list_creation(self): + """A pre-existing Selling default is restored, not blanked, after a managed list is created.""" + existing_pl = ( + frappe.db.get_value("Price List", {"selling": 1, "enabled": 1}, "name") or "Standard Selling" + ) + frappe.db.set_single_value("Selling Settings", "selling_price_list", existing_pl) + frappe.db.set_default("selling_price_list", existing_pl) + + item = helpers.make_test_item("life2b", self.uom) + pg = helpers.make_price_group("PG-Life-2B", items=[{"item_code": item, "rate": 15000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + current_single = frappe.db.get_single_value("Selling Settings", "selling_price_list") + rows = frappe.db.sql( + """select defvalue from tabDefaultValue + where parent = '__default' and defkey = 'selling_price_list'""" + ) + current_default = rows[0][0] if rows else None + + self.assertEqual( + current_single, + existing_pl, + msg=f"Selling Settings.selling_price_list should remain {existing_pl!r}, got {current_single!r}", + ) + self.assertNotEqual(current_single, pl_name) + self.assertEqual( + current_default, + existing_pl, + msg=f"tabDefaultValue should remain {existing_pl!r}, got {current_default!r}", + ) + self.assertNotEqual(current_default, pl_name) + + def test_managed_item_price_identity_is_item_and_uom(self): + """Two items generate marked rows with distinct (item_code, uom) identity.""" + item1 = helpers.make_test_item("life3-1", self.uom) + item2 = helpers.make_test_item("life3-2", self.custom_uom) + pg = helpers.make_price_group( + "PG-Life-3", + items=[ + {"item_code": item1, "rate": 12000}, + {"item_code": item2, "rate": 18000}, + ], + ) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + rows = frappe.get_all( + "Item Price", + filters={"price_list": pl_name, helpers.ITEM_PRICE_OWNER_FIELD: pg.name}, + fields=["item_code", "uom"], + ) + self.assertEqual( + len(rows), 2, msg=f"Expected exactly 2 marked Item Price rows on {pl_name}, got {len(rows)}" + ) + identities = {(r.item_code, r.uom) for r in rows} + self.assertEqual( + len(identities), 2, msg=f"Expected 2 distinct (item_code, uom) identities, got {identities}" + ) + self.assertIn( + (item1, self.uom), identities, msg=f"Expected identity ({item1}, {self.uom}) in {identities}" + ) + self.assertIn( + (item2, self.custom_uom), + identities, + msg=f"Expected identity ({item2}, {self.custom_uom}) in {identities}", + ) + + def test_rate_change_updates_managed_row_only(self): + """Rate change in Price Group updates only changed row, leaving other rows on the same Price List untouched.""" + item1 = helpers.make_test_item("life4-1", self.uom) + item2 = helpers.make_test_item("life4-2", self.uom) + item3 = helpers.make_test_item("life4-3", self.uom) + + pg = helpers.make_price_group( + "PG-Life-4", + items=[ + {"item_code": item1, "rate": 10000}, + {"item_code": item2, "rate": 20000}, + ], + ) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + # Manual unmarked row on the same managed list + manual_ip = helpers.manual_item_price(item3, pl_name, price_list_rate=30000) + + ip2_name = frappe.db.get_value("Item Price", {"price_list": pl_name, "item_code": item2}, "name") + ip2_rate = frappe.db.get_value("Item Price", ip2_name, "price_list_rate") + ip2_modified = frappe.db.get_value("Item Price", ip2_name, "modified") + snapshot_unchanged_marked = (ip2_name, ip2_rate, ip2_modified) + + manual_rate = frappe.db.get_value("Item Price", manual_ip, "price_list_rate") + manual_modified = frappe.db.get_value("Item Price", manual_ip, "modified") + snapshot_manual = (manual_ip, manual_rate, manual_modified) + + # Mutate rate of item1 + pg.items[0].rate = 15000 + pg.save() + + # Changed item rate updated + ip1_rate = frappe.db.get_value( + "Item Price", {"price_list": pl_name, "item_code": item1}, "price_list_rate" + ) + self.assertEqual(ip1_rate, 15000, msg=f"Item 1 rate should be updated to 15000, got {ip1_rate}") + + # Unchanged marked row identical + current_unchanged_marked = ( + ip2_name, + frappe.db.get_value("Item Price", ip2_name, "price_list_rate"), + frappe.db.get_value("Item Price", ip2_name, "modified"), + ) + self.assertEqual( + current_unchanged_marked, + snapshot_unchanged_marked, + msg=f"Unchanged marked row on same Price List was modified: {current_unchanged_marked} != {snapshot_unchanged_marked}", + ) + + # Manual row identical + current_manual = ( + manual_ip, + frappe.db.get_value("Item Price", manual_ip, "price_list_rate"), + frappe.db.get_value("Item Price", manual_ip, "modified"), + ) + self.assertEqual( + current_manual, + snapshot_manual, + msg=f"Manual row on same Price List was modified: {current_manual} != {snapshot_manual}", + ) + + def test_removed_item_deletes_only_marked_row(self): + """Removing an item from child table deletes only its marked row, preserving manual and scoped rows.""" + item1 = helpers.make_test_item("life5-1", self.uom) + item2 = helpers.make_test_item("life5-2", self.uom) + item3 = helpers.make_test_item("life5-3", self.uom) + cust = helpers.make_test_customer("life5") + + pg = helpers.make_price_group( + "PG-Life-5", + items=[ + {"item_code": item1, "rate": 10000}, + {"item_code": item2, "rate": 20000}, + ], + ) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + manual_ip = helpers.manual_item_price(item3, pl_name, price_list_rate=30000) + scoped_ip = helpers.manual_item_price(item2, pl_name, customer=cust, price_list_rate=25000) + manual_modified_before = frappe.db.get_value("Item Price", manual_ip, "modified") + scoped_modified_before = frappe.db.get_value("Item Price", scoped_ip, "modified") + + # Remove item2 from Price Group + pg.items = [pg.items[0]] + pg.save() + + # Marked row for item2 is gone + marked_item2_exists = frappe.db.exists( + "Item Price", + {"price_list": pl_name, helpers.OWNER_FIELD: pg.name, "item_code": item2}, + ) + self.assertFalse(marked_item2_exists, msg=f"Marked row for removed item2 still exists on {pl_name}") + + # Manual and scoped rows still exist with unchanged modified + self.assertTrue( + frappe.db.exists("Item Price", manual_ip), msg=f"Manual row {manual_ip} should survive" + ) + self.assertEqual( + frappe.db.get_value("Item Price", manual_ip, "modified"), + manual_modified_before, + msg=f"Manual row {manual_ip} modified timestamp changed", + ) + self.assertTrue( + frappe.db.exists("Item Price", scoped_ip), msg=f"Scoped row {scoped_ip} should survive" + ) + self.assertEqual( + frappe.db.get_value("Item Price", scoped_ip, "modified"), + scoped_modified_before, + msg=f"Scoped row {scoped_ip} modified timestamp changed", + ) + + def test_manual_unmarked_price_survives(self): + """Unmarked manual Item Price on managed Price List survives sync operations.""" + item1 = helpers.make_test_item("life6-1", self.uom) + item2 = helpers.make_test_item("life6-2", self.uom) + pg = helpers.make_price_group("PG-Life-6", items=[{"item_code": item1, "rate": 10000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + manual_ip = helpers.manual_item_price(item2, pl_name, price_list_rate=50000) + + pg.save() + + self.assertTrue( + frappe.db.exists("Item Price", manual_ip), msg=f"Manual row {manual_ip} was deleted on save" + ) + owner_val = frappe.db.get_value("Item Price", manual_ip, helpers.ITEM_PRICE_OWNER_FIELD) + self.assertFalse( + owner_val, msg=f"Manual row {manual_ip} should not have owner marker set, got {owner_val}" + ) + + def test_scoped_and_null_uom_prices_survive(self): + """Customer-, batch-, date-, and packing-unit-scoped and NULL-UOM rows survive sync untouched.""" + item1 = helpers.make_test_item("life7-1", self.uom, has_batch_no=1, is_stock_item=1) + item2 = helpers.make_test_item("life7-2", self.uom) + pg = helpers.make_price_group("PG-Life-7", items=[{"item_code": item1, "rate": 10000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + cust = helpers.make_test_customer("life7") + batch = helpers.make_test_batch(item1, "life7") + + scoped_customer = helpers.manual_item_price(item1, pl_name, customer=cust) + scoped_batch = helpers.manual_item_price(item1, pl_name, batch_no=batch) + scoped_date = helpers.manual_item_price( + item1, pl_name, valid_from="2026-01-01", valid_upto="2026-12-31" + ) + scoped_packing = helpers.manual_item_price(item1, pl_name, packing_unit=10) + + # NULL-UOM row scoped to item2 so check_duplicates does not collide with managed item1 row + null_uom_ip = helpers.manual_item_price(item2, pl_name, price_list_rate=33000) + frappe.db.set_value("Item Price", null_uom_ip, "uom", None, update_modified=False) + + scoped_rows = [scoped_customer, scoped_batch, scoped_date, scoped_packing, null_uom_ip] + snapshots = { + ip: ( + frappe.db.get_value("Item Price", ip, "price_list_rate"), + frappe.db.get_value("Item Price", ip, "modified"), + frappe.db.get_value("Item Price", ip, helpers.OWNER_FIELD), + ) + for ip in scoped_rows + } + + pg.save() + + for ip in scoped_rows: + self.assertTrue(frappe.db.exists("Item Price", ip), msg=f"Scoped row {ip} should survive save") + current = ( + frappe.db.get_value("Item Price", ip, "price_list_rate"), + frappe.db.get_value("Item Price", ip, "modified"), + frappe.db.get_value("Item Price", ip, helpers.OWNER_FIELD), + ) + self.assertEqual( + current, + snapshots[ip], + msg=f"Scoped row {ip} (rate, modified, owner) changed after save: {current} != {snapshots[ip]}", + ) + + self.assertIsNone( + frappe.db.get_value("Item Price", null_uom_ip, "uom"), + msg=f"NULL-UOM row {null_uom_ip} UOM should remain None", + ) + + def test_invalid_item_uom_fails_before_mutation(self): + """Missing UOM conversion detail raises validation error while Price List/Profile remain unchanged.""" + item = helpers.make_test_item("life8", self.uom) + wh = helpers.make_test_warehouse("life8", self.company) + pos = helpers.make_test_pos_profile("life8", self.company, wh) + + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}PG-Life-8" + + profile_pl_before = frappe.db.get_value("POS Profile", pos, "selling_price_list") + profile_owner_before = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD) + + # Remove the conversion row Frappe auto-creates for the stock UOM, so the resolved UOM has none. + frappe.db.delete( + "UOM Conversion Detail", + {"parenttype": "Item", "parent": item, "uom": self.uom}, + ) + + with self.assertRaisesRegex( + frappe.ValidationError, + r"Conversion Factor for UOM .* does not exist for Item", + msg="Expected controller validation error for missing UOM conversion", + ): + helpers.make_price_group( + "PG-Life-8", + items=[{"item_code": item, "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + self.assertFalse( + frappe.db.exists("Price List", pl_name), + msg=f"Price List {pl_name} should not be created on validation failure", + ) + self.assertEqual( + frappe.db.get_value("POS Profile", pos, "selling_price_list"), + profile_pl_before, + msg=f"POS Profile {pos} price list should remain {profile_pl_before!r}", + ) + self.assertEqual( + frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD), + profile_owner_before, + msg=f"POS Profile {pos} owner marker should remain {profile_owner_before!r}", + ) + + def test_stock_uom_change_moves_managed_identity(self): + """Spec 8.6: after an Item stock-UOM change, the next save moves the managed row's identity.""" + item = helpers.make_test_item("life9", self.uom) + pg = helpers.make_price_group("PG-Life-9", items=[{"item_code": item, "rate": 10000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + old_ip = frappe.db.get_value( + "Item Price", {"price_list": pl_name, "item_code": item, "uom": self.uom}, "name" + ) + self.assertIsNotNone(old_ip, msg=f"Old Item Price for ({item}, {self.uom}) was not created") + + item_doc = frappe.get_doc("Item", item) + item_doc.stock_uom = self.custom_uom + item_doc.save() + + pg.reload() + pg.save() + + self.assertEqual( + frappe.db.get_value("Price Group Item", pg.items[0].name, "uom"), + self.custom_uom, + msg="Child row UOM should be re-derived from the Item's new stock UOM", + ) + new_ip = frappe.db.get_value( + "Item Price", {"price_list": pl_name, "item_code": item, "uom": self.custom_uom}, "name" + ) + self.assertIsNotNone(new_ip, msg=f"New Item Price for ({item}, {self.custom_uom}) was not created") + self.assertFalse( + frappe.db.exists("Item Price", old_ip), msg=f"Old Item Price {old_ip} should be deleted" + ) + + def test_delete_succeeds_after_item_stock_uom_change(self): + """A stock-UOM change must not make an existing Price Group undeletable.""" + item = helpers.make_test_item("life9b", self.uom) + pg = helpers.make_price_group("PG-Life-9B", items=[{"item_code": item, "rate": 10000}]) + + item_doc = frappe.get_doc("Item", item) + item_doc.stock_uom = self.custom_uom + item_doc.save() + + pg.delete() + + self.assertFalse( + frappe.db.exists("Price Group", pg.name), + msg=f"Price Group {pg.name} should be successfully deleted after stock UOM change", + ) + + def test_outlet_claim_marks_profile_and_saves_previous(self): + """Claiming an outlet marks the POS Profile with owner and saves its previous price list.""" + wh = helpers.make_test_warehouse("life10", self.company) + pos = helpers.make_test_pos_profile("life10", self.company, wh) + + frappe.db.set_value("POS Profile", pos, "selling_price_list", "Standard Selling") + + pg = helpers.make_price_group( + "PG-Life-10", + items=[{"item_code": helpers.make_test_item("life10", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + pos_doc = frappe.get_doc("POS Profile", pos) + self.assertEqual( + pos_doc.selling_price_list, + pl_name, + msg=f"POS Profile {pos} price list should be updated to {pl_name}", + ) + self.assertEqual( + getattr(pos_doc, helpers.PROFILE_OWNER_FIELD, None), + pg.name, + msg=f"POS Profile {pos} owner marker should equal {pg.name}", + ) + self.assertEqual( + getattr(pos_doc, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD, None), + "Standard Selling", + msg=f"POS Profile {pos} previous price list should be preserved as 'Standard Selling'", + ) + + def test_reclaim_does_not_overwrite_stored_previous(self): + """Re-saving a Price Group retains original stored previous price list on claimed profile and keeps owner marker.""" + wh = helpers.make_test_warehouse("life11", self.company) + pos = helpers.make_test_pos_profile("life11", self.company, wh) + frappe.db.set_value("POS Profile", pos, "selling_price_list", "Standard Selling") + + pg = helpers.make_price_group( + "PG-Life-11", + items=[{"item_code": helpers.make_test_item("life11", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + pg.save() + + prev_pl = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD) + self.assertEqual( + prev_pl, + "Standard Selling", + msg=f"POS Profile {pos} previous price list was overwritten on re-save", + ) + owner = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD) + self.assertEqual(owner, pg.name, msg=f"POS Profile {pos} owner marker was cleared on re-save") + + def test_outlet_removal_restores_owned_profile(self): + """Removing an outlet from Price Group restores POS Profile's previous price list and clears marker.""" + wh = helpers.make_test_warehouse("life12", self.company) + pos = helpers.make_test_pos_profile("life12", self.company, wh) + frappe.db.set_value("POS Profile", pos, "selling_price_list", "Standard Selling") + + pg = helpers.make_price_group( + "PG-Life-12", + items=[{"item_code": helpers.make_test_item("life12", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + pg.outlets = [] + pg.save() + + pos_doc = frappe.get_doc("POS Profile", pos) + self.assertEqual( + pos_doc.selling_price_list, + "Standard Selling", + msg=f"POS Profile {pos} price list was not restored", + ) + self.assertFalse( + getattr(pos_doc, helpers.PROFILE_OWNER_FIELD, None), + msg=f"POS Profile {pos} owner marker was not cleared", + ) + self.assertFalse( + getattr(pos_doc, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD, None), + msg=f"POS Profile {pos} previous price list field was not cleared", + ) + + def test_cross_group_claim_is_rejected(self): + """Group B cannot claim an outlet whose POS Profile is already owned by Group A.""" + wh = helpers.make_test_warehouse("life13", self.company) + helpers.make_test_pos_profile("life13", self.company, wh) + + helpers.make_price_group( + "PG-Life-13A", + items=[{"item_code": helpers.make_test_item("life13A", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + with self.assertRaises( + frappe.ValidationError, msg="Group B should be rejected when claiming outlet owned by Group A" + ): + helpers.make_price_group( + "PG-Life-13B", + items=[{"item_code": helpers.make_test_item("life13B", self.uom), "rate": 20000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + def test_no_pos_profile_sets_outlet_status_without_throwing(self): + """When no POS Profile matches company/warehouse, outlet status is 'No POS Profile' in DB without raising.""" + wh = helpers.make_test_warehouse("life14", self.company) + + pg = helpers.make_price_group( + "PG-Life-14", + items=[{"item_code": helpers.make_test_item("life14", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + self.assertEqual(len(pg.outlets), 1, msg="Expected 1 outlet row on Price Group") + db_status = frappe.db.get_value("Price Group Outlet", pg.outlets[0].name, "status") + db_pos_profile = frappe.db.get_value("Price Group Outlet", pg.outlets[0].name, "pos_profile") + self.assertEqual( + db_status, + "No POS Profile", + msg=f"Database outlet status should be 'No POS Profile', got '{db_status}'", + ) + self.assertFalse( + db_pos_profile, msg=f"Database outlet pos_profile should be empty, got '{db_pos_profile}'" + ) + + def test_warehouse_company_mismatch_is_rejected(self): + """Warehouse company mismatch is rejected with ValidationError.""" + comp_b = helpers.get_second_company() + wh_b = helpers.make_test_warehouse("life15", comp_b) + + with self.assertRaisesRegex( + frappe.ValidationError, + r"belongs to company", + msg="Expected ValidationError for warehouse company mismatch", + ): + helpers.make_price_group( + "PG-Life-15", + items=[{"item_code": helpers.make_test_item("life15", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh_b}], + ) + + def test_duplicate_item_is_rejected(self): + """Duplicate item in child items table is rejected with ValidationError.""" + item = helpers.make_test_item("life16", self.uom) + with self.assertRaisesRegex( + frappe.ValidationError, + r"Duplicate item", + msg="Expected ValidationError for duplicate child items", + ): + helpers.make_price_group( + "PG-Life-16", + items=[ + {"item_code": item, "rate": 10000}, + {"item_code": item, "rate": 20000}, + ], + ) + + def test_non_positive_rate_is_rejected(self): + """Rate <= 0 is rejected with ValidationError.""" + item = helpers.make_test_item("life17", self.uom) + with self.assertRaisesRegex( + frappe.ValidationError, + r"Rate must be greater than zero", + msg="Expected ValidationError for non-positive rate", + ): + helpers.make_price_group( + "PG-Life-17", + items=[{"item_code": item, "rate": 0}], + ) + + def test_duplicate_outlet_is_rejected(self): + """Duplicate company/warehouse outlet row is rejected with ValidationError.""" + wh = helpers.make_test_warehouse("life18", self.company) + with self.assertRaisesRegex( + frappe.ValidationError, + r"Duplicate outlet", + msg="Expected ValidationError for duplicate outlets", + ): + helpers.make_price_group( + "PG-Life-18", + items=[{"item_code": helpers.make_test_item("life18", self.uom), "rate": 10000}], + outlets=[ + {"company": self.company, "warehouse": wh}, + {"company": self.company, "warehouse": wh}, + ], + ) + + # --- Disable and Delete Tests --- + + def test_disable_restores_profiles_and_clears_markers(self): + """Disabling Price Group restores linked POS Profile previous list and clears owner markers.""" + wh = helpers.make_test_warehouse("life19", self.company) + pos = helpers.make_test_pos_profile("life19", self.company, wh) + frappe.db.set_value("POS Profile", pos, "selling_price_list", "Standard Selling") + + pg = helpers.make_price_group( + "PG-Life-19", + items=[{"item_code": helpers.make_test_item("life19", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + pg.enabled = 0 + pg.save() + + pos_doc = frappe.get_doc("POS Profile", pos) + self.assertEqual( + pos_doc.selling_price_list, + "Standard Selling", + msg=f"POS Profile {pos} price list was not restored on disable", + ) + self.assertFalse( + getattr(pos_doc, helpers.PROFILE_OWNER_FIELD, None), + msg=f"POS Profile {pos} owner marker was not cleared on disable", + ) + + def test_disable_disables_price_list_and_skips_item_price_writes(self): + """Disabling Price Group disables Price List and skips Item Price updates, keeping timestamps intact.""" + item = helpers.make_test_item("life20", self.uom) + pg = helpers.make_price_group("PG-Life-20", items=[{"item_code": item, "rate": 10000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + ip_name = frappe.db.get_value("Item Price", {"price_list": pl_name, "item_code": item}, "name") + ip_modified_before = frappe.db.get_value("Item Price", ip_name, "modified") + + pg.enabled = 0 + pg.save() + + pl_enabled = frappe.db.get_value("Price List", pl_name, "enabled") + self.assertEqual(pl_enabled, 0, msg=f"Price List {pl_name} was not disabled") + + ip_modified_after = frappe.db.get_value("Item Price", ip_name, "modified") + self.assertEqual( + ip_modified_before, + ip_modified_after, + msg=f"Item Price {ip_name} modified timestamp changed: {ip_modified_before} -> {ip_modified_after}", + ) + + def test_reenable_after_disable_restores_managed_prices(self): + """Re-enabling disabled Price Group restores Price List enabled state and retains active managed prices without recreation.""" + item = helpers.make_test_item("life21", self.uom) + pg = helpers.make_price_group("PG-Life-21", items=[{"item_code": item, "rate": 10000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + ip_name = frappe.db.get_value("Item Price", {"price_list": pl_name, "item_code": item}, "name") + ip_rate_before = frappe.db.get_value("Item Price", ip_name, "price_list_rate") + ip_creation_before = frappe.db.get_value("Item Price", ip_name, "creation") + + pg.enabled = 0 + pg.save() + + self.assertTrue( + frappe.db.exists("Item Price", ip_name), + msg=f"Item Price {ip_name} was deleted while Price Group was disabled", + ) + + pg.enabled = 1 + pg.save() + + pl_enabled = frappe.db.get_value("Price List", pl_name, "enabled") + self.assertEqual(pl_enabled, 1, msg=f"Price List {pl_name} was not re-enabled") + + self.assertTrue( + frappe.db.exists("Item Price", ip_name), + msg=f"Item Price {ip_name} was deleted/recreated on re-enable", + ) + ip_rate_after = frappe.db.get_value("Item Price", ip_name, "price_list_rate") + ip_creation_after = frappe.db.get_value("Item Price", ip_name, "creation") + owner = frappe.db.get_value("Item Price", ip_name, helpers.OWNER_FIELD) + self.assertEqual( + ip_rate_after, ip_rate_before, msg=f"Item Price {ip_name} rate changed after re-enable" + ) + self.assertEqual( + ip_creation_after, + ip_creation_before, + msg=( + f"Item Price {ip_name} creation timestamp changed " + f"({ip_creation_before} -> {ip_creation_after}), so the row was deleted on disable " + "and re-inserted on re-enable instead of being retained" + ), + ) + self.assertEqual(owner, pg.name, msg=f"Item Price {ip_name} owner marker was lost after re-enable") + + def test_delete_restores_profiles_and_keeps_price_list(self): + """Deleting Price Group restores profiles, keeps Price List (disabled), deletes marked rows, preserves unmanaged.""" + wh = helpers.make_test_warehouse("life22", self.company) + pos = helpers.make_test_pos_profile("life22", self.company, wh) + frappe.db.set_value("POS Profile", pos, "selling_price_list", "Standard Selling") + + item = helpers.make_test_item("life22", self.uom) + pg = helpers.make_price_group( + "PG-Life-22", + items=[{"item_code": item, "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + manual_ip = helpers.manual_item_price(helpers.make_test_item("life22-manual", self.uom), pl_name) + + pg.delete() + + pos_doc = frappe.get_doc("POS Profile", pos) + self.assertEqual( + pos_doc.selling_price_list, + "Standard Selling", + msg=f"POS Profile {pos} price list was not restored after delete", + ) + + self.assertTrue( + frappe.db.exists("Price List", pl_name), + msg=f"Price List {pl_name} was deleted after Price Group delete", + ) + pl_enabled = frappe.db.get_value("Price List", pl_name, "enabled") + self.assertEqual(pl_enabled, 0, msg=f"Price List {pl_name} should be disabled after delete") + + marked_ip_exists = frappe.db.exists("Item Price", {"price_list": pl_name, "item_code": item}) + self.assertFalse(marked_ip_exists, msg=f"Marked Item Price row for {item} still exists on {pl_name}") + + self.assertTrue( + frappe.db.exists("Item Price", manual_ip), msg=f"Manual Item Price {manual_ip} was deleted" + ) + + def test_delete_keeps_price_list_and_preserves_unmarked_rows(self): + """Deleting Price Group succeeds: keeps disabled Price List, preserves unmanaged rows, removes marked rows.""" + item = helpers.make_test_item("life23", self.uom) + pg = helpers.make_price_group("PG-Life-23", items=[{"item_code": item, "rate": 10000}]) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + manual_ip = helpers.manual_item_price( + helpers.make_test_item("life23-manual", self.uom), pl_name, price_list_rate=45000 + ) + manual_rate = frappe.db.get_value("Item Price", manual_ip, "price_list_rate") + manual_modified = frappe.db.get_value("Item Price", manual_ip, "modified") + + pg.delete() + + # Price List exists and disabled + self.assertTrue( + frappe.db.exists("Price List", pl_name), msg=f"Price List {pl_name} should be kept after delete" + ) + pl_enabled = frappe.db.get_value("Price List", pl_name, "enabled") + self.assertEqual(pl_enabled, 0, msg=f"Price List {pl_name} should be disabled") + + # Manual unmarked row still exists with unchanged rate and modified + self.assertTrue( + frappe.db.exists("Item Price", manual_ip), msg=f"Manual Item Price {manual_ip} should survive" + ) + self.assertEqual( + frappe.db.get_value("Item Price", manual_ip, "price_list"), + pl_name, + msg=f"Manual Item Price {manual_ip} price_list should still resolve to {pl_name}", + ) + self.assertEqual( + frappe.db.get_value("Item Price", manual_ip, "price_list_rate"), + manual_rate, + msg=f"Manual Item Price {manual_ip} rate changed after delete", + ) + self.assertEqual( + frappe.db.get_value("Item Price", manual_ip, "modified"), + manual_modified, + msg=f"Manual Item Price {manual_ip} modified timestamp changed after delete", + ) + + # All marked Item Prices for this group are gone + marked_rows = frappe.get_all( + "Item Price", filters={"price_list": pl_name, helpers.OWNER_FIELD: pg.name} + ) + self.assertEqual( + len(marked_rows), 0, msg=f"Marked Item Price rows remain after delete: {marked_rows}" + ) + + def test_controller_source_has_no_force_delete(self): + """Source contract check: the PriceGroup doctype package and ownership module must not use force=True in delete_doc.""" + app_path = Path(frappe.get_app_path("pos_next")) + search_paths = [ + app_path / "pos_next" / "doctype" / "price_group", + app_path / "price_group_ownership.py", + ] + + checked_files = 0 + target_needle = "force" + "=True" + for target in search_paths: + if target.is_dir(): + for py_file in target.rglob("*.py"): + source = py_file.read_text() + if py_file.name == "test_price_group_lifecycle.py": + source = source.split("def test_controller_source_has_no_force_delete")[0] + self.assertNotIn( + target_needle, + source, + msg=f"force=True found in {py_file}", + ) + checked_files += 1 + elif target.is_file(): + source = target.read_text() + if target.name == "test_price_group_lifecycle.py": + source = source.split("def test_controller_source_has_no_force_delete")[0] + self.assertNotIn( + target_needle, + source, + msg=f"force=True found in {target}", + ) + checked_files += 1 + + self.assertTrue( + checked_files > 0, msg=f"No python source files found in target search paths: {search_paths}" + ) + + def test_delete_fails_before_mutation_when_price_list_owned_by_other_group(self): + """Delete blocks, mutating nothing, when the managed Price List belongs to another group.""" + item = helpers.make_test_item("life25", self.uom) + wh = helpers.make_test_warehouse("life25", self.company) + pos = helpers.make_test_pos_profile("life25", self.company, wh) + + pg = helpers.make_price_group( + "PG-Life-25", + items=[{"item_code": item, "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + pl_name = f"{helpers.MANAGED_PRICE_LIST_PREFIX}{pg.price_group_name}" + + # Snapshot BEFORE attempt + pl_enabled_before = frappe.db.get_value("Price List", pl_name, "enabled") + self.assertEqual(frappe.db.get_value("Price List", pl_name, helpers.PRICE_LIST_OWNER_FIELD), pg.name) + + ip_rows_before = { + ( + r.name, + r.price_list_rate, + frappe.db.get_value("Item Price", r.name, "modified"), + ) + for r in frappe.get_all( + "Item Price", filters={"price_list": pl_name}, fields=["name", "price_list_rate"] + ) + } + + pos_pl_before = frappe.db.get_value("POS Profile", pos, "selling_price_list") + pos_owner_before = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD) + pos_prev_before = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD) + + frappe.db.set_value("Price List", pl_name, helpers.PRICE_LIST_OWNER_FIELD, "OtherGroup") + + with self.assertRaisesRegex( + frappe.ValidationError, + r"already exists and is owned by OtherGroup", + msg="Expected ValidationError when deleting Price Group with Price List owned by another group", + ): + pg.delete() + + # Snapshots unchanged + self.assertEqual(frappe.db.get_value("Price List", pl_name, "enabled"), pl_enabled_before) + self.assertEqual( + frappe.db.get_value("Price List", pl_name, helpers.PRICE_LIST_OWNER_FIELD), "OtherGroup" + ) + + ip_rows_after = { + ( + r.name, + r.price_list_rate, + frappe.db.get_value("Item Price", r.name, "modified"), + ) + for r in frappe.get_all( + "Item Price", filters={"price_list": pl_name}, fields=["name", "price_list_rate"] + ) + } + self.assertEqual(ip_rows_after, ip_rows_before) + + self.assertEqual(frappe.db.get_value("POS Profile", pos, "selling_price_list"), pos_pl_before) + self.assertEqual( + frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD), pos_owner_before + ) + self.assertEqual( + frappe.db.get_value("POS Profile", pos, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD), + pos_prev_before, + ) + self.assertTrue(frappe.db.exists("Price Group", pg.name)) + + def test_delete_succeeds_when_foreign_owned_profile_is_only_desired(self): + """Deleting a group must not be blocked by a profile another group owns and this one merely desires.""" + wh = helpers.make_test_warehouse("life26", self.company) + + # Create Group B before any matching POS Profile exists, so creation-time desired-profile + # validation (which still runs on save) has nothing to collide with. + pg_b = helpers.make_price_group( + "PG-Life-26B", + items=[{"item_code": helpers.make_test_item("life26", self.uom), "rate": 10000}], + outlets=[{"company": self.company, "warehouse": wh}], + ) + + # A POS Profile now appears for the same outlet, already owned by another group. + # Group B never claimed it via save, so it is only ever DESIRED, never OWNED, by B. + pos = helpers.make_test_pos_profile("life26", self.company, wh) + frappe.db.set_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD, "PG-A") + frappe.db.set_value("POS Profile", pos, "selling_price_list", "PG-PG-A") + + pos_pl_before = frappe.db.get_value("POS Profile", pos, "selling_price_list") + pos_owner_before = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD) + pos_prev_before = frappe.db.get_value("POS Profile", pos, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD) + pos_modified_before = frappe.db.get_value("POS Profile", pos, "modified") + + pg_b.delete() + + self.assertFalse(frappe.db.exists("Price Group", pg_b.name)) + self.assertEqual(frappe.db.get_value("POS Profile", pos, "selling_price_list"), pos_pl_before) + self.assertEqual( + frappe.db.get_value("POS Profile", pos, helpers.PROFILE_OWNER_FIELD), pos_owner_before + ) + self.assertEqual( + frappe.db.get_value("POS Profile", pos, helpers.PROFILE_PREVIOUS_PRICE_LIST_FIELD), + pos_prev_before, + ) + self.assertEqual(frappe.db.get_value("POS Profile", pos, "modified"), pos_modified_before) From ea08fb72b02951bf623e4d2382e21d5dad62b1b1 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:55:44 +0700 Subject: [PATCH 08/17] feat: add Price Group shortcut and Pricing card to POSNext workspace The modified timestamp is bumped because import_file_by_path skips any workspace file whose modified is not newer than the row in the database, which silently drops workspace edits on migrate. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../pos_next/workspace/posnext/posnext.json | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/pos_next/pos_next/workspace/posnext/posnext.json b/pos_next/pos_next/workspace/posnext/posnext.json index e29e564fb..54b382368 100644 --- a/pos_next/pos_next/workspace/posnext/posnext.json +++ b/pos_next/pos_next/workspace/posnext/posnext.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"id\":\"cDBfxZcI12\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"EuDVjJUKSQ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Start POS\",\"col\":3}},{\"id\":\"uXQ4aBaRfk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"jEFYB2fX3t\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Settings\",\"col\":3}},{\"id\":\"RoWvjX8Ocp\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Profile\",\"col\":3}},{\"id\":\"p4KrSYzInK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Opening Shift\",\"col\":3}},{\"id\":\"VO-VLNdx_2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Closing Shift\",\"col\":3}},{\"id\":\"OYrA05uyCG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Offer\",\"col\":3}},{\"id\":\"FUd4_fFBgH\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Coupon\",\"col\":3}},{\"id\":\"spacer1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"header2\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Configuration\",\"col\":12}},{\"id\":\"reports_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"pos_card\",\"type\":\"card\",\"data\":{\"card_name\":\"POS\",\"col\":4}},{\"id\":\"config_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Configuration\",\"col\":4}},{\"id\":\"shift_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Shift Management\",\"col\":4}},{\"id\":\"offers_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Offers & Coupons\",\"col\":4}},{\"id\":\"items_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Items\",\"col\":4}}]", + "content": "[{\"id\":\"cDBfxZcI12\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"EuDVjJUKSQ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Start POS\",\"col\":3}},{\"id\":\"uXQ4aBaRfk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"jEFYB2fX3t\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Settings\",\"col\":3}},{\"id\":\"RoWvjX8Ocp\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Profile\",\"col\":3}},{\"id\":\"p4KrSYzInK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Opening Shift\",\"col\":3}},{\"id\":\"VO-VLNdx_2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Closing Shift\",\"col\":3}},{\"id\":\"OYrA05uyCG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Offer\",\"col\":3}},{\"id\":\"FUd4_fFBgH\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Coupon\",\"col\":3}},{\"id\":\"pn_pg_shortcut\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Price Group\",\"col\":3}},{\"id\":\"spacer1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"header2\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Configuration\",\"col\":12}},{\"id\":\"reports_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"pos_card\",\"type\":\"card\",\"data\":{\"card_name\":\"POS\",\"col\":4}},{\"id\":\"config_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Configuration\",\"col\":4}},{\"id\":\"shift_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Shift Management\",\"col\":4}},{\"id\":\"offers_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Offers & Coupons\",\"col\":4}},{\"id\":\"items_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Items\",\"col\":4}},{\"id\":\"pricing_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Pricing\",\"col\":4}}]", "creation": "2026-01-27 21:23:15.819052", "custom_blocks": [], "docstatus": 0, @@ -176,9 +176,28 @@ "link_type": "DocType", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Pricing", + "link_count": 1, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Price Group", + "link_count": 0, + "link_to": "Price Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" } ], - "modified": "2026-06-03 15:43:52.445464", + "modified": "2026-08-31 22:00:00.000000", "modified_by": "Administrator", "module": "POS Next", "name": "POSNext", @@ -251,7 +270,15 @@ "link_to": "POS Coupon", "stats_filter": "[]", "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Price Group", + "link_to": "Price Group", + "stats_filter": "[]", + "type": "DocType" } ], "title": "POSNext" -} \ No newline at end of file +} From 7a36590d3c5f2501ff805ddf3d9b1e46013a9c57 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:55:54 +0700 Subject: [PATCH 09/17] feat: add POSNext workspace sidebar with Price Group entry The sidebar previously existed only as an auto-generated database row from after_app_install, which runs once and never updates. Shipping it as a standard app-level file makes frappe sync it on every migrate. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/workspace_sidebar/posnext.json | 127 ++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 pos_next/workspace_sidebar/posnext.json diff --git a/pos_next/workspace_sidebar/posnext.json b/pos_next/workspace_sidebar/posnext.json new file mode 100644 index 000000000..bda9f95db --- /dev/null +++ b/pos_next/workspace_sidebar/posnext.json @@ -0,0 +1,127 @@ +{ + "app": "pos_next", + "creation": "2026-08-30 18:20:18.313092", + "docstatus": 0, + "doctype": "Workspace Sidebar", + "header_icon": "message", + "idx": 0, + "items": [ + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Home", + "link_to": "POSNext", + "link_type": "Workspace", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pos Settings", + "link_to": "POS Settings", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Start POS", + "link_type": "URL", + "show_arrow": 0, + "type": "Link", + "url": "/pos/" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pos Profile", + "link_to": "POS Profile", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Price Group", + "link_to": "Price Group", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pos Opening Shift", + "link_to": "POS Opening Shift", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pos Closing Shift", + "link_to": "POS Closing Shift", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pos Offer", + "link_to": "POS Offer", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pos Coupon", + "link_to": "POS Coupon", + "link_type": "DocType", + "show_arrow": 0, + "type": "Link" + } + ], + "modified": "2026-08-31 22:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POSNext", + "owner": "Administrator", + "standard": 1, + "title": "POSNext" +} From eac9f3987c2a8d62ab564ecd3ee08573d7289521 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:08:54 +0700 Subject: [PATCH 10/17] feat: add IDR cash denomination quick buttons to payment dialog Replaces the computed quick-amount row with fixed banknote face values (2K/5K/10K/20K/50K/100K) plus a Pas button for the exact remaining, shown only when the selected method is cash and the currency is IDR. Cashiers tender physical notes, so face values match the workflow better than derived round-ups. Non-IDR and non-cash keep the existing quick amounts. Applied to both the desktop and mobile layouts. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- POS/src/components/sale/PaymentDialog.vue | 67 ++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/POS/src/components/sale/PaymentDialog.vue b/POS/src/components/sale/PaymentDialog.vue index ee71d01cc..9a199d360 100644 --- a/POS/src/components/sale/PaymentDialog.vue +++ b/POS/src/components/sale/PaymentDialog.vue @@ -1347,7 +1347,7 @@ ]) }} -
+
+ +
+ + +
@@ -1413,6 +1437,35 @@ {{ formatCurrency(amount) }}
+ +
+ + +
@@ -2898,6 +2951,18 @@ const isLastMethodCash = computed(() => { }); const { quickAmounts } = useQuickAmounts(remainingAmount, isLastMethodCash); +// Cash denomination quick buttons (IDR only): fixed banknote face values + "Pas" (exact) +const CASH_DENOMINATIONS = [2000, 5000, 10000, 20000, 50000, 100000]; +const isIDRCash = computed(() => { + return ( + isCashPaymentMethod(lastSelectedMethod.value) && + (props.currency || "").toUpperCase() === "IDR" + ); +}); +function denomLabel(amount) { + return amount >= 1000 ? `${amount / 1000}K` : `${amount}`; +} + // Whether a quick amount button should be disabled in exact-amount mode // Non-cash methods can only pay the exact remaining — no rounding allowed function isQuickAmountDisabled(amount) { From 172144a94fb43d9ab53e69091791c4183646eec4 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:09:00 +0700 Subject: [PATCH 11/17] chore: ignore .omo agent runtime directory Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d267bbffa..9137d97e7 100644 --- a/.gitignore +++ b/.gitignore @@ -65,5 +65,6 @@ dev-dist/ .playwright-mcp/* TODO.md .claude/settings.json +.omo/ *.jpeg missing.txt From 67ab4cd4e3dbe979caea86af686f468add7ea9ef Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:42:16 +0700 Subject: [PATCH 12/17] feat: add POS Package doctypes with min/max choice groups Master carries base_price + non-stock parent item; child tables model mandatory items, choice groups (min_qty..max_qty units across options), per-option price adjustments, and outlet scoping. Replaces the pick-count-only promo model from selling_additional so optional mixed selections (e.g. up to 3 vouchers of any mix) are expressible. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../pos_next/doctype/pos_package/__init__.py | 0 .../doctype/pos_package/pos_package.json | 225 ++++++++++++++++++ .../doctype/pos_package/pos_package.py | 205 ++++++++++++++++ .../doctype/pos_package_group/__init__.py | 0 .../pos_package_group/pos_package_group.json | 74 ++++++ .../pos_package_group/pos_package_group.py | 15 ++ .../doctype/pos_package_item/__init__.py | 0 .../pos_package_item/pos_package_item.json | 67 ++++++ .../pos_package_item/pos_package_item.py | 10 + .../doctype/pos_package_option/__init__.py | 0 .../pos_package_option.json | 94 ++++++++ .../pos_package_option/pos_package_option.py | 15 ++ .../doctype/pos_package_outlet/__init__.py | 0 .../pos_package_outlet.json | 44 ++++ .../pos_package_outlet/pos_package_outlet.py | 10 + 15 files changed, 759 insertions(+) create mode 100644 pos_next/pos_next/doctype/pos_package/__init__.py create mode 100644 pos_next/pos_next/doctype/pos_package/pos_package.json create mode 100644 pos_next/pos_next/doctype/pos_package/pos_package.py create mode 100644 pos_next/pos_next/doctype/pos_package_group/__init__.py create mode 100644 pos_next/pos_next/doctype/pos_package_group/pos_package_group.json create mode 100644 pos_next/pos_next/doctype/pos_package_group/pos_package_group.py create mode 100644 pos_next/pos_next/doctype/pos_package_item/__init__.py create mode 100644 pos_next/pos_next/doctype/pos_package_item/pos_package_item.json create mode 100644 pos_next/pos_next/doctype/pos_package_item/pos_package_item.py create mode 100644 pos_next/pos_next/doctype/pos_package_option/__init__.py create mode 100644 pos_next/pos_next/doctype/pos_package_option/pos_package_option.json create mode 100644 pos_next/pos_next/doctype/pos_package_option/pos_package_option.py create mode 100644 pos_next/pos_next/doctype/pos_package_outlet/__init__.py create mode 100644 pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.json create mode 100644 pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.py diff --git a/pos_next/pos_next/doctype/pos_package/__init__.py b/pos_next/pos_next/doctype/pos_package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/pos_package/pos_package.json b/pos_next/pos_next/doctype/pos_package/pos_package.json new file mode 100644 index 000000000..ada4dc489 --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package/pos_package.json @@ -0,0 +1,225 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:package_name", + "creation": "2026-08-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "header_section", + "package_name", + "disabled", + "column_break_header", + "company", + "currency", + "parent_section", + "parent_item", + "base_price", + "column_break_parent", + "valid_from", + "valid_upto", + "description", + "items_section", + "items", + "groups_section", + "groups", + "options_section", + "options", + "outlets_section", + "outlets" + ], + "fields": [ + { + "fieldname": "header_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "package_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Package Name", + "reqd": 1, + "unique": 1 + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Disabled" + }, + { + "fieldname": "column_break_header", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fetch_from": "company.default_currency", + "fetch_if_empty": 1, + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "reqd": 1 + }, + { + "fieldname": "parent_section", + "fieldtype": "Section Break", + "label": "Package Item & Price" + }, + { + "description": "Non-stock Item that represents the package on the invoice and carries its price.", + "fieldname": "parent_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Package Item", + "options": "Item", + "reqd": 1, + "unique": 1 + }, + { + "description": "Price of the package before any option price adjustments.", + "fieldname": "base_price", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Base Price", + "options": "currency", + "reqd": 1 + }, + { + "fieldname": "column_break_parent", + "fieldtype": "Column Break" + }, + { + "fieldname": "valid_from", + "fieldtype": "Date", + "label": "Valid From" + }, + { + "fieldname": "valid_upto", + "fieldtype": "Date", + "label": "Valid Upto" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + }, + { + "description": "Items always included in the package.", + "fieldname": "items_section", + "fieldtype": "Section Break", + "label": "Included Items" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Included Items", + "options": "POS Package Item" + }, + { + "description": "Each group lets the customer pick between Min Qty and Max Qty units.", + "fieldname": "groups_section", + "fieldtype": "Section Break", + "label": "Choice Groups" + }, + { + "fieldname": "groups", + "fieldtype": "Table", + "label": "Choice Groups", + "options": "POS Package Group" + }, + { + "fieldname": "options_section", + "fieldtype": "Section Break", + "label": "Choice Options" + }, + { + "fieldname": "options", + "fieldtype": "Table", + "label": "Choice Options", + "options": "POS Package Option" + }, + { + "description": "Leave empty to make the package available on every POS Profile of the company.", + "fieldname": "outlets_section", + "fieldtype": "Section Break", + "label": "Outlets" + }, + { + "fieldname": "outlets", + "fieldtype": "Table", + "label": "Outlets", + "options": "POS Package Outlet" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Package", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Nexus POS Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "POSNext Cashier", + "share": 1 + } + ], + "row_format": "Dynamic", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/pos_package/pos_package.py b/pos_next/pos_next/doctype/pos_package/pos_package.py new file mode 100644 index 000000000..d87a2879d --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package/pos_package.py @@ -0,0 +1,205 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +import re + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint, flt, getdate + +GROUP_KEY_PATTERN = re.compile(r"^[a-z0-9_]+$") + + +def slugify_group_key(label, fallback_idx): + """Derive a stable, readable group key from a label.""" + slug = re.sub(r"[^a-z0-9]+", "_", (label or "").strip().lower()).strip("_") + return slug or f"group_{fallback_idx}" + + +class POSPackage(Document): + def validate(self): + self.assign_group_keys() + self.validate_dates() + self.validate_price() + self.validate_content() + self.validate_groups() + self.validate_options() + self.validate_parent_item() + self.validate_component_items() + self.validate_outlets() + + def assign_group_keys(self): + """Fill blank group keys from the label and reject malformed/duplicate ones.""" + seen = set() + for idx, group in enumerate(self.groups or [], start=1): + if not group.group_key: + group.group_key = slugify_group_key(group.label, idx) + + group.group_key = group.group_key.strip().lower() + + if not GROUP_KEY_PATTERN.match(group.group_key): + frappe.throw( + _( + "Row {0}: Group Key {1} may only contain lowercase letters, digits and underscores." + ).format(group.idx, frappe.bold(group.group_key)) + ) + + if group.group_key in seen: + frappe.throw( + _("Row {0}: Group Key {1} is used more than once.").format( + group.idx, frappe.bold(group.group_key) + ) + ) + seen.add(group.group_key) + + def validate_dates(self): + if self.valid_from and self.valid_upto and getdate(self.valid_from) > getdate(self.valid_upto): + frappe.throw(_("Valid Upto cannot be earlier than Valid From.")) + + def validate_price(self): + if flt(self.base_price) < 0: + frappe.throw(_("Base Price cannot be negative.")) + + def validate_content(self): + if not (self.items or self.groups): + frappe.throw(_("A package needs at least one included item or one choice group.")) + + def validate_groups(self): + for group in self.groups or []: + min_qty = cint(group.min_qty) + max_qty = cint(group.max_qty) + + if max_qty < 1: + frappe.throw( + _("Row {0}: Max Qty must be at least 1 for group {1}.").format( + group.idx, frappe.bold(group.label) + ) + ) + + if min_qty > max_qty: + frappe.throw( + _("Row {0}: Min Qty cannot exceed Max Qty for group {1}.").format( + group.idx, frappe.bold(group.label) + ) + ) + + options = [o for o in (self.options or []) if o.group_key == group.group_key] + if not options: + frappe.throw( + _("Group {0} has no options. Add at least one option or remove the group.").format( + frappe.bold(group.label) + ) + ) + + # The group is unsatisfiable when every option's own cap sums below min_qty. + capacity = sum(cint(o.max_qty) or max_qty for o in options) + if capacity < min_qty: + frappe.throw( + _("Group {0} requires {1} unit(s) but its options allow at most {2}.").format( + frappe.bold(group.label), min_qty, capacity + ) + ) + + def validate_options(self): + group_keys = {g.group_key for g in self.groups or []} + for option in self.options or []: + option.group_key = (option.group_key or "").strip().lower() + + if option.group_key not in group_keys: + frappe.throw( + _("Row {0}: Group Key {1} does not match any choice group.").format( + option.idx, frappe.bold(option.group_key) + ) + ) + + if flt(option.qty_per_unit) <= 0: + frappe.throw(_("Row {0}: Qty Per Unit must be greater than zero.").format(option.idx)) + + def validate_parent_item(self): + item = frappe.db.get_value( + "Item", + self.parent_item, + ["is_stock_item", "is_sales_item", "is_fixed_asset", "has_batch_no", "has_serial_no", "disabled"], + as_dict=True, + ) + if not item: + frappe.throw(_("Package Item {0} does not exist.").format(frappe.bold(self.parent_item))) + + if item.disabled: + frappe.throw(_("Package Item {0} is disabled.").format(frappe.bold(self.parent_item))) + + if not item.is_sales_item: + frappe.throw(_("Package Item {0} must be a sales item.").format(frappe.bold(self.parent_item))) + + if item.is_stock_item: + frappe.throw( + _( + "Package Item {0} must be a non-stock item — stock moves on the included items, not on the package line." + ).format(frappe.bold(self.parent_item)) + ) + + if item.is_fixed_asset: + frappe.throw(_("Package Item {0} cannot be a fixed asset.").format(frappe.bold(self.parent_item))) + + if item.has_batch_no or item.has_serial_no: + frappe.throw( + _("Package Item {0} cannot be batch or serial tracked.").format(frappe.bold(self.parent_item)) + ) + + if frappe.db.exists("Product Bundle", {"new_item_code": self.parent_item, "disabled": 0}): + frappe.throw( + _( + "Item {0} is already used by a Product Bundle. Use a dedicated item for the package." + ).format(frappe.bold(self.parent_item)) + ) + + def validate_component_items(self): + """Included items and options must be sellable items distinct from the package item.""" + rows = [("items", row) for row in (self.items or [])] + rows += [("options", row) for row in (self.options or [])] + + for table, row in rows: + if row.item_code == self.parent_item: + frappe.throw( + _("Row {0}: {1} cannot contain the Package Item itself.").format( + row.idx, _("Included Items") if table == "items" else _("Choice Options") + ) + ) + + item = frappe.db.get_value("Item", row.item_code, ["is_sales_item", "disabled"], as_dict=True) + if not item: + frappe.throw( + _("Row {0}: Item {1} does not exist.").format(row.idx, frappe.bold(row.item_code)) + ) + if item.disabled: + frappe.throw(_("Row {0}: Item {1} is disabled.").format(row.idx, frappe.bold(row.item_code))) + if not item.is_sales_item: + frappe.throw( + _("Row {0}: Item {1} is not a sales item.").format(row.idx, frappe.bold(row.item_code)) + ) + + if table == "items" and flt(row.qty) <= 0: + frappe.throw(_("Row {0}: Qty must be greater than zero.").format(row.idx)) + + def validate_outlets(self): + seen = set() + for outlet in self.outlets or []: + if outlet.pos_profile in seen: + frappe.throw( + _("Row {0}: POS Profile {1} is listed more than once.").format( + outlet.idx, frappe.bold(outlet.pos_profile) + ) + ) + seen.add(outlet.pos_profile) + + profile_company = frappe.db.get_value("POS Profile", outlet.pos_profile, "company") + if profile_company != self.company: + frappe.throw( + _("Row {0}: POS Profile {1} belongs to company {2}, not {3}.").format( + outlet.idx, + frappe.bold(outlet.pos_profile), + frappe.bold(profile_company), + frappe.bold(self.company), + ) + ) diff --git a/pos_next/pos_next/doctype/pos_package_group/__init__.py b/pos_next/pos_next/doctype/pos_package_group/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/pos_package_group/pos_package_group.json b/pos_next/pos_next/doctype/pos_package_group/pos_package_group.json new file mode 100644 index 000000000..c89b8306d --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_group/pos_package_group.json @@ -0,0 +1,74 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-08-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "group_key", + "label", + "min_qty", + "max_qty", + "description" + ], + "fields": [ + { + "columns": 2, + "description": "Stable identifier referenced by options. Auto-filled if left blank.", + "fieldname": "group_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Group Key" + }, + { + "columns": 3, + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "columns": 1, + "default": "1", + "description": "Minimum units the customer must pick. 0 makes the group optional.", + "fieldname": "min_qty", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Min Qty", + "non_negative": 1 + }, + { + "columns": 1, + "default": "1", + "description": "Maximum units the customer may pick across all options in this group.", + "fieldname": "max_qty", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Max Qty", + "non_negative": 1, + "reqd": 1 + }, + { + "columns": 3, + "fieldname": "description", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Package Group", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/pos_package_group/pos_package_group.py b/pos_next/pos_next/doctype/pos_package_group/pos_package_group.py new file mode 100644 index 000000000..7202770de --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_group/pos_package_group.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class POSPackageGroup(Document): + """A choice group inside a POS Package. + + The customer picks between ``min_qty`` and ``max_qty`` units in total across + the group's options. ``min_qty == max_qty == 1`` means "pick exactly one"; + ``min_qty = 0, max_qty = 3`` means "pick up to three, any mix". + """ + + pass diff --git a/pos_next/pos_next/doctype/pos_package_item/__init__.py b/pos_next/pos_next/doctype/pos_package_item/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/pos_package_item/pos_package_item.json b/pos_next/pos_next/doctype/pos_package_item/pos_package_item.json new file mode 100644 index 000000000..e9d05d20d --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_item/pos_package_item.json @@ -0,0 +1,67 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-08-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "qty", + "uom" + ], + "fields": [ + { + "columns": 4, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "columns": 4, + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Item Name", + "read_only": 1 + }, + { + "columns": 2, + "default": "1", + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty", + "non_negative": 1, + "reqd": 1 + }, + { + "columns": 2, + "fetch_from": "item_code.sales_uom", + "fetch_if_empty": 1, + "fieldname": "uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "UOM", + "options": "UOM" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Package Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/pos_package_item/pos_package_item.py b/pos_next/pos_next/doctype/pos_package_item/pos_package_item.py new file mode 100644 index 000000000..d166e0703 --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_item/pos_package_item.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class POSPackageItem(Document): + """Mandatory component of a POS Package (always included, no customer choice).""" + + pass diff --git a/pos_next/pos_next/doctype/pos_package_option/__init__.py b/pos_next/pos_next/doctype/pos_package_option/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/pos_package_option/pos_package_option.json b/pos_next/pos_next/doctype/pos_package_option/pos_package_option.json new file mode 100644 index 000000000..543dae67e --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_option/pos_package_option.json @@ -0,0 +1,94 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-08-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "group_key", + "item_code", + "item_name", + "qty_per_unit", + "uom", + "price_adjustment", + "max_qty" + ], + "fields": [ + { + "columns": 2, + "description": "Must match a Group Key from the Choice Groups table.", + "fieldname": "group_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Group Key", + "reqd": 1 + }, + { + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "columns": 1, + "default": "1", + "description": "Item qty delivered per selected unit.", + "fieldname": "qty_per_unit", + "fieldtype": "Float", + "label": "Qty Per Unit", + "non_negative": 1, + "reqd": 1 + }, + { + "fetch_from": "item_code.sales_uom", + "fetch_if_empty": 1, + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM" + }, + { + "columns": 2, + "default": "0", + "description": "Added to the package price per unit picked. May be negative.", + "fieldname": "price_adjustment", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Price Adjustment" + }, + { + "columns": 1, + "default": "0", + "description": "Max units of this option alone. 0 = no extra limit.", + "fieldname": "max_qty", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Max Qty", + "non_negative": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Package Option", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/pos_package_option/pos_package_option.py b/pos_next/pos_next/doctype/pos_package_option/pos_package_option.py new file mode 100644 index 000000000..d2fa521fb --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_option/pos_package_option.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class POSPackageOption(Document): + """A selectable item inside a POS Package Group. + + ``price_adjustment`` is added to the package base price for each unit picked; + it may be negative. ``max_qty`` caps repeats of this single option (0 = only + the group's own max applies). + """ + + pass diff --git a/pos_next/pos_next/doctype/pos_package_outlet/__init__.py b/pos_next/pos_next/doctype/pos_package_outlet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.json b/pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.json new file mode 100644 index 000000000..9bb7a41dc --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.json @@ -0,0 +1,44 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-08-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "pos_profile", + "enabled" + ], + "fields": [ + { + "columns": 6, + "fieldname": "pos_profile", + "fieldtype": "Link", + "in_list_view": 1, + "label": "POS Profile", + "options": "POS Profile", + "reqd": 1 + }, + { + "columns": 2, + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enabled" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Package Outlet", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.py b/pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.py new file mode 100644 index 000000000..c9f42131e --- /dev/null +++ b/pos_next/pos_next/doctype/pos_package_outlet/pos_package_outlet.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class POSPackageOutlet(Document): + """Restricts a POS Package to a specific POS Profile.""" + + pass From 260e060f244b7605cbff83beb85acb42b44821be Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:42:59 +0700 Subject: [PATCH 13/17] feat: add server-authoritative POS Package quoting API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_packages serves full definitions for offline caching; quote_package prices a selection. validate_invoice_packages re-quotes every package on Sales Invoice validate and recalculates totals, since Frappe runs the controller's validate before app hooks — otherwise a tampered payload is repriced but still charged the old grand_total. Returns are mirrored against the original invoice: package membership is re-derived through sales_invoice_item because the POS return payload strips the package fields, so a credit note cannot refund the priced parent while dropping the components. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/_pn_run_tests.py | 61 ++++ pos_next/api/packages.py | 593 ++++++++++++++++++++++++++++++++++ pos_next/api/test_packages.py | 578 +++++++++++++++++++++++++++++++++ 3 files changed, 1232 insertions(+) create mode 100644 pos_next/_pn_run_tests.py create mode 100644 pos_next/api/packages.py create mode 100644 pos_next/api/test_packages.py diff --git a/pos_next/_pn_run_tests.py b/pos_next/_pn_run_tests.py new file mode 100644 index 000000000..c971412f7 --- /dev/null +++ b/pos_next/_pn_run_tests.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +"""Bootstrap runner for pos_next tests. + +`python -m unittest` imports test modules before `frappe.init`, which crashes, +and `bench run-tests` dies on ERPNext bootstrap (DuplicateEntryError on +'Standard Buying'). This inits frappe first, then loads the named modules. + +Usage (inside the container, serial only — parallel runs deadlock on +Stock Settings/tabSingles): + + ./env/bin/python apps/pos_next/pos_next/_pn_run_tests.py pos_next.api.test_packages +""" + +import os +import sys +import unittest + +import frappe + +SITE = "erpnext16.localhost" +# frappe.init resolves sites/ relative to the cwd, so anchor at the bench root +# (three levels up from apps/pos_next/pos_next/). +BENCH_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +def main(module_names): + if not module_names: + print("usage: _pn_run_tests.py [ ...]", file=sys.stderr) + return 2 + + os.chdir(BENCH_ROOT) + + # This script lives in pos_next/, which contains a nested pos_next/ module + # folder — leaving the script dir on sys.path makes `pos_next` resolve there + # (no .api subpackage). Drop it and use the app root instead. + script_dir = os.path.dirname(os.path.abspath(__file__)) + sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != script_dir] + if APP_ROOT not in sys.path: + sys.path.insert(0, APP_ROOT) + + frappe.init(site=SITE, sites_path=os.path.join(BENCH_ROOT, "sites")) + frappe.connect() + frappe.flags.in_test = True + + try: + loader = unittest.TestLoader() + suite = unittest.TestSuite() + for name in module_names: + # loadTestsFromName on a package silently collects 0 tests, so + # modules must always be listed explicitly. + suite.addTests(loader.loadTestsFromName(name)) + + result = unittest.TextTestRunner(verbosity=2).run(suite) + return 0 if result.wasSuccessful() else 1 + finally: + frappe.destroy() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/pos_next/api/packages.py b/pos_next/api/packages.py new file mode 100644 index 000000000..3bac0e5e2 --- /dev/null +++ b/pos_next/api/packages.py @@ -0,0 +1,593 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +"""POS Package API. + +A POS Package ("Paket") sells a fixed set of items plus customer-chosen options +under one price. On the invoice it materialises as: + +- one **parent** row (the non-stock package item) carrying the whole price, and +- one **component** row per included/chosen item at rate 0. + +Stock therefore moves on the components while revenue sits on the parent line. + +Pricing is ``base_price + sum(option.price_adjustment * qty)``. + +The quote is computed here on the server and mirrored byte-for-byte by +``POS/src/utils/packageQuote.js`` so the POS can price packages while offline. +``validate_invoice_packages`` re-quotes every package on the Sales Invoice, so a +tampered or stale client payload can never set its own price. +""" + +import json +import re + +import frappe +from frappe import _ +from frappe.utils import cint, flt, getdate, nowdate + +PARENT_ROLE = "Package" +COMPONENT_ROLE = "Package Item" + +INSTANCE_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]{1,140}$") + + +def _parse_json(value, default): + if value is None or value == "": + return default + if isinstance(value, str): + try: + return json.loads(value) + except (ValueError, TypeError): + frappe.throw(_("Malformed package payload.")) + return value + + +def _assert_profile_access(pos_profile): + """Reject callers who aren't assigned to this POS Profile. + + Both whitelisted endpoints take `pos_profile` straight from the caller, so + without this any logged-in user could read another outlet's packages and + pricing. Mirrors pos_next.api.pos_profile.get_pos_profile_data. + """ + if not pos_profile: + frappe.throw(_("POS Profile is required")) + + if frappe.db.exists("POS Profile User", {"parent": pos_profile, "user": frappe.session.user}): + return + + if frappe.has_permission("POS Profile", "write"): + return + + frappe.throw(_("You don't have access to this POS Profile"), frappe.PermissionError) + + +def _package_is_valid_on(package, on_date): + if package.get("valid_from") and getdate(on_date) < getdate(package["valid_from"]): + return False + if package.get("valid_upto") and getdate(on_date) > getdate(package["valid_upto"]): + return False + return True + + +def _serialize_package(doc): + """Full package definition — enough for the POS to render and price offline.""" + component_codes = {row.item_code for row in doc.items or []} + component_codes |= {row.item_code for row in doc.options or []} + stock_flags = ( + { + code: cint(flag) + for code, flag in frappe.get_all( + "Item", + filters={"name": ["in", list(component_codes)]}, + fields=["name", "is_stock_item"], + as_list=True, + ) + } + if component_codes + else {} + ) + + return { + "name": doc.name, + "package_name": doc.package_name, + "parent_item": doc.parent_item, + "base_price": flt(doc.base_price), + "currency": doc.currency, + "company": doc.company, + "description": doc.description, + "valid_from": str(doc.valid_from) if doc.valid_from else None, + "valid_upto": str(doc.valid_upto) if doc.valid_upto else None, + "items": [ + { + "item_code": row.item_code, + "item_name": row.item_name, + "qty": flt(row.qty), + "uom": row.uom, + "is_stock_item": stock_flags.get(row.item_code, 1), + } + for row in doc.items or [] + ], + "groups": [ + { + "group_key": row.group_key, + "label": row.label, + "description": row.description, + "min_qty": cint(row.min_qty), + "max_qty": cint(row.max_qty), + } + for row in doc.groups or [] + ], + "options": [ + { + "option_id": row.name, + "group_key": row.group_key, + "item_code": row.item_code, + "item_name": row.item_name, + "qty_per_unit": flt(row.qty_per_unit) or 1.0, + "uom": row.uom, + "price_adjustment": flt(row.price_adjustment), + "max_qty": cint(row.max_qty), + "is_stock_item": stock_flags.get(row.item_code, 1), + } + for row in doc.options or [] + ], + } + + +def _eligible_package_names(pos_profile, on_date=None): + """Names of enabled, in-date packages available on this POS Profile. + + A package with no outlet rows is available to every profile of its company; + otherwise the profile must be listed with ``enabled = 1``. + """ + profile = frappe.db.get_value("POS Profile", pos_profile, ["name", "company"], as_dict=True) + if not profile: + frappe.throw(_("POS Profile {0} not found.").format(frappe.bold(pos_profile))) + + on_date = on_date or nowdate() + + packages = frappe.get_all( + "POS Package", + filters={"disabled": 0, "company": profile.company}, + fields=["name", "valid_from", "valid_upto"], + ) + if not packages: + return [] + + names = [p.name for p in packages] + + outlet_rows = frappe.get_all( + "POS Package Outlet", + filters={"parent": ["in", names], "parenttype": "POS Package"}, + fields=["parent", "pos_profile", "enabled"], + ) + restricted = {row.parent for row in outlet_rows} + allowed = {row.parent for row in outlet_rows if row.enabled and row.pos_profile == profile.name} + + return [ + p.name + for p in packages + if _package_is_valid_on(p, on_date) and (p.name not in restricted or p.name in allowed) + ] + + +@frappe.whitelist() +def get_packages(pos_profile, on_date=None): + """Return every package available on this profile, fully expanded. + + The POS caches this payload in IndexedDB so package selection and pricing keep + working offline. + """ + _assert_profile_access(pos_profile) + + names = _eligible_package_names(pos_profile, on_date) + return [_serialize_package(frappe.get_cached_doc("POS Package", name)) for name in names] + + +def _index_choices(choices): + """Normalise the client payload into ``{group_key: {option_id: qty}}``.""" + indexed = {} + for entry in choices or []: + group_key = (entry or {}).get("group_key") + if not group_key: + frappe.throw(_("Each selection must reference a group.")) + + bucket = indexed.setdefault(group_key, {}) + for option in entry.get("options") or []: + option_id = (option or {}).get("option_id") + qty = cint((option or {}).get("qty")) + if not option_id: + frappe.throw(_("Each selection must reference an option.")) + if qty < 0: + frappe.throw(_("Selected quantity cannot be negative.")) + if qty: + bucket[option_id] = bucket.get(option_id, 0) + qty + return indexed + + +def quote(package_name, choices, pos_profile, warehouse=None): + """Validate a selection and return the priced package (non-whitelisted core). + + Returns ``{package, package_name, total, currency, lines, snapshot}`` where + ``lines[0]`` is the parent row and the rest are components at rate 0. + """ + if package_name not in _eligible_package_names(pos_profile): + frappe.throw(_("Package {0} is not available on this POS Profile.").format(frappe.bold(package_name))) + + doc = frappe.get_cached_doc("POS Package", package_name) + indexed = _index_choices(choices) + + options_by_id = {row.name: row for row in doc.options or []} + group_keys = {group.group_key for group in doc.groups or []} + + for group_key in indexed: + if group_key not in group_keys: + frappe.throw(_("Unknown choice group {0}.").format(frappe.bold(group_key))) + + total = flt(doc.base_price) + component_lines = [] + snapshot_selections = [] + + for group in doc.groups or []: + picks = indexed.get(group.group_key, {}) + picked_qty = sum(picks.values()) + min_qty = cint(group.min_qty) + max_qty = cint(group.max_qty) + + if picked_qty < min_qty: + frappe.throw(_("Choose at least {0} item(s) from {1}.").format(min_qty, frappe.bold(group.label))) + if picked_qty > max_qty: + frappe.throw(_("Choose at most {0} item(s) from {1}.").format(max_qty, frappe.bold(group.label))) + + for option_id, qty in picks.items(): + option = options_by_id.get(option_id) + if not option or option.group_key != group.group_key: + frappe.throw( + _("Option {0} does not belong to {1}.").format(option_id, frappe.bold(group.label)) + ) + + option_max = cint(option.max_qty) + if option_max and qty > option_max: + frappe.throw( + _("You can pick at most {0} x {1}.").format( + option_max, frappe.bold(option.item_name or option.item_code) + ) + ) + + total += flt(option.price_adjustment) * qty + + component_lines.append( + { + "item_code": option.item_code, + "item_name": option.item_name, + "qty": (flt(option.qty_per_unit) or 1.0) * qty, + "uom": option.uom, + "rate": 0.0, + "role": COMPONENT_ROLE, + "is_stock_item": cint(frappe.db.get_value("Item", option.item_code, "is_stock_item")), + } + ) + snapshot_selections.append( + { + "group_key": group.group_key, + "group_label": group.label, + "option_id": option_id, + "item_code": option.item_code, + "item_name": option.item_name, + "qty": qty, + "price_adjustment": flt(option.price_adjustment), + } + ) + + for row in doc.items or []: + component_lines.append( + { + "item_code": row.item_code, + "item_name": row.item_name, + "qty": flt(row.qty), + "uom": row.uom, + "rate": 0.0, + "role": COMPONENT_ROLE, + "is_stock_item": cint(frappe.db.get_value("Item", row.item_code, "is_stock_item")), + } + ) + + if total < 0: + frappe.throw(_("Package price cannot be negative.")) + + total = flt(total, frappe.get_precision("Sales Invoice Item", "rate")) + + parent_line = { + "item_code": doc.parent_item, + "item_name": doc.package_name, + "qty": 1, + "rate": total, + "role": PARENT_ROLE, + } + + snapshot = { + "package": doc.name, + "package_name": doc.package_name, + "base_price": flt(doc.base_price), + "total": total, + "selections": snapshot_selections, + "included_items": [ + {"item_code": row.item_code, "item_name": row.item_name, "qty": flt(row.qty)} + for row in doc.items or [] + ], + } + + return { + "package": doc.name, + "package_name": doc.package_name, + "parent_item": doc.parent_item, + "currency": doc.currency, + "total": total, + "lines": [parent_line, *component_lines], + "snapshot": snapshot, + } + + +@frappe.whitelist() +def quote_package(package, choices, pos_profile, warehouse=None): + """Server-authoritative price for a package selection.""" + _assert_profile_access(pos_profile) + + return quote(package, _parse_json(choices, []), pos_profile, warehouse) + + +def _group_invoice_rows_by_instance(doc): + instances = {} + for row in doc.get("items") or []: + instance = row.get("pos_package_instance") + if not instance: + continue + instances.setdefault(instance, []).append(row) + return instances + + +def _recalculate_totals(doc): + """Recompute invoice totals after re-pricing package rows. + + Frappe runs the controller's own ``validate`` (which calculates taxes and + totals) BEFORE app ``doc_events`` hooks, so correcting a rate here leaves + grand_total holding the client's figure — a tampered payload would be + repriced yet still charged the old amount. Recalculating closes that gap. + """ + # `frappe._dict` returns None for unknown keys, so hasattr() is not enough. + recalculate = getattr(doc, "calculate_taxes_and_totals", None) + if callable(recalculate): + recalculate() + + +def _restore_return_package_metadata(doc): + """Rebuild package fields that the return payload never carries. + + ``ReturnInvoiceDialog.vue`` builds its items from a fixed field whitelist, so + ``pos_package_instance`` / ``pos_package_role`` never reach the server. Without + restoring them a credit note looks package-free and skips every guard below — + letting the priced parent be refunded while its components are dropped. + + Membership is re-derived from the original invoice through + ``sales_invoice_item`` (the link ERPNext itself uses for return tracking), so + the client never gets to declare which rows belong to a package. + """ + rows = doc.get("items") or [] + + link_names = [ + row.sales_invoice_item + for row in rows + if not row.get("pos_package_instance") and row.get("sales_invoice_item") + ] + + if link_names: + sources = frappe.get_all( + "Sales Invoice Item", + filters={"name": ["in", link_names], "parent": doc.get("return_against")}, + fields=[ + "name", + "pos_package", + "pos_package_instance", + "pos_package_role", + "pos_package_snapshot", + ], + ) + by_name = {source["name"]: source for source in sources} + + for row in rows: + source = by_name.get(row.get("sales_invoice_item")) + if not source or not source.get("pos_package_instance"): + continue + + row.pos_package = source["pos_package"] + row.pos_package_instance = source["pos_package_instance"] + row.pos_package_role = source["pos_package_role"] + row.pos_package_snapshot = source["pos_package_snapshot"] + + for row in rows: + if row.get("pos_package_instance") and not row.get("sales_invoice_item"): + frappe.throw( + _( + "Package return rows must reference the original invoice row. Create the return from the POS Return screen." + ) + ) + + +def _validate_return_packages(doc): + """Force a package credit note to mirror the invoice it returns. + + A return cannot be re-quoted (its rows are copies), so it is checked against + the original instead. Without this, two things are possible: raising the + parent row's qty to refund more than was sold, and deleting the component + rows to get the money back without returning any goods. + """ + if not doc.get("return_against"): + if _group_invoice_rows_by_instance(doc): + frappe.throw(_("A package return must reference the original invoice.")) + return + + _restore_return_package_metadata(doc) + + instances = _group_invoice_rows_by_instance(doc) + if not instances: + return + + precision = frappe.get_precision("Sales Invoice Item", "qty") or 3 + + for instance, rows in instances.items(): + original_rows = frappe.get_all( + "Sales Invoice Item", + filters={"parent": doc.return_against, "pos_package_instance": instance}, + fields=["item_code", "qty", "rate", "pos_package_role"], + ) + if not original_rows: + frappe.throw( + _("Package {0} does not exist on invoice {1}.").format( + frappe.bold(instance), frappe.bold(doc.return_against) + ) + ) + + parents = [r for r in rows if r.get("pos_package_role") == PARENT_ROLE] + original_parents = [r for r in original_rows if r.get("pos_package_role") == PARENT_ROLE] + if len(parents) != 1 or len(original_parents) != 1: + frappe.throw(_("Package {0} must have exactly one package line.").format(frappe.bold(instance))) + + parent = parents[0] + original_parent = original_parents[0] + + original_parent_qty = flt(original_parent["qty"]) + if not original_parent_qty: + frappe.throw(_("Package {0} has no quantity on the original invoice.").format(instance)) + + # Returns are negative; compare magnitudes. + fraction = abs(flt(parent.qty)) / abs(original_parent_qty) + if fraction <= 0 or fraction > 1: + frappe.throw( + _("You cannot return more of {0} than was sold.").format(frappe.bold(parent.item_code)) + ) + + parent.rate = flt(original_parent["rate"]) + parent.price_list_rate = flt(original_parent["rate"]) + parent.discount_amount = 0 + parent.discount_percentage = 0 + + expected = {} + for row in original_rows: + if row.get("pos_package_role") != COMPONENT_ROLE: + continue + expected[row["item_code"]] = expected.get(row["item_code"], 0) + flt(row["qty"]) + + submitted = {} + for row in rows: + if row.get("pos_package_role") != COMPONENT_ROLE: + continue + row.rate = 0 + row.price_list_rate = 0 + row.discount_amount = 0 + row.discount_percentage = 0 + submitted[row.item_code] = submitted.get(row.item_code, 0) + abs(flt(row.qty)) + + # Every component must come back in the same proportion as the parent, + # so a partial return stays consistent and none can be dropped. + for item_code, original_qty in expected.items(): + wanted = flt(abs(original_qty) * fraction, precision) + got = flt(submitted.get(item_code, 0), precision) + if wanted != got: + frappe.throw( + _("Package {0}: return {1} x {2} to match the package being returned (got {3}).").format( + frappe.bold(parent.item_code), wanted, frappe.bold(item_code), got + ) + ) + + for item_code in submitted: + if item_code not in expected: + frappe.throw( + _("Package {0} does not contain {1}.").format( + frappe.bold(parent.item_code), frappe.bold(item_code) + ) + ) + + _recalculate_totals(doc) + + +def validate_invoice_packages(doc, method=None): + """Re-price every package on the invoice from its stored selection. + + Hooked on Sales Invoice ``validate``. The client sends the chosen options; the + rates come from here, never from the payload — so an edited offline queue or a + crafted request cannot change what a package costs. + """ + if doc.get("is_return"): + _validate_return_packages(doc) + return + + instances = _group_invoice_rows_by_instance(doc) + if not instances: + return + + if not doc.get("pos_profile"): + frappe.throw(_("Packages can only be sold from a POS Profile.")) + + for instance, rows in instances.items(): + if not INSTANCE_PATTERN.match(instance): + frappe.throw(_("Invalid package reference {0}.").format(frappe.bold(instance))) + + parents = [r for r in rows if r.get("pos_package_role") == PARENT_ROLE] + if len(parents) != 1: + frappe.throw( + _("Package {0} must have exactly one package line, found {1}.").format( + frappe.bold(instance), len(parents) + ) + ) + + parent = parents[0] + package_name = parent.get("pos_package") + if not package_name: + frappe.throw( + _("Package line {0} is missing its package reference.").format(frappe.bold(instance)) + ) + + selections = _parse_json(parent.get("pos_package_snapshot"), {}).get("selections") or [] + choices = {} + for selection in selections: + choices.setdefault(selection.get("group_key"), []).append( + {"option_id": selection.get("option_id"), "qty": cint(selection.get("qty"))} + ) + + result = quote( + package_name, + [{"group_key": key, "options": options} for key, options in choices.items()], + doc.pos_profile, + ) + + # Authoritative price on the parent, zero on every component. + parent.rate = result["total"] + parent.price_list_rate = result["total"] + parent.discount_amount = 0 + parent.discount_percentage = 0 + parent.qty = 1 + parent.pos_package_snapshot = json.dumps(result["snapshot"]) + + expected = {} + for line in result["lines"][1:]: + expected[line["item_code"]] = expected.get(line["item_code"], 0) + flt(line["qty"]) + + submitted = {} + for row in rows: + if row.get("pos_package_role") != COMPONENT_ROLE: + continue + row.rate = 0 + row.price_list_rate = 0 + row.discount_amount = 0 + row.discount_percentage = 0 + submitted[row.item_code] = submitted.get(row.item_code, 0) + flt(row.qty) + + if expected != submitted: + frappe.throw( + _("Package {0} contents do not match its definition. Please re-add the package.").format( + frappe.bold(result["package_name"]) + ) + ) + + _recalculate_totals(doc) diff --git a/pos_next/api/test_packages.py b/pos_next/api/test_packages.py new file mode 100644 index 000000000..788c7938e --- /dev/null +++ b/pos_next/api/test_packages.py @@ -0,0 +1,578 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +"""Tests for POS Package quoting and invoice rate enforcement. + +Run inside the container (serial only): + + ./env/bin/python apps/pos_next/pos_next/_pn_run_tests.py pos_next.api.test_packages +""" + +import json +import unittest + +import frappe + +from pos_next.api.packages import ( + COMPONENT_ROLE, + PARENT_ROLE, + get_packages, + quote, + validate_invoice_packages, +) + +PROFILE = "_PNXT_TEST_POS_PROFILE__Test Company" +COMPANY = "_Test Company" +PACKAGE = "_PNXT Year End Laptop Package" + +LAPTOP = "_PNXT_PKG_LAPTOP" +BACKPACK = "_PNXT_PKG_BACKPACK" +HEADPHONE = "_PNXT_PKG_HEADPHONE" +VOUCHER_PULSA = "_PNXT_PKG_VOUCHER_PULSA" +VOUCHER_LISTRIK = "_PNXT_PKG_VOUCHER_LISTRIK" +PARENT_ITEM = "_PNXT_PKG_PARENT" + +BASE_PRICE = 10_000_000.0 +BACKPACK_ADJ = 0.0 +HEADPHONE_ADJ = 250_000.0 +PULSA_ADJ = 50_000.0 +LISTRIK_ADJ = 75_000.0 + + +def _ensure_item(item_code, item_name, is_stock_item): + if frappe.db.exists("Item", item_code): + return + + frappe.get_doc( + { + "doctype": "Item", + "item_code": item_code, + "item_name": item_name, + "item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"), + "stock_uom": "Nos", + "is_stock_item": 1 if is_stock_item else 0, + "is_sales_item": 1, + } + ).insert(ignore_permissions=True) + + +def _ensure_package(): + for code, name in ( + (LAPTOP, "PNXT Laptop"), + (BACKPACK, "PNXT Backpack"), + (HEADPHONE, "PNXT Headphone"), + (VOUCHER_PULSA, "PNXT Voucher Pulsa"), + (VOUCHER_LISTRIK, "PNXT Voucher Listrik"), + ): + _ensure_item(code, name, is_stock_item=True) + + _ensure_item(PARENT_ITEM, "PNXT Year End Laptop Package", is_stock_item=False) + + if frappe.db.exists("POS Package", PACKAGE): + return + + frappe.get_doc( + { + "doctype": "POS Package", + "package_name": PACKAGE, + "company": COMPANY, + "currency": frappe.db.get_value("Company", COMPANY, "default_currency"), + "parent_item": PARENT_ITEM, + "base_price": BASE_PRICE, + "items": [{"item_code": LAPTOP, "qty": 1}], + "groups": [ + {"group_key": "accessory", "label": "Accessory", "min_qty": 1, "max_qty": 1}, + {"group_key": "voucher", "label": "Voucher", "min_qty": 0, "max_qty": 3}, + ], + "options": [ + { + "group_key": "accessory", + "item_code": BACKPACK, + "qty_per_unit": 1, + "price_adjustment": BACKPACK_ADJ, + }, + { + "group_key": "accessory", + "item_code": HEADPHONE, + "qty_per_unit": 1, + "price_adjustment": HEADPHONE_ADJ, + }, + { + "group_key": "voucher", + "item_code": VOUCHER_PULSA, + "qty_per_unit": 1, + "price_adjustment": PULSA_ADJ, + }, + { + "group_key": "voucher", + "item_code": VOUCHER_LISTRIK, + "qty_per_unit": 1, + "price_adjustment": LISTRIK_ADJ, + }, + ], + "outlets": [{"pos_profile": PROFILE, "enabled": 1}], + } + ).insert(ignore_permissions=True) + + +def _option_id(pkg, item_code): + for option in pkg["options"]: + if option["item_code"] == item_code: + return option["option_id"] + raise AssertionError(f"option for {item_code} not found") + + +class TestPackageQuote(unittest.TestCase): + @classmethod + def setUpClass(cls): + _ensure_package() + frappe.db.commit() + cls.pkg = next(p for p in get_packages(PROFILE) if p["name"] == PACKAGE) + + def choices(self, accessory=None, vouchers=None): + """Build a choices payload. `vouchers` maps item_code -> qty.""" + out = [] + if accessory: + out.append( + { + "group_key": "accessory", + "options": [{"option_id": _option_id(self.pkg, accessory), "qty": 1}], + } + ) + if vouchers: + out.append( + { + "group_key": "voucher", + "options": [ + {"option_id": _option_id(self.pkg, code), "qty": qty} + for code, qty in vouchers.items() + ], + } + ) + return out + + def test_mandatory_item_is_always_included(self): + result = quote(PACKAGE, self.choices(accessory=BACKPACK), PROFILE) + + components = [line for line in result["lines"] if line["role"] == COMPONENT_ROLE] + self.assertIn(LAPTOP, [line["item_code"] for line in components]) + + def test_parent_line_carries_price_and_components_are_free(self): + result = quote(PACKAGE, self.choices(accessory=BACKPACK), PROFILE) + + parent = result["lines"][0] + self.assertEqual(parent["role"], PARENT_ROLE) + self.assertEqual(parent["item_code"], PARENT_ITEM) + self.assertEqual(parent["rate"], BASE_PRICE + BACKPACK_ADJ) + + for line in result["lines"][1:]: + self.assertEqual(line["rate"], 0.0) + + def test_choosing_the_priced_accessory_adds_its_adjustment(self): + result = quote(PACKAGE, self.choices(accessory=HEADPHONE), PROFILE) + + self.assertEqual(result["total"], BASE_PRICE + HEADPHONE_ADJ) + + def test_exactly_one_group_rejects_zero_picks(self): + with self.assertRaises(frappe.ValidationError): + quote(PACKAGE, self.choices(), PROFILE) + + def test_exactly_one_group_rejects_two_picks(self): + choices = [ + { + "group_key": "accessory", + "options": [ + {"option_id": _option_id(self.pkg, BACKPACK), "qty": 1}, + {"option_id": _option_id(self.pkg, HEADPHONE), "qty": 1}, + ], + } + ] + + with self.assertRaises(frappe.ValidationError): + quote(PACKAGE, choices, PROFILE) + + def test_optional_group_allows_zero_picks(self): + result = quote(PACKAGE, self.choices(accessory=BACKPACK, vouchers={}), PROFILE) + + self.assertEqual(result["total"], BASE_PRICE) + + def test_voucher_group_accepts_a_mix_up_to_three(self): + result = quote( + PACKAGE, + self.choices(accessory=BACKPACK, vouchers={VOUCHER_PULSA: 2, VOUCHER_LISTRIK: 1}), + PROFILE, + ) + + self.assertEqual(result["total"], BASE_PRICE + (2 * PULSA_ADJ) + LISTRIK_ADJ) + + qty_by_item = { + line["item_code"]: line["qty"] for line in result["lines"] if line["role"] == COMPONENT_ROLE + } + self.assertEqual(qty_by_item[VOUCHER_PULSA], 2) + self.assertEqual(qty_by_item[VOUCHER_LISTRIK], 1) + + def test_voucher_group_accepts_three_of_one_option(self): + result = quote(PACKAGE, self.choices(accessory=BACKPACK, vouchers={VOUCHER_LISTRIK: 3}), PROFILE) + + self.assertEqual(result["total"], BASE_PRICE + (3 * LISTRIK_ADJ)) + + def test_voucher_group_rejects_four_units(self): + with self.assertRaises(frappe.ValidationError): + quote( + PACKAGE, + self.choices(accessory=BACKPACK, vouchers={VOUCHER_PULSA: 2, VOUCHER_LISTRIK: 2}), + PROFILE, + ) + + def test_unknown_group_is_rejected(self): + choices = [{"group_key": "nope", "options": [{"option_id": "x", "qty": 1}]}] + + with self.assertRaises(frappe.ValidationError): + quote(PACKAGE, choices, PROFILE) + + def test_option_from_another_group_is_rejected(self): + choices = [ + { + "group_key": "accessory", + "options": [{"option_id": _option_id(self.pkg, VOUCHER_PULSA), "qty": 1}], + } + ] + + with self.assertRaises(frappe.ValidationError): + quote(PACKAGE, choices, PROFILE) + + +class TestInvoicePackageEnforcement(unittest.TestCase): + @classmethod + def setUpClass(cls): + _ensure_package() + frappe.db.commit() + cls.pkg = next(p for p in get_packages(PROFILE) if p["name"] == PACKAGE) + + def build_invoice(self, parent_rate, snapshot_qty=1): + """Sales Invoice-shaped doc carrying one package instance. + + Returns ``(doc, rows)``: `_dict.items` is dict.items, so the item rows + must be handed back separately rather than read off the doc. + """ + option_id = _option_id(self.pkg, HEADPHONE) + snapshot = {"selections": [{"group_key": "accessory", "option_id": option_id, "qty": snapshot_qty}]} + + rows = [ + frappe._dict( + { + "item_code": PARENT_ITEM, + "qty": 1, + "rate": parent_rate, + "price_list_rate": parent_rate, + "discount_amount": 0, + "discount_percentage": 0, + "pos_package": PACKAGE, + "pos_package_instance": "pkg-test-1", + "pos_package_role": PARENT_ROLE, + "pos_package_snapshot": json.dumps(snapshot), + } + ), + frappe._dict( + { + "item_code": LAPTOP, + "qty": 1, + "rate": 0, + "price_list_rate": 0, + "discount_amount": 0, + "discount_percentage": 0, + "pos_package": PACKAGE, + "pos_package_instance": "pkg-test-1", + "pos_package_role": COMPONENT_ROLE, + } + ), + frappe._dict( + { + "item_code": HEADPHONE, + "qty": snapshot_qty, + "rate": 0, + "price_list_rate": 0, + "discount_amount": 0, + "discount_percentage": 0, + "pos_package": PACKAGE, + "pos_package_instance": "pkg-test-1", + "pos_package_role": COMPONENT_ROLE, + } + ), + ] + + doc = frappe._dict({"is_return": 0, "pos_profile": PROFILE}) + doc["items"] = rows + return doc, rows + + def test_tampered_parent_rate_is_overwritten_with_the_server_quote(self): + """A client claiming a cheap package price must not be believed.""" + doc, rows = self.build_invoice(parent_rate=1.0) + + validate_invoice_packages(doc) + + self.assertEqual(rows[0].rate, BASE_PRICE + HEADPHONE_ADJ) + self.assertEqual(rows[0].price_list_rate, BASE_PRICE + HEADPHONE_ADJ) + + def test_component_rates_are_forced_to_zero(self): + doc, rows = self.build_invoice(parent_rate=BASE_PRICE + HEADPHONE_ADJ) + rows[1].rate = 999.0 + + validate_invoice_packages(doc) + + self.assertEqual(rows[1].rate, 0) + self.assertEqual(rows[2].rate, 0) + + def test_component_qty_not_matching_the_definition_is_rejected(self): + """Padding a package with extra free stock must fail validation.""" + doc, rows = self.build_invoice(parent_rate=BASE_PRICE + HEADPHONE_ADJ) + rows[1].qty = 5 + + with self.assertRaises(frappe.ValidationError): + validate_invoice_packages(doc) + + def test_missing_parent_row_is_rejected(self): + doc, rows = self.build_invoice(parent_rate=BASE_PRICE) + rows[0].pos_package_role = COMPONENT_ROLE + + with self.assertRaises(frappe.ValidationError): + validate_invoice_packages(doc) + + def test_invoice_without_packages_is_untouched(self): + row = frappe._dict({"item_code": LAPTOP, "qty": 1, "rate": 500.0}) + doc = frappe._dict({"is_return": 0, "pos_profile": PROFILE}) + doc["items"] = [row] + + validate_invoice_packages(doc) + + self.assertEqual(row.rate, 500.0) + + def test_return_without_return_against_is_rejected(self): + """A package credit note must name the invoice it reverses, otherwise + there is nothing to validate its rate and contents against.""" + doc, _rows = self.build_invoice(parent_rate=1.0) + doc.is_return = 1 + + with self.assertRaises(frappe.ValidationError): + validate_invoice_packages(doc) + + def test_return_of_unknown_package_instance_is_rejected(self): + """Returning a package that never existed on the original invoice must + fail rather than mint a credit note out of nothing.""" + doc, _rows = self.build_invoice(parent_rate=1.0) + doc.is_return = 1 + doc.return_against = "NON-EXISTENT-INVOICE" + + with self.assertRaises(frappe.ValidationError): + validate_invoice_packages(doc) + + +class TestPackageGrandTotal(unittest.TestCase): + """Frappe runs the controller's validate (which totals the invoice) BEFORE + app hooks, so re-pricing a row is not enough — grand_total must be + recalculated or a tampered payload is repriced yet still charged the old sum. + """ + + @classmethod + def setUpClass(cls): + _ensure_package() + frappe.db.commit() + cls.pkg = next(p for p in get_packages(PROFILE) if p["name"] == PACKAGE) + cls.company = frappe.db.get_value("POS Profile", PROFILE, "company") + + def build_real_invoice(self, parent_rate): + option_id = _option_id(self.pkg, BACKPACK) + result = quote( + PACKAGE, + [{"group_key": "accessory", "options": [{"option_id": option_id, "qty": 1}]}], + PROFILE, + ) + + inv = frappe.new_doc("Sales Invoice") + inv.customer = frappe.db.get_value("Customer", {}, "name") + inv.company = self.company + inv.pos_profile = PROFILE + inv.is_pos = 0 + inv.set_posting_time = 1 + + for idx, line in enumerate(result["lines"]): + inv.append( + "items", + { + "item_code": line["item_code"], + "qty": line["qty"], + "rate": parent_rate if idx == 0 else 0, + "uom": line.get("uom") or "Nos", + "warehouse": frappe.db.get_value("POS Profile", PROFILE, "warehouse"), + "pos_package": PACKAGE, + "pos_package_instance": "pkg-total-check", + "pos_package_role": line["role"], + "pos_package_snapshot": ( + json.dumps(result["snapshot"]) if line["role"] == PARENT_ROLE else None + ), + }, + ) + + inv.set_missing_values() + return inv, result["total"] + + def test_totals_reflect_the_server_price_not_the_payload(self): + """net_total, not grand_total: the fixture company may add tax on top.""" + inv, true_total = self.build_real_invoice(parent_rate=1.0) + + inv.run_method("validate") + + self.assertEqual(inv.items[0].rate, true_total) + self.assertEqual(inv.net_total, true_total) + + def test_tampered_and_honest_payloads_total_identically(self): + """The whole point of re-quoting: what the client sends cannot change + the amount charged.""" + tampered, true_total = self.build_real_invoice(parent_rate=1.0) + tampered.run_method("validate") + + honest, _ = self.build_real_invoice(parent_rate=true_total) + honest.run_method("validate") + + self.assertEqual(tampered.grand_total, honest.grand_total) + self.assertEqual(tampered.net_total, true_total) + + +class TestReturnExportPath(unittest.TestCase): + """ReturnInvoiceDialog.vue builds its payload from a field whitelist, so + package fields never survive the trip — membership is re-derived on the + server via the row link. These tests replay that exact shape.""" + + @classmethod + def setUpClass(cls): + _ensure_package() + frappe.db.commit() + cls.pkg = next(p for p in get_packages(PROFILE) if p["name"] == PACKAGE) + cls.company = frappe.db.get_value("POS Profile", PROFILE, "company") + cls.original, cls.rows = cls._submit_package_invoice() + + @classmethod + def _submit_package_invoice(cls): + option_id = _option_id(cls.pkg, BACKPACK) + result = quote( + PACKAGE, + [{"group_key": "accessory", "options": [{"option_id": option_id, "qty": 1}]}], + PROFILE, + ) + + inv = frappe.new_doc("Sales Invoice") + inv.customer = frappe.db.get_value("Customer", {}, "name") + inv.company = cls.company + inv.pos_profile = PROFILE + inv.is_pos = 0 + inv.set_posting_time = 1 + + for idx, line in enumerate(result["lines"]): + inv.append( + "items", + { + "item_code": line["item_code"], + "qty": line["qty"], + "rate": result["total"] if idx == 0 else 0, + "uom": line.get("uom") or "Nos", + "warehouse": frappe.db.get_value("POS Profile", PROFILE, "warehouse"), + "pos_package": PACKAGE, + "pos_package_instance": "pkg-export-path", + "pos_package_role": line["role"], + "pos_package_snapshot": ( + json.dumps(result["snapshot"]) if line["role"] == PARENT_ROLE else None + ), + }, + ) + + inv.set_missing_values() + inv.submit() + return inv.name, {row.item_code: row for row in inv.items} + + @staticmethod + def _dialog_item(row): + """The exact field set ReturnInvoiceDialog.vue sends per line.""" + return frappe._dict( + { + "item_code": row.item_code, + "item_name": row.item_name, + "qty": -abs(row.qty), + "rate": row.rate, + "warehouse": row.warehouse, + "uom": row.uom, + "conversion_factor": 1, + "sales_invoice_item": row.name, + } + ) + + def _return_doc(self, items): + doc = frappe._dict( + { + "is_return": 1, + "return_against": self.original, + "pos_profile": PROFILE, + } + ) + doc["items"] = items + return doc + + def test_dialog_shaped_package_return_is_accepted_and_mirrored(self): + items = [self._dialog_item(row) for row in self.rows.values()] + doc = self._return_doc(items) + + validate_invoice_packages(doc) + + parent = next(i for i in doc["items"] if i.item_code == PARENT_ITEM) + self.assertEqual(parent.rate, self.rows[PARENT_ITEM].rate) + self.assertEqual(parent.pos_package, PACKAGE) + self.assertEqual(parent.pos_package_instance, "pkg-export-path") + for row in doc["items"]: + if row.get("pos_package_role") == COMPONENT_ROLE: + self.assertEqual(row.rate, 0) + + def test_dialog_shaped_return_with_stripped_component_is_blocked(self): + items = [self._dialog_item(row) for code, row in self.rows.items() if code != BACKPACK] + + with self.assertRaises(frappe.ValidationError): + validate_invoice_packages(self._return_doc(items)) + + def test_forged_package_row_without_row_link_is_blocked(self): + items = [self._dialog_item(row) for row in self.rows.values()] + forged = self._dialog_item(self.rows[PARENT_ITEM]) + forged.item_code = LAPTOP + forged.pos_package = PACKAGE + forged.pos_package_instance = "pkg-forged" + forged.pos_package_role = PARENT_ROLE + forged.sales_invoice_item = None + items.append(forged) + + with self.assertRaises(frappe.ValidationError): + validate_invoice_packages(self._return_doc(items)) + + +class TestPackageAccessControl(unittest.TestCase): + @classmethod + def setUpClass(cls): + _ensure_package() + frappe.db.commit() + + def tearDown(self): + frappe.set_user("Administrator") + + def test_get_packages_rejects_users_without_profile_access(self): + """pos_profile comes from the caller, so an unassigned user must not be + able to read another outlet's packages and pricing.""" + user = frappe.db.get_value( + "User", {"enabled": 1, "user_type": "System User", "name": ("!=", "Administrator")}, "name" + ) + if not user: + self.skipTest("no non-admin user available") + + frappe.set_user(user) + + with self.assertRaises(frappe.PermissionError): + get_packages(PROFILE) + + +if __name__ == "__main__": + unittest.main() From 925c35f680327b2b1762901af0fe5782057c9ee3 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:43:09 +0700 Subject: [PATCH 14/17] feat: wire POS Package lifecycle via hooks and install Register validate_invoice_packages on Sales Invoice validate and manage the four pos_package* custom fields on Sales Invoice Item through the install.py contract (fixtures do not export Custom Field) and uninstall.py cleanup. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pos_next/hooks.py | 1 + pos_next/install.py | 64 +++++++++++++++++++++++++++++++++++++++++++ pos_next/uninstall.py | 8 ++++++ 3 files changed, 73 insertions(+) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 534680d70..987e6ddf1 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -155,6 +155,7 @@ "validate": [ "pos_next.api.sales_invoice_hooks.validate", "pos_next.api.wallet.validate_wallet_payment", + "pos_next.api.packages.validate_invoice_packages", "pos_next.overrides.pricing_rule.apply_min_max_price_discounts", ], "before_cancel": "pos_next.api.sales_invoice_hooks.before_cancel", diff --git a/pos_next/install.py b/pos_next/install.py index c749d4961..6e97478d6 100644 --- a/pos_next/install.py +++ b/pos_next/install.py @@ -18,6 +18,51 @@ # Configure logger logger = logging.getLogger(__name__) +# Custom Fields live here, not in fixtures: hooks.py:fixtures exports only Role / +# Custom DocPerm, and pos_next/pos_next/custom/*.json is never applied. +CUSTOM_FIELDS = { + "Sales Invoice Item": [ + { + "fieldname": "pos_package", + "label": "POS Package", + "fieldtype": "Link", + "options": "POS Package", + "insert_after": "item_name", + "read_only": 1, + "no_copy": 0, + "print_hide": 1, + "description": "Package this row belongs to.", + }, + { + "fieldname": "pos_package_instance", + "label": "POS Package Instance", + "fieldtype": "Data", + "insert_after": "pos_package", + "read_only": 1, + "print_hide": 1, + "description": "Groups the package line with its component rows.", + }, + { + "fieldname": "pos_package_role", + "label": "POS Package Role", + "fieldtype": "Select", + "options": "\nPackage\nPackage Item", + "insert_after": "pos_package_instance", + "read_only": 1, + "print_hide": 1, + }, + { + "fieldname": "pos_package_snapshot", + "label": "POS Package Snapshot", + "fieldtype": "Long Text", + "insert_after": "pos_package_role", + "read_only": 1, + "print_hide": 1, + "description": "Selected options at the time of sale (JSON).", + }, + ], +} + def after_install(): """Hook that runs after app installation""" @@ -27,6 +72,8 @@ def after_install(): # Setup default print format for POS Profiles setup_default_print_format() + sync_custom_fields() + # Clear cache to ensure changes take effect frappe.clear_cache() frappe.db.commit() @@ -51,6 +98,8 @@ def after_migrate(): # Setup default print format setup_default_print_format(quiet=True) + sync_custom_fields(quiet=True) + # Clear cache frappe.clear_cache() frappe.db.commit() @@ -109,6 +158,21 @@ def setup_default_print_format(quiet=False): frappe.log_error(title="Default Print Format Setup Error", message=frappe.get_traceback()) +def sync_custom_fields(quiet=False): + """Upsert CUSTOM_FIELDS. Idempotent — safe on every migrate.""" + from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + try: + create_custom_fields(CUSTOM_FIELDS, ignore_validate=True, update=True) + if not quiet: + total = sum(len(fields) for fields in CUSTOM_FIELDS.values()) + log_message(f"Synced {total} custom field(s)", level="success") + except Exception as e: + log_message(f"Error syncing custom fields: {e!s}", level="error") + frappe.log_error(title="POS Next Custom Field Sync Error", message=frappe.get_traceback()) + raise + + def log_message(message, level="info", indent=0): """ Standardized logging function with consistent formatting. diff --git a/pos_next/uninstall.py b/pos_next/uninstall.py index cc8b078a4..d2431c7d3 100644 --- a/pos_next/uninstall.py +++ b/pos_next/uninstall.py @@ -51,6 +51,10 @@ def remove_custom_fields(): custom_fields = [ "Sales Invoice-posa_pos_opening_shift", "Sales Invoice-posa_is_printed", + "Sales Invoice Item-pos_package", + "Sales Invoice Item-pos_package_instance", + "Sales Invoice Item-pos_package_role", + "Sales Invoice Item-pos_package_snapshot", ] removed_count = 0 @@ -223,6 +227,10 @@ def get_custom_fields_for_cleanup(): custom_fields = [ "Sales Invoice-posa_pos_opening_shift", "Sales Invoice-posa_is_printed", + "Sales Invoice Item-pos_package", + "Sales Invoice Item-pos_package_instance", + "Sales Invoice Item-pos_package_role", + "Sales Invoice Item-pos_package_snapshot", ] return custom_fields From 74812de89597943db840286c11022b8a01bfe5ff Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:43:18 +0700 Subject: [PATCH 15/17] feat: add offline POS Package quote and cache support Client-side quote mirrors the Python pricing deterministically and is authoritative for offline previews; definitions are cached in IndexedDB via the offline worker so included items, per-option is_stock and UOM travel offline. Tests lock the mixed-max-3 and per-option-cap rules. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- POS/src/stores/posPackages.js | 159 ++++++++++++++++++++++++ POS/src/utils/offline/db.js | 4 + POS/src/utils/offline/workerClient.js | 28 +++++ POS/src/utils/packageQuote.js | 172 ++++++++++++++++++++++++++ POS/src/utils/packageQuote.test.js | 159 ++++++++++++++++++++++++ POS/src/workers/offline.worker.js | 111 +++++++++++++++++ 6 files changed, 633 insertions(+) create mode 100644 POS/src/stores/posPackages.js create mode 100644 POS/src/utils/packageQuote.js create mode 100644 POS/src/utils/packageQuote.test.js diff --git a/POS/src/stores/posPackages.js b/POS/src/stores/posPackages.js new file mode 100644 index 000000000..028d8720d --- /dev/null +++ b/POS/src/stores/posPackages.js @@ -0,0 +1,159 @@ +/** + * @fileoverview POS Package ("Paket") store. + * + * Loads package definitions once per shift, caches them in IndexedDB, and + * resolves a tapped catalog item to the package it represents. + * + * Quoting is server-authoritative when online. Offline it falls back to + * `packageQuote.js`, which mirrors the Python implementation; either way the + * server re-quotes on Sales Invoice validate. + * + * @module stores/posPackages + */ + +import { call } from "@/utils/apiWrapper"; +import { logger } from "@/utils/logger"; +import { isOffline } from "@/utils/offline/offlineState"; +import { offlineWorker } from "@/utils/offline/workerClient"; +import { quotePackageLocally, selectionsToChoices } from "@/utils/packageQuote"; +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +const log = logger.create("POSPackages"); + +export const usePOSPackagesStore = defineStore("posPackages", () => { + const packages = ref([]); + const hasFetched = ref(false); + const isLoading = ref(false); + + let fetchPromise = null; + + /** Package definitions keyed by their parent item_code. */ + const packagesByParentItem = computed(() => { + const map = new Map(); + for (const pkg of packages.value) { + map.set(pkg.parent_item, pkg); + } + return map; + }); + + /** Item codes that open the package dialog instead of being added directly. */ + const packageItemCodes = computed(() => new Set(packagesByParentItem.value.keys())); + + function isPackageItem(itemCode) { + return packageItemCodes.value.has(itemCode); + } + + function getPackageForItem(itemCode) { + return packagesByParentItem.value.get(itemCode) || null; + } + + function setPackages(list = []) { + packages.value = Array.isArray(list) ? list : []; + } + + function clearPackages() { + packages.value = []; + hasFetched.value = false; + fetchPromise = null; + } + + /** + * Load packages for a profile, from cache when offline. + * Concurrent calls share one in-flight request. + * + * @param {string} posProfile - POS Profile name + * @param {boolean} force - Refetch even if already loaded + * @returns {Promise} True when packages are available + */ + async function ensurePackagesFetched(posProfile, force = false) { + if (hasFetched.value && !force) return packages.value.length > 0; + if (fetchPromise) return fetchPromise; + if (!posProfile) return false; + + isLoading.value = true; + fetchPromise = (async () => { + try { + if (isOffline()) { + const cached = await offlineWorker.getCachedPackages(posProfile); + setPackages(cached || []); + hasFetched.value = true; + return packages.value.length > 0; + } + + const response = await call("pos_next.api.packages.get_packages", { + pos_profile: posProfile, + }); + const list = response?.message || response || []; + setPackages(list); + hasFetched.value = true; + + offlineWorker.cachePackages(list, posProfile).catch((error) => { + log.warn("Failed to cache packages for offline use", error); + }); + + return list.length > 0; + } catch (error) { + log.error("Failed to load packages", error); + hasFetched.value = true; + return false; + } finally { + isLoading.value = false; + fetchPromise = null; + } + })(); + + return fetchPromise; + } + + /** + * Price a selection. Uses the server when online so the preview matches what + * the invoice will charge; falls back to the local mirror when offline. + * + * @param {Object} pkg - Package definition + * @param {Object>} selections - group_key -> option_id -> qty + * @param {string} posProfile - POS Profile name + * @returns {Promise<{valid: boolean, error: string|null, total: number, lines: Array, snapshot: Object}>} + */ + async function quote(pkg, selections, posProfile) { + const local = quotePackageLocally(pkg, selections); + + // Local validation failed — no point asking the server the same question. + if (!local.valid || isOffline()) return local; + + try { + const response = await call("pos_next.api.packages.quote_package", { + package: pkg.name, + choices: JSON.stringify(selectionsToChoices(selections)), + pos_profile: posProfile, + }); + const result = response?.message || response; + if (!result) return local; + + return { + valid: true, + error: null, + total: result.total, + lines: result.lines, + snapshot: result.snapshot, + }; + } catch (error) { + log.warn("Server quote failed, using local quote", error); + return local; + } + } + + return { + packages, + hasFetched, + isLoading, + packagesByParentItem, + packageItemCodes, + isPackageItem, + getPackageForItem, + setPackages, + clearPackages, + ensurePackagesFetched, + quote, + }; +}); diff --git a/POS/src/utils/offline/db.js b/POS/src/utils/offline/db.js index 8fc6a6712..de7584513 100644 --- a/POS/src/utils/offline/db.js +++ b/POS/src/utils/offline/db.js @@ -75,6 +75,10 @@ const CURRENT_SCHEMA = { // Indexed by name (unique), filterable by pos_profile offers: "&name, pos_profile, apply_on, valid_upto", + // POS Package definitions cache for offline package selection and pricing. + // parent_item is indexed so the item grid can resolve a tapped item to its package. + packages: "&name, pos_profile, parent_item, valid_upto", + // Invoice history cache for offline viewing // Stores submitted invoices for offline access invoice_history: "&name, pos_profile, posting_date, customer", diff --git a/POS/src/utils/offline/workerClient.js b/POS/src/utils/offline/workerClient.js index 8fa4b3728..434aa7993 100644 --- a/POS/src/utils/offline/workerClient.js +++ b/POS/src/utils/offline/workerClient.js @@ -604,6 +604,34 @@ class OfflineWorkerClient { return this.sendMessage("CLEAR_OFFERS_CACHE", { posProfile }); } + /** + * Cache POS Package definitions for offline selection and pricing + * @param {Array} packages - Packages from pos_next.api.packages.get_packages + * @param {string} posProfile - POS Profile name to associate with packages + * @returns {Promise<{success: boolean, count: number}>} + */ + async cachePackages(packages, posProfile) { + return this.sendMessage("CACHE_PACKAGES", { packages, posProfile }); + } + + /** + * Get cached POS Packages for a profile + * @param {string} posProfile - POS Profile name + * @returns {Promise} Cached packages (excluding expired) + */ + async getCachedPackages(posProfile) { + return this.sendMessage("GET_CACHED_PACKAGES", { posProfile }); + } + + /** + * Clear cached POS Packages + * @param {string} posProfile - POS Profile name (optional, clears all if not provided) + * @returns {Promise<{success: boolean}>} + */ + async clearPackagesCache(posProfile = null) { + return this.sendMessage("CLEAR_PACKAGES_CACHE", { posProfile }); + } + terminate() { // Stop health check if (this.healthCheckInterval) { diff --git a/POS/src/utils/packageQuote.js b/POS/src/utils/packageQuote.js new file mode 100644 index 000000000..8243bfb8e --- /dev/null +++ b/POS/src/utils/packageQuote.js @@ -0,0 +1,172 @@ +/** + * @fileoverview Client-side POS Package quoting. + * + * Mirrors `pos_next/api/packages.py:quote()` so packages can be selected and + * priced while offline. The server re-quotes every package on Sales Invoice + * validate, so this result is a preview — never the authority. + * + * Keep this file and `packages.py` in lockstep: same validation order, same + * price formula `base_price + Σ(price_adjustment × qty)`. + * + * @module packageQuote + */ + +import { roundCurrency } from "@/utils/currency"; + +export const PACKAGE_ROLE = "Package"; +export const PACKAGE_ITEM_ROLE = "Package Item"; + +/** + * Total units picked in a group. + * @param {Object} picks - option_id -> qty + * @returns {number} + */ +export function pickedQty(picks) { + return Object.values(picks || {}).reduce((sum, qty) => sum + (Number(qty) || 0), 0); +} + +/** + * Validate a group's picks against its min/max and per-option caps. + * @param {Object} group - POS Package Group + * @param {Array} options - Options belonging to the group + * @param {Object} picks - option_id -> qty + * @returns {string|null} Error message, or null when valid + */ +export function validateGroup(group, options, picks) { + const total = pickedQty(picks); + const minQty = Number(group.min_qty) || 0; + const maxQty = Number(group.max_qty) || 0; + + if (total < minQty) { + return __("Choose at least {0} item(s) from {1}.", [minQty, group.label]); + } + if (total > maxQty) { + return __("Choose at most {0} item(s) from {1}.", [maxQty, group.label]); + } + + for (const [optionId, qty] of Object.entries(picks || {})) { + if (!qty) continue; + const option = options.find((o) => o.option_id === optionId); + if (!option) { + return __("Option {0} does not belong to {1}.", [optionId, group.label]); + } + const optionMax = Number(option.max_qty) || 0; + if (optionMax && qty > optionMax) { + return __("You can pick at most {0} x {1}.", [ + optionMax, + option.item_name || option.item_code, + ]); + } + } + + return null; +} + +/** + * Price a package selection locally. + * + * @param {Object} pkg - Package definition from `pos_next.api.packages.get_packages` + * @param {Object>} selections - group_key -> option_id -> qty + * @returns {{valid: boolean, error: string|null, total: number, lines: Array, snapshot: Object}} + */ +export function quotePackageLocally(pkg, selections = {}) { + const invalid = (error) => ({ valid: false, error, total: 0, lines: [], snapshot: null }); + + if (!pkg) return invalid(__("Package not found.")); + + let total = Number(pkg.base_price) || 0; + const componentLines = []; + const snapshotSelections = []; + + for (const group of pkg.groups || []) { + const picks = selections[group.group_key] || {}; + const options = (pkg.options || []).filter((o) => o.group_key === group.group_key); + + const error = validateGroup(group, options, picks); + if (error) return invalid(error); + + for (const [optionId, rawQty] of Object.entries(picks)) { + const qty = Number(rawQty) || 0; + if (!qty) continue; + + const option = options.find((o) => o.option_id === optionId); + total += (Number(option.price_adjustment) || 0) * qty; + + componentLines.push({ + item_code: option.item_code, + item_name: option.item_name, + qty: (Number(option.qty_per_unit) || 1) * qty, + uom: option.uom, + rate: 0, + role: PACKAGE_ITEM_ROLE, + is_stock_item: option.is_stock_item, + }); + snapshotSelections.push({ + group_key: group.group_key, + group_label: group.label, + option_id: optionId, + item_code: option.item_code, + item_name: option.item_name, + qty, + price_adjustment: Number(option.price_adjustment) || 0, + }); + } + } + + for (const row of pkg.items || []) { + componentLines.push({ + item_code: row.item_code, + item_name: row.item_name, + qty: Number(row.qty) || 0, + uom: row.uom, + rate: 0, + role: PACKAGE_ITEM_ROLE, + is_stock_item: row.is_stock_item, + }); + } + + if (total < 0) return invalid(__("Package price cannot be negative.")); + + total = roundCurrency(total); + + const parentLine = { + item_code: pkg.parent_item, + item_name: pkg.package_name, + qty: 1, + rate: total, + role: PACKAGE_ROLE, + }; + + return { + valid: true, + error: null, + total, + lines: [parentLine, ...componentLines], + snapshot: { + package: pkg.name, + package_name: pkg.package_name, + base_price: Number(pkg.base_price) || 0, + total, + selections: snapshotSelections, + included_items: (pkg.items || []).map((row) => ({ + item_code: row.item_code, + item_name: row.item_name, + qty: Number(row.qty) || 0, + })), + }, + }; +} + +/** + * Convert the dialog's selection map into the API's `choices` payload. + * @param {Object>} selections + * @returns {Array<{group_key: string, options: Array<{option_id: string, qty: number}>}>} + */ +export function selectionsToChoices(selections = {}) { + return Object.entries(selections).map(([groupKey, picks]) => ({ + group_key: groupKey, + options: Object.entries(picks || {}) + .filter(([, qty]) => Number(qty) > 0) + .map(([optionId, qty]) => ({ option_id: optionId, qty: Number(qty) })), + })); +} diff --git a/POS/src/utils/packageQuote.test.js b/POS/src/utils/packageQuote.test.js new file mode 100644 index 000000000..1f7d27264 --- /dev/null +++ b/POS/src/utils/packageQuote.test.js @@ -0,0 +1,159 @@ +import { beforeAll, describe, expect, it } from "vitest" + +import { + pickedQty, + quotePackageLocally, + selectionsToChoices, + validateGroup, +} from "./packageQuote" + +const pkg = { + name: "Paket Laptop Akhir Tahun", + package_name: "Paket Laptop Akhir Tahun", + parent_item: "PKG-LAPTOP-AKHIR-TAHUN", + base_price: 12_000_000, + items: [ + { + item_code: "LAPTOP", + item_name: "Laptop", + qty: 1, + uom: "Nos", + is_stock_item: 1, + }, + ], + groups: [ + { group_key: "aksesori", label: "Aksesori", min_qty: 1, max_qty: 1 }, + { group_key: "voucher", label: "Voucher", min_qty: 0, max_qty: 3 }, + ], + options: [ + { + option_id: "backpack", + group_key: "aksesori", + item_code: "BACKPACK", + item_name: "Backpack", + qty_per_unit: 1, + uom: "Nos", + price_adjustment: 0, + max_qty: 1, + is_stock_item: 1, + }, + { + option_id: "headphone", + group_key: "aksesori", + item_code: "HEADPHONE", + item_name: "Headphone", + qty_per_unit: 1, + uom: "Nos", + price_adjustment: 350_000, + max_qty: 1, + is_stock_item: 1, + }, + { + option_id: "pulsa", + group_key: "voucher", + item_code: "PULSA", + item_name: "Voucher Pulsa", + qty_per_unit: 1, + uom: "Nos", + price_adjustment: 45_000, + max_qty: 3, + is_stock_item: 0, + }, + { + option_id: "listrik", + group_key: "voucher", + item_code: "LISTRIK", + item_name: "Voucher Listrik", + qty_per_unit: 1, + uom: "Nos", + price_adjustment: 48_000, + max_qty: 3, + is_stock_item: 0, + }, + ], +} + +beforeAll(() => { + globalThis.__ = (message, replacements = []) => + message.replace( + /\{(\d+)\}/g, + (_match, index) => replacements[Number(index)] ?? "", + ) +}) + +describe("quotePackageLocally", () => { + it("prices one required accessory and a mixed three-unit voucher selection", () => { + const selections = { + aksesori: { headphone: 1 }, + voucher: { pulsa: 2, listrik: 1 }, + } + + const quote = quotePackageLocally(pkg, selections) + + expect(quote.valid).toBe(true) + expect(quote.total).toBe(12_488_000) + expect( + quote.lines.map(({ item_code, qty, rate }) => ({ item_code, qty, rate })), + ).toEqual([ + { item_code: "PKG-LAPTOP-AKHIR-TAHUN", qty: 1, rate: 12_488_000 }, + { item_code: "HEADPHONE", qty: 1, rate: 0 }, + { item_code: "PULSA", qty: 2, rate: 0 }, + { item_code: "LISTRIK", qty: 1, rate: 0 }, + { item_code: "LAPTOP", qty: 1, rate: 0 }, + ]) + }) + + it("rejects a missing required selection", () => { + const quote = quotePackageLocally(pkg, { voucher: {} }) + + expect(quote.valid).toBe(false) + expect(quote.error).toBe("Choose at least 1 item(s) from Aksesori.") + }) + + it("rejects a group selection above its maximum", () => { + const quote = quotePackageLocally(pkg, { + aksesori: { backpack: 1 }, + voucher: { pulsa: 2, listrik: 2 }, + }) + + expect(quote.valid).toBe(false) + expect(quote.error).toBe("Choose at most 3 item(s) from Voucher.") + }) + + it("rejects an individual option above its maximum", () => { + const group = pkg.groups[1] + const options = pkg.options + .filter(({ group_key }) => group_key === group.group_key) + .map((option) => + option.option_id === "pulsa" ? { ...option, max_qty: 2 } : option, + ) + + const error = validateGroup(group, options, { pulsa: 3 }) + + expect(error).toBe("You can pick at most 2 x Voucher Pulsa.") + }) +}) + +describe("selection helpers", () => { + it("counts selected units across options", () => { + expect(pickedQty({ pulsa: 2, listrik: 1 })).toBe(3) + }) + + it("serializes only positive selections for the API", () => { + const choices = selectionsToChoices({ + aksesori: { backpack: 1, headphone: 0 }, + voucher: { pulsa: 2, listrik: 1 }, + }) + + expect(choices).toEqual([ + { group_key: "aksesori", options: [{ option_id: "backpack", qty: 1 }] }, + { + group_key: "voucher", + options: [ + { option_id: "pulsa", qty: 2 }, + { option_id: "listrik", qty: 1 }, + ], + }, + ]) + }) +}) diff --git a/POS/src/workers/offline.worker.js b/POS/src/workers/offline.worker.js index f1964d7cb..619dd5bf6 100644 --- a/POS/src/workers/offline.worker.js +++ b/POS/src/workers/offline.worker.js @@ -1317,6 +1317,104 @@ async function clearOffersCache(posProfile = null) { } } +/** + * Cache POS Package definitions for offline selection and pricing. + * + * @param {Array} packages - Packages from pos_next.api.packages.get_packages + * @param {string} posProfile - POS Profile name to associate with packages + * @returns {Promise<{success: boolean, count: number}>} + */ +async function cachePackages(packages, posProfile) { + try { + if (!Array.isArray(packages) || !posProfile) { + return { success: false, count: 0 }; + } + + const db = await initDB(); + + const packagesWithProfile = packages.map((pkg) => ({ + ...pkg, + pos_profile: posProfile, + _cached_at: Date.now(), + })); + + await db.transaction("rw", db.table("packages"), async () => { + await db.table("packages").where("pos_profile").equals(posProfile).delete(); + if (packagesWithProfile.length > 0) { + await db.table("packages").bulkPut(packagesWithProfile); + } + }); + + await db.table("settings").put({ + key: `packages_last_sync_${posProfile}`, + value: Date.now(), + }); + + log.success(`Cached ${packages.length} packages for profile ${posProfile}`); + return { success: true, count: packages.length }; + } catch (error) { + log.error("Error caching packages", error); + return { success: false, count: 0, error: error.message }; + } +} + +/** + * Get cached POS Packages for a profile, dropping expired ones. + * + * @param {string} posProfile - POS Profile name + * @returns {Promise} Cached package definitions still in their validity window + */ +async function getCachedPackages(posProfile) { + try { + if (!posProfile) { + return []; + } + + const db = await initDB(); + const today = new Date().toISOString().split("T")[0]; + + const allPackages = await db + .table("packages") + .where("pos_profile") + .equals(posProfile) + .toArray(); + + const validPackages = allPackages.filter((pkg) => { + if (pkg.valid_from && pkg.valid_from > today) return false; + if (pkg.valid_upto && pkg.valid_upto < today) return false; + return true; + }); + + log.info(`Retrieved ${validPackages.length} cached packages for profile ${posProfile}`); + return validPackages; + } catch (error) { + log.error("Error getting cached packages", error); + return []; + } +} + +/** + * Clear cached POS Packages. + * @param {string|null} posProfile - Profile to clear, or null for all + * @returns {Promise<{success: boolean}>} + */ +async function clearPackagesCache(posProfile = null) { + try { + const db = await initDB(); + + if (posProfile) { + await db.table("packages").where("pos_profile").equals(posProfile).delete(); + } else { + await db.table("packages").clear(); + } + + return { success: true }; + } catch (error) { + log.error("Error clearing packages cache", error); + return { success: false, error: error.message }; + } +} + // Check if cache is ready async function isCacheReady() { try { @@ -1875,6 +1973,19 @@ self.onmessage = async (event) => { result = await clearOffersCache(payload.posProfile); break; + // ===== PACKAGE CACHE OPERATIONS ===== + case "CACHE_PACKAGES": + result = await cachePackages(payload.packages, payload.posProfile); + break; + + case "GET_CACHED_PACKAGES": + result = await getCachedPackages(payload.posProfile); + break; + + case "CLEAR_PACKAGES_CACHE": + result = await clearPackagesCache(payload.posProfile); + break; + default: throw new Error(`Unknown message type: ${type}`); } From b76d164f5babab460d656b81956fe5502666a986 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:45:06 +0700 Subject: [PATCH 16/17] feat: add POS Package selection flow to the POS UI Package items carry a badge in the catalog and open a styled selection dialog: single-pick groups as toggles, multi-pick groups as steppers honoring min/max with live running total. Each instance owns its cart lines via package_instance (never merged with loose items), renders nested under the priced parent in the cart, and emits the pos_package fields for server re-quoting. Components use their real Item stock flag; batch/serial auto-pick at submission. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- POS/components.d.ts | 1 + POS/src/components/sale/InvoiceCart.vue | 109 ++++- POS/src/components/sale/ItemsSelector.vue | 21 +- .../sale/PackageSelectionDialog.vue | 371 ++++++++++++++++++ POS/src/composables/useInvoice.js | 127 ++++-- POS/src/pages/POSSale.vue | 36 ++ POS/src/stores/posCart.js | 43 ++ POS/src/stores/posUI.js | 3 + 8 files changed, 676 insertions(+), 35 deletions(-) create mode 100644 POS/src/components/sale/PackageSelectionDialog.vue diff --git a/POS/components.d.ts b/POS/components.d.ts index 34185d022..5bae9f497 100644 --- a/POS/components.d.ts +++ b/POS/components.d.ts @@ -35,6 +35,7 @@ declare module 'vue' { NumberField: typeof import('./src/components/settings/NumberField.vue')['default'] OffersDialog: typeof import('./src/components/sale/OffersDialog.vue')['default'] OfflineInvoicesDialog: typeof import('./src/components/sale/OfflineInvoicesDialog.vue')['default'] + PackageSelectionDialog: typeof import('./src/components/sale/PackageSelectionDialog.vue')['default'] PartialPayments: typeof import('./src/components/partials/PartialPayments.vue')['default'] PaymentDialog: typeof import('./src/components/sale/PaymentDialog.vue')['default'] PhoneInput: typeof import('./src/components/common/PhoneInput.vue')['default'] diff --git a/POS/src/components/sale/InvoiceCart.vue b/POS/src/components/sale/InvoiceCart.vue index 8e6efd748..ac7b6b460 100644 --- a/POS/src/components/sale/InvoiceCart.vue +++ b/POS/src/components/sale/InvoiceCart.vue @@ -940,19 +940,24 @@
@@ -991,6 +996,12 @@
+ + {{ __("PACKAGE") }} +

@@ -1047,7 +1058,29 @@

+
+ + +
    +
  • + + + + {{ + component.item_name || component.item_code + }} + x{{ formatQuantity(component.quantity) }} +
  • +
@@ -1487,6 +1559,7 @@ import { usePOSSettingsStore } from "@/stores/posSettings"; import { usePOSOffersStore } from "@/stores/posOffers"; import { useCustomerSearchStore } from "@/stores/customerSearch"; import { DEFAULT_CURRENCY, formatCurrency as formatCurrencyUtil } from "@/utils/currency"; +import { PACKAGE_ITEM_ROLE } from "@/utils/packageQuote"; import { useFormatters } from "@/composables/useFormatters"; import { useCartSort } from "@/composables/useCartSort"; import { isOffline } from "@/utils/offline"; @@ -1575,6 +1648,7 @@ const props = defineProps({ const emit = defineEmits([ "update-quantity", // (itemCode, newQty, uom?) - Update item quantity "remove-item", // (itemCode, uom?) - Remove item from cart + "remove-package", // (packageInstance) - Remove a package and its component rows "select-customer", // (customer) - Select/change customer "edit-customer", // (customer) - Open edit customer dialog "create-customer", // (searchText) - Open create customer dialog @@ -1610,6 +1684,21 @@ const { getCartSortIconState, } = useCartSort(() => props.items); +/** + * Cart rows to render at the top level. + * Package component rows are excluded — they render nested under their parent. + */ +const topLevelItems = computed(() => + sortedItems.value.filter((item) => item.package_role !== PACKAGE_ITEM_ROLE) +); + +/** Component rows belonging to a package instance, in cart order. */ +function packageComponents(instance) { + return props.items.filter( + (item) => item.package_instance === instance && item.package_role === PACKAGE_ITEM_ROLE + ); +} + /** * ============================================================================ * REACTIVE STATE diff --git a/POS/src/components/sale/ItemsSelector.vue b/POS/src/components/sale/ItemsSelector.vue index 79597bb9a..17f2c1e40 100644 --- a/POS/src/components/sale/ItemsSelector.vue +++ b/POS/src/components/sale/ItemsSelector.vue @@ -539,6 +539,12 @@
+ + {{ __("Package") }} +

@@ -803,6 +809,12 @@ class="text-xs sm:text-sm font-medium text-gray-900 truncate" :title="item.item_name" > + + {{ __("Package") }} + {{ item.item_name }}

{ // Create optimized click handlers for better touch response const optimizedClickHandlers = new Map(); +function isPackageItem(itemCode) { + return packagesStore.isPackageItem(itemCode); +} + function getOptimizedClickHandler(item) { const key = item.item_code; if (!optimizedClickHandlers.has(key)) { @@ -1414,8 +1432,9 @@ function clearLongPress() { function selectItem(item, autoAdd = false) { if (!item) return false; - // Early out-of-stock guard — full qty validation happens in cartStore.addItem() + // Packages are non-stock parents; their component stock is checked on add. if ( + !isPackageItem(item.item_code) && !item.has_variants && settingsStore.shouldEnforceStockValidation() && shouldValidateItemStock(item) diff --git a/POS/src/components/sale/PackageSelectionDialog.vue b/POS/src/components/sale/PackageSelectionDialog.vue new file mode 100644 index 000000000..85fb47a59 --- /dev/null +++ b/POS/src/components/sale/PackageSelectionDialog.vue @@ -0,0 +1,371 @@ + + + diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 34b22585c..7c54c8b2e 100644 --- a/POS/src/composables/useInvoice.js +++ b/POS/src/composables/useInvoice.js @@ -5,6 +5,7 @@ import { useSerialNumberStore } from "@/stores/serialNumber"; import { CoalescingMutex } from "@/utils/mutex"; import { logger } from "@/utils/logger"; import { roundCurrency } from "@/utils/currency"; +import { PACKAGE_ROLE } from "@/utils/packageQuote"; const log = logger.create("Invoice"); @@ -214,12 +215,32 @@ export function useInvoice() { ); }); + /** + * Find a cart row that a standalone item may merge into or be edited through. + * + * Package rows are excluded: each package instance owns its lines, so a loose + * item of the same code must never merge into (or be edited via) a package. + * + * @param {string} itemCode + * @param {string|null} uom - Match UOM too when provided + * @returns {Object|undefined} + */ + function findStandaloneItem(itemCode, uom = null) { + return invoiceItems.value.find( + (i) => + i.item_code === itemCode && + !i.package_instance && + (uom === null || i.uom === uom) + ); + } + // Actions function addItem(item, quantity = 1) { const itemUom = item.uom || item.stock_uom; - const existingItem = invoiceItems.value.find( - (i) => i.item_code === item.item_code && i.uom === itemUom - ); + // Never merge: each package instance owns its own lines. + const existingItem = item.package_instance + ? null + : findStandaloneItem(item.item_code, itemUom); if (existingItem) { // Store old values before update for incremental cache adjustment @@ -288,6 +309,12 @@ export function useInvoice() { is_stock_item: item.is_stock_item ?? 1, is_bundle: item.is_bundle || false, allow_negative_stock: item.allow_negative_stock || 0, + // POS Package linkage — groups a package line with its component rows + package_instance: item.package_instance || null, + package_name: item.package_name || null, + package_role: item.package_role || null, + package_label: item.package_label || null, + package_snapshot: item.package_snapshot || null, }; invoiceItems.value.push(newItem); // Recalculate the newly added item to apply taxes @@ -312,14 +339,7 @@ export function useInvoice() { * If null, removes the first item matching item_code. */ function removeItem(itemCode, uom = null) { - let itemToRemove; - if (uom) { - itemToRemove = invoiceItems.value.find( - (i) => i.item_code === itemCode && i.uom === uom - ); - } else { - itemToRemove = invoiceItems.value.find((i) => i.item_code === itemCode); - } + const itemToRemove = findStandaloneItem(itemCode, uom); if (itemToRemove) { // Update cache incrementally (subtract removed item values) @@ -338,15 +358,69 @@ export function useInvoice() { if (itemToRemove.serial_no && itemToRemove.has_serial_no) { serialStore.returnSerials(itemCode, itemToRemove.serial_no); } - } - if (uom) { + // Match the historical contract: without a UOM this drops every + // standalone row for the code (paid + its free BOGO row). invoiceItems.value = invoiceItems.value.filter( - (i) => !(i.item_code === itemCode && i.uom === uom) + (i) => + i.package_instance || + i.item_code !== itemCode || + (uom !== null && i.uom !== uom) + ); + } + } + + /** + * Add a priced package to the cart as one parent line plus its component lines. + * + * Every line carries the same `package_instance`, which keeps the group + * together in the cart and lets the server re-price it on validate. + * + * @param {Object} quote - Result from `posPackages.quote()` + * @param {Object} pkg - Package definition + * @param {Object} context - `{ warehouse }` applied to component rows + * @returns {string} The generated package instance id + */ + function addPackage(quote, pkg, { warehouse = null } = {}) { + const instance = `pkg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + + for (const line of quote.lines) { + const isParent = line.role === PACKAGE_ROLE; + addItem( + { + item_code: line.item_code, + item_name: line.item_name, + rate: line.rate, + price_list_rate: line.rate, + uom: line.uom, + stock_uom: line.uom, + warehouse: isParent ? null : warehouse, + // The parent is always non-stock; components carry the real + // Item flag so non-stock components (e.g. vouchers) are not + // stock-validated and do not expect a warehouse. + is_stock_item: isParent ? 0 : (line.is_stock_item ?? 1), + package_instance: instance, + package_name: pkg.name, + package_role: line.role, + package_label: pkg.package_name, + package_snapshot: isParent ? quote.snapshot : null, + }, + line.qty ); - } else { - invoiceItems.value = invoiceItems.value.filter((i) => i.item_code !== itemCode); } + + return instance; + } + + /** + * Remove a whole package (parent line and every component) from the cart. + * @param {string} instance - Package instance id + */ + function removePackage(instance) { + if (!instance) return; + + invoiceItems.value = invoiceItems.value.filter((i) => i.package_instance !== instance); + rebuildIncrementalCache(); } /** @@ -358,12 +432,7 @@ export function useInvoice() { * If null, updates the first item matching item_code. */ function updateItemQuantity(itemCode, quantity, uom = null) { - let item; - if (uom) { - item = invoiceItems.value.find((i) => i.item_code === itemCode && i.uom === uom); - } else { - item = invoiceItems.value.find((i) => i.item_code === itemCode); - } + const item = findStandaloneItem(itemCode, uom); if (item) { // Store old values before update for incremental cache adjustment @@ -408,7 +477,7 @@ export function useInvoice() { } function updateItemRate(itemCode, rate, isManualEdit = false) { - const item = invoiceItems.value.find((i) => i.item_code === itemCode); + const item = findStandaloneItem(itemCode); if (item) { // Store old values before update for incremental cache adjustment // Use effective rate (manually edited rate or price_list_rate) @@ -450,7 +519,7 @@ export function useInvoice() { } function updateItemDiscount(itemCode, discountPercentage) { - const item = invoiceItems.value.find((i) => i.item_code === itemCode); + const item = findStandaloneItem(itemCode); if (item) { // Validate discount percentage (0-100) let validDiscount = Number.parseFloat(discountPercentage) || 0; @@ -752,6 +821,14 @@ export function useInvoice() { is_rate_manually_edited: item.is_rate_manually_edited || 0, original_rate: item.original_rate || null, is_free_item: item.is_free_item || 0, + // POS Package linkage — the server re-quotes from the snapshot and + // overrides these rates, so the payload can never set its own price. + pos_package: item.package_name || null, + pos_package_instance: item.package_instance || null, + pos_package_role: item.package_role || null, + pos_package_snapshot: item.package_snapshot + ? JSON.stringify(item.package_snapshot) + : null, }); const out = []; @@ -1281,6 +1358,8 @@ export function useInvoice() { // Actions addItem, + addPackage, + removePackage, removeItem, updateItemQuantity, updateItemRate, diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue index 28d292815..a51d2022e 100644 --- a/POS/src/pages/POSSale.vue +++ b/POS/src/pages/POSSale.vue @@ -391,6 +391,7 @@ @remove-item=" (itemCode, uom) => cartStore.removeItem(itemCode, uom) " + @remove-package="cartStore.removePackage" @select-customer="handleCustomerSelected" @create-customer="handleCreateCustomer" @edit-customer="handleEditCustomer" @@ -612,6 +613,15 @@ @batch-serial-selected="handleBatchSerialSelected" /> + + + { ? offlineStore.checkOfflineCacheAvailability() : offlineStore.preloadDataForOffline(shiftStore.currentProfile), draftsStore.updateDraftsCount(), + packagesStore.ensurePackagesFetched(shiftStore.profileName), ]); // Wait for settings (required for tax rules) + all background ops @@ -1826,6 +1841,7 @@ async function handleShiftOpened() { ? offlineStore.checkOfflineCacheAvailability() : offlineStore.preloadDataForOffline(shiftStore.currentProfile), draftsStore.updateDraftsCount(), + packagesStore.ensurePackagesFetched(shiftStore.profileName), ]); // Wait for settings (required for tax rules) + all background ops @@ -1870,6 +1886,15 @@ async function handleShiftClosed() { } function handleItemSelected(item, autoAdd = false) { + // Packages always open their picker — even under auto-add / barcode scan, + // since the customer still has to choose the optional items. + const pkg = packagesStore.getPackageForItem(item.item_code); + if (pkg) { + selectedPackage.value = pkg; + uiStore.showPackageDialog = true; + return; + } + // Auto-add mode if (autoAdd) { try { @@ -1959,6 +1984,17 @@ function handleItemSelected(item, autoAdd = false) { } } +function handlePackageSelected({ quote, pkg }) { + try { + cartStore.addPackage(quote, pkg, shiftStore.currentProfile); + showSuccess(__("{0} added to cart", [pkg.package_name])); + } catch (error) { + uiStore.showError(__("Insufficient Stock"), error.message, __("Package: {0}", [pkg.package_name])); + } finally { + selectedPackage.value = null; + } +} + async function handleEditItem(updatedItem) { await cartStore.updateItemDetails(updatedItem.item_code, updatedItem); } diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index 8d451c946..46a5261f7 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -1,8 +1,10 @@ import { useInvoice } from "@/composables/useInvoice"; +import { useItemSearchStore } from "@/stores/itemSearch"; import { usePOSOffersStore } from "@/stores/posOffers"; import { usePOSSettingsStore } from "@/stores/posSettings"; import { usePOSShiftStore } from "@/stores/posShift"; import { parseError } from "@/utils/errorHandler"; +import { PACKAGE_ROLE } from "@/utils/packageQuote"; import { shouldValidateItemStock, checkStockAvailability } from "@/utils/stockValidator"; import { offlineState } from "@/utils/offline/offlineState"; import { useToast } from "@/composables/useToast"; @@ -92,6 +94,8 @@ export const usePOSCartStore = defineStore("posCart", () => { taxInclusive, isSubmitting, addItem: addItemToInvoice, + addPackage: addPackageToInvoice, + removePackage, removeItem, updateItemQuantity: baseUpdateItemQuantity, submitInvoice: baseSubmitInvoice, @@ -198,6 +202,43 @@ export const usePOSCartStore = defineStore("posCart", () => { addItemToInvoice(item, qty); } + /** + * Add a priced package to the cart, validating stock on its component items. + * + * @param {Object} quote - Result from `posPackages.quote()` + * @param {Object} pkg - Package definition + * @param {Object|null} currentProfile - POS Profile, used for stock validation + * @returns {string} Package instance id + * @throws {Error} When a component item lacks stock + */ + function addPackage(quote, pkg, currentProfile = null) { + const warehouse = currentProfile?.warehouse || null; + + if (currentProfile && settingsStore.shouldEnforceStockValidation()) { + const itemsStore = useItemSearchStore(); + + // Components of the same code can appear twice (an included item and a + // chosen option), so validate against the package's combined demand. + const demand = new Map(); + for (const line of quote.lines) { + if (line.role === PACKAGE_ROLE) continue; + demand.set(line.item_code, (demand.get(line.item_code) || 0) + line.qty); + } + + for (const [itemCode, qty] of demand) { + const catalogItem = itemsStore.allItems.find((i) => i.item_code === itemCode); + if (!catalogItem || !shouldValidateItemStock(catalogItem)) continue; + + const check = checkStockAvailability(catalogItem, qty, warehouse); + if (!check.available) { + throw new Error(check.error); + } + } + } + + return addPackageToInvoice(quote, pkg, { warehouse }); + } + /** * Update item quantity with stock validation. * Wraps useInvoice.updateItemQuantity to enforce stock limits @@ -1880,6 +1921,8 @@ export const usePOSCartStore = defineStore("posCart", () => { // Actions addItem, + addPackage, + removePackage, removeItem, updateItemQuantity, clearCart, diff --git a/POS/src/stores/posUI.js b/POS/src/stores/posUI.js index d64f361d6..144939756 100644 --- a/POS/src/stores/posUI.js +++ b/POS/src/stores/posUI.js @@ -26,6 +26,7 @@ export const usePOSUIStore = defineStore("posUI", () => { const { isOpen: showClearCartDialog } = useDialog("clearCart"); const { isOpen: showLogoutDialog } = useDialog("logout"); const { isOpen: showItemSelectionDialog } = useDialog("itemSelection"); + const { isOpen: showPackageDialog } = useDialog("package"); const { isOpen: showErrorDialog } = useDialog("invoiceError"); // Global dialog state @@ -158,6 +159,7 @@ export const usePOSUIStore = defineStore("posUI", () => { showClearCartDialog.value = false; showLogoutDialog.value = false; showItemSelectionDialog.value = false; + showPackageDialog.value = false; showErrorDialog.value = false; clearError(); lastOfflinePrintDoc.value = null; @@ -182,6 +184,7 @@ export const usePOSUIStore = defineStore("posUI", () => { showClearCartDialog, showLogoutDialog, showItemSelectionDialog, + showPackageDialog, showErrorDialog, isAnyDialogOpen, errorDialogTitle, From a570bdd77b1909c6d378afb05aad44c2329fc308 Mon Sep 17 00:00:00 2001 From: Muhammad Yusuf Tri Daryanto <114971841+Muhayustrid@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:12:34 +0700 Subject: [PATCH 17/17] fix: scope POS Package cache by profile and never downgrade a rejected server quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-session POS profile switches kept showing the previous outlet's packages (global hasFetched), and a server rejection (wrong outlet, expired, edited definition) fell back to a locally-valid quote — which the invoice later refuses at sync. The store is now keyed by profile and surfaces the rejection as an error the dialog can show. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- POS/src/stores/posPackages.js | 37 +++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/POS/src/stores/posPackages.js b/POS/src/stores/posPackages.js index 028d8720d..d92e21968 100644 --- a/POS/src/stores/posPackages.js +++ b/POS/src/stores/posPackages.js @@ -23,7 +23,7 @@ const log = logger.create("POSPackages"); export const usePOSPackagesStore = defineStore("posPackages", () => { const packages = ref([]); - const hasFetched = ref(false); + const fetchedProfile = ref(null); const isLoading = ref(false); let fetchPromise = null; @@ -54,7 +54,7 @@ export const usePOSPackagesStore = defineStore("posPackages", () => { function clearPackages() { packages.value = []; - hasFetched.value = false; + fetchedProfile.value = null; fetchPromise = null; } @@ -67,17 +67,20 @@ export const usePOSPackagesStore = defineStore("posPackages", () => { * @returns {Promise} True when packages are available */ async function ensurePackagesFetched(posProfile, force = false) { - if (hasFetched.value && !force) return packages.value.length > 0; - if (fetchPromise) return fetchPromise; if (!posProfile) return false; + // Keyed by profile: a shift switch must not keep showing the previous + // outlet's packages, which the server would then reject at checkout. + if (fetchedProfile.value === posProfile && !force) return packages.value.length > 0; + if (fetchPromise) return fetchPromise; + isLoading.value = true; fetchPromise = (async () => { try { if (isOffline()) { const cached = await offlineWorker.getCachedPackages(posProfile); setPackages(cached || []); - hasFetched.value = true; + fetchedProfile.value = posProfile; return packages.value.length > 0; } @@ -86,7 +89,7 @@ export const usePOSPackagesStore = defineStore("posPackages", () => { }); const list = response?.message || response || []; setPackages(list); - hasFetched.value = true; + fetchedProfile.value = posProfile; offlineWorker.cachePackages(list, posProfile).catch((error) => { log.warn("Failed to cache packages for offline use", error); @@ -95,7 +98,9 @@ export const usePOSPackagesStore = defineStore("posPackages", () => { return list.length > 0; } catch (error) { log.error("Failed to load packages", error); - hasFetched.value = true; + // Do not cache the failure against this profile: a retry must be + // able to load the real list instead of showing an empty catalog. + setPackages([]); return false; } finally { isLoading.value = false; @@ -138,14 +143,26 @@ export const usePOSPackagesStore = defineStore("posPackages", () => { snapshot: result.snapshot, }; } catch (error) { - log.warn("Server quote failed, using local quote", error); - return local; + // The server rejected this package (expired, wrong outlet, edited + // definition). Falling back to the local price would quote the + // customer an amount that the invoice will refuse at checkout. + log.error("Server rejected the package quote", error); + + return { + valid: false, + error: + error?.message || + __("This package is no longer available. Please reload the POS."), + total: 0, + lines: [], + snapshot: null, + }; } } return { packages, - hasFetched, + fetchedProfile, isLoading, packagesByParentItem, packageItemCodes,