feat(admin): recettes produit - composition product_ingredient + dispo calculee RG-T21 (P3, ferme #27)
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 20s
CI / static-tests (pull_request) Successful in 41s
CI / secret-scan (push) Successful in 8s
CI / static-tests (push) Successful in 45s
CI / auto-merge (pull_request) Successful in 4s
CI / php-lint (push) Successful in 19s
CI / auto-merge (push) Has been skipped
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 20s
CI / static-tests (pull_request) Successful in 41s
CI / secret-scan (push) Successful in 8s
CI / static-tests (push) Successful in 45s
CI / auto-merge (pull_request) Successful in 4s
CI / php-lint (push) Successful in 19s
CI / auto-merge (push) Has been skipped
PR-B du lot P3 stock. Editeur de recette (composition product_ingredient) et disponibilite produit calculee, sur la couche stock de PR-A. - Composition (ProductRepository) : composition() (JOIN ingredient), setComposition() en delete-and-reinsert dans UNE transaction (RG-2/RG-T08), ingredientExists() + dedup par PK composite, compositionCount(). FK product_id CASCADE, ingredient_id RESTRICT. - Editeur (ProductController) : recipeForm/saveRecipe gardes par ingredient.manage (composition, DISTINCTE du CRUD produit), sans PIN (hors RG-T13). Revalidation serveur RG-T18 + allowlist RG-T16 (ingredient inconnu filtre, bornes des CHECK). Vue recipe.php + product-recipe.js (builder vanilla CSP-safe, data-*). - Disponibilite calculee RG-T21 : isOrderable() = is_available ET chaque ingredient NON RETIRABLE au-dessus de la bande critique (reutilise IngredientRepository:: stockBand). Un ingredient retirable en critique ne bloque pas ; un retrait manuel prime. Badge "Rupture auto" dans la liste (autoUnavailableIds, distinct du retrait manuel). - Dette #27 close : la suppression dure d'un produit cascade product_ingredient ; le nombre de lignes emportees est compte et trace dans le resume d'audit. Tests : 259 / 777 assertions verts (WAKDO_DB_TESTS=1), PHPStan L6 propre.
This commit is contained in:
parent
1f4b9478ca
commit
06450b2db5
10 changed files with 1048 additions and 11 deletions
|
|
@ -17,9 +17,9 @@ use App\Core\DatabaseInterface;
|
|||
* (SQLSTATE 23000) -> 409 Conflit, plutot que de pre-tester chaque reference.
|
||||
* - CASCADE : product_ingredient (la recette appartient au produit ; la
|
||||
* supprimer avec le produit est voulu). La suppression n'est donc PAS bloquee
|
||||
* par une recette existante. TODO (phase stock/recettes, table aujourd'hui
|
||||
* vide) : tracer le nombre de lignes product_ingredient cascade-supprimees
|
||||
* dans l'audit_log pour ne laisser aucune perte hors-trace.
|
||||
* par une recette existante. Le nombre de lignes cascade-supprimees est compte
|
||||
* (compositionCount) et trace dans le resume d'audit par ProductController::destroy
|
||||
* (dette #27 close) pour ne laisser aucune perte hors-trace.
|
||||
*/
|
||||
final class ProductRepository
|
||||
{
|
||||
|
|
@ -89,6 +89,133 @@ final class ProductRepository
|
|||
return $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function ingredientExists(int $id): bool
|
||||
{
|
||||
return $this->db->fetch('SELECT id FROM ingredient WHERE id = :id', ['id' => $id]) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition (recette) d'un produit : lignes product_ingredient enrichies du
|
||||
* nom + de l'unite de l'ingredient et de ses champs de stock (pour la
|
||||
* disponibilite calculee RG-T21). Ordonnee par nom d'ingredient.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function composition(int $productId): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT pi.product_id, pi.ingredient_id, pi.quantity_normal, pi.quantity_maxi, '
|
||||
. 'pi.is_removable, pi.is_addable, pi.extra_price_cents, '
|
||||
. 'i.name AS ingredient_name, i.unit AS ingredient_unit, '
|
||||
. 'i.stock_quantity, i.stock_capacity, i.low_stock_pct, i.critical_stock_pct '
|
||||
. 'FROM product_ingredient pi JOIN ingredient i ON i.id = pi.ingredient_id '
|
||||
. 'WHERE pi.product_id = :id ORDER BY i.name',
|
||||
['id' => $productId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de lignes de composition d'un produit. Sert a tracer la cascade #27 :
|
||||
* combien de product_ingredient seront emportees par la suppression du produit
|
||||
* (FK product_id CASCADE), pour ne laisser aucune perte hors-trace dans l'audit.
|
||||
*/
|
||||
public function compositionCount(int $productId): int
|
||||
{
|
||||
return (int) ($this->db->fetch(
|
||||
'SELECT COUNT(*) AS n FROM product_ingredient WHERE product_id = :id',
|
||||
['id' => $productId],
|
||||
)['n'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace integralement la composition d'un produit (delete-and-reinsert, mlt
|
||||
* 8.5 RG-2 transpose a la recette) dans UNE transaction (RG-T08) : reposer
|
||||
* l'ensemble est plus simple et sur qu'une reconciliation en place. La PK
|
||||
* composite (product_id, ingredient_id) garantit l'unicite par ingredient ;
|
||||
* l'appelant (controleur) a deja deduplique et valide les bornes (RG-T18).
|
||||
*
|
||||
* @param list<array{ingredient_id:int, quantity_normal:int, quantity_maxi:int, is_removable:int, is_addable:int, extra_price_cents:int}> $lines
|
||||
*/
|
||||
public function setComposition(int $productId, array $lines): void
|
||||
{
|
||||
$this->db->transaction(function (DatabaseInterface $db) use ($productId, $lines): void {
|
||||
$db->execute('DELETE FROM product_ingredient WHERE product_id = :id', ['id' => $productId]);
|
||||
foreach ($lines as $line) {
|
||||
$db->execute(
|
||||
'INSERT INTO product_ingredient (product_id, ingredient_id, quantity_normal, '
|
||||
. 'quantity_maxi, is_removable, is_addable, extra_price_cents) '
|
||||
. 'VALUES (:product, :ingredient, :qn, :qm, :rem, :add, :extra)',
|
||||
[
|
||||
'product' => $productId,
|
||||
'ingredient' => $line['ingredient_id'],
|
||||
'qn' => $line['quantity_normal'],
|
||||
'qm' => $line['quantity_maxi'],
|
||||
'rem' => $line['is_removable'],
|
||||
'add' => $line['is_addable'],
|
||||
'extra' => $line['extra_price_cents'],
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids des produits en RUPTURE AUTOMATIQUE par le stock (RG-T21) : au moins un
|
||||
* ingredient requis (is_removable=0) au niveau ou sous la bande critique
|
||||
* (stock_quantity * 100 <= stock_capacity * critical_stock_pct, l'arithmetique
|
||||
* entiere de IngredientRepository::stockBand). Calcule en UNE requete pour
|
||||
* eviter le N+1 a l'affichage de la liste. Distinct du retrait manuel
|
||||
* (is_available=0), que la vue signale separement.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function autoUnavailableIds(): array
|
||||
{
|
||||
$rows = $this->db->fetchAll(
|
||||
'SELECT DISTINCT pi.product_id FROM product_ingredient pi '
|
||||
. 'JOIN ingredient i ON i.id = pi.ingredient_id '
|
||||
. 'WHERE pi.is_removable = 0 AND i.stock_quantity * 100 <= i.stock_capacity * i.critical_stock_pct',
|
||||
);
|
||||
|
||||
return array_map(static fn (array $r): int => (int) ($r['product_id'] ?? 0), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disponibilite produit CALCULEE (RG-T21) : commandable ssi le flag
|
||||
* is_available vaut 1 ET chaque ingredient NON RETIRABLE (is_removable=0) de la
|
||||
* composition est au-dessus de la bande critique (stockBand != 'critical').
|
||||
* Derivation pure, sans ecriture ni cascade : un ingredient requis tombant en
|
||||
* critique met le produit en rupture automatique ; un ingredient retirable/
|
||||
* optionnel en critique ne bloque pas (seul son supplement devient indispo) ;
|
||||
* un retrait manuel (is_available=0) prime sur tout. La bande critique est celle
|
||||
* d'IngredientRepository::stockBand (source unique de la derivation).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $composition lignes de composition()
|
||||
*/
|
||||
public static function isOrderable(bool $flagAvailable, array $composition): bool
|
||||
{
|
||||
if (!$flagAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($composition as $line) {
|
||||
if ((int) ($line['is_removable'] ?? 1) !== 0) {
|
||||
continue; // retirable/optionnel : n'entre pas dans la disponibilite du produit
|
||||
}
|
||||
$band = IngredientRepository::stockBand(
|
||||
(int) ($line['stock_quantity'] ?? 0),
|
||||
(int) ($line['stock_capacity'] ?? 0),
|
||||
(int) ($line['low_stock_pct'] ?? 0),
|
||||
(int) ($line['critical_stock_pct'] ?? 0),
|
||||
);
|
||||
if ($band === 'critical') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Auth\PasswordHasher;
|
|||
use App\Auth\PinThrottle;
|
||||
use App\Auth\PinVerifier;
|
||||
use App\Catalogue\CategoryRepository;
|
||||
use App\Catalogue\IngredientRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
|
|
@ -44,6 +45,9 @@ class ProductController extends AdminController
|
|||
'title' => 'Produits - Wakdo Admin',
|
||||
'activeNav' => 'products',
|
||||
'products' => $this->productRepository()->all(),
|
||||
// Rupture AUTOMATIQUE par le stock (RG-T21), distincte du retrait manuel
|
||||
// (is_available=0) : la vue signale les deux differemment.
|
||||
'autoUnavailable' => $this->productRepository()->autoUnavailableIds(),
|
||||
], $guard);
|
||||
}
|
||||
|
||||
|
|
@ -251,15 +255,23 @@ class ProductController extends AdminController
|
|||
|
||||
$name = (string) ($product['name'] ?? '');
|
||||
|
||||
// Dette #27 : product_ingredient (FK product_id CASCADE) sera emporte par la
|
||||
// suppression. On compte AVANT (lecture hors transaction) pour tracer le
|
||||
// nombre de lignes de recette cascade-supprimees dans le resume d'audit :
|
||||
// aucune perte hors-trace dans le journal append-only.
|
||||
$cascaded = $this->productRepository()->compositionCount($id);
|
||||
$summary = 'Suppression produit: ' . $name
|
||||
. ' (' . $cascaded . ' ligne(s) de recette cascade-supprimee(s))';
|
||||
|
||||
// FK RESTRICT (order_item / menu / menu_slot_option / order_item_selection)
|
||||
// -> PDOException 23000 -> 409 Conflit (catch ci-dessous). product_ingredient
|
||||
// est CASCADE (recette possedee par le produit) : supprimee avec lui, jamais
|
||||
// bloquante (cf. docblock ProductRepository).
|
||||
try {
|
||||
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void {
|
||||
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $summary): void {
|
||||
$deleted = (new ProductRepository($db))->delete($id);
|
||||
if ($deleted === 1) {
|
||||
$this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, 'Suppression produit: ' . $name);
|
||||
$this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, $summary);
|
||||
}
|
||||
});
|
||||
} catch (PDOException $exception) {
|
||||
|
|
@ -280,11 +292,75 @@ class ProductController extends AdminController
|
|||
return $this->redirect('/admin/products');
|
||||
}
|
||||
|
||||
/**
|
||||
* Editeur de recette (PR-B, mlt domaine recettes). Compose product_ingredient :
|
||||
* la commandabilite est gardee par `ingredient.manage` (composition du produit),
|
||||
* DISTINCTE de product.create/update/delete (CRUD produit). Aucun PIN : editer
|
||||
* une recette n'est pas une action sensible RG-T13.
|
||||
*
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function recipeForm(array $params): Response
|
||||
{
|
||||
$guard = $this->guard('ingredient.manage');
|
||||
if ($guard instanceof Response) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
$product = $this->productRepository()->find($id);
|
||||
if ($product === null) {
|
||||
return $this->notFound($guard);
|
||||
}
|
||||
|
||||
return $this->renderRecipe($guard, $id, $product, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function saveRecipe(array $params): Response
|
||||
{
|
||||
$guard = $this->guard('ingredient.manage');
|
||||
if ($guard instanceof Response) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$form = $this->request->formBody();
|
||||
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||
return $this->invalidCsrf();
|
||||
}
|
||||
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
$product = $this->productRepository()->find($id);
|
||||
if ($product === null) {
|
||||
return $this->notFound($guard);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$lines = $this->parseComposition($form['composition_json'] ?? '', $errors);
|
||||
if ($errors !== []) {
|
||||
return $this->renderRecipe($guard, $id, $product, $errors, 422);
|
||||
}
|
||||
|
||||
// Composition vide autorisee : un produit peut n'avoir aucune recette
|
||||
// definie (setComposition purge alors la table sans rien reinserer).
|
||||
$this->productRepository()->setComposition($id, $lines);
|
||||
$this->setFlash('Recette mise a jour.');
|
||||
|
||||
return $this->redirect('/admin/products');
|
||||
}
|
||||
|
||||
protected function productRepository(): ProductRepository
|
||||
{
|
||||
return new ProductRepository($this->db());
|
||||
}
|
||||
|
||||
protected function ingredientRepository(): IngredientRepository
|
||||
{
|
||||
return new IngredientRepository($this->db());
|
||||
}
|
||||
|
||||
protected function categoryRepository(): CategoryRepository
|
||||
{
|
||||
return new CategoryRepository($this->db());
|
||||
|
|
@ -416,6 +492,96 @@ class ProductController extends AdminController
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode + valide la composition soumise en JSON (champ cache composition_json),
|
||||
* RG-T18 (revalidation serveur) + RG-T16 (allowlist). Un ingredient inconnu est
|
||||
* FILTRE (jamais une erreur bloquante) ; la PK composite impose un ingredient au
|
||||
* plus une fois (dedup). Les bornes refletent les CHECK de table : quantity_normal
|
||||
* >= 1, quantity_maxi >= quantity_normal, extra_price_cents >= 0. Composition vide
|
||||
* = aucune ligne, sans erreur.
|
||||
*
|
||||
* @param array<string, string> $errors
|
||||
* @return list<array{ingredient_id:int, quantity_normal:int, quantity_maxi:int, is_removable:int, is_addable:int, extra_price_cents:int}>
|
||||
*/
|
||||
private function parseComposition(string $json, array &$errors): array
|
||||
{
|
||||
$json = trim($json);
|
||||
if ($json === '' || $json === '[]') {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($json, true);
|
||||
if (!is_array($decoded)) {
|
||||
$errors['composition'] = 'Composition invalide.';
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
$seen = [];
|
||||
foreach ($decoded as $raw) {
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ingredientId = is_numeric($raw['ingredient_id'] ?? null) ? (int) $raw['ingredient_id'] : 0;
|
||||
if ($ingredientId <= 0 || !$this->productRepository()->ingredientExists($ingredientId)) {
|
||||
continue; // ingredient inconnu : filtre (allowlist), pas une erreur
|
||||
}
|
||||
if (isset($seen[$ingredientId])) {
|
||||
continue; // PK composite (product_id, ingredient_id) : un seul par ingredient
|
||||
}
|
||||
|
||||
$qn = is_numeric($raw['quantity_normal'] ?? null) ? (int) $raw['quantity_normal'] : 0;
|
||||
$qm = is_numeric($raw['quantity_maxi'] ?? null) ? (int) $raw['quantity_maxi'] : 0;
|
||||
$extra = is_numeric($raw['extra_price_cents'] ?? null) ? (int) $raw['extra_price_cents'] : -1;
|
||||
|
||||
if ($qn < 1 || $qn > 65535) {
|
||||
$errors['composition'] = 'La quantite normale doit etre un entier >= 1.';
|
||||
continue;
|
||||
}
|
||||
if ($qm < $qn || $qm > 65535) {
|
||||
$errors['composition'] = 'La quantite maxi doit etre >= la quantite normale.';
|
||||
continue;
|
||||
}
|
||||
if ($extra < 0 || $extra > 4294967295) {
|
||||
$errors['composition'] = 'Le supplement (en centimes) doit etre un entier >= 0.';
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$ingredientId] = true;
|
||||
$lines[] = [
|
||||
'ingredient_id' => $ingredientId,
|
||||
'quantity_normal' => $qn,
|
||||
'quantity_maxi' => $qm,
|
||||
'is_removable' => empty($raw['is_removable']) ? 0 : 1,
|
||||
'is_addable' => empty($raw['is_addable']) ? 0 : 1,
|
||||
'extra_price_cents' => $extra,
|
||||
];
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $product
|
||||
* @param array<string, string> $errors
|
||||
*/
|
||||
private function renderRecipe(GuardResult $guard, int $id, array $product, array $errors, int $status = 200): Response
|
||||
{
|
||||
return $this->adminView('admin/products/recipe', [
|
||||
'title' => 'Recette - ' . (string) ($product['name'] ?? '') . ' - Wakdo Admin',
|
||||
'activeNav' => 'products',
|
||||
'productId' => $id,
|
||||
'productName' => (string) ($product['name'] ?? ''),
|
||||
'ingredients' => $this->ingredientRepository()->all(),
|
||||
'composition' => $this->productRepository()->composition($id),
|
||||
'errors' => $errors,
|
||||
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||
], $guard, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
* @param array<string, string> $errors
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ declare(strict_types=1);
|
|||
* Liste des produits (CRUD admin), injectee dans admin/layout.php. Texte echappe.
|
||||
*
|
||||
* @var array<int, array<string, mixed>> $products
|
||||
* @var list<int> $autoUnavailable ids en rupture auto (RG-T21)
|
||||
*/
|
||||
|
||||
/** @var array<int, array<string, mixed>> $rows */
|
||||
$rows = isset($products) && is_array($products) ? $products : [];
|
||||
/** @var list<int> $autoIds */
|
||||
$autoIds = isset($autoUnavailable) && is_array($autoUnavailable) ? array_map('intval', $autoUnavailable) : [];
|
||||
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR';
|
||||
?>
|
||||
|
|
@ -44,6 +47,7 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', '
|
|||
<?php
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$available = (int) ($row['is_available'] ?? 0) === 1;
|
||||
$autoRupture = in_array($id, $autoIds, true); // RG-T21 : stock-driven
|
||||
$vat = (int) ($row['vat_rate'] ?? 100);
|
||||
?>
|
||||
<tr>
|
||||
|
|
@ -52,14 +56,17 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', '
|
|||
<td><?= $esc($euros((int) ($row['price_cents'] ?? 0))) ?></td>
|
||||
<td class="muted"><?= $vat === 55 ? '5,5%' : '10%' ?></td>
|
||||
<td>
|
||||
<?php if ($available): ?>
|
||||
<span class="pill pill-success">Disponible</span>
|
||||
<?php else: ?>
|
||||
<?php if (!$available): ?>
|
||||
<span class="pill pill-neutral">Indisponible</span>
|
||||
<?php elseif ($autoRupture): ?>
|
||||
<span class="pill pill-warning" title="Un ingredient requis est en rupture critique (RG-T21)">Rupture auto</span>
|
||||
<?php else: ?>
|
||||
<span class="pill pill-success">Disponible</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-secondary" href="/admin/products/<?= $id ?>/edit">Modifier</a>
|
||||
<a class="btn btn-secondary" href="/admin/products/<?= $id ?>/recipe">Recette</a>
|
||||
<a class="btn btn-secondary" href="/admin/products/<?= $id ?>/delete">Supprimer</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
88
src/app/Views/admin/products/recipe.php
Normal file
88
src/app/Views/admin/products/recipe.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Editeur de recette d'un produit (composition product_ingredient), injecte dans
|
||||
* admin/layout.php. La composition est geree par le builder vanilla product-recipe.js
|
||||
* qui serialise son etat dans le champ cache composition_json a la soumission. Pas
|
||||
* de PIN (editer une recette n'est pas une action sensible RG-T13). Permission
|
||||
* ingredient.manage (distincte du CRUD produit). CSP 'self' : aucun script inline,
|
||||
* donnees passees en attributs data-*.
|
||||
*
|
||||
* @var int $productId
|
||||
* @var string $productName
|
||||
* @var array<int, array<string, mixed>> $ingredients catalogue pour le picker
|
||||
* @var array<int, array<string, mixed>> $composition lignes existantes
|
||||
* @var array<string, string> $errors
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$id = (int) ($productId ?? 0);
|
||||
$name = htmlspecialchars((string) ($productName ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$action = '/admin/products/' . $id . '/recipe';
|
||||
|
||||
/** @var array<int, array<string, mixed>> $ings */
|
||||
$ings = isset($ingredients) && is_array($ingredients) ? $ingredients : [];
|
||||
/** @var array<int, array<string, mixed>> $comp */
|
||||
$comp = isset($composition) && is_array($composition) ? $composition : [];
|
||||
/** @var array<string, string> $errs */
|
||||
$errs = isset($errors) && is_array($errors) ? $errors : [];
|
||||
$compError = isset($errs['composition']) && is_string($errs['composition']) ? $errs['composition'] : '';
|
||||
|
||||
// Donnees pour le builder JS, en attributs data-* (CSP 'self'). htmlspecialchars
|
||||
// rend le JSON sur-able comme valeur d'attribut.
|
||||
$slimIngredients = array_map(
|
||||
static fn (array $i): array => [
|
||||
'id' => (int) ($i['id'] ?? 0),
|
||||
'name' => (string) ($i['name'] ?? ''),
|
||||
'unit' => (string) ($i['unit'] ?? ''),
|
||||
],
|
||||
$ings,
|
||||
);
|
||||
$slimComposition = array_map(
|
||||
static fn (array $c): array => [
|
||||
'ingredient_id' => (int) ($c['ingredient_id'] ?? 0),
|
||||
'quantity_normal' => (int) ($c['quantity_normal'] ?? 1),
|
||||
'quantity_maxi' => (int) ($c['quantity_maxi'] ?? 1),
|
||||
'is_removable' => (int) ($c['is_removable'] ?? 0),
|
||||
'is_addable' => (int) ($c['is_addable'] ?? 0),
|
||||
'extra_price_cents' => (int) ($c['extra_price_cents'] ?? 0),
|
||||
],
|
||||
$comp,
|
||||
);
|
||||
$attr = static fn (mixed $data): string => htmlspecialchars(
|
||||
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||
ENT_QUOTES,
|
||||
'UTF-8',
|
||||
);
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Recette - <?= $name ?></h1>
|
||||
<p class="page-subtitle">Composition en ingredients (RG-T21 : la disponibilite du produit en decoule)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card" id="recipe-form">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
|
||||
<fieldset class="form-group">
|
||||
<legend>Ingredients</legend>
|
||||
<p><small>Un ingredient NON RETIRABLE en rupture critique met le produit en rupture automatique. Un ingredient retirable/optionnel ne bloque pas le produit.</small></p>
|
||||
<?php if ($compError !== ''): ?><p class="form-error"><?= htmlspecialchars($compError, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
<div id="recipe-builder"
|
||||
data-ingredients="<?= $attr($slimIngredients) ?>"
|
||||
data-composition="<?= $attr($slimComposition) ?>"></div>
|
||||
<button class="btn btn-secondary" type="button" id="add-ingredient">Ajouter un ingredient</button>
|
||||
</fieldset>
|
||||
|
||||
<input type="hidden" name="composition_json" id="composition_json" value="">
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Enregistrer la recette</button>
|
||||
<a class="btn btn-secondary" href="/admin/products">Retour</a>
|
||||
</div>
|
||||
</form>
|
||||
<script src="/assets/js/product-recipe.js"></script>
|
||||
164
src/public/admin/assets/js/product-recipe.js
Normal file
164
src/public/admin/assets/js/product-recipe.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* product-recipe.js — Builder de composition (recette) du formulaire produit.
|
||||
*
|
||||
* CSP 'self' : script externe (pas d'inline). Les donnees (catalogue d'ingredients,
|
||||
* composition initiale) sont lues depuis les attributs data-* de #recipe-builder.
|
||||
* A la soumission, l'etat est serialise en JSON dans le champ cache #composition_json
|
||||
* (Request::formBody cote serveur ne garde que les scalaires). Le serveur revalide
|
||||
* tout (RG-T18) : bornes, existence de l'ingredient, dedup par PK composite.
|
||||
*
|
||||
* Une composition VIDE est valide (un produit peut n'avoir aucune recette definie).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var builder = document.getElementById('recipe-builder');
|
||||
var form = document.getElementById('recipe-form');
|
||||
var hidden = document.getElementById('composition_json');
|
||||
var addBtn = document.getElementById('add-ingredient');
|
||||
if (!builder || !form || !hidden || !addBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
function parseData(key, fallback) {
|
||||
try {
|
||||
var v = JSON.parse(builder.dataset[key] || fallback);
|
||||
return Array.isArray(v) ? v : JSON.parse(fallback);
|
||||
} catch (e) {
|
||||
return JSON.parse(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
var ingredients = parseData('ingredients', '[]'); // [{id, name, unit}]
|
||||
var initial = parseData('composition', '[]'); // [{ingredient_id, quantity_normal, ...}]
|
||||
|
||||
function el(tag, className) {
|
||||
var e = document.createElement(tag);
|
||||
if (className) {
|
||||
e.className = className;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function numberInput(className, value, min) {
|
||||
var input = el('input', 'form-input ' + className);
|
||||
input.type = 'number';
|
||||
input.min = String(min);
|
||||
input.value = String(value);
|
||||
input.style.width = '7rem';
|
||||
return input;
|
||||
}
|
||||
|
||||
// Construit le bloc DOM d'une ligne de composition. `line` peut etre vide (ajout).
|
||||
function renderLine(line) {
|
||||
line = line || {};
|
||||
|
||||
var block = el('fieldset', 'recipe-line form-group');
|
||||
block.style.border = '1px solid #ddd';
|
||||
block.style.padding = '0.75rem';
|
||||
block.style.marginBottom = '0.75rem';
|
||||
|
||||
// Ingredient (picker)
|
||||
var ingLabel = el('label');
|
||||
ingLabel.appendChild(document.createTextNode('Ingredient '));
|
||||
var ingSelect = el('select', 'form-input recipe-ingredient');
|
||||
ingredients.forEach(function (i) {
|
||||
var opt = el('option');
|
||||
opt.value = String(i.id);
|
||||
opt.textContent = String(i.name) + (i.unit ? ' (' + String(i.unit) + ')' : '');
|
||||
if (Number(line.ingredient_id) === Number(i.id)) {
|
||||
opt.selected = true;
|
||||
}
|
||||
ingSelect.appendChild(opt);
|
||||
});
|
||||
ingLabel.appendChild(ingSelect);
|
||||
block.appendChild(ingLabel);
|
||||
|
||||
// Quantites
|
||||
var qnLabel = el('label');
|
||||
qnLabel.appendChild(document.createTextNode(' Qte normale '));
|
||||
qnLabel.appendChild(numberInput('recipe-qn', line.quantity_normal != null ? line.quantity_normal : 1, 1));
|
||||
block.appendChild(qnLabel);
|
||||
|
||||
var qmLabel = el('label');
|
||||
qmLabel.appendChild(document.createTextNode(' Qte maxi '));
|
||||
qmLabel.appendChild(numberInput('recipe-qm', line.quantity_maxi != null ? line.quantity_maxi : 1, 1));
|
||||
block.appendChild(qmLabel);
|
||||
|
||||
// Supplement (centimes)
|
||||
var extraLabel = el('label');
|
||||
extraLabel.appendChild(document.createTextNode(' Supplement (cts) '));
|
||||
extraLabel.appendChild(numberInput('recipe-extra', line.extra_price_cents != null ? line.extra_price_cents : 0, 0));
|
||||
block.appendChild(extraLabel);
|
||||
|
||||
// Retirable / Ajoutable
|
||||
var remLabel = el('label');
|
||||
var remInput = el('input', 'recipe-removable');
|
||||
remInput.type = 'checkbox';
|
||||
if (Number(line.is_removable) === 1) {
|
||||
remInput.checked = true;
|
||||
}
|
||||
remLabel.appendChild(remInput);
|
||||
remLabel.appendChild(document.createTextNode(' Retirable'));
|
||||
block.appendChild(remLabel);
|
||||
|
||||
var addLabel = el('label');
|
||||
var addInput = el('input', 'recipe-addable');
|
||||
addInput.type = 'checkbox';
|
||||
if (Number(line.is_addable) === 1) {
|
||||
addInput.checked = true;
|
||||
}
|
||||
addLabel.appendChild(addInput);
|
||||
addLabel.appendChild(document.createTextNode(' Ajoutable'));
|
||||
block.appendChild(addLabel);
|
||||
|
||||
// Retirer la ligne
|
||||
var removeBtn = el('button', 'btn btn-secondary recipe-remove');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.textContent = 'Retirer';
|
||||
removeBtn.addEventListener('click', function () {
|
||||
block.parentNode.removeChild(block);
|
||||
});
|
||||
block.appendChild(removeBtn);
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
// Lit l'etat des lignes et le serialise dans #composition_json.
|
||||
function serialize() {
|
||||
var lines = [];
|
||||
var blocks = builder.querySelectorAll('.recipe-line');
|
||||
Array.prototype.forEach.call(blocks, function (block) {
|
||||
var ingredientId = Number(block.querySelector('.recipe-ingredient').value);
|
||||
if (!ingredientId) {
|
||||
return;
|
||||
}
|
||||
lines.push({
|
||||
ingredient_id: ingredientId,
|
||||
quantity_normal: Number(block.querySelector('.recipe-qn').value),
|
||||
quantity_maxi: Number(block.querySelector('.recipe-qm').value),
|
||||
extra_price_cents: Number(block.querySelector('.recipe-extra').value),
|
||||
is_removable: block.querySelector('.recipe-removable').checked ? 1 : 0,
|
||||
is_addable: block.querySelector('.recipe-addable').checked ? 1 : 0
|
||||
});
|
||||
});
|
||||
hidden.value = JSON.stringify(lines);
|
||||
}
|
||||
|
||||
addBtn.addEventListener('click', function () {
|
||||
if (!ingredients.length) {
|
||||
return; // aucun ingredient au catalogue : rien a composer
|
||||
}
|
||||
builder.appendChild(renderLine(null));
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function () {
|
||||
serialize();
|
||||
});
|
||||
|
||||
// Rendu initial : lignes existantes (edition). Composition vide -> aucune ligne
|
||||
// (l'utilisateur ajoute a la demande, ou enregistre une recette vide).
|
||||
initial.forEach(function (l) {
|
||||
builder.appendChild(renderLine(l));
|
||||
});
|
||||
})();
|
||||
|
|
@ -91,6 +91,11 @@ try {
|
|||
$router->add('POST', '/admin/products/{id}', [ProductController::class, 'update']);
|
||||
$router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']);
|
||||
$router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']);
|
||||
// Editeur de recette (composition product_ingredient). Permission ingredient.manage
|
||||
// (composition), distincte du CRUD produit ; sans PIN. Debloque la dispo calculee
|
||||
// RG-T21 et ferme la dette #27 (trace cascade a la suppression).
|
||||
$router->add('GET', '/admin/products/{id}/recipe', [ProductController::class, 'recipeForm']);
|
||||
$router->add('POST', '/admin/products/{id}/recipe', [ProductController::class, 'saveRecipe']);
|
||||
|
||||
// CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base +
|
||||
// slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression
|
||||
|
|
|
|||
213
tests/Integration/ProductIngredientDbTest.php
Normal file
213
tests/Integration/ProductIngredientDbTest.php
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Throwable;
|
||||
use App\Catalogue\IngredientRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* Composition produit (product_ingredient) contre une vraie MariaDB (schema migre
|
||||
* + seede). Auto-skip si WAKDO_DB_TESTS != 1. Produit (it-prod-*) et ingredients
|
||||
* (it-ping-*) jetables. Couvre : persistance + delete-and-reinsert de la recette,
|
||||
* CASCADE a la suppression du produit (FK product_id), RESTRICT a la suppression
|
||||
* d'un ingredient reference (FK ingredient_id), et la disponibilite calculee RG-T21
|
||||
* (autoUnavailableIds + isOrderable) sur des donnees reelles.
|
||||
*
|
||||
* teardown FK-safe : on supprime le produit (CASCADE emporte sa composition, ce
|
||||
* qui libere les ingredients de la FK RESTRICT) PUIS les ingredients.
|
||||
*/
|
||||
final class ProductIngredientDbTest extends TestCase
|
||||
{
|
||||
private Database $db;
|
||||
private string $product = '';
|
||||
private string $ingA = '';
|
||||
private string $ingB = '';
|
||||
private int $categoryId = 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->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0);
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$this->product = 'it-prod-' . $suffix;
|
||||
$this->ingA = 'it-ping-a-' . $suffix;
|
||||
$this->ingB = 'it-ping-b-' . $suffix;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->product === '') {
|
||||
return;
|
||||
}
|
||||
$pid = (int) ($this->db->fetch('SELECT id FROM product WHERE name = :n', ['n' => $this->product])['id'] ?? 0);
|
||||
if ($pid > 0) {
|
||||
$this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $pid]); // CASCADE product_ingredient
|
||||
}
|
||||
// Ordre FK-safe : retirer les mouvements de stock (FK RESTRICT) avant
|
||||
// l'ingredient (le test de dispo cree un restock -> stock_movement).
|
||||
foreach ([$this->ingA, $this->ingB] as $name) {
|
||||
$iid = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $name])['id'] ?? 0);
|
||||
if ($iid > 0) {
|
||||
$this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $iid]);
|
||||
$this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $iid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testSetCompositionPersistsAndReplaces(): void
|
||||
{
|
||||
$products = new ProductRepository($this->db);
|
||||
$ingredients = new IngredientRepository($this->db);
|
||||
$pid = $this->createProduct($products);
|
||||
$iaId = $this->createIngredient($ingredients, $this->ingA, 50);
|
||||
$ibId = $this->createIngredient($ingredients, $this->ingB, 50);
|
||||
|
||||
self::assertTrue($products->ingredientExists($iaId));
|
||||
self::assertFalse($products->ingredientExists(0));
|
||||
|
||||
$products->setComposition($pid, [
|
||||
$this->line($iaId, ['quantity_normal' => 2, 'quantity_maxi' => 3, 'is_removable' => 1, 'extra_price_cents' => 50]),
|
||||
]);
|
||||
|
||||
$composition = $products->composition($pid);
|
||||
self::assertCount(1, $composition);
|
||||
self::assertSame($iaId, (int) $composition[0]['ingredient_id']);
|
||||
self::assertSame($this->ingA, (string) $composition[0]['ingredient_name']); // JOIN ingredient
|
||||
self::assertSame(2, (int) $composition[0]['quantity_normal']);
|
||||
self::assertSame(3, (int) $composition[0]['quantity_maxi']);
|
||||
self::assertSame(1, (int) $composition[0]['is_removable']);
|
||||
self::assertSame(50, (int) $composition[0]['extra_price_cents']);
|
||||
self::assertSame(1, $products->compositionCount($pid));
|
||||
|
||||
// Delete-and-reinsert : la nouvelle composition REMPLACE l'ancienne.
|
||||
$products->setComposition($pid, [$this->line($ibId)]);
|
||||
$replaced = $products->composition($pid);
|
||||
self::assertCount(1, $replaced);
|
||||
self::assertSame($ibId, (int) $replaced[0]['ingredient_id']);
|
||||
}
|
||||
|
||||
public function testProductDeleteCascadesComposition(): void
|
||||
{
|
||||
$products = new ProductRepository($this->db);
|
||||
$ingredients = new IngredientRepository($this->db);
|
||||
$pid = $this->createProduct($products);
|
||||
$iaId = $this->createIngredient($ingredients, $this->ingA, 50);
|
||||
$products->setComposition($pid, [$this->line($iaId)]);
|
||||
|
||||
self::assertSame(1, $products->compositionCount($pid));
|
||||
self::assertSame(1, $products->delete($pid)); // FK product_id CASCADE
|
||||
self::assertCount(0, $products->composition($pid)); // recette emportee
|
||||
|
||||
// L'ingredient, lui, survit (la cascade ne remonte pas vers lui).
|
||||
self::assertNotNull($ingredients->find($iaId));
|
||||
}
|
||||
|
||||
public function testIngredientReferencedByCompositionCannotBeHardDeleted(): void
|
||||
{
|
||||
$products = new ProductRepository($this->db);
|
||||
$ingredients = new IngredientRepository($this->db);
|
||||
$pid = $this->createProduct($products);
|
||||
$iaId = $this->createIngredient($ingredients, $this->ingA, 50);
|
||||
$products->setComposition($pid, [$this->line($iaId)]);
|
||||
|
||||
// FK ingredient_id RESTRICT : un ingredient utilise dans une recette ne peut
|
||||
// pas etre supprime durement (il faut le desactiver).
|
||||
$blocked = false;
|
||||
try {
|
||||
$ingredients->delete($iaId);
|
||||
} catch (PDOException $exception) {
|
||||
$blocked = (string) $exception->getCode() === '23000';
|
||||
}
|
||||
self::assertTrue($blocked, 'product_ingredient.ingredient_id (RESTRICT) doit bloquer la suppression.');
|
||||
self::assertTrue($ingredients->isReferenced($iaId)); // pre-check FK-safe
|
||||
}
|
||||
|
||||
public function testAvailabilityIsDerivedFromRequiredIngredientStock(): void
|
||||
{
|
||||
$products = new ProductRepository($this->db);
|
||||
$ingredients = new IngredientRepository($this->db);
|
||||
$pid = $this->createProduct($products);
|
||||
// Ingredient requis SOUS la bande critique (3/100 <= 5%).
|
||||
$critId = $this->createIngredient($ingredients, $this->ingA, 3);
|
||||
$products->setComposition($pid, [$this->line($critId, ['is_removable' => 0])]);
|
||||
|
||||
$product = $products->find($pid);
|
||||
self::assertNotNull($product);
|
||||
$composition = $products->composition($pid);
|
||||
self::assertFalse(ProductRepository::isOrderable((int) $product['is_available'] === 1, $composition));
|
||||
self::assertContains($pid, $products->autoUnavailableIds()); // rupture auto (RG-T21)
|
||||
|
||||
// Reapprovisionnement au-dessus du critique -> redevient commandable de lui-meme.
|
||||
$ingredients->restock($critId, 50, null, null); // 3 -> 53 (pack_size 1 * 50)
|
||||
$composition = $products->composition($pid);
|
||||
self::assertTrue(ProductRepository::isOrderable(true, $composition));
|
||||
self::assertNotContains($pid, $products->autoUnavailableIds());
|
||||
}
|
||||
|
||||
private function createProduct(ProductRepository $repo): int
|
||||
{
|
||||
$repo->create([
|
||||
'category_id' => $this->categoryId,
|
||||
'name' => $this->product,
|
||||
'description' => null,
|
||||
'price_cents' => 590,
|
||||
'vat_rate' => 100,
|
||||
'image_path' => null,
|
||||
'is_available' => 1,
|
||||
'display_order' => 99,
|
||||
]);
|
||||
|
||||
return (int) ($this->db->fetch('SELECT id FROM product WHERE name = :n', ['n' => $this->product])['id'] ?? 0);
|
||||
}
|
||||
|
||||
private function createIngredient(IngredientRepository $repo, string $name, int $stock): int
|
||||
{
|
||||
$repo->create([
|
||||
'name' => $name,
|
||||
'unit' => 'portion',
|
||||
'stock_quantity' => $stock,
|
||||
'stock_capacity' => 100,
|
||||
'pack_size' => 1,
|
||||
'pack_label' => null,
|
||||
'low_stock_pct' => 10,
|
||||
'critical_stock_pct' => 5,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
return (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $name])['id'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $over
|
||||
* @return array{ingredient_id:int, quantity_normal:int, quantity_maxi:int, is_removable:int, is_addable:int, extra_price_cents:int}
|
||||
*/
|
||||
private function line(int $ingredientId, array $over = []): array
|
||||
{
|
||||
return [
|
||||
'ingredient_id' => $ingredientId,
|
||||
'quantity_normal' => $over['quantity_normal'] ?? 1,
|
||||
'quantity_maxi' => $over['quantity_maxi'] ?? 1,
|
||||
'is_removable' => $over['is_removable'] ?? 0,
|
||||
'is_addable' => $over['is_addable'] ?? 0,
|
||||
'extra_price_cents' => $over['extra_price_cents'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +183,24 @@ final class FakeDatabase implements DatabaseInterface
|
|||
*/
|
||||
public array $movementsRows = [];
|
||||
|
||||
/**
|
||||
* Lignes renvoyees par ProductRepository::composition() (JOIN product_ingredient/ingredient).
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $compositionRows = [];
|
||||
|
||||
/**
|
||||
* Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds()
|
||||
* (produits en rupture automatique par le stock, RG-T21).
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $autoUnavailableRows = [];
|
||||
|
||||
/** Compteur renvoye par ProductRepository::compositionCount() (trace cascade #27). */
|
||||
public int $productCompositionCount = 0;
|
||||
|
||||
/**
|
||||
* Allowlist optionnelle de codes de permission accordes (RG-T03). Si non nul,
|
||||
* can() repond par appartenance du :code lie a cette liste (permet de tester la
|
||||
|
|
@ -313,6 +331,10 @@ final class FakeDatabase implements DatabaseInterface
|
|||
return $this->ingredientRow;
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'COUNT(*) AS n FROM product_ingredient')) {
|
||||
return ['n' => $this->productCompositionCount];
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM category WHERE name = :name')) {
|
||||
return $this->categoryNameTaken ? ['id' => 1] : null;
|
||||
}
|
||||
|
|
@ -364,6 +386,16 @@ final class FakeDatabase implements DatabaseInterface
|
|||
return $this->ingredientsRows;
|
||||
}
|
||||
|
||||
// Composition d'un produit (recette) vs ensemble des produits en rupture
|
||||
// auto : meme table jointe, distingues par la clause WHERE.
|
||||
if (str_contains($sql, 'FROM product_ingredient pi') && str_contains($sql, 'is_removable = 0')) {
|
||||
return $this->autoUnavailableRows;
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM product_ingredient pi') && str_contains($sql, 'WHERE pi.product_id')) {
|
||||
return $this->compositionRows;
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM stock_movement WHERE ingredient_id')) {
|
||||
return $this->movementsRows;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,6 +398,168 @@ final class ProductControllerTest extends TestCase
|
|||
self::assertSame([], $db->auditActions());
|
||||
}
|
||||
|
||||
// --- Editeur de recette (PR-B, product_ingredient, permission ingredient.manage) ---
|
||||
|
||||
public function testRecipeFormRequiresIngredientManage(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
$db->canResult = false; // ni ingredient.manage ni rien
|
||||
|
||||
self::assertSame(403, $this->controller($this->get('/admin/products/5/recipe'), $db)->recipeForm(['id' => '5'])->status());
|
||||
}
|
||||
|
||||
public function testRecipeFormNotFound(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = null;
|
||||
|
||||
self::assertSame(404, $this->controller($this->get('/admin/products/9/recipe'), $db)->recipeForm(['id' => '9'])->status());
|
||||
}
|
||||
|
||||
public function testRecipeFormShowsCompositionAndPicker(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
$db->ingredientsRows = [$this->ingredientPick(7, 'Cheddar'), $this->ingredientPick(8, 'Cornichon')];
|
||||
$db->compositionRows = [[
|
||||
'product_id' => 5, 'ingredient_id' => 7, 'quantity_normal' => 2, 'quantity_maxi' => 3,
|
||||
'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0,
|
||||
'ingredient_name' => 'Cheddar', 'ingredient_unit' => 'tranche',
|
||||
'stock_quantity' => 50, 'stock_capacity' => 100, 'low_stock_pct' => 10, 'critical_stock_pct' => 5,
|
||||
]];
|
||||
|
||||
$response = $this->controller($this->get('/admin/products/5/recipe'), $db)->recipeForm(['id' => '5']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertStringContainsString('Big Mac', $response->body());
|
||||
self::assertStringContainsString('Cheddar', $response->body()); // picker + composition existante
|
||||
self::assertStringContainsString('composition_json', $response->body());
|
||||
}
|
||||
|
||||
public function testSaveRecipeReplacesCompositionInTransaction(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
$db->ingredientRow = ['id' => 7, 'name' => 'Cheddar']; // ingredientExists -> true
|
||||
$json = (string) json_encode([[
|
||||
'ingredient_id' => 7, 'quantity_normal' => 2, 'quantity_maxi' => 3,
|
||||
'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 50,
|
||||
]]);
|
||||
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']);
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
self::assertSame(['begin', 'commit'], $db->transactionEvents);
|
||||
self::assertTrue($db->wrote('DELETE FROM product_ingredient')); // delete-and-reinsert (RG-2)
|
||||
$insert = $this->findWrite($db, 'INSERT INTO product_ingredient');
|
||||
self::assertNotNull($insert);
|
||||
self::assertSame(5, $insert['params']['product'] ?? null);
|
||||
self::assertSame(7, $insert['params']['ingredient'] ?? null);
|
||||
self::assertSame(2, $insert['params']['qn'] ?? null);
|
||||
self::assertSame(3, $insert['params']['qm'] ?? null);
|
||||
self::assertSame(50, $insert['params']['extra'] ?? null);
|
||||
}
|
||||
|
||||
public function testSaveRecipeEmptyClearsComposition(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
|
||||
// Composition vide : un produit peut n'avoir aucune recette definie -> on
|
||||
// purge sans erreur (DELETE seul, aucun INSERT).
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => '[]'], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']);
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
self::assertTrue($db->wrote('DELETE FROM product_ingredient'));
|
||||
self::assertFalse($db->wrote('INSERT INTO product_ingredient'));
|
||||
}
|
||||
|
||||
public function testSaveRecipeRejectsMaxiBelowNormal(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
$db->ingredientRow = ['id' => 7, 'name' => 'Cheddar'];
|
||||
$json = (string) json_encode([[
|
||||
'ingredient_id' => 7, 'quantity_normal' => 3, 'quantity_maxi' => 1, // viole quantity_maxi >= quantity_normal
|
||||
'is_removable' => 0, 'is_addable' => 0, 'extra_price_cents' => 0,
|
||||
]]);
|
||||
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']);
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertFalse($db->wrote('product_ingredient')); // aucun ecrit (validation RG-T18)
|
||||
}
|
||||
|
||||
public function testSaveRecipeDropsUnknownIngredient(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
$db->ingredientRow = null; // ingredientExists -> false : ligne ignoree (allowlist)
|
||||
$json = (string) json_encode([[
|
||||
'ingredient_id' => 999, 'quantity_normal' => 1, 'quantity_maxi' => 1,
|
||||
'is_removable' => 0, 'is_addable' => 0, 'extra_price_cents' => 0,
|
||||
]]);
|
||||
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']);
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
self::assertTrue($db->wrote('DELETE FROM product_ingredient'));
|
||||
self::assertFalse($db->wrote('INSERT INTO product_ingredient')); // l'ingredient inconnu est filtre
|
||||
}
|
||||
|
||||
public function testSaveRecipeRejectsInvalidCsrf(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
|
||||
$response = $this->controller($this->post(['_csrf' => 'bad', 'composition_json' => '[]'], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']);
|
||||
|
||||
self::assertSame(403, $response->status());
|
||||
self::assertFalse($db->wrote('product_ingredient'));
|
||||
}
|
||||
|
||||
public function testIndexFlagsStockDrivenRupture(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->productsRows = [
|
||||
['id' => 1, 'category_id' => 3, 'name' => 'Big Mac', 'price_cents' => 590, 'vat_rate' => 100, 'is_available' => 1, 'category_name' => 'Burgers'],
|
||||
];
|
||||
$db->autoUnavailableRows = [['product_id' => 1]]; // un ingredient requis en bande critique (RG-T21)
|
||||
|
||||
$response = $this->controller($this->get('/admin/products'), $db)->index();
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertStringContainsString('Rupture auto', $response->body()); // distinct du retrait manuel
|
||||
}
|
||||
|
||||
public function testDestroyTracesCascadedCompositionCount(): void
|
||||
{
|
||||
// Dette #27 : la suppression dure cascade product_ingredient (FK CASCADE) ;
|
||||
// on trace combien de lignes de recette ont ete emportees, pour ne laisser
|
||||
// aucune perte hors-trace dans l'audit_log.
|
||||
$db = $this->permittedDb();
|
||||
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
|
||||
$db->productCompositionCount = 3;
|
||||
$this->actingPin($db);
|
||||
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']);
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
$audit = $this->firstAudit($db);
|
||||
self::assertNotNull($audit);
|
||||
self::assertSame('product.delete', $audit['params']['code'] ?? null);
|
||||
self::assertStringContainsString('3', (string) ($audit['params']['summary'] ?? '')); // nb de lignes cascade tracees
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function ingredientPick(int $id, string $name): array
|
||||
{
|
||||
return ['id' => $id, 'name' => $name, 'unit' => 'tranche', 'stock_quantity' => 50, 'stock_capacity' => 100, 'pack_size' => 1, 'pack_label' => null, 'low_stock_pct' => 10, 'critical_stock_pct' => 5, 'is_active' => 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sql: string, params: array<string|int, mixed>}|null
|
||||
*/
|
||||
|
|
|
|||
73
tests/Unit/Catalogue/ProductRepositoryTest.php
Normal file
73
tests/Unit/Catalogue/ProductRepositoryTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Catalogue;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Catalogue\ProductRepository;
|
||||
|
||||
/**
|
||||
* Disponibilite produit CALCULEE (RG-T21), logique pure sans base. Un produit est
|
||||
* commandable ssi son flag is_available vaut 1 ET chaque ingredient NON RETIRABLE
|
||||
* (is_removable=0) de sa composition est au-dessus de la bande critique. Un retrait
|
||||
* manuel (is_available=0) prime sur tout ; un ingredient retirable/optionnel en
|
||||
* critique ne bloque pas le produit (seul son supplement devient indisponible).
|
||||
* Derivation pure, sans ecriture ni cascade (mcd 5.3 / RG-T21). La bande critique
|
||||
* est celle d'IngredientRepository::stockBand (source unique de la derivation).
|
||||
*/
|
||||
final class ProductRepositoryTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $over
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function line(array $over = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'is_removable' => 0,
|
||||
'stock_quantity' => 50,
|
||||
'stock_capacity' => 100,
|
||||
'low_stock_pct' => 10,
|
||||
'critical_stock_pct' => 5,
|
||||
], $over);
|
||||
}
|
||||
|
||||
public function testManualUnavailabilityAlwaysBlocks(): void
|
||||
{
|
||||
// is_available=0 : retrait manuel, prime meme sur un stock plein (surcharge forte).
|
||||
self::assertFalse(ProductRepository::isOrderable(false, [$this->line()]));
|
||||
}
|
||||
|
||||
public function testAvailableWithoutCompositionIsOrderable(): void
|
||||
{
|
||||
self::assertTrue(ProductRepository::isOrderable(true, []));
|
||||
}
|
||||
|
||||
public function testRequiredIngredientAtCriticalBlocks(): void
|
||||
{
|
||||
// Requis, 5/100 <= 5% -> bande critique -> rupture automatique.
|
||||
self::assertFalse(ProductRepository::isOrderable(true, [$this->line(['stock_quantity' => 5])]));
|
||||
}
|
||||
|
||||
public function testRequiredIngredientJustAboveCriticalDoesNotBlock(): void
|
||||
{
|
||||
// Requis, 6/100 > 5% -> bande basse (pas critique) -> reste commandable.
|
||||
self::assertTrue(ProductRepository::isOrderable(true, [$this->line(['stock_quantity' => 6])]));
|
||||
}
|
||||
|
||||
public function testRemovableIngredientAtCriticalDoesNotBlock(): void
|
||||
{
|
||||
// Retirable (is_removable=1) a 0 : seul son supplement saute, le produit reste commandable.
|
||||
self::assertTrue(ProductRepository::isOrderable(true, [$this->line(['is_removable' => 1, 'stock_quantity' => 0])]));
|
||||
}
|
||||
|
||||
public function testOneRequiredCriticalAmongManyBlocks(): void
|
||||
{
|
||||
self::assertFalse(ProductRepository::isOrderable(true, [
|
||||
$this->line(['stock_quantity' => 50]), // requis, ok
|
||||
$this->line(['is_removable' => 1, 'stock_quantity' => 0]), // retirable critique, ok
|
||||
$this->line(['stock_quantity' => 5]), // requis critique -> bloque
|
||||
]));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue