/** * Product rules → cart constraints. * * Conflict policy (v1, documented in the merchant UI): * 1. "allowOnlyMethods" rules intersect across items. * 2. "prohibitMethods" always wins over any allowance. * 3. "restrictLocations" intersects across items. * 4. Prep time: each item's prep = sum of its matching addPrep rules; * cart prep = longest item (or sum, per shop setting). * 5. Date/weekday blocks and freshness windows are all applied (most restrictive). * Every constraint keeps the rule id + item that caused it so the merchant * diagnostics screen and the customer message can explain it. */ import type { Cart, CartItem, Method, ProductRule, Reason, Weekday } from "./types"; import { METHODS } from "./types"; import { addDays } from "./time"; export interface FreshnessConstraint { ruleId: string; itemTitle: string; arrivals: { date: string; locationIds?: string[] }[]; minDaysAfterArrival: number; shelfLifeDays: number; message?: string; } export interface CartConstraints { /** Methods the cart may use, with reasons for any removed. */ allowedMethods: Set; methodReasons: Map; /** null = no location restriction. */ allowedLocations: Set | null; locationReasons: Reason[]; extraPrepMinutes: number; prepDriver?: { itemTitle: string; ruleId: string; minutes: number }; blockedWeekdays: Map; blockedDates: Map; windows: { from?: string; to?: string; reason: Reason }[]; freshness: FreshnessConstraint[]; requiresScheduling: boolean; matched: { ruleId: string; ruleName: string; itemTitle: string }[]; } export function itemMatches(item: CartItem, rule: ProductRule): boolean { const t = rule.target; const vals = t.values.map((v) => String(v).toLowerCase()); switch (t.type) { case "product": return vals.includes(String(item.productId).toLowerCase()); case "variant": return vals.includes(String(item.variantId).toLowerCase()); case "collection": return (item.collectionIds ?? []).some((c) => vals.includes(String(c).toLowerCase())); case "vendor": return !!item.vendor && vals.includes(item.vendor.toLowerCase()); case "productType": return !!item.productType && vals.includes(item.productType.toLowerCase()); case "tag": return (item.tags ?? []).some((tag) => vals.includes(tag.toLowerCase())); } } function why(rule: ProductRule, item: CartItem, fallback: string): Reason { return { code: "BLOCKED_BY_PRODUCT", message: rule.message || fallback.replace("{item}", item.title), ruleId: rule.id }; } export function evaluateRules( cart: Cart, rules: ProductRule[], policy: "longest" | "sum" = "longest", ): CartConstraints { const c: CartConstraints = { allowedMethods: new Set(METHODS), methodReasons: new Map(), allowedLocations: null, locationReasons: [], extraPrepMinutes: 0, blockedWeekdays: new Map(), blockedDates: new Map(), windows: [], freshness: [], requiresScheduling: false, matched: [], }; const active = rules.filter((r) => r.enabled).sort((a, b) => a.priority - b.priority); const prohibitions: { m: Method; reason: Reason }[] = []; let prepTotal = 0; for (const item of cart.items) { let itemPrep = 0; let itemPrepRule: string | undefined; for (const rule of active) { if (!itemMatches(item, rule)) continue; c.matched.push({ ruleId: rule.id, ruleName: rule.name, itemTitle: item.title }); const a = rule.action; switch (a.type) { case "allowOnlyMethods": for (const m of METHODS) { if (!a.methods.includes(m) && c.allowedMethods.has(m)) { c.allowedMethods.delete(m); pushReason(c.methodReasons, m, { ...why(rule, item, `{item} is only available for ${a.methods.join(" or ")}`), code: "METHOD_PROHIBITED_BY_PRODUCT", }); } } break; case "prohibitMethods": for (const m of a.methods) prohibitions.push({ m, reason: { ...why(rule, item, `{item} can't be sent by ${m}`), code: "METHOD_PROHIBITED_BY_PRODUCT" }, }); break; case "restrictLocations": { const next = new Set(a.locationIds); c.allowedLocations = c.allowedLocations ? new Set([...c.allowedLocations].filter((x) => next.has(x))) : next; c.locationReasons.push({ ...why(rule, item, `{item} is only available from selected locations`), code: "LOCATION_PROHIBITED_BY_PRODUCT", }); break; } case "addPrep": itemPrep += a.minutes; itemPrepRule = rule.id; break; case "blockWeekdays": for (const w of a.weekdays) if (!c.blockedWeekdays.has(w)) c.blockedWeekdays.set(w, why(rule, item, `{item} isn't available on this day`)); break; case "blockDates": for (const d of a.dates) if (!c.blockedDates.has(d)) c.blockedDates.set(d, why(rule, item, `{item} isn't available on this date`)); break; case "dateWindow": c.windows.push({ from: a.from, to: a.to, reason: why(rule, item, `{item} is only available in a limited window`) }); break; case "freshness": c.freshness.push({ ruleId: rule.id, itemTitle: item.title, arrivals: a.arrivals, minDaysAfterArrival: a.minDaysAfterArrival, shelfLifeDays: a.shelfLifeDays, message: rule.message, }); break; case "requireScheduling": c.requiresScheduling = true; break; } } if (policy === "sum") prepTotal += itemPrep; if (itemPrep > (c.prepDriver?.minutes ?? 0) && itemPrepRule) c.prepDriver = { itemTitle: item.title, ruleId: itemPrepRule, minutes: itemPrep }; } // Prohibitions win over allowances. for (const { m, reason } of prohibitions) { c.allowedMethods.delete(m); pushReason(c.methodReasons, m, reason); } c.extraPrepMinutes = policy === "sum" ? prepTotal : c.prepDriver?.minutes ?? 0; return c; } function pushReason(map: Map, m: Method, r: Reason) { const list = map.get(m) ?? []; if (!list.some((x) => x.ruleId === r.ruleId && x.message === r.message)) list.push(r); map.set(m, list); } /** * Freshness check for one date at one location. A date passes when, for every * perishable item, some stock arrival makes the date fall inside * [arrival + minDays, arrival + shelfLife]. */ export function checkFreshness( constraints: FreshnessConstraint[], date: string, locationId: string, ): Reason | null { for (const f of constraints) { const arrivals = f.arrivals.filter((a) => !a.locationIds?.length || a.locationIds.includes(locationId)); if (!arrivals.length) return { code: "NO_STOCK_ARRIVAL", message: f.message || `${f.itemTitle}: no fresh stock is scheduled for this location yet`, ruleId: f.ruleId, }; const ok = arrivals.some( (a) => date >= addDays(a.date, f.minDaysAfterArrival) && date <= addDays(a.date, f.shelfLifeDays), ); if (!ok) { const nextReady = arrivals .map((a) => addDays(a.date, f.minDaysAfterArrival)) .filter((d) => d > date) .sort()[0]; return nextReady ? { code: "NOT_FRESH_YET", message: f.message || `${f.itemTitle}: next fresh batch is ready from ${nextReady}`, ruleId: f.ruleId, } : { code: "PAST_FRESHNESS", message: f.message || `${f.itemTitle}: outside its freshness window on this date`, ruleId: f.ruleId, }; } } return null; }