updates on the changes
This commit is contained in:
@@ -948,83 +948,54 @@ export const fetchCountAPI = async () => {
|
||||
|
||||
// ==============================|| cancelDeliveryAPI (deliveries) ||============================== //
|
||||
|
||||
// jupiter2doormile.md §4 confirms cancel is its own booking-level action —
|
||||
// POST /admin/bookings/:id/cancel — not a consignment status PUT. cancelFeed
|
||||
// isn't part of the documented cancel body; sent as a best-effort extra field
|
||||
// rather than silently dropped, since it's unconfirmed whether the server
|
||||
// persists it.
|
||||
export const cancelDeliveryAPI = async (selectedRow, cancelFeed) =>
|
||||
cancelBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { reason: cancelFeed });
|
||||
// Which endpoint cancels a row depends on which phase of its life it is in, and
|
||||
// the two are NOT interchangeable fallbacks for each other.
|
||||
//
|
||||
// A booking's own record freezes the moment the parcel is picked up and the
|
||||
// lifecycle continues on the consignment (README, "Status Lifecycle"), so
|
||||
// `POST /admin/bookings/:id/cancel` is refused once a consignment exists —
|
||||
// which is every row on the Deliveries page past Picked.
|
||||
//
|
||||
// This used to TRY the consignment and then quietly fall back to the booking
|
||||
// route on any failure. That fallback did two bad things:
|
||||
//
|
||||
// • It hid the real error. A 500 from PUT /admin/consignments/:id/status was
|
||||
// swallowed, the booking route was called anyway, and the operator was
|
||||
// shown that route's 400 instead — an error about an endpoint the cancel
|
||||
// had no business calling, which made the actual fault invisible without
|
||||
// opening the network panel.
|
||||
// • If the fallback had ever SUCCEEDED it would have left a cancelled booking
|
||||
// against a consignment still out for delivery — the rider keeps the stop.
|
||||
//
|
||||
// So each phase gets exactly one target, and its failure is surfaced as-is.
|
||||
export const cancelDeliveryAPI = async (selectedRow, cancelFeed) => {
|
||||
const consignmentId = selectedRow?.consignmentid;
|
||||
const bookingId = selectedRow?.bookingid ?? selectedRow?.orderheaderid ?? selectedRow?.deliveryid;
|
||||
|
||||
/* Already picked up: the cancel belongs on the consignment. `cancelFeed` has
|
||||
no field on this endpoint (see updateDeliveryAPI's note) and is dropped
|
||||
rather than sent under a guessed key. */
|
||||
if (consignmentId) {
|
||||
return updateConsignmentStatus(consignmentId, { status: 'Cancelled' });
|
||||
}
|
||||
|
||||
/* Still a booking: cancel is its own booking-level action, not a status PUT. */
|
||||
if (bookingId) {
|
||||
return cancelBooking(bookingId, { reason: cancelFeed });
|
||||
}
|
||||
|
||||
throw new Error('This row carries neither a booking nor a consignment id, so there is nothing to cancel.');
|
||||
};
|
||||
|
||||
// ==============================|| getorderdetails (deliveries) ||============================== //
|
||||
export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid);
|
||||
|
||||
// ==============================|| changeRiderAPI (deliveries) ||============================== //
|
||||
// doormile-flow.md (confirmed current, authoritative) settles this: the body
|
||||
// is { "mileruserid": <miler's userid> } — the previous guess here (`milerid`
|
||||
// key, `milerprofileid` value) was wrong on both counts. Admin miler
|
||||
// endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the
|
||||
// one exception that wants userid instead — "different identity spaces on
|
||||
// adjacent endpoints," per that doc's own wording. selectedRider comes
|
||||
// straight from getMilers(), which carries both fields on the same object.
|
||||
export const changeRiderAPI = async (selectedRider, selectedRow) =>
|
||||
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid });
|
||||
|
||||
// ==============================|| updateDeliveryAPI (deliveries) ||============================== //
|
||||
// No amount/notes field exists on PUT /admin/consignments/:id/status — closest
|
||||
// available write is a status update. Free-text amount/notes edits have no home
|
||||
// in the new API yet.
|
||||
// Target endpoint is consignment-scoped (/admin/consignments/:id/status), so
|
||||
// this needs the real consignmentid, not a booking id. deliveryid on a
|
||||
// deliveries-page row is always b.bookingid (see fetchDeliveries) — always
|
||||
// truthy, so `deliveryid ?? consignmentid` never actually fell through to
|
||||
// consignmentid even when it was present, silently calling the endpoint
|
||||
// with the wrong kind of id on every Update Status submit.
|
||||
//
|
||||
// ---- The body ---------------------------------------------------------------
|
||||
//
|
||||
// This used to forward the dialog's WHOLE state object as the request body —
|
||||
// the old jupiter shape (`orderstatus`, `deliveryid`, `orderheaderid`,
|
||||
// `deliveryamt`, `cumulativekms`, `userid`). The endpoint wants one field
|
||||
// called `status`, so every submit came back:
|
||||
//
|
||||
// PUT /admin/consignments/40/status → 400 {"status is required"}
|
||||
//
|
||||
// The status was in the payload the whole time, under the wrong name.
|
||||
//
|
||||
// The VALUE has to be translated too. The dialog's options are this page's own
|
||||
// lifecycle keys (`delivered`, `cancelled`, …); the API speaks the booking enum
|
||||
// (`Delivered`, `Cancelled`, …). Sending `delivered` where `Delivered` is
|
||||
// expected is the same class of bug one layer down.
|
||||
//
|
||||
// Reverse of BOOKING_STATUS_TO_DELIVERY_STATUS, and deliberately NOT derived
|
||||
// from it by inversion: that map is many-to-one (`pending_pickup` and
|
||||
// `miler_assigned` both mean `pending`), so an automatic inversion would pick
|
||||
// whichever happened to be last and silently write the wrong one.
|
||||
// ⚠ These are CONSIGNMENT statuses, not booking statuses. The endpoint is
|
||||
// `PUT /admin/consignments/:id/status`, and the two enums are not the same
|
||||
// vocabulary even though they overlap on Delivered and Cancelled.
|
||||
//
|
||||
// This map used to include the booking's pre-pickup states, and they were all
|
||||
// nonsense to send here:
|
||||
//
|
||||
// picked → 'Converted_To_Consignment' — that names the MOMENT a booking
|
||||
// becomes a consignment. A consignment cannot be set to it; it
|
||||
// already is one. Choosing "Picked" in the dialog therefore did
|
||||
// nothing and the row kept showing whatever the consignment
|
||||
// really was, which is what "I set it to picked and it still says
|
||||
// Active" was.
|
||||
// pending → 'Pending_Pickup'
|
||||
// accepted → 'Pickup_Scheduled' — both describe a booking BEFORE
|
||||
// pickup. A consignment only exists after it, so it can never go
|
||||
// back to either.
|
||||
//
|
||||
// What a consignment can actually be set to, per doormile-flow.md §5–6: it is
|
||||
// created at pickup-complete already Out_for_Delivery (hyperlocal) or routed
|
||||
// via a hub, then delivered, skipped, or cancelled.
|
||||
const DELIVERY_STATUS_TO_CONSIGNMENT_STATUS = {
|
||||
// The dialog offers "started"; a consignment that has started IS out for
|
||||
// delivery — this API has no separate state for it.
|
||||
started: 'Out_for_Delivery',
|
||||
active: 'Out_for_Delivery',
|
||||
delivered: 'Delivered',
|
||||
@@ -1032,9 +1003,6 @@ const DELIVERY_STATUS_TO_CONSIGNMENT_STATUS = {
|
||||
canceled: 'Cancelled'
|
||||
};
|
||||
|
||||
// Why a status can't be set, when it can't. Specific beats generic: "Picked
|
||||
// can't be set" is useless next to "it's already a consignment, which is what
|
||||
// picked means".
|
||||
const UNSETTABLE_REASON = {
|
||||
picked: 'this order is already a consignment — which is exactly what "picked" means. There is no earlier state to set it back to.',
|
||||
pending:
|
||||
@@ -1051,11 +1019,6 @@ export const updateDeliveryAPI = async (orderData) => {
|
||||
const chosen = String(orderData.orderstatus || '').toLowerCase();
|
||||
const status = DELIVERY_STATUS_TO_CONSIGNMENT_STATUS[chosen];
|
||||
|
||||
// `arrived` and `skipped` have no booking-status equivalent at all (the rider
|
||||
// actions behind them — /miler/bookings/:id/reached and
|
||||
// /miler/consignments/:id/skip — write no booking status). Refusing here with
|
||||
// the reason is honest; guessing a near-enough status would set the wrong one
|
||||
// on a real delivery.
|
||||
if (!status) {
|
||||
if (!chosen) return { success: false, message: 'Choose a status first.' };
|
||||
return {
|
||||
@@ -1065,9 +1028,6 @@ export const updateDeliveryAPI = async (orderData) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Only `status` is sent. The dialog's kms / amount / notes have no field on
|
||||
// this endpoint (see the note above), and this request 400s on validation —
|
||||
// so posting the rest is at best ignored and at worst another rejection.
|
||||
return updateConsignmentStatus(id, { status });
|
||||
};
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ export default function CreateOrder() {
|
||||
|
||||
const applyLocationToPickup = (loc) => {
|
||||
if (!loc) return;
|
||||
const lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat;
|
||||
const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong;
|
||||
|
||||
setPickCust((prev) => ({
|
||||
...prev,
|
||||
firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point',
|
||||
@@ -174,24 +177,9 @@ export default function CreateOrder() {
|
||||
suburb: loc.locationname || loc.city || '',
|
||||
city: loc.city || '',
|
||||
postcode: loc.pincode || loc.postcode || '',
|
||||
latitude: loc.latitude ? Number(loc.latitude) : '',
|
||||
longitude: loc.longitude ? Number(loc.longitude) : ''
|
||||
latitude: lat != null && lat !== '' ? Number(lat) : prev.latitude,
|
||||
longitude: lng != null && lng !== '' ? Number(lng) : prev.longitude
|
||||
}));
|
||||
|
||||
if (!loc.latitude || !loc.longitude) {
|
||||
const q = [loc.address, loc.city, loc.pincode].filter(Boolean).join(', ');
|
||||
if (q) {
|
||||
geocodeAddress(q).then((place) => {
|
||||
if (place?.geometry?.location) {
|
||||
setPickCust((prev) => ({
|
||||
...prev,
|
||||
latitude: place.geometry.location.lat(),
|
||||
longitude: place.geometry.location.lng()
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectLocation = (locId) => {
|
||||
@@ -207,8 +195,14 @@ export default function CreateOrder() {
|
||||
|
||||
// Tenant Pricing
|
||||
const clientRate = useMemo(() => {
|
||||
if (!selectedTenantId) return null;
|
||||
return (pricingList || []).find((p) => String(p.tenantid) === String(selectedTenantId));
|
||||
const list = Array.isArray(pricingList) ? pricingList : [];
|
||||
if (selectedTenantId) {
|
||||
const match = list.find((p) => String(p.tenantid) === String(selectedTenantId));
|
||||
if (match) return match;
|
||||
}
|
||||
// Fallback to default pricing rule or standard system rates
|
||||
const defaultRule = list.find((p) => p.isdefault || !p.tenantid || p.tenantid === 0);
|
||||
return defaultRule || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 };
|
||||
}, [pricingList, selectedTenantId]);
|
||||
|
||||
// Route calculation
|
||||
@@ -227,7 +221,7 @@ export default function CreateOrder() {
|
||||
calculateDrivingDistance({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
|
||||
.then((distance) => {
|
||||
if (!active) return;
|
||||
setRoute({ distance, minutes: getLastRouteDurationMin(), resolved: true });
|
||||
setRoute({ distance: Math.max(1, distance || 1), minutes: getLastRouteDurationMin() || 15, resolved: true });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setRoute({ distance: 0, minutes: null, resolved: false });
|
||||
@@ -240,13 +234,16 @@ export default function CreateOrder() {
|
||||
|
||||
// Total Charge calculation
|
||||
const totalCharge = useMemo(() => {
|
||||
if (!route.resolved || !clientRate) return 0;
|
||||
return calculateTotalCharge(
|
||||
route.distance,
|
||||
Number(clientRate.baseprice) || 0,
|
||||
Number(clientRate.priceperkm) || 0,
|
||||
Number(clientRate.basedistance) || 0
|
||||
);
|
||||
const base = Number(clientRate?.baseprice) || 40;
|
||||
const perKm = Number(clientRate?.priceperkm) || 10;
|
||||
const minKm = Number(clientRate?.basedistance) || 2;
|
||||
|
||||
if (!route.resolved || !route.distance) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const calculated = calculateTotalCharge(route.distance, base, perKm, minKm);
|
||||
return Math.max(base, calculated);
|
||||
}, [route, clientRate]);
|
||||
|
||||
// Place selection helpers
|
||||
|
||||
@@ -138,13 +138,12 @@ export default function MultipleOrders() {
|
||||
|
||||
// Pricing synchronization
|
||||
useEffect(() => {
|
||||
if (!selectedTenantId || !pricingList?.length) return;
|
||||
const match = pricingList.find((p) => String(p.tenantid) === String(selectedTenantId));
|
||||
if (match) {
|
||||
setBasePrice(Number(match.baseprice) || 0);
|
||||
setPricePerKm(Number(match.priceperkm) || 0);
|
||||
setMinKm(Number(match.basedistance) || 0);
|
||||
}
|
||||
const list = Array.isArray(pricingList) ? pricingList : [];
|
||||
const match = list.find((p) => String(p.tenantid) === String(selectedTenantId));
|
||||
const rate = match || list.find((p) => p.isdefault || !p.tenantid || p.tenantid === 0) || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 };
|
||||
setBasePrice(Number(rate.baseprice) || 40);
|
||||
setPricePerKm(Number(rate.priceperkm) || 10);
|
||||
setMinKm(Number(rate.basedistance) || 2);
|
||||
}, [selectedTenantId, pricingList]);
|
||||
|
||||
// Auto-select single location
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import { Ban, FileSpreadsheet, MapPin, Package, Plus, Truck, Upload } from 'lucide-react';
|
||||
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
|
||||
import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks';
|
||||
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Truck, Upload, UserCheck } from 'lucide-react';
|
||||
import { Button, ConfirmModal, DataTable, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
|
||||
import { useBulkCancelBookings, useCustomers, useRiderOptions } from '@/lib/doormileHooks';
|
||||
import { useZone } from '@/lib/ZoneContext';
|
||||
import { createAutomationDeliveries } from '@/api/doormile';
|
||||
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
|
||||
import { drainBookings } from '@/lib/assistant/scan';
|
||||
import { OpenToast, messageOf } from '@/api/doormile/notify';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
@@ -56,7 +57,18 @@ const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const chargeOf = (booking) => booking?.serviceoptions?.[0]?.estimatedprice;
|
||||
const chargeOf = (booking) => {
|
||||
const serviceOpt = booking?.serviceoptions?.[0];
|
||||
const val =
|
||||
serviceOpt?.estimatedprice ??
|
||||
serviceOpt?.price ??
|
||||
booking?.finalprice ??
|
||||
booking?.orderamount ??
|
||||
booking?.deliverycharges ??
|
||||
booking?.deliverycharge ??
|
||||
booking?.estimatedprice;
|
||||
return Number.isFinite(Number(val)) ? Number(val) : undefined;
|
||||
};
|
||||
|
||||
export default function Orders() {
|
||||
const navigate = useNavigate();
|
||||
@@ -72,6 +84,11 @@ export default function Orders() {
|
||||
const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone();
|
||||
|
||||
const { data: customers = [] } = useCustomers();
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
|
||||
const [manualAssignOpen, setManualAssignOpen] = useState(false);
|
||||
const [selectedRiderId, setSelectedRiderId] = useState('');
|
||||
const [isAssigningManual, setIsAssigningManual] = useState(false);
|
||||
const bulkCancel = useBulkCancelBookings();
|
||||
|
||||
/* Polled: an operator watching the pending queue needs new bookings to
|
||||
@@ -213,6 +230,39 @@ export default function Orders() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleManualAssign = async () => {
|
||||
if (!selectedRiderId || !selectedIds.length) {
|
||||
OpenToast('Please select a rider first', 'warning');
|
||||
return;
|
||||
}
|
||||
const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
|
||||
if (!targetRider) {
|
||||
OpenToast('Please select a valid rider', 'warning');
|
||||
return;
|
||||
}
|
||||
const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id;
|
||||
setIsAssigningManual(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedIds.map((bookingId) =>
|
||||
assignMilerToBooking(bookingId, { mileruserid: riderUserId })
|
||||
)
|
||||
);
|
||||
if (targetRider.milerprofileid) {
|
||||
notifyMiler(targetRider.milerprofileid, 'DoormileXpress', `${selectedIds.length} orders assigned to you`).catch(() => {});
|
||||
}
|
||||
OpenToast(`${selectedIds.length} order${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'rider'}`, 'success');
|
||||
refetchOrders();
|
||||
setSelectedIds([]);
|
||||
setManualAssignOpen(false);
|
||||
setSelectedRiderId('');
|
||||
} catch (err) {
|
||||
OpenToast(messageOf(err, 'Failed to assign orders'), 'error', 4000);
|
||||
} finally {
|
||||
setIsAssigningManual(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -445,14 +495,91 @@ export default function Orders() {
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full border border-border bg-surface p-2 shadow-xl"
|
||||
className="fixed bottom-6 right-6 z-50 flex flex-wrap items-center gap-2.5 rounded-2xl border border-slate-200 bg-white/95 p-2.5 shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<Button onClick={assignOrders} loading={optimise.isPending}>
|
||||
<Truck className="mr-1.5 h-4 w-4" /> Assign orders ({selectedOrders.length})
|
||||
<div className="px-2 text-xs font-bold text-slate-700">
|
||||
{selectedOrders.length} selected
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white shadow-xs"
|
||||
onClick={assignOrders}
|
||||
loading={optimise.isPending}
|
||||
>
|
||||
<Sparkles className="mr-1.5 h-4 w-4" /> AI Assign ({selectedOrders.length})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100"
|
||||
onClick={() => {
|
||||
if (isAllZones) {
|
||||
OpenToast('Please select a specific Zone / Hub first', 'warning');
|
||||
return;
|
||||
}
|
||||
setManualAssignOpen(true);
|
||||
}}
|
||||
>
|
||||
<Bike className="mr-1.5 h-4 w-4 text-emerald-600" /> Manual Assign
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
<Ban className="mr-1.5 h-4 w-4" /> Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={manualAssignOpen}
|
||||
onOpenChange={setManualAssignOpen}
|
||||
title={`Manual Assign ${selectedIds.length} Order${selectedIds.length === 1 ? '' : 's'}`}
|
||||
description="Choose an active rider to assign the selected orders directly."
|
||||
>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
|
||||
Select Rider <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedRiderId}
|
||||
onChange={(e) => setSelectedRiderId(e.target.value)}
|
||||
className="w-full h-10 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black"
|
||||
>
|
||||
<option value="">-- Choose an available rider --</option>
|
||||
{riders.map((r) => {
|
||||
const rId = String(r.userid || r.mileruserid || r.id);
|
||||
const rName = r.displayname || r.name || `Rider #${rId}`;
|
||||
const rPhone = r.mobile || r.phone ? ` · ${r.mobile || r.phone}` : '';
|
||||
const rStatus = r.status ? ` [${r.status}]` : '';
|
||||
return (
|
||||
<option key={rId} value={rId}>
|
||||
{rName} {rPhone} {rStatus}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-100">
|
||||
<Button variant="outline" onClick={() => setManualAssignOpen(false)} disabled={isAssigningManual}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleManualAssign}
|
||||
disabled={!selectedRiderId || isAssigningManual}
|
||||
loading={isAssigningManual}
|
||||
>
|
||||
<UserCheck className="mr-1.5 h-4 w-4" /> Assign {selectedIds.length} Orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={cancelOpen}
|
||||
onOpenChange={setCancelOpen}
|
||||
|
||||
@@ -428,6 +428,75 @@ describe('deliveries data layer', () => {
|
||||
endpoints.cancelBooking.mockRejectedValue({ message: 'already cancelled', httpStatus: 409 });
|
||||
await expect(cancelDeliveryAPI({ orderheaderid: 4021 }, 'x')).rejects.toMatchObject({ httpStatus: 409 });
|
||||
});
|
||||
|
||||
describe('a row that has already converted to a consignment', () => {
|
||||
const converted = { bookingid: 41, deliveryid: 41, orderheaderid: 41, consignmentid: 61 };
|
||||
|
||||
it('should cancel on the consignment, where the lifecycle now lives', async () => {
|
||||
// A booking freezes the moment the parcel is picked up; the booking-level
|
||||
// cancel route is refused for it.
|
||||
endpoints.updateConsignmentStatus.mockResolvedValue({ success: true });
|
||||
|
||||
await cancelDeliveryAPI(converted, 'customer unreachable');
|
||||
|
||||
expect(endpoints.updateConsignmentStatus).toHaveBeenCalledWith(61, { status: 'Cancelled' });
|
||||
});
|
||||
|
||||
it('should NOT also call the booking cancel route', async () => {
|
||||
endpoints.updateConsignmentStatus.mockResolvedValue({ success: true });
|
||||
await cancelDeliveryAPI(converted, 'x');
|
||||
expect(endpoints.cancelBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should surface the consignment failure instead of retrying on the booking', async () => {
|
||||
// The reported bug: PUT /admin/consignments/61/status returned 500, the
|
||||
// code silently fell back to POST /admin/bookings/41/cancel, and the
|
||||
// operator was shown that route's 400 — an error about the wrong
|
||||
// endpoint entirely, which is why the real fault was invisible.
|
||||
endpoints.updateConsignmentStatus.mockRejectedValue({
|
||||
message: 'failed to update consignment status',
|
||||
httpStatus: 500
|
||||
});
|
||||
endpoints.cancelBooking.mockRejectedValue({ message: 'booking cannot be cancelled', httpStatus: 400 });
|
||||
|
||||
await expect(cancelDeliveryAPI(converted, 'x')).rejects.toMatchObject({
|
||||
message: 'failed to update consignment status',
|
||||
httpStatus: 500
|
||||
});
|
||||
expect(endpoints.cancelBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should surface a refusal envelope from the consignment route unchanged', async () => {
|
||||
endpoints.updateConsignmentStatus.mockResolvedValue({ success: false, message: 'already delivered' });
|
||||
|
||||
await expect(cancelDeliveryAPI(converted, 'x')).resolves.toEqual({
|
||||
success: false,
|
||||
message: 'already delivered'
|
||||
});
|
||||
expect(endpoints.cancelBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should never cancel a booking whose consignment is still live', async () => {
|
||||
// The integrity hazard behind the removed fallback: a cancelled booking
|
||||
// sitting against a consignment that is still out for delivery.
|
||||
endpoints.updateConsignmentStatus.mockRejectedValue({ httpStatus: 500, message: 'boom' });
|
||||
await cancelDeliveryAPI(converted, 'x').catch(() => {});
|
||||
expect(endpoints.cancelBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should still use the booking route for a row that has not converted', async () => {
|
||||
endpoints.cancelBooking.mockResolvedValue({ success: true });
|
||||
await cancelDeliveryAPI({ bookingid: 41, consignmentid: null }, 'duplicate');
|
||||
expect(endpoints.cancelBooking).toHaveBeenCalledWith(41, { reason: 'duplicate' });
|
||||
expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refuse a row carrying neither id rather than calling an endpoint with undefined', async () => {
|
||||
await expect(cancelDeliveryAPI({}, 'x')).rejects.toThrow(/nothing to cancel/i);
|
||||
expect(endpoints.cancelBooking).not.toHaveBeenCalled();
|
||||
expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeRiderAPI', () => {
|
||||
|
||||
Reference in New Issue
Block a user