feat(admin): stock ingredients - CRUD, restock, inventaire PIN, mouvements (P3, mlt 8.8 + domaine 9)
All checks were successful
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 43s
CI / auto-merge (push) Has been skipped
CI / secret-scan (pull_request) Successful in 8s
CI / php-lint (pull_request) Successful in 21s
CI / static-tests (pull_request) Successful in 47s
CI / auto-merge (pull_request) Successful in 5s

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.
This commit is contained in:
Imugiii 2026-06-17 09:04:19 +00:00
parent c2a4854083
commit 44fa7557a7
14 changed files with 2270 additions and 0 deletions

View file

@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Catalogue;
use App\Core\DatabaseInterface;
/**
* Acces aux donnees du stock (sous-domaine Ingredients/stock). Suit le pattern
* etabli par CategoryRepository / ProductRepository (DatabaseInterface, requetes
* preparees, allowlist d'ecriture RG-T16).
*
* Modele de stock en pourcentage (mcd 5.3) : stock_capacity (> 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<int, array<string, mixed>>
*/
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<string, mixed>|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<int, array<string, mixed>>
*/
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<string, mixed> $row
* @return array<string, mixed>
*/
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;
}
}

View file

@ -0,0 +1,669 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use PDOException;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Auth\PasswordHasher;
use App\Auth\PinThrottle;
use App\Auth\PinVerifier;
use App\Catalogue\IngredientRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
/**
* Stock / Ingredients (P3, mlt 8.8 + domaine 9). Quatre familles d'operations,
* gardees par des permissions distinctes :
* - CRUD ingredient (8.8 MANAGE_INGREDIENT) : `ingredient.manage`, SANS PIN
* (8.8 n'est pas dans l'ensemble sensible RG-T13). Conflit d'unicite -> 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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string>}
*/
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<string, mixed> $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<string, mixed> $values
* @param array<string, string> $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<string, mixed> $ingredient
* @param array<string, mixed> $values
* @param array<string, string> $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<string, mixed> $ingredient
* @param array<string, mixed> $values
* @param array<string, string> $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<string, mixed> $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']);
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* Confirmation de suppression d'un ingredient, injecte dans admin/layout.php. La
* suppression d'ingredient n'est PAS une action sensible (8.8 hors RG-T13) : pas de
* PIN, juste une confirmation CSRF. La suppression dure echoue (409) si l'ingredient
* est reference par une recette ou un mouvement (FK RESTRICT) -> 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;
?>
<div class="page-header">
<div>
<h1 class="page-title">Supprimer un ingredient</h1>
<p class="page-subtitle">Confirmez la suppression de "<?= $ingredientName ?>".</p>
</div>
</div>
<section>
<?php if ($errorMessage !== null): ?>
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/admin/ingredients/<?= $id ?>/delete" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<p><small>Un ingredient deja utilise (recette ou mouvement de stock) ne peut pas etre supprime : desactivez-le a la place.</small></p>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Supprimer definitivement</button>
<a class="btn btn-secondary" href="/admin/ingredients">Annuler</a>
</div>
</form>
</section>

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* Formulaire ingredient (creation/edition), injecte dans admin/layout.php. Reaffiche
* valeurs + erreurs (RG-T18). Ne porte QUE les champs de definition : le stock
* (stock_quantity) se gere via reappro/inventaire, is_active via le bouton
* activer/desactiver de la liste (RG-T16). CSRF cache.
*
* @var int $ingredientId
* @var array<string, mixed> $values
* @var array<string, string> $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<string, mixed> $vals */
$vals = isset($values) && is_array($values) ? $values : [];
/** @var array<string, string> $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] : '';
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier l ingredient' : 'Nouvel ingredient' ?></h1>
</div>
</div>
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="name">Nom</label>
<input class="form-input" type="text" id="name" name="name" maxlength="120" value="<?= $val('name') ?>" required>
<?php if ($err('name') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('name'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="unit">Unite (ex. portion, sachet, piece)</label>
<input class="form-input" type="text" id="unit" name="unit" maxlength="40" value="<?= $val('unit') ?>" required>
<?php if ($err('unit') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('unit'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="stock_capacity">Capacite (reference 100%, en unites)</label>
<input class="form-input" type="number" id="stock_capacity" name="stock_capacity" min="1" value="<?= $val('stock_capacity') ?>" required>
<?php if ($err('stock_capacity') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('stock_capacity'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="pack_size">Taille d un pack de reappro (unites)</label>
<input class="form-input" type="number" id="pack_size" name="pack_size" min="1" max="65535" value="<?= $val('pack_size') ?>" required>
<?php if ($err('pack_size') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('pack_size'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="pack_label">Libelle du pack (optionnel)</label>
<input class="form-input" type="text" id="pack_label" name="pack_label" maxlength="80" value="<?= $val('pack_label') ?>">
<?php if ($err('pack_label') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('pack_label'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="low_stock_pct">Seuil d alerte (% de la capacite)</label>
<input class="form-input" type="number" id="low_stock_pct" name="low_stock_pct" min="0" max="100" value="<?= $val('low_stock_pct') ?>" required>
<?php if ($err('low_stock_pct') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('low_stock_pct'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="critical_stock_pct">Seuil critique (% de la capacite, &lt; alerte)</label>
<input class="form-input" type="number" id="critical_stock_pct" name="critical_stock_pct" min="0" max="100" value="<?= $val('critical_stock_pct') ?>" required>
<?php if ($err('critical_stock_pct') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('critical_stock_pct'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<?php if ($id === 0): ?>
<p><small>Le stock initial est a 0 : etablissez-le ensuite via un reapprovisionnement ou un inventaire (chaque mouvement est trace).</small></p>
<?php endif; ?>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Enregistrer</button>
<a class="btn btn-secondary" href="/admin/ingredients">Annuler</a>
</div>
</form>

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/**
* Liste du stock (READ_STOCK 9.3), injectee dans admin/layout.php. Affiche le
* pourcentage et la bande calcules (RG-2) ; les liens d'action sont conditionnes
* aux permissions (la garde reelle reste par-route). Texte echappe.
*
* @var array<int, array<string, mixed>> $ingredients
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
* @var string $csrfToken
*/
/** @var array<int, array<string, mixed>> $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',
};
?>
<div class="page-header">
<div>
<h1 class="page-title">Stock</h1>
<p class="page-subtitle">Ingredients, niveaux de stock et mouvements</p>
</div>
<?php if ($manage): ?>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/ingredients/new">Nouvel ingredient</a>
</div>
<?php endif; ?>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Ingredient</th>
<th>Unite</th>
<th>Stock</th>
<th>Niveau</th>
<th>Statut</th>
<th style="width:280px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="6" class="muted">Aucun ingredient.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
$band = (string) ($row['stock_band'] ?? 'normal');
$pct = (int) ($row['stock_pct'] ?? 0);
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="muted"><?= $esc($row['unit'] ?? '') ?></td>
<td>
<?= $esc((string) ((int) ($row['stock_quantity'] ?? 0))) ?>
<span class="muted">/ <?= $esc((string) ((int) ($row['stock_capacity'] ?? 0))) ?> (<?= $pct ?>%)</span>
</td>
<td><span class="<?= $bandLabel($band) ?>"><?= $bandText($band) ?></span></td>
<td>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($restock): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/restock">Reappro</a>
<?php endif; ?>
<?php if ($count): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<?php if ($manage): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-secondary" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
</form>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* Inventaire (INVENTORY_COUNT 9.2), injecte dans admin/layout.php. Action sensible :
* exige le PIN equipier (email + PIN, RG-T13). Le comptage absolu cale le stock et
* ecrit une correction tracee a l'equipier (stock_movement.user_id), sans audit_log
* (RG-T14). CSRF cache.
*
* @var int $ingredientId
* @var array<string, mixed> $ingredient
* @var array<string, mixed> $values
* @var array<string, string> $errors
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($ingredientId ?? 0);
/** @var array<string, mixed> $ing */
$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : [];
/** @var array<string, mixed> $vals */
$vals = isset($values) && is_array($values) ? $values : [];
/** @var array<string, string> $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] : '';
?>
<div class="page-header">
<div>
<h1 class="page-title">Inventaire</h1>
<p class="page-subtitle"><?= $esc($ing['name'] ?? '') ?> - stock theorique <?= $esc((string) ((int) ($ing['stock_quantity'] ?? 0))) ?> <?= $esc($ing['unit'] ?? '') ?></p>
</div>
</div>
<form method="post" action="/admin/ingredients/<?= $id ?>/inventory" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<p><small>Saisissez le comptage physique reel. L'ecart avec le theorique est enregistre et impute a l'equipier (action tracee).</small></p>
<div class="form-group">
<label class="form-label" for="actual_quantity">Comptage physique</label>
<input class="form-input" type="number" id="actual_quantity" name="actual_quantity" min="0" value="<?= $val('actual_quantity') ?>" required>
<?php if ($err('actual_quantity') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('actual_quantity'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="note">Raison (optionnelle)</label>
<input class="form-input" type="text" id="note" name="note" maxlength="255" value="<?= $val('note') ?>">
<?php if ($err('note') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('note'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<fieldset class="form-group">
<legend>Confirmation par PIN equipier</legend>
<div class="form-group">
<label class="form-label" for="pin_email">Votre email</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off" required>
</div>
<div class="form-group">
<label class="form-label" for="pin">Votre PIN</label>
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
</div>
<?php if ($err('pin') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('pin'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Valider l inventaire</button>
<a class="btn btn-secondary" href="/admin/ingredients">Annuler</a>
</div>
</form>

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Historique des mouvements de stock d'un ingredient (READ_STOCK 9.3 RG-3), injecte
* dans admin/layout.php. RG-4 : la colonne "acteur" n'est rendue que pour
* manager/admin ($showActor) ; le personnel de ligne voit les deltas sans l'auteur.
* Texte echappe.
*
* @var array<string, mixed> $ingredient
* @var array<int, array<string, mixed>> $movements
* @var bool $showActor
* @var array<int, string> $actorNames
*/
/** @var array<string, mixed> $ing */
$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : [];
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($movements) && is_array($movements) ? $movements : [];
/** @var array<int, string> $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;
?>
<div class="page-header">
<div>
<h1 class="page-title">Mouvements - <?= $esc($ing['name'] ?? '') ?></h1>
<p class="page-subtitle">Stock actuel <?= $esc((string) ((int) ($ing['stock_quantity'] ?? 0))) ?> <?= $esc($ing['unit'] ?? '') ?> (<?= (int) ($ing['stock_pct'] ?? 0) ?>%)</p>
</div>
<div class="page-actions">
<a class="btn btn-secondary" href="/admin/ingredients">Retour</a>
</div>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Delta</th>
<th>Note</th>
<?php if ($withActor): ?><th>Acteur</th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="<?= $colspan ?>" class="muted">Aucun mouvement.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$delta = (int) ($row['delta'] ?? 0);
$uid = $row['user_id'] !== null ? (int) $row['user_id'] : 0;
?>
<tr>
<td class="muted"><?= $esc($row['created_at'] ?? '') ?></td>
<td><?= $esc($typeText((string) ($row['movement_type'] ?? ''))) ?></td>
<td><?= $delta > 0 ? '+' . $delta : (string) $delta ?></td>
<td class="muted"><?= $esc($row['note'] ?? '') ?></td>
<?php if ($withActor): ?>
<td class="muted"><?= $uid > 0 ? $esc($names[$uid] ?? ('#' . $uid)) : '-' ?></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/**
* Reapprovisionnement (RESTOCK 9.1), injecte dans admin/layout.php. SANS PIN
* (9.1 hors ensemble sensible RG-T13) : +N packs => stock += N * pack_size. CSRF cache.
*
* @var int $ingredientId
* @var array<string, mixed> $ingredient
* @var array<string, mixed> $values
* @var array<string, string> $errors
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($ingredientId ?? 0);
/** @var array<string, mixed> $ing */
$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : [];
/** @var array<string, mixed> $vals */
$vals = isset($values) && is_array($values) ? $values : [];
/** @var array<string, string> $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'] ?? '');
?>
<div class="page-header">
<div>
<h1 class="page-title">Reapprovisionner</h1>
<p class="page-subtitle"><?= $esc($ing['name'] ?? '') ?> - stock actuel <?= $esc((string) ((int) ($ing['stock_quantity'] ?? 0))) ?> <?= $esc($ing['unit'] ?? '') ?></p>
</div>
</div>
<form method="post" action="/admin/ingredients/<?= $id ?>/restock" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<p><small>Un pack = <?= $esc((string) $packSize) ?> unite(s)<?= $packLabel !== '' ? ' (' . $esc($packLabel) . ')' : '' ?>. Le stock augmente de N x taille de pack.</small></p>
<div class="form-group">
<label class="form-label" for="packs">Nombre de packs recus</label>
<input class="form-input" type="number" id="packs" name="packs" min="1" max="65535" value="<?= $val('packs') ?>" required>
<?php if ($err('packs') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('packs'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="note">Note (optionnelle : ref. livraison)</label>
<input class="form-input" type="text" id="note" name="note" maxlength="255" value="<?= $val('note') ?>">
<?php if ($err('note') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('note'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Enregistrer le reappro</button>
<a class="btn btn-secondary" href="/admin/ingredients">Annuler</a>
</div>
</form>

View file

@ -111,6 +111,13 @@ $navClass = static function (string $code, string $current): string {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($can('stock.read')): ?>
<div class="sidebar-section">
<div class="sidebar-section-label">Stock</div>
<a href="/admin/ingredients" class="<?= $navClass('stock', $active) ?>">Ingredients</a>
</div>
<?php endif; ?>
<?php /* <?php /*
Items de nav volontairement absents tant que leur page n'existe pas Items de nav volontairement absents tant que leur page n'existe pas
(un lien vers une route non enregistree renvoie un 404). A reactiver (un lien vers une route non enregistree renvoie un 404). A reactiver

View file

@ -16,6 +16,7 @@ use App\Controllers\CategoryController;
use App\Controllers\DashboardController; use App\Controllers\DashboardController;
use App\Controllers\HealthController; use App\Controllers\HealthController;
use App\Controllers\HomeController; use App\Controllers\HomeController;
use App\Controllers\IngredientController;
use App\Controllers\MeController; use App\Controllers\MeController;
use App\Controllers\MenuController; use App\Controllers\MenuController;
use App\Controllers\PasswordResetController; use App\Controllers\PasswordResetController;
@ -104,6 +105,24 @@ try {
$router->add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']); $router->add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']);
$router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']); $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 = $router->dispatch(Request::fromGlobals());
$response->send(); $response->send();
} catch (Throwable $exception) { } catch (Throwable $exception) {

View file

@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PDOException;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Catalogue\IngredientRepository;
use App\Core\Config;
use App\Core\Database;
/**
* Comportement reel d'IngredientRepository contre une vraie MariaDB (schema migre
* + seede). Auto-skip si WAKDO_DB_TESTS != 1. Ingredient jetable (nom it-ing-*) ;
* nettoyage en tearDown : on retire d'abord ses mouvements (FK stock_movement
* RESTRICT) puis l'ingredient.
*/
final class IngredientRepositoryDbTest extends TestCase
{
private Database $db;
private string $name = '';
private int $userId = 0;
protected function setUp(): void
{
if (getenv('WAKDO_DB_TESTS') !== '1') {
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
}
$this->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<string, int|string|null> $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);
}
}

