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

+240
View File
@@ -0,0 +1,240 @@
import axios from 'axios';
import crypto from 'crypto';
import { TrackingProvider } from '../../types/gen/types.gen';
import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator';
/**
* Adaptéry rozvozových služeb pro sledování objednávek.
*
* Každá služba umí ze sdílecího odkazu vytáhnout svůj kód a dotázat se svého
* (nedokumentovaného) veřejného API. Výsledek normalizují do tvaru TrackedOrder,
* se kterým už dál pracuje jen scheduler v orderTracking.ts.
*/
/** Znormalizovaný stav sledované objednávky — společný jmenovatel všech služeb. */
export interface TrackedOrder {
/** Raw stav objednávky ze služby (Bolt order_state, Wolt status). */
orderState: string;
/** Raw stav kurýra, pokud ho služba poskytuje. */
courierState?: string;
/** Očekávaný čas doručení ve formátu HH:MM, pokud ho lze určit. */
deliveryAt?: string;
}
export interface DeliveryTracker {
provider: TrackingProvider;
/** Lidský název služby (do UI hlášek a logů). */
label: string;
/** Vytáhne kód sledování ze vstupu, nebo null, pokud vstup službě nepatří. */
extractCode(input: string): string | null;
/** Dotáže se API služby. Vrátí null, pokud objednávka už neexistuje. */
poll(code: string): Promise<TrackedOrder | null>;
}
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
const BOLT_SHARE_HOST = 'bolt.eu';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
const WOLT_TRACKING_URL = 'https://consumer-api.wolt.com/order-tracking-api/v1/details/tracking-code/track/';
const WOLT_SHARE_HOST = 'wolt.com';
/** Wolt tracking code je base64url (v praxi 22 znaků); délku bereme s rezervou. */
const WOLT_CODE_REGEX = /^[A-Za-z0-9_-]{20,32}$/;
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
const DEVICE_ID = crypto.randomUUID();
/** Vrátí poslední neprázdný segment cesty URL (u odkazu bez URL tvaru vrátí null). */
function lastPathSegment(input: string): string | null {
let url: URL;
try {
url = new URL(input);
} catch {
return null;
}
const segments = url.pathname.split('/').filter(Boolean);
return segments[segments.length - 1] ?? null;
}
/** Patří hostitel odkazu dané službě (včetně subdomén)? */
function isHost(input: string, host: string): boolean {
try {
const hostname = new URL(input).hostname.toLowerCase();
return hostname === host || hostname.endsWith(`.${host}`);
} catch {
return false;
}
}
/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */
export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string {
const eta = new Date(now.getTime() + seconds * 1000);
return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`;
}
/**
* Zformátuje absolutní ISO čas na HH:MM. Wolt v odpovědi posílá i časovou zónu
* objednávky, takže se čas zobrazí správně i kdyby server běžel v jiné zóně.
*/
export function formatEtaHHMM(iso: string, timeZone?: string): string | undefined {
const eta = new Date(iso);
if (Number.isNaN(eta.getTime())) return undefined;
const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' };
try {
return new Intl.DateTimeFormat('cs-CZ', { ...options, timeZone }).format(eta);
} catch {
// Neznámá zóna z API — spadneme na lokální čas serveru (TZ=Europe/Prague)
return new Intl.DateTimeFormat('cs-CZ', options).format(eta);
}
}
interface BoltOrder {
order_id?: number;
order_state: string;
expected_time_to_client_in_seconds?: number;
courier?: { state?: string } | null;
}
/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */
async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
// DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API.
// V produkci je registr vždy prázdný, takže se sem nikdy nedostane.
if (isBoltSimulated(token)) {
return getSimulatedBoltOrder(token);
}
const res = await axios.post(BOLT_POLLING_URL, { token }, {
params: {
version: 'FW.1.113',
language: 'cs-CZ',
country: 'cz',
device_name: 'web',
device_os_version: 'web',
deviceType: 'web',
session_id: DEVICE_ID,
distinct_id: `$device:${DEVICE_ID}`,
deviceId: DEVICE_ID,
},
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
if (res.data?.code !== 0) {
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
}
return res.data?.data?.orders?.[0] ?? null;
}
const boltTracker: DeliveryTracker = {
provider: TrackingProvider.BOLT,
label: 'Bolt Food',
/**
* Přijme sdílecí URL Bolt Food (https://food.bolt.eu/sharedActiveOrder/<token>)
* nebo samotný token (64 hex znaků).
*/
extractCode(input) {
if (BOLT_TOKEN_REGEX.test(input)) return input;
if (!isHost(input, BOLT_SHARE_HOST)) return null;
const last = lastPathSegment(input);
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
},
async poll(code) {
const order = await pollBoltOrder(code);
if (!order) return null;
const seconds = order.expected_time_to_client_in_seconds;
return {
orderState: order.order_state || '',
courierState: order.courier?.state || undefined,
deliveryAt: typeof seconds === 'number' ? computeDeliveryHHMM(seconds) : undefined,
};
},
};
/** Odpověď Wolt tracking API (jen pole, která používáme). */
interface WoltTrackingDetails {
status?: string;
delivery_eta?: string | null;
couriers?: WoltCourier[];
timezone?: string;
}
interface WoltCourier {
/** Kurýr veze nějakou objednávku (nemusí to být ta naše — viz is_delivering_other_order). */
is_delivering?: boolean;
/** Kurýr právě doručuje cizí objednávku, tu naši teprve vyzvedne nebo veze až po ní. */
is_delivering_other_order?: boolean;
}
/**
* Wolt neposílá stav kurýra, jen jeho pozici a dva příznaky. Odvodíme z nich
* dvojí stav: 'delivering' (veze naši objednávku → krok „Na cestě") a 'assigned'
* (kurýr přiřazen, ale naše jídlo ještě nemá — typicky doručuje cizí objednávku).
*/
function woltCourierState(couriers?: WoltCourier[]): string | undefined {
const courier = couriers?.[0];
if (!courier) return undefined;
return courier.is_delivering && !courier.is_delivering_other_order ? 'delivering' : 'assigned';
}
const woltTracker: DeliveryTracker = {
provider: TrackingProvider.WOLT,
label: 'Wolt',
/**
* Přijme odkaz na sledování Wolt (https://track.wolt.com/<code>, případně
* s jazykovým segmentem /en/<code>) nebo samotný tracking code.
*/
extractCode(input) {
if (WOLT_CODE_REGEX.test(input)) return input;
if (!isHost(input, WOLT_SHARE_HOST)) return null;
const last = lastPathSegment(input);
return last && WOLT_CODE_REGEX.test(last) ? last : null;
},
async poll(code) {
let details: WoltTrackingDetails;
try {
const res = await axios.get<WoltTrackingDetails>(`${WOLT_TRACKING_URL}${encodeURIComponent(code)}`, {
headers: {
Accept: 'application/json',
'Accept-Language': 'cs',
// Wolt API odpovídá jen na dotazy z jeho tracking stránky
Referer: 'https://track.wolt.com/',
Origin: 'https://track.wolt.com',
},
timeout: 10_000,
});
details = res.data ?? {};
} catch (e) {
// Neznámý/expirovaný kód → objednávka už neexistuje (stejně jako u Boltu)
if (axios.isAxiosError(e) && e.response?.status === 404) return null;
throw e;
}
if (!details.status) {
throw new Error('Wolt API vrátilo odpověď bez stavu objednávky');
}
return {
orderState: details.status,
courierState: woltCourierState(details.couriers),
deliveryAt: details.delivery_eta ? formatEtaHHMM(details.delivery_eta, details.timezone) : undefined,
};
},
};
export const TRACKERS: Record<TrackingProvider, DeliveryTracker> = {
[TrackingProvider.BOLT]: boltTracker,
[TrackingProvider.WOLT]: woltTracker,
};
/**
* Rozpozná službu podle sdílecího odkazu (nebo samotného kódu) a vytáhne z něj
* kód sledování. Vrátí null, pokud vstup nepatří žádné podporované službě.
*/
export function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
const trimmed = input.trim();
if (!trimmed) return null;
for (const tracker of Object.values(TRACKERS)) {
const code = tracker.extractCode(trimmed);
if (code) return { provider: tracker.provider, code };
}
return null;
}