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
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:
1 parent
eeb1630391
commit
5c048fe1f1
25 files changed
+1326
-869
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user