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
+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";