Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion POS/src/components/sale/BatchSerialDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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, {
Comment thread
MohamedAliSmk marked this conversation as resolved.
batch_no_data: data.batch_no_data,
}).catch(() => {});
}
}
},
onError(error) {
Expand Down Expand Up @@ -436,6 +447,8 @@ async function loadBatchesOrSerials() {
}));
return;
}
warehouseBatches.value = [];
return;
}
// Fetch from server when online
batchesResource.reload();
Expand All @@ -447,6 +460,8 @@ async function loadBatchesOrSerials() {
availableSerials.value = cachedSerials;
return;
}
availableSerials.value = [];
return;
}
// Set warehouse in store
serialStore.setWarehouse(props.warehouse);
Expand Down
15 changes: 10 additions & 5 deletions POS/src/composables/useInvoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions POS/src/pages/POSSale.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";

Expand Down Expand Up @@ -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 = "";

Expand Down
34 changes: 32 additions & 2 deletions POS/src/stores/itemSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -1709,6 +1730,15 @@ export const useItemSearchStore = defineStore("itemSearch", () => {
// Cache server results for future searches
await offlineWorker.cacheItems(serverResults);

if (shiftStore.profileWarehouse) {
cacheBatchSerialForItems(
Comment thread
MohamedAliSmk marked this conversation as resolved.
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);
Expand Down
4 changes: 2 additions & 2 deletions POS/src/stores/posCart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
13 changes: 13 additions & 0 deletions POS/src/stores/serialNumber.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}`);
};

Expand Down Expand Up @@ -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}`);
};

Expand Down
100 changes: 94 additions & 6 deletions POS/src/utils/offline/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
Expand All @@ -173,6 +207,60 @@ export const updateItemBatchSerialData = async (batchSerialDataMap) => {
}
};

// Remove consumed serial numbers from offline cache
export const consumeCachedSerials = async (itemCode, serialNumbers) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 T4 — All four cache mutators are non-atomic read-modify-write

consumeCachedSerials, returnCachedSerials, consumeCachedBatchQty and updateItemBatchSerialData each do get → compute → db.items.update() with no db.transaction("rw", db.items, ...).

Two concurrent consumes, or a consume racing the background cacheBatchSerialForItems write for the same item, silently loses one update. And every call site is fire-and-forget with .catch(() => {}), so it's completely invisible when it happens.

await db.transaction("rw", db.items, async () => {
	const serials = await getCachedSerialData(itemCode);
	...
});

Applies to whichever of these survives the split.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2adb1cf — wrapped surviving mutators (persistItemBatchSerialData, updateItemBatchSerialData, consumeCachedSerials, returnCachedSerials) in db.transaction("rw", db.items, ...).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — fire-and-forget .catch(() => {}) on non-atomic RMW made lost updates invisible.

Done in b2adb1cf: wrapped the surviving mutators in db.transaction("rw", db.items, …):

  • persistItemBatchSerialData
  • updateItemBatchSerialData
  • consumeCachedSerials
  • returnCachedSerials

(consumeCachedBatchQty was already removed by T1/T2.) Nested calls reuse Dexie’s same-table transaction.

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 {
Expand Down
Loading
Loading