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
160 lines
7.2 KiB
TypeScript
160 lines
7.2 KiB
TypeScript
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();
|
|
}
|