From f58646028d838506c756c3009db2c4c7aeb2e6f3 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 9 Jun 2026 15:11:07 +0300 Subject: [PATCH 1/3] fix linter issues --- POS/postcss.config.js | 2 +- POS/src/App.vue | 12 +- POS/src/components/ShiftClosingDialog.vue | 1805 ++++++++++------ POS/src/components/ShiftOpeningDialog.vue | 596 +++--- POS/src/components/common/ActionButton.vue | 33 +- .../components/common/AutocompleteSelect.vue | 288 ++- .../components/common/ClearCacheOverlay.vue | 71 +- .../components/common/CountryCodeSelector.vue | 104 +- POS/src/components/common/InstallAppBadge.vue | 73 +- .../components/common/LanguageSwitcher.vue | 49 +- POS/src/components/common/LazyImage.vue | 32 +- POS/src/components/common/LoadingSpinner.vue | 6 +- POS/src/components/common/POSFooter.vue | 342 +-- POS/src/components/common/PhoneInput.vue | 76 +- POS/src/components/common/SelectInput.vue | 166 +- .../components/common/SessionLockScreen.vue | 108 +- POS/src/components/common/StatusBadge.vue | 45 +- POS/src/components/common/Toast.vue | 39 +- POS/src/components/common/TranslatedHTML.vue | 37 +- POS/src/components/common/UserMenu.vue | 160 +- .../invoices/InvoiceDetailDialog.vue | 528 +++-- .../components/invoices/InvoiceFilters.vue | 341 ++- .../components/invoices/InvoiceManagement.vue | 1079 +++++++--- .../components/partials/PartialPayments.vue | 373 ++-- POS/src/components/pos/ManagementSlider.vue | 48 +- POS/src/components/pos/POSHeader.vue | 297 ++- POS/src/components/sale/BatchSerialDialog.vue | 374 ++-- POS/src/components/sale/CouponDialog.vue | 331 +-- POS/src/components/sale/CouponManagement.vue | 703 +++--- .../components/sale/CreateCustomerDialog.vue | 388 ++-- POS/src/components/sale/CustomerDialog.vue | 182 +- .../components/sale/DraftInvoicesDialog.vue | 222 +- POS/src/components/sale/EditItemDialog.vue | 735 ++++--- POS/src/components/sale/InvoiceCart.vue | 372 +++- .../components/sale/InvoiceHistoryDialog.vue | 262 ++- .../components/sale/ItemSelectionDialog.vue | 471 ++-- POS/src/components/sale/ItemsSelector.vue | 1130 ++++++---- POS/src/components/sale/OffersDialog.vue | 443 ++-- .../components/sale/OfflineInvoicesDialog.vue | 406 +++- .../components/sale/PromotionManagement.vue | 1890 ++++++++++------- .../components/sale/ReturnInvoiceDialog.vue | 1486 ++++++++----- .../sale/WarehouseAvailabilityDialog.vue | 1630 ++++++++------ POS/src/components/settings/CheckboxField.vue | 10 +- POS/src/components/settings/NumberField.vue | 10 +- POS/src/components/settings/POSSettings.vue | 1248 ++++++++--- POS/src/components/settings/SelectField.vue | 18 +- .../components/settings/SettingsSection.vue | 22 +- POS/src/components/settings/settingsConfig.js | 14 +- POS/src/composables/useCartSort.js | 170 +- POS/src/composables/useCountryCodes.js | 82 +- POS/src/composables/useDialogState.js | 62 +- POS/src/composables/useFormatters.js | 52 +- POS/src/composables/useInvoiceFilters.js | 162 +- POS/src/composables/useItems.js | 131 +- POS/src/composables/useLazyLoad.js | 42 +- POS/src/composables/useLocale.js | 184 +- POS/src/composables/useLongPress.js | 108 +- POS/src/composables/useOffline.js | 152 +- POS/src/composables/useOfflineStatus.js | 22 +- POS/src/composables/usePOSEvents.js | 58 +- POS/src/composables/usePWAInstall.js | 140 +- POS/src/composables/usePaymentCalculations.js | 43 +- POS/src/composables/usePaymentNumpad.js | 103 +- POS/src/composables/usePermissions.js | 94 +- POS/src/composables/usePosProfile.js | 20 +- POS/src/composables/useQuickAmounts.js | 52 +- POS/src/composables/useQzTray.js | 142 +- POS/src/composables/useRealtimeCustomers.js | 290 +-- POS/src/composables/useRealtimePosProfile.js | 190 +- POS/src/composables/useRealtimeStock.js | 102 +- POS/src/composables/useResponsivePayment.js | 122 +- POS/src/composables/useSalesPersons.js | 117 +- POS/src/composables/useSearchInput.js | 111 +- POS/src/composables/useSessionLock.js | 373 ++-- POS/src/composables/useShift.js | 94 +- POS/src/composables/useStock.js | 12 +- POS/src/composables/useToast.js | 82 +- POS/src/data/session.js | 54 +- POS/src/data/user.js | 64 +- POS/src/main.js | 160 +- POS/src/pages/Home.vue | 634 +++--- POS/src/pages/Login.vue | 286 +-- POS/src/router.js | 28 +- POS/src/socket.js | 54 +- POS/src/stores/bootstrap.js | 100 +- POS/src/stores/countries.js | 100 +- POS/src/stores/customerSearch.js | 309 ++- POS/src/stores/invoiceFilters.js | 224 +- POS/src/stores/itemSearch.js | 1674 ++++++++------- POS/src/stores/posDrafts.js | 80 +- POS/src/stores/posEvents.js | 292 +-- POS/src/stores/posOffers.js | 253 ++- POS/src/stores/posSettings.js | 274 +-- POS/src/stores/posShift.js | 101 +- POS/src/stores/posSync.js | 253 +-- POS/src/stores/posUI.js | 200 +- POS/src/stores/serialNumber.js | 154 +- POS/src/stores/stock.js | 149 +- POS/src/utils/apiWrapper.js | 22 +- POS/src/utils/csrf.js | 170 +- POS/src/utils/currency.js | 106 +- POS/src/utils/draftManager.js | 170 +- POS/src/utils/errorHandler.js | 213 +- POS/src/utils/invoice.js | 48 +- POS/src/utils/logger.js | 239 ++- POS/src/utils/lowEndOptimizations.js | 303 +-- POS/src/utils/mutex.js | 78 +- POS/src/utils/offline/cache.js | 359 ++-- POS/src/utils/offline/db.js | 221 +- POS/src/utils/offline/index.js | 10 +- POS/src/utils/offline/items.js | 47 +- POS/src/utils/offline/offlineReceiptCache.js | 38 +- POS/src/utils/offline/offlineState.js | 499 +++-- POS/src/utils/offline/sync.js | 458 ++-- POS/src/utils/offline/translationCache.js | 98 +- POS/src/utils/offline/uuid.js | 14 +- POS/src/utils/offline/workerClient.js | 397 ++-- POS/src/utils/payment.js | 4 +- POS/src/utils/performanceConfig.js | 214 +- POS/src/utils/printEod.js | 6 +- POS/src/utils/printInvoice.js | 401 ++-- POS/src/utils/qzTray.js | 178 +- POS/src/utils/sessionCleanup.js | 42 +- POS/src/utils/stockValidator.js | 38 +- POS/src/workers/offline.worker.js | 1351 ++++++------ POS/tailwind.config.js | 4 +- POS/vite.config.js | 47 +- pos_next/__init__.py | 49 +- pos_next/api/__init__.py | 15 +- pos_next/api/auth.py | 26 +- pos_next/api/bootstrap.py | 28 +- pos_next/api/branding.py | 77 +- pos_next/api/credit_sales.py | 268 +-- pos_next/api/customers.py | 13 +- pos_next/api/items.py | 3 +- pos_next/api/localization.py | 25 +- pos_next/api/offers.py | 112 +- pos_next/api/partial_payments.py | 1676 ++++++++------- pos_next/api/pos_profile.py | 213 +- pos_next/api/promotions.py | 352 +-- pos_next/api/qz.py | 36 +- pos_next/api/sales_invoice_hooks.py | 20 +- pos_next/api/shifts.py | 20 +- pos_next/api/test_customers.py | 191 +- pos_next/api/utilities.py | 20 +- pos_next/api/wallet.py | 151 +- pos_next/hooks.py | 40 +- pos_next/install.py | 42 +- pos_next/overrides/frappe_compat.py | 4 +- pos_next/overrides/sales_invoice.py | 2 +- .../patches/v1_7_0/reinstall_workspace.py | 16 +- .../v2_0_0/remove_custom_company_fields.py | 1 - .../brainwise_branding/brainwise_branding.js | 171 +- .../brainwise_branding/brainwise_branding.py | 8 +- .../offline_invoice_sync.py | 183 +- .../pos_closing_shift/pos_closing_shift.js | 43 +- .../pos_closing_shift/pos_closing_shift.py | 1140 +++++----- .../test_pos_closing_shift.py | 2 +- .../pos_closing_shift_detail.py | 2 +- .../pos_closing_shift_taxes.py | 2 +- .../pos_next/doctype/pos_coupon/pos_coupon.py | 329 +-- .../doctype/pos_coupon/test_pos_coupon.py | 88 +- .../pos_coupon_detail/pos_coupon_detail.py | 2 +- .../pos_next/doctype/pos_offer/pos_offer.js | 30 +- .../pos_next/doctype/pos_offer/pos_offer.py | 2 +- .../doctype/pos_offer/test_pos_offer.py | 2 +- .../pos_offer_detail/pos_offer_detail.py | 2 +- .../pos_opening_shift/pos_opening_shift.py | 67 +- .../test_pos_opening_shift.py | 2 +- .../pos_opening_shift_detail.py | 2 +- .../pos_payment_entry_reference.py | 2 +- .../doctype/pos_settings/pos_settings.py | 30 +- .../doctype/referral_code/referral_code.js | 3 +- .../doctype/referral_code/referral_code.py | 440 ++-- .../referral_code/test_referral_code.py | 2 +- .../sales_invoice_reference.py | 2 +- pos_next/pos_next/doctype/wallet/wallet.py | 79 +- .../wallet_transaction/wallet_transaction.py | 271 +-- .../cashier_performance_report.js | 50 +- .../cashier_performance_report.py | 32 +- ...inventory_impact_and_fast_movers_report.js | 70 +- ...inventory_impact_and_fast_movers_report.py | 99 +- .../offline_sync_and_system_health_report.js | 54 +- .../offline_sync_and_system_health_report.py | 103 +- .../payments_and_cash_control_report.js | 58 +- .../payments_and_cash_control_report.py | 159 +- .../sales_vs_shifts_report.js | 204 +- .../sales_vs_shifts_report.py | 172 +- pos_next/pos_next/utils/pos_closing_print.py | 140 +- .../utils/tests/test_pos_closing_print.py | 112 +- pos_next/services/__init__.py | 6 +- pos_next/services/barcode.py | 478 ++--- pos_next/tasks/branding_monitor.py | 5 +- pos_next/tasks/cleanup_expired_promotions.py | 2 +- pos_next/test_packed_items_regression.py | 9 +- pos_next/test_promotions.py | 27 +- pos_next/uninstall.py | 3 +- 197 files changed, 23438 insertions(+), 18238 deletions(-) diff --git a/POS/postcss.config.js b/POS/postcss.config.js index 1a5262473..7b75c83af 100644 --- a/POS/postcss.config.js +++ b/POS/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/POS/src/App.vue b/POS/src/App.vue index bcaf2d9bf..73c5f397b 100644 --- a/POS/src/App.vue +++ b/POS/src/App.vue @@ -1,11 +1,11 @@ diff --git a/POS/src/components/ShiftClosingDialog.vue b/POS/src/components/ShiftClosingDialog.vue index 8f110ed2b..ad02948af 100644 --- a/POS/src/components/ShiftClosingDialog.vue +++ b/POS/src/components/ShiftClosingDialog.vue @@ -1,533 +1,986 @@ diff --git a/POS/src/components/ShiftOpeningDialog.vue b/POS/src/components/ShiftOpeningDialog.vue index b6b6dea6b..e35f19656 100644 --- a/POS/src/components/ShiftOpeningDialog.vue +++ b/POS/src/components/ShiftOpeningDialog.vue @@ -1,249 +1,305 @@ diff --git a/POS/src/components/common/AutocompleteSelect.vue b/POS/src/components/common/AutocompleteSelect.vue index 827e3a5c0..4182c207d 100644 --- a/POS/src/components/common/AutocompleteSelect.vue +++ b/POS/src/components/common/AutocompleteSelect.vue @@ -1,8 +1,14 @@ diff --git a/POS/src/components/sale/WarehouseAvailabilityDialog.vue b/POS/src/components/sale/WarehouseAvailabilityDialog.vue index 3ba0c7244..58d10e479 100644 --- a/POS/src/components/sale/WarehouseAvailabilityDialog.vue +++ b/POS/src/components/sale/WarehouseAvailabilityDialog.vue @@ -1,483 +1,837 @@ diff --git a/POS/src/components/settings/POSSettings.vue b/POS/src/components/settings/POSSettings.vue index 89ca3e85e..c28c2d258 100644 --- a/POS/src/components/settings/POSSettings.vue +++ b/POS/src/components/settings/POSSettings.vue @@ -8,21 +8,52 @@ >
-
+
-
+
- - - + + +
-

{{ __('POS Settings') }}

+

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

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

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

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

+
+
+

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

-
+
-
+
- - + +
-

{{ __('Stock Management') }}

-

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

+

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

+

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

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

{{ __('Warehouse Selection') }}

+

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

-
- - +
+ + -

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

+

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

- - + + -

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

+

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

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

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

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

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

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

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

-

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

-

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

+

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

+

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

+

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

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

{{ __('Sales Management') }}

-

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

+

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

+

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

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

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

+

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

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

{{ __('Sales Operations') }}

+

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

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

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

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

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

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

-
+
-

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

-
+
- - + + -

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

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

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

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

-

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

+

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

+

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

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

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

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

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

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

View detailed logs in Error Log

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