feat(catalogue): CRUD variantes (taille/Maxi) + menus base-only + garde serveur (#112)
All checks were successful
CI / secret-scan (push) Successful in 26s
CI / php-lint (push) Successful in 36s
CI / static-tests (push) Successful in 1m23s
CI / js-tests (push) Successful in 48s

This commit is contained in:
Corentin JOGUET 2026-06-25 14:01:47 +02:00
parent ba2abbfae9
commit be4585aeb2
14 changed files with 590 additions and 42 deletions

View file

@ -147,6 +147,23 @@ final class MenuRepository
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
}
/**
* Le produit existe-t-il ET est-il un produit de BASE (base_product_id IS NULL,
* R4) ? Garde serveur de l'eligibilite au menu (F9-2) : un menu ne peut prendre
* comme burger principal NI comme option de slot une VARIANTE de taille (ex.
* "Coca Cola 50cl"), qui n'est pas un produit autonome. Predicat plus strict que
* productExists() : il rejette une variante meme si l'UI est contournee. Le
* formulaire menu n'expose deja que des bases (ProductRepository::basesOnly),
* cette garde verrouille le chemin serveur en plus.
*/
public function productIsBase(int $id): bool
{
return $this->db->fetch(
'SELECT id FROM product WHERE id = :id AND base_product_id IS NULL',
['id' => $id],
) !== null;
}
/**
* Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une
* ligne de commande historique ? La FK order_item.menu_id est RESTRICT.

View file

@ -28,7 +28,16 @@ final class ProductRepository
}
/**
* Liste pour le back-office, avec le libelle de categorie.
* Liste pour le back-office, avec le libelle de categorie et, pour une VARIANTE
* de taille (base_product_id non nul, R4), le nom de sa base. La liste admin
* affiche AINSI toutes les lignes produit -- bases ET variantes -- mais marque
* chaque variante "Variante de X" : l'admin la voit, comprend qu'elle n'est pas
* un produit autonome, et peut la delier/relier via le formulaire. La projection
* remonte base_product_id pour que la vue distingue les deux.
*
* Cette methode N'ALIMENTE PLUS les selects du formulaire menu (qui doivent etre
* base-only, R4/F9-1) : ceux-ci passent par basesOnly(). all() peut donc porter
* le LEFT JOIN d'enrichissement sans fausser une liste deroulante.
*
* @return array<int, array<string, mixed>>
*/
@ -36,12 +45,34 @@ final class ProductRepository
{
return $this->db->fetchAll(
'SELECT p.id, p.category_id, p.name, p.price_cents, p.vat_rate, p.is_available, '
. 'p.display_order, c.name AS category_name '
. 'p.display_order, p.size_cl, p.base_product_id, c.name AS category_name, '
. 'b.name AS base_name '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'LEFT JOIN product b ON b.id = p.base_product_id '
. 'ORDER BY p.display_order, p.name',
);
}
/**
* Produits de BASE uniquement (base_product_id IS NULL, R4), pour alimenter les
* listes deroulantes du formulaire menu (burger principal + options de slot,
* F9-1) et le select base_product_id du formulaire produit. Une VARIANTE de
* taille (ex. "Coca Cola 50cl") n'est jamais un produit autonome : la proposer
* comme burger/option/base ferait apparaitre la variante comme un produit a part
* entiere. Le predicat anti-variante vit ici (cote requete), miroir de la garde
* serveur MenuRepository::productIsBase(). Projection minimale {id, name} : seules
* colonnes utiles a un <option>.
*
* @return array<int, array<string, mixed>>
*/
public function basesOnly(): array
{
return $this->db->fetchAll(
'SELECT id, name FROM product WHERE base_product_id IS NULL '
. 'ORDER BY display_order, name',
);
}
/**
* @return array<string, mixed>|null
*/
@ -50,9 +81,12 @@ final class ProductRepository
// maxi_variant_product_id : expose la variante Grande de l'accompagnement
// pour que OrderRepository::resolveSelections puisse substituer au format
// Maxi (cote serveur uniquement ; la borne n'en a pas besoin).
// size_cl + base_product_id (R4) : remontes pour que le formulaire produit
// pre-remplisse les champs de variante a l'edition (F9-3).
return $this->db->fetch(
'SELECT id, category_id, name, description, price_cents, maxi_variant_product_id, '
. 'vat_rate, image_path, is_available, display_order FROM product WHERE id = :id',
'SELECT id, category_id, name, description, price_cents, size_cl, base_product_id, '
. 'maxi_variant_product_id, vat_rate, image_path, is_available, display_order '
. 'FROM product WHERE id = :id',
['id' => $id],
);
}
@ -183,27 +217,49 @@ final class ProductRepository
return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null;
}
public function productExists(int $id): bool
{
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* Le produit existe-t-il ET est-il une BASE (base_product_id IS NULL, R4) ?
* Sert a valider les FK de variante du formulaire produit (F9-3) : une base ne
* peut pointer vers une AUTRE variante (pas de chaine de variantes), et la cible
* d'une variante de taille doit elle-meme etre une base. Retourne false si l'id
* est inconnu OU si la ligne est deja une variante.
*/
public function productIsBase(int $id): bool
{
return $this->db->fetch(
'SELECT id FROM product WHERE id = :id AND base_product_id IS NULL',
['id' => $id],
) !== null;
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
*/
public function create(array $data): void
{
$this->db->execute(
'INSERT INTO product (category_id, name, description, price_cents, vat_rate, image_path, is_available, display_order) '
. 'VALUES (:category, :name, :description, :price, :vat, :image, :available, :ord)',
'INSERT INTO product (category_id, name, description, price_cents, size_cl, base_product_id, '
. 'maxi_variant_product_id, vat_rate, image_path, is_available, display_order) '
. 'VALUES (:category, :name, :description, :price, :size, :base, :maxi, :vat, :image, :available, :ord)',
$this->bind($data),
);
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
*/
public function update(int $id, array $data): void
{
$this->db->execute(
'UPDATE product SET category_id = :category, name = :name, description = :description, '
. 'price_cents = :price, vat_rate = :vat, image_path = :image, is_available = :available, '
. 'display_order = :ord WHERE id = :id',
. 'price_cents = :price, size_cl = :size, base_product_id = :base, '
. 'maxi_variant_product_id = :maxi, vat_rate = :vat, image_path = :image, '
. 'is_available = :available, display_order = :ord WHERE id = :id',
$this->bind($data) + ['id' => $id],
);
}
@ -343,7 +399,7 @@ final class ProductRepository
/**
* Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees.
*
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* @return array<string, mixed>
*/
private function bind(array $data): array
@ -353,6 +409,11 @@ final class ProductRepository
'name' => $data['name'],
'description' => $data['description'],
'price' => $data['price_cents'],
// Champs de variante (R4/0006-0007), tous nullables : null = produit de
// base/autonome sans dimension taille ni substitution Maxi.
'size' => $data['size_cl'],
'base' => $data['base_product_id'],
'maxi' => $data['maxi_variant_product_id'],
'vat' => $data['vat_rate'],
'image' => $data['image_path'],
'available' => $data['is_available'],

View file

@ -309,10 +309,13 @@ class MenuController extends AdminController
$errors['category_id'] = 'Categorie requise et valide.';
}
// F9-2 : le burger principal doit etre un produit de BASE (R4). productIsBase
// rejette une variante de taille meme si l'UI (base-only) est contournee :
// une variante n'est pas un produit autonome commercialisable en menu.
$burgerRaw = trim($form['burger_product_id'] ?? '');
$burgerId = ctype_digit($burgerRaw) ? (int) $burgerRaw : 0;
if ($burgerId === 0 || !$this->menuRepository()->productExists($burgerId)) {
$errors['burger_product_id'] = 'Le produit burger de base est requis et doit exister.';
if ($burgerId === 0 || !$this->menuRepository()->productIsBase($burgerId)) {
$errors['burger_product_id'] = 'Le produit burger de base est requis et doit etre un produit de base (pas une variante de taille).';
}
$name = trim($form['name'] ?? '');
@ -385,12 +388,23 @@ class MenuController extends AdminController
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
$required = !empty($raw['is_required']) ? 1 : 0;
// F9-2 : une option de slot doit etre un produit de BASE (R4). Un id de
// variante de taille (base_product_id non nul) est REJETE explicitement
// (422) plutot que filtre en silence : choisir une variante comme option
// serait un contournement de l'UI base-only, et un drop muet ferait perdre
// un choix sans message clair. Un id inconnu reste filtre (allowlist).
$optionIds = [];
$hasVariantOption = false;
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
$pid = is_numeric($opt) ? (int) $opt : 0;
if ($pid > 0 && $this->menuRepository()->productExists($pid)) {
$optionIds[] = $pid;
if ($pid <= 0 || !$this->menuRepository()->productExists($pid)) {
continue; // id inconnu : filtre (allowlist), pas une erreur
}
if (!$this->menuRepository()->productIsBase($pid)) {
$hasVariantOption = true;
continue; // variante de taille : non eligible comme option de menu
}
$optionIds[] = $pid;
}
$optionIds = array_values(array_unique($optionIds));
@ -402,6 +416,10 @@ class MenuController extends AdminController
$errors['slots'] = 'Type de slot invalide.';
continue;
}
if ($hasVariantOption) {
$errors['slots'] = 'Une variante de taille ne peut pas etre proposee comme option de menu (choisissez le produit de base).';
continue;
}
if ($optionIds === []) {
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
continue;
@ -459,7 +477,10 @@ class MenuController extends AdminController
'activeNav' => 'menus',
'menuId' => $id,
'categories' => $this->categoryRepository()->all(),
'products' => $this->productRepository()->all(),
// F9-1 : listes deroulantes base-only (burger principal + options de
// slot). basesOnly() exclut les variantes de taille (R4) ; all() les
// inclut (liste admin), il ne doit donc pas alimenter ces selects.
'products' => $this->productRepository()->basesOnly(),
'slotTypes' => self::SLOT_TYPES,
'values' => [
'category_id' => (string) ($values['category_id'] ?? ''),

View file

@ -79,7 +79,9 @@ class ProductController extends AdminController
return $this->invalidCsrf();
}
[$data, $errors] = $this->validate($form);
// id = 0 a la creation : pas d'auto-reference possible (le produit n'existe
// pas encore), validate() le sait par le 2e argument.
[$data, $errors] = $this->validate($form, 0);
if ($errors !== []) {
return $this->renderForm($guard, 0, $form, $errors, 422);
}
@ -130,7 +132,7 @@ class ProductController extends AdminController
return $this->notFound($guard);
}
[$data, $errors] = $this->validate($form);
[$data, $errors] = $this->validate($form, $id);
if ($errors !== []) {
return $this->renderForm($guard, $id, $form, $errors, 422);
}
@ -383,11 +385,13 @@ class ProductController extends AdminController
/**
* Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees, erreurs].
* $currentId = id du produit edite (0 a la creation), pour interdire l'auto-
* reference des FK de variante (F9-3).
*
* @param array<string, string> $form
* @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array<string, string>}
* @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array<string, string>}
*/
private function validate(array $form): array
private function validate(array $form, int $currentId): array
{
$errors = [];
@ -425,11 +429,68 @@ class ProductController extends AdminController
$description = trim($form['description'] ?? '');
// --- Champs de variante (F9-3, R4 / migrations 0006-0007) ---
// Tous nullables : un champ vide signifie "produit de base / autonome, sans
// dimension taille ni substitution Maxi". Bornes refletant les colonnes :
// size_cl SMALLINT UNSIGNED (0..65535), base/maxi FK INT UNSIGNED.
// size_cl : volume en cl, entier >= 0 si fourni (vide = NULL).
$sizeRaw = trim($form['size_cl'] ?? '');
$sizeCl = null;
if ($sizeRaw !== '') {
if (!ctype_digit($sizeRaw) || (int) $sizeRaw > 65535) {
$errors['size_cl'] = 'La taille (en cl) doit etre un entier entre 0 et 65535.';
} else {
$sizeCl = (int) $sizeRaw;
}
}
// base_product_id : ce produit devient une VARIANTE de taille de la base
// designee. La base doit exister, etre differente de soi (pas d'auto-
// reference), et etre elle-meme une BASE (productIsBase) : on interdit une
// chaine de variantes (une variante ne peut pointer vers une autre variante).
$baseRaw = trim($form['base_product_id'] ?? '');
$baseId = null;
if ($baseRaw !== '') {
if (!ctype_digit($baseRaw)) {
$errors['base_product_id'] = 'Le produit de base doit etre un produit existant.';
} elseif ((int) $baseRaw === $currentId) {
$errors['base_product_id'] = 'Un produit ne peut pas etre sa propre base.';
} elseif (!$this->productRepository()->productExists((int) $baseRaw)) {
$errors['base_product_id'] = 'Le produit de base doit etre un produit existant.';
} elseif (!$this->productRepository()->productIsBase((int) $baseRaw)) {
$errors['base_product_id'] = 'Le produit de base doit lui-meme etre un produit de base (pas une variante).';
} else {
$baseId = (int) $baseRaw;
}
}
// maxi_variant_product_id : la variante Grande servie quand un MENU est
// commande en Maxi. Doit exister et etre differente de soi (auto-reference
// directe interdite). Pas de contrainte de base ici : la cible Maxi est elle
// aussi un produit a part entiere (ex. "Grande Frite"), pas une base de taille.
$maxiRaw = trim($form['maxi_variant_product_id'] ?? '');
$maxiId = null;
if ($maxiRaw !== '') {
if (!ctype_digit($maxiRaw)) {
$errors['maxi_variant_product_id'] = 'La variante Maxi doit etre un produit existant.';
} elseif ((int) $maxiRaw === $currentId) {
$errors['maxi_variant_product_id'] = 'Un produit ne peut pas etre sa propre variante Maxi.';
} elseif (!$this->productRepository()->productExists((int) $maxiRaw)) {
$errors['maxi_variant_product_id'] = 'La variante Maxi doit etre un produit existant.';
} else {
$maxiId = (int) $maxiRaw;
}
}
$data = [
'category_id' => $categoryId,
'name' => $name,
'description' => $description !== '' ? $description : null,
'price_cents' => $priceValid ? (int) $priceRaw : 0,
'size_cl' => $sizeCl,
'base_product_id' => $baseId,
'maxi_variant_product_id' => $maxiId,
'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100,
'image_path' => $image !== '' ? $image : null,
'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0,
@ -588,16 +649,31 @@ class ProductController extends AdminController
*/
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response
{
// F9-3 : selects base_product_id (de quelle base ce produit est-il la
// variante de taille ?) et maxi_variant_product_id (quelle variante Grande
// servir en menu Maxi ?). On ne propose que des produits de BASE
// (basesOnly, R4) -- une variante ne peut etre ni une base ni, par
// simplicite, une cible Maxi -- et on exclut le produit lui-meme de la liste
// (pas d'auto-reference), garde miroir de validate().
$baseCandidates = array_values(array_filter(
$this->productRepository()->basesOnly(),
static fn (array $p): bool => (int) ($p['id'] ?? 0) !== $id,
));
return $this->adminView('admin/products/form', [
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin',
'activeNav' => 'products',
'productId' => $id,
'categories' => $this->categoryRepository()->all(),
'baseCandidates' => $baseCandidates,
'values' => [
'category_id' => (string) ($values['category_id'] ?? ''),
'name' => (string) ($values['name'] ?? ''),
'description' => (string) ($values['description'] ?? ''),
'price_cents' => (string) ($values['price_cents'] ?? ''),
'size_cl' => (string) ($values['size_cl'] ?? ''),
'base_product_id' => (string) ($values['base_product_id'] ?? ''),
'maxi_variant_product_id' => (string) ($values['maxi_variant_product_id'] ?? ''),
'vat_rate' => (string) ($values['vat_rate'] ?? '100'),
'image_path' => (string) ($values['image_path'] ?? ''),
// Defaut coche a la creation (errors vide + values vide) ; sur un

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
*
* @var int $productId
* @var array<int, array<string, mixed>> $categories
* @var array<int, array<string, mixed>> $baseCandidates produits de base eligibles (R4)
* @var array<string, mixed> $values
* @var array<string, string> $errors
* @var string $csrfToken
@ -24,12 +25,16 @@ $vals = isset($values) && is_array($values) ? $values : [];
$errs = isset($errors) && is_array($errors) ? $errors : [];
/** @var array<int, array<string, mixed>> $cats */
$cats = isset($categories) && is_array($categories) ? $categories : [];
/** @var array<int, array<string, mixed>> $bases */
$bases = isset($baseCandidates) && is_array($baseCandidates) ? $baseCandidates : [];
$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8');
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : '';
$selectedCat = (string) ($vals['category_id'] ?? '');
$selectedVat = (string) ($vals['vat_rate'] ?? '100');
$available = (bool) ($vals['is_available'] ?? true);
$selectedBase = (string) ($vals['base_product_id'] ?? '');
$selectedMaxi = (string) ($vals['maxi_variant_product_id'] ?? '');
?>
<div class="page-header">
<div>
@ -96,6 +101,48 @@ $available = (bool) ($vals['is_available'] ?? true);
<label class="form-label"><input type="checkbox" name="is_available" value="1"<?= $available ? ' checked' : '' ?>> Disponible</label>
</div>
<fieldset class="form-group">
<legend>Variantes (optionnel)</legend>
<p><small>A remplir seulement pour une boisson en plusieurs tailles ou un accompagnement servi en plus grand au format Maxi. Laissez vide pour un produit ordinaire.</small></p>
<div class="form-group">
<label class="form-label" for="size_cl">Taille en centilitres (boissons)</label>
<input class="form-input" type="number" id="size_cl" name="size_cl" min="0" max="65535" value="<?= $val('size_cl') ?>">
<small>Exemple : 30 ou 50 pour un soda. Laissez vide si le produit n'a pas de taille.</small>
<?php if ($err('size_cl') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('size_cl'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="base_product_id">Variante de taille de</label>
<select class="form-input" id="base_product_id" name="base_product_id">
<option value="">-- ce produit est un produit a part entiere --</option>
<?php foreach ($bases as $b): ?>
<?php $bid = (string) ($b['id'] ?? ''); ?>
<option value="<?= htmlspecialchars($bid, ENT_QUOTES, 'UTF-8') ?>"<?= $bid === $selectedBase ? ' selected' : '' ?>>
<?= htmlspecialchars((string) ($b['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<small>Rattache ce produit a un produit principal comme une autre taille (exemple : "Coca 50cl" rattache a "Coca"). Une variante n'apparait pas seule sur la borne.</small>
<?php if ($err('base_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('base_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="maxi_variant_product_id">Version servie en format Maxi</label>
<select class="form-input" id="maxi_variant_product_id" name="maxi_variant_product_id">
<option value="">-- aucune (pas de version Maxi) --</option>
<?php foreach ($bases as $b): ?>
<?php $bid = (string) ($b['id'] ?? ''); ?>
<option value="<?= htmlspecialchars($bid, ENT_QUOTES, 'UTF-8') ?>"<?= $bid === $selectedMaxi ? ' selected' : '' ?>>
<?= htmlspecialchars((string) ($b['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<small>Le produit servi a la place de celui-ci quand le menu est commande en Maxi (exemple : "Moyenne Frite" servie en "Grande Frite").</small>
<?php if ($err('maxi_variant_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('maxi_variant_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
</fieldset>
<?php if ($id !== 0): ?>
<fieldset class="form-group">
<legend>Changement de prix ou de TVA : confirmation par PIN</legend>

View file

@ -49,9 +49,22 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', '
$available = (int) ($row['is_available'] ?? 0) === 1;
$autoRupture = in_array($id, $autoIds, true); // RG-T21 : stock-driven
$vat = (int) ($row['vat_rate'] ?? 100);
// R4/F9-4 : une ligne dont base_product_id est non nul est une
// VARIANTE de taille, pas un produit autonome. On la garde dans la
// liste (l'admin la voit et la gere) mais on la marque "Variante de
// X" pour qu'aucune confusion ne subsiste.
$baseProductId = isset($row['base_product_id']) && $row['base_product_id'] !== null
? (int) $row['base_product_id'] : 0;
$isVariant = $baseProductId > 0;
$baseName = (string) ($row['base_name'] ?? '');
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="fw-600">
<?= $esc($row['name'] ?? '') ?>
<?php if ($isVariant): ?>
<span class="pill pill-neutral" title="Cette ligne est une variante de taille, pas un produit affiche seul sur la borne">Variante de <?= $esc($baseName !== '' ? $baseName : '?') ?></span>
<?php endif; ?>
</td>
<td class="muted"><?= $esc($row['category_name'] ?? '') ?></td>
<td><?= $esc($euros((int) ($row['price_cents'] ?? 0))) ?></td>
<td class="muted"><?= $vat === 55 ? '5,5%' : '10%' ?></td>

View file

@ -199,7 +199,7 @@ final class CatalogueReadDbTest extends TestCase
}
/**
* @return array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}
* @return array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}
*/
private function productData(string $name, int $categoryId, int $available): array
{
@ -208,6 +208,9 @@ final class CatalogueReadDbTest extends TestCase
'name' => $name,
'description' => null,
'price_cents' => 500,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 100,
'image_path' => null,
'is_available' => $available,

View file

@ -169,6 +169,9 @@ final class ProductIngredientDbTest extends TestCase
'name' => $this->product,
'description' => null,
'price_cents' => 590,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 100,
'image_path' => null,
'is_available' => 1,

View file

@ -59,6 +59,9 @@ final class ProductRepositoryDbTest extends TestCase
'name' => $this->name,
'description' => null,
'price_cents' => 999,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 100,
'image_path' => null,
'is_available' => 1,
@ -77,6 +80,9 @@ final class ProductRepositoryDbTest extends TestCase
'name' => $this->name,
'description' => 'maj',
'price_cents' => 1099,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 55,
'image_path' => null,
'is_available' => 0,

View file

@ -33,6 +33,20 @@ final class FakeCatalogueDatabase implements DatabaseInterface
*/
public array $productsRows = [];
/**
* Lignes {id, name} renvoyees par ProductRepository::basesOnly() (R4/F9-1).
*
* @var list<array<string, mixed>>
*/
public array $baseProductsRows = [];
/**
* Lignes renvoyees par ProductRepository::all() (liste admin enrichie, F9-4).
*
* @var list<array<string, mixed>>
*/
public array $allProductsRows = [];
/**
* Ligne renvoyee par ProductRepository::findForCatalogue() ; null = absent /
* indisponible / categorie inactive.
@ -140,6 +154,18 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->productSizes;
}
// F9-1 : liste base-only (basesOnly) pour les selects menu/produit.
if (str_contains($sql, 'FROM product WHERE base_product_id IS NULL')) {
return $this->baseProductsRows;
}
// F9-4 : liste admin enrichie (all()) -- LEFT JOIN base, sans le filtre de
// disponibilite borne. Distinguee de availableForCatalogue() par l'absence
// de 'WHERE p.is_available = 1' et la presence de 'LEFT JOIN product b'.
if (str_contains($sql, 'FROM product p JOIN category') && str_contains($sql, 'LEFT JOIN product b')) {
return $this->allProductsRows;
}
if (str_contains($sql, 'FROM product p JOIN category') && str_contains($sql, 'WHERE p.is_available = 1')) {
return $this->productsRows;
}

View file

@ -142,6 +142,22 @@ final class FakeDatabase implements DatabaseInterface
*/
public ?array $productRow = null;
/**
* Lignes {id, name} renvoyees par ProductRepository::basesOnly() (R4/F9-1) :
* produits de base eligibles aux selects menu / formulaire produit.
*
* @var list<array<string, mixed>>
*/
public array $baseProductsRows = [];
/**
* Resultat de ProductRepository::productIsBase() / MenuRepository::productIsBase()
* (R4/F9-2) : true => l'id designe un produit de BASE (base_product_id IS NULL).
* Defaut true : un produit ordinaire est une base ; un test le passe a false pour
* simuler une VARIANTE de taille presentee la ou seules les bases sont eligibles.
*/
public bool $productIsBase = true;
/**
* Ligne renvoyee par MenuRepository::find() ; null = introuvable.
*
@ -447,6 +463,12 @@ final class FakeDatabase implements DatabaseInterface
return $this->actingUserRow;
}
// R4/F9-2 : predicat base-only (productIsBase). Doit passer AVANT la route
// generique 'FROM product WHERE id = :id' (productRow) qu'elle matche aussi.
if (str_contains($sql, 'FROM product WHERE id = :id') && str_contains($sql, 'base_product_id IS NULL')) {
return $this->productIsBase ? ['id' => 1] : null;
}
if (str_contains($sql, 'FROM product WHERE id = :id')) {
return $this->productRow;
}
@ -524,6 +546,12 @@ final class FakeDatabase implements DatabaseInterface
return $this->categoriesRows;
}
// R4/F9-1 : liste base-only (basesOnly) pour les selects. Distincte de la
// liste admin enrichie (all(), 'FROM product p JOIN category').
if (str_contains($sql, 'FROM product WHERE base_product_id IS NULL')) {
return $this->baseProductsRows;
}
if (str_contains($sql, 'FROM product p JOIN category')) {
return $this->productsRows;
}

View file

@ -175,6 +175,33 @@ final class MenuControllerTest extends TestCase
self::assertSame('Menu cree.', $this->session->get('_flash'));
}
public function testStoreRejectsVariantAsBurger(): void
{
// F9-2 : garde serveur. Le burger principal est une VARIANTE de taille
// (productIsBase=false) -> 422 meme si l'UI base-only est contournee.
$db = $this->permittedDb();
$db->productIsBase = false; // l'id burger designe une variante
$response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO menu'));
self::assertStringContainsString('produit de base', $response->body());
}
public function testStoreRejectsVariantAsSlotOption(): void
{
// F9-2 : une variante de taille proposee comme OPTION de slot -> 422.
// productExists=true (la ligne existe) mais productIsBase=false.
$db = $this->permittedDb();
$db->productIsBase = false;
$response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO menu'));
}
public function testStoreRejectsWithoutSlots(): void
{
$db = $this->permittedDb();

View file

@ -166,6 +166,164 @@ final class ProductControllerTest extends TestCase
self::assertSame('Produit cree.', $this->session->get('_flash'));
}
// --- Champs de variante (F9-3) : size_cl, base_product_id, maxi_variant_product_id ---
public function testStorePersistsVariantFields(): void
{
// Une variante de taille : base_product_id pointe une base, size_cl=50.
$db = $this->permittedDb();
$db->productIsBase = true; // la base designee EST une base (eligible)
$db->productRow = ['id' => 7, 'name' => 'Coca Cola']; // productExists -> true
$form = $this->validForm(['name' => 'Coca Cola 50cl', 'size_cl' => '50', 'base_product_id' => '7', 'maxi_variant_product_id' => '8']);
$response = $this->controller($this->post($form, '/admin/products'), $db)->store();
self::assertSame(302, $response->status());
$insert = $this->findWrite($db, 'INSERT INTO product');
self::assertNotNull($insert);
self::assertSame(50, $insert['params']['size'] ?? null);
self::assertSame(7, $insert['params']['base'] ?? null);
self::assertSame(8, $insert['params']['maxi'] ?? null);
}
public function testStoreEmptyVariantFieldsBindNull(): void
{
// Produit ordinaire : aucun champ de variante -> NULL en base (pas 0).
$db = $this->permittedDb();
$response = $this->controller($this->post($this->validForm(), '/admin/products'), $db)->store();
self::assertSame(302, $response->status());
$insert = $this->findWrite($db, 'INSERT INTO product');
self::assertNotNull($insert);
// Cles bien liees (allowlist bind()) ET valeur NULL. Pas de `?? 'x'` ici :
// `null ?? 'x'` vaudrait 'x' et ferait echouer l'assertion sur un null legitime.
self::assertArrayHasKey('size', $insert['params']);
self::assertNull($insert['params']['size']);
self::assertArrayHasKey('base', $insert['params']);
self::assertNull($insert['params']['base']);
self::assertArrayHasKey('maxi', $insert['params']);
self::assertNull($insert['params']['maxi']);
}
public function testUpdatePersistsVariantFields(): void
{
// Edition sans changement prix/TVA -> pas de PIN ; les colonnes de variante
// sont bien dans l'UPDATE.
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Coca Cola', 'description' => null, 'price_cents' => 190, 'size_cl' => 30, 'base_product_id' => null, 'maxi_variant_product_id' => null, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
$db->productIsBase = true;
$form = $this->validForm(['name' => 'Coca Cola', 'price_cents' => '190', 'base_product_id' => '7']);
$response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(302, $response->status());
$update = $this->findWrite($db, 'UPDATE product SET');
self::assertNotNull($update);
self::assertSame(7, $update['params']['base'] ?? null);
}
public function testStoreRejectsBaseReferencingAVariant(): void
{
// Anti-chaine de variantes (F9-3) : la base designee est elle-meme une
// variante (productIsBase=false) -> 422, aucun ecrit.
$db = $this->permittedDb();
$db->productRow = ['id' => 99, 'name' => 'Coca 50cl']; // existe
$db->productIsBase = false; // mais c'est une variante
$form = $this->validForm(['base_product_id' => '99']);
$response = $this->controller($this->post($form, '/admin/products'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO product'));
self::assertStringContainsString('produit de base', $response->body());
}
public function testUpdateRejectsSelfAsBase(): void
{
// Anti auto-reference (F9-3) : base_product_id = soi-meme -> 422.
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'X', 'description' => null, 'price_cents' => 190, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
$form = $this->validForm(['name' => 'X', 'price_cents' => '190', 'base_product_id' => '5']);
$response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE product SET'));
self::assertStringContainsString('sa propre base', $response->body());
}
public function testUpdateRejectsSelfAsMaxiVariant(): void
{
// Anti auto-reference (F9-3) : maxi_variant_product_id = soi-meme -> 422.
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'X', 'description' => null, 'price_cents' => 190, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
$form = $this->validForm(['name' => 'X', 'price_cents' => '190', 'maxi_variant_product_id' => '5']);
$response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE product SET'));
self::assertStringContainsString('sa propre variante Maxi', $response->body());
}
public function testStoreRejectsNegativeSize(): void
{
// size_cl non entier (ici une valeur non numerique) -> 422.
$db = $this->permittedDb();
$form = $this->validForm(['size_cl' => '-5']);
$response = $this->controller($this->post($form, '/admin/products'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO product'));
}
public function testStoreRejectsUnknownBaseProduct(): void
{
// base_product_id reference un produit inexistant -> 422.
$db = $this->permittedDb();
$db->productRow = null; // productExists -> false
$form = $this->validForm(['base_product_id' => '404']);
$response = $this->controller($this->post($form, '/admin/products'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO product'));
}
public function testFormOffersBaseCandidatesExcludingSelf(): void
{
// Le select base_product_id n'expose que des bases (basesOnly) et exclut le
// produit edite (pas d'auto-reference dans l'UI).
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Coca Cola', 'description' => null, 'price_cents' => 190, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
$db->baseProductsRows = [
['id' => 5, 'name' => 'Coca Cola'], // soi-meme : exclu
['id' => 7, 'name' => 'Fanta'], // autre base : propose
];
$response = $this->controller($this->get('/admin/products/5/edit'), $db)->edit(['id' => '5']);
self::assertSame(200, $response->status());
self::assertStringContainsString('Fanta', $response->body());
self::assertStringContainsString('base_product_id', $response->body());
}
public function testIndexMarksVariantRows(): void
{
// F9-4 : une variante de taille (base_product_id non nul) est marquee
// "Variante de X" dans la liste admin, pas affichee comme produit autonome.
$db = $this->permittedDb();
$db->productsRows = [
['id' => 99, 'category_id' => 2, 'name' => 'Coca Cola 50cl', 'price_cents' => 240, 'vat_rate' => 100, 'is_available' => 1, 'category_name' => 'Boissons', 'base_product_id' => 14, 'base_name' => 'Coca Cola'],
];
$response = $this->controller($this->get('/admin/products'), $db)->index();
self::assertSame(200, $response->status());
self::assertStringContainsString('Variante de Coca Cola', $response->body());
}
public function testStoreValidationErrorNoWrite(): void
{
$db = $this->permittedDb();

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Catalogue;
use App\Catalogue\ProductRepository;
use App\Tests\Support\FakeCatalogueDatabase;
use PHPUnit\Framework\TestCase;
/**
* Requete base-only (R4/F9-1) cote ProductRepository : la liste qui alimente les
* selects du formulaire menu (burger principal + options) ET le select de base du
* formulaire produit ne doit proposer QUE des produits de BASE (base_product_id IS
* NULL). On verrouille la garde sur le SQL trace : une regression (filtre retire)
* ferait virer le test au rouge. Le double FakeCatalogueDatabase scripte les lignes.
*/
final class ProductRepositoryBaseOnlyTest extends TestCase
{
public function testBasesOnlyQueryFiltersVariants(): void
{
$db = new FakeCatalogueDatabase();
$db->baseProductsRows = [
['id' => '14', 'name' => 'Coca Cola'],
['id' => '15', 'name' => 'Fanta'],
];
$rows = (new ProductRepository($db))->basesOnly();
self::assertCount(2, $rows);
self::assertSame('14', $rows[0]['id']);
// Le predicat anti-variante vit dans le SQL : on l'asserte sur la requete.
self::assertStringContainsString('base_product_id IS NULL', $db->reads[0]['sql']);
}
public function testProductIsBaseQueryCarriesBaseOnlyPredicate(): void
{
// Garde serveur : productIsBase() ne doit matcher qu'une ligne de base.
$db = new FakeCatalogueDatabase();
(new ProductRepository($db))->productIsBase(14);
self::assertStringContainsString('base_product_id IS NULL', $db->reads[0]['sql']);
self::assertSame(14, $db->reads[0]['params']['id'] ?? null);
}
public function testAllQueryJoinsBaseNameForVariantLabel(): void
{
// F9-4 : la liste admin enrichit chaque ligne du nom de sa base (LEFT JOIN)
// pour pouvoir marquer "Variante de X" sans exclure les variantes.
$db = new FakeCatalogueDatabase();
$db->allProductsRows = [
['id' => '99', 'category_id' => '2', 'name' => 'Coca Cola 50cl', 'price_cents' => '240', 'vat_rate' => '100', 'is_available' => '1', 'display_order' => '2', 'size_cl' => '50', 'base_product_id' => '14', 'category_name' => 'Boissons', 'base_name' => 'Coca Cola'],
];
$rows = (new ProductRepository($db))->all();
self::assertCount(1, $rows);
self::assertSame('Coca Cola', $rows[0]['base_name']);
self::assertStringContainsString('LEFT JOIN product b', $db->reads[0]['sql']);
}
}