Merge pull request 'feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine' (#49) from feat/leftovers-assign-discard into main
This commit is contained in:
commit
f146e65348
11 changed files with 536 additions and 29 deletions
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue