143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
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);
|
|
}
|