import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { schema } from "@app/database"; import { computeCoverage, checkRecipeSafety, isRecipeSafe, type IngredientSafetyInfo, type PantryItem, } from "@app/recipe-engine"; import type { MealType } from "@app/shared-types"; import type { WorkerContext } from "../context.js"; interface WeekPlanJobInput { weekPlanId: string; householdId: string; userId: string; input: { weekStartDate: string; daysToPlann?: number; mealTypes: MealType[]; portionsPerMeal?: number; budgetMinorTotal?: number; preferLeftoversFirst: boolean; varietyLevel: "low" | "medium" | "high"; }; } /** * Veckoplansgenerering (spec §25). Deterministisk kärna: * 1. Matlådor planeras först (spec §24) när preferLeftoversFirst. * 2. Recept väljs på täckning + utgångsdatum + variation + budget. * 3. Samma recept upprepas inte inom planen (styrs av varietyLevel). * AAMOS (GENERATE_WEEK_PLAN) kan förfina ordningen men aldrig bryta reglerna. */ export async function processGenerateWeekPlan( ctx: WorkerContext, data: WeekPlanJobInput, ): Promise { const { weekPlanId, householdId } = data; const [plan] = await ctx.db .select() .from(schema.weekPlans) .where(eq(schema.weekPlans.id, weekPlanId)) .limit(1); if (!plan) return; const days = data.input.daysToPlann ?? 7; const mealTypes = data.input.mealTypes; const portions = data.input.portionsPerMeal ?? (await defaultPortions(ctx, householdId)); // --- Lager & säkerhet --- const stockRows = await ctx.db .select({ item: schema.inventoryItems, locationType: schema.storageLocations.type, shelfLife: schema.canonicalIngredients.shelfLifeGuidance, density: schema.canonicalIngredients.densityGPerMl, gramsPerPiece: schema.canonicalIngredients.gramsPerPiece, }) .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), isNull(schema.inventoryItems.depletedAt), gt(schema.inventoryItems.quantity, 0), ), ); const pantry: PantryItem[] = stockRows.map((r) => ({ id: r.item.id, canonicalIngredientId: r.item.canonicalIngredientId, quantity: r.item.quantity, unit: r.item.unit, 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, })); const unitInfo = new Map( stockRows .filter((r) => r.item.canonicalIngredientId) .map((r) => [ r.item.canonicalIngredientId!, { densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece }, ]), ); const members = await ctx.db .select({ userId: schema.householdMembers.userId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.householdId, householdId)); const prefs = await ctx.db .select() .from(schema.userPreferences) .where( inArray( schema.userPreferences.userId, members.map((m) => m.userId), ), ); const combinedAllergens = [...new Set(prefs.flatMap((p) => p.allergens))]; const combinedAvoid = [...new Set(prefs.flatMap((p) => p.avoidIngredientIds))]; const strictestSpice = prefs.length > 0 ? Math.min(...prefs.map((p) => p.spiceLevelMax)) : 5; // --- Kandidater --- const candidates = await ctx.db .select() .from(schema.recipes) .where(and(eq(schema.recipes.status, "published"))) .limit(300); const allIngredients = await ctx.db .select() .from(schema.recipeIngredients) .where( inArray( schema.recipeIngredients.recipeId, candidates.map((c) => c.id), ), ); const safetyRows = await ctx.db .select() .from(schema.canonicalIngredients) .where( inArray(schema.canonicalIngredients.id, [ ...new Set(allIngredients.map((i) => i.canonicalIngredientId)), ]), ); const safetyMap = new Map( safetyRows.map((r) => [ r.id, { id: r.id, allergens: r.allergens, isVegan: r.isVegan, isVegetarian: r.isVegetarian, containsGluten: r.containsGluten, containsLactose: r.containsLactose, isPork: r.isPork, isBeef: r.isBeef, isAlcohol: r.isAlcohol, }, ]), ); for (const r of safetyRows) { if (!unitInfo.has(r.id)) unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece }); } const scored = candidates .filter((recipe) => { const ings = allIngredients.filter((i) => i.recipeId === recipe.id); const violations = checkRecipeSafety( { ingredients: ings.map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, optional: i.optional, })), spiceLevel: recipe.spiceLevel, }, { allergens: combinedAllergens, avoidIngredientIds: combinedAvoid, spiceLevelMax: strictestSpice, }, safetyMap, ); return isRecipeSafe(violations); }) .map((recipe) => { const ings = allIngredients .filter((i) => i.recipeId === recipe.id) .map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, displayNameSv: i.displayNameSv, quantity: i.quantity, unit: i.unit, optional: i.optional, })); const coverage = computeCoverage(ings, pantry, unitInfo); const expiryBoost = coverage.expiringUsed.length > 0 ? 0.3 : 0; const budgetOk = data.input.budgetMinorTotal == null || recipe.estimatedCostMinorPerPortion == null || recipe.estimatedCostMinorPerPortion * portions <= (data.input.budgetMinorTotal / (days * mealTypes.length)) * 1.5; return { recipe, coverage, score: coverage.coverage + expiryBoost + (budgetOk ? 0 : -0.5), }; }) .sort((a, b) => b.score - a.score); // --- Matlådor först (spec §24) --- const mealBoxes = data.input.preferLeftoversFirst ? await ctx.db .select() .from(schema.mealBoxes) .where( and( eq(schema.mealBoxes.householdId, householdId), eq(schema.mealBoxes.status, "available"), ), ) .orderBy(schema.mealBoxes.recommendedUseBy) : []; // --- Bygg planen --- const usedRecipeIds = new Set(); const repeatLimit = data.input.varietyLevel === "low" ? 2 : 1; const recipeUseCount = new Map(); let boxIndex = 0; let candidateIndex = 0; const entries: Array = []; for (let day = 0; day < days; day++) { const date = new Date(Date.parse(data.input.weekStartDate) + day * 86_400_000) .toISOString() .slice(0, 10); for (const mealType of mealTypes) { // Matlåda om det finns och portionerna räcker const box = mealBoxes[boxIndex]; if (box && box.portionsRemaining >= Math.min(portions, 2)) { entries.push({ weekPlanId, date, mealType, mealBoxId: box.id, titleSv: `${box.titleSv} (matlåda)`, portions: Math.min(box.portionsRemaining, portions), status: "planned", rescheduleReasonSv: `Matlådan bör användas senast ${box.recommendedUseBy}.`, sortOrder: entries.length, }); boxIndex++; continue; } // Nästa bästa recept som passar måltidstyp och variationsregeln let chosen = null; for (let i = 0; i < scored.length; i++) { const idx = (candidateIndex + i) % scored.length; const candidate = scored[idx]!; if (!candidate.recipe.mealTypes.includes(mealType)) continue; const used = recipeUseCount.get(candidate.recipe.id) ?? 0; if (used >= repeatLimit) continue; chosen = candidate; candidateIndex = idx + 1; break; } if (!chosen) continue; recipeUseCount.set(chosen.recipe.id, (recipeUseCount.get(chosen.recipe.id) ?? 0) + 1); usedRecipeIds.add(chosen.recipe.id); const expiring = chosen.coverage.expiringUsed[0]; entries.push({ weekPlanId, date, mealType, recipeId: chosen.recipe.id, titleSv: chosen.recipe.titleSv, portions, status: "planned", rescheduleReasonSv: expiring ? `Använder ${expiring.displayNameSv.toLowerCase()} som bör ätas snart.` : null, sortOrder: entries.length, }); } } await ctx.db .delete(schema.weekPlanEntries) .where(eq(schema.weekPlanEntries.weekPlanId, weekPlanId)); if (entries.length > 0) await ctx.db.insert(schema.weekPlanEntries).values(entries); await ctx.db .update(schema.weekPlans) .set({ status: "draft", updatedAt: new Date() }) .where(eq(schema.weekPlans.id, weekPlanId)); await ctx.db.insert(schema.domainEvents).values({ type: "WEEK_PLAN_UPDATED", userId: data.userId, householdId, payload: { weekPlanId, reason: "generated" }, }); } async function defaultPortions(ctx: WorkerContext, householdId: string): Promise { const [row] = await ctx.db .select({ total: sql`coalesce(sum(${schema.householdMembers.portionFactor}), 2)` }) .from(schema.householdMembers) .where(eq(schema.householdMembers.householdId, householdId)); return Math.max(1, Math.round(Number(row?.total ?? 2))); }