240 lines
10 KiB
JavaScript
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))
|
|
};
|
|
};
|