Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 01:02:08 +07:00
parent 4385fe5125
commit 218bc44d08
24 changed files with 1069 additions and 17 deletions
+1
View File
@@ -4,3 +4,4 @@ export * from "./fefo.js";
export * from "./dedup.js";
export * from "./forecast.js";
export * from "./trust.js";
export * from "./reconciliation.js";
@@ -0,0 +1,142 @@
import { type InventoryItemLike, itemTrustState, TRUST_STATUS_THRESHOLDS } from "./trust.js";
export interface ReconciliationCandidate {
itemId: string;
displayName: string;
quantity: number;
unit: string;
locationName: string;
/** Prioritet: lägre = viktigare. */
priority: number;
reasons: ReconciliationReason[];
/** Förslag baserat på planerade recept, förbrukningstakt etc. */
suggestedAction: "exists" | "depleted" | "uncertain";
suggestedQuantity?: number;
}
export type ReconciliationReason =
| { kind: "planned_recipe"; recipeIds: string[] }
| { kind: "expiring_soon"; daysLeft: number | null }
| { kind: "high_value"; priceMinor: number }
| { kind: "low_confidence"; confidence: number }
| { kind: "stale_trust"; trustState: string }
| { kind: "likely_depleted"; remainingDays: number };
export interface ReconciliationContext {
/** Ingredienser som behövs i planerade recept under närmaste 7 dagarna. */
plannedRecipeIngredientIds: Map<string, string[]>;
/** Utgångsdatum per vara-id. */
daysLeftByItemId: Map<string, number | null>;
/** Estimerad förbrukning per dygn per vara (enkel heuristik). */
dailyConsumptionRate: Map<string, number>;
/** Pris i minor units per vara. */
priceMinorByItemId: Map<string, number>;
}
const MS_PER_DAY = 86_400_000;
/**
* Bygg en prioriterad lista över varor som behöver en snabb avstämning.
*
* Prioritet (lägre = viktigare, §5.4):
* 1. Behövs i planerade recept (inom 7 dagar)
* 2. Snart utgår
* 3. Högt ekonomiskt värde
* 4. Låg confidence / stale trust
* 5. Borde vara slut enligt förbrukningstakt
*
* Deterministisk, UTC, alltid samma svar för samma input.
*/
export function buildReconciliationCandidates(
items: Array<InventoryItemLike & { id: string; displayName: string; unit: string; locationName: string }>,
context: ReconciliationContext,
now: Date = new Date(),
maxItems = 15,
): ReconciliationCandidate[] {
const candidates: ReconciliationCandidate[] = [];
for (const item of items) {
if ((item.quantity <= 0 && item.depletedAt) || item.depletedAt) continue;
const reasons: ReconciliationReason[] = [];
let priorityPenalty = 0;
// 1. Planerade recept
const recipeIds = context.plannedRecipeIngredientIds.get(item.id) ?? [];
if (recipeIds.length > 0) {
reasons.push({ kind: "planned_recipe", recipeIds });
priorityPenalty += 10_000;
}
// 2. Snart utgår
const daysLeft = context.daysLeftByItemId.get(item.id) ?? null;
if (daysLeft != null && daysLeft <= 7) {
reasons.push({ kind: "expiring_soon", daysLeft });
priorityPenalty += 8_000 - Math.min(7, Math.max(0, daysLeft)) * 1_000;
}
// 3. Ekonomiskt värde
const priceMinor = context.priceMinorByItemId.get(item.id) ?? 0;
if (priceMinor > 0) {
reasons.push({ kind: "high_value", priceMinor });
priorityPenalty += Math.min(5_000, Math.floor(priceMinor / 10));
}
// 4. Låg confidence / stale trust
if (item.confidence < 0.6) {
reasons.push({ kind: "low_confidence", confidence: item.confidence });
priorityPenalty += 3_000;
}
const trust = itemTrustState(item, now, undefined);
if (trust === "stale" || trust === "decaying") {
reasons.push({ kind: "stale_trust", trustState: trust });
priorityPenalty += trust === "stale" ? 4_000 : 2_000;
}
// 5. Borde vara slut enligt förbrukningstakt
const dailyRate = context.dailyConsumptionRate.get(item.id) ?? 0;
if (dailyRate > 0 && item.quantity > 0) {
const remainingDays = item.quantity / dailyRate;
if (remainingDays < 3) {
reasons.push({ kind: "likely_depleted", remainingDays });
priorityPenalty += 2_500 - Math.min(2, Math.floor(remainingDays)) * 800;
}
}
if (reasons.length === 0) continue;
// Förslag
let suggestedAction: ReconciliationCandidate["suggestedAction"] = "uncertain";
let suggestedQuantity: number | undefined;
if (dailyRate > 0 && item.quantity > 0) {
const remainingDays = item.quantity / dailyRate;
if (remainingDays < 0.5) {
suggestedAction = "depleted";
} else {
suggestedQuantity = Math.max(0, Math.round(item.quantity * 10) / 10);
suggestedAction = "exists";
}
} else if (trust === "trusted") {
suggestedAction = "exists";
}
candidates.push({
itemId: item.id,
displayName: item.displayName,
quantity: item.quantity,
unit: item.unit,
locationName: item.locationName,
priority: -priorityPenalty,
reasons,
suggestedAction,
suggestedQuantity,
});
}
candidates.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.displayName.localeCompare(b.displayName, "sv");
});
return candidates.slice(0, maxItems);
}