217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
/**
|
|
* 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<Method>;
|
|
methodReasons: Map<Method, Reason[]>;
|
|
/** null = no location restriction. */
|
|
allowedLocations: Set<string> | null;
|
|
locationReasons: Reason[];
|
|
extraPrepMinutes: number;
|
|
prepDriver?: { itemTitle: string; ruleId: string; minutes: number };
|
|
blockedWeekdays: Map<Weekday, Reason>;
|
|
blockedDates: Map<string, Reason>;
|
|
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<Method, Reason[]>, 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;
|
|
}
|