updates on the ui changes and the bot changes

This commit is contained in:
2026-08-20 13:12:56 +05:30
parent e1cc874218
commit ce32465a6f
12 changed files with 567 additions and 25 deletions

View File

@@ -98,4 +98,5 @@ export const someFetch = async () => {
- Several `api.js` functions ignore most of their destructured `queryKey`/arguments — the new endpoint doesn't accept those filters. This is deliberate degradation, not a bug; see §3.
- A handful of pages (`createorder1.js`, `multipleOrders.js`) synthesize fallback data client-side (e.g. a fixed 09:00–21:00 delivery-slot window) because the endpoint that used to supply it no longer exists. Look for the comment explaining why before "fixing" it.
- **`updateDeliveryAPI` refuses half the Deliveries dialog's options, on purpose.** It writes to `PUT /admin/consignments/:id/status`, which speaks the **consignment** enum — not the booking enum they look like. `Picked` maps to `Converted_To_Consignment`, which names the *moment a booking becomes a consignment*; a consignment cannot be set to it, so choosing Picked did nothing and the row kept showing the consignment's real state. `Pending` and `Accepted` describe a booking *before* pickup, which a consignment can never return to. Only `Out_for_Delivery`, `Delivered` and `Cancelled` are sent; everything else is refused with a reason and **nothing goes on the wire**. If the real consignment enum is ever captured, widen the map — don't guess a value.
- `notifyRider` / `notifyMiler` now take a **miler profile ID**, not an FCM token — the new API's `/admin/milers/:id/notify` looks the device token up server-side. Don't pass a token here.

View File

@@ -714,6 +714,13 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// Falls back to the booking whenever the consignment is absent or carries
// nothing status-shaped — never invents a state.
orderstatus: mapBookingStatusToDeliveryStatus(consignmentStatusFor(b, consignmentMap) ?? b.status),
// Which record the status above came from, and what it said. A booking
// freezes at Converted_To_Consignment the moment it is picked up, so a
// row can legitimately read "Active" while GET /admin/bookings still says
// Converted_To_Consignment — which looks exactly like a bug unless the UI
// can say where the value came from.
consignmentstatus: consignmentStatusFor(b, consignmentMap),
statusfromconsignment: consignmentStatusFor(b, consignmentMap) != null,
droplat: b.deliverylatitude,
droplon: b.deliverylongitude
};
@@ -858,12 +865,30 @@ export const changeRiderAPI = async (selectedRider, selectedRow) =>
// 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.
const DELIVERY_STATUS_TO_BOOKING_STATUS = {
pending: 'Pending_Pickup',
accepted: 'Pickup_Scheduled',
picked: 'Converted_To_Consignment',
// The dialog offers "started", which this API has no separate state for — a
// consignment that has started IS out for delivery.
// ⚠ 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',
@@ -871,10 +896,24 @@ const DELIVERY_STATUS_TO_BOOKING_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:
'a consignment only exists after pickup, so it can’t go back to Pending — that describes a booking before the parcel was collected.',
accepted:
'a consignment only exists after pickup, so it can’t go back to Accepted — that describes a booking before the parcel was collected.',
arrived: 'the rider action behind Arrived (POST /miler/bookings/:id/reached) writes no consignment status, so there is nothing to set.',
skipped:
'Skipped is recorded by the rider (POST /miler/consignments/:id/skip) as an attempt count, not as a status this endpoint can write.'
};
export const updateDeliveryAPI = async (orderData) => {
const id = orderData.consignmentid ?? orderData.deliveryid;
const chosen = String(orderData.orderstatus || '').toLowerCase();
const status = DELIVERY_STATUS_TO_BOOKING_STATUS[chosen];
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
@@ -882,11 +921,11 @@ export const updateDeliveryAPI = async (orderData) => {
// 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 {
success: false,
message: chosen
? `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.`
: 'Choose a status first.'
message:
UNSETTABLE_REASON[chosen] || `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.`
};
}

View File

