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,104 +0,0 @@
|
|||||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
|
||||||
import './BoltOrderProgress.scss';
|
|
||||||
|
|
||||||
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
|
||||||
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
|
||||||
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
|
||||||
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
|
||||||
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
|
||||||
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
|
||||||
*/
|
|
||||||
const ORDER_STATE_TO_STEP: Record<string, number> = {
|
|
||||||
created: 0,
|
|
||||||
pending: 0,
|
|
||||||
waiting_acceptance: 0,
|
|
||||||
accepted: 0,
|
|
||||||
waiting_preparation: 0,
|
|
||||||
preparing: 1,
|
|
||||||
waiting_delivery: 1,
|
|
||||||
ready_for_pickup: 1,
|
|
||||||
waiting_courier: 1,
|
|
||||||
waiting_pickup: 1,
|
|
||||||
picked_up: 3,
|
|
||||||
in_delivery: 3,
|
|
||||||
delivering: 3,
|
|
||||||
heading_to_client: 3,
|
|
||||||
delivered: 4,
|
|
||||||
finished: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
|
||||||
const COURIER_STATE_TO_STEP: Record<string, number> = {
|
|
||||||
matched: 0,
|
|
||||||
accepted: 0,
|
|
||||||
heading_to_provider: 1,
|
|
||||||
arrived_to_provider: 1,
|
|
||||||
picked_up: 3,
|
|
||||||
heading_to_client: 3,
|
|
||||||
delivering: 3,
|
|
||||||
arrived_to_client: 3,
|
|
||||||
delivered: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Order states ve kterých už jídlo čeká připravené na kurýra. */
|
|
||||||
const PICKUP_READY_ORDER_STATES = new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']);
|
|
||||||
|
|
||||||
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
|
||||||
function stepForOrderState(state: string): number | 'cancelled' {
|
|
||||||
const s = state.toLowerCase();
|
|
||||||
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
|
|
||||||
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
|
||||||
if (/delivered|finished/.test(s)) return 4;
|
|
||||||
if (/accept|pending|created/.test(s)) return 0;
|
|
||||||
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
|
|
||||||
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepForCourierState(state?: string): number {
|
|
||||||
if (!state) return 0;
|
|
||||||
const s = state.toLowerCase();
|
|
||||||
if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s];
|
|
||||||
if (/picked|client|delivering|transport/.test(s)) return 3;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Raw order_state z Bolt API (např. waiting_preparation) */
|
|
||||||
state: string;
|
|
||||||
/** Raw courier.state z Bolt API (např. arrived_to_provider) */
|
|
||||||
courierState?: string;
|
|
||||||
/** Zda sledování stále běží (skupina má boltTrackingToken) */
|
|
||||||
tracking: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mini progress stepper se stavem objednávky Bolt Food. */
|
|
||||||
export default function BoltOrderProgress({ state, courierState, tracking }: Props) {
|
|
||||||
const orderStep = stepForOrderState(state);
|
|
||||||
if (orderStep === 'cancelled') {
|
|
||||||
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
|
|
||||||
}
|
|
||||||
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
|
||||||
let step = Math.max(orderStep, stepForCourierState(courierState));
|
|
||||||
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
|
||||||
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u ORDER_STATE_TO_STEP.
|
|
||||||
const courierAtProvider = courierState?.toLowerCase() === 'arrived_to_provider';
|
|
||||||
const foodReadyForPickup = PICKUP_READY_ORDER_STATES.has(state.toLowerCase());
|
|
||||||
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
|
||||||
const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state;
|
|
||||||
return (
|
|
||||||
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {rawInfo}</Tooltip>}>
|
|
||||||
<div className={`bolt-progress${tracking && step < 4 ? ' live' : ''}`}>
|
|
||||||
{STEPS.map((label, i) => (
|
|
||||||
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
|
||||||
<div className="bolt-dot" />
|
|
||||||
<div className="bolt-label">{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</OverlayTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+9
-9
@@ -1,9 +1,9 @@
|
|||||||
.bolt-progress {
|
.tracking-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
|
||||||
.bolt-step {
|
.tracking-step {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 64px;
|
min-width: 64px;
|
||||||
|
|
||||||
.bolt-dot {
|
.tracking-dot {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
background: var(--luncher-border, #ced4da);
|
background: var(--luncher-border, #ced4da);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bolt-label {
|
.tracking-label {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
color: var(--luncher-text-muted, #6c757d);
|
color: var(--luncher-text-muted, #6c757d);
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.done {
|
&.done {
|
||||||
.bolt-dot {
|
.tracking-dot {
|
||||||
background: var(--bs-success, #198754);
|
background: var(--bs-success, #198754);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,19 +49,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active .bolt-label {
|
&.active .tracking-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pulzování aktivního kroku, dokud sledování běží
|
// Pulzování aktivního kroku, dokud sledování běží
|
||||||
&.live .bolt-step.active .bolt-dot {
|
&.live .tracking-step.active .tracking-dot {
|
||||||
animation: bolt-pulse 2s ease-in-out infinite;
|
animation: tracking-pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bolt-pulse {
|
@keyframes tracking-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||||
|
import { TrackingProvider } from '../../../types';
|
||||||
|
import './OrderProgress.scss';
|
||||||
|
|
||||||
|
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
||||||
|
|
||||||
|
/** Lidský název rozvozové služby do tooltipu. */
|
||||||
|
export const PROVIDER_LABEL: Record<TrackingProvider, string> = {
|
||||||
|
bolt: 'Bolt Food',
|
||||||
|
wolt: 'Wolt',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
||||||
|
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
||||||
|
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
||||||
|
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
||||||
|
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
||||||
|
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
||||||
|
*/
|
||||||
|
const BOLT_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
created: 0,
|
||||||
|
pending: 0,
|
||||||
|
waiting_acceptance: 0,
|
||||||
|
accepted: 0,
|
||||||
|
waiting_preparation: 0,
|
||||||
|
preparing: 1,
|
||||||
|
waiting_delivery: 1,
|
||||||
|
ready_for_pickup: 1,
|
||||||
|
waiting_courier: 1,
|
||||||
|
waiting_pickup: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
in_delivery: 3,
|
||||||
|
delivering: 3,
|
||||||
|
heading_to_client: 3,
|
||||||
|
delivered: 4,
|
||||||
|
finished: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
||||||
|
const BOLT_COURIER_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
matched: 0,
|
||||||
|
accepted: 0,
|
||||||
|
heading_to_provider: 1,
|
||||||
|
arrived_to_provider: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
heading_to_client: 3,
|
||||||
|
delivering: 3,
|
||||||
|
arrived_to_client: 3,
|
||||||
|
delivered: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Známé stavy objednávky z Wolt tracking API → index kroku ve stepperu.
|
||||||
|
* Stav "ready" znamená hotové jídlo čekající na kurýra, proto zůstává v „Přípravě"
|
||||||
|
* a na „Vyzvedávání" se posune, až když je kurýr přiřazený. Že objednávku kurýr
|
||||||
|
* skutečně veze, se pozná až ze stavu kurýra 'delivering' (viz trackingProviders.ts).
|
||||||
|
*/
|
||||||
|
const WOLT_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
received: 0,
|
||||||
|
acknowledged: 0,
|
||||||
|
confirmed: 0,
|
||||||
|
waiting: 0,
|
||||||
|
production: 1,
|
||||||
|
preparing: 1,
|
||||||
|
ready: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
transport: 3,
|
||||||
|
delivering: 3,
|
||||||
|
delivered: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stavy, ve kterých už jídlo čeká připravené na kurýra. */
|
||||||
|
const PICKUP_READY_STATES: Record<TrackingProvider, Set<string>> = {
|
||||||
|
bolt: new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']),
|
||||||
|
wolt: new Set(['ready']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
||||||
|
function stepForOrderState(provider: TrackingProvider, state: string): number | 'cancelled' {
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
const known = provider === 'wolt' ? WOLT_STATE_TO_STEP : BOLT_STATE_TO_STEP;
|
||||||
|
if (s in known) return known[s];
|
||||||
|
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||||
|
if (/delivered|finished/.test(s)) return 4;
|
||||||
|
if (/accept|pending|created|received/.test(s)) return 0;
|
||||||
|
if (/^waiting|prepar|ready|cook|production/.test(s)) return 1;
|
||||||
|
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepForCourierState(provider: TrackingProvider, state?: string): number {
|
||||||
|
if (!state) return 0;
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
// Wolt má jen odvozené stavy: 'assigned' o kroku nic neříká, 'delivering' = veze naši objednávku
|
||||||
|
if (provider === 'wolt') return s === 'delivering' ? 3 : 0;
|
||||||
|
if (s in BOLT_COURIER_STATE_TO_STEP) return BOLT_COURIER_STATE_TO_STEP[s];
|
||||||
|
if (/picked|client|delivering|transport/.test(s)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Rozvozová služba, ze které stav pochází */
|
||||||
|
provider: TrackingProvider;
|
||||||
|
/** Raw stav objednávky (Bolt order_state, Wolt status) */
|
||||||
|
state: string;
|
||||||
|
/** Raw stav kurýra (Bolt courier.state, Wolt 'assigned') */
|
||||||
|
courierState?: string;
|
||||||
|
/** Zda sledování stále běží (skupina má trackingCode) */
|
||||||
|
tracking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mini progress stepper se stavem objednávky u rozvozové služby. */
|
||||||
|
export default function OrderProgress({ provider, state, courierState, tracking }: Props) {
|
||||||
|
const label = PROVIDER_LABEL[provider] ?? provider;
|
||||||
|
const orderStep = stepForOrderState(provider, state);
|
||||||
|
if (orderStep === 'cancelled') {
|
||||||
|
return <small className="text-danger">Objednávka {label} byla zrušena</small>;
|
||||||
|
}
|
||||||
|
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
||||||
|
let step = Math.max(orderStep, stepForCourierState(provider, courierState));
|
||||||
|
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
||||||
|
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u BOLT_STATE_TO_STEP.
|
||||||
|
const courierAtProvider = provider === 'wolt'
|
||||||
|
? !!courierState
|
||||||
|
: courierState?.toLowerCase() === 'arrived_to_provider';
|
||||||
|
const foodReadyForPickup = PICKUP_READY_STATES[provider]?.has(state.toLowerCase());
|
||||||
|
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
||||||
|
const courierInfo = provider === 'wolt'
|
||||||
|
? (courierState === 'delivering' ? 'kurýr veze objednávku' : 'kurýr přiřazen')
|
||||||
|
: `kurýr: ${courierState}`;
|
||||||
|
const rawInfo = courierState ? `${state} / ${courierInfo}` : state;
|
||||||
|
return (
|
||||||
|
<OverlayTrigger overlay={<Tooltip>Stav z {label}: {rawInfo}</Tooltip>}>
|
||||||
|
<div className={`tracking-progress${tracking && step < 4 ? ' live' : ''}`}>
|
||||||
|
{STEPS.map((stepLabel, i) => (
|
||||||
|
<div key={stepLabel} className={`tracking-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
||||||
|
<div className="tracking-dot" />
|
||||||
|
<div className="tracking-label">{stepLabel}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
group: OrderGroup;
|
group: OrderGroup;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v BoltOrderProgress). */
|
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v OrderProgress). */
|
||||||
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
||||||
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
||||||
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
||||||
@@ -30,7 +30,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
|
|||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
const [manualKey, setManualKey] = useState<string>('preparing');
|
const [manualKey, setManualKey] = useState<string>('preparing');
|
||||||
|
|
||||||
const running = !!group.boltTrackingToken;
|
const running = !!group.trackingCode;
|
||||||
|
|
||||||
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
||||||
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
||||||
@@ -113,7 +113,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
|
|||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
Stav simulace:{' '}
|
Stav simulace:{' '}
|
||||||
{running
|
{running
|
||||||
? <Badge bg="success">běží{group.boltOrderState ? ` — ${group.boltOrderState}` : ''}</Badge>
|
? <Badge bg="success">běží{group.trackingOrderState ? ` — ${group.trackingOrderState}` : ''}</Badge>
|
||||||
: <Badge bg="secondary">neběží</Badge>}
|
: <Badge bg="secondary">neběží</Badge>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -141,13 +141,13 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
id="boltDeliveredCheckbox"
|
id="boltDeliveredCheckbox"
|
||||||
ref={boltDeliveredRef}
|
ref={boltDeliveredRef}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
label="Upozornění na doručení objednávky (Bolt Food)"
|
label="Upozornění na doručení objednávky (Bolt Food / Wolt)"
|
||||||
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
||||||
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
<Form.Text className="text-muted">
|
||||||
Až bude skupinová objednávka sledovaná přes Bolt Food doručena, přijde vám push notifikace.
|
Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace.
|
||||||
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace Bolt.
|
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby.
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import DatePicker, { registerLocale } from 'react-datepicker';
|
|||||||
import { cs } from 'date-fns/locale';
|
import { cs } from 'date-fns/locale';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import {
|
import {
|
||||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
||||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
|
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||||
@@ -24,26 +24,57 @@ import PayForGroupModal from '../components/modals/PayForGroupModal';
|
|||||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||||
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
||||||
import PendingPayments from '../components/PendingPayments';
|
import PendingPayments from '../components/PendingPayments';
|
||||||
import BoltOrderProgress from '../components/BoltOrderProgress';
|
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
|
||||||
|
|
||||||
const SLOT = MealSlot.EXTRA;
|
const SLOT = MealSlot.EXTRA;
|
||||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
|
|
||||||
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
|
|
||||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
|
/** Předpony sdílecích odkazů jednotlivých služeb (pro sestavení odkazu z uloženého kódu). */
|
||||||
function extractBoltToken(input: string): string | null {
|
const TRACKING_URL_PREFIX: Record<TrackingProvider, string> = {
|
||||||
|
bolt: 'https://food.bolt.eu/sharedActiveOrder/',
|
||||||
|
wolt: 'https://track.wolt.com/',
|
||||||
|
};
|
||||||
|
/** Tvar kódu sledování u jednotlivých služeb — musí odpovídat serveru (trackingProviders.ts). */
|
||||||
|
const TRACKING_CODE_REGEX: Record<TrackingProvider, RegExp> = {
|
||||||
|
bolt: /^[0-9a-f]{64}$/i,
|
||||||
|
wolt: /^[A-Za-z0-9_-]{20,32}$/,
|
||||||
|
};
|
||||||
|
const TRACKING_HOST: Record<TrackingProvider, string> = {
|
||||||
|
bolt: 'bolt.eu',
|
||||||
|
wolt: 'wolt.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sestaví odkaz na sledování objednávky ze služby a kódu. */
|
||||||
|
function trackingUrlFor(provider: TrackingProvider, code: string): string {
|
||||||
|
return `${TRACKING_URL_PREFIX[provider] ?? ''}${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozpozná rozvozovou službu podle odkazu na sledování (nebo podle samotného kódu)
|
||||||
|
* a vytáhne z něj kód sledování. Null = vstup nepatří žádné podporované službě.
|
||||||
|
*/
|
||||||
|
function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
let hostname = '';
|
||||||
|
let lastSegment = '';
|
||||||
try {
|
try {
|
||||||
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
|
const url = new URL(trimmed);
|
||||||
const last = segments[segments.length - 1];
|
hostname = url.hostname.toLowerCase();
|
||||||
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
const segments = url.pathname.split('/').filter(Boolean);
|
||||||
|
lastSegment = segments[segments.length - 1] ?? '';
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// Vstup není URL — zkusíme ho vzít jako samotný kód
|
||||||
}
|
}
|
||||||
|
for (const provider of Object.values(TrackingProvider)) {
|
||||||
|
const regex = TRACKING_CODE_REGEX[provider];
|
||||||
|
if (!hostname && regex.test(trimmed)) return { provider, code: trimmed };
|
||||||
|
const host = TRACKING_HOST[provider];
|
||||||
|
const hostMatches = hostname === host || hostname.endsWith(`.${host}`);
|
||||||
|
if (hostMatches && regex.test(lastSegment)) return { provider, code: lastSegment };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
|
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
|
||||||
@@ -99,7 +130,7 @@ export default function OrderGroupsPage() {
|
|||||||
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||||
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
||||||
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
||||||
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({});
|
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; trackingUrl: string }>>({});
|
||||||
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||||
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
|
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
|
||||||
@@ -276,7 +307,7 @@ export default function OrderGroupsPage() {
|
|||||||
const handleSaveTimes = async (group: OrderGroup) => {
|
const handleSaveTimes = async (group: OrderGroup) => {
|
||||||
const times = editTimes[group.id];
|
const times = editTimes[group.id];
|
||||||
if (!times) return;
|
if (!times) return;
|
||||||
const { orderedAt, deliveryAt, boltUrl } = times;
|
const { orderedAt, deliveryAt, trackingUrl } = times;
|
||||||
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||||
setPageError('Čas objednání musí být ve formátu HH:MM');
|
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||||
return;
|
return;
|
||||||
@@ -285,16 +316,16 @@ export default function OrderGroupsPage() {
|
|||||||
setPageError('Čas doručení musí být ve formátu HH:MM');
|
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
|
// Odkaz na sledování se odesílá jen při změně oproti aktuálnímu kódu skupiny
|
||||||
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
|
const tracking = trackingUrl.trim() ? extractTracking(trackingUrl) : null;
|
||||||
if (boltUrl.trim() && !boltToken) {
|
if (trackingUrl.trim() && !tracking) {
|
||||||
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
|
setPageError('Neplatný odkaz pro sledování (očekáván odkaz Bolt Food nebo Wolt)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
|
const trackingChanged = tracking?.code !== group.trackingCode || tracking?.provider !== group.trackingProvider;
|
||||||
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||||
if (ok && boltChanged) {
|
if (ok && trackingChanged) {
|
||||||
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
|
ok = await refresh(() => setTracking({ body: { id: group.id, shareUrl: trackingUrl.trim() } }));
|
||||||
}
|
}
|
||||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
||||||
};
|
};
|
||||||
@@ -731,13 +762,13 @@ export default function OrderGroupsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex align-items-center gap-1">
|
<div className="d-flex align-items-center gap-1">
|
||||||
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small>
|
<small className="text-muted text-nowrap">Odkaz pro sledování (Bolt Food / Wolt):</small>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="text"
|
type="text"
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder={`${BOLT_SHARE_URL_PREFIX}…`}
|
placeholder={`${TRACKING_URL_PREFIX.wolt}…`}
|
||||||
value={editTimes[group.id]?.boltUrl ?? ''}
|
value={editTimes[group.id]?.trackingUrl ?? ''}
|
||||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))}
|
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], trackingUrl: e.target.value } }))}
|
||||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||||
style={{ width: 260 }}
|
style={{ width: 260 }}
|
||||||
/>
|
/>
|
||||||
@@ -750,8 +781,10 @@ export default function OrderGroupsPage() {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const canEdit = !isReadOnly && isCreator;
|
const canEdit = !isReadOnly && isCreator;
|
||||||
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
|
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
|
||||||
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }));
|
const trackingUrl = group.trackingProvider && group.trackingCode
|
||||||
const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null;
|
? trackingUrlFor(group.trackingProvider, group.trackingCode)
|
||||||
|
: null;
|
||||||
|
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', trackingUrl: trackingUrl ?? '' } }));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
@@ -775,9 +808,9 @@ export default function OrderGroupsPage() {
|
|||||||
onClick={startEdit}
|
onClick={startEdit}
|
||||||
>
|
>
|
||||||
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||||
{group.boltTrackingToken && (
|
{group.trackingCode && group.trackingProvider && (
|
||||||
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}>
|
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z {PROVIDER_LABEL[group.trackingProvider]}</Tooltip>}>
|
||||||
<Badge bg="success" className="ms-1">Bolt</Badge>
|
<Badge bg="success" className="ms-1">{PROVIDER_LABEL[group.trackingProvider]}</Badge>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
)}
|
)}
|
||||||
</small>
|
</small>
|
||||||
@@ -802,9 +835,14 @@ export default function OrderGroupsPage() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{group.boltOrderState && (
|
{group.trackingOrderState && group.trackingProvider && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} />
|
<OrderProgress
|
||||||
|
provider={group.trackingProvider}
|
||||||
|
state={group.trackingOrderState}
|
||||||
|
courierState={group.trackingCourierState}
|
||||||
|
tracking={!!group.trackingCode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{IS_DEV && (
|
{IS_DEV && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { loginViaApi } from './helpers';
|
import { loginViaApi } from './helpers';
|
||||||
|
|
||||||
const BOLT_LABEL = 'Upozornění na doručení objednávky (Bolt Food)';
|
const DELIVERY_LABEL = 'Upozornění na doručení objednávky (Bolt Food / Wolt)';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginViaApi(page, 'e2e-user');
|
await loginViaApi(page, 'e2e-user');
|
||||||
@@ -15,24 +15,24 @@ async function openSettings(page: import('@playwright/test').Page) {
|
|||||||
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
|
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
test('Přepínač doručení Bolt je pod výběrem času připomínky', async ({ page }) => {
|
test('Přepínač doručení objednávky je pod výběrem času připomínky', async ({ page }) => {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
const reminderGroup = page.locator('.modal-body .mb-3').filter({ hasText: 'Připomínka výběru oběda' });
|
const reminderGroup = page.locator('.modal-body .mb-3').filter({ hasText: 'Připomínka výběru oběda' });
|
||||||
const boltGroup = page.locator('.modal-body .mb-3').filter({ hasText: BOLT_LABEL });
|
const deliveryGroup = page.locator('.modal-body .mb-3').filter({ hasText: DELIVERY_LABEL });
|
||||||
await expect(reminderGroup).toBeVisible();
|
await expect(reminderGroup).toBeVisible();
|
||||||
await expect(boltGroup).toBeVisible();
|
await expect(deliveryGroup).toBeVisible();
|
||||||
|
|
||||||
// Přepínač musí v DOM následovat až za skupinou s časem připomínky
|
// Přepínač musí v DOM následovat až za skupinou s časem připomínky
|
||||||
const order = await page.evaluate((label) => {
|
const order = await page.evaluate((label) => {
|
||||||
const groups = Array.from(document.querySelectorAll('.modal-body .mb-3'));
|
const groups = Array.from(document.querySelectorAll('.modal-body .mb-3'));
|
||||||
return {
|
return {
|
||||||
reminder: groups.findIndex(g => g.textContent?.includes('Připomínka výběru oběda')),
|
reminder: groups.findIndex(g => g.textContent?.includes('Připomínka výběru oběda')),
|
||||||
bolt: groups.findIndex(g => g.textContent?.includes(label)),
|
delivery: groups.findIndex(g => g.textContent?.includes(label)),
|
||||||
};
|
};
|
||||||
}, BOLT_LABEL);
|
}, DELIVERY_LABEL);
|
||||||
expect(order.reminder).toBeGreaterThanOrEqual(0);
|
expect(order.reminder).toBeGreaterThanOrEqual(0);
|
||||||
expect(order.bolt).toBe(order.reminder + 1);
|
expect(order.delivery).toBe(order.reminder + 1);
|
||||||
|
|
||||||
// Výchozí stav je vypnuto
|
// Výchozí stav je vypnuto
|
||||||
await expect(page.locator('#boltDeliveredCheckbox')).not.toBeChecked();
|
await expect(page.locator('#boltDeliveredCheckbox')).not.toBeChecked();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||||
# ADMIN_PASSWORD=
|
# ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Interval (ms) scheduleru sledování objednávek Bolt Food. Výchozí 60000 (60 s).
|
# Interval (ms) scheduleru sledování objednávek (Bolt Food, Wolt). Výchozí 60000 (60 s).
|
||||||
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
|
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
|
||||||
# BOLT_POLL_INTERVAL_MS=3000
|
# BOLT_POLL_INTERVAL_MS=3000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
"Sledování objednávky nově i přes Wolt — stačí u objednané skupiny vložit odkaz z track.wolt.com",
|
||||||
|
"Upozornění na doručení objednávky nyní chodí i u objednávek sledovaných přes Wolt"
|
||||||
|
]
|
||||||
@@ -4,7 +4,7 @@ import crypto from 'crypto';
|
|||||||
* Vývojový simulátor sledování objednávek Bolt Food.
|
* Vývojový simulátor sledování objednávek Bolt Food.
|
||||||
*
|
*
|
||||||
* Drží in-memory registr „simulovaných" objednávek klíčovaný tokenem. Funkce
|
* Drží in-memory registr „simulovaných" objednávek klíčovaný tokenem. Funkce
|
||||||
* pollBoltOrder v boltTracking.ts se na začátku podívá, zda je token simulovaný,
|
* pollBoltOrder v trackingProviders.ts se na začátku podívá, zda je token simulovaný,
|
||||||
* a pokud ano, vrátí vyfabrikovaný stav místo dotazu na reálné Bolt API.
|
* a pokud ano, vrátí vyfabrikovaný stav místo dotazu na reálné Bolt API.
|
||||||
*
|
*
|
||||||
* Registr se plní výhradně přes dev endpointy (gated requireDevMode), takže
|
* Registr se plní výhradně přes dev endpointy (gated requireDevMode), takže
|
||||||
@@ -13,7 +13,7 @@ import crypto from 'crypto';
|
|||||||
* Postup stavů je řízen ručně (krokováním), bez časové osy — viz advance/setState.
|
* Postup stavů je řízen ručně (krokováním), bez časové osy — viz advance/setState.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Jeden krok simulace — odpovídá tomu, co vrací Bolt API a co čte BoltOrderProgress. */
|
/** Jeden krok simulace — odpovídá tomu, co vrací Bolt API a co čte OrderProgress. */
|
||||||
export interface SimStep {
|
export interface SimStep {
|
||||||
order_state: string;
|
order_state: string;
|
||||||
courier_state?: string;
|
courier_state?: string;
|
||||||
@@ -40,7 +40,7 @@ interface Simulation {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Výchozí scénář „happy path". Stavy a stavy kurýra odpovídají mapování ve
|
* Výchozí scénář „happy path". Stavy a stavy kurýra odpovídají mapování ve
|
||||||
* client/src/components/BoltOrderProgress.tsx
|
* client/src/components/OrderProgress.tsx
|
||||||
* (Přijato → Příprava → Vyzvedávání → Na cestě → Doručeno).
|
* (Přijato → Příprava → Vyzvedávání → Na cestě → Doručeno).
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_SCENARIO: SimStep[] = [
|
export const DEFAULT_SCENARIO: SimStep[] = [
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import getStorage from './storage';
|
|
||||||
import { createLeaderLease } from './leaderLease';
|
|
||||||
import { getToday } from './service';
|
|
||||||
import { formatDate } from './utils';
|
|
||||||
import { getWebsocket } from './websocket';
|
|
||||||
import { ClientData, GroupState } from '../../types/gen/types.gen';
|
|
||||||
import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator';
|
|
||||||
import { notifyBoltDelivered } from './notifikace';
|
|
||||||
|
|
||||||
const storage = getStorage();
|
|
||||||
const lease = createLeaderLease('luncher:bolt:leader');
|
|
||||||
|
|
||||||
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
|
|
||||||
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
|
|
||||||
const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i;
|
|
||||||
const DELIVERED_STATE_REGEX = /delivered|finished/i;
|
|
||||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
|
||||||
|
|
||||||
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
|
|
||||||
const DEVICE_ID = crypto.randomUUID();
|
|
||||||
|
|
||||||
let boltInterval: ReturnType<typeof setInterval> | undefined;
|
|
||||||
|
|
||||||
/** Mapa groupId → počet po sobě jdoucích selhání dotazu na Bolt API. */
|
|
||||||
const consecutiveFailures = new Map<string, number>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vytáhne sledovací token ze sdílecí URL Bolt Food
|
|
||||||
* (https://food.bolt.eu/sharedActiveOrder/<token>) nebo přijme samotný token.
|
|
||||||
* Vrátí null, pokud vstup neobsahuje platný token (64 hex znaků).
|
|
||||||
*/
|
|
||||||
export function extractBoltToken(input: string): string | null {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
|
||||||
let pathname: string;
|
|
||||||
try {
|
|
||||||
pathname = new URL(trimmed).pathname;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const segments = pathname.split('/').filter(Boolean);
|
|
||||||
const last = segments[segments.length - 1];
|
|
||||||
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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. */
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím tokenem
|
|
||||||
* zjistí očekávaný čas doručení z Bolt API a aktualizuje deliveryAt.
|
|
||||||
* Sledování se automaticky ukončí (token se smaže), když objednávka skončí
|
|
||||||
* nebo dotazy opakovaně selhávají.
|
|
||||||
*/
|
|
||||||
export async function checkBoltTracking(): 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 => g.boltTrackingToken && g.state === GroupState.ORDERED);
|
|
||||||
|
|
||||||
// Ú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) {
|
|
||||||
let deliveryAt: string | undefined;
|
|
||||||
let orderState: string | undefined;
|
|
||||||
let courierState: string | undefined;
|
|
||||||
let clearToken = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const order = await pollBoltOrder(group.boltTrackingToken!);
|
|
||||||
consecutiveFailures.delete(group.id);
|
|
||||||
if (!order) {
|
|
||||||
// Objednávka z API zmizela — považujeme ji za doručenou
|
|
||||||
orderState = 'delivered';
|
|
||||||
clearToken = true;
|
|
||||||
} else {
|
|
||||||
orderState = order.order_state || undefined;
|
|
||||||
courierState = order.courier?.state || undefined;
|
|
||||||
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
|
|
||||||
clearToken = true;
|
|
||||||
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
|
|
||||||
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
|
|
||||||
consecutiveFailures.set(group.id, failures);
|
|
||||||
console.error(`Bolt tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e);
|
|
||||||
if (failures < MAX_CONSECUTIVE_FAILURES) continue;
|
|
||||||
consecutiveFailures.delete(group.id);
|
|
||||||
clearToken = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
|
|
||||||
const stateChanged = orderState !== undefined && orderState !== group.boltOrderState;
|
|
||||||
const courierChanged = courierState !== group.boltCourierState && !clearToken;
|
|
||||||
if (!clearToken && !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.boltOrderState ?? '');
|
|
||||||
|
|
||||||
// Log každého přechodu stavu — Bolt API není dokumentované, takže si takhle
|
|
||||||
// průběžně mapujeme jeho stavový automat (viz mapování v BoltOrderProgress.tsx).
|
|
||||||
if (stateChanged || courierChanged) {
|
|
||||||
console.log(
|
|
||||||
`Bolt tracking: skupina "${group.name}" stav ${group.boltOrderState ?? '(žádný)'} → ${orderState ?? '(žádný)'}` +
|
|
||||||
`, kurýr ${group.boltCourierState ?? '(žá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?.boltTrackingToken) {
|
|
||||||
if (timeChanged) g.deliveryAt = deliveryAt;
|
|
||||||
if (stateChanged) g.boltOrderState = orderState;
|
|
||||||
if (courierChanged) g.boltCourierState = courierState;
|
|
||||||
if (clearToken) g.boltTrackingToken = undefined;
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
});
|
|
||||||
if (clearToken) {
|
|
||||||
console.log(`Bolt 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 notifyBoltDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Bolt 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í Bolt 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 startBoltTrackingScheduler(): void {
|
|
||||||
const parsed = Number(process.env.BOLT_POLL_INTERVAL_MS);
|
|
||||||
const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000;
|
|
||||||
boltInterval = setInterval(checkBoltTracking, intervalMs);
|
|
||||||
console.log(`Bolt tracking: scheduler spuštěn (interval ${intervalMs} ms)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stopne scheduler sledování. Volá se při graceful shutdown. */
|
|
||||||
export function stopBoltTrackingScheduler(): void {
|
|
||||||
if (boltInterval) {
|
|
||||||
clearInterval(boltInterval);
|
|
||||||
boltInterval = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uvolní leader lease při graceful shutdown. */
|
|
||||||
export async function releaseBoltTrackingLease(): Promise<void> {
|
|
||||||
await lease.release();
|
|
||||||
}
|
|
||||||
+19
-16
@@ -3,7 +3,7 @@ import getStorage from "./storage";
|
|||||||
import { getClientData, getToday, initIfNeeded } from "./service";
|
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||||
import { getStores } from "./stores";
|
import { getStores } from "./stores";
|
||||||
import { removePendingQrsByGroupId } from "./pizza";
|
import { removePendingQrsByGroupId } from "./pizza";
|
||||||
import { extractBoltToken } from "./boltTracking";
|
import { extractTracking } from "./trackingProviders";
|
||||||
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
||||||
import { formatDate } from "./utils";
|
import { formatDate } from "./utils";
|
||||||
|
|
||||||
@@ -151,9 +151,10 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
|||||||
group.orderedAt = undefined;
|
group.orderedAt = undefined;
|
||||||
group.deliveryAt = undefined;
|
group.deliveryAt = undefined;
|
||||||
group.qrGenerated = undefined;
|
group.qrGenerated = undefined;
|
||||||
group.boltTrackingToken = undefined;
|
group.trackingProvider = undefined;
|
||||||
group.boltOrderState = undefined;
|
group.trackingCode = undefined;
|
||||||
group.boltCourierState = undefined;
|
group.trackingOrderState = undefined;
|
||||||
|
group.trackingCourierState = undefined;
|
||||||
for (const ml of memberLogins) {
|
for (const ml of memberLogins) {
|
||||||
group.members[ml] = { ...group.members[ml], paid: undefined };
|
group.members[ml] = { ...group.members[ml], paid: undefined };
|
||||||
}
|
}
|
||||||
@@ -204,24 +205,26 @@ export async function updateGroupTimes(login: string, groupId: string, orderedAt
|
|||||||
return saveExtraData(data, date);
|
return saveExtraData(data, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setGroupBoltTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise<ClientData> {
|
export async function setGroupTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise<ClientData> {
|
||||||
const data = await getExtraData(date);
|
const data = await getExtraData(date);
|
||||||
const group = findGroup(data, groupId);
|
const group = findGroup(data, groupId);
|
||||||
if (!group) throw new Error('Skupina nebyla nalezena');
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel');
|
if (group.creatorLogin !== login) throw new Error('Sledování objednávky může nastavit pouze zakladatel');
|
||||||
if (!shareUrl) {
|
if (!shareUrl) {
|
||||||
group.boltTrackingToken = undefined;
|
group.trackingProvider = undefined;
|
||||||
group.boltOrderState = undefined;
|
group.trackingCode = undefined;
|
||||||
group.boltCourierState = undefined;
|
group.trackingOrderState = undefined;
|
||||||
|
group.trackingCourierState = undefined;
|
||||||
} else {
|
} else {
|
||||||
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
|
if (group.state !== GroupState.ORDERED) throw new Error('Sledování objednávky lze nastavit pouze ve stavu "objednáno"');
|
||||||
const token = extractBoltToken(shareUrl);
|
const tracking = extractTracking(shareUrl);
|
||||||
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
|
if (!tracking) throw new Error('Neplatný odkaz na sledování objednávky');
|
||||||
if (token !== group.boltTrackingToken) {
|
if (tracking.code !== group.trackingCode || tracking.provider !== group.trackingProvider) {
|
||||||
group.boltTrackingToken = token;
|
group.trackingProvider = tracking.provider;
|
||||||
|
group.trackingCode = tracking.code;
|
||||||
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
|
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
|
||||||
group.boltOrderState = undefined;
|
group.trackingOrderState = undefined;
|
||||||
group.boltCourierState = undefined;
|
group.trackingCourierState = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return saveExtraData(data, date);
|
return saveExtraData(data, date);
|
||||||
|
|||||||
+5
-5
@@ -13,7 +13,7 @@ import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToke
|
|||||||
import { getPendingQrs } from "./pizza";
|
import { getPendingQrs } from "./pizza";
|
||||||
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
|
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
|
||||||
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
|
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
|
||||||
import { startBoltTrackingScheduler, stopBoltTrackingScheduler, releaseBoltTrackingLease } from "./boltTracking";
|
import { startOrderTrackingScheduler, stopOrderTrackingScheduler, releaseOrderTrackingLease } from "./orderTracking";
|
||||||
import { storageReady } from "./storage";
|
import { storageReady } from "./storage";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { shutdownRedisStorage } from "./storage/redis";
|
import { shutdownRedisStorage } from "./storage/redis";
|
||||||
@@ -89,9 +89,9 @@ async function shutdown(signal: string) {
|
|||||||
stopReminderScheduler();
|
stopReminderScheduler();
|
||||||
await releaseReminderLease();
|
await releaseReminderLease();
|
||||||
|
|
||||||
// Stop Bolt tracking scheduler and release leader lease
|
// Stop order tracking scheduler and release leader lease
|
||||||
stopBoltTrackingScheduler();
|
stopOrderTrackingScheduler();
|
||||||
await releaseBoltTrackingLease();
|
await releaseOrderTrackingLease();
|
||||||
|
|
||||||
// Shut down Redis pub/sub clients (Socket.io adapter)
|
// Shut down Redis pub/sub clients (Socket.io adapter)
|
||||||
await shutdownWebsocketClients();
|
await shutdownWebsocketClients();
|
||||||
@@ -310,6 +310,6 @@ storageReady.then(async () => {
|
|||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
startBoltTrackingScheduler();
|
startOrderTrackingScheduler();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -231,9 +231,12 @@ const OBJEDNANI_PATH = '/objednani';
|
|||||||
/**
|
/**
|
||||||
* Odešle push notifikaci o doručení skupinové objednávky členům skupiny,
|
* Odešle push notifikaci o doručení skupinové objednávky členům skupiny,
|
||||||
* kteří si to zapnuli v nastavení. Zakladatel skupiny notifikaci nedostává —
|
* kteří si to zapnuli v nastavení. Zakladatel skupiny notifikaci nedostává —
|
||||||
* ten je o doručení informován přímo aplikací Bolt.
|
* ten je o doručení informován přímo aplikací rozvozové služby.
|
||||||
|
*
|
||||||
|
* Klíč nastavení zůstal boltDeliveredPush z doby, kdy sledování uměl jen Bolt Food —
|
||||||
|
* přejmenování by uživatelům zahodilo už uložené zapnutí.
|
||||||
*/
|
*/
|
||||||
export async function notifyBoltDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise<void> {
|
export async function notifyOrderDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise<void> {
|
||||||
const recipients: string[] = [];
|
const recipients: string[] = [];
|
||||||
for (const login of memberLogins) {
|
for (const login of memberLogins) {
|
||||||
if (login === creatorLogin) continue;
|
if (login === creatorLogin) continue;
|
||||||
@@ -245,7 +248,7 @@ export async function notifyBoltDelivered(groupName: string, memberLogins: strin
|
|||||||
await sendPushToLogins(recipients, {
|
await sendPushToLogins(recipients, {
|
||||||
title: 'Luncher',
|
title: 'Luncher',
|
||||||
body: `Objednávka „${groupName}" byla doručena!`,
|
body: `Objednávka „${groupName}" byla doručena!`,
|
||||||
tag: `bolt-delivered-${groupName}`,
|
tag: `order-delivered-${groupName}`,
|
||||||
url: OBJEDNANI_PATH,
|
url: OBJEDNANI_PATH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -6,12 +6,12 @@ import { getWebsocket } from "../websocket";
|
|||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { ClientData, GroupState } from "../../../types/gen/types.gen";
|
import { ClientData, GroupState, TrackingProvider } from "../../../types/gen/types.gen";
|
||||||
import {
|
import {
|
||||||
startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep,
|
startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep,
|
||||||
stopBoltSimulationByGroup, getBoltSimulation,
|
stopBoltSimulationByGroup, getBoltSimulation,
|
||||||
} from "../boltSimulator";
|
} from "../boltSimulator";
|
||||||
import { checkBoltTracking } from "../boltTracking";
|
import { checkOrderTracking } from "../orderTracking";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -201,7 +201,7 @@ router.post("/testPush", async (req, res, next) => {
|
|||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- DEV simulace sledování Bolt Food ---
|
// --- DEV simulace sledování Bolt Food (Wolt simulaci zatím nemá) ---
|
||||||
|
|
||||||
/** Nastaví/zruší sledovací token skupiny a zajistí stav ORDERED. Vrátí aktualizovaná data. */
|
/** Nastaví/zruší sledovací token skupiny a zajistí stav ORDERED. Vrátí aktualizovaná data. */
|
||||||
async function applyBoltToken(groupId: string, token: string | undefined): Promise<ClientData> {
|
async function applyBoltToken(groupId: string, token: string | undefined): Promise<ClientData> {
|
||||||
@@ -210,12 +210,13 @@ async function applyBoltToken(groupId: string, token: string | undefined): Promi
|
|||||||
const d = current;
|
const d = current;
|
||||||
const group = d?.groups?.find(g => g.id === groupId);
|
const group = d?.groups?.find(g => g.id === groupId);
|
||||||
if (!group) throw new Error('Skupina nebyla nalezena');
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
group.boltTrackingToken = token;
|
group.trackingProvider = token ? TrackingProvider.BOLT : undefined;
|
||||||
|
group.trackingCode = token;
|
||||||
if (token) {
|
if (token) {
|
||||||
group.state = GroupState.ORDERED;
|
group.state = GroupState.ORDERED;
|
||||||
} else {
|
} else {
|
||||||
group.boltOrderState = undefined;
|
group.trackingOrderState = undefined;
|
||||||
group.boltCourierState = undefined;
|
group.trackingCourierState = undefined;
|
||||||
}
|
}
|
||||||
return d!;
|
return d!;
|
||||||
});
|
});
|
||||||
@@ -228,7 +229,7 @@ router.post("/bolt/simulate", async (req: Request<{}, any, any>, res, next) => {
|
|||||||
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
|
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
|
||||||
const token = startBoltSimulation(groupId);
|
const token = startBoltSimulation(groupId);
|
||||||
await applyBoltToken(groupId, token);
|
await applyBoltToken(groupId, token);
|
||||||
await checkBoltTracking(); // okamžitý první poll → stav "accepted" + websocket
|
await checkOrderTracking(); // okamžitý první poll → stav "accepted" + websocket
|
||||||
res.status(200).json({ success: true, token, simulation: getBoltSimulation(groupId) });
|
res.status(200).json({ success: true, token, simulation: getBoltSimulation(groupId) });
|
||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
@@ -239,7 +240,7 @@ router.post("/bolt/advance", async (req: Request<{}, any, any>, res, next) => {
|
|||||||
const groupId = req.body?.groupId;
|
const groupId = req.body?.groupId;
|
||||||
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
|
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
|
||||||
advanceBoltSimulation(groupId);
|
advanceBoltSimulation(groupId);
|
||||||
await checkBoltTracking();
|
await checkOrderTracking();
|
||||||
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
|
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
|
||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
@@ -250,7 +251,7 @@ router.post("/bolt/state", async (req: Request<{}, any, any>, res, next) => {
|
|||||||
const { groupId, order_state, courier_state, etaSeconds } = req.body ?? {};
|
const { groupId, order_state, courier_state, etaSeconds } = req.body ?? {};
|
||||||
if (!groupId || !order_state) return res.status(400).json({ error: 'Chybí groupId nebo order_state' });
|
if (!groupId || !order_state) return res.status(400).json({ error: 'Chybí groupId nebo order_state' });
|
||||||
setBoltSimulationStep(groupId, { order_state, courier_state, etaSeconds });
|
setBoltSimulationStep(groupId, { order_state, courier_state, etaSeconds });
|
||||||
await checkBoltTracking();
|
await checkOrderTracking();
|
||||||
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
|
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
|
||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
@@ -258,7 +259,7 @@ router.post("/bolt/state", async (req: Request<{}, any, any>, res, next) => {
|
|||||||
/** Spustí jeden tik scheduleru okamžitě (bez čekání na interval). */
|
/** Spustí jeden tik scheduleru okamžitě (bez čekání na interval). */
|
||||||
router.post("/bolt/poll", async (_req, res, next) => {
|
router.post("/bolt/poll", async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await checkBoltTracking();
|
await checkOrderTracking();
|
||||||
res.status(200).json({ success: true });
|
res.status(200).json({ success: true });
|
||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import express, { Request } from "express";
|
|||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups";
|
||||||
import { GroupState } from "../../../types/gen/types.gen";
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
import { checkBoltTracking } from "../boltTracking";
|
import { checkOrderTracking } from "../orderTracking";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -161,20 +161,20 @@ router.post("/updateTimes", async (req: Request, res, next) => {
|
|||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/setBoltTracking", async (req: Request, res, next) => {
|
router.post("/setTracking", async (req: Request, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, shareUrl } = req.body ?? {};
|
const { id, shareUrl } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
if (shareUrl !== undefined && typeof shareUrl !== 'string') {
|
if (shareUrl !== undefined && typeof shareUrl !== 'string') {
|
||||||
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky Bolt' });
|
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await setGroupBoltTracking(login, id, shareUrl);
|
const data = await setGroupTracking(login, id, shareUrl);
|
||||||
broadcastExtra(data);
|
broadcastExtra(data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
// Okamžitý poll, ať uživatel nečeká na další tik scheduleru
|
// Okamžitý poll, ať uživatel nečeká na další tik scheduleru
|
||||||
if (shareUrl) {
|
if (shareUrl) {
|
||||||
checkBoltTracking().catch(e => console.error('Bolt tracking: okamžitý poll selhal', e));
|
checkOrderTracking().catch(e => console.error('Sledování objednávek: okamžitý poll selhal', e));
|
||||||
}
|
}
|
||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,416 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { resetMemoryStorage } from '../storage/memory';
|
|
||||||
import getStorage from '../storage';
|
|
||||||
import { addStore } from '../stores';
|
|
||||||
import { createGroup, setGroupState, setGroupBoltTracking, addGroupMember } from '../groups';
|
|
||||||
import { saveNotificationSettings } from '../notifikace';
|
|
||||||
import { sendPushToLogins } from '../pushReminder';
|
|
||||||
import { extractBoltToken, computeDeliveryHHMM, checkBoltTracking } from '../boltTracking';
|
|
||||||
import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator';
|
|
||||||
import { ClientData, GroupState } from '../../../types/gen/types.gen';
|
|
||||||
import { formatDate } from '../utils';
|
|
||||||
|
|
||||||
jest.mock('axios');
|
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
||||||
|
|
||||||
const mockEmit = jest.fn();
|
|
||||||
jest.mock('../websocket', () => ({
|
|
||||||
getWebsocket: () => ({ emit: mockEmit }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../pushReminder', () => ({ sendPushToLogins: jest.fn() }));
|
|
||||||
const mockedSendPush = sendPushToLogins as jest.MockedFunction<typeof sendPushToLogins>;
|
|
||||||
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
const CREATOR = 'tomas';
|
|
||||||
const USER = 'petr';
|
|
||||||
const ADMIN_PW = 'testadmin';
|
|
||||||
const STORE = 'McDonald\'s';
|
|
||||||
const TOKEN = '0d521a8be3c4acebb26d8bd5716d91eac67050fb152a899a55fa19bd5ed65f15';
|
|
||||||
const SHARE_URL = `https://food.bolt.eu/sharedActiveOrder/${TOKEN}`;
|
|
||||||
|
|
||||||
function boltResponse(order: object | null) {
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
code: 0,
|
|
||||||
message: 'OK',
|
|
||||||
data: { orders: order ? [order] : [], baskets: [] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
resetMemoryStorage();
|
|
||||||
jest.clearAllMocks();
|
|
||||||
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
|
||||||
await addStore(STORE, ADMIN_PW);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
delete process.env.ADMIN_PASSWORD;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('extractBoltToken', () => {
|
|
||||||
test('přijme plnou share URL', () => {
|
|
||||||
expect(extractBoltToken(SHARE_URL)).toBe(TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toleruje lomítko, query a hash na konci', () => {
|
|
||||||
expect(extractBoltToken(`${SHARE_URL}/`)).toBe(TOKEN);
|
|
||||||
expect(extractBoltToken(`${SHARE_URL}?utm=x`)).toBe(TOKEN);
|
|
||||||
expect(extractBoltToken(`${SHARE_URL}#sekce`)).toBe(TOKEN);
|
|
||||||
expect(extractBoltToken(` ${SHARE_URL} `)).toBe(TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('přijme samotný token včetně velkých písmen', () => {
|
|
||||||
expect(extractBoltToken(TOKEN)).toBe(TOKEN);
|
|
||||||
expect(extractBoltToken(TOKEN.toUpperCase())).toBe(TOKEN.toUpperCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('odmítne neplatný vstup', () => {
|
|
||||||
expect(extractBoltToken('')).toBeNull();
|
|
||||||
expect(extractBoltToken('nesmysl')).toBeNull();
|
|
||||||
expect(extractBoltToken('https://food.bolt.eu/sharedActiveOrder/abc123')).toBeNull();
|
|
||||||
expect(extractBoltToken(`https://food.bolt.eu/sharedActiveOrder/${'z'.repeat(64)}`)).toBeNull();
|
|
||||||
expect(extractBoltToken(TOKEN.slice(0, 63))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('computeDeliveryHHMM', () => {
|
|
||||||
test('přičte sekundy k aktuálnímu času', () => {
|
|
||||||
expect(computeDeliveryHHMM(1800, new Date('2025-01-10T11:00:00'))).toBe('11:30');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('přechod přes půlnoc', () => {
|
|
||||||
expect(computeDeliveryHHMM(1200, new Date('2025-01-10T23:50:00'))).toBe('00:10');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setGroupBoltTracking', () => {
|
|
||||||
const TODAY = new Date('2025-01-10');
|
|
||||||
let groupId: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const d = await createGroup(CREATOR, STORE, TODAY);
|
|
||||||
groupId = d.groups![0].id;
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uloží token ze share URL', async () => {
|
|
||||||
const d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
|
||||||
expect(d.groups![0].boltTrackingToken).toBe(TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prázdná hodnota sledování zruší včetně stavu', async () => {
|
|
||||||
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
|
||||||
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
|
|
||||||
expect(d.groups![0].boltTrackingToken).toBeUndefined();
|
|
||||||
expect(d.groups![0].boltOrderState).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nový token vynuluje stav předchozí objednávky', async () => {
|
|
||||||
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
|
||||||
await storage.updateData<ClientData>(`2025-01-10_extra`, (current) => {
|
|
||||||
current!.groups![0].boltOrderState = 'preparing';
|
|
||||||
return current!;
|
|
||||||
});
|
|
||||||
// Stejný token stav nemění
|
|
||||||
let d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
|
||||||
expect(d.groups![0].boltOrderState).toBe('preparing');
|
|
||||||
// Jiný token stav vynuluje
|
|
||||||
const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`;
|
|
||||||
d = await setGroupBoltTracking(CREATOR, groupId, otherUrl, TODAY);
|
|
||||||
expect(d.groups![0].boltOrderState).toBeUndefined();
|
|
||||||
expect(d.groups![0].boltTrackingToken).toBe('b'.repeat(64));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('odmítne neplatný odkaz', async () => {
|
|
||||||
await expect(setGroupBoltTracking(CREATOR, groupId, 'nesmysl', TODAY)).rejects.toThrow('Neplatný odkaz');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nezakladatel nemůže sledování nastavit', async () => {
|
|
||||||
await expect(setGroupBoltTracking(USER, groupId, SHARE_URL, TODAY)).rejects.toThrow('zakladatel');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nelze nastavit mimo stav objednáno', async () => {
|
|
||||||
const d = await createGroup(CREATOR, STORE, TODAY);
|
|
||||||
const openGroupId = d.groups![1].id;
|
|
||||||
await expect(setGroupBoltTracking(CREATOR, openGroupId, SHARE_URL, TODAY)).rejects.toThrow('objednáno');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkBoltTracking', () => {
|
|
||||||
// Scheduler čte vždy dnešní data (getToday), proto se skupiny zakládají bez explicitního data
|
|
||||||
const extraKey = () => `${formatDate(new Date())}_extra`;
|
|
||||||
let groupId: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const d = await createGroup(CREATOR, STORE);
|
|
||||||
groupId = d.groups![0].id;
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
|
|
||||||
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getGroup() {
|
|
||||||
const data = await storage.getData<ClientData>(extraKey());
|
|
||||||
return data!.groups!.find(g => g.id === groupId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('aktualizuje deliveryAt podle expected_time_to_client_in_seconds', async () => {
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
|
|
||||||
const before = computeDeliveryHHMM(1800);
|
|
||||||
await checkBoltTracking();
|
|
||||||
const after = computeDeliveryHHMM(1800);
|
|
||||||
const group = await getGroup();
|
|
||||||
expect([before, after]).toContain(group.deliveryAt);
|
|
||||||
expect(group.boltOrderState).toBe('waiting_preparation');
|
|
||||||
expect(group.boltTrackingToken).toBe(TOKEN);
|
|
||||||
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
|
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('getOrderPolling'),
|
|
||||||
{ token: TOKEN },
|
|
||||||
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nezapisuje, pokud se čas nezměnil', async () => {
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockEmit).toHaveBeenCalledTimes(1);
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockEmit).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ukončí sledování po doručení (token smazán, deliveryAt zůstává)', async () => {
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered', expected_time_to_client_in_seconds: 0 }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
const group = await getGroup();
|
|
||||||
expect(group.boltTrackingToken).toBeUndefined();
|
|
||||||
expect(group.boltOrderState).toBe('delivered');
|
|
||||||
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ukončí sledování, když objednávka už neexistuje', async () => {
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse(null));
|
|
||||||
await checkBoltTracking();
|
|
||||||
const group = await getGroup();
|
|
||||||
expect(group.boltTrackingToken).toBeUndefined();
|
|
||||||
expect(group.boltOrderState).toBe('delivered');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ukládá stav kurýra (reálná odpověď s waiting_delivery)', async () => {
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({
|
|
||||||
order_id: 312222357,
|
|
||||||
order_state: 'waiting_delivery',
|
|
||||||
expected_time_to_client_in_seconds: 911,
|
|
||||||
provider: { provider_id: 82859, state: 'waiting_pickup' },
|
|
||||||
courier: { courier_id: 1958424, state: 'arrived_to_provider', lat: 49.7, lng: 13.3 },
|
|
||||||
}));
|
|
||||||
await checkBoltTracking();
|
|
||||||
let group = await getGroup();
|
|
||||||
expect(group.boltOrderState).toBe('waiting_delivery');
|
|
||||||
expect(group.boltCourierState).toBe('arrived_to_provider');
|
|
||||||
|
|
||||||
// Kurýr vyzvedl — změní se jen courier state
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({
|
|
||||||
order_state: 'waiting_delivery',
|
|
||||||
expected_time_to_client_in_seconds: 911,
|
|
||||||
courier: { state: 'picked_up' },
|
|
||||||
}));
|
|
||||||
await checkBoltTracking();
|
|
||||||
group = await getGroup();
|
|
||||||
expect(group.boltCourierState).toBe('picked_up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('aktualizuje boltOrderState při změně stavu beze změny času', async () => {
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
const group = await getGroup();
|
|
||||||
expect(group.boltOrderState).toBe('preparing');
|
|
||||||
expect(group.boltTrackingToken).toBe(TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => {
|
|
||||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
mockedAxios.post.mockResolvedValue({ data: { code: 42, message: 'FAIL' } });
|
|
||||||
await checkBoltTracking();
|
|
||||||
const group = await getGroup();
|
|
||||||
expect(group.boltTrackingToken).toBe(TOKEN);
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('po 10 po sobě jdoucích selháních sledování ukončí', async () => {
|
|
||||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
mockedAxios.post.mockRejectedValue(new Error('network down'));
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
await checkBoltTracking();
|
|
||||||
}
|
|
||||||
expect((await getGroup()).boltTrackingToken).toBe(TOKEN);
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect((await getGroup()).boltTrackingToken).toBeUndefined();
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ignoruje skupiny mimo stav objednáno', async () => {
|
|
||||||
await storage.updateData<ClientData>(extraKey(), (current) => {
|
|
||||||
const d = current!;
|
|
||||||
const g = d.groups!.find(x => x.id === groupId)!;
|
|
||||||
g.state = GroupState.LOCKED;
|
|
||||||
return d;
|
|
||||||
});
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DEV simulace (boltSimulator + checkBoltTracking)', () => {
|
|
||||||
const extraKey = () => `${formatDate(new Date())}_extra`;
|
|
||||||
let groupId: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const d = await createGroup(CREATOR, STORE);
|
|
||||||
groupId = d.groups![0].id;
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
|
|
||||||
// Simulátor vygeneruje validní 64-hex token a přiřadíme ho skupině jako reálný dev endpoint
|
|
||||||
const token = startBoltSimulation(groupId);
|
|
||||||
await setGroupBoltTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => stopBoltSimulationByGroup(groupId));
|
|
||||||
|
|
||||||
async function getGroup() {
|
|
||||||
const data = await storage.getData<ClientData>(extraKey());
|
|
||||||
return data!.groups!.find(g => g.id === groupId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('simulovaný token nevolá reálné Bolt API', async () => {
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('první poll nastaví stav waiting_acceptance a ETA', async () => {
|
|
||||||
await checkBoltTracking();
|
|
||||||
const g = await getGroup();
|
|
||||||
expect(g.boltOrderState).toBe('waiting_acceptance');
|
|
||||||
expect(g.deliveryAt).toBe(computeDeliveryHHMM(2100));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('advance posune sekvenci na accepted', async () => {
|
|
||||||
await checkBoltTracking();
|
|
||||||
advanceBoltSimulation(groupId);
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect((await getGroup()).boltOrderState).toBe('accepted');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ruční nastavení stavu (override) se projeví při pollu', async () => {
|
|
||||||
setBoltSimulationStep(groupId, { order_state: 'in_delivery', courier_state: 'heading_to_client', etaSeconds: 300 });
|
|
||||||
await checkBoltTracking();
|
|
||||||
const g = await getGroup();
|
|
||||||
expect(g.boltOrderState).toBe('in_delivery');
|
|
||||||
expect(g.boltCourierState).toBe('heading_to_client');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('terminální stav delivered ukončí sledování (smaže token)', async () => {
|
|
||||||
setBoltSimulationStep(groupId, { order_state: 'delivered' });
|
|
||||||
await checkBoltTracking();
|
|
||||||
const g = await getGroup();
|
|
||||||
expect(g.boltOrderState).toBe('delivered');
|
|
||||||
expect(g.boltTrackingToken).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('notifikace o doručení objednávky', () => {
|
|
||||||
const MEMBER = 'petr';
|
|
||||||
const OTHER = 'jana';
|
|
||||||
let groupId: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const d = await createGroup(CREATOR, STORE);
|
|
||||||
groupId = d.groups![0].id;
|
|
||||||
await addGroupMember(CREATOR, groupId, MEMBER);
|
|
||||||
await addGroupMember(CREATOR, groupId, OTHER);
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
|
|
||||||
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
|
|
||||||
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Posune objednávku do stavu "na cestě", aby další poll byl skutečný přechod. */
|
|
||||||
async function tickInDelivery() {
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'in_delivery', expected_time_to_client_in_seconds: 600 }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
}
|
|
||||||
|
|
||||||
test('doručení notifikuje členy, kteří to mají zapnuté', async () => {
|
|
||||||
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
|
||||||
await saveNotificationSettings(OTHER, { boltDeliveredPush: false });
|
|
||||||
await tickInDelivery();
|
|
||||||
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
|
|
||||||
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockedSendPush).toHaveBeenCalledWith(
|
|
||||||
[MEMBER],
|
|
||||||
expect.objectContaining({ body: expect.stringContaining(STORE), url: '/objednani' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zakladatel notifikaci nedostane, ani když ji má zapnutou', async () => {
|
|
||||||
await saveNotificationSettings(CREATOR, { boltDeliveredPush: true });
|
|
||||||
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
|
||||||
await tickInDelivery();
|
|
||||||
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
|
|
||||||
expect(mockedSendPush).toHaveBeenCalledWith([MEMBER], expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bez zapnutého nastavení se neposílá nic', async () => {
|
|
||||||
await tickInDelivery();
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockedSendPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('notifikace se neposílá opakovaně (delivered → finished → objednávka zmizí)', async () => {
|
|
||||||
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
|
||||||
await tickInDelivery();
|
|
||||||
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'finished' }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse(null));
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zmizelá objednávka bez předchozího doručení notifikuje', async () => {
|
|
||||||
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
|
||||||
await tickInDelivery();
|
|
||||||
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse(null));
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zrušená objednávka notifikaci neposílá', async () => {
|
|
||||||
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
|
||||||
await tickInDelivery();
|
|
||||||
|
|
||||||
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'cancelled' }));
|
|
||||||
await checkBoltTracking();
|
|
||||||
expect(mockedSendPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { resetMemoryStorage } from '../storage/memory';
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
import { getStores, addStore } from '../stores';
|
import { getStores, addStore } from '../stores';
|
||||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupBoltTracking, markGroupMemberPaid } from '../groups';
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupTracking, markGroupMemberPaid } from '../groups';
|
||||||
import { GroupState } from '../../../types/gen/types.gen';
|
import { GroupState } from '../../../types/gen/types.gen';
|
||||||
|
|
||||||
const CREATOR = 'tomas';
|
const CREATOR = 'tomas';
|
||||||
@@ -193,13 +193,14 @@ describe('setGroupState', () => {
|
|||||||
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
|
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ordered → locked smaže boltTrackingToken', async () => {
|
test('ordered → locked smaže sledování objednávky', async () => {
|
||||||
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
const token = 'a'.repeat(64);
|
const token = 'a'.repeat(64);
|
||||||
await setGroupBoltTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`, TODAY);
|
await setGroupTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`, TODAY);
|
||||||
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
expect(d.groups![0].boltTrackingToken).toBeUndefined();
|
expect(d.groups![0].trackingCode).toBeUndefined();
|
||||||
|
expect(d.groups![0].trackingProvider).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import getStorage from '../storage';
|
||||||
|
import { addStore } from '../stores';
|
||||||
|
import { createGroup, setGroupState, setGroupTracking, addGroupMember } from '../groups';
|
||||||
|
import { saveNotificationSettings } from '../notifikace';
|
||||||
|
import { sendPushToLogins } from '../pushReminder';
|
||||||
|
import { checkOrderTracking } from '../orderTracking';
|
||||||
|
import { extractTracking, computeDeliveryHHMM, formatEtaHHMM } from '../trackingProviders';
|
||||||
|
import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator';
|
||||||
|
import { ClientData, GroupState, TrackingProvider } from '../../../types/gen/types.gen';
|
||||||
|
import { formatDate } from '../utils';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
const mockEmit = jest.fn();
|
||||||
|
jest.mock('../websocket', () => ({
|
||||||
|
getWebsocket: () => ({ emit: mockEmit }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../pushReminder', () => ({ sendPushToLogins: jest.fn() }));
|
||||||
|
const mockedSendPush = sendPushToLogins as jest.MockedFunction<typeof sendPushToLogins>;
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
const CREATOR = 'tomas';
|
||||||
|
const USER = 'petr';
|
||||||
|
const ADMIN_PW = 'testadmin';
|
||||||
|
const STORE = 'McDonald\'s';
|
||||||
|
const TOKEN = '0d521a8be3c4acebb26d8bd5716d91eac67050fb152a899a55fa19bd5ed65f15';
|
||||||
|
const SHARE_URL = `https://food.bolt.eu/sharedActiveOrder/${TOKEN}`;
|
||||||
|
const WOLT_CODE = 'ZfKWLEtm1JyB-nI0zk6S7g';
|
||||||
|
const WOLT_URL = `https://track.wolt.com/${WOLT_CODE}`;
|
||||||
|
|
||||||
|
function boltResponse(order: object | null) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
code: 0,
|
||||||
|
message: 'OK',
|
||||||
|
data: { orders: order ? [order] : [], baskets: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||||
|
await addStore(STORE, ADMIN_PW);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractTracking', () => {
|
||||||
|
const bolt = (code: string) => ({ provider: TrackingProvider.BOLT, code });
|
||||||
|
const wolt = (code: string) => ({ provider: TrackingProvider.WOLT, code });
|
||||||
|
|
||||||
|
test('přijme plnou share URL Bolt Food', () => {
|
||||||
|
expect(extractTracking(SHARE_URL)).toEqual(bolt(TOKEN));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toleruje lomítko, query a hash na konci', () => {
|
||||||
|
expect(extractTracking(`${SHARE_URL}/`)).toEqual(bolt(TOKEN));
|
||||||
|
expect(extractTracking(`${SHARE_URL}?utm=x`)).toEqual(bolt(TOKEN));
|
||||||
|
expect(extractTracking(`${SHARE_URL}#sekce`)).toEqual(bolt(TOKEN));
|
||||||
|
expect(extractTracking(` ${SHARE_URL} `)).toEqual(bolt(TOKEN));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('přijme samotný token Bolt včetně velkých písmen', () => {
|
||||||
|
expect(extractTracking(TOKEN)).toEqual(bolt(TOKEN));
|
||||||
|
expect(extractTracking(TOKEN.toUpperCase())).toEqual(bolt(TOKEN.toUpperCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('přijme odkaz na sledování Wolt i s jazykem v cestě', () => {
|
||||||
|
expect(extractTracking(WOLT_URL)).toEqual(wolt(WOLT_CODE));
|
||||||
|
expect(extractTracking(`https://track.wolt.com/en/${WOLT_CODE}`)).toEqual(wolt(WOLT_CODE));
|
||||||
|
expect(extractTracking(` ${WOLT_URL}?utm=x `)).toEqual(wolt(WOLT_CODE));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('přijme samotný tracking code Wolt', () => {
|
||||||
|
expect(extractTracking(WOLT_CODE)).toEqual(wolt(WOLT_CODE));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne neplatný vstup', () => {
|
||||||
|
expect(extractTracking('')).toBeNull();
|
||||||
|
expect(extractTracking('nesmysl')).toBeNull();
|
||||||
|
expect(extractTracking('https://food.bolt.eu/sharedActiveOrder/abc123')).toBeNull();
|
||||||
|
expect(extractTracking(`https://food.bolt.eu/sharedActiveOrder/${'z'.repeat(64)}`)).toBeNull();
|
||||||
|
expect(extractTracking(TOKEN.slice(0, 63))).toBeNull();
|
||||||
|
expect(extractTracking('https://track.wolt.com/cs')).toBeNull();
|
||||||
|
// Odkaz cizí služby se stejně tvarovaným kódem nesmí projít jako Wolt
|
||||||
|
expect(extractTracking(`https://track.example.com/${WOLT_CODE}`)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatEtaHHMM', () => {
|
||||||
|
test('převede ISO čas do zóny objednávky', () => {
|
||||||
|
expect(formatEtaHHMM('2026-08-24T09:35:33.066000+00:00', 'Europe/Prague')).toBe('11:35');
|
||||||
|
expect(formatEtaHHMM('2026-08-24T09:35:33.066000+00:00', 'UTC')).toBe('09:35');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('půlnoc se zobrazí jako 00:00', () => {
|
||||||
|
expect(formatEtaHHMM('2026-08-24T22:00:00Z', 'Europe/Prague')).toBe('00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neznámá zóna spadne na čas serveru, nesmyslný čas vrátí undefined', () => {
|
||||||
|
expect(formatEtaHHMM('2026-08-24T09:35:00Z', 'Neznama/Zona')).toMatch(/^\d{2}:\d{2}$/);
|
||||||
|
expect(formatEtaHHMM('nesmysl')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeDeliveryHHMM', () => {
|
||||||
|
test('přičte sekundy k aktuálnímu času', () => {
|
||||||
|
expect(computeDeliveryHHMM(1800, new Date('2025-01-10T11:00:00'))).toBe('11:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('přechod přes půlnoc', () => {
|
||||||
|
expect(computeDeliveryHHMM(1200, new Date('2025-01-10T23:50:00'))).toBe('00:10');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setGroupTracking (Bolt)', () => {
|
||||||
|
const TODAY = new Date('2025-01-10');
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uloží token ze share URL', async () => {
|
||||||
|
const d = await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
||||||
|
expect(d.groups![0].trackingCode).toBe(TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prázdná hodnota sledování zruší včetně stavu', async () => {
|
||||||
|
await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
||||||
|
const d = await setGroupTracking(CREATOR, groupId, '', TODAY);
|
||||||
|
expect(d.groups![0].trackingCode).toBeUndefined();
|
||||||
|
expect(d.groups![0].trackingOrderState).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nový token vynuluje stav předchozí objednávky', async () => {
|
||||||
|
await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
||||||
|
await storage.updateData<ClientData>(`2025-01-10_extra`, (current) => {
|
||||||
|
current!.groups![0].trackingOrderState = 'preparing';
|
||||||
|
return current!;
|
||||||
|
});
|
||||||
|
// Stejný token stav nemění
|
||||||
|
let d = await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
||||||
|
expect(d.groups![0].trackingOrderState).toBe('preparing');
|
||||||
|
// Jiný token stav vynuluje
|
||||||
|
const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`;
|
||||||
|
d = await setGroupTracking(CREATOR, groupId, otherUrl, TODAY);
|
||||||
|
expect(d.groups![0].trackingOrderState).toBeUndefined();
|
||||||
|
expect(d.groups![0].trackingCode).toBe('b'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne neplatný odkaz', async () => {
|
||||||
|
await expect(setGroupTracking(CREATOR, groupId, 'nesmysl', TODAY)).rejects.toThrow('Neplatný odkaz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nezakladatel nemůže sledování nastavit', async () => {
|
||||||
|
await expect(setGroupTracking(USER, groupId, SHARE_URL, TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nelze nastavit mimo stav objednáno', async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
const openGroupId = d.groups![1].id;
|
||||||
|
await expect(setGroupTracking(CREATOR, openGroupId, SHARE_URL, TODAY)).rejects.toThrow('objednáno');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkOrderTracking (Bolt)', () => {
|
||||||
|
// Scheduler čte vždy dnešní data (getToday), proto se skupiny zakládají bez explicitního data
|
||||||
|
const extraKey = () => `${formatDate(new Date())}_extra`;
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
|
||||||
|
await setGroupTracking(CREATOR, groupId, SHARE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getGroup() {
|
||||||
|
const data = await storage.getData<ClientData>(extraKey());
|
||||||
|
return data!.groups!.find(g => g.id === groupId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('aktualizuje deliveryAt podle expected_time_to_client_in_seconds', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
|
||||||
|
const before = computeDeliveryHHMM(1800);
|
||||||
|
await checkOrderTracking();
|
||||||
|
const after = computeDeliveryHHMM(1800);
|
||||||
|
const group = await getGroup();
|
||||||
|
expect([before, after]).toContain(group.deliveryAt);
|
||||||
|
expect(group.trackingOrderState).toBe('waiting_preparation');
|
||||||
|
expect(group.trackingCode).toBe(TOKEN);
|
||||||
|
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('getOrderPolling'),
|
||||||
|
{ token: TOKEN },
|
||||||
|
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nezapisuje, pokud se čas nezměnil', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockEmit).toHaveBeenCalledTimes(1);
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockEmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ukončí sledování po doručení (token smazán, deliveryAt zůstává)', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered', expected_time_to_client_in_seconds: 0 }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingCode).toBeUndefined();
|
||||||
|
expect(group.trackingOrderState).toBe('delivered');
|
||||||
|
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ukončí sledování, když objednávka už neexistuje', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse(null));
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingCode).toBeUndefined();
|
||||||
|
expect(group.trackingOrderState).toBe('delivered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ukládá stav kurýra (reálná odpověď s waiting_delivery)', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({
|
||||||
|
order_id: 312222357,
|
||||||
|
order_state: 'waiting_delivery',
|
||||||
|
expected_time_to_client_in_seconds: 911,
|
||||||
|
provider: { provider_id: 82859, state: 'waiting_pickup' },
|
||||||
|
courier: { courier_id: 1958424, state: 'arrived_to_provider', lat: 49.7, lng: 13.3 },
|
||||||
|
}));
|
||||||
|
await checkOrderTracking();
|
||||||
|
let group = await getGroup();
|
||||||
|
expect(group.trackingOrderState).toBe('waiting_delivery');
|
||||||
|
expect(group.trackingCourierState).toBe('arrived_to_provider');
|
||||||
|
|
||||||
|
// Kurýr vyzvedl — změní se jen courier state
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({
|
||||||
|
order_state: 'waiting_delivery',
|
||||||
|
expected_time_to_client_in_seconds: 911,
|
||||||
|
courier: { state: 'picked_up' },
|
||||||
|
}));
|
||||||
|
await checkOrderTracking();
|
||||||
|
group = await getGroup();
|
||||||
|
expect(group.trackingCourierState).toBe('picked_up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aktualizuje trackingOrderState při změně stavu beze změny času', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingOrderState).toBe('preparing');
|
||||||
|
expect(group.trackingCode).toBe(TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => {
|
||||||
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockedAxios.post.mockResolvedValue({ data: { code: 42, message: 'FAIL' } });
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingCode).toBe(TOKEN);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('po 10 po sobě jdoucích selháních sledování ukončí', async () => {
|
||||||
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockedAxios.post.mockRejectedValue(new Error('network down'));
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
await checkOrderTracking();
|
||||||
|
}
|
||||||
|
expect((await getGroup()).trackingCode).toBe(TOKEN);
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect((await getGroup()).trackingCode).toBeUndefined();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignoruje skupiny mimo stav objednáno', async () => {
|
||||||
|
await storage.updateData<ClientData>(extraKey(), (current) => {
|
||||||
|
const d = current!;
|
||||||
|
const g = d.groups!.find(x => x.id === groupId)!;
|
||||||
|
g.state = GroupState.LOCKED;
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkOrderTracking (Wolt)', () => {
|
||||||
|
const extraKey = () => `${formatDate(new Date())}_extra`;
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
/** Odpověď Wolt tracking API — tvarem odpovídá reálnému /order-tracking-api/v1/details. */
|
||||||
|
function woltResponse(details: object) {
|
||||||
|
return { data: { refresh_in_seconds: 30, timezone: 'Europe/Prague', ...details } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404 z Wolt API (neznámý nebo expirovaný tracking code). */
|
||||||
|
function woltNotFound() {
|
||||||
|
(mockedAxios.isAxiosError as unknown as jest.Mock).mockImplementation((e: any) => !!e?.isAxiosError);
|
||||||
|
return Object.assign(new Error('Request failed with status code 404'), {
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 404, data: { detail: 'Not Found' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await addGroupMember(CREATOR, groupId, USER);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
|
||||||
|
await setGroupTracking(CREATOR, groupId, WOLT_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getGroup() {
|
||||||
|
const data = await storage.getData<ClientData>(extraKey());
|
||||||
|
return data!.groups!.find(g => g.id === groupId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('odkaz uloží službu i tracking code', async () => {
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingProvider).toBe(TrackingProvider.WOLT);
|
||||||
|
expect(group.trackingCode).toBe(WOLT_CODE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nastaví deliveryAt z delivery_eta v zóně objednávky', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse({
|
||||||
|
status: 'production',
|
||||||
|
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
|
||||||
|
pickup_eta: '2026-08-24T09:12:45.481000+00:00',
|
||||||
|
couriers: [],
|
||||||
|
}));
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.deliveryAt).toBe('11:35');
|
||||||
|
expect(group.trackingOrderState).toBe('production');
|
||||||
|
expect(group.trackingCourierState).toBeUndefined();
|
||||||
|
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
|
||||||
|
// Wolt se dotazuje GETem, Bolt API se nesmí volat vůbec
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(`/track/${WOLT_CODE}`),
|
||||||
|
expect.objectContaining({ headers: expect.objectContaining({ Origin: 'https://track.wolt.com' }) }),
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kurýr doručující cizí objednávku je jen assigned', async () => {
|
||||||
|
// Reálná odpověď: jídlo hotové, kurýr u podniku, ale veze ještě cizí objednávku
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse({
|
||||||
|
status: 'ready',
|
||||||
|
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
|
||||||
|
couriers: [{
|
||||||
|
id: '31ce2f6e139bec6c',
|
||||||
|
coordinates: { lat: 49.72954, lon: 13.34632 },
|
||||||
|
vehicle_type: 'car',
|
||||||
|
is_delivering: true,
|
||||||
|
is_delivering_other_order: true,
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingOrderState).toBe('ready');
|
||||||
|
expect(group.trackingCourierState).toBe('assigned');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kurýr vezoucí naši objednávku se uloží jako delivering', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse({
|
||||||
|
status: 'ready',
|
||||||
|
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
|
||||||
|
couriers: [{ is_delivering: true, is_delivering_other_order: false }],
|
||||||
|
}));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect((await getGroup()).trackingCourierState).toBe('delivering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kurýr bez příznaků doručování je assigned', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse({
|
||||||
|
status: 'ready',
|
||||||
|
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
|
||||||
|
couriers: [{ coordinates: { lat: 49.72, lon: 13.34 } }],
|
||||||
|
}));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect((await getGroup()).trackingCourierState).toBe('assigned');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doručení ukončí sledování a upozorní členy', async () => {
|
||||||
|
await saveNotificationSettings(USER, { boltDeliveredPush: true });
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse({
|
||||||
|
status: 'production',
|
||||||
|
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
|
||||||
|
}));
|
||||||
|
await checkOrderTracking();
|
||||||
|
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse({ status: 'delivered' }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingCode).toBeUndefined();
|
||||||
|
expect(group.trackingOrderState).toBe('delivered');
|
||||||
|
expect(group.deliveryAt).toBe('11:35');
|
||||||
|
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neznámý tracking code (404) ukončí sledování', async () => {
|
||||||
|
mockedAxios.get.mockRejectedValue(woltNotFound());
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odpověď bez stavu se počítá jako selhání, sledování pokračuje', async () => {
|
||||||
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockedAxios.get.mockResolvedValue(woltResponse({ delivery_eta: null }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.trackingCode).toBe(WOLT_CODE);
|
||||||
|
expect(group.trackingOrderState).toBeUndefined();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEV simulace (boltSimulator + checkOrderTracking)', () => {
|
||||||
|
const extraKey = () => `${formatDate(new Date())}_extra`;
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
|
||||||
|
// Simulátor vygeneruje validní 64-hex token a přiřadíme ho skupině jako reálný dev endpoint
|
||||||
|
const token = startBoltSimulation(groupId);
|
||||||
|
await setGroupTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => stopBoltSimulationByGroup(groupId));
|
||||||
|
|
||||||
|
async function getGroup() {
|
||||||
|
const data = await storage.getData<ClientData>(extraKey());
|
||||||
|
return data!.groups!.find(g => g.id === groupId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('simulovaný token nevolá reálné Bolt API', async () => {
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('první poll nastaví stav waiting_acceptance a ETA', async () => {
|
||||||
|
await checkOrderTracking();
|
||||||
|
const g = await getGroup();
|
||||||
|
expect(g.trackingOrderState).toBe('waiting_acceptance');
|
||||||
|
expect(g.deliveryAt).toBe(computeDeliveryHHMM(2100));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('advance posune sekvenci na accepted', async () => {
|
||||||
|
await checkOrderTracking();
|
||||||
|
advanceBoltSimulation(groupId);
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect((await getGroup()).trackingOrderState).toBe('accepted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ruční nastavení stavu (override) se projeví při pollu', async () => {
|
||||||
|
setBoltSimulationStep(groupId, { order_state: 'in_delivery', courier_state: 'heading_to_client', etaSeconds: 300 });
|
||||||
|
await checkOrderTracking();
|
||||||
|
const g = await getGroup();
|
||||||
|
expect(g.trackingOrderState).toBe('in_delivery');
|
||||||
|
expect(g.trackingCourierState).toBe('heading_to_client');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminální stav delivered ukončí sledování (smaže token)', async () => {
|
||||||
|
setBoltSimulationStep(groupId, { order_state: 'delivered' });
|
||||||
|
await checkOrderTracking();
|
||||||
|
const g = await getGroup();
|
||||||
|
expect(g.trackingOrderState).toBe('delivered');
|
||||||
|
expect(g.trackingCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notifikace o doručení objednávky', () => {
|
||||||
|
const MEMBER = 'petr';
|
||||||
|
const OTHER = 'jana';
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await addGroupMember(CREATOR, groupId, MEMBER);
|
||||||
|
await addGroupMember(CREATOR, groupId, OTHER);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
|
||||||
|
await setGroupTracking(CREATOR, groupId, SHARE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Posune objednávku do stavu "na cestě", aby další poll byl skutečný přechod. */
|
||||||
|
async function tickInDelivery() {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'in_delivery', expected_time_to_client_in_seconds: 600 }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('doručení notifikuje členy, kteří to mají zapnuté', async () => {
|
||||||
|
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
||||||
|
await saveNotificationSettings(OTHER, { boltDeliveredPush: false });
|
||||||
|
await tickInDelivery();
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
|
||||||
|
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedSendPush).toHaveBeenCalledWith(
|
||||||
|
[MEMBER],
|
||||||
|
expect.objectContaining({ body: expect.stringContaining(STORE), url: '/objednani' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zakladatel notifikaci nedostane, ani když ji má zapnutou', async () => {
|
||||||
|
await saveNotificationSettings(CREATOR, { boltDeliveredPush: true });
|
||||||
|
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
||||||
|
await tickInDelivery();
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
|
||||||
|
expect(mockedSendPush).toHaveBeenCalledWith([MEMBER], expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bez zapnutého nastavení se neposílá nic', async () => {
|
||||||
|
await tickInDelivery();
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockedSendPush).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifikace se neposílá opakovaně (delivered → finished → objednávka zmizí)', async () => {
|
||||||
|
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
||||||
|
await tickInDelivery();
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'finished' }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse(null));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zmizelá objednávka bez předchozího doručení notifikuje', async () => {
|
||||||
|
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
||||||
|
await tickInDelivery();
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse(null));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockedSendPush).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zrušená objednávka notifikaci neposílá', async () => {
|
||||||
|
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
|
||||||
|
await tickInDelivery();
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'cancelled' }));
|
||||||
|
await checkOrderTracking();
|
||||||
|
expect(mockedSendPush).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+2
-2
@@ -142,8 +142,8 @@ paths:
|
|||||||
$ref: "./paths/groups/setState.yml"
|
$ref: "./paths/groups/setState.yml"
|
||||||
/groups/updateTimes:
|
/groups/updateTimes:
|
||||||
$ref: "./paths/groups/updateTimes.yml"
|
$ref: "./paths/groups/updateTimes.yml"
|
||||||
/groups/setBoltTracking:
|
/groups/setTracking:
|
||||||
$ref: "./paths/groups/setBoltTracking.yml"
|
$ref: "./paths/groups/setTracking.yml"
|
||||||
/groups/updateFees:
|
/groups/updateFees:
|
||||||
$ref: "./paths/groups/updateFees.yml"
|
$ref: "./paths/groups/updateFees.yml"
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
post:
|
|
||||||
operationId: setBoltTracking
|
|
||||||
summary: Nastaví nebo zruší sledování objednávky Bolt Food (pouze zakladatel).
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- id
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
description: ID skupiny
|
|
||||||
type: string
|
|
||||||
shareUrl:
|
|
||||||
description: Sdílecí URL objednávky Bolt Food (https://food.bolt.eu/sharedActiveOrder/...). Prázdná hodnota sledování zruší.
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
post:
|
||||||
|
operationId: setTracking
|
||||||
|
summary: Nastaví nebo zruší sledování objednávky u rozvozové služby (pouze zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
shareUrl:
|
||||||
|
description: |
|
||||||
|
Odkaz na sledování objednávky — Bolt Food (https://food.bolt.eu/sharedActiveOrder/...)
|
||||||
|
nebo Wolt (https://track.wolt.com/...). Služba se pozná z odkazu.
|
||||||
|
Prázdná hodnota sledování zruší.
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -694,7 +694,7 @@ NotificationSettings:
|
|||||||
description: Čas, ve který má být uživatel upozorněn na nezvolený oběd (HH:MM). Prázdné = vypnuto.
|
description: Čas, ve který má být uživatel upozorněn na nezvolený oběd (HH:MM). Prázdné = vypnuto.
|
||||||
type: string
|
type: string
|
||||||
boltDeliveredPush:
|
boltDeliveredPush:
|
||||||
description: Zapnuté push upozornění na doručení skupinové objednávky sledované přes Bolt Food.
|
description: Zapnuté push upozornění na doručení sledované skupinové objednávky (Bolt Food i Wolt). Název klíče zůstal kvůli zpětné kompatibilitě uloženého nastavení uživatelů.
|
||||||
type: boolean
|
type: boolean
|
||||||
GotifyServer:
|
GotifyServer:
|
||||||
type: object
|
type: object
|
||||||
@@ -871,6 +871,16 @@ Store:
|
|||||||
description: Volitelná URL na nabídku podniku (např. Bolt Food/Wolt/Foodora)
|
description: Volitelná URL na nabídku podniku (např. Bolt Food/Wolt/Foodora)
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
TrackingProvider:
|
||||||
|
description: Rozvozová služba, přes kterou lze sledovat stav objednávky
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- bolt
|
||||||
|
- wolt
|
||||||
|
x-enum-varnames:
|
||||||
|
- BOLT
|
||||||
|
- WOLT
|
||||||
|
|
||||||
GroupState:
|
GroupState:
|
||||||
description: Stav skupiny objednávky
|
description: Stav skupiny objednávky
|
||||||
type: string
|
type: string
|
||||||
@@ -956,14 +966,16 @@ OrderGroup:
|
|||||||
qrGenerated:
|
qrGenerated:
|
||||||
description: Příznak, zda byly pro skupinu vygenerovány QR kódy (blokuje opakované generování)
|
description: Příznak, zda byly pro skupinu vygenerovány QR kódy (blokuje opakované generování)
|
||||||
type: boolean
|
type: boolean
|
||||||
boltTrackingToken:
|
trackingProvider:
|
||||||
description: Token sdíleného sledování objednávky Bolt Food (poslední segment share URL). Pokud je vyplněn, server automaticky aktualizuje deliveryAt.
|
$ref: "#/TrackingProvider"
|
||||||
|
trackingCode:
|
||||||
|
description: Kód sledování objednávky (poslední segment share URL — u Bolt Food 64-hex token, u Wolt tracking code). Pokud je vyplněn, server automaticky aktualizuje deliveryAt.
|
||||||
type: string
|
type: string
|
||||||
boltOrderState:
|
trackingOrderState:
|
||||||
description: Poslední známý stav objednávky z Bolt API (raw order_state, např. waiting_preparation, waiting_delivery, delivered). Zůstává vyplněn i po ukončení sledování.
|
description: Poslední známý stav objednávky ze sledovací služby (raw hodnota — Bolt order_state, např. waiting_preparation; Wolt status, např. production). Zůstává vyplněn i po ukončení sledování.
|
||||||
type: string
|
type: string
|
||||||
boltCourierState:
|
trackingCourierState:
|
||||||
description: Poslední známý stav kurýra z Bolt API (raw courier.state, např. arrived_to_provider, picked_up). Zpřesňuje krok "Na cestě" ve stepperu.
|
description: Poslední známý stav kurýra ze sledovací služby (Bolt raw courier.state, např. arrived_to_provider; Wolt odvozené "assigned"/"delivering"). Zpřesňuje krok "Na cestě" ve stepperu.
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
# --- NEVYŘÍZENÉ QR KÓDY ---
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
|
|||||||
Reference in New Issue
Block a user