Skip to content

Commit e0e8822

Browse files
author
Ahmed Osama
committed
feat: implement real-time stock updates via Socket.IO
Add real-time stock synchronization across POS terminals when invoices are submitted. This ensures all terminals see updated stock quantities immediately without manual refresh. Backend: - Add realtime_events.py with stock update event emitter - Use optimized bulk SQL query for performance - Register doc_events hooks for Sales Invoice on_submit/on_cancel - Broadcast pos_stock_update events via Socket.IO Frontend: - Add useRealtimeStock composable for event management - Implement warehouse-based filtering to prevent data corruption - Update reactive arrays in-place for immediate UI refresh - Add worker support for IndexedDB cache updates - Integrate stock update listener in POSSale.vue Fixes: - Prevent multi-warehouse data corruption with warehouse filtering - Ensure UI refreshes by mutating reactive arrays directly - Clean and efficient with minimal logging
1 parent edfcb2b commit e0e8822

7 files changed

Lines changed: 463 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Real-time Stock Updates Composable
3+
*
4+
* Listens to Socket.IO events for stock changes and notifies registered handlers.
5+
* Provides intelligent event management with deduplication and batching.
6+
* Each handler is responsible for filtering by warehouse and updating its cache.
7+
*/
8+
9+
import { ref, onUnmounted } from 'vue'
10+
11+
// Shared state across all instances
12+
const isListening = ref(false)
13+
const eventHandlers = new Set()
14+
const pendingUpdates = new Map()
15+
let batchTimeout = null
16+
17+
/**
18+
* Batch update configuration
19+
*/
20+
const BATCH_DELAY_MS = 500 // Wait 500ms before applying batched updates
21+
const MAX_BATCH_SIZE = 100 // Maximum items to batch before forcing update
22+
23+
/**
24+
* Process pending stock updates in batch
25+
*/
26+
async function processBatchedUpdates() {
27+
if (pendingUpdates.size === 0) {
28+
return
29+
}
30+
31+
const updates = Array.from(pendingUpdates.values())
32+
pendingUpdates.clear()
33+
34+
try {
35+
// Notify all registered handlers
36+
// Each handler can filter by warehouse before applying updates
37+
eventHandlers.forEach(handler => {
38+
try {
39+
handler(updates)
40+
} catch (error) {
41+
console.error('[Realtime Stock] Handler error:', error)
42+
}
43+
})
44+
} catch (error) {
45+
console.error('[Realtime Stock] Failed to process batch updates:', error)
46+
}
47+
}
48+
49+
/**
50+
* Schedule batch processing
51+
*/
52+
function scheduleBatchUpdate() {
53+
if (batchTimeout) {
54+
clearTimeout(batchTimeout)
55+
}
56+
57+
// Force update if batch is getting too large
58+
if (pendingUpdates.size >= MAX_BATCH_SIZE) {
59+
processBatchedUpdates()
60+
return
61+
}
62+
63+
batchTimeout = setTimeout(() => {
64+
processBatchedUpdates()
65+
batchTimeout = null
66+
}, BATCH_DELAY_MS)
67+
}
68+
69+
/**
70+
* Handle incoming stock update event
71+
*/
72+
function handleStockUpdate(data) {
73+
if (!data || !data.stock_updates) {
74+
return
75+
}
76+
77+
// Add updates to pending batch (deduplicate by item_code + warehouse)
78+
data.stock_updates.forEach(update => {
79+
const key = `${update.item_code}|${update.warehouse}`
80+
pendingUpdates.set(key, update)
81+
})
82+
83+
scheduleBatchUpdate()
84+
}
85+
86+
/**
87+
* Handle invoice created event (optional, for future use)
88+
*/
89+
function handleInvoiceCreated(data) {
90+
// Can be used to update sales dashboards, notifications, etc.
91+
}
92+
93+
/**
94+
* Start listening to real-time events
95+
*/
96+
function startListening() {
97+
if (isListening.value) {
98+
return
99+
}
100+
101+
if (!window.frappe?.realtime) {
102+
console.warn('[Realtime Stock] Socket.IO not available')
103+
return
104+
}
105+
106+
// Subscribe to stock update events
107+
window.frappe.realtime.on('pos_stock_update', handleStockUpdate)
108+
window.frappe.realtime.on('pos_invoice_created', handleInvoiceCreated)
109+
110+
isListening.value = true
111+
}
112+
113+
/**
114+
* Stop listening to real-time events
115+
*/
116+
function stopListening() {
117+
if (!isListening.value) {
118+
return
119+
}
120+
121+
if (window.frappe?.realtime) {
122+
window.frappe.realtime.off('pos_stock_update', handleStockUpdate)
123+
window.frappe.realtime.off('pos_invoice_created', handleInvoiceCreated)
124+
}
125+
126+
// Clear pending updates
127+
if (batchTimeout) {
128+
clearTimeout(batchTimeout)
129+
batchTimeout = null
130+
}
131+
pendingUpdates.clear()
132+
133+
isListening.value = false
134+
}
135+
136+
/**
137+
* Flush pending updates immediately
138+
*/
139+
async function flushUpdates() {
140+
if (batchTimeout) {
141+
clearTimeout(batchTimeout)
142+
batchTimeout = null
143+
}
144+
await processBatchedUpdates()
145+
}
146+
147+
/**
148+
* Main composable
149+
*/
150+
export function useRealtimeStock() {
151+
/**
152+
* Register a callback to be notified of stock updates
153+
* @param {Function} handler - Called with array of stock updates
154+
* @returns {Function} Cleanup function to unregister handler
155+
*/
156+
function onStockUpdate(handler) {
157+
if (typeof handler !== 'function') {
158+
throw new Error('Handler must be a function')
159+
}
160+
161+
eventHandlers.add(handler)
162+
163+
// Start listening when first handler is registered
164+
if (eventHandlers.size === 1) {
165+
startListening()
166+
}
167+
168+
// Return cleanup function
169+
return () => {
170+
eventHandlers.delete(handler)
171+
172+
// Stop listening when last handler is removed
173+
if (eventHandlers.size === 0) {
174+
stopListening()
175+
}
176+
}
177+
}
178+
179+
// Cleanup on component unmount
180+
onUnmounted(() => {
181+
// Note: We don't stop listening here because other components
182+
// might still be using it. Each handler cleans up individually.
183+
})
184+
185+
return {
186+
isListening,
187+
onStockUpdate,
188+
flushUpdates,
189+
startListening,
190+
stopListening
191+
}
192+
}

