Initial commit (unpacked platform)
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user