View file

@ -158,6 +158,41 @@ final class FakeDatabase implements DatabaseInterface
/** Resultat de MenuRepository::isReferencedByOrders() (true = reference par une commande). */ /** Resultat de MenuRepository::isReferencedByOrders() (true = reference par une commande). */
public bool $menuReferenced = false; public bool $menuReferenced = false;
/**
* Ligne renvoyee pour IngredientRepository::find() et les lectures ciblees de
* restock/inventory (pack_size, stock_quantity) ; null = introuvable.
*
* @var array<string, mixed>|null
*/
public ?array $ingredientRow = null;
/**
* Lignes renvoyees par IngredientRepository::all().
*
* @var list<array<string, mixed>>
*/
public array $ingredientsRows = [];
/** Resultat de IngredientRepository::nameExists(). */
public bool $ingredientNameTaken = false;
/**
* Lignes renvoyees par IngredientRepository::movements().
*
* @var list<array<string, mixed>>
*/
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<string>|null
*/
public ?array $grantedCodes = null;
/** /**
* Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ;
* null = email inconnu/inactif. * 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 (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; return ($this->canResult && $this->roleActive) ? ['granted' => 1] : null;
} }
@ -262,6 +303,16 @@ final class FakeDatabase implements DatabaseInterface
return $this->menuReferenced ? ['menu_id' => 1] : null; 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')) { if (str_contains($sql, 'FROM category WHERE name = :name')) {
return $this->categoryNameTaken ? ['id' => 1] : null; return $this->categoryNameTaken ? ['id' => 1] : null;
} }
@ -309,6 +360,14 @@ final class FakeDatabase implements DatabaseInterface
return $this->menuSlotRows; 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 (str_contains($sql, 'SELECT p.code FROM role_permission')) {
if (!$this->roleActive) { if (!$this->roleActive) {
return []; return [];

View file

@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PDOException;
use PHPUnit\Framework\TestCase;
use App\Auth\Csrf;
use App\Auth\PasswordHasher;
use App\Auth\SessionManager;
use App\Controllers\IngredientController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
/**
* Sous-classe de test : le seam db() injecte le double, sessionManager() la session.
*/
final class TestIngredientController extends IngredientController
{
public function __construct(
Request $request,
Config $config,
Database $database,
private readonly SessionManager $testSession,
private readonly FakeDatabase $fakeDb,
) {
parent::__construct($request, $config, $database);
}
protected function sessionManager(): SessionManager
{
return $this->testSession;
}
protected function db(): DatabaseInterface
{
return $this->fakeDb;
}
}
final class IngredientControllerTest extends TestCase
{
/** @var list<string> */
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<string, mixed> $overrides
* @return array<string, mixed>
*/
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<string, string> $overrides
* @return array<string, string>
*/
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<string, string> $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<string|int, mixed>|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)
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Catalogue;
use PHPUnit\Framework\TestCase;
use App\Catalogue\IngredientRepository;
/**
* Logique pure du calcul de stock (pourcentage + bande a 3 niveaux), sans base.
* Le pourcentage et la bande sont CALCULES, jamais stockes (mcd 5.3) : ces deux
* fonctions sont la source unique de cette derivation, reutilisee par all()/find()
* et par les vues. Le stock peut etre negatif (survente assumee) -> 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
}
}