Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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)));
|
||||
}
|
||||
Reference in New Issue
Block a user