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