122 lines
4.7 KiB
TypeScript
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 };
|
|
}
|