feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine #49

Merged
kyuno merged 1 commit from feat/leftovers-assign-discard into main 2026-09-06 00:08:09 +02:00
11 changed files with 536 additions and 29 deletions
Showing only changes of commit 1c62625c47 - Show all commits

View file

@ -22,6 +22,11 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
function cookingSessionPlanningInclude() { function cookingSessionPlanningInclude() {
return { return {
items: { 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: { include: {
recipe: { recipe: {
select: { select: {

View file

@ -1,10 +1,21 @@
import { parseDateOnly } from "@batch-cooking/date-tools"; import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-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 { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; 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. */ /** Router mounted at `/planning` in app.ts. */
export const planningRouter = Router(); 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<unknown, AuthLocals>(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<unknown, AuthLocals>(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. */ /** Removes one recipe from a planning slot. */
planningRouter.delete( planningRouter.delete(
"/items/:id", "/items/:id",

View file

@ -2,6 +2,8 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
type AddPlanningItemInput, type AddPlanningItemInput,
type AssignLeftoverInput,
computeAvailableLeftover,
computeBatchedPortions, computeBatchedPortions,
ErrorCode, ErrorCode,
type PlanningItemView, type PlanningItemView,
@ -11,6 +13,35 @@ import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js"; import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible } from "../recipe/recipe.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 * Finds the household's planning that covers `date` and shapes it into a
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is * {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
@ -65,16 +96,7 @@ export async function getPlanningForDate(
id: planning.id, id: planning.id,
startDate: planning.startDate.toISOString(), startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(), finishDate: planning.finishDate.toISOString(),
items: planning.items.map((item) => ({ items: planning.items.map(toPlanningItemView),
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,
})),
}; };
} catch (err) { } catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
@ -184,16 +206,7 @@ export async function addPlanningItem(
}, },
}); });
return { return toPlanningItemView(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,
};
} catch (err) { } catch (err) {
throw err; // see getPlanningForDate()'s catch comment above 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 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<PlanningItemView> {
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<PlanningItemView> {
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
}
}

View file

@ -13,6 +13,11 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
function shoppingListPlanningInclude() { function shoppingListPlanningInclude() {
return { return {
items: { 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: { include: {
recipe: { recipe: {
select: { select: {

View file

@ -328,5 +328,42 @@ describe("Cooking session", () => {
180, 210, 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"]);
});
}); });
}); });

View file

@ -271,4 +271,182 @@ describe("Planning", () => {
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); 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);
});
});
}); });

View file

@ -172,6 +172,48 @@ describe("Shopping list", () => {
expect(res.body.items[0].quantity).to.equal(400); 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 () => { it("sums the same ingredient across two different recipes sharing a unit", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());

View file

@ -47,7 +47,8 @@
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas", "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", "INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard", "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": { "auth": {
"login": { "login": {

View file

@ -42,6 +42,9 @@ export enum ErrorCode {
RECIPE_ALREADY_IMPORTED = 4022, 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. */ /** `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, 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. */ /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
NOT_HOUSE_ADMIN = 4030, 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. */ /** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */

View file

@ -42,3 +42,23 @@ export const addPlanningItemSchema = z.object({
}); });
/** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */ /** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */
export type AddPlanningItemInput = z.infer<typeof addPlanningItemSchema>; export type AddPlanningItemInput = z.infer<typeof addPlanningItemSchema>;
/**
* 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<typeof assignLeftoverSchema>;

View file

@ -232,6 +232,18 @@ Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`.
par l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404 par l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404
`RECIPE_NOT_FOUND` sinon. `RECIPE_NOT_FOUND` sinon.
- `DELETE /planning/items/:id` → `removePlanningItem`, 204. - `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 **Portions demandées vs préparées (issue #34)** — le client n'envoie que
`portionsRequested` (« combien ce créneau doit nourrir »). `addPlanningItem` `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 (`@batch-cooking/shared`, pur) : on arrondit au multiple supérieur du
rendement de la recette (`ceil(demandées / recette.portions) × recette.portions`) rendement de la recette (`ceil(demandées / recette.portions) × recette.portions`)
— on cuisine des lots entiers, jamais une fraction de plat. L'écart — on cuisine des lots entiers, jamais une fraction de plat. L'écart
`portions − portionsRequested` est le **pool de restes** de cet item. `portions − portionsRequested` est le **pool de restes** de cet item,
`getPlanningForDate` renvoie les deux valeurs plus `discardedPortions` et consommé par les items « restes » (`sourcePlanningItemId` pointant vers lui)
`sourceItem` (dérivé de `sourcePlanningItemId`) dans chaque et par `discardedPortions`. `getPlanningForDate` renvoie les deux valeurs
`PlanningItemView`, sans `include` Prisma supplémentaire. Les routes plus `discardedPortions` et `sourceItem` dans chaque `PlanningItemView`,
d'attribution/jet de restes et l'exclusion des items « restes » de la liste sans `include` Prisma supplémentaire (le front reconstitue `leftoverUses` à
de courses / session de cuisine arrivent dans une itération ultérieure. 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 **Création de la semaine** — `findOrCreatePlanningForWeek` est le seul
endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un