@@ -224,6 +224,30 @@ A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row arra
Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
### Repeat Runs — `repeatRuns.js` / `repeatFlow.js`
"Same orders as yesterday." One question (which day), then a pass, then the usual gate.
**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply.
**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination:
| Trap | Seen on |
|---|---|
| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key |
| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` |
| pickup pincode no longer served | CityGate refuses at the middleware, before the handler |
Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale.
**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key.
**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number.
**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders.
Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round".
### Assigning a rider — `assignActions.js` / `assignFlow.js`
The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run.
@@ -238,7 +262,7 @@ The fourth write. Reached three ways: automatically after a single create, from
Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed.
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
---

View File

@@ -141,7 +141,9 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
<HStack gap={1.5} padding={0}>
<Button
label={
message.form.kind === 'confirmBulk'
message.form.confirmLabel
? message.form.confirmLabel
: message.form.kind === 'confirmBulk'
? `Create ${message.form.draft?.rows?.length || 0} orders`
: message.form.kind === 'confirmOrder'
? 'Create order'
@@ -156,6 +158,16 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
/>
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
{/* A second, wider commit — used when rows were excluded by
default but including them is a legitimate choice. */}
{message.form.altDraft && (
<Button
label={message.form.altLabel || 'Include everything'}
variant="ghost"
size="sm"
onClick={() => onSubmitForm(message, message.form.altDraft)}
/>
)}
</HStack>
) : (
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}>

View File

@@ -23,6 +23,8 @@ import { startOrderFlow, answerOrderStep } from '../orderFlow';
import { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '../bulkFlow';
import { startAssignFlow, answerAssignStep } from '../assignFlow';
import { executeAssign, executeBatchAssign, describeRider } from '../assignActions';
import { startRepeatFlow, answerRepeatStep } from '../repeatFlow';
import { buildRepeatRun, describeDay } from '../repeatRuns';
import { getPageContext } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome from './AIWelcome';
@@ -280,6 +282,116 @@ const AIPanel = ({ isOpen, onClose }) => {
// The assign conversation's end. Two outcomes: the operator chose to keep the
// rider the backend already picked (nothing to send), or they picked someone
// and get the usual one-press gate.
// Everything after "which day" — resolving customers, the drift check,
// re-quoting at today's tariff, the already-repeated guard. Narrated into one
// message like the bulk file's locate pass, because none of it is a question.
const finishRepeat = useCallback(
async (draft) => {
bulkStop.current = false;
const liveId = pushLive({
sender: 'assistant',
text: `Checking ${describeDay(draft.day)}’s orders…`,
liveStop: true
});
const run = await buildRepeatRun(draft.day, {
shouldStop: () => bulkStop.current,
onProgress: (p) => patch(liveId, { text: `Pricing at today’s tariff… ${Math.min(p.done + 1, p.total)} of ${p.total}` })
});
patch(liveId, { text: `Checked ${run.total} order${run.total === 1 ? '' : 's'} from ${describeDay(draft.day)}.`, liveStop: false });
// Nothing NEW, but everything already exists today. That is not a dead
// end — running the same round twice in a day (a lunch wave and a dinner
// wave to the same customers) is ordinary work. The guard's job is to make
// sure it is deliberate, not to refuse it, so this warns loudly and still
// offers the button.
if (!run.valid.length && run.already.length) {
push({
sender: 'assistant',
text: `All ${run.already.length} of ${describeDay(draft.day)}’s orders have already been created today.`,
list: {
title: 'Already created today',
numbered: false,
items: run.already.slice(0, 12).map((r) => ({
label: `${r.customer_name} · ${r.customer_phone}`,
meta: r.deliveryaddress
}))
},
detail: `Creating them again produces a SECOND set of orders to the same customers — a second real rider to each door. Only do it if that is what you mean.`,
form: {
kind: 'confirmBulk',
status: 'open',
draft: { rows: run.already, invalid: run.drifted, repeatOf: draft.day },
confirmLabel: `Create all ${run.already.length} again`
}
});
setFlow(null);
return;
}
if (!run.valid.length) {
push({
sender: 'assistant',
state: {
tone: STATUS.muted,
iconKey: 'info',
title: 'Nothing to repeat',
body: run.drifted.length
? `None can be repeated — the first problem is that ${run.drifted[0].error}.`
: 'That day has no repeatable orders.'
},
downloadRows: run.drifted.length ? run.drifted : undefined
});
setFlow(null);
return;
}
push({
sender: 'assistant',
text: `${run.valid.length} order${run.valid.length === 1 ? '' : 's'} ready to repeat.`,
list: {
title: 'Will be created',
numbered: false,
items: run.valid.slice(0, 12).map((r) => ({
label: `${r.customer_name} · ${r.customer_phone}`,
meta: `₹${Number(r.finalprice).toFixed(2)}${
r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice)
? ` (was ₹${Number(r.previousPrice).toFixed(2)})`
: ''
} · ${r.deliveryaddress}`
}))
},
// Every line here is a row NOT being created, and the reason why.
detail: [
run.valid.length > 12 ? `Showing the first 12 of ${run.valid.length}.` : null,
run.already.length ? `${run.already.length} already created today — left out so nobody is booked twice.` : null,
run.drifted.length ? `${run.drifted.length} can’t be repeated (${run.drifted[0].error}).` : null,
run.unpriced.length ? `${run.unpriced.length} couldn’t be priced at today’s tariff.` : null,
run.changed.length
? `${run.changed.length} price${run.changed.length === 1 ? '' : 's'} changed since ${describeDay(draft.day)}.`
: null
]
.filter(Boolean)
.join(' '),
downloadRows: run.drifted.length ? run.drifted : undefined,
form: {
kind: 'confirmBulk',
status: 'open',
draft: { rows: run.valid, invalid: run.drifted, repeatOf: draft.day },
// Rows already created today are excluded by default — but repeating
// a round twice in a day is legitimate, so there is a way to include
// them without starting over.
altLabel: run.already.length
? `Include the ${run.already.length} already created today (${run.valid.length + run.already.length} total)`
: undefined,
altDraft: run.already.length ? { rows: [...run.valid, ...run.already], invalid: run.drifted, repeatOf: draft.day } : undefined
}
});
},
[push, pushLive, patch]
);
const finishAssign = useCallback(
async (draft) => {
const label = draft.booking?.bookingno || `#${draft.booking?.bookingid}`;
@@ -424,6 +536,14 @@ const AIPanel = ({ isOpen, onClose }) => {
// An order flow answers through this same path when the step is
// free text; `select` steps come in through chooseStep instead.
if (flow.kind === 'repeatRun') {
const next = await answerRepeatStep(flow, question);
setFlow(next.flow);
await renderFlowStep(next, finishRepeat);
setIsSending(false);
return;
}
if (flow.kind === 'assignRider') {
const next = await answerAssignStep(flow, question);
setFlow(next.flow);
@@ -478,6 +598,14 @@ const AIPanel = ({ isOpen, onClose }) => {
// The form version was replaced on explicit request; asking one
// question at a time reads better and matches how an operator would
// dictate a record.
if (result.form?.kind === 'repeatRun') {
const started = await startRepeatFlow();
setFlow(started.flow);
await renderFlowStep(started, finishRepeat);
setIsSending(false);
return;
}
if (result.form?.kind === 'assignRider') {
const started = await startAssignFlow(result.form.booking);
setFlow(started.flow);
@@ -567,7 +695,7 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(false);
}
},
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, push]
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, finishRepeat, push]
);
const copyMessage = useCallback((m) => {
@@ -726,7 +854,9 @@ const AIPanel = ({ isOpen, onClose }) => {
// ---- bulk orders ----
if (message.form.kind === 'confirmBulk') {
const { rows, invalid, ...shared } = message.form.draft;
// `values` is whichever button was pressed — the default draft, or the
// alternative that includes today's existing rows.
const { rows, invalid, ...shared } = values?.rows ? values : message.form.draft;
const res = await executeCreateBulk(rows, shared);
// A bulk import is normally a PARTIAL result, so the outcome is reported
// per row rather than as a single success/failure. Without that, the only
@@ -856,8 +986,16 @@ const AIPanel = ({ isOpen, onClose }) => {
setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, flowStep: { ...m.flowStep, __answered: true } } : m)));
setIsSending(true);
try {
const answer = flow.kind === 'createBulk' ? answerBulkStep : flow.kind === 'assignRider' ? answerAssignStep : answerOrderStep;
const finish = flow.kind === 'createBulk' ? finishBulk : flow.kind === 'assignRider' ? finishAssign : finishOrder;
// A table, not a ternary chain: with four conversations the chain became
// unreadable, and mispairing an answer with the wrong finisher sends a
// reply into the wrong conversation.
const BY_KIND = {
createBulk: [answerBulkStep, finishBulk],
assignRider: [answerAssignStep, finishAssign],
repeatRun: [answerRepeatStep, finishRepeat],
createOrder: [answerOrderStep, finishOrder]
};
const [answer, finish] = BY_KIND[flow.kind] || BY_KIND.createOrder;
const next = await answer(flow, value, option);
setFlow(next.flow);
await renderFlowStep(next, finish);
@@ -868,7 +1006,7 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(false);
}
},
[flow, push, renderFlowStep, finishBulk, finishOrder, finishAssign]
[flow, push, renderFlowStep, finishBulk, finishOrder, finishAssign, finishRepeat]
);
// Stops the long locate/price pass. The flag is a ref for the reason

