datewise/app/engine/locations.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

122 lines
4.7 KiB
TypeScript

/**
* Location eligibility + ranking for a cart and method.
*
* Policy: never silently assign a whole cart to one location when its items are
* only stocked across several. Instead return SPLIT_REQUIRED with an explanation.
*/
import type { AvailabilityResult, Cart, LocationConfig, Method, PeakDay, ProductRule, Reason, ShopSettings, UsageSnapshot } from "./types";
import { computeAvailability, firstAvailable } from "./availability";
import { evaluateRules } from "./rules";
import { type CustomerAddress, type FeeQuote, type ZoneMatch, matchZone, quoteFee } from "./zones";
export type Inventory = Record<string, Record<string, number>>; // locationId → variantId → available
export interface RankedLocation {
location: LocationConfig;
eligible: boolean;
reasons: Reason[];
zone?: ZoneMatch;
fee?: FeeQuote;
distanceKm: number | null;
first: { date: string; slot: string } | null;
availability?: AvailabilityResult;
}
export interface RankInput {
locations: LocationConfig[];
method: Method;
cart: Cart;
rules: ProductRule[];
peakDays: PeakDay[];
usage: UsageSnapshot;
settings: Pick<ShopSettings, "mixedPrepPolicy">;
now: number;
address?: CustomerAddress;
inventory?: Inventory;
ranking?: "priority" | "distance" | "earliest";
}
/**
* A location with no inventory entry (not linked to a Shopify location) is "unknown" and
* never blocks. For linked locations, a missing variant means none in stock there.
*/
export function stockedAt(inv: Inventory | undefined, locId: string, cart: Cart): boolean {
if (!inv || !inv[locId]) return true;
const at = inv[locId];
return cart.items.every((i) => (at[i.variantId] ?? 0) >= i.quantity);
}
export function rankLocations(input: RankInput): { ranked: RankedLocation[]; splitRequired: Reason | null } {
const constraints = evaluateRules(input.cart, input.rules, input.settings.mixedPrepPolicy);
const subtotal = input.cart.subtotal ?? input.cart.items.reduce((s, i) => s + i.price * i.quantity, 0);
const ranked: RankedLocation[] = [];
for (const loc of input.locations) {
const r: RankedLocation = { location: loc, eligible: true, reasons: [], distanceKm: null, first: null };
ranked.push(r);
if (input.method === "delivery") {
const z = matchZone(loc, input.address ?? {});
r.zone = z;
r.distanceKm = z.distanceKm;
if (!z.zone) {
r.eligible = false;
r.reasons.push(z.reason!);
} else {
r.fee = quoteFee(z.zone, subtotal);
if (r.fee.belowMinimum) {
r.eligible = false;
r.reasons.push({
code: "BELOW_MINIMUM_ORDER",
message: `Delivery needs a minimum order of $${(r.fee.minOrder! / 100).toFixed(2)}`,
});
}
}
}
if (input.method !== "shipping" && !stockedAt(input.inventory, loc.id, input.cart)) {
r.eligible = false;
r.reasons.push({ code: "OUT_OF_STOCK_AT_LOCATION", message: `Not all items are in stock at ${loc.name}` });
}
const av = computeAvailability({ ...input, location: loc, constraints });
r.availability = av;
if (av.blockers.length) {
r.eligible = false;
r.reasons.push(...av.blockers);
}
const f = firstAvailable(av);
r.first = f ? { date: f.date, slot: f.slot.id } : null;
if (r.eligible && !f) {
r.eligible = false;
r.reasons.push({ code: "DAY_FULL", message: "No available dates in the booking window" });
}
}
const mode = input.ranking ?? "priority";
ranked.sort((a, b) => {
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
if (mode === "distance" && a.distanceKm != null && b.distanceKm != null && a.distanceKm !== b.distanceKm)
return a.distanceKm - b.distanceKm;
if (mode === "earliest" && a.first && b.first) {
const ka = `${a.first.date} ${a.first.slot}`;
const kb = `${b.first.date} ${b.first.slot}`;
if (ka !== kb) return ka < kb ? -1 : 1;
}
return a.location.priority - b.location.priority;
});
// Split detection: nobody stocks everything, but the union does.
let splitRequired: Reason | null = null;
if (input.inventory && input.method !== "shipping" && !ranked.some((r) => r.eligible)) {
const unionOk = input.cart.items.every(
(i) => input.locations.reduce((s, l) => s + (input.inventory![l.id]?.[i.variantId] ?? 0), 0) >= i.quantity,
);
const anyStocked = input.locations.some((l) => stockedAt(input.inventory, l.id, input.cart));
if (unionOk && !anyStocked)
splitRequired = {
code: "OUT_OF_STOCK_AT_LOCATION",
message:
"Your items are stocked at different locations. Choose shipping, or remove some items to schedule a single pickup or delivery.",
};
}
return { ranked, splitRequired };
}