Première PR du chantier « gestion des restes » (issue #34) — les fondations, sans encore d'UI de panier. À l'ajout d'une recette au planning on ne saisit plus qu'un seul nombre, `portionsRequested` (« combien ce créneau doit nourrir »). Le serveur en dérive `portions` (ce qui est réellement préparé) via `computeBatchedPortions` : arrondi au multiple supérieur du rendement de la recette (on cuisine des lots entiers, jamais une fraction de plat). L'écart est le futur pool de restes de l'item. Modèle (`PlanningItem`, purement additif) : - `portionsRequested` NOT NULL — migration nullable → backfill (`= portions` pour les lignes existantes, sans restes à l'époque) → verrou, même discipline que `ingredient_unit_catalog`. - `sourcePlanningItemId` (auto-FK nullable, `onDelete: Cascade`) et `discardedPortions` (défaut 0) — posés maintenant, utilisés plus tard. Partagé (`@batch-cooking/shared/logic/planning-portions.ts`) : - `computeBatchedPortions(recipePortions, requested)` → `{ portions, leftover }` - `computeAvailableLeftover(item)` → pool encore disponible (posé pour les PRs suivantes : validation d'écriture côté API, affichage du panier côté web — une seule formule, deux appelants) API : `addPlanningItemSchema` prend `portionsRequested` ; `addPlanningItem` calcule et stocke les deux valeurs + l'évènement analytics ; `PlanningItemView` et `getPlanningForDate` exposent `portionsRequested`/`discardedPortions`/ `sourceItem`. Front : `RecipePickerDialog` — champ « portions demandées » + indice en direct « → N préparées, M en restes » quand l'arrondi crée un reste ; envoie `portionsRequested`. `RecipeImportForm` — envoie `portionsRequested` (un import atterrit à son propre rendement, donc reste nul). Tests : `planning-portions.test.ts` (les deux exemples de #34 + bornes) ; nouveau bloc `POST /planning/items` dans `planning.test.ts` (arrondi au lot, plusieurs lots, borne exacte, 400 sur non-positif) ; fixtures `planningItem.create` des suites existantes mises à jour ; scénario Cypress planning ajusté (`portionsRequested` dans le corps). Docs : `batch-cooking-modele.md`, `backend-architecture.md`, `frontend-architecture.md`. Réf #34 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
73 lines
2.7 KiB
TypeScript
73 lines
2.7 KiB
TypeScript
import { computeAvailableLeftover, computeBatchedPortions } from "@batch-cooking/shared";
|
|
import { expect } from "chai";
|
|
|
|
/**
|
|
* Pure portion arithmetic from `@batch-cooking/shared` — no DB, so this
|
|
* suite skips `resetDatabase`. The API (`addPlanningItem`, and later the
|
|
* leftover-assignment endpoints) and the web app both call these; the
|
|
* numbers below are the two worked examples from issue #34 plus the
|
|
* boundary cases.
|
|
*/
|
|
describe("planning portion arithmetic", () => {
|
|
describe("computeBatchedPortions", () => {
|
|
it("rounds up to one batch when fewer portions are requested than the recipe yields", () => {
|
|
expect(computeBatchedPortions(4, 3)).to.deep.equal({ portions: 4, leftover: 1 });
|
|
});
|
|
|
|
it("cooks several batches when more portions are requested than one batch yields", () => {
|
|
expect(computeBatchedPortions(4, 10)).to.deep.equal({ portions: 12, leftover: 2 });
|
|
});
|
|
|
|
it("leaves no leftover on an exact batch boundary", () => {
|
|
expect(computeBatchedPortions(4, 4)).to.deep.equal({ portions: 4, leftover: 0 });
|
|
expect(computeBatchedPortions(4, 8)).to.deep.equal({ portions: 8, leftover: 0 });
|
|
});
|
|
|
|
it("still prepares one batch when a single portion is requested", () => {
|
|
expect(computeBatchedPortions(6, 1)).to.deep.equal({ portions: 6, leftover: 5 });
|
|
});
|
|
|
|
it("handles a one-portion recipe (every request is an exact number of batches)", () => {
|
|
expect(computeBatchedPortions(1, 5)).to.deep.equal({ portions: 5, leftover: 0 });
|
|
});
|
|
});
|
|
|
|
describe("computeAvailableLeftover", () => {
|
|
const base = { portions: 12, portionsRequested: 10, discardedPortions: 0, leftoverUses: [] };
|
|
|
|
it("is the prepared-minus-requested pool when nothing has been discarded or served", () => {
|
|
expect(computeAvailableLeftover(base)).to.equal(2);
|
|
});
|
|
|
|
it("subtracts portions already served to other slots", () => {
|
|
expect(
|
|
computeAvailableLeftover({ ...base, leftoverUses: [{ portions: 1 }, { portions: 1 }] }),
|
|
).to.equal(0);
|
|
});
|
|
|
|
it("subtracts explicitly discarded portions", () => {
|
|
expect(computeAvailableLeftover({ ...base, discardedPortions: 2 })).to.equal(0);
|
|
});
|
|
|
|
it("never goes negative", () => {
|
|
expect(
|
|
computeAvailableLeftover({
|
|
...base,
|
|
discardedPortions: 5,
|
|
leftoverUses: [{ portions: 3 }],
|
|
}),
|
|
).to.equal(0);
|
|
});
|
|
|
|
it("is 0 for an item whose request landed exactly on a batch (no pool at all)", () => {
|
|
expect(
|
|
computeAvailableLeftover({
|
|
portions: 8,
|
|
portionsRequested: 8,
|
|
discardedPortions: 0,
|
|
leftoverUses: [],
|
|
}),
|
|
).to.equal(0);
|
|
});
|
|
});
|
|
});
|