feat(catalogue): administration CRUD des variantes (taille/Maxi) + selects menu base-only + garde serveur
Some checks failed
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 32s
CI / static-tests (push) Failing after 1m1s
CI / js-tests (push) Successful in 33s
CI / secret-scan (pull_request) Successful in 20s
CI / php-lint (pull_request) Successful in 29s
CI / static-tests (pull_request) Failing after 1m8s
CI / js-tests (pull_request) Successful in 30s
Some checks failed
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 32s
CI / static-tests (push) Failing after 1m1s
CI / js-tests (push) Successful in 33s
CI / secret-scan (pull_request) Successful in 20s
CI / php-lint (pull_request) Successful in 29s
CI / static-tests (pull_request) Failing after 1m8s
CI / js-tests (pull_request) Successful in 30s
This commit is contained in:
parent
a6dcb31c16
commit
268c64e4bb
14 changed files with 585 additions and 42 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'] ?? ''),
|
||||
|
|
|
|||
|
|
@ -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,15 +429,72 @@ 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,
|
||||
'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100,
|
||||
'image_path' => $image !== '' ? $image : null,
|
||||
'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0,
|
||||
'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0,
|
||||
'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,
|
||||
'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0,
|
||||
];
|
||||
|
||||
return [$data, $errors];
|
||||
|
|
@ -588,23 +649,38 @@ 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(),
|
||||
'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'] ?? ''),
|
||||
'vat_rate' => (string) ($values['vat_rate'] ?? '100'),
|
||||
'image_path' => (string) ($values['image_path'] ?? ''),
|
||||
'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
|
||||
// re-rendu POST (erreurs), refleter la presence reelle du champ
|
||||
// (case decochee = absente = non cochee), pas le defaut a 1.
|
||||
'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values),
|
||||
'display_order' => (string) ($values['display_order'] ?? '0'),
|
||||
'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values),
|
||||
'display_order' => (string) ($values['display_order'] ?? '0'),
|
||||
],
|
||||
'errors' => $errors,
|
||||
], $guard, $status);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -166,6 +166,159 @@ 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);
|
||||
self::assertNull($insert['params']['size'] ?? 'x');
|
||||
self::assertNull($insert['params']['base'] ?? 'x');
|
||||
self::assertNull($insert['params']['maxi'] ?? 'x');
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
62
tests/Unit/Catalogue/ProductRepositoryBaseOnlyTest.php
Normal file
62
tests/Unit/Catalogue/ProductRepositoryBaseOnlyTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue