Files
Cibello-app/packages/inventory-engine/src/expiry.ts
T
2026-08-05 19:21:11 +07:00

128 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { EXPIRY_THRESHOLDS, type ExpiryStatus, type StorageLocationType } from "@app/shared-types";
export interface ExpiryInput {
bestBeforeDate?: string | null;
useByDate?: string | null;
openedAt?: string | null;
frozenAt?: string | null;
thawedAt?: string | null;
storageLocationType?: StorageLocationType | null;
/** Riktvärde i dagar efter öppning/inköp per plats (från canonical ingredient). */
shelfLifeGuidance?: Partial<Record<StorageLocationType, number>> | null;
purchasedAt?: string | null;
}
export interface ExpiryResult {
status: ExpiryStatus;
/** Dagar kvar till den mest bindande gränsen. Negativt = passerad. */
daysLeft: number | null;
/** Vilken gräns som styr klassningen. */
limitingFactor: "use_by" | "best_before" | "opened_guidance" | "purchased_guidance" | "none";
/** Bäst före passerad men troligen ätbar kommunicera skillnaden (spec §13). */
pastBestBefore: boolean;
}
const DAY_MS = 86_400_000;
function daysBetween(from: Date, to: Date): number {
return Math.floor((startOfDay(to).getTime() - startOfDay(from).getTime()) / DAY_MS);
}
/**
* All kalenderaritmetik sker i UTC (deterministiskt oavsett serverns tidszon).
* Datumsträngar ("2026-08-05") är kalenderdatum och tolkas som UTC-midnatt;
* resultatet blir identiskt på en server i Stockholm, Auckland eller UTC.
*/
function startOfDay(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
function parse(dateStr: string | null | undefined): Date | null {
if (!dateStr) return null;
const d = new Date(`${dateStr}T00:00:00Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Deterministisk bäst före-klassning (spec §13).
*
* Regler:
* - Fryst vara ("frozenAt" satt, ej upptinad): datumklockan pausad → "fresh"
* (kvalitetsförsämring hanteras som vägledning i UI, inte som utgång).
* - "Sista förbrukningsdag" är hård gräns → efter den: "expired".
* - "Bäst före" passerad: "expired"-klass används INTE automatiskt varan
* flaggas "use_soon/expiring" med pastBestBefore=true. Appen får aldrig
* garantera säkerhet (spec §61.3) användaren avgör.
* - Öppnad vara: öppningsdatum + riktvärde per förvaringsplats kan vara mer
* bindande än tryckt datum.
*/
export function classifyExpiry(input: ExpiryInput, today: Date = new Date()): ExpiryResult {
const isFrozen = Boolean(input.frozenAt) && !input.thawedAt;
if (isFrozen) {
return { status: "fresh", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
}
const candidates: Array<{ factor: ExpiryResult["limitingFactor"]; date: Date }> = [];
const useBy = parse(input.useByDate);
if (useBy) candidates.push({ factor: "use_by", date: useBy });
const bestBefore = parse(input.bestBeforeDate);
if (bestBefore) candidates.push({ factor: "best_before", date: bestBefore });
const guidanceDays = input.storageLocationType
? input.shelfLifeGuidance?.[input.storageLocationType]
: undefined;
const opened = parse(input.openedAt);
if (opened && guidanceDays != null) {
candidates.push({
factor: "opened_guidance",
date: new Date(opened.getTime() + guidanceDays * DAY_MS),
});
} else if (!useBy && !bestBefore && guidanceDays != null) {
const purchased = parse(input.purchasedAt);
if (purchased) {
candidates.push({
factor: "purchased_guidance",
date: new Date(purchased.getTime() + guidanceDays * DAY_MS),
});
}
}
if (candidates.length === 0) {
return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
}
// Mest bindande = tidigaste datum.
candidates.sort((a, b) => a.date.getTime() - b.date.getTime());
const limiting = candidates[0]!;
const daysLeft = daysBetween(today, limiting.date);
const pastBestBefore = bestBefore ? daysBetween(today, bestBefore) < 0 : false;
let status: ExpiryStatus;
if (daysLeft < 0) {
// Hård gräns endast för sista förbrukningsdag; annars "expiring" + flagga.
status = limiting.factor === "use_by" ? "expired" : "expiring";
} else if (daysLeft <= EXPIRY_THRESHOLDS.EXPIRING_DAYS) {
status = "expiring";
} else if (daysLeft <= EXPIRY_THRESHOLDS.USE_SOON_DAYS) {
status = "use_soon";
} else {
status = "fresh";
}
return { status, daysLeft, limitingFactor: limiting.factor, pastBestBefore };
}
/** Sorteringsnyckel: mest brådskande först, okända sist. */
export function urgencyRank(result: ExpiryResult): number {
const order: Record<ExpiryStatus, number> = {
expired: 0,
expiring: 1,
use_soon: 2,
fresh: 3,
unknown: 4,
};
return order[result.status] * 10_000 + (result.daysLeft ?? 9_000);
}