updates on the changes

This commit is contained in:
2026-08-27 16:49:09 +05:30
parent 8855b62b39
commit c9a80cbd20
5 changed files with 271 additions and 119 deletions

View File

@@ -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 });
};

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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', () => {