feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine
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 <noreply@anthropic.com>
This commit is contained in:
parent
5e34a250df
commit
1c62625c47
11 changed files with 536 additions and 29 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<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. */
|
||||
planningRouter.delete(
|
||||
"/items/:id",
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -42,3 +42,23 @@ export const addPlanningItemSchema = z.object({
|
|||
});
|
||||
/** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue