Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@app/inventory-engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Food Twin-logik: transaktionsbaserat lager, FEFO, bäst före-klassning, dubblettkandidater (spec §8, §13)",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/nutrition-engine": "workspace:*",
|
||||
"@app/shared-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { InventoryTransactionType, Unit } from "@app/shared-types";
|
||||
|
||||
export interface TransactionLike {
|
||||
type: InventoryTransactionType;
|
||||
quantityDelta: number;
|
||||
unit: Unit;
|
||||
}
|
||||
|
||||
/** Transaktionstyper som ska vara negativa (uttag). */
|
||||
const OUTFLOW_TYPES: ReadonlySet<InventoryTransactionType> = new Set([
|
||||
"consume",
|
||||
"discard",
|
||||
"cook_use",
|
||||
"leftover_consumed",
|
||||
]);
|
||||
/** Transaktionstyper som ska vara positiva (inflöde). */
|
||||
const INFLOW_TYPES: ReadonlySet<InventoryTransactionType> = new Set([
|
||||
"purchase",
|
||||
"leftover_created",
|
||||
]);
|
||||
|
||||
export interface BalanceResult {
|
||||
balance: number;
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beräkna saldo ur transaktionshistorik (spec §8: transaktioner är sanningen).
|
||||
* Saldo tillåts aldrig bli negativt – i så fall flaggas historiken som
|
||||
* inkonsistent så att en correction kan föreslås, i stället för tyst clamp.
|
||||
*/
|
||||
export function computeBalance(transactions: TransactionLike[]): BalanceResult {
|
||||
let balance = 0;
|
||||
const errors: string[] = [];
|
||||
for (const [i, tx] of transactions.entries()) {
|
||||
if (OUTFLOW_TYPES.has(tx.type) && tx.quantityDelta > 0) {
|
||||
errors.push(`Transaktion ${i} (${tx.type}) borde vara negativ men är +${tx.quantityDelta}`);
|
||||
}
|
||||
if (INFLOW_TYPES.has(tx.type) && tx.quantityDelta < 0) {
|
||||
errors.push(`Transaktion ${i} (${tx.type}) borde vara positiv men är ${tx.quantityDelta}`);
|
||||
}
|
||||
balance += tx.quantityDelta;
|
||||
if (balance < -1e-9) {
|
||||
errors.push(
|
||||
`Saldo blev negativt (${balance.toFixed(3)}) efter transaktion ${i} (${tx.type})`,
|
||||
);
|
||||
balance = 0;
|
||||
}
|
||||
}
|
||||
return { balance: round3(balance), valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Normalisera ett uttag: rätt tecken oavsett hur anroparen skickade mängden. */
|
||||
export function normalizeDelta(type: InventoryTransactionType, quantity: number): number {
|
||||
const magnitude = Math.abs(quantity);
|
||||
if (OUTFLOW_TYPES.has(type)) return -magnitude;
|
||||
if (INFLOW_TYPES.has(type)) return magnitude;
|
||||
return quantity; // adjust/correction/move/freeze/thaw får vara valfritt tecken
|
||||
}
|
||||
|
||||
function round3(v: number): number {
|
||||
return Math.round(v * 1000) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { InventorySource } from "@app/shared-types";
|
||||
|
||||
export interface DedupCandidateInput {
|
||||
canonicalIngredientId?: string | null;
|
||||
displayName: string;
|
||||
brand?: string | null;
|
||||
quantity: number;
|
||||
source: InventorySource;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DedupExistingItem extends DedupCandidateInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DedupCandidate {
|
||||
itemId: string;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
const WINDOW_MS = 3 * 86_400_000; // 3 dygn
|
||||
|
||||
/**
|
||||
* Dubblettkandidater mellan kvitto, streckkod och bilder (spec §9).
|
||||
* Ren heuristik – användaren fattar alltid beslutet. AI-baserad
|
||||
* dedup (DEDUPLICATE_INVENTORY-jobbet) kan förfina men aldrig auto-slå ihop
|
||||
* utan bekräftelse.
|
||||
*/
|
||||
export function findDuplicateCandidates(
|
||||
incoming: DedupCandidateInput,
|
||||
existing: DedupExistingItem[],
|
||||
): DedupCandidate[] {
|
||||
const incomingTime = Date.parse(incoming.createdAt);
|
||||
const results: DedupCandidate[] = [];
|
||||
|
||||
for (const item of existing) {
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
const sameCanonical =
|
||||
incoming.canonicalIngredientId != null &&
|
||||
incoming.canonicalIngredientId === item.canonicalIngredientId;
|
||||
const nameMatch = normalizedEquals(incoming.displayName, item.displayName);
|
||||
if (sameCanonical) {
|
||||
score += 0.45;
|
||||
reasons.push("samma ingrediens");
|
||||
} else if (nameMatch) {
|
||||
score += 0.3;
|
||||
reasons.push("liknande namn");
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dt = Math.abs(Date.parse(item.createdAt) - incomingTime);
|
||||
if (dt <= WINDOW_MS) {
|
||||
score += 0.25;
|
||||
reasons.push("registrerad inom 3 dygn");
|
||||
}
|
||||
|
||||
if (incoming.source !== item.source) {
|
||||
score += 0.15;
|
||||
reasons.push(`olika källor (${incoming.source} + ${item.source})`);
|
||||
}
|
||||
|
||||
const qtyRatio =
|
||||
item.quantity > 0 && incoming.quantity > 0
|
||||
? Math.min(incoming.quantity, item.quantity) / Math.max(incoming.quantity, item.quantity)
|
||||
: 0;
|
||||
if (qtyRatio >= 0.7) {
|
||||
score += 0.15;
|
||||
reasons.push("liknande mängd");
|
||||
}
|
||||
|
||||
if (
|
||||
incoming.brand &&
|
||||
item.brand &&
|
||||
incoming.brand.toLowerCase().trim() === item.brand.toLowerCase().trim()
|
||||
) {
|
||||
score += 0.1;
|
||||
reasons.push("samma varumärke");
|
||||
}
|
||||
|
||||
if (score >= 0.5) {
|
||||
results.push({ itemId: item.id, score: Math.min(1, round2(score)), reasons });
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
function normalizedEquals(a: string, b: string): boolean {
|
||||
const na = normalize(a);
|
||||
const nb = normalize(b);
|
||||
if (na === nb) return true;
|
||||
return na.length > 3 && nb.length > 3 && (na.includes(nb) || nb.includes(na));
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zåäö0-9 ]/gi, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function round2(v: number): number {
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { convert, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||||
import { classifyExpiry, urgencyRank, type ExpiryInput } from "./expiry.js";
|
||||
|
||||
export interface StockItemLike extends ExpiryInput {
|
||||
id: string;
|
||||
canonicalIngredientId?: string | null;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
purchasedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface Allocation {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
}
|
||||
|
||||
export interface FefoResult {
|
||||
allocations: Allocation[];
|
||||
/** Hur mycket som saknas i efterfrågad enhet (0 om täckt). */
|
||||
shortfall: number;
|
||||
covered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* FEFO – First Expire, First Out (spec §17–18: prioritera utgångsdatum).
|
||||
* Plockar från de poster som går ut först; vid lika datum äldst inköp först.
|
||||
*/
|
||||
export function allocateFefo(
|
||||
required: number,
|
||||
unit: Unit,
|
||||
items: StockItemLike[],
|
||||
info: IngredientUnitInfo = {},
|
||||
today: Date = new Date(),
|
||||
): FefoResult {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const rank = urgencyRank(classifyExpiry(a, today)) - urgencyRank(classifyExpiry(b, today));
|
||||
if (rank !== 0) return rank;
|
||||
const pa = a.purchasedAt ?? "9999-12-31";
|
||||
const pb = b.purchasedAt ?? "9999-12-31";
|
||||
return pa.localeCompare(pb);
|
||||
});
|
||||
|
||||
let remaining = required;
|
||||
const allocations: Allocation[] = [];
|
||||
for (const item of sorted) {
|
||||
if (remaining <= 1e-9) break;
|
||||
const available = convert(item.quantity, item.unit, unit, info);
|
||||
if (available == null || available <= 0) continue;
|
||||
const take = Math.min(available, remaining);
|
||||
const takeInItemUnit = convert(take, unit, item.unit, info);
|
||||
if (takeInItemUnit == null) continue;
|
||||
allocations.push({ itemId: item.id, quantity: round3(takeInItemUnit), unit: item.unit });
|
||||
remaining -= take;
|
||||
}
|
||||
|
||||
return {
|
||||
allocations,
|
||||
shortfall: round3(Math.max(0, remaining)),
|
||||
covered: remaining <= 1e-9,
|
||||
};
|
||||
}
|
||||
|
||||
function round3(v: number): number {
|
||||
return Math.round(v * 1000) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Pantry Forecast (spec §40): "Ni brukar använda mjölk var sjätte dag."
|
||||
* Enkel deterministisk konsumtionstakt ur transaktionshistorik.
|
||||
* Prognoser märks ALLTID som prognoser i UI.
|
||||
*/
|
||||
|
||||
export interface ConsumptionEvent {
|
||||
/** ISO-datum */
|
||||
date: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface ForecastResult {
|
||||
/** Genomsnittlig förbrukning per dag (i postens enhet). */
|
||||
dailyRate: number;
|
||||
/** Dagar tills nuvarande saldo beräknas ta slut. */
|
||||
daysUntilEmpty: number | null;
|
||||
/** Föreslaget inköpsdatum (2 dagars marginal). */
|
||||
suggestedRestockDate: string | null;
|
||||
confidence: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
export function forecastDepletion(
|
||||
currentBalance: number,
|
||||
consumption: ConsumptionEvent[],
|
||||
today: Date = new Date(),
|
||||
): ForecastResult {
|
||||
if (consumption.length < 2) {
|
||||
return { dailyRate: 0, daysUntilEmpty: null, suggestedRestockDate: null, confidence: "low" };
|
||||
}
|
||||
const sorted = [...consumption].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const first = Date.parse(sorted[0]!.date);
|
||||
const last = Date.parse(sorted[sorted.length - 1]!.date);
|
||||
const spanDays = Math.max(1, (last - first) / 86_400_000);
|
||||
const total = sorted.reduce((sum, e) => sum + Math.abs(e.quantity), 0);
|
||||
const dailyRate = total / spanDays;
|
||||
|
||||
if (dailyRate <= 0) {
|
||||
return { dailyRate: 0, daysUntilEmpty: null, suggestedRestockDate: null, confidence: "low" };
|
||||
}
|
||||
|
||||
const daysUntilEmpty = currentBalance / dailyRate;
|
||||
const restock = new Date(today.getTime() + Math.max(0, daysUntilEmpty - 2) * 86_400_000);
|
||||
const confidence = consumption.length >= 6 ? "high" : consumption.length >= 4 ? "medium" : "low";
|
||||
|
||||
return {
|
||||
dailyRate: Math.round(dailyRate * 1000) / 1000,
|
||||
daysUntilEmpty: Math.round(daysUntilEmpty * 10) / 10,
|
||||
suggestedRestockDate: restock.toISOString().slice(0, 10),
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./expiry.js";
|
||||
export * from "./balance.js";
|
||||
export * from "./fefo.js";
|
||||
export * from "./dedup.js";
|
||||
export * from "./forecast.js";
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allocateFefo,
|
||||
classifyExpiry,
|
||||
computeBalance,
|
||||
findDuplicateCandidates,
|
||||
forecastDepletion,
|
||||
normalizeDelta,
|
||||
} from "../src/index.js";
|
||||
|
||||
const TODAY = new Date("2026-08-02T00:00:00Z"); // UTC – testet ska vara sant i alla tidszoner
|
||||
|
||||
describe("bäst före-klassning (spec §13)", () => {
|
||||
it("sista förbrukningsdag passerad → expired (hård gräns)", () => {
|
||||
const result = classifyExpiry({ useByDate: "2026-08-01" }, TODAY);
|
||||
expect(result.status).toBe("expired");
|
||||
expect(result.limitingFactor).toBe("use_by");
|
||||
});
|
||||
it("bäst före passerad → expiring + pastBestBefore, ALDRIG auto-expired", () => {
|
||||
const result = classifyExpiry({ bestBeforeDate: "2026-07-30" }, TODAY);
|
||||
expect(result.status).toBe("expiring");
|
||||
expect(result.pastBestBefore).toBe(true);
|
||||
});
|
||||
it("trösklar: ≤2 dagar expiring, ≤5 use_soon, annars fresh", () => {
|
||||
expect(classifyExpiry({ bestBeforeDate: "2026-08-04" }, TODAY).status).toBe("expiring");
|
||||
expect(classifyExpiry({ bestBeforeDate: "2026-08-06" }, TODAY).status).toBe("use_soon");
|
||||
expect(classifyExpiry({ bestBeforeDate: "2026-08-20" }, TODAY).status).toBe("fresh");
|
||||
});
|
||||
it("fryst vara pausar klockan", () => {
|
||||
const result = classifyExpiry({ bestBeforeDate: "2026-07-01", frozenAt: "2026-06-20" }, TODAY);
|
||||
expect(result.status).toBe("fresh");
|
||||
});
|
||||
it("upptinad vara räknas igen", () => {
|
||||
const result = classifyExpiry(
|
||||
{ bestBeforeDate: "2026-07-01", frozenAt: "2026-06-20", thawedAt: "2026-08-01" },
|
||||
TODAY,
|
||||
);
|
||||
expect(result.status).not.toBe("fresh");
|
||||
});
|
||||
it("öppnad vara: riktvärde per plats kan vara mest bindande", () => {
|
||||
const result = classifyExpiry(
|
||||
{
|
||||
bestBeforeDate: "2026-09-01",
|
||||
openedAt: "2026-07-30",
|
||||
storageLocationType: "fridge",
|
||||
shelfLifeGuidance: { fridge: 5 },
|
||||
},
|
||||
TODAY,
|
||||
);
|
||||
expect(result.limitingFactor).toBe("opened_guidance");
|
||||
expect(result.daysLeft).toBe(2);
|
||||
expect(result.status).toBe("expiring");
|
||||
});
|
||||
it("ingen data → unknown, aldrig gissning", () => {
|
||||
expect(classifyExpiry({}, TODAY).status).toBe("unknown");
|
||||
});
|
||||
it("mjölkprincipen: gick ut igår → använd sinnena, inte soptunnan", () => {
|
||||
// Bäst före är en KVALITETSgräns: mjölken som gick ut igår är inte
|
||||
// automatiskt dålig – användaren ska lukta/smaka. Appen får aldrig
|
||||
// klassa den som "expired" eller föreslå att den slängs.
|
||||
const result = classifyExpiry({ bestBeforeDate: "2026-08-01" }, TODAY);
|
||||
expect(result.status).toBe("expiring"); // synlig och prioriterad – inte dömd
|
||||
expect(result.pastBestBefore).toBe(true); // UI: "lukta och smaka"
|
||||
expect(result.daysLeft).toBe(-1);
|
||||
expect(result.status).not.toBe("expired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaktionssaldo (spec §8)", () => {
|
||||
it("summerar spec-exemplet korrekt", () => {
|
||||
const result = computeBalance([
|
||||
{ type: "purchase", quantityDelta: 1000, unit: "GRAM" },
|
||||
{ type: "cook_use", quantityDelta: -300, unit: "GRAM" },
|
||||
{ type: "discard", quantityDelta: -150, unit: "GRAM" },
|
||||
]);
|
||||
expect(result.balance).toBe(550);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
it("flaggar negativt saldo som inkonsistens", () => {
|
||||
const result = computeBalance([
|
||||
{ type: "purchase", quantityDelta: 100, unit: "GRAM" },
|
||||
{ type: "consume", quantityDelta: -200, unit: "GRAM" },
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.balance).toBe(0);
|
||||
});
|
||||
it("normaliserar tecken per transaktionstyp", () => {
|
||||
expect(normalizeDelta("consume", 300)).toBe(-300);
|
||||
expect(normalizeDelta("purchase", -300)).toBe(300);
|
||||
expect(normalizeDelta("adjust", -50)).toBe(-50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FEFO-allokering (spec §17)", () => {
|
||||
it("plockar från den som går ut först", () => {
|
||||
const result = allocateFefo(
|
||||
400,
|
||||
"GRAM",
|
||||
[
|
||||
{ id: "fresh", quantity: 500, unit: "GRAM", bestBeforeDate: "2026-08-20" },
|
||||
{ id: "old", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-03" },
|
||||
],
|
||||
{},
|
||||
TODAY,
|
||||
);
|
||||
expect(result.covered).toBe(true);
|
||||
expect(result.allocations[0]?.itemId).toBe("old");
|
||||
expect(result.allocations[0]?.quantity).toBe(300);
|
||||
expect(result.allocations[1]?.itemId).toBe("fresh");
|
||||
expect(result.allocations[1]?.quantity).toBe(100);
|
||||
});
|
||||
it("rapporterar shortfall ärligt", () => {
|
||||
const result = allocateFefo(
|
||||
1000,
|
||||
"GRAM",
|
||||
[{ id: "a", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-10" }],
|
||||
{},
|
||||
TODAY,
|
||||
);
|
||||
expect(result.covered).toBe(false);
|
||||
expect(result.shortfall).toBe(700);
|
||||
});
|
||||
it("mjölkprincipen: passerat bäst före utesluts ALDRIG – räddas först", () => {
|
||||
const result = allocateFefo(
|
||||
400,
|
||||
"GRAM",
|
||||
[
|
||||
{ id: "fresh", quantity: 500, unit: "GRAM", bestBeforeDate: "2026-08-20" },
|
||||
{ id: "past", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-01" }, // gick ut igår
|
||||
],
|
||||
{},
|
||||
TODAY,
|
||||
);
|
||||
// Varan som passerat bäst före plockas FÖRST (rädda den), inte bort.
|
||||
expect(result.covered).toBe(true);
|
||||
expect(result.allocations[0]?.itemId).toBe("past");
|
||||
expect(result.allocations[0]?.quantity).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dubblettkandidater (spec §9)", () => {
|
||||
it("hittar kvitto+bild-dubblett inom fönstret", () => {
|
||||
const candidates = findDuplicateCandidates(
|
||||
{
|
||||
canonicalIngredientId: "milk_3",
|
||||
displayName: "Mjölk 3%",
|
||||
quantity: 1,
|
||||
source: "receipt",
|
||||
createdAt: "2026-08-02T10:00:00Z",
|
||||
},
|
||||
[
|
||||
{
|
||||
id: "existing",
|
||||
canonicalIngredientId: "milk_3",
|
||||
displayName: "mjölk",
|
||||
quantity: 1,
|
||||
source: "fridge_photo",
|
||||
createdAt: "2026-08-01T18:00:00Z",
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0]?.score).toBeGreaterThanOrEqual(0.7);
|
||||
});
|
||||
it("matchar inte helt olika varor", () => {
|
||||
const candidates = findDuplicateCandidates(
|
||||
{
|
||||
canonicalIngredientId: "milk_3",
|
||||
displayName: "Mjölk",
|
||||
quantity: 1,
|
||||
source: "receipt",
|
||||
createdAt: "2026-08-02T10:00:00Z",
|
||||
},
|
||||
[
|
||||
{
|
||||
id: "x",
|
||||
canonicalIngredientId: "chicken_breast",
|
||||
displayName: "Kyckling",
|
||||
quantity: 500,
|
||||
source: "barcode",
|
||||
createdAt: "2026-08-02T09:00:00Z",
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(candidates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pantry forecast (spec §40)", () => {
|
||||
it("beräknar förbrukningstakt och påfyllnadsdatum", () => {
|
||||
const result = forecastDepletion(
|
||||
1,
|
||||
[
|
||||
{ date: "2026-07-20", quantity: 1 },
|
||||
{ date: "2026-07-26", quantity: 1 },
|
||||
{ date: "2026-08-01", quantity: 1 },
|
||||
],
|
||||
TODAY,
|
||||
);
|
||||
expect(result.dailyRate).toBeCloseTo(0.25, 2);
|
||||
expect(result.daysUntilEmpty).toBeCloseTo(4, 0);
|
||||
expect(result.suggestedRestockDate).toBe("2026-08-04");
|
||||
});
|
||||
it("låg konfidens vid för lite data", () => {
|
||||
expect(forecastDepletion(1, [{ date: "2026-08-01", quantity: 1 }], TODAY).confidence).toBe(
|
||||
"low",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user