POS/src/pages/POSSale.vue

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,8 @@ import CreateCustomerDialog from "@/components/sale/CreateCustomerDialog.vue"
620620
import ItemSelectionDialog from "@/components/sale/ItemSelectionDialog.vue"
621621
import PromotionManagement from "@/components/sale/PromotionManagement.vue"
622622
import { printInvoiceByName } from "@/utils/printInvoice"
623+
import { useRealtimeStock } from "@/composables/useRealtimeStock"
624+
import { offlineWorker } from "@/utils/offline/workerClient"
623625
624626
// Pinia Stores
625627
import { usePOSCartStore } from "@/stores/posCart"
@@ -637,6 +639,9 @@ const offlineStore = usePOSSyncStore()
637639
const draftsStore = usePOSDraftsStore()
638640
const itemStore = useItemSearchStore()
639641
642+
// Real-time stock updates
643+
const { onStockUpdate } = useRealtimeStock()
644+
640645
// Component refs
641646
const itemsSelectorRef = ref(null)
642647
const offersDialogRef = ref(null)
@@ -716,6 +721,29 @@ onMounted(async () => {
716721
}
717722
window.addEventListener('resize', handleResize, { passive: true })
718723
724+
// Set up real-time stock update listener
725+
const cleanup = onStockUpdate(async (stockUpdates) => {
726+
// Filter updates to only include items from our warehouse(s)
727+
const profileWarehouses = shiftStore.profileWarehouse
728+
? [shiftStore.profileWarehouse]
729+
: warehousesList.value.map(w => w.warehouse_name || w.name)
730+
731+
const relevantUpdates = stockUpdates.filter(update =>
732+
profileWarehouses.includes(update.warehouse)
733+
)
734+
735+
if (relevantUpdates.length > 0) {
736+
// Update IndexedDB cache with filtered updates (warehouse-specific)
737+
await offlineWorker.updateStockQuantities(relevantUpdates)
738+
739+
// Apply stock updates directly to reactive arrays for immediate UI refresh
740+
itemStore.applyStockUpdates(relevantUpdates)
741+
}
742+
})
743+
744+
// Store cleanup function for unmount
745+
onUnmounted(cleanup)
746+
719747
try {
720748
// Start timers for current time and shift duration
721749
shiftStore.startTimers()

POS/src/stores/itemSearch.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,47 @@ export const useItemSearchStore = defineStore('itemSearch', () => {
539539
posProfile.value = profile
540540
}
541541

542+
function invalidateCache() {
543+
// Clear result cache to force UI refresh with updated stock
544+
resultCache.value.clear()
545+
}
546+
547+
function applyStockUpdates(stockUpdates) {
548+
// Apply stock updates directly to reactive arrays for immediate UI refresh
549+
if (!stockUpdates || stockUpdates.length === 0) {
550+
return
551+
}
552+
553+
// Build lookup map for O(1) access
554+
const updateMap = new Map()
555+
stockUpdates.forEach(update => {
556+
updateMap.set(update.item_code, update)
557+
})
558+
559+
// Update allItems array in-place
560+
allItems.value.forEach(item => {
561+
const update = updateMap.get(item.item_code)
562+
if (update) {
563+
item.actual_qty = update.actual_qty
564+
item.stock_qty = update.stock_qty
565+
item.warehouse = update.warehouse
566+
}
567+
})
568+
569+
// Update searchResults array in-place
570+
searchResults.value.forEach(item => {
571+
const update = updateMap.get(item.item_code)
572+
if (update) {
573+
item.actual_qty = update.actual_qty
574+
item.stock_qty = update.stock_qty
575+
item.warehouse = update.warehouse
576+
}
577+
})
578+
579+
// Clear result cache to force recomputation
580+
resultCache.value.clear()
581+
}
582+
542583
return {
543584
// State
544585
allItems,
@@ -576,6 +617,8 @@ export const useItemSearchStore = defineStore('itemSearch', () => {
576617
startBackgroundCacheSync,
577618
stopBackgroundCacheSync,
578619
cleanup,
620+
invalidateCache,
621+
applyStockUpdates,
579622

580623
// Resources
581624
itemGroupsResource,

POS/src/utils/offline/workerClient.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ class OfflineWorkerClient {
139139
return this.sendMessage('SET_MANUAL_OFFLINE', { value })
140140
}
141141

142+
async updateStockQuantities(stockUpdates) {
143+
return this.sendMessage('UPDATE_STOCK_QUANTITIES', { stockUpdates })
144+
}
145+
142146
terminate() {
143147
if (this.worker) {
144148
this.worker.terminate()

POS/src/workers/offline.worker.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,49 @@ async function deleteOfflineInvoice(id) {
342342
}
343343
}
344344

345+
// Update stock quantities in cached items
346+
async function updateStockQuantities(stockUpdates) {
347+
try {
348+
const db = await initDB()
349+
350+
if (!stockUpdates || stockUpdates.length === 0) {
351+
return { success: true, updated: 0 }
352+
}
353+
354+
let updatedCount = 0
355+
356+
// Process each stock update
357+
for (const update of stockUpdates) {
358+
const { item_code, warehouse, actual_qty, stock_qty } = update
359+
360+
if (!item_code) {
361+
continue
362+
}
363+
364+
// Get the cached item
365+
const item = await db.items.get(item_code)
366+
367+
if (!item) {
368+
continue
369+
}
370+
371+
// Update stock quantities for this warehouse
372+
item.actual_qty = actual_qty !== undefined ? actual_qty : stock_qty
373+
item.stock_qty = stock_qty !== undefined ? stock_qty : actual_qty
374+
item.warehouse = warehouse || item.warehouse
375+
376+
// Save updated item back to cache
377+
await db.items.put(item)
378+
updatedCount++
379+
}
380+
381+
return { success: true, updated: updatedCount }
382+
} catch (error) {
383+
console.error('Worker: Error updating stock quantities:', error)
384+
throw error
385+
}
386+
}
387+
345388
// Message handler
346389
self.onmessage = async (event) => {
347390
const { type, payload, id } = event.data
@@ -403,6 +446,10 @@ self.onmessage = async (event) => {
403446
result = { success: true, manualOffline }
404447
break
405448

449+
case 'UPDATE_STOCK_QUANTITIES':
450+
result = await updateStockQuantities(payload.stockUpdates)
451+
break
452+
406453
default:
407454
throw new Error(`Unknown message type: ${type}`)
408455
}

pos_next/hooks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@
179179
doc_events = {
180180
"Item": {
181181
"validate": "pos_next.validations.validate_item"
182+
},
183+
"Sales Invoice": {
184+
"on_submit": "pos_next.realtime_events.emit_stock_update_event",
185+
"on_cancel": "pos_next.realtime_events.emit_stock_update_event",
186+
"after_insert": "pos_next.realtime_events.emit_invoice_created_event"
182187
}
183188
}
184189

0 commit comments

Comments
 (0)