From 44fa7557a70af33e7955c811bee44efc184b8c17 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Wed, 17 Jun 2026 09:04:19 +0000 Subject: [PATCH] feat(admin): stock ingredients - CRUD, restock, inventaire PIN, mouvements (P3, mlt 8.8 + domaine 9) PR-A du lot P3 stock. Couche complete de gestion des ingredients et du stock, gardee par des permissions distinctes par operation : - CRUD ingredient (8.8) : ingredient.manage, sans PIN (hors set RG-T13). Conflit d'unicite name + hard-delete bloque par FK RESTRICT -> 409. - RESTOCK (9.1) : stock.manage, sans PIN ; +N packs -> stock += N*pack_size + stock_movement(restock) dans une transaction ; order_id NULL (RG-I6). - INVENTORY_COUNT (9.2) : stock.count + PIN equipier (RG-T13). Ecrit une ligne stock_movement(inventory_correction) MEME si delta=0 (RG-3). Succes -> stock_movement.user_id (acteur resolu par PIN), PAS d'audit_log (RG-T14). Echec PIN -> pin.failed + throttle dans UNE transaction (RG-T22). - READ_STOCK (9.3) : stock.read ; user_id des mouvements visible manager/admin seulement (RG-4). Tests : 239 / 717 assertions verts (WAKDO_DB_TESTS=1, 24 d'integration DB reels), PHPStan L6 propre. --- src/app/Catalogue/IngredientRepository.php | 297 ++++++++ src/app/Controllers/IngredientController.php | 669 ++++++++++++++++++ src/app/Views/admin/ingredients/delete.php | 45 ++ src/app/Views/admin/ingredients/form.php | 88 +++ src/app/Views/admin/ingredients/index.php | 109 +++ src/app/Views/admin/ingredients/inventory.php | 72 ++ src/app/Views/admin/ingredients/movements.php | 79 +++ src/app/Views/admin/ingredients/restock.php | 59 ++ src/app/Views/admin/layout.php | 7 + src/public/admin/index.php | 19 + .../IngredientRepositoryDbTest.php | 258 +++++++ tests/Support/FakeDatabase.php | 59 ++ tests/Unit/Admin/IngredientControllerTest.php | 443 ++++++++++++ .../Catalogue/IngredientRepositoryTest.php | 66 ++ 14 files changed, 2270 insertions(+) create mode 100644 src/app/Catalogue/IngredientRepository.php create mode 100644 src/app/Controllers/IngredientController.php create mode 100644 src/app/Views/admin/ingredients/delete.php create mode 100644 src/app/Views/admin/ingredients/form.php create mode 100644 src/app/Views/admin/ingredients/index.php create mode 100644 src/app/Views/admin/ingredients/inventory.php create mode 100644 src/app/Views/admin/ingredients/movements.php create mode 100644 src/app/Views/admin/ingredients/restock.php create mode 100644 tests/Integration/IngredientRepositoryDbTest.php create mode 100644 tests/Unit/Admin/IngredientControllerTest.php create mode 100644 tests/Unit/Catalogue/IngredientRepositoryTest.php diff --git a/src/app/Catalogue/IngredientRepository.php b/src/app/Catalogue/IngredientRepository.php new file mode 100644 index 0000000..7d9e6b5 --- /dev/null +++ b/src/app/Catalogue/IngredientRepository.php @@ -0,0 +1,297 @@ + 0) = 100 % de + * reference ; stock_pct et la bande (normal/low/critical) sont CALCULES, jamais + * stockes (stockPct/stockBand). stock_quantity est SIGNE : il peut devenir + * negatif quand les ventes depassent le stock compte (survente assumee, remontee + * au manager) ; le systeme ne bloque jamais une commande sur le stock. + * + * Le stock ne bouge JAMAIS par ecriture directe de stock_quantity hors creation : + * - restock(...) : +N packs (mlt 9.1), sans PIN, acteur capture par permission ; + * - inventoryCount(...) : comptage absolu (mlt 9.2), PIN, ecrit une ligne MEME si delta=0. + * Chaque mouvement insere une ligne stock_movement (journal append-only) dans la + * MEME transaction que la mise a jour du stock (RG-T08). L'imputabilite passe par + * stock_movement.user_id, PAS par audit_log (RG-T14 exclut le stock du double-journal). + * + * Topologie FK (db/migrations/0001) : ingredient est reference par product_ingredient + * (RESTRICT) et stock_movement (RESTRICT) -> la suppression dure est bloquee des + * qu'une recette ou un mouvement existe ; le controleur traduit la violation + * (SQLSTATE 23000) en 409 et propose la desactivation (is_active). + */ +final class IngredientRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Liste pour le back-office, enrichie du pourcentage et de la bande calcules. + * + * @return array> + */ + public function all(): array + { + $rows = $this->db->fetchAll( + 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient ORDER BY name', + ); + + return array_map([self::class, 'withStatus'], $rows); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + $row = $this->db->fetch( + 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient WHERE id = :id', + ['id' => $id], + ); + + return $row === null ? null : self::withStatus($row); + } + + public function nameExists(string $name, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM ingredient WHERE name = :name AND id <> :id', + ['name' => $name, 'id' => $exceptId], + ) !== null; + } + + /** + * Creation : pose les valeurs initiales, stock_quantity inclus (point de + * depart du stock). Allowlist RG-T16. + * + * @param array{name: string, unit: string, stock_quantity: int, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int, is_active: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO ingredient (name, unit, stock_quantity, stock_capacity, pack_size, ' + . 'pack_label, low_stock_pct, critical_stock_pct, is_active) ' + . 'VALUES (:name, :unit, :qty, :cap, :pack, :label, :low, :crit, :active)', + [ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'qty' => $data['stock_quantity'], + 'cap' => $data['stock_capacity'], + 'pack' => $data['pack_size'], + 'label' => $data['pack_label'], + 'low' => $data['low_stock_pct'], + 'crit' => $data['critical_stock_pct'], + 'active' => $data['is_active'], + ], + ); + } + + /** + * Mise a jour des attributs de definition. Allowlist RG-T16 : stock_quantity + * et is_active NE sont PAS modifiables ici. Le stock ne bouge que via + * restock/inventoryCount (ledger) ; is_active bascule via setActive + * (soft-delete). Les lier ici ouvrirait une affectation de masse non voulue. + * + * @param array{name: string, unit: string, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE ingredient SET name = :name, unit = :unit, stock_capacity = :cap, ' + . 'pack_size = :pack, pack_label = :label, low_stock_pct = :low, ' + . 'critical_stock_pct = :crit WHERE id = :id', + [ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'cap' => $data['stock_capacity'], + 'pack' => $data['pack_size'], + 'label' => $data['pack_label'], + 'low' => $data['low_stock_pct'], + 'crit' => $data['critical_stock_pct'], + 'id' => $id, + ], + ); + } + + public function setActive(int $id, bool $active): int + { + return $this->db->execute( + 'UPDATE ingredient SET is_active = :a WHERE id = :id', + ['a' => $active ? 1 : 0, 'id' => $id], + ); + } + + /** + * Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement) + * des qu'une recette ou un mouvement reference l'ingredient ; le controleur + * attrape SQLSTATE 23000 -> 409 et propose la desactivation. + */ + public function delete(int $id): int + { + return $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]); + } + + /** + * Pre-verification FK-safe : l'ingredient est-il reference par une recette + * (product_ingredient) ou un mouvement de stock (stock_movement) ? Les deux + * FK sont RESTRICT, donc l'un ou l'autre bloque la suppression dure. + */ + public function isReferenced(int $id): bool + { + if ($this->db->fetch('SELECT ingredient_id FROM product_ingredient WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null) { + return true; + } + + return $this->db->fetch('SELECT id FROM stock_movement WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null; + } + + /** + * Reapprovisionnement (mlt 9.1) : +N packs => stock += N * pack_size, et une + * ligne stock_movement(restock) dans la MEME transaction (RG-T08). Sans PIN : + * $userId est l'acteur de session (capture par la permission stock.manage, + * RG-4), pas un acteur resolu par PIN. Les bornes d'entree (packs >= 1, mlt 9.1 + * PRE-3) sont validees par l'appelant (controleur, RG-T18), pas ici. + */ + public function restock(int $id, int $packs, ?int $userId, ?string $note = null): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $packs, $userId, $note): void { + $packSize = (int) ($db->fetch('SELECT pack_size FROM ingredient WHERE id = :id', ['id' => $id])['pack_size'] ?? 0); + $delta = $packs * $packSize; + $db->execute( + 'UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id', + ['delta' => $delta, 'id' => $id], + ); + $this->insertMovement($db, $id, 'restock', $delta, $userId, $note); + }); + } + + /** + * Inventaire (mlt 9.2) : comptage physique absolu => stock_quantity = compte, + * et une ligne stock_movement(inventory_correction, delta = compte - actuel) + * dans la MEME transaction. RG-3 : la ligne est ecrite MEME si delta = 0 (un + * comptage conforme reste une preuve de controle a tracer). $userId est + * l'acteur resolu par le PIN (RG-T13). La borne d'entree (compte >= 0, mlt 9.2 + * PRE-3) est validee par l'appelant (controleur, RG-T18), pas ici. + */ + public function inventoryCount(int $id, int $countedQuantity, ?int $userId, ?string $note = null): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $countedQuantity, $userId, $note): void { + $current = (int) ($db->fetch('SELECT stock_quantity FROM ingredient WHERE id = :id', ['id' => $id])['stock_quantity'] ?? 0); + $delta = $countedQuantity - $current; + $db->execute( + 'UPDATE ingredient SET stock_quantity = :q WHERE id = :id', + ['q' => $countedQuantity, 'id' => $id], + ); + $this->insertMovement($db, $id, 'inventory_correction', $delta, $userId, $note); + }); + } + + /** + * Registre append-only des mouvements d'un ingredient, du plus recent au plus + * ancien, BORNE (mlt 9.3 READ_STOCK RG-3 prescrit LIMIT :n ; stock_movement + * croit a chaque vente, on ne materialise pas tout). La FK order_id reste NULL + * pour restock/inventory (renseignee cote commande en P4). La visibilite de + * user_id (RG-4 : manager/admin seulement) est appliquee par le controleur, pas ici. + * + * @return array> + */ + public function movements(int $id, int $limit = 50): array + { + // La borne est interpolee en entier (cast int + plancher 1) plutot que + // liee en placeholder : avec ATTR_EMULATE_PREPARES=false (Database), un + // ':limit' lie comme chaine fait echouer MariaDB sur LIMIT. Un int n'a + // aucun risque d'injection. + $bounded = max(1, $limit); + + return $this->db->fetchAll( + 'SELECT id, ingredient_id, movement_type, delta, order_id, user_id, note, created_at ' + . 'FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC, id DESC ' + . 'LIMIT ' . $bounded, + ['id' => $id], + ); + } + + private function insertMovement(DatabaseInterface $db, int $ingredientId, string $type, int $delta, ?int $userId, ?string $note): void + { + $db->execute( + 'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) ' + . 'VALUES (:ingredient, :type, :delta, NULL, :user, :note)', + [ + 'ingredient' => $ingredientId, + 'type' => $type, + 'delta' => $delta, + 'user' => $userId, + 'note' => $note, + ], + ); + } + + /** + * Pourcentage de stock = round(quantity / capacity * 100). Calcule, non stocke. + * Garde anti division par zero (stock_capacity porte un CHECK > 0 en base). + */ + public static function stockPct(int $quantity, int $capacity): int + { + if ($capacity <= 0) { + return 0; + } + + return (int) round($quantity * 100 / $capacity); + } + + /** + * Bande a 3 niveaux (mcd 5.3), en arithmetique entiere (pas de flottant) : + * - critical : quantity <= capacity * critical_pct / 100 (rupture auto) + * - low : quantity <= capacity * low_pct / 100 (alerte, encore commandable) + * - normal : au-dessus. + * Un stock negatif (survente) tombe en critical. critical_pct < low_pct est + * garanti par un CHECK de table. + */ + public static function stockBand(int $quantity, int $capacity, int $lowPct, int $critPct): string + { + if ($capacity <= 0) { + return 'critical'; + } + + $scaled = $quantity * 100; + if ($scaled <= $capacity * $critPct) { + return 'critical'; + } + if ($scaled <= $capacity * $lowPct) { + return 'low'; + } + + return 'normal'; + } + + /** + * Enrichit une ligne ingredient des champs calcules stock_pct et stock_band. + * + * @param array $row + * @return array + */ + private static function withStatus(array $row): array + { + $quantity = (int) ($row['stock_quantity'] ?? 0); + $capacity = (int) ($row['stock_capacity'] ?? 0); + $lowPct = (int) ($row['low_stock_pct'] ?? 0); + $critPct = (int) ($row['critical_stock_pct'] ?? 0); + + $row['stock_pct'] = self::stockPct($quantity, $capacity); + $row['stock_band'] = self::stockBand($quantity, $capacity, $lowPct, $critPct); + + return $row; + } +} diff --git a/src/app/Controllers/IngredientController.php b/src/app/Controllers/IngredientController.php new file mode 100644 index 0000000..f8cca52 --- /dev/null +++ b/src/app/Controllers/IngredientController.php @@ -0,0 +1,669 @@ + 409. + * - RESTOCK (9.1) : `stock.manage`, SANS PIN ; PRE-2 ingredient actif, PRE-3 N>=1 ; + * user_id = acteur de SESSION (capture par permission, RG-4). + * - INVENTORY_COUNT (9.2) : `stock.count` + PIN equipier (RG-T13) ; PRE-3 compte>=0 ; + * user_id = acteur resolu par PIN, ecrit dans stock_movement.user_id. PAS d'audit_log + * au succes (RG-T14 : le stock_movement EST la trace). Echec PIN -> pin.failed + + * throttle (RG-T22), comme produit/menu. + * - READ_STOCK (9.3) : `stock.read` ; le user_id des mouvements n'est expose qu'a + * manager/admin (RG-4), detecte via la permission stock.manage. + * + * Le stock ne bouge JAMAIS par le formulaire de definition : creation pose + * stock_quantity=0 (RG-CREATE-ING), update ne lie ni stock_quantity ni is_active + * (RG-T16 ; is_active bascule via toggle, soft-delete). Non `final` : les tests + * sous-classent pour injecter des doubles. + */ +class IngredientController extends AdminController +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('stock.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/ingredients/index', [ + 'title' => 'Stock - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredients' => $this->ingredientRepository()->all(), + 'canManage' => $this->may($guard, 'ingredient.manage'), + 'canRestock' => $this->may($guard, 'stock.manage'), + 'canCount' => $this->may($guard, 'stock.count'), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(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(); + } + + [$data, $errors] = $this->validate($form, 0); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + // stock_quantity initial = 0 (RG-CREATE-ING) ; is_active = 1 : valeurs posees + // cote serveur, pas liees au formulaire (RG-T16). Le stock s'etablit ensuite + // via restock/inventaire (chaque mouvement laisse une trace). + try { + $this->ingredientRepository()->create($data + ['stock_quantity' => 0, 'is_active' => 1]); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, 0, $form); + } + + $this->setFlash('Ingredient cree.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $ingredient, []); + } + + /** + * @param array $params + */ + public function update(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); + if ($this->ingredientRepository()->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, $id); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + try { + $this->ingredientRepository()->update($id, $data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, $id, $form); + } + + $this->setFlash('Ingredient mis a jour.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function toggle(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); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $newActive = (int) ($ingredient['is_active'] ?? 0) !== 1; + $this->ingredientRepository()->setActive($id, $newActive); + $this->setFlash($newActive ? 'Ingredient reactive.' : 'Ingredient desactive.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function confirmDelete(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderDelete($guard, $id, $ingredient, null); + } + + /** + * @param array $params + */ + public function destroy(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); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + // 8.8 n'est PAS dans l'ensemble PIN (RG-T13) : pas de PIN a la suppression. + // Hard-delete bloquee par FK RESTRICT (product_ingredient / stock_movement) + // -> PDOException 23000 -> 409 Conflit (proposer la desactivation). + try { + $this->ingredientRepository()->delete($id); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderDelete($guard, $id, $ingredient, 'Ingredient reference par une recette ou des mouvements de stock : suppression impossible. Desactivez-le plutot.', 409); + } + + throw $exception; + } + + $this->setFlash('Ingredient supprime.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function restockForm(array $params): Response + { + $guard = $this->guard('stock.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderRestock($guard, $id, $ingredient, [], []); + } + + /** + * @param array $params + */ + public function restock(array $params): Response + { + $guard = $this->guard('stock.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); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $errors = []; + + // PRE-2 (9.1) : on ne reapprovisionne qu'un ingredient actif. + if ((int) ($ingredient['is_active'] ?? 0) !== 1) { + $errors['packs'] = 'Ingredient inactif : reactivez-le avant de reapprovisionner.'; + } + + // PRE-3 (9.1) : N >= 1 (borne haute pour eviter un debordement de stock_quantity). + $packsRaw = trim($form['packs'] ?? ''); + $packsValid = ctype_digit($packsRaw) && (int) $packsRaw >= 1 && (int) $packsRaw <= 65535; + if (!$packsValid && !isset($errors['packs'])) { + $errors['packs'] = 'Le nombre de packs doit etre un entier entre 1 et 65535.'; + } + + $note = trim($form['note'] ?? ''); + if (mb_strlen($note) > 255) { + $errors['note'] = 'Note trop longue (255 caracteres max).'; + } + + if ($errors !== []) { + return $this->renderRestock($guard, $id, $ingredient, $form, $errors, 422); + } + + $this->ingredientRepository()->restock($id, (int) $packsRaw, $guard->userId, $note !== '' ? $note : null); + $this->setFlash('Reapprovisionnement enregistre.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function inventoryForm(array $params): Response + { + $guard = $this->guard('stock.count'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderInventory($guard, $id, $ingredient, [], []); + } + + /** + * @param array $params + */ + public function inventory(array $params): Response + { + $guard = $this->guard('stock.count'); + 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); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $errors = []; + + // PRE-3 (9.2) : comptage physique non negatif. ctype_digit borne deja >= 0. + $actualRaw = trim($form['actual_quantity'] ?? ''); + $actualValid = ctype_digit($actualRaw) && (int) $actualRaw <= 2147483647; + if (!$actualValid) { + $errors['actual_quantity'] = 'Le comptage doit etre un entier >= 0.'; + } + + $note = trim($form['note'] ?? ''); + if (mb_strlen($note) > 255) { + $errors['note'] = 'Note trop longue (255 caracteres max).'; + } + + if ($errors !== []) { + return $this->renderInventory($guard, $id, $ingredient, $form, $errors, 422); + } + + // RG-T13/RG-4 : correction d'inventaire = action sensible, PIN equipier. + // RG-T22 : verrou du throttle par utilisateur AGISSANT (session), evalue AVANT + // la verification ; sous verrou, leurre de timing et message generique, pas de + // nouvelle ligne pin.failed. + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderInventory($guard, $id, $ingredient, $form, ['pin' => 'Email ou PIN invalide (requis pour l inventaire).'], 422); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans UNE + // transaction. pin.failed est un evenement securite (aucun stock_movement + // n'est cree), il n'entre donc pas en conflit avec l'exclusion stock de RG-T14. + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void { + $this->logFailedPin($db, $email, $id); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderInventory($guard, $id, $ingredient, $form, ['pin' => 'Email ou PIN invalide (requis pour l inventaire).'], 422); + } + + // Succes : la correction ecrit stock_movement.user_id (acteur resolu par PIN). + // PAS de ligne audit_log (RG-T14 : la trace stock_movement suffit, pas de + // double-journal). inventoryCount ouvre sa propre transaction (UPDATE+INSERT). + $this->ingredientRepository()->inventoryCount($id, (int) $actualRaw, $actor['id'], $note !== '' ? $note : null); + $this->pinThrottle()->reset($actorId); + + $this->setFlash('Inventaire enregistre.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function movements(array $params): Response + { + $guard = $this->guard('stock.read'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + // RG-4 (9.3) : l'identite de l'acteur d'un mouvement n'est exposee qu'a + // manager/admin (detenteurs de stock.manage) ; le personnel de ligne voit + // les deltas sans l'auteur. + $showActor = $this->may($guard, 'stock.manage'); + $movements = $this->ingredientRepository()->movements($id); + + $actorNames = []; + if ($showActor) { + foreach ($movements as $movement) { + $uid = $movement['user_id'] !== null ? (int) $movement['user_id'] : 0; + if ($uid > 0 && !isset($actorNames[$uid])) { + $actorNames[$uid] = $this->userDirectory()->displayInfo($uid)['name']; + } + } + } + + return $this->adminView('admin/ingredients/movements', [ + 'title' => 'Mouvements de stock - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredient' => $ingredient, + 'movements' => $movements, + 'showActor' => $showActor, + 'actorNames' => $actorNames, + ], $guard); + } + + protected function ingredientRepository(): IngredientRepository + { + return new IngredientRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * RG-T03 : la permission est-elle detenue par le role de la session courante ? + * Utilise pour adapter l'affichage (liens d'action, visibilite acteur RG-4) sans + * remplacer la garde par-action (chaque route reste gardee independamment). + */ + private function may(GuardResult $guard, string $permission): bool + { + return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission); + } + + /** + * Validation serveur (RG-T18) + allowlist des champs de definition (RG-T16). + * stock_quantity et is_active ne sont jamais lies ici (poses cote serveur a la + * creation, modifies via restock/inventaire/toggle). Renvoie [donnees, erreurs]. + * + * @param array $form + * @return array{0: array{name: string, unit: string, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int}, 1: array} + */ + private function validate(array $form, int $exceptId): array + { + $errors = []; + + $name = trim($form['name'] ?? ''); + if ($name === '' || mb_strlen($name) > 120) { + $errors['name'] = 'Le nom est requis (120 caracteres max).'; + } elseif ($this->ingredientRepository()->nameExists($name, $exceptId)) { + $errors['name'] = 'Cet ingredient existe deja.'; + } + + $unit = trim($form['unit'] ?? ''); + if ($unit === '' || mb_strlen($unit) > 40) { + $errors['unit'] = 'L unite est requise (40 caracteres max).'; + } + + $capRaw = trim($form['stock_capacity'] ?? ''); + $capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647; + if (!$capValid) { + $errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.'; + } + + $packRaw = trim($form['pack_size'] ?? ''); + $packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535; + if (!$packValid) { + $errors['pack_size'] = 'La taille de pack doit etre un entier entre 1 et 65535.'; + } + + $label = trim($form['pack_label'] ?? ''); + if ($label !== '' && mb_strlen($label) > 80) { + $errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).'; + } + + $lowRaw = trim($form['low_stock_pct'] ?? ''); + $lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100; + if (!$lowValid) { + $errors['low_stock_pct'] = 'Le seuil d alerte doit etre un entier entre 0 et 100.'; + } + + $critRaw = trim($form['critical_stock_pct'] ?? ''); + $critValid = ctype_digit($critRaw) && (int) $critRaw <= 100; + if (!$critValid) { + $errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.'; + } + + // RG-CREATE-ING : critical_stock_pct < low_stock_pct (strict). + if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) { + $errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.'; + } + + $data = [ + 'name' => $name, + 'unit' => $unit, + 'stock_capacity' => $capValid ? (int) $capRaw : 0, + 'pack_size' => $packValid ? (int) $packRaw : 0, + 'pack_label' => $label !== '' ? $label : null, + 'low_stock_pct' => $lowValid ? (int) $lowRaw : 0, + 'critical_stock_pct' => $critValid ? (int) $critRaw : 0, + ]; + + return [$data, $errors]; + } + + /** + * Traduit une violation d'unicite (SQLSTATE 23000, name deja pris) en + * re-affichage 409 du formulaire (coherent avec la convention de conflit du + * back-office). Tout autre code est repropage. + * + * @param array $form + */ + private function onWriteConflict(PDOException $exception, GuardResult $guard, int $id, array $form): Response + { + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, $id, $form, ['name' => 'Cet ingredient existe deja.'], 409); + } + + throw $exception; + } + + private function logFailedPin(DatabaseInterface $db, string $email, int $ingredientId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'ingredient', + 'eid' => $ingredientId, + 'summary' => 'Echec PIN inventaire (email tente: ' . $email . ')', + ], + ); + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouvel') . ' ingredient - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'values' => [ + 'name' => (string) ($values['name'] ?? ''), + 'unit' => (string) ($values['unit'] ?? ''), + 'stock_capacity' => (string) ($values['stock_capacity'] ?? ''), + 'pack_size' => (string) ($values['pack_size'] ?? '1'), + 'pack_label' => (string) ($values['pack_label'] ?? ''), + 'low_stock_pct' => (string) ($values['low_stock_pct'] ?? '10'), + 'critical_stock_pct' => (string) ($values['critical_stock_pct'] ?? '5'), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + * @param array $values + * @param array $errors + */ + private function renderRestock(GuardResult $guard, int $id, array $ingredient, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/restock', [ + 'title' => 'Reapprovisionner - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'ingredient' => $ingredient, + 'values' => ['packs' => (string) ($values['packs'] ?? ''), 'note' => (string) ($values['note'] ?? '')], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + * @param array $values + * @param array $errors + */ + private function renderInventory(GuardResult $guard, int $id, array $ingredient, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/inventory', [ + 'title' => 'Inventaire - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'ingredient' => $ingredient, + 'values' => ['actual_quantity' => (string) ($values['actual_quantity'] ?? ''), 'note' => (string) ($values['note'] ?? '')], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + */ + private function renderDelete(GuardResult $guard, int $id, array $ingredient, ?string $error, ?int $status = null): Response + { + return $this->adminView('admin/ingredients/delete', [ + 'title' => 'Supprimer un ingredient - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'name' => (string) ($ingredient['name'] ?? ''), + 'error' => $error, + ], $guard, $status ?? ($error !== null ? 422 : 200)); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'stock'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Views/admin/ingredients/delete.php b/src/app/Views/admin/ingredients/delete.php new file mode 100644 index 0000000..30d06bf --- /dev/null +++ b/src/app/Views/admin/ingredients/delete.php @@ -0,0 +1,45 @@ + proposer la + * desactivation. CSRF cache. + * + * @var int $ingredientId + * @var string $name + * @var string|null $error + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +$ingredientName = htmlspecialchars((string) ($name ?? ''), ENT_QUOTES, 'UTF-8'); +$errorMessage = isset($error) && is_string($error) ? $error : null; +?> + + +
+ +

+ + +
+ + +

Un ingredient deja utilise (recette ou mouvement de stock) ne peut pas etre supprime : desactivez-le a la place.

+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/ingredients/form.php b/src/app/Views/admin/ingredients/form.php new file mode 100644 index 0000000..a8cbeba --- /dev/null +++ b/src/app/Views/admin/ingredients/form.php @@ -0,0 +1,88 @@ + $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +$action = $id !== 0 ? '/admin/ingredients/' . $id : '/admin/ingredients'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ + +

Le stock initial est a 0 : etablissez-le ensuite via un reapprovisionnement ou un inventaire (chaque mouvement est trace).

+ + +
+ + Annuler +
+
diff --git a/src/app/Views/admin/ingredients/index.php b/src/app/Views/admin/ingredients/index.php new file mode 100644 index 0000000..3204d7c --- /dev/null +++ b/src/app/Views/admin/ingredients/index.php @@ -0,0 +1,109 @@ +> $ingredients + * @var bool $canManage + * @var bool $canRestock + * @var bool $canCount + * @var string $csrfToken + */ + +/** @var array> $rows */ +$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : []; +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$manage = (bool) ($canManage ?? false); +$restock = (bool) ($canRestock ?? false); +$count = (bool) ($canCount ?? false); + +$bandLabel = static fn (string $band): string => match ($band) { + 'critical' => 'pill pill-danger', + 'low' => 'pill pill-warning', + default => 'pill pill-success', +}; +$bandText = static fn (string $band): string => match ($band) { + 'critical' => 'Critique', + 'low' => 'Alerte', + default => 'Normal', +}; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IngredientUniteStockNiveauStatut
Aucun ingredient.
+ + / (%) + + + Actif + + Inactif + + + Mouvements + + Reappro + + + Inventaire + + + Modifier +
+ + +
+ Supprimer + +
+
+
diff --git a/src/app/Views/admin/ingredients/inventory.php b/src/app/Views/admin/ingredients/inventory.php new file mode 100644 index 0000000..bdad7ce --- /dev/null +++ b/src/app/Views/admin/ingredients/inventory.php @@ -0,0 +1,72 @@ + $ingredient + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +

Saisissez le comptage physique reel. L'ecart avec le theorique est enregistre et impute a l'equipier (action tracee).

+ +
+ + +

+
+ +
+ + +

+
+ +
+ Confirmation par PIN equipier +
+ + +
+
+ + +
+

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/ingredients/movements.php b/src/app/Views/admin/ingredients/movements.php new file mode 100644 index 0000000..b53a756 --- /dev/null +++ b/src/app/Views/admin/ingredients/movements.php @@ -0,0 +1,79 @@ + $ingredient + * @var array> $movements + * @var bool $showActor + * @var array $actorNames + */ + +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array> $rows */ +$rows = isset($movements) && is_array($movements) ? $movements : []; +/** @var array $names */ +$names = isset($actorNames) && is_array($actorNames) ? $actorNames : []; +$withActor = (bool) ($showActor ?? false); + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$typeText = static fn (string $t): string => match ($t) { + 'restock' => 'Reappro', + 'inventory_correction' => 'Inventaire', + 'sale' => 'Vente', + 'cancellation' => 'Annulation', + default => $t, +}; +$colspan = $withActor ? 5 : 4; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateTypeDeltaNoteActeur
Aucun mouvement.
0 ? '+' . $delta : (string) $delta ?> 0 ? $esc($names[$uid] ?? ('#' . $uid)) : '-' ?>
+
+
diff --git a/src/app/Views/admin/ingredients/restock.php b/src/app/Views/admin/ingredients/restock.php new file mode 100644 index 0000000..e044758 --- /dev/null +++ b/src/app/Views/admin/ingredients/restock.php @@ -0,0 +1,59 @@ + stock += N * pack_size. CSRF cache. + * + * @var int $ingredientId + * @var array $ingredient + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$packSize = (int) ($ing['pack_size'] ?? 1); +$packLabel = (string) ($ing['pack_label'] ?? ''); +?> + + +
+ + +

Un pack = unite(s). Le stock augmente de N x taille de pack.

+ +
+ + +

+
+ +
+ + +

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index a8f174a..8d0036a 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -111,6 +111,13 @@ $navClass = static function (string $code, string $current): string { + + + + add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']); $router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']); + // Stock / Ingredients (P3, mlt 8.8 + domaine 9). Permissions par operation : + // stock.read (liste/mouvements, tous roles) ; ingredient.manage (CRUD, sans PIN) ; + // stock.manage (reappro, sans PIN) ; stock.count (inventaire, + PIN). Pas d'audit_log + // (RG-T14) : l'attribution passe par stock_movement.user_id. + $router->add('GET', '/admin/ingredients', [IngredientController::class, 'index']); + $router->add('GET', '/admin/ingredients/new', [IngredientController::class, 'create']); + $router->add('POST', '/admin/ingredients', [IngredientController::class, 'store']); + $router->add('GET', '/admin/ingredients/{id}/edit', [IngredientController::class, 'edit']); + $router->add('POST', '/admin/ingredients/{id}', [IngredientController::class, 'update']); + $router->add('POST', '/admin/ingredients/{id}/toggle', [IngredientController::class, 'toggle']); + $router->add('GET', '/admin/ingredients/{id}/delete', [IngredientController::class, 'confirmDelete']); + $router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']); + $router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']); + $router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']); + $router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']); + $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); + $router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/IngredientRepositoryDbTest.php b/tests/Integration/IngredientRepositoryDbTest.php new file mode 100644 index 0000000..9eb0e08 --- /dev/null +++ b/tests/Integration/IngredientRepositoryDbTest.php @@ -0,0 +1,258 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->userId = (int) ($this->db->fetch('SELECT id FROM user ORDER BY id LIMIT 1')['id'] ?? 0); + $this->name = 'it-ing-' . bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if ($this->name === '') { + return; + } + $id = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->name])['id'] ?? 0); + if ($id > 0) { + $this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $id]); + $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]); + } + } + + public function testCreateFindUpdateComputesPctAndBand(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 50, 'stock_capacity' => 100]); + + self::assertFalse($repo->nameExists($this->name, $id)); // s'exclut lui-meme + self::assertTrue($repo->nameExists($this->name)); + self::assertFalse($repo->isReferenced($id)); // ni recette ni mouvement + + $found = $repo->find($id); + self::assertNotNull($found); + self::assertSame(50, (int) $found['stock_pct']); + self::assertSame('normal', (string) $found['stock_band']); + + // all() porte aussi les champs calcules. + $names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all()); + self::assertContains($this->name, $names); + + // update ne touche ni stock_quantity ni is_active (allowlist RG-T16). + $repo->update($id, [ + 'name' => $this->name, + 'unit' => 'sachet', + 'stock_capacity' => 200, + 'pack_size' => 25, + 'pack_label' => 'Sac 25', + 'low_stock_pct' => 20, + 'critical_stock_pct' => 10, + ]); + $updated = $repo->find($id); + self::assertNotNull($updated); + self::assertSame(200, (int) $updated['stock_capacity']); + self::assertSame(50, (int) $updated['stock_quantity']); // inchange + self::assertSame(25, (int) $updated['stock_pct']); // 50/200 + } + + public function testRestockIncrementsStockAndRecordsMovement(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 0, 'stock_capacity' => 100, 'pack_size' => 50]); + + $repo->restock($id, 2, $this->userId, 'Livraison A'); + + self::assertSame(100, (int) ($repo->find($id)['stock_quantity'] ?? -1)); + $movements = $repo->movements($id); + self::assertCount(1, $movements); + self::assertSame('restock', (string) $movements[0]['movement_type']); + self::assertSame(100, (int) $movements[0]['delta']); + self::assertSame($this->userId, (int) $movements[0]['user_id']); + self::assertNull($movements[0]['order_id']); + self::assertTrue($repo->isReferenced($id)); // un mouvement reference l'ingredient + } + + public function testInventoryCountRecordsMovementEvenWhenDeltaZero(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 100, 'stock_capacity' => 100]); + + // Comptage conforme au theorique : delta = 0, MAIS une ligne est ecrite (RG-3). + $repo->inventoryCount($id, 100, $this->userId, 'Inventaire mensuel'); + $movements = $repo->movements($id); + self::assertCount(1, $movements); + self::assertSame('inventory_correction', (string) $movements[0]['movement_type']); + self::assertSame(0, (int) $movements[0]['delta']); + + // Comptage divergent : delta negatif, stock cale sur le compte. + $repo->inventoryCount($id, 30, $this->userId, null); + self::assertSame(30, (int) ($repo->find($id)['stock_quantity'] ?? -1)); + $movements = $repo->movements($id); + self::assertCount(2, $movements); // plus recent en tete + self::assertSame(-70, (int) $movements[0]['delta']); + } + + public function testReferencedIngredientCannotBeHardDeletedButCanBeDeactivated(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 0, 'stock_capacity' => 100, 'pack_size' => 10]); + $repo->restock($id, 1, $this->userId, null); // cree un mouvement -> FK RESTRICT + + $blocked = false; + try { + $repo->delete($id); + } catch (PDOException $exception) { + $blocked = (string) $exception->getCode() === '23000'; + } + self::assertTrue($blocked, 'La suppression dure doit etre bloquee par stock_movement (FK RESTRICT).'); + + // Repli : soft-delete via is_active. + self::assertSame(1, $repo->setActive($id, false)); + self::assertSame(0, (int) ($repo->find($id)['is_active'] ?? -1)); + } + + public function testUnreferencedIngredientCanBeHardDeleted(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 5, 'stock_capacity' => 100]); + + self::assertFalse($repo->isReferenced($id)); + self::assertSame(1, $repo->delete($id)); + self::assertNull($repo->find($id)); + } + + public function testRestockIsCumulative(): void + { + $repo = new IngredientRepository($this->db); + // Stock initial > 0 + DEUX restock : tue une mutation 'stock = :delta' (set) + // au lieu de 'stock += :delta', et un test qui partirait de 0. + $id = $this->createIngredient($repo, ['stock_quantity' => 30, 'stock_capacity' => 200, 'pack_size' => 50]); + + $repo->restock($id, 1, $this->userId, null); // 30 -> 80 + $repo->restock($id, 1, $this->userId, null); // 80 -> 130 + + self::assertSame(130, (int) ($repo->find($id)['stock_quantity'] ?? -1)); + $movements = $repo->movements($id); + self::assertCount(2, $movements); + self::assertSame(50, (int) $movements[0]['delta']); + self::assertSame(50, (int) $movements[1]['delta']); + } + + public function testRestockRollsBackWhenMovementInsertFails(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 40, 'stock_capacity' => 100, 'pack_size' => 10]); + + // user_id inexistant : l'UPDATE stock passe, l'INSERT stock_movement viole + // la FK user_id -> la transaction (RG-T08) doit TOUT annuler. + $rolledBack = false; + try { + $repo->restock($id, 2, 2147483647, null); + } catch (PDOException $exception) { + $rolledBack = (string) $exception->getCode() === '23000'; + } + self::assertTrue($rolledBack, 'La violation FK user_id doit lever une 23000.'); + self::assertSame(40, (int) ($repo->find($id)['stock_quantity'] ?? -1)); // stock intact (rollback) + self::assertCount(0, $repo->movements($id)); // aucun mouvement laisse + } + + public function testDuplicateNameViolatesUniqueConstraint(): void + { + $repo = new IngredientRepository($this->db); + $this->createIngredient($repo); + + // Meme name : la contrainte DB uk_ingredient_name (independante de l'appel + // applicatif nameExists) doit rejeter le doublon. + $violated = false; + try { + $this->createIngredient($repo); + } catch (PDOException $exception) { + $violated = (string) $exception->getCode() === '23000'; + } + self::assertTrue($violated, 'uk_ingredient_name doit rejeter un doublon (SQLSTATE 23000).'); + } + + public function testMovementsAreBoundedByLimit(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_capacity' => 100, 'pack_size' => 1]); + $repo->restock($id, 1, $this->userId, null); + $repo->restock($id, 1, $this->userId, null); + $repo->restock($id, 1, $this->userId, null); + + self::assertCount(3, $repo->movements($id)); // defaut large + self::assertCount(2, $repo->movements($id, 2)); // borne LIMIT (RG-3) sur la vraie base + } + + public function testMovementsOrderByCreatedAtBeforeId(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_capacity' => 100, 'pack_size' => 1]); + $repo->restock($id, 1, $this->userId, 'recent-1'); + $repo->restock($id, 1, $this->userId, 'recent-2'); + // Mouvement au created_at le plus ANCIEN mais a l'id le plus ELEVE (insere en dernier) : + // prouve que created_at DESC prime sur le tie-breaker id DESC. + $this->db->execute( + "INSERT INTO stock_movement (ingredient_id, movement_type, delta, created_at) " + . "VALUES (:id, 'inventory_correction', 0, '2000-01-01 00:00:00')", + ['id' => $id], + ); + + $movements = $repo->movements($id); + self::assertCount(3, $movements); + self::assertSame('2000-01-01 00:00:00', (string) $movements[2]['created_at']); // ancien -> dernier + } + + /** + * @param array $overrides + */ + private function createIngredient(IngredientRepository $repo, array $overrides = []): int + { + $repo->create([ + 'name' => $this->name, + 'unit' => 'portion', + 'stock_quantity' => (int) ($overrides['stock_quantity'] ?? 0), + 'stock_capacity' => (int) ($overrides['stock_capacity'] ?? 100), + 'pack_size' => (int) ($overrides['pack_size'] ?? 1), + 'pack_label' => $overrides['pack_label'] ?? null, + 'low_stock_pct' => (int) ($overrides['low_stock_pct'] ?? 10), + 'critical_stock_pct' => (int) ($overrides['critical_stock_pct'] ?? 5), + 'is_active' => 1, + ]); + + return (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->name])['id'] ?? 0); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index cdf0d6e..82869fa 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -158,6 +158,41 @@ final class FakeDatabase implements DatabaseInterface /** Resultat de MenuRepository::isReferencedByOrders() (true = reference par une commande). */ public bool $menuReferenced = false; + /** + * Ligne renvoyee pour IngredientRepository::find() et les lectures ciblees de + * restock/inventory (pack_size, stock_quantity) ; null = introuvable. + * + * @var array|null + */ + public ?array $ingredientRow = null; + + /** + * Lignes renvoyees par IngredientRepository::all(). + * + * @var list> + */ + public array $ingredientsRows = []; + + /** Resultat de IngredientRepository::nameExists(). */ + public bool $ingredientNameTaken = false; + + /** + * Lignes renvoyees par IngredientRepository::movements(). + * + * @var list> + */ + public array $movementsRows = []; + + /** + * 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 + * differenciation par permission, ex. RG-4 : stock.read sans stock.manage) ; + * sinon on retombe sur le bouton global $canResult. + * + * @var list|null + */ + public ?array $grantedCodes = null; + /** * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; * null = email inconnu/inactif. @@ -223,6 +258,12 @@ final class FakeDatabase implements DatabaseInterface } if (str_contains($sql, 'SELECT 1 AS granted FROM role_permission')) { + if ($this->grantedCodes !== null) { + $code = $params['code'] ?? null; + + return (is_string($code) && in_array($code, $this->grantedCodes, true) && $this->roleActive) ? ['granted' => 1] : null; + } + return ($this->canResult && $this->roleActive) ? ['granted' => 1] : null; } @@ -262,6 +303,16 @@ final class FakeDatabase implements DatabaseInterface return $this->menuReferenced ? ['menu_id' => 1] : null; } + // Ingredient : nameExists (avant la route par id, qui ne matche pas + // 'WHERE name'), puis find() + lectures ciblees pack_size/stock_quantity. + if (str_contains($sql, 'FROM ingredient WHERE name = :name')) { + return $this->ingredientNameTaken ? ['id' => 1] : null; + } + + if (str_contains($sql, 'FROM ingredient WHERE id = :id')) { + return $this->ingredientRow; + } + if (str_contains($sql, 'FROM category WHERE name = :name')) { return $this->categoryNameTaken ? ['id' => 1] : null; } @@ -309,6 +360,14 @@ final class FakeDatabase implements DatabaseInterface return $this->menuSlotRows; } + if (str_contains($sql, 'FROM ingredient ORDER BY name')) { + return $this->ingredientsRows; + } + + if (str_contains($sql, 'FROM stock_movement WHERE ingredient_id')) { + return $this->movementsRows; + } + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; diff --git a/tests/Unit/Admin/IngredientControllerTest.php b/tests/Unit/Admin/IngredientControllerTest.php new file mode 100644 index 0000000..121b99b --- /dev/null +++ b/tests/Unit/Admin/IngredientControllerTest.php @@ -0,0 +1,443 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class IngredientControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Sam', 'last_name' => 'K', 'role_label' => 'Manager']; + $db->canResult = true; + $db->permissionCodes = ['stock.read', 'ingredient.manage', 'stock.manage', 'stock.count']; + $db->ingredientRow = $this->ingredient(); + + return $db; + } + + /** + * @param array $overrides + * @return array + */ + private function ingredient(array $overrides = []): array + { + return array_merge([ + 'id' => 5, 'name' => 'Cheddar', 'unit' => 'tranche', + 'stock_quantity' => 40, 'stock_capacity' => 100, 'pack_size' => 10, + 'pack_label' => 'Sachet 10', 'low_stock_pct' => 10, 'critical_stock_pct' => 5, + 'is_active' => 1, + ], $overrides); + } + + /** + * @param array $overrides + * @return array + */ + private function validForm(array $overrides = []): array + { + return array_merge([ + '_csrf' => $this->csrf, + 'name' => 'Cheddar', + 'unit' => 'tranche', + 'stock_capacity' => '100', + 'pack_size' => '10', + 'pack_label' => 'Sachet 10', + 'low_stock_pct' => '10', + 'critical_stock_pct' => '5', + ], $overrides); + } + + private function actingPin(FakeDatabase $db): void + { + $db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')]; + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5'); + } + + private function controller(Request $request, FakeDatabase $db): TestIngredientController + { + return new TestIngredientController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + /** + * @return array|null + */ + private function writeParams(FakeDatabase $db, string $needle): ?array + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write['params']; + } + } + + return null; + } + + private function writeSql(FakeDatabase $db, string $needle): string + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write['sql']; + } + } + + return ''; + } + + // --- Lecture (READ_STOCK 9.3) --- + + public function testIndexListsStockForStockReader(): void + { + $db = $this->permittedDb(); + $db->ingredientsRows = [$this->ingredient(['stock_quantity' => 8])]; // 8% -> bande alerte + + $response = $this->controller($this->get('/admin/ingredients'), $db)->index(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Cheddar', $response->body()); + self::assertStringContainsString('Alerte', $response->body()); + } + + public function testIndexForbiddenWithoutStockRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($this->get('/admin/ingredients'), $db)->index()->status()); + } + + // --- CRUD ingredient (8.8, ingredient.manage, SANS PIN) --- + + public function testStoreCreatesWithZeroStockAndActiveServerSet(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store(); + + self::assertSame(302, $response->status()); + $params = $this->writeParams($db, 'INSERT INTO ingredient'); + self::assertNotNull($params); + self::assertSame(0, $params['qty']); // stock_quantity initial = 0 (RG-CREATE-ING) + self::assertSame(1, $params['active']); // is_active pose cote serveur (RG-T16) + } + + public function testStoreRejectsInvalidInput(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['name' => '', 'stock_capacity' => '0']), '/admin/ingredients'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO ingredient')); + } + + public function testStoreRejectsCriticalNotStrictlyBelowLow(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['low_stock_pct' => '5', 'critical_stock_pct' => '5']), '/admin/ingredients'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('strictement inferieur', $response->body()); + } + + public function testStoreRejectsDuplicateName(): void + { + $db = $this->permittedDb(); + $db->ingredientNameTaken = true; + + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO ingredient')); + } + + public function testStoreTranslatesUniqueRaceTo409(): void + { + $db = $this->permittedDb(); + $db->failOnExecute = new PDOException('duplicate', 23000); + + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store(); + + self::assertSame(409, $response->status()); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['_csrf' => 'bad']), '/admin/ingredients'), $db)->store(); + + self::assertSame(403, $response->status()); + } + + public function testUpdateDoesNotBindStockOrActive(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + $sql = $this->writeSql($db, 'UPDATE ingredient'); + self::assertNotSame('', $sql); + self::assertStringNotContainsString('stock_quantity', $sql); // RG-T16 + self::assertStringNotContainsString('is_active', $sql); // RG-T16 (bascule via toggle) + } + + public function testUpdateNotFound(): void + { + $db = $this->permittedDb(); + $db->ingredientRow = null; + + self::assertSame(404, $this->controller($this->post($this->validForm(), '/admin/ingredients/9'), $db)->update(['id' => '9'])->status()); + } + + public function testToggleFlipsActive(): void + { + $db = $this->permittedDb(); // is_active = 1 -> doit basculer a 0 + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/toggle'), $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + $params = $this->writeParams($db, 'UPDATE ingredient SET is_active'); + self::assertNotNull($params); + self::assertSame(0, $params['a']); + } + + public function testDestroyUnreferencedDeletesWithoutPin(): void + { + $db = $this->permittedDb(); + // Aucun champ PIN dans le form : 8.8 n'est pas une action sensible. + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM ingredient')); + } + + public function testDestroyReferencedReturns409(): void + { + $db = $this->permittedDb(); + $db->failOnExecute = new PDOException('fk', 23000); // FK RESTRICT (recette / mouvement) + + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(409, $response->status()); + self::assertStringContainsString('reference', $response->body()); + } + + // --- RESTOCK (9.1, stock.manage, SANS PIN) --- + + public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void + { + $db = $this->permittedDb(); // pack_size 10 + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '2', 'note' => 'Livraison A'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame(['begin', 'commit'], $db->transactionEvents); + self::assertTrue($db->wrote('SET stock_quantity = stock_quantity +')); + $movement = $this->writeParams($db, 'INSERT INTO stock_movement'); + self::assertNotNull($movement); + self::assertSame('restock', $movement['type']); + self::assertSame(20, $movement['delta']); // 2 packs x pack_size 10 + self::assertSame(1, $movement['user']); // acteur de SESSION (RG-4), pas un PIN + self::assertSame([], $db->auditActions()); // pas d'audit_log (RG-T14) + } + + public function testRestockRejectedWhenInactive(): void + { + $db = $this->permittedDb(); + $db->ingredientRow = $this->ingredient(['is_active' => 0]); // PRE-2 + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '2'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('stock_movement')); + } + + public function testRestockRejectsPacksBelowOne(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '0'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('stock_movement')); + } + + // --- INVENTORY_COUNT (9.2, stock.count + PIN) --- + + public function testInventoryWithValidPinRecordsCorrectionUnderPinActorWithoutAudit(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); // equipier id 9, PIN 4729 + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '30', 'note' => 'mensuel', + 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(302, $response->status()); + $movement = $this->writeParams($db, 'INSERT INTO stock_movement'); + self::assertNotNull($movement); + self::assertSame('inventory_correction', $movement['type']); + self::assertSame(-10, $movement['delta']); // 30 compte - 40 theorique + self::assertSame(9, $movement['user']); // acteur resolu par PIN (RG-4) + self::assertSame([], $db->auditActions()); // RG-T14 : pas de double-journal + } + + public function testInventoryWithBadPinLogsFailedAndChangesNoStock(): void + { + $db = $this->permittedDb(); + $db->actingUserRow = null; // email/PIN non resolu + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '30', + 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame(['pin.failed'], $db->auditActions()); // trace detective (RG-T22) + self::assertFalse($db->wrote('stock_movement')); // aucun effet sur le stock + } + + public function testInventoryLockedActorReturns422WithoutEffect(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); + $db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', time() + 300); // verrou actif + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '30', + 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou (RG-T22) + self::assertFalse($db->wrote('stock_movement')); + } + + public function testInventoryRejectsNegativeCount(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '-5', + 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('stock_movement')); + } + + // --- Visibilite de l'acteur (RG-4) --- + + public function testMovementsShowActorForManager(): void + { + $db = $this->permittedDb(); + $db->grantedCodes = ['stock.read', 'stock.manage']; // manager + $db->movementsRows = [['id' => 1, 'ingredient_id' => 5, 'movement_type' => 'restock', 'delta' => 20, 'order_id' => null, 'user_id' => 9, 'note' => null, 'created_at' => '2026-06-17 09:00:00']]; + + $response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Acteur', $response->body()); + self::assertStringContainsString('Sam K', $response->body()); // nom resolu + } + + public function testMovementsHideActorForLineStaff(): void + { + $db = $this->permittedDb(); + $db->grantedCodes = ['stock.read']; // ligne : stock.read sans stock.manage + $db->movementsRows = [['id' => 1, 'ingredient_id' => 5, 'movement_type' => 'restock', 'delta' => 20, 'order_id' => null, 'user_id' => 9, 'note' => null, 'created_at' => '2026-06-17 09:00:00']]; + + $response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']); + + self::assertSame(200, $response->status()); + self::assertStringNotContainsString('Acteur', $response->body()); // colonne masquee (RG-4) + } +} diff --git a/tests/Unit/Catalogue/IngredientRepositoryTest.php b/tests/Unit/Catalogue/IngredientRepositoryTest.php new file mode 100644 index 0000000..e05e33e --- /dev/null +++ b/tests/Unit/Catalogue/IngredientRepositoryTest.php @@ -0,0 +1,66 @@ + bande critique. + */ +final class IngredientRepositoryTest extends TestCase +{ + public function testStockPctRoundsQuantityOverCapacity(): void + { + self::assertSame(50, IngredientRepository::stockPct(50, 100)); + self::assertSame(0, IngredientRepository::stockPct(0, 100)); + self::assertSame(100, IngredientRepository::stockPct(100, 100)); + self::assertSame(33, IngredientRepository::stockPct(1, 3)); // arrondi + self::assertSame(-10, IngredientRepository::stockPct(-10, 100)); // survente + // Cas ou l'arrondi MONTE : verrouille round() vs troncature/floor. + self::assertSame(67, IngredientRepository::stockPct(2, 3)); // round(66.67) -> 67 + self::assertSame(13, IngredientRepository::stockPct(1, 8)); // round(12.5) -> 13 (half away from zero) + } + + public function testStockPctGuardsAgainstZeroCapacity(): void + { + // stock_capacity porte un CHECK > 0 en base ; garde defensive si une ligne + // aberrante arrive quand meme (pas de division par zero). + self::assertSame(0, IngredientRepository::stockPct(10, 0)); + } + + public function testStockBandNormalAboveLowThreshold(): void + { + self::assertSame('normal', IngredientRepository::stockBand(50, 100, 10, 5)); + self::assertSame('normal', IngredientRepository::stockBand(11, 100, 10, 5)); + } + + public function testStockBandLowAtOrUnderLowThreshold(): void + { + self::assertSame('low', IngredientRepository::stockBand(10, 100, 10, 5)); + self::assertSame('low', IngredientRepository::stockBand(6, 100, 10, 5)); + } + + public function testStockBandCriticalAtOrUnderCriticalThreshold(): void + { + self::assertSame('critical', IngredientRepository::stockBand(5, 100, 10, 5)); + self::assertSame('critical', IngredientRepository::stockBand(0, 100, 10, 5)); + self::assertSame('critical', IngredientRepository::stockBand(-3, 100, 10, 5)); // survente + } + + public function testStockBandStressesIntegerArithmeticAtNonHundredCapacity(): void + { + // capacity != 100 : seuil low = 150*10/100 = 15, seuil critical = 150*5/100 = 7.5. + // L'arithmetique entiere (quantity*100 <= capacity*pct) doit tomber juste sur + // ces frontieres non rondes (sinon une mutation de la formule passerait). + self::assertSame('low', IngredientRepository::stockBand(15, 150, 10, 5)); // 1500 <= 1500 + self::assertSame('normal', IngredientRepository::stockBand(16, 150, 10, 5)); // 1600 > 1500 + self::assertSame('critical', IngredientRepository::stockBand(7, 150, 10, 5)); // 700 <= 750 + self::assertSame('low', IngredientRepository::stockBand(8, 150, 10, 5)); // 800 > 750, <= 1500 + } +}