From 1c62625c470b8f8724497e890817c3ff2babfed0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 5 Sep 2026 23:53:25 +0200 Subject: [PATCH] feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2/4 du chantier « gestion des restes » (issue #34). Deux nouvelles routes sur `/planning` : - `POST /planning/items/leftover` → `assignLeftoverPlanningItem`, 201. Crée un item « restes » (`sourcePlanningItemId` non nul) sur un créneau, même recette que la source (`recipeId` copié, jamais envoyé par le client). `portions` revérifié serveur contre le pool réellement disponible via `computeAvailableLeftover` → 409 `LEFTOVER_QUANTITY_EXCEEDED` (nouveau code, 4024) si dépassé ; 404 `PLANNING_ITEM_NOT_FOUND` si source inconnue / d'un autre foyer / elle- même un item « restes » (pas de restes-sur-restes). - `PATCH /planning/items/:id/discard-leftover` → `discardLeftover`, 200. Jette tout le pool restant (`discardedPortions += disponible`) ; idempotent (rien à jeter → renvoyé inchangé, aucun évènement). Exclusion : `shopping-list.service.ts` et `cooking-session.service.ts` filtrent `items: { where: { sourcePlanningItemId: null } }` dans leur `include` — un créneau qui ne fait que resservir un plat déjà cuisiné n'a rien à acheter ni à cuisiner. `getPlanningForDate` ne filtre pas (la grille affiche ces items). Analytics : `planning.leftover_assigned` / `planning.leftover_discarded`. Le mapping `PlanningItem` → `PlanningItemView` est factorisé (`toPlanningItemView`) — 4 chemins le partagent désormais. Tests (Mocha, DB) : `assignLeftoverPlanningItem` (succès + `recipeId` copié, 409 sur dépassement, 404 source étrangère/inconnue/« restes », cascade à la suppression de la source) ; `discardLeftover` (jette le disponible, idempotent, ne jette pas ce qui est déjà servi, 404) ; non-régression liste de courses et session de cuisine ignorant les items « restes ». Réf #34 Co-Authored-By: Claude Sonnet 5 --- .../cooking-session.service.ts | 5 + .../src/modules/planning/planning.routes.ts | 53 ++++- .../src/modules/planning/planning.service.ts | 189 ++++++++++++++++-- .../shopping-list/shopping-list.service.ts | 5 + apps/api/test/cooking-session.test.ts | 37 ++++ apps/api/test/planning.test.ts | 178 +++++++++++++++++ apps/api/test/shopping-list.test.ts | 42 ++++ apps/web/src/locales/fr/translation.json | 3 +- packages/shared/src/errors/error-codes.ts | 3 + packages/shared/src/schemas/planning.ts | 20 ++ specs/backend-architecture.md | 30 ++- 11 files changed, 536 insertions(+), 29 deletions(-) diff --git a/apps/api/src/modules/cooking-session/cooking-session.service.ts b/apps/api/src/modules/cooking-session/cooking-session.service.ts index b87cde0..9cc170e 100644 --- a/apps/api/src/modules/cooking-session/cooking-session.service.ts +++ b/apps/api/src/modules/cooking-session/cooking-session.service.ts @@ -22,6 +22,11 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js"; function cookingSessionPlanningInclude() { return { items: { + // A "leftovers" item (`sourcePlanningItemId` set) reserves part of an + // already-cooked batch — it has nothing to cook, so it must not enter + // the optimized cooking plan (issue #34). Excluded at the query so it + // never reaches `toOptimizerRecipe`. + where: { sourcePlanningItemId: null }, include: { recipe: { select: { diff --git a/apps/api/src/modules/planning/planning.routes.ts b/apps/api/src/modules/planning/planning.routes.ts index 26ce92d..e54831c 100644 --- a/apps/api/src/modules/planning/planning.routes.ts +++ b/apps/api/src/modules/planning/planning.routes.ts @@ -1,10 +1,21 @@ import { parseDateOnly } from "@batch-cooking/date-tools"; import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared"; +import { + addPlanningItemSchema, + assignLeftoverSchema, + ErrorCode, + getPlanningByDateSchema, +} from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js"; +import { + addPlanningItem, + assignLeftoverPlanningItem, + discardLeftover, + getPlanningForDate, + removePlanningItem, +} from "./planning.service.js"; /** Router mounted at `/planning` in app.ts. */ export const planningRouter = Router(); @@ -69,6 +80,44 @@ planningRouter.post( }), ); +/** + * Serves part of another planning item's leftover pool on a slot — creates + * a "leftovers" `PlanningItem` (see {@link assignLeftoverPlanningItem}). + * `POST /items/leftover`, not `/items/:id/...`: the `:id` here would be the + * *target* slot, which doesn't exist yet — the source is in the body. + */ +planningRouter.post( + "/items/leftover", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = assignLeftoverSchema.parse(req.body); + const date = parseDateOnly(input.date); + if (date === null) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + `Not a real calendar date: ${input.date}`, + ); + } + + const { id: viewerId, houseId } = res.locals.userProfile; + const item = await assignLeftoverPlanningItem(houseId, viewerId, date, input); + res.status(201).json(item); + }), +); + +/** Throws away item `:id`'s whole remaining leftover pool (idempotent — see {@link discardLeftover}). */ +planningRouter.patch( + "/items/:id/discard-leftover", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const id = parsePlanningItemId(req.params.id); + const { id: viewerId, houseId } = res.locals.userProfile; + const item = await discardLeftover(id, viewerId, houseId); + res.status(200).json(item); + }), +); + /** Removes one recipe from a planning slot. */ planningRouter.delete( "/items/:id", diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index 30c02cc..1b1a30e 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -2,6 +2,8 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too import { HttpError } from "@batch-cooking/error-tools"; import { type AddPlanningItemInput, + type AssignLeftoverInput, + computeAvailableLeftover, computeBatchedPortions, ErrorCode, type PlanningItemView, @@ -11,6 +13,35 @@ import { prisma } from "../../db/prisma.js"; import { analytics } from "../../lib/analytics.service.js"; import { assertRecipeVisible } from "../recipe/recipe.service.js"; +/** + * One `PlanningItem` row (with its recipe resolved to `{id, name}`) shaped + * into the wire {@link PlanningItemView}. The three read/write paths below + * that return an item — `getPlanningForDate`, `addPlanningItem`, + * `assignLeftoverPlanningItem`, `discardLeftover` — all go through this so + * the shape is defined once. + */ +function toPlanningItemView(item: { + id: number; + weekDay: string; + meal: string; + portions: number; + portionsRequested: number; + discardedPortions: number; + sourcePlanningItemId: number | null; + recipe: { id: number; name: string }; +}): PlanningItemView { + return { + id: item.id, + weekDay: item.weekDay, + meal: item.meal, + portions: item.portions, + portionsRequested: item.portionsRequested, + discardedPortions: item.discardedPortions, + sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null, + recipe: item.recipe, + }; +} + /** * Finds the household's planning that covers `date` and shapes it into a * {@link PlanningView} (recipes resolved to `{id, name}`). `date` is @@ -65,16 +96,7 @@ export async function getPlanningForDate( id: planning.id, startDate: planning.startDate.toISOString(), finishDate: planning.finishDate.toISOString(), - items: planning.items.map((item) => ({ - id: item.id, - weekDay: item.weekDay, - meal: item.meal, - portions: item.portions, - portionsRequested: item.portionsRequested, - discardedPortions: item.discardedPortions, - sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null, - recipe: item.recipe, - })), + items: planning.items.map(toPlanningItemView), }; } catch (err) { // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which @@ -184,16 +206,7 @@ export async function addPlanningItem( }, }); - return { - id: item.id, - weekDay: item.weekDay, - meal: item.meal, - portions: item.portions, - portionsRequested: item.portionsRequested, - discardedPortions: item.discardedPortions, - sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null, - recipe: item.recipe, - }; + return toPlanningItemView(item); } catch (err) { throw err; // see getPlanningForDate()'s catch comment above } @@ -221,3 +234,139 @@ export async function removePlanningItem(id: number, houseId: number | null): Pr throw err; // see getPlanningForDate()'s catch comment above } } + +/** + * Loads a `PlanningItem` (scoped to `houseId`) with everything + * {@link computeAvailableLeftover} needs — its own portion fields plus how + * much of its pool has already been served elsewhere (`leftoverUses`) — or + * throws `404 PLANNING_ITEM_NOT_FOUND` (same "don't confirm what exists" + * posture as `removePlanningItem`). Shared by + * {@link assignLeftoverPlanningItem} and {@link discardLeftover}. + */ +async function loadItemWithLeftoverPool(id: number, houseId: number | null) { + const item = await prisma.planningItem.findUnique({ + where: { id }, + include: { + planning: { select: { houseId: true } }, + leftoverUses: { select: { portions: true } }, + recipe: { select: { id: true, name: true } }, + }, + }); + if (!item || houseId === null || item.planning.houseId !== houseId) { + throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`); + } + return item; +} + +/** + * Serves `input.portions` of another item's leftover pool on the + * `(date, weekDay, meal)` slot — creates a "leftovers" `PlanningItem` + * (`sourcePlanningItemId` set) on the same recipe as its source. Nothing + * new is cooked for it, so `portions === portionsRequested` and it excludes + * itself from the shopping list / cooking session (see those services' + * `where: { sourcePlanningItemId: null }`). + * + * The source's `recipeId` is copied, never taken from the client (a + * leftovers item can only be its source's recipe). `input.portions` is + * re-checked against the pool actually still available + * ({@link computeAvailableLeftover}) — trusting a client value here would + * let a household conjure portions it never cooked. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null`. + * @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `sourceItemId` doesn't exist, belongs to another household, or is itself a leftovers item (no leftovers-on-leftovers). + * @throws {HttpError} `409 LEFTOVER_QUANTITY_EXCEEDED` if `portions` exceeds the source's still-available pool. + */ +export async function assignLeftoverPlanningItem( + houseId: number | null, + viewerId: number, + date: DateTime, + input: AssignLeftoverInput, +): Promise { + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + + const source = await loadItemWithLeftoverPool(input.sourceItemId, houseId); + if (source.sourcePlanningItemId !== null) { + // A leftovers item has no pool of its own — serving leftovers cooks + // nothing, so it can't be a source for more. + throw new HttpError( + 404, + ErrorCode.PLANNING_ITEM_NOT_FOUND, + `Planning item ${input.sourceItemId} is not a leftovers source`, + ); + } + + const available = computeAvailableLeftover(source); + if (input.portions > available) { + throw new HttpError( + 409, + ErrorCode.LEFTOVER_QUANTITY_EXCEEDED, + `Item ${source.id} has ${available} leftover portion(s) available, asked for ${input.portions}`, + ); + } + + const weekStart = getWeekStart(toDateOnly(date)); + const planning = await findOrCreatePlanningForWeek(houseId, weekStart); + + const item = await prisma.planningItem.create({ + data: { + planningId: planning.id, + weekDay: input.weekDay, + meal: input.meal, + recipeId: source.recipeId, + portions: input.portions, + portionsRequested: input.portions, + sourcePlanningItemId: source.id, + }, + include: { recipe: { select: { id: true, name: true } } }, + }); + + analytics.recordEvent("planning.leftover_assigned", { + actorId: viewerId, + context: { recipeId: source.recipeId, portions: input.portions, sourceItemId: source.id }, + }); + + return toPlanningItemView(item); + } catch (err) { + throw err; // see getPlanningForDate()'s catch comment above + } +} + +/** + * Throws away the whole still-available leftover pool of item `id` + * (`discardedPortions += available`) — the basket's "jeter" button. + * Idempotent: an item with nothing left to discard is returned unchanged, + * no event recorded. + * + * @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` — same guard as `removePlanningItem`. + */ +export async function discardLeftover( + id: number, + viewerId: number, + houseId: number | null, +): Promise { + try { + const item = await loadItemWithLeftoverPool(id, houseId); + const available = computeAvailableLeftover(item); + if (available === 0) { + return toPlanningItemView(item); + } + + const updated = await prisma.planningItem.update({ + where: { id }, + data: { discardedPortions: item.discardedPortions + available }, + include: { recipe: { select: { id: true, name: true } } }, + }); + + analytics.recordEvent("planning.leftover_discarded", { + actorId: viewerId, + context: { recipeId: item.recipe.id, portions: available }, + }); + + return toPlanningItemView(updated); + } catch (err) { + throw err; // see getPlanningForDate()'s catch comment above + } +} diff --git a/apps/api/src/modules/shopping-list/shopping-list.service.ts b/apps/api/src/modules/shopping-list/shopping-list.service.ts index 50c1d10..a66c1bc 100644 --- a/apps/api/src/modules/shopping-list/shopping-list.service.ts +++ b/apps/api/src/modules/shopping-list/shopping-list.service.ts @@ -13,6 +13,11 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js"; function shoppingListPlanningInclude() { return { items: { + // "Leftovers" items (`sourcePlanningItemId` set) reserve a portion of + // an already-cooked batch — nothing new to buy for them. Excluded + // here so they never reach `aggregateShoppingList`; counting them + // would double up the source recipe's ingredients (issue #34). + where: { sourcePlanningItemId: null }, include: { recipe: { select: { diff --git a/apps/api/test/cooking-session.test.ts b/apps/api/test/cooking-session.test.ts index a20c038..71c0a0d 100644 --- a/apps/api/test/cooking-session.test.ts +++ b/apps/api/test/cooking-session.test.ts @@ -328,5 +328,42 @@ describe("Cooking session", () => { 180, 210, ]); }); + + it("leaves a leftovers item out of the cooking plan — it has nothing to cook (issue #34)", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + + const recipe = await prisma.recipe.create({ + data: { + name: "Soupe", + authorId: houseRes.body.adminId, + portions: 4, + steps: { create: [{ order: 0, description: "Faire mijoter la soupe." }] }, + }, + }); + + // One real slot (12 prepared for 10 requested), then a leftovers slot + // reusing 2 of it — the plan must still contain only "Soupe" once. + const source = await agent.post("/planning/items").send({ + date: today(), + weekDay: "lundi", + meal: "diner", + recipeId: recipe.id, + portionsRequested: 10, + }); + await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.body.id, + portions: 2, + }); + + 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.deep.equal(["Soupe"]); + }); }); }); diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index 70bc1c2..6015883 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -271,4 +271,182 @@ describe("Planning", () => { expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); }); + + describe("leftovers — POST /planning/items/leftover and PATCH .../discard-leftover", () => { + /** + * Signs up + creates a household, saves a 4-portion recipe, and plans + * it once with `requested` portions — so the resulting item has a + * leftover pool of `ceil(requested/4)*4 - requested`. Returns the agent, + * the recipe, and that source item. + */ + async function setupWithSource(requested: number) { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const recipe = await prisma.recipe.create({ + data: { name: "Lasagnes", authorId: houseRes.body.adminId, portions: 4 }, + }); + const addRes = await agent.post("/planning/items").send({ + date: today(), + weekDay: "lundi", + meal: "diner", + recipeId: recipe.id, + portionsRequested: requested, + }); + return { agent, recipe, source: addRes.body }; + } + + it("serves part of a source item's leftover pool as a new leftovers item on another slot", async () => { + // 10 requested on a 4-portion recipe -> 12 prepared, pool of 2. + const { agent, recipe, source } = await setupWithSource(10); + expect(source.portions).to.equal(12); + + const res = await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.id, + portions: 2, + }); + + expect(res.status).to.equal(201); + expect(res.body).to.include({ + weekDay: "mardi", + meal: "dejeuner", + portions: 2, + portionsRequested: 2, + discardedPortions: 0, + }); + expect(res.body.sourceItem).to.deep.equal({ id: source.id }); + // The recipe is copied from the source, not sent by the client. + expect(res.body.recipe.id).to.equal(recipe.id); + }); + + it("rejects assigning more portions than the source pool still has, with 409 LEFTOVER_QUANTITY_EXCEEDED", async () => { + const { agent, source } = await setupWithSource(10); // pool of 2 + + // Take 2 of the 2 available... + await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.id, + portions: 2, + }); + // ...then ask for 1 more — nothing left. + const res = await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mercredi", + meal: "diner", + sourceItemId: source.id, + portions: 1, + }); + + expect(res.status).to.equal(409); + expect(res.body.code).to.equal(ErrorCode.LEFTOVER_QUANTITY_EXCEEDED); + }); + + it("404s when the source item belongs to another household", async () => { + const { source } = await setupWithSource(10); + + const stranger = request.agent(app); + await stranger.post("/auth/signup").send(buildSignupPayload()); + await stranger.post("/house").send({ name: "Ailleurs" }); + + const res = await stranger.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.id, + portions: 1, + }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND); + }); + + it("404s when the source item is itself a leftovers item (no leftovers-on-leftovers)", async () => { + const { agent, source } = await setupWithSource(10); + const leftover = await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.id, + portions: 1, + }); + + const res = await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mercredi", + meal: "diner", + sourceItemId: leftover.body.id, + portions: 1, + }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND); + }); + + it("deleting the source item cascades to every leftovers item served from it", async () => { + const { agent, source } = await setupWithSource(10); + const leftover = await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.id, + portions: 2, + }); + + await agent.delete(`/planning/items/${source.id}`).expect(204); + + const stillThere = await prisma.planningItem.findUnique({ where: { id: leftover.body.id } }); + expect(stillThere).to.equal(null); + }); + + it("discards the whole remaining pool, and is idempotent", async () => { + const { agent, source } = await setupWithSource(10); // pool of 2 + + const first = await agent.patch(`/planning/items/${source.id}/discard-leftover`); + expect(first.status).to.equal(200); + expect(first.body.discardedPortions).to.equal(2); + + // Nothing left — a second call changes nothing. + const second = await agent.patch(`/planning/items/${source.id}/discard-leftover`); + expect(second.status).to.equal(200); + expect(second.body.discardedPortions).to.equal(2); + + // And the pool is now empty for an assignment too. + const assign = await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.id, + portions: 1, + }); + expect(assign.status).to.equal(409); + }); + + it("only discards what's still available after some was already served", async () => { + const { agent, source } = await setupWithSource(10); // pool of 2 + await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.id, + portions: 1, + }); + + const res = await agent.patch(`/planning/items/${source.id}/discard-leftover`); + expect(res.status).to.equal(200); + // 1 of the 2 was served; only the other 1 gets discarded. + expect(res.body.discardedPortions).to.equal(1); + }); + + it("404s discarding an unknown planning item", async () => { + const { agent } = await setupWithSource(4); + const res = await agent.patch("/planning/items/999999/discard-leftover"); + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND); + }); + }); }); diff --git a/apps/api/test/shopping-list.test.ts b/apps/api/test/shopping-list.test.ts index 8f5f1ef..5c282dc 100644 --- a/apps/api/test/shopping-list.test.ts +++ b/apps/api/test/shopping-list.test.ts @@ -172,6 +172,48 @@ describe("Shopping list", () => { expect(res.body.items[0].quantity).to.equal(400); }); + it("ignores a leftovers item — nothing new to buy for a slot that just reserves an already-cooked batch (issue #34)", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const authorId: number = houseRes.body.adminId; + + const tomatoId = await ingredientId("tomato"); + const gramId = await unitId("gram"); + const recipe = await prisma.recipe.create({ + data: { + name: "Salade de tomates", + authorId, + portions: 4, + ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] }, + }, + }); + + // One real slot at 12 prepared (10 requested), then a leftovers slot + // reusing 2 of that batch — the shopping list must still be 300g + // (100 × 12/4), not 350g. + const source = await agent.post("/planning/items").send({ + date: today(), + weekDay: "lundi", + meal: "diner", + recipeId: recipe.id, + portionsRequested: 10, + }); + await agent.post("/planning/items/leftover").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + sourceItemId: source.body.id, + portions: 2, + }); + + const res = await agent.get("/shopping-list").query({ date: today() }); + + expect(res.status).to.equal(200); + expect(res.body.items).to.have.length(1); + expect(res.body.items[0].quantity).to.equal(300); + }); + it("sums the same ingredient across two different recipes sharing a unit", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 25aae22..1261f86 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -47,7 +47,8 @@ "UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas", "INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard", - "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours" + "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", + "LEFTOVER_QUANTITY_EXCEEDED": "Il ne reste plus assez de portions disponibles" }, "auth": { "login": { diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index cb62ce2..91c8a56 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -42,6 +42,9 @@ export enum ErrorCode { RECIPE_ALREADY_IMPORTED = 4022, /** `POST /admin/api/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */ RETRAIN_ALREADY_RUNNING = 4023, + + /** `POST /planning/items/leftover` asked to assign more portions than the source item's leftover pool still has available (`computeAvailableLeftover`, revalidated server-side). */ + LEFTOVER_QUANTITY_EXCEEDED = 4024, /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */ NOT_HOUSE_ADMIN = 4030, /** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */ diff --git a/packages/shared/src/schemas/planning.ts b/packages/shared/src/schemas/planning.ts index e0f48b3..61f033c 100644 --- a/packages/shared/src/schemas/planning.ts +++ b/packages/shared/src/schemas/planning.ts @@ -42,3 +42,23 @@ export const addPlanningItemSchema = z.object({ }); /** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */ export type AddPlanningItemInput = z.infer; + +/** + * Payload accepted by `POST /planning/items/leftover` — serves `portions` + * of another item's leftover pool on the `(date, weekDay, meal)` slot, + * creating a "leftovers" `PlanningItem` (see `planning.service.ts`'s + * `assignLeftoverPlanningItem`). `recipeId` is deliberately **not** here: + * a leftovers item can only be the same recipe as its source, so the + * server copies it from `sourceItemId` rather than trusting the client. + * `portions` is re-checked against the pool actually still available + * server-side — a client value over that is a `409 LEFTOVER_QUANTITY_EXCEEDED`. + */ +export const assignLeftoverSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"), + weekDay: z.enum(WEEK_DAYS), + meal: z.enum(MEALS), + sourceItemId: z.number().int().positive(), + portions: z.number().int().positive("Le nombre de portions doit être positif"), +}); +/** Inferred TS type for {@link assignLeftoverSchema}'s validated output. */ +export type AssignLeftoverInput = z.infer; diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 49c6131..b4a8ac2 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -232,6 +232,18 @@ Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`. par l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404 `RECIPE_NOT_FOUND` sinon. - `DELETE /planning/items/:id` → `removePlanningItem`, 204. +- `POST /planning/items/leftover` → `assignLeftoverPlanningItem`, 201. Body + `assignLeftoverSchema` = `{ date, weekDay, meal, sourceItemId, portions }`. + Crée un item « restes » (`sourcePlanningItemId` non nul) sur le créneau + cible, **même recette que la source** (`recipeId` copié, jamais envoyé par + le client). `portions` est revérifié contre le pool réellement disponible + (`computeAvailableLeftover`) → 409 `LEFTOVER_QUANTITY_EXCEEDED` si dépassé, + 404 `PLANNING_ITEM_NOT_FOUND` si `sourceItemId` inconnu / d'un autre foyer / + lui-même un item « restes » (pas de restes-sur-restes). +- `PATCH /planning/items/:id/discard-leftover` → `discardLeftover`, 200. Jette + tout le pool encore disponible de l'item (`discardedPortions += disponible`) ; + idempotent (rien à jeter → renvoyé inchangé, aucun évènement). 404 même + garde que `removePlanningItem`. **Portions demandées vs préparées (issue #34)** — le client n'envoie que `portionsRequested` (« combien ce créneau doit nourrir »). `addPlanningItem` @@ -240,12 +252,18 @@ en dérive `portions` (ce qui est **réellement préparé**) via (`@batch-cooking/shared`, pur) : on arrondit au multiple supérieur du rendement de la recette (`ceil(demandées / recette.portions) × recette.portions`) — on cuisine des lots entiers, jamais une fraction de plat. L'écart -`portions − portionsRequested` est le **pool de restes** de cet item. -`getPlanningForDate` renvoie les deux valeurs plus `discardedPortions` et -`sourceItem` (dérivé de `sourcePlanningItemId`) dans chaque -`PlanningItemView`, sans `include` Prisma supplémentaire. Les routes -d'attribution/jet de restes et l'exclusion des items « restes » de la liste -de courses / session de cuisine arrivent dans une itération ultérieure. +`portions − portionsRequested` est le **pool de restes** de cet item, +consommé par les items « restes » (`sourcePlanningItemId` pointant vers lui) +et par `discardedPortions`. `getPlanningForDate` renvoie les deux valeurs +plus `discardedPortions` et `sourceItem` dans chaque `PlanningItemView`, +sans `include` Prisma supplémentaire (le front reconstitue `leftoverUses` à +partir du tableau plat `planning.items` pour l'affichage du panier). + +**Exclusion des items « restes »** — `shopping-list.service.ts` et +`cooking-session.service.ts` filtrent `items: { where: { sourcePlanningItemId: +null } }` dans leur `include` : un créneau qui ne fait que resservir un plat +déjà cuisiné n'a rien à acheter ni à cuisiner. `getPlanningForDate` **ne** +filtre pas — la grille affiche bien ces items (badge « restes »). **Création de la semaine** — `findOrCreatePlanningForWeek` est le seul endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un -- 2.45.2