Files
Doormilexpress_console/src/pages/nearle/assistant/repeatRuns.js

240 lines
10 KiB
JavaScript

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