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. - 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. - 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. - `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 // Falls back to the booking whenever the consignment is absent or carries
// nothing status-shaped — never invents a state. // nothing status-shaped — never invents a state.
orderstatus: mapBookingStatusToDeliveryStatus(consignmentStatusFor(b, consignmentMap) ?? b.status), 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, droplat: b.deliverylatitude,
droplon: b.deliverylongitude 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 // from it by inversion: that map is many-to-one (`pending_pickup` and
// `miler_assigned` both mean `pending`), so an automatic inversion would pick // `miler_assigned` both mean `pending`), so an automatic inversion would pick
// whichever happened to be last and silently write the wrong one. // whichever happened to be last and silently write the wrong one.
const DELIVERY_STATUS_TO_BOOKING_STATUS = { // ⚠ These are CONSIGNMENT statuses, not booking statuses. The endpoint is
pending: 'Pending_Pickup', // `PUT /admin/consignments/:id/status`, and the two enums are not the same
accepted: 'Pickup_Scheduled', // vocabulary even though they overlap on Delivered and Cancelled.
picked: 'Converted_To_Consignment', //
// The dialog offers "started", which this API has no separate state for — a // This map used to include the booking's pre-pickup states, and they were all
// consignment that has started IS out for delivery. // 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', started: 'Out_for_Delivery',
active: 'Out_for_Delivery', active: 'Out_for_Delivery',
delivered: 'Delivered', delivered: 'Delivered',
@@ -871,10 +896,24 @@ const DELIVERY_STATUS_TO_BOOKING_STATUS = {
canceled: 'Cancelled' 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) => { export const updateDeliveryAPI = async (orderData) => {
const id = orderData.consignmentid ?? orderData.deliveryid; const id = orderData.consignmentid ?? orderData.deliveryid;
const chosen = String(orderData.orderstatus || '').toLowerCase(); 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 // `arrived` and `skipped` have no booking-status equivalent at all (the rider
// actions behind them — /miler/bookings/:id/reached and // 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 // the reason is honest; guessing a near-enough status would set the wrong one
// on a real delivery. // on a real delivery.
if (!status) { if (!status) {
if (!chosen) return { success: false, message: 'Choose a status first.' };
return { return {
success: false, success: false,
message: chosen message:
? `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.` UNSETTABLE_REASON[chosen] || `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.`
: 'Choose a status first.'
}; };
} }

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. 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` ### 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. 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. 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}> <HStack gap={1.5} padding={0}>
<Button <Button
label={ label={
message.form.kind === 'confirmBulk' message.form.confirmLabel
? message.form.confirmLabel
: message.form.kind === 'confirmBulk'
? `Create ${message.form.draft?.rows?.length || 0} orders` ? `Create ${message.form.draft?.rows?.length || 0} orders`
: message.form.kind === 'confirmOrder' : message.form.kind === 'confirmOrder'
? 'Create order' ? 'Create order'
@@ -156,6 +158,16 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)} onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
/> />
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} /> <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> </HStack>
) : ( ) : (
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}> <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 { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '../bulkFlow';
import { startAssignFlow, answerAssignStep } from '../assignFlow'; import { startAssignFlow, answerAssignStep } from '../assignFlow';
import { executeAssign, executeBatchAssign, describeRider } from '../assignActions'; import { executeAssign, executeBatchAssign, describeRider } from '../assignActions';
import { startRepeatFlow, answerRepeatStep } from '../repeatFlow';
import { buildRepeatRun, describeDay } from '../repeatRuns';
import { getPageContext } from './pageContext'; import { getPageContext } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts'; import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome from './AIWelcome'; 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 // 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 // rider the backend already picked (nothing to send), or they picked someone
// and get the usual one-press gate. // 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( const finishAssign = useCallback(
async (draft) => { async (draft) => {
const label = draft.booking?.bookingno || `#${draft.booking?.bookingid}`; 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 // An order flow answers through this same path when the step is
// free text; `select` steps come in through chooseStep instead. // 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') { if (flow.kind === 'assignRider') {
const next = await answerAssignStep(flow, question); const next = await answerAssignStep(flow, question);
setFlow(next.flow); setFlow(next.flow);
@@ -478,6 +598,14 @@ const AIPanel = ({ isOpen, onClose }) => {
// The form version was replaced on explicit request; asking one // The form version was replaced on explicit request; asking one
// question at a time reads better and matches how an operator would // question at a time reads better and matches how an operator would
// dictate a record. // 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') { if (result.form?.kind === 'assignRider') {
const started = await startAssignFlow(result.form.booking); const started = await startAssignFlow(result.form.booking);
setFlow(started.flow); setFlow(started.flow);
@@ -567,7 +695,7 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(false); setIsSending(false);
} }
}, },
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, push] [context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, finishRepeat, push]
); );
const copyMessage = useCallback((m) => { const copyMessage = useCallback((m) => {
@@ -726,7 +854,9 @@ const AIPanel = ({ isOpen, onClose }) => {
// ---- bulk orders ---- // ---- bulk orders ----
if (message.form.kind === 'confirmBulk') { 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); const res = await executeCreateBulk(rows, shared);
// A bulk import is normally a PARTIAL result, so the outcome is reported // 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 // 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))); setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, flowStep: { ...m.flowStep, __answered: true } } : m)));
setIsSending(true); setIsSending(true);
try { try {
const answer = flow.kind === 'createBulk' ? answerBulkStep : flow.kind === 'assignRider' ? answerAssignStep : answerOrderStep; // A table, not a ternary chain: with four conversations the chain became
const finish = flow.kind === 'createBulk' ? finishBulk : flow.kind === 'assignRider' ? finishAssign : finishOrder; // 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); const next = await answer(flow, value, option);
setFlow(next.flow); setFlow(next.flow);
await renderFlowStep(next, finish); await renderFlowStep(next, finish);
@@ -868,7 +1006,7 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(false); 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 // 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 // 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. // most needs the warning, and it never reaches a success handler.
submitted.add(rowSetFingerprint(rows)); 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 batches = chunk(payloads, BULK_MAX);
const sourceCalls = []; const sourceCalls = [];

View File

@@ -31,6 +31,7 @@ import { STATUS } from 'themes/dt/tokens';
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions'; import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
import { CREATE_ORDER_TRIGGER } from './orderActions'; import { CREATE_ORDER_TRIGGER } from './orderActions';
import { ASSIGN_TRIGGER } from './assignActions'; import { ASSIGN_TRIGGER } from './assignActions';
import { REPEAT_TRIGGER } from './repeatRuns';
import { CREATE_BULK_TRIGGER } from './bulkOrderActions'; import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter'; import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups'; 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 MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [ 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 // Ordered FIRST, ahead of createOrder: "create multiple orders" also
// matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger // 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 Status
</Text> </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', 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. 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` ### ⛔ 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. 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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; 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); background: var(--bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
z-index: 1010; z-index: 1010;
@@ -74,17 +79,29 @@
gap: 12px; 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 { .dispatch-container .logo-badge {
width: 44px; width: 30px;
height: 44px; height: 30px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
flex: 0 0 auto;
} }
.dispatch-container .logo-badge-img { .dispatch-container .logo-badge-img {
width: 100%; width: 150%;
height: 100%; height: 150%;
max-width: none;
object-fit: contain; object-fit: contain;
} }
@@ -287,7 +304,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 0 24px; /* Same left gutter as #hdr — see the note there. */
padding: 0 24px 0 12px;
background: var(--bg); background: var(--bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }