From 06450b2db5acf0e987ee8aef1c680008b5124e5b Mon Sep 17 00:00:00 2001 From: Imugiii Date: Wed, 17 Jun 2026 09:27:43 +0000 Subject: [PATCH] feat(admin): recettes produit - composition product_ingredient + dispo calculee RG-T21 (P3, ferme #27) PR-B du lot P3 stock. Editeur de recette (composition product_ingredient) et disponibilite produit calculee, sur la couche stock de PR-A. - Composition (ProductRepository) : composition() (JOIN ingredient), setComposition() en delete-and-reinsert dans UNE transaction (RG-2/RG-T08), ingredientExists() + dedup par PK composite, compositionCount(). FK product_id CASCADE, ingredient_id RESTRICT. - Editeur (ProductController) : recipeForm/saveRecipe gardes par ingredient.manage (composition, DISTINCTE du CRUD produit), sans PIN (hors RG-T13). Revalidation serveur RG-T18 + allowlist RG-T16 (ingredient inconnu filtre, bornes des CHECK). Vue recipe.php + product-recipe.js (builder vanilla CSP-safe, data-*). - Disponibilite calculee RG-T21 : isOrderable() = is_available ET chaque ingredient NON RETIRABLE au-dessus de la bande critique (reutilise IngredientRepository:: stockBand). Un ingredient retirable en critique ne bloque pas ; un retrait manuel prime. Badge "Rupture auto" dans la liste (autoUnavailableIds, distinct du retrait manuel). - Dette #27 close : la suppression dure d'un produit cascade product_ingredient ; le nombre de lignes emportees est compte et trace dans le resume d'audit. Tests : 259 / 777 assertions verts (WAKDO_DB_TESTS=1), PHPStan L6 propre. --- src/app/Catalogue/ProductRepository.php | 133 ++++++++++- src/app/Controllers/ProductController.php | 176 ++++++++++++++- src/app/Views/admin/products/index.php | 13 +- src/app/Views/admin/products/recipe.php | 88 ++++++++ src/public/admin/assets/js/product-recipe.js | 164 ++++++++++++++ src/public/admin/index.php | 5 + tests/Integration/ProductIngredientDbTest.php | 213 ++++++++++++++++++ tests/Support/FakeDatabase.php | 32 +++ tests/Unit/Admin/ProductControllerTest.php | 162 +++++++++++++ .../Unit/Catalogue/ProductRepositoryTest.php | 73 ++++++ 10 files changed, 1048 insertions(+), 11 deletions(-) create mode 100644 src/app/Views/admin/products/recipe.php create mode 100644 src/public/admin/assets/js/product-recipe.js create mode 100644 tests/Integration/ProductIngredientDbTest.php create mode 100644 tests/Unit/Catalogue/ProductRepositoryTest.php diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 38e7ba8..3d1d371 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -17,9 +17,9 @@ use App\Core\DatabaseInterface; * (SQLSTATE 23000) -> 409 Conflit, plutot que de pre-tester chaque reference. * - CASCADE : product_ingredient (la recette appartient au produit ; la * supprimer avec le produit est voulu). La suppression n'est donc PAS bloquee - * par une recette existante. TODO (phase stock/recettes, table aujourd'hui - * vide) : tracer le nombre de lignes product_ingredient cascade-supprimees - * dans l'audit_log pour ne laisser aucune perte hors-trace. + * par une recette existante. Le nombre de lignes cascade-supprimees est compte + * (compositionCount) et trace dans le resume d'audit par ProductController::destroy + * (dette #27 close) pour ne laisser aucune perte hors-trace. */ final class ProductRepository { @@ -89,6 +89,133 @@ final class ProductRepository return $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $id]); } + public function ingredientExists(int $id): bool + { + return $this->db->fetch('SELECT id FROM ingredient WHERE id = :id', ['id' => $id]) !== null; + } + + /** + * Composition (recette) d'un produit : lignes product_ingredient enrichies du + * nom + de l'unite de l'ingredient et de ses champs de stock (pour la + * disponibilite calculee RG-T21). Ordonnee par nom d'ingredient. + * + * @return array> + */ + public function composition(int $productId): array + { + return $this->db->fetchAll( + 'SELECT pi.product_id, pi.ingredient_id, pi.quantity_normal, pi.quantity_maxi, ' + . 'pi.is_removable, pi.is_addable, pi.extra_price_cents, ' + . 'i.name AS ingredient_name, i.unit AS ingredient_unit, ' + . 'i.stock_quantity, i.stock_capacity, i.low_stock_pct, i.critical_stock_pct ' + . 'FROM product_ingredient pi JOIN ingredient i ON i.id = pi.ingredient_id ' + . 'WHERE pi.product_id = :id ORDER BY i.name', + ['id' => $productId], + ); + } + + /** + * Nombre de lignes de composition d'un produit. Sert a tracer la cascade #27 : + * combien de product_ingredient seront emportees par la suppression du produit + * (FK product_id CASCADE), pour ne laisser aucune perte hors-trace dans l'audit. + */ + public function compositionCount(int $productId): int + { + return (int) ($this->db->fetch( + 'SELECT COUNT(*) AS n FROM product_ingredient WHERE product_id = :id', + ['id' => $productId], + )['n'] ?? 0); + } + + /** + * Remplace integralement la composition d'un produit (delete-and-reinsert, mlt + * 8.5 RG-2 transpose a la recette) dans UNE transaction (RG-T08) : reposer + * l'ensemble est plus simple et sur qu'une reconciliation en place. La PK + * composite (product_id, ingredient_id) garantit l'unicite par ingredient ; + * l'appelant (controleur) a deja deduplique et valide les bornes (RG-T18). + * + * @param list $lines + */ + public function setComposition(int $productId, array $lines): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($productId, $lines): void { + $db->execute('DELETE FROM product_ingredient WHERE product_id = :id', ['id' => $productId]); + foreach ($lines as $line) { + $db->execute( + 'INSERT INTO product_ingredient (product_id, ingredient_id, quantity_normal, ' + . 'quantity_maxi, is_removable, is_addable, extra_price_cents) ' + . 'VALUES (:product, :ingredient, :qn, :qm, :rem, :add, :extra)', + [ + 'product' => $productId, + 'ingredient' => $line['ingredient_id'], + 'qn' => $line['quantity_normal'], + 'qm' => $line['quantity_maxi'], + 'rem' => $line['is_removable'], + 'add' => $line['is_addable'], + 'extra' => $line['extra_price_cents'], + ], + ); + } + }); + } + + /** + * Ids des produits en RUPTURE AUTOMATIQUE par le stock (RG-T21) : au moins un + * ingredient requis (is_removable=0) au niveau ou sous la bande critique + * (stock_quantity * 100 <= stock_capacity * critical_stock_pct, l'arithmetique + * entiere de IngredientRepository::stockBand). Calcule en UNE requete pour + * eviter le N+1 a l'affichage de la liste. Distinct du retrait manuel + * (is_available=0), que la vue signale separement. + * + * @return list + */ + public function autoUnavailableIds(): array + { + $rows = $this->db->fetchAll( + 'SELECT DISTINCT pi.product_id FROM product_ingredient pi ' + . 'JOIN ingredient i ON i.id = pi.ingredient_id ' + . 'WHERE pi.is_removable = 0 AND i.stock_quantity * 100 <= i.stock_capacity * i.critical_stock_pct', + ); + + return array_map(static fn (array $r): int => (int) ($r['product_id'] ?? 0), $rows); + } + + /** + * Disponibilite produit CALCULEE (RG-T21) : commandable ssi le flag + * is_available vaut 1 ET chaque ingredient NON RETIRABLE (is_removable=0) de la + * composition est au-dessus de la bande critique (stockBand != 'critical'). + * Derivation pure, sans ecriture ni cascade : un ingredient requis tombant en + * critique met le produit en rupture automatique ; un ingredient retirable/ + * optionnel en critique ne bloque pas (seul son supplement devient indispo) ; + * un retrait manuel (is_available=0) prime sur tout. La bande critique est celle + * d'IngredientRepository::stockBand (source unique de la derivation). + * + * @param array> $composition lignes de composition() + */ + public static function isOrderable(bool $flagAvailable, array $composition): bool + { + if (!$flagAvailable) { + return false; + } + + foreach ($composition as $line) { + if ((int) ($line['is_removable'] ?? 1) !== 0) { + continue; // retirable/optionnel : n'entre pas dans la disponibilite du produit + } + $band = IngredientRepository::stockBand( + (int) ($line['stock_quantity'] ?? 0), + (int) ($line['stock_capacity'] ?? 0), + (int) ($line['low_stock_pct'] ?? 0), + (int) ($line['critical_stock_pct'] ?? 0), + ); + if ($band === 'critical') { + return false; + } + } + + return true; + } + /** * Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees. * diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php index 062f8ce..766f1fd 100644 --- a/src/app/Controllers/ProductController.php +++ b/src/app/Controllers/ProductController.php @@ -11,6 +11,7 @@ use App\Auth\PasswordHasher; use App\Auth\PinThrottle; use App\Auth\PinVerifier; use App\Catalogue\CategoryRepository; +use App\Catalogue\IngredientRepository; use App\Catalogue\ProductRepository; use App\Core\DatabaseInterface; use App\Core\Response; @@ -41,9 +42,12 @@ class ProductController extends AdminController } return $this->adminView('admin/products/index', [ - 'title' => 'Produits - Wakdo Admin', - 'activeNav' => 'products', - 'products' => $this->productRepository()->all(), + 'title' => 'Produits - Wakdo Admin', + 'activeNav' => 'products', + 'products' => $this->productRepository()->all(), + // Rupture AUTOMATIQUE par le stock (RG-T21), distincte du retrait manuel + // (is_available=0) : la vue signale les deux differemment. + 'autoUnavailable' => $this->productRepository()->autoUnavailableIds(), ], $guard); } @@ -251,15 +255,23 @@ class ProductController extends AdminController $name = (string) ($product['name'] ?? ''); + // Dette #27 : product_ingredient (FK product_id CASCADE) sera emporte par la + // suppression. On compte AVANT (lecture hors transaction) pour tracer le + // nombre de lignes de recette cascade-supprimees dans le resume d'audit : + // aucune perte hors-trace dans le journal append-only. + $cascaded = $this->productRepository()->compositionCount($id); + $summary = 'Suppression produit: ' . $name + . ' (' . $cascaded . ' ligne(s) de recette cascade-supprimee(s))'; + // FK RESTRICT (order_item / menu / menu_slot_option / order_item_selection) // -> PDOException 23000 -> 409 Conflit (catch ci-dessous). product_ingredient // est CASCADE (recette possedee par le produit) : supprimee avec lui, jamais // bloquante (cf. docblock ProductRepository). try { - $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void { + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $summary): void { $deleted = (new ProductRepository($db))->delete($id); if ($deleted === 1) { - $this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, 'Suppression produit: ' . $name); + $this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, $summary); } }); } catch (PDOException $exception) { @@ -280,11 +292,75 @@ class ProductController extends AdminController return $this->redirect('/admin/products'); } + /** + * Editeur de recette (PR-B, mlt domaine recettes). Compose product_ingredient : + * la commandabilite est gardee par `ingredient.manage` (composition du produit), + * DISTINCTE de product.create/update/delete (CRUD produit). Aucun PIN : editer + * une recette n'est pas une action sensible RG-T13. + * + * @param array $params + */ + public function recipeForm(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + return $this->renderRecipe($guard, $id, $product, []); + } + + /** + * @param array $params + */ + public function saveRecipe(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + $errors = []; + $lines = $this->parseComposition($form['composition_json'] ?? '', $errors); + if ($errors !== []) { + return $this->renderRecipe($guard, $id, $product, $errors, 422); + } + + // Composition vide autorisee : un produit peut n'avoir aucune recette + // definie (setComposition purge alors la table sans rien reinserer). + $this->productRepository()->setComposition($id, $lines); + $this->setFlash('Recette mise a jour.'); + + return $this->redirect('/admin/products'); + } + protected function productRepository(): ProductRepository { return new ProductRepository($this->db()); } + protected function ingredientRepository(): IngredientRepository + { + return new IngredientRepository($this->db()); + } + protected function categoryRepository(): CategoryRepository { return new CategoryRepository($this->db()); @@ -416,6 +492,96 @@ class ProductController extends AdminController ); } + /** + * Decode + valide la composition soumise en JSON (champ cache composition_json), + * RG-T18 (revalidation serveur) + RG-T16 (allowlist). Un ingredient inconnu est + * FILTRE (jamais une erreur bloquante) ; la PK composite impose un ingredient au + * plus une fois (dedup). Les bornes refletent les CHECK de table : quantity_normal + * >= 1, quantity_maxi >= quantity_normal, extra_price_cents >= 0. Composition vide + * = aucune ligne, sans erreur. + * + * @param array $errors + * @return list + */ + private function parseComposition(string $json, array &$errors): array + { + $json = trim($json); + if ($json === '' || $json === '[]') { + return []; + } + + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + $errors['composition'] = 'Composition invalide.'; + + return []; + } + + $lines = []; + $seen = []; + foreach ($decoded as $raw) { + if (!is_array($raw)) { + continue; + } + + $ingredientId = is_numeric($raw['ingredient_id'] ?? null) ? (int) $raw['ingredient_id'] : 0; + if ($ingredientId <= 0 || !$this->productRepository()->ingredientExists($ingredientId)) { + continue; // ingredient inconnu : filtre (allowlist), pas une erreur + } + if (isset($seen[$ingredientId])) { + continue; // PK composite (product_id, ingredient_id) : un seul par ingredient + } + + $qn = is_numeric($raw['quantity_normal'] ?? null) ? (int) $raw['quantity_normal'] : 0; + $qm = is_numeric($raw['quantity_maxi'] ?? null) ? (int) $raw['quantity_maxi'] : 0; + $extra = is_numeric($raw['extra_price_cents'] ?? null) ? (int) $raw['extra_price_cents'] : -1; + + if ($qn < 1 || $qn > 65535) { + $errors['composition'] = 'La quantite normale doit etre un entier >= 1.'; + continue; + } + if ($qm < $qn || $qm > 65535) { + $errors['composition'] = 'La quantite maxi doit etre >= la quantite normale.'; + continue; + } + if ($extra < 0 || $extra > 4294967295) { + $errors['composition'] = 'Le supplement (en centimes) doit etre un entier >= 0.'; + continue; + } + + $seen[$ingredientId] = true; + $lines[] = [ + 'ingredient_id' => $ingredientId, + 'quantity_normal' => $qn, + 'quantity_maxi' => $qm, + 'is_removable' => empty($raw['is_removable']) ? 0 : 1, + 'is_addable' => empty($raw['is_addable']) ? 0 : 1, + 'extra_price_cents' => $extra, + ]; + } + + return $lines; + } + + /** + * @param array $product + * @param array $errors + */ + private function renderRecipe(GuardResult $guard, int $id, array $product, array $errors, int $status = 200): Response + { + return $this->adminView('admin/products/recipe', [ + 'title' => 'Recette - ' . (string) ($product['name'] ?? '') . ' - Wakdo Admin', + 'activeNav' => 'products', + 'productId' => $id, + 'productName' => (string) ($product['name'] ?? ''), + 'ingredients' => $this->ingredientRepository()->all(), + 'composition' => $this->productRepository()->composition($id), + 'errors' => $errors, + 'csrfToken' => Csrf::token($this->sessionManager()), + ], $guard, $status); + } + /** * @param array $values * @param array $errors diff --git a/src/app/Views/admin/products/index.php b/src/app/Views/admin/products/index.php index cb57b85..dcfa489 100644 --- a/src/app/Views/admin/products/index.php +++ b/src/app/Views/admin/products/index.php @@ -6,10 +6,13 @@ declare(strict_types=1); * Liste des produits (CRUD admin), injectee dans admin/layout.php. Texte echappe. * * @var array> $products + * @var list $autoUnavailable ids en rupture auto (RG-T21) */ /** @var array> $rows */ $rows = isset($products) && is_array($products) ? $products : []; +/** @var list $autoIds */ +$autoIds = isset($autoUnavailable) && is_array($autoUnavailable) ? array_map('intval', $autoUnavailable) : []; $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR'; ?> @@ -44,6 +47,7 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' @@ -52,14 +56,17 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' - - Disponible - + Indisponible + + Rupture auto + + Disponible Modifier + Recette Supprimer diff --git a/src/app/Views/admin/products/recipe.php b/src/app/Views/admin/products/recipe.php new file mode 100644 index 0000000..66c1548 --- /dev/null +++ b/src/app/Views/admin/products/recipe.php @@ -0,0 +1,88 @@ +> $ingredients catalogue pour le picker + * @var array> $composition lignes existantes + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($productId ?? 0); +$name = htmlspecialchars((string) ($productName ?? ''), ENT_QUOTES, 'UTF-8'); +$action = '/admin/products/' . $id . '/recipe'; + +/** @var array> $ings */ +$ings = isset($ingredients) && is_array($ingredients) ? $ingredients : []; +/** @var array> $comp */ +$comp = isset($composition) && is_array($composition) ? $composition : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +$compError = isset($errs['composition']) && is_string($errs['composition']) ? $errs['composition'] : ''; + +// Donnees pour le builder JS, en attributs data-* (CSP 'self'). htmlspecialchars +// rend le JSON sur-able comme valeur d'attribut. +$slimIngredients = array_map( + static fn (array $i): array => [ + 'id' => (int) ($i['id'] ?? 0), + 'name' => (string) ($i['name'] ?? ''), + 'unit' => (string) ($i['unit'] ?? ''), + ], + $ings, +); +$slimComposition = array_map( + static fn (array $c): array => [ + 'ingredient_id' => (int) ($c['ingredient_id'] ?? 0), + 'quantity_normal' => (int) ($c['quantity_normal'] ?? 1), + 'quantity_maxi' => (int) ($c['quantity_maxi'] ?? 1), + 'is_removable' => (int) ($c['is_removable'] ?? 0), + 'is_addable' => (int) ($c['is_addable'] ?? 0), + 'extra_price_cents' => (int) ($c['extra_price_cents'] ?? 0), + ], + $comp, +); +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); +?> + + +
+ + +
+ Ingredients +

Un ingredient NON RETIRABLE en rupture critique met le produit en rupture automatique. Un ingredient retirable/optionnel ne bloque pas le produit.

+

+
+ +
+ + + +
+ + Retour +
+
+ diff --git a/src/public/admin/assets/js/product-recipe.js b/src/public/admin/assets/js/product-recipe.js new file mode 100644 index 0000000..80ff9b5 --- /dev/null +++ b/src/public/admin/assets/js/product-recipe.js @@ -0,0 +1,164 @@ +/* + * product-recipe.js — Builder de composition (recette) du formulaire produit. + * + * CSP 'self' : script externe (pas d'inline). Les donnees (catalogue d'ingredients, + * composition initiale) sont lues depuis les attributs data-* de #recipe-builder. + * A la soumission, l'etat est serialise en JSON dans le champ cache #composition_json + * (Request::formBody cote serveur ne garde que les scalaires). Le serveur revalide + * tout (RG-T18) : bornes, existence de l'ingredient, dedup par PK composite. + * + * Une composition VIDE est valide (un produit peut n'avoir aucune recette definie). + */ +(function () { + 'use strict'; + + var builder = document.getElementById('recipe-builder'); + var form = document.getElementById('recipe-form'); + var hidden = document.getElementById('composition_json'); + var addBtn = document.getElementById('add-ingredient'); + if (!builder || !form || !hidden || !addBtn) { + return; + } + + function parseData(key, fallback) { + try { + var v = JSON.parse(builder.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + var ingredients = parseData('ingredients', '[]'); // [{id, name, unit}] + var initial = parseData('composition', '[]'); // [{ingredient_id, quantity_normal, ...}] + + function el(tag, className) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + function numberInput(className, value, min) { + var input = el('input', 'form-input ' + className); + input.type = 'number'; + input.min = String(min); + input.value = String(value); + input.style.width = '7rem'; + return input; + } + + // Construit le bloc DOM d'une ligne de composition. `line` peut etre vide (ajout). + function renderLine(line) { + line = line || {}; + + var block = el('fieldset', 'recipe-line form-group'); + block.style.border = '1px solid #ddd'; + block.style.padding = '0.75rem'; + block.style.marginBottom = '0.75rem'; + + // Ingredient (picker) + var ingLabel = el('label'); + ingLabel.appendChild(document.createTextNode('Ingredient ')); + var ingSelect = el('select', 'form-input recipe-ingredient'); + ingredients.forEach(function (i) { + var opt = el('option'); + opt.value = String(i.id); + opt.textContent = String(i.name) + (i.unit ? ' (' + String(i.unit) + ')' : ''); + if (Number(line.ingredient_id) === Number(i.id)) { + opt.selected = true; + } + ingSelect.appendChild(opt); + }); + ingLabel.appendChild(ingSelect); + block.appendChild(ingLabel); + + // Quantites + var qnLabel = el('label'); + qnLabel.appendChild(document.createTextNode(' Qte normale ')); + qnLabel.appendChild(numberInput('recipe-qn', line.quantity_normal != null ? line.quantity_normal : 1, 1)); + block.appendChild(qnLabel); + + var qmLabel = el('label'); + qmLabel.appendChild(document.createTextNode(' Qte maxi ')); + qmLabel.appendChild(numberInput('recipe-qm', line.quantity_maxi != null ? line.quantity_maxi : 1, 1)); + block.appendChild(qmLabel); + + // Supplement (centimes) + var extraLabel = el('label'); + extraLabel.appendChild(document.createTextNode(' Supplement (cts) ')); + extraLabel.appendChild(numberInput('recipe-extra', line.extra_price_cents != null ? line.extra_price_cents : 0, 0)); + block.appendChild(extraLabel); + + // Retirable / Ajoutable + var remLabel = el('label'); + var remInput = el('input', 'recipe-removable'); + remInput.type = 'checkbox'; + if (Number(line.is_removable) === 1) { + remInput.checked = true; + } + remLabel.appendChild(remInput); + remLabel.appendChild(document.createTextNode(' Retirable')); + block.appendChild(remLabel); + + var addLabel = el('label'); + var addInput = el('input', 'recipe-addable'); + addInput.type = 'checkbox'; + if (Number(line.is_addable) === 1) { + addInput.checked = true; + } + addLabel.appendChild(addInput); + addLabel.appendChild(document.createTextNode(' Ajoutable')); + block.appendChild(addLabel); + + // Retirer la ligne + var removeBtn = el('button', 'btn btn-secondary recipe-remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + block.parentNode.removeChild(block); + }); + block.appendChild(removeBtn); + + return block; + } + + // Lit l'etat des lignes et le serialise dans #composition_json. + function serialize() { + var lines = []; + var blocks = builder.querySelectorAll('.recipe-line'); + Array.prototype.forEach.call(blocks, function (block) { + var ingredientId = Number(block.querySelector('.recipe-ingredient').value); + if (!ingredientId) { + return; + } + lines.push({ + ingredient_id: ingredientId, + quantity_normal: Number(block.querySelector('.recipe-qn').value), + quantity_maxi: Number(block.querySelector('.recipe-qm').value), + extra_price_cents: Number(block.querySelector('.recipe-extra').value), + is_removable: block.querySelector('.recipe-removable').checked ? 1 : 0, + is_addable: block.querySelector('.recipe-addable').checked ? 1 : 0 + }); + }); + hidden.value = JSON.stringify(lines); + } + + addBtn.addEventListener('click', function () { + if (!ingredients.length) { + return; // aucun ingredient au catalogue : rien a composer + } + builder.appendChild(renderLine(null)); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + // Rendu initial : lignes existantes (edition). Composition vide -> aucune ligne + // (l'utilisateur ajoute a la demande, ou enregistre une recette vide). + initial.forEach(function (l) { + builder.appendChild(renderLine(l)); + }); +})(); diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 951a074..332a9f3 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -91,6 +91,11 @@ try { $router->add('POST', '/admin/products/{id}', [ProductController::class, 'update']); $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); + // Editeur de recette (composition product_ingredient). Permission ingredient.manage + // (composition), distincte du CRUD produit ; sans PIN. Debloque la dispo calculee + // RG-T21 et ferme la dette #27 (trace cascade a la suppression). + $router->add('GET', '/admin/products/{id}/recipe', [ProductController::class, 'recipeForm']); + $router->add('POST', '/admin/products/{id}/recipe', [ProductController::class, 'saveRecipe']); // CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base + // slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression diff --git a/tests/Integration/ProductIngredientDbTest.php b/tests/Integration/ProductIngredientDbTest.php new file mode 100644 index 0000000..ed478a2 --- /dev/null +++ b/tests/Integration/ProductIngredientDbTest.php @@ -0,0 +1,213 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0); + $suffix = bin2hex(random_bytes(4)); + $this->product = 'it-prod-' . $suffix; + $this->ingA = 'it-ping-a-' . $suffix; + $this->ingB = 'it-ping-b-' . $suffix; + } + + protected function tearDown(): void + { + if ($this->product === '') { + return; + } + $pid = (int) ($this->db->fetch('SELECT id FROM product WHERE name = :n', ['n' => $this->product])['id'] ?? 0); + if ($pid > 0) { + $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $pid]); // CASCADE product_ingredient + } + // Ordre FK-safe : retirer les mouvements de stock (FK RESTRICT) avant + // l'ingredient (le test de dispo cree un restock -> stock_movement). + foreach ([$this->ingA, $this->ingB] as $name) { + $iid = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $name])['id'] ?? 0); + if ($iid > 0) { + $this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $iid]); + $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $iid]); + } + } + } + + public function testSetCompositionPersistsAndReplaces(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + $iaId = $this->createIngredient($ingredients, $this->ingA, 50); + $ibId = $this->createIngredient($ingredients, $this->ingB, 50); + + self::assertTrue($products->ingredientExists($iaId)); + self::assertFalse($products->ingredientExists(0)); + + $products->setComposition($pid, [ + $this->line($iaId, ['quantity_normal' => 2, 'quantity_maxi' => 3, 'is_removable' => 1, 'extra_price_cents' => 50]), + ]); + + $composition = $products->composition($pid); + self::assertCount(1, $composition); + self::assertSame($iaId, (int) $composition[0]['ingredient_id']); + self::assertSame($this->ingA, (string) $composition[0]['ingredient_name']); // JOIN ingredient + self::assertSame(2, (int) $composition[0]['quantity_normal']); + self::assertSame(3, (int) $composition[0]['quantity_maxi']); + self::assertSame(1, (int) $composition[0]['is_removable']); + self::assertSame(50, (int) $composition[0]['extra_price_cents']); + self::assertSame(1, $products->compositionCount($pid)); + + // Delete-and-reinsert : la nouvelle composition REMPLACE l'ancienne. + $products->setComposition($pid, [$this->line($ibId)]); + $replaced = $products->composition($pid); + self::assertCount(1, $replaced); + self::assertSame($ibId, (int) $replaced[0]['ingredient_id']); + } + + public function testProductDeleteCascadesComposition(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + $iaId = $this->createIngredient($ingredients, $this->ingA, 50); + $products->setComposition($pid, [$this->line($iaId)]); + + self::assertSame(1, $products->compositionCount($pid)); + self::assertSame(1, $products->delete($pid)); // FK product_id CASCADE + self::assertCount(0, $products->composition($pid)); // recette emportee + + // L'ingredient, lui, survit (la cascade ne remonte pas vers lui). + self::assertNotNull($ingredients->find($iaId)); + } + + public function testIngredientReferencedByCompositionCannotBeHardDeleted(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + $iaId = $this->createIngredient($ingredients, $this->ingA, 50); + $products->setComposition($pid, [$this->line($iaId)]); + + // FK ingredient_id RESTRICT : un ingredient utilise dans une recette ne peut + // pas etre supprime durement (il faut le desactiver). + $blocked = false; + try { + $ingredients->delete($iaId); + } catch (PDOException $exception) { + $blocked = (string) $exception->getCode() === '23000'; + } + self::assertTrue($blocked, 'product_ingredient.ingredient_id (RESTRICT) doit bloquer la suppression.'); + self::assertTrue($ingredients->isReferenced($iaId)); // pre-check FK-safe + } + + public function testAvailabilityIsDerivedFromRequiredIngredientStock(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + // Ingredient requis SOUS la bande critique (3/100 <= 5%). + $critId = $this->createIngredient($ingredients, $this->ingA, 3); + $products->setComposition($pid, [$this->line($critId, ['is_removable' => 0])]); + + $product = $products->find($pid); + self::assertNotNull($product); + $composition = $products->composition($pid); + self::assertFalse(ProductRepository::isOrderable((int) $product['is_available'] === 1, $composition)); + self::assertContains($pid, $products->autoUnavailableIds()); // rupture auto (RG-T21) + + // Reapprovisionnement au-dessus du critique -> redevient commandable de lui-meme. + $ingredients->restock($critId, 50, null, null); // 3 -> 53 (pack_size 1 * 50) + $composition = $products->composition($pid); + self::assertTrue(ProductRepository::isOrderable(true, $composition)); + self::assertNotContains($pid, $products->autoUnavailableIds()); + } + + private function createProduct(ProductRepository $repo): int + { + $repo->create([ + 'category_id' => $this->categoryId, + 'name' => $this->product, + 'description' => null, + 'price_cents' => 590, + 'vat_rate' => 100, + 'image_path' => null, + 'is_available' => 1, + 'display_order' => 99, + ]); + + return (int) ($this->db->fetch('SELECT id FROM product WHERE name = :n', ['n' => $this->product])['id'] ?? 0); + } + + private function createIngredient(IngredientRepository $repo, string $name, int $stock): int + { + $repo->create([ + 'name' => $name, + 'unit' => 'portion', + 'stock_quantity' => $stock, + 'stock_capacity' => 100, + 'pack_size' => 1, + 'pack_label' => null, + 'low_stock_pct' => 10, + 'critical_stock_pct' => 5, + 'is_active' => 1, + ]); + + return (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $name])['id'] ?? 0); + } + + /** + * @param array $over + * @return array{ingredient_id:int, quantity_normal:int, quantity_maxi:int, is_removable:int, is_addable:int, extra_price_cents:int} + */ + private function line(int $ingredientId, array $over = []): array + { + return [ + 'ingredient_id' => $ingredientId, + 'quantity_normal' => $over['quantity_normal'] ?? 1, + 'quantity_maxi' => $over['quantity_maxi'] ?? 1, + 'is_removable' => $over['is_removable'] ?? 0, + 'is_addable' => $over['is_addable'] ?? 0, + 'extra_price_cents' => $over['extra_price_cents'] ?? 0, + ]; + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 82869fa..222bb2b 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -183,6 +183,24 @@ final class FakeDatabase implements DatabaseInterface */ public array $movementsRows = []; + /** + * Lignes renvoyees par ProductRepository::composition() (JOIN product_ingredient/ingredient). + * + * @var list> + */ + public array $compositionRows = []; + + /** + * Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds() + * (produits en rupture automatique par le stock, RG-T21). + * + * @var list> + */ + public array $autoUnavailableRows = []; + + /** Compteur renvoye par ProductRepository::compositionCount() (trace cascade #27). */ + public int $productCompositionCount = 0; + /** * Allowlist optionnelle de codes de permission accordes (RG-T03). Si non nul, * can() repond par appartenance du :code lie a cette liste (permet de tester la @@ -313,6 +331,10 @@ final class FakeDatabase implements DatabaseInterface return $this->ingredientRow; } + if (str_contains($sql, 'COUNT(*) AS n FROM product_ingredient')) { + return ['n' => $this->productCompositionCount]; + } + if (str_contains($sql, 'FROM category WHERE name = :name')) { return $this->categoryNameTaken ? ['id' => 1] : null; } @@ -364,6 +386,16 @@ final class FakeDatabase implements DatabaseInterface return $this->ingredientsRows; } + // Composition d'un produit (recette) vs ensemble des produits en rupture + // auto : meme table jointe, distingues par la clause WHERE. + if (str_contains($sql, 'FROM product_ingredient pi') && str_contains($sql, 'is_removable = 0')) { + return $this->autoUnavailableRows; + } + + if (str_contains($sql, 'FROM product_ingredient pi') && str_contains($sql, 'WHERE pi.product_id')) { + return $this->compositionRows; + } + if (str_contains($sql, 'FROM stock_movement WHERE ingredient_id')) { return $this->movementsRows; } diff --git a/tests/Unit/Admin/ProductControllerTest.php b/tests/Unit/Admin/ProductControllerTest.php index 317d379..120c25f 100644 --- a/tests/Unit/Admin/ProductControllerTest.php +++ b/tests/Unit/Admin/ProductControllerTest.php @@ -398,6 +398,168 @@ final class ProductControllerTest extends TestCase self::assertSame([], $db->auditActions()); } + // --- Editeur de recette (PR-B, product_ingredient, permission ingredient.manage) --- + + public function testRecipeFormRequiresIngredientManage(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->canResult = false; // ni ingredient.manage ni rien + + self::assertSame(403, $this->controller($this->get('/admin/products/5/recipe'), $db)->recipeForm(['id' => '5'])->status()); + } + + public function testRecipeFormNotFound(): void + { + $db = $this->permittedDb(); + $db->productRow = null; + + self::assertSame(404, $this->controller($this->get('/admin/products/9/recipe'), $db)->recipeForm(['id' => '9'])->status()); + } + + public function testRecipeFormShowsCompositionAndPicker(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientsRows = [$this->ingredientPick(7, 'Cheddar'), $this->ingredientPick(8, 'Cornichon')]; + $db->compositionRows = [[ + 'product_id' => 5, 'ingredient_id' => 7, 'quantity_normal' => 2, 'quantity_maxi' => 3, + 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, + 'ingredient_name' => 'Cheddar', 'ingredient_unit' => 'tranche', + 'stock_quantity' => 50, 'stock_capacity' => 100, 'low_stock_pct' => 10, 'critical_stock_pct' => 5, + ]]; + + $response = $this->controller($this->get('/admin/products/5/recipe'), $db)->recipeForm(['id' => '5']); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Big Mac', $response->body()); + self::assertStringContainsString('Cheddar', $response->body()); // picker + composition existante + self::assertStringContainsString('composition_json', $response->body()); + } + + public function testSaveRecipeReplacesCompositionInTransaction(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientRow = ['id' => 7, 'name' => 'Cheddar']; // ingredientExists -> true + $json = (string) json_encode([[ + 'ingredient_id' => 7, 'quantity_normal' => 2, 'quantity_maxi' => 3, + 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 50, + ]]); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame(['begin', 'commit'], $db->transactionEvents); + self::assertTrue($db->wrote('DELETE FROM product_ingredient')); // delete-and-reinsert (RG-2) + $insert = $this->findWrite($db, 'INSERT INTO product_ingredient'); + self::assertNotNull($insert); + self::assertSame(5, $insert['params']['product'] ?? null); + self::assertSame(7, $insert['params']['ingredient'] ?? null); + self::assertSame(2, $insert['params']['qn'] ?? null); + self::assertSame(3, $insert['params']['qm'] ?? null); + self::assertSame(50, $insert['params']['extra'] ?? null); + } + + public function testSaveRecipeEmptyClearsComposition(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + + // Composition vide : un produit peut n'avoir aucune recette definie -> on + // purge sans erreur (DELETE seul, aucun INSERT). + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => '[]'], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM product_ingredient')); + self::assertFalse($db->wrote('INSERT INTO product_ingredient')); + } + + public function testSaveRecipeRejectsMaxiBelowNormal(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientRow = ['id' => 7, 'name' => 'Cheddar']; + $json = (string) json_encode([[ + 'ingredient_id' => 7, 'quantity_normal' => 3, 'quantity_maxi' => 1, // viole quantity_maxi >= quantity_normal + 'is_removable' => 0, 'is_addable' => 0, 'extra_price_cents' => 0, + ]]); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('product_ingredient')); // aucun ecrit (validation RG-T18) + } + + public function testSaveRecipeDropsUnknownIngredient(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientRow = null; // ingredientExists -> false : ligne ignoree (allowlist) + $json = (string) json_encode([[ + 'ingredient_id' => 999, 'quantity_normal' => 1, 'quantity_maxi' => 1, + 'is_removable' => 0, 'is_addable' => 0, 'extra_price_cents' => 0, + ]]); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM product_ingredient')); + self::assertFalse($db->wrote('INSERT INTO product_ingredient')); // l'ingredient inconnu est filtre + } + + public function testSaveRecipeRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + + $response = $this->controller($this->post(['_csrf' => 'bad', 'composition_json' => '[]'], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('product_ingredient')); + } + + public function testIndexFlagsStockDrivenRupture(): void + { + $db = $this->permittedDb(); + $db->productsRows = [ + ['id' => 1, 'category_id' => 3, 'name' => 'Big Mac', 'price_cents' => 590, 'vat_rate' => 100, 'is_available' => 1, 'category_name' => 'Burgers'], + ]; + $db->autoUnavailableRows = [['product_id' => 1]]; // un ingredient requis en bande critique (RG-T21) + + $response = $this->controller($this->get('/admin/products'), $db)->index(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Rupture auto', $response->body()); // distinct du retrait manuel + } + + public function testDestroyTracesCascadedCompositionCount(): void + { + // Dette #27 : la suppression dure cascade product_ingredient (FK CASCADE) ; + // on trace combien de lignes de recette ont ete emportees, pour ne laisser + // aucune perte hors-trace dans l'audit_log. + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->productCompositionCount = 3; + $this->actingPin($db); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(302, $response->status()); + $audit = $this->firstAudit($db); + self::assertNotNull($audit); + self::assertSame('product.delete', $audit['params']['code'] ?? null); + self::assertStringContainsString('3', (string) ($audit['params']['summary'] ?? '')); // nb de lignes cascade tracees + } + + /** + * @return array + */ + private function ingredientPick(int $id, string $name): array + { + return ['id' => $id, 'name' => $name, 'unit' => 'tranche', 'stock_quantity' => 50, 'stock_capacity' => 100, 'pack_size' => 1, 'pack_label' => null, 'low_stock_pct' => 10, 'critical_stock_pct' => 5, 'is_active' => 1]; + } + /** * @return array{sql: string, params: array}|null */ diff --git a/tests/Unit/Catalogue/ProductRepositoryTest.php b/tests/Unit/Catalogue/ProductRepositoryTest.php new file mode 100644 index 0000000..e309278 --- /dev/null +++ b/tests/Unit/Catalogue/ProductRepositoryTest.php @@ -0,0 +1,73 @@ + $over + * @return array + */ + private function line(array $over = []): array + { + return array_merge([ + 'is_removable' => 0, + 'stock_quantity' => 50, + 'stock_capacity' => 100, + 'low_stock_pct' => 10, + 'critical_stock_pct' => 5, + ], $over); + } + + public function testManualUnavailabilityAlwaysBlocks(): void + { + // is_available=0 : retrait manuel, prime meme sur un stock plein (surcharge forte). + self::assertFalse(ProductRepository::isOrderable(false, [$this->line()])); + } + + public function testAvailableWithoutCompositionIsOrderable(): void + { + self::assertTrue(ProductRepository::isOrderable(true, [])); + } + + public function testRequiredIngredientAtCriticalBlocks(): void + { + // Requis, 5/100 <= 5% -> bande critique -> rupture automatique. + self::assertFalse(ProductRepository::isOrderable(true, [$this->line(['stock_quantity' => 5])])); + } + + public function testRequiredIngredientJustAboveCriticalDoesNotBlock(): void + { + // Requis, 6/100 > 5% -> bande basse (pas critique) -> reste commandable. + self::assertTrue(ProductRepository::isOrderable(true, [$this->line(['stock_quantity' => 6])])); + } + + public function testRemovableIngredientAtCriticalDoesNotBlock(): void + { + // Retirable (is_removable=1) a 0 : seul son supplement saute, le produit reste commandable. + self::assertTrue(ProductRepository::isOrderable(true, [$this->line(['is_removable' => 1, 'stock_quantity' => 0])])); + } + + public function testOneRequiredCriticalAmongManyBlocks(): void + { + self::assertFalse(ProductRepository::isOrderable(true, [ + $this->line(['stock_quantity' => 50]), // requis, ok + $this->line(['is_removable' => 1, 'stock_quantity' => 0]), // retirable critique, ok + $this->line(['stock_quantity' => 5]), // requis critique -> bloque + ])); + } +}