3c-fix: partial consumption now deducts eaten + leftovers

- consumptionPortions = min(planned, actualPortionsEaten + leftoverEstimatePortions).
  Meal boxes are a subset of leftovers; raw inventory is only retained for
  portions that were never cooked.
- Added validation leftoverEstimatePortions >= mealBoxPortions with localized
  400 error (cooked.leftoverLessThanBox) in server i18n + all 12 locales.
- Removed unused COOKING_SESSION_STATUSES / MEAL_BOX_STATUSES imports from
  apps/api/src/lib/cooking.ts.
- Hardened test cleanup to delete mealBoxes/meals/recipeCooks/cookingSessions
  by household before dropping storageLocations.
- New regression tests: meal-box double-counting, eaten+leftover split, and
  leftover < box rejection.
- Updated FAS3-COOKING-SESSIONS-AUDIT.md with corrected physics semantics.
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 15:35:25 +07:00
parent cb493010d0
commit 8637eaa6c3
16 changed files with 257 additions and 25 deletions
+15 -9
View File
@@ -9,11 +9,7 @@ import {
} from "@app/inventory-engine";
import { scaleNutrition } from "@app/nutrition-engine";
import type { Unit } from "@app/shared-types";
import {
COOKING_SESSION_STATUSES,
MEAL_BOX_STATUSES,
MEAL_TYPES,
} from "@app/shared-types";
import { MEAL_TYPES } from "@app/shared-types";
import {
cookingSessionStarted,
cookingSessionCompleted,
@@ -76,6 +72,11 @@ export async function completeCookingSession(
throw errors.badRequest(t("cooked.portionsSumExceedsPlanned", languageTag));
}
if (leftoverEstimatePortions < mealBoxPortions) {
const languageTag = await userLanguageTag(app.db, userId);
throw errors.badRequest(t("cooked.leftoverLessThanBox", languageTag));
}
if (options.emitStartedEvent && session.status === "started") {
await trackProductAnalytics(
app.db,
@@ -204,10 +205,15 @@ export async function completeCookingSessionCore(
// 1. FEFO-avdrag
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
if (input.deductInventory !== false) {
// Partiell förbrukning (Fas 3 §6.3): om actualPortionsEaten anges drar vi
// endast råvaror för de faktiskt ätna portionerna. Rester/överskott stannar
// kvar som råvara i lagret tills de förbrukas på annat sätt.
const consumptionPortions = input.actualPortionsEaten ?? portionsCooked;
// Partiell förbrukning (Fas 3 §6.3): råvaror dras för de tillagade
// portionerna = ätna + rester (inklusive matlådor). Endast portioner som
// aldrig tillagats stannar kvar som råvara i lagret.
const actualPortionsEaten = input.actualPortionsEaten ?? portionsCooked;
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? 0;
const consumptionPortions = Math.min(
portionsCooked,
actualPortionsEaten + leftoverEstimatePortions,
);
const factor = consumptionPortions / recipe.portions;
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
+19
View File
@@ -49,6 +49,21 @@ const COOKED_UNDO_WINDOW_EXPIRED: Record<string, string> = {
sv: "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.",
};
const COOKED_LEFTOVER_LESS_THAN_BOX: Record<string, string> = {
da: "Resterne må være mindst lige så mange som antallet af madkasseportioner.",
de: "Die Reste müssen mindestens so viele sein wie die Anzahl der Lunchbox-Portionen.",
en: "Leftovers must be at least as many as the number of meal-box portions.",
es: "Las sobras deben ser al menos tantas como el número de porciones de tupper.",
fi: "Tähteitä on oltava vähintään yhtä paljon kuin eväsrasioiden annosten määrä.",
fr: "Les restes doivent être au moins aussi nombreux que le nombre de portions de lunch-box.",
it: "Gli avanzi devono essere almeno tanti quanto il numero di porzioni dei contenitori per il pranzo.",
nb: "Restene må være minst like mange som antallet matboksporsjoner.",
nl: "De restjes moeten minstens even veel zijn als het aantal lunchboxporties.",
pl: "Pozostałości muszą być co najmniej tak liczne jak liczba porcji w lunchboxie.",
pt: "As sobras têm de ser pelo menos tantas quanto o número de porções da marmita.",
sv: "Rester måste vara minst lika många som antalet matlådeportioner.",
};
function resolve(catalog: Record<string, string>, languageTag: string): string {
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!;
@@ -63,6 +78,10 @@ export function t(key: string, languageTag: string): string {
return resolve(COOKED_UNDO_WINDOW_EXPIRED, languageTag);
}
if (key === "cooked.leftoverLessThanBox") {
return resolve(COOKED_LEFTOVER_LESS_THAN_BOX, languageTag);
}
if (key !== "onboarding.householdDefaultName") {
// No other server-side keys are supported yet; fall back to a safe default.
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;