release: dev -> main v0.2.0 #93
14 changed files with 2270 additions and 0 deletions
297
src/app/Catalogue/IngredientRepository.php
Normal file
297
src/app/Catalogue/IngredientRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
669
src/app/Controllers/IngredientController.php
Normal file
669
src/app/Controllers/IngredientController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
45
src/app/Views/admin/ingredients/delete.php
Normal file
45
src/app/Views/admin/ingredients/delete.php
Normal 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>
|
||||
88
src/app/Views/admin/ingredients/form.php
Normal file
88
src/app/Views/admin/ingredients/form.php
Normal 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, < 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>
|
||||
109
src/app/Views/admin/ingredients/index.php
Normal file
109
src/app/Views/admin/ingredients/index.php
Normal 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>
|
||||
72
src/app/Views/admin/ingredients/inventory.php
Normal file
72
src/app/Views/admin/ingredients/inventory.php
Normal 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>
|
||||
79
src/app/Views/admin/ingredients/movements.php
Normal file
79
src/app/Views/admin/ingredients/movements.php
Normal 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>
|
||||
59
src/app/Views/admin/ingredients/restock.php
Normal file
59
src/app/Views/admin/ingredients/restock.php
Normal 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>
|
||||
|
|
@ -111,6 +111,13 @@ $navClass = static function (string $code, string $current): string {
|
|||
</div>
|
||||
<?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 /*
|
||||
Items de nav volontairement absents tant que leur page n'existe pas
|
||||
(un lien vers une route non enregistree renvoie un 404). A reactiver
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use App\Controllers\CategoryController;
|
|||
use App\Controllers\DashboardController;
|
||||
use App\Controllers\HealthController;
|
||||
use App\Controllers\HomeController;
|
||||
use App\Controllers\IngredientController;
|
||||
use App\Controllers\MeController;
|
||||
use App\Controllers\MenuController;
|
||||
use App\Controllers\PasswordResetController;
|
||||
|
|
@ -104,6 +105,24 @@ try {
|
|||
$router->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) {
|
||||
|
|
|
|||
258
tests/Integration/IngredientRepositoryDbTest.php
Normal file
258
tests/Integration/IngredientRepositoryDbTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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) ;
|
||||
* 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 [];
|
||||
|
|
|
|||
443
tests/Unit/Admin/IngredientControllerTest.php
Normal file
443
tests/Unit/Admin/IngredientControllerTest.php
Normal 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)
|
||||
}
|
||||
}
|
||||
66
tests/Unit/Catalogue/IngredientRepositoryTest.php
Normal file
66
tests/Unit/Catalogue/IngredientRepositoryTest.php
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue