datewise/app/engine/rules.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

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