Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+18
View File
@@ -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:*"
}
}
+64
View File
@@ -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;
}
+109
View File
@@ -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;
}
+127
View File
@@ -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);
}
+67
View File
@@ -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 §1718: 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;
}
+52
View File
@@ -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,
};
}
+5
View File
@@ -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",
);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}