diff --git a/POS/src/components/sale/BatchSerialDialog.vue b/POS/src/components/sale/BatchSerialDialog.vue index 20f22b7d0..88f642bae 100644 --- a/POS/src/components/sale/BatchSerialDialog.vue +++ b/POS/src/components/sale/BatchSerialDialog.vue @@ -301,7 +301,11 @@ import { Button, Dialog, createResource } from "frappe-ui"; import { computed, ref, watch } from "vue"; import { useSerialNumberStore } from "@/stores/serialNumber"; import { usePOSCartStore } from "@/stores/posCart"; -import { getCachedBatchData, getCachedSerialData } from "@/utils/offline/items"; +import { + getCachedBatchData, + getCachedSerialData, + persistItemBatchSerialData, +} from "@/utils/offline/items"; import { isOffline } from "@/utils/offline"; const props = defineProps({ @@ -370,6 +374,13 @@ const batchesResource = createResource({ expiry_date: batch.expiry_date, manufacturing_date: batch.manufacturing_date, })); + + // Persist for offline batch selection + if (props.item?.item_code) { + persistItemBatchSerialData(props.item.item_code, { + batch_no_data: data.batch_no_data, + }).catch(() => {}); + } } }, onError(error) { @@ -436,6 +447,8 @@ async function loadBatchesOrSerials() { })); return; } + warehouseBatches.value = []; + return; } // Fetch from server when online batchesResource.reload(); @@ -447,6 +460,8 @@ async function loadBatchesOrSerials() { availableSerials.value = cachedSerials; return; } + availableSerials.value = []; + return; } // Set warehouse in store serialStore.setWarehouse(props.warehouse); diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 34b22585c..75a4687df 100644 --- a/POS/src/composables/useInvoice.js +++ b/POS/src/composables/useInvoice.js @@ -1178,12 +1178,17 @@ export function useInvoice() { /** * Clears the cart and resets to default state. * If a POS Profile is active and has a default customer, it will be pre-selected. + * @param {{ returnSerials?: boolean }} [options] + * When false (post-submit), sold serials stay consumed in durable cache. + * Default true restores serials for abandoned / cleared carts. */ - async function clearCart() { - // Return all serial numbers back to cache before clearing - for (const item of invoiceItems.value) { - if (item.has_serial_no && item.serial_no) { - serialStore.returnSerials(item.item_code, item.serial_no); + async function clearCart({ returnSerials = true } = {}) { + // Return serials only when the cart is abandoned — not after a successful sale + if (returnSerials) { + for (const item of invoiceItems.value) { + if (item.has_serial_no && item.serial_no) { + serialStore.returnSerials(item.item_code, item.serial_no); + } } } diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue index 35cd5c858..0458f11f9 100644 --- a/POS/src/pages/POSSale.vue +++ b/POS/src/pages/POSSale.vue @@ -2130,7 +2130,7 @@ async function handlePaymentCompleted(paymentData) { uiStore.setLastOfflinePrintDoc(offlinePrintDoc); cacheOfflineReceiptPayload(offlineReceiptName, offlinePrintDoc); uiStore.showPaymentDialog = false; - cartStore.clearCart(); + cartStore.clearCart({ returnSerials: false }); // Reset cart hash after successful payment previousCartHash = ""; @@ -2201,7 +2201,7 @@ async function handlePaymentCompleted(paymentData) { const paidAmount = paymentData.paid_amount || invoiceTotal; uiStore.showPaymentDialog = false; - cartStore.clearCart(); + cartStore.clearCart({ returnSerials: false }); // Reset cart hash after successful payment previousCartHash = ""; diff --git a/POS/src/stores/itemSearch.js b/POS/src/stores/itemSearch.js index bac1ff698..209c8f60e 100644 --- a/POS/src/stores/itemSearch.js +++ b/POS/src/stores/itemSearch.js @@ -14,6 +14,10 @@ import { useRealtimePosProfile } from "@/composables/useRealtimePosProfile"; const log = logger.create("ItemSearch"); +/** Recently fetched batch/serial item codes → timestamp (dedupe search fan-out) */ +const recentlyFetchedBatchSerial = new Map(); +const BATCH_SERIAL_FETCH_TTL_MS = 5 * 60 * 1000; + /** * Fetch and cache batch/serial data for items with batch or serial tracking * This ensures batch/serial selection works offline @@ -31,11 +35,23 @@ async function cacheBatchSerialForItems(items, warehouse) { return; } - log.info(`Caching batch/serial data for ${batchSerialItems.length} items`); + const now = Date.now(); + const itemCodes = batchSerialItems + .map((item) => item.item_code) + .filter((code) => { + const lastFetched = recentlyFetchedBatchSerial.get(code); + return !lastFetched || now - lastFetched > BATCH_SERIAL_FETCH_TTL_MS; + }); + + if (itemCodes.length === 0) { + log.debug("All batch/serial items were fetched recently — skipping"); + return; + } + + log.info(`Caching batch/serial data for ${itemCodes.length} items`); // Fetch in batches to avoid too large requests const BATCH_SIZE = 20; - const itemCodes = batchSerialItems.map((item) => item.item_code); for (let i = 0; i < itemCodes.length; i += BATCH_SIZE) { const batchCodes = itemCodes.slice(i, i + BATCH_SIZE); @@ -52,6 +68,11 @@ async function cacheBatchSerialForItems(items, warehouse) { await updateItemBatchSerialData(data); log.debug(`Cached batch/serial data for ${Object.keys(data).length} items`); } + + const fetchedAt = Date.now(); + for (const code of batchCodes) { + recentlyFetchedBatchSerial.set(code, fetchedAt); + } } catch (error) { log.warn(`Failed to fetch batch/serial data for batch ${i}:`, error.message); } @@ -1709,6 +1730,15 @@ export const useItemSearchStore = defineStore("itemSearch", () => { // Cache server results for future searches await offlineWorker.cacheItems(serverResults); + if (shiftStore.profileWarehouse) { + cacheBatchSerialForItems( + serverResults, + shiftStore.profileWarehouse + ).catch((err) => { + log.warn("Background batch/serial caching failed:", err.message); + }); + } + // If we didn't resolve with cache, resolve with server results if (!cached || cached.length === 0) { resolve(serverResults); diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index 8d451c946..693d7da7e 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -228,12 +228,12 @@ export const usePOSCartStore = defineStore("posCart", () => { baseUpdateItemQuantity(itemCode, quantity, uom); } - function clearCart() { + function clearCart(options = {}) { // Cancel any pending offer processing debouncedProcessOffers.cancel(); offerQueue.cancel(); - clearInvoiceCart(); + clearInvoiceCart(options); customer.value = null; offersStore.clearOneTimeContext(); appliedOffers.value = []; diff --git a/POS/src/stores/serialNumber.js b/POS/src/stores/serialNumber.js index 4b29f0902..ae4b8c68b 100644 --- a/POS/src/stores/serialNumber.js +++ b/POS/src/stores/serialNumber.js @@ -11,6 +11,11 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import { call } from "@/utils/apiWrapper"; +import { + consumeCachedSerials, + persistItemBatchSerialData, + returnCachedSerials, +} from "@/utils/offline/items"; import { logger } from "@/utils/logger"; const log = logger.create("SerialNumber"); @@ -107,6 +112,10 @@ export const useSerialNumberStore = defineStore("serialNumber", () => { }); log.success(`Loaded ${serials.length} serials for ${itemCode}`); + + // Persist for offline batch/serial selection + persistItemBatchSerialData(itemCode, { serial_no_data: serials }).catch(() => {}); + return serials; } catch (error) { log.error(`Failed to fetch serials for ${itemCode}`, error); @@ -134,6 +143,8 @@ export const useSerialNumberStore = defineStore("serialNumber", () => { cached.serials = cached.serials.filter((s) => !serialsToRemove.has(s.serial_no)); + consumeCachedSerials(itemCode, serialNumbers).catch(() => {}); + log.info(`Consumed ${serialsToRemove.size} serials for ${itemCode}`); }; @@ -168,6 +179,8 @@ export const useSerialNumberStore = defineStore("serialNumber", () => { a.serial_no.localeCompare(b.serial_no, undefined, { numeric: true }) ); + returnCachedSerials(itemCode, serialNumbers, currentWarehouse.value).catch(() => {}); + log.info(`Returned ${serialsToReturn.length} serials for ${itemCode}`); }; diff --git a/POS/src/utils/offline/items.js b/POS/src/utils/offline/items.js index 813de2576..0c83fdbf0 100644 --- a/POS/src/utils/offline/items.js +++ b/POS/src/utils/offline/items.js @@ -146,23 +146,57 @@ export const getCachedSerialData = async (itemCode) => { } }; +export function parseSerialNumbers(serialNumbers) { + if (!serialNumbers) return []; + return Array.isArray(serialNumbers) + ? serialNumbers + : String(serialNumbers) + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +// Persist batch/serial data for a single cached item (partial updates supported) +export const persistItemBatchSerialData = async (itemCode, data) => { + try { + if (!itemCode || !data) return false; + + const update = {}; + if (data.batch_no_data !== undefined) update.batch_no_data = data.batch_no_data; + if (data.serial_no_data !== undefined) update.serial_no_data = data.serial_no_data; + + if (Object.keys(update).length === 0) return false; + + return await db.transaction("rw", db.items, async () => { + const item = await db.items.get(itemCode); + if (!item) { + // Avoid phantom rows (no item_name/barcodes) that blank the product grid + return false; + } + + await db.items.update(itemCode, update); + return true; + }); + } catch (error) { + console.error("Error persisting item batch/serial data:", error); + return false; + } +}; + // Update batch/serial data for items in cache export const updateItemBatchSerialData = async (batchSerialDataMap) => { try { if (!batchSerialDataMap || Object.keys(batchSerialDataMap).length === 0) return; - // Update each item with its batch/serial data - const updates = Object.entries(batchSerialDataMap).map(async ([itemCode, data]) => { - const item = await db.items.get(itemCode); - if (item) { - await db.items.update(itemCode, { + await db.transaction("rw", db.items, async () => { + for (const [itemCode, data] of Object.entries(batchSerialDataMap)) { + await persistItemBatchSerialData(itemCode, { batch_no_data: data.batch_no_data || [], serial_no_data: data.serial_no_data || [], }); } }); - await Promise.all(updates); console.log( `Updated batch/serial data for ${Object.keys(batchSerialDataMap).length} items` ); @@ -173,6 +207,60 @@ export const updateItemBatchSerialData = async (batchSerialDataMap) => { } }; +// Remove consumed serial numbers from offline cache +export const consumeCachedSerials = async (itemCode, serialNumbers) => { + try { + if (!itemCode) return; + + await db.transaction("rw", db.items, async () => { + const item = await db.items.get(itemCode); + const serials = item?.serial_no_data || []; + if (!serials.length) return; + + const toRemove = new Set(parseSerialNumbers(serialNumbers)); + const remaining = serials.filter((s) => !toRemove.has(s.serial_no)); + + await db.items.update(itemCode, { serial_no_data: remaining }); + }); + } catch (error) { + console.error("Error consuming cached serials:", error); + } +}; + +// Return serial numbers to offline cache (e.g. item removed from cart) +export const returnCachedSerials = async (itemCode, serialNumbers, warehouse = null) => { + try { + if (!itemCode) return; + + await db.transaction("rw", db.items, async () => { + const item = await db.items.get(itemCode); + if (!item) return; + + const serials = item.serial_no_data || []; + const toReturn = parseSerialNumbers(serialNumbers); + if (!toReturn.length) return; + + const scopedWarehouse = warehouse || serials[0]?.warehouse; + if (!scopedWarehouse) return; + + const existing = new Set(serials.map((s) => s.serial_no)); + const added = toReturn + .filter((serialNo) => !existing.has(serialNo)) + .map((serial_no) => ({ serial_no, warehouse: scopedWarehouse })); + + if (!added.length) return; + + const merged = [...serials, ...added].sort((a, b) => + a.serial_no.localeCompare(b.serial_no, undefined, { numeric: true }) + ); + + await db.items.update(itemCode, { serial_no_data: merged }); + }); + } catch (error) { + console.error("Error returning cached serials:", error); + } +}; + // Get item with price export const getItemWithPrice = async (itemCode, priceList) => { try { diff --git a/POS/src/utils/offline/items.test.js b/POS/src/utils/offline/items.test.js new file mode 100644 index 000000000..69896fd58 --- /dev/null +++ b/POS/src/utils/offline/items.test.js @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const itemStore = new Map(); + +vi.mock("./db", () => { + const items = { + get: vi.fn(async (itemCode) => itemStore.get(itemCode) ?? undefined), + update: vi.fn(async (itemCode, update) => { + const existing = itemStore.get(itemCode); + if (!existing) return 0; + itemStore.set(itemCode, { ...existing, ...update }); + return 1; + }), + put: vi.fn(async (row) => { + itemStore.set(row.item_code, { ...row }); + return row.item_code; + }), + }; + + return { + db: { + items, + transaction: vi.fn(async (_mode, _table, fn) => fn()), + }, + getSetting: vi.fn(), + setSetting: vi.fn(), + }; +}); + +const { + parseSerialNumbers, + persistItemBatchSerialData, + consumeCachedSerials, + returnCachedSerials, + getCachedSerialData, +} = await import("./items"); + +describe("parseSerialNumbers", () => { + it("parses newline-delimited strings", () => { + expect(parseSerialNumbers("SN-1\nSN-2\n\n SN-3 ")).toEqual(["SN-1", "SN-2", "SN-3"]); + }); + + it("passes through arrays and empty input", () => { + expect(parseSerialNumbers(["A", "B"])).toEqual(["A", "B"]); + expect(parseSerialNumbers(null)).toEqual([]); + expect(parseSerialNumbers("")).toEqual([]); + }); +}); + +describe("offline serial cache mutators", () => { + beforeEach(() => { + itemStore.clear(); + itemStore.set("ITEM-1", { + item_code: "ITEM-1", + item_name: "Widget", + serial_no_data: [ + { serial_no: "SN-1", warehouse: "Stores - T" }, + { serial_no: "SN-2", warehouse: "Stores - T" }, + { serial_no: "SN-3", warehouse: "Stores - T" }, + ], + }); + }); + + it("consume then return restores serials (round-trip)", async () => { + await consumeCachedSerials("ITEM-1", "SN-1\nSN-2"); + expect(await getCachedSerialData("ITEM-1")).toEqual([ + { serial_no: "SN-3", warehouse: "Stores - T" }, + ]); + + await returnCachedSerials("ITEM-1", ["SN-1", "SN-2"], "Stores - T"); + expect(await getCachedSerialData("ITEM-1")).toEqual([ + { serial_no: "SN-1", warehouse: "Stores - T" }, + { serial_no: "SN-2", warehouse: "Stores - T" }, + { serial_no: "SN-3", warehouse: "Stores - T" }, + ]); + }); + + it("does not create phantom rows when the item is missing", async () => { + const ok = await persistItemBatchSerialData("MISSING", { + serial_no_data: [{ serial_no: "SN-X", warehouse: "Stores - T" }], + }); + expect(ok).toBe(false); + expect(itemStore.has("MISSING")).toBe(false); + }); + + it("refuses to return serials without a warehouse when cache is empty", async () => { + itemStore.set("ITEM-1", { + item_code: "ITEM-1", + item_name: "Widget", + serial_no_data: [], + }); + + await returnCachedSerials("ITEM-1", ["SN-1"]); + expect(await getCachedSerialData("ITEM-1")).toEqual([]); + + await returnCachedSerials("ITEM-1", ["SN-1"], "Stores - T"); + expect(await getCachedSerialData("ITEM-1")).toEqual([ + { serial_no: "SN-1", warehouse: "Stores - T" }, + ]); + }); +}); diff --git a/POS/vite.config.js b/POS/vite.config.js index 273b06b01..f5fa610dc 100644 --- a/POS/vite.config.js +++ b/POS/vite.config.js @@ -230,6 +230,11 @@ export default defineConfig({ define: { __BUILD_VERSION__: JSON.stringify(buildVersion), }, + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.{test,spec}.{js,ts}"], + }, optimizeDeps: { include: ["feather-icons", "showdown", "highlight.js/lib/core", "interactjs", "qz-tray"], },