Files
Cibello-app/apps/worker/src/processors/weekplan.ts
T
2026-08-05 19:21:11 +07:00

305 lines
9.8 KiB
TypeScript

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<void> {
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<string, IngredientSafetyInfo>(
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<string>();
const repeatLimit = data.input.varietyLevel === "low" ? 2 : 1;
const recipeUseCount = new Map<string, number>();
let boxIndex = 0;
let candidateIndex = 0;
const entries: Array<typeof schema.weekPlanEntries.$inferInsert> = [];
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<number> {
const [row] = await ctx.db
.select({ total: sql<number>`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)));
}