feat: sledování objednávek přes Wolt
CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s

Vedle Bolt Food jde nově sledovat i objednávka z Woltu — stačí u objednané
skupiny vložit odkaz z track.wolt.com. Logika sledování je zobecněná do
registru rozvozových služeb (trackingProviders.ts): každá služba umí vytáhnout
kód ze sdílecího odkazu a dotázat se svého API, scheduler i stepper jsou společné.

- pole skupiny bolt* přejmenována na tracking* + nové trackingProvider
- endpoint /groups/setBoltTracking → /groups/setTracking
- Wolt: čas doručení z delivery_eta v časové zóně objednávky, 404 ukončí
  sledování, stav kurýra odvozen z is_delivering/is_delivering_other_order
- DEV simulace zůstává jen pro Bolt Food
This commit is contained in:
batmanisko committed 2026-08-24 11:27:56 +02:00
1 parent eeb1630391
commit 5c048fe1f1
25 files changed
+1326 -869

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
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<typeof setInterval> | undefined;
/** Mapa groupId → počet po sobě jdoucích selhání dotazu na API rozvozové služby. */
const consecutiveFailures = new Map<string, number>();
/** 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<void> {
const isLeader = await lease.tryAcquireOrRenew();
if (!isLeader) return;
const key = `${formatDate(getToday())}_extra`;
const data = await storage.getData<ClientData>(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<ClientData>(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<void> {
await lease.release();
}