View File

@@ -190,7 +190,10 @@ export const executeCreateBulk = async (rows, shared) => {
// Recorded BEFORE the request, not after: a timed-out submit is the case that
// most needs the warning, and it never reaches a success handler.
submitted.add(rowSetFingerprint(rows));
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, shared.__pickup));
// A row may carry its OWN pickup. A bulk FILE shares one kitchen, but a
// repeated day's run can span several tenants and locations — collapsing
// those onto one shared pickup would silently re-address half the orders.
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, r.__pickup ?? shared.__pickup));
const batches = chunk(payloads, BULK_MAX);
const sourceCalls = [];

View File

@@ -31,6 +31,7 @@ import { STATUS } from 'themes/dt/tokens';
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
import { CREATE_ORDER_TRIGGER } from './orderActions';
import { ASSIGN_TRIGGER } from './assignActions';
import { REPEAT_TRIGGER } from './repeatRuns';
import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
@@ -744,6 +745,19 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
const MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [
{
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
// contains "orders", so createBulkOrders and createOrder would otherwise
// claim it and open a blank create instead of recalling the run.
id: 'repeatRun',
label: 'Repeat a past day’s orders — e.g. "repeat yesterday’s orders"',
match: (text) => (REPEAT_TRIGGER.test(text) ? {} : null),
run: async () => ({
headline: 'Let’s repeat a previous run.',
form: { kind: 'repeatRun', status: 'open' },
sourceCalls: []
})
},
{
// Ordered FIRST, ahead of createOrder: "create multiple orders" also
// matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger

View File

@@ -0,0 +1,30 @@
import { findRecentRuns, describeDay } from './repeatRuns';
import { startFlow, advanceFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — repeat a run ||============================== //
//
// One question: which day. Everything after it — resolving customers, the drift
// check, re-quoting at today's tariff, the already-repeated guard — is a pass
// the panel runs and narrates, exactly like the bulk file's locate/price phase.
// None of it is a question, so none of it is a step.
export const REPEAT_STEPS = [
{
id: 'day',
type: 'select',
ask: 'Which day’s orders should I repeat?',
options: async () => {
const { runs } = await findRecentRuns();
return runs.map((r) => ({
value: r.day,
label: `${describeDay(r.day)} — ${r.count} order${r.count === 1 ? '' : 's'}`,
record: r
}));
},
apply: (d, v, option) => ({ ...d, day: v, sourceCount: option?.record?.count })
}
];
export const startRepeatFlow = () => startFlow(REPEAT_STEPS, 'repeatRun', {});
export const advanceRepeat = (flow) => advanceFlow(REPEAT_STEPS, flow);
export const answerRepeatStep = (flow, raw, option) => answerFlowStep(REPEAT_STEPS, flow, raw, option);

View File

@@ -0,0 +1,239 @@
import dayjs from 'dayjs';
import { getAdminCustomers } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { groupForBookingStatus } from 'utils/orderStatusGroups';
import { scanBookings } from './intents';
import { cityGateFor } from './orderActions';
import { priceBulkRows } from './bulkOrderActions';
// ==============================|| Doormile AI — repeat a past day's run ||============================== //
//
// "I created ten orders yesterday; make the same ten today."
//
// This is the cheapest write in the assistant, and the reason is worth stating:
// a booking already carries 15 of the 17 fields `buildOrderPayload` needs —
// both addresses, both pincodes, BOTH SETS OF COORDINATES, the service tier,
// the parcels. Only the recipient's name and phone are missing, and those come
// from the `appcustomerid` → /admin/customers join the assistant already does.
//
// So a repeat needs NO geocoding. The ~1 lookup/second Nominatim throttle that
// dominates the bulk-file flow does not apply here at all.
//
// ---- A booking is a snapshot, not a template --------------------------------
//
// Which is why every row goes through a drift check before it can be repeated.
// These are not hypothetical; all three were found in one live page of 36
// bookings:
//
// • `pickupaddress` can be ABSENT entirely (booking 57 carries the pincode
// and coordinates but no address key at all) — repeating it blind sends an
// empty pickup address.
// • `tenantid` can be null (every `Customer_App` booking) — `Number(null)`
// is 0, so the payload would claim tenant zero.
// • a pickup pincode that was open when the order was placed may not be now,
// and CityGate refuses at the middleware, before the handler runs.
//
// ---- Duplicate safety is INVERTED here --------------------------------------
//
// Everywhere else in this assistant, near-identical orders are an error to
// prevent (`wasAlreadySubmitted`). A repeat run deliberately creates them, so
// that guard would misfire on every single run. The question that actually
// matters is different: *has this run already been repeated today?* The
// endpoint has no idempotency key, so it is answered by looking: today's own
// bookings are fingerprinted and any row already present is set aside rather
// than booked twice.
export const REPEAT_TRIGGER = /\b(?:repeat|redo|re-?run)\b|\bsame\s+orders?\s+as\b|\bsame\s+as\s+(?:yesterday|last)\b/i;
// How far back a run can be recalled from. Beyond a week it stops being "the
// usual round" and starts being archaeology.
const LOOKBACK_DAYS = 7;
const dayOf = (value) => {
const t = parseDoormileTimestamp(value);
return t.isValid() ? t.format('YYYY-MM-DD') : null;
};
// A row is only worth repeating if it was a real order. Cancelled ones are
// excluded — repeating a cancellation is never what "same as yesterday" means.
const isRepeatable = (b) => groupForBookingStatus(b.status) !== 'cancelled';
// ---- which days have a run to repeat ---------------------------------------
export const findRecentRuns = async () => {
const scan = await scanBookings();
const today = dayjs().format('YYYY-MM-DD');
const counts = new Map();
(scan.rows || []).forEach((b) => {
if (!isRepeatable(b)) return;
const day = dayOf(b.createdat);
if (!day || day === today) return;
if (dayjs(today).diff(dayjs(day), 'day') > LOOKBACK_DAYS) return;
counts.set(day, (counts.get(day) || 0) + 1);
});
return {
scan,
runs: [...counts.entries()].map(([day, count]) => ({ day, count })).sort((a, b) => (a.day < b.day ? 1 : -1))
};
};
export const describeDay = (day) => {
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
if (day === yesterday) return 'Yesterday';
return dayjs(day).format('ddd D MMM');
};
// ---- one booking → one repeatable row ---------------------------------------
//
// `__pickup` travels on the ROW, not on the shared draft: a day's run can span
// several kitchens and tenants, and collapsing them onto one shared pickup
// would silently re-address half the orders.
const toRow = (booking, customer, index) => ({
line: index + 1,
source: booking.bookingno || `#${booking.bookingid}`,
tenantid: booking.tenantid,
customer_name: customer?.name || [customer?.firstname, customer?.lastname].filter(Boolean).join(' ') || '',
customer_phone: String(customer?.phone || customer?.contactno || '').replace(/\D/g, ''),
deliveryaddress: booking.deliveryaddress || '',
deliverypincode: String(booking.deliverypincode || ''),
deliverycity: booking.deliverycity || '',
deliverylatitude: booking.deliverylatitude,
deliverylongitude: booking.deliverylongitude,
service_option: booking.serviceoptions?.[0]?.servicetype || 'Normal',
// Deliberately blank: the chosen behaviour is to re-quote at today's tariff,
// so this is left for priceBulkRows to fill exactly as an unpriced bulk row
// would be. Copying yesterday's number would silently bill an old tariff.
finalprice: '',
previousPrice: booking.serviceoptions?.[0]?.estimatedprice,
itemcategory: booking.parcels?.[0]?.itemcategory || 'General',
itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
quantity: Math.max(1, booking.parcels?.length || 1),
notes: booking.notes || '',
__pickup: {
address: booking.pickupaddress,
pincode: booking.pickuppincode,
city: booking.pickupcity,
latitude: booking.pickuplatitude,
longitude: booking.pickuplongitude
}
});
// ---- the drift check --------------------------------------------------------
//
// Returns a REASON, never a boolean — an operator dropping a row deserves to
// know which field went stale.
const driftReason = (row) => {
if (!row.tenantid) return 'the original had no tenant, so this would be booked against tenant 0';
if (!row.__pickup?.address) return 'the original booking carries no pickup address';
if (!row.customer_phone) return 'the customer record is gone, so there is no phone number';
if (!row.customer_name) return 'the customer record is gone, so there is no name';
if (!row.deliveryaddress) return 'no delivery address on the original';
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
return 'the original has no delivery coordinates, so it could never be routed';
}
if (!cityGateFor(row.__pickup.pincode)) {
return `its pickup pincode (${row.__pickup.pincode || 'unknown'}) is no longer a city Doormile serves`;
}
return null;
};
// What makes two orders "the same order". It has to include the PARCEL, not
// just the destination: a run to one address for one customer is a completely
// normal thing to do twice in a day with different contents, and judging on
// phone + address + pickup alone made three unrelated bookings to the same bus
// stand look identical — the repeat then reported all of yesterday as already
// created when only one unrelated order existed today.
//
// Every field here is one a repeat reproduces EXACTLY, which is what makes it a
// usable identity. Price is deliberately excluded: it is re-quoted at today's
// tariff, so it differs by design on every legitimate repeat.
const fingerprint = (row) =>
[
row.customer_phone,
String(row.deliveryaddress || '').toLowerCase(),
row.__pickup?.pincode || '',
row.service_option,
row.itemcategory,
row.itemdescription,
row.quantity
].join('|');
// ---- assemble the run -------------------------------------------------------
export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
const [{ scan }, customers] = await Promise.all([findRecentRuns(), getAdminCustomers().catch(() => [])]);
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.id, c]));
const source = (scan.rows || []).filter((b) => isRepeatable(b) && dayOf(b.createdat) === day);
const rows = source.map((b, i) => toRow(b, customerMap.get(b.appcustomerid), i));
// Already repeated today? Today's own bookings are put through the SAME
// `toRow` shaping, so both sides of the comparison get identical defaults —
// hand-rolling the today side is how the two drifted apart in the first place.
//
// Counts, not a Set. A Set answers "does anything today look like this", so a
// single matching order suppressed EVERY row that shared its fingerprint —
// one order created today wiped out all of yesterday's. A multiset answers
// the question that was actually meant: how many of these already exist. Two
// identical orders yesterday and one today means one still needs creating.
const today = dayjs().format('YYYY-MM-DD');
const todayCounts = new Map();
(scan.rows || [])
.filter((b) => isRepeatable(b) && dayOf(b.createdat) === today)
.forEach((b) => {
const key = fingerprint(toRow(b, customerMap.get(b.appcustomerid), 0));
todayCounts.set(key, (todayCounts.get(key) || 0) + 1);
});
const drifted = [];
const already = [];
const candidates = [];
rows.forEach((row) => {
const reason = driftReason(row);
if (reason) {
drifted.push({ ...row, error: reason });
return;
}
// Consume one match per already-existing order, so a second identical row
// is still offered once the first has been accounted for.
const key = fingerprint(row);
const remaining = todayCounts.get(key) || 0;
if (remaining > 0) {
todayCounts.set(key, remaining - 1);
already.push(row);
return;
}
candidates.push(row);
});
// Re-quote at today's tariff. Priced per row because a run can span tenants,
// and a tenant's own pricing row is what decides the number.
const priced = [];
for (let i = 0; i < candidates.length; i += 1) {
if (shouldStop?.()) break;
const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
// eslint-disable-next-line no-await-in-loop
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out);
}
if (priced.length < candidates.length) priced.push(...candidates.slice(priced.length));
const valid = priced.filter((r) => !r.priceError && Number(r.finalprice) > 0);
const unpriced = priced.filter((r) => r.priceError || !(Number(r.finalprice) > 0));
return {
day,
scan,
total: rows.length,
valid,
drifted,
already,
unpriced,
// Every price that moved since the original, so a tariff change is visible
// rather than discovered on an invoice.
changed: valid.filter((r) => r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice))
};
};

