import type { FastifyInstance } from "fastify"; import { and, eq, gte, inArray, sql } from "drizzle-orm"; import { schema } from "@app/database"; import { z } from "zod"; import { buildReconciliationCandidates, classifyExpiry, computeTrust } from "@app/inventory-engine"; import { errors, parse } from "../lib/errors.js"; import { getActiveDecayProfile, requireActiveHousehold, requireMembership, trackProductAnalytics, } from "../lib/helpers.js"; import { reconciliationResolveInputSchema, reconciliationStartInputSchema } from "@app/validation"; import { inventoryReconciliationCompleted, inventoryReconciliationStarted } from "@app/analytics"; /** Quick Reconciliation (Fas 2 §5.4) */ export async function reconciliationRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.post("/v1/reconciliations/start", auth, async (req) => { const householdId = await requireActiveHousehold(app.db, req.userId); await requireMembership(app.db, householdId, req.userId); const input = parse(reconciliationStartInputSchema, req.body); const decayProfile = await getActiveDecayProfile(app.db); const items = await app.db .select({ item: schema.inventoryItems, locationType: schema.storageLocations.type, locationName: schema.storageLocations.name, shelfLife: schema.canonicalIngredients.shelfLifeGuidance, }) .from(schema.inventoryItems) .innerJoin( schema.storageLocations, eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), ) .leftJoin( schema.canonicalIngredients, eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), ) .where( and( eq(schema.inventoryItems.householdId, householdId), sql`${schema.inventoryItems.depletedAt} IS NULL`, sql`${schema.inventoryItems.quantity} > 0`, ), ); const itemIds = items.map((r) => r.item.id); // Ingredienser i planerade recept närmaste 7 dagarna const plannedRecipeIngredientIds = new Map(); const upcomingEntries = await app.db .select({ recipeId: schema.weekPlanEntries.recipeId, date: schema.weekPlanEntries.date }) .from(schema.weekPlanEntries) .innerJoin(schema.weekPlans, eq(schema.weekPlanEntries.weekPlanId, schema.weekPlans.id)) .where( and( eq(schema.weekPlans.householdId, householdId), sql`${schema.weekPlanEntries.date} >= CURRENT_DATE`, sql`${schema.weekPlanEntries.date} <= CURRENT_DATE + INTERVAL '7 days'`, ), ); if (upcomingEntries.length > 0) { const recipeIds = [ ...new Set(upcomingEntries.map((m) => m.recipeId).filter(Boolean)), ] as string[]; if (recipeIds.length > 0) { const ingredients = await app.db .select({ recipeId: schema.recipeIngredients.recipeId, canonicalId: schema.recipeIngredients.canonicalIngredientId, }) .from(schema.recipeIngredients) .where(inArray(schema.recipeIngredients.recipeId, recipeIds)); for (const ing of ingredients) { if (!ing.canonicalId) continue; const list = plannedRecipeIngredientIds.get(ing.canonicalId) ?? []; if (!list.includes(ing.recipeId)) list.push(ing.recipeId); plannedRecipeIngredientIds.set(ing.canonicalId, list); } } } const priceMinorByItemId = new Map(); const dailyConsumptionRate = new Map(); const daysLeftByItemId = new Map(); for (const r of items) { const expiry = classifyExpiry({ bestBeforeDate: r.item.bestBeforeDate, useByDate: r.item.useByDate, openedAt: r.item.openedAt, frozenAt: r.item.frozenAt, thawedAt: r.item.thawedAt, purchasedAt: r.item.purchasedAt, storageLocationType: r.locationType, shelfLifeGuidance: r.shelfLife, }); daysLeftByItemId.set(r.item.id, expiry.daysLeft); if (r.item.priceMinor != null) { priceMinorByItemId.set(r.item.id, r.item.priceMinor); } // Enkel heuristik: senaste 30 dagarnas genomsnittliga dagliga förbrukning const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000); const txAgg = await app.db .select({ total: sql`COALESCE(SUM(ABS(${schema.inventoryTransactions.quantityDelta})), 0)`, days: sql`GREATEST(1, COUNT(DISTINCT DATE(${schema.inventoryTransactions.createdAt})))`, }) .from(schema.inventoryTransactions) .where( and( eq(schema.inventoryTransactions.inventoryItemId, r.item.id), eq(schema.inventoryTransactions.type, "consume"), sql`${schema.inventoryTransactions.createdAt} >= ${thirtyDaysAgo}`, ), ); const rate = Number(txAgg[0]?.total ?? 0) / Number(txAgg[0]?.days ?? 1); if (rate > 0) dailyConsumptionRate.set(r.item.id, rate); } const mappedItems = items.map((r) => { const trust = computeTrust( { confidence: r.item.confidence, verifiedByUser: r.item.verifiedByUser, lastVerifiedAt: r.item.lastVerifiedAt, quantity: r.item.quantity, updatedAt: r.item.updatedAt, }, new Date(), decayProfile, ); return { id: r.item.id, displayName: r.item.displayName, quantity: r.item.quantity, unit: r.item.unit, locationName: r.locationName, confidence: r.item.confidence, verifiedByUser: r.item.verifiedByUser, lastVerifiedAt: r.item.lastVerifiedAt, updatedAt: r.item.updatedAt, depletedAt: r.item.depletedAt, canonicalIngredientId: r.item.canonicalIngredientId, trustState: trust.state, }; }); const candidates = buildReconciliationCandidates( mappedItems, { plannedRecipeIngredientIds, daysLeftByItemId, dailyConsumptionRate, priceMinorByItemId, }, new Date(), input.maxItems ?? 15, ); await trackProductAnalytics( app.db, req.userId, inventoryReconciliationStarted({ householdId, properties: { candidateCount: candidates.length }, }), ); return { candidates: candidates.map((c) => ({ itemId: c.itemId, displayName: c.displayName, quantity: c.quantity, unit: c.unit, locationName: c.locationName, reasons: c.reasons, suggestedAction: c.suggestedAction, suggestedQuantity: c.suggestedQuantity, })), }; }); app.post("/v1/reconciliations/items/:itemId/resolve", auth, async (req) => { const householdId = await requireActiveHousehold(app.db, req.userId); await requireMembership(app.db, householdId, req.userId); const params = z.object({ itemId: z.uuid() }).parse(req.params); const input = parse(reconciliationResolveInputSchema, req.body); const [item] = await app.db .select() .from(schema.inventoryItems) .where( and( eq(schema.inventoryItems.id, params.itemId), eq(schema.inventoryItems.householdId, householdId), ), ) .limit(1); if (!item) throw errors.notFound("Varan finns inte."); const now = new Date(); const newQuantity = input.quantity; const quantityChange = newQuantity != null ? newQuantity - item.quantity : 0; const update: Partial = { updatedAt: now, }; if (input.action === "exists") { update.verifiedByUser = true; update.lastVerifiedAt = now; update.depletedAt = null; if (newQuantity != null) update.quantity = newQuantity; } else if (input.action === "depleted") { update.quantity = 0; update.depletedAt = now; } else { // uncertain: bara registrera en adjustment om användaren justerat mängd if (newQuantity != null) update.quantity = newQuantity; } const [updated] = await app.db .update(schema.inventoryItems) .set(update) .where(eq(schema.inventoryItems.id, params.itemId)) .returning(); if (!updated) throw errors.internal("Kunde inte uppdatera varan."); if (input.action === "depleted") { await app.db.insert(schema.inventoryTransactions).values({ inventoryItemId: params.itemId, householdId, actorUserId: req.userId, type: "correction", quantityDelta: -item.quantity, unit: item.unit, note: input.note, }); } else if (input.action === "exists" || quantityChange !== 0) { await app.db.insert(schema.inventoryTransactions).values({ inventoryItemId: params.itemId, householdId, actorUserId: req.userId, type: input.action === "exists" ? "correction" : "adjust", quantityDelta: quantityChange, unit: item.unit, note: input.note, }); } await trackProductAnalytics( app.db, req.userId, inventoryReconciliationCompleted({ householdId, properties: { itemId: params.itemId, action: input.action, hadAdjustment: quantityChange !== 0, }, }), ); return { itemId: params.itemId, action: input.action, quantity: updated.quantity, verifiedByUser: updated.verifiedByUser, }; }); }