From 63581b597242ec8992619862f096e3a92dc3ba28 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Wed, 20 May 2026 23:08:21 +0300 Subject: [PATCH] feat(isolation): remove single-DB multi-company isolation; rely on ERPNext defaults --- pos_next/api/items.py | 48 ------------ pos_next/company_isolation.py | 39 ---------- pos_next/hooks.py | 14 ---- pos_next/patches.txt | 3 +- pos_next/patches/v2_0_0/__init__.py | 0 .../v2_0_0/remove_custom_company_fields.py | 21 ++++++ pos_next/pos_next/custom/brand.json | 21 ------ pos_next/pos_next/custom/customer.json | 21 ------ pos_next/pos_next/custom/customer_group.json | 21 ------ pos_next/pos_next/custom/item.json | 73 ------------------- pos_next/pos_next/custom/item_group.json | 21 ------ pos_next/pos_next/custom/price_list.json | 21 ------ .../pos_next/custom/promotional_scheme.json | 2 +- pos_next/pos_next/custom/supplier.json | 21 ------ pos_next/pos_next/custom/supplier_group.json | 21 ------ .../doctype/pos_settings/pos_settings.json | 8 -- pos_next/test_packed_items_regression.py | 8 +- pos_next/uninstall.py | 43 +---------- pos_next/validations.py | 66 ----------------- 19 files changed, 32 insertions(+), 440 deletions(-) delete mode 100644 pos_next/company_isolation.py create mode 100644 pos_next/patches/v2_0_0/__init__.py create mode 100644 pos_next/patches/v2_0_0/remove_custom_company_fields.py delete mode 100644 pos_next/pos_next/custom/brand.json delete mode 100644 pos_next/pos_next/custom/customer.json delete mode 100644 pos_next/pos_next/custom/customer_group.json delete mode 100644 pos_next/pos_next/custom/item.json delete mode 100644 pos_next/pos_next/custom/item_group.json delete mode 100644 pos_next/pos_next/custom/price_list.json delete mode 100644 pos_next/pos_next/custom/supplier.json delete mode 100644 pos_next/pos_next/custom/supplier_group.json delete mode 100644 pos_next/validations.py diff --git a/pos_next/api/items.py b/pos_next/api/items.py index b0c608ec..d2dc2acf 100644 --- a/pos_next/api/items.py +++ b/pos_next/api/items.py @@ -24,7 +24,6 @@ "brand", "has_variants", "variant_of", - "custom_company", "disabled", ] @@ -525,7 +524,6 @@ def get_item_variants(template_item, pos_profile): Item.has_serial_no, Item.item_group, Item.brand, - Item.custom_company, Item.variant_of, ) .where(Item.variant_of == template_item) @@ -533,14 +531,6 @@ def get_item_variants(template_item, pos_profile): .where(Item.is_sales_item == 1) ) - # Company scope: strict by default; include empty (global) items only if - # the profile's POS Settings explicitly opts in. - if pos_profile_doc.company: - if _pos_settings_allow_global_items(pos_profile_doc.name): - query = query.where(fn.Coalesce(Item.custom_company, "").isin([pos_profile_doc.company, ""])) - else: - query = query.where(Item.custom_company == pos_profile_doc.company) - variants = query.run(as_dict=True) # If no variants found, return empty with helpful message @@ -726,37 +716,6 @@ def _get_allowed_profile_brands(pos_profile): return _get_pos_profile_configured_brands(pos_profile) -def invalidate_pos_settings_cache(doc, method=None): - """Doc-event hook: drop cached `allow_global_items` when POS Settings is saved.""" - if doc and getattr(doc, "pos_profile", None): - frappe.cache().delete_value(f"pos_settings_allow_global_items:{doc.pos_profile}") - - -def _pos_settings_allow_global_items(pos_profile_name): - """Whether the POS Settings row for this profile opts into global items. - - A "global item" is one whose ``custom_company`` is NULL or empty — historically - used as a shared SKU across companies. With strict company filtering as the - default, these items are hidden unless this flag is enabled. - """ - cache_key = f"pos_settings_allow_global_items:{pos_profile_name}" - cached = frappe.cache().get_value(cache_key) - if cached is not None: - return cached - - value = ( - frappe.db.get_value( - "POS Settings", - {"pos_profile": pos_profile_name, "enabled": 1}, - "allow_global_items", - ) - or 0 - ) - result = int(value) - frappe.cache().set_value(cache_key, result, expires_in_sec=300) - return result - - def _get_pos_profile_allowed_item_groups(pos_profile_doc): """Return the item groups (with descendants) authorised for a POS Profile. @@ -806,13 +765,6 @@ def _build_item_base_conditions( where_params = [] - if pos_profile_doc.company: - if _pos_settings_allow_global_items(pos_profile_doc.name): - conditions.append("(i.custom_company = %s OR IFNULL(i.custom_company, '') = '')") - else: - conditions.append("i.custom_company = %s") - where_params.append(pos_profile_doc.company) - allowed_item_groups = _get_pos_profile_allowed_item_groups(pos_profile_doc) if item_group: diff --git a/pos_next/company_isolation.py b/pos_next/company_isolation.py deleted file mode 100644 index 8d3204b8..00000000 --- a/pos_next/company_isolation.py +++ /dev/null @@ -1,39 +0,0 @@ -import frappe - - -def get_user_companies(user=None): - """Return company names the current user is allowed to access. - - Used by `validations.item_query` and any other code that wants to scope a - query to the user's companies. Empty list means "no company restriction - derivable for this user" — callers decide whether that should fall through - to stock permission handling or block the query. - """ - user = user or frappe.session.user - - if user == "Administrator": - return [] - - companies = set() - - default_company = frappe.defaults.get_user_default("company", user=user) - if default_company: - companies.add(default_company) - - for company in frappe.get_all( - "User Permission", - filters={"user": user, "allow": "Company"}, - pluck="for_value", - ): - if company: - companies.add(company) - - employee_company = frappe.db.get_value( - "Employee", - {"user_id": user, "status": "Active"}, - "company", - ) - if employee_company: - companies.add(employee_company) - - return sorted(companies) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 91c2804d..3816db68 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -136,14 +136,6 @@ # notification_config = "pos_next.notifications.get_notification_config" -# Permissions -# Standard Queries -# ---------------- -# Custom query for company-aware item filtering -standard_queries = { - "Item": "pos_next.validations.item_query" -} - # DocType Class # --------------- # Override standard doctype classes @@ -157,9 +149,6 @@ # Hook on document methods and events doc_events = { - "Item": { - "validate": "pos_next.validations.validate_item" - }, "Customer": { "after_insert": [ "pos_next.api.customers.auto_assign_loyalty_program", @@ -185,9 +174,6 @@ "POS Profile": { "on_update": "pos_next.realtime_events.emit_pos_profile_updated_event" }, - "POS Settings": { - "on_update": "pos_next.api.items.invalidate_pos_settings_cache" - }, "Promotional Scheme": { "on_update": "pos_next.overrides.pricing_rule.sync_pos_only_to_pricing_rules" } diff --git a/pos_next/patches.txt b/pos_next/patches.txt index 6c51d983..c0f7ec25 100644 --- a/pos_next/patches.txt +++ b/pos_next/patches.txt @@ -4,4 +4,5 @@ [post_model_sync] # Patches added in this section will be executed after doctypes are migrated -pos_next.patches.v1_7_0.reinstall_workspace \ No newline at end of file +pos_next.patches.v1_7_0.reinstall_workspace +pos_next.patches.v2_0_0.remove_custom_company_fields \ No newline at end of file diff --git a/pos_next/patches/v2_0_0/__init__.py b/pos_next/patches/v2_0_0/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pos_next/patches/v2_0_0/remove_custom_company_fields.py b/pos_next/patches/v2_0_0/remove_custom_company_fields.py new file mode 100644 index 00000000..5f22c4ba --- /dev/null +++ b/pos_next/patches/v2_0_0/remove_custom_company_fields.py @@ -0,0 +1,21 @@ +import frappe + + +CUSTOM_FIELDS = [ + "Brand-custom_company", + "Customer-custom_company", + "Customer Group-custom_company", + "Item-custom_company", + "Item Group-custom_company", + "Price List-custom_company", + "Supplier-custom_company", + "Supplier Group-custom_company", +] + + +def execute(): + for field_name in CUSTOM_FIELDS: + if frappe.db.exists("Custom Field", field_name): + frappe.delete_doc("Custom Field", field_name, force=True, ignore_permissions=True) + + frappe.cache().delete_keys("pos_settings_allow_global_items:*") diff --git a/pos_next/pos_next/custom/brand.json b/pos_next/pos_next/custom/brand.json deleted file mode 100644 index 8cf62848..00000000 --- a/pos_next/pos_next/custom/brand.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "custom_fields": [ - { - "dt": "Brand", - "fieldname": "custom_company", - "fieldtype": "Link", - "insert_after": "brand", - "in_standard_filter": 1, - "label": "Company", - "module": "POS Next", - "name": "Brand-custom_company", - "options": "Company", - "reqd": 0 - } - ], - "custom_perms": [], - "doctype": "Brand", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/custom/customer.json b/pos_next/pos_next/custom/customer.json deleted file mode 100644 index 77646095..00000000 --- a/pos_next/pos_next/custom/customer.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "custom_fields": [ - { - "dt": "Customer", - "fieldname": "custom_company", - "fieldtype": "Link", - "insert_after": "customer_group", - "in_standard_filter": 1, - "label": "Company", - "module": "POS Next", - "name": "Customer-custom_company", - "options": "Company", - "reqd": 0 - } - ], - "custom_perms": [], - "doctype": "Customer", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/custom/customer_group.json b/pos_next/pos_next/custom/customer_group.json deleted file mode 100644 index f8298e43..00000000 --- a/pos_next/pos_next/custom/customer_group.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "custom_fields": [ - { - "dt": "Customer Group", - "fieldname": "custom_company", - "fieldtype": "Link", - "insert_after": "parent_customer_group", - "in_standard_filter": 1, - "label": "Company", - "module": "POS Next", - "name": "Customer Group-custom_company", - "options": "Company", - "reqd": 0 - } - ], - "custom_perms": [], - "doctype": "Customer Group", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/custom/item.json b/pos_next/pos_next/custom/item.json deleted file mode 100644 index a583213c..00000000 --- a/pos_next/pos_next/custom/item.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "custom_fields": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2026-03-01 14:10:08.477134", - "default": null, - "depends_on": null, - "description": "Leave empty for global items available to all companies", - "docstatus": 0, - "dt": "Item", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "custom_company", - "fieldtype": "Link", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 6, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 1, - "insert_after": "stock_uom", - "is_system_generated": 0, - "is_virtual": 0, - "label": "Company", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-10-31 17:41:05.902075", - "modified_by": "Administrator", - "module": "POS Next", - "name": "Item-custom_company", - "no_copy": 0, - "non_negative": 0, - "options": "Company", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - } - ], - "custom_perms": [], - "doctype": "Item", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/custom/item_group.json b/pos_next/pos_next/custom/item_group.json deleted file mode 100644 index 7a7850ab..00000000 --- a/pos_next/pos_next/custom/item_group.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "custom_fields": [ - { - "dt": "Item Group", - "fieldname": "custom_company", - "fieldtype": "Link", - "insert_after": "parent_item_group", - "in_standard_filter": 1, - "label": "Company", - "module": "POS Next", - "name": "Item Group-custom_company", - "options": "Company", - "reqd": 0 - } - ], - "custom_perms": [], - "doctype": "Item Group", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/custom/price_list.json b/pos_next/pos_next/custom/price_list.json deleted file mode 100644 index d43aa3b8..00000000 --- a/pos_next/pos_next/custom/price_list.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "custom_fields": [ - { - "dt": "Price List", - "fieldname": "custom_company", - "fieldtype": "Link", - "insert_after": "currency", - "in_standard_filter": 1, - "label": "Company", - "module": "POS Next", - "name": "Price List-custom_company", - "options": "Company", - "reqd": 0 - } - ], - "custom_perms": [], - "doctype": "Price List", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/custom/promotional_scheme.json b/pos_next/pos_next/custom/promotional_scheme.json index 038b9751..b166d30f 100644 --- a/pos_next/pos_next/custom/promotional_scheme.json +++ b/pos_next/pos_next/custom/promotional_scheme.json @@ -32,7 +32,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "disable", + "insert_after": "selling", "is_system_generated": 0, "is_virtual": 0, "label": "POS Only", diff --git a/pos_next/pos_next/custom/supplier.json b/pos_next/pos_next/custom/supplier.json deleted file mode 100644 index 6cf7f2f3..00000000 --- a/pos_next/pos_next/custom/supplier.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "custom_fields": [ - { - "dt": "Supplier", - "fieldname": "custom_company", - "fieldtype": "Link", - "insert_after": "supplier_group", - "in_standard_filter": 1, - "label": "Company", - "module": "POS Next", - "name": "Supplier-custom_company", - "options": "Company", - "reqd": 0 - } - ], - "custom_perms": [], - "doctype": "Supplier", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/custom/supplier_group.json b/pos_next/pos_next/custom/supplier_group.json deleted file mode 100644 index 35cb2465..00000000 --- a/pos_next/pos_next/custom/supplier_group.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "custom_fields": [ - { - "dt": "Supplier Group", - "fieldname": "custom_company", - "fieldtype": "Link", - "insert_after": "parent_supplier_group", - "in_standard_filter": 1, - "label": "Company", - "module": "POS Next", - "name": "Supplier Group-custom_company", - "options": "Company", - "reqd": 0 - } - ], - "custom_perms": [], - "doctype": "Supplier Group", - "links": [], - "property_setters": [], - "sync_on_migrate": 1 -} diff --git a/pos_next/pos_next/doctype/pos_settings/pos_settings.json b/pos_next/pos_next/doctype/pos_settings/pos_settings.json index 9d5dd576..c5711a43 100644 --- a/pos_next/pos_next/doctype/pos_settings/pos_settings.json +++ b/pos_next/pos_next/doctype/pos_settings/pos_settings.json @@ -69,7 +69,6 @@ "section_break_misc", "input_qty", "allow_negative_stock", - "allow_global_items", "section_break_security", "enable_session_lock", "session_lock_timeout", @@ -516,13 +515,6 @@ "fieldtype": "Check", "label": "Allow Negative Stock" }, - { - "default": "0", - "description": "When enabled, items with no Company set are visible in this POS (treated as shared/global SKUs). Leave off for strict per-company isolation on multi-company sites.", - "fieldname": "allow_global_items", - "fieldtype": "Check", - "label": "Include Global Items (no Company)" - }, { "collapsible": 1, "fieldname": "section_break_security", diff --git a/pos_next/test_packed_items_regression.py b/pos_next/test_packed_items_regression.py index e555e448..7c76a77b 100644 --- a/pos_next/test_packed_items_regression.py +++ b/pos_next/test_packed_items_regression.py @@ -120,8 +120,8 @@ def test_repeated_reload_save_no_duplicate_packed_items(self): ctx = _sales_invoice_bundle_context() bundle_code, child_code = self._unique_codes() - make_item(child_code, {"is_stock_item": 1, "custom_company": ctx.company}) - bundle_item = make_item(bundle_code, {"is_stock_item": 0, "custom_company": ctx.company}) + make_item(child_code, {"is_stock_item": 1}) + bundle_item = make_item(bundle_code, {"is_stock_item": 0}) bundle_item.reload() for row in bundle_item.item_defaults: if row.company == ctx.company: @@ -171,8 +171,8 @@ def test_two_bundle_lines_repeated_save_no_duplicate_packed_items(self): ctx = _sales_invoice_bundle_context() bundle_code, child_code = self._unique_codes() - make_item(child_code, {"is_stock_item": 1, "custom_company": ctx.company}) - bundle_item = make_item(bundle_code, {"is_stock_item": 0, "custom_company": ctx.company}) + make_item(child_code, {"is_stock_item": 1}) + bundle_item = make_item(bundle_code, {"is_stock_item": 0}) bundle_item.reload() for row in bundle_item.item_defaults: if row.company == ctx.company: diff --git a/pos_next/uninstall.py b/pos_next/uninstall.py index 2e043e13..a3ee275a 100644 --- a/pos_next/uninstall.py +++ b/pos_next/uninstall.py @@ -50,29 +50,11 @@ def remove_custom_fields(): custom_fields = [ "Sales Invoice-posa_pos_opening_shift", "Sales Invoice-posa_is_printed", - "Customer-custom_company", - "Supplier-custom_company", - "Item Group-custom_company", - "Customer Group-custom_company", - "Supplier Group-custom_company", - "Brand-custom_company", - "Price List-custom_company", - # Note: Item-custom_company is shared with Nexus app - # Only remove if Nexus is not installed ] removed_count = 0 skipped_count = 0 - # Check if Nexus app is installed - nexus_installed = "nexus" in frappe.get_installed_apps() - - # Add Item-custom_company to removal list only if Nexus is not installed - if not nexus_installed: - custom_fields.append("Item-custom_company") - else: - log_message("Nexus app detected - preserving Item-custom_company field", level="info", indent=1) - for field_name in custom_fields: try: if frappe.db.exists("Custom Field", field_name): @@ -236,27 +218,10 @@ def get_custom_fields_for_cleanup(): Get list of custom fields that can be safely removed Returns list of field names that belong to POS Next """ - custom_fields = [] - - # Always safe to remove (POS Next specific) - custom_fields.extend( - [ - "Sales Invoice-posa_pos_opening_shift", - "Sales Invoice-posa_is_printed", - "Customer-custom_company", - "Supplier-custom_company", - "Item Group-custom_company", - "Customer Group-custom_company", - "Supplier Group-custom_company", - "Brand-custom_company", - "Price List-custom_company", - ] - ) - - # Conditional removal (shared with other apps) - nexus_installed = "nexus" in frappe.get_installed_apps() - if not nexus_installed: - custom_fields.append("Item-custom_company") + custom_fields = [ + "Sales Invoice-posa_pos_opening_shift", + "Sales Invoice-posa_is_printed", + ] return custom_fields diff --git a/pos_next/validations.py b/pos_next/validations.py deleted file mode 100644 index cab6b11c..00000000 --- a/pos_next/validations.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2024, POS Next and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe import _ - - -def validate_item(doc, method): - """ - Validate Item doctype - - Keep custom_company value as provided by user - - Do not auto-fill defaults - """ - pass - - -@frappe.whitelist() -def item_query(doctype, txt, searchfield, start, page_len, filters): - """ - Custom query to filter items by company - - If company is specified in filters, show matching company items only - - If no company is specified, show items based on the current user's allowed companies - """ - import json - from pos_next.company_isolation import get_user_companies - - # Parse filters if it's a string (when called from frontend) - if isinstance(filters, str): - filters = json.loads(filters) - - conditions = ["disabled = 0"] - values = [] - - if txt: - conditions.append(f"({searchfield} LIKE %s OR item_name LIKE %s)") - values.extend([f"%{txt}%", f"%{txt}%"]) - - company = filters.get("company") if filters else None - - if company: - conditions.append("(custom_company = %s OR custom_company IS NULL OR custom_company = '')") - values.append(company) - else: - user_companies = get_user_companies() - if user_companies: - placeholders = ", ".join(["%s"] * len(user_companies)) - conditions.append( - f"(custom_company IN ({placeholders}) OR custom_company IS NULL OR custom_company = '')" - ) - values.extend(user_companies) - - query = f""" - SELECT name, item_name, item_group - FROM `tabItem` - WHERE {' AND '.join(conditions)} - ORDER BY - CASE WHEN name LIKE %s THEN 0 ELSE 1 END, - item_name - LIMIT %s, %s - """ - - values.extend([f"{txt}%", start, page_len]) - - return frappe.db.sql(query, values)