From 89f56527cad50cc53af2e0002e596af19b5260be Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 9 Jun 2026 11:40:39 +0300 Subject: [PATCH 1/5] feat: add District and Governorate doctype with custom fields --- pos_next/pos_next/custom/customer.json | 137 ++++++++++++++++++ .../pos_next/doctype/district/__init__.py | 0 .../pos_next/doctype/district/district.js | 8 + .../pos_next/doctype/district/district.json | 63 ++++++++ .../pos_next/doctype/district/district.py | 9 ++ .../doctype/district/test_district.py | 9 ++ .../pos_next/doctype/governorate/__init__.py | 0 .../doctype/governorate/governorate.js | 8 + .../doctype/governorate/governorate.json | 46 ++++++ .../doctype/governorate/governorate.py | 9 ++ .../doctype/governorate/test_governorate.py | 9 ++ 11 files changed, 298 insertions(+) create mode 100644 pos_next/pos_next/custom/customer.json create mode 100644 pos_next/pos_next/doctype/district/__init__.py create mode 100644 pos_next/pos_next/doctype/district/district.js create mode 100644 pos_next/pos_next/doctype/district/district.json create mode 100644 pos_next/pos_next/doctype/district/district.py create mode 100644 pos_next/pos_next/doctype/district/test_district.py create mode 100644 pos_next/pos_next/doctype/governorate/__init__.py create mode 100644 pos_next/pos_next/doctype/governorate/governorate.js create mode 100644 pos_next/pos_next/doctype/governorate/governorate.json create mode 100644 pos_next/pos_next/doctype/governorate/governorate.py create mode 100644 pos_next/pos_next/doctype/governorate/test_governorate.py diff --git a/pos_next/pos_next/custom/customer.json b/pos_next/pos_next/custom/customer.json new file mode 100644 index 000000000..ba3c426ed --- /dev/null +++ b/pos_next/pos_next/custom/customer.json @@ -0,0 +1,137 @@ +{ + "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-06-09 11:35:04.696593", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Customer", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_district", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 47, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 0, + "insert_after": "custom_governorate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "District", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-06-09 11:35:04.696593", + "modified_by": "Administrator", + "module": null, + "name": "Customer-custom_district", + "no_copy": 0, + "non_negative": 0, + "options": "District", + "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 + }, + { + "_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-06-09 10:57:06.280375", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Customer", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_governorate", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 47, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 0, + "insert_after": "primary_address", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Governorate", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-06-09 10:57:06.280375", + "modified_by": "Administrator", + "module": null, + "name": "Customer-custom_governorate", + "no_copy": 0, + "non_negative": 0, + "options": "Governorate", + "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": "Customer", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} \ No newline at end of file diff --git a/pos_next/pos_next/doctype/district/__init__.py b/pos_next/pos_next/doctype/district/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/district/district.js b/pos_next/pos_next/doctype/district/district.js new file mode 100644 index 000000000..bf6bfed54 --- /dev/null +++ b/pos_next/pos_next/doctype/district/district.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, BrainWise and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("District", { +// refresh(frm) { + +// }, +// }); diff --git a/pos_next/pos_next/doctype/district/district.json b/pos_next/pos_next/doctype/district/district.json new file mode 100644 index 000000000..28e237f6d --- /dev/null +++ b/pos_next/pos_next/doctype/district/district.json @@ -0,0 +1,63 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-09 10:55:40.584980", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "governorate", + "column_break_drcm", + "district" + ], + "fields": [ + { + "fieldname": "district", + "fieldtype": "Data", + "in_list_view": 1, + "label": "District", + "reqd": 1 + }, + { + "fieldname": "governorate", + "fieldtype": "Link", + "in_filter": 1, + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Governorate", + "options": "Governorate", + "reqd": 1 + }, + { + "fieldname": "column_break_drcm", + "fieldtype": "Column Break" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-06-09 11:02:01.069029", + "modified_by": "Administrator", + "module": "POS Next", + "name": "District", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/pos_next/pos_next/doctype/district/district.py b/pos_next/pos_next/doctype/district/district.py new file mode 100644 index 000000000..0f49a3460 --- /dev/null +++ b/pos_next/pos_next/doctype/district/district.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class District(Document): + pass diff --git a/pos_next/pos_next/doctype/district/test_district.py b/pos_next/pos_next/doctype/district/test_district.py new file mode 100644 index 000000000..979b3b9e2 --- /dev/null +++ b/pos_next/pos_next/doctype/district/test_district.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, BrainWise and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestDistrict(FrappeTestCase): + pass diff --git a/pos_next/pos_next/doctype/governorate/__init__.py b/pos_next/pos_next/doctype/governorate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pos_next/pos_next/doctype/governorate/governorate.js b/pos_next/pos_next/doctype/governorate/governorate.js new file mode 100644 index 000000000..e4f1b3f7e --- /dev/null +++ b/pos_next/pos_next/doctype/governorate/governorate.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, BrainWise and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Governorate", { +// refresh(frm) { + +// }, +// }); diff --git a/pos_next/pos_next/doctype/governorate/governorate.json b/pos_next/pos_next/doctype/governorate/governorate.json new file mode 100644 index 000000000..7a720c0c0 --- /dev/null +++ b/pos_next/pos_next/doctype/governorate/governorate.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-09 10:54:57.009043", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "governorate" + ], + "fields": [ + { + "fieldname": "governorate", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Governorate", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-06-09 10:56:00.364650", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Governorate", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/pos_next/pos_next/doctype/governorate/governorate.py b/pos_next/pos_next/doctype/governorate/governorate.py new file mode 100644 index 000000000..bcb14a4da --- /dev/null +++ b/pos_next/pos_next/doctype/governorate/governorate.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class Governorate(Document): + pass diff --git a/pos_next/pos_next/doctype/governorate/test_governorate.py b/pos_next/pos_next/doctype/governorate/test_governorate.py new file mode 100644 index 000000000..eebff73f0 --- /dev/null +++ b/pos_next/pos_next/doctype/governorate/test_governorate.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, BrainWise and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestGovernorate(FrappeTestCase): + pass From af58a4d7895d9f580a338fbfd98c62c2bea78e93 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 9 Jun 2026 12:54:10 +0300 Subject: [PATCH 2/5] feat: add custom governorate and district fields to customer creation and management --- .../components/sale/CreateCustomerDialog.vue | 117 +++++++++++++++++- pos_next/api/customers.py | 6 + pos_next/hooks.py | 2 +- .../pos_next/doctype/district/district.json | 4 +- .../doctype/governorate/governorate.json | 15 ++- pos_next/public/js/customer.js | 20 +++ pos_next/translations/ar.csv | 7 +- 7 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 pos_next/public/js/customer.js diff --git a/POS/src/components/sale/CreateCustomerDialog.vue b/POS/src/components/sale/CreateCustomerDialog.vue index cba4978d8..ff13eca08 100644 --- a/POS/src/components/sale/CreateCustomerDialog.vue +++ b/POS/src/components/sale/CreateCustomerDialog.vue @@ -130,6 +130,41 @@ + + +
+ + +
+ + +
+ + +
@@ -228,6 +263,8 @@ const countrySearchRef = ref(null) const customerGroups = ref([]) const territories = ref([]) +const governorates = ref([]) +const districts = ref([]) const customerData = ref({ customer_name: "", @@ -235,6 +272,8 @@ const customerData = ref({ email_id: "", customer_group: "", territory: "", + custom_governorate: "", + custom_district: "", }) // ============================================================================= @@ -339,6 +378,8 @@ const createCustomerResource = createResource({ email_id: customerData.value.email_id || "", customer_group: customerData.value.customer_group || "", territory: customerData.value.territory || "", + custom_governorate: customerData.value.custom_governorate || "", + custom_district: customerData.value.custom_district || "", pos_profile: props.posProfile, }), onSuccess: (data) => { @@ -363,6 +404,8 @@ const updateCustomerResource = createResource({ territory: customerData.value.territory || "", mobile_no: customerData.value.mobile_no || "", email_id: customerData.value.email_id || "", + custom_governorate: customerData.value.custom_governorate || "", + custom_district: customerData.value.custom_district || "", }, }), onSuccess: (data) => { @@ -427,6 +470,50 @@ const territoriesResource = createListResource("Territory", (names) => { } }) +const governoratesResource = createListResource("Governorate", (names) => { + governorates.value = names +}) + + +const customerLocationResource = createResource({ + url: "frappe.client.get_value", + makeParams: () => ({ + doctype: "Customer", + filters: { name: props.customer?.name }, + fieldname: ["custom_governorate", "custom_district"], + }), + auto: false, + onSuccess: (data) => { + customerData.value.custom_governorate = data?.custom_governorate || "" + customerData.value.custom_district = data?.custom_district || "" + }, + onError: (err) => log.error("Error loading customer location", err), +}) + + +const districtsResource = createResource({ + url: "frappe.client.get_list", + makeParams: () => ({ + doctype: "District", + fields: ["name", "district"], + filters: { governorate: customerData.value.custom_governorate }, + limit_page_length: 0, + order_by: "district asc", + }), + auto: false, + onSuccess: (data) => { + districts.value = data || [] + // Drop the selected district if it no longer belongs to the governorate + if ( + customerData.value.custom_district && + !districts.value.some((d) => d.name === customerData.value.custom_district) + ) { + customerData.value.custom_district = "" + } + }, + onError: (err) => log.error("Error loading Districts", err), +}) + const posProfileResource = createResource({ url: "frappe.client.get_value", makeParams: () => ({ @@ -458,7 +545,17 @@ const loadDialogData = async () => { } // Load form options - await Promise.all([territoriesResource.reload(), customerGroupsResource.reload()]) + await Promise.all([ + territoriesResource.reload(), + customerGroupsResource.reload(), + governoratesResource.reload(), + ]) + if (isEditMode.value && props.customer?.name) { + await customerLocationResource.reload() + } + if (customerData.value.custom_governorate) { + await districtsResource.reload() + } checkPermissions() // Set country from POS Profile @@ -504,7 +601,10 @@ const resetForm = () => { territories.value, (list) => list.find((n) => n === "All Territories"), ), + custom_governorate: "", + custom_district: "", }) + districts.value = [] selectedCountryCode.value = "" phoneNumber.value = "" } @@ -531,6 +631,8 @@ watch( territories.value.find((n) => n === "All Territories") || territories.value[0] || "" + customerData.value.custom_governorate = customer.custom_governorate || "" + customerData.value.custom_district = customer.custom_district || "" // Handle mobile_no with country code if (customer.mobile_no) { customerData.value.mobile_no = customer.mobile_no @@ -564,6 +666,19 @@ watch(selectedCountryCode, async (newVal, oldVal) => { updateTerritoryFromCountry() }) + +watch( + () => customerData.value.custom_governorate, + (governorate) => { + if (governorate) { + districtsResource.reload() + } else { + districts.value = [] + customerData.value.custom_district = "" + } + } +) + watch(showCountryDropdown, async (isOpen) => { if (isOpen) { await nextTick() diff --git a/pos_next/api/customers.py b/pos_next/api/customers.py index 08f96d084..fbc664a59 100644 --- a/pos_next/api/customers.py +++ b/pos_next/api/customers.py @@ -81,6 +81,8 @@ def create_customer( territory=None, company=None, pos_profile=None, + custom_governorate=None, + custom_district=None, ): """ Create a new customer from POS. @@ -93,6 +95,8 @@ def create_customer( territory (str): Territory (default: from Selling Settings) company (str): Company (optional, used to auto-assign loyalty program) pos_profile (str): POS Profile (optional, preferred for context-aware loyalty assignment) + custom_governorate (str): Governorate (optional) + custom_district (str): District (optional, must belong to the governorate) Returns: dict: Created customer document @@ -135,6 +139,8 @@ def create_customer( "mobile_no": mobile_no or "", "email_id": email_id or "", "loyalty_program": loyalty_program, + "custom_governorate": custom_governorate or None, + "custom_district": custom_district or None, } ) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index a08c97b63..e72e35b84 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -48,7 +48,7 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} +doctype_js = {"Customer": "public/js/customer.js"} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} diff --git a/pos_next/pos_next/doctype/district/district.json b/pos_next/pos_next/doctype/district/district.json index 28e237f6d..de1f44f5b 100644 --- a/pos_next/pos_next/doctype/district/district.json +++ b/pos_next/pos_next/doctype/district/district.json @@ -1,6 +1,7 @@ { "actions": [], "allow_rename": 1, + "autoname": "format:{governorate}-{district}", "creation": "2026-06-09 10:55:40.584980", "doctype": "DocType", "engine": "InnoDB", @@ -36,10 +37,11 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-06-09 11:02:01.069029", + "modified": "2026-06-09 12:50:24.717788", "modified_by": "Administrator", "module": "POS Next", "name": "District", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { diff --git a/pos_next/pos_next/doctype/governorate/governorate.json b/pos_next/pos_next/doctype/governorate/governorate.json index 7a720c0c0..dc672cf68 100644 --- a/pos_next/pos_next/doctype/governorate/governorate.json +++ b/pos_next/pos_next/doctype/governorate/governorate.json @@ -1,6 +1,7 @@ { "actions": [], "allow_rename": 1, + "autoname": "field:governorate", "creation": "2026-06-09 10:54:57.009043", "doctype": "DocType", "engine": "InnoDB", @@ -11,18 +12,23 @@ { "fieldname": "governorate", "fieldtype": "Data", + "in_filter": 1, "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, "label": "Governorate", - "reqd": 1 + "reqd": 1, + "unique": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-06-09 10:56:00.364650", + "modified": "2026-06-09 12:45:46.769004", "modified_by": "Administrator", "module": "POS Next", "name": "Governorate", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -40,7 +46,10 @@ ], "row_format": "Dynamic", "rows_threshold_for_grid_search": 20, + "show_title_field_in_link": 1, "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "title_field": "governorate", + "translated_doctype": 1 } \ No newline at end of file diff --git a/pos_next/public/js/customer.js b/pos_next/public/js/customer.js new file mode 100644 index 000000000..38d37196f --- /dev/null +++ b/pos_next/public/js/customer.js @@ -0,0 +1,20 @@ +// Copyright (c) 2026, BrainWise and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Customer", { + refresh(frm) { + frm.set_query("custom_district", () => { + return { + filters: { + governorate: frm.doc.custom_governorate || "", + }, + } + }) + }, + + custom_governorate(frm) { + if (frm.doc.custom_district) { + frm.set_value("custom_district", null) + } + }, +}) diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv index f89e6390a..c99f44df1 100644 --- a/pos_next/translations/ar.csv +++ b/pos_next/translations/ar.csv @@ -1559,4 +1559,9 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط: "This offline receipt is no longer in browser storage. Sync the invoice, then print from history.","هذا الإيصال المحفوظ دون اتصال لم يعد موجودًا في تخزين المتصفح. قم بمزامنة الفاتورة أولًا، ثم اطبعها من السجل.","" "Invoice {0} saved offline and sent to printer — will sync when online","تم حفظ الفاتورة {0} دون اتصال وإرسالها إلى الطابعة — وستتم مزامنتها عند عودة الاتصال","" "Invoice {0} saved offline but print failed — open Print from the success dialog","تم حفظ الفاتورة {0} دون اتصال، لكن تعذّرت الطباعة — افتح خيار الطباعة من نافذة النجاح","" -"Popup blocked — check your browser settings.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.","" \ No newline at end of file +"Popup blocked — check your browser settings.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.","" +"Governorate","المحافظة","" +"Select Governorate","اختر المحافظة","" +"District","الحي","" +"Select District","اختر الحي","" +"Select a governorate first","اختر المحافظة أولاً","" From f58646028d838506c756c3009db2c4c7aeb2e6f3 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 9 Jun 2026 15:11:07 +0300 Subject: [PATCH 3/5] fix linter issues --- POS/postcss.config.js | 2 +- POS/src/App.vue | 12 +- POS/src/components/ShiftClosingDialog.vue | 1805 ++++++++++------ POS/src/components/ShiftOpeningDialog.vue | 596 +++--- POS/src/components/common/ActionButton.vue | 33 +- .../components/common/AutocompleteSelect.vue | 288 ++- .../components/common/ClearCacheOverlay.vue | 71 +- .../components/common/CountryCodeSelector.vue | 104 +- POS/src/components/common/InstallAppBadge.vue | 73 +- .../components/common/LanguageSwitcher.vue | 49 +- POS/src/components/common/LazyImage.vue | 32 +- POS/src/components/common/LoadingSpinner.vue | 6 +- POS/src/components/common/POSFooter.vue | 342 +-- POS/src/components/common/PhoneInput.vue | 76 +- POS/src/components/common/SelectInput.vue | 166 +- .../components/common/SessionLockScreen.vue | 108 +- POS/src/components/common/StatusBadge.vue | 45 +- POS/src/components/common/Toast.vue | 39 +- POS/src/components/common/TranslatedHTML.vue | 37 +- POS/src/components/common/UserMenu.vue | 160 +- .../invoices/InvoiceDetailDialog.vue | 528 +++-- .../components/invoices/InvoiceFilters.vue | 341 ++- .../components/invoices/InvoiceManagement.vue | 1079 +++++++--- .../components/partials/PartialPayments.vue | 373 ++-- POS/src/components/pos/ManagementSlider.vue | 48 +- POS/src/components/pos/POSHeader.vue | 297 ++- POS/src/components/sale/BatchSerialDialog.vue | 374 ++-- POS/src/components/sale/CouponDialog.vue | 331 +-- POS/src/components/sale/CouponManagement.vue | 703 +++--- .../components/sale/CreateCustomerDialog.vue | 388 ++-- POS/src/components/sale/CustomerDialog.vue | 182 +- .../components/sale/DraftInvoicesDialog.vue | 222 +- POS/src/components/sale/EditItemDialog.vue | 735 ++++--- POS/src/components/sale/InvoiceCart.vue | 372 +++- .../components/sale/InvoiceHistoryDialog.vue | 262 ++- .../components/sale/ItemSelectionDialog.vue | 471 ++-- POS/src/components/sale/ItemsSelector.vue | 1130 ++++++---- POS/src/components/sale/OffersDialog.vue | 443 ++-- .../components/sale/OfflineInvoicesDialog.vue | 406 +++- .../components/sale/PromotionManagement.vue | 1890 ++++++++++------- .../components/sale/ReturnInvoiceDialog.vue | 1486 ++++++++----- .../sale/WarehouseAvailabilityDialog.vue | 1630 ++++++++------ POS/src/components/settings/CheckboxField.vue | 10 +- POS/src/components/settings/NumberField.vue | 10 +- POS/src/components/settings/POSSettings.vue | 1248 ++++++++--- POS/src/components/settings/SelectField.vue | 18 +- .../components/settings/SettingsSection.vue | 22 +- POS/src/components/settings/settingsConfig.js | 14 +- POS/src/composables/useCartSort.js | 170 +- POS/src/composables/useCountryCodes.js | 82 +- POS/src/composables/useDialogState.js | 62 +- POS/src/composables/useFormatters.js | 52 +- POS/src/composables/useInvoiceFilters.js | 162 +- POS/src/composables/useItems.js | 131 +- POS/src/composables/useLazyLoad.js | 42 +- POS/src/composables/useLocale.js | 184 +- POS/src/composables/useLongPress.js | 108 +- POS/src/composables/useOffline.js | 152 +- POS/src/composables/useOfflineStatus.js | 22 +- POS/src/composables/usePOSEvents.js | 58 +- POS/src/composables/usePWAInstall.js | 140 +- POS/src/composables/usePaymentCalculations.js | 43 +- POS/src/composables/usePaymentNumpad.js | 103 +- POS/src/composables/usePermissions.js | 94 +- POS/src/composables/usePosProfile.js | 20 +- POS/src/composables/useQuickAmounts.js | 52 +- POS/src/composables/useQzTray.js | 142 +- POS/src/composables/useRealtimeCustomers.js | 290 +-- POS/src/composables/useRealtimePosProfile.js | 190 +- POS/src/composables/useRealtimeStock.js | 102 +- POS/src/composables/useResponsivePayment.js | 122 +- POS/src/composables/useSalesPersons.js | 117 +- POS/src/composables/useSearchInput.js | 111 +- POS/src/composables/useSessionLock.js | 373 ++-- POS/src/composables/useShift.js | 94 +- POS/src/composables/useStock.js | 12 +- POS/src/composables/useToast.js | 82 +- POS/src/data/session.js | 54 +- POS/src/data/user.js | 64 +- POS/src/main.js | 160 +- POS/src/pages/Home.vue | 634 +++--- POS/src/pages/Login.vue | 286 +-- POS/src/router.js | 28 +- POS/src/socket.js | 54 +- POS/src/stores/bootstrap.js | 100 +- POS/src/stores/countries.js | 100 +- POS/src/stores/customerSearch.js | 309 ++- POS/src/stores/invoiceFilters.js | 224 +- POS/src/stores/itemSearch.js | 1674 ++++++++------- POS/src/stores/posDrafts.js | 80 +- POS/src/stores/posEvents.js | 292 +-- POS/src/stores/posOffers.js | 253 ++- POS/src/stores/posSettings.js | 274 +-- POS/src/stores/posShift.js | 101 +- POS/src/stores/posSync.js | 253 +-- POS/src/stores/posUI.js | 200 +- POS/src/stores/serialNumber.js | 154 +- POS/src/stores/stock.js | 149 +- POS/src/utils/apiWrapper.js | 22 +- POS/src/utils/csrf.js | 170 +- POS/src/utils/currency.js | 106 +- POS/src/utils/draftManager.js | 170 +- POS/src/utils/errorHandler.js | 213 +- POS/src/utils/invoice.js | 48 +- POS/src/utils/logger.js | 239 ++- POS/src/utils/lowEndOptimizations.js | 303 +-- POS/src/utils/mutex.js | 78 +- POS/src/utils/offline/cache.js | 359 ++-- POS/src/utils/offline/db.js | 221 +- POS/src/utils/offline/index.js | 10 +- POS/src/utils/offline/items.js | 47 +- POS/src/utils/offline/offlineReceiptCache.js | 38 +- POS/src/utils/offline/offlineState.js | 499 +++-- POS/src/utils/offline/sync.js | 458 ++-- POS/src/utils/offline/translationCache.js | 98 +- POS/src/utils/offline/uuid.js | 14 +- POS/src/utils/offline/workerClient.js | 397 ++-- POS/src/utils/payment.js | 4 +- POS/src/utils/performanceConfig.js | 214 +- POS/src/utils/printEod.js | 6 +- POS/src/utils/printInvoice.js | 401 ++-- POS/src/utils/qzTray.js | 178 +- POS/src/utils/sessionCleanup.js | 42 +- POS/src/utils/stockValidator.js | 38 +- POS/src/workers/offline.worker.js | 1351 ++++++------ POS/tailwind.config.js | 4 +- POS/vite.config.js | 47 +- pos_next/__init__.py | 49 +- pos_next/api/__init__.py | 15 +- pos_next/api/auth.py | 26 +- pos_next/api/bootstrap.py | 28 +- pos_next/api/branding.py | 77 +- pos_next/api/credit_sales.py | 268 +-- pos_next/api/customers.py | 13 +- pos_next/api/items.py | 3 +- pos_next/api/localization.py | 25 +- pos_next/api/offers.py | 112 +- pos_next/api/partial_payments.py | 1676 ++++++++------- pos_next/api/pos_profile.py | 213 +- pos_next/api/promotions.py | 352 +-- pos_next/api/qz.py | 36 +- pos_next/api/sales_invoice_hooks.py | 20 +- pos_next/api/shifts.py | 20 +- pos_next/api/test_customers.py | 191 +- pos_next/api/utilities.py | 20 +- pos_next/api/wallet.py | 151 +- pos_next/hooks.py | 40 +- pos_next/install.py | 42 +- pos_next/overrides/frappe_compat.py | 4 +- pos_next/overrides/sales_invoice.py | 2 +- .../patches/v1_7_0/reinstall_workspace.py | 16 +- .../v2_0_0/remove_custom_company_fields.py | 1 - .../brainwise_branding/brainwise_branding.js | 171 +- .../brainwise_branding/brainwise_branding.py | 8 +- .../offline_invoice_sync.py | 183 +- .../pos_closing_shift/pos_closing_shift.js | 43 +- .../pos_closing_shift/pos_closing_shift.py | 1140 +++++----- .../test_pos_closing_shift.py | 2 +- .../pos_closing_shift_detail.py | 2 +- .../pos_closing_shift_taxes.py | 2 +- .../pos_next/doctype/pos_coupon/pos_coupon.py | 329 +-- .../doctype/pos_coupon/test_pos_coupon.py | 88 +- .../pos_coupon_detail/pos_coupon_detail.py | 2 +- .../pos_next/doctype/pos_offer/pos_offer.js | 30 +- .../pos_next/doctype/pos_offer/pos_offer.py | 2 +- .../doctype/pos_offer/test_pos_offer.py | 2 +- .../pos_offer_detail/pos_offer_detail.py | 2 +- .../pos_opening_shift/pos_opening_shift.py | 67 +- .../test_pos_opening_shift.py | 2 +- .../pos_opening_shift_detail.py | 2 +- .../pos_payment_entry_reference.py | 2 +- .../doctype/pos_settings/pos_settings.py | 30 +- .../doctype/referral_code/referral_code.js | 3 +- .../doctype/referral_code/referral_code.py | 440 ++-- .../referral_code/test_referral_code.py | 2 +- .../sales_invoice_reference.py | 2 +- pos_next/pos_next/doctype/wallet/wallet.py | 79 +- .../wallet_transaction/wallet_transaction.py | 271 +-- .../cashier_performance_report.js | 50 +- .../cashier_performance_report.py | 32 +- ...inventory_impact_and_fast_movers_report.js | 70 +- ...inventory_impact_and_fast_movers_report.py | 99 +- .../offline_sync_and_system_health_report.js | 54 +- .../offline_sync_and_system_health_report.py | 103 +- .../payments_and_cash_control_report.js | 58 +- .../payments_and_cash_control_report.py | 159 +- .../sales_vs_shifts_report.js | 204 +- .../sales_vs_shifts_report.py | 172 +- pos_next/pos_next/utils/pos_closing_print.py | 140 +- .../utils/tests/test_pos_closing_print.py | 112 +- pos_next/services/__init__.py | 6 +- pos_next/services/barcode.py | 478 ++--- pos_next/tasks/branding_monitor.py | 5 +- pos_next/tasks/cleanup_expired_promotions.py | 2 +- pos_next/test_packed_items_regression.py | 9 +- pos_next/test_promotions.py | 27 +- pos_next/uninstall.py | 3 +- 197 files changed, 23438 insertions(+), 18238 deletions(-) diff --git a/POS/postcss.config.js b/POS/postcss.config.js index 1a5262473..7b75c83af 100644 --- a/POS/postcss.config.js +++ b/POS/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/POS/src/App.vue b/POS/src/App.vue index bcaf2d9bf..73c5f397b 100644 --- a/POS/src/App.vue +++ b/POS/src/App.vue @@ -1,11 +1,11 @@ diff --git a/POS/src/components/ShiftClosingDialog.vue b/POS/src/components/ShiftClosingDialog.vue index 8f110ed2b..ad02948af 100644 --- a/POS/src/components/ShiftClosingDialog.vue +++ b/POS/src/components/ShiftClosingDialog.vue @@ -1,533 +1,986 @@ diff --git a/POS/src/components/ShiftOpeningDialog.vue b/POS/src/components/ShiftOpeningDialog.vue index b6b6dea6b..e35f19656 100644 --- a/POS/src/components/ShiftOpeningDialog.vue +++ b/POS/src/components/ShiftOpeningDialog.vue @@ -1,249 +1,305 @@ diff --git a/POS/src/components/common/AutocompleteSelect.vue b/POS/src/components/common/AutocompleteSelect.vue index 827e3a5c0..4182c207d 100644 --- a/POS/src/components/common/AutocompleteSelect.vue +++ b/POS/src/components/common/AutocompleteSelect.vue @@ -1,8 +1,14 @@ diff --git a/POS/src/components/sale/WarehouseAvailabilityDialog.vue b/POS/src/components/sale/WarehouseAvailabilityDialog.vue index 3ba0c7244..58d10e479 100644 --- a/POS/src/components/sale/WarehouseAvailabilityDialog.vue +++ b/POS/src/components/sale/WarehouseAvailabilityDialog.vue @@ -1,483 +1,837 @@ diff --git a/POS/src/components/settings/POSSettings.vue b/POS/src/components/settings/POSSettings.vue index 89ca3e85e..c28c2d258 100644 --- a/POS/src/components/settings/POSSettings.vue +++ b/POS/src/components/settings/POSSettings.vue @@ -8,21 +8,52 @@ >
-
+
-
+
- - - + + +
-

{{ __('POS Settings') }}

+

+ {{ __("POS Settings") }} +

- - + + {{ settings.pos_profile || posProfile }}

@@ -36,11 +67,21 @@ size="sm" > - {{ __('Refresh') }} + {{ __("Refresh") }}
@@ -69,49 +130,102 @@
-
-
-

{{ __('Loading settings...') }}

+
+
+

+ {{ __("Loading settings...") }} +

-
+
-
+
- - + +
-

{{ __('Stock Management') }}

-

{{ __('Configure warehouse and inventory settings') }}

+

+ {{ __("Stock Management") }} +

+

+ {{ + __( + "Configure warehouse and inventory settings" + ) + }} +

- - + + - {{ __('Stock Controls') }} + {{ + __("Stock Controls") + }}
@@ -119,49 +233,110 @@
- - + + -

{{ __('Warehouse Selection') }}

+

+ {{ __("Warehouse Selection") }} +

-
- - +
+ + -

{{ __('Loading warehouses...') }}

+

+ {{ __("Loading warehouses...") }} +

- - + + -

{{ __('Stock Validation Policy') }}

+

+ {{ __("Stock Validation Policy") }} +

- - + + -
@@ -171,17 +346,43 @@
- - + + -

{{ __('Background Stock Sync') }}

-
-
- {{ __('Active') }} +

+ {{ __("Background Stock Sync") }} +

+
+
+ {{ + __("Active") + }}
-
-
- {{ __('Inactive') }} +
+
+ {{ + __("Inactive") + }}
@@ -190,63 +391,147 @@ -
+
-
+
- - + + -
- + - -
-
+
- - + +
-

{{ __('Network Usage:') }}

-

{{ __('~15 KB per sync cycle') }}

-

{{ __('~{0} MB per hour', [Math.round((3600 / stockSyncIntervalSeconds) * 15 / 1024)]) }}

+

+ {{ __("Network Usage:") }} +

+

+ {{ __("~15 KB per sync cycle") }} +

+

+ {{ + __("~{0} MB per hour", [ + Math.round( + ((3600 / + stockSyncIntervalSeconds) * + 15) / + 1024 + ), + ]) + }} +

@@ -257,25 +542,58 @@
-
+
- - + +
-

{{ __('Sales Management') }}

-

{{ __('Configure pricing, discounts, and sales operations') }}

+

+ {{ __("Sales Management") }} +

+

+ {{ + __( + "Configure pricing, discounts, and sales operations" + ) + }} +

- - + + - {{ __('Sales Controls') }} + {{ + __("Sales Controls") + }}
@@ -283,16 +601,32 @@
- - + + -

{{ __('Pricing & Discounts') }}

+

+ {{ __("Pricing & Discounts") }} +

@@ -332,10 +676,22 @@
- - + + -

{{ __('Sales Operations') }}

+

+ {{ __("Sales Operations") }} +

-
+
- {{ qzConnecting ? __('Connecting to QZ Tray...') : qzConnected ? __('QZ Tray Connected') : __('QZ Tray Not Connected') }} + {{ + qzConnecting + ? __("Connecting to QZ Tray...") + : qzConnected + ? __("QZ Tray Connected") + : __("QZ Tray Not Connected") + }}
@@ -394,7 +777,14 @@ v-model="selectedPrinter" :label="__('Printer')" :options="printerOptions" - :description="qzPrinters.length === 0 && !loadingPrinters ? __('No printers found. Is QZ Tray running?') : ''" + :description=" + qzPrinters.length === 0 && + !loadingPrinters + ? __( + 'No printers found. Is QZ Tray running?' + ) + : '' + " />
@@ -420,49 +821,108 @@ qzCertStatus === 'trusted' ? 'bg-green-50 border-green-200' : qzCertStatus === 'untrusted' - ? 'bg-red-50 border-red-200' - : 'bg-amber-50 border-amber-200' + ? 'bg-red-50 border-red-200' + : 'bg-amber-50 border-amber-200', ]" >
- - + +
-
+

- {{ __('Silent Print Certificate') }} + {{ + __( + "Silent Print Certificate" + ) + }}

- - {{ __('Installed') }} + + {{ + __("Installed") + }} - - {{ __('Not Installed') }} + + {{ + __("Not Installed") + }} - - {{ __('Checking...') }} + + {{ + __("Checking...") + }}
@@ -471,70 +931,173 @@ v-if="qzCertStatus === 'trusted'" class="text-xs text-green-800 leading-relaxed mb-2" > - {{ __('Certificate is installed and signing is active. Print jobs will be sent silently without confirmation dialogs.') }} + {{ + __( + "Certificate is installed and signing is active. Print jobs will be sent silently without confirmation dialogs." + ) + }}

- {{ __('Certificate is not installed on this machine. Generate a certificate, download it, and import it into QZ Tray.') }} + {{ + __( + "Certificate is not installed on this machine. Generate a certificate, download it, and import it into QZ Tray." + ) + }}

- {{ __('To print without confirmation dialogs, generate a signing certificate and install it on each POS machine.') }} + {{ + __( + "To print without confirmation dialogs, generate a signing certificate and install it on each POS machine." + ) + }}

-
+
-

- {{ __('Download the certificate and import it into QZ Tray, then restart QZ Tray.') }} + {{ + __( + "Download the certificate and import it into QZ Tray, then restart QZ Tray." + ) + }}

-
+
- - + + -

- {{ __('QZ Tray must be installed and running on this computer. Download from') }} - qz.io. - {{ __('If QZ Tray is unavailable, printing will fall back to the browser dialog.') }} +

+ {{ + __( + "QZ Tray must be installed and running on this computer. Download from" + ) + }} + qz.io. + {{ + __( + "If QZ Tray is unavailable, printing will fall back to the browser dialog." + ) + }}

@@ -543,17 +1106,38 @@
-
-
- - - +
+ + + -

{{ __('No POS Profile Selected') }}

-

{{ __('Please select a POS Profile to configure settings') }}

+

+ {{ __("No POS Profile Selected") }} +

+

+ {{ __("Please select a POS Profile to configure settings") }} +

@@ -563,43 +1147,39 @@ ${html} -` +`; - await qzPrintHTML(fullHTML) - return true + await qzPrintHTML(fullHTML); + return true; } /** @@ -393,30 +487,30 @@ export async function silentPrintDoc(doctype, name, printFormat) { */ export async function silentPrintInvoice(invoiceName, printFormat = null) { if (isLocalOnlyInvoiceName(invoiceName)) { - const doc = await hydrateLocalOnlyInvoice({ name: invoiceName }) - if (doc.items?.length > 0) return silentPrintInvoiceFromDoc(doc) + const doc = await hydrateLocalOnlyInvoice({ name: invoiceName }); + if (doc.items?.length > 0) return silentPrintInvoiceFromDoc(doc); throw new Error( __( - "This offline receipt is no longer in browser storage. Use browser print from the success dialog after checkout.", - ), - ) + "This offline receipt is no longer in browser storage. Use browser print from the success dialog after checkout." + ) + ); } - const format = printFormat || DEFAULT_PRINT_FORMAT + const format = printFormat || DEFAULT_PRINT_FORMAT; - await silentPrintDoc("Sales Invoice", invoiceName, format) - log.info(`Silent print sent for ${invoiceName}`) - return true + await silentPrintDoc("Sales Invoice", invoiceName, format); + log.info(`Silent print sent for ${invoiceName}`); + return true; } /** * Silent-print a full invoice dict using the same HTML as the offline receipt fallback. */ export async function silentPrintInvoiceFromDoc(invoiceData) { - const fullHTML = buildReceiptDocumentHTML(invoiceData, { includeControls: false }) - await qzPrintHTML(fullHTML) - log.info(`Silent print (local receipt) for ${invoiceData?.name}`) - flagOfflineInvoicePrinted(invoiceData?.name) - return true + const fullHTML = buildReceiptDocumentHTML(invoiceData, { includeControls: false }); + await qzPrintHTML(fullHTML); + log.info(`Silent print (local receipt) for ${invoiceData?.name}`); + flagOfflineInvoicePrinted(invoiceData?.name); + return true; } /** @@ -425,42 +519,39 @@ export async function silentPrintInvoiceFromDoc(invoiceData) { * internally, so no separate connection logic is needed here. */ export async function printWithSilentFallback(invoiceData, printFormat = null) { - invoiceData = await hydrateLocalOnlyInvoice(invoiceData) - const invoiceName = invoiceData?.name - if (!invoiceName) throw new Error("Invalid invoice data — missing name") - - if ( - isLocalOnlyInvoiceName(invoiceName) && - invoiceData.items?.length > 0 - ) { + invoiceData = await hydrateLocalOnlyInvoice(invoiceData); + const invoiceName = invoiceData?.name; + if (!invoiceName) throw new Error("Invalid invoice data — missing name"); + + if (isLocalOnlyInvoiceName(invoiceName) && invoiceData.items?.length > 0) { try { - await silentPrintInvoiceFromDoc(invoiceData) - return { method: "silent", success: true } + await silentPrintInvoiceFromDoc(invoiceData); + return { method: "silent", success: true }; } catch (err) { - log.warn("Silent local receipt failed, falling back to browser:", err?.message || err) + log.warn("Silent local receipt failed, falling back to browser:", err?.message || err); } try { - printInvoiceCustom(invoiceData) - return { method: "browser", success: true } + printInvoiceCustom(invoiceData); + return { method: "browser", success: true }; } catch (err) { - log.error("Browser print for local receipt failed:", err) - return { method: "browser", success: false } + log.error("Browser print for local receipt failed:", err); + return { method: "browser", success: false }; } } try { - await silentPrintInvoice(invoiceName, printFormat) - return { method: "silent", success: true } + await silentPrintInvoice(invoiceName, printFormat); + return { method: "silent", success: true }; } catch (err) { - log.warn("Silent print failed, falling back to browser:", err?.message || err) + log.warn("Silent print failed, falling back to browser:", err?.message || err); } try { - await printInvoiceByName(invoiceName, printFormat) - return { method: "browser", success: true } + await printInvoiceByName(invoiceName, printFormat); + return { method: "browser", success: true }; } catch (err) { - log.error("Browser print fallback also failed:", err) - return { method: "browser", success: false } + log.error("Browser print fallback also failed:", err); + return { method: "browser", success: false }; } } @@ -473,19 +564,19 @@ export async function printWithSilentFallback(invoiceData, printFormat = null) { * local-only invoices, and as the fallback when /printview is unavailable. */ export function printInvoiceCustom(invoiceData) { - const printWindow = window.open("", "_blank", "width=350,height=600") + const printWindow = window.open("", "_blank", "width=350,height=600"); if (!printWindow) { - log.error("Cannot open print window — popup blocked.") - throw new Error(__("Popup blocked — check your browser settings.")) + log.error("Cannot open print window — popup blocked."); + throw new Error(__("Popup blocked — check your browser settings.")); } - const printContent = buildReceiptDocumentHTML(invoiceData, { includeControls: true }) + const printContent = buildReceiptDocumentHTML(invoiceData, { includeControls: true }); - printWindow.document.write(printContent) - printWindow.document.close() + printWindow.document.write(printContent); + printWindow.document.close(); printWindow.onload = () => { - setTimeout(() => printWindow.print(), 250) - } - flagOfflineInvoicePrinted(invoiceData?.name) - return true + setTimeout(() => printWindow.print(), 250); + }; + flagOfflineInvoicePrinted(invoiceData?.name); + return true; } diff --git a/POS/src/utils/qzTray.js b/POS/src/utils/qzTray.js index e9e3b3e6c..5be3cb11e 100644 --- a/POS/src/utils/qzTray.js +++ b/POS/src/utils/qzTray.js @@ -1,19 +1,19 @@ -import qz from "qz-tray" -import { ref } from "vue" -import { call } from "@/utils/apiWrapper" -import { logger } from "@/utils/logger" +import qz from "qz-tray"; +import { ref } from "vue"; +import { call } from "@/utils/apiWrapper"; +import { logger } from "@/utils/logger"; -const log = logger.create("QZTray") +const log = logger.create("QZTray"); // ============================================================================ // Reactive State // ============================================================================ /** Whether QZ Tray is currently connected */ -export const qzConnected = ref(false) +export const qzConnected = ref(false); /** Whether a connection attempt is in progress */ -export const qzConnecting = ref(false) +export const qzConnecting = ref(false); /** * Certificate trust status: @@ -21,27 +21,27 @@ export const qzConnecting = ref(false) * "trusted" — cert was provided AND first signing succeeded (silent print) * "untrusted" — cert or signing failed (dialogs will appear) */ -export const qzCertStatus = ref("unknown") +export const qzCertStatus = ref("unknown"); // ============================================================================ // localStorage Persistence // ============================================================================ -const PRINTER_STORAGE_KEY = "pos_qz_printer_name" +const PRINTER_STORAGE_KEY = "pos_qz_printer_name"; export function getSavedPrinterName() { try { - return localStorage.getItem(PRINTER_STORAGE_KEY) || "" + return localStorage.getItem(PRINTER_STORAGE_KEY) || ""; } catch { - return "" + return ""; } } export function savePrinterName(name) { try { - localStorage.setItem(PRINTER_STORAGE_KEY, name || "") + localStorage.setItem(PRINTER_STORAGE_KEY, name || ""); } catch (e) { - log.warn("Failed to save printer name to localStorage:", e) + log.warn("Failed to save printer name to localStorage:", e); } } @@ -49,69 +49,69 @@ export function savePrinterName(name) { // Security Setup (once) // ============================================================================ -let _securityInitialized = false +let _securityInitialized = false; /** Cached certificate text — fetched once, reused for the session */ -let _cachedCert = null +let _cachedCert = null; /** Whether the server has a valid cert to provide */ -let _certProvided = false +let _certProvided = false; function setupSecurity() { - if (_securityInitialized) return - _securityInitialized = true + if (_securityInitialized) return; + _securityInitialized = true; // Certificate callback — called once during WebSocket handshake. // Fetches the public cert from the server and caches it. qz.security.setCertificatePromise((resolve, reject) => { if (_cachedCert) { - _certProvided = true - resolve(_cachedCert) - return + _certProvided = true; + resolve(_cachedCert); + return; } call("pos_next.api.qz.get_certificate") .then((cert) => { - const pem = cert?.message || cert + const pem = cert?.message || cert; if (pem) { - _cachedCert = pem - _certProvided = true + _cachedCert = pem; + _certProvided = true; } else { - _certProvided = false - qzCertStatus.value = "untrusted" + _certProvided = false; + qzCertStatus.value = "untrusted"; } - resolve(pem) + resolve(pem); }) .catch((err) => { - log.warn("Could not fetch QZ certificate:", err?.message || err) - _certProvided = false - qzCertStatus.value = "untrusted" + log.warn("Could not fetch QZ certificate:", err?.message || err); + _certProvided = false; + qzCertStatus.value = "untrusted"; // Resolve empty so QZ falls back to unsigned (shows dialog) - resolve() - }) - }) + resolve(); + }); + }); // Signature callback — called on every print/serial operation. // Sends the message to the server for RSA-SHA512 signing. - qz.security.setSignatureAlgorithm("SHA512") + qz.security.setSignatureAlgorithm("SHA512"); qz.security.setSignaturePromise((toSign) => { return (resolve, reject) => { call("pos_next.api.qz.sign_message", { message: toSign }) .then((sig) => { - const signature = sig?.message || sig + const signature = sig?.message || sig; if (signature && _certProvided) { - qzCertStatus.value = "trusted" + qzCertStatus.value = "trusted"; } - resolve(signature) + resolve(signature); }) .catch((err) => { - log.warn("Could not sign QZ message:", err?.message || err) - qzCertStatus.value = "untrusted" + log.warn("Could not sign QZ message:", err?.message || err); + qzCertStatus.value = "untrusted"; // Resolve empty so QZ falls back to unsigned (shows dialog) - resolve() - }) - } - }) + resolve(); + }); + }; + }); } // ============================================================================ @@ -119,7 +119,7 @@ function setupSecurity() { // ============================================================================ /** Guards against concurrent connect() calls */ -let _connectPromise = null +let _connectPromise = null; /** * Connect to the locally-running QZ Tray application. @@ -128,49 +128,49 @@ let _connectPromise = null */ export async function connect() { if (qz.websocket.isActive()) { - qzConnected.value = true - return true + qzConnected.value = true; + return true; } // Deduplicate concurrent calls - if (_connectPromise) return _connectPromise + if (_connectPromise) return _connectPromise; - _connectPromise = _doConnect() + _connectPromise = _doConnect(); try { - return await _connectPromise + return await _connectPromise; } finally { - _connectPromise = null + _connectPromise = null; } } async function _doConnect() { - setupSecurity() + setupSecurity(); qz.websocket.setClosedCallbacks(() => { - log.info("QZ Tray connection closed") - qzConnected.value = false - qzConnecting.value = false - qzCertStatus.value = "unknown" - }) + log.info("QZ Tray connection closed"); + qzConnected.value = false; + qzConnecting.value = false; + qzCertStatus.value = "unknown"; + }); - qzConnecting.value = true + qzConnecting.value = true; try { - await qz.websocket.connect() - qzConnected.value = true - log.info("Connected to QZ Tray") + await qz.websocket.connect(); + qzConnected.value = true; + log.info("Connected to QZ Tray"); // Probe trust status — findPrinters triggers the signature callback, // which updates qzCertStatus to "trusted" or "untrusted". - qz.printers.find().catch(() => {}) + qz.printers.find().catch(() => {}); - return true + return true; } catch (err) { - qzConnected.value = false - log.warn("Could not connect to QZ Tray:", err?.message || err) - return false + qzConnected.value = false; + log.warn("Could not connect to QZ Tray:", err?.message || err); + return false; } finally { - qzConnecting.value = false + qzConnecting.value = false; } } @@ -179,16 +179,16 @@ async function _doConnect() { */ export async function disconnect() { if (!qz.websocket.isActive()) { - qzConnected.value = false - return + qzConnected.value = false; + return; } try { - await qz.websocket.disconnect() + await qz.websocket.disconnect(); } catch (err) { - log.warn("Error disconnecting from QZ Tray:", err?.message || err) + log.warn("Error disconnecting from QZ Tray:", err?.message || err); } finally { - qzConnected.value = false + qzConnected.value = false; } } @@ -203,17 +203,17 @@ export async function disconnect() { */ export async function findPrinters() { if (!qz.websocket.isActive()) { - const ok = await connect() - if (!ok) return [] + const ok = await connect(); + if (!ok) return []; } try { - const printers = await qz.printers.find() - log.info(`Found ${printers.length} printer(s)`) - return printers + const printers = await qz.printers.find(); + log.info(`Found ${printers.length} printer(s)`); + return printers; } catch (err) { - log.error("Error discovering printers:", err?.message || err) - return [] + log.error("Error discovering printers:", err?.message || err); + return []; } } @@ -233,15 +233,15 @@ export async function findPrinters() { */ export async function printHTML(html, printerName, options = {}) { if (!qz.websocket.isActive()) { - const ok = await connect() + const ok = await connect(); if (!ok) { - throw new Error("QZ Tray is not available") + throw new Error("QZ Tray is not available"); } } - const printer = printerName || getSavedPrinterName() + const printer = printerName || getSavedPrinterName(); if (!printer) { - throw new Error("No printer selected. Please select a printer in POS Settings.") + throw new Error("No printer selected. Please select a printer in POS Settings."); } const config = qz.configs.create(printer, { @@ -254,7 +254,7 @@ export async function printHTML(html, printerName, options = {}) { margins: { top: 0, right: 0, bottom: 0, left: 0 }, colorType: "grayscale", interpolation: "nearest-neighbor", - }) + }); const data = [ { @@ -263,14 +263,14 @@ export async function printHTML(html, printerName, options = {}) { flavor: "plain", data: html, }, - ] + ]; try { - await qz.print(config, data) - log.info(`Print job sent to "${printer}"`) - return true + await qz.print(config, data); + log.info(`Print job sent to "${printer}"`); + return true; } catch (err) { - log.error(`Print failed on "${printer}":`, err?.message || err) - throw err + log.error(`Print failed on "${printer}":`, err?.message || err); + throw err; } } diff --git a/POS/src/utils/sessionCleanup.js b/POS/src/utils/sessionCleanup.js index f065d336b..cc46971ec 100644 --- a/POS/src/utils/sessionCleanup.js +++ b/POS/src/utils/sessionCleanup.js @@ -1,9 +1,9 @@ -import { clearAllDrafts } from "@/utils/draftManager" -import { clearAllOfflineReceiptPayloads } from "@/utils/offline/offlineReceiptCache" -import { usePOSCartStore } from "@/stores/posCart" -import { usePOSUIStore } from "@/stores/posUI" -import { useSessionLock } from "@/composables/useSessionLock" -import { shiftState } from "@/composables/useShift" +import { clearAllDrafts } from "@/utils/draftManager"; +import { clearAllOfflineReceiptPayloads } from "@/utils/offline/offlineReceiptCache"; +import { usePOSCartStore } from "@/stores/posCart"; +import { usePOSUIStore } from "@/stores/posUI"; +import { useSessionLock } from "@/composables/useSessionLock"; +import { shiftState } from "@/composables/useShift"; // All user-specific localStorage keys that must be cleared on logout. // Device-level settings (pos_qz_printer_name, pos_stock_sync_settings, @@ -18,7 +18,7 @@ const USER_KEYS = [ "pos_frequent_customers", "pos_customers_last_sync", "pos_invoice_filters", -] +]; /** * Centralized cleanup of all user-specific session state. @@ -29,27 +29,27 @@ const USER_KEYS = [ export async function cleanupUserSession() { // 1. Clear all user-specific localStorage keys for (const key of USER_KEYS) { - localStorage.removeItem(key) + localStorage.removeItem(key); } // Clear cashier-specific sessionStorage (offline receipt cache) so the next // user on the same tab can't read the previous user's receipts. - clearAllOfflineReceiptPayloads() + clearAllOfflineReceiptPayloads(); // 2. Clear Pinia stores - const cartStore = usePOSCartStore() - const uiStore = usePOSUIStore() - cartStore.clearCart() + const cartStore = usePOSCartStore(); + const uiStore = usePOSUIStore(); + cartStore.clearCart(); // Reset shift/profile refs that persist in the useInvoice singleton // (clearCart intentionally does NOT reset these since it's also called between transactions) - cartStore.posOpeningShift = null - cartStore.posProfile = null - uiStore.resetAllDialogs() + cartStore.posOpeningShift = null; + cartStore.posProfile = null; + uiStore.resetAllDialogs(); // 3. Reset composable singletons - const { clearLock, stopActivityTracking } = useSessionLock() - clearLock() - stopActivityTracking() + const { clearLock, stopActivityTracking } = useSessionLock(); + clearLock(); + stopActivityTracking(); // Reset shift state shiftState.value = { @@ -59,12 +59,12 @@ export async function cleanupUserSession() { isOpen: false, _initialElapsedMs: 0, _receivedAt: 0, - } + }; // 4. Clear draft invoices from IndexedDB try { - await clearAllDrafts() + await clearAllDrafts(); } catch (error) { - console.error("Failed to clear draft invoices:", error) + console.error("Failed to clear draft invoices:", error); } } diff --git a/POS/src/utils/stockValidator.js b/POS/src/utils/stockValidator.js index cd8e96731..1d6fbe525 100644 --- a/POS/src/utils/stockValidator.js +++ b/POS/src/utils/stockValidator.js @@ -3,7 +3,7 @@ * Single source of truth for stock availability checks. */ -import { call } from "frappe-ui" +import { call } from "frappe-ui"; /** * Determine whether an item requires stock validation. @@ -13,20 +13,20 @@ import { call } from "frappe-ui" * @returns {boolean} true when stock should be enforced for this item */ export function shouldValidateItemStock(item) { - if (!item) return false + if (!item) return false; // Non-stock items are never validated - if (item.is_stock_item === 0 || item.is_stock_item === false) return false + if (item.is_stock_item === 0 || item.is_stock_item === false) return false; // Item-level allow_negative_stock bypasses validation - if (item.allow_negative_stock === 1 || item.allow_negative_stock === true) return false + if (item.allow_negative_stock === 1 || item.allow_negative_stock === true) return false; // Batch / serial items have their own dialog-level validation - if (item.has_serial_no || item.has_batch_no) return false + if (item.has_serial_no || item.has_batch_no) return false; // Must be a stock item or bundle (or have stock data) - const hasStockData = item.actual_qty !== undefined || item.stock_qty !== undefined - return !!(item.is_stock_item || item.is_bundle || hasStockData) + const hasStockData = item.actual_qty !== undefined || item.stock_qty !== undefined; + return !!(item.is_stock_item || item.is_bundle || hasStockData); } /** @@ -38,18 +38,18 @@ export function shouldValidateItemStock(item) { * @returns {{ available: boolean, actualQty: number, error: string|null }} */ export function checkStockAvailability(item, requestedQty, warehouse) { - const actualQty = item.actual_qty ?? item.stock_qty ?? 0 - const wh = warehouse || item.warehouse || '' + const actualQty = item.actual_qty ?? item.stock_qty ?? 0; + const wh = warehouse || item.warehouse || ""; if (actualQty >= requestedQty) { - return { available: true, actualQty, error: null } + return { available: true, actualQty, error: null }; } return { available: false, actualQty, error: formatStockError(item.item_name, requestedQty, actualQty, wh), - } + }; } /** @@ -67,12 +67,12 @@ export async function getItemStock(itemCode, warehouse) { warehouse: warehouse, }, fieldname: "actual_qty", - }) + }); - return Number.parseFloat(result?.actual_qty || 0) + return Number.parseFloat(result?.actual_qty || 0); } catch (error) { - console.warn("Failed to fetch stock:", error) - return 0 + console.warn("Failed to fetch stock:", error); + return 0; } } @@ -86,10 +86,10 @@ export async function getItemStock(itemCode, warehouse) { */ export function formatStockError(itemName, requested, available, warehouse) { if (available <= 0) { - return `"${itemName}" is out of stock in warehouse "${warehouse}".` + return `"${itemName}" is out of stock in warehouse "${warehouse}".`; } - const unit = requested === 1 ? "unit" : "units" - const availableUnit = available === 1 ? "unit" : "units" - return `Not enough stock for "${itemName}".\n\nYou requested ${requested} ${unit}, but only ${available} ${availableUnit} available in "${warehouse}".` + const unit = requested === 1 ? "unit" : "units"; + const availableUnit = available === 1 ? "unit" : "units"; + return `Not enough stock for "${itemName}".\n\nYou requested ${requested} ${unit}, but only ${available} ${availableUnit} available in "${warehouse}".`; } diff --git a/POS/src/workers/offline.worker.js b/POS/src/workers/offline.worker.js index 2a6d992ad..f1964d7cb 100644 --- a/POS/src/workers/offline.worker.js +++ b/POS/src/workers/offline.worker.js @@ -17,10 +17,10 @@ * @module workers/offline.worker */ -import { logger } from '../utils/logger' -import { generateOfflineId } from '../utils/offline/uuid' +import { logger } from "../utils/logger"; +import { generateOfflineId } from "../utils/offline/uuid"; -const log = logger.create('OfflineWorker') +const log = logger.create("OfflineWorker"); // ============================================================================ // CONFIGURATION @@ -28,37 +28,37 @@ const log = logger.create('OfflineWorker') const CONFIG = { DB_NAME: "pos_next_offline", - BATCH_SIZE: 500, // Optimal for IndexedDB performance + BATCH_SIZE: 500, // Optimal for IndexedDB performance MAX_RETRY_ATTEMPTS: 3, RETRY_DELAY_MS: 1000, QUERY_CACHE_SIZE: 100, QUERY_CACHE_TTL_MS: 5 * 60 * 1000, // 5 minutes -} +}; // ============================================================================ // SINGLETON STATE // ============================================================================ /** @type {import('dexie').Dexie|null} Singleton database instance */ -let db = null +let db = null; /** @type {boolean} Database initialization status */ -let dbInitialized = false +let dbInitialized = false; /** @type {Promise|null} Pending init promise (prevents race conditions) */ -let dbInitPromise = null +let dbInitPromise = null; /** @type {Map} Query result cache */ -const queryCache = new Map() +const queryCache = new Map(); /** @type {Map} Performance metrics */ -const metrics = new Map() +const metrics = new Map(); /** @type {number} Circuit breaker failure count */ -let circuitBreakerFailures = 0 +let circuitBreakerFailures = 0; /** @type {boolean} Circuit breaker state */ -let circuitBreakerOpen = false +let circuitBreakerOpen = false; // ============================================================================ // DATABASE CONNECTION MANAGEMENT @@ -74,113 +74,114 @@ let circuitBreakerOpen = false async function initDB() { // Fast path: return existing connection if (db && dbInitialized) { - return db + return db; } // Prevent concurrent initialization (race condition guard) if (dbInitPromise) { - return dbInitPromise + return dbInitPromise; } // Circuit breaker: fail fast if DB is consistently unavailable if (circuitBreakerOpen) { - throw new Error("Circuit breaker open - database unavailable") + throw new Error("Circuit breaker open - database unavailable"); } dbInitPromise = (async () => { - const startTime = performance.now() - let lastError = null + const startTime = performance.now(); + let lastError = null; for (let attempt = 1; attempt <= CONFIG.MAX_RETRY_ATTEMPTS; attempt++) { try { // Dynamic import for worker context - const dexieModule = await import("dexie") - const Dexie = dexieModule.default || dexieModule + const dexieModule = await import("dexie"); + const Dexie = dexieModule.default || dexieModule; // Create singleton instance - db = new Dexie(CONFIG.DB_NAME) + db = new Dexie(CONFIG.DB_NAME); // Open database - await db.open() + await db.open(); // Verify tables exist - const tables = db.tables.map(t => t.name) + const tables = db.tables.map((t) => t.name); if (tables.length === 0) { - throw new Error("No tables found in database") + throw new Error("No tables found in database"); } - dbInitialized = true - circuitBreakerFailures = 0 // Reset on success + dbInitialized = true; + circuitBreakerFailures = 0; // Reset on success - const duration = Math.round(performance.now() - startTime) + const duration = Math.round(performance.now() - startTime); log.success(`DB initialized in ${duration}ms (attempt ${attempt})`, { tables: tables.length, - }) - - return db + }); + return db; } catch (error) { - lastError = error + lastError = error; log.error(`DB init failed (attempt ${attempt}/${CONFIG.MAX_RETRY_ATTEMPTS})`, { error: error.message, - }) + }); // Clean up failed connection if (db) { try { - await db.close() + await db.close(); } catch (closeError) { // Ignore close errors } - db = null - dbInitialized = false + db = null; + dbInitialized = false; } // Last attempt - open circuit breaker if (attempt >= CONFIG.MAX_RETRY_ATTEMPTS) { - circuitBreakerFailures++ + circuitBreakerFailures++; if (circuitBreakerFailures >= 5) { - circuitBreakerOpen = true - log.error("Circuit breaker opened - DB permanently unavailable") + circuitBreakerOpen = true; + log.error("Circuit breaker opened - DB permanently unavailable"); } - throw new Error(`DB init failed after ${attempt} attempts: ${lastError.message}`) + throw new Error( + `DB init failed after ${attempt} attempts: ${lastError.message}` + ); } // Exponential backoff before retry - await new Promise(resolve => + await new Promise((resolve) => setTimeout(resolve, CONFIG.RETRY_DELAY_MS * Math.pow(2, attempt - 1)) - ) + ); } } - throw lastError - })() + throw lastError; + })(); try { - return await dbInitPromise + return await dbInitPromise; } finally { - dbInitPromise = null + dbInitPromise = null; } } // Server connectivity state -let serverOnline = true -let manualOffline = false -let csrfToken = null // CSRF token passed from main thread +let serverOnline = true; +let manualOffline = false; +let csrfToken = null; // CSRF token passed from main thread // Display mode: controlled by POS Settings "Show Variants as Items" (default: off) // true = variants shown directly, templates hidden // false = templates shown, variants hidden from browse (but still cached for barcode scan) -let showVariantsAsItems = false +let showVariantsAsItems = false; // Periodic stock sync state -let stockSyncInterval = null -let stockSyncEnabled = false -let stockSyncIntervalMs = 60000 // Default: 1 minute -let currentWarehouse = null -let trackedItemCodes = new Set() // Items to sync -let lastStockSyncTime = null -let stockSyncRunning = false +let stockSyncInterval = null; +let stockSyncEnabled = false; +let stockSyncIntervalMs = 60000; // Default: 1 minute +let currentWarehouse = null; +let trackedItemCodes = new Set(); // Items to sync +let lastStockSyncTime = null; +let stockSyncRunning = false; // ============================================================================ // PERFORMANCE UTILITIES @@ -194,18 +195,25 @@ let stockSyncRunning = false */ function recordMetric(operation, duration, isError = false) { if (!metrics.has(operation)) { - metrics.set(operation, { count: 0, totalTime: 0, errors: 0, avgTime: 0, minTime: Infinity, maxTime: 0 }) - } - - const metric = metrics.get(operation) - metric.count++ - metric.totalTime += duration - metric.avgTime = Math.round(metric.totalTime / metric.count) - metric.minTime = Math.min(metric.minTime, duration) - metric.maxTime = Math.max(metric.maxTime, duration) + metrics.set(operation, { + count: 0, + totalTime: 0, + errors: 0, + avgTime: 0, + minTime: Infinity, + maxTime: 0, + }); + } + + const metric = metrics.get(operation); + metric.count++; + metric.totalTime += duration; + metric.avgTime = Math.round(metric.totalTime / metric.count); + metric.minTime = Math.min(metric.minTime, duration); + metric.maxTime = Math.max(metric.maxTime, duration); if (isError) { - metric.errors++ + metric.errors++; } } @@ -216,22 +224,22 @@ function recordMetric(operation, duration, isError = false) { */ function extractBarcodes(item) { // Fast path: already normalized - if (Array.isArray(item.barcodes)) return item.barcodes + if (Array.isArray(item.barcodes)) return item.barcodes; // Single barcode - if (item.barcode) return [item.barcode] + if (item.barcode) return [item.barcode]; // item_barcode field (various formats) if (item.item_barcode) { if (Array.isArray(item.item_barcode)) { return item.item_barcode - .map(b => (typeof b === "object" ? b.barcode : b)) - .filter(Boolean) + .map((b) => (typeof b === "object" ? b.barcode : b)) + .filter(Boolean); } - return [item.item_barcode] + return [item.item_barcode]; } - return [] + return []; } /** @@ -241,11 +249,11 @@ function extractBarcodes(item) { * @returns {Array} Chunked arrays */ function chunkArray(array, size) { - const chunks = [] + const chunks = []; for (let i = 0; i < array.length; i += size) { - chunks.push(array.slice(i, i + size)) + chunks.push(array.slice(i, i + size)); } - return chunks + return chunks; } // ============================================================================ @@ -260,14 +268,14 @@ function chunkArray(array, size) { function cacheQueryResult(key, value) { // LRU eviction: remove oldest entry when full if (queryCache.size >= CONFIG.QUERY_CACHE_SIZE) { - const firstKey = queryCache.keys().next().value - queryCache.delete(firstKey) + const firstKey = queryCache.keys().next().value; + queryCache.delete(firstKey); } queryCache.set(key, { value, timestamp: Date.now(), - }) + }); } /** @@ -276,16 +284,16 @@ function cacheQueryResult(key, value) { * @returns {any|null} Cached value or null if expired/missing */ function getCachedQuery(key) { - const entry = queryCache.get(key) - if (!entry) return null + const entry = queryCache.get(key); + if (!entry) return null; // Check TTL if (Date.now() - entry.timestamp > CONFIG.QUERY_CACHE_TTL_MS) { - queryCache.delete(key) - return null + queryCache.delete(key); + return null; } - return entry.value + return entry.value; } /** @@ -294,13 +302,13 @@ function getCachedQuery(key) { */ function invalidateCache(prefix) { if (!prefix) { - queryCache.clear() - return + queryCache.clear(); + return; } for (const key of queryCache.keys()) { if (key.startsWith(prefix)) { - queryCache.delete(key) + queryCache.delete(key); } } } @@ -324,100 +332,100 @@ function getMetrics() { db: { initialized: dbInitialized, }, - } + }; } // Ping server to check connectivity async function pingServer() { try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 3000) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); const response = await fetch("/api/method/pos_next.api.ping", { method: "GET", signal: controller.signal, - }) + }); - clearTimeout(timeoutId) - serverOnline = response.ok - return serverOnline + clearTimeout(timeoutId); + serverOnline = response.ok; + return serverOnline; } catch (error) { - serverOnline = false - return false + serverOnline = false; + return false; } } // Check offline status function isOffline(browserOnline) { - if (manualOffline) return true - return !browserOnline || !serverOnline + if (manualOffline) return true; + return !browserOnline || !serverOnline; } // Get offline invoice count async function getOfflineInvoiceCount() { try { - const db = await initDB() + const db = await initDB(); // Check if invoice_queue table exists - const tableExists = db.tables.some(table => table.name === "invoice_queue") + const tableExists = db.tables.some((table) => table.name === "invoice_queue"); if (!tableExists) { - log.debug("invoice_queue table does not exist yet, returning 0") - return 0 + log.debug("invoice_queue table does not exist yet, returning 0"); + return 0; } const count = await db .table("invoice_queue") .filter((invoice) => invoice.synced === false && !invoice.superseded) - .count() - return count + .count(); + return count; } catch (error) { // Handle Dexie errors gracefully - if (error.name === 'NotFoundError' || error.name === 'DatabaseClosedError') { - log.debug("Invoice queue not accessible yet, returning 0") - return 0 + if (error.name === "NotFoundError" || error.name === "DatabaseClosedError") { + log.debug("Invoice queue not accessible yet, returning 0"); + return 0; } - log.error("Error getting offline invoice count", error) - return 0 + log.error("Error getting offline invoice count", error); + return 0; } } // Get offline invoices async function getOfflineInvoices() { try { - const db = await initDB() + const db = await initDB(); // Check if invoice_queue table exists - const tableExists = db.tables.some(table => table.name === "invoice_queue") + const tableExists = db.tables.some((table) => table.name === "invoice_queue"); if (!tableExists) { - log.debug("invoice_queue table does not exist yet, returning empty array") - return [] + log.debug("invoice_queue table does not exist yet, returning empty array"); + return []; } const invoices = await db .table("invoice_queue") .filter((invoice) => invoice.synced === false && !invoice.superseded) - .toArray() - return invoices + .toArray(); + return invoices; } catch (error) { - log.error("Error getting offline invoices", error) - return [] + log.error("Error getting offline invoices", error); + return []; } } // Save invoice to offline queue async function saveOfflineInvoice(invoiceData) { try { - const db = await initDB() + const db = await initDB(); if (!invoiceData.items || invoiceData.items.length === 0) { - throw new Error("Cannot save empty invoice") + throw new Error("Cannot save empty invoice"); } // Generate unique offline_id for deduplication - const offlineId = generateOfflineId() + const offlineId = generateOfflineId(); // Store offline_id in the invoice data for server-side tracking - invoiceData.offline_id = offlineId + invoiceData.offline_id = offlineId; const id = await db.table("invoice_queue").add({ offline_id: offlineId, @@ -425,49 +433,49 @@ async function saveOfflineInvoice(invoiceData) { timestamp: Date.now(), synced: false, retry_count: 0, - }) + }); // NOTE: We don't update local stock here because: // 1. The invoice hasn't been submitted to server yet // 2. When we sync, the server will handle stock reduction // 3. Updating stock locally causes NegativeStockError on sync - log.info(`Invoice saved to offline queue with offline_id: ${offlineId}`) - return { success: true, id, offline_id: offlineId } + log.info(`Invoice saved to offline queue with offline_id: ${offlineId}`); + return { success: true, id, offline_id: offlineId }; } catch (error) { - log.error("Error saving offline invoice", error) - throw error + log.error("Error saving offline invoice", error); + throw error; } } // Update local stock async function updateLocalStock(items) { try { - const db = await initDB() + const db = await initDB(); for (const item of items) { // Skip if no warehouse specified if (!item.warehouse || !item.item_code) { - continue + continue; } const currentStock = await db.table("stock").get({ item_code: item.item_code, warehouse: item.warehouse, - }) + }); - const qty = item.quantity || item.qty || 0 - const newQty = (currentStock?.qty || 0) - qty + const qty = item.quantity || item.qty || 0; + const newQty = (currentStock?.qty || 0) - qty; await db.table("stock").put({ item_code: item.item_code, warehouse: item.warehouse, qty: newQty, updated_at: Date.now(), - }) + }); } } catch (error) { - log.error("Error updating local stock", error) + log.error("Error updating local stock", error); } } @@ -477,9 +485,9 @@ async function updateLocalStock(items) { * @returns {boolean} True if item should be shown */ function shouldShowItem(item) { - if (item.disabled) return false - if (showVariantsAsItems) return !item.has_variants - return !item.variant_of + if (item.disabled) return false; + if (showVariantsAsItems) return !item.has_variants; + return !item.variant_of; } /** @@ -493,120 +501,126 @@ function shouldShowItem(item) { * @returns {Promise} Matching items */ async function searchCachedItems(searchTerm = "", limit = 50, offset = 0) { - const startTime = performance.now() + const startTime = performance.now(); // Check cache first (5-10x faster for repeated queries) - const cacheKey = `search:${searchTerm}:${limit}:${offset}` - const cached = getCachedQuery(cacheKey) + const cacheKey = `search:${searchTerm}:${limit}:${offset}`; + const cached = getCachedQuery(cacheKey); if (cached) { - log.debug("Cache hit for search", { searchTerm }) - return cached + log.debug("Cache hit for search", { searchTerm }); + return cached; } try { - const db = await initDB() + const db = await initDB(); // Empty search - return top N items sorted alphabetically // Exclude disabled and template items (templates are not shown in grid, variants are) if (!searchTerm || searchTerm.trim().length === 0) { - const results = await db.table("items") + const results = await db + .table("items") .orderBy("item_name") - .filter(item => shouldShowItem(item)) + .filter((item) => shouldShowItem(item)) .offset(offset) .limit(limit) - .toArray() - cacheQueryResult(cacheKey, results) - return results + .toArray(); + cacheQueryResult(cacheKey, results); + return results; } - const term = searchTerm.toLowerCase().trim() - const searchWords = term.split(/\s+/).filter(Boolean) + const term = searchTerm.toLowerCase().trim(); + const searchWords = term.split(/\s+/).filter(Boolean); // Optimize: Use indexes for single-word searches if (searchWords.length === 1) { // Try barcode index first (most specific) - const barcodeResults = await db.table("items") + const barcodeResults = await db + .table("items") .where("barcodes") .equals(term) - .filter(item => !item.disabled) + .filter((item) => !item.disabled) .limit(limit) - .toArray() + .toArray(); if (barcodeResults.length > 0) { - cacheQueryResult(cacheKey, barcodeResults) - recordMetric('searchCachedItems', performance.now() - startTime, false) - return barcodeResults + cacheQueryResult(cacheKey, barcodeResults); + recordMetric("searchCachedItems", performance.now() - startTime, false); + return barcodeResults; } // Try item_code index (second most specific) - const codeResults = await db.table("items") + const codeResults = await db + .table("items") .where("item_code") .startsWithIgnoreCase(term) - .filter(item => !item.disabled) + .filter((item) => !item.disabled) .limit(limit) - .toArray() + .toArray(); if (codeResults.length > 0) { - cacheQueryResult(cacheKey, codeResults) - recordMetric('searchCachedItems', performance.now() - startTime, false) - return codeResults + cacheQueryResult(cacheKey, codeResults); + recordMetric("searchCachedItems", performance.now() - startTime, false); + return codeResults; } // Try item_name index - const nameResults = await db.table("items") + const nameResults = await db + .table("items") .where("item_name") .startsWithIgnoreCase(term) - .filter(item => !item.disabled) + .filter((item) => !item.disabled) .limit(limit) - .toArray() + .toArray(); if (nameResults.length > 0) { - cacheQueryResult(cacheKey, nameResults) - recordMetric('searchCachedItems', performance.now() - startTime, false) - return nameResults + cacheQueryResult(cacheKey, nameResults); + recordMetric("searchCachedItems", performance.now() - startTime, false); + return nameResults; } } // Fallback: Multi-word or complex search // Fetch larger sample and filter in memory (trade memory for speed) - const allItems = await db.table("items") - .filter(item => !item.disabled) + const allItems = await db + .table("items") + .filter((item) => !item.disabled) .limit(limit * 10) - .toArray() + .toArray(); const results = allItems - .map(item => { - const searchable = `${item.item_code || ""} ${item.item_name || ""} ${item.description || ""}`.toLowerCase() + .map((item) => { + const searchable = `${item.item_code || ""} ${item.item_name || ""} ${ + item.description || "" + }`.toLowerCase(); // All words must match - if (!searchWords.every(word => searchable.includes(word))) { - return null + if (!searchWords.every((word) => searchable.includes(word))) { + return null; } // Score for relevance ranking - let score = 100 - if (item.item_name?.toLowerCase() === term) score = 1000 - else if (item.item_code?.toLowerCase() === term) score = 900 - else if (item.item_name?.toLowerCase().startsWith(term)) score = 500 - else if (item.item_code?.toLowerCase().startsWith(term)) score = 400 + let score = 100; + if (item.item_name?.toLowerCase() === term) score = 1000; + else if (item.item_code?.toLowerCase() === term) score = 900; + else if (item.item_name?.toLowerCase().startsWith(term)) score = 500; + else if (item.item_code?.toLowerCase().startsWith(term)) score = 400; - return { item, score } + return { item, score }; }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, limit) - .map(({ item }) => item) - - const duration = Math.round(performance.now() - startTime) - recordMetric('searchCachedItems', duration, false) + .map(({ item }) => item); - cacheQueryResult(cacheKey, results) - return results + const duration = Math.round(performance.now() - startTime); + recordMetric("searchCachedItems", duration, false); + cacheQueryResult(cacheKey, results); + return results; } catch (error) { - recordMetric('searchCachedItems', performance.now() - startTime, true) - log.error("Error searching cached items", error) - return [] + recordMetric("searchCachedItems", performance.now() - startTime, true); + log.error("Error searching cached items", error); + return []; } } @@ -620,51 +634,53 @@ async function searchCachedItems(searchTerm = "", limit = 50, offset = 0) { * @returns {Promise} Matching items sorted by item_name */ async function searchCachedItemsByGroup(itemGroups = [], limit = 50, offset = 0) { - const startTime = performance.now() + const startTime = performance.now(); if (!itemGroups || itemGroups.length === 0) { - return searchCachedItems("", limit, offset) + return searchCachedItems("", limit, offset); } - const cacheKey = `group:${itemGroups.sort().join(",")}:${limit}:${offset}` - const cached = getCachedQuery(cacheKey) + const cacheKey = `group:${itemGroups.sort().join(",")}:${limit}:${offset}`; + const cached = getCachedQuery(cacheKey); if (cached) { - log.debug("Cache hit for group search", { itemGroups }) - return cached + log.debug("Cache hit for group search", { itemGroups }); + return cached; } try { - const db = await initDB() + const db = await initDB(); // Use item_group index for efficient lookup // Exclude template items (has_variants is set) — only show variants + regular items - let allResults = [] + let allResults = []; for (const group of itemGroups) { - const items = await db.table("items") + const items = await db + .table("items") .where("item_group") .equals(group) - .filter(item => shouldShowItem(item)) - .toArray() - allResults.push(...items) + .filter((item) => shouldShowItem(item)) + .toArray(); + allResults.push(...items); } // Sort by item_name for consistent ordering - allResults.sort((a, b) => (a.item_name || "").localeCompare(b.item_name || "")) + allResults.sort((a, b) => (a.item_name || "").localeCompare(b.item_name || "")); // Apply pagination - const paginated = allResults.slice(offset, offset + limit) + const paginated = allResults.slice(offset, offset + limit); - const duration = Math.round(performance.now() - startTime) - recordMetric('searchCachedItemsByGroup', duration, false) - log.debug(`Group search: ${paginated.length} items from ${itemGroups.length} groups in ${duration}ms`) - - cacheQueryResult(cacheKey, paginated) - return paginated + const duration = Math.round(performance.now() - startTime); + recordMetric("searchCachedItemsByGroup", duration, false); + log.debug( + `Group search: ${paginated.length} items from ${itemGroups.length} groups in ${duration}ms` + ); + cacheQueryResult(cacheKey, paginated); + return paginated; } catch (error) { - recordMetric('searchCachedItemsByGroup', performance.now() - startTime, true) - log.error("Error searching cached items by group", error) - return [] + recordMetric("searchCachedItemsByGroup", performance.now() - startTime, true); + log.error("Error searching cached items by group", error); + return []; } } @@ -678,45 +694,46 @@ async function searchCachedItemsByGroup(itemGroups = [], limit = 50, offset = 0) * @returns {Promise} Matching items */ async function searchCachedItemsByBrand(brand, limit = 50, offset = 0) { - const startTime = performance.now() + const startTime = performance.now(); if (!brand) { // No brand filter → fall back to generic search - return searchCachedItems("", limit, offset) + return searchCachedItems("", limit, offset); } - const cacheKey = `brand:${brand}:${limit}:${offset}` - const cached = getCachedQuery(cacheKey) + const cacheKey = `brand:${brand}:${limit}:${offset}`; + const cached = getCachedQuery(cacheKey); if (cached) { - log.debug("Cache hit for brand search", { brand }) - return cached + log.debug("Cache hit for brand search", { brand }); + return cached; } try { - const db = await initDB() + const db = await initDB(); // Use brand index for lookup, then sort and paginate in memory - let results = await db.table("items") + let results = await db + .table("items") .where("brand") .equals(brand) - .filter(item => shouldShowItem(item)) - .toArray() + .filter((item) => shouldShowItem(item)) + .toArray(); // Stable ordering by item_name for consistent UI - results.sort((a, b) => (a.item_name || "").localeCompare(b.item_name || "")) + results.sort((a, b) => (a.item_name || "").localeCompare(b.item_name || "")); - const paginated = results.slice(offset, offset + limit) + const paginated = results.slice(offset, offset + limit); - const duration = Math.round(performance.now() - startTime) - recordMetric('searchCachedItemsByBrand', duration, false) - log.debug(`Brand search: ${paginated.length} items for "${brand}" in ${duration}ms`) + const duration = Math.round(performance.now() - startTime); + recordMetric("searchCachedItemsByBrand", duration, false); + log.debug(`Brand search: ${paginated.length} items for "${brand}" in ${duration}ms`); - cacheQueryResult(cacheKey, paginated) - return paginated + cacheQueryResult(cacheKey, paginated); + return paginated; } catch (error) { - recordMetric('searchCachedItemsByBrand', performance.now() - startTime, true) - log.error("Error searching cached items by brand", error) - return [] + recordMetric("searchCachedItemsByBrand", performance.now() - startTime, true); + log.error("Error searching cached items by brand", error); + return []; } } @@ -729,57 +746,61 @@ async function searchCachedItemsByBrand(brand, limit = 50, offset = 0) { */ async function countCachedItemsByGroup(itemGroups = []) { try { - const db = await initDB() + const db = await initDB(); if (!itemGroups || itemGroups.length === 0) { - return await db.table("items").filter(item => shouldShowItem(item)).count() + return await db + .table("items") + .filter((item) => shouldShowItem(item)) + .count(); } - let total = 0 + let total = 0; for (const group of itemGroups) { - total += await db.table("items") + total += await db + .table("items") .where("item_group") .equals(group) - .filter(item => shouldShowItem(item)) - .count() + .filter((item) => shouldShowItem(item)) + .count(); } - return total + return total; } catch (error) { - log.error("Error counting cached items by group", error) - return 0 + log.error("Error counting cached items by group", error); + return 0; } } // Search cached customers async function searchCachedCustomers(searchTerm = "", limit = 20) { try { - const db = await initDB() - const term = searchTerm.toLowerCase() + const db = await initDB(); + const term = searchTerm.toLowerCase(); if (!term) { return limit > 0 ? await db.table("customers").limit(limit).toArray() - : await db.table("customers").toArray() + : await db.table("customers").toArray(); } // Get all customers and filter in memory for 'includes' behavior // This is fast because IndexedDB is already in-memory for small datasets - const allCustomers = await db.table("customers").toArray() + const allCustomers = await db.table("customers").toArray(); const results = allCustomers .filter((cust) => { - const name = (cust.customer_name || "").toLowerCase() - const mobile = (cust.mobile_no || "").toLowerCase() - const id = (cust.name || "").toLowerCase() + const name = (cust.customer_name || "").toLowerCase(); + const mobile = (cust.mobile_no || "").toLowerCase(); + const id = (cust.name || "").toLowerCase(); - return name.includes(term) || mobile.includes(term) || id.includes(term) + return name.includes(term) || mobile.includes(term) || id.includes(term); }) - .slice(0, limit || allCustomers.length) + .slice(0, limit || allCustomers.length); - return results + return results; } catch (error) { - log.error("Error searching cached customers", error) - return [] + log.error("Error searching cached customers", error); + return []; } } @@ -790,15 +811,15 @@ async function searchCachedCustomers(searchTerm = "", limit = 20) { * @returns {Promise} Success status */ async function deleteCustomers(customerNames) { - if (!customerNames || customerNames.length === 0) return true + if (!customerNames || customerNames.length === 0) return true; try { - const db = await initDB() - await db.table("customers").bulkDelete(customerNames) - log.success(`Deleted ${customerNames.length} customers from cache`) - return true + const db = await initDB(); + await db.table("customers").bulkDelete(customerNames); + log.success(`Deleted ${customerNames.length} customers from cache`); + return true; } catch (error) { - log.error("Error deleting customers from cache", error) - throw error + log.error("Error deleting customers from cache", error); + throw error; } } @@ -811,119 +832,118 @@ async function deleteCustomers(customerNames) { */ async function cacheItemsFromServer(items, batchSize) { if (!items || items.length === 0) { - return { success: true, count: 0, duration: 0 } + return { success: true, count: 0, duration: 0 }; } - const startTime = performance.now() + const startTime = performance.now(); try { - const db = await initDB() + const db = await initDB(); // Split into batches to prevent memory spikes with large datasets // Use caller-provided batchSize if given, otherwise default - const effectiveBatchSize = batchSize || CONFIG.BATCH_SIZE - const batches = chunkArray(items, effectiveBatchSize) - let totalProcessed = 0 + const effectiveBatchSize = batchSize || CONFIG.BATCH_SIZE; + const batches = chunkArray(items, effectiveBatchSize); + let totalProcessed = 0; // Process all batches in single transaction (ACID + 10x performance boost) - await db.transaction('rw', 'items', 'item_prices', 'settings', async () => { + await db.transaction("rw", "items", "item_prices", "settings", async () => { for (const batch of batches) { // Normalize data using helper (zero-copy where possible) - const processedItems = batch.map(item => ({ + const processedItems = batch.map((item) => ({ ...item, barcodes: extractBarcodes(item), - })) + })); // Bulk insert items (single DB round trip per batch) - await db.table("items").bulkPut(processedItems) + await db.table("items").bulkPut(processedItems); // Extract and bulk insert prices // CRITICAL: Compound primary key requires valid price_list AND item_code const prices = batch - .filter(item => { + .filter((item) => { // Must have item_code (mandatory) - if (!item.item_code) return false + if (!item.item_code) return false; // Must have some price data - return item.rate || item.price_list_rate + return item.rate || item.price_list_rate; }) - .map(item => { + .map((item) => { // Provide default price_list if missing (prevents key constraint violations) - const priceList = item.selling_price_list || item.price_list || "Standard" + const priceList = item.selling_price_list || item.price_list || "Standard"; return { price_list: priceList, item_code: item.item_code, rate: item.rate || item.price_list_rate || 0, timestamp: Date.now(), - } - }) + }; + }); if (prices.length > 0) { try { - await db.table("item_prices").bulkPut(prices) + await db.table("item_prices").bulkPut(prices); } catch (priceError) { // Log detailed error for debugging log.error("Failed to cache item prices", { error: priceError.message, batchSize: prices.length, samplePrices: prices.slice(0, 3), // Log first 3 for debugging - }) + }); // Attempt individual inserts to isolate problematic records - let successCount = 0 + let successCount = 0; for (const price of prices) { try { - await db.table("item_prices").put(price) - successCount++ + await db.table("item_prices").put(price); + successCount++; } catch (individualError) { log.warn("Skipping invalid price record", { item_code: price.item_code, price_list: price.price_list, - error: individualError.message - }) + error: individualError.message, + }); } } if (successCount > 0) { - log.info(`Recovered ${successCount}/${prices.length} price records`) + log.info(`Recovered ${successCount}/${prices.length} price records`); } } } - totalProcessed += batch.length + totalProcessed += batch.length; } // Update sync metadata (inside transaction) await db.table("settings").put({ key: "items_last_sync", value: Date.now(), - }) - }) + }); + }); - const duration = Math.round(performance.now() - startTime) - recordMetric('cacheItems', duration, false) + const duration = Math.round(performance.now() - startTime); + recordMetric("cacheItems", duration, false); // Invalidate query cache - invalidateCache('search:') - invalidateCache('items:') + invalidateCache("search:"); + invalidateCache("items:"); log.success(`Cached ${totalProcessed} items in ${duration}ms`, { batches: batches.length, - throughput: Math.round(totalProcessed / (duration / 1000)) + ' items/s', - }) - - return { success: true, count: totalProcessed, duration } + throughput: Math.round(totalProcessed / (duration / 1000)) + " items/s", + }); + return { success: true, count: totalProcessed, duration }; } catch (error) { - const duration = Math.round(performance.now() - startTime) - recordMetric('cacheItems', duration, true) + const duration = Math.round(performance.now() - startTime); + recordMetric("cacheItems", duration, true); log.error("Error caching items", { error: error.message, count: items.length, - }) + }); - throw error + throw error; } } @@ -934,43 +954,42 @@ async function cacheItemsFromServer(items, batchSize) { */ async function cacheCustomersFromServer(customers) { if (!customers || customers.length === 0) { - return { success: true, count: 0, duration: 0 } + return { success: true, count: 0, duration: 0 }; } - const startTime = performance.now() + const startTime = performance.now(); try { - const db = await initDB() + const db = await initDB(); // Use transaction for consistency - await db.transaction('rw', 'customers', 'settings', async () => { + await db.transaction("rw", "customers", "settings", async () => { // Batch insert in chunks - const batches = chunkArray(customers, CONFIG.BATCH_SIZE) + const batches = chunkArray(customers, CONFIG.BATCH_SIZE); for (const batch of batches) { - await db.table("customers").bulkPut(batch) + await db.table("customers").bulkPut(batch); } // Update metadata await db.table("settings").put({ key: "customers_last_sync", value: Date.now(), - }) - }) + }); + }); - const duration = Math.round(performance.now() - startTime) - recordMetric('cacheCustomers', duration, false) + const duration = Math.round(performance.now() - startTime); + recordMetric("cacheCustomers", duration, false); // Invalidate cache - invalidateCache('customers:') - - log.success(`Cached ${customers.length} customers in ${duration}ms`) + invalidateCache("customers:"); - return { success: true, count: customers.length, duration } + log.success(`Cached ${customers.length} customers in ${duration}ms`); + return { success: true, count: customers.length, duration }; } catch (error) { - recordMetric('cacheCustomers', performance.now() - startTime, true) - log.error("Error caching customers", error) - throw error + recordMetric("cacheCustomers", performance.now() - startTime, true); + log.error("Error caching customers", error); + throw error; } } @@ -980,23 +999,22 @@ async function cacheCustomersFromServer(customers) { */ async function clearItemsCache() { try { - const db = await initDB() + const db = await initDB(); - await db.transaction('rw', 'items', 'item_prices', 'settings', async () => { - await db.table("items").clear() - await db.table("item_prices").clear() - await db.table("settings").put({ key: "items_last_sync", value: null }) - }) + await db.transaction("rw", "items", "item_prices", "settings", async () => { + await db.table("items").clear(); + await db.table("item_prices").clear(); + await db.table("settings").put({ key: "items_last_sync", value: null }); + }); - invalidateCache('items') - invalidateCache('search') - - log.info("Items cache cleared") - return { success: true } + invalidateCache("items"); + invalidateCache("search"); + log.info("Items cache cleared"); + return { success: true }; } catch (error) { - log.error("Error clearing items cache", error) - throw error + log.error("Error clearing items cache", error); + throw error; } } @@ -1006,21 +1024,20 @@ async function clearItemsCache() { */ async function clearCustomersCache() { try { - const db = await initDB() - - await db.transaction('rw', 'customers', 'settings', async () => { - await db.table("customers").clear() - await db.table("settings").put({ key: "customers_last_sync", value: null }) - }) + const db = await initDB(); - invalidateCache('customers') + await db.transaction("rw", "customers", "settings", async () => { + await db.table("customers").clear(); + await db.table("settings").put({ key: "customers_last_sync", value: null }); + }); - log.info("Customers cache cleared") - return { success: true } + invalidateCache("customers"); + log.info("Customers cache cleared"); + return { success: true }; } catch (error) { - log.error("Error clearing customers cache", error) - throw error + log.error("Error clearing customers cache", error); + throw error; } } @@ -1033,111 +1050,112 @@ async function clearCustomersCache() { */ async function removeItemsByGroups(itemGroups) { if (!itemGroups || itemGroups.length === 0) { - return { success: true, removed: 0, pricesRemoved: 0 } + return { success: true, removed: 0, pricesRemoved: 0 }; } - const startTime = performance.now() + const startTime = performance.now(); try { - const db = await initDB() - let totalRemoved = 0 - let totalPricesRemoved = 0 + const db = await initDB(); + let totalRemoved = 0; + let totalPricesRemoved = 0; // Use transaction for ACID guarantees (all-or-nothing) - await db.transaction('rw', 'items', 'item_prices', async () => { + await db.transaction("rw", "items", "item_prices", async () => { // Collect item codes for price cleanup (memory efficient) - const itemCodesToRemove = [] + const itemCodesToRemove = []; // Process groups efficiently using indexes for (const group of itemGroups) { // Use index for O(log n) lookup instead of O(n) table scan - const items = await db.table("items") + const items = await db + .table("items") .where("item_group") .equals(group) - .primaryKeys() // Fetch only keys (not full objects - saves memory) + .primaryKeys(); // Fetch only keys (not full objects - saves memory) - itemCodesToRemove.push(...items) + itemCodesToRemove.push(...items); // Bulk delete by index (fastest method available) - const deleted = await db.table("items") - .where("item_group") - .equals(group) - .delete() + const deleted = await db.table("items").where("item_group").equals(group).delete(); - totalRemoved += deleted + totalRemoved += deleted; } // Batch delete associated prices (if any items were removed) if (itemCodesToRemove.length > 0) { // Split into chunks to prevent query size limits - const chunks = chunkArray(itemCodesToRemove, 500) + const chunks = chunkArray(itemCodesToRemove, 500); for (const chunk of chunks) { - const pricesDeleted = await db.table("item_prices") + const pricesDeleted = await db + .table("item_prices") .where("item_code") .anyOf(chunk) - .delete() + .delete(); - totalPricesRemoved += pricesDeleted + totalPricesRemoved += pricesDeleted; } } - }) + }); - const duration = Math.round(performance.now() - startTime) - recordMetric('removeItemsByGroups', duration, false) + const duration = Math.round(performance.now() - startTime); + recordMetric("removeItemsByGroups", duration, false); // Invalidate cache - invalidateCache('items') - invalidateCache('search') + invalidateCache("items"); + invalidateCache("search"); - log.success(`Removed ${totalRemoved} items, ${totalPricesRemoved} prices in ${duration}ms`, { - groups: itemGroups.length, - }) + log.success( + `Removed ${totalRemoved} items, ${totalPricesRemoved} prices in ${duration}ms`, + { + groups: itemGroups.length, + } + ); return { success: true, removed: totalRemoved, pricesRemoved: totalPricesRemoved, duration, - } - + }; } catch (error) { - recordMetric('removeItemsByGroups', performance.now() - startTime, true) + recordMetric("removeItemsByGroups", performance.now() - startTime, true); log.error("Error removing items by groups", { error: error.message, groups: itemGroups, - }) - throw error + }); + throw error; } } // Cache payment methods from server async function cachePaymentMethodsFromServer(paymentMethods) { try { - const db = await initDB() - await db.table("payment_methods").bulkPut(paymentMethods) + const db = await initDB(); + await db.table("payment_methods").bulkPut(paymentMethods); // Update settings await db.table("settings").put({ key: "payment_methods_last_sync", value: Date.now(), - }) + }); - return { success: true, count: paymentMethods.length } + return { success: true, count: paymentMethods.length }; } catch (error) { - log.error("Error caching payment methods", error) - throw error + log.error("Error caching payment methods", error); + throw error; } } // Get cached payment methods for a POS profile async function getCachedPaymentMethods(posProfile) { try { - const db = await initDB() + const db = await initDB(); if (!posProfile) { // Return all payment methods if no profile specified - return await db.table("payment_methods").toArray() + return await db.table("payment_methods").toArray(); } // Get payment methods for specific profile @@ -1145,12 +1163,12 @@ async function getCachedPaymentMethods(posProfile) { .table("payment_methods") .where("pos_profile") .equals(posProfile) - .toArray() + .toArray(); - return methods + return methods; } catch (error) { - log.error("Error getting cached payment methods", error) - return [] + log.error("Error getting cached payment methods", error); + return []; } } @@ -1161,38 +1179,34 @@ async function getCachedPaymentMethods(posProfile) { // Cache sales persons for offline use async function cacheSalesPersons(salesPersons) { try { - const db = await initDB() - await db.table("sales_persons").bulkPut(salesPersons) + const db = await initDB(); + await db.table("sales_persons").bulkPut(salesPersons); await db.table("settings").put({ key: "sales_persons_last_sync", value: Date.now(), - }) + }); - return { success: true, count: salesPersons.length } + return { success: true, count: salesPersons.length }; } catch (error) { - log.error("Error caching sales persons", error) - throw error + log.error("Error caching sales persons", error); + throw error; } } // Get cached sales persons for a POS profile async function getCachedSalesPersons(posProfile) { try { - const db = await initDB() + const db = await initDB(); if (!posProfile) { - return await db.table("sales_persons").toArray() + return await db.table("sales_persons").toArray(); } - return await db - .table("sales_persons") - .where("pos_profile") - .equals(posProfile) - .toArray() + return await db.table("sales_persons").where("pos_profile").equals(posProfile).toArray(); } catch (error) { - log.error("Error getting cached sales persons", error) - return [] + log.error("Error getting cached sales persons", error); + return []; } } @@ -1211,37 +1225,37 @@ async function getCachedSalesPersons(posProfile) { async function cacheOffers(offers, posProfile) { try { if (!Array.isArray(offers) || !posProfile) { - return { success: false, count: 0 } + return { success: false, count: 0 }; } - const db = await initDB() + const db = await initDB(); // Add pos_profile to each offer for filtering - const offersWithProfile = offers.map(offer => ({ + const offersWithProfile = offers.map((offer) => ({ ...offer, pos_profile: posProfile, _cached_at: Date.now(), - })) + })); // Clear existing offers for this profile and insert new ones - await db.transaction('rw', db.table('offers'), async () => { - await db.table('offers').where('pos_profile').equals(posProfile).delete() + await db.transaction("rw", db.table("offers"), async () => { + await db.table("offers").where("pos_profile").equals(posProfile).delete(); if (offersWithProfile.length > 0) { - await db.table('offers').bulkPut(offersWithProfile) + await db.table("offers").bulkPut(offersWithProfile); } - }) + }); // Update settings with last sync timestamp - await db.table('settings').put({ + await db.table("settings").put({ key: `offers_last_sync_${posProfile}`, value: Date.now(), - }) + }); - log.success(`Cached ${offers.length} offers for profile ${posProfile}`) - return { success: true, count: offers.length } + log.success(`Cached ${offers.length} offers for profile ${posProfile}`); + return { success: true, count: offers.length }; } catch (error) { - log.error('Error caching offers', error) - return { success: false, count: 0, error: error.message } + log.error("Error caching offers", error); + return { success: false, count: 0, error: error.message }; } } @@ -1255,30 +1269,30 @@ async function cacheOffers(offers, posProfile) { async function getCachedOffers(posProfile) { try { if (!posProfile) { - return [] + return []; } - const db = await initDB() - const today = new Date().toISOString().split('T')[0] + const db = await initDB(); + const today = new Date().toISOString().split("T")[0]; // Get offers for specific profile const allOffers = await db - .table('offers') - .where('pos_profile') + .table("offers") + .where("pos_profile") .equals(posProfile) - .toArray() + .toArray(); // Filter out expired offers (keep offers without expiry or with future expiry) - const validOffers = allOffers.filter(offer => { - if (!offer.valid_upto) return true // No expiry - return offer.valid_upto >= today - }) + const validOffers = allOffers.filter((offer) => { + if (!offer.valid_upto) return true; // No expiry + return offer.valid_upto >= today; + }); - log.info(`Retrieved ${validOffers.length} cached offers for profile ${posProfile}`) - return validOffers + log.info(`Retrieved ${validOffers.length} cached offers for profile ${posProfile}`); + return validOffers; } catch (error) { - log.error('Error getting cached offers', error) - return [] + log.error("Error getting cached offers", error); + return []; } } @@ -1288,36 +1302,36 @@ async function getCachedOffers(posProfile) { */ async function clearOffersCache(posProfile = null) { try { - const db = await initDB() + const db = await initDB(); if (posProfile) { - await db.table('offers').where('pos_profile').equals(posProfile).delete() + await db.table("offers").where("pos_profile").equals(posProfile).delete(); } else { - await db.table('offers').clear() + await db.table("offers").clear(); } - return { success: true } + return { success: true }; } catch (error) { - log.error('Error clearing offers cache', error) - return { success: false, error: error.message } + log.error("Error clearing offers cache", error); + return { success: false, error: error.message }; } } // Check if cache is ready async function isCacheReady() { try { - const db = await initDB() - const itemCount = await db.table("items").count() - return itemCount > 0 + const db = await initDB(); + const itemCount = await db.table("items").count(); + return itemCount > 0; } catch (error) { - return false + return false; } } // Get cache stats async function getCacheStats() { try { - const db = await initDB() + const db = await initDB(); const [totalCount, hiddenCount, customerCount, queuedInvoices, lastSyncSetting] = await Promise.all([ @@ -1326,14 +1340,20 @@ async function getCacheStats() { // showVariantsAsItems=true: hide templates (has_variants) // showVariantsAsItems=false: hide variants (variant_of) showVariantsAsItems - ? db.table("items").filter(item => !!item.has_variants).count() - : db.table("items").filter(item => !!item.variant_of).count(), + ? db + .table("items") + .filter((item) => !!item.has_variants) + .count() + : db + .table("items") + .filter((item) => !!item.variant_of) + .count(), db.table("customers").count(), getOfflineInvoiceCount(), db.table("settings").get("items_last_sync"), - ]) + ]); // Exclude hidden items from display count - const itemCount = totalCount - hiddenCount + const itemCount = totalCount - hiddenCount; return { items: itemCount, @@ -1341,28 +1361,28 @@ async function getCacheStats() { queuedInvoices, cacheReady: itemCount > 0, lastSync: lastSyncSetting?.value || null, - } + }; } catch (error) { - log.error("Error getting cache stats", error) + log.error("Error getting cache stats", error); return { items: 0, customers: 0, queuedInvoices: 0, cacheReady: false, lastSync: null, - } + }; } } // Delete offline invoice async function deleteOfflineInvoice(id) { try { - const db = await initDB() - await db.table("invoice_queue").delete(id) - return { success: true } + const db = await initDB(); + await db.table("invoice_queue").delete(id); + return { success: true }; } catch (error) { - log.error("Error deleting offline invoice", error) - throw error + log.error("Error deleting offline invoice", error); + throw error; } } @@ -1370,71 +1390,71 @@ async function deleteOfflineInvoice(id) { // for audit but is excluded from sync and from the pending count. async function supersedeOfflineInvoice(id, replacedBy) { try { - const db = await initDB() + const db = await initDB(); await db.table("invoice_queue").update(id, { superseded: true, replaced_by: replacedBy || null, superseded_at: Date.now(), - }) - return { success: true } + }); + return { success: true }; } catch (error) { - log.error("Error superseding offline invoice", error) - throw error + log.error("Error superseding offline invoice", error); + throw error; } } // Mark a queued offline invoice as printed. Used by the print flow so // later edits can warn the cashier that a physical receipt is already out. async function markOfflineInvoicePrinted(offlineId) { - if (!offlineId) return { success: false } + if (!offlineId) return { success: false }; try { - const db = await initDB() - const row = await db.table("invoice_queue").where("offline_id").equals(offlineId).first() - if (!row) return { success: false, reason: "not found" } + const db = await initDB(); + const row = await db.table("invoice_queue").where("offline_id").equals(offlineId).first(); + if (!row) return { success: false, reason: "not found" }; await db.table("invoice_queue").update(row.id, { data: { ...row.data, was_printed: true, last_printed_at: Date.now() }, - }) - return { success: true } + }); + return { success: true }; } catch (error) { - log.error("Error marking offline invoice printed", error) - return { success: false, error: String(error) } + log.error("Error marking offline invoice printed", error); + return { success: false, error: String(error) }; } } // Update stock quantities in cached items async function updateStockQuantities(stockUpdates) { try { - const db = await initDB() + const db = await initDB(); if (!stockUpdates || stockUpdates.length === 0) { - return { success: true, updated: 0 } + return { success: true, updated: 0 }; } - let updatedCount = 0 + let updatedCount = 0; // Process each stock update for (const update of stockUpdates) { - const { item_code, warehouse, actual_qty, stock_qty } = update + const { item_code, warehouse, actual_qty, stock_qty } = update; if (!item_code) { - continue + continue; } // Get the cached item - const item = await db.table("items").get(item_code) + const item = await db.table("items").get(item_code); if (!item) { - continue + continue; } // Update stock quantities for this warehouse - item.actual_qty = actual_qty !== undefined ? actual_qty : stock_qty - item.stock_qty = stock_qty !== undefined ? stock_qty : actual_qty - item.warehouse = warehouse || item.warehouse + item.actual_qty = actual_qty !== undefined ? actual_qty : stock_qty; + item.stock_qty = stock_qty !== undefined ? stock_qty : actual_qty; + item.warehouse = warehouse || item.warehouse; // Save updated item back to cache - await db.table("items").put(item) - updatedCount++ + await db.table("items").put(item); + updatedCount++; } // Update the last sync timestamp so cache tooltip shows latest update @@ -1443,16 +1463,16 @@ async function updateStockQuantities(stockUpdates) { await db.table("settings").put({ key: "items_last_sync", value: Date.now(), - }) + }); } catch (error) { - log.error("Error updating items_last_sync timestamp", error) + log.error("Error updating items_last_sync timestamp", error); } } - return { success: true, updated: updatedCount } + return { success: true, updated: updatedCount }; } catch (error) { - log.error("Error updating stock quantities", error) - throw error + log.error("Error updating stock quantities", error); + throw error; } } @@ -1466,51 +1486,51 @@ async function updateStockQuantities(stockUpdates) { */ async function fetchStockFromServer() { if (!currentWarehouse || trackedItemCodes.size === 0) { - log.debug('Stock sync skipped: No warehouse or items tracked') - return [] + log.debug("Stock sync skipped: No warehouse or items tracked"); + return []; } try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 5000) // 5s timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout - const itemCodes = Array.from(trackedItemCodes) + const itemCodes = Array.from(trackedItemCodes); const headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } + "Content-Type": "application/json", + Accept: "application/json", + }; // Add CSRF token if available if (csrfToken) { - headers['X-Frappe-CSRF-Token'] = csrfToken + headers["X-Frappe-CSRF-Token"] = csrfToken; } - const response = await fetch('/api/method/pos_next.api.items.get_stock_quantities', { - method: 'POST', + const response = await fetch("/api/method/pos_next.api.items.get_stock_quantities", { + method: "POST", headers, body: JSON.stringify({ item_codes: JSON.stringify(itemCodes), - warehouse: currentWarehouse + warehouse: currentWarehouse, }), - signal: controller.signal - }) + signal: controller.signal, + }); - clearTimeout(timeoutId) + clearTimeout(timeoutId); if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const data = await response.json() - return data?.message || data || [] + const data = await response.json(); + return data?.message || data || []; } catch (error) { - if (error.name === 'AbortError') { - log.warn('Stock fetch timeout') + if (error.name === "AbortError") { + log.warn("Stock fetch timeout"); } else { - log.error('Error fetching stock from server', error) + log.error("Error fetching stock from server", error); } - return [] + return []; } } @@ -1519,57 +1539,59 @@ async function fetchStockFromServer() { */ async function performStockSync() { if (stockSyncRunning) { - log.debug('Stock sync already running, skipping') - return + log.debug("Stock sync already running, skipping"); + return; } if (!serverOnline || manualOffline) { - log.debug('Stock sync skipped: Server offline') - return + log.debug("Stock sync skipped: Server offline"); + return; } try { - stockSyncRunning = true - const startTime = Date.now() + stockSyncRunning = true; + const startTime = Date.now(); // Fetch fresh stock from server - const stockUpdates = await fetchStockFromServer() + const stockUpdates = await fetchStockFromServer(); if (stockUpdates.length > 0) { // Update IndexedDB cache - const result = await updateStockQuantities(stockUpdates) + const result = await updateStockQuantities(stockUpdates); - lastStockSyncTime = Date.now() - const duration = lastStockSyncTime - startTime + lastStockSyncTime = Date.now(); + const duration = lastStockSyncTime - startTime; - log.success(`Stock sync completed: ${result.updated}/${stockUpdates.length} items updated in ${duration}ms`) + log.success( + `Stock sync completed: ${result.updated}/${stockUpdates.length} items updated in ${duration}ms` + ); // Notify main thread about successful sync self.postMessage({ - type: 'STOCK_SYNC_COMPLETE', + type: "STOCK_SYNC_COMPLETE", payload: { updated: result.updated, total: stockUpdates.length, duration, - timestamp: lastStockSyncTime - } - }) + timestamp: lastStockSyncTime, + }, + }); } else { - log.debug('Stock sync: No updates received') + log.debug("Stock sync: No updates received"); } } catch (error) { - log.error('Stock sync failed', error) + log.error("Stock sync failed", error); // Notify main thread about sync failure self.postMessage({ - type: 'STOCK_SYNC_ERROR', + type: "STOCK_SYNC_ERROR", payload: { message: error.message, - timestamp: Date.now() - } - }) + timestamp: Date.now(), + }, + }); } finally { - stockSyncRunning = false + stockSyncRunning = false; } } @@ -1578,25 +1600,25 @@ async function performStockSync() { */ function startPeriodicStockSync() { if (stockSyncInterval) { - log.debug('Stock sync already running') - return + log.debug("Stock sync already running"); + return; } - stockSyncEnabled = true + stockSyncEnabled = true; // Perform initial sync immediately - performStockSync().catch(err => { - log.error('Initial stock sync failed', err) - }) + performStockSync().catch((err) => { + log.error("Initial stock sync failed", err); + }); // Set up periodic sync stockSyncInterval = setInterval(() => { - performStockSync().catch(err => { - log.error('Periodic stock sync failed', err) - }) - }, stockSyncIntervalMs) + performStockSync().catch((err) => { + log.error("Periodic stock sync failed", err); + }); + }, stockSyncIntervalMs); - log.success(`Periodic stock sync started (interval: ${stockSyncIntervalMs}ms)`) + log.success(`Periodic stock sync started (interval: ${stockSyncIntervalMs}ms)`); } /** @@ -1604,10 +1626,10 @@ function startPeriodicStockSync() { */ function stopPeriodicStockSync() { if (stockSyncInterval) { - clearInterval(stockSyncInterval) - stockSyncInterval = null - stockSyncEnabled = false - log.info('Periodic stock sync stopped') + clearInterval(stockSyncInterval); + stockSyncInterval = null; + stockSyncEnabled = false; + log.info("Periodic stock sync stopped"); } } @@ -1615,30 +1637,31 @@ function stopPeriodicStockSync() { * Configure periodic stock sync */ function configureStockSync({ warehouse, itemCodes, intervalMs }) { - let restartNeeded = false + let restartNeeded = false; if (warehouse !== undefined) { - currentWarehouse = warehouse - log.debug(`Stock sync warehouse set: ${warehouse}`) - restartNeeded = true + currentWarehouse = warehouse; + log.debug(`Stock sync warehouse set: ${warehouse}`); + restartNeeded = true; } if (itemCodes !== undefined && Array.isArray(itemCodes)) { - trackedItemCodes = new Set(itemCodes) - log.debug(`Stock sync tracking ${itemCodes.length} items`) - restartNeeded = true + trackedItemCodes = new Set(itemCodes); + log.debug(`Stock sync tracking ${itemCodes.length} items`); + restartNeeded = true; } - if (intervalMs !== undefined && intervalMs >= 10000) { // Min 10 seconds - stockSyncIntervalMs = intervalMs - log.debug(`Stock sync interval set: ${intervalMs}ms`) - restartNeeded = true + if (intervalMs !== undefined && intervalMs >= 10000) { + // Min 10 seconds + stockSyncIntervalMs = intervalMs; + log.debug(`Stock sync interval set: ${intervalMs}ms`); + restartNeeded = true; } // Restart sync if it's currently running and config changed if (restartNeeded && stockSyncEnabled) { - stopPeriodicStockSync() - startPeriodicStockSync() + stopPeriodicStockSync(); + startPeriodicStockSync(); } return { @@ -1646,8 +1669,8 @@ function configureStockSync({ warehouse, itemCodes, intervalMs }) { itemCount: trackedItemCodes.size, intervalMs: stockSyncIntervalMs, enabled: stockSyncEnabled, - lastSync: lastStockSyncTime - } + lastSync: lastStockSyncTime, + }; } /** @@ -1660,195 +1683,207 @@ function getStockSyncStatus() { itemCount: trackedItemCodes.size, intervalMs: stockSyncIntervalMs, lastSync: lastStockSyncTime, - running: stockSyncRunning - } + running: stockSyncRunning, + }; } // Message handler self.onmessage = async (event) => { - const { type, payload, id } = event.data + const { type, payload, id } = event.data; try { - let result + let result; switch (type) { case "SET_CSRF_TOKEN": - csrfToken = payload.token - result = { success: true } - break + csrfToken = payload.token; + result = { success: true }; + break; case "PING_SERVER": - result = await pingServer() - break + result = await pingServer(); + break; case "CHECK_OFFLINE": - result = isOffline(payload.browserOnline) - break + result = isOffline(payload.browserOnline); + break; case "GET_INVOICE_COUNT": - result = await getOfflineInvoiceCount() - break + result = await getOfflineInvoiceCount(); + break; case "GET_INVOICES": - result = await getOfflineInvoices() - break + result = await getOfflineInvoices(); + break; case "SAVE_INVOICE": - result = await saveOfflineInvoice(payload.invoiceData) - break + result = await saveOfflineInvoice(payload.invoiceData); + break; case "SEARCH_ITEMS": - result = await searchCachedItems(payload.searchTerm, payload.limit, payload.offset || 0) - break + result = await searchCachedItems( + payload.searchTerm, + payload.limit, + payload.offset || 0 + ); + break; case "SEARCH_ITEMS_BY_GROUP": - result = await searchCachedItemsByGroup(payload.itemGroups, payload.limit, payload.offset || 0) - break + result = await searchCachedItemsByGroup( + payload.itemGroups, + payload.limit, + payload.offset || 0 + ); + break; case "COUNT_ITEMS_BY_GROUP": - result = await countCachedItemsByGroup(payload.itemGroups) - break + result = await countCachedItemsByGroup(payload.itemGroups); + break; case "SEARCH_CUSTOMERS": - result = await searchCachedCustomers(payload.searchTerm, payload.limit) - break + result = await searchCachedCustomers(payload.searchTerm, payload.limit); + break; case "CACHE_ITEMS": - result = await cacheItemsFromServer(payload.items, payload.batchSize) - break + result = await cacheItemsFromServer(payload.items, payload.batchSize); + break; case "CACHE_CUSTOMERS": - result = await cacheCustomersFromServer(payload.customers) - break + result = await cacheCustomersFromServer(payload.customers); + break; case "SEARCH_ITEMS_BY_BRAND": - result = await searchCachedItemsByBrand(payload.brand, payload.limit, payload.offset || 0) - break + result = await searchCachedItemsByBrand( + payload.brand, + payload.limit, + payload.offset || 0 + ); + break; case "DELETE_CUSTOMERS": - result = await deleteCustomers(payload.customerNames) - break + result = await deleteCustomers(payload.customerNames); + break; case "CLEAR_ITEMS_CACHE": - result = await clearItemsCache() - break + result = await clearItemsCache(); + break; case "CLEAR_CUSTOMERS_CACHE": - result = await clearCustomersCache() - break + result = await clearCustomersCache(); + break; case "REMOVE_ITEMS_BY_GROUPS": - result = await removeItemsByGroups(payload.itemGroups) - break + result = await removeItemsByGroups(payload.itemGroups); + break; case "GET_METRICS": - result = getMetrics() - break + result = getMetrics(); + break; case "CACHE_PAYMENT_METHODS": - result = await cachePaymentMethodsFromServer(payload.paymentMethods) - break + result = await cachePaymentMethodsFromServer(payload.paymentMethods); + break; case "GET_PAYMENT_METHODS": - result = await getCachedPaymentMethods(payload.posProfile) - break + result = await getCachedPaymentMethods(payload.posProfile); + break; case "CACHE_SALES_PERSONS": - result = await cacheSalesPersons(payload.salesPersons) - break + result = await cacheSalesPersons(payload.salesPersons); + break; case "GET_SALES_PERSONS": - result = await getCachedSalesPersons(payload.posProfile) - break + result = await getCachedSalesPersons(payload.posProfile); + break; case "IS_CACHE_READY": - result = await isCacheReady() - break + result = await isCacheReady(); + break; case "GET_CACHE_STATS": - result = await getCacheStats() - break + result = await getCacheStats(); + break; case "DELETE_INVOICE": - result = await deleteOfflineInvoice(payload.id) - break + result = await deleteOfflineInvoice(payload.id); + break; case "MARK_INVOICE_PRINTED": - result = await markOfflineInvoicePrinted(payload.offline_id) - break + result = await markOfflineInvoicePrinted(payload.offline_id); + break; case "SUPERSEDE_INVOICE": - result = await supersedeOfflineInvoice(payload.id, payload.replaced_by) - break + result = await supersedeOfflineInvoice(payload.id, payload.replaced_by); + break; case "SET_SHOW_VARIANTS_AS_ITEMS": - showVariantsAsItems = Boolean(payload.value) + showVariantsAsItems = Boolean(payload.value); // Invalidate query cache since display filters changed - invalidateCache('search:') - invalidateCache('group:') - log.info(`Display mode updated: showVariantsAsItems=${showVariantsAsItems}`) - result = { success: true, showVariantsAsItems } - break + invalidateCache("search:"); + invalidateCache("group:"); + log.info(`Display mode updated: showVariantsAsItems=${showVariantsAsItems}`); + result = { success: true, showVariantsAsItems }; + break; case "SET_MANUAL_OFFLINE": - manualOffline = payload.value + manualOffline = payload.value; // Broadcast status change so UI updates immediately self.postMessage({ type: "SERVER_STATUS_CHANGE", payload: { serverOnline: serverOnline && !manualOffline, manualOffline }, - }) - result = { success: true, manualOffline } - break + }); + result = { success: true, manualOffline }; + break; case "UPDATE_STOCK_QUANTITIES": - result = await updateStockQuantities(payload.stockUpdates) - break + result = await updateStockQuantities(payload.stockUpdates); + break; case "START_STOCK_SYNC": - startPeriodicStockSync() - result = { success: true, status: getStockSyncStatus() } - break + startPeriodicStockSync(); + result = { success: true, status: getStockSyncStatus() }; + break; case "STOP_STOCK_SYNC": - stopPeriodicStockSync() - result = { success: true, status: getStockSyncStatus() } - break + stopPeriodicStockSync(); + result = { success: true, status: getStockSyncStatus() }; + break; case "CONFIGURE_STOCK_SYNC": - result = configureStockSync(payload) - break + result = configureStockSync(payload); + break; case "GET_STOCK_SYNC_STATUS": - result = getStockSyncStatus() - break + result = getStockSyncStatus(); + break; case "TRIGGER_STOCK_SYNC": // Manually trigger a sync cycle - await performStockSync() - result = { success: true, status: getStockSyncStatus() } - break + await performStockSync(); + result = { success: true, status: getStockSyncStatus() }; + break; // ===== OFFER CACHE OPERATIONS ===== case "CACHE_OFFERS": - result = await cacheOffers(payload.offers, payload.posProfile) - break + result = await cacheOffers(payload.offers, payload.posProfile); + break; case "GET_CACHED_OFFERS": - result = await getCachedOffers(payload.posProfile) - break + result = await getCachedOffers(payload.posProfile); + break; case "CLEAR_OFFERS_CACHE": - result = await clearOffersCache(payload.posProfile) - break + result = await clearOffersCache(payload.posProfile); + break; default: - throw new Error(`Unknown message type: ${type}`) + throw new Error(`Unknown message type: ${type}`); } self.postMessage({ type: "SUCCESS", id, payload: result, - }) + }); } catch (error) { self.postMessage({ type: "ERROR", @@ -1857,46 +1892,46 @@ self.onmessage = async (event) => { message: error.message, stack: error.stack, }, - }) + }); } -} +}; // Initialize worker async function initialize() { try { // Initialize database first - await initDB() - log.info("Database ready") + await initDB(); + log.info("Database ready"); // Start periodic server ping (every 30 seconds) setInterval(async () => { - const isOnline = await pingServer() + const isOnline = await pingServer(); self.postMessage({ type: "SERVER_STATUS_CHANGE", payload: { serverOnline: isOnline, manualOffline }, - }) - }, 30000) + }); + }, 30000); // Initial ping - const isOnline = await pingServer() + const isOnline = await pingServer(); self.postMessage({ type: "WORKER_READY", payload: { serverOnline: isOnline, manualOffline }, - }) + }); - log.success("Offline worker initialized and ready") + log.success("Offline worker initialized and ready"); } catch (error) { - log.error("Offline worker initialization failed", error) + log.error("Offline worker initialization failed", error); self.postMessage({ type: "ERROR", payload: { message: `Worker initialization failed: ${error.message}`, stack: error.stack, }, - }) + }); } } // Start initialization -initialize() +initialize(); diff --git a/POS/tailwind.config.js b/POS/tailwind.config.js index 3474a40af..081f191a3 100644 --- a/POS/tailwind.config.js +++ b/POS/tailwind.config.js @@ -1,4 +1,4 @@ -import frappeUIPreset from "frappe-ui/tailwind" +import frappeUIPreset from "frappe-ui/tailwind"; export default { presets: [frappeUIPreset], @@ -11,4 +11,4 @@ export default { extend: {}, }, plugins: [], -} +}; diff --git a/POS/vite.config.js b/POS/vite.config.js index 4762cb0b7..273b06b01 100644 --- a/POS/vite.config.js +++ b/POS/vite.config.js @@ -1,14 +1,14 @@ -import path from "node:path" -import { promises as fs } from "node:fs" -import vue from "@vitejs/plugin-vue" -import frappeui from "frappe-ui/vite" -import { defineConfig } from "vite" -import { VitePWA } from "vite-plugin-pwa" -import { viteStaticCopy } from "vite-plugin-static-copy" +import path from "node:path"; +import { promises as fs } from "node:fs"; +import vue from "@vitejs/plugin-vue"; +import frappeui from "frappe-ui/vite"; +import { defineConfig } from "vite"; +import { VitePWA } from "vite-plugin-pwa"; +import { viteStaticCopy } from "vite-plugin-static-copy"; // Get build version from environment or use timestamp -const buildVersion = process.env.POS_NEXT_BUILD_VERSION || Date.now().toString() -const enableSourceMap = process.env.POS_NEXT_ENABLE_SOURCEMAP === "true" +const buildVersion = process.env.POS_NEXT_BUILD_VERSION || Date.now().toString(); +const enableSourceMap = process.env.POS_NEXT_ENABLE_SOURCEMAP === "true"; /** * Vite plugin to write build version to version.json file @@ -19,8 +19,8 @@ function posNextBuildVersionPlugin(version) { name: "pos-next-build-version", apply: "build", async writeBundle() { - const versionFile = path.resolve(__dirname, "../pos_next/public/pos/version.json") - await fs.mkdir(path.dirname(versionFile), { recursive: true }) + const versionFile = path.resolve(__dirname, "../pos_next/public/pos/version.json"); + await fs.mkdir(path.dirname(versionFile), { recursive: true }); await fs.writeFile( versionFile, JSON.stringify( @@ -37,10 +37,10 @@ function posNextBuildVersionPlugin(version) { 2 ), "utf8" - ) - console.log(`\n✓ Build version written: ${version}`) + ); + console.log(`\n✓ Build version written: ${version}`); }, - } + }; } // https://vitejs.dev/config/ @@ -231,13 +231,7 @@ export default defineConfig({ __BUILD_VERSION__: JSON.stringify(buildVersion), }, optimizeDeps: { - include: [ - "feather-icons", - "showdown", - "highlight.js/lib/core", - "interactjs", - "qz-tray", - ], + include: ["feather-icons", "showdown", "highlight.js/lib/core", "interactjs", "qz-tray"], }, server: { allowedHosts: true, @@ -250,14 +244,13 @@ export default defineConfig({ secure: false, cookieDomainRewrite: "localhost", router: (req) => { - const site_name = req.headers.host.split(":")[0] + const site_name = req.headers.host.split(":")[0]; // Support both localhost and 127.0.0.1 - const isLocalhost = - site_name === "localhost" || site_name === "127.0.0.1" - const targetHost = isLocalhost ? "127.0.0.1" : site_name - return `http://${targetHost}:8000` + const isLocalhost = site_name === "localhost" || site_name === "127.0.0.1"; + const targetHost = isLocalhost ? "127.0.0.1" : site_name; + return `http://${targetHost}:8000`; }, }, }, }, -}) +}); diff --git a/pos_next/__init__.py b/pos_next/__init__.py index 9cb025935..899e3ed6a 100644 --- a/pos_next/__init__.py +++ b/pos_next/__init__.py @@ -2,17 +2,17 @@ from __future__ import unicode_literals try: - import frappe + import frappe except ModuleNotFoundError: # pragma: no cover - frappe may not be installed during setup - frappe = None + frappe = None __version__ = "1.16.0" def console(*data): - """Publish data to browser console for debugging""" - if frappe: - frappe.publish_realtime("toconsole", data, user=frappe.session.user) + """Publish data to browser console for debugging""" + if frappe: + frappe.publish_realtime("toconsole", data, user=frappe.session.user) # Patch get_other_conditions to exclude pos_only pricing rules from non-POS documents. @@ -20,38 +20,45 @@ def console(*data): # only works for @frappe.whitelist() HTTP endpoints, override_doctype_class only for DocType # classes). This is the standard Python module init approach — runs once at import. try: - from erpnext.accounts.doctype.pricing_rule import utils as pr_utils - from pos_next.overrides.pricing_rule import patch_get_other_conditions - patch_get_other_conditions(pr_utils) + from erpnext.accounts.doctype.pricing_rule import utils as pr_utils + + from pos_next.overrides.pricing_rule import patch_get_other_conditions + + patch_get_other_conditions(pr_utils) except Exception: - pass + pass # Frappe/ERPNext compatibility shim: # ERPNext may pass do_not_round_fields to round_floats_in, but older Frappe # versions don't accept that kwarg. try: - from frappe.model.document import Document - from pos_next.overrides.frappe_compat import patch_round_floats_in_signature - patch_round_floats_in_signature(Document) + from frappe.model.document import Document + + from pos_next.overrides.frappe_compat import patch_round_floats_in_signature + + patch_round_floats_in_signature(Document) except Exception: - pass + pass # Patch packed item keying to avoid duplicate Product Bundle rows in Packed Items # during repeated save/submit cycles in POS flows. try: - from erpnext.stock.doctype.packed_item import packed_item as packed_item_module - from pos_next.overrides.packed_item import patch_packed_item_keying - patch_packed_item_keying(packed_item_module) + from erpnext.stock.doctype.packed_item import packed_item as packed_item_module + + from pos_next.overrides.packed_item import patch_packed_item_keying + + patch_packed_item_keying(packed_item_module) except Exception: - pass + pass # Patch Document.round_floats_in for ERPNext/Frappe compatibility: # newer ERPNext may pass do_not_round_fields, while older Frappe # only supports fieldnames. try: - from frappe.model import document as document_module - from pos_next.overrides.rounding_compat import patch_round_floats_in_compat + from frappe.model import document as document_module + + from pos_next.overrides.rounding_compat import patch_round_floats_in_compat - patch_round_floats_in_compat(document_module) + patch_round_floats_in_compat(document_module) except Exception: - pass + pass diff --git a/pos_next/api/__init__.py b/pos_next/api/__init__.py index 51ebb2a80..19bf481bb 100644 --- a/pos_next/api/__init__.py +++ b/pos_next/api/__init__.py @@ -4,17 +4,10 @@ import frappe # Import API modules to make them accessible -from . import invoices -from . import items -from . import shifts -from . import pos_profile -from . import customers -from . import offers -from . import promotions -from . import utilities -from . import auth +from . import auth, customers, invoices, items, offers, pos_profile, promotions, shifts, utilities + @frappe.whitelist(allow_guest=True) def ping(): - """Simple ping endpoint for connectivity checks""" - return "pong" + """Simple ping endpoint for connectivity checks""" + return "pong" diff --git a/pos_next/api/auth.py b/pos_next/api/auth.py index 726fd30b2..78d251425 100644 --- a/pos_next/api/auth.py +++ b/pos_next/api/auth.py @@ -8,18 +8,18 @@ @frappe.whitelist() @rate_limit(limit=5, seconds=60) def verify_session_password(password=None): - """Verify the current session user's password for session lock re-authentication. + """Verify the current session user's password for session lock re-authentication. - NOTE: We must NOT raise frappe.AuthenticationError here because Frappe's - error handler (app.py) calls login_manager.clear_cookies() for that - exception type, which would destroy the user's session on a wrong password. - Instead, we return a structured response indicating success or failure. - """ - if not password: - return {"verified": False, "message": _("Password is required")} + NOTE: We must NOT raise frappe.AuthenticationError here because Frappe's + error handler (app.py) calls login_manager.clear_cookies() for that + exception type, which would destroy the user's session on a wrong password. + Instead, we return a structured response indicating success or failure. + """ + if not password: + return {"verified": False, "message": _("Password is required")} - try: - check_password(frappe.session.user, password) - return {"verified": True} - except frappe.AuthenticationError: - return {"verified": False, "message": _("Incorrect password")} + try: + check_password(frappe.session.user, password) + return {"verified": True} + except frappe.AuthenticationError: + return {"verified": False, "message": _("Incorrect password")} diff --git a/pos_next/api/bootstrap.py b/pos_next/api/bootstrap.py index c66df9300..142f4b5a1 100644 --- a/pos_next/api/bootstrap.py +++ b/pos_next/api/bootstrap.py @@ -30,7 +30,7 @@ from frappe.query_builder import DocType from frappe.query_builder.functions import Coalesce -from pos_next.api.constants import POS_SETTINGS_FIELDS, DEFAULT_POS_SETTINGS +from pos_next.api.constants import DEFAULT_POS_SETTINGS, POS_SETTINGS_FIELDS @frappe.whitelist() @@ -113,6 +113,7 @@ def get_initial_data(): # Private Helper Functions # ============================================================================= + def _get_user_language(): """ Get user's language preference from User doctype. @@ -143,7 +144,7 @@ def _get_precision_settings(): "System Settings", "System Settings", ["currency_precision", "float_precision", "rounding_method", "number_format"], - as_dict=True + as_dict=True, ) return { @@ -207,17 +208,20 @@ def _get_pos_settings(pos_profile_doc): dict: POS Settings with derived values """ try: - settings = frappe.db.get_value( - "POS Settings", - {"pos_profile": pos_profile_doc.name, "enabled": 1}, - POS_SETTINGS_FIELDS, - as_dict=True - ) or DEFAULT_POS_SETTINGS.copy() + settings = ( + frappe.db.get_value( + "POS Settings", + {"pos_profile": pos_profile_doc.name, "enabled": 1}, + POS_SETTINGS_FIELDS, + as_dict=True, + ) + or DEFAULT_POS_SETTINGS.copy() + ) # Derive from POS Profile (single source of truth) - settings["allow_write_off_change"] = 1 if ( - pos_profile_doc.write_off_account and (pos_profile_doc.write_off_limit or 0) > 0 - ) else 0 + settings["allow_write_off_change"] = ( + 1 if (pos_profile_doc.write_off_account and (pos_profile_doc.write_off_limit or 0) > 0) else 0 + ) settings["disable_rounded_total"] = pos_profile_doc.disable_rounded_total or 0 return settings @@ -255,7 +259,7 @@ def _get_payment_methods(pos_profile_name): POSPaymentMethod.mode_of_payment, POSPaymentMethod.default, POSPaymentMethod.allow_in_returns, - Coalesce(ModeOfPayment.type, "Cash").as_("type") + Coalesce(ModeOfPayment.type, "Cash").as_("type"), ) .where(POSPaymentMethod.parent == pos_profile_name) .orderby(POSPaymentMethod.idx) diff --git a/pos_next/api/branding.py b/pos_next/api/branding.py index a942ab582..a5b658968 100644 --- a/pos_next/api/branding.py +++ b/pos_next/api/branding.py @@ -6,11 +6,12 @@ Provides secure branding configuration and validation endpoints """ -import frappe -from frappe import _ -import json import base64 import hashlib +import json + +import frappe +from frappe import _ @frappe.whitelist(allow_guest=False) @@ -47,8 +48,8 @@ def get_branding_config(): "ta": "center", "fs": "13px", "c": "#6b7280", - "z": 100 - } + "z": 100, + }, } return config @@ -73,8 +74,8 @@ def get_default_config(): "ta": "center", "fs": "13px", "c": "#6b7280", - "z": 100 - } + "z": 100, + }, } @@ -95,24 +96,24 @@ def validate_branding(client_signature=None, brand_name=None, brand_url=None): return {"valid": True, "message": "Validation disabled"} # Validate branding data - is_valid = ( - brand_name == doc.brand_name and - brand_url == doc.brand_url - ) + is_valid = brand_name == doc.brand_name and brand_url == doc.brand_url if not is_valid: # Log tampering attempt - log_tampering_attempt(doc, { - "type": "validation_failed", - "user": frappe.session.user, - "timestamp": frappe.utils.now(), - "client_signature": client_signature, - "expected_brand": doc.brand_name, - "received_brand": brand_name, - "expected_url": doc.brand_url, - "received_url": brand_url, - "ip_address": frappe.local.request_ip if hasattr(frappe.local, 'request_ip') else None - }) + log_tampering_attempt( + doc, + { + "type": "validation_failed", + "user": frappe.session.user, + "timestamp": frappe.utils.now(), + "client_signature": client_signature, + "expected_brand": doc.brand_name, + "received_brand": brand_name, + "expected_url": doc.brand_url, + "received_url": brand_url, + "ip_address": frappe.local.request_ip if hasattr(frappe.local, "request_ip") else None, + }, + ) # Update last validation time frappe.db.set_value("BrainWise Branding", doc.name, "last_validation", frappe.utils.now()) @@ -121,7 +122,7 @@ def validate_branding(client_signature=None, brand_name=None, brand_url=None): return { "valid": is_valid, "timestamp": frappe.utils.now(), - "message": "Validation successful" if is_valid else "Branding mismatch detected" + "message": "Validation successful" if is_valid else "Branding mismatch detected", } except Exception as e: frappe.log_error(f"Error validating branding: {str(e)}", "BrainWise Branding Validation") @@ -153,24 +154,26 @@ def log_client_event(event_type=None, details=None): # Log different event types if event_type in ["removal", "modification", "hide", "integrity_fail", "visibility_change"]: - log_tampering_attempt(doc, { - "event_type": event_type, - "user": frappe.session.user, - "timestamp": frappe.utils.now(), - "details": details, - "ip_address": frappe.local.request_ip if hasattr(frappe.local, 'request_ip') else None - }) + log_tampering_attempt( + doc, + { + "event_type": event_type, + "user": frappe.session.user, + "timestamp": frappe.utils.now(), + "details": details, + "ip_address": frappe.local.request_ip if hasattr(frappe.local, "request_ip") else None, + }, + ) return {"logged": True, "message": f"Event {event_type} logged"} elif event_type == "link_click": # Log link clicks (for analytics) frappe.log_error( title="BrainWise Branding - Link Click", - message=json.dumps({ - "user": frappe.session.user, - "timestamp": frappe.utils.now(), - "details": details - }, indent=2) + message=json.dumps( + {"user": frappe.session.user, "timestamp": frappe.utils.now(), "details": details}, + indent=2, + ), ) return {"logged": True, "message": "Link click logged"} @@ -191,7 +194,7 @@ def log_tampering_attempt(doc, details): # Create error log frappe.log_error( title="BrainWise Branding - Tampering Detected", - message=json.dumps(details, indent=2, default=str) + message=json.dumps(details, indent=2, default=str), ) except Exception as e: frappe.log_error(f"Error logging tampering: {str(e)}", "BrainWise Branding") @@ -214,7 +217,7 @@ def get_tampering_stats(): "tampering_attempts": doc.tampering_attempts or 0, "last_validation": doc.last_validation, "server_validation": doc.enable_server_validation, - "logging_enabled": doc.log_tampering_attempts + "logging_enabled": doc.log_tampering_attempts, } except Exception as e: frappe.log_error(f"Error getting tampering stats: {str(e)}", "BrainWise Branding Stats") diff --git a/pos_next/api/credit_sales.py b/pos_next/api/credit_sales.py index 20ba50b81..f3ca14aa8 100644 --- a/pos_next/api/credit_sales.py +++ b/pos_next/api/credit_sales.py @@ -11,7 +11,7 @@ import frappe from frappe import _ -from frappe.utils import flt, nowdate, today, cint, get_datetime +from frappe.utils import cint, flt, get_datetime, nowdate, today @frappe.whitelist() @@ -46,16 +46,13 @@ def get_customer_balance(customer, company=None): try: from frappe.query_builder import DocType - from frappe.query_builder.functions import Sum, Abs, Coalesce + from frappe.query_builder.functions import Abs, Coalesce, Sum from pypika import Case SalesInvoice = DocType("Sales Invoice") # Build base filters - base_filters = ( - (SalesInvoice.customer == customer) & - (SalesInvoice.docstatus == 1) - ) + base_filters = (SalesInvoice.customer == customer) & (SalesInvoice.docstatus == 1) if company: base_filters = base_filters & (SalesInvoice.company == company) @@ -72,7 +69,7 @@ def get_customer_balance(customer, company=None): .when(SalesInvoice.outstanding_amount > 0, SalesInvoice.outstanding_amount) .else_(0) ), - 0 + 0, ).as_("total_outstanding") ) .where(base_filters & (SalesInvoice.is_return == 0)) @@ -84,14 +81,8 @@ def get_customer_balance(customer, company=None): # If no cash refund (added to customer credit), outstanding_amount < 0 return_query = ( frappe.qb.from_(SalesInvoice) - .select( - Coalesce(Sum(Abs(SalesInvoice.outstanding_amount)), 0).as_("return_credit") - ) - .where( - base_filters & - (SalesInvoice.is_return == 1) & - (SalesInvoice.outstanding_amount < 0) - ) + .select(Coalesce(Sum(Abs(SalesInvoice.outstanding_amount)), 0).as_("return_credit")) + .where(base_filters & (SalesInvoice.is_return == 1) & (SalesInvoice.outstanding_amount < 0)) ) # Execute queries @@ -109,19 +100,15 @@ def get_customer_balance(customer, company=None): return { "total_outstanding": total_outstanding, "total_credit": total_credit, - "net_balance": net_balance + "net_balance": net_balance, } except Exception as e: frappe.log_error( title="Customer Balance Error", - message=f"Customer: {customer}, Company: {company}, Error: {str(e)}\n{frappe.get_traceback()}" + message=f"Customer: {customer}, Company: {company}, Error: {str(e)}\n{frappe.get_traceback()}", ) - return { - "total_outstanding": 0.0, - "total_credit": 0.0, - "net_balance": 0.0 - } + return {"total_outstanding": 0.0, "total_credit": 0.0, "net_balance": 0.0} def check_credit_sale_enabled(pos_profile): @@ -139,10 +126,7 @@ def check_credit_sale_enabled(pos_profile): # Get POS Settings for the profile pos_settings = frappe.db.get_value( - "POS Settings", - {"pos_profile": pos_profile}, - "allow_credit_sale", - as_dict=False + "POS Settings", {"pos_profile": pos_profile}, "allow_credit_sale", as_dict=False ) return bool(pos_settings) @@ -190,7 +174,7 @@ def get_available_credit(customer, company, pos_profile=None): "is_return": 1, }, fields=["name", "outstanding_amount", "is_return", "posting_date", "grand_total", "modified"], - order_by="posting_date desc" + order_by="posting_date desc", ) for row in outstanding_invoices: @@ -198,17 +182,19 @@ def get_available_credit(customer, company, pos_profile=None): available_credit = -flt(row.outstanding_amount) if available_credit > 0: - total_credit.append({ - "type": "Invoice", - "credit_origin": row.name, - "total_credit": available_credit, - "available_credit": available_credit, - "source_type": "Sales Return", - "posting_date": row.posting_date, - "reference_amount": row.grand_total, - "credit_to_redeem": 0, # User will set this - "modified": row.modified, # For optimistic locking - }) + total_credit.append( + { + "type": "Invoice", + "credit_origin": row.name, + "total_credit": available_credit, + "available_credit": available_credit, + "source_type": "Sales Return", + "posting_date": row.posting_date, + "reference_amount": row.grand_total, + "credit_to_redeem": 0, # User will set this + "modified": row.modified, # For optimistic locking + } + ) # Get unallocated advance payments advances = frappe.get_all( @@ -221,22 +207,24 @@ def get_available_credit(customer, company, pos_profile=None): "payment_type": "Receive", }, fields=["name", "unallocated_amount", "posting_date", "paid_amount", "mode_of_payment", "modified"], - order_by="posting_date desc" + order_by="posting_date desc", ) for row in advances: - total_credit.append({ - "type": "Advance", - "credit_origin": row.name, - "total_credit": flt(row.unallocated_amount), - "available_credit": flt(row.unallocated_amount), - "source_type": "Payment Entry", - "posting_date": row.posting_date, - "reference_amount": row.paid_amount, - "mode_of_payment": row.mode_of_payment, - "credit_to_redeem": 0, # User will set this - "modified": row.modified, # For optimistic locking - }) + total_credit.append( + { + "type": "Advance", + "credit_origin": row.name, + "total_credit": flt(row.unallocated_amount), + "available_credit": flt(row.unallocated_amount), + "source_type": "Payment Entry", + "posting_date": row.posting_date, + "reference_amount": row.paid_amount, + "mode_of_payment": row.mode_of_payment, + "credit_to_redeem": 0, # User will set this + "modified": row.modified, # For optimistic locking + } + ) return total_credit @@ -296,11 +284,7 @@ def redeem_customer_credit(invoice_name, customer_credit_dict): ) # Create JE to allocate credit from original invoice to new invoice - je_name = _create_credit_allocation_journal_entry( - invoice_doc, - credit_origin, - credit_to_redeem - ) + je_name = _create_credit_allocation_journal_entry(invoice_doc, credit_origin, credit_to_redeem) created_journal_entries.append(je_name) elif credit_type == "Advance": @@ -313,11 +297,7 @@ def redeem_customer_credit(invoice_name, customer_credit_dict): ) # Create Payment Entry to allocate advance payment - pe_name = _create_payment_entry_from_advance( - invoice_doc, - credit_origin, - credit_to_redeem - ) + pe_name = _create_payment_entry_from_advance(invoice_doc, credit_origin, credit_to_redeem) created_journal_entries.append(pe_name) return created_journal_entries @@ -326,14 +306,10 @@ def redeem_customer_credit(invoice_name, customer_credit_dict): def _validate_credit_source_ownership(source_name, source_customer, source_company, customer, company): """Ensure a credit source belongs to the same customer and company as the target invoice.""" if source_customer != customer: - frappe.throw( - _("Credit source {0} does not belong to customer {1}").format(source_name, customer) - ) + frappe.throw(_("Credit source {0} does not belong to customer {1}").format(source_name, customer)) if source_company != company: - frappe.throw( - _("Credit source {0} does not belong to company {1}").format(source_name, company) - ) + frappe.throw(_("Credit source {0} does not belong to company {1}").format(source_name, company)) def _validate_and_lock_invoice_credit(invoice_name, amount_to_redeem, customer, company): @@ -364,10 +340,7 @@ def _validate_and_lock_invoice_credit(invoice_name, amount_to_redeem, customer, SalesInvoice.customer, SalesInvoice.company, ) - .where( - (SalesInvoice.name == invoice_name) & - (SalesInvoice.docstatus == 1) - ) + .where((SalesInvoice.name == invoice_name) & (SalesInvoice.docstatus == 1)) .for_update() ) @@ -392,7 +365,7 @@ def _validate_and_lock_invoice_credit(invoice_name, amount_to_redeem, customer, _("Insufficient credit available from {0}. Available: {1}, Requested: {2}").format( invoice_name, frappe.format_value(available_credit, {"fieldtype": "Currency"}), - frappe.format_value(amount_to_redeem, {"fieldtype": "Currency"}) + frappe.format_value(amount_to_redeem, {"fieldtype": "Currency"}), ) ) @@ -426,10 +399,7 @@ def _validate_and_lock_advance_credit(payment_entry_name, amount_to_redeem, cust PaymentEntry.party_type, PaymentEntry.payment_type, ) - .where( - (PaymentEntry.name == payment_entry_name) & - (PaymentEntry.docstatus == 1) - ) + .where((PaymentEntry.name == payment_entry_name) & (PaymentEntry.docstatus == 1)) .for_update() ) @@ -439,9 +409,7 @@ def _validate_and_lock_advance_credit(payment_entry_name, amount_to_redeem, cust frappe.throw(_("Payment Entry {0} not found or not submitted").format(payment_entry_name)) if result[0].party_type != "Customer" or result[0].payment_type != "Receive": - frappe.throw( - _("Payment Entry {0} is not a valid customer advance").format(payment_entry_name) - ) + frappe.throw(_("Payment Entry {0} is not a valid customer advance").format(payment_entry_name)) _validate_credit_source_ownership( payment_entry_name, @@ -458,7 +426,7 @@ def _validate_and_lock_advance_credit(payment_entry_name, amount_to_redeem, cust _("Insufficient unallocated amount in {0}. Available: {1}, Requested: {2}").format( payment_entry_name, frappe.format_value(available_amount, {"fieldtype": "Currency"}), - frappe.format_value(amount_to_redeem, {"fieldtype": "Currency"}) + frappe.format_value(amount_to_redeem, {"fieldtype": "Currency"}), ) ) @@ -496,48 +464,51 @@ def _create_credit_allocation_journal_entry(invoice_doc, original_invoice_name, ) # Create Journal Entry - jv_doc = frappe.get_doc({ - "doctype": "Journal Entry", - "voucher_type": "Journal Entry", - "posting_date": today(), - "company": invoice_doc.company, - "user_remark": get_credit_redeem_remark(invoice_doc.name), - }) + jv_doc = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "posting_date": today(), + "company": invoice_doc.company, + "user_remark": get_credit_redeem_remark(invoice_doc.name), + } + ) # Debit Entry - Original Invoice (reduces outstanding) debit_row = jv_doc.append("accounts", {}) - debit_row.update({ - "account": original_invoice.debit_to, - "party_type": "Customer", - "party": invoice_doc.customer, - "reference_type": "Sales Invoice", - "reference_name": original_invoice.name, - "debit_in_account_currency": amount, - "credit_in_account_currency": 0, - "cost_center": cost_center, - }) + debit_row.update( + { + "account": original_invoice.debit_to, + "party_type": "Customer", + "party": invoice_doc.customer, + "reference_type": "Sales Invoice", + "reference_name": original_invoice.name, + "debit_in_account_currency": amount, + "credit_in_account_currency": 0, + "cost_center": cost_center, + } + ) # Credit Entry - New Invoice (reduces outstanding) credit_row = jv_doc.append("accounts", {}) - credit_row.update({ - "account": invoice_doc.debit_to, - "party_type": "Customer", - "party": invoice_doc.customer, - "reference_type": "Sales Invoice", - "reference_name": invoice_doc.name, - "debit_in_account_currency": 0, - "credit_in_account_currency": amount, - "cost_center": cost_center, - }) + credit_row.update( + { + "account": invoice_doc.debit_to, + "party_type": "Customer", + "party": invoice_doc.customer, + "reference_type": "Sales Invoice", + "reference_name": invoice_doc.name, + "debit_in_account_currency": 0, + "credit_in_account_currency": amount, + "cost_center": cost_center, + } + ) jv_doc.flags.ignore_permissions = True jv_doc.save() jv_doc.submit() - frappe.msgprint( - _("Journal Entry {0} created for credit redemption").format(jv_doc.name), - alert=True - ) + frappe.msgprint(_("Journal Entry {0} created for credit redemption").format(jv_doc.name), alert=True) return jv_doc.name @@ -559,9 +530,7 @@ def _create_payment_entry_from_advance(invoice_doc, payment_entry_name, amount): payment_entry = frappe.get_doc("Payment Entry", payment_entry_name) if payment_entry.party_type != "Customer" or payment_entry.payment_type != "Receive": - frappe.throw( - _("Payment Entry {0} is not a valid customer advance").format(payment_entry_name) - ) + frappe.throw(_("Payment Entry {0} is not a valid customer advance").format(payment_entry_name)) _validate_credit_source_ownership( payment_entry.name, @@ -573,20 +542,19 @@ def _create_payment_entry_from_advance(invoice_doc, payment_entry_name, amount): # Check if already allocated if payment_entry.unallocated_amount < amount: - frappe.throw( - _("Payment Entry {0} has insufficient unallocated amount").format( - payment_entry_name - ) - ) + frappe.throw(_("Payment Entry {0} has insufficient unallocated amount").format(payment_entry_name)) # Add reference to invoice - payment_entry.append("references", { - "reference_doctype": "Sales Invoice", - "reference_name": invoice_doc.name, - "total_amount": invoice_doc.grand_total, - "outstanding_amount": invoice_doc.outstanding_amount, - "allocated_amount": amount, - }) + payment_entry.append( + "references", + { + "reference_doctype": "Sales Invoice", + "reference_name": invoice_doc.name, + "total_amount": invoice_doc.grand_total, + "outstanding_amount": invoice_doc.outstanding_amount, + "allocated_amount": amount, + }, + ) # Recalculate unallocated amount payment_entry.set_amounts() @@ -595,10 +563,7 @@ def _create_payment_entry_from_advance(invoice_doc, payment_entry_name, amount): payment_entry.flags.ignore_validate_update_after_submit = True payment_entry.save() - frappe.msgprint( - _("Payment Entry {0} allocated to invoice").format(payment_entry.name), - alert=True - ) + frappe.msgprint(_("Payment Entry {0} allocated to invoice").format(payment_entry.name), alert=True) return payment_entry.name @@ -620,12 +585,7 @@ def cancel_credit_journal_entries(invoice_name): # Find linked journal entries linked_journal_entries = frappe.get_all( - "Journal Entry", - filters={ - "docstatus": 1, - "user_remark": remark - }, - pluck="name" + "Journal Entry", filters={"docstatus": 1, "user_remark": remark}, pluck="name" ) cancelled_count = 0 @@ -648,13 +608,12 @@ def cancel_credit_journal_entries(invoice_name): except Exception as e: frappe.log_error( f"Failed to cancel Journal Entry {journal_entry_name}: {str(e)}", - "Credit Sale JE Cancellation" + "Credit Sale JE Cancellation", ) if cancelled_count > 0: frappe.msgprint( - _("Cancelled {0} credit redemption journal entries").format(cancelled_count), - alert=True + _("Cancelled {0} credit redemption journal entries").format(cancelled_count), alert=True ) return cancelled_count @@ -675,7 +634,8 @@ def get_credit_sale_summary(pos_profile): frappe.throw(_("POS Profile is required")) # Get credit sales (outstanding > 0) - summary = frappe.db.sql(""" + summary = frappe.db.sql( + """ SELECT COUNT(*) as count, SUM(outstanding_amount) as total_outstanding, @@ -689,14 +649,12 @@ def get_credit_sale_summary(pos_profile): AND is_pos = 1 AND outstanding_amount > 0 AND is_return = 0 - """, {"pos_profile": pos_profile}, as_dict=True) + """, + {"pos_profile": pos_profile}, + as_dict=True, + ) - return summary[0] if summary else { - "count": 0, - "total_outstanding": 0, - "total_amount": 0, - "total_paid": 0 - } + return summary[0] if summary else {"count": 0, "total_outstanding": 0, "total_amount": 0, "total_paid": 0} @frappe.whitelist() @@ -715,16 +673,14 @@ def get_credit_invoices(pos_profile, limit=100): frappe.throw(_("POS Profile is required")) # Check if user has access to this POS Profile - has_access = frappe.db.exists( - "POS Profile User", - {"parent": pos_profile, "user": frappe.session.user} - ) + has_access = frappe.db.exists("POS Profile User", {"parent": pos_profile, "user": frappe.session.user}) if not has_access and not frappe.has_permission("Sales Invoice", "read"): frappe.throw(_("You don't have access to this POS Profile")) # Query for credit invoices - invoices = frappe.db.sql(""" + invoices = frappe.db.sql( + """ SELECT name, customer, @@ -748,9 +704,9 @@ def get_credit_invoices(pos_profile, limit=100): posting_date DESC, posting_time DESC LIMIT %(limit)s - """, { - "pos_profile": pos_profile, - "limit": limit - }, as_dict=True) + """, + {"pos_profile": pos_profile, "limit": limit}, + as_dict=True, + ) return invoices diff --git a/pos_next/api/customers.py b/pos_next/api/customers.py index 08f96d084..1f7b56511 100644 --- a/pos_next/api/customers.py +++ b/pos_next/api/customers.py @@ -113,17 +113,18 @@ def create_customer( if not resolved_customer_group: resolved_customer_group = frappe.db.get_single_value("Selling Settings", "customer_group") if not resolved_customer_group: - resolved_customer_group = frappe.db.get_value( - "Customer Group", {"is_group": 0}, "name", order_by="lft" - ) or "All Customer Groups" + resolved_customer_group = ( + frappe.db.get_value("Customer Group", {"is_group": 0}, "name", order_by="lft") + or "All Customer Groups" + ) resolved_territory = territory if not resolved_territory: resolved_territory = frappe.db.get_single_value("Selling Settings", "territory") if not resolved_territory: - resolved_territory = frappe.db.get_value( - "Territory", {"is_group": 0}, "name", order_by="lft" - ) or "All Territories" + resolved_territory = ( + frappe.db.get_value("Territory", {"is_group": 0}, "name", order_by="lft") or "All Territories" + ) customer = frappe.get_doc( { diff --git a/pos_next/api/items.py b/pos_next/api/items.py index d2dc2acfc..6b16f208b 100644 --- a/pos_next/api/items.py +++ b/pos_next/api/items.py @@ -8,7 +8,8 @@ from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.get_item_details import get_item_details as erpnext_get_item_details from frappe import _ -from frappe.query_builder import DocType, functions as fn +from frappe.query_builder import DocType +from frappe.query_builder import functions as fn from frappe.utils import flt, nowdate ITEM_RESULT_FIELDS = [ diff --git a/pos_next/api/localization.py b/pos_next/api/localization.py index d7d6d3eed..152fd9d51 100644 --- a/pos_next/api/localization.py +++ b/pos_next/api/localization.py @@ -38,10 +38,7 @@ def get_user_language(): # Get user's language preference language = frappe.db.get_value("User", frappe.session.user, "language") or "en" - return { - "success": True, - "locale": language.lower() - } + return {"success": True, "locale": language.lower()} @frappe.whitelist() @@ -53,10 +50,7 @@ def get_allowed_locales(): dict: List of allowed locale codes """ allowed = get_allowed_locales_from_settings() - return { - "success": True, - "locales": list(allowed) - } + return {"success": True, "locales": list(allowed)} def get_allowed_locales_from_settings(): @@ -67,16 +61,11 @@ def get_allowed_locales_from_settings(): Returns: set: Set of allowed locale codes """ - default_locales = {'ar', 'en'} + default_locales = {"ar", "en"} try: # Get the first POS Settings (or we could use a specific one based on user's profile) - pos_settings_list = frappe.get_all( - "POS Settings", - filters={"enabled": 1}, - fields=["name"], - limit=1 - ) + pos_settings_list = frappe.get_all("POS Settings", filters={"enabled": 1}, fields=["name"], limit=1) if not pos_settings_list: return default_locales @@ -130,11 +119,7 @@ def change_user_language(locale): frappe.db.set_value("User", frappe.session.user, "language", locale) frappe.db.commit() - return { - "success": True, - "message": f"Language changed to {locale}", - "locale": locale - } + return {"success": True, "message": f"Language changed to {locale}", "locale": locale} except Exception as e: frappe.log_error(f"Failed to change user language: {str(e)}") frappe.throw(f"Failed to change language: {str(e)}", frappe.ValidationError) diff --git a/pos_next/api/offers.py b/pos_next/api/offers.py index 118c0db78..ca89537d4 100644 --- a/pos_next/api/offers.py +++ b/pos_next/api/offers.py @@ -9,25 +9,28 @@ Promotional Schemes and standalone Pricing Rules. """ +from dataclasses import asdict, dataclass from typing import Dict, List, Optional -from dataclasses import dataclass, asdict + import frappe from frappe import _ from frappe.utils import flt, getdate, nowdate - # ============================================================================ # Constants # ============================================================================ + class DiscountType: """Discount type constants""" + PRICE = "Price" PRODUCT = "Product" class ApplyOn: """Apply on constants""" + ITEM_CODE = "Item Code" ITEM_GROUP = "Item Group" BRAND = "Brand" @@ -36,6 +39,7 @@ class ApplyOn: class OfferSource: """Offer source constants""" + PROMOTIONAL_SCHEME = "Promotional Scheme" PRICING_RULE = "Pricing Rule" @@ -44,9 +48,11 @@ class OfferSource: # Data Classes # ============================================================================ + @dataclass class OfferEligibility: """Eligibility criteria for an offer""" + items: List[str] item_groups: List[str] brands: List[str] @@ -55,6 +61,7 @@ class OfferEligibility: @dataclass class Offer: """Structured offer data""" + name: str title: str description: str @@ -96,6 +103,7 @@ def to_dict(self) -> Dict: # Database Query Helpers # ============================================================================ + class EligibilityFetcher: """Fetches eligibility criteria for pricing rules/schemes in bulk""" @@ -123,7 +131,7 @@ def fetch_all(parent_names: List[str]) -> Dict[str, OfferEligibility]: eligibility[parent] = OfferEligibility( items=items_map.get(parent, []), item_groups=item_groups_map.get(parent, []), - brands=brands_map.get(parent, []) + brands=brands_map.get(parent, []), ) return eligibility @@ -137,11 +145,15 @@ def _fetch_items(parent_names: List[str]) -> Dict[str, List[str]]: automatically includes all its variant items in the eligible items list. This ensures offers work correctly when variants are added to cart. """ - results = frappe.db.sql(""" + results = frappe.db.sql( + """ SELECT parent, item_code FROM `tabPricing Rule Item Code` WHERE parent IN %s - """, [parent_names], as_dict=1) + """, + [parent_names], + as_dict=1, + ) if not results: return {} @@ -151,12 +163,7 @@ def _fetch_items(parent_names: List[str]) -> Dict[str, List[str]]: # Find which items are templates (have variants) template_items = frappe.get_all( - "Item", - filters={ - "name": ["in", all_item_codes], - "has_variants": 1 - }, - pluck="name" + "Item", filters={"name": ["in", all_item_codes], "has_variants": 1}, pluck="name" ) # Fetch variants for all template items in one query @@ -164,11 +171,8 @@ def _fetch_items(parent_names: List[str]) -> Dict[str, List[str]]: if template_items: variants = frappe.get_all( "Item", - filters={ - "variant_of": ["in", template_items], - "disabled": 0 - }, - fields=["name", "variant_of"] + filters={"variant_of": ["in", template_items], "disabled": 0}, + fields=["name", "variant_of"], ) for variant in variants: variants_map.setdefault(variant["variant_of"], []).append(variant["name"]) @@ -190,11 +194,15 @@ def _fetch_items(parent_names: List[str]) -> Dict[str, List[str]]: @staticmethod def _fetch_item_groups(parent_names: List[str]) -> Dict[str, List[str]]: """Fetch item groups for given parents""" - results = frappe.db.sql(""" + results = frappe.db.sql( + """ SELECT parent, item_group FROM `tabPricing Rule Item Group` WHERE parent IN %s - """, [parent_names], as_dict=1) + """, + [parent_names], + as_dict=1, + ) groups_map = {} for row in results: @@ -204,11 +212,15 @@ def _fetch_item_groups(parent_names: List[str]) -> Dict[str, List[str]]: @staticmethod def _fetch_brands(parent_names: List[str]) -> Dict[str, List[str]]: """Fetch brands for given parents""" - results = frappe.db.sql(""" + results = frappe.db.sql( + """ SELECT parent, brand FROM `tabPricing Rule Brand` WHERE parent IN %s - """, [parent_names], as_dict=1) + """, + [parent_names], + as_dict=1, + ) brands_map = {} for row in results: @@ -225,7 +237,8 @@ def fetch_price_slabs(scheme_names: List[str]) -> Dict[str, Dict]: if not scheme_names: return {} - results = frappe.db.sql(""" + results = frappe.db.sql( + """ SELECT parent, min_qty, max_qty, min_amount, max_amount, rate_or_discount, rate, discount_amount, discount_percentage, @@ -233,7 +246,10 @@ def fetch_price_slabs(scheme_names: List[str]) -> Dict[str, Dict]: FROM `tabPromotional Scheme Price Discount` WHERE parent IN %s AND disable = 0 ORDER BY parent, min_amount ASC, min_qty ASC - """, [scheme_names], as_dict=1) + """, + [scheme_names], + as_dict=1, + ) # Take first slab for each parent slabs_map = {} @@ -249,7 +265,8 @@ def fetch_product_slabs(scheme_names: List[str]) -> Dict[str, Dict]: if not scheme_names: return {} - results = frappe.db.sql(""" + results = frappe.db.sql( + """ SELECT parent, min_qty, max_qty, min_amount, max_amount, apply_multiple_pricing_rules, @@ -258,7 +275,10 @@ def fetch_product_slabs(scheme_names: List[str]) -> Dict[str, Dict]: FROM `tabPromotional Scheme Product Discount` WHERE parent IN %s AND disable = 0 ORDER BY parent, min_amount ASC, min_qty ASC - """, [scheme_names], as_dict=1) + """, + [scheme_names], + as_dict=1, + ) # Take first slab for each parent slabs_map = {} @@ -273,15 +293,12 @@ def fetch_product_slabs(scheme_names: List[str]) -> Dict[str, Dict]: # Offer Builders # ============================================================================ + class OfferBuilder: """Builds Offer objects from pricing rules and schemes""" @staticmethod - def build_from_scheme_rule( - rule: Dict, - slab: Dict, - eligibility: OfferEligibility - ) -> Offer: + def build_from_scheme_rule(rule: Dict, slab: Dict, eligibility: OfferEligibility) -> Offer: """Build offer from promotional scheme pricing rule""" # Determine if auto-apply @@ -336,14 +353,11 @@ def build_from_scheme_rule( same_item=1 if slab.get("same_item") and not is_price_discount else 0, is_recursive=1 if slab.get("is_recursive") and not is_price_discount else 0, recurse_for=flt(slab.get("recurse_for", 0)) if not is_price_discount else 0, - apply_recursion_over=flt(slab.get("apply_recursion_over", 0)) if not is_price_discount else 0 + apply_recursion_over=flt(slab.get("apply_recursion_over", 0)) if not is_price_discount else 0, ) @staticmethod - def build_from_standalone_rule( - rule: Dict, - eligibility: OfferEligibility - ) -> Offer: + def build_from_standalone_rule(rule: Dict, eligibility: OfferEligibility) -> Offer: """Build offer from standalone pricing rule""" # Standalone rules auto-apply unless coupon-based @@ -384,7 +398,7 @@ def build_from_standalone_rule( promotional_scheme_id=None, eligible_items=eligible_items, eligible_item_groups=eligible_item_groups, - eligible_brands=eligible_brands + eligible_brands=eligible_brands, ) @@ -392,6 +406,7 @@ def build_from_standalone_rule( # Main API Functions # ============================================================================ + @frappe.whitelist() def get_offers(pos_profile: str) -> List[Dict]: """ @@ -433,7 +448,8 @@ def _get_promotional_scheme_offers(company: str, date: str) -> List[Offer]: """Fetch offers from promotional schemes""" # Fetch pricing rules linked to promotional schemes - pricing_rules = frappe.db.sql(""" + pricing_rules = frappe.db.sql( + """ SELECT name, title, apply_on, selling, promotional_scheme, promotional_scheme_id, coupon_code_based, @@ -447,7 +463,10 @@ def _get_promotional_scheme_offers(company: str, date: str) -> List[Offer]: AND (valid_from IS NULL OR valid_from <= %(date)s) AND (valid_upto IS NULL OR valid_upto >= %(date)s) ORDER BY priority DESC, name - """, {"company": company, "date": date}, as_dict=1) + """, + {"company": company, "date": date}, + as_dict=1, + ) if not pricing_rules: return [] @@ -485,7 +504,8 @@ def _get_standalone_pricing_rule_offers(company: str, date: str) -> List[Offer]: """Fetch offers from standalone pricing rules""" # Fetch standalone pricing rules (not linked to schemes) - pricing_rules = frappe.db.sql(""" + pricing_rules = frappe.db.sql( + """ SELECT name, title, apply_on, selling, coupon_code_based, price_or_product_discount, @@ -502,7 +522,10 @@ def _get_standalone_pricing_rule_offers(company: str, date: str) -> List[Offer]: AND (valid_upto IS NULL OR valid_upto >= %(date)s) AND price_or_product_discount = %(discount_type)s ORDER BY priority DESC, name - """, {"company": company, "date": date, "discount_type": DiscountType.PRICE}, as_dict=1) + """, + {"company": company, "date": date, "discount_type": DiscountType.PRICE}, + as_dict=1, + ) if not pricing_rules: return [] @@ -527,6 +550,7 @@ def _get_standalone_pricing_rule_offers(company: str, date: str) -> List[Offer]: # Coupon Functions # ============================================================================ + @frappe.whitelist() def get_active_coupons(customer: str, company: str) -> List[Dict]: """Get active gift card coupons for a customer""" @@ -558,10 +582,7 @@ def validate_coupon(coupon_code: str, customer: str, company: str) -> Dict: # Fetch coupon with case-insensitive code matching # Note: coupon_code field is unique, so we can fetch directly coupon = frappe.db.get_value( - "POS Coupon", - {"coupon_code": coupon_code, "company": company}, - ["*"], - as_dict=1 + "POS Coupon", {"coupon_code": coupon_code, "company": company}, ["*"], as_dict=1 ) if not coupon: @@ -590,7 +611,4 @@ def validate_coupon(coupon_code: str, customer: str, company: str) -> Dict: if coupon.customer and coupon.customer != customer: return {"valid": False, "message": _("This coupon is not valid for this customer")} - return { - "valid": True, - "coupon": coupon - } + return {"valid": True, "coupon": coupon} diff --git a/pos_next/api/partial_payments.py b/pos_next/api/partial_payments.py index f0c087643..d26495a28 100644 --- a/pos_next/api/partial_payments.py +++ b/pos_next/api/partial_payments.py @@ -21,24 +21,26 @@ Always create Payment Entry documents which automatically update Payment Ledger. """ -import frappe -from frappe import _ -from typing import Dict, List, Optional, Tuple, Any -from frappe.utils import flt, nowdate, get_datetime, cint, get_time from datetime import datetime from enum import Enum +from typing import Any, Dict, List, Optional, Tuple +import frappe +from frappe import _ +from frappe.utils import cint, flt, get_datetime, get_time, nowdate # ========================================== # Constants and Configuration # ========================================== + class PaymentSource(Enum): - """Payment source types for audit trail""" - POS = "POS" - POS_PAYMENT_ENTRY = "POS Payment Entry" - PAYMENT_ENTRY = "Payment Entry" - UNKNOWN = "Unknown" + """Payment source types for audit trail""" + + POS = "POS" + POS_PAYMENT_ENTRY = "POS Payment Entry" + PAYMENT_ENTRY = "Payment Entry" + UNKNOWN = "Unknown" # Float comparison tolerance for amount matching (accounting precision) @@ -58,48 +60,48 @@ class PaymentSource(Enum): def get_payment_history(invoice_name: str, include_metadata: bool = True) -> Dict: - """ - Get complete payment history from Payment Ledger using optimized queries. - - Payment Ledger is ERPNext's single source of truth for all payments. - This includes both POS payments and Payment Entries. - - Performance: Uses batch queries to avoid N+1 problem. - - Args: - invoice_name: Sales Invoice name - include_metadata: If False, skips fetching mode_of_payment details for performance - - Returns: - dict: { - 'payments': List of payment records in chronological order, - 'total_paid': Total amount paid, - 'outstanding': Current outstanding amount, - 'grand_total': Invoice grand total, - 'payment_count': Number of payments - } - - Raises: - frappe.DoesNotExistError: If invoice doesn't exist - """ - # Validate and get invoice using ORM - if not invoice_name or not isinstance(invoice_name, str): - frappe.throw(_("Invalid invoice name provided")) - - try: - invoice = frappe.get_doc("Sales Invoice", invoice_name) - except frappe.DoesNotExistError: - frappe.log_error( - title="Invoice Not Found", - message=f"Attempted to get payment history for non-existent invoice: {invoice_name}" - ) - raise - - # Query Payment Ledger for all entries related to this invoice - # Payment Ledger tracks: Invoice creation (positive), Payments (negative) - # Need to check BOTH voucher_no (for invoice) and against_voucher_no (for payments) - payment_ledger_entries = frappe.db.sql( - """ + """ + Get complete payment history from Payment Ledger using optimized queries. + + Payment Ledger is ERPNext's single source of truth for all payments. + This includes both POS payments and Payment Entries. + + Performance: Uses batch queries to avoid N+1 problem. + + Args: + invoice_name: Sales Invoice name + include_metadata: If False, skips fetching mode_of_payment details for performance + + Returns: + dict: { + 'payments': List of payment records in chronological order, + 'total_paid': Total amount paid, + 'outstanding': Current outstanding amount, + 'grand_total': Invoice grand total, + 'payment_count': Number of payments + } + + Raises: + frappe.DoesNotExistError: If invoice doesn't exist + """ + # Validate and get invoice using ORM + if not invoice_name or not isinstance(invoice_name, str): + frappe.throw(_("Invalid invoice name provided")) + + try: + invoice = frappe.get_doc("Sales Invoice", invoice_name) + except frappe.DoesNotExistError: + frappe.log_error( + title="Invoice Not Found", + message=f"Attempted to get payment history for non-existent invoice: {invoice_name}", + ) + raise + + # Query Payment Ledger for all entries related to this invoice + # Payment Ledger tracks: Invoice creation (positive), Payments (negative) + # Need to check BOTH voucher_no (for invoice) and against_voucher_no (for payments) + payment_ledger_entries = frappe.db.sql( + """ SELECT name, voucher_type, @@ -119,224 +121,216 @@ def get_payment_history(invoice_name: str, include_metadata: bool = True) -> Dic AND company = %(company)s ORDER BY posting_date ASC, creation ASC """, - { - "invoice_name": invoice_name, - "company": invoice.company - }, - as_dict=True, - ) - - # Build payment history with details - payments = [] - - # Collect voucher numbers for batch queries (performance optimization) - sales_invoice_vouchers = set() - payment_entry_vouchers = set() - - for ple in payment_ledger_entries: - # Negative amounts are payments (positive is invoice creation) - if ple.amount < 0: - if ple.voucher_type == "Sales Invoice": - sales_invoice_vouchers.add(ple.voucher_no) - elif ple.voucher_type == "Payment Entry": - payment_entry_vouchers.add(ple.voucher_no) - - # Batch fetch Sales Invoice Payments (eliminates N+1 query problem) - si_payments_map = {} - if sales_invoice_vouchers and include_metadata: - si_payments = frappe.get_all( - "Sales Invoice Payment", - filters={"parent": ["in", list(sales_invoice_vouchers)]}, - fields=["parent", "mode_of_payment", "amount", "idx"], - order_by="parent, idx asc", - ) - - # Group by parent invoice - for sip in si_payments: - if sip.parent not in si_payments_map: - si_payments_map[sip.parent] = [] - si_payments_map[sip.parent].append(sip) - - # Batch fetch Payment Entries (eliminates N+1 query problem) - payment_entries_map = {} - if payment_entry_vouchers and include_metadata: - payment_entries = frappe.get_all( - "Payment Entry", - filters={"name": ["in", list(payment_entry_vouchers)]}, - fields=["name", "mode_of_payment", "reference_no", "paid_to", "paid_to_account_type"], - ) - - for pe in payment_entries: - payment_entries_map[pe.name] = pe - - # Process Payment Ledger entries with batched data - for ple in payment_ledger_entries: - # Negative amounts are payments (positive is invoice creation) - if ple.amount < 0: - payment_record = { - "posting_date": ple.posting_date, - "creation": ple.creation, - "amount": abs(flt(ple.amount)), - "voucher_type": ple.voucher_type, - "voucher_no": ple.voucher_no, - "source": _determine_payment_source(ple, payment_entries_map), - "mode_of_payment": None, - "reference": None, - "account": ple.account, - } - - if include_metadata: - # Get mode of payment based on voucher type - if ple.voucher_type == "Sales Invoice": - # This is a POS payment - recorded at invoice submission - pos_payments = si_payments_map.get(ple.voucher_no, []) - - # Match by amount using accounting tolerance - for pos_pay in pos_payments: - if abs(flt(pos_pay.amount) - abs(ple.amount)) < AMOUNT_TOLERANCE: - payment_record["mode_of_payment"] = pos_pay.mode_of_payment - break - - # Fallback to first payment mode if no exact match - if not payment_record["mode_of_payment"] and pos_payments: - payment_record["mode_of_payment"] = pos_payments[0].mode_of_payment - - # Final fallback - if not payment_record["mode_of_payment"]: - payment_record["mode_of_payment"] = DEFAULT_PAYMENT_MODE - - elif ple.voucher_type == "Payment Entry": - # Get Payment Entry details from batched data - pe_data = payment_entries_map.get(ple.voucher_no) - - if pe_data: - payment_record["mode_of_payment"] = ( - pe_data.mode_of_payment or _derive_payment_method(pe_data) - ) - payment_record["reference"] = pe_data.name - payment_record["payment_entry"] = pe_data.name - else: - # Payment Entry was deleted or doesn't exist - payment_record["mode_of_payment"] = "Unknown" - frappe.log_error( - title="Missing Payment Entry", - message=f"Payment Ledger references non-existent Payment Entry: {ple.voucher_no}" - ) - - payments.append(payment_record) - - # Calculate totals from invoice (most reliable source) - total_paid = flt(invoice.grand_total) - flt(invoice.outstanding_amount) - - return { - "payments": payments, - "total_paid": total_paid, - "outstanding": flt(invoice.outstanding_amount), - "grand_total": flt(invoice.grand_total), - "payment_count": len(payments), - "currency": invoice.currency, - } - - -def _determine_payment_source( - payment_ledger_entry: Dict, - payment_entries_map: Dict[str, Any] -) -> str: - """ - Determine the source of a payment for audit trail. - - Args: - payment_ledger_entry: Payment Ledger Entry record - payment_entries_map: Pre-fetched Payment Entry data - - Returns: - str: Payment source label - """ - if payment_ledger_entry.voucher_type == "Sales Invoice": - return PaymentSource.POS.value - elif payment_ledger_entry.voucher_type == "Payment Entry": - pe_data = payment_entries_map.get(payment_ledger_entry.voucher_no) - if pe_data and pe_data.reference_no and pe_data.reference_no.startswith("POS-"): - return PaymentSource.POS_PAYMENT_ENTRY.value - return PaymentSource.PAYMENT_ENTRY.value - - return PaymentSource.UNKNOWN.value + {"invoice_name": invoice_name, "company": invoice.company}, + as_dict=True, + ) + + # Build payment history with details + payments = [] + + # Collect voucher numbers for batch queries (performance optimization) + sales_invoice_vouchers = set() + payment_entry_vouchers = set() + + for ple in payment_ledger_entries: + # Negative amounts are payments (positive is invoice creation) + if ple.amount < 0: + if ple.voucher_type == "Sales Invoice": + sales_invoice_vouchers.add(ple.voucher_no) + elif ple.voucher_type == "Payment Entry": + payment_entry_vouchers.add(ple.voucher_no) + + # Batch fetch Sales Invoice Payments (eliminates N+1 query problem) + si_payments_map = {} + if sales_invoice_vouchers and include_metadata: + si_payments = frappe.get_all( + "Sales Invoice Payment", + filters={"parent": ["in", list(sales_invoice_vouchers)]}, + fields=["parent", "mode_of_payment", "amount", "idx"], + order_by="parent, idx asc", + ) + + # Group by parent invoice + for sip in si_payments: + if sip.parent not in si_payments_map: + si_payments_map[sip.parent] = [] + si_payments_map[sip.parent].append(sip) + + # Batch fetch Payment Entries (eliminates N+1 query problem) + payment_entries_map = {} + if payment_entry_vouchers and include_metadata: + payment_entries = frappe.get_all( + "Payment Entry", + filters={"name": ["in", list(payment_entry_vouchers)]}, + fields=["name", "mode_of_payment", "reference_no", "paid_to", "paid_to_account_type"], + ) + + for pe in payment_entries: + payment_entries_map[pe.name] = pe + + # Process Payment Ledger entries with batched data + for ple in payment_ledger_entries: + # Negative amounts are payments (positive is invoice creation) + if ple.amount < 0: + payment_record = { + "posting_date": ple.posting_date, + "creation": ple.creation, + "amount": abs(flt(ple.amount)), + "voucher_type": ple.voucher_type, + "voucher_no": ple.voucher_no, + "source": _determine_payment_source(ple, payment_entries_map), + "mode_of_payment": None, + "reference": None, + "account": ple.account, + } + + if include_metadata: + # Get mode of payment based on voucher type + if ple.voucher_type == "Sales Invoice": + # This is a POS payment - recorded at invoice submission + pos_payments = si_payments_map.get(ple.voucher_no, []) + + # Match by amount using accounting tolerance + for pos_pay in pos_payments: + if abs(flt(pos_pay.amount) - abs(ple.amount)) < AMOUNT_TOLERANCE: + payment_record["mode_of_payment"] = pos_pay.mode_of_payment + break + + # Fallback to first payment mode if no exact match + if not payment_record["mode_of_payment"] and pos_payments: + payment_record["mode_of_payment"] = pos_payments[0].mode_of_payment + + # Final fallback + if not payment_record["mode_of_payment"]: + payment_record["mode_of_payment"] = DEFAULT_PAYMENT_MODE + + elif ple.voucher_type == "Payment Entry": + # Get Payment Entry details from batched data + pe_data = payment_entries_map.get(ple.voucher_no) + + if pe_data: + payment_record["mode_of_payment"] = pe_data.mode_of_payment or _derive_payment_method( + pe_data + ) + payment_record["reference"] = pe_data.name + payment_record["payment_entry"] = pe_data.name + else: + # Payment Entry was deleted or doesn't exist + payment_record["mode_of_payment"] = "Unknown" + frappe.log_error( + title="Missing Payment Entry", + message=f"Payment Ledger references non-existent Payment Entry: {ple.voucher_no}", + ) + + payments.append(payment_record) + + # Calculate totals from invoice (most reliable source) + total_paid = flt(invoice.grand_total) - flt(invoice.outstanding_amount) + + return { + "payments": payments, + "total_paid": total_paid, + "outstanding": flt(invoice.outstanding_amount), + "grand_total": flt(invoice.grand_total), + "payment_count": len(payments), + "currency": invoice.currency, + } + + +def _determine_payment_source(payment_ledger_entry: Dict, payment_entries_map: Dict[str, Any]) -> str: + """ + Determine the source of a payment for audit trail. + + Args: + payment_ledger_entry: Payment Ledger Entry record + payment_entries_map: Pre-fetched Payment Entry data + + Returns: + str: Payment source label + """ + if payment_ledger_entry.voucher_type == "Sales Invoice": + return PaymentSource.POS.value + elif payment_ledger_entry.voucher_type == "Payment Entry": + pe_data = payment_entries_map.get(payment_ledger_entry.voucher_no) + if pe_data and pe_data.reference_no and pe_data.reference_no.startswith("POS-"): + return PaymentSource.POS_PAYMENT_ENTRY.value + return PaymentSource.PAYMENT_ENTRY.value + + return PaymentSource.UNKNOWN.value def _derive_payment_method(payment_entry_data: Dict) -> str: - """ - Derive payment method from Payment Entry when mode_of_payment is not set. - - Fallback logic: - 1. Check paid_to_account_type (Bank, Cash) - 2. Extract account name from paid_to - 3. Default to Unknown - - Args: - payment_entry_data: Payment Entry data dict - - Returns: - str: Derived payment method name - """ - account_type = payment_entry_data.get("paid_to_account_type") - - if account_type == "Bank": - paid_to = payment_entry_data.get("paid_to", "") - account_name = paid_to.split(" - ")[0] if " - " in paid_to else paid_to - return f"Bank ({account_name})" if account_name else "Bank" - elif account_type == "Cash": - return "Cash" - - return account_type or "Unknown" - - -def enrich_invoice_with_payment_history( - invoice: Dict, - include_metadata: bool = True -) -> Dict: - """ - Enrich invoice dict with payment history from Payment Ledger. - - Uses Payment Ledger as single source of truth. This ensures - accounting integrity and proper audit trail. - - Modifies invoice dict in-place and returns it. - - Args: - invoice: Invoice dict from frappe.get_all() - include_metadata: If False, skips detailed payment metadata for performance - - Returns: - dict: Invoice enriched with payment history - - Raises: - Exception: If payment history fetch fails - """ - try: - payment_data = get_payment_history( - invoice.get("name"), - include_metadata=include_metadata - ) - - invoice.update({ - "payments": payment_data["payments"], - "paid_amount": payment_data["total_paid"], - "outstanding_amount": payment_data["outstanding"], - "payment_count": payment_data["payment_count"], - }) - except Exception as e: - # Log but don't fail - return invoice without payment history - frappe.log_error( - title=f"Failed to enrich invoice {invoice.get('name')} with payment history", - message=frappe.get_traceback() - ) - # Set defaults - invoice.update({ - "payments": [], - "payment_count": 0, - }) - - return invoice + """ + Derive payment method from Payment Entry when mode_of_payment is not set. + + Fallback logic: + 1. Check paid_to_account_type (Bank, Cash) + 2. Extract account name from paid_to + 3. Default to Unknown + + Args: + payment_entry_data: Payment Entry data dict + + Returns: + str: Derived payment method name + """ + account_type = payment_entry_data.get("paid_to_account_type") + + if account_type == "Bank": + paid_to = payment_entry_data.get("paid_to", "") + account_name = paid_to.split(" - ")[0] if " - " in paid_to else paid_to + return f"Bank ({account_name})" if account_name else "Bank" + elif account_type == "Cash": + return "Cash" + + return account_type or "Unknown" + + +def enrich_invoice_with_payment_history(invoice: Dict, include_metadata: bool = True) -> Dict: + """ + Enrich invoice dict with payment history from Payment Ledger. + + Uses Payment Ledger as single source of truth. This ensures + accounting integrity and proper audit trail. + + Modifies invoice dict in-place and returns it. + + Args: + invoice: Invoice dict from frappe.get_all() + include_metadata: If False, skips detailed payment metadata for performance + + Returns: + dict: Invoice enriched with payment history + + Raises: + Exception: If payment history fetch fails + """ + try: + payment_data = get_payment_history(invoice.get("name"), include_metadata=include_metadata) + + invoice.update( + { + "payments": payment_data["payments"], + "paid_amount": payment_data["total_paid"], + "outstanding_amount": payment_data["outstanding"], + "payment_count": payment_data["payment_count"], + } + ) + except Exception as e: + # Log but don't fail - return invoice without payment history + frappe.log_error( + title=f"Failed to enrich invoice {invoice.get('name')} with payment history", + message=frappe.get_traceback(), + ) + # Set defaults + invoice.update( + { + "payments": [], + "payment_count": 0, + } + ) + + return invoice # ========================================== @@ -345,147 +339,143 @@ def enrich_invoice_with_payment_history( def create_payment_entry( - invoice_name: str, - amount: float, - mode_of_payment: str = DEFAULT_PAYMENT_MODE, - payment_account: Optional[str] = None, - reference_no: Optional[str] = None, - remarks: Optional[str] = None, - posting_date: Optional[str] = None, + invoice_name: str, + amount: float, + mode_of_payment: str = DEFAULT_PAYMENT_MODE, + payment_account: Optional[str] = None, + reference_no: Optional[str] = None, + remarks: Optional[str] = None, + posting_date: Optional[str] = None, ) -> str: - """ - Create a proper Payment Entry that updates Payment Ledger. - - This is the ONLY correct way to add payments to a submitted invoice. - Never modify Sales Invoice Payment child table after submission! - - Business Rules Enforced: - - Invoice must be submitted (docstatus = 1) - - Invoice must not be cancelled - - Amount must be positive - - Amount must not exceed outstanding - - Payment date must not be before invoice date - - Currency must match - - Args: - invoice_name: Sales Invoice name - amount: Payment amount (must be positive) - mode_of_payment: Mode of Payment name - payment_account: Optional specific account to use - reference_no: Optional reference number - remarks: Optional remarks - posting_date: Optional posting date (defaults to today) - - Returns: - str: Created Payment Entry name - - Raises: - frappe.ValidationError: If validation fails - frappe.DoesNotExistError: If invoice doesn't exist - frappe.PermissionError: If user lacks permission - """ - # Input validation - if not invoice_name or not isinstance(invoice_name, str): - frappe.throw(_("Invalid invoice name provided")) - - amount = flt(amount) - if amount <= 0: - frappe.throw(_("Payment amount must be greater than zero")) - - # Get invoice using ORM with permission check - try: - invoice = frappe.get_doc("Sales Invoice", invoice_name) - except frappe.DoesNotExistError: - frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) - - # Validate invoice state - if invoice.docstatus != 1: - frappe.throw(_("Invoice must be submitted before adding payments")) - - if invoice.docstatus == 2: - frappe.throw(_("Cannot add payment to cancelled invoice")) - - # Validate amount doesn't exceed outstanding - if amount > flt(invoice.outstanding_amount) + AMOUNT_TOLERANCE: - frappe.throw( - _("Payment amount {0} exceeds outstanding amount {1}").format( - frappe.format_value(amount, {"fieldtype": "Currency"}), - frappe.format_value(invoice.outstanding_amount, {"fieldtype": "Currency"}), - ) - ) - - # Validate posting date - posting_date = posting_date or nowdate() - if get_datetime(posting_date) < get_datetime(invoice.posting_date): - frappe.throw( - _("Payment date {0} cannot be before invoice date {1}").format( - posting_date, invoice.posting_date - ) - ) - - # Validate mode of payment exists - if not frappe.db.exists("Mode of Payment", mode_of_payment): - frappe.throw(_("Mode of Payment {0} does not exist").format(mode_of_payment)) - - # Save and submit with proper error handling - try: - from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry - from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account - - if payment_account: - if not frappe.db.exists("Account", payment_account): - frappe.throw(_("Payment account {0} does not exist").format(payment_account)) - else: - account_info = get_bank_cash_account(mode_of_payment, invoice.company) - if not account_info or not account_info.get("account"): - frappe.throw( - _("Could not determine payment account for {0}. Please specify payment_account parameter.").format( - mode_of_payment - ) - ) - payment_account = account_info.get("account") - - pe = get_payment_entry( - "Sales Invoice", - invoice_name, - party_amount=amount, - bank_account=payment_account, - reference_date=posting_date, - ) - pe.posting_date = posting_date - pe.reference_date = posting_date - pe.mode_of_payment = mode_of_payment - - if reference_no: - pe.reference_no = str(reference_no)[:140] - else: - pe.reference_no = f"POS-{invoice_name}" - - if remarks: - pe.remarks = str(remarks)[:500] - else: - pe.remarks = f"Payment for {invoice_name} via POS - {mode_of_payment}" - - # Allow system to create payment entry even if user doesn't have direct permission - # This is safe because we've already validated invoice access - pe.flags.ignore_permissions = True - pe.insert() - pe.submit() - - return pe.name - - except frappe.ValidationError as e: - frappe.log_error( - title=f"Payment Entry Validation Failed for {invoice_name}", - message=frappe.get_traceback() - ) - raise - except Exception as e: - frappe.log_error( - title=f"Payment Entry Creation Failed for {invoice_name}", - message=frappe.get_traceback() - ) - frappe.throw(_("Failed to create payment entry: {0}").format(str(e))) + """ + Create a proper Payment Entry that updates Payment Ledger. + + This is the ONLY correct way to add payments to a submitted invoice. + Never modify Sales Invoice Payment child table after submission! + + Business Rules Enforced: + - Invoice must be submitted (docstatus = 1) + - Invoice must not be cancelled + - Amount must be positive + - Amount must not exceed outstanding + - Payment date must not be before invoice date + - Currency must match + + Args: + invoice_name: Sales Invoice name + amount: Payment amount (must be positive) + mode_of_payment: Mode of Payment name + payment_account: Optional specific account to use + reference_no: Optional reference number + remarks: Optional remarks + posting_date: Optional posting date (defaults to today) + + Returns: + str: Created Payment Entry name + + Raises: + frappe.ValidationError: If validation fails + frappe.DoesNotExistError: If invoice doesn't exist + frappe.PermissionError: If user lacks permission + """ + # Input validation + if not invoice_name or not isinstance(invoice_name, str): + frappe.throw(_("Invalid invoice name provided")) + + amount = flt(amount) + if amount <= 0: + frappe.throw(_("Payment amount must be greater than zero")) + + # Get invoice using ORM with permission check + try: + invoice = frappe.get_doc("Sales Invoice", invoice_name) + except frappe.DoesNotExistError: + frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) + + # Validate invoice state + if invoice.docstatus != 1: + frappe.throw(_("Invoice must be submitted before adding payments")) + + if invoice.docstatus == 2: + frappe.throw(_("Cannot add payment to cancelled invoice")) + + # Validate amount doesn't exceed outstanding + if amount > flt(invoice.outstanding_amount) + AMOUNT_TOLERANCE: + frappe.throw( + _("Payment amount {0} exceeds outstanding amount {1}").format( + frappe.format_value(amount, {"fieldtype": "Currency"}), + frappe.format_value(invoice.outstanding_amount, {"fieldtype": "Currency"}), + ) + ) + + # Validate posting date + posting_date = posting_date or nowdate() + if get_datetime(posting_date) < get_datetime(invoice.posting_date): + frappe.throw( + _("Payment date {0} cannot be before invoice date {1}").format(posting_date, invoice.posting_date) + ) + + # Validate mode of payment exists + if not frappe.db.exists("Mode of Payment", mode_of_payment): + frappe.throw(_("Mode of Payment {0} does not exist").format(mode_of_payment)) + + # Save and submit with proper error handling + try: + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account + + if payment_account: + if not frappe.db.exists("Account", payment_account): + frappe.throw(_("Payment account {0} does not exist").format(payment_account)) + else: + account_info = get_bank_cash_account(mode_of_payment, invoice.company) + if not account_info or not account_info.get("account"): + frappe.throw( + _( + "Could not determine payment account for {0}. Please specify payment_account parameter." + ).format(mode_of_payment) + ) + payment_account = account_info.get("account") + + pe = get_payment_entry( + "Sales Invoice", + invoice_name, + party_amount=amount, + bank_account=payment_account, + reference_date=posting_date, + ) + pe.posting_date = posting_date + pe.reference_date = posting_date + pe.mode_of_payment = mode_of_payment + + if reference_no: + pe.reference_no = str(reference_no)[:140] + else: + pe.reference_no = f"POS-{invoice_name}" + + if remarks: + pe.remarks = str(remarks)[:500] + else: + pe.remarks = f"Payment for {invoice_name} via POS - {mode_of_payment}" + + # Allow system to create payment entry even if user doesn't have direct permission + # This is safe because we've already validated invoice access + pe.flags.ignore_permissions = True + pe.insert() + pe.submit() + + return pe.name + + except frappe.ValidationError as e: + frappe.log_error( + title=f"Payment Entry Validation Failed for {invoice_name}", message=frappe.get_traceback() + ) + raise + except Exception as e: + frappe.log_error( + title=f"Payment Entry Creation Failed for {invoice_name}", message=frappe.get_traceback() + ) + frappe.throw(_("Failed to create payment entry: {0}").format(str(e))) # ========================================== @@ -495,384 +485,380 @@ def create_payment_entry( @frappe.whitelist() def get_partial_paid_invoices(pos_profile: str, limit: int = DEFAULT_INVOICE_LIMIT) -> List[Dict]: - """ - Get partially paid invoices for a POS Profile. - - A partially paid invoice has: - - Outstanding amount > 0 (not fully paid) - - Paid amount > 0 (not fully unpaid) - - Can be in any status including "Overdue" - - Args: - pos_profile: POS Profile name - limit: Maximum invoices to return (default 50, max 500) - - Returns: - List[dict]: Invoices with payment history from Payment Ledger - - Raises: - frappe.ValidationError: If validation fails - frappe.PermissionError: If user lacks access - """ - # Input validation - if not pos_profile: - frappe.throw(_("POS Profile is required")) - - # Validate POS Profile exists - if not frappe.db.exists("POS Profile", pos_profile): - frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) - - # Check permissions - if not _has_pos_profile_access(pos_profile): - frappe.throw(_("You don't have access to this POS Profile")) - - # Validate and sanitize limit - limit = cint(limit) - if limit <= 0: - limit = DEFAULT_INVOICE_LIMIT - elif limit > MAX_INVOICE_LIMIT: - limit = MAX_INVOICE_LIMIT - - # Get partially paid invoices using ORM - # Filter logic: outstanding > 0 AND paid > 0 (mathematical definition of partial payment) - invoices = frappe.get_all( - "Sales Invoice", - filters={ - "pos_profile": pos_profile, - "docstatus": 1, - "is_pos": 1, - "outstanding_amount": [">", 0], - "paid_amount": [">", 0], - "is_return": 0, - }, - fields=[ - "name", - "customer", - "customer_name", - "posting_date", - "posting_time", - "grand_total", - "paid_amount", - "outstanding_amount", - "status", - "creation", - "currency", - ], - order_by="posting_date desc, posting_time desc", - limit=limit, - ) - - # Enrich with payment history - # Note: This makes additional queries. For summary-only views, use get_partial_payment_summary() instead. - for invoice in invoices: - enrich_invoice_with_payment_history(invoice, include_metadata=True) - - return invoices + """ + Get partially paid invoices for a POS Profile. + + A partially paid invoice has: + - Outstanding amount > 0 (not fully paid) + - Paid amount > 0 (not fully unpaid) + - Can be in any status including "Overdue" + + Args: + pos_profile: POS Profile name + limit: Maximum invoices to return (default 50, max 500) + + Returns: + List[dict]: Invoices with payment history from Payment Ledger + + Raises: + frappe.ValidationError: If validation fails + frappe.PermissionError: If user lacks access + """ + # Input validation + if not pos_profile: + frappe.throw(_("POS Profile is required")) + + # Validate POS Profile exists + if not frappe.db.exists("POS Profile", pos_profile): + frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) + + # Check permissions + if not _has_pos_profile_access(pos_profile): + frappe.throw(_("You don't have access to this POS Profile")) + + # Validate and sanitize limit + limit = cint(limit) + if limit <= 0: + limit = DEFAULT_INVOICE_LIMIT + elif limit > MAX_INVOICE_LIMIT: + limit = MAX_INVOICE_LIMIT + + # Get partially paid invoices using ORM + # Filter logic: outstanding > 0 AND paid > 0 (mathematical definition of partial payment) + invoices = frappe.get_all( + "Sales Invoice", + filters={ + "pos_profile": pos_profile, + "docstatus": 1, + "is_pos": 1, + "outstanding_amount": [">", 0], + "paid_amount": [">", 0], + "is_return": 0, + }, + fields=[ + "name", + "customer", + "customer_name", + "posting_date", + "posting_time", + "grand_total", + "paid_amount", + "outstanding_amount", + "status", + "creation", + "currency", + ], + order_by="posting_date desc, posting_time desc", + limit=limit, + ) + + # Enrich with payment history + # Note: This makes additional queries. For summary-only views, use get_partial_payment_summary() instead. + for invoice in invoices: + enrich_invoice_with_payment_history(invoice, include_metadata=True) + + return invoices @frappe.whitelist() def get_unpaid_invoices(pos_profile: str, limit: int = DEFAULT_INVOICE_LIMIT) -> List[Dict]: - """ - Get all unpaid invoices (partial + fully unpaid) for a POS Profile. - - Includes: - - Fully unpaid invoices (paid_amount = 0) - - Partially paid invoices (0 < paid_amount < grand_total) - - Overdue invoices (any invoice with outstanding > 0) - - Args: - pos_profile: POS Profile name - limit: Maximum invoices to return (default 50, max 500) - - Returns: - List[dict]: Unpaid invoices with payment history - - Raises: - frappe.ValidationError: If validation fails - frappe.PermissionError: If user lacks access - """ - # Input validation - if not pos_profile: - frappe.throw(_("POS Profile is required")) - - # Validate POS Profile exists - if not frappe.db.exists("POS Profile", pos_profile): - frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) - - if not _has_pos_profile_access(pos_profile): - frappe.throw(_("You don't have access to this POS Profile")) - - # Validate and sanitize limit - limit = cint(limit) - if limit <= 0: - limit = DEFAULT_INVOICE_LIMIT - elif limit > MAX_INVOICE_LIMIT: - limit = MAX_INVOICE_LIMIT - - # Get all unpaid invoices (any invoice with outstanding > 0) - invoices = frappe.get_all( - "Sales Invoice", - filters={ - "pos_profile": pos_profile, - "docstatus": 1, - "is_pos": 1, - "outstanding_amount": [">", 0], - "is_return": 0, - }, - fields=[ - "name", - "customer", - "customer_name", - "posting_date", - "posting_time", - "grand_total", - "paid_amount", - "outstanding_amount", - "status", - "creation", - "currency", - ], - order_by="posting_date desc, posting_time desc", - limit=limit, - ) - - # Enrich with payment history - for invoice in invoices: - enrich_invoice_with_payment_history(invoice, include_metadata=True) - - return invoices + """ + Get all unpaid invoices (partial + fully unpaid) for a POS Profile. + + Includes: + - Fully unpaid invoices (paid_amount = 0) + - Partially paid invoices (0 < paid_amount < grand_total) + - Overdue invoices (any invoice with outstanding > 0) + + Args: + pos_profile: POS Profile name + limit: Maximum invoices to return (default 50, max 500) + + Returns: + List[dict]: Unpaid invoices with payment history + + Raises: + frappe.ValidationError: If validation fails + frappe.PermissionError: If user lacks access + """ + # Input validation + if not pos_profile: + frappe.throw(_("POS Profile is required")) + + # Validate POS Profile exists + if not frappe.db.exists("POS Profile", pos_profile): + frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) + + if not _has_pos_profile_access(pos_profile): + frappe.throw(_("You don't have access to this POS Profile")) + + # Validate and sanitize limit + limit = cint(limit) + if limit <= 0: + limit = DEFAULT_INVOICE_LIMIT + elif limit > MAX_INVOICE_LIMIT: + limit = MAX_INVOICE_LIMIT + + # Get all unpaid invoices (any invoice with outstanding > 0) + invoices = frappe.get_all( + "Sales Invoice", + filters={ + "pos_profile": pos_profile, + "docstatus": 1, + "is_pos": 1, + "outstanding_amount": [">", 0], + "is_return": 0, + }, + fields=[ + "name", + "customer", + "customer_name", + "posting_date", + "posting_time", + "grand_total", + "paid_amount", + "outstanding_amount", + "status", + "creation", + "currency", + ], + order_by="posting_date desc, posting_time desc", + limit=limit, + ) + + # Enrich with payment history + for invoice in invoices: + enrich_invoice_with_payment_history(invoice, include_metadata=True) + + return invoices @frappe.whitelist() def get_partial_payment_details(invoice_name: str) -> Dict: - """ - Get detailed payment information for an invoice. - - Includes complete payment history, items, and invoice details. - - Args: - invoice_name: Sales Invoice name - - Returns: - dict: Complete invoice details with payment history - - Raises: - frappe.ValidationError: If validation fails - frappe.PermissionError: If user lacks permission - frappe.DoesNotExistError: If invoice doesn't exist - """ - # Input validation - if not invoice_name: - frappe.throw(_("Invoice name is required")) - - # Permission check - if not frappe.has_permission("Sales Invoice", "read", invoice_name): - frappe.throw(_("You don't have permission to view this invoice")) - - # Get invoice using ORM - try: - invoice = frappe.get_doc("Sales Invoice", invoice_name) - except frappe.DoesNotExistError: - frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) - - # Get payment history - payment_data = get_payment_history(invoice_name, include_metadata=True) - - # Get items with proper data types - items = [ - { - "item_code": item.item_code, - "item_name": item.item_name, - "qty": flt(item.qty), - "rate": flt(item.rate), - "amount": flt(item.amount), - "uom": item.uom, - } - for item in invoice.items - ] - - return { - "name": invoice.name, - "customer": invoice.customer, - "customer_name": invoice.customer_name, - "posting_date": invoice.posting_date, - "posting_time": invoice.posting_time, - "grand_total": flt(invoice.grand_total), - "paid_amount": payment_data["total_paid"], - "outstanding_amount": payment_data["outstanding"], - "status": invoice.status, - "currency": invoice.currency, - "payments": payment_data["payments"], - "payment_count": payment_data["payment_count"], - "items": items, - "item_count": len(items), - } + """ + Get detailed payment information for an invoice. + + Includes complete payment history, items, and invoice details. + + Args: + invoice_name: Sales Invoice name + + Returns: + dict: Complete invoice details with payment history + + Raises: + frappe.ValidationError: If validation fails + frappe.PermissionError: If user lacks permission + frappe.DoesNotExistError: If invoice doesn't exist + """ + # Input validation + if not invoice_name: + frappe.throw(_("Invoice name is required")) + + # Permission check + if not frappe.has_permission("Sales Invoice", "read", invoice_name): + frappe.throw(_("You don't have permission to view this invoice")) + + # Get invoice using ORM + try: + invoice = frappe.get_doc("Sales Invoice", invoice_name) + except frappe.DoesNotExistError: + frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) + + # Get payment history + payment_data = get_payment_history(invoice_name, include_metadata=True) + + # Get items with proper data types + items = [ + { + "item_code": item.item_code, + "item_name": item.item_name, + "qty": flt(item.qty), + "rate": flt(item.rate), + "amount": flt(item.amount), + "uom": item.uom, + } + for item in invoice.items + ] + + return { + "name": invoice.name, + "customer": invoice.customer, + "customer_name": invoice.customer_name, + "posting_date": invoice.posting_date, + "posting_time": invoice.posting_time, + "grand_total": flt(invoice.grand_total), + "paid_amount": payment_data["total_paid"], + "outstanding_amount": payment_data["outstanding"], + "status": invoice.status, + "currency": invoice.currency, + "payments": payment_data["payments"], + "payment_count": payment_data["payment_count"], + "items": items, + "item_count": len(items), + } @frappe.whitelist() def add_payment_to_partial_invoice(invoice_name: str, payments) -> Dict: - """ - Add payments to a partially paid invoice via Payment Entry. - - Creates proper Payment Entry documents that update Payment Ledger. - This is the ONLY correct way to add payments after invoice submission. - - Transactional: If any payment fails, the batch is rolled back to a savepoint - so no submitted Payment Entry from this request is persisted. - - Args: - invoice_name: Sales Invoice name - payments: List of payment dicts with keys: - - mode_of_payment: Mode of Payment name - - amount: Payment amount (positive number) - - account: (optional) Specific payment account - - reference_no: (optional) Reference number - Can also accept JSON string which will be parsed. - - Returns: - dict: Updated invoice details with created Payment Entry names - - Raises: - frappe.ValidationError: If validation fails - frappe.PermissionError: If user lacks permission - - Example: - >>> add_payment_to_partial_invoice( - ... "SINV-00001", - ... [ - ... {"mode_of_payment": "Cash", "amount": 100.00}, - ... {"mode_of_payment": "Card", "amount": 50.00} - ... ] - ... ) - """ - import json - - # Input validation - if not invoice_name: - frappe.throw(_("Invoice name is required")) - - # Parse payments if string, otherwise use as-is - if isinstance(payments, str): - try: - payments = json.loads(payments) - except json.JSONDecodeError: - frappe.throw(_("Invalid payments payload: malformed JSON")) - - # Ensure it's a list - if not isinstance(payments, list): - frappe.throw(_("Payments must be a list")) - - if not payments: - frappe.throw(_("At least one payment is required")) - - # Permission check - if not frappe.has_permission("Sales Invoice", "write", invoice_name): - frappe.throw(_("You don't have permission to add payments to this invoice")) - - # Validate total payment amount doesn't exceed outstanding - try: - invoice = frappe.get_doc("Sales Invoice", invoice_name) - except frappe.DoesNotExistError: - frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) - - total_payment_amount = sum(flt(p.get("amount", 0)) for p in payments) - if total_payment_amount > flt(invoice.outstanding_amount) + AMOUNT_TOLERANCE: - frappe.throw( - _("Total payment amount {0} exceeds outstanding amount {1}").format( - frappe.format_value(total_payment_amount, {"fieldtype": "Currency"}), - frappe.format_value(invoice.outstanding_amount, {"fieldtype": "Currency"}), - ) - ) - - # Create Payment Entries inside one savepoint-backed batch. - payment_entries_created = [] - batch_savepoint = "partial_payment_batch" - - try: - frappe.db.savepoint(batch_savepoint) - - for idx, payment in enumerate(payments, 1): - amount = flt(payment.get("amount", 0)) - - # Skip zero amounts - if amount <= 0: - frappe.log_error( - title=f"Skipped zero payment for {invoice_name}", - message=f"Payment #{idx}: {payment}" - ) - continue - - mode_of_payment = payment.get("mode_of_payment") or DEFAULT_PAYMENT_MODE - payment_account = payment.get("account") - reference_no = payment.get("reference_no") - - pe_name = create_payment_entry( - invoice_name=invoice_name, - amount=amount, - mode_of_payment=mode_of_payment, - payment_account=payment_account, - reference_no=reference_no, - remarks=f"POS Payment - {mode_of_payment}", - ) - - payment_entries_created.append(pe_name) - - except Exception as e: - frappe.db.rollback(save_point=batch_savepoint) - frappe.log_error( - title=f"Payment Entry Creation Failed for {invoice_name}", - message=f"Payments: {payments}\nError: {str(e)}\n\n{frappe.get_traceback()}", - ) - frappe.throw( - _("Failed to create payment entry: {0}. All changes have been rolled back.").format(str(e)) - ) - - # Get updated invoice details - result = get_partial_payment_details(invoice_name) - result["payment_entries_created"] = payment_entries_created - result["success"] = True - - return result + """ + Add payments to a partially paid invoice via Payment Entry. + + Creates proper Payment Entry documents that update Payment Ledger. + This is the ONLY correct way to add payments after invoice submission. + + Transactional: If any payment fails, the batch is rolled back to a savepoint + so no submitted Payment Entry from this request is persisted. + + Args: + invoice_name: Sales Invoice name + payments: List of payment dicts with keys: + - mode_of_payment: Mode of Payment name + - amount: Payment amount (positive number) + - account: (optional) Specific payment account + - reference_no: (optional) Reference number + Can also accept JSON string which will be parsed. + + Returns: + dict: Updated invoice details with created Payment Entry names + + Raises: + frappe.ValidationError: If validation fails + frappe.PermissionError: If user lacks permission + + Example: + >>> add_payment_to_partial_invoice( + ... "SINV-00001", + ... [{"mode_of_payment": "Cash", "amount": 100.00}, {"mode_of_payment": "Card", "amount": 50.00}], + ... ) + """ + import json + + # Input validation + if not invoice_name: + frappe.throw(_("Invoice name is required")) + + # Parse payments if string, otherwise use as-is + if isinstance(payments, str): + try: + payments = json.loads(payments) + except json.JSONDecodeError: + frappe.throw(_("Invalid payments payload: malformed JSON")) + + # Ensure it's a list + if not isinstance(payments, list): + frappe.throw(_("Payments must be a list")) + + if not payments: + frappe.throw(_("At least one payment is required")) + + # Permission check + if not frappe.has_permission("Sales Invoice", "write", invoice_name): + frappe.throw(_("You don't have permission to add payments to this invoice")) + + # Validate total payment amount doesn't exceed outstanding + try: + invoice = frappe.get_doc("Sales Invoice", invoice_name) + except frappe.DoesNotExistError: + frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) + + total_payment_amount = sum(flt(p.get("amount", 0)) for p in payments) + if total_payment_amount > flt(invoice.outstanding_amount) + AMOUNT_TOLERANCE: + frappe.throw( + _("Total payment amount {0} exceeds outstanding amount {1}").format( + frappe.format_value(total_payment_amount, {"fieldtype": "Currency"}), + frappe.format_value(invoice.outstanding_amount, {"fieldtype": "Currency"}), + ) + ) + + # Create Payment Entries inside one savepoint-backed batch. + payment_entries_created = [] + batch_savepoint = "partial_payment_batch" + + try: + frappe.db.savepoint(batch_savepoint) + + for idx, payment in enumerate(payments, 1): + amount = flt(payment.get("amount", 0)) + + # Skip zero amounts + if amount <= 0: + frappe.log_error( + title=f"Skipped zero payment for {invoice_name}", message=f"Payment #{idx}: {payment}" + ) + continue + + mode_of_payment = payment.get("mode_of_payment") or DEFAULT_PAYMENT_MODE + payment_account = payment.get("account") + reference_no = payment.get("reference_no") + + pe_name = create_payment_entry( + invoice_name=invoice_name, + amount=amount, + mode_of_payment=mode_of_payment, + payment_account=payment_account, + reference_no=reference_no, + remarks=f"POS Payment - {mode_of_payment}", + ) + + payment_entries_created.append(pe_name) + + except Exception as e: + frappe.db.rollback(save_point=batch_savepoint) + frappe.log_error( + title=f"Payment Entry Creation Failed for {invoice_name}", + message=f"Payments: {payments}\nError: {str(e)}\n\n{frappe.get_traceback()}", + ) + frappe.throw( + _("Failed to create payment entry: {0}. All changes have been rolled back.").format(str(e)) + ) + + # Get updated invoice details + result = get_partial_payment_details(invoice_name) + result["payment_entries_created"] = payment_entries_created + result["success"] = True + + return result @frappe.whitelist() def get_partial_payment_summary(pos_profile: str) -> Dict: - """ - Get summary statistics for partial payments. - - Performance: Uses direct SQL aggregation - single query, no N+1 issues. - Use this for dashboard views instead of fetching full invoice lists. - - Args: - pos_profile: POS Profile name - - Returns: - dict: { - 'count': Number of partially paid invoices, - 'total_outstanding': Sum of outstanding amounts, - 'total_paid': Sum of paid amounts, - 'total_grand_total': Sum of invoice totals - } - - Raises: - frappe.ValidationError: If validation fails - frappe.PermissionError: If user lacks access - """ - # Input validation - if not pos_profile: - frappe.throw(_("POS Profile is required")) - - # Validate POS Profile exists - if not frappe.db.exists("POS Profile", pos_profile): - frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) - - if not _has_pos_profile_access(pos_profile): - frappe.throw(_("You don't have access to this POS Profile")) - - # Use direct SQL aggregation - single query instead of N queries - # This is critical for performance with large datasets - summary = frappe.db.sql( - """ + """ + Get summary statistics for partial payments. + + Performance: Uses direct SQL aggregation - single query, no N+1 issues. + Use this for dashboard views instead of fetching full invoice lists. + + Args: + pos_profile: POS Profile name + + Returns: + dict: { + 'count': Number of partially paid invoices, + 'total_outstanding': Sum of outstanding amounts, + 'total_paid': Sum of paid amounts, + 'total_grand_total': Sum of invoice totals + } + + Raises: + frappe.ValidationError: If validation fails + frappe.PermissionError: If user lacks access + """ + # Input validation + if not pos_profile: + frappe.throw(_("POS Profile is required")) + + # Validate POS Profile exists + if not frappe.db.exists("POS Profile", pos_profile): + frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) + + if not _has_pos_profile_access(pos_profile): + frappe.throw(_("You don't have access to this POS Profile")) + + # Use direct SQL aggregation - single query instead of N queries + # This is critical for performance with large datasets + summary = frappe.db.sql( + """ SELECT COUNT(*) as count, COALESCE(SUM(outstanding_amount), 0) as total_outstanding, @@ -886,55 +872,55 @@ def get_partial_payment_summary(pos_profile: str) -> Dict: AND paid_amount > 0 AND is_return = 0 """, - {"pos_profile": pos_profile}, - as_dict=True, - )[0] + {"pos_profile": pos_profile}, + as_dict=True, + )[0] - return { - "count": cint(summary.get("count")), - "total_outstanding": flt(summary.get("total_outstanding")), - "total_paid": flt(summary.get("total_paid")), - "total_grand_total": flt(summary.get("total_grand_total")), - } + return { + "count": cint(summary.get("count")), + "total_outstanding": flt(summary.get("total_outstanding")), + "total_paid": flt(summary.get("total_paid")), + "total_grand_total": flt(summary.get("total_grand_total")), + } @frappe.whitelist() def get_unpaid_summary(pos_profile: str) -> Dict: - """ - Get summary statistics for all unpaid invoices. - - Performance: Uses direct SQL aggregation - single query, no N+1 issues. - Use this for dashboard views instead of fetching full invoice lists. - - Args: - pos_profile: POS Profile name - - Returns: - dict: { - 'count': Number of unpaid invoices, - 'total_outstanding': Sum of outstanding amounts, - 'total_paid': Sum of paid amounts, - 'total_grand_total': Sum of invoice totals - } - - Raises: - frappe.ValidationError: If validation fails - frappe.PermissionError: If user lacks access - """ - # Input validation - if not pos_profile: - frappe.throw(_("POS Profile is required")) - - # Validate POS Profile exists - if not frappe.db.exists("POS Profile", pos_profile): - frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) - - if not _has_pos_profile_access(pos_profile): - frappe.throw(_("You don't have access to this POS Profile")) - - # Use direct SQL aggregation - critical for performance - summary = frappe.db.sql( - """ + """ + Get summary statistics for all unpaid invoices. + + Performance: Uses direct SQL aggregation - single query, no N+1 issues. + Use this for dashboard views instead of fetching full invoice lists. + + Args: + pos_profile: POS Profile name + + Returns: + dict: { + 'count': Number of unpaid invoices, + 'total_outstanding': Sum of outstanding amounts, + 'total_paid': Sum of paid amounts, + 'total_grand_total': Sum of invoice totals + } + + Raises: + frappe.ValidationError: If validation fails + frappe.PermissionError: If user lacks access + """ + # Input validation + if not pos_profile: + frappe.throw(_("POS Profile is required")) + + # Validate POS Profile exists + if not frappe.db.exists("POS Profile", pos_profile): + frappe.throw(_("POS Profile {0} does not exist").format(pos_profile)) + + if not _has_pos_profile_access(pos_profile): + frappe.throw(_("You don't have access to this POS Profile")) + + # Use direct SQL aggregation - critical for performance + summary = frappe.db.sql( + """ SELECT COUNT(*) as count, COALESCE(SUM(outstanding_amount), 0) as total_outstanding, @@ -947,16 +933,16 @@ def get_unpaid_summary(pos_profile: str) -> Dict: AND outstanding_amount > 0 AND is_return = 0 """, - {"pos_profile": pos_profile}, - as_dict=True, - )[0] + {"pos_profile": pos_profile}, + as_dict=True, + )[0] - return { - "count": cint(summary.get("count")), - "total_outstanding": flt(summary.get("total_outstanding")), - "total_paid": flt(summary.get("total_paid")), - "total_grand_total": flt(summary.get("total_grand_total")), - } + return { + "count": cint(summary.get("count")), + "total_outstanding": flt(summary.get("total_outstanding")), + "total_paid": flt(summary.get("total_paid")), + "total_grand_total": flt(summary.get("total_grand_total")), + } # ========================================== @@ -965,29 +951,25 @@ def get_unpaid_summary(pos_profile: str) -> Dict: def _has_pos_profile_access(pos_profile: str) -> bool: - """ - Check if current user has access to POS Profile. - - Access is granted if: - - User is in POS Profile User child table, OR - - User has Sales Invoice read permission - - Args: - pos_profile: POS Profile name - - Returns: - bool: True if user has access - """ - # Check if user is explicitly assigned to this POS Profile - has_direct_access = frappe.db.exists( - "POS Profile User", - { - "parent": pos_profile, - "user": frappe.session.user - } - ) - - # Check if user has general Sales Invoice permission - has_general_access = frappe.has_permission("Sales Invoice", "read") - - return bool(has_direct_access or has_general_access) + """ + Check if current user has access to POS Profile. + + Access is granted if: + - User is in POS Profile User child table, OR + - User has Sales Invoice read permission + + Args: + pos_profile: POS Profile name + + Returns: + bool: True if user has access + """ + # Check if user is explicitly assigned to this POS Profile + has_direct_access = frappe.db.exists( + "POS Profile User", {"parent": pos_profile, "user": frappe.session.user} + ) + + # Check if user has general Sales Invoice permission + has_general_access = frappe.has_permission("Sales Invoice", "read") + + return bool(has_direct_access or has_general_access) diff --git a/pos_next/api/pos_profile.py b/pos_next/api/pos_profile.py index c357ef9ba..c52382d68 100644 --- a/pos_next/api/pos_profile.py +++ b/pos_next/api/pos_profile.py @@ -3,6 +3,7 @@ # For license information, please see license.txt from __future__ import unicode_literals + import frappe from frappe import _ from pos_next.api.utilities import check_user_company @@ -119,8 +120,8 @@ def get_payment_methods(pos_profile): .on(POSPaymentMethod.mode_of_payment == ModeOfPayment.name) .left_join(ModeOfPaymentAccount) .on( - (ModeOfPaymentAccount.parent == ModeOfPayment.name) & - (ModeOfPaymentAccount.company == company) + (ModeOfPaymentAccount.parent == ModeOfPayment.name) + & (ModeOfPaymentAccount.company == company) ) .left_join(Account) .on(Account.name == ModeOfPaymentAccount.default_account) @@ -129,7 +130,7 @@ def get_payment_methods(pos_profile): POSPaymentMethod.default, POSPaymentMethod.allow_in_returns, Coalesce(ModeOfPayment.type, "Cash").as_("type"), - Coalesce(Account.account_type, "").as_("account_type") + Coalesce(Account.account_type, "").as_("account_type"), ) .where(POSPaymentMethod.parent == pos_profile) .orderby(POSPaymentMethod.idx) @@ -151,7 +152,7 @@ def get_taxes(pos_profile): # Get the POS Profile profile_doc = frappe.get_cached_doc("POS Profile", pos_profile) - taxes_and_charges = getattr(profile_doc, 'taxes_and_charges', None) + taxes_and_charges = getattr(profile_doc, "taxes_and_charges", None) if not taxes_and_charges: return [] @@ -162,14 +163,16 @@ def get_taxes(pos_profile): # Extract tax rows taxes = [] for tax_row in template_doc.taxes: - taxes.append({ - "account_head": tax_row.account_head, - "charge_type": tax_row.charge_type, - "rate": tax_row.rate, - "description": tax_row.description, - "included_in_print_rate": getattr(tax_row, 'included_in_print_rate', 0), - "idx": tax_row.idx - }) + taxes.append( + { + "account_head": tax_row.account_head, + "charge_type": tax_row.charge_type, + "rate": tax_row.rate, + "description": tax_row.description, + "included_in_print_rate": getattr(tax_row, "included_in_print_rate", 0), + "idx": tax_row.idx, + } + ) return taxes except Exception as e: @@ -194,11 +197,7 @@ def get_warehouses(pos_profile): # Get all active warehouses for the company warehouses = frappe.get_list( "Warehouse", - filters={ - "company": company, - "disabled": 0, - "is_group": 0 - }, + filters={"company": company, "disabled": 0, "is_group": 0}, fields=["name", "warehouse_name"], order_by="warehouse_name", limit_page_length=0 @@ -248,8 +247,7 @@ def update_warehouse(pos_profile, warehouse): # Check if user has access to this POS Profile has_access = frappe.db.exists( - "POS Profile User", - {"parent": pos_profile, "user": frappe.session.user} + "POS Profile User", {"parent": pos_profile, "user": frappe.session.user} ) if not has_access and not frappe.has_permission("POS Profile", "write"): @@ -265,19 +263,17 @@ def update_warehouse(pos_profile, warehouse): # Validate warehouse belongs to same company if warehouse_doc.company != profile_doc.company: - frappe.throw(_( - "Warehouse {0} belongs to {1}, but POS Profile belongs to {2}" - ).format(warehouse, warehouse_doc.company, profile_doc.company)) + frappe.throw( + _("Warehouse {0} belongs to {1}, but POS Profile belongs to {2}").format( + warehouse, warehouse_doc.company, profile_doc.company + ) + ) # Update the POS Profile profile_doc.warehouse = warehouse profile_doc.save() - return { - "success": True, - "message": _("Warehouse updated successfully"), - "warehouse": warehouse - } + return {"success": True, "message": _("Warehouse updated successfully"), "warehouse": warehouse} except Exception as e: frappe.log_error(frappe.get_traceback(), "Update Warehouse Error") frappe.throw(_("Error updating warehouse: {0}").format(str(e))) @@ -315,10 +311,7 @@ def get_wallet_payment_flags(methods): query = ( frappe.qb.from_(ModeOfPayment) - .select( - ModeOfPayment.name, - ModeOfPayment.is_wallet_payment - ) + .select(ModeOfPayment.name, ModeOfPayment.is_wallet_payment) .where(ModeOfPayment.name.isin(methods)) ) @@ -334,7 +327,7 @@ def get_sales_persons(pos_profile=None): try: filters = { "enabled": 1, - "is_group": 0 # Only get individual sales persons, not group nodes + "is_group": 0, # Only get individual sales persons, not group nodes } # If company is specified via POS Profile, filter by company (if Sales Person has company field) @@ -349,7 +342,7 @@ def get_sales_persons(pos_profile=None): filters=filters, fields=["name", "sales_person_name", "commission_rate", "employee"], order_by="sales_person_name", - limit_page_length=0 + limit_page_length=0, ) return sales_persons @@ -357,11 +350,12 @@ def get_sales_persons(pos_profile=None): frappe.log_error(frappe.get_traceback(), "Get Sales Persons Error") return [] + @frappe.whitelist() def get_create_pos_profile(*args, **kwargs): """ Get selection data for creating POS Profile - + Returns: - warehouses: Available warehouses for user's company - customers: Available customers @@ -385,40 +379,31 @@ def get_create_pos_profile(*args, **kwargs): "Warehouse", filters={"disabled": 0, "is_group": 0, "company": user_company}, fields=["name"], - order_by="name" + order_by="name", ) customers = frappe.get_list( "Customer", filters={"disabled": 0}, ) - + currencies = frappe.get_list( "Currency", filters={"enabled": 1}, fields=["name", "currency_name", "symbol"], ) - + payments = frappe.get_list("Mode of Payment") - + posa_cash_mode_of_payment = payments - + write_off_accounts = frappe.get_list( "Account", - filters={ - "report_type": "Profit and Loss", - "disabled": 0, - "is_group": 0, - "company": user_company - }, + filters={"report_type": "Profit and Loss", "disabled": 0, "is_group": 0, "company": user_company}, ) write_off_cost_centers = frappe.get_list( "Cost Center", - filters={ - "is_group": 0, - "disabled": 0, - "company": user_company - }, + filters={"is_group": 0, "disabled": 0, "company": user_company}, ) applicable_for_users = frappe.get_list( @@ -427,13 +412,13 @@ def get_create_pos_profile(*args, **kwargs): "enabled": 1, }, fields=["name", "full_name"], - order_by="full_name" + order_by="full_name", ) item_groups = frappe.get_list( "Item Group", filters={"is_group": 0}, ) - + customer_groups = frappe.get_list( "Customer Group", filters={"is_group": 0}, @@ -457,18 +442,19 @@ def get_create_pos_profile(*args, **kwargs): "apply_discount_on_options": [ {"value": "Grand Total", "label": "Grand Total"}, {"value": "Net Total", "label": "Net Total"}, - ] + ], } return data - + except Exception as e: frappe.throw(_("Error getting create POS profile: {0}").format(str(e))) + @frappe.whitelist() -def create_pos_profile(*arg ,**parameters): +def create_pos_profile(*arg, **parameters): """ Create a new POS Profile - + Required fields: - __newname: POS Profile name - currency: Currency code @@ -477,7 +463,7 @@ def create_pos_profile(*arg ,**parameters): - write_off_account: Account name for write-off - write_off_cost_center: Cost center name - write_off_limit: Write-off limit amount - + Optional fields: - customer: Default customer - applicable_for_users: List of users @@ -488,25 +474,24 @@ def create_pos_profile(*arg ,**parameters): - apply_discount_on: Discount application method """ - - # Extract list parameters + # Extract list parameters payments = parameters.pop("payments", []) applicable_for_users = parameters.pop("applicable_for_users", []) item_groups = parameters.pop("item_groups", []) customer_groups = parameters.pop("customer_groups", []) brands = parameters.pop("brands", []) - + # parse list parameters payments = _parse_list_parameter(payments, "payments") applicable_for_users = _parse_list_parameter(applicable_for_users, "applicable_for_users") item_groups = _parse_list_parameter(item_groups, "item_groups") customer_groups = _parse_list_parameter(customer_groups, "customer_groups") brands = _parse_list_parameter(brands, "brands") - + # Get user's company user_company_data = check_user_company() user_company = user_company_data.get("company") - + if not user_company: frappe.throw(_("User must have a company assigned")) @@ -518,24 +503,30 @@ def create_pos_profile(*arg ,**parameters): # Child tables if not payments or len(payments) == 0: frappe.throw(_("At least one payment method is required")) - + for payment in payments: if isinstance(payment, dict): - pos_profile.append("payments", { - "mode_of_payment": payment.get("mode_of_payment"), - "default": payment.get("default", 0), - "allow_in_returns": payment.get("allow_in_returns", 0), - }) + pos_profile.append( + "payments", + { + "mode_of_payment": payment.get("mode_of_payment"), + "default": payment.get("default", 0), + "allow_in_returns": payment.get("allow_in_returns", 0), + }, + ) elif isinstance(payment, str) and payment != "": pos_profile.append("payments", {"mode_of_payment": payment}) if isinstance(applicable_for_users, list) and len(applicable_for_users) > 0: for user in applicable_for_users: if isinstance(user, dict): - pos_profile.append("applicable_for_users", { - "user": user.get("user"), - "default": user.get("default", 0), - }) + pos_profile.append( + "applicable_for_users", + { + "user": user.get("user"), + "default": user.get("default", 0), + }, + ) elif isinstance(user, str) and user != "": pos_profile.append("applicable_for_users", {"user": user}) @@ -546,7 +537,9 @@ def create_pos_profile(*arg ,**parameters): if isinstance(customer_groups, list) and len(customer_groups) > 0: for customer_group in customer_groups: - customer_group_name = customer_group if isinstance(customer_group, str) else customer_group.get("customer_group") + customer_group_name = ( + customer_group if isinstance(customer_group, str) else customer_group.get("customer_group") + ) pos_profile.append("customer_groups", {"customer_group": customer_group_name}) if isinstance(brands, list) and len(brands) > 0: @@ -558,14 +551,15 @@ def create_pos_profile(*arg ,**parameters): pos_profile.insert() return pos_profile -@frappe.whitelist() + +@frappe.whitelist() def update_pos_profile(*args, **parameters): """ - Update an existing POS Profile - - Args: - pos_profile: POS Profile name - parameters: Update parameters (all optional) + Update an existing POS Profile + + Args: + pos_profile: POS Profile name + parameters: Update parameters (all optional) """ # Extract child table parameters BEFORE prepare_query_parameters filters them payments = parameters.pop("payments", None) @@ -580,53 +574,61 @@ def update_pos_profile(*args, **parameters): item_groups = _parse_list_parameter(item_groups, "item_groups") customer_groups = _parse_list_parameter(customer_groups, "customer_groups") brands = _parse_list_parameter(brands, "brands") - + pos_profile = frappe.get_doc("POS Profile", pos_profile_name) - + # Update main fields if parameters: pos_profile.update(parameters) if payments is not None: - pos_profile.payments = [] + pos_profile.payments = [] for payment in payments: if isinstance(payment, dict): mode_of_payment = payment.get("mode_of_payment") if mode_of_payment: - pos_profile.append("payments", { - "mode_of_payment": mode_of_payment, - "default": payment.get("default", 0), - "allow_in_returns": payment.get("allow_in_returns", 0) - }) + pos_profile.append( + "payments", + { + "mode_of_payment": mode_of_payment, + "default": payment.get("default", 0), + "allow_in_returns": payment.get("allow_in_returns", 0), + }, + ) elif isinstance(payment, str) and payment: pos_profile.append("payments", {"mode_of_payment": payment}) - - + if applicable_for_users is not None: pos_profile.applicable_for_users = [] for user in applicable_for_users: if isinstance(user, dict): user_name = user.get("user") or user.get("name") - if user_name: - pos_profile.append("applicable_for_users", { - "user": user_name, - "default": user.get("default", 0) - }) + if user_name: + pos_profile.append( + "applicable_for_users", {"user": user_name, "default": user.get("default", 0)} + ) elif isinstance(user, str): pos_profile.append("applicable_for_users", {"user": user, "default": 0}) - + if item_groups is not None: pos_profile.item_groups = [] for item_group in item_groups: - item_group_name = item_group if isinstance(item_group, str) else item_group.get("item_group") or item_group.get("name") + item_group_name = ( + item_group + if isinstance(item_group, str) + else item_group.get("item_group") or item_group.get("name") + ) if item_group_name: pos_profile.append("item_groups", {"item_group": item_group_name}) - if customer_groups is not None: pos_profile.customer_groups = [] for customer_group in customer_groups: - customer_group_name = customer_group if isinstance(customer_group, str) else customer_group.get("customer_group") or customer_group.get("name") + customer_group_name = ( + customer_group + if isinstance(customer_group, str) + else customer_group.get("customer_group") or customer_group.get("name") + ) if customer_group_name: pos_profile.append("customer_groups", {"customer_group": customer_group_name}) @@ -636,7 +638,7 @@ def update_pos_profile(*args, **parameters): brand_name = brand if isinstance(brand, str) else brand.get("brand") or brand.get("name") if brand_name: pos_profile.append("custom_brands_table", {"brand": brand_name}) - + pos_profile.save() # Invalidate cached POS filters so changes are reflected immediately in POS UI @@ -651,14 +653,15 @@ def update_pos_profile(*args, **parameters): frappe.log_error(frappe.get_traceback(), "POS Profile Cache Invalidation Error") return pos_profile - + + @frappe.whitelist() def delete_pos_profile(pos_profile): """ - Delete a POS Profile - - Args: - pos_profile: POS Profile name + Delete a POS Profile + + Args: + pos_profile: POS Profile name """ pos_profile = frappe.get_doc("POS Profile", pos_profile) - pos_profile.delete() \ No newline at end of file + pos_profile.delete() diff --git a/pos_next/api/promotions.py b/pos_next/api/promotions.py index c160cfe12..6e97efa9a 100644 --- a/pos_next/api/promotions.py +++ b/pos_next/api/promotions.py @@ -2,10 +2,11 @@ # Copyright (c) 2025, POS Next and contributors # For license information, please see license.txt +import re + import frappe from frappe import _ -from frappe.utils import flt, nowdate, getdate, cstr, cint -import re +from frappe.utils import cint, cstr, flt, getdate, nowdate def check_promotion_permissions(action="read"): @@ -24,7 +25,9 @@ def check_promotion_permissions(action="read"): frappe.throw(_("You don't have permission to view promotions"), frappe.PermissionError) elif action == "write": if not frappe.has_permission("Promotional Scheme", "write"): - frappe.throw(_("You don't have permission to create or modify promotions"), frappe.PermissionError) + frappe.throw( + _("You don't have permission to create or modify promotions"), frappe.PermissionError + ) elif action == "delete": if not frappe.has_permission("Promotional Scheme", "delete"): frappe.throw(_("You don't have permission to delete promotions"), frappe.PermissionError) @@ -54,11 +57,19 @@ def get_promotions(pos_profile=None, company=None, include_disabled=False): "Promotional Scheme", filters=filters, fields=[ - "name", "apply_on", "disable", "selling", "buying", - "applicable_for", "valid_from", "valid_upto", "company", - "mixed_conditions", "is_cumulative" + "name", + "apply_on", + "disable", + "selling", + "buying", + "applicable_for", + "valid_from", + "valid_upto", + "company", + "mixed_conditions", + "is_cumulative", ], - order_by="modified desc" + order_by="modified desc", ) # Enrich with pricing rules count and details @@ -69,10 +80,7 @@ def get_promotions(pos_profile=None, company=None, include_disabled=False): scheme["source"] = "Promotional Scheme" # Get pricing rules count - scheme["pricing_rules_count"] = frappe.db.count( - "Pricing Rule", - {"promotional_scheme": scheme.name} - ) + scheme["pricing_rules_count"] = frappe.db.count("Pricing Rule", {"promotional_scheme": scheme.name}) # Get discount slabs scheme_doc = frappe.get_doc("Promotional Scheme", scheme.name) @@ -107,12 +115,26 @@ def get_promotions(pos_profile=None, company=None, include_disabled=False): "Pricing Rule", filters=pr_filters, fields=[ - "name", "title", "apply_on", "disable", "selling", "buying", - "applicable_for", "valid_from", "valid_upto", "company", - "rate_or_discount", "discount_percentage", "discount_amount", - "min_qty", "max_qty", "min_amt", "max_amt", "priority" + "name", + "title", + "apply_on", + "disable", + "selling", + "buying", + "applicable_for", + "valid_from", + "valid_upto", + "company", + "rate_or_discount", + "discount_percentage", + "discount_amount", + "min_qty", + "max_qty", + "min_amt", + "max_amt", + "priority", ], - order_by="modified desc" + order_by="modified desc", ) # Transform pricing rules to match promotional scheme structure @@ -165,7 +187,7 @@ def get_promotion_details(scheme_name): data["pricing_rules"] = frappe.get_all( "Pricing Rule", filters={"promotional_scheme": scheme_name, "disable": 0}, - fields=["name", "title", "priority", "valid_from", "valid_upto"] + fields=["name", "title", "priority", "valid_from", "valid_upto"], ) return data @@ -182,15 +204,19 @@ def get_promotion_details(scheme_name): # Create a synthetic price discount slab from pricing rule fields if pr.rate_or_discount in ["Discount Percentage", "Discount Amount"]: - data["price_discount_slabs"] = [{ - "min_qty": pr.min_qty or 0, - "max_qty": pr.max_qty or 0, - "min_amount": pr.min_amt or 0, - "max_amount": pr.max_amt or 0, - "discount_percentage": pr.discount_percentage if pr.rate_or_discount == "Discount Percentage" else 0, - "discount_amount": pr.discount_amount if pr.rate_or_discount == "Discount Amount" else 0, - "rate_or_discount": pr.rate_or_discount - }] + data["price_discount_slabs"] = [ + { + "min_qty": pr.min_qty or 0, + "max_qty": pr.max_qty or 0, + "min_amount": pr.min_amt or 0, + "max_amount": pr.max_amt or 0, + "discount_percentage": pr.discount_percentage + if pr.rate_or_discount == "Discount Percentage" + else 0, + "discount_amount": pr.discount_amount if pr.rate_or_discount == "Discount Amount" else 0, + "rate_or_discount": pr.rate_or_discount, + } + ] else: data["price_discount_slabs"] = [] @@ -228,6 +254,7 @@ def create_promotion(data): check_promotion_permissions("write") import json + if isinstance(data, str): data = json.loads(data) @@ -242,17 +269,19 @@ def create_promotion(data): try: # Create promotional scheme scheme = frappe.new_doc("Promotional Scheme") - scheme.update({ - "name": data.get("name"), - "company": data.get("company"), - "apply_on": data.get("apply_on"), - "selling": 1, # Always enable selling for POS - "buying": 0, - "valid_from": data.get("valid_from") or nowdate(), - "valid_upto": data.get("valid_upto"), - "mixed_conditions": cint(data.get("mixed_conditions", 0)), - "is_cumulative": cint(data.get("is_cumulative", 0)), - }) + scheme.update( + { + "name": data.get("name"), + "company": data.get("company"), + "apply_on": data.get("apply_on"), + "selling": 1, # Always enable selling for POS + "buying": 0, + "valid_from": data.get("valid_from") or nowdate(), + "valid_upto": data.get("valid_upto"), + "mixed_conditions": cint(data.get("mixed_conditions", 0)), + "is_cumulative": cint(data.get("is_cumulative", 0)), + } + ) # Set applicable for if data.get("applicable_for"): @@ -260,7 +289,9 @@ def create_promotion(data): applicable_key = frappe.scrub(data["applicable_for"]) if data.get(applicable_key): # Handle both single value and list - values = data[applicable_key] if isinstance(data[applicable_key], list) else [data[applicable_key]] + values = ( + data[applicable_key] if isinstance(data[applicable_key], list) else [data[applicable_key]] + ) for value in values: scheme.append(applicable_key, {applicable_key: value}) @@ -270,22 +301,13 @@ def create_promotion(data): if data["apply_on"] == "Item Code" and items_data: for item in items_data: - scheme.append("items", { - "item_code": item.get("item_code"), - "uom": item.get("uom") - }) + scheme.append("items", {"item_code": item.get("item_code"), "uom": item.get("uom")}) elif data["apply_on"] == "Item Group" and items_data: for item in items_data: - scheme.append("item_groups", { - "item_group": item.get("item_group"), - "uom": item.get("uom") - }) + scheme.append("item_groups", {"item_group": item.get("item_group"), "uom": item.get("uom")}) elif data["apply_on"] == "Brand" and items_data: for item in items_data: - scheme.append("brands", { - "brand": item.get("brand"), - "uom": item.get("uom") - }) + scheme.append("brands", {"brand": item.get("brand"), "uom": item.get("uom")}) # Add discount slab discount_type = data.get("discount_type", "percentage") @@ -331,15 +353,12 @@ def create_promotion(data): return { "success": True, "message": _("Promotion {0} created successfully").format(scheme.name), - "scheme_name": scheme.name + "scheme_name": scheme.name, } except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Promotion Creation Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Promotion Creation Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to create promotion: {0}").format(str(e))) @@ -352,6 +371,7 @@ def update_promotion(scheme_name, data): check_promotion_permissions("write") import json + if isinstance(data, str): data = json.loads(data) @@ -370,7 +390,13 @@ def update_promotion(scheme_name, data): scheme.disable = cint(data["disable"]) # Update discount values in slabs - if "discount_value" in data or "min_qty" in data or "max_qty" in data or "min_amt" in data or "max_amt" in data: + if ( + "discount_value" in data + or "min_qty" in data + or "max_qty" in data + or "min_amt" in data + or "max_amt" in data + ): # Update price discount slabs if scheme.price_discount_slabs and len(scheme.price_discount_slabs) > 0: slab = scheme.price_discount_slabs[0] @@ -389,7 +415,14 @@ def update_promotion(scheme_name, data): slab.discount_amount = flt(data["discount_value"]) # Update free item slabs - if "free_item" in data or "free_qty" in data or "min_qty" in data or "max_qty" in data or "min_amt" in data or "max_amt" in data: + if ( + "free_item" in data + or "free_qty" in data + or "min_qty" in data + or "max_qty" in data + or "min_amt" in data + or "max_amt" in data + ): if scheme.product_discount_slabs and len(scheme.product_discount_slabs) > 0: slab = scheme.product_discount_slabs[0] if "free_item" in data: @@ -408,17 +441,11 @@ def update_promotion(scheme_name, data): # Save scheme.save() - return { - "success": True, - "message": _("Promotion {0} updated successfully").format(scheme_name) - } + return {"success": True, "message": _("Promotion {0} updated successfully").format(scheme_name)} except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Promotion Update Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Promotion Update Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to update promotion: {0}").format(str(e))) @@ -444,15 +471,12 @@ def toggle_promotion(scheme_name, disable=None): return { "success": True, "message": _("Promotion {0} {1}").format(scheme_name, status), - "disabled": scheme.disable + "disabled": scheme.disable, } except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Promotion Toggle Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Promotion Toggle Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to toggle promotion: {0}").format(str(e))) @@ -468,17 +492,11 @@ def delete_promotion(scheme_name): # This will automatically delete associated pricing rules via on_trash frappe.delete_doc("Promotional Scheme", scheme_name) - return { - "success": True, - "message": _("Promotion {0} deleted successfully").format(scheme_name) - } + return {"success": True, "message": _("Promotion {0} deleted successfully").format(scheme_name)} except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Promotion Deletion Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Promotion Deletion Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to delete promotion: {0}").format(str(e))) @@ -487,21 +505,13 @@ def get_item_groups(company=None): """Get all item groups.""" # Item Group is a global doctype, not company-specific # Return all item groups (both parent groups and leaf nodes) - return frappe.get_all( - "Item Group", - fields=["name", "parent_item_group", "is_group"], - order_by="name" - ) + return frappe.get_all("Item Group", fields=["name", "parent_item_group", "is_group"], order_by="name") @frappe.whitelist() def get_brands(): """Get all brands.""" - return frappe.get_all( - "Brand", - fields=["name"], - order_by="name" - ) + return frappe.get_all("Brand", fields=["name"], order_by="name") @frappe.whitelist() @@ -522,7 +532,7 @@ def search_items(search_term, pos_profile=None, limit=20): return [] # Remove any special SQL characters and limit length - search_term = re.sub(r'[^\w\s-]', '', search_term)[:100] + search_term = re.sub(r"[^\w\s-]", "", search_term)[:100] if len(search_term) < 2: return [] @@ -541,18 +551,16 @@ def search_items(search_term, pos_profile=None, limit=20): return frappe.get_all( "Item", filters=filters, - or_filters={ - "item_code": ["like", f"%{search_term}%"], - "item_name": ["like", f"%{search_term}%"] - }, + or_filters={"item_code": ["like", f"%{search_term}%"], "item_name": ["like", f"%{search_term}%"]}, fields=["item_code", "item_name", "item_group", "brand", "stock_uom"], limit=limit, - order_by="item_name" + order_by="item_name", ) # ==================== COUPON MANAGEMENT ==================== + @frappe.whitelist() def get_coupons(company=None, include_disabled=False, coupon_type=None): """Get all coupons for the company with enhanced filtering.""" @@ -574,22 +582,26 @@ def get_coupons(company=None, include_disabled=False, coupon_type=None): # Build field list - only include fields that exist fields = [ - "name", "coupon_name", "coupon_code", "coupon_type", - "customer", "customer_name", - "valid_from", "valid_upto", "maximum_use", "used", - "one_use", "company", "campaign" + "name", + "coupon_name", + "coupon_code", + "coupon_type", + "customer", + "customer_name", + "valid_from", + "valid_upto", + "maximum_use", + "used", + "one_use", + "company", + "campaign", ] # Check for optional fields if has_disabled_field: fields.append("disabled") - coupons = frappe.get_all( - "POS Coupon", - filters=filters, - fields=fields, - order_by="modified desc" - ) + coupons = frappe.get_all("POS Coupon", filters=filters, fields=fields, order_by="modified desc") # Enrich with status today = getdate(nowdate()) @@ -662,6 +674,7 @@ def create_coupon(data): check_promotion_permissions("write") import json + if isinstance(data, str): data = json.loads(data) @@ -694,24 +707,30 @@ def create_coupon(data): try: # Create coupon coupon = frappe.new_doc("POS Coupon") - coupon.update({ - "coupon_name": data.get("coupon_name"), - "coupon_type": data.get("coupon_type"), - "coupon_code": data.get("coupon_code"), # Will auto-generate if empty - "discount_type": data.get("discount_type"), - "discount_percentage": flt(data.get("discount_percentage")) if data.get("discount_type") == "Percentage" else None, - "discount_amount": flt(data.get("discount_amount")) if data.get("discount_type") == "Amount" else None, - "min_amount": flt(data.get("min_amount")) if data.get("min_amount") else None, - "max_amount": flt(data.get("max_amount")) if data.get("max_amount") else None, - "apply_on": data.get("apply_on", "Grand Total"), - "company": data.get("company"), - "customer": data.get("customer"), - "valid_from": data.get("valid_from"), - "valid_upto": data.get("valid_upto"), - "maximum_use": cint(data.get("maximum_use", 0)) or None, - "one_use": cint(data.get("one_use", 0)), - "campaign": data.get("campaign"), - }) + coupon.update( + { + "coupon_name": data.get("coupon_name"), + "coupon_type": data.get("coupon_type"), + "coupon_code": data.get("coupon_code"), # Will auto-generate if empty + "discount_type": data.get("discount_type"), + "discount_percentage": flt(data.get("discount_percentage")) + if data.get("discount_type") == "Percentage" + else None, + "discount_amount": flt(data.get("discount_amount")) + if data.get("discount_type") == "Amount" + else None, + "min_amount": flt(data.get("min_amount")) if data.get("min_amount") else None, + "max_amount": flt(data.get("max_amount")) if data.get("max_amount") else None, + "apply_on": data.get("apply_on", "Grand Total"), + "company": data.get("company"), + "customer": data.get("customer"), + "valid_from": data.get("valid_from"), + "valid_upto": data.get("valid_upto"), + "maximum_use": cint(data.get("maximum_use", 0)) or None, + "one_use": cint(data.get("one_use", 0)), + "campaign": data.get("campaign"), + } + ) coupon.insert() @@ -719,15 +738,12 @@ def create_coupon(data): "success": True, "message": _("Coupon {0} created successfully").format(coupon.coupon_code), "coupon_name": coupon.name, - "coupon_code": coupon.coupon_code + "coupon_code": coupon.coupon_code, } except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Coupon Creation Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Coupon Creation Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to create coupon: {0}").format(str(e))) @@ -740,6 +756,7 @@ def update_coupon(coupon_name, data): check_promotion_permissions("write") import json + if isinstance(data, str): data = json.loads(data) @@ -753,7 +770,9 @@ def update_coupon(coupon_name, data): if "discount_type" in data: coupon.discount_type = data["discount_type"] if "discount_percentage" in data: - coupon.discount_percentage = flt(data["discount_percentage"]) if data["discount_percentage"] else None + coupon.discount_percentage = ( + flt(data["discount_percentage"]) if data["discount_percentage"] else None + ) if "discount_amount" in data: coupon.discount_amount = flt(data["discount_amount"]) if data["discount_amount"] else None if "min_amount" in data: @@ -779,17 +798,11 @@ def update_coupon(coupon_name, data): coupon.save() - return { - "success": True, - "message": _("Coupon {0} updated successfully").format(coupon.coupon_code) - } + return {"success": True, "message": _("Coupon {0} updated successfully").format(coupon.coupon_code)} except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Coupon Update Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Coupon Update Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to update coupon: {0}").format(str(e))) @@ -816,15 +829,12 @@ def toggle_coupon(coupon_name, disabled=None): return { "success": True, "message": _("Coupon {0} {1}").format(coupon.coupon_code, status), - "disabled": coupon.disabled + "disabled": coupon.disabled, } except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Coupon Toggle Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Coupon Toggle Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to toggle coupon: {0}").format(str(e))) @@ -840,23 +850,19 @@ def delete_coupon(coupon_name): # Check if coupon has been used coupon = frappe.get_doc("POS Coupon", coupon_name) if coupon.used > 0: - frappe.throw(_("Cannot delete coupon {0} as it has been used {1} times").format( - coupon.coupon_code, coupon.used - )) + frappe.throw( + _("Cannot delete coupon {0} as it has been used {1} times").format( + coupon.coupon_code, coupon.used + ) + ) frappe.delete_doc("POS Coupon", coupon_name) - return { - "success": True, - "message": _("Coupon deleted successfully") - } + return {"success": True, "message": _("Coupon deleted successfully")} except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Coupon Deletion Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Coupon Deletion Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to delete coupon: {0}").format(str(e))) @@ -864,6 +870,7 @@ def delete_coupon(coupon_name): # REFERRAL CODE APIs # ============================================================================= + @frappe.whitelist() def apply_referral_code(referral_code, customer): """ @@ -884,14 +891,11 @@ def apply_referral_code(referral_code, customer): "success": True, "message": _("Referral code applied successfully! You've received a welcome coupon."), "referrer_coupon": result.get("referrer_coupon"), - "referee_coupon": result.get("referee_coupon") + "referee_coupon": result.get("referee_coupon"), } except Exception as e: frappe.db.rollback() - frappe.log_error( - title=_("Apply Referral Code Failed"), - message=frappe.get_traceback() - ) + frappe.log_error(title=_("Apply Referral Code Failed"), message=frappe.get_traceback()) frappe.throw(_("Failed to apply referral code: {0}").format(str(e))) @@ -910,12 +914,23 @@ def get_referral_codes(company=None, include_disabled=False): "Referral Code", filters=filters, fields=[ - "name", "referral_name", "referral_code", "customer", "customer_name", - "company", "campaign", "disabled", "referrals_count", - "referrer_discount_type", "referrer_discount_percentage", "referrer_discount_amount", - "referee_discount_type", "referee_discount_percentage", "referee_discount_amount" + "name", + "referral_name", + "referral_code", + "customer", + "customer_name", + "company", + "campaign", + "disabled", + "referrals_count", + "referrer_discount_type", + "referrer_discount_percentage", + "referrer_discount_amount", + "referee_discount_type", + "referee_discount_percentage", + "referee_discount_amount", ], - order_by="creation desc" + order_by="creation desc", ) return referrals @@ -937,10 +952,17 @@ def get_referral_details(referral_name): "POS Coupon", filters={"referral_code": referral_name}, fields=[ - "name", "coupon_code", "coupon_type", "customer", "customer_name", - "used", "valid_from", "valid_upto", "disabled" + "name", + "coupon_code", + "coupon_type", + "customer", + "customer_name", + "used", + "valid_from", + "valid_upto", + "disabled", ], - order_by="creation desc" + order_by="creation desc", ) data["generated_coupons"] = coupons diff --git a/pos_next/api/qz.py b/pos_next/api/qz.py index 2c96c4d57..6042fe89f 100644 --- a/pos_next/api/qz.py +++ b/pos_next/api/qz.py @@ -22,11 +22,11 @@ import frappe from frappe import _ - # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- + def _qz_dir(): return frappe.get_site_path("private", "qz") @@ -43,6 +43,7 @@ def _key_path(): # Public API # --------------------------------------------------------------------------- + @frappe.whitelist() def get_certificate(): """Return the public certificate PEM text for QZ Tray signing.""" @@ -131,29 +132,34 @@ def setup_qz_certificate(): qz_dir = _qz_dir() os.makedirs(qz_dir, exist_ok=True) + from datetime import datetime, timedelta, timezone + from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID - from datetime import datetime, timedelta, timezone # Generate 2048-bit RSA key key = rsa.generate_private_key(public_exponent=65537, key_size=2048) # Write private key with open(key_path, "wb") as f: - f.write(key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - )) + f.write( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) os.chmod(key_path, 0o600) # Build self-signed certificate (valid ~31 years) - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "POS Next QZ Tray Signing"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, frappe.db.get_default("company") or "POS Next"), - ]) + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "POS Next QZ Tray Signing"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, frappe.db.get_default("company") or "POS Next"), + ] + ) now = datetime.now(timezone.utc) cert = ( @@ -172,9 +178,11 @@ def setup_qz_certificate(): f.write(cert.public_bytes(serialization.Encoding.PEM)) frappe.msgprint( - _("QZ Tray certificate generated successfully.

" - "Download the certificate from POS Settings and import it into " - "QZ Tray on each POS machine, then restart QZ Tray."), + _( + "QZ Tray certificate generated successfully.

" + "Download the certificate from POS Settings and import it into " + "QZ Tray on each POS machine, then restart QZ Tray." + ), title=_("QZ Certificate Ready"), indicator="green", ) diff --git a/pos_next/api/sales_invoice_hooks.py b/pos_next/api/sales_invoice_hooks.py index 10304f898..9f73471ef 100644 --- a/pos_next/api/sales_invoice_hooks.py +++ b/pos_next/api/sales_invoice_hooks.py @@ -41,10 +41,7 @@ def apply_tax_inclusive(doc): try: # Get POS Settings for this profile pos_settings = frappe.db.get_value( - "POS Settings", - {"pos_profile": doc.pos_profile}, - ["tax_inclusive"], - as_dict=True + "POS Settings", {"pos_profile": doc.pos_profile}, ["tax_inclusive"], as_dict=True ) tax_inclusive = pos_settings.get("tax_inclusive", 0) if pos_settings else 0 except Exception: @@ -95,7 +92,7 @@ def auto_assign_loyalty_program_on_invoice(doc): "POS Settings", {"pos_profile": doc.pos_profile}, ["enable_loyalty_program", "default_loyalty_program"], - as_dict=True + as_dict=True, ) if not pos_settings: @@ -109,13 +106,7 @@ def auto_assign_loyalty_program_on_invoice(doc): return # Assign loyalty program to customer - frappe.db.set_value( - "Customer", - doc.customer, - "loyalty_program", - loyalty_program, - update_modified=False - ) + frappe.db.set_value("Customer", doc.customer, "loyalty_program", loyalty_program, update_modified=False) def before_cancel(doc, method=None): @@ -129,15 +120,16 @@ def before_cancel(doc, method=None): """ try: from pos_next.api.credit_sales import cancel_credit_journal_entries + cancel_credit_journal_entries(doc.name) except Exception as e: frappe.log_error( title="Credit Sale JE Cancellation Error", - message=f"Invoice: {doc.name}, Error: {str(e)}\n{frappe.get_traceback()}" + message=f"Invoice: {doc.name}, Error: {str(e)}\n{frappe.get_traceback()}", ) # Don't block invoice cancellation if JE cancellation fails frappe.msgprint( _("Warning: Some credit journal entries may not have been cancelled. Please check manually."), alert=True, - indicator="orange" + indicator="orange", ) diff --git a/pos_next/api/shifts.py b/pos_next/api/shifts.py index 50f723212..4f57fb912 100644 --- a/pos_next/api/shifts.py +++ b/pos_next/api/shifts.py @@ -3,10 +3,13 @@ # For license information, please see license.txt from __future__ import unicode_literals + import json + import frappe from frappe import _ -from frappe.utils import nowdate, nowtime, get_datetime +from frappe.utils import get_datetime, nowdate, nowtime + from pos_next.api.utilities import get_wallet_payment_modes @@ -108,7 +111,9 @@ def create_opening_shift(pos_profile, company, balance_details): # Check if user already has an open shift existing_shift = check_opening_shift(frappe.session.user) if existing_shift: - frappe.throw(_("You already have an open shift: {0}").format(existing_shift["pos_opening_shift"].name)) + frappe.throw( + _("You already have an open shift: {0}").format(existing_shift["pos_opening_shift"].name) + ) new_pos_opening = frappe.get_doc( { @@ -126,10 +131,9 @@ def create_opening_shift(pos_profile, company, balance_details): # Add balance details - map opening_amount to amount formatted_balance_details = [] for detail in balance_details: - formatted_balance_details.append({ - "mode_of_payment": detail.get("mode_of_payment"), - "amount": detail.get("opening_amount", 0) - }) + formatted_balance_details.append( + {"mode_of_payment": detail.get("mode_of_payment"), "amount": detail.get("opening_amount", 0)} + ) new_pos_opening.set("balance_details", formatted_balance_details) new_pos_opening.insert(ignore_permissions=True) @@ -169,7 +173,9 @@ def get_closing_shift_data(opening_shift): @frappe.whitelist() def submit_closing_shift(closing_shift): """Submit closing shift""" - from pos_next.pos_next.doctype.pos_closing_shift.pos_closing_shift import submit_closing_shift as submit_shift + from pos_next.pos_next.doctype.pos_closing_shift.pos_closing_shift import ( + submit_closing_shift as submit_shift, + ) try: # closing_shift is already a JSON string from frontend diff --git a/pos_next/api/test_customers.py b/pos_next/api/test_customers.py index fe955b77e..b85194b51 100644 --- a/pos_next/api/test_customers.py +++ b/pos_next/api/test_customers.py @@ -5,98 +5,107 @@ from unittest.mock import Mock, patch from pos_next.api.customers import ( - _get_customer_assignment_context, - create_customer, - get_customers, - get_default_loyalty_program_from_settings, + _get_customer_assignment_context, + create_customer, + get_customers, + get_default_loyalty_program_from_settings, ) class TestCustomersAPI(unittest.TestCase): - @patch("pos_next.api.customers.frappe.logger") - @patch("pos_next.api.customers.frappe.get_all") - @patch("pos_next.api.customers.frappe.db") - def test_get_customers_applies_search_term_filters(self, mock_db, mock_get_all, mock_logger): - mock_logger.return_value = Mock() - mock_get_all.return_value = [] - - get_customers(search_term="john", limit=10) - - mock_get_all.assert_called_once() - kwargs = mock_get_all.call_args.kwargs - self.assertEqual(kwargs["filters"], {"disabled": 0}) - self.assertEqual( - kwargs["or_filters"], - [ - ["Customer", "name", "like", "%john%"], - ["Customer", "customer_name", "like", "%john%"], - ["Customer", "mobile_no", "like", "%john%"], - ["Customer", "email_id", "like", "%john%"], - ], - ) - - @patch("pos_next.api.customers.frappe.db") - def test_get_default_loyalty_program_from_settings_uses_explicit_pos_profile(self, mock_db): - mock_db.get_value.return_value = "LOYALTY-A" - - result = get_default_loyalty_program_from_settings(pos_profile="POS-A") - - self.assertEqual(result, "LOYALTY-A") - mock_db.get_value.assert_called_once_with( - "POS Settings", - {"enabled": 1, "pos_profile": "POS-A"}, - "default_loyalty_program", - ) - - @patch("pos_next.api.customers.frappe.get_cached_value") - @patch("pos_next.api.customers.frappe.get_all") - def test_get_default_loyalty_program_from_settings_skips_ambiguous_company_context( - self, - mock_get_all, - mock_get_cached_value, - ): - mock_get_all.return_value = [ - Mock(pos_profile="POS-1", default_loyalty_program="LOYALTY-A"), - Mock(pos_profile="POS-2", default_loyalty_program="LOYALTY-B"), - ] - mock_get_cached_value.side_effect = ["Company A", "Company A"] - - result = get_default_loyalty_program_from_settings(company="Company A") - - self.assertIsNone(result) - - @patch("pos_next.api.customers.frappe.local", new=Mock(form_dict={"company": "Company A", "pos_profile": "POS-A"})) - @patch("pos_next.api.customers.frappe.flags", new=Mock(pos_next_customer_company=None, pos_next_customer_pos_profile=None)) - def test_get_customer_assignment_context_uses_request_context(self): - company, pos_profile = _get_customer_assignment_context() - - self.assertEqual(company, "Company A") - self.assertEqual(pos_profile, "POS-A") - - @patch("pos_next.api.customers.frappe.flags", new=Mock(pos_next_customer_company=None, pos_next_customer_pos_profile=None)) - @patch("pos_next.api.customers.frappe.get_doc") - @patch("pos_next.api.customers.get_default_loyalty_program_from_settings") - @patch("pos_next.api.customers.frappe.has_permission") - def test_create_customer_uses_pos_profile_for_loyalty_assignment( - self, - mock_has_permission, - mock_get_loyalty, - mock_get_doc, - ): - mock_has_permission.return_value = True - mock_get_loyalty.return_value = "LOYALTY-A" - - customer_doc = Mock() - customer_doc.as_dict.return_value = {"name": "CUST-0001", "loyalty_program": "LOYALTY-A"} - mock_get_doc.return_value = customer_doc - - result = create_customer( - customer_name="John Doe", - customer_group="Individual", - territory="All Territories", - pos_profile="POS-A", - ) - - mock_get_loyalty.assert_called_once_with(company=None, pos_profile="POS-A") - customer_doc.insert.assert_called_once_with() - self.assertEqual(result["loyalty_program"], "LOYALTY-A") + @patch("pos_next.api.customers.frappe.logger") + @patch("pos_next.api.customers.frappe.get_all") + @patch("pos_next.api.customers.frappe.db") + def test_get_customers_applies_search_term_filters(self, mock_db, mock_get_all, mock_logger): + mock_logger.return_value = Mock() + mock_get_all.return_value = [] + + get_customers(search_term="john", limit=10) + + mock_get_all.assert_called_once() + kwargs = mock_get_all.call_args.kwargs + self.assertEqual(kwargs["filters"], {"disabled": 0}) + self.assertEqual( + kwargs["or_filters"], + [ + ["Customer", "name", "like", "%john%"], + ["Customer", "customer_name", "like", "%john%"], + ["Customer", "mobile_no", "like", "%john%"], + ["Customer", "email_id", "like", "%john%"], + ], + ) + + @patch("pos_next.api.customers.frappe.db") + def test_get_default_loyalty_program_from_settings_uses_explicit_pos_profile(self, mock_db): + mock_db.get_value.return_value = "LOYALTY-A" + + result = get_default_loyalty_program_from_settings(pos_profile="POS-A") + + self.assertEqual(result, "LOYALTY-A") + mock_db.get_value.assert_called_once_with( + "POS Settings", + {"enabled": 1, "pos_profile": "POS-A"}, + "default_loyalty_program", + ) + + @patch("pos_next.api.customers.frappe.get_cached_value") + @patch("pos_next.api.customers.frappe.get_all") + def test_get_default_loyalty_program_from_settings_skips_ambiguous_company_context( + self, + mock_get_all, + mock_get_cached_value, + ): + mock_get_all.return_value = [ + Mock(pos_profile="POS-1", default_loyalty_program="LOYALTY-A"), + Mock(pos_profile="POS-2", default_loyalty_program="LOYALTY-B"), + ] + mock_get_cached_value.side_effect = ["Company A", "Company A"] + + result = get_default_loyalty_program_from_settings(company="Company A") + + self.assertIsNone(result) + + @patch( + "pos_next.api.customers.frappe.local", + new=Mock(form_dict={"company": "Company A", "pos_profile": "POS-A"}), + ) + @patch( + "pos_next.api.customers.frappe.flags", + new=Mock(pos_next_customer_company=None, pos_next_customer_pos_profile=None), + ) + def test_get_customer_assignment_context_uses_request_context(self): + company, pos_profile = _get_customer_assignment_context() + + self.assertEqual(company, "Company A") + self.assertEqual(pos_profile, "POS-A") + + @patch( + "pos_next.api.customers.frappe.flags", + new=Mock(pos_next_customer_company=None, pos_next_customer_pos_profile=None), + ) + @patch("pos_next.api.customers.frappe.get_doc") + @patch("pos_next.api.customers.get_default_loyalty_program_from_settings") + @patch("pos_next.api.customers.frappe.has_permission") + def test_create_customer_uses_pos_profile_for_loyalty_assignment( + self, + mock_has_permission, + mock_get_loyalty, + mock_get_doc, + ): + mock_has_permission.return_value = True + mock_get_loyalty.return_value = "LOYALTY-A" + + customer_doc = Mock() + customer_doc.as_dict.return_value = {"name": "CUST-0001", "loyalty_program": "LOYALTY-A"} + mock_get_doc.return_value = customer_doc + + result = create_customer( + customer_name="John Doe", + customer_group="Individual", + territory="All Territories", + pos_profile="POS-A", + ) + + mock_get_loyalty.assert_called_once_with(company=None, pos_profile="POS-A") + customer_doc.insert.assert_called_once_with() + self.assertEqual(result["loyalty_program"], "LOYALTY-A") diff --git a/pos_next/api/utilities.py b/pos_next/api/utilities.py index 93b55af4a..2fab94d4d 100644 --- a/pos_next/api/utilities.py +++ b/pos_next/api/utilities.py @@ -3,8 +3,10 @@ # For license information, please see license.txt from __future__ import unicode_literals -import frappe + import json + +import frappe from frappe import _ from frappe.utils import cint @@ -34,10 +36,7 @@ def get_csrf_token(): if not csrf_token: frappe.throw(_("Failed to generate CSRF token"), frappe.ValidationError) - return { - "csrf_token": csrf_token, - "session_id": frappe.session.sid - } + return {"csrf_token": csrf_token, "session_id": frappe.session.sid} def _parse_list_parameter(value, param_name="parameter"): @@ -69,10 +68,7 @@ def check_user_company(): user = frappe.session.user permission = frappe.db.get_value( - "User Permission", - {"user": user, "allow": "Company"}, - ["for_value"], - as_dict=True + "User Permission", {"user": user, "allow": "Company"}, ["for_value"], as_dict=True ) if permission: @@ -89,11 +85,7 @@ def get_wallet_payment_modes(): Returns: list: List of Mode of Payment names with is_wallet_payment=1 """ - return frappe.get_all( - "Mode of Payment", - filters={"is_wallet_payment": 1}, - pluck="name" - ) + return frappe.get_all("Mode of Payment", filters={"is_wallet_payment": 1}, pluck="name") def is_wallet_payment_mode(mode_of_payment): diff --git a/pos_next/api/wallet.py b/pos_next/api/wallet.py index 6e3e9e5d6..36ce82d7d 100644 --- a/pos_next/api/wallet.py +++ b/pos_next/api/wallet.py @@ -8,7 +8,7 @@ import frappe from frappe import _ -from frappe.utils import flt, cint +from frappe.utils import cint, flt def validate_wallet_payment(doc, method=None): @@ -32,9 +32,9 @@ def validate_wallet_payment(doc, method=None): frappe.throw( _("Insufficient wallet balance. Available: {0}, Requested: {1}").format( frappe.format_value(wallet_balance, {"fieldtype": "Currency"}), - frappe.format_value(wallet_amount, {"fieldtype": "Currency"}) + frappe.format_value(wallet_amount, {"fieldtype": "Currency"}), ), - title=_("Wallet Balance Error") + title=_("Wallet Balance Error"), ) @@ -51,7 +51,9 @@ def process_loyalty_to_wallet(doc, method=None): if not pos_settings: return - if not cint(pos_settings.get("enable_loyalty_program")) or not cint(pos_settings.get("loyalty_to_wallet")): + if not cint(pos_settings.get("enable_loyalty_program")) or not cint( + pos_settings.get("loyalty_to_wallet") + ): return # Check if customer has loyalty program @@ -79,13 +81,9 @@ def process_loyalty_to_wallet(doc, method=None): # Get the loyalty points earned from this invoice loyalty_entry = frappe.db.get_value( "Loyalty Point Entry", - { - "invoice_type": "Sales Invoice", - "invoice": doc.name, - "loyalty_points": [">", 0] - }, + {"invoice_type": "Sales Invoice", "invoice": doc.name, "loyalty_points": [">", 0]}, ["loyalty_points", "name"], - as_dict=True + as_dict=True, ) if not loyalty_entry or loyalty_entry.loyalty_points <= 0: @@ -117,26 +115,25 @@ def process_loyalty_to_wallet(doc, method=None): remarks=_("Loyalty points conversion from {0}: {1} points = {2}").format( doc.name, loyalty_entry.loyalty_points, - frappe.format_value(credit_amount, {"fieldtype": "Currency"}) + frappe.format_value(credit_amount, {"fieldtype": "Currency"}), ), reference_doctype="Sales Invoice", reference_name=doc.name, - submit=True + submit=True, ) frappe.msgprint( _("Loyalty points converted to wallet: {0} points = {1}").format( - loyalty_entry.loyalty_points, - frappe.format_value(credit_amount, {"fieldtype": "Currency"}) + loyalty_entry.loyalty_points, frappe.format_value(credit_amount, {"fieldtype": "Currency"}) ), alert=True, - indicator="green" + indicator="green", ) except Exception as e: frappe.log_error( title="Loyalty to Wallet Conversion Error", - message=f"Invoice: {doc.name}, Error: {str(e)}\n{frappe.get_traceback()}" + message=f"Invoice: {doc.name}, Error: {str(e)}\n{frappe.get_traceback()}", ) @@ -150,11 +147,7 @@ def get_wallet_amount_from_payments(payments): if not payment.mode_of_payment: continue - is_wallet = frappe.db.get_value( - "Mode of Payment", - payment.mode_of_payment, - "is_wallet_payment" - ) + is_wallet = frappe.db.get_value("Mode of Payment", payment.mode_of_payment, "is_wallet_payment") if is_wallet: wallet_amount += flt(payment.amount) @@ -192,11 +185,7 @@ def get_customer_wallet_balance(customer, company=None, exclude_invoice=None): return 0.0 # Get balance from GL entries - gl_balance = get_balance_on( - account=wallet.account, - party_type="Customer", - party=customer - ) + gl_balance = get_balance_on(account=wallet.account, party_type="Customer", party=customer) # Negate because negative receivable balance = positive wallet credit wallet_balance = -flt(gl_balance) @@ -217,18 +206,9 @@ def get_pending_wallet_payments(customer, exclude_invoice=None): """ Get total wallet payments from unconsolidated/pending POS invoices. """ - filters = { - "customer": customer, - "docstatus": ["in", [0, 1]], - "outstanding_amount": [">", 0], - "is_pos": 1 - } + filters = {"customer": customer, "docstatus": ["in", [0, 1]], "outstanding_amount": [">", 0], "is_pos": 1} - invoices = frappe.get_all( - "Sales Invoice", - filters=filters, - fields=["name"] - ) + invoices = frappe.get_all("Sales Invoice", filters=filters, fields=["name"]) pending_amount = 0.0 @@ -237,15 +217,11 @@ def get_pending_wallet_payments(customer, exclude_invoice=None): continue payments = frappe.get_all( - "Sales Invoice Payment", - filters={"parent": invoice.name}, - fields=["mode_of_payment", "amount"] + "Sales Invoice Payment", filters={"parent": invoice.name}, fields=["mode_of_payment", "amount"] ) for payment in payments: - is_wallet = frappe.db.get_value( - "Mode of Payment", payment.mode_of_payment, "is_wallet_payment" - ) + is_wallet = frappe.db.get_value("Mode of Payment", payment.mode_of_payment, "is_wallet_payment") if is_wallet: pending_amount += flt(payment.amount) @@ -263,7 +239,7 @@ def get_customer_wallet(customer, company=None): "Wallet", filters, ["name", "customer", "company", "account", "status", "current_balance"], - as_dict=True + as_dict=True, ) if wallet: @@ -281,11 +257,7 @@ def create_wallet_on_customer_insert(doc, method=None): return # Only auto-create wallets when a POS profile with auto_create_wallet exists - pos_profile = frappe.db.get_value( - "POS Profile", - {"company": company, "disabled": 0}, - "name" - ) + pos_profile = frappe.db.get_value("POS Profile", {"company": company, "disabled": 0}, "name") if not pos_profile: return @@ -308,7 +280,7 @@ def get_or_create_wallet(customer, company, pos_settings=None, force_create=Fals "Wallet", {"customer": customer, "company": company}, ["name", "customer", "company", "account", "status"], - as_dict=True + as_dict=True, ) if wallet: @@ -316,11 +288,7 @@ def get_or_create_wallet(customer, company, pos_settings=None, force_create=Fals # Check if auto-create is enabled if not pos_settings: - pos_profile = frappe.db.get_value( - "POS Profile", - {"company": company, "disabled": 0}, - "name" - ) + pos_profile = frappe.db.get_value("POS Profile", {"company": company, "disabled": 0}, "name") if pos_profile: pos_settings = get_pos_settings(pos_profile) @@ -336,13 +304,8 @@ def get_or_create_wallet(customer, company, pos_settings=None, force_create=Fals # Try to find a receivable account with 'wallet' in name wallet_account = frappe.db.get_value( "Account", - { - "company": company, - "account_type": "Receivable", - "is_group": 0, - "name": ["like", "%wallet%"] - }, - "name" + {"company": company, "account_type": "Receivable", "is_group": 0, "name": ["like", "%wallet%"]}, + "name", ) if not wallet_account: @@ -351,29 +314,27 @@ def get_or_create_wallet(customer, company, pos_settings=None, force_create=Fals if not wallet_account: frappe.log_error( - f"Cannot create wallet for {customer}: No wallet account configured", - "Wallet Creation Error" + f"Cannot create wallet for {customer}: No wallet account configured", "Wallet Creation Error" ) return None # Create new wallet try: - wallet_doc = frappe.get_doc({ - "doctype": "Wallet", - "customer": customer, - "company": company, - "account": wallet_account, - "status": "Active" - }) + wallet_doc = frappe.get_doc( + { + "doctype": "Wallet", + "customer": customer, + "company": company, + "account": wallet_account, + "status": "Active", + } + ) wallet_doc.insert(ignore_permissions=True) return wallet_doc except Exception as e: - frappe.log_error( - f"Failed to create wallet for {customer}: {str(e)}", - "Wallet Creation Error" - ) + frappe.log_error(f"Failed to create wallet for {customer}: {str(e)}", "Wallet Creation Error") return None @@ -390,9 +351,9 @@ def get_pos_settings(pos_profile): "default_loyalty_program", "wallet_account", "auto_create_wallet", - "loyalty_to_wallet" + "loyalty_to_wallet", ], - as_dict=True + as_dict=True, ) @@ -400,24 +361,20 @@ def get_pos_settings(pos_profile): def get_wallet_payment_methods(pos_profile): """Get payment methods that are wallet-enabled for a POS profile.""" payment_methods = frappe.get_all( - "POS Payment Method", - filters={"parent": pos_profile}, - fields=["mode_of_payment", "default"] + "POS Payment Method", filters={"parent": pos_profile}, fields=["mode_of_payment", "default"] ) wallet_methods = [] for method in payment_methods: - is_wallet = frappe.db.get_value( - "Mode of Payment", - method.mode_of_payment, - "is_wallet_payment" - ) + is_wallet = frappe.db.get_value("Mode of Payment", method.mode_of_payment, "is_wallet_payment") if is_wallet: - wallet_methods.append({ - "mode_of_payment": method.mode_of_payment, - "default": method.default, - "is_wallet_payment": True - }) + wallet_methods.append( + { + "mode_of_payment": method.mode_of_payment, + "default": method.default, + "is_wallet_payment": True, + } + ) return wallet_methods @@ -436,7 +393,7 @@ def get_wallet_info(customer, company, pos_profile=None): "wallet_name": None, "auto_create": False, "loyalty_program": None, - "loyalty_to_wallet": False + "loyalty_to_wallet": False, } # Check if loyalty program is enabled in POS Settings @@ -457,7 +414,7 @@ def get_wallet_info(customer, company, pos_profile=None): "Wallet", {"customer": customer, "company": company, "status": ["in", ["Active", "active"]]}, ["name", "account"], - as_dict=True + as_dict=True, ) if wallet: @@ -470,12 +427,14 @@ def get_wallet_info(customer, company, pos_profile=None): new_wallet = get_or_create_wallet(customer, company, pos_settings) if new_wallet: result["wallet_exists"] = True - result["wallet_name"] = new_wallet.name if hasattr(new_wallet, 'name') else new_wallet.get("name") + result["wallet_name"] = ( + new_wallet.name if hasattr(new_wallet, "name") else new_wallet.get("name") + ) result["wallet_balance"] = 0.0 # New wallet starts with 0 balance except Exception as e: frappe.log_error( title="Auto-create Wallet Error", - message=f"Customer: {customer}, Company: {company}, Error: {str(e)}" + message=f"Customer: {customer}, Company: {company}, Error: {str(e)}", ) return result @@ -509,11 +468,11 @@ def create_manual_wallet_credit(customer, company, amount, remarks=None): from pos_next.pos_next.doctype.wallet_transaction.wallet_transaction import create_wallet_credit transaction = create_wallet_credit( - wallet=wallet.name if hasattr(wallet, 'name') else wallet["name"], + wallet=wallet.name if hasattr(wallet, "name") else wallet["name"], amount=amount, source_type="Manual Adjustment", remarks=remarks or _("Manual wallet credit"), - submit=True + submit=True, ) return transaction.name diff --git a/pos_next/hooks.py b/pos_next/hooks.py index a08c97b63..0a0f26de7 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -88,18 +88,8 @@ # Fixtures # -------- fixtures = [ - { - "dt": "Role", - "filters": [ - ["role_name", "in", ["POSNext Cashier","Nexus POS Manager"]] - ] - }, - { - "dt": "Custom DocPerm", - "filters": [ - ["role", "in", ["POSNext Cashier"]] - ] - } + {"dt": "Role", "filters": [["role_name", "in", ["POSNext Cashier", "Nexus POS Manager"]]]}, + {"dt": "Custom DocPerm", "filters": [["role", "in", ["POSNext Cashier"]]]}, ] # Installation @@ -141,9 +131,7 @@ # --------------- # Override standard doctype classes -override_doctype_class = { - "Sales Invoice": "pos_next.overrides.sales_invoice.CustomSalesInvoice" -} +override_doctype_class = {"Sales Invoice": "pos_next.overrides.sales_invoice.CustomSalesInvoice"} # Document Events # --------------- @@ -154,30 +142,26 @@ "after_insert": [ "pos_next.api.customers.auto_assign_loyalty_program", "pos_next.realtime_events.emit_customer_event", - "pos_next.api.wallet.create_wallet_on_customer_insert" + "pos_next.api.wallet.create_wallet_on_customer_insert", ], "on_update": "pos_next.realtime_events.emit_customer_event", - "on_trash": "pos_next.realtime_events.emit_customer_event" + "on_trash": "pos_next.realtime_events.emit_customer_event", }, "Sales Invoice": { "validate": [ "pos_next.api.sales_invoice_hooks.validate", - "pos_next.api.wallet.validate_wallet_payment" + "pos_next.api.wallet.validate_wallet_payment", ], "before_cancel": "pos_next.api.sales_invoice_hooks.before_cancel", "on_submit": [ "pos_next.realtime_events.emit_stock_update_event", - "pos_next.api.wallet.process_loyalty_to_wallet" + "pos_next.api.wallet.process_loyalty_to_wallet", ], "on_cancel": "pos_next.realtime_events.emit_stock_update_event", - "after_insert": "pos_next.realtime_events.emit_invoice_created_event" - }, - "POS Profile": { - "on_update": "pos_next.realtime_events.emit_pos_profile_updated_event" + "after_insert": "pos_next.realtime_events.emit_invoice_created_event", }, - "Promotional Scheme": { - "on_update": "pos_next.overrides.pricing_rule.sync_pos_only_to_pricing_rules" - } + "POS Profile": {"on_update": "pos_next.realtime_events.emit_pos_profile_updated_event"}, + "Promotional Scheme": {"on_update": "pos_next.overrides.pricing_rule.sync_pos_only_to_pricing_rules"}, } # Scheduled Tasks @@ -273,4 +257,6 @@ # } -website_route_rules = [{'from_route': '/pos/', 'to_route': 'pos'},] \ No newline at end of file +website_route_rules = [ + {"from_route": "/pos/", "to_route": "pos"}, +] diff --git a/pos_next/install.py b/pos_next/install.py index 0a67dc12d..ca2ac34a5 100644 --- a/pos_next/install.py +++ b/pos_next/install.py @@ -10,9 +10,11 @@ The fixtures are defined in hooks.py and synced automatically during install/migrate. This module handles post-fixture tasks like setting defaults and clearing cache. """ -import frappe + import logging +import frappe + # Configure logger logger = logging.getLogger(__name__) @@ -32,10 +34,7 @@ def after_install(): log_message("POS Next: Installation completed successfully", level="success") except Exception as e: frappe.db.rollback() - frappe.log_error( - title="POS Next Installation Error", - message=frappe.get_traceback() - ) + frappe.log_error(title="POS Next Installation Error", message=frappe.get_traceback()) log_message(f"POS Next: Installation error - {str(e)}", level="error") raise @@ -43,7 +42,6 @@ def after_install(): def after_migrate(): """Hook that runs after bench migrate""" try: - # Reclaim POS Settings if ERPNext re-imported its Single on top of ours. # Must run in after_migrate (not as a one-shot patch) because ERPNext's # doctype sync runs after pos_next's and would overwrite anything we did @@ -60,10 +58,7 @@ def after_migrate(): log_message("POS Next: Migration completed successfully", level="success") except Exception as e: frappe.db.rollback() - frappe.log_error( - title="POS Next Migration Error", - message=frappe.get_traceback() - ) + frappe.log_error(title="POS Next Migration Error", message=frappe.get_traceback()) log_message(f"POS Next: Migration error - {str(e)}", level="error") raise @@ -79,14 +74,14 @@ def setup_default_print_format(quiet=False): # Check if the print format exists if not frappe.db.exists("Print Format", "POS Next Receipt"): if not quiet: - log_message("POS Next Receipt print format not found, skipping default setup", level="warning") + log_message( + "POS Next Receipt print format not found, skipping default setup", level="warning" + ) return # Get all POS Profiles without a print format pos_profiles = frappe.get_all( - "POS Profile", - filters={"print_format": ["in", ["", None]]}, - fields=["name"] + "POS Profile", filters={"print_format": ["in", ["", None]]}, fields=["name"] ) if pos_profiles: @@ -94,27 +89,24 @@ def setup_default_print_format(quiet=False): for profile in pos_profiles: try: frappe.db.set_value( - "POS Profile", - profile.name, - "print_format", - "POS Next Receipt", - update_modified=False + "POS Profile", profile.name, "print_format", "POS Next Receipt", update_modified=False ) if not quiet: log_message(f"Set default print format for: {profile.name}", level="info", indent=1) updated_count += 1 except Exception as e: - log_message(f"Error updating POS Profile {profile.name}: {str(e)}", level="error", indent=1) + log_message( + f"Error updating POS Profile {profile.name}: {str(e)}", level="error", indent=1 + ) if updated_count > 0 and not quiet: - log_message(f"Updated {updated_count} POS Profile(s) with default print format", level="success") + log_message( + f"Updated {updated_count} POS Profile(s) with default print format", level="success" + ) except Exception as e: log_message(f"Error setting up default print format: {str(e)}", level="error") - frappe.log_error( - title="Default Print Format Setup Error", - message=frappe.get_traceback() - ) + frappe.log_error(title="Default Print Format Setup Error", message=frappe.get_traceback()) def log_message(message, level="info", indent=0): diff --git a/pos_next/overrides/frappe_compat.py b/pos_next/overrides/frappe_compat.py index c02b72b95..8229e01b2 100644 --- a/pos_next/overrides/frappe_compat.py +++ b/pos_next/overrides/frappe_compat.py @@ -34,9 +34,7 @@ def round_floats_in(self, doc, fieldnames=None, do_not_round_fields=None): else: fieldnames = [ df.fieldname - for df in doc.meta.get( - "fields", {"fieldtype": ["in", ["Currency", "Float", "Percent"]]} - ) + for df in doc.meta.get("fields", {"fieldtype": ["in", ["Currency", "Float", "Percent"]]}) if df.fieldname not in do_not_round_fields ] diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index 0bc8555c1..144b9c731 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -8,9 +8,9 @@ """ import frappe -from frappe.utils import cint, flt from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice from erpnext.accounts.utils import get_account_currency +from frappe.utils import cint, flt def _find_paid_bundle_row_for_free(si_doc, free_row): diff --git a/pos_next/patches/v1_7_0/reinstall_workspace.py b/pos_next/patches/v1_7_0/reinstall_workspace.py index 02a632d44..5654c7ebc 100644 --- a/pos_next/patches/v1_7_0/reinstall_workspace.py +++ b/pos_next/patches/v1_7_0/reinstall_workspace.py @@ -34,8 +34,7 @@ def _reinstall_workspace_from_file(workspace_file: Path): workspace_name = workspace_data.get("name") or workspace_data.get("label") if not workspace_name: frappe.log_error( - title="Workspace Migration Failed", - message=f"Workspace in {workspace_file} has no name or label" + title="Workspace Migration Failed", message=f"Workspace in {workspace_file} has no name or label" ) return @@ -58,8 +57,7 @@ def _remove_workspace(workspace_name: str): frappe.logger().info(f"Removed workspace: {workspace_name}") except Exception: frappe.log_error( - title=f"Failed to Remove Workspace: {workspace_name}", - message=frappe.get_traceback() + title=f"Failed to Remove Workspace: {workspace_name}", message=frappe.get_traceback() ) @@ -76,8 +74,7 @@ def _install_workspace(workspace_data: dict, workspace_name: str): frappe.logger().info(f"Successfully installed workspace: {workspace_name}") except Exception: frappe.log_error( - title=f"Workspace Installation Failed: {workspace_name}", - message=frappe.get_traceback() + title=f"Workspace Installation Failed: {workspace_name}", message=frappe.get_traceback() ) @@ -92,8 +89,7 @@ def _load_workspace_data(workspace_file: Path): """ if not workspace_file.exists(): frappe.log_error( - title="Workspace File Not Found", - message=f"Expected workspace file at: {workspace_file}" + title="Workspace File Not Found", message=f"Expected workspace file at: {workspace_file}" ) return None @@ -102,14 +98,14 @@ def _load_workspace_data(workspace_file: Path): except json.JSONDecodeError: frappe.log_error( title="Invalid Workspace JSON", - message=f"Failed to parse: {workspace_file}\n\n{frappe.get_traceback()}" + message=f"Failed to parse: {workspace_file}\n\n{frappe.get_traceback()}", ) return None if not isinstance(workspace_data, list) or not workspace_data: frappe.log_error( title="Invalid Workspace Structure", - message=f"Workspace JSON must be a non-empty array: {workspace_file}" + message=f"Workspace JSON must be a non-empty array: {workspace_file}", ) return None 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 index 5f22c4ba2..1d52390ce 100644 --- a/pos_next/patches/v2_0_0/remove_custom_company_fields.py +++ b/pos_next/patches/v2_0_0/remove_custom_company_fields.py @@ -1,6 +1,5 @@ import frappe - CUSTOM_FIELDS = [ "Brand-custom_company", "Customer-custom_company", diff --git a/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.js b/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.js index 77adc0596..460f0b9c9 100644 --- a/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.js +++ b/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.js @@ -1,8 +1,8 @@ // Copyright (c) 2025, BrainWise and contributors // For license information, please see license.txt -frappe.ui.form.on('BrainWise Branding', { - refresh: function(frm) { +frappe.ui.form.on("BrainWise Branding", { + refresh: function (frm) { // Add custom buttons and UI elements add_master_key_controls(frm); @@ -13,103 +13,143 @@ frappe.ui.form.on('BrainWise Branding', { add_security_indicators(frm); }, - master_key_provided: function(frm) { + master_key_provided: function (frm) { // When master key is entered, unlock the protected fields if (frm.doc.master_key_provided) { unlock_protected_fields(frm); - frm.dashboard.add_comment('Master key detected. Protected fields are now editable.', 'blue', true); + frm.dashboard.add_comment( + "Master key detected. Protected fields are now editable.", + "blue", + true + ); } }, - enabled: function(frm) { + enabled: function (frm) { // When trying to disable, show warning if (!frm.doc.enabled) { frappe.msgprint({ - title: __('Master Key Required'), - indicator: 'red', - message: __('To disable branding, you must provide the Master Key in JSON format: {"key": "...", "phrase": "..."}') + title: __("Master Key Required"), + indicator: "red", + message: __( + 'To disable branding, you must provide the Master Key in JSON format: {"key": "...", "phrase": "..."}' + ), }); } - } + }, }); function add_master_key_controls(frm) { // Add verify key button - frm.add_custom_button(__('Verify Master Key'), function() { - verify_master_key(frm); - }, __('Actions')); + frm.add_custom_button( + __("Verify Master Key"), + function () { + verify_master_key(frm); + }, + __("Actions") + ); // Add help button - frm.add_custom_button(__('Master Key Help'), function() { - show_master_key_help(); - }, __('Help')); + frm.add_custom_button( + __("Master Key Help"), + function () { + show_master_key_help(); + }, + __("Help") + ); // Add tampering stats button (System Manager only) - if (frappe.user.has_role('System Manager')) { - frm.add_custom_button(__('View Tampering Stats'), function() { - show_tampering_stats(); - }, __('Security')); + if (frappe.user.has_role("System Manager")) { + frm.add_custom_button( + __("View Tampering Stats"), + function () { + show_tampering_stats(); + }, + __("Security") + ); } } function update_field_permissions(frm) { // Protected fields - const protected_fields = ['enabled', 'brand_text', 'brand_name', 'brand_url', 'check_interval']; + const protected_fields = [ + "enabled", + "brand_text", + "brand_name", + "brand_url", + "check_interval", + ]; // If master key is not provided, ensure fields are read-only if (!frm.doc.master_key_provided) { - protected_fields.forEach(field => { - frm.set_df_property(field, 'read_only', 1); + protected_fields.forEach((field) => { + frm.set_df_property(field, "read_only", 1); }); } } function unlock_protected_fields(frm) { // Temporarily unlock protected fields when master key is provided - const protected_fields = ['enabled', 'brand_text', 'brand_name', 'brand_url', 'check_interval']; + const protected_fields = [ + "enabled", + "brand_text", + "brand_name", + "brand_url", + "check_interval", + ]; - protected_fields.forEach(field => { - frm.set_df_property(field, 'read_only', 0); + protected_fields.forEach((field) => { + frm.set_df_property(field, "read_only", 0); }); - frappe.show_alert({ - message: __('Protected fields unlocked. You can now make changes.'), - indicator: 'green' - }, 5); + frappe.show_alert( + { + message: __("Protected fields unlocked. You can now make changes."), + indicator: "green", + }, + 5 + ); } function verify_master_key(frm) { if (!frm.doc.master_key_provided) { frappe.msgprint({ - title: __('No Master Key Provided'), - indicator: 'red', - message: __('Please enter the Master Key in the field above to verify.') + title: __("No Master Key Provided"), + indicator: "red", + message: __("Please enter the Master Key in the field above to verify."), }); return; } frappe.call({ - method: 'pos_next.pos_next.doctype.brainwise_branding.brainwise_branding.verify_master_key', + method: "pos_next.pos_next.doctype.brainwise_branding.brainwise_branding.verify_master_key", args: { - master_key_input: frm.doc.master_key_provided + master_key_input: frm.doc.master_key_provided, }, - callback: function(r) { + callback: function (r) { if (r.message && r.message.valid) { - frappe.show_alert({ - message: __('✅ Master Key is VALID! You can now modify protected fields.'), - indicator: 'green' - }, 10); + frappe.show_alert( + { + message: __( + "✅ Master Key is VALID! You can now modify protected fields." + ), + indicator: "green", + }, + 10 + ); // Unlock fields unlock_protected_fields(frm); } else { frappe.msgprint({ - title: __('Invalid Master Key'), - indicator: 'red', - message: __('The master key you provided is invalid. Please check and try again.

Format: {"key": "...", "phrase": "..."}') + title: __("Invalid Master Key"), + indicator: "red", + message: __( + 'The master key you provided is invalid. Please check and try again.

Format: {"key": "...", "phrase": "..."}' + ), }); } - } + }, }); } @@ -156,16 +196,16 @@ function show_master_key_help() { `; frappe.msgprint({ - title: __('Master Key Help'), + title: __("Master Key Help"), message: help_html, - wide: true + wide: true, }); } function show_tampering_stats() { frappe.call({ - method: 'pos_next.api.branding.get_tampering_stats', - callback: function(r) { + method: "pos_next.api.branding.get_tampering_stats", + callback: function (r) { if (r.message) { const stats = r.message; const stats_html = ` @@ -178,7 +218,11 @@ function show_tampering_stats() { Branding Enabled - ${stats.enabled ? 'Yes' : 'No'} + ${ + stats.enabled + ? 'Yes' + : 'No' + } Total Tampering Attempts @@ -186,15 +230,23 @@ function show_tampering_stats() { Last Validation - ${stats.last_validation || 'Never'} + ${stats.last_validation || "Never"} Server Validation - ${stats.server_validation ? 'Enabled' : 'Disabled'} + ${ + stats.server_validation + ? 'Enabled' + : 'Disabled' + } Logging Enabled - ${stats.logging_enabled ? 'Yes' : 'No'} + ${ + stats.logging_enabled + ? 'Yes' + : 'No' + }

View detailed logs in Error Log

@@ -202,28 +254,31 @@ function show_tampering_stats() { `; frappe.msgprint({ - title: __('Security Statistics'), + title: __("Security Statistics"), message: stats_html, - wide: true + wide: true, }); } - } + }, }); } function add_security_indicators(frm) { // Add security indicator to dashboard if (frm.doc.enabled) { - frm.dashboard.add_indicator(__('Branding Active'), 'green'); + frm.dashboard.add_indicator(__("Branding Active"), "green"); } else { - frm.dashboard.add_indicator(__('Branding Disabled'), 'red'); + frm.dashboard.add_indicator(__("Branding Disabled"), "red"); } // Add tampering indicator if there are attempts if (frm.doc.tampering_attempts > 0) { - frm.dashboard.add_indicator(__('Tampering Attempts: {0}', [frm.doc.tampering_attempts]), 'orange'); + frm.dashboard.add_indicator( + __("Tampering Attempts: {0}", [frm.doc.tampering_attempts]), + "orange" + ); } // Add protection indicator - frm.dashboard.add_indicator(__('🔒 Master Key Protected'), 'blue'); + frm.dashboard.add_indicator(__("🔒 Master Key Protected"), "blue"); } diff --git a/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.py b/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.py index 7a16e35b0..c9cff269d 100644 --- a/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.py +++ b/pos_next/pos_next/doctype/brainwise_branding/brainwise_branding.py @@ -1,15 +1,15 @@ # Copyright (c) 2025, BrainWise and contributors # For license information, please see license.txt -import frappe -from frappe.model.document import Document +import base64 import hashlib import hmac import json -import base64 -from datetime import datetime import secrets +from datetime import datetime +import frappe +from frappe.model.document import Document # MASTER KEY HASH - Only the person with the original key can disable branding # This hash was created from: secrets.token_urlsafe(32) diff --git a/pos_next/pos_next/doctype/offline_invoice_sync/offline_invoice_sync.py b/pos_next/pos_next/doctype/offline_invoice_sync/offline_invoice_sync.py index cbb72a956..f0c1af709 100644 --- a/pos_next/pos_next/doctype/offline_invoice_sync/offline_invoice_sync.py +++ b/pos_next/pos_next/doctype/offline_invoice_sync/offline_invoice_sync.py @@ -7,95 +7,94 @@ class OfflineInvoiceSync(Document): - """ - Tracks offline invoice synchronization to prevent duplicate submissions. - - Each record maps an offline_id (generated client-side) to a Sales Invoice, - allowing the system to detect and prevent duplicate sync attempts. - """ - - def before_insert(self): - """Set synced_at timestamp before insert.""" - if not self.synced_at: - self.synced_at = frappe.utils.now_datetime() - - @staticmethod - def create_sync_record(offline_id, sales_invoice, pos_profile=None, customer=None, status="Synced"): - """ - Create a sync record for an offline invoice. - - Args: - offline_id: The unique offline ID generated by the client - sales_invoice: The Sales Invoice name created on the server - pos_profile: Optional POS Profile name - customer: Optional Customer name - status: Sync status - "Pending", "Synced", or "Failed" - - Returns: - The created OfflineInvoiceSync document or existing one if duplicate - """ - if not offline_id: - return None - - # Check if record already exists - existing = frappe.db.get_value( - "Offline Invoice Sync", - {"offline_id": offline_id}, - ["name", "status"], - as_dict=True - ) - - if existing: - # If existing record is Pending and we're setting to Synced, update it - if existing.status == "Pending" and status == "Synced" and sales_invoice: - sync_doc = frappe.get_doc("Offline Invoice Sync", existing.name) - sync_doc.sales_invoice = sales_invoice - sync_doc.status = "Synced" - sync_doc.synced_at = frappe.utils.now_datetime() - sync_doc.flags.ignore_permissions = True - sync_doc.save() - return frappe.get_doc("Offline Invoice Sync", existing.name) - - doc = frappe.get_doc({ - "doctype": "Offline Invoice Sync", - "offline_id": offline_id, - "sales_invoice": sales_invoice or "", - "pos_profile": pos_profile, - "customer": customer, - "status": status, - }) - doc.flags.ignore_permissions = True - doc.insert() - return doc - - @staticmethod - def is_synced(offline_id): - """ - Check if an offline_id has already been synced. - - Args: - offline_id: The offline ID to check - - Returns: - dict with 'synced' (bool), 'sales_invoice' (str or None), and 'status' (str or None) - """ - if not offline_id: - return {"synced": False, "sales_invoice": None, "status": None} - - existing = frappe.db.get_value( - "Offline Invoice Sync", - {"offline_id": offline_id}, - ["name", "sales_invoice", "status"], - as_dict=True - ) - - if existing: - # Only consider it synced if status is "Synced" and has a sales_invoice - is_synced = existing.status == "Synced" and existing.sales_invoice - return { - "synced": is_synced, - "sales_invoice": existing.sales_invoice if is_synced else None, - "status": existing.status - } - - return {"synced": False, "sales_invoice": None, "status": None} + """ + Tracks offline invoice synchronization to prevent duplicate submissions. + + Each record maps an offline_id (generated client-side) to a Sales Invoice, + allowing the system to detect and prevent duplicate sync attempts. + """ + + def before_insert(self): + """Set synced_at timestamp before insert.""" + if not self.synced_at: + self.synced_at = frappe.utils.now_datetime() + + @staticmethod + def create_sync_record(offline_id, sales_invoice, pos_profile=None, customer=None, status="Synced"): + """ + Create a sync record for an offline invoice. + + Args: + offline_id: The unique offline ID generated by the client + sales_invoice: The Sales Invoice name created on the server + pos_profile: Optional POS Profile name + customer: Optional Customer name + status: Sync status - "Pending", "Synced", or "Failed" + + Returns: + The created OfflineInvoiceSync document or existing one if duplicate + """ + if not offline_id: + return None + + # Check if record already exists + existing = frappe.db.get_value( + "Offline Invoice Sync", {"offline_id": offline_id}, ["name", "status"], as_dict=True + ) + + if existing: + # If existing record is Pending and we're setting to Synced, update it + if existing.status == "Pending" and status == "Synced" and sales_invoice: + sync_doc = frappe.get_doc("Offline Invoice Sync", existing.name) + sync_doc.sales_invoice = sales_invoice + sync_doc.status = "Synced" + sync_doc.synced_at = frappe.utils.now_datetime() + sync_doc.flags.ignore_permissions = True + sync_doc.save() + return frappe.get_doc("Offline Invoice Sync", existing.name) + + doc = frappe.get_doc( + { + "doctype": "Offline Invoice Sync", + "offline_id": offline_id, + "sales_invoice": sales_invoice or "", + "pos_profile": pos_profile, + "customer": customer, + "status": status, + } + ) + doc.flags.ignore_permissions = True + doc.insert() + return doc + + @staticmethod + def is_synced(offline_id): + """ + Check if an offline_id has already been synced. + + Args: + offline_id: The offline ID to check + + Returns: + dict with 'synced' (bool), 'sales_invoice' (str or None), and 'status' (str or None) + """ + if not offline_id: + return {"synced": False, "sales_invoice": None, "status": None} + + existing = frappe.db.get_value( + "Offline Invoice Sync", + {"offline_id": offline_id}, + ["name", "sales_invoice", "status"], + as_dict=True, + ) + + if existing: + # Only consider it synced if status is "Synced" and has a sales_invoice + is_synced = existing.status == "Synced" and existing.sales_invoice + return { + "synced": is_synced, + "sales_invoice": existing.sales_invoice if is_synced else None, + "status": existing.status, + } + + return {"synced": False, "sales_invoice": None, "status": None} diff --git a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js index e5201644f..5d67b3c45 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js +++ b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js @@ -20,7 +20,8 @@ frappe.ui.form.on("POS Closing Shift", { return { filters: { status: "Open", docstatus: 1 } }; }); - if (frm.doc.docstatus === 0) frm.set_value("period_end_date", frappe.datetime.now_datetime()); + if (frm.doc.docstatus === 0) + frm.set_value("period_end_date", frappe.datetime.now_datetime()); if (frm.doc.docstatus === 1) set_html_data(frm); }, @@ -35,10 +36,10 @@ frappe.ui.form.on("POS Closing Shift", { frm.docname, "POS Next EOD Report", frm.doc.letter_head, - frm.doc.language || frappe.boot.lang, + frm.doc.language || frappe.boot.lang ); }, - __("Print"), + __("Print") ); }, @@ -54,8 +55,8 @@ frappe.ui.form.on("POS Closing Shift", { }, set_opening_amounts(frm) { - return frappe - .db.get_doc("POS Opening Shift", frm.doc.pos_opening_shift) + return frappe.db + .get_doc("POS Opening Shift", frm.doc.pos_opening_shift) .then(({ balance_details }) => { balance_details.forEach((detail) => { frm.add_child("payment_reconciliation", { @@ -101,7 +102,12 @@ frappe.ui.form.on("POS Closing Shift", { frappe.ui.form.on("POS Closing Shift Detail", { closing_amount: (frm, cdt, cdn) => { const row = locals[cdt][cdn]; - frappe.model.set_value(cdt, cdn, "difference", flt(row.expected_amount - row.closing_amount)); + frappe.model.set_value( + cdt, + cdn, + "difference", + flt(row.expected_amount - row.closing_amount) + ); }, }); @@ -138,7 +144,12 @@ function set_form_payments_data(data, frm) { function add_to_pos_transaction(d, frm, base_grand_total) { if (base_grand_total === undefined) { - base_grand_total = get_base_value(d, "grand_total", "base_grand_total", get_conversion_rate(d)); + base_grand_total = get_base_value( + d, + "grand_total", + "base_grand_total", + get_conversion_rate(d) + ); } const child = { posting_date: d.posting_date, @@ -169,7 +180,7 @@ function add_to_payments(d, frm, conversion_rate) { let cash_mode_of_payment = get_value( "POS Profile", frm.doc.pos_profile, - "posa_cash_mode_of_payment", + "posa_cash_mode_of_payment" ); if (!cash_mode_of_payment) { cash_mode_of_payment = "Cash"; @@ -177,9 +188,7 @@ function add_to_payments(d, frm, conversion_rate) { // Cross-branch return safety net: collect known modes from opening balance // so we can remap foreign payment modes on return invoices. - const known_modes = new Set( - frm.doc.payment_reconciliation.map((pay) => pay.mode_of_payment), - ); + const known_modes = new Set(frm.doc.payment_reconciliation.map((pay) => pay.mode_of_payment)); // Aggregate each payment row's amount into the reconciliation buckets. d.payments.forEach((p) => { @@ -204,7 +213,7 @@ function add_to_payments(d, frm, conversion_rate) { function aggregate_payment(frm, mode_of_payment, amount) { const payment = frm.doc.payment_reconciliation.find( - (pay) => pay.mode_of_payment === mode_of_payment, + (pay) => pay.mode_of_payment === mode_of_payment ); if (payment) { payment.expected_amount += flt(amount); @@ -218,13 +227,19 @@ function aggregate_payment(frm, mode_of_payment, amount) { } function add_pos_payment_to_payments(p, frm) { - aggregate_payment(frm, p.mode_of_payment, get_base_value(p, "paid_amount", "base_paid_amount")); + aggregate_payment( + frm, + p.mode_of_payment, + get_base_value(p, "paid_amount", "base_paid_amount") + ); } function add_to_taxes(d, frm, conversion_rate) { d.taxes.forEach((t) => { const tax_amount = get_base_value(t, "tax_amount", "base_tax_amount", conversion_rate); - const tax = frm.doc.taxes.find((tx) => tx.account_head === t.account_head && tx.rate === t.rate); + const tax = frm.doc.taxes.find( + (tx) => tx.account_head === t.account_head && tx.rate === t.rate + ); if (tax) { tax.amount += flt(tax_amount); } else { diff --git a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py index f45c56d10..5ac43bdbc 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py +++ b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py @@ -6,7 +6,7 @@ import frappe from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import ( - consolidate_pos_invoices, + consolidate_pos_invoices, ) from frappe import _ from frappe.model.document import Document @@ -14,164 +14,158 @@ def get_base_value(doc, fieldname, base_fieldname=None, conversion_rate=None): - """Return the value for a field in company currency.""" + """Return the value for a field in company currency.""" - base_fieldname = base_fieldname or f"base_{fieldname}" - base_value = doc.get(base_fieldname) + base_fieldname = base_fieldname or f"base_{fieldname}" + base_value = doc.get(base_fieldname) - if base_value not in (None, ""): - return flt(base_value) + if base_value not in (None, ""): + return flt(base_value) - value = doc.get(fieldname) - if value in (None, ""): - return 0 + value = doc.get(fieldname) + if value in (None, ""): + return 0 - if conversion_rate is None: - conversion_rate = ( - doc.get("conversion_rate") - or doc.get("exchange_rate") - or doc.get("target_exchange_rate") - or doc.get("plc_conversion_rate") - or 1 - ) + if conversion_rate is None: + conversion_rate = ( + doc.get("conversion_rate") + or doc.get("exchange_rate") + or doc.get("target_exchange_rate") + or doc.get("plc_conversion_rate") + or 1 + ) - return flt(value) * flt(conversion_rate or 1) + return flt(value) * flt(conversion_rate or 1) class POSClosingShift(Document): - def validate(self): - user = frappe.get_all( - "POS Closing Shift", - filters={ - "user": self.user, - "docstatus": 1, - "pos_opening_shift": self.pos_opening_shift, - "name": ["!=", self.name], - }, - ) - - if user: - frappe.throw( - _( - "POS Closing Shift already exists against {0} between selected period".format( - frappe.bold(self.user) - ) - ), - title=_("Invalid Period"), - ) - - if frappe.db.get_value("POS Opening Shift", self.pos_opening_shift, "status") != "Open": - frappe.throw( - _("Selected POS Opening Shift should be open."), - title=_("Invalid Opening Entry"), - ) - self.update_payment_reconciliation() - - def update_payment_reconciliation(self): - # update the difference values in Payment Reconciliation child table - # get default precision for site - precision = frappe.get_cached_value("System Settings", None, "currency_precision") or 3 - for d in self.payment_reconciliation: - d.difference = +flt(d.closing_amount, precision) - flt(d.expected_amount, precision) - - def on_submit(self): - opening_entry = frappe.get_doc("POS Opening Shift", self.pos_opening_shift) - opening_entry.pos_closing_shift = self.name - opening_entry.set_status() - self.delete_draft_invoices() - opening_entry.save() - # link invoices with this closing shift so ERPNext can block edits - self._set_closing_entry_invoices() - - def on_cancel(self): - if frappe.db.exists("POS Opening Shift", self.pos_opening_shift): - opening_entry = frappe.get_doc("POS Opening Shift", self.pos_opening_shift) - if opening_entry.pos_closing_shift == self.name: - opening_entry.pos_closing_shift = "" - opening_entry.set_status() - opening_entry.save() - # remove links from invoices so they can be cancelled - self._clear_closing_entry_invoices() - - def _set_closing_entry_invoices(self): - """Set `pos_closing_entry` on linked invoices.""" - for d in self.pos_transactions: - invoice = d.get("sales_invoice") or d.get("pos_invoice") - if not invoice: - continue - doctype = "Sales Invoice" if d.get("sales_invoice") else "POS Invoice" - if frappe.db.has_column(doctype, "pos_closing_entry"): - frappe.db.set_value(doctype, invoice, "pos_closing_entry", self.name) - - def _clear_closing_entry_invoices(self): - """Clear closing shift links, cancel merge logs and cancel consolidated sales invoices.""" - consolidated_sales_invoices = set() - for d in self.pos_transactions: - pos_invoice = d.get("pos_invoice") - sales_invoice = d.get("sales_invoice") - if pos_invoice: - if frappe.db.has_column("POS Invoice", "pos_closing_entry"): - frappe.db.set_value("POS Invoice", pos_invoice, "pos_closing_entry", None) - - merge_logs = frappe.get_all( - "POS Invoice Merge Log", - filters={"pos_invoice": pos_invoice}, - pluck="name", - ) - for log in merge_logs: - log_doc = frappe.get_doc("POS Invoice Merge Log", log) - for field in ( - "consolidated_invoice", - "consolidated_credit_note", - ): - si = log_doc.get(field) - if si: - consolidated_sales_invoices.add(si) - if log_doc.docstatus == 1: - log_doc.cancel() - frappe.delete_doc("POS Invoice Merge Log", log_doc.name, force=1) - - if frappe.db.has_column("POS Invoice", "consolidated_invoice"): - frappe.db.set_value("POS Invoice", pos_invoice, "consolidated_invoice", None) - - if frappe.db.has_column("POS Invoice", "status"): - pos_doc = frappe.get_doc("POS Invoice", pos_invoice) - pos_doc.set_status(update=True) - - if sales_invoice: - if frappe.db.has_column("Sales Invoice", "pos_closing_entry"): - frappe.db.set_value("Sales Invoice", sales_invoice, "pos_closing_entry", None) - if self._is_consolidated_sales_invoice(sales_invoice): - consolidated_sales_invoices.add(sales_invoice) - - for si in consolidated_sales_invoices: - if frappe.db.exists("Sales Invoice", si): - si_doc = frappe.get_doc("Sales Invoice", si) - if si_doc.docstatus == 1: - si_doc.cancel() - - def _is_consolidated_sales_invoice(self, sales_invoice): - """Return True if the Sales Invoice was generated by consolidating POS Invoices.""" - - if not sales_invoice: - return False - - if frappe.db.exists( - "POS Invoice Merge Log", {"consolidated_invoice": sales_invoice} - ): - return True - - return bool( - frappe.db.exists( - "POS Invoice Merge Log", {"consolidated_credit_note": sales_invoice} - ) - ) - - def delete_draft_invoices(self): - if frappe.get_value("POS Profile", self.pos_profile, "posa_allow_delete"): - doctype = "Sales Invoice" - data = frappe.db.sql( - f""" + def validate(self): + user = frappe.get_all( + "POS Closing Shift", + filters={ + "user": self.user, + "docstatus": 1, + "pos_opening_shift": self.pos_opening_shift, + "name": ["!=", self.name], + }, + ) + + if user: + frappe.throw( + _( + "POS Closing Shift already exists against {0} between selected period".format( + frappe.bold(self.user) + ) + ), + title=_("Invalid Period"), + ) + + if frappe.db.get_value("POS Opening Shift", self.pos_opening_shift, "status") != "Open": + frappe.throw( + _("Selected POS Opening Shift should be open."), + title=_("Invalid Opening Entry"), + ) + self.update_payment_reconciliation() + + def update_payment_reconciliation(self): + # update the difference values in Payment Reconciliation child table + # get default precision for site + precision = frappe.get_cached_value("System Settings", None, "currency_precision") or 3 + for d in self.payment_reconciliation: + d.difference = +flt(d.closing_amount, precision) - flt(d.expected_amount, precision) + + def on_submit(self): + opening_entry = frappe.get_doc("POS Opening Shift", self.pos_opening_shift) + opening_entry.pos_closing_shift = self.name + opening_entry.set_status() + self.delete_draft_invoices() + opening_entry.save() + # link invoices with this closing shift so ERPNext can block edits + self._set_closing_entry_invoices() + + def on_cancel(self): + if frappe.db.exists("POS Opening Shift", self.pos_opening_shift): + opening_entry = frappe.get_doc("POS Opening Shift", self.pos_opening_shift) + if opening_entry.pos_closing_shift == self.name: + opening_entry.pos_closing_shift = "" + opening_entry.set_status() + opening_entry.save() + # remove links from invoices so they can be cancelled + self._clear_closing_entry_invoices() + + def _set_closing_entry_invoices(self): + """Set `pos_closing_entry` on linked invoices.""" + for d in self.pos_transactions: + invoice = d.get("sales_invoice") or d.get("pos_invoice") + if not invoice: + continue + doctype = "Sales Invoice" if d.get("sales_invoice") else "POS Invoice" + if frappe.db.has_column(doctype, "pos_closing_entry"): + frappe.db.set_value(doctype, invoice, "pos_closing_entry", self.name) + + def _clear_closing_entry_invoices(self): + """Clear closing shift links, cancel merge logs and cancel consolidated sales invoices.""" + consolidated_sales_invoices = set() + for d in self.pos_transactions: + pos_invoice = d.get("pos_invoice") + sales_invoice = d.get("sales_invoice") + if pos_invoice: + if frappe.db.has_column("POS Invoice", "pos_closing_entry"): + frappe.db.set_value("POS Invoice", pos_invoice, "pos_closing_entry", None) + + merge_logs = frappe.get_all( + "POS Invoice Merge Log", + filters={"pos_invoice": pos_invoice}, + pluck="name", + ) + for log in merge_logs: + log_doc = frappe.get_doc("POS Invoice Merge Log", log) + for field in ( + "consolidated_invoice", + "consolidated_credit_note", + ): + si = log_doc.get(field) + if si: + consolidated_sales_invoices.add(si) + if log_doc.docstatus == 1: + log_doc.cancel() + frappe.delete_doc("POS Invoice Merge Log", log_doc.name, force=1) + + if frappe.db.has_column("POS Invoice", "consolidated_invoice"): + frappe.db.set_value("POS Invoice", pos_invoice, "consolidated_invoice", None) + + if frappe.db.has_column("POS Invoice", "status"): + pos_doc = frappe.get_doc("POS Invoice", pos_invoice) + pos_doc.set_status(update=True) + + if sales_invoice: + if frappe.db.has_column("Sales Invoice", "pos_closing_entry"): + frappe.db.set_value("Sales Invoice", sales_invoice, "pos_closing_entry", None) + if self._is_consolidated_sales_invoice(sales_invoice): + consolidated_sales_invoices.add(sales_invoice) + + for si in consolidated_sales_invoices: + if frappe.db.exists("Sales Invoice", si): + si_doc = frappe.get_doc("Sales Invoice", si) + if si_doc.docstatus == 1: + si_doc.cancel() + + def _is_consolidated_sales_invoice(self, sales_invoice): + """Return True if the Sales Invoice was generated by consolidating POS Invoices.""" + + if not sales_invoice: + return False + + if frappe.db.exists("POS Invoice Merge Log", {"consolidated_invoice": sales_invoice}): + return True + + return bool(frappe.db.exists("POS Invoice Merge Log", {"consolidated_credit_note": sales_invoice})) + + def delete_draft_invoices(self): + if frappe.get_value("POS Profile", self.pos_profile, "posa_allow_delete"): + doctype = "Sales Invoice" + data = frappe.db.sql( + f""" select name from @@ -179,197 +173,192 @@ def delete_draft_invoices(self): where docstatus = 0 and posa_is_printed = 0 and posa_pos_opening_shift = %s """, - (self.pos_opening_shift), - as_dict=1, - ) - - for invoice in data: - frappe.delete_doc(doctype, invoice.name, force=1) - - @frappe.whitelist() - def get_payment_reconciliation_details(self): - company_currency = frappe.get_cached_value( - "Company", self.company, "default_currency" - ) - - sales_breakdown = defaultdict(float) - net_breakdown = defaultdict(float) - payment_breakdown = {} - - def update_payment_breakdown(mode_of_payment, base_amount=0, currency=None, amount=0): - if not mode_of_payment: - return - - row = payment_breakdown.setdefault( - mode_of_payment, - {"base": 0.0, "currencies": defaultdict(float)}, - ) - row["base"] += flt(base_amount) - if currency: - row["currencies"][currency] += flt(amount) - - cash_mode_of_payment = ( - frappe.db.get_value( - "POS Profile", self.pos_profile, "posa_cash_mode_of_payment" - ) - or "Cash" - ) - - for row in self.get("pos_transactions", []): - invoice = row.get("sales_invoice") or row.get("pos_invoice") - if not invoice: - continue - - doctype = "Sales Invoice" if row.get("sales_invoice") else "POS Invoice" - if not frappe.db.exists(doctype, invoice): - continue - - invoice_doc = frappe.get_cached_doc(doctype, invoice) - currency = invoice_doc.get("currency") or company_currency - conversion_rate = ( - invoice_doc.get("conversion_rate") - or invoice_doc.get("exchange_rate") - or invoice_doc.get("target_exchange_rate") - or invoice_doc.get("plc_conversion_rate") - or 1 - ) - - sales_breakdown[currency] += flt(invoice_doc.get("grand_total") or 0) - net_breakdown[currency] += flt(invoice_doc.get("net_total") or 0) - - for payment in invoice_doc.get("payments", []): - update_payment_breakdown( - payment.mode_of_payment, - get_base_value(payment, "amount", "base_amount", conversion_rate), - currency, - payment.amount, - ) - - change_amount = invoice_doc.get("change_amount") or 0 - if change_amount: - update_payment_breakdown( - cash_mode_of_payment, - -get_base_value( - invoice_doc, - "change_amount", - "base_change_amount", - conversion_rate, - ), - currency, - -change_amount, - ) - - for row in self.get("pos_payments", []): - payment_entry = row.get("payment_entry") - if not payment_entry or not frappe.db.exists("Payment Entry", payment_entry): - continue - - payment_doc = frappe.get_cached_doc("Payment Entry", payment_entry) - currency = ( - payment_doc.get("paid_from_account_currency") - or payment_doc.get("paid_to_account_currency") - or payment_doc.get("party_account_currency") - or payment_doc.get("currency") - or company_currency - ) - base_amount = flt(payment_doc.get("base_paid_amount") or 0) - paid_amount = flt(payment_doc.get("paid_amount") or 0) - mode_of_payment = row.get("mode_of_payment") or payment_doc.get("mode_of_payment") - - update_payment_breakdown(mode_of_payment, base_amount, currency, paid_amount) - - mode_summaries = [] - payment_breakdown_copy = payment_breakdown.copy() - for detail in self.get("payment_reconciliation", []): - mop = detail.mode_of_payment - breakdown = payment_breakdown_copy.pop(mop, None) - currencies = [] - if breakdown: - currencies = [ - frappe._dict({"currency": currency, "amount": amount}) - for currency, amount in sorted(breakdown["currencies"].items()) - if amount - ] - - base_total = flt(detail.expected_amount) - flt(detail.opening_amount) - - mode_summaries.append( - frappe._dict( - { - "mode_of_payment": mop, - "base_amount": base_total, - "opening_amount": flt(detail.opening_amount), - "expected_amount": flt(detail.expected_amount), - "difference": flt(detail.difference), - "currency_breakdown": currencies, - } - ) - ) - - for mop, breakdown in payment_breakdown_copy.items(): - mode_summaries.append( - frappe._dict( - { - "mode_of_payment": mop, - "base_amount": breakdown["base"], - "opening_amount": 0, - "expected_amount": breakdown["base"], - "difference": 0, - "currency_breakdown": [ - frappe._dict({"currency": currency, "amount": amount}) - for currency, amount in sorted(breakdown["currencies"].items()) - if amount - ], - } - ) - ) - - sales_currency_breakdown = [ - frappe._dict({"currency": currency, "amount": amount}) - for currency, amount in sorted(sales_breakdown.items()) - if amount - ] - net_currency_breakdown = [ - frappe._dict({"currency": currency, "amount": amount}) - for currency, amount in sorted(net_breakdown.items()) - if amount - ] - - return frappe.render_template( - "pos_next/pos_next/doctype/pos_closing_shift/closing_shift_details.html", - { - "data": self, - "currency": company_currency, - "company_currency": company_currency, - "mode_summaries": mode_summaries, - "sales_currency_breakdown": sales_currency_breakdown, - "net_currency_breakdown": net_currency_breakdown, - }, - ) + (self.pos_opening_shift), + as_dict=1, + ) + + for invoice in data: + frappe.delete_doc(doctype, invoice.name, force=1) + + @frappe.whitelist() + def get_payment_reconciliation_details(self): + company_currency = frappe.get_cached_value("Company", self.company, "default_currency") + + sales_breakdown = defaultdict(float) + net_breakdown = defaultdict(float) + payment_breakdown = {} + + def update_payment_breakdown(mode_of_payment, base_amount=0, currency=None, amount=0): + if not mode_of_payment: + return + + row = payment_breakdown.setdefault( + mode_of_payment, + {"base": 0.0, "currencies": defaultdict(float)}, + ) + row["base"] += flt(base_amount) + if currency: + row["currencies"][currency] += flt(amount) + + cash_mode_of_payment = ( + frappe.db.get_value("POS Profile", self.pos_profile, "posa_cash_mode_of_payment") or "Cash" + ) + + for row in self.get("pos_transactions", []): + invoice = row.get("sales_invoice") or row.get("pos_invoice") + if not invoice: + continue + + doctype = "Sales Invoice" if row.get("sales_invoice") else "POS Invoice" + if not frappe.db.exists(doctype, invoice): + continue + + invoice_doc = frappe.get_cached_doc(doctype, invoice) + currency = invoice_doc.get("currency") or company_currency + conversion_rate = ( + invoice_doc.get("conversion_rate") + or invoice_doc.get("exchange_rate") + or invoice_doc.get("target_exchange_rate") + or invoice_doc.get("plc_conversion_rate") + or 1 + ) + + sales_breakdown[currency] += flt(invoice_doc.get("grand_total") or 0) + net_breakdown[currency] += flt(invoice_doc.get("net_total") or 0) + + for payment in invoice_doc.get("payments", []): + update_payment_breakdown( + payment.mode_of_payment, + get_base_value(payment, "amount", "base_amount", conversion_rate), + currency, + payment.amount, + ) + + change_amount = invoice_doc.get("change_amount") or 0 + if change_amount: + update_payment_breakdown( + cash_mode_of_payment, + -get_base_value( + invoice_doc, + "change_amount", + "base_change_amount", + conversion_rate, + ), + currency, + -change_amount, + ) + + for row in self.get("pos_payments", []): + payment_entry = row.get("payment_entry") + if not payment_entry or not frappe.db.exists("Payment Entry", payment_entry): + continue + + payment_doc = frappe.get_cached_doc("Payment Entry", payment_entry) + currency = ( + payment_doc.get("paid_from_account_currency") + or payment_doc.get("paid_to_account_currency") + or payment_doc.get("party_account_currency") + or payment_doc.get("currency") + or company_currency + ) + base_amount = flt(payment_doc.get("base_paid_amount") or 0) + paid_amount = flt(payment_doc.get("paid_amount") or 0) + mode_of_payment = row.get("mode_of_payment") or payment_doc.get("mode_of_payment") + + update_payment_breakdown(mode_of_payment, base_amount, currency, paid_amount) + + mode_summaries = [] + payment_breakdown_copy = payment_breakdown.copy() + for detail in self.get("payment_reconciliation", []): + mop = detail.mode_of_payment + breakdown = payment_breakdown_copy.pop(mop, None) + currencies = [] + if breakdown: + currencies = [ + frappe._dict({"currency": currency, "amount": amount}) + for currency, amount in sorted(breakdown["currencies"].items()) + if amount + ] + + base_total = flt(detail.expected_amount) - flt(detail.opening_amount) + + mode_summaries.append( + frappe._dict( + { + "mode_of_payment": mop, + "base_amount": base_total, + "opening_amount": flt(detail.opening_amount), + "expected_amount": flt(detail.expected_amount), + "difference": flt(detail.difference), + "currency_breakdown": currencies, + } + ) + ) + + for mop, breakdown in payment_breakdown_copy.items(): + mode_summaries.append( + frappe._dict( + { + "mode_of_payment": mop, + "base_amount": breakdown["base"], + "opening_amount": 0, + "expected_amount": breakdown["base"], + "difference": 0, + "currency_breakdown": [ + frappe._dict({"currency": currency, "amount": amount}) + for currency, amount in sorted(breakdown["currencies"].items()) + if amount + ], + } + ) + ) + + sales_currency_breakdown = [ + frappe._dict({"currency": currency, "amount": amount}) + for currency, amount in sorted(sales_breakdown.items()) + if amount + ] + net_currency_breakdown = [ + frappe._dict({"currency": currency, "amount": amount}) + for currency, amount in sorted(net_breakdown.items()) + if amount + ] + + return frappe.render_template( + "pos_next/pos_next/doctype/pos_closing_shift/closing_shift_details.html", + { + "data": self, + "currency": company_currency, + "company_currency": company_currency, + "mode_summaries": mode_summaries, + "sales_currency_breakdown": sales_currency_breakdown, + "net_currency_breakdown": net_currency_breakdown, + }, + ) @frappe.whitelist() def get_cashiers(doctype, txt, searchfield, start, page_len, filters): - cashiers_list = frappe.get_all("POS Profile User", filters=filters, fields=["user"]) - result = [] - for cashier in cashiers_list: - user_email = frappe.get_value("User", cashier.user, "email") - if user_email: - # Return list of tuples in format (value, label) where value is user ID and label shows both ID and email - result.append([cashier.user, f"{cashier.user} ({user_email})"]) - return result + cashiers_list = frappe.get_all("POS Profile User", filters=filters, fields=["user"]) + result = [] + for cashier in cashiers_list: + user_email = frappe.get_value("User", cashier.user, "email") + if user_email: + # Return list of tuples in format (value, label) where value is user ID and label shows both ID and email + result.append([cashier.user, f"{cashier.user} ({user_email})"]) + return result @frappe.whitelist() def get_pos_invoices(pos_opening_shift, doctype=None): - if not doctype: - pos_profile = frappe.db.get_value("POS Opening Shift", pos_opening_shift, "pos_profile") - use_pos_invoice = False - doctype = "POS Invoice" if use_pos_invoice else "Sales Invoice" - submit_printed_invoices(pos_opening_shift, doctype) - cond = " and ifnull(consolidated_invoice,'') = ''" if doctype == "POS Invoice" else "" - data = frappe.db.sql( - f""" + if not doctype: + pos_profile = frappe.db.get_value("POS Opening Shift", pos_opening_shift, "pos_profile") + use_pos_invoice = False + doctype = "POS Invoice" if use_pos_invoice else "Sales Invoice" + submit_printed_invoices(pos_opening_shift, doctype) + cond = " and ifnull(consolidated_invoice,'') = ''" if doctype == "POS Invoice" else "" + data = frappe.db.sql( + f""" select name from @@ -377,258 +366,289 @@ def get_pos_invoices(pos_opening_shift, doctype=None): where docstatus = 1 and posa_pos_opening_shift = %s{cond} """, - (pos_opening_shift), - as_dict=1, - ) + (pos_opening_shift), + as_dict=1, + ) - data = [frappe.get_doc(doctype, d.name).as_dict() for d in data] + data = [frappe.get_doc(doctype, d.name).as_dict() for d in data] - return data + return data @frappe.whitelist() def get_payments_entries(pos_opening_shift): - return frappe.get_all( - "Payment Entry", - filters={ - "docstatus": 1, - "reference_no": pos_opening_shift, - "payment_type": "Receive", - }, - fields=[ - "name", - "mode_of_payment", - "paid_amount", - "base_paid_amount", - "target_exchange_rate", - "reference_no", - "posting_date", - "party", - ], - ) + return frappe.get_all( + "Payment Entry", + filters={ + "docstatus": 1, + "reference_no": pos_opening_shift, + "payment_type": "Receive", + }, + fields=[ + "name", + "mode_of_payment", + "paid_amount", + "base_paid_amount", + "target_exchange_rate", + "reference_no", + "posting_date", + "party", + ], + ) def _get_cash_mode_of_payment(pos_profile): - """Get the cash mode of payment for a POS profile.""" - cash_mode = frappe.get_value("POS Profile", pos_profile, "posa_cash_mode_of_payment") - return cash_mode or "Cash" + """Get the cash mode of payment for a POS profile.""" + cash_mode = frappe.get_value("POS Profile", pos_profile, "posa_cash_mode_of_payment") + return cash_mode or "Cash" def _aggregate_payment(payments, mode_of_payment, amount, opening_amount=0): - """Add or update payment amount for a mode of payment.""" - for pay in payments: - if pay.mode_of_payment == mode_of_payment: - pay.expected_amount += flt(amount) - return - payments.append(frappe._dict({ - "mode_of_payment": mode_of_payment, - "opening_amount": opening_amount, - "expected_amount": flt(amount) + opening_amount, - })) + """Add or update payment amount for a mode of payment.""" + for pay in payments: + if pay.mode_of_payment == mode_of_payment: + pay.expected_amount += flt(amount) + return + payments.append( + frappe._dict( + { + "mode_of_payment": mode_of_payment, + "opening_amount": opening_amount, + "expected_amount": flt(amount) + opening_amount, + } + ) + ) def _aggregate_tax(taxes, account_head, rate, amount): - """Add or update tax amount for an account.""" - for tax in taxes: - if tax.account_head == account_head and tax.rate == rate: - tax.amount += amount - return - taxes.append(frappe._dict({ - "account_head": account_head, - "rate": rate, - "amount": amount, - })) + """Add or update tax amount for an account.""" + for tax in taxes: + if tax.account_head == account_head and tax.rate == rate: + tax.amount += amount + return + taxes.append( + frappe._dict( + { + "account_head": account_head, + "rate": rate, + "amount": amount, + } + ) + ) def _process_invoice(invoice, invoice_field, company_currency, cash_mode, payments, taxes, summary): - """Process a single invoice and update aggregates.""" - conversion_rate = invoice.get("conversion_rate") - is_return = invoice.get("is_return", 0) - - base_grand_total = get_base_value(invoice, "grand_total", "base_grand_total", conversion_rate) - base_net_total = get_base_value(invoice, "net_total", "base_net_total", conversion_rate) - - # Credit returns with no payment rows were added to customer credit — - # no money entered or left the drawer. Skip entirely. - if is_return and not invoice.payments: - return frappe._dict({ - invoice_field: invoice.name, - "posting_date": invoice.posting_date, - "grand_total": 0, - "transaction_currency": invoice.get("currency") or company_currency, - "transaction_amount": flt(invoice.get("grand_total")), - "customer": invoice.customer, - "is_return": is_return, - "return_against": invoice.get("return_against"), - }) - - # Build transaction record - transaction = frappe._dict({ - invoice_field: invoice.name, - "posting_date": invoice.posting_date, - "grand_total": base_grand_total, - "transaction_currency": invoice.get("currency") or company_currency, - "transaction_amount": flt(invoice.get("grand_total")), - "customer": invoice.customer, - "is_return": is_return, - "return_against": invoice.get("return_against") if is_return else None, - }) - - # Update summary totals - summary["grand_total"] += base_grand_total - summary["net_total"] += base_net_total - summary["total_quantity"] += flt(invoice.total_qty) - - if is_return: - summary["returns_total"] += abs(base_grand_total) - summary["returns_count"] += 1 - else: - summary["sales_total"] += base_grand_total - summary["sales_count"] += 1 - - # Process taxes - for t in invoice.taxes: - tax_amount = get_base_value(t, "tax_amount", "base_tax_amount", conversion_rate) - _aggregate_tax(taxes, t.account_head, t.rate, tax_amount) - - # Process payments - # - # Cross-branch return safety net (Layer 3): - # Return invoices may carry foreign payment modes from the original - # invoice's POS profile. Remap unknown modes to the cash mode so the - # reconciliation table stays clean. - known_modes = {pay.mode_of_payment for pay in payments} - - # Aggregate each payment row's amount into the reconciliation buckets. - for p in invoice.payments: - amount = get_base_value(p, "amount", "base_amount", conversion_rate) - mode = p.mode_of_payment - - if is_return and mode not in known_modes: - mode = cash_mode - - _aggregate_payment(payments, mode, amount) - - # Subtract change_amount once from the cash mode. change_amount is an - # invoice-level field — the customer overpaid and received change back, - # so the drawer's net gain is (sum of cash rows − change). Handling it - # outside the loop avoids double-subtraction when multiple payment rows - # share the same cash mode. - base_change = get_base_value(invoice, "change_amount", "base_change_amount", conversion_rate) - if base_change: - _aggregate_payment(payments, cash_mode, -base_change) - - return transaction + """Process a single invoice and update aggregates.""" + conversion_rate = invoice.get("conversion_rate") + is_return = invoice.get("is_return", 0) + + base_grand_total = get_base_value(invoice, "grand_total", "base_grand_total", conversion_rate) + base_net_total = get_base_value(invoice, "net_total", "base_net_total", conversion_rate) + + # Credit returns with no payment rows were added to customer credit — + # no money entered or left the drawer. Skip entirely. + if is_return and not invoice.payments: + return frappe._dict( + { + invoice_field: invoice.name, + "posting_date": invoice.posting_date, + "grand_total": 0, + "transaction_currency": invoice.get("currency") or company_currency, + "transaction_amount": flt(invoice.get("grand_total")), + "customer": invoice.customer, + "is_return": is_return, + "return_against": invoice.get("return_against"), + } + ) + + # Build transaction record + transaction = frappe._dict( + { + invoice_field: invoice.name, + "posting_date": invoice.posting_date, + "grand_total": base_grand_total, + "transaction_currency": invoice.get("currency") or company_currency, + "transaction_amount": flt(invoice.get("grand_total")), + "customer": invoice.customer, + "is_return": is_return, + "return_against": invoice.get("return_against") if is_return else None, + } + ) + + # Update summary totals + summary["grand_total"] += base_grand_total + summary["net_total"] += base_net_total + summary["total_quantity"] += flt(invoice.total_qty) + + if is_return: + summary["returns_total"] += abs(base_grand_total) + summary["returns_count"] += 1 + else: + summary["sales_total"] += base_grand_total + summary["sales_count"] += 1 + + # Process taxes + for t in invoice.taxes: + tax_amount = get_base_value(t, "tax_amount", "base_tax_amount", conversion_rate) + _aggregate_tax(taxes, t.account_head, t.rate, tax_amount) + + # Process payments + # + # Cross-branch return safety net (Layer 3): + # Return invoices may carry foreign payment modes from the original + # invoice's POS profile. Remap unknown modes to the cash mode so the + # reconciliation table stays clean. + known_modes = {pay.mode_of_payment for pay in payments} + + # Aggregate each payment row's amount into the reconciliation buckets. + for p in invoice.payments: + amount = get_base_value(p, "amount", "base_amount", conversion_rate) + mode = p.mode_of_payment + + if is_return and mode not in known_modes: + mode = cash_mode + + _aggregate_payment(payments, mode, amount) + + # Subtract change_amount once from the cash mode. change_amount is an + # invoice-level field — the customer overpaid and received change back, + # so the drawer's net gain is (sum of cash rows − change). Handling it + # outside the loop avoids double-subtraction when multiple payment rows + # share the same cash mode. + base_change = get_base_value(invoice, "change_amount", "base_change_amount", conversion_rate) + if base_change: + _aggregate_payment(payments, cash_mode, -base_change) + + return transaction @frappe.whitelist() def make_closing_shift_from_opening(opening_shift): - opening_shift = json.loads(opening_shift) - doctype = "Sales Invoice" - invoice_field = "sales_invoice" - - submit_printed_invoices(opening_shift.get("name"), doctype) - - # Initialize closing shift document - closing_shift = frappe.new_doc("POS Closing Shift") - closing_shift.update({ - "pos_opening_shift": opening_shift.get("name"), - "period_start_date": opening_shift.get("period_start_date"), - "period_end_date": frappe.utils.get_datetime(), - "pos_profile": opening_shift.get("pos_profile"), - "user": opening_shift.get("user"), - "company": opening_shift.get("company"), - }) - - company_currency = frappe.get_cached_value("Company", closing_shift.company, "default_currency") - cash_mode = _get_cash_mode_of_payment(opening_shift.get("pos_profile")) - - # Initialize collections - payments = [] - taxes = [] - pos_transactions = [] - - # Summary for tracking totals - summary = { - "grand_total": 0, "net_total": 0, "total_quantity": 0, - "returns_total": 0, "returns_count": 0, - "sales_total": 0, "sales_count": 0, - } - - # Add opening balances to payments - for detail in opening_shift.get("balance_details", []): - opening_amount = flt(detail.get("amount")) - payments.append(frappe._dict({ - "mode_of_payment": detail.get("mode_of_payment"), - "opening_amount": opening_amount, - "expected_amount": opening_amount, - })) - - # Process invoices - invoices = get_pos_invoices(opening_shift.get("name"), doctype) - for invoice in invoices: - txn = _process_invoice(invoice, invoice_field, company_currency, cash_mode, payments, taxes, summary) - pos_transactions.append(txn) - - # Process payment entries - pos_payments_table = [] - for py in get_payments_entries(opening_shift.get("name")): - pos_payments_table.append(frappe._dict({ - "payment_entry": py.name, - "mode_of_payment": py.mode_of_payment, - "paid_amount": py.paid_amount, - "posting_date": py.posting_date, - "customer": py.party, - })) - amount = get_base_value(py, "paid_amount", "base_paid_amount") - _aggregate_payment(payments, py.mode_of_payment, amount) - - # Update closing shift with totals - closing_shift.grand_total = summary["grand_total"] - closing_shift.net_total = summary["net_total"] - closing_shift.total_quantity = summary["total_quantity"] - - # Set child tables (without return info - that's for display only) - closing_shift.set("pos_transactions", [ - {k: v for k, v in txn.items() if k not in ("is_return", "return_against")} - for txn in pos_transactions - ]) - closing_shift.set("payment_reconciliation", payments) - closing_shift.set("taxes", taxes) - closing_shift.set("pos_payments", pos_payments_table) - - # Build response with display-only fields - result = closing_shift.as_dict() - result.update({ - "returns_total": summary["returns_total"], - "returns_count": summary["returns_count"], - "sales_total": summary["sales_total"], - "sales_count": summary["sales_count"], - "pos_transactions": pos_transactions, # Include return info for display - }) - - return result + opening_shift = json.loads(opening_shift) + doctype = "Sales Invoice" + invoice_field = "sales_invoice" + + submit_printed_invoices(opening_shift.get("name"), doctype) + + # Initialize closing shift document + closing_shift = frappe.new_doc("POS Closing Shift") + closing_shift.update( + { + "pos_opening_shift": opening_shift.get("name"), + "period_start_date": opening_shift.get("period_start_date"), + "period_end_date": frappe.utils.get_datetime(), + "pos_profile": opening_shift.get("pos_profile"), + "user": opening_shift.get("user"), + "company": opening_shift.get("company"), + } + ) + + company_currency = frappe.get_cached_value("Company", closing_shift.company, "default_currency") + cash_mode = _get_cash_mode_of_payment(opening_shift.get("pos_profile")) + + # Initialize collections + payments = [] + taxes = [] + pos_transactions = [] + + # Summary for tracking totals + summary = { + "grand_total": 0, + "net_total": 0, + "total_quantity": 0, + "returns_total": 0, + "returns_count": 0, + "sales_total": 0, + "sales_count": 0, + } + + # Add opening balances to payments + for detail in opening_shift.get("balance_details", []): + opening_amount = flt(detail.get("amount")) + payments.append( + frappe._dict( + { + "mode_of_payment": detail.get("mode_of_payment"), + "opening_amount": opening_amount, + "expected_amount": opening_amount, + } + ) + ) + + # Process invoices + invoices = get_pos_invoices(opening_shift.get("name"), doctype) + for invoice in invoices: + txn = _process_invoice(invoice, invoice_field, company_currency, cash_mode, payments, taxes, summary) + pos_transactions.append(txn) + + # Process payment entries + pos_payments_table = [] + for py in get_payments_entries(opening_shift.get("name")): + pos_payments_table.append( + frappe._dict( + { + "payment_entry": py.name, + "mode_of_payment": py.mode_of_payment, + "paid_amount": py.paid_amount, + "posting_date": py.posting_date, + "customer": py.party, + } + ) + ) + amount = get_base_value(py, "paid_amount", "base_paid_amount") + _aggregate_payment(payments, py.mode_of_payment, amount) + + # Update closing shift with totals + closing_shift.grand_total = summary["grand_total"] + closing_shift.net_total = summary["net_total"] + closing_shift.total_quantity = summary["total_quantity"] + + # Set child tables (without return info - that's for display only) + closing_shift.set( + "pos_transactions", + [ + {k: v for k, v in txn.items() if k not in ("is_return", "return_against")} + for txn in pos_transactions + ], + ) + closing_shift.set("payment_reconciliation", payments) + closing_shift.set("taxes", taxes) + closing_shift.set("pos_payments", pos_payments_table) + + # Build response with display-only fields + result = closing_shift.as_dict() + result.update( + { + "returns_total": summary["returns_total"], + "returns_count": summary["returns_count"], + "sales_total": summary["sales_total"], + "sales_count": summary["sales_count"], + "pos_transactions": pos_transactions, # Include return info for display + } + ) + + return result @frappe.whitelist() def submit_closing_shift(closing_shift): - closing_shift = json.loads(closing_shift) - closing_shift_doc = frappe.get_doc(closing_shift) - closing_shift_doc.flags.ignore_permissions = True - closing_shift_doc.save() - closing_shift_doc.submit() - return closing_shift_doc.name + closing_shift = json.loads(closing_shift) + closing_shift_doc = frappe.get_doc(closing_shift) + closing_shift_doc.flags.ignore_permissions = True + closing_shift_doc.save() + closing_shift_doc.submit() + return closing_shift_doc.name def submit_printed_invoices(pos_opening_shift, doctype): - invoices_list = frappe.get_all( - doctype, - filters={ - "posa_pos_opening_shift": pos_opening_shift, - "docstatus": 0, - "posa_is_printed": 1, - }, - ) - for invoice in invoices_list: - invoice_doc = frappe.get_doc(doctype, invoice.name) - invoice_doc.submit() + invoices_list = frappe.get_all( + doctype, + filters={ + "posa_pos_opening_shift": pos_opening_shift, + "docstatus": 0, + "posa_is_printed": 1, + }, + ) + for invoice in invoices_list: + invoice_doc = frappe.get_doc(doctype, invoice.name) + invoice_doc.submit() diff --git a/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py b/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py index d0c2a2e1d..65453dd15 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py +++ b/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py @@ -8,4 +8,4 @@ class TestPOSClosingShift(unittest.TestCase): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_closing_shift_detail/pos_closing_shift_detail.py b/pos_next/pos_next/doctype/pos_closing_shift_detail/pos_closing_shift_detail.py index c056dcba6..11c81dab9 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift_detail/pos_closing_shift_detail.py +++ b/pos_next/pos_next/doctype/pos_closing_shift_detail/pos_closing_shift_detail.py @@ -9,4 +9,4 @@ class POSClosingShiftDetail(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.py b/pos_next/pos_next/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.py index c278a7a9b..969655ae0 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.py +++ b/pos_next/pos_next/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.py @@ -9,4 +9,4 @@ class POSClosingShiftTaxes(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py index 92bee49b6..23b3897f2 100644 --- a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py +++ b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py @@ -2,204 +2,207 @@ # For license information, please see license.txt from __future__ import unicode_literals + import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import strip, flt -from frappe.utils import getdate, today - +from frappe.utils import flt, getdate, strip, today ONE_USE_COUPON_DOCTYPES = ("Sales Invoice", "POS Invoice") class POSCoupon(Document): - def autoname(self): - self.coupon_name = strip(self.coupon_name) - self.name = self.coupon_name - - if not self.coupon_code: - if self.coupon_type == "Promotional": - self.coupon_code = "".join(i for i in self.coupon_name if not i.isdigit())[0:8].upper() - elif self.coupon_type == "Gift Card": - self.coupon_code = frappe.generate_hash()[:10].upper() - - def validate(self): - # Gift Card validations - if self.coupon_type == "Gift Card": - self.maximum_use = 1 - if not self.customer: - frappe.throw(_("Please select the customer for Gift Card.")) - - # Discount validations - if not self.discount_type: - frappe.throw(_("Discount Type is required")) - - if self.discount_type == "Percentage": - if not self.discount_percentage: - frappe.throw(_("Discount Percentage is required")) - if flt(self.discount_percentage) <= 0 or flt(self.discount_percentage) > 100: - frappe.throw(_("Discount Percentage must be between 0 and 100")) - elif self.discount_type == "Amount": - if not self.discount_amount: - frappe.throw(_("Discount Amount is required")) - if flt(self.discount_amount) <= 0: - frappe.throw(_("Discount Amount must be greater than 0")) - - # Minimum amount validation - if self.min_amount and flt(self.min_amount) < 0: - frappe.throw(_("Minimum Amount cannot be negative")) - - # Maximum discount validation - if self.max_amount and flt(self.max_amount) <= 0: - frappe.throw(_("Maximum Discount Amount must be greater than 0")) - - # Date validations - if self.valid_from and self.valid_upto: - if getdate(self.valid_from) > getdate(self.valid_upto): - frappe.throw(_("Valid From date cannot be after Valid Until date")) - + def autoname(self): + self.coupon_name = strip(self.coupon_name) + self.name = self.coupon_name + + if not self.coupon_code: + if self.coupon_type == "Promotional": + self.coupon_code = "".join(i for i in self.coupon_name if not i.isdigit())[0:8].upper() + elif self.coupon_type == "Gift Card": + self.coupon_code = frappe.generate_hash()[:10].upper() + + def validate(self): + # Gift Card validations + if self.coupon_type == "Gift Card": + self.maximum_use = 1 + if not self.customer: + frappe.throw(_("Please select the customer for Gift Card.")) + + # Discount validations + if not self.discount_type: + frappe.throw(_("Discount Type is required")) + + if self.discount_type == "Percentage": + if not self.discount_percentage: + frappe.throw(_("Discount Percentage is required")) + if flt(self.discount_percentage) <= 0 or flt(self.discount_percentage) > 100: + frappe.throw(_("Discount Percentage must be between 0 and 100")) + elif self.discount_type == "Amount": + if not self.discount_amount: + frappe.throw(_("Discount Amount is required")) + if flt(self.discount_amount) <= 0: + frappe.throw(_("Discount Amount must be greater than 0")) + + # Minimum amount validation + if self.min_amount and flt(self.min_amount) < 0: + frappe.throw(_("Minimum Amount cannot be negative")) + + # Maximum discount validation + if self.max_amount and flt(self.max_amount) <= 0: + frappe.throw(_("Maximum Discount Amount must be greater than 0")) + + # Date validations + if self.valid_from and self.valid_upto: + if getdate(self.valid_from) > getdate(self.valid_upto): + frappe.throw(_("Valid From date cannot be after Valid Until date")) def check_coupon_code(coupon_code, customer=None, company=None): - """Validate and return coupon details""" - res = {"coupon": None} + """Validate and return coupon details""" + res = {"coupon": None} - if not frappe.db.exists("POS Coupon", {"coupon_code": coupon_code.upper()}): - res["msg"] = _("Sorry, this coupon code does not exist") - return res + if not frappe.db.exists("POS Coupon", {"coupon_code": coupon_code.upper()}): + res["msg"] = _("Sorry, this coupon code does not exist") + return res - coupon = frappe.get_doc("POS Coupon", {"coupon_code": coupon_code.upper()}) + coupon = frappe.get_doc("POS Coupon", {"coupon_code": coupon_code.upper()}) - # Check if coupon is disabled - if coupon.disabled: - res["msg"] = _("Sorry, this coupon has been disabled") - return res + # Check if coupon is disabled + if coupon.disabled: + res["msg"] = _("Sorry, this coupon has been disabled") + return res - # Check validity dates - if coupon.valid_from: - if coupon.valid_from > getdate(today()): - res["msg"] = _("Sorry, this coupon code's validity has not started") - return res + # Check validity dates + if coupon.valid_from: + if coupon.valid_from > getdate(today()): + res["msg"] = _("Sorry, this coupon code's validity has not started") + return res - if coupon.valid_upto: - if coupon.valid_upto < getdate(today()): - res["msg"] = _("Sorry, this coupon code has expired") - return res + if coupon.valid_upto: + if coupon.valid_upto < getdate(today()): + res["msg"] = _("Sorry, this coupon code has expired") + return res - # Check usage limits - if coupon.used and coupon.maximum_use and coupon.used >= coupon.maximum_use: - res["msg"] = _("Sorry, this coupon code has been fully redeemed") - return res + # Check usage limits + if coupon.used and coupon.maximum_use and coupon.used >= coupon.maximum_use: + res["msg"] = _("Sorry, this coupon code has been fully redeemed") + return res - # Check company - if company and coupon.company != company: - res["msg"] = _("Sorry, this coupon is not valid for this company") - return res + # Check company + if company and coupon.company != company: + res["msg"] = _("Sorry, this coupon is not valid for this company") + return res - # Check customer (for Gift Cards) - if coupon.coupon_type == "Gift Card" and coupon.customer: - if not customer or coupon.customer != customer: - res["msg"] = _("Sorry, this gift card is assigned to a specific customer") - return res + # Check customer (for Gift Cards) + if coupon.coupon_type == "Gift Card" and coupon.customer: + if not customer or coupon.customer != customer: + res["msg"] = _("Sorry, this gift card is assigned to a specific customer") + return res - # Check one-time use per customer - if coupon.one_use and customer: - used_count = _get_customer_coupon_usage_count(customer, coupon.coupon_code) - if used_count > 0: - res["msg"] = _("Sorry, you have already used this coupon code") - return res + # Check one-time use per customer + if coupon.one_use and customer: + used_count = _get_customer_coupon_usage_count(customer, coupon.coupon_code) + if used_count > 0: + res["msg"] = _("Sorry, you have already used this coupon code") + return res - # All validations passed - res["coupon"] = coupon - res["valid"] = True + # All validations passed + res["coupon"] = coupon + res["valid"] = True - return res + return res def _get_customer_coupon_usage_count(customer, coupon_code): - """Count submitted coupon usage across POSNext's actual sales doctypes.""" - used_count = 0 + """Count submitted coupon usage across POSNext's actual sales doctypes.""" + used_count = 0 - for doctype in ONE_USE_COUPON_DOCTYPES: - if not frappe.db.table_exists(doctype): - continue + for doctype in ONE_USE_COUPON_DOCTYPES: + if not frappe.db.table_exists(doctype): + continue - meta = frappe.get_meta(doctype) - if not meta.has_field("coupon_code"): - continue + meta = frappe.get_meta(doctype) + if not meta.has_field("coupon_code"): + continue - used_count += frappe.db.count(doctype, filters={ - "customer": customer, - "coupon_code": coupon_code, - "docstatus": 1, - }) + used_count += frappe.db.count( + doctype, + filters={ + "customer": customer, + "coupon_code": coupon_code, + "docstatus": 1, + }, + ) - return used_count + return used_count def apply_coupon_discount(coupon, cart_total, net_total=None): - """Calculate discount amount based on coupon configuration""" - from frappe.utils import flt - - # Determine the base amount for discount calculation - base_amount = cart_total if coupon.apply_on == "Grand Total" else (net_total or cart_total) - - # Check minimum amount - if coupon.min_amount and flt(base_amount) < flt(coupon.min_amount): - return { - "valid": False, - "message": _("Minimum cart amount of {0} is required").format(frappe.format_value(coupon.min_amount, {"fieldtype": "Currency"})), - "discount": 0 - } - - # Calculate discount - discount = 0 - if coupon.discount_type == "Percentage": - discount = flt(base_amount) * flt(coupon.discount_percentage) / 100 - elif coupon.discount_type == "Amount": - discount = flt(coupon.discount_amount) - - # Apply maximum discount limit - if coupon.max_amount and flt(discount) > flt(coupon.max_amount): - discount = flt(coupon.max_amount) - - # Ensure discount doesn't exceed cart total - if discount > base_amount: - discount = base_amount - - return { - "valid": True, - "discount": discount, - "discount_type": coupon.discount_type, - "discount_percentage": coupon.discount_percentage if coupon.discount_type == "Percentage" else None, - "apply_on": coupon.apply_on - } + """Calculate discount amount based on coupon configuration""" + from frappe.utils import flt + + # Determine the base amount for discount calculation + base_amount = cart_total if coupon.apply_on == "Grand Total" else (net_total or cart_total) + + # Check minimum amount + if coupon.min_amount and flt(base_amount) < flt(coupon.min_amount): + return { + "valid": False, + "message": _("Minimum cart amount of {0} is required").format( + frappe.format_value(coupon.min_amount, {"fieldtype": "Currency"}) + ), + "discount": 0, + } + + # Calculate discount + discount = 0 + if coupon.discount_type == "Percentage": + discount = flt(base_amount) * flt(coupon.discount_percentage) / 100 + elif coupon.discount_type == "Amount": + discount = flt(coupon.discount_amount) + + # Apply maximum discount limit + if coupon.max_amount and flt(discount) > flt(coupon.max_amount): + discount = flt(coupon.max_amount) + + # Ensure discount doesn't exceed cart total + if discount > base_amount: + discount = base_amount + + return { + "valid": True, + "discount": discount, + "discount_type": coupon.discount_type, + "discount_percentage": coupon.discount_percentage if coupon.discount_type == "Percentage" else None, + "apply_on": coupon.apply_on, + } def increment_coupon_usage(coupon_code): - """Increment the usage counter for a coupon""" - try: - coupon = frappe.get_doc("POS Coupon", {"coupon_code": coupon_code.upper()}) - coupon.used = (coupon.used or 0) + 1 - coupon.db_set('used', coupon.used) - frappe.db.commit() - except Exception as e: - frappe.log_error( - title="Coupon Usage Increment Failed", - message=f"Failed to increment usage for coupon {coupon_code}: {str(e)}" - ) + """Increment the usage counter for a coupon""" + try: + coupon = frappe.get_doc("POS Coupon", {"coupon_code": coupon_code.upper()}) + coupon.used = (coupon.used or 0) + 1 + coupon.db_set("used", coupon.used) + frappe.db.commit() + except Exception as e: + frappe.log_error( + title="Coupon Usage Increment Failed", + message=f"Failed to increment usage for coupon {coupon_code}: {str(e)}", + ) def decrement_coupon_usage(coupon_code): - """Decrement the usage counter for a coupon (for cancelled invoices)""" - try: - coupon = frappe.get_doc("POS Coupon", {"coupon_code": coupon_code.upper()}) - if coupon.used and coupon.used > 0: - coupon.used = coupon.used - 1 - coupon.db_set('used', coupon.used) - frappe.db.commit() - except Exception as e: - frappe.log_error( - title="Coupon Usage Decrement Failed", - message=f"Failed to decrement usage for coupon {coupon_code}: {str(e)}" - ) + """Decrement the usage counter for a coupon (for cancelled invoices)""" + try: + coupon = frappe.get_doc("POS Coupon", {"coupon_code": coupon_code.upper()}) + if coupon.used and coupon.used > 0: + coupon.used = coupon.used - 1 + coupon.db_set("used", coupon.used) + frappe.db.commit() + except Exception as e: + frappe.log_error( + title="Coupon Usage Decrement Failed", + message=f"Failed to decrement usage for coupon {coupon_code}: {str(e)}", + ) diff --git a/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py b/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py index 85e1e3bbc..d6004d5da 100644 --- a/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py +++ b/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py @@ -5,51 +5,51 @@ from unittest.mock import Mock, patch from pos_next.pos_next.doctype.pos_coupon.pos_coupon import ( - _get_customer_coupon_usage_count, + _get_customer_coupon_usage_count, ) class TestPOSCoupon(unittest.TestCase): - @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.get_meta") - @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.db") - def test_one_use_coupon_counts_sales_invoice_and_pos_invoice(self, mock_db, mock_get_meta): - def table_exists(doctype): - return doctype in {"Sales Invoice", "POS Invoice"} - - def count(doctype, filters=None): - counts = {"Sales Invoice": 1, "POS Invoice": 2} - return counts[doctype] - - mock_db.table_exists.side_effect = table_exists - mock_db.count.side_effect = count - mock_get_meta.return_value = Mock(has_field=Mock(return_value=True)) - - used_count = _get_customer_coupon_usage_count("Customer A", "SAVE10") - - self.assertEqual(used_count, 3) - mock_db.count.assert_any_call( - "Sales Invoice", - filters={"customer": "Customer A", "coupon_code": "SAVE10", "docstatus": 1}, - ) - mock_db.count.assert_any_call( - "POS Invoice", - filters={"customer": "Customer A", "coupon_code": "SAVE10", "docstatus": 1}, - ) - - @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.get_meta") - @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.db") - def test_one_use_coupon_skips_doctypes_without_coupon_field(self, mock_db, mock_get_meta): - mock_db.table_exists.return_value = True - mock_db.count.return_value = 4 - mock_get_meta.side_effect = [ - Mock(has_field=Mock(return_value=True)), - Mock(has_field=Mock(return_value=False)), - ] - - used_count = _get_customer_coupon_usage_count("Customer A", "SAVE10") - - self.assertEqual(used_count, 4) - mock_db.count.assert_called_once_with( - "Sales Invoice", - filters={"customer": "Customer A", "coupon_code": "SAVE10", "docstatus": 1}, - ) + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.get_meta") + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.db") + def test_one_use_coupon_counts_sales_invoice_and_pos_invoice(self, mock_db, mock_get_meta): + def table_exists(doctype): + return doctype in {"Sales Invoice", "POS Invoice"} + + def count(doctype, filters=None): + counts = {"Sales Invoice": 1, "POS Invoice": 2} + return counts[doctype] + + mock_db.table_exists.side_effect = table_exists + mock_db.count.side_effect = count + mock_get_meta.return_value = Mock(has_field=Mock(return_value=True)) + + used_count = _get_customer_coupon_usage_count("Customer A", "SAVE10") + + self.assertEqual(used_count, 3) + mock_db.count.assert_any_call( + "Sales Invoice", + filters={"customer": "Customer A", "coupon_code": "SAVE10", "docstatus": 1}, + ) + mock_db.count.assert_any_call( + "POS Invoice", + filters={"customer": "Customer A", "coupon_code": "SAVE10", "docstatus": 1}, + ) + + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.get_meta") + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.db") + def test_one_use_coupon_skips_doctypes_without_coupon_field(self, mock_db, mock_get_meta): + mock_db.table_exists.return_value = True + mock_db.count.return_value = 4 + mock_get_meta.side_effect = [ + Mock(has_field=Mock(return_value=True)), + Mock(has_field=Mock(return_value=False)), + ] + + used_count = _get_customer_coupon_usage_count("Customer A", "SAVE10") + + self.assertEqual(used_count, 4) + mock_db.count.assert_called_once_with( + "Sales Invoice", + filters={"customer": "Customer A", "coupon_code": "SAVE10", "docstatus": 1}, + ) diff --git a/pos_next/pos_next/doctype/pos_coupon_detail/pos_coupon_detail.py b/pos_next/pos_next/doctype/pos_coupon_detail/pos_coupon_detail.py index ca2f3a36f..9e4f97e90 100644 --- a/pos_next/pos_next/doctype/pos_coupon_detail/pos_coupon_detail.py +++ b/pos_next/pos_next/doctype/pos_coupon_detail/pos_coupon_detail.py @@ -6,4 +6,4 @@ class POSCouponDetail(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_offer/pos_offer.js b/pos_next/pos_next/doctype/pos_offer/pos_offer.js index 3c707feb4..015e976bb 100644 --- a/pos_next/pos_next/doctype/pos_offer/pos_offer.js +++ b/pos_next/pos_next/doctype/pos_offer/pos_offer.js @@ -77,28 +77,37 @@ const controllers = (frm) => { "replace_item", frm.doc.apply_on === "Item Code" && frm.doc.offer === "Give Product" && - frm.doc.apply_type === "Item Code", + frm.doc.apply_type === "Item Code" ); frm.toggle_display( "replace_cheapest_item", frm.doc.apply_on === "Item Group" && frm.doc.offer === "Give Product" && - frm.doc.apply_type === "Item Group", + frm.doc.apply_type === "Item Group" ); - frm.toggle_display("apply_item_code", frm.doc.apply_type === "Item Code" && !frm.doc.replace_item); - frm.toggle_reqd("apply_item_code", frm.doc.apply_type === "Item Code" && !frm.doc.replace_item); + frm.toggle_display( + "apply_item_code", + frm.doc.apply_type === "Item Code" && !frm.doc.replace_item + ); + frm.toggle_reqd( + "apply_item_code", + frm.doc.apply_type === "Item Code" && !frm.doc.replace_item + ); frm.toggle_display( "apply_item_group", - frm.doc.apply_type === "Item Group" && !frm.doc.replace_cheapest_item, + frm.doc.apply_type === "Item Group" && !frm.doc.replace_cheapest_item ); frm.toggle_reqd( "apply_item_group", - frm.doc.apply_type === "Item Group" && !frm.doc.replace_cheapest_item, + frm.doc.apply_type === "Item Group" && !frm.doc.replace_cheapest_item ); - frm.toggle_display("less_then", frm.doc.apply_type === "Item Group" && !frm.doc.replace_cheapest_item); + frm.toggle_display( + "less_then", + frm.doc.apply_type === "Item Group" && !frm.doc.replace_cheapest_item + ); frm.toggle_display("product_discount_scheme_section", frm.doc.offer === "Give Product"); frm.toggle_display("given_qty", frm.doc.offer === "Give Product"); @@ -136,7 +145,12 @@ const controllers = (frm) => { } if (frm.doc.apply_on === "Transaction") { - frm.set_df_property("offer", "options", ["", "Give Product", "Grand Total", "Loyalty Point"]); + frm.set_df_property("offer", "options", [ + "", + "Give Product", + "Grand Total", + "Loyalty Point", + ]); } else { frm.set_df_property("offer", "options", [ "", diff --git a/pos_next/pos_next/doctype/pos_offer/pos_offer.py b/pos_next/pos_next/doctype/pos_offer/pos_offer.py index e1578c12a..c6df523b5 100644 --- a/pos_next/pos_next/doctype/pos_offer/pos_offer.py +++ b/pos_next/pos_next/doctype/pos_offer/pos_offer.py @@ -9,4 +9,4 @@ class POSOffer(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_offer/test_pos_offer.py b/pos_next/pos_next/doctype/pos_offer/test_pos_offer.py index fc134604c..aab8790b7 100644 --- a/pos_next/pos_next/doctype/pos_offer/test_pos_offer.py +++ b/pos_next/pos_next/doctype/pos_offer/test_pos_offer.py @@ -8,4 +8,4 @@ class TestPOSOffer(unittest.TestCase): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_offer_detail/pos_offer_detail.py b/pos_next/pos_next/doctype/pos_offer_detail/pos_offer_detail.py index 9486ec096..a20f61477 100644 --- a/pos_next/pos_next/doctype/pos_offer_detail/pos_offer_detail.py +++ b/pos_next/pos_next/doctype/pos_offer_detail/pos_offer_detail.py @@ -9,4 +9,4 @@ class POSOfferDetail(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_opening_shift/pos_opening_shift.py b/pos_next/pos_next/doctype/pos_opening_shift/pos_opening_shift.py index 8bb64a706..3df5a5593 100644 --- a/pos_next/pos_next/doctype/pos_opening_shift/pos_opening_shift.py +++ b/pos_next/pos_next/doctype/pos_opening_shift/pos_opening_shift.py @@ -3,42 +3,43 @@ # For license information, please see license.txt from __future__ import unicode_literals + import frappe from frappe import _ -from frappe.utils import cint from frappe.model.document import Document +from frappe.utils import cint class POSOpeningShift(Document): - def validate(self): - self.validate_pos_profile_and_cashier() - self.set_status() - - def validate_pos_profile_and_cashier(self): - if self.company != frappe.db.get_value("POS Profile", self.pos_profile, "company"): - frappe.throw( - _("POS Profile {} does not belongs to company {}".format(self.pos_profile, self.company)) - ) - - if not cint(frappe.db.get_value("User", self.user, "enabled")): - frappe.throw(_("User {} has been disabled. Please select valid user/cashier".format(self.user))) - - def on_submit(self): - self.set_status(update=True) - - def set_status(self, update=False): - """Set the status of the opening shift""" - if self.docstatus == 0: - status = "Draft" - elif self.docstatus == 1: - if self.pos_closing_shift: - status = "Closed" - else: - status = "Open" - else: - status = "Cancelled" - - if update: - frappe.db.set_value("POS Opening Shift", self.name, "status", status) - else: - self.status = status + def validate(self): + self.validate_pos_profile_and_cashier() + self.set_status() + + def validate_pos_profile_and_cashier(self): + if self.company != frappe.db.get_value("POS Profile", self.pos_profile, "company"): + frappe.throw( + _("POS Profile {} does not belongs to company {}".format(self.pos_profile, self.company)) + ) + + if not cint(frappe.db.get_value("User", self.user, "enabled")): + frappe.throw(_("User {} has been disabled. Please select valid user/cashier".format(self.user))) + + def on_submit(self): + self.set_status(update=True) + + def set_status(self, update=False): + """Set the status of the opening shift""" + if self.docstatus == 0: + status = "Draft" + elif self.docstatus == 1: + if self.pos_closing_shift: + status = "Closed" + else: + status = "Open" + else: + status = "Cancelled" + + if update: + frappe.db.set_value("POS Opening Shift", self.name, "status", status) + else: + self.status = status diff --git a/pos_next/pos_next/doctype/pos_opening_shift/test_pos_opening_shift.py b/pos_next/pos_next/doctype/pos_opening_shift/test_pos_opening_shift.py index b667abc05..1816dd1c1 100644 --- a/pos_next/pos_next/doctype/pos_opening_shift/test_pos_opening_shift.py +++ b/pos_next/pos_next/doctype/pos_opening_shift/test_pos_opening_shift.py @@ -8,4 +8,4 @@ class TestPOSOpeningShift(unittest.TestCase): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_opening_shift_detail/pos_opening_shift_detail.py b/pos_next/pos_next/doctype/pos_opening_shift_detail/pos_opening_shift_detail.py index 741ca123d..5dfff4956 100644 --- a/pos_next/pos_next/doctype/pos_opening_shift_detail/pos_opening_shift_detail.py +++ b/pos_next/pos_next/doctype/pos_opening_shift_detail/pos_opening_shift_detail.py @@ -9,4 +9,4 @@ class POSOpeningShiftDetail(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_payment_entry_reference/pos_payment_entry_reference.py b/pos_next/pos_next/doctype/pos_payment_entry_reference/pos_payment_entry_reference.py index 6a9524d08..5db62dde2 100644 --- a/pos_next/pos_next/doctype/pos_payment_entry_reference/pos_payment_entry_reference.py +++ b/pos_next/pos_next/doctype/pos_payment_entry_reference/pos_payment_entry_reference.py @@ -6,4 +6,4 @@ class POSPaymentEntryReference(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/pos_settings/pos_settings.py b/pos_next/pos_next/doctype/pos_settings/pos_settings.py index 7cec9262c..f7dc2babd 100644 --- a/pos_next/pos_next/doctype/pos_settings/pos_settings.py +++ b/pos_next/pos_next/doctype/pos_settings/pos_settings.py @@ -58,7 +58,7 @@ def sync_negative_stock_setting(self): frappe.msgprint( "Stock Settings 'Allow Negative Stock' has been automatically enabled.", indicator="green", - alert=True + alert=True, ) else: # Only disable if no other enabled POS Settings have it enabled @@ -69,16 +69,18 @@ def sync_negative_stock_setting(self): { "allow_negative_stock": 1, "enabled": 1, # Only check enabled POS Settings - "name": ["!=", self.name] - } + "name": ["!=", self.name], + }, ) if other_enabled_count == 0: - frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0, update_modified=False) + frappe.db.set_single_value( + "Stock Settings", "allow_negative_stock", 0, update_modified=False + ) frappe.msgprint( "Stock Settings 'Allow Negative Stock' has been automatically disabled.", indicator="orange", - alert=True + alert=True, ) @@ -97,20 +99,12 @@ def get_pos_settings(pos_profile): return None # Check if user has access to this POS Profile - has_access = frappe.db.exists( - "POS Profile User", - {"parent": pos_profile, "user": frappe.session.user} - ) + has_access = frappe.db.exists("POS Profile User", {"parent": pos_profile, "user": frappe.session.user}) if not has_access and not frappe.has_permission("POS Settings", "read"): frappe.throw(_("You don't have access to this POS Profile")) - settings = frappe.db.get_value( - "POS Settings", - {"pos_profile": pos_profile}, - "*", - as_dict=True - ) + settings = frappe.db.get_value("POS Settings", {"pos_profile": pos_profile}, "*", as_dict=True) # If no settings exist, create default settings if not settings: @@ -139,16 +133,14 @@ def create_default_settings(pos_profile): def update_pos_settings(pos_profile, settings): """Update POS Settings for a POS Profile""" import json + from frappe import _ if isinstance(settings, str): settings = json.loads(settings) # Check if user has access to this POS Profile - has_access = frappe.db.exists( - "POS Profile User", - {"parent": pos_profile, "user": frappe.session.user} - ) + has_access = frappe.db.exists("POS Profile User", {"parent": pos_profile, "user": frappe.session.user}) if not has_access and not frappe.has_permission("POS Settings", "write"): frappe.throw(_("You don't have permission to update this POS Profile")) diff --git a/pos_next/pos_next/doctype/referral_code/referral_code.js b/pos_next/pos_next/doctype/referral_code/referral_code.js index eedb546f6..1a186c4c1 100644 --- a/pos_next/pos_next/doctype/referral_code/referral_code.js +++ b/pos_next/pos_next/doctype/referral_code/referral_code.js @@ -38,7 +38,8 @@ frappe.ui.form.on("Referral Code", { let referral_name = frm.doc.referral_name; let referral_code; if (!referral_name) { - frm.doc.referral_name = frm.doc.party + Math.random().toString(5).substring(2, 5).toUpperCase(); + frm.doc.referral_name = + frm.doc.party + Math.random().toString(5).substring(2, 5).toUpperCase(); referral_code = Math.random().toString(12).substring(2, 12).toUpperCase(); } else { referral_name = referral_name.replace(/\s/g, ""); diff --git a/pos_next/pos_next/doctype/referral_code/referral_code.py b/pos_next/pos_next/doctype/referral_code/referral_code.py index 465bada11..443afa7f7 100644 --- a/pos_next/pos_next/doctype/referral_code/referral_code.py +++ b/pos_next/pos_next/doctype/referral_code/referral_code.py @@ -4,230 +4,240 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import strip, flt, add_days, today +from frappe.utils import add_days, flt, strip, today class ReferralCode(Document): - def autoname(self): - if not self.referral_name: - self.referral_name = strip(self.customer) + "-" + frappe.generate_hash()[:5].upper() - self.name = self.referral_name - else: - self.referral_name = strip(self.referral_name) - self.name = self.referral_name - - if not self.referral_code: - self.referral_code = frappe.generate_hash()[:10].upper() - - def validate(self): - # Validate Referrer (Primary Customer) rewards - if not self.referrer_discount_type: - frappe.throw(_("Referrer Discount Type is required")) - - if self.referrer_discount_type == "Percentage": - if not self.referrer_discount_percentage: - frappe.throw(_("Referrer Discount Percentage is required")) - if flt(self.referrer_discount_percentage) <= 0 or flt(self.referrer_discount_percentage) > 100: - frappe.throw(_("Referrer Discount Percentage must be between 0 and 100")) - elif self.referrer_discount_type == "Amount": - if not self.referrer_discount_amount: - frappe.throw(_("Referrer Discount Amount is required")) - if flt(self.referrer_discount_amount) <= 0: - frappe.throw(_("Referrer Discount Amount must be greater than 0")) - - # Validate Referee (New Customer) rewards - if not self.referee_discount_type: - frappe.throw(_("Referee Discount Type is required")) - - if self.referee_discount_type == "Percentage": - if not self.referee_discount_percentage: - frappe.throw(_("Referee Discount Percentage is required")) - if flt(self.referee_discount_percentage) <= 0 or flt(self.referee_discount_percentage) > 100: - frappe.throw(_("Referee Discount Percentage must be between 0 and 100")) - elif self.referee_discount_type == "Amount": - if not self.referee_discount_amount: - frappe.throw(_("Referee Discount Amount is required")) - if flt(self.referee_discount_amount) <= 0: - frappe.throw(_("Referee Discount Amount must be greater than 0")) - - -def create_referral_code(company, customer, referrer_discount_type, referrer_discount_percentage=None, - referrer_discount_amount=None, referee_discount_type="Percentage", - referee_discount_percentage=None, referee_discount_amount=None, campaign=None): - """ - Create a new referral code with discount configuration - - Args: - company: Company name - customer: Referrer customer ID - referrer_discount_type: "Percentage" or "Amount" for referrer reward - referrer_discount_percentage: Percentage discount for referrer (if type is Percentage) - referrer_discount_amount: Fixed amount discount for referrer (if type is Amount) - referee_discount_type: "Percentage" or "Amount" for referee reward - referee_discount_percentage: Percentage discount for referee (if type is Percentage) - referee_discount_amount: Fixed amount discount for referee (if type is Amount) - campaign: Optional campaign name - """ - doc = frappe.new_doc("Referral Code") - doc.company = company - doc.customer = customer - doc.campaign = campaign - - # Referrer rewards - doc.referrer_discount_type = referrer_discount_type - doc.referrer_discount_percentage = referrer_discount_percentage - doc.referrer_discount_amount = referrer_discount_amount - - # Referee rewards - doc.referee_discount_type = referee_discount_type - doc.referee_discount_percentage = referee_discount_percentage - doc.referee_discount_amount = referee_discount_amount - - doc.insert() - frappe.db.commit() - return doc + def autoname(self): + if not self.referral_name: + self.referral_name = strip(self.customer) + "-" + frappe.generate_hash()[:5].upper() + self.name = self.referral_name + else: + self.referral_name = strip(self.referral_name) + self.name = self.referral_name + + if not self.referral_code: + self.referral_code = frappe.generate_hash()[:10].upper() + + def validate(self): + # Validate Referrer (Primary Customer) rewards + if not self.referrer_discount_type: + frappe.throw(_("Referrer Discount Type is required")) + + if self.referrer_discount_type == "Percentage": + if not self.referrer_discount_percentage: + frappe.throw(_("Referrer Discount Percentage is required")) + if flt(self.referrer_discount_percentage) <= 0 or flt(self.referrer_discount_percentage) > 100: + frappe.throw(_("Referrer Discount Percentage must be between 0 and 100")) + elif self.referrer_discount_type == "Amount": + if not self.referrer_discount_amount: + frappe.throw(_("Referrer Discount Amount is required")) + if flt(self.referrer_discount_amount) <= 0: + frappe.throw(_("Referrer Discount Amount must be greater than 0")) + + # Validate Referee (New Customer) rewards + if not self.referee_discount_type: + frappe.throw(_("Referee Discount Type is required")) + + if self.referee_discount_type == "Percentage": + if not self.referee_discount_percentage: + frappe.throw(_("Referee Discount Percentage is required")) + if flt(self.referee_discount_percentage) <= 0 or flt(self.referee_discount_percentage) > 100: + frappe.throw(_("Referee Discount Percentage must be between 0 and 100")) + elif self.referee_discount_type == "Amount": + if not self.referee_discount_amount: + frappe.throw(_("Referee Discount Amount is required")) + if flt(self.referee_discount_amount) <= 0: + frappe.throw(_("Referee Discount Amount must be greater than 0")) + + +def create_referral_code( + company, + customer, + referrer_discount_type, + referrer_discount_percentage=None, + referrer_discount_amount=None, + referee_discount_type="Percentage", + referee_discount_percentage=None, + referee_discount_amount=None, + campaign=None, +): + """ + Create a new referral code with discount configuration + + Args: + company: Company name + customer: Referrer customer ID + referrer_discount_type: "Percentage" or "Amount" for referrer reward + referrer_discount_percentage: Percentage discount for referrer (if type is Percentage) + referrer_discount_amount: Fixed amount discount for referrer (if type is Amount) + referee_discount_type: "Percentage" or "Amount" for referee reward + referee_discount_percentage: Percentage discount for referee (if type is Percentage) + referee_discount_amount: Fixed amount discount for referee (if type is Amount) + campaign: Optional campaign name + """ + doc = frappe.new_doc("Referral Code") + doc.company = company + doc.customer = customer + doc.campaign = campaign + + # Referrer rewards + doc.referrer_discount_type = referrer_discount_type + doc.referrer_discount_percentage = referrer_discount_percentage + doc.referrer_discount_amount = referrer_discount_amount + + # Referee rewards + doc.referee_discount_type = referee_discount_type + doc.referee_discount_percentage = referee_discount_percentage + doc.referee_discount_amount = referee_discount_amount + + doc.insert() + frappe.db.commit() + return doc def apply_referral_code(referral_code, referee_customer): - """ - Apply a referral code - generates coupons for both referrer and referee - - Args: - referral_code: The referral code to apply - referee_customer: The new customer using the referral code - - Returns: - dict with generated coupons info - """ - # Get referral code document - if not frappe.db.exists("Referral Code", {"referral_code": referral_code.upper()}): - frappe.throw(_("Invalid referral code")) - - referral = frappe.get_doc("Referral Code", {"referral_code": referral_code.upper()}) - - # Check if disabled - if referral.disabled: - frappe.throw(_("This referral code has been disabled")) - - # Check if referee has already used this referral code - existing_coupon = frappe.db.exists("POS Coupon", { - "referral_code": referral.name, - "customer": referee_customer, - "coupon_type": "Promotional" - }) - - if existing_coupon: - frappe.throw(_("You have already used this referral code")) - - result = { - "referrer_coupon": None, - "referee_coupon": None - } - - # Generate Gift Card coupon for referrer (primary customer) - try: - referrer_coupon = generate_referrer_coupon(referral) - result["referrer_coupon"] = { - "name": referrer_coupon.name, - "coupon_code": referrer_coupon.coupon_code, - "customer": referrer_coupon.customer - } - except Exception as e: - frappe.log_error( - title="Referrer Coupon Generation Failed", - message=f"Failed to generate referrer coupon: {str(e)}" - ) - - # Generate Promotional coupon for referee (new customer) - try: - referee_coupon = generate_referee_coupon(referral, referee_customer) - result["referee_coupon"] = { - "name": referee_coupon.name, - "coupon_code": referee_coupon.coupon_code, - "customer": referee_customer - } - except Exception as e: - frappe.log_error( - title="Referee Coupon Generation Failed", - message=f"Failed to generate referee coupon: {str(e)}" - ) - frappe.throw(_("Failed to generate your welcome coupon")) - - # Increment referrals count - referral.referrals_count = (referral.referrals_count or 0) + 1 - referral.save() - frappe.db.commit() - - return result + """ + Apply a referral code - generates coupons for both referrer and referee + + Args: + referral_code: The referral code to apply + referee_customer: The new customer using the referral code + + Returns: + dict with generated coupons info + """ + # Get referral code document + if not frappe.db.exists("Referral Code", {"referral_code": referral_code.upper()}): + frappe.throw(_("Invalid referral code")) + + referral = frappe.get_doc("Referral Code", {"referral_code": referral_code.upper()}) + + # Check if disabled + if referral.disabled: + frappe.throw(_("This referral code has been disabled")) + + # Check if referee has already used this referral code + existing_coupon = frappe.db.exists( + "POS Coupon", + {"referral_code": referral.name, "customer": referee_customer, "coupon_type": "Promotional"}, + ) + + if existing_coupon: + frappe.throw(_("You have already used this referral code")) + + result = {"referrer_coupon": None, "referee_coupon": None} + + # Generate Gift Card coupon for referrer (primary customer) + try: + referrer_coupon = generate_referrer_coupon(referral) + result["referrer_coupon"] = { + "name": referrer_coupon.name, + "coupon_code": referrer_coupon.coupon_code, + "customer": referrer_coupon.customer, + } + except Exception as e: + frappe.log_error( + title="Referrer Coupon Generation Failed", message=f"Failed to generate referrer coupon: {str(e)}" + ) + + # Generate Promotional coupon for referee (new customer) + try: + referee_coupon = generate_referee_coupon(referral, referee_customer) + result["referee_coupon"] = { + "name": referee_coupon.name, + "coupon_code": referee_coupon.coupon_code, + "customer": referee_customer, + } + except Exception as e: + frappe.log_error( + title="Referee Coupon Generation Failed", message=f"Failed to generate referee coupon: {str(e)}" + ) + frappe.throw(_("Failed to generate your welcome coupon")) + + # Increment referrals count + referral.referrals_count = (referral.referrals_count or 0) + 1 + referral.save() + frappe.db.commit() + + return result def generate_referrer_coupon(referral): - """Generate a Gift Card coupon for the referrer""" - coupon = frappe.new_doc("POS Coupon") - - # Calculate validity dates - valid_from = today() - valid_days = referral.referrer_coupon_valid_days or 30 - valid_upto = add_days(valid_from, valid_days) - - coupon.update({ - "coupon_name": f"Referral Reward - {referral.customer} - {frappe.utils.now_datetime().strftime('%Y%m%d%H%M%S')}", - "coupon_type": "Gift Card", - "customer": referral.customer, - "company": referral.company, - "campaign": referral.campaign, - "referral_code": referral.name, - - # Discount configuration - "discount_type": referral.referrer_discount_type, - "discount_percentage": flt(referral.referrer_discount_percentage) if referral.referrer_discount_type == "Percentage" else None, - "discount_amount": flt(referral.referrer_discount_amount) if referral.referrer_discount_type == "Amount" else None, - "min_amount": flt(referral.referrer_min_amount) if referral.referrer_min_amount else None, - "max_amount": flt(referral.referrer_max_amount) if referral.referrer_max_amount else None, - "apply_on": "Grand Total", - - # Validity - "valid_from": valid_from, - "valid_upto": valid_upto, - "maximum_use": 1, # Gift cards are single-use - "one_use": 1, - }) - - coupon.insert() - return coupon + """Generate a Gift Card coupon for the referrer""" + coupon = frappe.new_doc("POS Coupon") + + # Calculate validity dates + valid_from = today() + valid_days = referral.referrer_coupon_valid_days or 30 + valid_upto = add_days(valid_from, valid_days) + + coupon.update( + { + "coupon_name": f"Referral Reward - {referral.customer} - {frappe.utils.now_datetime().strftime('%Y%m%d%H%M%S')}", + "coupon_type": "Gift Card", + "customer": referral.customer, + "company": referral.company, + "campaign": referral.campaign, + "referral_code": referral.name, + # Discount configuration + "discount_type": referral.referrer_discount_type, + "discount_percentage": flt(referral.referrer_discount_percentage) + if referral.referrer_discount_type == "Percentage" + else None, + "discount_amount": flt(referral.referrer_discount_amount) + if referral.referrer_discount_type == "Amount" + else None, + "min_amount": flt(referral.referrer_min_amount) if referral.referrer_min_amount else None, + "max_amount": flt(referral.referrer_max_amount) if referral.referrer_max_amount else None, + "apply_on": "Grand Total", + # Validity + "valid_from": valid_from, + "valid_upto": valid_upto, + "maximum_use": 1, # Gift cards are single-use + "one_use": 1, + } + ) + + coupon.insert() + return coupon def generate_referee_coupon(referral, referee_customer): - """Generate a Promotional coupon for the referee (new customer)""" - coupon = frappe.new_doc("POS Coupon") - - # Calculate validity dates - valid_from = today() - valid_days = referral.referee_coupon_valid_days or 30 - valid_upto = add_days(valid_from, valid_days) - - coupon.update({ - "coupon_name": f"Welcome Referral - {referee_customer} - {frappe.utils.now_datetime().strftime('%Y%m%d%H%M%S')}", - "coupon_type": "Promotional", - "customer": referee_customer, - "company": referral.company, - "campaign": referral.campaign, - "referral_code": referral.name, - - # Discount configuration - "discount_type": referral.referee_discount_type, - "discount_percentage": flt(referral.referee_discount_percentage) if referral.referee_discount_type == "Percentage" else None, - "discount_amount": flt(referral.referee_discount_amount) if referral.referee_discount_type == "Amount" else None, - "min_amount": flt(referral.referee_min_amount) if referral.referee_min_amount else None, - "max_amount": flt(referral.referee_max_amount) if referral.referee_max_amount else None, - "apply_on": "Grand Total", - - # Validity - "valid_from": valid_from, - "valid_upto": valid_upto, - "maximum_use": 1, # One-time use for referee - "one_use": 1, - }) - - coupon.insert() - return coupon + """Generate a Promotional coupon for the referee (new customer)""" + coupon = frappe.new_doc("POS Coupon") + + # Calculate validity dates + valid_from = today() + valid_days = referral.referee_coupon_valid_days or 30 + valid_upto = add_days(valid_from, valid_days) + + coupon.update( + { + "coupon_name": f"Welcome Referral - {referee_customer} - {frappe.utils.now_datetime().strftime('%Y%m%d%H%M%S')}", + "coupon_type": "Promotional", + "customer": referee_customer, + "company": referral.company, + "campaign": referral.campaign, + "referral_code": referral.name, + # Discount configuration + "discount_type": referral.referee_discount_type, + "discount_percentage": flt(referral.referee_discount_percentage) + if referral.referee_discount_type == "Percentage" + else None, + "discount_amount": flt(referral.referee_discount_amount) + if referral.referee_discount_type == "Amount" + else None, + "min_amount": flt(referral.referee_min_amount) if referral.referee_min_amount else None, + "max_amount": flt(referral.referee_max_amount) if referral.referee_max_amount else None, + "apply_on": "Grand Total", + # Validity + "valid_from": valid_from, + "valid_upto": valid_upto, + "maximum_use": 1, # One-time use for referee + "one_use": 1, + } + ) + + coupon.insert() + return coupon diff --git a/pos_next/pos_next/doctype/referral_code/test_referral_code.py b/pos_next/pos_next/doctype/referral_code/test_referral_code.py index 27cf673b8..1acc7284a 100644 --- a/pos_next/pos_next/doctype/referral_code/test_referral_code.py +++ b/pos_next/pos_next/doctype/referral_code/test_referral_code.py @@ -6,4 +6,4 @@ class TestReferralCode(unittest.TestCase): - pass + pass diff --git a/pos_next/pos_next/doctype/sales_invoice_reference/sales_invoice_reference.py b/pos_next/pos_next/doctype/sales_invoice_reference/sales_invoice_reference.py index 534442418..8bdae29af 100644 --- a/pos_next/pos_next/doctype/sales_invoice_reference/sales_invoice_reference.py +++ b/pos_next/pos_next/doctype/sales_invoice_reference/sales_invoice_reference.py @@ -9,4 +9,4 @@ class SalesInvoiceReference(Document): - pass + pass diff --git a/pos_next/pos_next/doctype/wallet/wallet.py b/pos_next/pos_next/doctype/wallet/wallet.py index 77bc33794..a15b4169e 100644 --- a/pos_next/pos_next/doctype/wallet/wallet.py +++ b/pos_next/pos_next/doctype/wallet/wallet.py @@ -2,10 +2,10 @@ # For license information, please see license.txt import frappe +from erpnext.accounts.utils import get_balance_on from frappe import _ from frappe.model.document import Document from frappe.utils import flt -from erpnext.accounts.utils import get_balance_on class Wallet(Document): @@ -25,13 +25,14 @@ def validate_duplicate_wallet(self): if not self.is_new(): return existing = frappe.db.exists( - "Wallet", - {"customer": self.customer, "company": self.company, "name": ("!=", self.name)} + "Wallet", {"customer": self.customer, "company": self.company, "name": ("!=", self.name)} ) if existing: - frappe.throw(_("A wallet already exists for customer {0} in company {1}").format( - self.customer, self.company - )) + frappe.throw( + _("A wallet already exists for customer {0} in company {1}").format( + self.customer, self.company + ) + ) def get_balance(self): """Get current wallet balance from GL entries. @@ -43,11 +44,7 @@ def get_balance(self): if not self.account or not self.customer: return 0.0 - balance = get_balance_on( - account=self.account, - party_type="Customer", - party=self.customer - ) + balance = get_balance_on(account=self.account, party_type="Customer", party=self.customer) # Negate because negative receivable balance = positive wallet credit return -flt(balance) @@ -74,10 +71,7 @@ def get_customer_wallet(customer, company=None): filters["company"] = company wallet = frappe.db.get_value( - "Wallet", - filters, - ["name", "customer", "company", "account", "status"], - as_dict=True + "Wallet", filters, ["name", "customer", "company", "account", "status"], as_dict=True ) return wallet @@ -111,11 +105,7 @@ def get_customer_wallet_balance(customer, company=None, exclude_invoice=None): return 0.0 # Get balance from GL entries - gl_balance = get_balance_on( - account=wallet.account, - party_type="Customer", - party=customer - ) + gl_balance = get_balance_on(account=wallet.account, party_type="Customer", party=customer) # Negate because negative receivable balance = positive wallet credit wallet_balance = -flt(gl_balance) @@ -142,14 +132,10 @@ def get_pending_wallet_payments(customer, exclude_invoice=None): "customer": customer, "docstatus": ["in", [0, 1]], # Draft or Submitted "outstanding_amount": [">", 0], - "is_pos": 1 + "is_pos": 1, } - invoices = frappe.get_all( - "Sales Invoice", - filters=filters, - fields=["name"] - ) + invoices = frappe.get_all("Sales Invoice", filters=filters, fields=["name"]) pending_amount = 0.0 @@ -159,15 +145,11 @@ def get_pending_wallet_payments(customer, exclude_invoice=None): # Get wallet payments from this invoice payments = frappe.get_all( - "Sales Invoice Payment", - filters={"parent": invoice.name}, - fields=["mode_of_payment", "amount"] + "Sales Invoice Payment", filters={"parent": invoice.name}, fields=["mode_of_payment", "amount"] ) for payment in payments: - is_wallet = frappe.db.get_value( - "Mode of Payment", payment.mode_of_payment, "is_wallet_payment" - ) + is_wallet = frappe.db.get_value("Mode of Payment", payment.mode_of_payment, "is_wallet_payment") if is_wallet: pending_amount += flt(payment.amount) @@ -199,13 +181,15 @@ def create_customer_wallet(customer, company, account=None): if not account: frappe.throw(_("Please configure a default wallet account for company {0}").format(company)) - wallet = frappe.get_doc({ - "doctype": "Wallet", - "customer": customer, - "company": company, - "account": account, - "status": "Active" - }) + wallet = frappe.get_doc( + { + "doctype": "Wallet", + "customer": customer, + "company": company, + "account": account, + "status": "Active", + } + ) wallet.insert(ignore_permissions=True) return wallet @@ -214,11 +198,7 @@ def create_customer_wallet(customer, company, account=None): def get_default_wallet_account(company): """Get default wallet account for a company""" # Try to get from POS Settings - wallet_account = frappe.db.get_value( - "POS Settings", - {"company": company}, - "wallet_account" - ) + wallet_account = frappe.db.get_value("POS Settings", {"company": company}, "wallet_account") if wallet_account: return wallet_account @@ -226,13 +206,8 @@ def get_default_wallet_account(company): # Fallback: Find a receivable account with 'wallet' in the name wallet_account = frappe.db.get_value( "Account", - { - "company": company, - "account_type": "Receivable", - "is_group": 0, - "name": ["like", "%wallet%"] - }, - "name" + {"company": company, "account_type": "Receivable", "is_group": 0, "name": ["like", "%wallet%"]}, + "name", ) return wallet_account @@ -250,7 +225,7 @@ def get_or_create_wallet(customer, company): "customer": wallet_doc.customer, "company": wallet_doc.company, "account": wallet_doc.account, - "status": wallet_doc.status + "status": wallet_doc.status, } return wallet diff --git a/pos_next/pos_next/doctype/wallet_transaction/wallet_transaction.py b/pos_next/pos_next/doctype/wallet_transaction/wallet_transaction.py index 662c9b9ab..5c17e3cff 100644 --- a/pos_next/pos_next/doctype/wallet_transaction/wallet_transaction.py +++ b/pos_next/pos_next/doctype/wallet_transaction/wallet_transaction.py @@ -2,12 +2,14 @@ # For license information, please see license.txt import frappe -from frappe import _ -from frappe.utils import flt, today from erpnext.accounts.general_ledger import make_gl_entries from erpnext.controllers.accounts_controller import AccountsController +from frappe import _ +from frappe.utils import flt, today + from pos_next.api.wallet import get_or_create_wallet + class WalletTransaction(AccountsController): def validate(self): self.validate_wallet() @@ -31,12 +33,13 @@ def validate_amount(self): # For debit transactions, check if sufficient balance if self.transaction_type == "Debit": from pos_next.pos_next.doctype.wallet.wallet import get_customer_wallet_balance + balance = get_customer_wallet_balance(self.customer, self.company) if flt(self.amount) > flt(balance): frappe.throw( _("Insufficient wallet balance. Available: {0}, Requested: {1}").format( frappe.format_value(balance, {"fieldtype": "Currency"}), - frappe.format_value(self.amount, {"fieldtype": "Currency"}) + frappe.format_value(self.amount, {"fieldtype": "Currency"}), ) ) @@ -52,10 +55,7 @@ def on_submit(self): def on_cancel(self): """Reverse GL entries on cancel""" - self.ignore_linked_doctypes = ( - "GL Entry", - "Payment Ledger Entry" - ) + self.ignore_linked_doctypes = ("GL Entry", "Payment Ledger Entry") self.make_gl_entries(cancel=True) self.update_wallet_balance() @@ -73,9 +73,7 @@ def make_gl_entries(self, cancel=False): gl_entries, cancel=cancel, update_outstanding="Yes", - merge_entries=frappe.db.get_single_value( - "Accounts Settings", "merge_similar_account_heads" - ) + merge_entries=frappe.db.get_single_value("Accounts Settings", "merge_similar_account_heads"), ) def build_gl_entries(self): @@ -92,9 +90,7 @@ def build_gl_entries(self): if not source_account: frappe.throw(_("Source account is required for wallet transaction")) - cost_center = self.cost_center or frappe.get_cached_value( - "Company", self.company, "cost_center" - ) + cost_center = self.cost_center or frappe.get_cached_value("Company", self.company, "cost_center") amount = flt(self.amount, self.precision("amount")) @@ -106,50 +102,54 @@ def build_gl_entries(self): "debit": amount, "debit_in_account_currency": amount, "cost_center": cost_center, - "remarks": self.remarks or _("Wallet Credit: {0}").format(self.name) + "remarks": self.remarks or _("Wallet Credit: {0}").format(self.name), } # Receivable/Payable accounts require party information - if not hasattr(self, '_source_account_type'): + if not hasattr(self, "_source_account_type"): self._source_account_type = frappe.get_cached_value("Account", source_account, "account_type") if self._source_account_type in ("Receivable", "Payable") and self.customer: source_gl["party_type"] = "Customer" source_gl["party"] = self.customer gl_entries.append(self.get_gl_dict(source_gl)) gl_entries.append( - self.get_gl_dict({ - "account": wallet_account, - "party_type": "Customer", - "party": self.customer, - "credit": amount, - "credit_in_account_currency": amount, - "cost_center": cost_center, - "remarks": self.remarks or _("Wallet Credit: {0}").format(self.name) - }) + self.get_gl_dict( + { + "account": wallet_account, + "party_type": "Customer", + "party": self.customer, + "credit": amount, + "credit_in_account_currency": amount, + "cost_center": cost_center, + "remarks": self.remarks or _("Wallet Credit: {0}").format(self.name), + } + ) ) elif self.transaction_type == "Debit": # Debit from wallet (decrease balance) # Debit wallet account (with party), Credit source account gl_entries.append( - self.get_gl_dict({ - "account": wallet_account, - "party_type": "Customer", - "party": self.customer, - "debit": amount, - "debit_in_account_currency": amount, - "cost_center": cost_center, - "remarks": self.remarks or _("Wallet Debit: {0}").format(self.name) - }) + self.get_gl_dict( + { + "account": wallet_account, + "party_type": "Customer", + "party": self.customer, + "debit": amount, + "debit_in_account_currency": amount, + "cost_center": cost_center, + "remarks": self.remarks or _("Wallet Debit: {0}").format(self.name), + } + ) ) debit_source_gl = { "account": source_account, "credit": amount, "credit_in_account_currency": amount, "cost_center": cost_center, - "remarks": self.remarks or _("Wallet Debit: {0}").format(self.name) + "remarks": self.remarks or _("Wallet Debit: {0}").format(self.name), } # Receivable/Payable accounts require party information - if not hasattr(self, '_source_account_type'): + if not hasattr(self, "_source_account_type"): self._source_account_type = frappe.get_cached_value("Account", source_account, "account_type") if self._source_account_type in ("Receivable", "Payable") and self.customer: debit_source_gl["party_type"] = "Customer" @@ -169,9 +169,7 @@ def get_source_account(self): if self.source_type == "Loyalty Program": # Get loyalty expense account from loyalty program or company loyalty_account = frappe.db.get_value( - "Loyalty Program", - {"company": self.company}, - "expense_account" + "Loyalty Program", {"company": self.company}, "expense_account" ) if loyalty_account: return loyalty_account @@ -191,8 +189,15 @@ def get_source_account(self): @frappe.whitelist() -def create_wallet_credit(wallet, amount, source_type="Manual Adjustment", remarks=None, - reference_doctype=None, reference_name=None, submit=True): +def create_wallet_credit( + wallet, + amount, + source_type="Manual Adjustment", + remarks=None, + reference_doctype=None, + reference_name=None, + submit=True, +): """ Create a wallet credit transaction. @@ -213,34 +218,28 @@ def create_wallet_credit(wallet, amount, source_type="Manual Adjustment", remark # Get source account based on source type source_account = None if source_type == "Loyalty Program": - loyalty_program = frappe.db.get_value( - "Loyalty Program", - {"company": wallet_doc.company}, - "name" - ) + loyalty_program = frappe.db.get_value("Loyalty Program", {"company": wallet_doc.company}, "name") if loyalty_program: - source_account = frappe.db.get_value( - "Loyalty Program", loyalty_program, "expense_account" - ) + source_account = frappe.db.get_value("Loyalty Program", loyalty_program, "expense_account") if not source_account: - source_account = frappe.get_cached_value( - "Company", wallet_doc.company, "default_expense_account" - ) + source_account = frappe.get_cached_value("Company", wallet_doc.company, "default_expense_account") - transaction = frappe.get_doc({ - "doctype": "Wallet Transaction", - "transaction_type": "Loyalty Credit" if source_type == "Loyalty Program" else "Credit", - "wallet": wallet, - "company": wallet_doc.company, - "posting_date": today(), - "amount": amount, - "source_type": source_type, - "source_account": source_account, - "remarks": remarks, - "reference_doctype": reference_doctype, - "reference_name": reference_name - }) + transaction = frappe.get_doc( + { + "doctype": "Wallet Transaction", + "transaction_type": "Loyalty Credit" if source_type == "Loyalty Program" else "Credit", + "wallet": wallet, + "company": wallet_doc.company, + "posting_date": today(), + "amount": amount, + "source_type": source_type, + "source_account": source_account, + "remarks": remarks, + "reference_doctype": reference_doctype, + "reference_name": reference_name, + } + ) transaction.insert(ignore_permissions=True) @@ -271,9 +270,7 @@ def credit_loyalty_points_to_wallet(customer, company, loyalty_points, conversio if not conversion_factor: loyalty_program = frappe.db.get_value("Customer", customer, "loyalty_program") if loyalty_program: - conversion_factor = frappe.db.get_value( - "Loyalty Program", loyalty_program, "conversion_factor" - ) + conversion_factor = frappe.db.get_value("Loyalty Program", loyalty_program, "conversion_factor") if not conversion_factor: conversion_factor = 1.0 # Default: 1 point = 1 currency @@ -293,14 +290,14 @@ def credit_loyalty_points_to_wallet(customer, company, loyalty_points, conversio amount=credit_amount, source_type="Loyalty Program", remarks=_("Loyalty points conversion: {0} points = {1}").format( - loyalty_points, - frappe.format_value(credit_amount, {"fieldtype": "Currency"}) + loyalty_points, frappe.format_value(credit_amount, {"fieldtype": "Currency"}) ), - submit=True + submit=True, ) return transaction + def credit_return_to_wallet(return_invoice, amount=None): """ Create a Credit wallet transaction when "Add to Customer Credit Balance" @@ -327,8 +324,7 @@ def credit_return_to_wallet(return_invoice, amount=None): if not return_data or not return_data.is_return: frappe.log_error( - title="Wallet Credit on Return Error", - message=f"Invoice {return_invoice} is not a return invoice" + title="Wallet Credit on Return Error", message=f"Invoice {return_invoice} is not a return invoice" ) return None @@ -347,7 +343,7 @@ def credit_return_to_wallet(return_invoice, amount=None): if not wallet: frappe.log_error( title="Wallet Credit on Return Error", - message=f"Could not get or create wallet for customer {customer}, company {company}" + message=f"Could not get or create wallet for customer {customer}, company {company}", ) return None @@ -357,7 +353,7 @@ def credit_return_to_wallet(return_invoice, amount=None): if not source_account: frappe.log_error( title="Wallet Credit on Return Error", - message=f"No default receivable account for company {company}" + message=f"No default receivable account for company {company}", ) return None @@ -398,37 +394,39 @@ def credit_return_to_wallet(return_invoice, amount=None): except Exception: frappe.log_error( title="Wallet Transaction Recovery Error", - message=f"Could not cancel broken WT {existing_transaction.name}: {frappe.get_traceback()}" + message=f"Could not cancel broken WT {existing_transaction.name}: {frappe.get_traceback()}", ) return None - transaction = frappe.get_doc({ - "doctype": "Wallet Transaction", - "transaction_type": "Credit", - "wallet": wallet["name"], - "company": company, - "posting_date": today(), - "amount": credit_amount, - "source_type": "Refund", - "source_account": source_account, - "reference_doctype": "Sales Invoice", - "reference_name": return_invoice, - "remarks": _("Return credit to wallet for {0} against {1}: {2}").format( - return_invoice, - return_data.return_against or "", - frappe.format_value(credit_amount, {"fieldtype": "Currency"}) - ) - }) + transaction = frappe.get_doc( + { + "doctype": "Wallet Transaction", + "transaction_type": "Credit", + "wallet": wallet["name"], + "company": company, + "posting_date": today(), + "amount": credit_amount, + "source_type": "Refund", + "source_account": source_account, + "reference_doctype": "Sales Invoice", + "reference_name": return_invoice, + "remarks": _("Return credit to wallet for {0} against {1}: {2}").format( + return_invoice, + return_data.return_against or "", + frappe.format_value(credit_amount, {"fieldtype": "Currency"}), + ), + } + ) transaction.flags.ignore_permissions = True transaction.insert(ignore_permissions=True) transaction.submit() frappe.msgprint( _("Credited {0} to customer wallet for return {1}").format( - frappe.format_value(credit_amount, {"fieldtype": "Currency"}), - return_invoice + frappe.format_value(credit_amount, {"fieldtype": "Currency"}), return_invoice ), - alert=True, indicator="green" + alert=True, + indicator="green", ) return transaction @@ -452,13 +450,16 @@ def reverse_wallet_transactions_for_return(original_invoice, return_invoice): if not return_doc.is_return or return_doc.return_against != original_invoice: return - existing = frappe.db.exists("Wallet Transaction", { - "reference_doctype": "Sales Invoice", - "reference_name": return_invoice, - "transaction_type": "Debit", - "source_type": "Refund", - "docstatus": ["!=", 2], - }) + existing = frappe.db.exists( + "Wallet Transaction", + { + "reference_doctype": "Sales Invoice", + "reference_name": return_invoice, + "transaction_type": "Debit", + "source_type": "Refund", + "docstatus": ["!=", 2], + }, + ) if existing: return # Find all submitted Wallet Transactions linked to the original invoice @@ -468,10 +469,18 @@ def reverse_wallet_transactions_for_return(original_invoice, return_invoice): "reference_doctype": "Sales Invoice", "reference_name": original_invoice, "docstatus": 1, - "transaction_type": ["in", ["Credit", "Loyalty Credit"]] + "transaction_type": ["in", ["Credit", "Loyalty Credit"]], }, - fields=["name", "wallet", "amount", "transaction_type", "source_type", - "source_account", "company", "customer"] + fields=[ + "name", + "wallet", + "amount", + "transaction_type", + "source_type", + "source_account", + "company", + "customer", + ], ) if not wallet_transactions: @@ -554,43 +563,49 @@ def _find_tier(amount): wt_doc.cancel() frappe.msgprint( _("Cancelled Wallet Transaction {0} due to return").format(wt.name), - alert=True, indicator="blue" + alert=True, + indicator="blue", ) except Exception as e: frappe.log_error( title="Wallet Transaction Cancel on Return Error", - message=f"WT: {wt.name}, Return: {return_invoice}, Error: {str(e)}\n{frappe.get_traceback()}" + message=f"WT: {wt.name}, Return: {return_invoice}, Error: {str(e)}\n{frappe.get_traceback()}", ) elif reverse_amount > 0: try: - reverse_wt = frappe.get_doc({ - "doctype": "Wallet Transaction", - "transaction_type": "Debit", - "wallet": wt.wallet, - "company": wt.company, - "posting_date": today(), - "amount": reverse_amount, - "source_type": "Refund", - "source_account": wt.source_account, - "reference_doctype": "Sales Invoice", - "reference_name": return_invoice, - "remarks": _("Wallet reversal for return {0} against {1}: returned {2}, reversed {3}").format( - return_invoice, original_invoice, - frappe.format_value(returned_amount, {"fieldtype": "Currency"}), - frappe.format_value(reverse_amount, {"fieldtype": "Currency"}) - ) - }) + reverse_wt = frappe.get_doc( + { + "doctype": "Wallet Transaction", + "transaction_type": "Debit", + "wallet": wt.wallet, + "company": wt.company, + "posting_date": today(), + "amount": reverse_amount, + "source_type": "Refund", + "source_account": wt.source_account, + "reference_doctype": "Sales Invoice", + "reference_name": return_invoice, + "remarks": _( + "Wallet reversal for return {0} against {1}: returned {2}, reversed {3}" + ).format( + return_invoice, + original_invoice, + frappe.format_value(returned_amount, {"fieldtype": "Currency"}), + frappe.format_value(reverse_amount, {"fieldtype": "Currency"}), + ), + } + ) reverse_wt.flags.ignore_permissions = True reverse_wt.insert() reverse_wt.submit() frappe.msgprint( _("Created wallet debit of {0} for partial return {1}").format( - frappe.format_value(reverse_amount, {"fieldtype": "Currency"}), - return_invoice + frappe.format_value(reverse_amount, {"fieldtype": "Currency"}), return_invoice ), - alert=True, indicator="blue" + alert=True, + indicator="blue", ) except Exception as e: frappe.log_error( @@ -599,5 +614,5 @@ def _find_tier(amount): f"WT: {wt.name}, Return: {return_invoice}, " f"Original: {original_invoice}, Reverse Amount: {reverse_amount}, " f"Error: {str(e)}\n{frappe.get_traceback()}" - ) + ), ) diff --git a/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.js b/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.js index a64edbe44..91ce4aaae 100644 --- a/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.js +++ b/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.js @@ -2,38 +2,38 @@ // For license information, please see license.txt frappe.query_reports["Cashier Performance Report"] = { - "filters": [ + filters: [ { - "fieldname": "from_date", - "label": __("From Date"), - "fieldtype": "Date", - "default": frappe.datetime.add_days(frappe.datetime.get_today(), -30), - "reqd": 0 + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_days(frappe.datetime.get_today(), -30), + reqd: 0, }, { - "fieldname": "to_date", - "label": __("To Date"), - "fieldtype": "Date", - "default": frappe.datetime.get_today(), - "reqd": 0 + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 0, }, { - "fieldname": "shift", - "label": __("Shift"), - "fieldtype": "Link", - "options": "POS Closing Shift" + fieldname: "shift", + label: __("Shift"), + fieldtype: "Link", + options: "POS Closing Shift", }, { - "fieldname": "pos_profile", - "label": __("POS Profile"), - "fieldtype": "Link", - "options": "POS Profile" + fieldname: "pos_profile", + label: __("POS Profile"), + fieldtype: "Link", + options: "POS Profile", }, { - "fieldname": "cashier", - "label": __("Cashier"), - "fieldtype": "Link", - "options": "User" - } - ] + fieldname: "cashier", + label: __("Cashier"), + fieldtype: "Link", + options: "User", + }, + ], }; diff --git a/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.py b/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.py index f39fa4322..d7001c114 100644 --- a/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.py +++ b/pos_next/pos_next/report/cashier_performance_report/cashier_performance_report.py @@ -20,16 +20,36 @@ def get_columns(): {"fieldname": "cashier_name", "label": _("Cashier Name"), "fieldtype": "Data", "width": 150}, {"fieldname": "total_sales", "label": _("Total Sales"), "fieldtype": "Currency", "width": 130}, {"fieldname": "invoice_count", "label": _("Invoices"), "fieldtype": "Int", "width": 90}, - {"fieldname": "average_invoice_value", "label": _("Avg Invoice Value"), "fieldtype": "Currency", "width": 140}, - {"fieldname": "total_discounts", "label": _("Discounts Given"), "fieldtype": "Currency", "width": 130}, + { + "fieldname": "average_invoice_value", + "label": _("Avg Invoice Value"), + "fieldtype": "Currency", + "width": 140, + }, + { + "fieldname": "total_discounts", + "label": _("Discounts Given"), + "fieldtype": "Currency", + "width": 130, + }, {"fieldname": "discount_percentage", "label": _("Discount %"), "fieldtype": "Percent", "width": 100}, {"fieldname": "return_count", "label": _("Returns"), "fieldtype": "Int", "width": 90}, {"fieldname": "return_amount", "label": _("Return Amount"), "fieldtype": "Currency", "width": 130}, {"fieldname": "return_percentage", "label": _("Return %"), "fieldtype": "Percent", "width": 100}, {"fieldname": "net_sales", "label": _("Net Sales"), "fieldtype": "Currency", "width": 130}, {"fieldname": "shifts_worked", "label": _("Shifts Worked"), "fieldtype": "Int", "width": 110}, - {"fieldname": "avg_sales_per_shift", "label": _("Avg Sales/Shift"), "fieldtype": "Currency", "width": 140}, - {"fieldname": "performance_rating", "label": _("Performance Rating"), "fieldtype": "Data", "width": 140} + { + "fieldname": "avg_sales_per_shift", + "label": _("Avg Sales/Shift"), + "fieldtype": "Currency", + "width": 140, + }, + { + "fieldname": "performance_rating", + "label": _("Performance Rating"), + "fieldtype": "Data", + "width": 140, + }, ] @@ -212,8 +232,8 @@ def get_chart_data(data): return { "data": { "labels": [row.get("cashier_name") or row.get("cashier") for row in top_cashiers], - "datasets": [{"name": "Total Sales", "values": [row.total_sales for row in top_cashiers]}] + "datasets": [{"name": "Total Sales", "values": [row.total_sales for row in top_cashiers]}], }, "type": "bar", - "colors": ["#4CAF50"] + "colors": ["#4CAF50"], } diff --git a/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.js b/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.js index 687286c96..e2d9f2cef 100644 --- a/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.js +++ b/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.js @@ -2,54 +2,54 @@ // For license information, please see license.txt frappe.query_reports["Inventory Impact and Fast Movers Report"] = { - "filters": [ + filters: [ { - "fieldname": "from_date", - "label": __("From Date"), - "fieldtype": "Date", - "default": frappe.datetime.add_days(frappe.datetime.get_today(), -30), - "reqd": 0 + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_days(frappe.datetime.get_today(), -30), + reqd: 0, }, { - "fieldname": "to_date", - "label": __("To Date"), - "fieldtype": "Date", - "default": frappe.datetime.get_today(), - "reqd": 0 + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 0, }, { - "fieldname": "shift", - "label": __("Shift"), - "fieldtype": "Link", - "options": "POS Closing Shift" + fieldname: "shift", + label: __("Shift"), + fieldtype: "Link", + options: "POS Closing Shift", }, { - "fieldname": "pos_profile", - "label": __("POS Profile"), - "fieldtype": "Link", - "options": "POS Profile", - "reqd": 1 + fieldname: "pos_profile", + label: __("POS Profile"), + fieldtype: "Link", + options: "POS Profile", + reqd: 1, }, { - "fieldname": "item_group", - "label": __("Item Group"), - "fieldtype": "Link", - "options": "Item Group" + fieldname: "item_group", + label: __("Item Group"), + fieldtype: "Link", + options: "Item Group", }, { - "fieldname": "stock_status", - "label": __("Stock Status"), - "fieldtype": "Select", - "options": "\nOut of Stock\nCritical\nLow\nGood\nExcess" + fieldname: "stock_status", + label: __("Stock Status"), + fieldtype: "Select", + options: "\nOut of Stock\nCritical\nLow\nGood\nExcess", }, { - "fieldname": "include_zero_stock", - "label": __("Include Zero Stock Items"), - "fieldtype": "Check", - "default": 0 - } + fieldname: "include_zero_stock", + label: __("Include Zero Stock Items"), + fieldtype: "Check", + default: 0, + }, ], - "formatter": function(value, row, column, data, default_formatter) { + formatter: function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); if (column.fieldname == "stock_status") { @@ -68,5 +68,5 @@ frappe.query_reports["Inventory Impact and Fast Movers Report"] = { } return value; - } + }, }; diff --git a/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.py b/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.py index 8134823e5..6f849cc7f 100644 --- a/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.py +++ b/pos_next/pos_next/report/inventory_impact_and_fast_movers_report/inventory_impact_and_fast_movers_report.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.utils import flt, cint +from frappe.utils import cint, flt def execute(filters=None): @@ -21,75 +21,30 @@ def get_columns(): "label": _("Item Code"), "fieldtype": "Link", "options": "Item", - "width": 130 - }, - { - "fieldname": "item_name", - "label": _("Item Name"), - "fieldtype": "Data", - "width": 200 + "width": 130, }, + {"fieldname": "item_name", "label": _("Item Name"), "fieldtype": "Data", "width": 200}, { "fieldname": "item_group", "label": _("Item Group"), "fieldtype": "Link", "options": "Item Group", - "width": 130 - }, - { - "fieldname": "qty_sold", - "label": _("Qty Sold"), - "fieldtype": "Float", - "width": 100 - }, - { - "fieldname": "total_sales_value", - "label": _("Sales Value"), - "fieldtype": "Currency", - "width": 130 - }, - { - "fieldname": "avg_selling_rate", - "label": _("Avg Rate"), - "fieldtype": "Currency", - "width": 110 - }, - { - "fieldname": "current_stock", - "label": _("Current Stock"), - "fieldtype": "Float", - "width": 120 - }, - { - "fieldname": "days_to_stockout", - "label": _("Days to Stockout"), - "fieldtype": "Int", - "width": 140 + "width": 130, }, + {"fieldname": "qty_sold", "label": _("Qty Sold"), "fieldtype": "Float", "width": 100}, + {"fieldname": "total_sales_value", "label": _("Sales Value"), "fieldtype": "Currency", "width": 130}, + {"fieldname": "avg_selling_rate", "label": _("Avg Rate"), "fieldtype": "Currency", "width": 110}, + {"fieldname": "current_stock", "label": _("Current Stock"), "fieldtype": "Float", "width": 120}, + {"fieldname": "days_to_stockout", "label": _("Days to Stockout"), "fieldtype": "Int", "width": 140}, { "fieldname": "stock_depletion_rate", "label": _("Depletion Rate/Day"), "fieldtype": "Float", - "width": 150 - }, - { - "fieldname": "stock_status", - "label": _("Stock Status"), - "fieldtype": "Data", - "width": 120 - }, - { - "fieldname": "velocity_rank", - "label": _("Velocity Rank"), - "fieldtype": "Data", - "width": 120 + "width": 150, }, - { - "fieldname": "reorder_level", - "label": _("Reorder Level"), - "fieldtype": "Float", - "width": 120 - } + {"fieldname": "stock_status", "label": _("Stock Status"), "fieldtype": "Data", "width": 120}, + {"fieldname": "velocity_rank", "label": _("Velocity Rank"), "fieldtype": "Data", "width": 120}, + {"fieldname": "reorder_level", "label": _("Reorder Level"), "fieldtype": "Float", "width": 120}, ] @@ -112,6 +67,7 @@ def get_data(filters): if from_date and to_date: from frappe.utils import date_diff + date_range_days = max(date_diff(to_date, from_date), 1) else: date_range_days = 30 # Default to 30 days @@ -231,19 +187,27 @@ def _get_stock_map(item_codes, warehouse=None): placeholders = ", ".join(["%s"] * len(item_codes)) if warehouse: - rows = frappe.db.sql(""" + rows = frappe.db.sql( + """ SELECT item_code, actual_qty FROM `tabBin` WHERE item_code IN ({placeholders}) AND warehouse = %s - """.format(placeholders=placeholders), item_codes + [warehouse], as_dict=1) + """.format(placeholders=placeholders), + item_codes + [warehouse], + as_dict=1, + ) else: - rows = frappe.db.sql(""" + rows = frappe.db.sql( + """ SELECT item_code, SUM(actual_qty) as actual_qty FROM `tabBin` WHERE item_code IN ({placeholders}) GROUP BY item_code - """.format(placeholders=placeholders), item_codes, as_dict=1) + """.format(placeholders=placeholders), + item_codes, + as_dict=1, + ) return {row.item_code: flt(row.actual_qty) for row in rows} @@ -349,16 +313,9 @@ def get_chart_data(data): return { "data": { "labels": [row.item_code for row in top_movers], - "datasets": [ - { - "name": "Quantity Sold", - "values": [row.qty_sold for row in top_movers] - } - ] + "datasets": [{"name": "Quantity Sold", "values": [row.qty_sold for row in top_movers]}], }, "type": "bar", "colors": ["#2196F3"], - "barOptions": { - "stacked": False - } + "barOptions": {"stacked": False}, } diff --git a/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.js b/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.js index 6b8169590..7db57456b 100644 --- a/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.js +++ b/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.js @@ -2,42 +2,42 @@ // For license information, please see license.txt frappe.query_reports["Offline Sync and System Health Report"] = { - "filters": [ + filters: [ { - "fieldname": "from_date", - "label": __("From Date"), - "fieldtype": "Date", - "default": frappe.datetime.add_days(frappe.datetime.get_today(), -7), - "reqd": 0 + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_days(frappe.datetime.get_today(), -7), + reqd: 0, }, { - "fieldname": "to_date", - "label": __("To Date"), - "fieldtype": "Date", - "default": frappe.datetime.get_today(), - "reqd": 0 + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 0, }, { - "fieldname": "pos_profile", - "label": __("POS Profile"), - "fieldtype": "Link", - "options": "POS Profile" + fieldname: "pos_profile", + label: __("POS Profile"), + fieldtype: "Link", + options: "POS Profile", }, { - "fieldname": "status", - "label": __("Sync Status"), - "fieldtype": "Select", - "options": "\nPending\nSynced\nFailed", - "default": "" + fieldname: "status", + label: __("Sync Status"), + fieldtype: "Select", + options: "\nPending\nSynced\nFailed", + default: "", }, { - "fieldname": "user", - "label": __("User"), - "fieldtype": "Link", - "options": "User" - } + fieldname: "user", + label: __("User"), + fieldtype: "Link", + options: "User", + }, ], - "formatter": function(value, row, column, data, default_formatter) { + formatter: function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); if (column.fieldname == "health_status") { @@ -61,5 +61,5 @@ frappe.query_reports["Offline Sync and System Health Report"] = { } return value; - } + }, }; diff --git a/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.py b/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.py index 9dc1d80dc..b52f34319 100644 --- a/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.py +++ b/pos_next/pos_next/report/offline_sync_and_system_health_report/offline_sync_and_system_health_report.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.utils import flt, time_diff_in_hours, get_datetime +from frappe.utils import flt, get_datetime, time_diff_in_hours def execute(filters=None): @@ -17,69 +17,44 @@ def execute(filters=None): def get_columns(): """Return columns for the report""" return [ - { - "fieldname": "offline_id", - "label": _("Offline ID"), - "fieldtype": "Data", - "width": 180 - }, + {"fieldname": "offline_id", "label": _("Offline ID"), "fieldtype": "Data", "width": 180}, { "fieldname": "sales_invoice", "label": _("Sales Invoice"), "fieldtype": "Link", "options": "Sales Invoice", - "width": 150 + "width": 150, }, { "fieldname": "pos_profile", "label": _("POS Profile"), "fieldtype": "Link", "options": "POS Profile", - "width": 130 + "width": 130, }, { "fieldname": "customer", "label": _("Customer"), "fieldtype": "Link", "options": "Customer", - "width": 150 - }, - { - "fieldname": "status", - "label": _("Sync Status"), - "fieldtype": "Data", - "width": 110 - }, - { - "fieldname": "synced_at", - "label": _("Synced At"), - "fieldtype": "Datetime", - "width": 150 + "width": 150, }, + {"fieldname": "status", "label": _("Sync Status"), "fieldtype": "Data", "width": 110}, + {"fieldname": "synced_at", "label": _("Synced At"), "fieldtype": "Datetime", "width": 150}, { "fieldname": "invoice_created_at", "label": _("Invoice Created"), "fieldtype": "Datetime", - "width": 150 + "width": 150, }, { "fieldname": "sync_delay_hours", "label": _("Sync Delay (Hours)"), "fieldtype": "Float", - "width": 150 - }, - { - "fieldname": "health_status", - "label": _("Health Status"), - "fieldtype": "Data", - "width": 130 + "width": 150, }, - { - "fieldname": "error_message", - "label": _("Error Message"), - "fieldtype": "Text", - "width": 200 - } + {"fieldname": "health_status", "label": _("Health Status"), "fieldtype": "Data", "width": 130}, + {"fieldname": "error_message", "label": _("Error Message"), "fieldtype": "Text", "width": 200}, ] @@ -117,11 +92,7 @@ def get_data(filters): # Calculate sync delay if row.synced_at and row.invoice_created_at: row.sync_delay_hours = flt( - time_diff_in_hours( - get_datetime(row.synced_at), - get_datetime(row.invoice_created_at) - ), - 2 + time_diff_in_hours(get_datetime(row.synced_at), get_datetime(row.invoice_created_at)), 2 ) else: row.sync_delay_hours = None @@ -132,12 +103,9 @@ def get_data(filters): # Try to get error from error log error_log = frappe.db.get_value( "Error Log", - { - "reference_doctype": "Offline Invoice Sync", - "reference_name": row.offline_id - }, + {"reference_doctype": "Offline Invoice Sync", "reference_name": row.offline_id}, "error", - order_by="creation desc" + order_by="creation desc", ) row.error_message = error_log[:200] if error_log else "Sync failed" @@ -200,49 +168,41 @@ def get_summary(data): pending_count = len([d for d in data if d.status == "Pending"]) # Calculate average sync delay for successful syncs - sync_delays = [d.sync_delay_hours for d in data if d.sync_delay_hours is not None and d.status == "Synced"] + sync_delays = [ + d.sync_delay_hours for d in data if d.sync_delay_hours is not None and d.status == "Synced" + ] avg_sync_delay = flt(sum(sync_delays) / len(sync_delays), 2) if sync_delays else 0 # Calculate success rate success_rate = flt((synced_count / total_syncs) * 100, 2) if total_syncs > 0 else 0 return [ - { - "value": total_syncs, - "label": "Total Sync Attempts", - "indicator": "Blue", - "datatype": "Int" - }, - { - "value": synced_count, - "label": "Successfully Synced", - "indicator": "Green", - "datatype": "Int" - }, + {"value": total_syncs, "label": "Total Sync Attempts", "indicator": "Blue", "datatype": "Int"}, + {"value": synced_count, "label": "Successfully Synced", "indicator": "Green", "datatype": "Int"}, { "value": failed_count, "label": "Failed Syncs", "indicator": "Red" if failed_count > 0 else "Gray", - "datatype": "Int" + "datatype": "Int", }, { "value": pending_count, "label": "Pending Syncs", "indicator": "Yellow" if pending_count > 0 else "Gray", - "datatype": "Int" + "datatype": "Int", }, { "value": success_rate, "label": "Success Rate (%)", "indicator": "Green" if success_rate >= 95 else "Orange", - "datatype": "Percent" + "datatype": "Percent", }, { "value": avg_sync_delay, "label": "Avg Sync Delay (Hours)", "indicator": "Green" if avg_sync_delay < 1 else "Orange", - "datatype": "Float" - } + "datatype": "Float", + }, ] @@ -252,11 +212,7 @@ def get_chart_data(data): return None # Count by status - status_counts = { - "Synced": 0, - "Failed": 0, - "Pending": 0 - } + status_counts = {"Synced": 0, "Failed": 0, "Pending": 0} for row in data: if row.status in status_counts: @@ -265,13 +221,8 @@ def get_chart_data(data): return { "data": { "labels": list(status_counts.keys()), - "datasets": [ - { - "name": "Sync Records", - "values": list(status_counts.values()) - } - ] + "datasets": [{"name": "Sync Records", "values": list(status_counts.values())}], }, "type": "donut", - "colors": ["#4CAF50", "#f44336", "#FFC107"] + "colors": ["#4CAF50", "#f44336", "#FFC107"], } diff --git a/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.js b/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.js index 69e3c9a28..2643fe4dc 100644 --- a/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.js +++ b/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.js @@ -2,44 +2,44 @@ // For license information, please see license.txt frappe.query_reports["Payments and Cash Control Report"] = { - "filters": [ + filters: [ { - "fieldname": "from_date", - "label": __("From Date"), - "fieldtype": "Date", - "default": frappe.datetime.add_days(frappe.datetime.get_today(), -30), - "reqd": 0 + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_days(frappe.datetime.get_today(), -30), + reqd: 0, }, { - "fieldname": "to_date", - "label": __("To Date"), - "fieldtype": "Date", - "default": frappe.datetime.get_today(), - "reqd": 0 + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 0, }, { - "fieldname": "shift", - "label": __("Shift"), - "fieldtype": "Link", - "options": "POS Closing Shift" + fieldname: "shift", + label: __("Shift"), + fieldtype: "Link", + options: "POS Closing Shift", }, { - "fieldname": "pos_profile", - "label": __("POS Profile"), - "fieldtype": "Link", - "options": "POS Profile" + fieldname: "pos_profile", + label: __("POS Profile"), + fieldtype: "Link", + options: "POS Profile", }, { - "fieldname": "cashier", - "label": __("Cashier"), - "fieldtype": "Link", - "options": "User" + fieldname: "cashier", + label: __("Cashier"), + fieldtype: "Link", + options: "User", }, { - "fieldname": "mode_of_payment", - "label": __("Mode of Payment"), - "fieldtype": "Link", - "options": "Mode of Payment" - } - ] + fieldname: "mode_of_payment", + label: __("Mode of Payment"), + fieldtype: "Link", + options: "Mode of Payment", + }, + ], }; diff --git a/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.py b/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.py index 3dae6e4c7..3d39d5da2 100644 --- a/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.py +++ b/pos_next/pos_next/report/payments_and_cash_control_report/payments_and_cash_control_report.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.utils import flt, time_diff_in_hours, get_datetime +from frappe.utils import flt, get_datetime, time_diff_in_hours def execute(filters=None): @@ -24,116 +24,84 @@ def get_columns(payment_methods): "label": _("Shift"), "fieldtype": "Link", "options": "POS Closing Shift", - "width": 150 + "width": 150, }, { "fieldname": "pos_profile", "label": _("POS Profile"), "fieldtype": "Link", "options": "POS Profile", - "width": 150 - }, - { - "fieldname": "cashier", - "label": _("Cashier"), - "fieldtype": "Link", - "options": "User", - "width": 150 - }, - { - "fieldname": "posting_date", - "label": _("Date"), - "fieldtype": "Date", - "width": 100 - }, - { - "fieldname": "shift_start", - "label": _("Shift Start"), - "fieldtype": "Time", - "width": 100 - }, - { - "fieldname": "shift_end", - "label": _("Shift End"), - "fieldtype": "Time", - "width": 100 - }, - { - "fieldname": "shift_hours", - "label": _("Shift Hours"), - "fieldtype": "Float", - "width": 90 - }, - { - "fieldname": "total_transactions", - "label": _("Transactions"), - "fieldtype": "Int", - "width": 100 + "width": 150, }, + {"fieldname": "cashier", "label": _("Cashier"), "fieldtype": "Link", "options": "User", "width": 150}, + {"fieldname": "posting_date", "label": _("Date"), "fieldtype": "Date", "width": 100}, + {"fieldname": "shift_start", "label": _("Shift Start"), "fieldtype": "Time", "width": 100}, + {"fieldname": "shift_end", "label": _("Shift End"), "fieldtype": "Time", "width": 100}, + {"fieldname": "shift_hours", "label": _("Shift Hours"), "fieldtype": "Float", "width": 90}, + {"fieldname": "total_transactions", "label": _("Transactions"), "fieldtype": "Int", "width": 100}, ] # Dynamic columns per payment method for method in payment_methods: safe = method.lower().replace(" ", "_") - columns.extend([ + columns.extend( + [ + { + "fieldname": f"{safe}_opening", + "label": _(f"{method} Opening"), + "fieldtype": "Currency", + "width": 130, + }, + { + "fieldname": f"{safe}_expected", + "label": _(f"{method} Expected"), + "fieldtype": "Currency", + "width": 130, + }, + { + "fieldname": f"{safe}_closing", + "label": _(f"{method} Closing"), + "fieldtype": "Currency", + "width": 130, + }, + { + "fieldname": f"{safe}_diff", + "label": _(f"{method} Diff"), + "fieldtype": "Currency", + "width": 110, + }, + ] + ) + + columns.extend( + [ { - "fieldname": f"{safe}_opening", - "label": _(f"{method} Opening"), + "fieldname": "total_opening", + "label": _("Total Opening"), "fieldtype": "Currency", - "width": 130 + "width": 130, }, { - "fieldname": f"{safe}_expected", - "label": _(f"{method} Expected"), + "fieldname": "total_expected", + "label": _("Total Expected"), "fieldtype": "Currency", - "width": 130 + "width": 130, }, { - "fieldname": f"{safe}_closing", - "label": _(f"{method} Closing"), + "fieldname": "total_closing", + "label": _("Total Closing"), "fieldtype": "Currency", - "width": 130 + "width": 130, }, { - "fieldname": f"{safe}_diff", - "label": _(f"{method} Diff"), + "fieldname": "total_difference", + "label": _("Total Difference"), "fieldtype": "Currency", - "width": 110 + "width": 130, }, - ]) - - columns.extend([ - { - "fieldname": "total_opening", - "label": _("Total Opening"), - "fieldtype": "Currency", - "width": 130 - }, - { - "fieldname": "total_expected", - "label": _("Total Expected"), - "fieldtype": "Currency", - "width": 130 - }, - { - "fieldname": "total_closing", - "label": _("Total Closing"), - "fieldtype": "Currency", - "width": 130 - }, - { - "fieldname": "total_difference", - "label": _("Total Difference"), - "fieldtype": "Currency", - "width": 130 - }, - { - "fieldname": "status", - "label": _("Status"), - "fieldtype": "Data", - "width": 120 - }, - ]) + {"fieldname": "status", "label": _("Status"), "fieldtype": "Data", "width": 120}, + ] + ) return columns @@ -188,10 +156,9 @@ def get_data(filters): shift_order.append(r.shift) # Calculate shift hours if r._shift_start_dt and r._shift_end_dt: - shift_hours = flt(time_diff_in_hours( - get_datetime(r._shift_end_dt), - get_datetime(r._shift_start_dt) - ), 1) + shift_hours = flt( + time_diff_in_hours(get_datetime(r._shift_end_dt), get_datetime(r._shift_start_dt)), 1 + ) else: shift_hours = 0 @@ -262,7 +229,8 @@ def _get_transaction_counts(data): placeholders = ", ".join(["%s"] * len(shift_names)) - rows = frappe.db.sql(""" + rows = frappe.db.sql( + """ SELECT sir.parent as shift, COUNT(DISTINCT sir.sales_invoice) as cnt @@ -270,7 +238,10 @@ def _get_transaction_counts(data): WHERE sir.parenttype = 'POS Closing Shift' AND sir.parent IN ({placeholders}) GROUP BY sir.parent - """.format(placeholders=placeholders), shift_names, as_dict=1) + """.format(placeholders=placeholders), + shift_names, + as_dict=1, + ) return {r.shift: r.cnt for r in rows} @@ -319,7 +290,7 @@ def get_chart_data(data, payment_methods): {"name": _("Expected"), "values": expected_values}, {"name": _("Closing"), "values": closing_values}, {"name": _("Difference"), "values": diff_values}, - ] + ], }, "type": "bar", "fieldtype": "Currency", diff --git a/pos_next/pos_next/report/sales_vs_shifts_report/sales_vs_shifts_report.js b/pos_next/pos_next/report/sales_vs_shifts_report/sales_vs_shifts_report.js index 6aa4608f2..5aa6bec48 100644 --- a/pos_next/pos_next/report/sales_vs_shifts_report/sales_vs_shifts_report.js +++ b/pos_next/pos_next/report/sales_vs_shifts_report/sales_vs_shifts_report.js @@ -156,23 +156,26 @@ frappe.query_reports["Sales vs Shifts Report"] = { // // ========================================================================= - onload: function(report) { + onload: function (report) { // Add "Guide" button with icon - report.page.add_inner_button(__("Report Guide"), function() { - this.show_report_guide(); - }.bind(this)); + report.page.add_inner_button( + __("Report Guide"), + function () { + this.show_report_guide(); + }.bind(this) + ); }, - show_report_guide: function() { + show_report_guide: function () { const dialog = new frappe.ui.Dialog({ title: __("Sales vs Shifts Report Guide"), size: "extra-large", fields: [ { fieldtype: "HTML", - fieldname: "guide_content" - } - ] + fieldname: "guide_content", + }, + ], }); dialog.fields_dict.guide_content.$wrapper.html(this.get_guide_html()); @@ -183,7 +186,7 @@ frappe.query_reports["Sales vs Shifts Report"] = { const tabs = dialog.$wrapper.find(".guide-tab"); const contents = dialog.$wrapper.find(".guide-tab-content"); - tabs.on("click", function() { + tabs.on("click", function () { const target = $(this).data("tab"); tabs.removeClass("active"); $(this).addClass("active"); @@ -193,7 +196,7 @@ frappe.query_reports["Sales vs Shifts Report"] = { }, 100); }, - get_guide_html: function() { + get_guide_html: function () { return `