feat(cooking): endpoint GET /cooking-session (plan de cuisine optimise)
Module cooking-session : charge le Planning couvrant ?date= (meme requete "plage couvrante" + degradation "jamais null" que /shopping-list), mappe chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/ unites/techniques/ustensiles resolus via toIngredientView/toUnitView reutilisees de recipe.service), renvoie OptimizedCookingPlanView. - cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude. - Route requireAuth, contrat ?date= identique a /shopping-list. - Monte /cooking-session dans app.ts. - Tests d'integration Mocha (401, date invalide, plan vide sans foyer / sans planning, mutualisation d'une decoupe entre 2 recettes planifiees). - specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO -> v1 implementee ; nouvelle section dans backend-architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
83a12d474d
commit
6b60c11408
6 changed files with 474 additions and 12 deletions
|
|
@ -6,6 +6,7 @@ import { env } from "./config/env.js";
|
||||||
import { errorLogger } from "./middlewares/error-logger.js";
|
import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
|
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
|
|
@ -52,6 +53,7 @@ export function createServer(): ExpressServer {
|
||||||
server.mountRouter("/recipes", recipeRouter);
|
server.mountRouter("/recipes", recipeRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
server.mountRouter("/shopping-list", shoppingListRouter);
|
server.mountRouter("/shopping-list", shoppingListRouter);
|
||||||
|
server.mountRouter("/cooking-session", cookingSessionRouter);
|
||||||
server.mountRouter("/sources", sourcesRouter);
|
server.mountRouter("/sources", sourcesRouter);
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
|
import { getCookingPlanForDate } from "./cooking-session.service.js";
|
||||||
|
|
||||||
|
/** Router mounted at `/cooking-session` in app.ts. */
|
||||||
|
export const cookingSessionRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the authenticated user's household's optimized cooking plan for
|
||||||
|
* the week covering `?date=` (`YYYY-MM-DD`) — every recipe planned that
|
||||||
|
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
|
||||||
|
* Always `200`, never `null` — no household or nothing planned that week
|
||||||
|
* both come back as a normal `OptimizedCookingPlanView` with empty
|
||||||
|
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
|
||||||
|
*/
|
||||||
|
cookingSessionRouter.get(
|
||||||
|
"/",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = getCookingSessionSchema.parse(req.query);
|
||||||
|
const date = parseDateOnly(input.date);
|
||||||
|
if (date === null) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
`Not a real calendar date: ${input.date}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
|
||||||
|
res.status(200).json(plan);
|
||||||
|
}),
|
||||||
|
);
|
||||||
168
apps/api/src/modules/cooking-session/cooking-session.service.ts
Normal file
168
apps/api/src/modules/cooking-session/cooking-session.service.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||||
|
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import {
|
||||||
|
type OptimizerRecipeInput,
|
||||||
|
type OptimizerStepInput,
|
||||||
|
optimizeCookingPlan,
|
||||||
|
} from "../../lib/recipe-matching/cooking-optimizer.js";
|
||||||
|
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma `include` for a `Planning` query that needs, for every item, its
|
||||||
|
* recipe's ordered steps with the full detected-technique tree — the raw
|
||||||
|
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
|
||||||
|
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
|
||||||
|
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
|
||||||
|
* as-is; deliberately narrower than a full `RecipeView` fetch (no
|
||||||
|
* diets/favorites/recipe-level ingredient list — the optimizer reads
|
||||||
|
* quantities off the technique clauses, not the recipe header).
|
||||||
|
*/
|
||||||
|
function cookingSessionPlanningInclude() {
|
||||||
|
return {
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
recipe: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
portions: true,
|
||||||
|
steps: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
techSteps: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
techStep: true,
|
||||||
|
ingredients: {
|
||||||
|
include: {
|
||||||
|
ingredient: {
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
utensils: { include: { utensil: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PlanningInclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanningWithSteps = Prisma.PlanningGetPayload<{
|
||||||
|
include: ReturnType<typeof cookingSessionPlanningInclude>;
|
||||||
|
}>;
|
||||||
|
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
|
||||||
|
* to the optimizer's pure input shape — ingredient/unit/technique/utensil
|
||||||
|
* rows resolved to their reference views here so the optimizer itself never
|
||||||
|
* touches Prisma. `Decimal` quantities become plain numbers (same
|
||||||
|
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
|
||||||
|
* unresolved-unit line keeps `unit: null`.
|
||||||
|
*/
|
||||||
|
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
||||||
|
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
|
||||||
|
stepId: step.id,
|
||||||
|
order: step.order,
|
||||||
|
description: step.description,
|
||||||
|
techSteps: step.techSteps.map((techStep) => {
|
||||||
|
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
|
||||||
|
ingredient: toIngredientView(line.ingredient),
|
||||||
|
quantity: line.quantity === null ? null : Number(line.quantity),
|
||||||
|
unit: line.unit === null ? null : toUnitView(line.unit),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
||||||
|
order: techStep.order,
|
||||||
|
ingredients,
|
||||||
|
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipeId: item.recipe.id,
|
||||||
|
name: item.recipe.name,
|
||||||
|
// The slot's own portion count vs. the recipe's as-written yield — the
|
||||||
|
// optimizer scales technique-clause quantities by the ratio, same
|
||||||
|
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
|
||||||
|
portions: item.portions,
|
||||||
|
recipePortions: item.recipe.portions,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the household's optimized cooking plan for the week covering
|
||||||
|
* `date` — every recipe planned that week, reorganized into ordered phases
|
||||||
|
* that pool shared prep and float passive cooks into the background (see
|
||||||
|
* `cooking-optimizer.ts`). `date` follows the same convention as
|
||||||
|
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
|
||||||
|
* not necessarily a Monday).
|
||||||
|
*
|
||||||
|
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
|
||||||
|
* **never** returns `null` — no household and "no planning covers this week
|
||||||
|
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
|
||||||
|
* {@link OptimizedCookingPlanView} (the week's date range is always
|
||||||
|
* computable from `date` alone).
|
||||||
|
*/
|
||||||
|
export async function getCookingPlanForDate(
|
||||||
|
houseId: number | null,
|
||||||
|
date: DateTime,
|
||||||
|
): Promise<OptimizedCookingPlanView> {
|
||||||
|
try {
|
||||||
|
const weekStart = getWeekStart(toDateOnly(date));
|
||||||
|
const weekFinish = weekStart.plus({ days: 6 });
|
||||||
|
const emptyPlan: OptimizedCookingPlanView = {
|
||||||
|
startDate: weekStart.toJSDate().toISOString(),
|
||||||
|
finishDate: weekFinish.toJSDate().toISOString(),
|
||||||
|
recipes: [],
|
||||||
|
phases: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (houseId === null) {
|
||||||
|
return emptyPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same "covering range" lookup as getShoppingListForDate — see
|
||||||
|
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
|
||||||
|
const dateOnly = toDateOnly(date).toJSDate();
|
||||||
|
const planning = await prisma.planning.findFirst({
|
||||||
|
where: {
|
||||||
|
houseId,
|
||||||
|
startDate: { lte: dateOnly },
|
||||||
|
finishDate: { gte: dateOnly },
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "desc" },
|
||||||
|
include: cookingSessionPlanningInclude(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!planning) {
|
||||||
|
return emptyPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
|
||||||
|
return {
|
||||||
|
startDate: planning.startDate.toISOString(),
|
||||||
|
finishDate: planning.finishDate.toISOString(),
|
||||||
|
recipes,
|
||||||
|
phases,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
||||||
|
// this service layer just isn't allowed a bare `await` per the repo's
|
||||||
|
// async/try-catch convention.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
apps/api/test/cooking-session.test.ts
Normal file
204
apps/api/test/cooking-session.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import type { DateTime } from "@batch-cooking/date-tools";
|
||||||
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../src/app.js";
|
||||||
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
|
||||||
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
||||||
|
function buildSignupPayload(): SignupInput {
|
||||||
|
const firstName = faker.person.firstName();
|
||||||
|
const lastName = faker.person.lastName();
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
|
||||||
|
function isoDate(date: DateTime): string {
|
||||||
|
const iso = date.toISODate();
|
||||||
|
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
|
||||||
|
function today(): string {
|
||||||
|
return isoDate(TEST_REFERENCE_DATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
|
||||||
|
async function ingredientId(key: string): Promise<number> {
|
||||||
|
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
|
||||||
|
}
|
||||||
|
async function unitId(key: string): Promise<number> {
|
||||||
|
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
|
||||||
|
}
|
||||||
|
async function techStepId(key: string): Promise<number> {
|
||||||
|
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Cooking session", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /cooking-session", () => {
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app).get("/cooking-session").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty plan when the profile has no household", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/cooking-session").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.recipes).to.deep.equal([]);
|
||||||
|
expect(res.body.phases).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty plan when no planning covers that week", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
|
||||||
|
const res = await agent.get("/cooking-session").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.phases).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const houseId: number = houseRes.body.id;
|
||||||
|
const authorId: number = houseRes.body.adminId;
|
||||||
|
|
||||||
|
const onionId = await ingredientId("onion");
|
||||||
|
const pieceId = await unitId("piece");
|
||||||
|
const chopId = await techStepId("chop");
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
|
||||||
|
/** A recipe: one pure-prep "chop onion" step, then one simmer step. */
|
||||||
|
async function makeRecipe(name: string, onionQty: number) {
|
||||||
|
return prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
authorId,
|
||||||
|
portions: 4,
|
||||||
|
steps: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
order: 0,
|
||||||
|
description: "Émincer les oignons",
|
||||||
|
techSteps: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
techStepId: chopId,
|
||||||
|
order: 0,
|
||||||
|
ingredients: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
ingredientId: onionId,
|
||||||
|
quantity: onionQty,
|
||||||
|
unitId: pieceId,
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
description: "Faire mijoter",
|
||||||
|
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const soupe = await makeRecipe("Soupe", 2);
|
||||||
|
const tarte = await makeRecipe("Tarte", 3);
|
||||||
|
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||||
|
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "dejeuner",
|
||||||
|
recipeId: soupe.id,
|
||||||
|
portions: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "mardi",
|
||||||
|
meal: "diner",
|
||||||
|
recipeId: tarte.id,
|
||||||
|
portions: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/cooking-session").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
|
||||||
|
"Soupe",
|
||||||
|
"Tarte",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mise = res.body.phases[0];
|
||||||
|
expect(mise.kind).to.equal("mise-en-place");
|
||||||
|
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
|
||||||
|
expect(merged).to.have.length(1);
|
||||||
|
expect(merged[0].technique.key).to.equal("chop");
|
||||||
|
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
|
||||||
|
expect(merged[0].ingredients[0].quantity).to.equal(5);
|
||||||
|
expect(merged[0].sourceRecipes).to.have.length(2);
|
||||||
|
|
||||||
|
// The simmer steps land in a later phase, and one shows as background.
|
||||||
|
const later = res.body.phases.slice(1);
|
||||||
|
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
|
||||||
|
expect(backgrounds.length).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -296,6 +296,57 @@ telles quelles par typage structurel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Cooking session — optimisation des étapes planifiées
|
||||||
|
|
||||||
|
Router `/cooking-session` (`cooking-session.routes.ts`/`.service.ts`),
|
||||||
|
`requireAuth` — un seul endpoint : `GET /cooking-session?date=YYYY-MM-DD` →
|
||||||
|
`getCookingPlanForDate` → `OptimizedCookingPlanView`. Même contrat `?date=`
|
||||||
|
que `GET /shopping-list` (schéma shape-only + `parseDateOnly`), même requête
|
||||||
|
"plage couvrante" que `getPlanningForDate`, et **jamais `null`** de la même
|
||||||
|
façon que la liste de courses : pas de foyer / aucun `Planning` couvrant la
|
||||||
|
semaine ⇒ `recipes: []`, `phases: []`.
|
||||||
|
|
||||||
|
C'est la première brique du module « Calcul batch-cooking »
|
||||||
|
([batch-cooking-architecture.md](./batch-cooking-architecture.md)),
|
||||||
|
jusqu'ici `TODO`. Le service ne fait que **charger + façonner** : sa requête
|
||||||
|
Prisma (`cookingSessionPlanningInclude`) reprend le sous-arbre `steps` de
|
||||||
|
`recipe.service.ts`'s `recipeInclude` (steps ordonnés → `StepTechStep`
|
||||||
|
ordonnés → `techStep` + `ingredients` résolus + `utensils`), puis
|
||||||
|
`toOptimizerRecipe` mappe chaque `PlanningItem` vers l'entrée pure de
|
||||||
|
l'optimiseur (ingrédients/unités/techniques/ustensiles déjà en vues de
|
||||||
|
référence via `toIngredientView`/`toUnitView` réutilisées — même raison que
|
||||||
|
`shopping-list.service.ts`). Un `PlanningItem` = une entrée d'optimiseur,
|
||||||
|
même si deux créneaux pointent la même recette à des portions différentes
|
||||||
|
(deux vraies préparations ; la mutualisation de la découpe les regroupe
|
||||||
|
quand même).
|
||||||
|
|
||||||
|
**`optimizeCookingPlan`** (`lib/recipe-matching/cooking-optimizer.ts`,
|
||||||
|
pure/synchrone — testable sans base, même split que `ingredient-matcher.ts`
|
||||||
|
/ `tech-step-matcher.ts`) réorganise les recettes en **phases ordonnées** :
|
||||||
|
|
||||||
|
- **Mutualisation de la mise en place** : une technique de découpe
|
||||||
|
(`PREP_TECHNIQUES` : `chop`/`peel`/`mince`/`julienne`/…) appliquée au même
|
||||||
|
ingrédient dans une étape *purement prep* (toutes ses techniques sont des
|
||||||
|
`PREP_TECHNIQUES`) de **≥ 2 recettes** est regroupée en une tâche
|
||||||
|
`merged-prep` ; les étapes d'origine sont *absorbées* (ne produisent plus
|
||||||
|
de tâche). Une découpe présente dans une seule recette reste inline (juste
|
||||||
|
classée en mise en place). Les découpes à l'intérieur d'une étape de
|
||||||
|
cuisson ne sont pas mutualisées en v1.
|
||||||
|
- **Parallélisme** : `TECHNIQUE_ATTENTION` classe chaque étape en `SETUP`
|
||||||
|
(préchauffage, eau à ébullition — poussé en mise en place), `PASSIVE`
|
||||||
|
(mijoter, braiser, cuire au four, mariner… — non surveillé une fois
|
||||||
|
lancé) ou `ACTIVE` (défaut). Les phases de cuisson interclassent les
|
||||||
|
recettes (une « prochaine étape de chaque recette » par phase) ; une
|
||||||
|
étape `PASSIVE` fait patienter sa recette une phase et s'affiche en
|
||||||
|
`background` des phases suivantes tant que sa consommatrice n'est pas
|
||||||
|
remontée.
|
||||||
|
|
||||||
|
Quantités mises à l'échelle par `PlanningItem.portions / Recipe.portions`
|
||||||
|
comme la liste de courses. Sommes d'ingrédients uniquement à unité
|
||||||
|
identique (aucune conversion — même posture que `ShoppingListItemView`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `reference` — catalogues publics (pas de session requise)
|
## `reference` — catalogues publics (pas de session requise)
|
||||||
|
|
||||||
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les
|
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ L'application repose sur une architecture **client-serveur** classique :
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph SERVER["Server"]
|
subgraph SERVER["Server"]
|
||||||
API["API (REST)"]
|
API["API (REST)"]
|
||||||
CALC["Calcul batch-cooking<br/><i>(TODO)</i>"]
|
CALC["Calcul batch-cooking<br/><i>v1 implémentée (GET /cooking-session)</i>"]
|
||||||
IMPORT["Import d'une recette<br/><i>implémenté</i>"]
|
IMPORT["Import d'une recette<br/><i>implémenté</i>"]
|
||||||
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
|
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
|
||||||
IMP2["Traduction en étapes<br/>(ingrédients + techniques)"]
|
IMP2["Traduction en étapes<br/>(ingrédients + techniques)"]
|
||||||
|
|
@ -38,8 +38,8 @@ flowchart TB
|
||||||
```
|
```
|
||||||
|
|
||||||
*(Le canal websocket envisagé dans la conception d'origine pour le calcul
|
*(Le canal websocket envisagé dans la conception d'origine pour le calcul
|
||||||
batch-cooking temps réel n'existe pas encore — rien à documenter tant que ce
|
batch-cooking temps réel n'existe pas encore — le module v1 est un `GET`
|
||||||
module reste TODO ; voir la note plus bas.)*
|
recalculé à chaque visite, pas de temps réel ; voir la note plus bas.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ module reste TODO ; voir la note plus bas.)*
|
||||||
Point d'entrée principal pour les échanges entre les clients et le serveur — REST classique, `requireAuth` (cookie JWT httpOnly) sur toute route qui n'est pas une donnée de référence publique. Détail complet des modules : [backend-architecture.md](./backend-architecture.md).
|
Point d'entrée principal pour les échanges entre les clients et le serveur — REST classique, `requireAuth` (cookie JWT httpOnly) sur toute route qui n'est pas une donnée de référence publique. Détail complet des modules : [backend-architecture.md](./backend-architecture.md).
|
||||||
|
|
||||||
### Module « Calcul batch-cooking »
|
### Module « Calcul batch-cooking »
|
||||||
Logique de calcul du batch-cooking (optimisation du planning/des recettes selon le planning). **Statut : TODO — reste à développer**, avec `packages/shared`'s `assertIsNever` déjà en place comme outil prêt à l'emploi pour ce futur module (voir [backend-architecture.md](./backend-architecture.md#packagesshared--assertisnever)).
|
Logique de calcul du batch-cooking (optimisation des recettes entre elles selon le planning). **Statut : v1 implémentée** — `GET /cooking-session?date=` → `optimizeCookingPlan` (`apps/api/src/lib/recipe-matching/cooking-optimizer.ts`, pur) réorganise les recettes d'une semaine planifiée en **phases ordonnées** : une « mise en place » qui mutualise la découpe commune (même technique de découpe + même ingrédient dans une étape purement prep de ≥ 2 recettes = une seule tâche), puis des phases de cuisson qui interclassent les recettes en poussant les cuissons passives (mijotage, four…) en tâche de fond. v1 hors périmètre : fusion de cuissons, durées estimées, persistance/progression, canal websocket. Détail : [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées).
|
||||||
|
|
||||||
### Module « Import d'une recette »
|
### Module « Import d'une recette »
|
||||||
**Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception :
|
**Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception :
|
||||||
|
|
@ -91,15 +91,15 @@ détectées, favoris, visibilité des recettes).
|
||||||
[backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés))
|
[backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés))
|
||||||
— une simple **agrégation** des ingrédients déjà planifiés (somme par
|
— une simple **agrégation** des ingrédients déjà planifiés (somme par
|
||||||
ingrédient/unité, mise à l'échelle par les portions de chaque créneau),
|
ingrédient/unité, mise à l'échelle par les portions de chaque créneau),
|
||||||
pas une optimisation. Le module « Calcul batch-cooking » lui-même reste
|
pas une optimisation. Le module « Calcul batch-cooking », lui, optimise les
|
||||||
`TODO` : il désigne quelque chose de plus ambitieux qu'une somme
|
recettes **entre elles** (mutualiser une préparation commune, paralléliser
|
||||||
d'ingrédients — optimiser le planning/les recettes entre elles (ex.
|
les cuissons passives) — **v1 implémentée** via `GET /cooking-session`
|
||||||
mutualiser une préparation entre plusieurs recettes de la semaine), pas
|
(voir [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées)).
|
||||||
encore défini plus précisément. C'est le principal chantier restant côté
|
|
||||||
serveur.
|
|
||||||
- Le canal websocket envisagé pour la communication temps réel n'a pas encore
|
- Le canal websocket envisagé pour la communication temps réel n'a pas encore
|
||||||
été construit — rien ne le remplace aujourd'hui (pas de polling), à
|
été construit — rien ne le remplace aujourd'hui (pas de polling). Le calcul
|
||||||
reconsidérer au moment d'attaquer le calcul batch-cooking.
|
batch-cooking v1 est un simple `GET` recalculé à chaque visite (comme la
|
||||||
|
liste de courses), pas de temps réel ; à reconsidérer si une session de
|
||||||
|
cuisine partagée/synchronisée est ajoutée.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue