import type { FastifyInstance } from "fastify"; import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { schema } from "@app/database"; import type { StoreSection } from "@app/shared-types"; import { addShoppingItemInputSchema, completeShoppingInputSchema, createShoppingListInputSchema, idParamSchema, updateShoppingItemInputSchema, } from "@app/validation"; import { convert } from "@app/nutrition-engine"; import { errors, parse } from "../lib/errors.js"; import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js"; /** * Inköpslista (spec §27): dra av lager, slå ihop ingredienser, sortera per * butiksavdelning, dela i hushållet, uppdatera lagret efter köp. */ const CATEGORY_TO_SECTION: Record = { mejeri: "mejeri", kott_fagel: "kott_fagel", fisk: "fisk", gronsaker: "frukt_gront", frukt: "frukt_gront", spannmal: "skafferi", baljvaxter: "skafferi", skafferi: "skafferi", konserver: "konserver", kryddor: "kryddor_bak", brod: "brod", }; export async function shoppingRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.get("/v1/shopping-lists", auth, async (req) => { const householdId = await requireActiveHousehold(app.db, req.userId); const lists = await app.db .select() .from(schema.shoppingLists) .where( and( eq(schema.shoppingLists.householdId, householdId), eq(schema.shoppingLists.status, "active"), ), ); return { lists }; }); app.post("/v1/shopping-lists", auth, async (req, reply) => { const input = parse(createShoppingListInputSchema, req.body); const householdId = await requireActiveHousehold(app.db, req.userId); const [list] = await app.db .insert(schema.shoppingLists) .values({ householdId, name: input.name, weekPlanId: input.weekPlanId ?? null }) .returning(); // Generera från veckoplan: receptbehov − befintligt lager (spec §27) if (input.generateFromPlan && input.weekPlanId) { await generateItemsFromPlan(app, list!.id, input.weekPlanId, householdId, req.userId); } const items = await app.db .select() .from(schema.shoppingListItems) .where(eq(schema.shoppingListItems.shoppingListId, list!.id)); return reply.status(201).send({ list, items }); }); app.get("/v1/shopping-lists/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const list = await getOwnedList(app, id, req.userId); const items = await app.db .select() .from(schema.shoppingListItems) .where(eq(schema.shoppingListItems.shoppingListId, id)) .orderBy(schema.shoppingListItems.storeSection, schema.shoppingListItems.sortOrder); const estimatedTotal = items.reduce((sum, i) => sum + (i.estimatedPriceMinor ?? 0), 0); // Prisuppskattningar härleds ur katalogens baspriser (SEK) tills per-marknads-priser (M8). return { list, items, estimatedTotalMinor: Math.round(estimatedTotal), currency: "SEK" }; }); app.post("/v1/shopping-lists/:id/items", auth, async (req, reply) => { const { id } = parse(idParamSchema, req.params); await getOwnedList(app, id, req.userId); const input = parse(addShoppingItemInputSchema, req.body); let section = input.storeSection; let estimatedPrice = input.estimatedPriceMinor; if (input.canonicalIngredientId) { const [ing] = await app.db .select() .from(schema.canonicalIngredients) .where(eq(schema.canonicalIngredients.id, input.canonicalIngredientId)) .limit(1); if (ing) { section = section ?? CATEGORY_TO_SECTION[ing.category] ?? "hygien_ovrigt"; if (estimatedPrice == null && ing.defaultPriceMinorPerKg != null) { const grams = convert(input.quantity, input.unit, "GRAM", { densityGPerMl: ing.densityGPerMl, gramsPerPiece: ing.gramsPerPiece, }); if (grams != null) estimatedPrice = Math.round((grams / 1000) * ing.defaultPriceMinorPerKg * 10) / 10; } } } // Slå ihop med befintlig rad för samma ingrediens (spec §27) if (input.canonicalIngredientId) { const [existing] = await app.db .select() .from(schema.shoppingListItems) .where( and( eq(schema.shoppingListItems.shoppingListId, id), eq(schema.shoppingListItems.canonicalIngredientId, input.canonicalIngredientId), eq(schema.shoppingListItems.unit, input.unit), eq(schema.shoppingListItems.checked, false), ), ) .limit(1); if (existing) { const [merged] = await app.db .update(schema.shoppingListItems) .set({ quantity: existing.quantity + input.quantity, estimatedPriceMinor: existing.estimatedPriceMinor != null && estimatedPrice != null ? existing.estimatedPriceMinor + estimatedPrice : (existing.estimatedPriceMinor ?? estimatedPrice ?? null), }) .where(eq(schema.shoppingListItems.id, existing.id)) .returning(); return reply.send({ item: merged, merged: true }); } } const [item] = await app.db .insert(schema.shoppingListItems) .values({ shoppingListId: id, canonicalIngredientId: input.canonicalIngredientId ?? null, displayName: input.displayName, quantity: input.quantity, unit: input.unit, storeSection: section ?? "hygien_ovrigt", estimatedPriceMinor: estimatedPrice ?? null, addedByUserId: req.userId, origin: "manual", }) .returning(); return reply.status(201).send({ item, merged: false }); }); app.patch("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => { const params = req.params as { id: string; itemId: string }; await getOwnedList(app, params.id, req.userId); const input = parse(updateShoppingItemInputSchema, req.body); const [item] = await app.db .update(schema.shoppingListItems) .set(input) .where( and( eq(schema.shoppingListItems.id, params.itemId), eq(schema.shoppingListItems.shoppingListId, params.id), ), ) .returning(); if (!item) throw errors.notFound(); return item; }); app.delete("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => { const params = req.params as { id: string; itemId: string }; await getOwnedList(app, params.id, req.userId); await app.db .delete(schema.shoppingListItems) .where( and( eq(schema.shoppingListItems.id, params.itemId), eq(schema.shoppingListItems.shoppingListId, params.id), ), ); return { ok: true }; }); /** Avsluta köprundan: bockade varor in i lagret (spec §27). */ app.post("/v1/shopping-lists/:id/complete", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const list = await getOwnedList(app, id, req.userId); const input = parse(completeShoppingInputSchema, req.body); const items = await app.db .select() .from(schema.shoppingListItems) .where( and( eq(schema.shoppingListItems.shoppingListId, id), eq(schema.shoppingListItems.checked, true), ), ); let added = 0; if (input.addToInventory && items.length > 0) { const overrides = new Map(input.storageDefaults.map((s) => [s.shoppingListItemId, s])); const fallback = input.defaultStorageLocationId ?? ( await app.db .select({ id: schema.storageLocations.id }) .from(schema.storageLocations) .where( and( eq(schema.storageLocations.householdId, list.householdId), eq(schema.storageLocations.type, "fridge"), ), ) .limit(1) )[0]?.id; if (!fallback) throw errors.badRequest("Ingen standardplats (kyl) hittades i hushållet."); for (const item of items) { const override = overrides.get(item.id); const [inv] = await app.db .insert(schema.inventoryItems) .values({ householdId: list.householdId, canonicalIngredientId: item.canonicalIngredientId, displayName: item.displayName, quantity: item.quantity, unit: item.unit, storageLocationId: override?.storageLocationId ?? fallback, purchasedAt: todayIso(), bestBeforeDate: override?.bestBeforeDate ?? null, priceMinor: override?.priceMinor ?? item.estimatedPriceMinor, source: "manual_search", confidence: 1, verifiedByUser: true, lastVerifiedAt: new Date(), }) .returning(); await app.db.insert(schema.inventoryTransactions).values({ householdId: list.householdId, inventoryItemId: inv!.id, type: "purchase", quantityDelta: item.quantity, unit: item.unit, refType: "shopping", refId: id, actorUserId: req.userId, valueMinor: override?.priceMinor ?? item.estimatedPriceMinor, }); added += 1; } } await app.db .update(schema.shoppingLists) .set({ status: "completed", updatedAt: new Date() }) .where(eq(schema.shoppingLists.id, id)); await emitEvent(app.db, { type: "SHOPPING_COMPLETED", payload: { shoppingListId: id, itemsAdded: added }, userId: req.userId, householdId: list.householdId, correlationId: req.correlationId, }); return { ok: true, itemsAddedToInventory: added }; }); } async function getOwnedList(app: FastifyInstance, listId: string, userId: string) { const [list] = await app.db .select() .from(schema.shoppingLists) .where(eq(schema.shoppingLists.id, listId)) .limit(1); if (!list) throw errors.notFound("Listan finns inte."); await requireMembership(app.db, list.householdId, userId); return list; } /** Aggregera receptbehov från plan, dra av befintligt lager, skapa rader. */ async function generateItemsFromPlan( app: FastifyInstance, listId: string, weekPlanId: string, householdId: string, userId: string, ) { const entries = await app.db .select() .from(schema.weekPlanEntries) .where( and( eq(schema.weekPlanEntries.weekPlanId, weekPlanId), eq(schema.weekPlanEntries.status, "planned"), ), ); const recipeIds = [...new Set(entries.filter((e) => e.recipeId).map((e) => e.recipeId!))]; if (recipeIds.length === 0) return; const allIngredients = await app.db .select() .from(schema.recipeIngredients) .where(inArray(schema.recipeIngredients.recipeId, recipeIds)); const recipes = await app.db .select({ id: schema.recipes.id, portions: schema.recipes.portions }) .from(schema.recipes) .where(inArray(schema.recipes.id, recipeIds)); const portionsMap = new Map(recipes.map((r) => [r.id, r.portions])); // Aggregera behov per ingrediens (i gram där möjligt) const needs = new Map(); const infoRows = await app.db .select() .from(schema.canonicalIngredients) .where( inArray(schema.canonicalIngredients.id, [ ...new Set(allIngredients.map((i) => i.canonicalIngredientId)), ]), ); const infoMap = new Map(infoRows.map((r) => [r.id, r])); for (const entry of entries) { if (!entry.recipeId) continue; const basePortions = portionsMap.get(entry.recipeId) ?? 4; const factor = entry.portions / basePortions; for (const ing of allIngredients.filter((i) => i.recipeId === entry.recipeId && !i.optional)) { const info = infoMap.get(ing.canonicalIngredientId); const grams = convert(ing.quantity * factor, ing.unit, "GRAM", { densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece, }); if (grams == null) continue; const current = needs.get(ing.canonicalIngredientId) ?? { name: ing.displayNameSv, grams: 0 }; current.grams += grams; needs.set(ing.canonicalIngredientId, current); } } // Dra av lager const stock = await app.db .select() .from(schema.inventoryItems) .where( and( eq(schema.inventoryItems.householdId, householdId), isNull(schema.inventoryItems.depletedAt), gt(schema.inventoryItems.quantity, 0), inArray(schema.inventoryItems.canonicalIngredientId, [...needs.keys()]), ), ); for (const item of stock) { if (!item.canonicalIngredientId) continue; const need = needs.get(item.canonicalIngredientId); if (!need) continue; const info = infoMap.get(item.canonicalIngredientId); const grams = convert(item.quantity, item.unit, "GRAM", { densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece, }); if (grams != null) need.grams = Math.max(0, need.grams - grams); } // Skapa rader för det som saknas let sortOrder = 0; for (const [ingredientId, need] of needs) { if (need.grams < 5) continue; const info = infoMap.get(ingredientId); const section = info ? (CATEGORY_TO_SECTION[info.category] ?? "hygien_ovrigt") : "hygien_ovrigt"; // Konvertera tillbaka till naturlig enhet const targetUnit = info?.defaultUnit ?? "GRAM"; const qty = convert(need.grams, "GRAM", targetUnit, { densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece, }) ?? need.grams; const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10; const estimatedPrice = info?.defaultPriceMinorPerKg != null ? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg * 10) / 10 : null; await app.db.insert(schema.shoppingListItems).values({ shoppingListId: listId, canonicalIngredientId: ingredientId, displayName: need.name, quantity: rounded, unit: targetUnit, storeSection: section, estimatedPriceMinor: estimatedPrice, addedByUserId: userId, origin: "plan", sortOrder: sortOrder++, }); } }