diff --git a/.gitignore b/.gitignore index d267bbffa..9137d97e7 100644 --- a/.gitignore +++ b/.gitignore @@ -65,5 +65,6 @@ dev-dist/ .playwright-mcp/* TODO.md .claude/settings.json +.omo/ *.jpeg missing.txt diff --git a/POS/components.d.ts b/POS/components.d.ts index 34185d022..5bae9f497 100644 --- a/POS/components.d.ts +++ b/POS/components.d.ts @@ -35,6 +35,7 @@ declare module 'vue' { NumberField: typeof import('./src/components/settings/NumberField.vue')['default'] OffersDialog: typeof import('./src/components/sale/OffersDialog.vue')['default'] OfflineInvoicesDialog: typeof import('./src/components/sale/OfflineInvoicesDialog.vue')['default'] + PackageSelectionDialog: typeof import('./src/components/sale/PackageSelectionDialog.vue')['default'] PartialPayments: typeof import('./src/components/partials/PartialPayments.vue')['default'] PaymentDialog: typeof import('./src/components/sale/PaymentDialog.vue')['default'] PhoneInput: typeof import('./src/components/common/PhoneInput.vue')['default'] diff --git a/POS/src/components/sale/InvoiceCart.vue b/POS/src/components/sale/InvoiceCart.vue index 8e6efd748..ac7b6b460 100644 --- a/POS/src/components/sale/InvoiceCart.vue +++ b/POS/src/components/sale/InvoiceCart.vue @@ -940,19 +940,24 @@
@@ -991,6 +996,12 @@
+ + {{ __("PACKAGE") }} +

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

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

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

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