feat: enhance offline batch and serial number management - #359
MohamedAliSmk wants to merge 22 commits into
Conversation
- Added functions to persist and consume batch and serial data for items in offline mode. - Implemented caching for batch and serial numbers during item searches. - Improved handling of cached data to ensure accurate updates and retrievals when offline. - Enhanced error handling for background caching operations.
engahmed1190
left a comment
There was a problem hiding this comment.
🔍 Full review — code logic, security, performance, coverage
Verdict: request changes. The offline read fix is correct and should ship. The consumption-accounting layer bolted on top has three high-severity defects and wasn't part of PN-83.
Structural read
What actually fixes "Batch/Serial unable to select at offline mode" is ~30 lines:
| Change | Why it fixes PN-83 |
|---|---|
persistItemBatchSerialData in batchesResource.onSuccess |
the dialog's own batch fetch was never written to IndexedDB |
persistItemBatchSerialData in fetchSerials |
serial store results were never written to IndexedDB |
the two new return guards in loadBatchesOrSerials |
offline previously fell through to batchesResource.reload(), which fails |
Everything else — the three new cacheBatchSerialForItems call sites, consumeCachedBatchQty, consumeCachedSerials, returnCachedSerials, and the create-on-missing db.items.put — is offline inventory accounting. Every defect below lives in that second layer. Cutting it removes ~90 of the 161 added lines and every high-severity finding.
Security
No new attack surface. No new endpoints, no new server-side code, no HTML/URL/query sink. get_batch_serial_data_for_items is @frappe.whitelist() and unchanged by this diff. All new code writes client-side IndexedDB, already trusted-per-device here.
The security-relevant findings are the inventory-integrity ones: T1/T2 understate stock, T3 lets the same serial reach two invoices. For a POS that's the risk surface that matters.
Coverage
Zero. find . -name "*.test.*" returns nothing, and there's no test block in POS/vite.config.js despite test / test:run / test:coverage in package.json — yarn test cannot run today. This diff adds four pure-ish mutators with real arithmetic (set diffing, Math.max(0, qty - n), sort-merge); they're the most testable and highest-consequence code in the changeset.
Verified
- Patch applies cleanly to current
develop(git apply --3way) — no conflicts, no duplicated call sites. npx biome linton the changed files: no new violations (one pre-existinguseOptionalChainatBatchSerialDialog.vue:365).- T1 reproduced with a standalone script — see the inline comment.
✅ Task list — please do not merge or close this PR until every box is checked
🔴 Blocking
- T1 — Batch qty subtracted twice offline. Delete
consumeCachedBatchQty;availableBatchesalready handles cart subtraction.BatchSerialDialog.vue:516 - T2 — Batch consumption has no return path (4 divergence paths: line remove, qty edit,
addItemuom-merge droppingbatch_no,addItemthrow). Resolved by T1.items.js:254 - T3 — Sold serials returned to durable cache on offline submit. Fix in
clearCart(useInvoice.js:1182) with areturnSerialsflag,falsefrom the two post-submit call sites.serialNumber.js:182
🟡 Should fix
- T4 — Wrap the surviving cache mutators in
db.transaction("rw", db.items, ...); they're non-atomic read-modify-write with.catch(() => {})at every call site.items.js:210 - T5 — Offline serial fallback bypasses
isCacheValid(warehouse + 5-min TTL).BatchSerialDialog.vue:460 - T6 — Drop the two pagination
cacheBatchSerialForItemssites; keep the search one with a recently-fetchedSet+ TTL. Up to 25 sequential POSTs per search today.itemSearch.js:1224,1339,1725
🟢 Nice to have
- T7 — Don't
putphantom item rows (noitem_name/barcodes); guard theundefinedwarehouse inreturnCachedSerials.items.js:173,236 - T8 — Add a
testblock toPOS/vite.config.js+ oneitems.test.jswithdbmocked, covering the consume/return round-trip,Math.max(0, …)clamping, andparseSerialNumberson both string and array input. Not a suite — one file. - T9 — Wrap the 118-char import at
BatchSerialDialog.vue:304.
Latent / no action needed this PR
- Persisted batch/serial data has no warehouse key. Harmless today (dialog is bound to
shiftStore.profileWarehouse,POSSale.vue:613) but a shift/profile change leaves stale data with nothing to invalidate it. Worth an issue.
🤖 Generated with Claude Code
availableBatches already subtracts cart qty from warehouseBatches; consumeCachedBatchQty was decrementing IndexedDB as well, under-reporting stock offline. Co-authored-by: Cursor <cursoragent@cursor.com>
Batch consume had no symmetric return, so line-remove, qty-edit, uom-merge, and addItem throw permanently drifted offline stock. Deleting the mutator closes all four paths; availableBatches remains the source of truth. Co-authored-by: Cursor <cursoragent@cursor.com>
clearCart could not tell abandoned cart from sold cart, so offline submit wrote sold serials back into IndexedDB. Pass returnSerials:false from both payment-success paths; abandoned clears still restore by default. Co-authored-by: Cursor <cursoragent@cursor.com>
Wrap persist/update/consume/return in db.transaction('rw', db.items) so
concurrent consumes or races with background cache writes cannot silently
drop updates.
Co-authored-by: Cursor <cursoragent@cursor.com>
Raw serialStore.getSerials() ignored warehouse match and 5-minute TTL. IndexedDB via getCachedSerialData already covers offline after fetchSerials persists. Co-authored-by: Cursor <cursoragent@cursor.com>
Pagination sites duplicated shift-start sync and could fire 20-per-request sequential POSTs per page. Keep search-path caching with a 5-minute recently-fetched Set so the same items are not re-fetched every search. Co-authored-by: Cursor <cursoragent@cursor.com>
Do not put incomplete {item_code, batch/serial} rows that blank the product
grid. returnCachedSerials now takes an explicit warehouse (or refuses when
the cache is empty so warehouse would be undefined).
Co-authored-by: Cursor <cursoragent@cursor.com>
Enable yarn test via a vite test block and cover parseSerialNumbers plus consume/return round-trip, phantom-row skip, and warehouse guard. Batch qty Math.max clamping left with consumeCachedBatchQty (T1/T2). Co-authored-by: Cursor <cursoragent@cursor.com>
Match the multi-line import style used elsewhere in the file. Co-authored-by: Cursor <cursoragent@cursor.com>
Review follow-up (local, not pushed yet)Addressed T1–T9 as 9 separate commits on
T8 note: Holding push until author review of the local commits. |
T8 — testsAgreed the mutators were the most testable / highest-consequence part of the changeset, and Done in
Coverage included:
Note:
|
Brings yarn lint back to the base count (212). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Move items.test.js under utils/offline/__tests__/ to match repo convention - Drop the vite test block; develop runs its suite without one - Mark batch/serial codes as fetched before the request so concurrent searches skip in-flight codes; unmark on failure so a retry happens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving a draft returned its serials to the offline cache, and loading the draft never consumed them again. Offline, a parked serial could be sold in another sale and then submitted a second time from the draft. - Save-draft clears the cart with returnSerials: false - clearCart defaults to keeping serials when the cart came from a draft - User deletes (single, clear-all, invoice management) return the draft's serials, except for the draft loaded in the cart - Post-submit draft deletes keep them consumed (default) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
consumeSerials/returnSerials returned early when the in-memory serial cache had no entry for the item. Offline that cache is never filled (only an online fetchSerials fills it), so the IndexedDB updates never ran: a serial in the cart or parked in a draft stayed selectable and could be sold twice. - Write to the durable cache before the in-memory early return - Set the dialog warehouse before the offline branch so returned serials keep their warehouse when the cache is empty - serialNumber.spec.js pins both paths (fails on the previous code) Verified end to end in the browser offline: cart, hold draft, delete draft, offline sale, sync (serial Delivered on the server). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setDefaultCustomer only asked the server. Offline the request failed and the error was swallowed, so after a sale, hold or clear the cart had no customer and stayed empty even back online. Fall back to the customer on the cached POS Profile (shiftState, persisted for offline use). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Offline sales never touched local stock: once the cart cleared, the item badge and the batch dialog went back to the last server figures until the invoice synced, so the POS kept offering stock it had already sold. On offline commit, deduct the sold qty (qty x conversion_factor) from the stock store and IndexedDB for tracked items, and from the sold batch in the cached batch_no_data. The server refresh after sync replaces these values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Syncing queued offline invoices refreshed nothing, so local figures (including the offline deductions) stayed until an unrelated refresh. syncOfflineInvoices now emits offlineInvoicesSynced when any invoice synced; POSSale then refreshes stock from the server, clears the in-memory serial cache and re-fetches batch/serial data past the search dedupe, replacing every local figure with server values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ial data The offline batch dialog came up empty because concurrent writers replaced whole item rows after batch/serial data had been cached: - cacheItems (worker and main thread) bulkPut server rows, which carry no batch_no_data/serial_no_data - updateStockQuantities did get() ... put() of the whole row outside a transaction, so a stale copy overwrote data written in between Carry the cached batch/serial fields over inside the same transaction when re-caching items, and update only the stock fields in updateStockQuantities. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… startup - The header cache icon read cache stats before the first background item caching finished and never re-read them, so a fresh POS showed a red "empty" cache while IndexedDB was full. Re-read the stats when the background caching completes. - Invoices queued offline in an earlier session only synced on the next offline->online transition. Sync them at startup when online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loadAllCustomers asked the server only for customers modified since the stored last-sync key. When IndexedDB had lost the customers (storage eviction, cleared site data, failed write) while the key survived, the delta came back empty and the POS never got customers back: the customer search spun forever and a customer could not be picked. Ignore the key when the cache is empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an online sale POSSale refreshed stock from the server but left the cached batch_no_data untouched, so a later drop to offline showed the pre-sale batch qty and allowed overselling. Deduct the sold batch qty from the cache on online success as well; the next batch fetch replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Loading /pos with no connection failed: the service worker was served from /assets/pos_next/pos/ and could not control the /pos page. - Serve the worker at /pos-sw.js (copied to www/ by the build) and register it with scope /pos; drop the old /assets-scoped registration. The page is first opened as /pos, so the scope has no trailing slash. - Reuse the existing network-first pos-page-cache rule for the page, with one cache key for every /pos route and no 24h expiry; cache it on first load. - Precache URLs made absolute (modifyURLPrefix + manifest/icon entries), and the Workbox runtime inlined, since the worker no longer sits next to them. - Remove the /api network-first rule: once the worker controls the page it answered the offline ping from cache, so the POS thought it was online. - Offline start: trust the session cookie when the user check fails for a network reason (only an AuthenticationError logs out); keep the cached shift when the shift check fails; decide online/offline on the first ping; fall back to cached payment methods when the server request fails. Verified in the browser: cold offline start, batch sale, auto-sync on reconnect (invoice submitted, stock matches the server). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🛑 Review action list — do not merge/close until all checked
Full review · 11 inline comments · details in each
Blocking
consumeCachedBatchQtyclearCartShould fix
db.transaction("rw", …)isCacheValid(warehouse + TTL)Nice to have
db.itemsrows +undefinedwarehouse guarditems.test.js(repo has zero tests)