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..635713e95 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 @@ -113,17 +117,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( { @@ -135,6 +140,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/api/invoices.py b/pos_next/api/invoices.py index c7a716fd4..de59cd3de 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -3,14 +3,15 @@ # For license information, please see license.txt from __future__ import unicode_literals + import json from functools import lru_cache + import frappe -from frappe import _ -from frappe.utils import flt, cint, nowdate, nowtime, get_datetime, cstr -from erpnext.stock.doctype.batch.batch import get_batch_qty, get_batch_no from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account - +from erpnext.stock.doctype.batch.batch import get_batch_no, get_batch_qty +from frappe import _ +from frappe.utils import cint, cstr, flt, get_datetime, nowdate, nowtime # ========================================== # Constants for field names (avoid typos and enable refactoring) @@ -34,19 +35,19 @@ try: - from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( - apply_pricing_rule as erpnext_apply_pricing_rule, - ) - from erpnext.accounts.doctype.pricing_rule.utils import ( - get_applied_pricing_rules as erpnext_get_applied_pricing_rules, - ) - from erpnext.accounts.doctype.pricing_rule.utils import ( - apply_pricing_rule_on_transaction as erpnext_apply_pricing_rule_on_transaction, - ) + from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( + apply_pricing_rule as erpnext_apply_pricing_rule, + ) + from erpnext.accounts.doctype.pricing_rule.utils import ( + apply_pricing_rule_on_transaction as erpnext_apply_pricing_rule_on_transaction, + ) + from erpnext.accounts.doctype.pricing_rule.utils import ( + get_applied_pricing_rules as erpnext_get_applied_pricing_rules, + ) except Exception: # pragma: no cover - ERPNext not installed in some environments - erpnext_apply_pricing_rule = None - erpnext_get_applied_pricing_rules = None - erpnext_apply_pricing_rule_on_transaction = None + erpnext_apply_pricing_rule = None + erpnext_get_applied_pricing_rules = None + erpnext_apply_pricing_rule_on_transaction = None # ========================================== @@ -55,260 +56,257 @@ def calculate_price_list_rate(item_rate, discount_pct, current_price_list_rate): - """ - Calculate price_list_rate from discounted rate and discount percentage. + """ + Calculate price_list_rate from discounted rate and discount percentage. - Formula: rate = price_list_rate * (1 - discount_percentage/100) - Reverse: price_list_rate = rate / (1 - discount_percentage/100) + Formula: rate = price_list_rate * (1 - discount_percentage/100) + Reverse: price_list_rate = rate / (1 - discount_percentage/100) - Args: - item_rate: The current item rate (after discount) - discount_pct: The discount percentage (0-100) - current_price_list_rate: The existing price_list_rate if any + Args: + item_rate: The current item rate (after discount) + discount_pct: The discount percentage (0-100) + current_price_list_rate: The existing price_list_rate if any - Returns: - float: The calculated price_list_rate - """ - # Early exit: no discount applied - if discount_pct <= 0 or discount_pct >= 100: - return current_price_list_rate if current_price_list_rate else item_rate + Returns: + float: The calculated price_list_rate + """ + # Early exit: no discount applied + if discount_pct <= 0 or discount_pct >= 100: + return current_price_list_rate if current_price_list_rate else item_rate - # Reverse-calculate price_list_rate from discounted rate - if item_rate > 0: - discount_multiplier = 1 - discount_pct / 100 - return item_rate / discount_multiplier + # Reverse-calculate price_list_rate from discounted rate + if item_rate > 0: + discount_multiplier = 1 - discount_pct / 100 + return item_rate / discount_multiplier - return current_price_list_rate if current_price_list_rate else item_rate + return current_price_list_rate if current_price_list_rate else item_rate def validate_manual_rate_edit(item, pos_profile=None, pos_settings_cache=None): - """ - Validate manually edited item rates against POS Settings business rules. - - This function enforces: - 1. Rate must be positive - 2. Rate editing must be enabled in POS Settings - 3. Rate reduction must not exceed max_discount_allowed (if configured) - - Args: - item: The item dict/object with rate information. Must contain: - - is_rate_manually_edited: Flag indicating manual edit (1 or 0) - - item_code: The item code for error messages - - rate: The edited rate - - original_rate or price_list_rate: The original catalog price - pos_profile: POS Profile name for settings lookup. Required for manual edits. - pos_settings_cache: Optional pre-fetched POS Settings dict to avoid repeated DB queries. - Should contain: allow_user_to_edit_rate, max_discount_allowed - - Returns: - dict with 'valid' boolean and 'message' string if invalid - """ - is_manual_edit = cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED) or 0) - - # Skip validation if not a manual edit - if not is_manual_edit: - return {"valid": True} - - item_code = item.get(FIELD_ITEM_CODE) - item_rate = flt(item.get(FIELD_RATE) or 0) - original_rate = flt(item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE) or 0) - - # Validate rate is positive - if item_rate <= 0: - return { - "valid": False, - "message": _("Rate for item {0} must be greater than zero").format(item_code) - } - - # POS Profile is required for manual rate edit validation - if not pos_profile: - return { - "valid": False, - "message": _("POS Profile is required to validate rate edit for item {0}").format(item_code) - } - - # Use cached POS Settings if provided, otherwise fetch from DB - pos_settings = pos_settings_cache - if pos_settings is None: - pos_settings = frappe.db.get_value( - DOCTYPE_POS_SETTINGS, - {"pos_profile": pos_profile}, - [FIELD_ALLOW_USER_TO_EDIT_RATE, FIELD_MAX_DISCOUNT_ALLOWED], - as_dict=True - ) - - # Check if POS Settings exists - if not pos_settings: - return { - "valid": False, - "message": _("POS Settings not found for profile {0}. Cannot validate rate edit.").format(pos_profile) - } - - # Check if rate editing is allowed - if not cint(pos_settings.get(FIELD_ALLOW_USER_TO_EDIT_RATE)): - return { - "valid": False, - "message": _("Rate editing is not allowed for this POS Profile") - } - - # Validate against max discount if configured and rate is reduced - max_discount = flt(pos_settings.get(FIELD_MAX_DISCOUNT_ALLOWED) or 0) - if max_discount > 0 and original_rate > 0 and item_rate < original_rate: - # Calculate effective discount percentage - discount_pct = round(((original_rate - item_rate) / original_rate) * 100, 2) - if discount_pct > max_discount: - return { - "valid": False, - "message": _("Rate reduction for item {0} is {1}% which exceeds the maximum allowed discount of {2}%").format( - item_code, discount_pct, max_discount - ) - } - - return {"valid": True} + """ + Validate manually edited item rates against POS Settings business rules. + + This function enforces: + 1. Rate must be positive + 2. Rate editing must be enabled in POS Settings + 3. Rate reduction must not exceed max_discount_allowed (if configured) + + Args: + item: The item dict/object with rate information. Must contain: + - is_rate_manually_edited: Flag indicating manual edit (1 or 0) + - item_code: The item code for error messages + - rate: The edited rate + - original_rate or price_list_rate: The original catalog price + pos_profile: POS Profile name for settings lookup. Required for manual edits. + pos_settings_cache: Optional pre-fetched POS Settings dict to avoid repeated DB queries. + Should contain: allow_user_to_edit_rate, max_discount_allowed + + Returns: + dict with 'valid' boolean and 'message' string if invalid + """ + is_manual_edit = cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED) or 0) + + # Skip validation if not a manual edit + if not is_manual_edit: + return {"valid": True} + + item_code = item.get(FIELD_ITEM_CODE) + item_rate = flt(item.get(FIELD_RATE) or 0) + original_rate = flt(item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE) or 0) + + # Validate rate is positive + if item_rate <= 0: + return {"valid": False, "message": _("Rate for item {0} must be greater than zero").format(item_code)} + + # POS Profile is required for manual rate edit validation + if not pos_profile: + return { + "valid": False, + "message": _("POS Profile is required to validate rate edit for item {0}").format(item_code), + } + + # Use cached POS Settings if provided, otherwise fetch from DB + pos_settings = pos_settings_cache + if pos_settings is None: + pos_settings = frappe.db.get_value( + DOCTYPE_POS_SETTINGS, + {"pos_profile": pos_profile}, + [FIELD_ALLOW_USER_TO_EDIT_RATE, FIELD_MAX_DISCOUNT_ALLOWED], + as_dict=True, + ) + + # Check if POS Settings exists + if not pos_settings: + return { + "valid": False, + "message": _("POS Settings not found for profile {0}. Cannot validate rate edit.").format( + pos_profile + ), + } + + # Check if rate editing is allowed + if not cint(pos_settings.get(FIELD_ALLOW_USER_TO_EDIT_RATE)): + return {"valid": False, "message": _("Rate editing is not allowed for this POS Profile")} + + # Validate against max discount if configured and rate is reduced + max_discount = flt(pos_settings.get(FIELD_MAX_DISCOUNT_ALLOWED) or 0) + if max_discount > 0 and original_rate > 0 and item_rate < original_rate: + # Calculate effective discount percentage + discount_pct = round(((original_rate - item_rate) / original_rate) * 100, 2) + if discount_pct > max_discount: + return { + "valid": False, + "message": _( + "Rate reduction for item {0} is {1}% which exceeds the maximum allowed discount of {2}%" + ).format(item_code, discount_pct, max_discount), + } + + return {"valid": True} def log_manual_rate_edit(item, invoice_name, user=None): - """ - Create an audit log entry for manual rate edits. - - This function creates a Comment on the Sales Invoice documenting the rate change. - It should only be called ONCE per item, after the invoice is successfully submitted. - - Args: - item: The item dict/object with rate information. Must contain: - - is_rate_manually_edited: Flag indicating manual edit (1 or 0) - - item_code: The item code - - rate: The new/edited rate - - original_rate: The original price before edit (or price_list_rate as fallback) - invoice_name: The Sales Invoice document name - user: Optional user who made the edit (defaults to session user) - - Returns: - None - """ - # Only log if rate was manually edited - if not cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED)): - return - - user = user or frappe.session.user - item_code = item.get(FIELD_ITEM_CODE) - original_rate = flt(item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE) or 0) - new_rate = flt(item.get(FIELD_RATE) or 0) - - # Skip logging if rates are the same (no actual change) - if original_rate == new_rate: - return - - # Calculate discount/markup percentage for logging - change_pct = 0 - change_type = "reduction" - if original_rate > 0: - change_pct = round(abs((original_rate - new_rate) / original_rate) * 100, 2) - if new_rate > original_rate: - change_type = "increase" - - # Create audit comment on the invoice - frappe.get_doc({ - "doctype": DOCTYPE_COMMENT, - "comment_type": "Comment", - "reference_doctype": DOCTYPE_SALES_INVOICE, - "reference_name": invoice_name, - "content": _("Manual rate edit by {user}: Item {item_code} rate changed from {original} to {new} ({change_pct}% {change_type})").format( - user=user, - item_code=item_code, - original=frappe.format_value(original_rate, {"fieldtype": "Currency"}), - new=frappe.format_value(new_rate, {"fieldtype": "Currency"}), - change_pct=change_pct, - change_type=change_type - ) - }).insert(ignore_permissions=True) + """ + Create an audit log entry for manual rate edits. + + This function creates a Comment on the Sales Invoice documenting the rate change. + It should only be called ONCE per item, after the invoice is successfully submitted. + + Args: + item: The item dict/object with rate information. Must contain: + - is_rate_manually_edited: Flag indicating manual edit (1 or 0) + - item_code: The item code + - rate: The new/edited rate + - original_rate: The original price before edit (or price_list_rate as fallback) + invoice_name: The Sales Invoice document name + user: Optional user who made the edit (defaults to session user) + + Returns: + None + """ + # Only log if rate was manually edited + if not cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED)): + return + + user = user or frappe.session.user + item_code = item.get(FIELD_ITEM_CODE) + original_rate = flt(item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE) or 0) + new_rate = flt(item.get(FIELD_RATE) or 0) + + # Skip logging if rates are the same (no actual change) + if original_rate == new_rate: + return + + # Calculate discount/markup percentage for logging + change_pct = 0 + change_type = "reduction" + if original_rate > 0: + change_pct = round(abs((original_rate - new_rate) / original_rate) * 100, 2) + if new_rate > original_rate: + change_type = "increase" + + # Create audit comment on the invoice + frappe.get_doc( + { + "doctype": DOCTYPE_COMMENT, + "comment_type": "Comment", + "reference_doctype": DOCTYPE_SALES_INVOICE, + "reference_name": invoice_name, + "content": _( + "Manual rate edit by {user}: Item {item_code} rate changed from {original} to {new} ({change_pct}% {change_type})" + ).format( + user=user, + item_code=item_code, + original=frappe.format_value(original_rate, {"fieldtype": "Currency"}), + new=frappe.format_value(new_rate, {"fieldtype": "Currency"}), + change_pct=change_pct, + change_type=change_type, + ), + } + ).insert(ignore_permissions=True) def standardize_pricing_rules(items): - """ - Standardize pricing_rules field on invoice items. - ERPNext expects a comma-separated string, but frontend/offline may send: - - Python list: ["PRLE-0001", "PRLE-0002"] - - JSON string: '["PRLE-0001"]' or '[\\n "PRLE-0001"\\n]' + """ + Standardize pricing_rules field on invoice items. + ERPNext expects a comma-separated string, but frontend/offline may send: + - Python list: ["PRLE-0001", "PRLE-0002"] + - JSON string: '["PRLE-0001"]' or '[\\n "PRLE-0001"\\n]' - Args: - items: List of item dicts to standardize (modified in place) - """ - for item in items or []: - pricing_rules = item.get("pricing_rules") - if not pricing_rules: - continue + Args: + items: List of item dicts to standardize (modified in place) + """ + for item in items or []: + pricing_rules = item.get("pricing_rules") + if not pricing_rules: + continue - item["pricing_rules"] = _pricing_rule_to_string(pricing_rules) + item["pricing_rules"] = _pricing_rule_to_string(pricing_rules) def _pricing_rule_to_string(value): - """ - Convert pricing_rules value to comma-separated string. - Returns empty string if value is invalid/unparseable. - """ - if not value: - return "" - - # Already a list - join it - if isinstance(value, list): - return ",".join(str(r) for r in value if r) - - # Must be a string at this point - if not isinstance(value, str): - return "" - - stripped = value.strip() - - # Not JSON-like - return as-is (already a string like "PRLE-0001,PRLE-0002") - if not stripped.startswith("["): - return stripped - - # Try to parse JSON array - try: - parsed = json.loads(stripped) - if isinstance(parsed, list): - return ",".join(str(r) for r in parsed if r) - except (json.JSONDecodeError, TypeError, ValueError): - # Malformed JSON that looks like array - clear it to prevent issues - frappe.log_error( - f"Invalid pricing_rules JSON: {stripped[:100]}", - "Pricing Rules Normalization" - ) - return "" - - return "" + """ + Convert pricing_rules value to comma-separated string. + Returns empty string if value is invalid/unparseable. + """ + if not value: + return "" + + # Already a list - join it + if isinstance(value, list): + return ",".join(str(r) for r in value if r) + + # Must be a string at this point + if not isinstance(value, str): + return "" + + stripped = value.strip() + + # Not JSON-like - return as-is (already a string like "PRLE-0001,PRLE-0002") + if not stripped.startswith("["): + return stripped + + # Try to parse JSON array + try: + parsed = json.loads(stripped) + if isinstance(parsed, list): + return ",".join(str(r) for r in parsed if r) + except (json.JSONDecodeError, TypeError, ValueError): + # Malformed JSON that looks like array - clear it to prevent issues + frappe.log_error(f"Invalid pricing_rules JSON: {stripped[:100]}", "Pricing Rules Normalization") + return "" + + return "" def _strip_server_managed_fields(payload): - """Remove fields that are derived server-side and should not be replayed.""" - if not isinstance(payload, dict): - return payload + """Remove fields that are derived server-side and should not be replayed.""" + if not isinstance(payload, dict): + return payload - cleaned = dict(payload) - # Packed Items are regenerated from Product Bundle definitions during save. - # Accepting client-side packed rows can reintroduce duplicates on re-save. - cleaned.pop("packed_items", None) - return cleaned + cleaned = dict(payload) + # Packed Items are regenerated from Product Bundle definitions during save. + # Accepting client-side packed rows can reintroduce duplicates on re-save. + cleaned.pop("packed_items", None) + return cleaned def get_payment_account(mode_of_payment, company): - """ - Get account for mode of payment. - Tries multiple fallback methods to find a suitable account. - """ - # Try 1: Mode of Payment Account table - account = frappe.db.get_value( - "Mode of Payment Account", - {"parent": mode_of_payment, "company": company}, - "default_account", - ) - if account: - return {"account": account} - - # Try 2: POS Payment Method from POS Profile - account = frappe.db.sql( - """ + """ + Get account for mode of payment. + Tries multiple fallback methods to find a suitable account. + """ + # Try 1: Mode of Payment Account table + account = frappe.db.get_value( + "Mode of Payment Account", + {"parent": mode_of_payment, "company": company}, + "default_account", + ) + if account: + return {"account": account} + + # Try 2: POS Payment Method from POS Profile + account = frappe.db.sql( + """ SELECT ppm.default_account FROM `tabPOS Payment Method` ppm INNER JOIN `tabPOS Profile` pp ON ppm.parent = pp.name @@ -317,69 +315,107 @@ def get_payment_account(mode_of_payment, company): AND ppm.default_account IS NOT NULL LIMIT 1 """, - (mode_of_payment, company), - as_dict=1, - ) - - if account and account[0].default_account: - return {"account": account[0].default_account} - - # Try 3: Company default cash account (for cash payments) - if "cash" in mode_of_payment.lower(): - account = frappe.get_value("Company", company, "default_cash_account") - if account: - return {"account": account} - - # Try 4: Company default bank account - account = frappe.get_value("Company", company, "default_bank_account") - if account: - return {"account": account} - - # Try 5: Any Cash/Bank account for the company - account = frappe.db.get_value( - "Account", - {"company": company, "account_type": ["in", ["Cash", "Bank"]], "is_group": 0}, - "name", - ) - if account: - return {"account": account} - - # No account found - throw error - frappe.throw( - _( - "Please set default Cash or Bank account in Mode of Payment {0} or set default accounts in Company {1}" - ).format(mode_of_payment, company), - title=_("Missing Account"), - ) + (mode_of_payment, company), + as_dict=1, + ) + + if account and account[0].default_account: + return {"account": account[0].default_account} + + # Try 3: Company default cash account (for cash payments) + if "cash" in mode_of_payment.lower(): + account = frappe.get_value("Company", company, "default_cash_account") + if account: + return {"account": account} + + # Try 4: Company default bank account + account = frappe.get_value("Company", company, "default_bank_account") + if account: + return {"account": account} + + # Try 5: Any Cash/Bank account for the company + account = frappe.db.get_value( + "Account", + {"company": company, "account_type": ["in", ["Cash", "Bank"]], "is_group": 0}, + "name", + ) + if account: + return {"account": account} + + # No account found - throw error + frappe.throw( + _( + "Please set default Cash or Bank account in Mode of Payment {0} or set default accounts in Company {1}" + ).format(mode_of_payment, company), + title=_("Missing Account"), + ) + + +def _validate_receivable_account(account, company, pos_profile): + """Validate a cashier-selected receivable account for "Pay on Receivable Account". + + Ensures the account is a real, enabled, non-group Receivable account belonging to the + POS company, and that credit sales are enabled for the profile (the same gate that + governs the existing "Pay on Account" button). Raises on any violation so a tampered + client cannot force an arbitrary debit_to. + """ + if not account: + return + + if not company: + frappe.throw(_("Company is required to set a receivable account")) + + acc = frappe.db.get_value( + "Account", + account, + ["company", "account_type", "is_group", "disabled"], + as_dict=True, + ) + if not acc: + frappe.throw(_("Receivable account {0} does not exist").format(account)) + if acc.company != company: + frappe.throw(_("Receivable account {0} does not belong to company {1}").format(account, company)) + if acc.account_type != "Receivable": + frappe.throw(_("Account {0} is not a Receivable account").format(account)) + if cint(acc.is_group): + frappe.throw(_("Receivable account {0} is a group account").format(account)) + if cint(acc.disabled): + frappe.throw(_("Receivable account {0} is disabled").format(account)) + + allow_credit_sale = cint( + frappe.db.get_value(DOCTYPE_POS_SETTINGS, {"pos_profile": pos_profile}, "allow_credit_sale") + ) + if not allow_credit_sale: + frappe.throw(_("Credit sales are not enabled for this POS Profile.")) def _set_payment_accounts(payments, company): - """Set the account for each payment entry that is missing one. - - Handles both Document objects (from invoice_doc.payments) and plain dicts - (from frontend data). Document objects use BaseDocument.set() which writes - directly to __dict__, while plain dicts use normal key assignment. - """ - if not payments or not company: - return - - for payment in payments: - mode_of_payment = payment.get("mode_of_payment") - if not mode_of_payment or payment.get("account"): - continue - try: - account_info = get_payment_account(mode_of_payment, company) - if account_info: - account = account_info.get("account") - if hasattr(payment, "set") and callable(payment.set): - payment.set("account", account) - else: - payment["account"] = account - except Exception as e: - frappe.log_error( - f"Failed to get payment account for {mode_of_payment}: {e}", - "Payment Account Lookup", - ) + """Set the account for each payment entry that is missing one. + + Handles both Document objects (from invoice_doc.payments) and plain dicts + (from frontend data). Document objects use BaseDocument.set() which writes + directly to __dict__, while plain dicts use normal key assignment. + """ + if not payments or not company: + return + + for payment in payments: + mode_of_payment = payment.get("mode_of_payment") + if not mode_of_payment or payment.get("account"): + continue + try: + account_info = get_payment_account(mode_of_payment, company) + if account_info: + account = account_info.get("account") + if hasattr(payment, "set") and callable(payment.set): + payment.set("account", account) + else: + payment["account"] = account + except Exception as e: + frappe.log_error( + f"Failed to get payment account for {mode_of_payment}: {e}", + "Payment Account Lookup", + ) # ========================================== @@ -388,168 +424,149 @@ def _set_payment_accounts(payments, company): def _get_available_stock(item): - """Return available stock qty for an item row.""" - warehouse = item.get("warehouse") - batch_no = item.get("batch_no") - item_code = item.get("item_code") + """Return available stock qty for an item row.""" + warehouse = item.get("warehouse") + batch_no = item.get("batch_no") + item_code = item.get("item_code") - if not item_code or not warehouse: - return 0 + if not item_code or not warehouse: + return 0 - if batch_no: - return get_batch_qty(batch_no, warehouse) or 0 + if batch_no: + return get_batch_qty(batch_no, warehouse) or 0 - # Get stock from Bin - bin_qty = frappe.db.get_value( - "Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty" - ) - return flt(bin_qty) or 0 + # Get stock from Bin + bin_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty") + return flt(bin_qty) or 0 def _collect_stock_errors(items): - """Return list of items exceeding available stock. - - Respects per-item allow_negative_stock if the field exists on Item. - """ - allowed_items = _get_item_negative_stock_allow_set(items) - errors = [] - for d in items: - if flt(d.get("qty")) < 0: - continue - - available = _get_available_stock(d) - requested = flt( - d.get("stock_qty") - or (flt(d.get("qty")) * flt(d.get("conversion_factor") or 1)) - ) - - if requested > available: - if d.get("item_code") in allowed_items: - continue - errors.append( - { - "item_code": d.get("item_code"), - "warehouse": d.get("warehouse"), - "requested_qty": requested, - "available_qty": available, - } - ) - - return errors + """Return list of items exceeding available stock. + + Respects per-item allow_negative_stock if the field exists on Item. + """ + allowed_items = _get_item_negative_stock_allow_set(items) + errors = [] + for d in items: + if flt(d.get("qty")) < 0: + continue + + available = _get_available_stock(d) + requested = flt(d.get("stock_qty") or (flt(d.get("qty")) * flt(d.get("conversion_factor") or 1))) + + if requested > available: + if d.get("item_code") in allowed_items: + continue + errors.append( + { + "item_code": d.get("item_code"), + "warehouse": d.get("warehouse"), + "requested_qty": requested, + "available_qty": available, + } + ) + + return errors @lru_cache(maxsize=1) def _item_has_allow_negative_stock_field(): - """Check whether Item doctype has an allow_negative_stock field.""" - try: - return frappe.get_meta("Item").has_field("allow_negative_stock") - except Exception: - return False + """Check whether Item doctype has an allow_negative_stock field.""" + try: + return frappe.get_meta("Item").has_field("allow_negative_stock") + except Exception: + return False def _get_item_negative_stock_allow_set(items): - """Return set of item codes that allow negative stock at Item level.""" - if not items or not _item_has_allow_negative_stock_field(): - return set() - - item_codes = list({d.get("item_code") for d in items if d.get("item_code")}) - if not item_codes: - return set() - - return set(frappe.get_all( - "Item", - filters={"name": ["in", item_codes], "allow_negative_stock": 1}, - pluck="name", - ) or []) + """Return set of item codes that allow negative stock at Item level.""" + if not items or not _item_has_allow_negative_stock_field(): + return set() + + item_codes = list({d.get("item_code") for d in items if d.get("item_code")}) + if not item_codes: + return set() + + return set( + frappe.get_all( + "Item", + filters={"name": ["in", item_codes], "allow_negative_stock": 1}, + pluck="name", + ) + or [] + ) def _should_block(pos_profile): - """Check if sale should be blocked for insufficient stock.""" - # First check global ERPNext Stock Settings - allow_negative = cint( - frappe.db.get_single_value("Stock Settings", "allow_negative_stock") or 0 - ) - if allow_negative: - return False - - # Check POS Settings for the specific profile - if pos_profile: - # Check if POS Settings allows negative stock - pos_settings_allow_negative = cint( - frappe.db.get_value( - "POS Settings", - {"pos_profile": pos_profile}, - "allow_negative_stock" - ) or 0 - ) - if pos_settings_allow_negative: - return False - - # Try to get custom field (may not exist in vanilla ERPNext) - block_sale = cint( - frappe.db.get_value( - "POS Profile", pos_profile, "posa_block_sale_beyond_available_qty" - ) - or 1 - ) - return bool(block_sale) - - # Default to blocking if no profile specified - return True + """Check if sale should be blocked for insufficient stock.""" + # First check global ERPNext Stock Settings + allow_negative = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock") or 0) + if allow_negative: + return False + + # Check POS Settings for the specific profile + if pos_profile: + # Check if POS Settings allows negative stock + pos_settings_allow_negative = cint( + frappe.db.get_value("POS Settings", {"pos_profile": pos_profile}, "allow_negative_stock") or 0 + ) + if pos_settings_allow_negative: + return False + + # Try to get custom field (may not exist in vanilla ERPNext) + block_sale = cint( + frappe.db.get_value("POS Profile", pos_profile, "posa_block_sale_beyond_available_qty") or 1 + ) + return bool(block_sale) + + # Default to blocking if no profile specified + return True def _validate_stock_on_invoice(invoice_doc): - """Validate stock availability before submission.""" - if invoice_doc.doctype == "Sales Invoice" and not cint( - getattr(invoice_doc, "update_stock", 0) - ): - return + """Validate stock availability before submission.""" + if invoice_doc.doctype == "Sales Invoice" and not cint(getattr(invoice_doc, "update_stock", 0)): + return - # Collect all stock items to check - items_to_check = [d.as_dict() for d in invoice_doc.items if d.get("is_stock_item")] + # Collect all stock items to check + items_to_check = [d.as_dict() for d in invoice_doc.items if d.get("is_stock_item")] - # Include packed items if present - if hasattr(invoice_doc, "packed_items"): - items_to_check.extend([d.as_dict() for d in invoice_doc.packed_items]) + # Include packed items if present + if hasattr(invoice_doc, "packed_items"): + items_to_check.extend([d.as_dict() for d in invoice_doc.packed_items]) - # Check for stock errors - errors = _collect_stock_errors(items_to_check) + # Check for stock errors + errors = _collect_stock_errors(items_to_check) - # Throw error if stock insufficient and blocking is enabled - if errors and _should_block(invoice_doc.pos_profile): - frappe.throw(frappe.as_json({"errors": errors}), frappe.ValidationError) + # Throw error if stock insufficient and blocking is enabled + if errors and _should_block(invoice_doc.pos_profile): + frappe.throw(frappe.as_json({"errors": errors}), frappe.ValidationError) def _auto_set_return_batches(invoice_doc): - """Assign batch numbers for return invoices without a source invoice. - - When an item requires a batch number, this function allocates the first - available batch in FIFO order. If no batches exist in the selected - warehouse, an informative error is raised. - """ - if not invoice_doc.get("is_return") or invoice_doc.get("return_against"): - return - - for d in invoice_doc.items: - if not d.get("item_code") or not d.get("warehouse"): - continue - - has_batch = frappe.db.get_value("Item", d.item_code, "has_batch_no") - if has_batch and not d.get("batch_no"): - batch_list = ( - get_batch_qty(item_code=d.item_code, warehouse=d.warehouse) or [] - ) - batch_list = [b for b in batch_list if flt(b.get("qty")) > 0] - - if batch_list: - # FIFO: batches are already sorted by posting/expiry in ERPNext - d.batch_no = batch_list[0].get("batch_no") - else: - frappe.throw( - _("No batches available in {0} for {1}.").format( - d.warehouse, d.item_code - ) - ) + """Assign batch numbers for return invoices without a source invoice. + + When an item requires a batch number, this function allocates the first + available batch in FIFO order. If no batches exist in the selected + warehouse, an informative error is raised. + """ + if not invoice_doc.get("is_return") or invoice_doc.get("return_against"): + return + + for d in invoice_doc.items: + if not d.get("item_code") or not d.get("warehouse"): + continue + + has_batch = frappe.db.get_value("Item", d.item_code, "has_batch_no") + if has_batch and not d.get("batch_no"): + batch_list = get_batch_qty(item_code=d.item_code, warehouse=d.warehouse) or [] + batch_list = [b for b in batch_list if flt(b.get("qty")) > 0] + + if batch_list: + # FIFO: batches are already sorted by posting/expiry in ERPNext + d.batch_no = batch_list[0].get("batch_no") + else: + frappe.throw(_("No batches available in {0} for {1}.").format(d.warehouse, d.item_code)) # ========================================== @@ -559,121 +576,119 @@ def _auto_set_return_batches(invoice_doc): @frappe.whitelist() def validate_cart_items(items, pos_profile=None): - """Validate cart items for available stock. + """Validate cart items for available stock. - Returns a list of item dicts where requested quantity exceeds availability. - This can be used on the front-end for pre-submission checks. - """ - if isinstance(items, str): - items = json.loads(items) + Returns a list of item dicts where requested quantity exceeds availability. + This can be used on the front-end for pre-submission checks. + """ + if isinstance(items, str): + items = json.loads(items) - if pos_profile and not frappe.db.exists("POS Profile", pos_profile): - pos_profile = None + if pos_profile and not frappe.db.exists("POS Profile", pos_profile): + pos_profile = None - if not _should_block(pos_profile): - return [] + if not _should_block(pos_profile): + return [] - errors = _collect_stock_errors(items) - if not errors: - return [] + errors = _collect_stock_errors(items) + if not errors: + return [] - return errors + return errors @frappe.whitelist() def validate_return_items(original_invoice_name, return_items, doctype="Sales Invoice"): - """Ensure that return items do not exceed the quantity from the original invoice. - Also validates return time frame based on POS Settings. - - Uses query builder for parameterized queries. Fetches invoice details, original - item quantities, and already-returned quantities in 3 queries total. - """ - from frappe.utils import date_diff, getdate - from frappe.query_builder.functions import Sum, Abs - - if isinstance(return_items, str): - return_items = json.loads(return_items) - - # Fetch invoice pos_profile and posting_date for validation - si = frappe.qb.DocType(doctype) - invoice_data = ( - frappe.qb.from_(si) - .select(si.pos_profile, si.posting_date) - .where(si.name == original_invoice_name) - ).run(as_dict=True) - - if not invoice_data: - return {"valid": False, "message": _("Invoice {0} not found").format(original_invoice_name)} - - invoice_info = invoice_data[0] - - # Check return validity period from POS Settings - if invoice_info.pos_profile: - return_validity_days = cint( - frappe.db.get_value( - "POS Settings", - {"pos_profile": invoice_info.pos_profile}, - "return_validity_days" - ) or 0 - ) - - if return_validity_days > 0: - days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) - if days_since_invoice > return_validity_days: - return { - "valid": False, - "message": _( - "Return period has expired. Invoice {0} was created {1} days ago. " - "Returns are only allowed within {2} days of purchase." - ).format(original_invoice_name, days_since_invoice, return_validity_days), - } - - # Aggregate original item quantities by item_code - si_item = frappe.qb.DocType(f"{doctype} Item") - original_items = ( - frappe.qb.from_(si_item) - .select(si_item.item_code, Sum(si_item.qty).as_("total_qty")) - .where(si_item.parent == original_invoice_name) - .groupby(si_item.item_code) - ).run(as_dict=True) - - original_item_qty = {item.item_code: flt(item.total_qty) for item in original_items} - - # Aggregate quantities already returned from previous return invoices - ret_si = frappe.qb.DocType(doctype) - ret_item = frappe.qb.DocType(f"{doctype} Item") - - returned_qty_data = ( - frappe.qb.from_(ret_si) - .inner_join(ret_item).on(ret_item.parent == ret_si.name) - .select(ret_item.item_code, Sum(Abs(ret_item.qty)).as_("returned_qty")) - .where( - (ret_si.return_against == original_invoice_name) - & (ret_si.docstatus == 1) - & (ret_si.is_return == 1) - ) - .groupby(ret_item.item_code) - ).run(as_dict=True) - - # Subtract returned quantities - for row in returned_qty_data: - if row.item_code in original_item_qty: - original_item_qty[row.item_code] -= flt(row.returned_qty) - - # Validate new return items - for item in return_items: - item_code = item.get("item_code") - return_qty = abs(flt(item.get("qty", 0))) - remaining = original_item_qty.get(item_code, 0) - if return_qty > remaining: - return { - "valid": False, - "message": _( - "You are trying to return more quantity for item {0} than was sold." - ).format(item_code), - } - - return {"valid": True} + """Ensure that return items do not exceed the quantity from the original invoice. + Also validates return time frame based on POS Settings. + + Uses query builder for parameterized queries. Fetches invoice details, original + item quantities, and already-returned quantities in 3 queries total. + """ + from frappe.query_builder.functions import Abs, Sum + from frappe.utils import date_diff, getdate + + if isinstance(return_items, str): + return_items = json.loads(return_items) + + # Fetch invoice pos_profile and posting_date for validation + si = frappe.qb.DocType(doctype) + invoice_data = ( + frappe.qb.from_(si).select(si.pos_profile, si.posting_date).where(si.name == original_invoice_name) + ).run(as_dict=True) + + if not invoice_data: + return {"valid": False, "message": _("Invoice {0} not found").format(original_invoice_name)} + + invoice_info = invoice_data[0] + + # Check return validity period from POS Settings + if invoice_info.pos_profile: + return_validity_days = cint( + frappe.db.get_value( + "POS Settings", {"pos_profile": invoice_info.pos_profile}, "return_validity_days" + ) + or 0 + ) + + if return_validity_days > 0: + days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) + if days_since_invoice > return_validity_days: + return { + "valid": False, + "message": _( + "Return period has expired. Invoice {0} was created {1} days ago. " + "Returns are only allowed within {2} days of purchase." + ).format(original_invoice_name, days_since_invoice, return_validity_days), + } + + # Aggregate original item quantities by item_code + si_item = frappe.qb.DocType(f"{doctype} Item") + original_items = ( + frappe.qb.from_(si_item) + .select(si_item.item_code, Sum(si_item.qty).as_("total_qty")) + .where(si_item.parent == original_invoice_name) + .groupby(si_item.item_code) + ).run(as_dict=True) + + original_item_qty = {item.item_code: flt(item.total_qty) for item in original_items} + + # Aggregate quantities already returned from previous return invoices + ret_si = frappe.qb.DocType(doctype) + ret_item = frappe.qb.DocType(f"{doctype} Item") + + returned_qty_data = ( + frappe.qb.from_(ret_si) + .inner_join(ret_item) + .on(ret_item.parent == ret_si.name) + .select(ret_item.item_code, Sum(Abs(ret_item.qty)).as_("returned_qty")) + .where( + (ret_si.return_against == original_invoice_name) + & (ret_si.docstatus == 1) + & (ret_si.is_return == 1) + ) + .groupby(ret_item.item_code) + ).run(as_dict=True) + + # Subtract returned quantities + for row in returned_qty_data: + if row.item_code in original_item_qty: + original_item_qty[row.item_code] -= flt(row.returned_qty) + + # Validate new return items + for item in return_items: + item_code = item.get("item_code") + return_qty = abs(flt(item.get("qty", 0))) + remaining = original_item_qty.get(item_code, 0) + if return_qty > remaining: + return { + "valid": False, + "message": _("You are trying to return more quantity for item {0} than was sold.").format( + item_code + ), + } + + return {"valid": True} # ========================================== @@ -683,876 +698,890 @@ def validate_return_items(original_invoice_name, return_items, doctype="Sales In @frappe.whitelist() def update_invoice(data): - """Create or update invoice draft (Step 1).""" - try: - data = json.loads(data) if isinstance(data, str) else data - data = _strip_server_managed_fields(data) - - pos_profile = data.get("pos_profile") - doctype = data.get("doctype", "Sales Invoice") - - # Ensure the document type is set - data.setdefault("doctype", doctype) - - # Normalize pricing_rules before document creation - standardize_pricing_rules(data.get("items")) - - # Create or update invoice - if data.get("name"): - invoice_doc = frappe.get_doc(doctype, data.get("name")) - invoice_doc.update(data) - else: - invoice_doc = frappe.get_doc(data) - - # Important: set before set_missing_values()/pricing/validation paths that may - # read linked docs (e.g., Customer) and trigger controller permission checks. - invoice_doc.flags.ignore_permissions = True - frappe.flags.ignore_account_permission = True - - pos_profile_doc = None - if pos_profile: - try: - pos_profile_doc = frappe.get_cached_doc("POS Profile", pos_profile) - except Exception: - frappe.throw(_("Unable to load POS Profile {0}").format(pos_profile)) - - invoice_doc.pos_profile = pos_profile - - if pos_profile_doc: - if pos_profile_doc.company and not invoice_doc.get("company"): - invoice_doc.company = pos_profile_doc.company - if pos_profile_doc.currency and not invoice_doc.get("currency"): - invoice_doc.currency = pos_profile_doc.currency - - # Copy accounting dimensions from POS Profile - if hasattr(pos_profile_doc, "branch") and pos_profile_doc.branch: - invoice_doc.branch = pos_profile_doc.branch - # Also set branch on all items for GL entries - for item in invoice_doc.get("items", []): - item.branch = pos_profile_doc.branch - - company = invoice_doc.get("company") or ( - pos_profile_doc.company if pos_profile_doc else None - ) - - if company and invoice_doc.get("payments") and doctype == "Sales Invoice": - _set_payment_accounts(invoice_doc.payments, company) - - # Validate return items if this is a return invoice - if (data.get("is_return") or invoice_doc.get("is_return")) and invoice_doc.get( - "return_against" - ): - validation = validate_return_items( - invoice_doc.return_against, - [d.as_dict() for d in invoice_doc.items], - doctype=invoice_doc.doctype, - ) - if not validation.get("valid"): - frappe.throw(validation.get("message")) - - # Ensure customer exists - customer_name = invoice_doc.get("customer") - if customer_name and not frappe.db.exists("Customer", customer_name): - try: - cust = frappe.get_doc( - { - "doctype": "Customer", - "customer_name": customer_name, - "customer_group": "All Customer Groups", - "territory": "All Territories", - "customer_type": "Individual", - } - ) - cust.flags.ignore_permissions = True - cust.insert() - invoice_doc.customer = cust.name - invoice_doc.customer_name = cust.customer_name - except Exception as e: - frappe.log_error(f"Failed to create customer {customer_name}: {e}") - - # Disable automatic pricing rules (we handle discounts manually from POS) - invoice_doc.ignore_pricing_rule = 1 - invoice_doc.flags.ignore_pricing_rule = True - - # ======================================================================== - # OPTIMIZATION: Cache POS Settings to avoid repeated DB queries - # Fetch all needed settings in a single query at the start - # ======================================================================== - pos_settings_cache = None - if pos_profile: - pos_settings_cache = frappe.db.get_value( - DOCTYPE_POS_SETTINGS, - {"pos_profile": pos_profile}, - [ - FIELD_ALLOW_USER_TO_EDIT_RATE, - FIELD_MAX_DISCOUNT_ALLOWED, - FIELD_ALLOW_NEGATIVE_STOCK - ], - as_dict=True - ) - # disable_rounded_total is on POS Profile, not POS Settings - pos_profile_rounded = frappe.db.get_value( - DOCTYPE_POS_PROFILE, - pos_profile, - FIELD_DISABLE_ROUNDED_TOTAL - ) - if pos_settings_cache: - pos_settings_cache[FIELD_DISABLE_ROUNDED_TOTAL] = pos_profile_rounded - else: - pos_settings_cache = {FIELD_DISABLE_ROUNDED_TOTAL: pos_profile_rounded} - - # ======================================================================== - # DISCOUNT CALCULATION - CRITICAL LOGIC - # ======================================================================== - # Frontend sends: rate (discounted), price_list_rate (original), discount_percentage - # Priority: Trust frontend's price_list_rate if provided (avoids rounding errors) - # Fallback: Reverse-calculate price_list_rate from rate and discount_percentage - # - # Formula: rate = price_list_rate * (1 - discount_percentage/100) - # Reverse: price_list_rate = rate / (1 - discount_percentage/100) - # ======================================================================== - for item in invoice_doc.get("items", []): - item_rate = flt(item.rate or 0) - discount_pct = flt(item.discount_percentage or 0) - frontend_price_list_rate = flt(item.get("price_list_rate") or 0) - is_manual_edit = cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED) or 0) - - if is_manual_edit: - # MANUAL RATE EDIT: preserve original price_list_rate for audit - original_rate = flt(item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE) or 0) - if original_rate > 0: - item.price_list_rate = original_rate - - # Validate manual rate edit against business rules (uses cached settings) - validation = validate_manual_rate_edit(item, pos_profile, pos_settings_cache) - if not validation.get("valid"): - frappe.throw(validation.get("message")) - else: - # NORMAL FLOW: Trust frontend's price_list_rate if provided and valid - if frontend_price_list_rate > 0: - item.price_list_rate = frontend_price_list_rate - # Fallback: reverse-calculate if discount exists but no price_list_rate - elif discount_pct > 0 and discount_pct < 100 and item_rate > 0: - item.price_list_rate = calculate_price_list_rate( - item_rate, discount_pct, frontend_price_list_rate - ) - else: - # No discount or price_list_rate - use rate as is - item.price_list_rate = item_rate - - # Ensure price_list_rate is never less than rate (data integrity) - if flt(item.price_list_rate) < item_rate: - item.price_list_rate = item_rate - - # IMPORTANT: Keep the rate from frontend (do NOT set to 0) - # ERPNext will recalculate if needed, but preserving frontend rate - # prevents rounding issues and ensures UI matches invoice - - # POS Next computes offers itself (via apply_offers) and sends each - # item with discount_percentage / discount_amount / rate already set. - # We pair that with invoice_doc.ignore_pricing_rule = 1 so ERPNext's - # own pricing engine stays out of the way. - # - # However, ERPNext's get_pricing_rule_for_item() has a branch that - # fires when ignore_pricing_rule=1 AND the doc already exists in DB - # AND item.pricing_rules is non-empty — it interprets that as the - # user disabling pricing rules on an invoice that previously had - # them, calls remove_pricing_rule_for_item(), and silently zeroes - # discount_percentage / discount_amount / rate on the next save. - # That branch fires on the 2nd save (submit step), producing - # "Partly Paid" invoices where the cashier collected the discounted - # amount but the saved grand_total reverted to the pre-discount - # price. See erpnext/accounts/doctype/pricing_rule/pricing_rule.py - # around line 421. - # - # Clearing item.pricing_rules here avoids that branch entirely. The - # discount itself is preserved via the discount_percentage / - # discount_amount fields we already set above. - if item.get("pricing_rules"): - item.pricing_rules = "" - - # Set invoice flags BEFORE calculations - if doctype == "Sales Invoice": - invoice_doc.is_pos = 1 - invoice_doc.update_stock = 1 - if pos_profile_doc and pos_profile_doc.warehouse: - invoice_doc.set_warehouse = pos_profile_doc.warehouse - - # ======================================================================== - # ROUNDING CONFIGURATION - # ======================================================================== - # Load rounding preference from POS Settings (use cached value) - # When disabled (0): ERPNext rounds to nearest whole number - # When enabled (1): Shows exact amount without rounding - # ======================================================================== - disable_rounded = 1 # Default: disable rounding for POS (show exact amounts) - - if pos_settings_cache and pos_settings_cache.get(FIELD_DISABLE_ROUNDED_TOTAL) is not None: - disable_rounded = cint(pos_settings_cache.get(FIELD_DISABLE_ROUNDED_TOTAL)) - - invoice_doc.disable_rounded_total = disable_rounded - - # ======================================================================== - # POPULATE MISSING FIELDS — using for_validate=True intentionally - # ======================================================================== - # ERPNext's set_missing_values() calls set_pos_fields() internally. - # - # With for_validate=False (the default): - # set_pos_fields() -> update_multi_mode_option() which does: - # 1. doc.set("payments", []) — wipes ALL payment rows - # 2. Rebuilds payments from POS Profile template with amount=0 - # Result: frontend payment amounts are destroyed before the invoice - # is saved, causing invoices to appear unpaid (outstanding = grand_total). - # - # With for_validate=True: - # set_pos_fields() skips update_multi_mode_option() entirely, - # and only fills in missing fields (debit_to, currency, write_off_account, - # cost_center, etc.) without overwriting values already set. - # Payment accounts are set separately via _set_payment_accounts() below. - # - # This is safe on all ERPNext versions because POS Next already sets - # the fields that for_validate=True skips: - # - ignore_pricing_rule → set above (line ~752) - # - customer → sent from frontend - # - tax_category → sent from frontend or not needed - # ======================================================================== - invoice_doc.set_missing_values(for_validate=True) - - # Calculate totals and apply discounts (with rounding disabled) - invoice_doc.calculate_taxes_and_totals() - if invoice_doc.grand_total is None: - invoice_doc.grand_total = 0.0 - if invoice_doc.base_grand_total is None: - invoice_doc.base_grand_total = 0.0 - - # Set accounts for payment methods before saving - _set_payment_accounts(invoice_doc.payments, invoice_doc.company) - - # For return invoices, ensure payments are negative - if invoice_doc.get("is_return"): - # Return handling is primarily for Sales Invoice - if doctype == "Sales Invoice" and invoice_doc.get("payments"): - for payment in invoice_doc.payments: - payment.amount = -abs(payment.amount) - if payment.base_amount: - payment.base_amount = -abs(payment.base_amount) - - invoice_doc.paid_amount = flt(sum(p.amount for p in invoice_doc.payments)) - invoice_doc.base_paid_amount = flt( - sum(p.base_amount or 0 for p in invoice_doc.payments) - ) - - # Validate and track POS Coupon if coupon_code is provided - coupon_code = data.get("coupon_code") - if coupon_code: - # Validate POS Coupon exists and is valid - if frappe.db.table_exists("POS Coupon"): - from pos_next.pos_next.doctype.pos_coupon.pos_coupon import check_coupon_code - - coupon_result = check_coupon_code( - coupon_code, - customer=invoice_doc.customer, - company=invoice_doc.company - ) - - if not coupon_result or not coupon_result.get("valid"): - error_msg = coupon_result.get("msg", "Invalid coupon code") if coupon_result else "Invalid coupon code" - frappe.throw(_(error_msg)) - - # Store coupon code on invoice for tracking - invoice_doc.coupon_code = coupon_code - - # Validate stock availability before saving draft - # is_stock_item may not be set on unsaved doc items (frontend doesn't send it), - # so look it up from Item master - if not invoice_doc.get("is_return") and _should_block(pos_profile): - item_codes = list({d.item_code for d in invoice_doc.items if d.get("item_code")}) - if item_codes: - stock_item_set = set(frappe.get_all( - "Item", - filters={"name": ["in", item_codes], "is_stock_item": 1}, - pluck="name" - )) - stock_items = [ - d.as_dict() for d in invoice_doc.items - if d.get("item_code") in stock_item_set - ] - if stock_items: - errors = _collect_stock_errors(stock_items) - if errors: - frappe.throw(frappe.as_json({"errors": errors}), frappe.ValidationError) - - # Save as draft - invoice_doc.flags.ignore_permissions = True - frappe.flags.ignore_account_permission = True - invoice_doc.docstatus = 0 - invoice_doc.save() - - return invoice_doc.as_dict() - except Exception as e: - frappe.log_error(frappe.get_traceback(), "Update Invoice Error") - raise + """Create or update invoice draft (Step 1).""" + try: + data = json.loads(data) if isinstance(data, str) else data + data = _strip_server_managed_fields(data) + + pos_profile = data.get("pos_profile") + doctype = data.get("doctype", "Sales Invoice") + + # "Pay on Receivable Account": cashier-selected receivable account for the + # invoice's debit_to. Pop it so frappe.get_doc(data) doesn't treat it as a field. + receivable_account = data.pop("receivable_account", None) + + # Ensure the document type is set + data.setdefault("doctype", doctype) + + # Normalize pricing_rules before document creation + standardize_pricing_rules(data.get("items")) + + # Create or update invoice + if data.get("name"): + invoice_doc = frappe.get_doc(doctype, data.get("name")) + invoice_doc.update(data) + else: + invoice_doc = frappe.get_doc(data) + + # Important: set before set_missing_values()/pricing/validation paths that may + # read linked docs (e.g., Customer) and trigger controller permission checks. + invoice_doc.flags.ignore_permissions = True + frappe.flags.ignore_account_permission = True + + pos_profile_doc = None + if pos_profile: + try: + pos_profile_doc = frappe.get_cached_doc("POS Profile", pos_profile) + except Exception: + frappe.throw(_("Unable to load POS Profile {0}").format(pos_profile)) + + invoice_doc.pos_profile = pos_profile + + if pos_profile_doc: + if pos_profile_doc.company and not invoice_doc.get("company"): + invoice_doc.company = pos_profile_doc.company + if pos_profile_doc.currency and not invoice_doc.get("currency"): + invoice_doc.currency = pos_profile_doc.currency + + # Copy accounting dimensions from POS Profile + if hasattr(pos_profile_doc, "branch") and pos_profile_doc.branch: + invoice_doc.branch = pos_profile_doc.branch + # Also set branch on all items for GL entries + for item in invoice_doc.get("items", []): + item.branch = pos_profile_doc.branch + + company = invoice_doc.get("company") or (pos_profile_doc.company if pos_profile_doc else None) + + if company and invoice_doc.get("payments") and doctype == "Sales Invoice": + _set_payment_accounts(invoice_doc.payments, company) + + # Route the invoice's receivable (debit_to) to the chosen AR account. Set before + # save so ERPNext's set_missing_values keeps it (it only defaults debit_to when unset) + # and books the outstanding — with party=Customer — on that account. + if receivable_account and doctype == "Sales Invoice": + _validate_receivable_account(receivable_account, company, pos_profile) + invoice_doc.debit_to = receivable_account + + # Validate return items if this is a return invoice + if (data.get("is_return") or invoice_doc.get("is_return")) and invoice_doc.get("return_against"): + validation = validate_return_items( + invoice_doc.return_against, + [d.as_dict() for d in invoice_doc.items], + doctype=invoice_doc.doctype, + ) + if not validation.get("valid"): + frappe.throw(validation.get("message")) + + # Ensure customer exists + customer_name = invoice_doc.get("customer") + if customer_name and not frappe.db.exists("Customer", customer_name): + try: + cust = frappe.get_doc( + { + "doctype": "Customer", + "customer_name": customer_name, + "customer_group": "All Customer Groups", + "territory": "All Territories", + "customer_type": "Individual", + } + ) + cust.flags.ignore_permissions = True + cust.insert() + invoice_doc.customer = cust.name + invoice_doc.customer_name = cust.customer_name + except Exception as e: + frappe.log_error(f"Failed to create customer {customer_name}: {e}") + + # Disable automatic pricing rules (we handle discounts manually from POS) + invoice_doc.ignore_pricing_rule = 1 + invoice_doc.flags.ignore_pricing_rule = True + + # ======================================================================== + # OPTIMIZATION: Cache POS Settings to avoid repeated DB queries + # Fetch all needed settings in a single query at the start + # ======================================================================== + pos_settings_cache = None + if pos_profile: + pos_settings_cache = frappe.db.get_value( + DOCTYPE_POS_SETTINGS, + {"pos_profile": pos_profile}, + [FIELD_ALLOW_USER_TO_EDIT_RATE, FIELD_MAX_DISCOUNT_ALLOWED, FIELD_ALLOW_NEGATIVE_STOCK], + as_dict=True, + ) + # disable_rounded_total is on POS Profile, not POS Settings + pos_profile_rounded = frappe.db.get_value( + DOCTYPE_POS_PROFILE, pos_profile, FIELD_DISABLE_ROUNDED_TOTAL + ) + if pos_settings_cache: + pos_settings_cache[FIELD_DISABLE_ROUNDED_TOTAL] = pos_profile_rounded + else: + pos_settings_cache = {FIELD_DISABLE_ROUNDED_TOTAL: pos_profile_rounded} + + # ======================================================================== + # DISCOUNT CALCULATION - CRITICAL LOGIC + # ======================================================================== + # Frontend sends: rate (discounted), price_list_rate (original), discount_percentage + # Priority: Trust frontend's price_list_rate if provided (avoids rounding errors) + # Fallback: Reverse-calculate price_list_rate from rate and discount_percentage + # + # Formula: rate = price_list_rate * (1 - discount_percentage/100) + # Reverse: price_list_rate = rate / (1 - discount_percentage/100) + # ======================================================================== + for item in invoice_doc.get("items", []): + item_rate = flt(item.rate or 0) + discount_pct = flt(item.discount_percentage or 0) + frontend_price_list_rate = flt(item.get("price_list_rate") or 0) + is_manual_edit = cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED) or 0) + + if is_manual_edit: + # MANUAL RATE EDIT: preserve original price_list_rate for audit + original_rate = flt(item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE) or 0) + if original_rate > 0: + item.price_list_rate = original_rate + + # Validate manual rate edit against business rules (uses cached settings) + validation = validate_manual_rate_edit(item, pos_profile, pos_settings_cache) + if not validation.get("valid"): + frappe.throw(validation.get("message")) + else: + # NORMAL FLOW: Trust frontend's price_list_rate if provided and valid + if frontend_price_list_rate > 0: + item.price_list_rate = frontend_price_list_rate + # Fallback: reverse-calculate if discount exists but no price_list_rate + elif discount_pct > 0 and discount_pct < 100 and item_rate > 0: + item.price_list_rate = calculate_price_list_rate( + item_rate, discount_pct, frontend_price_list_rate + ) + else: + # No discount or price_list_rate - use rate as is + item.price_list_rate = item_rate + + # Ensure price_list_rate is never less than rate (data integrity) + if flt(item.price_list_rate) < item_rate: + item.price_list_rate = item_rate + + # IMPORTANT: Keep the rate from frontend (do NOT set to 0) + # ERPNext will recalculate if needed, but preserving frontend rate + # prevents rounding issues and ensures UI matches invoice + + # POS Next computes offers itself (via apply_offers) and sends each + # item with discount_percentage / discount_amount / rate already set. + # We pair that with invoice_doc.ignore_pricing_rule = 1 so ERPNext's + # own pricing engine stays out of the way. + # + # However, ERPNext's get_pricing_rule_for_item() has a branch that + # fires when ignore_pricing_rule=1 AND the doc already exists in DB + # AND item.pricing_rules is non-empty — it interprets that as the + # user disabling pricing rules on an invoice that previously had + # them, calls remove_pricing_rule_for_item(), and silently zeroes + # discount_percentage / discount_amount / rate on the next save. + # That branch fires on the 2nd save (submit step), producing + # "Partly Paid" invoices where the cashier collected the discounted + # amount but the saved grand_total reverted to the pre-discount + # price. See erpnext/accounts/doctype/pricing_rule/pricing_rule.py + # around line 421. + # + # Clearing item.pricing_rules here avoids that branch entirely. The + # discount itself is preserved via the discount_percentage / + # discount_amount fields we already set above. + if item.get("pricing_rules"): + item.pricing_rules = "" + + # Set invoice flags BEFORE calculations + if doctype == "Sales Invoice": + invoice_doc.is_pos = 1 + invoice_doc.update_stock = 1 + if pos_profile_doc and pos_profile_doc.warehouse: + invoice_doc.set_warehouse = pos_profile_doc.warehouse + + # ======================================================================== + # ROUNDING CONFIGURATION + # ======================================================================== + # Load rounding preference from POS Settings (use cached value) + # When disabled (0): ERPNext rounds to nearest whole number + # When enabled (1): Shows exact amount without rounding + # ======================================================================== + disable_rounded = 1 # Default: disable rounding for POS (show exact amounts) + + if pos_settings_cache and pos_settings_cache.get(FIELD_DISABLE_ROUNDED_TOTAL) is not None: + disable_rounded = cint(pos_settings_cache.get(FIELD_DISABLE_ROUNDED_TOTAL)) + + invoice_doc.disable_rounded_total = disable_rounded + + # ======================================================================== + # POPULATE MISSING FIELDS — using for_validate=True intentionally + # ======================================================================== + # ERPNext's set_missing_values() calls set_pos_fields() internally. + # + # With for_validate=False (the default): + # set_pos_fields() -> update_multi_mode_option() which does: + # 1. doc.set("payments", []) — wipes ALL payment rows + # 2. Rebuilds payments from POS Profile template with amount=0 + # Result: frontend payment amounts are destroyed before the invoice + # is saved, causing invoices to appear unpaid (outstanding = grand_total). + # + # With for_validate=True: + # set_pos_fields() skips update_multi_mode_option() entirely, + # and only fills in missing fields (debit_to, currency, write_off_account, + # cost_center, etc.) without overwriting values already set. + # Payment accounts are set separately via _set_payment_accounts() below. + # + # This is safe on all ERPNext versions because POS Next already sets + # the fields that for_validate=True skips: + # - ignore_pricing_rule → set above (line ~752) + # - customer → sent from frontend + # - tax_category → sent from frontend or not needed + # ======================================================================== + invoice_doc.set_missing_values(for_validate=True) + + # Calculate totals and apply discounts (with rounding disabled) + invoice_doc.calculate_taxes_and_totals() + if invoice_doc.grand_total is None: + invoice_doc.grand_total = 0.0 + if invoice_doc.base_grand_total is None: + invoice_doc.base_grand_total = 0.0 + + # Set accounts for payment methods before saving + _set_payment_accounts(invoice_doc.payments, invoice_doc.company) + + # For return invoices, ensure payments are negative + if invoice_doc.get("is_return"): + # Return handling is primarily for Sales Invoice + if doctype == "Sales Invoice" and invoice_doc.get("payments"): + for payment in invoice_doc.payments: + payment.amount = -abs(payment.amount) + if payment.base_amount: + payment.base_amount = -abs(payment.base_amount) + + invoice_doc.paid_amount = flt(sum(p.amount for p in invoice_doc.payments)) + invoice_doc.base_paid_amount = flt(sum(p.base_amount or 0 for p in invoice_doc.payments)) + + # Validate and track POS Coupon if coupon_code is provided + coupon_code = data.get("coupon_code") + if coupon_code: + # Validate POS Coupon exists and is valid + if frappe.db.table_exists("POS Coupon"): + from pos_next.pos_next.doctype.pos_coupon.pos_coupon import check_coupon_code + + coupon_result = check_coupon_code( + coupon_code, customer=invoice_doc.customer, company=invoice_doc.company + ) + + if not coupon_result or not coupon_result.get("valid"): + error_msg = ( + coupon_result.get("msg", "Invalid coupon code") + if coupon_result + else "Invalid coupon code" + ) + frappe.throw(_(error_msg)) + + # Store coupon code on invoice for tracking + invoice_doc.coupon_code = coupon_code + + # Validate stock availability before saving draft + # is_stock_item may not be set on unsaved doc items (frontend doesn't send it), + # so look it up from Item master + if not invoice_doc.get("is_return") and _should_block(pos_profile): + item_codes = list({d.item_code for d in invoice_doc.items if d.get("item_code")}) + if item_codes: + stock_item_set = set( + frappe.get_all( + "Item", filters={"name": ["in", item_codes], "is_stock_item": 1}, pluck="name" + ) + ) + stock_items = [d.as_dict() for d in invoice_doc.items if d.get("item_code") in stock_item_set] + if stock_items: + errors = _collect_stock_errors(stock_items) + if errors: + frappe.throw(frappe.as_json({"errors": errors}), frappe.ValidationError) + + # Save as draft + invoice_doc.flags.ignore_permissions = True + frappe.flags.ignore_account_permission = True + invoice_doc.docstatus = 0 + invoice_doc.save() + + return invoice_doc.as_dict() + except Exception as e: + frappe.log_error(frappe.get_traceback(), "Update Invoice Error") + raise PENDING_TIMEOUT_MINUTES = 5 # Pending records older than this are considered stale def _is_pending_expired(modified_time): - """Check if a pending record has expired based on modified time.""" - if not modified_time: - return True # No timestamp means treat as expired - age_minutes = (frappe.utils.now_datetime() - modified_time).total_seconds() / 60 - return age_minutes > PENDING_TIMEOUT_MINUTES + """Check if a pending record has expired based on modified time.""" + if not modified_time: + return True # No timestamp means treat as expired + age_minutes = (frappe.utils.now_datetime() - modified_time).total_seconds() / 60 + return age_minutes > PENDING_TIMEOUT_MINUTES def _reuse_sync_record(sync_record_name): - """Reset an existing sync record to Pending status for retry.""" - sync_doc = frappe.get_doc("Offline Invoice Sync", sync_record_name) - sync_doc.status = "Pending" - sync_doc.synced_at = None - sync_doc.flags.ignore_permissions = True - sync_doc.save() - return {"already_synced": False, "sync_record_name": sync_record_name} + """Reset an existing sync record to Pending status for retry.""" + sync_doc = frappe.get_doc("Offline Invoice Sync", sync_record_name) + sync_doc.status = "Pending" + sync_doc.synced_at = None + sync_doc.flags.ignore_permissions = True + sync_doc.save() + return {"already_synced": False, "sync_record_name": sync_record_name} def _ensure_offline_uniqueness(offline_id, pos_profile=None, customer=None): - """ - Ensure offline invoice uniqueness with race condition protection. - - Uses a reservation pattern: - 1. Check if a sync record exists (with row-level lock) - 2. If synced with valid invoice, return existing invoice - 3. If synced but invoice deleted/invalid, allow retry - 4. If pending but expired (>5 min), allow retry - 5. If pending and active, reject (another request processing) - 6. If failed, allow retry - 7. If not exists, create pending reservation - - Args: - offline_id: The unique offline ID from the client - pos_profile: POS Profile name - customer: Customer name - - Returns: - dict with: - - already_synced (bool): True if invoice was already synced - - invoice_data (dict): Existing invoice data if already_synced - - sync_record_name (str): Name of the sync record for this attempt - """ - # Acquire row-level lock to prevent race conditions - existing_sync = frappe.db.get_value( - "Offline Invoice Sync", - {"offline_id": offline_id}, - ["name", "sales_invoice", "status", "modified"], - as_dict=True, - for_update=True - ) - - if existing_sync: - sync_status = existing_sync.get("status") - sync_record_name = existing_sync.name - - # Handle Pending status - if sync_status == "Pending": - if _is_pending_expired(existing_sync.get("modified")): - # Expired pending - allow retry - return _reuse_sync_record(sync_record_name) - else: - # Active pending - reject with specific error code - frappe.throw( - _("This invoice is currently being processed. Please wait."), - exc=frappe.ValidationError, - title="SYNC_IN_PROGRESS" - ) - - # Handle Failed status - allow retry - if sync_status == "Failed": - return _reuse_sync_record(sync_record_name) - - # Handle Synced status - verify invoice still valid - if sync_status == "Synced" and existing_sync.sales_invoice: - if frappe.db.exists("Sales Invoice", existing_sync.sales_invoice): - existing_invoice = frappe.get_doc("Sales Invoice", existing_sync.sales_invoice) - if existing_invoice.docstatus == 1: - return { - "already_synced": True, - "invoice_data": { - "name": existing_invoice.name, - "status": existing_invoice.docstatus, - "grand_total": existing_invoice.grand_total, - "total": existing_invoice.total, - "net_total": existing_invoice.net_total, - "outstanding_amount": getattr(existing_invoice, "outstanding_amount", 0), - "paid_amount": getattr(existing_invoice, "paid_amount", 0), - "change_amount": getattr(existing_invoice, "change_amount", 0), - "duplicate_prevented": True, - "offline_id": offline_id, - } - } - - # Synced record points to deleted/invalid invoice - allow retry - return _reuse_sync_record(sync_record_name) - - # Unknown status or synced without invoice - allow retry - return _reuse_sync_record(sync_record_name) - - # No existing record - create pending reservation - try: - pending_sync = frappe.get_doc({ - "doctype": "Offline Invoice Sync", - "offline_id": offline_id, - "sales_invoice": "", - "pos_profile": pos_profile, - "customer": customer, - "status": "Pending", - }) - pending_sync.flags.ignore_permissions = True - pending_sync.insert() - - return { - "already_synced": False, - "sync_record_name": pending_sync.name - } - except frappe.DuplicateEntryError: - # Race condition: another request just created the record - # Retry the check to get the new record - return _ensure_offline_uniqueness(offline_id, pos_profile, customer) + """ + Ensure offline invoice uniqueness with race condition protection. + + Uses a reservation pattern: + 1. Check if a sync record exists (with row-level lock) + 2. If synced with valid invoice, return existing invoice + 3. If synced but invoice deleted/invalid, allow retry + 4. If pending but expired (>5 min), allow retry + 5. If pending and active, reject (another request processing) + 6. If failed, allow retry + 7. If not exists, create pending reservation + + Args: + offline_id: The unique offline ID from the client + pos_profile: POS Profile name + customer: Customer name + + Returns: + dict with: + - already_synced (bool): True if invoice was already synced + - invoice_data (dict): Existing invoice data if already_synced + - sync_record_name (str): Name of the sync record for this attempt + """ + # Acquire row-level lock to prevent race conditions + existing_sync = frappe.db.get_value( + "Offline Invoice Sync", + {"offline_id": offline_id}, + ["name", "sales_invoice", "status", "modified"], + as_dict=True, + for_update=True, + ) + + if existing_sync: + sync_status = existing_sync.get("status") + sync_record_name = existing_sync.name + + # Handle Pending status + if sync_status == "Pending": + if _is_pending_expired(existing_sync.get("modified")): + # Expired pending - allow retry + return _reuse_sync_record(sync_record_name) + else: + # Active pending - reject with specific error code + frappe.throw( + _("This invoice is currently being processed. Please wait."), + exc=frappe.ValidationError, + title="SYNC_IN_PROGRESS", + ) + + # Handle Failed status - allow retry + if sync_status == "Failed": + return _reuse_sync_record(sync_record_name) + + # Handle Synced status - verify invoice still valid + if sync_status == "Synced" and existing_sync.sales_invoice: + if frappe.db.exists("Sales Invoice", existing_sync.sales_invoice): + existing_invoice = frappe.get_doc("Sales Invoice", existing_sync.sales_invoice) + if existing_invoice.docstatus == 1: + return { + "already_synced": True, + "invoice_data": { + "name": existing_invoice.name, + "status": existing_invoice.docstatus, + "grand_total": existing_invoice.grand_total, + "total": existing_invoice.total, + "net_total": existing_invoice.net_total, + "outstanding_amount": getattr(existing_invoice, "outstanding_amount", 0), + "paid_amount": getattr(existing_invoice, "paid_amount", 0), + "change_amount": getattr(existing_invoice, "change_amount", 0), + "duplicate_prevented": True, + "offline_id": offline_id, + }, + } + + # Synced record points to deleted/invalid invoice - allow retry + return _reuse_sync_record(sync_record_name) + + # Unknown status or synced without invoice - allow retry + return _reuse_sync_record(sync_record_name) + + # No existing record - create pending reservation + try: + pending_sync = frappe.get_doc( + { + "doctype": "Offline Invoice Sync", + "offline_id": offline_id, + "sales_invoice": "", + "pos_profile": pos_profile, + "customer": customer, + "status": "Pending", + } + ) + pending_sync.flags.ignore_permissions = True + pending_sync.insert() + + return {"already_synced": False, "sync_record_name": pending_sync.name} + except frappe.DuplicateEntryError: + # Race condition: another request just created the record + # Retry the check to get the new record + return _ensure_offline_uniqueness(offline_id, pos_profile, customer) def _complete_offline_sync(sync_record_name, invoice_name): - """ - Mark an offline sync record as completed after successful invoice submission. - - Args: - sync_record_name: Name of the Offline Invoice Sync record - invoice_name: Name of the submitted Sales Invoice - """ - if not sync_record_name: - return - - try: - sync_doc = frappe.get_doc("Offline Invoice Sync", sync_record_name) - sync_doc.sales_invoice = invoice_name - sync_doc.status = "Synced" - sync_doc.synced_at = frappe.utils.now_datetime() - sync_doc.flags.ignore_permissions = True - sync_doc.save() - except Exception as error: - frappe.log_error( - title="Offline Sync Completion Error", - message=f"Failed to complete sync record {sync_record_name} for invoice {invoice_name}: {str(error)}" - ) + """ + Mark an offline sync record as completed after successful invoice submission. + + Args: + sync_record_name: Name of the Offline Invoice Sync record + invoice_name: Name of the submitted Sales Invoice + """ + if not sync_record_name: + return + + try: + sync_doc = frappe.get_doc("Offline Invoice Sync", sync_record_name) + sync_doc.sales_invoice = invoice_name + sync_doc.status = "Synced" + sync_doc.synced_at = frappe.utils.now_datetime() + sync_doc.flags.ignore_permissions = True + sync_doc.save() + except Exception as error: + frappe.log_error( + title="Offline Sync Completion Error", + message=f"Failed to complete sync record {sync_record_name} for invoice {invoice_name}: {str(error)}", + ) def _cleanup_failed_sync(sync_record_name): - """ - Mark a sync record as failed when invoice submission fails. - - Instead of deleting, we mark as 'failed' to: - 1. Preserve audit trail of sync attempts - 2. Allow manual investigation of failures - 3. Enable retry logic based on failure count - - Args: - sync_record_name: Name of the Offline Invoice Sync record - """ - if not sync_record_name: - return - - try: - sync_doc = frappe.get_doc("Offline Invoice Sync", sync_record_name) - sync_doc.status = "Failed" - sync_doc.synced_at = frappe.utils.now_datetime() - sync_doc.flags.ignore_permissions = True - sync_doc.save() - except Exception as error: - frappe.log_error( - title="Offline Sync Cleanup Error", - message=f"Failed to mark sync record {sync_record_name} as failed: {str(error)}" - ) + """ + Mark a sync record as failed when invoice submission fails. + + Instead of deleting, we mark as 'failed' to: + 1. Preserve audit trail of sync attempts + 2. Allow manual investigation of failures + 3. Enable retry logic based on failure count + + Args: + sync_record_name: Name of the Offline Invoice Sync record + """ + if not sync_record_name: + return + + try: + sync_doc = frappe.get_doc("Offline Invoice Sync", sync_record_name) + sync_doc.status = "Failed" + sync_doc.synced_at = frappe.utils.now_datetime() + sync_doc.flags.ignore_permissions = True + sync_doc.save() + except Exception as error: + frappe.log_error( + title="Offline Sync Cleanup Error", + message=f"Failed to mark sync record {sync_record_name} as failed: {str(error)}", + ) @frappe.whitelist() def check_offline_invoice_synced(offline_id): - """ - Check if an offline invoice has already been synced. + """ + Check if an offline invoice has already been synced. - This endpoint is called by the frontend before attempting to sync - an offline invoice, preventing duplicate submissions. + This endpoint is called by the frontend before attempting to sync + an offline invoice, preventing duplicate submissions. - Args: - offline_id: The unique offline ID to check + Args: + offline_id: The unique offline ID to check - Returns: - dict with 'synced' (bool) and 'sales_invoice' (str or None) - """ - from pos_next.pos_next.doctype.offline_invoice_sync.offline_invoice_sync import ( - OfflineInvoiceSync, - ) + Returns: + dict with 'synced' (bool) and 'sales_invoice' (str or None) + """ + from pos_next.pos_next.doctype.offline_invoice_sync.offline_invoice_sync import ( + OfflineInvoiceSync, + ) - result = OfflineInvoiceSync.is_synced(offline_id) + result = OfflineInvoiceSync.is_synced(offline_id) - # Defensive check - ensure result is a dict - if not result or not isinstance(result, dict): - return {"synced": False, "sales_invoice": None} + # Defensive check - ensure result is a dict + if not result or not isinstance(result, dict): + return {"synced": False, "sales_invoice": None} - # Additionally verify the sales invoice still exists and is submitted - if result.get("synced") and result.get("sales_invoice"): - if frappe.db.exists("Sales Invoice", result["sales_invoice"]): - docstatus = frappe.db.get_value( - "Sales Invoice", result["sales_invoice"], "docstatus" - ) - if docstatus == 1: # Submitted - return result + # Additionally verify the sales invoice still exists and is submitted + if result.get("synced") and result.get("sales_invoice"): + if frappe.db.exists("Sales Invoice", result["sales_invoice"]): + docstatus = frappe.db.get_value("Sales Invoice", result["sales_invoice"], "docstatus") + if docstatus == 1: # Submitted + return result - # Invoice was deleted or not submitted, clear the sync record - return {"synced": False, "sales_invoice": None} + # Invoice was deleted or not submitted, clear the sync record + return {"synced": False, "sales_invoice": None} - return result + return result @frappe.whitelist() def submit_invoice(invoice=None, data=None): - """Submit the invoice (Step 2).""" - # Handle different calling conventions - if invoice is None: - if data: - # Check if data is a JSON string containing both params - data_parsed = json.loads(data) if isinstance(data, str) else data - - # frappe-ui might send all params nested in data - if isinstance(data_parsed, dict): - if "invoice" in data_parsed: - invoice = data_parsed.get("invoice") - data = data_parsed.get("data", {}) - elif "name" in data_parsed or "doctype" in data_parsed: - # Data itself might be the invoice - invoice = data_parsed - data = {} - else: - frappe.throw( - _("Missing invoice parameter. Received data: {0}").format( - json.dumps(data_parsed, default=str) - ) - ) - else: - frappe.throw(_("Missing invoice parameter")) - else: - frappe.throw(_("Both invoice and data parameters are missing")) - - # Parse JSON strings if needed - if isinstance(data, str): - data = json.loads(data) if data and data != "{}" else {} - if isinstance(invoice, str): - invoice = json.loads(invoice) - - # Ensure invoice and data are dicts - if not isinstance(invoice, dict): - frappe.throw(_("Invalid invoice format")) - return # Never reached, but helps type checker - if not isinstance(data, dict): - data = {} - - invoice = _strip_server_managed_fields(invoice) - - pos_profile = invoice.get("pos_profile") - doctype = invoice.get("doctype", "Sales Invoice") - - # Normalize pricing_rules before processing - standardize_pricing_rules(invoice.get("items")) - - # ======================================================================== - # OFFLINE INVOICE DEDUPLICATION - # ======================================================================== - # Prevents duplicate invoice creation when the same offline invoice is - # submitted multiple times (e.g., network retry, multiple tabs). - # Uses a reservation pattern: create a "pending" record first, then - # update to "synced" after successful submission. - # ======================================================================== - offline_id = invoice.get("offline_id") or data.get("offline_id") - sync_record_name = None - - if offline_id: - dedup_result = _ensure_offline_uniqueness( - offline_id=offline_id, - pos_profile=pos_profile, - customer=invoice.get("customer") - ) - - if dedup_result and dedup_result.get("already_synced"): - # Invoice was already synced - return the existing invoice details - return dedup_result.get("invoice_data", {}) - - # Store the sync record name for later update - sync_record_name = dedup_result.get("sync_record_name") if dedup_result else None - - # Track whether invoice was successfully submitted - invoice_submitted = False - - try: - invoice_name = invoice.get("name") - - # Get or create invoice - if not invoice_name or not frappe.db.exists(doctype, invoice_name): - created = update_invoice(json.dumps(invoice)) - if not created or not isinstance(created, dict): - frappe.throw(_("Failed to create invoice draft")) - invoice_name = created.get("name") - if not invoice_name: - frappe.throw(_("Failed to get invoice name from draft")) - invoice_doc = frappe.get_doc(doctype, invoice_name) - else: - invoice_doc = frappe.get_doc(doctype, invoice_name) - invoice_doc.update(invoice) - - # Keep permission bypass consistent for POS API flow. - invoice_doc.flags.ignore_permissions = True - frappe.flags.ignore_account_permission = True - - # Ensure update_stock is set for Sales Invoice - if doctype == "Sales Invoice": - invoice_doc.update_stock = 1 - - # For return invoices, set update_outstanding_for_self = 0 - # This ensures the GL entry's against_voucher points to the original invoice, - # which properly reduces the original invoice's outstanding amount and - # sets its status to "Credit Note Issued" - if invoice_doc.get("is_return") and invoice_doc.get("return_against"): - invoice_doc.update_outstanding_for_self = 0 - - # Copy accounting dimensions from POS Profile if not already set - if pos_profile and not invoice_doc.get("branch"): - try: - pos_profile_doc = frappe.get_cached_doc("POS Profile", pos_profile) - if hasattr(pos_profile_doc, "branch") and pos_profile_doc.branch: - invoice_doc.branch = pos_profile_doc.branch - # Also set branch on all items for GL entries - for item in invoice_doc.get("items", []): - if not item.get("branch"): - item.branch = pos_profile_doc.branch - except Exception as e: - # Branch is optional, log and continue - frappe.log_error( - f"Failed to set branch from POS Profile {pos_profile}: {e}", - "POS Profile Branch" - ) - - # Set accounts for all payment methods before saving - if doctype == "Sales Invoice" and hasattr(invoice_doc, "payments"): - _set_payment_accounts(invoice_doc.payments, invoice_doc.company) - - # Handle sales team (multiple sales persons) - sales_team_data = invoice.get("sales_team") or data.get("sales_team") - if sales_team_data and isinstance(sales_team_data, list): - # Clear existing sales team entries - invoice_doc.sales_team = [] - - # Add new sales team entries - for member in sales_team_data: - if member and isinstance(member, dict): - invoice_doc.append("sales_team", { - "sales_person": member.get("sales_person"), - "allocated_percentage": member.get("allocated_percentage", 0), - }) - - # Handle POS Coupon if coupon_code is provided - coupon_code = invoice.get("coupon_code") or data.get("coupon_code") - if coupon_code: - # Increment usage counter for POS Coupon - if frappe.db.table_exists("POS Coupon"): - try: - from pos_next.pos_next.doctype.pos_coupon.pos_coupon import increment_coupon_usage - increment_coupon_usage(coupon_code) - except Exception as e: - frappe.log_error( - title="Failed to increment coupon usage", - message=f"Coupon: {coupon_code}, Error: {str(e)}" - ) - - # Auto-set batch numbers for returns - _auto_set_return_batches(invoice_doc) - - # Handle write-off amount if provided - write_off_amount = flt(data.get("write_off_amount") or invoice.get("write_off_amount") or 0) - if write_off_amount > 0 and doctype == "Sales Invoice": - # Get write-off account and cost center from POS Profile - if pos_profile: - try: - pos_profile_doc = frappe.get_cached_doc("POS Profile", pos_profile) - write_off_account = pos_profile_doc.write_off_account - write_off_cost_center = pos_profile_doc.write_off_cost_center - write_off_limit = flt(pos_profile_doc.write_off_limit or 0) - - # Validate write-off amount is within limit - if write_off_limit > 0 and write_off_amount > write_off_limit: - frappe.throw( - _("Write-off amount {0} exceeds limit {1}").format( - write_off_amount, write_off_limit - ) - ) - - # Set write-off fields on invoice - if write_off_account: - invoice_doc.write_off_account = write_off_account - invoice_doc.write_off_cost_center = write_off_cost_center - invoice_doc.write_off_amount = write_off_amount - invoice_doc.base_write_off_amount = write_off_amount # Assuming same currency - except Exception as e: - frappe.log_error( - f"Failed to apply write-off from POS Profile {pos_profile}: {e}", - "POS Write-Off Error" - ) - - # Validate stock availability before submission - # _validate_stock_on_invoice checks _should_block internally - # (global Stock Settings, POS Settings, and POS Profile flags) - _validate_stock_on_invoice(invoice_doc) - - # Allow pure customer-credit POS sales to submit without a payment row. - customer_credit_dict = data.get("customer_credit_dict") or invoice.get("customer_credit_dict") - redeemed_customer_credit = data.get("redeemed_customer_credit") or invoice.get("redeemed_customer_credit") - if redeemed_customer_credit and not invoice_doc.payments: - invoice_doc.flags.pos_next_redeemed_customer_credit = flt(redeemed_customer_credit) - - # Allow intentional "Pay on Account" credit sales to submit without a - # payment row. The frontend sends is_credit_sale=1 when the cashier puts - # the full amount on the customer's account. Only honour it when the POS - # Settings for this profile actually permit credit sales, so a tampered - # client can't bypass the core payment-row requirement. - is_credit_sale = cint(data.get("is_credit_sale") or invoice.get("is_credit_sale")) - if ( - is_credit_sale - and not invoice_doc.payments - and flt(invoice_doc.grand_total) > 0 - ): - allow_credit_sale = cint( - frappe.db.get_value( - DOCTYPE_POS_SETTINGS, {"pos_profile": pos_profile}, "allow_credit_sale" - ) - ) - if not allow_credit_sale: - frappe.throw(_("Credit sales are not enabled for this POS Profile.")) - invoice_doc.flags.pos_next_credit_sale = 1 - - # Save before submit - invoice_doc.flags.ignore_permissions = True - frappe.flags.ignore_account_permission = True - invoice_doc.save() - - # Submit invoice - invoice_doc.submit() - invoice_submitted = True - # Handle wallet transaction reversal for returns - wallet_reversal_ok = False - if invoice_doc.get("is_return") and invoice_doc.get("return_against"): - from pos_next.pos_next.doctype.wallet_transaction.wallet_transaction import reverse_wallet_transactions_for_return - try: - reverse_wallet_transactions_for_return( - original_invoice=invoice_doc.return_against, - return_invoice=invoice_doc.name - ) - wallet_reversal_ok = True - except Exception as wallet_reversal_error: - frappe.log_error( - title="Wallet Reversal Error", - message=( - f"Return invoice: {invoice_doc.name}, " - f"Original invoice: {invoice_doc.return_against}, " - f"Error: {str(wallet_reversal_error)}\n{frappe.get_traceback()}" - ) - ) - frappe.msgprint( - _("Return invoice submitted successfully, but wallet reversal failed. Please contact administrator."), - alert=True, - indicator="orange" - ) - - # Credit return amount to customer wallet when "Add to Customer Credit Balance" is enabled. - # Only proceed if the wallet reversal above succeeded (or was not needed) to - # avoid double-crediting the customer when reversal fails. - if invoice_doc.get("is_return"): - add_to_customer_balance = invoice.get("add_to_customer_balance") - has_return_against = bool(invoice_doc.get("return_against")) - if add_to_customer_balance and (wallet_reversal_ok or not has_return_against): - from pos_next.pos_next.doctype.wallet_transaction.wallet_transaction import credit_return_to_wallet - try: - credit_return_to_wallet( - return_invoice=invoice_doc.name, - amount=abs(flt(invoice_doc.grand_total)) - ) - except Exception as wallet_credit_error: - frappe.log_error( - title="Wallet Credit on Return Error", - message=( - f"Return invoice: {invoice_doc.name}, " - f"Error: {str(wallet_credit_error)}\n{frappe.get_traceback()}" - ) - ) - frappe.msgprint( - _("Return submitted but wallet credit failed. Please contact administrator."), - alert=True, - indicator="orange" - ) - # Complete the offline sync record - if sync_record_name: - _complete_offline_sync(sync_record_name, invoice_doc.name) - - # Handle credit redemption after successful submission - if redeemed_customer_credit and customer_credit_dict: - try: - from pos_next.api.credit_sales import redeem_customer_credit - redeem_customer_credit(invoice_doc.name, customer_credit_dict) - except Exception as credit_error: - frappe.log_error( - title="Credit Redemption Error", - message=f"Invoice: {invoice_doc.name}, Error: {str(credit_error)}\n{frappe.get_traceback()}" - ) - # Don't fail the entire transaction, just log the error - frappe.msgprint( - _("Invoice submitted successfully but credit redemption failed. Please contact administrator."), - alert=True, - indicator="orange" - ) - - # Log manual rate edits for audit trail (only after successful submission) - if doctype == DOCTYPE_SALES_INVOICE: - incoming_items = invoice.get("items") or [] - for item in incoming_items: - if cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED)): - log_manual_rate_edit({ - FIELD_ITEM_CODE: item.get(FIELD_ITEM_CODE), - "item_name": item.get("item_name"), - FIELD_RATE: flt(item.get(FIELD_RATE)), - FIELD_ORIGINAL_RATE: flt(item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE)), - FIELD_IS_RATE_MANUALLY_EDITED: 1 - }, invoice_doc.name) - - # Return complete invoice details - result = { - "name": invoice_doc.name, - "status": invoice_doc.docstatus, - "grand_total": invoice_doc.grand_total, - "total": invoice_doc.total, - "net_total": invoice_doc.net_total, - "outstanding_amount": getattr(invoice_doc, "outstanding_amount", 0), - "paid_amount": getattr(invoice_doc, "paid_amount", 0), - "change_amount": getattr(invoice_doc, "change_amount", 0), - } - - # Include offline_id in response for client-side tracking - if offline_id: - result["offline_id"] = offline_id - - return result - - except Exception as e: - frappe.log_error(frappe.get_traceback(), "Submit Invoice Error") - raise - - finally: - # Cleanup sync record if invoice was not successfully submitted - if sync_record_name and not invoice_submitted: - _cleanup_failed_sync(sync_record_name) + """Submit the invoice (Step 2).""" + # Handle different calling conventions + if invoice is None: + if data: + # Check if data is a JSON string containing both params + data_parsed = json.loads(data) if isinstance(data, str) else data + + # frappe-ui might send all params nested in data + if isinstance(data_parsed, dict): + if "invoice" in data_parsed: + invoice = data_parsed.get("invoice") + data = data_parsed.get("data", {}) + elif "name" in data_parsed or "doctype" in data_parsed: + # Data itself might be the invoice + invoice = data_parsed + data = {} + else: + frappe.throw( + _("Missing invoice parameter. Received data: {0}").format( + json.dumps(data_parsed, default=str) + ) + ) + else: + frappe.throw(_("Missing invoice parameter")) + else: + frappe.throw(_("Both invoice and data parameters are missing")) + + # Parse JSON strings if needed + if isinstance(data, str): + data = json.loads(data) if data and data != "{}" else {} + if isinstance(invoice, str): + invoice = json.loads(invoice) + + # Ensure invoice and data are dicts + if not isinstance(invoice, dict): + frappe.throw(_("Invalid invoice format")) + return # Never reached, but helps type checker + if not isinstance(data, dict): + data = {} + + invoice = _strip_server_managed_fields(invoice) + + pos_profile = invoice.get("pos_profile") + doctype = invoice.get("doctype", "Sales Invoice") + + # Normalize pricing_rules before processing + standardize_pricing_rules(invoice.get("items")) + + # ======================================================================== + # OFFLINE INVOICE DEDUPLICATION + # ======================================================================== + # Prevents duplicate invoice creation when the same offline invoice is + # submitted multiple times (e.g., network retry, multiple tabs). + # Uses a reservation pattern: create a "pending" record first, then + # update to "synced" after successful submission. + # ======================================================================== + offline_id = invoice.get("offline_id") or data.get("offline_id") + sync_record_name = None + + if offline_id: + dedup_result = _ensure_offline_uniqueness( + offline_id=offline_id, pos_profile=pos_profile, customer=invoice.get("customer") + ) + + if dedup_result and dedup_result.get("already_synced"): + # Invoice was already synced - return the existing invoice details + return dedup_result.get("invoice_data", {}) + + # Store the sync record name for later update + sync_record_name = dedup_result.get("sync_record_name") if dedup_result else None + + # Track whether invoice was successfully submitted + invoice_submitted = False + + try: + invoice_name = invoice.get("name") + + # Get or create invoice + if not invoice_name or not frappe.db.exists(doctype, invoice_name): + created = update_invoice(json.dumps(invoice)) + if not created or not isinstance(created, dict): + frappe.throw(_("Failed to create invoice draft")) + invoice_name = created.get("name") + if not invoice_name: + frappe.throw(_("Failed to get invoice name from draft")) + invoice_doc = frappe.get_doc(doctype, invoice_name) + else: + invoice_doc = frappe.get_doc(doctype, invoice_name) + invoice_doc.update(invoice) + + # Keep permission bypass consistent for POS API flow. + invoice_doc.flags.ignore_permissions = True + frappe.flags.ignore_account_permission = True + + # Ensure update_stock is set for Sales Invoice + if doctype == "Sales Invoice": + invoice_doc.update_stock = 1 + + # For return invoices, set update_outstanding_for_self = 0 + # This ensures the GL entry's against_voucher points to the original invoice, + # which properly reduces the original invoice's outstanding amount and + # sets its status to "Credit Note Issued" + if invoice_doc.get("is_return") and invoice_doc.get("return_against"): + invoice_doc.update_outstanding_for_self = 0 + + # Copy accounting dimensions from POS Profile if not already set + if pos_profile and not invoice_doc.get("branch"): + try: + pos_profile_doc = frappe.get_cached_doc("POS Profile", pos_profile) + if hasattr(pos_profile_doc, "branch") and pos_profile_doc.branch: + invoice_doc.branch = pos_profile_doc.branch + # Also set branch on all items for GL entries + for item in invoice_doc.get("items", []): + if not item.get("branch"): + item.branch = pos_profile_doc.branch + except Exception as e: + # Branch is optional, log and continue + frappe.log_error( + f"Failed to set branch from POS Profile {pos_profile}: {e}", "POS Profile Branch" + ) + + # Set accounts for all payment methods before saving + if doctype == "Sales Invoice" and hasattr(invoice_doc, "payments"): + _set_payment_accounts(invoice_doc.payments, invoice_doc.company) + + # "Pay on Receivable Account": defensively re-apply the chosen receivable account + # here too, covering offline-sync and edit paths that submit a previously saved + # draft. update_invoice already validates/sets it for the normal online flow. + receivable_account = data.get("receivable_account") or invoice.get("receivable_account") + if receivable_account and doctype == "Sales Invoice": + _validate_receivable_account(receivable_account, invoice_doc.company, pos_profile) + invoice_doc.debit_to = receivable_account + + # Handle sales team (multiple sales persons) + sales_team_data = invoice.get("sales_team") or data.get("sales_team") + if sales_team_data and isinstance(sales_team_data, list): + # Clear existing sales team entries + invoice_doc.sales_team = [] + + # Add new sales team entries + for member in sales_team_data: + if member and isinstance(member, dict): + invoice_doc.append( + "sales_team", + { + "sales_person": member.get("sales_person"), + "allocated_percentage": member.get("allocated_percentage", 0), + }, + ) + + # Handle POS Coupon if coupon_code is provided + coupon_code = invoice.get("coupon_code") or data.get("coupon_code") + if coupon_code: + # Increment usage counter for POS Coupon + if frappe.db.table_exists("POS Coupon"): + try: + from pos_next.pos_next.doctype.pos_coupon.pos_coupon import increment_coupon_usage + + increment_coupon_usage(coupon_code) + except Exception as e: + frappe.log_error( + title="Failed to increment coupon usage", + message=f"Coupon: {coupon_code}, Error: {str(e)}", + ) + + # Auto-set batch numbers for returns + _auto_set_return_batches(invoice_doc) + + # Handle write-off amount if provided + write_off_amount = flt(data.get("write_off_amount") or invoice.get("write_off_amount") or 0) + if write_off_amount > 0 and doctype == "Sales Invoice": + # Get write-off account and cost center from POS Profile + if pos_profile: + try: + pos_profile_doc = frappe.get_cached_doc("POS Profile", pos_profile) + write_off_account = pos_profile_doc.write_off_account + write_off_cost_center = pos_profile_doc.write_off_cost_center + write_off_limit = flt(pos_profile_doc.write_off_limit or 0) + + # Validate write-off amount is within limit + if write_off_limit > 0 and write_off_amount > write_off_limit: + frappe.throw( + _("Write-off amount {0} exceeds limit {1}").format( + write_off_amount, write_off_limit + ) + ) + + # Set write-off fields on invoice + if write_off_account: + invoice_doc.write_off_account = write_off_account + invoice_doc.write_off_cost_center = write_off_cost_center + invoice_doc.write_off_amount = write_off_amount + invoice_doc.base_write_off_amount = write_off_amount # Assuming same currency + except Exception as e: + frappe.log_error( + f"Failed to apply write-off from POS Profile {pos_profile}: {e}", + "POS Write-Off Error", + ) + + # Validate stock availability before submission + # _validate_stock_on_invoice checks _should_block internally + # (global Stock Settings, POS Settings, and POS Profile flags) + _validate_stock_on_invoice(invoice_doc) + + # Allow pure customer-credit POS sales to submit without a payment row. + customer_credit_dict = data.get("customer_credit_dict") or invoice.get("customer_credit_dict") + redeemed_customer_credit = data.get("redeemed_customer_credit") or invoice.get( + "redeemed_customer_credit" + ) + if redeemed_customer_credit and not invoice_doc.payments: + invoice_doc.flags.pos_next_redeemed_customer_credit = flt(redeemed_customer_credit) + + # Allow intentional "Pay on Account" credit sales to submit without a + # payment row. The frontend sends is_credit_sale=1 when the cashier puts + # the full amount on the customer's account. Only honour it when the POS + # Settings for this profile actually permit credit sales, so a tampered + # client can't bypass the core payment-row requirement. + is_credit_sale = cint(data.get("is_credit_sale") or invoice.get("is_credit_sale")) + if is_credit_sale and not invoice_doc.payments and flt(invoice_doc.grand_total) > 0: + allow_credit_sale = cint( + frappe.db.get_value(DOCTYPE_POS_SETTINGS, {"pos_profile": pos_profile}, "allow_credit_sale") + ) + if not allow_credit_sale: + frappe.throw(_("Credit sales are not enabled for this POS Profile.")) + invoice_doc.flags.pos_next_credit_sale = 1 + + # Save before submit + invoice_doc.flags.ignore_permissions = True + frappe.flags.ignore_account_permission = True + invoice_doc.save() + + # Submit invoice + invoice_doc.submit() + invoice_submitted = True + # Handle wallet transaction reversal for returns + wallet_reversal_ok = False + if invoice_doc.get("is_return") and invoice_doc.get("return_against"): + from pos_next.pos_next.doctype.wallet_transaction.wallet_transaction import ( + reverse_wallet_transactions_for_return, + ) + + try: + reverse_wallet_transactions_for_return( + original_invoice=invoice_doc.return_against, return_invoice=invoice_doc.name + ) + wallet_reversal_ok = True + except Exception as wallet_reversal_error: + frappe.log_error( + title="Wallet Reversal Error", + message=( + f"Return invoice: {invoice_doc.name}, " + f"Original invoice: {invoice_doc.return_against}, " + f"Error: {str(wallet_reversal_error)}\n{frappe.get_traceback()}" + ), + ) + frappe.msgprint( + _( + "Return invoice submitted successfully, but wallet reversal failed. Please contact administrator." + ), + alert=True, + indicator="orange", + ) + + # Credit return amount to customer wallet when "Add to Customer Credit Balance" is enabled. + # Only proceed if the wallet reversal above succeeded (or was not needed) to + # avoid double-crediting the customer when reversal fails. + if invoice_doc.get("is_return"): + add_to_customer_balance = invoice.get("add_to_customer_balance") + has_return_against = bool(invoice_doc.get("return_against")) + if add_to_customer_balance and (wallet_reversal_ok or not has_return_against): + from pos_next.pos_next.doctype.wallet_transaction.wallet_transaction import ( + credit_return_to_wallet, + ) + + try: + credit_return_to_wallet( + return_invoice=invoice_doc.name, amount=abs(flt(invoice_doc.grand_total)) + ) + except Exception as wallet_credit_error: + frappe.log_error( + title="Wallet Credit on Return Error", + message=( + f"Return invoice: {invoice_doc.name}, " + f"Error: {str(wallet_credit_error)}\n{frappe.get_traceback()}" + ), + ) + frappe.msgprint( + _("Return submitted but wallet credit failed. Please contact administrator."), + alert=True, + indicator="orange", + ) + # Complete the offline sync record + if sync_record_name: + _complete_offline_sync(sync_record_name, invoice_doc.name) + + # Handle credit redemption after successful submission + if redeemed_customer_credit and customer_credit_dict: + try: + from pos_next.api.credit_sales import redeem_customer_credit + + redeem_customer_credit(invoice_doc.name, customer_credit_dict) + except Exception as credit_error: + frappe.log_error( + title="Credit Redemption Error", + message=f"Invoice: {invoice_doc.name}, Error: {str(credit_error)}\n{frappe.get_traceback()}", + ) + # Don't fail the entire transaction, just log the error + frappe.msgprint( + _( + "Invoice submitted successfully but credit redemption failed. Please contact administrator." + ), + alert=True, + indicator="orange", + ) + + # Log manual rate edits for audit trail (only after successful submission) + if doctype == DOCTYPE_SALES_INVOICE: + incoming_items = invoice.get("items") or [] + for item in incoming_items: + if cint(item.get(FIELD_IS_RATE_MANUALLY_EDITED)): + log_manual_rate_edit( + { + FIELD_ITEM_CODE: item.get(FIELD_ITEM_CODE), + "item_name": item.get("item_name"), + FIELD_RATE: flt(item.get(FIELD_RATE)), + FIELD_ORIGINAL_RATE: flt( + item.get(FIELD_ORIGINAL_RATE) or item.get(FIELD_PRICE_LIST_RATE) + ), + FIELD_IS_RATE_MANUALLY_EDITED: 1, + }, + invoice_doc.name, + ) + + # Return complete invoice details + result = { + "name": invoice_doc.name, + "status": invoice_doc.docstatus, + "grand_total": invoice_doc.grand_total, + "total": invoice_doc.total, + "net_total": invoice_doc.net_total, + "outstanding_amount": getattr(invoice_doc, "outstanding_amount", 0), + "paid_amount": getattr(invoice_doc, "paid_amount", 0), + "change_amount": getattr(invoice_doc, "change_amount", 0), + } + + # Include offline_id in response for client-side tracking + if offline_id: + result["offline_id"] = offline_id + + return result + + except Exception as e: + frappe.log_error(frappe.get_traceback(), "Submit Invoice Error") + raise + + finally: + # Cleanup sync record if invoice was not successfully submitted + if sync_record_name and not invoice_submitted: + _cleanup_failed_sync(sync_record_name) # ========================================== @@ -1607,16 +1636,14 @@ def get_invoices(pos_profile: str, limit: int = 100, start: int = 0) -> list: start = cint(start) or 0 # 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 invoices - invoices = frappe.db.sql(""" + invoices = frappe.db.sql( + """ SELECT name, customer, @@ -1641,16 +1668,16 @@ def get_invoices(pos_profile: str, limit: int = 100, start: int = 0) -> list: posting_time DESC LIMIT %(limit)s OFFSET %(start)s - """, { - "pos_profile": pos_profile, - "limit": limit, - "start": start - }, as_dict=True) + """, + {"pos_profile": pos_profile, "limit": limit, "start": start}, + as_dict=True, + ) invoice_names = [invoice.name for invoice in invoices] payments_by_invoice = {} if invoice_names: - payments = frappe.db.sql(""" + payments = frappe.db.sql( + """ SELECT parent, mode_of_payment, @@ -1662,20 +1689,24 @@ def get_invoices(pos_profile: str, limit: int = 100, start: int = 0) -> list: ORDER BY parent, idx - """, { - "invoice_names": tuple(invoice_names) - }, as_dict=True) + """, + {"invoice_names": tuple(invoice_names)}, + as_dict=True, + ) for payment in payments: - payments_by_invoice.setdefault(payment.parent, []).append({ - "mode_of_payment": payment.mode_of_payment, - "amount": payment.amount, - }) + payments_by_invoice.setdefault(payment.parent, []).append( + { + "mode_of_payment": payment.mode_of_payment, + "amount": payment.amount, + } + ) # Load items for each invoice for filtering purposes for invoice in invoices: invoice.payments = payments_by_invoice.get(invoice.name, []) - items = frappe.db.sql(""" + items = frappe.db.sql( + """ SELECT item_code, item_name, @@ -1688,9 +1719,10 @@ def get_invoices(pos_profile: str, limit: int = 100, start: int = 0) -> list: parent = %(invoice_name)s ORDER BY idx - """, { - "invoice_name": invoice.name - }, as_dict=True) + """, + {"invoice_name": invoice.name}, + as_dict=True, + ) invoice.items = items return invoices @@ -1703,95 +1735,93 @@ def get_invoices(pos_profile: str, limit: int = 100, start: int = 0) -> list: @frappe.whitelist() def get_draft_invoices(pos_opening_shift, doctype="Sales Invoice"): - """Get all draft invoices for a POS opening shift.""" - filters = { - "docstatus": 0, - } - - # Add pos_opening_shift filter if the field exists - if frappe.db.has_column(doctype, "pos_opening_shift"): - filters["pos_opening_shift"] = pos_opening_shift - - # Performance: Get all invoice names first - invoices_list = frappe.get_list( - doctype, - filters=filters, - fields=["name"], - limit_page_length=0, - order_by="modified desc", - ) + """Get all draft invoices for a POS opening shift.""" + filters = { + "docstatus": 0, + } + + # Add pos_opening_shift filter if the field exists + if frappe.db.has_column(doctype, "pos_opening_shift"): + filters["pos_opening_shift"] = pos_opening_shift + + # Performance: Get all invoice names first + invoices_list = frappe.get_list( + doctype, + filters=filters, + fields=["name"], + limit_page_length=0, + order_by="modified desc", + ) - # Performance: Batch load all documents at once using get_cached_doc - # This leverages Frappe's internal caching and is faster than individual queries - data = [] - for invoice in invoices_list: - data.append(frappe.get_cached_doc(doctype, invoice["name"])) + # Performance: Batch load all documents at once using get_cached_doc + # This leverages Frappe's internal caching and is faster than individual queries + data = [] + for invoice in invoices_list: + data.append(frappe.get_cached_doc(doctype, invoice["name"])) - return data + return data @frappe.whitelist() def delete_invoice(invoice): - """Delete draft invoice.""" - doctype = "Sales Invoice" + """Delete draft invoice.""" + doctype = "Sales Invoice" - if not frappe.db.exists(doctype, invoice): - frappe.throw(_("Invoice {0} does not exist").format(invoice)) + if not frappe.db.exists(doctype, invoice): + frappe.throw(_("Invoice {0} does not exist").format(invoice)) - # Check if it's a draft - if frappe.db.get_value(doctype, invoice, "docstatus") != 0: - frappe.throw(_("Cannot delete submitted invoice {0}").format(invoice)) + # Check if it's a draft + if frappe.db.get_value(doctype, invoice, "docstatus") != 0: + frappe.throw(_("Cannot delete submitted invoice {0}").format(invoice)) - frappe.delete_doc(doctype, invoice, force=1) - return _("Invoice {0} Deleted").format(invoice) + frappe.delete_doc(doctype, invoice, force=1) + return _("Invoice {0} Deleted").format(invoice) @frappe.whitelist() def cleanup_old_drafts(pos_profile=None, max_age_hours=48): - """ - Clean up old draft invoices to prevent stock reservation issues. - Deletes drafts older than max_age_hours (default 24 hours). - """ - from datetime import datetime, timedelta - - doctype = "Sales Invoice" - cutoff_time = datetime.now() - timedelta(hours=int(max_age_hours)) - - filters = { - "docstatus": 0, # Draft only - "is_pos": 1, # Only POS Sales Invoices - "modified": ["<", cutoff_time.strftime("%Y-%m-%d %H:%M:%S")], - } - - # Optionally filter by POS profile - if pos_profile: - filters["pos_profile"] = pos_profile - - # Get old drafts - old_drafts = frappe.get_all( - doctype, - filters=filters, - fields=["name", "modified"], - limit_page_length=100, # Safety limit - ) - - deleted_count = 0 - for draft in old_drafts: - try: - frappe.delete_doc( - doctype, draft["name"], force=True, ignore_permissions=True - ) - deleted_count += 1 - except Exception as e: - frappe.log_error( - f"Failed to delete draft {draft['name']}: {str(e)}", - "Draft Cleanup Error", - ) - - return { - "deleted": deleted_count, - "message": f"Cleaned up {deleted_count} old draft invoices", - } + """ + Clean up old draft invoices to prevent stock reservation issues. + Deletes drafts older than max_age_hours (default 24 hours). + """ + from datetime import datetime, timedelta + + doctype = "Sales Invoice" + cutoff_time = datetime.now() - timedelta(hours=int(max_age_hours)) + + filters = { + "docstatus": 0, # Draft only + "is_pos": 1, # Only POS Sales Invoices + "modified": ["<", cutoff_time.strftime("%Y-%m-%d %H:%M:%S")], + } + + # Optionally filter by POS profile + if pos_profile: + filters["pos_profile"] = pos_profile + + # Get old drafts + old_drafts = frappe.get_all( + doctype, + filters=filters, + fields=["name", "modified"], + limit_page_length=100, # Safety limit + ) + + deleted_count = 0 + for draft in old_drafts: + try: + frappe.delete_doc(doctype, draft["name"], force=True, ignore_permissions=True) + deleted_count += 1 + except Exception as e: + frappe.log_error( + f"Failed to delete draft {draft['name']}: {str(e)}", + "Draft Cleanup Error", + ) + + return { + "deleted": deleted_count, + "message": f"Cleaned up {deleted_count} old draft invoices", + } # ========================================== @@ -1800,939 +1830,897 @@ def cleanup_old_drafts(pos_profile=None, max_age_hours=48): def _filter_fully_returned(invoices): - """Remove invoices where all items have already been returned. - - Uses two targeted queries instead of a 4-table LEFT JOIN to avoid - cartesian explosion (SI × SI_Item × Ret_SI × Ret_Item). - Only touches the small candidate set passed in. - """ - if not invoices: - return [] - - from frappe.query_builder.functions import Sum, Abs - - invoice_names = [inv["name"] for inv in invoices] - - # Original qty per invoice - si_item = frappe.qb.DocType("Sales Invoice Item") - orig_rows = ( - frappe.qb.from_(si_item) - .select(si_item.parent, Sum(si_item.qty).as_("total_original_qty")) - .where(si_item.parent.isin(invoice_names)) - .groupby(si_item.parent) - ).run(as_dict=True) - orig_map = {r["parent"]: flt(r["total_original_qty"]) for r in orig_rows} - - # Returned qty per original invoice - ret_si = frappe.qb.DocType("Sales Invoice") - ret_item = frappe.qb.DocType("Sales Invoice Item") - ret_rows = ( - frappe.qb.from_(ret_si) - .inner_join(ret_item).on(ret_item.parent == ret_si.name) - .select(ret_si.return_against, Sum(Abs(ret_item.qty)).as_("total_returned_qty")) - .where( - (ret_si.return_against.isin(invoice_names)) - & (ret_si.docstatus == 1) - & (ret_si.is_return == 1) - ) - .groupby(ret_si.return_against) - ).run(as_dict=True) - ret_map = {r["return_against"]: flt(r["total_returned_qty"]) for r in ret_rows} - - for inv in invoices: - inv["total_original_qty"] = orig_map.get(inv["name"], 0) - inv["total_returned_qty"] = ret_map.get(inv["name"], 0) - - return [ - inv for inv in invoices - if inv["total_original_qty"] > inv["total_returned_qty"] - ] + """Remove invoices where all items have already been returned. + + Uses two targeted queries instead of a 4-table LEFT JOIN to avoid + cartesian explosion (SI × SI_Item × Ret_SI × Ret_Item). + Only touches the small candidate set passed in. + """ + if not invoices: + return [] + + from frappe.query_builder.functions import Abs, Sum + + invoice_names = [inv["name"] for inv in invoices] + + # Original qty per invoice + si_item = frappe.qb.DocType("Sales Invoice Item") + orig_rows = ( + frappe.qb.from_(si_item) + .select(si_item.parent, Sum(si_item.qty).as_("total_original_qty")) + .where(si_item.parent.isin(invoice_names)) + .groupby(si_item.parent) + ).run(as_dict=True) + orig_map = {r["parent"]: flt(r["total_original_qty"]) for r in orig_rows} + + # Returned qty per original invoice + ret_si = frappe.qb.DocType("Sales Invoice") + ret_item = frappe.qb.DocType("Sales Invoice Item") + ret_rows = ( + frappe.qb.from_(ret_si) + .inner_join(ret_item) + .on(ret_item.parent == ret_si.name) + .select(ret_si.return_against, Sum(Abs(ret_item.qty)).as_("total_returned_qty")) + .where( + (ret_si.return_against.isin(invoice_names)) & (ret_si.docstatus == 1) & (ret_si.is_return == 1) + ) + .groupby(ret_si.return_against) + ).run(as_dict=True) + ret_map = {r["return_against"]: flt(r["total_returned_qty"]) for r in ret_rows} + + for inv in invoices: + inv["total_original_qty"] = orig_map.get(inv["name"], 0) + inv["total_returned_qty"] = ret_map.get(inv["name"], 0) + + return [inv for inv in invoices if inv["total_original_qty"] > inv["total_returned_qty"]] @frappe.whitelist() def get_returnable_invoices(limit=50, pos_profile=None): - """Get list of invoices that have items available for return. - Filters by return validity period if configured in POS Settings. - - Two-step approach for performance: - 1. Fetch recent POS invoices (fast indexed query, no JOINs) - 2. Filter out fully-returned ones via _filter_fully_returned - """ - from frappe.utils import add_days, today - - # Check return validity days from POS Settings - return_validity_days = 0 - if pos_profile: - return_validity_days = cint( - frappe.db.get_value( - "POS Settings", - {"pos_profile": pos_profile}, - "return_validity_days" - ) or 0 - ) - - si = frappe.qb.DocType("Sales Invoice") - - # Over-fetch to compensate for fully-returned invoices removed in step 2 - fetch_limit = cint(limit) * 2 - - # Step 1: fetch candidates (lightweight, no JOINs) - query = ( - frappe.qb.from_(si) - .select( - si.name, si.customer, si.customer_name, - si.contact_mobile, si.posting_date, - si.grand_total, si.status, - ) - .where( - (si.docstatus == 1) - & (si.is_return == 0) - & (si.is_pos == 1) - ) - .orderby(si.posting_date, order=frappe.qb.desc) - .orderby(si.creation, order=frappe.qb.desc) - .limit(fetch_limit) - ) - - if return_validity_days > 0: - cutoff_date = add_days(today(), -return_validity_days) - query = query.where(si.posting_date >= cutoff_date) - - candidates = query.run(as_dict=True) - - # Step 2: filter out fully-returned invoices, then trim to requested limit - return _filter_fully_returned(candidates)[:cint(limit)] + """Get list of invoices that have items available for return. + Filters by return validity period if configured in POS Settings. + + Two-step approach for performance: + 1. Fetch recent POS invoices (fast indexed query, no JOINs) + 2. Filter out fully-returned ones via _filter_fully_returned + """ + from frappe.utils import add_days, today + + # Check return validity days from POS Settings + return_validity_days = 0 + if pos_profile: + return_validity_days = cint( + frappe.db.get_value("POS Settings", {"pos_profile": pos_profile}, "return_validity_days") or 0 + ) + + si = frappe.qb.DocType("Sales Invoice") + + # Over-fetch to compensate for fully-returned invoices removed in step 2 + fetch_limit = cint(limit) * 2 + + # Step 1: fetch candidates (lightweight, no JOINs) + query = ( + frappe.qb.from_(si) + .select( + si.name, + si.customer, + si.customer_name, + si.contact_mobile, + si.posting_date, + si.grand_total, + si.status, + ) + .where((si.docstatus == 1) & (si.is_return == 0) & (si.is_pos == 1)) + .orderby(si.posting_date, order=frappe.qb.desc) + .orderby(si.creation, order=frappe.qb.desc) + .limit(fetch_limit) + ) + + if return_validity_days > 0: + cutoff_date = add_days(today(), -return_validity_days) + query = query.where(si.posting_date >= cutoff_date) + + candidates = query.run(as_dict=True) + + # Step 2: filter out fully-returned invoices, then trim to requested limit + return _filter_fully_returned(candidates)[: cint(limit)] @frappe.whitelist() def search_invoice_by_number(search_term, pos_profile=None): - """Search for invoices by invoice number across the entire database. - No date restrictions - searches all returnable invoices matching the term. - - Two-step approach for performance: - 1. Find matching POS invoices by name (fast indexed LIKE query) - 2. Filter out fully-returned ones via _filter_fully_returned - - Args: - search_term: Invoice number or partial number to search for (min 3 chars) - pos_profile: Optional POS profile for context (reserved for future use) - - Returns: - List of matching invoices with return availability info (max 10 results) - """ - if not search_term or len(search_term) < 3: - return [] - - # Escape LIKE wildcards in user input to prevent pattern abuse. - # frappe.db.escape() returns a quoted string for raw SQL — not usable with - # frappe.qb's .like() which parameterizes internally. Manual escaping needed. - search_term = cstr(search_term).strip().replace("%", r"\%").replace("_", r"\_") - si = frappe.qb.DocType("Sales Invoice") - - # Step 1: find matching invoices (lightweight, no JOINs) - candidates = ( - frappe.qb.from_(si) - .select( - si.name, si.customer, si.customer_name, - si.contact_mobile, si.posting_date, - si.grand_total, si.status, - ) - .where( - (si.docstatus == 1) - & (si.is_return == 0) - & (si.is_pos == 1) - & (si.name.like(f"%{search_term}%")) - ) - .orderby(si.posting_date, order=frappe.qb.desc) - .orderby(si.creation, order=frappe.qb.desc) - .limit(10) - ).run(as_dict=True) - - # Step 2: filter out fully-returned invoices - return _filter_fully_returned(candidates) + """Search for invoices by invoice number across the entire database. + No date restrictions - searches all returnable invoices matching the term. + + Two-step approach for performance: + 1. Find matching POS invoices by name (fast indexed LIKE query) + 2. Filter out fully-returned ones via _filter_fully_returned + + Args: + search_term: Invoice number or partial number to search for (min 3 chars) + pos_profile: Optional POS profile for context (reserved for future use) + + Returns: + List of matching invoices with return availability info (max 10 results) + """ + if not search_term or len(search_term) < 3: + return [] + + # Escape LIKE wildcards in user input to prevent pattern abuse. + # frappe.db.escape() returns a quoted string for raw SQL — not usable with + # frappe.qb's .like() which parameterizes internally. Manual escaping needed. + search_term = cstr(search_term).strip().replace("%", r"\%").replace("_", r"\_") + si = frappe.qb.DocType("Sales Invoice") + + # Step 1: find matching invoices (lightweight, no JOINs) + candidates = ( + frappe.qb.from_(si) + .select( + si.name, + si.customer, + si.customer_name, + si.contact_mobile, + si.posting_date, + si.grand_total, + si.status, + ) + .where( + (si.docstatus == 1) & (si.is_return == 0) & (si.is_pos == 1) & (si.name.like(f"%{search_term}%")) + ) + .orderby(si.posting_date, order=frappe.qb.desc) + .orderby(si.creation, order=frappe.qb.desc) + .limit(10) + ).run(as_dict=True) + + # Step 2: filter out fully-returned invoices + return _filter_fully_returned(candidates) @frappe.whitelist() def check_invoice_return_validity(invoice_name): - """Check if an invoice is within the return validity period. - - Returns detailed information for the UI to display, including: - - valid: Boolean indicating if return is allowed - - error_type: 'not_found' or 'return_period_expired' if invalid - - Additional context (invoice_date, days_since, allowed_days) for expired returns - """ - from frappe.utils import date_diff, getdate, formatdate - - # Fetch only the fields needed for validation - si = frappe.qb.DocType("Sales Invoice") - invoice_data = ( - frappe.qb.from_(si) - .select(si.pos_profile, si.posting_date) - .where(si.name == invoice_name) - ).run(as_dict=True) - - if not invoice_data: - return { - "valid": False, - "error_type": "not_found", - "message": _("Invoice {0} does not exist").format(invoice_name) - } - - invoice_info = invoice_data[0] - - # Check return validity period from POS Settings - if invoice_info.pos_profile: - return_validity_days = cint( - frappe.db.get_value( - "POS Settings", - {"pos_profile": invoice_info.pos_profile}, - "return_validity_days" - ) or 0 - ) - - if return_validity_days > 0: - days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) - if days_since_invoice > return_validity_days: - return { - "valid": False, - "error_type": "return_period_expired", - "invoice_name": invoice_name, - "invoice_date": formatdate(invoice_info.posting_date), - "days_since": days_since_invoice, - "allowed_days": return_validity_days, - "message": _("Return period has expired") - } - - return {"valid": True} + """Check if an invoice is within the return validity period. + + Returns detailed information for the UI to display, including: + - valid: Boolean indicating if return is allowed + - error_type: 'not_found' or 'return_period_expired' if invalid + - Additional context (invoice_date, days_since, allowed_days) for expired returns + """ + from frappe.utils import date_diff, formatdate, getdate + + # Fetch only the fields needed for validation + si = frappe.qb.DocType("Sales Invoice") + invoice_data = ( + frappe.qb.from_(si).select(si.pos_profile, si.posting_date).where(si.name == invoice_name) + ).run(as_dict=True) + + if not invoice_data: + return { + "valid": False, + "error_type": "not_found", + "message": _("Invoice {0} does not exist").format(invoice_name), + } + + invoice_info = invoice_data[0] + + # Check return validity period from POS Settings + if invoice_info.pos_profile: + return_validity_days = cint( + frappe.db.get_value( + "POS Settings", {"pos_profile": invoice_info.pos_profile}, "return_validity_days" + ) + or 0 + ) + + if return_validity_days > 0: + days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) + if days_since_invoice > return_validity_days: + return { + "valid": False, + "error_type": "return_period_expired", + "invoice_name": invoice_name, + "invoice_date": formatdate(invoice_info.posting_date), + "days_since": days_since_invoice, + "allowed_days": return_validity_days, + "message": _("Return period has expired"), + } + + return {"valid": True} @frappe.whitelist() def get_invoice_for_return(invoice_name): - """Get invoice with return tracking - calculates remaining qty for each item. - Also validates return validity period based on POS Settings. - - Returns the full invoice document with each item's qty adjusted to show - only the remaining returnable quantity (original qty minus already returned). - """ - from frappe.utils import date_diff, getdate - from frappe.query_builder.functions import Sum, Abs, Coalesce - - # Validate invoice exists and get fields needed for return period check - si = frappe.qb.DocType("Sales Invoice") - invoice_check = ( - frappe.qb.from_(si) - .select(si.pos_profile, si.posting_date) - .where(si.name == invoice_name) - ).run(as_dict=True) - - if not invoice_check: - frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) - - invoice_info = invoice_check[0] - - # Check return validity period from POS Settings - if invoice_info.pos_profile: - return_validity_days = cint( - frappe.db.get_value( - "POS Settings", - {"pos_profile": invoice_info.pos_profile}, - "return_validity_days" - ) or 0 - ) - - if return_validity_days > 0: - days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) - if days_since_invoice > return_validity_days: - frappe.throw( - _("Return period has expired. Invoice {0} was created {1} days ago. " - "Returns are only allowed within {2} days of purchase.").format( - invoice_name, days_since_invoice, return_validity_days - ) - ) - - # Aggregate quantities already returned from previous return invoices. - # Uses COALESCE to match by sales_invoice_item (row ID) first, then item_code as fallback. - ret_si = frappe.qb.DocType("Sales Invoice") - ret_item = frappe.qb.DocType("Sales Invoice Item") - - returned_qty_results = ( - frappe.qb.from_(ret_si) - .inner_join(ret_item).on(ret_item.parent == ret_si.name) - .select( - Coalesce(ret_item.sales_invoice_item, ret_item.item_code).as_("key_field"), - Sum(Abs(ret_item.qty)).as_("returned_qty") - ) - .where( - (ret_si.return_against == invoice_name) - & (ret_si.docstatus == 1) - & (ret_si.is_return == 1) - ) - .groupby(Coalesce(ret_item.sales_invoice_item, ret_item.item_code)) - ).run(as_dict=True) - - returned_qty = {row["key_field"]: flt(row["returned_qty"]) for row in returned_qty_results} - - # Get the full invoice document (needed for complete response) - invoice = frappe.get_doc("Sales Invoice", invoice_name) - invoice_dict = invoice.as_dict() - - # Calculate remaining quantities - updated_items = [] - for item in invoice_dict.get("items", []): - # Check how much has been returned using the item's name (row ID) - already_returned = returned_qty.get(item.name, 0) - remaining_qty = flt(item.qty) - already_returned - - if remaining_qty > 0: - item_copy = item.copy() - item_copy["original_qty"] = item.qty - item_copy["qty"] = remaining_qty - item_copy["already_returned"] = already_returned - updated_items.append(item_copy) - - invoice_dict["items"] = updated_items - return invoice_dict + """Get invoice with return tracking - calculates remaining qty for each item. + Also validates return validity period based on POS Settings. + + Returns the full invoice document with each item's qty adjusted to show + only the remaining returnable quantity (original qty minus already returned). + """ + from frappe.query_builder.functions import Abs, Coalesce, Sum + from frappe.utils import date_diff, getdate + + # Validate invoice exists and get fields needed for return period check + si = frappe.qb.DocType("Sales Invoice") + invoice_check = ( + frappe.qb.from_(si).select(si.pos_profile, si.posting_date).where(si.name == invoice_name) + ).run(as_dict=True) + + if not invoice_check: + frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) + + invoice_info = invoice_check[0] + + # Check return validity period from POS Settings + if invoice_info.pos_profile: + return_validity_days = cint( + frappe.db.get_value( + "POS Settings", {"pos_profile": invoice_info.pos_profile}, "return_validity_days" + ) + or 0 + ) + + if return_validity_days > 0: + days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) + if days_since_invoice > return_validity_days: + frappe.throw( + _( + "Return period has expired. Invoice {0} was created {1} days ago. " + "Returns are only allowed within {2} days of purchase." + ).format(invoice_name, days_since_invoice, return_validity_days) + ) + + # Aggregate quantities already returned from previous return invoices. + # Uses COALESCE to match by sales_invoice_item (row ID) first, then item_code as fallback. + ret_si = frappe.qb.DocType("Sales Invoice") + ret_item = frappe.qb.DocType("Sales Invoice Item") + + returned_qty_results = ( + frappe.qb.from_(ret_si) + .inner_join(ret_item) + .on(ret_item.parent == ret_si.name) + .select( + Coalesce(ret_item.sales_invoice_item, ret_item.item_code).as_("key_field"), + Sum(Abs(ret_item.qty)).as_("returned_qty"), + ) + .where((ret_si.return_against == invoice_name) & (ret_si.docstatus == 1) & (ret_si.is_return == 1)) + .groupby(Coalesce(ret_item.sales_invoice_item, ret_item.item_code)) + ).run(as_dict=True) + + returned_qty = {row["key_field"]: flt(row["returned_qty"]) for row in returned_qty_results} + + # Get the full invoice document (needed for complete response) + invoice = frappe.get_doc("Sales Invoice", invoice_name) + invoice_dict = invoice.as_dict() + + # Calculate remaining quantities + updated_items = [] + for item in invoice_dict.get("items", []): + # Check how much has been returned using the item's name (row ID) + already_returned = returned_qty.get(item.name, 0) + remaining_qty = flt(item.qty) - already_returned + + if remaining_qty > 0: + item_copy = item.copy() + item_copy["original_qty"] = item.qty + item_copy["qty"] = remaining_qty + item_copy["already_returned"] = already_returned + updated_items.append(item_copy) + + invoice_dict["items"] = updated_items + return invoice_dict def _parse_item_wise_tax_detail(raw_detail): - """Parse item_wise_tax_detail from string or dict format.""" - if not raw_detail: - return {} - if isinstance(raw_detail, str): - return json.loads(raw_detail) - return raw_detail + """Parse item_wise_tax_detail from string or dict format.""" + if not raw_detail: + return {} + if isinstance(raw_detail, str): + return json.loads(raw_detail) + return raw_detail def _build_item_tax_map(taxes: list) -> dict: - """Build item_code -> tax_amount map from taxes child table. + """Build item_code -> tax_amount map from taxes child table. - Args: - taxes: List of tax row dicts containing item_wise_tax_detail + Args: + taxes: List of tax row dicts containing item_wise_tax_detail + + Returns: + Dict mapping item_code to total tax amount (absolute value) - Returns: - Dict mapping item_code to total tax amount (absolute value) + Note: + item_wise_tax_detail format: {"ITEM-CODE": [tax_rate, tax_amount]} + Return documents have negative amounts, hence abs() is used. + """ + from collections import defaultdict - Note: - item_wise_tax_detail format: {"ITEM-CODE": [tax_rate, tax_amount]} - Return documents have negative amounts, hence abs() is used. - """ - from collections import defaultdict - tax_map = defaultdict(float) + tax_map = defaultdict(float) - for tax_row in taxes: - try: - details = _parse_item_wise_tax_detail(tax_row.get("item_wise_tax_detail")) - for item_code, (_, tax_amount) in details.items(): - tax_map[item_code] += abs(flt(tax_amount)) - except (json.JSONDecodeError, TypeError, ValueError, KeyError): - continue + for tax_row in taxes: + try: + details = _parse_item_wise_tax_detail(tax_row.get("item_wise_tax_detail")) + for item_code, (_, tax_amount) in details.items(): + tax_map[item_code] += abs(flt(tax_amount)) + except (json.JSONDecodeError, TypeError, ValueError, KeyError): + continue - return dict(tax_map) + return dict(tax_map) def _remap_foreign_payment_modes(payments_data, current_profile, original_profile): - """Remap payment modes that don't belong to the current POS profile. - - Cross-branch returns problem: - Each POS branch has its own cash Mode of Payment that maps to a - dedicated GL cash account (e.g. "Boulaq Cash" -> account 12114, - "Cash lebanon" -> account 12123). When a customer returns an invoice - that was originally sold at a different branch, ERPNext's - make_sales_return() copies the *original* branch's payment modes. - - If we don't remap, two things go wrong: - 1. GL entries: the refund is posted against the wrong branch's cash - account (e.g. crediting Lebanon's cash instead of Boulaq's). - 2. Shift closing: the foreign mode creates an orphan payment row - that doesn't exist in the current profile's opening balance, - blocking reconciliation. - - Remapping strategy: - Modes are matched by their Mode of Payment *type* field: - - Cash -> Cash (e.g. "Cash lebanon" -> "Boulaq Cash") - - Bank -> Bank (e.g. "Lebanon Visa" -> "Visa") - - General -> first available in profile, or cash fallback - - This ensures the GL account matches the physical cash drawer or - bank account at the branch where the return is processed. - - Fallback chain for default mode: - 1. POS Profile.posa_cash_mode_of_payment (explicit cash mode config) - 2. First mode with type "Cash" in the profile - 3. First mode in the profile (any type) - - When remapping is skipped (no-op): - - Same profile (current == original) - - No current_profile provided - - All original modes already exist in the current profile - - No default_mode could be determined (empty profile) - - Args: - payments_data: list of frappe._dict with mode_of_payment, amount, etc. - (from Sales Invoice Payment child table of original invoice) - current_profile: POS Profile name where the return is being processed - original_profile: POS Profile name where the original sale happened - - Returns: - The same payments_data list with mode_of_payment remapped in-place. - Shared modes (e.g. "Visa" exists in both profiles) are left unchanged. - - Example: - Original invoice (profile "2- Lebanon"): - payments = [{"mode_of_payment": "Cash lebanon", "amount": 3240}] - - Current profile "4- Boulaq" has modes: - [{"mode_of_payment": "Boulaq Cash", type: "Cash"}, - {"mode_of_payment": "Visa", type: "Bank"}] - - After remap: - payments = [{"mode_of_payment": "Boulaq Cash", "amount": 3240}] - - "Cash lebanon" (type=Cash) -> "Boulaq Cash" (type=Cash) - """ - if not current_profile or current_profile == original_profile: - return payments_data - - # Get current profile's payment modes - current_modes = { - row.mode_of_payment - for row in frappe.get_all( - "POS Payment Method", - filters={"parent": current_profile, "parenttype": "POS Profile"}, - fields=["mode_of_payment"], - ) - } - - # Check if any payment needs remapping - needs_remap = any(p.mode_of_payment not in current_modes for p in payments_data) - if not needs_remap: - return payments_data - - # Build type->mode map for the current profile. - # Uses setdefault so the first mode of each type wins (matches profile order). - mop = frappe.qb.DocType("Mode of Payment") - ppm = frappe.qb.DocType("POS Payment Method") - current_type_map = {} - rows = ( - frappe.qb.from_(ppm) - .inner_join(mop).on(mop.name == ppm.mode_of_payment) - .select(ppm.mode_of_payment, mop.type) - .where( - (ppm.parent == current_profile) - & (ppm.parenttype == "POS Profile") - ) - ).run(as_dict=True) - - for row in rows: - current_type_map.setdefault(row.type, row.mode_of_payment) - - # Default fallback: posa_cash_mode_of_payment > first Cash type > first mode - default_mode = ( - frappe.db.get_value("POS Profile", current_profile, "posa_cash_mode_of_payment") - or current_type_map.get("Cash") - or (rows[0].mode_of_payment if rows else None) - ) - - if not default_mode: - return payments_data - - # Fetch the type for each foreign mode in a single query - foreign_modes = [p.mode_of_payment for p in payments_data if p.mode_of_payment not in current_modes] - foreign_types = {} - if foreign_modes: - type_rows = frappe.get_all( - "Mode of Payment", - filters={"name": ["in", foreign_modes]}, - fields=["name", "type"], - ) - foreign_types = {r.name: r.type for r in type_rows} - - # Remap: foreign Cash -> current Cash, foreign Bank -> current Bank, etc. - # If no type match in current profile, fall back to default_mode. - for payment in payments_data: - if payment.mode_of_payment in current_modes: - continue - foreign_type = foreign_types.get(payment.mode_of_payment) - payment.mode_of_payment = current_type_map.get(foreign_type, default_mode) - - return payments_data + """Remap payment modes that don't belong to the current POS profile. + + Cross-branch returns problem: + Each POS branch has its own cash Mode of Payment that maps to a + dedicated GL cash account (e.g. "Boulaq Cash" -> account 12114, + "Cash lebanon" -> account 12123). When a customer returns an invoice + that was originally sold at a different branch, ERPNext's + make_sales_return() copies the *original* branch's payment modes. + + If we don't remap, two things go wrong: + 1. GL entries: the refund is posted against the wrong branch's cash + account (e.g. crediting Lebanon's cash instead of Boulaq's). + 2. Shift closing: the foreign mode creates an orphan payment row + that doesn't exist in the current profile's opening balance, + blocking reconciliation. + + Remapping strategy: + Modes are matched by their Mode of Payment *type* field: + - Cash -> Cash (e.g. "Cash lebanon" -> "Boulaq Cash") + - Bank -> Bank (e.g. "Lebanon Visa" -> "Visa") + - General -> first available in profile, or cash fallback + + This ensures the GL account matches the physical cash drawer or + bank account at the branch where the return is processed. + + Fallback chain for default mode: + 1. POS Profile.posa_cash_mode_of_payment (explicit cash mode config) + 2. First mode with type "Cash" in the profile + 3. First mode in the profile (any type) + + When remapping is skipped (no-op): + - Same profile (current == original) + - No current_profile provided + - All original modes already exist in the current profile + - No default_mode could be determined (empty profile) + + Args: + payments_data: list of frappe._dict with mode_of_payment, amount, etc. + (from Sales Invoice Payment child table of original invoice) + current_profile: POS Profile name where the return is being processed + original_profile: POS Profile name where the original sale happened + + Returns: + The same payments_data list with mode_of_payment remapped in-place. + Shared modes (e.g. "Visa" exists in both profiles) are left unchanged. + + Example: + Original invoice (profile "2- Lebanon"): + payments = [{"mode_of_payment": "Cash lebanon", "amount": 3240}] + + Current profile "4- Boulaq" has modes: + [{"mode_of_payment": "Boulaq Cash", type: "Cash"}, + {"mode_of_payment": "Visa", type: "Bank"}] + + After remap: + payments = [{"mode_of_payment": "Boulaq Cash", "amount": 3240}] + + "Cash lebanon" (type=Cash) -> "Boulaq Cash" (type=Cash) + """ + if not current_profile or current_profile == original_profile: + return payments_data + + # Get current profile's payment modes + current_modes = { + row.mode_of_payment + for row in frappe.get_all( + "POS Payment Method", + filters={"parent": current_profile, "parenttype": "POS Profile"}, + fields=["mode_of_payment"], + ) + } + + # Check if any payment needs remapping + needs_remap = any(p.mode_of_payment not in current_modes for p in payments_data) + if not needs_remap: + return payments_data + + # Build type->mode map for the current profile. + # Uses setdefault so the first mode of each type wins (matches profile order). + mop = frappe.qb.DocType("Mode of Payment") + ppm = frappe.qb.DocType("POS Payment Method") + current_type_map = {} + rows = ( + frappe.qb.from_(ppm) + .inner_join(mop) + .on(mop.name == ppm.mode_of_payment) + .select(ppm.mode_of_payment, mop.type) + .where((ppm.parent == current_profile) & (ppm.parenttype == "POS Profile")) + ).run(as_dict=True) + + for row in rows: + current_type_map.setdefault(row.type, row.mode_of_payment) + + # Default fallback: posa_cash_mode_of_payment > first Cash type > first mode + default_mode = ( + frappe.db.get_value("POS Profile", current_profile, "posa_cash_mode_of_payment") + or current_type_map.get("Cash") + or (rows[0].mode_of_payment if rows else None) + ) + + if not default_mode: + return payments_data + + # Fetch the type for each foreign mode in a single query + foreign_modes = [p.mode_of_payment for p in payments_data if p.mode_of_payment not in current_modes] + foreign_types = {} + if foreign_modes: + type_rows = frappe.get_all( + "Mode of Payment", + filters={"name": ["in", foreign_modes]}, + fields=["name", "type"], + ) + foreign_types = {r.name: r.type for r in type_rows} + + # Remap: foreign Cash -> current Cash, foreign Bank -> current Bank, etc. + # If no type match in current profile, fall back to default_mode. + for payment in payments_data: + if payment.mode_of_payment in current_modes: + continue + foreign_type = foreign_types.get(payment.mode_of_payment) + payment.mode_of_payment = current_type_map.get(foreign_type, default_mode) + + return payments_data @frappe.whitelist() def prepare_return_invoice(invoice_name, pos_opening_shift=None): - """Prepare a return invoice using ERPNext's make_sales_return. - - This uses ERPNext's standard return document creation which properly copies - all child tables including: - - sales_team: For correct commission reversal on returned items - - taxes: For correct tax reversal - - Other child tables maintained by ERPNext - - The function validates: - - Invoice exists and is submitted (docstatus = 1) - - Invoice is not already a return - - Return is within the validity period (if configured in POS Settings) - - Args: - invoice_name: The original Sales Invoice name to create return against - pos_opening_shift: The current POS Opening Shift name - - Returns: - dict: The prepared return invoice document with: - - items: Only items with remaining_qty > 0 (not fully returned) - - _original_invoice: Reference data from original invoice (payments, amounts) - - Each item includes original_qty, already_returned, and remaining_qty - """ - from frappe.utils import date_diff, getdate - from frappe.query_builder.functions import Sum, Abs, Coalesce - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return - - # Validate invoice and get fields needed for return period check - si = frappe.qb.DocType("Sales Invoice") - invoice_check = ( - frappe.qb.from_(si) - .select( - si.docstatus, - si.is_return, - si.pos_profile, - si.posting_date, - si.is_pos, - si.grand_total, - si.paid_amount, - si.outstanding_amount, - si.customer, - si.customer_name, - si.net_total, - si.total_taxes_and_charges - ) - .where(si.name == invoice_name) - ).run(as_dict=True) - - if not invoice_check: - frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) - - invoice_info = invoice_check[0] - - # Validate docstatus - if invoice_info.docstatus != 1: - frappe.throw(_("Invoice must be submitted to create a return")) - - # Check if it's already a return - if invoice_info.is_return: - frappe.throw(_("Cannot create return against a return invoice")) - - # Check return validity period from POS Settings - if invoice_info.pos_profile: - return_validity_days = cint( - frappe.db.get_value( - "POS Settings", - {"pos_profile": invoice_info.pos_profile}, - "return_validity_days" - ) or 0 - ) - - if return_validity_days > 0: - days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) - if days_since_invoice > return_validity_days: - frappe.throw( - _("Return period has expired. Invoice {0} was created {1} days ago. " - "Returns are only allowed within {2} days of purchase.").format( - invoice_name, days_since_invoice, return_validity_days - ) - ) - - # Use ERPNext's make_sales_return to create properly mapped return document - # This automatically copies sales_team, taxes, and other child tables - return_doc = make_sales_return(invoice_name) - - # Set POS-specific fields - if pos_opening_shift: - return_doc.posa_pos_opening_shift = pos_opening_shift - - # Ensure POS flags are set - return_doc.is_pos = invoice_info.is_pos - return_doc.pos_profile = invoice_info.pos_profile - - # Aggregate quantities already returned from previous return invoices - ret_si = frappe.qb.DocType("Sales Invoice") - ret_item = frappe.qb.DocType("Sales Invoice Item") - - returned_qty_results = ( - frappe.qb.from_(ret_si) - .inner_join(ret_item).on(ret_item.parent == ret_si.name) - .select( - Coalesce(ret_item.sales_invoice_item, ret_item.item_code).as_("key_field"), - Sum(Abs(ret_item.qty)).as_("returned_qty") - ) - .where( - (ret_si.return_against == invoice_name) - & (ret_si.docstatus == 1) - & (ret_si.is_return == 1) - ) - .groupby(Coalesce(ret_item.sales_invoice_item, ret_item.item_code)) - ).run(as_dict=True) - - returned_qty_map = {row["key_field"]: flt(row["returned_qty"]) for row in returned_qty_results} - - # Convert to dict and update items with remaining quantities - return_dict = return_doc.as_dict() - - # Fetch original invoice payments for refund handling in frontend - si_payment = frappe.qb.DocType("Sales Invoice Payment") - payments_data = ( - frappe.qb.from_(si_payment) - .select( - si_payment.mode_of_payment, - si_payment.amount, - si_payment.base_amount, - si_payment.account - ) - .where(si_payment.parent == invoice_name) - ).run(as_dict=True) - - # Cross-branch return: remap foreign payment modes to the current profile. - # - # When the original invoice's POS profile differs from the current shift's - # profile, the original payment modes (e.g. "Cash lebanon") won't exist in - # the current profile ("4- Boulaq"). _remap_foreign_payment_modes matches - # by Mode of Payment type (Cash->Cash, Bank->Bank) so the frontend - # pre-fills the correct refund method and the resulting GL entries debit - # the correct branch cash/bank account. - # - # This is the primary fix point (Layer 1). The frontend and closing shift - # code have additional safety nets for cases where this remap doesn't run - # (e.g. no pos_opening_shift provided) or for already-submitted invoices - # with the wrong payment mode. - if pos_opening_shift: - current_profile = frappe.db.get_value( - "POS Opening Shift", pos_opening_shift, "pos_profile" - ) - if current_profile: - payments_data = _remap_foreign_payment_modes( - payments_data, current_profile, invoice_info.pos_profile - ) - - # Include original invoice data for reference (payments, amounts, etc.) - return_dict["_original_invoice"] = { - "name": invoice_name, - "grand_total": invoice_info.grand_total, - "paid_amount": invoice_info.paid_amount, - "outstanding_amount": invoice_info.outstanding_amount, - "customer": invoice_info.customer, - "customer_name": invoice_info.customer_name, - "posting_date": invoice_info.posting_date, - "payments": payments_data, - "net_total": invoice_info.net_total, - "total_taxes_and_charges": invoice_info.total_taxes_and_charges, - } - - item_tax_map = _build_item_tax_map(return_dict.get("taxes", [])) - - # Check if taxes are inclusive by inspecting the tax rows copied from the original - # invoice (immutable after submission, unlike POS Settings which can change later). - # Only consider percentage-based taxes (On Net Total, etc.) — Actual charge types - # are never inclusive (same logic as sales_invoice_hooks.apply_tax_inclusive). - applicable_taxes = [ - tax for tax in return_dict.get("taxes", []) - if tax.get("charge_type") != "Actual" - ] - tax_inclusive = bool(applicable_taxes) and all( - tax.get("included_in_print_rate") for tax in applicable_taxes - ) - - precision = cint(frappe.get_cached_value("System Settings", None, "currency_precision")) or 2 - - def process_return_item(item): - """Process single item for return, returns None if not returnable.""" - item_ref = item.get("sales_invoice_item") or item.get("item_code") - original_qty = abs(flt(item.get("qty", 0))) - remaining_qty = original_qty - returned_qty_map.get(item_ref, 0) - - if remaining_qty <= 0: - return None - - # Get rate breakdown for display - price_list_rate = flt(item.get("price_list_rate") or item.get("rate"), precision) - net_rate = flt(item.get("net_rate") or item.get("rate"), precision) - tax_per_unit = flt(item_tax_map.get(item.get("item_code"), 0) / original_qty, precision) if original_qty else 0 - - # For inclusive taxes, use the original rate (already includes tax) to prevent - # ERPNext from back-calculating and double-reducing the tax. - # For exclusive taxes, use net_rate as before. - if tax_inclusive: - item_rate = flt(item.get("rate"), precision) - rate_with_tax = item_rate - # Both price_list_rate and rate are tax-inclusive, so discount is their difference - discount_per_unit = flt(price_list_rate - item_rate, precision) - else: - item_rate = net_rate - rate_with_tax = flt(net_rate + tax_per_unit, precision) - discount_per_unit = flt(price_list_rate - net_rate, precision) - - return { - **item, - "original_qty": original_qty, - "already_returned": original_qty - remaining_qty, - "remaining_qty": remaining_qty, - "qty": -remaining_qty, - "price_list_rate": price_list_rate, - "rate": item_rate, - "discount_per_unit": discount_per_unit, - "amount": flt(item_rate * -remaining_qty, precision), - "tax_per_unit": tax_per_unit, - "rate_with_tax": rate_with_tax, - "tax_included_in_rate": tax_inclusive, - } - - return_dict["items"] = [ - processed for item in return_dict.get("items", []) - if (processed := process_return_item(item)) is not None - ] - - # Check if all items have been fully returned - if not return_dict["items"]: - frappe.throw(_("All items from this invoice have already been returned")) - - return return_dict + """Prepare a return invoice using ERPNext's make_sales_return. + + This uses ERPNext's standard return document creation which properly copies + all child tables including: + - sales_team: For correct commission reversal on returned items + - taxes: For correct tax reversal + - Other child tables maintained by ERPNext + + The function validates: + - Invoice exists and is submitted (docstatus = 1) + - Invoice is not already a return + - Return is within the validity period (if configured in POS Settings) + + Args: + invoice_name: The original Sales Invoice name to create return against + pos_opening_shift: The current POS Opening Shift name + + Returns: + dict: The prepared return invoice document with: + - items: Only items with remaining_qty > 0 (not fully returned) + - _original_invoice: Reference data from original invoice (payments, amounts) + - Each item includes original_qty, already_returned, and remaining_qty + """ + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from frappe.query_builder.functions import Abs, Coalesce, Sum + from frappe.utils import date_diff, getdate + + # Validate invoice and get fields needed for return period check + si = frappe.qb.DocType("Sales Invoice") + invoice_check = ( + frappe.qb.from_(si) + .select( + si.docstatus, + si.is_return, + si.pos_profile, + si.posting_date, + si.is_pos, + si.grand_total, + si.paid_amount, + si.outstanding_amount, + si.customer, + si.customer_name, + si.net_total, + si.total_taxes_and_charges, + ) + .where(si.name == invoice_name) + ).run(as_dict=True) + + if not invoice_check: + frappe.throw(_("Invoice {0} does not exist").format(invoice_name)) + + invoice_info = invoice_check[0] + + # Validate docstatus + if invoice_info.docstatus != 1: + frappe.throw(_("Invoice must be submitted to create a return")) + + # Check if it's already a return + if invoice_info.is_return: + frappe.throw(_("Cannot create return against a return invoice")) + + # Check return validity period from POS Settings + if invoice_info.pos_profile: + return_validity_days = cint( + frappe.db.get_value( + "POS Settings", {"pos_profile": invoice_info.pos_profile}, "return_validity_days" + ) + or 0 + ) + + if return_validity_days > 0: + days_since_invoice = date_diff(getdate(nowdate()), getdate(invoice_info.posting_date)) + if days_since_invoice > return_validity_days: + frappe.throw( + _( + "Return period has expired. Invoice {0} was created {1} days ago. " + "Returns are only allowed within {2} days of purchase." + ).format(invoice_name, days_since_invoice, return_validity_days) + ) + + # Use ERPNext's make_sales_return to create properly mapped return document + # This automatically copies sales_team, taxes, and other child tables + return_doc = make_sales_return(invoice_name) + + # Set POS-specific fields + if pos_opening_shift: + return_doc.posa_pos_opening_shift = pos_opening_shift + + # Ensure POS flags are set + return_doc.is_pos = invoice_info.is_pos + return_doc.pos_profile = invoice_info.pos_profile + + # Aggregate quantities already returned from previous return invoices + ret_si = frappe.qb.DocType("Sales Invoice") + ret_item = frappe.qb.DocType("Sales Invoice Item") + + returned_qty_results = ( + frappe.qb.from_(ret_si) + .inner_join(ret_item) + .on(ret_item.parent == ret_si.name) + .select( + Coalesce(ret_item.sales_invoice_item, ret_item.item_code).as_("key_field"), + Sum(Abs(ret_item.qty)).as_("returned_qty"), + ) + .where((ret_si.return_against == invoice_name) & (ret_si.docstatus == 1) & (ret_si.is_return == 1)) + .groupby(Coalesce(ret_item.sales_invoice_item, ret_item.item_code)) + ).run(as_dict=True) + + returned_qty_map = {row["key_field"]: flt(row["returned_qty"]) for row in returned_qty_results} + + # Convert to dict and update items with remaining quantities + return_dict = return_doc.as_dict() + + # Fetch original invoice payments for refund handling in frontend + si_payment = frappe.qb.DocType("Sales Invoice Payment") + payments_data = ( + frappe.qb.from_(si_payment) + .select(si_payment.mode_of_payment, si_payment.amount, si_payment.base_amount, si_payment.account) + .where(si_payment.parent == invoice_name) + ).run(as_dict=True) + + # Cross-branch return: remap foreign payment modes to the current profile. + # + # When the original invoice's POS profile differs from the current shift's + # profile, the original payment modes (e.g. "Cash lebanon") won't exist in + # the current profile ("4- Boulaq"). _remap_foreign_payment_modes matches + # by Mode of Payment type (Cash->Cash, Bank->Bank) so the frontend + # pre-fills the correct refund method and the resulting GL entries debit + # the correct branch cash/bank account. + # + # This is the primary fix point (Layer 1). The frontend and closing shift + # code have additional safety nets for cases where this remap doesn't run + # (e.g. no pos_opening_shift provided) or for already-submitted invoices + # with the wrong payment mode. + if pos_opening_shift: + current_profile = frappe.db.get_value("POS Opening Shift", pos_opening_shift, "pos_profile") + if current_profile: + payments_data = _remap_foreign_payment_modes( + payments_data, current_profile, invoice_info.pos_profile + ) + + # Include original invoice data for reference (payments, amounts, etc.) + return_dict["_original_invoice"] = { + "name": invoice_name, + "grand_total": invoice_info.grand_total, + "paid_amount": invoice_info.paid_amount, + "outstanding_amount": invoice_info.outstanding_amount, + "customer": invoice_info.customer, + "customer_name": invoice_info.customer_name, + "posting_date": invoice_info.posting_date, + "payments": payments_data, + "net_total": invoice_info.net_total, + "total_taxes_and_charges": invoice_info.total_taxes_and_charges, + } + + item_tax_map = _build_item_tax_map(return_dict.get("taxes", [])) + + # Check if taxes are inclusive by inspecting the tax rows copied from the original + # invoice (immutable after submission, unlike POS Settings which can change later). + # Only consider percentage-based taxes (On Net Total, etc.) — Actual charge types + # are never inclusive (same logic as sales_invoice_hooks.apply_tax_inclusive). + applicable_taxes = [tax for tax in return_dict.get("taxes", []) if tax.get("charge_type") != "Actual"] + tax_inclusive = bool(applicable_taxes) and all( + tax.get("included_in_print_rate") for tax in applicable_taxes + ) + + precision = cint(frappe.get_cached_value("System Settings", None, "currency_precision")) or 2 + + def process_return_item(item): + """Process single item for return, returns None if not returnable.""" + item_ref = item.get("sales_invoice_item") or item.get("item_code") + original_qty = abs(flt(item.get("qty", 0))) + remaining_qty = original_qty - returned_qty_map.get(item_ref, 0) + + if remaining_qty <= 0: + return None + + # Get rate breakdown for display + price_list_rate = flt(item.get("price_list_rate") or item.get("rate"), precision) + net_rate = flt(item.get("net_rate") or item.get("rate"), precision) + tax_per_unit = ( + flt(item_tax_map.get(item.get("item_code"), 0) / original_qty, precision) if original_qty else 0 + ) + + # For inclusive taxes, use the original rate (already includes tax) to prevent + # ERPNext from back-calculating and double-reducing the tax. + # For exclusive taxes, use net_rate as before. + if tax_inclusive: + item_rate = flt(item.get("rate"), precision) + rate_with_tax = item_rate + # Both price_list_rate and rate are tax-inclusive, so discount is their difference + discount_per_unit = flt(price_list_rate - item_rate, precision) + else: + item_rate = net_rate + rate_with_tax = flt(net_rate + tax_per_unit, precision) + discount_per_unit = flt(price_list_rate - net_rate, precision) + + return { + **item, + "original_qty": original_qty, + "already_returned": original_qty - remaining_qty, + "remaining_qty": remaining_qty, + "qty": -remaining_qty, + "price_list_rate": price_list_rate, + "rate": item_rate, + "discount_per_unit": discount_per_unit, + "amount": flt(item_rate * -remaining_qty, precision), + "tax_per_unit": tax_per_unit, + "rate_with_tax": rate_with_tax, + "tax_included_in_rate": tax_inclusive, + } + + return_dict["items"] = [ + processed + for item in return_dict.get("items", []) + if (processed := process_return_item(item)) is not None + ] + + # Check if all items have been fully returned + if not return_dict["items"]: + frappe.throw(_("All items from this invoice have already been returned")) + + return return_dict @frappe.whitelist() def search_invoices_for_return( - invoice_name=None, - company=None, - customer_name=None, - customer_id=None, - mobile_no=None, - from_date=None, - to_date=None, - min_amount=None, - max_amount=None, - page=1, - doctype="Sales Invoice", + invoice_name=None, + company=None, + customer_name=None, + customer_id=None, + mobile_no=None, + from_date=None, + to_date=None, + min_amount=None, + max_amount=None, + page=1, + doctype="Sales Invoice", ): - """Search for invoices that can be returned with pagination. - - Supports filtering by: - - invoice_name: Partial match on invoice number - - company: Exact match - - customer_name, customer_id, mobile_no: Partial match (OR condition) - - from_date, to_date: Date range - - min_amount, max_amount: Amount range - - Returns invoices with their items adjusted to show remaining returnable quantities. - """ - from frappe.query_builder.functions import Sum, Abs, Count - - page = cint(page) or 1 - page_length = 100 - start = (page - 1) * page_length - - # Build main invoice query - si = frappe.qb.DocType(doctype) - - # Start building the query - query = ( - frappe.qb.from_(si) - .select( - si.name, - si.customer, - si.customer_name, - si.posting_date, - si.grand_total, - si.status - ) - .where( - (si.docstatus == 1) - & (si.is_return == 0) - ) - .orderby(si.posting_date, order=frappe.qb.desc) - .orderby(si.name, order=frappe.qb.desc) - .limit(page_length) - .offset(start) - ) - - # Add company filter - if company: - query = query.where(si.company == company) - - # Add invoice name filter - if invoice_name: - query = query.where(si.name.like(f"%{invoice_name}%")) - - # Add date range filters - if from_date and to_date: - query = query.where(si.posting_date.between(from_date, to_date)) - elif from_date: - query = query.where(si.posting_date >= from_date) - elif to_date: - query = query.where(si.posting_date <= to_date) - - # Add amount filters - if min_amount and max_amount: - query = query.where(si.grand_total.between(float(min_amount), float(max_amount))) - elif min_amount: - query = query.where(si.grand_total >= float(min_amount)) - elif max_amount: - query = query.where(si.grand_total <= float(max_amount)) - - # Search customers matching any of the provided criteria (OR logic) - if customer_name or customer_id or mobile_no: - cust = frappe.qb.DocType("Customer") - cust_query = frappe.qb.from_(cust).select(cust.name).limit(100) - - # Build OR conditions for customer search - cust_conditions = [] - if customer_name: - cust_conditions.append(cust.customer_name.like(f"%{customer_name}%")) - if customer_id: - cust_conditions.append(cust.name.like(f"%{customer_id}%")) - if mobile_no: - cust_conditions.append(cust.mobile_no.like(f"%{mobile_no}%")) - - # Combine with OR - if cust_conditions: - combined_condition = cust_conditions[0] - for cond in cust_conditions[1:]: - combined_condition = combined_condition | cond - cust_query = cust_query.where(combined_condition) - - customers = cust_query.run(as_dict=True) - customer_ids = [c.name for c in customers] - - if customer_ids: - query = query.where(si.customer.isin(customer_ids)) - else: - return {"invoices": [], "has_more": False} - - # Execute main query - invoices_list = query.run(as_dict=True) - - if not invoices_list: - return {"invoices": [], "has_more": False} - - invoice_names = [inv["name"] for inv in invoices_list] - - # Count total matching invoices for pagination - count_query = ( - frappe.qb.from_(si) - .select(Count(si.name).as_("total")) - .where( - (si.docstatus == 1) - & (si.is_return == 0) - ) - ) - - # Re-apply the same filters for count - if company: - count_query = count_query.where(si.company == company) - if invoice_name: - count_query = count_query.where(si.name.like(f"%{invoice_name}%")) - if from_date and to_date: - count_query = count_query.where(si.posting_date.between(from_date, to_date)) - elif from_date: - count_query = count_query.where(si.posting_date >= from_date) - elif to_date: - count_query = count_query.where(si.posting_date <= to_date) - if min_amount and max_amount: - count_query = count_query.where(si.grand_total.between(float(min_amount), float(max_amount))) - elif min_amount: - count_query = count_query.where(si.grand_total >= float(min_amount)) - elif max_amount: - count_query = count_query.where(si.grand_total <= float(max_amount)) - if customer_name or customer_id or mobile_no: - if customer_ids: - count_query = count_query.where(si.customer.isin(customer_ids)) - - count_result = count_query.run(as_dict=True) - total_count = count_result[0].total if count_result else 0 - - # Batch fetch returned quantities for all invoices in current page - ret_si = frappe.qb.DocType(doctype) - ret_item = frappe.qb.DocType(f"{doctype} Item") - - returned_qty_results = ( - frappe.qb.from_(ret_si) - .inner_join(ret_item).on(ret_item.parent == ret_si.name) - .select( - ret_si.return_against.as_("invoice_name"), - ret_item.item_code, - Sum(Abs(ret_item.qty)).as_("returned_qty") - ) - .where( - (ret_si.return_against.isin(invoice_names)) - & (ret_si.docstatus == 1) - & (ret_si.is_return == 1) - ) - .groupby(ret_si.return_against, ret_item.item_code) - ).run(as_dict=True) - - # Build a map of invoice_name -> {item_code: returned_qty} - returned_qty_map = {} - for row in returned_qty_results: - inv_name = row["invoice_name"] - if inv_name not in returned_qty_map: - returned_qty_map[inv_name] = {} - returned_qty_map[inv_name][row["item_code"]] = flt(row["returned_qty"]) - - # Batch fetch all items for invoices in current page - si_item = frappe.qb.DocType(f"{doctype} Item") - all_items = ( - frappe.qb.from_(si_item) - .select( - si_item.parent, - si_item.name, - si_item.item_code, - si_item.item_name, - si_item.qty, - si_item.rate, - si_item.amount, - si_item.stock_qty, - si_item.uom, - si_item.warehouse - ) - .where(si_item.parent.isin(invoice_names)) - .orderby(si_item.idx) - ).run(as_dict=True) - - # Group items by parent invoice - items_by_invoice = {} - for item in all_items: - parent = item["parent"] - if parent not in items_by_invoice: - items_by_invoice[parent] = [] - items_by_invoice[parent].append(item) - - # Process and return results - data = [] - for invoice in invoices_list: - inv_name = invoice["name"] - returned_qty = returned_qty_map.get(inv_name, {}) - items = items_by_invoice.get(inv_name, []) - - # Calculate remaining quantities - filtered_items = [] - for item in items: - already_returned = returned_qty.get(item["item_code"], 0) - remaining_qty = flt(item["qty"]) - already_returned - - if remaining_qty > 0: - new_item = item.copy() - new_item["qty"] = remaining_qty - new_item["amount"] = remaining_qty * flt(item["rate"]) - if item.get("stock_qty") and item.get("qty"): - new_item["stock_qty"] = flt(item["stock_qty"]) / flt(item["qty"]) * remaining_qty - filtered_items.append(frappe._dict(new_item)) - - # Only include invoices with returnable items - if filtered_items or not returned_qty: - invoice_data = frappe._dict(invoice) - invoice_data["items"] = filtered_items if filtered_items else items - data.append(invoice_data) - - # Check if there are more results - has_more = (start + page_length) < total_count - - return {"invoices": data, "has_more": has_more} + """Search for invoices that can be returned with pagination. + + Supports filtering by: + - invoice_name: Partial match on invoice number + - company: Exact match + - customer_name, customer_id, mobile_no: Partial match (OR condition) + - from_date, to_date: Date range + - min_amount, max_amount: Amount range + + Returns invoices with their items adjusted to show remaining returnable quantities. + """ + from frappe.query_builder.functions import Abs, Count, Sum + + page = cint(page) or 1 + page_length = 100 + start = (page - 1) * page_length + + # Build main invoice query + si = frappe.qb.DocType(doctype) + + # Start building the query + query = ( + frappe.qb.from_(si) + .select(si.name, si.customer, si.customer_name, si.posting_date, si.grand_total, si.status) + .where((si.docstatus == 1) & (si.is_return == 0)) + .orderby(si.posting_date, order=frappe.qb.desc) + .orderby(si.name, order=frappe.qb.desc) + .limit(page_length) + .offset(start) + ) + + # Add company filter + if company: + query = query.where(si.company == company) + + # Add invoice name filter + if invoice_name: + query = query.where(si.name.like(f"%{invoice_name}%")) + + # Add date range filters + if from_date and to_date: + query = query.where(si.posting_date.between(from_date, to_date)) + elif from_date: + query = query.where(si.posting_date >= from_date) + elif to_date: + query = query.where(si.posting_date <= to_date) + + # Add amount filters + if min_amount and max_amount: + query = query.where(si.grand_total.between(float(min_amount), float(max_amount))) + elif min_amount: + query = query.where(si.grand_total >= float(min_amount)) + elif max_amount: + query = query.where(si.grand_total <= float(max_amount)) + + # Search customers matching any of the provided criteria (OR logic) + if customer_name or customer_id or mobile_no: + cust = frappe.qb.DocType("Customer") + cust_query = frappe.qb.from_(cust).select(cust.name).limit(100) + + # Build OR conditions for customer search + cust_conditions = [] + if customer_name: + cust_conditions.append(cust.customer_name.like(f"%{customer_name}%")) + if customer_id: + cust_conditions.append(cust.name.like(f"%{customer_id}%")) + if mobile_no: + cust_conditions.append(cust.mobile_no.like(f"%{mobile_no}%")) + + # Combine with OR + if cust_conditions: + combined_condition = cust_conditions[0] + for cond in cust_conditions[1:]: + combined_condition = combined_condition | cond + cust_query = cust_query.where(combined_condition) + + customers = cust_query.run(as_dict=True) + customer_ids = [c.name for c in customers] + + if customer_ids: + query = query.where(si.customer.isin(customer_ids)) + else: + return {"invoices": [], "has_more": False} + + # Execute main query + invoices_list = query.run(as_dict=True) + + if not invoices_list: + return {"invoices": [], "has_more": False} + + invoice_names = [inv["name"] for inv in invoices_list] + + # Count total matching invoices for pagination + count_query = ( + frappe.qb.from_(si) + .select(Count(si.name).as_("total")) + .where((si.docstatus == 1) & (si.is_return == 0)) + ) + + # Re-apply the same filters for count + if company: + count_query = count_query.where(si.company == company) + if invoice_name: + count_query = count_query.where(si.name.like(f"%{invoice_name}%")) + if from_date and to_date: + count_query = count_query.where(si.posting_date.between(from_date, to_date)) + elif from_date: + count_query = count_query.where(si.posting_date >= from_date) + elif to_date: + count_query = count_query.where(si.posting_date <= to_date) + if min_amount and max_amount: + count_query = count_query.where(si.grand_total.between(float(min_amount), float(max_amount))) + elif min_amount: + count_query = count_query.where(si.grand_total >= float(min_amount)) + elif max_amount: + count_query = count_query.where(si.grand_total <= float(max_amount)) + if customer_name or customer_id or mobile_no: + if customer_ids: + count_query = count_query.where(si.customer.isin(customer_ids)) + + count_result = count_query.run(as_dict=True) + total_count = count_result[0].total if count_result else 0 + + # Batch fetch returned quantities for all invoices in current page + ret_si = frappe.qb.DocType(doctype) + ret_item = frappe.qb.DocType(f"{doctype} Item") + + returned_qty_results = ( + frappe.qb.from_(ret_si) + .inner_join(ret_item) + .on(ret_item.parent == ret_si.name) + .select( + ret_si.return_against.as_("invoice_name"), + ret_item.item_code, + Sum(Abs(ret_item.qty)).as_("returned_qty"), + ) + .where( + (ret_si.return_against.isin(invoice_names)) & (ret_si.docstatus == 1) & (ret_si.is_return == 1) + ) + .groupby(ret_si.return_against, ret_item.item_code) + ).run(as_dict=True) + + # Build a map of invoice_name -> {item_code: returned_qty} + returned_qty_map = {} + for row in returned_qty_results: + inv_name = row["invoice_name"] + if inv_name not in returned_qty_map: + returned_qty_map[inv_name] = {} + returned_qty_map[inv_name][row["item_code"]] = flt(row["returned_qty"]) + + # Batch fetch all items for invoices in current page + si_item = frappe.qb.DocType(f"{doctype} Item") + all_items = ( + frappe.qb.from_(si_item) + .select( + si_item.parent, + si_item.name, + si_item.item_code, + si_item.item_name, + si_item.qty, + si_item.rate, + si_item.amount, + si_item.stock_qty, + si_item.uom, + si_item.warehouse, + ) + .where(si_item.parent.isin(invoice_names)) + .orderby(si_item.idx) + ).run(as_dict=True) + + # Group items by parent invoice + items_by_invoice = {} + for item in all_items: + parent = item["parent"] + if parent not in items_by_invoice: + items_by_invoice[parent] = [] + items_by_invoice[parent].append(item) + + # Process and return results + data = [] + for invoice in invoices_list: + inv_name = invoice["name"] + returned_qty = returned_qty_map.get(inv_name, {}) + items = items_by_invoice.get(inv_name, []) + + # Calculate remaining quantities + filtered_items = [] + for item in items: + already_returned = returned_qty.get(item["item_code"], 0) + remaining_qty = flt(item["qty"]) - already_returned + + if remaining_qty > 0: + new_item = item.copy() + new_item["qty"] = remaining_qty + new_item["amount"] = remaining_qty * flt(item["rate"]) + if item.get("stock_qty") and item.get("qty"): + new_item["stock_qty"] = flt(item["stock_qty"]) / flt(item["qty"]) * remaining_qty + filtered_items.append(frappe._dict(new_item)) + + # Only include invoices with returnable items + if filtered_items or not returned_qty: + invoice_data = frappe._dict(invoice) + invoice_data["items"] = filtered_items if filtered_items else items + data.append(invoice_data) + + # Check if there are more results + has_more = (start + page_length) < total_count + + return {"invoices": data, "has_more": has_more} # ========================================== @@ -2741,592 +2729,540 @@ def search_invoices_for_return( def _evaluate_transaction_offers( - invoice, - profile, - pricing_items, - customer, - customer_group, - territory, - posting_date, - currency, - price_list, - rule_map, - selected_offer_names, + invoice, + profile, + pricing_items, + customer, + customer_group, + territory, + posting_date, + currency, + price_list, + rule_map, + selected_offer_names, ): - """Run ERPNext's transaction-level pricing engine and collect free items. - - ERPNext routes `apply_on = "Transaction"` rules through a different entry - point (`apply_pricing_rule_on_transaction`) than the per-item engine. That - function mutates a real Sales Invoice document in place — appending free - item rows via `doc.append("items", ...)` — so we build a transient, - never-saved Sales Invoice document for evaluation only. - - Returns {"free_items": dict keyed by (item_code, rule_name), "applied_rules": set}. - """ - if not erpnext_apply_pricing_rule_on_transaction or not pricing_items: - return {"free_items": {}, "applied_rules": set()} - - total_qty = sum(flt(it.qty) for it in pricing_items) - total = sum(flt(it.qty) * flt(it.rate) for it in pricing_items) - if total <= 0: - return {"free_items": {}, "applied_rules": set()} - - doc = frappe.new_doc("Sales Invoice") - doc.update( - { - "is_pos": 1, - "company": profile.company, - "currency": currency, - "conversion_rate": 1, - "selling_price_list": price_list, - "price_list_currency": currency, - "plc_conversion_rate": 1, - "customer": customer, - "customer_group": customer_group, - "territory": territory, - "transaction_date": posting_date, - "posting_date": posting_date, - "pos_profile": invoice.get("pos_profile"), - "coupon_code": invoice.get("coupon_code") or None, - } - ) - doc.flags.ignore_mandatory = True - - for prep in pricing_items: - doc.append( - "items", - { - "item_code": prep.item_code, - "item_name": prep.item_name, - "item_group": prep.item_group, - "brand": prep.brand, - "qty": prep.qty, - "stock_qty": prep.stock_qty, - "conversion_factor": prep.conversion_factor, - "uom": prep.uom, - "stock_uom": prep.stock_uom, - "rate": prep.rate, - "price_list_rate": prep.price_list_rate, - "base_rate": prep.base_rate, - "base_price_list_rate": prep.base_price_list_rate, - "amount": flt(prep.rate) * flt(prep.qty), - "warehouse": prep.warehouse, - }, - ) - - # filter_pricing_rules_for_qty_amount reads these straight off the doc - # (erpnext/accounts/doctype/pricing_rule/utils.py:572). - doc.total_qty = total_qty - doc.total = total - - initial_item_count = len(doc.items) - pre_addl_pct = flt(doc.get("additional_discount_percentage") or 0) - pre_discount_amt = flt(doc.get("discount_amount") or 0) - try: - erpnext_apply_pricing_rule_on_transaction(doc) - except Exception: - # A misconfigured transaction-scoped rule must not break the per-item - # discounts that have already been computed by the caller. - frappe.log_error( - frappe.get_traceback(), "POS Apply Offers (Transaction Rules)" - ) - return { - "free_items": {}, - "applied_rules": set(), - "additional_discount_percentage": 0, - "discount_amount": 0, - "apply_discount_on": None, - } - - free_items = {} - applied_rules = set() - for row in doc.items[initial_item_count:]: - if not getattr(row, "is_free_item", 0): - continue - rule_name = row.get("pricing_rules") - if not rule_name or rule_name not in rule_map: - continue - if selected_offer_names and rule_name not in selected_offer_names: - continue - fid = frappe._dict(row.as_dict()) - fid.applied_promotional_scheme = rule_map[rule_name].promotional_scheme - free_items[(row.item_code, rule_name)] = fid - applied_rules.add(rule_name) - - # Capture header-level discount that ERPNext's apply_pricing_rule_on_transaction - # set on the doc when a Price-type Transaction rule fired. ERPNext writes one of - # additional_discount_percentage / discount_amount onto the doc (see - # erpnext/accounts/doctype/pricing_rule/utils.py:578-616) but does not surface - # which rule fired. We detect "fired" by diffing the doc fields against the - # pre-call snapshot and attribute the application to every selected, in-scope - # transaction-level Price rule in rule_map. The frontend treats the response - # additional_discount_percentage / discount_amount as authoritative for the - # header, so attribution mismatches only affect the UI badge, not totals. - post_addl_pct = flt(doc.get("additional_discount_percentage") or 0) - post_discount_amt = flt(doc.get("discount_amount") or 0) - apply_discount_on = doc.get("apply_discount_on") or None - - header_discount_changed = ( - post_addl_pct != pre_addl_pct or post_discount_amt != pre_discount_amt - ) - if header_discount_changed: - for rule_name, details in rule_map.items(): - if selected_offer_names and rule_name not in selected_offer_names: - continue - if details.get("price_or_product_discount") != "Price": - continue - if frappe.db.get_value("Pricing Rule", rule_name, "apply_on") != "Transaction": - continue - applied_rules.add(rule_name) - - return { - "free_items": free_items, - "applied_rules": applied_rules, - "additional_discount_percentage": post_addl_pct, - "discount_amount": post_discount_amt, - "apply_discount_on": apply_discount_on, - } + """Run ERPNext's transaction-level pricing engine and collect free items. + + ERPNext routes `apply_on = "Transaction"` rules through a different entry + point (`apply_pricing_rule_on_transaction`) than the per-item engine. That + function mutates a real Sales Invoice document in place — appending free + item rows via `doc.append("items", ...)` — so we build a transient, + never-saved Sales Invoice document for evaluation only. + + Returns {"free_items": dict keyed by (item_code, rule_name), "applied_rules": set}. + """ + if not erpnext_apply_pricing_rule_on_transaction or not pricing_items: + return {"free_items": {}, "applied_rules": set()} + + total_qty = sum(flt(it.qty) for it in pricing_items) + total = sum(flt(it.qty) * flt(it.rate) for it in pricing_items) + if total <= 0: + return {"free_items": {}, "applied_rules": set()} + + doc = frappe.new_doc("Sales Invoice") + doc.update( + { + "is_pos": 1, + "company": profile.company, + "currency": currency, + "conversion_rate": 1, + "selling_price_list": price_list, + "price_list_currency": currency, + "plc_conversion_rate": 1, + "customer": customer, + "customer_group": customer_group, + "territory": territory, + "transaction_date": posting_date, + "posting_date": posting_date, + "pos_profile": invoice.get("pos_profile"), + "coupon_code": invoice.get("coupon_code") or None, + } + ) + doc.flags.ignore_mandatory = True + + for prep in pricing_items: + doc.append( + "items", + { + "item_code": prep.item_code, + "item_name": prep.item_name, + "item_group": prep.item_group, + "brand": prep.brand, + "qty": prep.qty, + "stock_qty": prep.stock_qty, + "conversion_factor": prep.conversion_factor, + "uom": prep.uom, + "stock_uom": prep.stock_uom, + "rate": prep.rate, + "price_list_rate": prep.price_list_rate, + "base_rate": prep.base_rate, + "base_price_list_rate": prep.base_price_list_rate, + "amount": flt(prep.rate) * flt(prep.qty), + "warehouse": prep.warehouse, + }, + ) + + # filter_pricing_rules_for_qty_amount reads these straight off the doc + # (erpnext/accounts/doctype/pricing_rule/utils.py:572). + doc.total_qty = total_qty + doc.total = total + + initial_item_count = len(doc.items) + pre_addl_pct = flt(doc.get("additional_discount_percentage") or 0) + pre_discount_amt = flt(doc.get("discount_amount") or 0) + try: + erpnext_apply_pricing_rule_on_transaction(doc) + except Exception: + # A misconfigured transaction-scoped rule must not break the per-item + # discounts that have already been computed by the caller. + frappe.log_error(frappe.get_traceback(), "POS Apply Offers (Transaction Rules)") + return { + "free_items": {}, + "applied_rules": set(), + "additional_discount_percentage": 0, + "discount_amount": 0, + "apply_discount_on": None, + } + + free_items = {} + applied_rules = set() + for row in doc.items[initial_item_count:]: + if not getattr(row, "is_free_item", 0): + continue + rule_name = row.get("pricing_rules") + if not rule_name or rule_name not in rule_map: + continue + if selected_offer_names and rule_name not in selected_offer_names: + continue + fid = frappe._dict(row.as_dict()) + fid.applied_promotional_scheme = rule_map[rule_name].promotional_scheme + free_items[(row.item_code, rule_name)] = fid + applied_rules.add(rule_name) + + # Capture header-level discount that ERPNext's apply_pricing_rule_on_transaction + # set on the doc when a Price-type Transaction rule fired. ERPNext writes one of + # additional_discount_percentage / discount_amount onto the doc (see + # erpnext/accounts/doctype/pricing_rule/utils.py:578-616) but does not surface + # which rule fired. We detect "fired" by diffing the doc fields against the + # pre-call snapshot and attribute the application to every selected, in-scope + # transaction-level Price rule in rule_map. The frontend treats the response + # additional_discount_percentage / discount_amount as authoritative for the + # header, so attribution mismatches only affect the UI badge, not totals. + post_addl_pct = flt(doc.get("additional_discount_percentage") or 0) + post_discount_amt = flt(doc.get("discount_amount") or 0) + apply_discount_on = doc.get("apply_discount_on") or None + + header_discount_changed = post_addl_pct != pre_addl_pct or post_discount_amt != pre_discount_amt + if header_discount_changed: + for rule_name, details in rule_map.items(): + if selected_offer_names and rule_name not in selected_offer_names: + continue + if details.get("price_or_product_discount") != "Price": + continue + if frappe.db.get_value("Pricing Rule", rule_name, "apply_on") != "Transaction": + continue + applied_rules.add(rule_name) + + return { + "free_items": free_items, + "applied_rules": applied_rules, + "additional_discount_percentage": post_addl_pct, + "discount_amount": post_discount_amt, + "apply_discount_on": apply_discount_on, + } @frappe.whitelist() def apply_offers(invoice_data, selected_offers=None): - """Calculate and apply promotional offers using ERPNext Pricing Rules. - - Args: - invoice_data (str | dict): Sales Invoice payload used for offer evaluation. - selected_offers (str | list | None): Optional collection of Pricing Rule names. - When provided, results are filtered to only include these rules. - ERPNext handles all conflict resolution based on priority. - """ - try: - if isinstance(invoice_data, str): - invoice_data = json.loads(invoice_data or "{}") - - invoice = frappe._dict(invoice_data or {}) - items = invoice.get("items") or [] - - if isinstance(selected_offers, str): - try: - selected_offers = json.loads(selected_offers) - except ValueError: - selected_offers = [selected_offers] - - if isinstance(selected_offers, (list, tuple, set)): - selected_offer_names = { - cstr(name) for name in selected_offers if cstr(name) - } - else: - selected_offer_names = set() - - if not items: - return {"items": []} - - if not invoice.get("pos_profile") or not erpnext_apply_pricing_rule: - # Either no POS profile supplied or ERPNext promotional engine unavailable - return {"items": items} - - profile = frappe.get_cached_doc("POS Profile", invoice.get("pos_profile")) - - # Respect POS Profile's ignore_pricing_rule setting - if profile.ignore_pricing_rule: - return {"items": items} - - # Batch fetch all item details in a single query (reduces N queries to 1) - item_codes = list({item.get("item_code") for item in items if item.get("item_code")}) - item_details_map = {} - if item_codes: - item_records = frappe.get_all( - "Item", - filters={"name": ["in", item_codes]}, - fields=["name", "item_name", "item_group", "brand", "stock_uom"], - ) - item_details_map = {r.name: r for r in item_records} - - pricing_items = [] - index_map = [] - prepared_items = [frappe._dict(row) for row in items] - - for idx, item in enumerate(prepared_items): - item_code = item.get("item_code") - qty = flt(item.get("qty") or item.get("quantity") or 0) - - if not item_code or qty <= 0: - continue - - # Use batch-fetched item details - cached = item_details_map.get(item_code) - - conversion_factor = flt(item.get("conversion_factor") or 1) or 1 - price_list_rate = flt(item.get("price_list_rate") or item.get("rate") or 0) - - pricing_items.append( - frappe._dict( - { - "doctype": "Sales Invoice Item", - "name": item.get("name") or f"POS-{idx}", - "item_code": item_code, - "item_name": ( - cached.item_name if cached else item.get("item_name") - ), - "item_group": ( - cached.item_group if cached else item.get("item_group") - ), - "brand": (cached.brand if cached else item.get("brand")), - "qty": qty, - "stock_qty": qty * conversion_factor, - "conversion_factor": conversion_factor, - "uom": item.get("uom") - or item.get("stock_uom") - or (cached.stock_uom if cached else None), - "stock_uom": item.get("stock_uom") - or (cached.stock_uom if cached else None), - "price_list_rate": price_list_rate, - "base_price_list_rate": price_list_rate, - "rate": flt(item.get("rate") or price_list_rate), - "base_rate": flt(item.get("rate") or price_list_rate), - "discount_percentage": 0, - "discount_amount": 0, - "warehouse": item.get("warehouse") or profile.warehouse, - "parenttype": invoice.get("doctype") or "Sales Invoice", - } - ) - ) - index_map.append(idx) - - # Clear previously applied promotional metadata if the - # current quantity can no longer satisfy the rule. - item.discount_percentage = 0 - item.discount_amount = 0 - item.pricing_rules = [] - item.applied_promotional_schemes = [] - - if not pricing_items: - return {"items": items} - - company_currency = frappe.get_cached_value( - "Company", profile.company, "default_currency" - ) - - # Get customer details if customer is provided - customer = invoice.get("customer") - customer_group = invoice.get("customer_group") - territory = invoice.get("territory") - - if customer and not customer_group: - # Fetch customer_group from customer - try: - customer_data = frappe.get_cached_value( - "Customer", customer, ["customer_group", "territory"], as_dict=1 - ) - if customer_data: - customer_group = customer_data.get("customer_group") - if not territory: - territory = customer_data.get("territory") - except Exception as e: - # Customer lookup failed, will use defaults - frappe.log_error( - f"Failed to fetch customer data for {customer}: {e}", - "Customer Data Lookup" - ) - - # If still no customer_group, use default - if not customer_group: - customer_group = "All Customer Groups" - - pricing_args = frappe._dict( - { - "doctype": invoice.get("doctype") or "Sales Invoice", - "name": invoice.get("name") or "POS-INVOICE", - "is_pos": 1, - "company": profile.company, - "transaction_date": invoice.get("posting_date") or nowdate(), - "posting_date": invoice.get("posting_date") or nowdate(), - "currency": invoice.get("currency") - or profile.get("currency") - or company_currency, - "conversion_rate": flt(invoice.get("conversion_rate") or 1) or 1, - "plc_conversion_rate": flt(invoice.get("plc_conversion_rate") or 1) - or 1, - "price_list": invoice.get("price_list") - or profile.get("selling_price_list"), - "customer": customer, - "customer_group": customer_group, - "territory": territory, - "items": pricing_items, - } - ) - - # Call ERPNext pricing engine - it handles all conflicts based on priority - # - # Why we pass pricing_args twice: - # - 1st param (args): ERPNext extracts and pops 'items' from this, then processes each item individually - # - 2nd param (doc): Used by 'mixed_conditions' pricing rules to access the FULL items list - # for quantity accumulation across different items in the same group - # - # Example: A rule "Buy 2 from Demo Item Group, get 10% off" with mixed_conditions=1 - # needs to see ALL items (1 Book + 1 Camera) to know total qty=2, not just each item's qty=1 - # - # See: erpnext/accounts/doctype/pricing_rule/utils.py -> get_qty_and_rate_for_mixed_conditions() - pricing_results = erpnext_apply_pricing_rule(pricing_args, doc=pricing_args) or [] - - if not pricing_results: - return {"items": items} - - raw_rule_names = set() - for result in pricing_results: - if not result: - continue - rules = [] - if erpnext_get_applied_pricing_rules: - rules = erpnext_get_applied_pricing_rules(result.get("pricing_rules")) - else: - raw_rules = result.get("pricing_rules") or [] - if isinstance(raw_rules, str): - if raw_rules.startswith("["): - rules = json.loads(raw_rules) - else: - rules = [r.strip() for r in raw_rules.split(",") if r.strip()] - elif isinstance(raw_rules, (list, tuple, set)): - rules = list(raw_rules) - raw_rule_names.update(rules) - - # Build a map of applicable pricing rules from the ERPNext engine results. - # - # ERPNext has two types of pricing rules: - # - # 1. Promotional Scheme Rules (promotional_scheme is set): - # - Created automatically when a Promotional Scheme is saved - # - The scheme acts as a "template" that generates one or more Pricing Rules - # - Example: "Summer Sale" scheme creates "PRLE-0001", "PRLE-0002" rules - # - # 2. Standalone Pricing Rules (promotional_scheme is empty): - # - Created directly as Pricing Rule documents - # - Not linked to any Promotional Scheme - # - Example: A direct "10% off Item X" rule created in Pricing Rule doctype - # - # We include BOTH types for POS, but exclude coupon_code_based rules - # (those require explicit coupon entry and are handled separately). - # - rule_map = {} - if raw_rule_names: - rule_records = frappe.get_all( - "Pricing Rule", - filters={"name": ["in", list(raw_rule_names)]}, - fields=[ - "name", - "promotional_scheme", - "coupon_code_based", - "promotional_scheme_id", - "price_or_product_discount", - ], - ) - for record in rule_records: - # Skip coupon-based rules (require explicit coupon code entry) - if record.coupon_code_based: - continue - - # Include both promotional scheme rules and standalone pricing rules - rule_map[record.name] = record - - # Top up rule_map with transaction-scoped rules. The per-item engine - # never surfaces apply_on="Transaction" rules, so without this they - # would be dropped at the `if not rule_map: return` check below. - # ERPNext's own SQL inside apply_pricing_rule_on_transaction handles - # date/currency/pos_only filtering, so a broad superset is sufficient. - if erpnext_apply_pricing_rule_on_transaction: - txn_rule_records = frappe.get_all( - "Pricing Rule", - filters={ - "disable": 0, - "apply_on": "Transaction", - "company": profile.company, - "selling": 1, - "coupon_code_based": 0, - }, - fields=[ - "name", - "promotional_scheme", - "coupon_code_based", - "promotional_scheme_id", - "price_or_product_discount", - ], - ) - for record in txn_rule_records: - rule_map.setdefault(record.name, record) - - if selected_offer_names: - # Restrict available rules to the ones explicitly selected from the UI. - rule_map = { - name: details - for name, details in rule_map.items() - if name in selected_offer_names - } - - if not rule_map: - return {"items": items} - - applied_rules = set() - # Deduplicate free items using a dict keyed by (item_code, pricing_rule). - # ERPNext's apply_pricing_rule() returns one result per cart item and for - # mixed_conditions rules attaches the same free_item_data to every matching - # item's result. ERPNext's own apply_pricing_rule_for_free_items() deduplicates - # the same way: {(item_code, pricing_rules): data for data in free_item_data}. - free_items_map = {} - - for result, item_index in zip(pricing_results, index_map): - if not result: - continue - - if erpnext_get_applied_pricing_rules: - rule_names = erpnext_get_applied_pricing_rules( - result.get("pricing_rules") - ) - else: - raw_rules = result.get("pricing_rules") or [] - if isinstance(raw_rules, str): - if raw_rules.startswith("["): - rule_names = json.loads(raw_rules) - else: - rule_names = [ - r.strip() for r in raw_rules.split(",") if r.strip() - ] - elif isinstance(raw_rules, (list, tuple, set)): - rule_names = list(raw_rules) - else: - rule_names = [] - - applicable_rule_names = [ - name for name in rule_names or [] if name in rule_map - ] - - if not applicable_rule_names: - continue - - applied_rules.update(applicable_rule_names) - - item_doc = prepared_items[item_index] - qty = flt(item_doc.get("qty") or item_doc.get("quantity") or 0) - price_list_rate = flt( - result.get("price_list_rate") - or item_doc.get("price_list_rate") - or item_doc.get("rate") - or 0 - ) - - # Get discount from result or fetch from pricing rule - discount_percentage = flt(result.get("discount_percentage") or 0) - per_unit_discount = flt(result.get("discount_amount") or 0) - - # If ERPNext didn't calculate discount (validate_applied_rule=1), - # we need to fetch and apply it manually - if ( - not discount_percentage - and not per_unit_discount - and applicable_rule_names - ): - for rule_name in applicable_rule_names: - rule_doc = rule_map.get(rule_name) - if not rule_doc: - continue - - # Fetch full pricing rule to get discount values - full_rule = frappe.get_cached_doc("Pricing Rule", rule_name) - - if ( - full_rule.rate_or_discount == "Discount Percentage" - and full_rule.discount_percentage - ): - discount_percentage += flt(full_rule.discount_percentage) - elif ( - full_rule.rate_or_discount == "Discount Amount" - and full_rule.discount_amount - ): - per_unit_discount += flt(full_rule.discount_amount) - elif full_rule.rate_or_discount == "Rate" and full_rule.rate: - # Apply fixed rate - price_list_rate = flt(full_rule.rate) - - line_discount_amount = 0 - if discount_percentage and qty and price_list_rate: - line_discount_amount = price_list_rate * qty * discount_percentage / 100 - elif per_unit_discount and qty: - line_discount_amount = per_unit_discount * qty - else: - line_discount_amount = per_unit_discount - - if ( - not discount_percentage - and line_discount_amount - and qty - and price_list_rate - ): - base_amount = price_list_rate * qty - if base_amount: - discount_percentage = (line_discount_amount / base_amount) * 100 - - item_doc.discount_percentage = discount_percentage - item_doc.discount_amount = line_discount_amount - item_doc.price_list_rate = price_list_rate - item_doc.rate = flt(item_doc.get("rate") or price_list_rate) - # ERPNext expects pricing_rules as comma-separated string, not a list - item_doc.pricing_rules = ",".join(applicable_rule_names) if applicable_rule_names else "" - - item_doc.applied_promotional_schemes = list( - { - rule_map[name].promotional_scheme - for name in applicable_rule_names - if rule_map[name].promotional_scheme - } - ) - - for free_item in result.get("free_item_data") or []: - rule_name = free_item.get("pricing_rules") - if not rule_name or rule_name not in rule_map: - continue - free_item_doc = frappe._dict(free_item) - free_item_doc.applied_promotional_scheme = rule_map[ - rule_name - ].promotional_scheme - free_items_map[(free_item.get("item_code"), rule_name)] = free_item_doc - - # Evaluate apply_on="Transaction" rules through ERPNext's separate - # transaction-level engine. The per-item engine above does not see - # them, so without this step "Entire Transaction" promotional schemes - # (free product based on cart total) would never apply. - txn_result = _evaluate_transaction_offers( - invoice, - profile, - pricing_items, - customer, - customer_group, - territory, - invoice.get("posting_date") or nowdate(), - pricing_args.currency, - pricing_args.price_list, - rule_map, - selected_offer_names, - ) - # Per-item results win on collisions because they already carry full - # discount metadata from the per-item engine result. - for key, free_item_doc in txn_result.get("free_items", {}).items(): - free_items_map.setdefault(key, free_item_doc) - applied_rules.update(txn_result.get("applied_rules", set())) - - return { - "items": [dict(item) for item in prepared_items], - "free_items": [dict(item) for item in free_items_map.values()], - "applied_pricing_rules": sorted(applied_rules), - # Header-level (transaction-scope) discount surfaced from - # _evaluate_transaction_offers. Frontend should apply these to the - # invoice header (additionalDiscount + apply_discount_on) when - # present. Both fields are zero when no transaction-level Price - # rule fired. - "additional_discount_percentage": flt( - txn_result.get("additional_discount_percentage") or 0 - ), - "discount_amount": flt(txn_result.get("discount_amount") or 0), - "apply_discount_on": txn_result.get("apply_discount_on"), - } - except Exception as e: - frappe.log_error(frappe.get_traceback(), "Apply Offers Error") - frappe.throw(_("Error applying offers: {0}").format(str(e))) + """Calculate and apply promotional offers using ERPNext Pricing Rules. + + Args: + invoice_data (str | dict): Sales Invoice payload used for offer evaluation. + selected_offers (str | list | None): Optional collection of Pricing Rule names. + When provided, results are filtered to only include these rules. + ERPNext handles all conflict resolution based on priority. + """ + try: + if isinstance(invoice_data, str): + invoice_data = json.loads(invoice_data or "{}") + + invoice = frappe._dict(invoice_data or {}) + items = invoice.get("items") or [] + + if isinstance(selected_offers, str): + try: + selected_offers = json.loads(selected_offers) + except ValueError: + selected_offers = [selected_offers] + + if isinstance(selected_offers, (list, tuple, set)): + selected_offer_names = {cstr(name) for name in selected_offers if cstr(name)} + else: + selected_offer_names = set() + + if not items: + return {"items": []} + + if not invoice.get("pos_profile") or not erpnext_apply_pricing_rule: + # Either no POS profile supplied or ERPNext promotional engine unavailable + return {"items": items} + + profile = frappe.get_cached_doc("POS Profile", invoice.get("pos_profile")) + + # Respect POS Profile's ignore_pricing_rule setting + if profile.ignore_pricing_rule: + return {"items": items} + + # Batch fetch all item details in a single query (reduces N queries to 1) + item_codes = list({item.get("item_code") for item in items if item.get("item_code")}) + item_details_map = {} + if item_codes: + item_records = frappe.get_all( + "Item", + filters={"name": ["in", item_codes]}, + fields=["name", "item_name", "item_group", "brand", "stock_uom"], + ) + item_details_map = {r.name: r for r in item_records} + + pricing_items = [] + index_map = [] + prepared_items = [frappe._dict(row) for row in items] + + for idx, item in enumerate(prepared_items): + item_code = item.get("item_code") + qty = flt(item.get("qty") or item.get("quantity") or 0) + + if not item_code or qty <= 0: + continue + + # Use batch-fetched item details + cached = item_details_map.get(item_code) + + conversion_factor = flt(item.get("conversion_factor") or 1) or 1 + price_list_rate = flt(item.get("price_list_rate") or item.get("rate") or 0) + + pricing_items.append( + frappe._dict( + { + "doctype": "Sales Invoice Item", + "name": item.get("name") or f"POS-{idx}", + "item_code": item_code, + "item_name": (cached.item_name if cached else item.get("item_name")), + "item_group": (cached.item_group if cached else item.get("item_group")), + "brand": (cached.brand if cached else item.get("brand")), + "qty": qty, + "stock_qty": qty * conversion_factor, + "conversion_factor": conversion_factor, + "uom": item.get("uom") + or item.get("stock_uom") + or (cached.stock_uom if cached else None), + "stock_uom": item.get("stock_uom") or (cached.stock_uom if cached else None), + "price_list_rate": price_list_rate, + "base_price_list_rate": price_list_rate, + "rate": flt(item.get("rate") or price_list_rate), + "base_rate": flt(item.get("rate") or price_list_rate), + "discount_percentage": 0, + "discount_amount": 0, + "warehouse": item.get("warehouse") or profile.warehouse, + "parenttype": invoice.get("doctype") or "Sales Invoice", + } + ) + ) + index_map.append(idx) + + # Clear previously applied promotional metadata if the + # current quantity can no longer satisfy the rule. + item.discount_percentage = 0 + item.discount_amount = 0 + item.pricing_rules = [] + item.applied_promotional_schemes = [] + + if not pricing_items: + return {"items": items} + + company_currency = frappe.get_cached_value("Company", profile.company, "default_currency") + + # Get customer details if customer is provided + customer = invoice.get("customer") + customer_group = invoice.get("customer_group") + territory = invoice.get("territory") + + if customer and not customer_group: + # Fetch customer_group from customer + try: + customer_data = frappe.get_cached_value( + "Customer", customer, ["customer_group", "territory"], as_dict=1 + ) + if customer_data: + customer_group = customer_data.get("customer_group") + if not territory: + territory = customer_data.get("territory") + except Exception as e: + # Customer lookup failed, will use defaults + frappe.log_error(f"Failed to fetch customer data for {customer}: {e}", "Customer Data Lookup") + + # If still no customer_group, use default + if not customer_group: + customer_group = "All Customer Groups" + + pricing_args = frappe._dict( + { + "doctype": invoice.get("doctype") or "Sales Invoice", + "name": invoice.get("name") or "POS-INVOICE", + "is_pos": 1, + "company": profile.company, + "transaction_date": invoice.get("posting_date") or nowdate(), + "posting_date": invoice.get("posting_date") or nowdate(), + "currency": invoice.get("currency") or profile.get("currency") or company_currency, + "conversion_rate": flt(invoice.get("conversion_rate") or 1) or 1, + "plc_conversion_rate": flt(invoice.get("plc_conversion_rate") or 1) or 1, + "price_list": invoice.get("price_list") or profile.get("selling_price_list"), + "customer": customer, + "customer_group": customer_group, + "territory": territory, + "items": pricing_items, + } + ) + + # Call ERPNext pricing engine - it handles all conflicts based on priority + # + # Why we pass pricing_args twice: + # - 1st param (args): ERPNext extracts and pops 'items' from this, then processes each item individually + # - 2nd param (doc): Used by 'mixed_conditions' pricing rules to access the FULL items list + # for quantity accumulation across different items in the same group + # + # Example: A rule "Buy 2 from Demo Item Group, get 10% off" with mixed_conditions=1 + # needs to see ALL items (1 Book + 1 Camera) to know total qty=2, not just each item's qty=1 + # + # See: erpnext/accounts/doctype/pricing_rule/utils.py -> get_qty_and_rate_for_mixed_conditions() + pricing_results = erpnext_apply_pricing_rule(pricing_args, doc=pricing_args) or [] + + if not pricing_results: + return {"items": items} + + raw_rule_names = set() + for result in pricing_results: + if not result: + continue + rules = [] + if erpnext_get_applied_pricing_rules: + rules = erpnext_get_applied_pricing_rules(result.get("pricing_rules")) + else: + raw_rules = result.get("pricing_rules") or [] + if isinstance(raw_rules, str): + if raw_rules.startswith("["): + rules = json.loads(raw_rules) + else: + rules = [r.strip() for r in raw_rules.split(",") if r.strip()] + elif isinstance(raw_rules, (list, tuple, set)): + rules = list(raw_rules) + raw_rule_names.update(rules) + + # Build a map of applicable pricing rules from the ERPNext engine results. + # + # ERPNext has two types of pricing rules: + # + # 1. Promotional Scheme Rules (promotional_scheme is set): + # - Created automatically when a Promotional Scheme is saved + # - The scheme acts as a "template" that generates one or more Pricing Rules + # - Example: "Summer Sale" scheme creates "PRLE-0001", "PRLE-0002" rules + # + # 2. Standalone Pricing Rules (promotional_scheme is empty): + # - Created directly as Pricing Rule documents + # - Not linked to any Promotional Scheme + # - Example: A direct "10% off Item X" rule created in Pricing Rule doctype + # + # We include BOTH types for POS, but exclude coupon_code_based rules + # (those require explicit coupon entry and are handled separately). + # + rule_map = {} + if raw_rule_names: + rule_records = frappe.get_all( + "Pricing Rule", + filters={"name": ["in", list(raw_rule_names)]}, + fields=[ + "name", + "promotional_scheme", + "coupon_code_based", + "promotional_scheme_id", + "price_or_product_discount", + ], + ) + for record in rule_records: + # Skip coupon-based rules (require explicit coupon code entry) + if record.coupon_code_based: + continue + + # Include both promotional scheme rules and standalone pricing rules + rule_map[record.name] = record + + # Top up rule_map with transaction-scoped rules. The per-item engine + # never surfaces apply_on="Transaction" rules, so without this they + # would be dropped at the `if not rule_map: return` check below. + # ERPNext's own SQL inside apply_pricing_rule_on_transaction handles + # date/currency/pos_only filtering, so a broad superset is sufficient. + if erpnext_apply_pricing_rule_on_transaction: + txn_rule_records = frappe.get_all( + "Pricing Rule", + filters={ + "disable": 0, + "apply_on": "Transaction", + "company": profile.company, + "selling": 1, + "coupon_code_based": 0, + }, + fields=[ + "name", + "promotional_scheme", + "coupon_code_based", + "promotional_scheme_id", + "price_or_product_discount", + ], + ) + for record in txn_rule_records: + rule_map.setdefault(record.name, record) + + if selected_offer_names: + # Restrict available rules to the ones explicitly selected from the UI. + rule_map = {name: details for name, details in rule_map.items() if name in selected_offer_names} + + if not rule_map: + return {"items": items} + + applied_rules = set() + # Deduplicate free items using a dict keyed by (item_code, pricing_rule). + # ERPNext's apply_pricing_rule() returns one result per cart item and for + # mixed_conditions rules attaches the same free_item_data to every matching + # item's result. ERPNext's own apply_pricing_rule_for_free_items() deduplicates + # the same way: {(item_code, pricing_rules): data for data in free_item_data}. + free_items_map = {} + + for result, item_index in zip(pricing_results, index_map): + if not result: + continue + + if erpnext_get_applied_pricing_rules: + rule_names = erpnext_get_applied_pricing_rules(result.get("pricing_rules")) + else: + raw_rules = result.get("pricing_rules") or [] + if isinstance(raw_rules, str): + if raw_rules.startswith("["): + rule_names = json.loads(raw_rules) + else: + rule_names = [r.strip() for r in raw_rules.split(",") if r.strip()] + elif isinstance(raw_rules, (list, tuple, set)): + rule_names = list(raw_rules) + else: + rule_names = [] + + applicable_rule_names = [name for name in rule_names or [] if name in rule_map] + + if not applicable_rule_names: + continue + + applied_rules.update(applicable_rule_names) + + item_doc = prepared_items[item_index] + qty = flt(item_doc.get("qty") or item_doc.get("quantity") or 0) + price_list_rate = flt( + result.get("price_list_rate") or item_doc.get("price_list_rate") or item_doc.get("rate") or 0 + ) + + # Get discount from result or fetch from pricing rule + discount_percentage = flt(result.get("discount_percentage") or 0) + per_unit_discount = flt(result.get("discount_amount") or 0) + + # If ERPNext didn't calculate discount (validate_applied_rule=1), + # we need to fetch and apply it manually + if not discount_percentage and not per_unit_discount and applicable_rule_names: + for rule_name in applicable_rule_names: + rule_doc = rule_map.get(rule_name) + if not rule_doc: + continue + + # Fetch full pricing rule to get discount values + full_rule = frappe.get_cached_doc("Pricing Rule", rule_name) + + if full_rule.rate_or_discount == "Discount Percentage" and full_rule.discount_percentage: + discount_percentage += flt(full_rule.discount_percentage) + elif full_rule.rate_or_discount == "Discount Amount" and full_rule.discount_amount: + per_unit_discount += flt(full_rule.discount_amount) + elif full_rule.rate_or_discount == "Rate" and full_rule.rate: + # Apply fixed rate + price_list_rate = flt(full_rule.rate) + + line_discount_amount = 0 + if discount_percentage and qty and price_list_rate: + line_discount_amount = price_list_rate * qty * discount_percentage / 100 + elif per_unit_discount and qty: + line_discount_amount = per_unit_discount * qty + else: + line_discount_amount = per_unit_discount + + if not discount_percentage and line_discount_amount and qty and price_list_rate: + base_amount = price_list_rate * qty + if base_amount: + discount_percentage = (line_discount_amount / base_amount) * 100 + + item_doc.discount_percentage = discount_percentage + item_doc.discount_amount = line_discount_amount + item_doc.price_list_rate = price_list_rate + item_doc.rate = flt(item_doc.get("rate") or price_list_rate) + # ERPNext expects pricing_rules as comma-separated string, not a list + item_doc.pricing_rules = ",".join(applicable_rule_names) if applicable_rule_names else "" + + item_doc.applied_promotional_schemes = list( + { + rule_map[name].promotional_scheme + for name in applicable_rule_names + if rule_map[name].promotional_scheme + } + ) + + for free_item in result.get("free_item_data") or []: + rule_name = free_item.get("pricing_rules") + if not rule_name or rule_name not in rule_map: + continue + free_item_doc = frappe._dict(free_item) + free_item_doc.applied_promotional_scheme = rule_map[rule_name].promotional_scheme + free_items_map[(free_item.get("item_code"), rule_name)] = free_item_doc + + # Evaluate apply_on="Transaction" rules through ERPNext's separate + # transaction-level engine. The per-item engine above does not see + # them, so without this step "Entire Transaction" promotional schemes + # (free product based on cart total) would never apply. + txn_result = _evaluate_transaction_offers( + invoice, + profile, + pricing_items, + customer, + customer_group, + territory, + invoice.get("posting_date") or nowdate(), + pricing_args.currency, + pricing_args.price_list, + rule_map, + selected_offer_names, + ) + # Per-item results win on collisions because they already carry full + # discount metadata from the per-item engine result. + for key, free_item_doc in txn_result.get("free_items", {}).items(): + free_items_map.setdefault(key, free_item_doc) + applied_rules.update(txn_result.get("applied_rules", set())) + + return { + "items": [dict(item) for item in prepared_items], + "free_items": [dict(item) for item in free_items_map.values()], + "applied_pricing_rules": sorted(applied_rules), + # Header-level (transaction-scope) discount surfaced from + # _evaluate_transaction_offers. Frontend should apply these to the + # invoice header (additionalDiscount + apply_discount_on) when + # present. Both fields are zero when no transaction-level Price + # rule fired. + "additional_discount_percentage": flt(txn_result.get("additional_discount_percentage") or 0), + "discount_amount": flt(txn_result.get("discount_amount") or 0), + "apply_discount_on": txn_result.get("apply_discount_on"), + } + except Exception as e: + frappe.log_error(frappe.get_traceback(), "Apply Offers Error") + frappe.throw(_("Error applying offers: {0}").format(str(e))) 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..c931b4aa1 100644 --- a/pos_next/api/pos_profile.py +++ b/pos_next/api/pos_profile.py @@ -3,10 +3,12 @@ # 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 -from pos_next.api.utilities import _parse_list_parameter +from frappe.utils import cint + +from pos_next.api.utilities import _parse_list_parameter, check_user_company @frappe.whitelist() @@ -35,10 +37,7 @@ def get_pos_profile_data(pos_profile): 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: frappe.throw(_("You don't have access to this POS Profile")) @@ -52,6 +51,7 @@ def get_pos_profile_data(pos_profile): # Get hierarchical item groups (with child_groups info) in same call # This eliminates a separate API call to get_item_groups from pos_next.api.items import get_item_groups + item_groups_with_hierarchy = get_item_groups(pos_profile) return { @@ -63,14 +63,14 @@ def get_pos_profile_data(pos_profile): "auto_print": profile_doc.get("print_receipt_on_order_complete", 0), "print_format": profile_doc.get("print_format"), "letter_head": profile_doc.get("letter_head"), - } + }, } @frappe.whitelist() def get_pos_settings(pos_profile): """Get POS Settings for a given POS Profile""" - from pos_next.api.constants import POS_SETTINGS_FIELDS, DEFAULT_POS_SETTINGS + from pos_next.api.constants import DEFAULT_POS_SETTINGS, POS_SETTINGS_FIELDS if not pos_profile: return DEFAULT_POS_SETTINGS.copy() @@ -78,10 +78,7 @@ def get_pos_settings(pos_profile): try: # Get POS Settings linked to this POS Profile pos_settings = frappe.db.get_value( - "POS Settings", - {"pos_profile": pos_profile, "enabled": 1}, - POS_SETTINGS_FIELDS, - as_dict=True + "POS Settings", {"pos_profile": pos_profile, "enabled": 1}, POS_SETTINGS_FIELDS, as_dict=True ) if not pos_settings: @@ -119,8 +116,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 +126,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) @@ -142,6 +139,45 @@ def get_payment_methods(pos_profile): frappe.throw(_("Error fetching payment methods: {0}").format(str(e))) +@frappe.whitelist() +def get_receivable_accounts(pos_profile): + """Receivable accounts selectable for the "Pay on Receivable Account" feature. + + Lists every enabled, non-group Receivable account for the POS Profile's company, + excluding the company default receivable account (already covered by the existing + "Pay on Account" button). Returns an empty list when credit sales are not enabled + for the profile, so the feature stays hidden behind the same gate. + """ + if not pos_profile: + frappe.throw(_("POS Profile is required")) + + company = frappe.db.get_value("POS Profile", pos_profile, "company") + if not company: + return [] + + allow_credit_sale = cint( + frappe.db.get_value("POS Settings", {"pos_profile": pos_profile}, "allow_credit_sale") + ) + if not allow_credit_sale: + return [] + + default_ar = frappe.get_cached_value("Company", company, "default_receivable_account") + + accounts = frappe.get_all( + "Account", + filters={ + "company": company, + "account_type": "Receivable", + "is_group": 0, + "disabled": 0, + }, + fields=["name", "account_name", "account_currency"], + order_by="account_name asc", + ) + + return [a for a in accounts if a.name != default_ar] + + @frappe.whitelist() def get_taxes(pos_profile): """Get tax configuration from POS Profile""" @@ -151,7 +187,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 +198,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,14 +232,10 @@ 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 + limit_page_length=0, ) # Return warehouses with human-readable names @@ -248,8 +282,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 +298,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 +346,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 +362,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 +377,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 +385,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 +414,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 +447,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 +477,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 +498,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 +509,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 +538,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 +572,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 +586,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 +609,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 +673,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 +688,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..d51ab641e 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"} @@ -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/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/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/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..de1f44f5b --- /dev/null +++ b/pos_next/pos_next/doctype/district/district.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{governorate}-{district}", + "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 12:50:24.717788", + "modified_by": "Administrator", + "module": "POS Next", + "name": "District", + "naming_rule": "Expression (old style)", + "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..dc672cf68 --- /dev/null +++ b/pos_next/pos_next/doctype/governorate/governorate.json @@ -0,0 +1,55 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:governorate", + "creation": "2026-06-09 10:54:57.009043", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "governorate" + ], + "fields": [ + { + "fieldname": "governorate", + "fieldtype": "Data", + "in_filter": 1, + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Governorate", + "reqd": 1, + "unique": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-06-09 12:45:46.769004", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Governorate", + "naming_rule": "By fieldname", + "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, + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "governorate", + "translated_doctype": 1 +} \ 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 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 `