/** * 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>; // 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; 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 }; }