Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user