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

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:
Imugiii 2026-06-17 09:27:43 +00:00
parent 1f4b9478ca
commit 06450b2db5
10 changed files with 1048 additions and 11 deletions

View file

@ -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.
*

View file

@ -11,6 +11,7 @@ use App\Auth\PasswordHasher;
use App\Auth\PinThrottle;
use App\Auth\PinVerifier;
use App\Catalogue\CategoryRepository;
use App\Catalogue\IngredientRepository;
use App\Catalogue\ProductRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
@ -41,9 +42,12 @@ class ProductController extends AdminController
}
return $this->adminView('admin/products/index', [
'title' => 'Produits - Wakdo Admin',
'activeNav' => 'products',
'products' => $this->productRepository()->all(),
'title' => 'Produits - Wakdo Admin',
'activeNav' => 'products',
'products' => $this->productRepository()->all(),
// Rupture AUTOMATIQUE par le stock (RG-T21), distincte du retrait manuel
// (is_available=0) : la vue signale les deux differemment.
'autoUnavailable' => $this->productRepository()->autoUnavailableIds(),
], $guard);
}
@ -251,15 +255,23 @@ class ProductController extends AdminController
$name = (string) ($product['name'] ?? '');
// Dette #27 : product_ingredient (FK product_id CASCADE) sera emporte par la
// suppression. On compte AVANT (lecture hors transaction) pour tracer le
// nombre de lignes de recette cascade-supprimees dans le resume d'audit :
// aucune perte hors-trace dans le journal append-only.
$cascaded = $this->productRepository()->compositionCount($id);
$summary = 'Suppression produit: ' . $name
. ' (' . $cascaded . ' ligne(s) de recette cascade-supprimee(s))';
// FK RESTRICT (order_item / menu / menu_slot_option / order_item_selection)
// -> PDOException 23000 -> 409 Conflit (catch ci-dessous). product_ingredient
// est CASCADE (recette possedee par le produit) : supprimee avec lui, jamais
// bloquante (cf. docblock ProductRepository).
try {
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void {
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $summary): void {
$deleted = (new ProductRepository($db))->delete($id);
if ($deleted === 1) {
$this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, 'Suppression produit: ' . $name);
$this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, $summary);
}
});
} catch (PDOException $exception) {
@ -280,11 +292,75 @@ class ProductController extends AdminController
return $this->redirect('/admin/products');
}
/**
* Editeur de recette (PR-B, mlt domaine recettes). Compose product_ingredient :
* la commandabilite est gardee par `ingredient.manage` (composition du produit),
* DISTINCTE de product.create/update/delete (CRUD produit). Aucun PIN : editer
* une recette n'est pas une action sensible RG-T13.
*
* @param array<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

View file

@ -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>

View 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>

View 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));
});
})();

View file

@ -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

View 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,
];
}
}

View file

@ -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;
}

View file

@ -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
*/

View 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
]));
}
}