import getStorage from './storage'; import { createLeaderLease } from './leaderLease'; import { getToday } from './service'; import { formatDate } from './utils'; import { getWebsocket } from './websocket'; import { ClientData, GroupState, OrderGroup, TrackingProvider } from '../../types/gen/types.gen'; import { TRACKERS } from './trackingProviders'; import { notifyOrderDelivered } from './notifikace'; const storage = getStorage(); // Klíč lease zůstal z doby, kdy sledování uměl jen Bolt — přejmenování by při // rolling deployi na chvíli pustilo dva „leadery" (starý a nový klíč) najednou. const lease = createLeaderLease('luncher:bolt:leader'); const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|canceled|rejected|failed/i; const DELIVERED_STATE_REGEX = /delivered|finished/i; const MAX_CONSECUTIVE_FAILURES = 10; let trackingInterval: ReturnType | undefined; /** Mapa groupId → počet po sobě jdoucích selhání dotazu na API rozvozové služby. */ const consecutiveFailures = new Map(); /** Skupina se sledováním — trackingCode i trackingProvider jsou zaručeně vyplněné. */ type TrackedGroup = OrderGroup & { trackingProvider: TrackingProvider; trackingCode: string }; function isTracked(group: OrderGroup): group is TrackedGroup { return !!group.trackingCode && !!group.trackingProvider && !!TRACKERS[group.trackingProvider]; } /** * Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím kódem * zjistí očekávaný čas doručení z API rozvozové služby a aktualizuje deliveryAt. * Sledování se automaticky ukončí (kód se smaže), když objednávka skončí * nebo dotazy opakovaně selhávají. */ export async function checkOrderTracking(): Promise { const isLeader = await lease.tryAcquireOrRenew(); if (!isLeader) return; const key = `${formatDate(getToday())}_extra`; const data = await storage.getData(key); const candidates = (data?.groups ?? []).filter(g => isTracked(g) && g.state === GroupState.ORDERED) as TrackedGroup[]; // Úklid čítačů selhání pro skupiny, které už nesledujeme for (const groupId of consecutiveFailures.keys()) { if (!candidates.some(g => g.id === groupId)) consecutiveFailures.delete(groupId); } if (candidates.length === 0) return; let updated: ClientData | undefined; for (const group of candidates) { const tracker = TRACKERS[group.trackingProvider]; let deliveryAt: string | undefined; let orderState: string | undefined; let courierState: string | undefined; let clearTracking = false; try { const order = await tracker.poll(group.trackingCode); consecutiveFailures.delete(group.id); if (!order) { // Objednávka z API zmizela — považujeme ji za doručenou orderState = 'delivered'; clearTracking = true; } else { orderState = order.orderState || undefined; courierState = order.courierState; if (TERMINAL_STATE_REGEX.test(order.orderState)) { clearTracking = true; } else { deliveryAt = order.deliveryAt; } } } catch (e) { const failures = (consecutiveFailures.get(group.id) ?? 0) + 1; consecutiveFailures.set(group.id, failures); console.error(`${tracker.label} tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e); if (failures < MAX_CONSECUTIVE_FAILURES) continue; consecutiveFailures.delete(group.id); clearTracking = true; } const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt; const stateChanged = orderState !== undefined && orderState !== group.trackingOrderState; const courierChanged = courierState !== group.trackingCourierState && !clearTracking; if (!clearTracking && !timeChanged && !stateChanged && !courierChanged) continue; // Notifikujeme jen skutečný přechod do doručeného stavu. Porovnání s předchozím // stavem hlídá duplicity jak u přechodu delivered → finished, tak u tiku, kdy // objednávka zmizí z API poté, co už byla označena za doručenou. const deliveredNow = stateChanged && DELIVERED_STATE_REGEX.test(orderState ?? '') && !DELIVERED_STATE_REGEX.test(group.trackingOrderState ?? ''); // Log každého přechodu stavu — API rozvozových služeb nejsou dokumentovaná, takže // si takhle průběžně mapujeme jejich stavové automaty (viz mapování v OrderProgress.tsx). if (stateChanged || courierChanged) { console.log( `${tracker.label} tracking: skupina "${group.name}" stav ${group.trackingOrderState ?? '(žádný)'} → ${orderState ?? '(žádný)'}` + `, kurýr ${group.trackingCourierState ?? '(žádný)'} → ${courierState ?? '(žádný)'}` ); } updated = await storage.updateData(key, current => { const d = current ?? data!; const g = d.groups?.find(x => x.id === group.id); if (g?.trackingCode) { if (timeChanged) g.deliveryAt = deliveryAt; if (stateChanged) g.trackingOrderState = orderState; if (courierChanged) g.trackingCourierState = courierState; if (clearTracking) g.trackingCode = undefined; } return d; }); if (clearTracking) { console.log(`${tracker.label} tracking: sledování skupiny "${group.name}" ukončeno`); } // Až po uložení stavu, aby pád neposlal notifikaci podruhé. // Selhání push nesmí shodit celý tik sledování. if (deliveredNow) { try { await notifyOrderDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin); } catch (e) { console.error(`${tracker.label} tracking: chyba při odesílání notifikace o doručení skupiny "${group.name}"`, e); } } } if (updated) { getWebsocket()?.emit('message', updated); } } /** * Spustí scheduler pro sledování objednávek. Interval je 60 s, lze ho ale * zkrátit přes env BOLT_POLL_INTERVAL_MS (užitečné při vývoji se simulací). */ export function startOrderTrackingScheduler(): void { const parsed = Number(process.env.BOLT_POLL_INTERVAL_MS); const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000; trackingInterval = setInterval(checkOrderTracking, intervalMs); console.log(`Sledování objednávek: scheduler spuštěn (interval ${intervalMs} ms)`); } /** Stopne scheduler sledování. Volá se při graceful shutdown. */ export function stopOrderTrackingScheduler(): void { if (trackingInterval) { clearInterval(trackingInterval); trackingInterval = undefined; } } /** Uvolní leader lease při graceful shutdown. */ export async function releaseOrderTrackingLease(): Promise { await lease.release(); }