View File

@@ -799,7 +799,23 @@ const Deliveries = () => {
Status
</Text>
),
cell: ({ getValue }) => <StatusBadge status={getValue()} />
// The badge explains itself when the value did not come from the
// booking. Without this an operator comparing the table against
// GET /admin/bookings sees "Active" next to
// `"status": "Converted_To_Consignment"` and reasonably assumes the
// page is wrong — it isn't; the booking simply stops moving at pickup.
cell: ({ getValue, row }) =>
row.original.statusfromconsignment ? (
<Tooltip
content={`From consignment #${row.original.consignmentid} (${row.original.consignmentstatus}). The booking itself stays at Converted_To_Consignment once it has been picked up.`}
>
<span>
<StatusBadge status={getValue()} />
</span>
</Tooltip>
) : (
<StatusBadge status={getValue()} />
)
},
{
id: 'tenant',

View File

@@ -54,6 +54,14 @@ An opt-in "activity" date basis was added so an order booked yesterday and still
The filter field and the bucket field have to be the same field. If carried-over work needs to be visible on the board, it needs its own bucket ("Carried over") — not a time-of-day wave it does not belong to. Decide that before reaching for the date filter again.
### Picked is empty for hyperlocal traffic, and that is correct
`doormile-flow.md` §5: at `pickup-complete` the booking becomes a consignment, and **matching 3-digit pickup/delivery pincode prefixes go straight to `Out_for_Delivery`** (hyperlocal); everything else routes via a hub.
So a Coimbatore-to-Coimbatore order (641xxx → 641xxx) is picked up and is *immediately* out for delivery. It never rests in a "picked" state. The Picked tab reading 0 while orders are plainly moving is expected behaviour on hyperlocal traffic, not a missing mapping.
The confusing part is downstream of that: the booking's own `status` **freezes at `Converted_To_Consignment`** the moment it is picked up. The Deliveries page joins `GET /admin/consignments` and lets the consignment's status win, so a row correctly reads **Active** while `GET /admin/bookings` still says `Converted_To_Consignment`. That looks like a bug on inspection, which is why the status badge carries a tooltip naming the consignment and its status whenever the value came from there (`statusfromconsignment` on the row).
### ⛔ Never bucket on `assigntime`
It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it.

View File

@@ -62,7 +62,12 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
/* Tighter on the LEFT than the right. The logo is the page's anchor and was
sitting 24px in; the right side keeps the wider gutter because the controls
there need clearance from the window edge. #strat-row below carries the
same asymmetry — the two are stacked, so their left edges have to agree or
the logo no longer lines up with the batch row under it. */
padding: 0 24px 0 12px;
background: var(--bg);
border-bottom: 1px solid var(--border);
z-index: 1010;
@@ -74,17 +79,29 @@
gap: 12px;
}
/* The badge is sized to the D ITSELF, not to the image file.
doormile-mark.png fills only 66.8% of its own canvas (alpha bounding box
170..853 of 1024) — a third of every edge is transparent padding. In the old
44px box that drew a 29px D with 7.3px of empty space on each side, so the
12px gap to "Dispatch" read as ~19px. That whitespace was the "large gap",
not the gap property.
The image is scaled 150% (1 / 0.668) to cancel the padding exactly, and
overflow clips what spills — which is only transparent pixels. Net effect:
the D stays the size it already looked, and the gap becomes a true 12px. */
.dispatch-container .logo-badge {
width: 44px;
height: 44px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex: 0 0 auto;
}
.dispatch-container .logo-badge-img {
width: 100%;
height: 100%;
width: 150%;
height: 150%;
max-width: none;
object-fit: contain;
}
@@ -287,7 +304,8 @@
display: flex;
align-items: center;
gap: 8px;
padding: 0 24px;
/* Same left gutter as #hdr — see the note there. */
padding: 0 24px 0 12px;
background: var(--bg);
border-bottom: 1px solid var(--border);
}