Compare commits
2 commits
a1e69d2f33
...
2f98168182
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f98168182 | ||
| 411b04d548 |
18 changed files with 388 additions and 29 deletions
|
|
@ -8,10 +8,15 @@
|
|||
-- domaine commande facture deja par product_id : le flux de commande
|
||||
-- reste inchange, la borne resout juste la taille choisie en product_id.
|
||||
--
|
||||
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) :
|
||||
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu
|
||||
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une
|
||||
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu).
|
||||
-- Grouping DEDIE (base_product_id), distinct de maxi_variant_product_id
|
||||
-- (migration 0006) : base_product_id pilote la selection de taille A LA
|
||||
-- CARTE (picker 30/50 cl) ; maxi_variant_product_id pilote la substitution
|
||||
-- Maxi en MENU (resolveSelections). Les deux coexistent sur une boisson :
|
||||
-- le seed 0006 pointe desormais chaque soda 30 cl vers sa variante 50 cl
|
||||
-- pour qu'un menu Maxi serve la grande boisson (decision metier). Cet
|
||||
-- "effet" est VOULU et ne s'applique qu'aux selections de menu au format
|
||||
-- maxi ; une boisson 30 cl commandee a la carte (resolveLine type product)
|
||||
-- ne consulte jamais maxi_variant_product_id et reste en 30 cl.
|
||||
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
|
||||
-- =============================================================================
|
||||
|
||||
|
|
|
|||
50
db/seeds/0006_drink_maxi_variant.sql
Normal file
50
db/seeds/0006_drink_maxi_variant.sql
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
-- =============================================================================
|
||||
-- Wakdo — Seed 0006 : boisson de menu = variante 50 cl automatique en Maxi
|
||||
-- =============================================================================
|
||||
-- Purpose : cabler la regle metier "boisson Maxi" sur les donnees seedees, sans
|
||||
-- toucher au code. En menu Maxi, la boisson fontaine doit passer en
|
||||
-- grande (50 cl), comme l'accompagnement passe en Grande Frite.
|
||||
--
|
||||
-- Mecanique reutilisee : product.maxi_variant_product_id (schema 0006),
|
||||
-- deja exploite par OrderRepository::resolveSelections (substitution de
|
||||
-- toute selection de menu au format 'maxi', sans garde sur le slot_type).
|
||||
-- Il suffit donc de POINTER chaque soda fontaine 30 cl vers sa variante
|
||||
-- 50 cl (creee par le seed 0005) : aucune ligne de code serveur a ecrire.
|
||||
-- Le decrement de stock (consumption) frappera la 50 cl, et le snapshot
|
||||
-- de libelle reflechira "<soda> 50cl".
|
||||
--
|
||||
-- Perimetre : seules les boissons fontaine ont une variante 50 cl (Coca Cola, Coca
|
||||
-- Sans Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en
|
||||
-- bouteille (Eau, Jus d'Orange, Jus de Pommes Bio) n'ont pas de variante : elles
|
||||
-- restent en taille standard meme en Maxi (degradation gracieuse, modele fast-food
|
||||
-- usuel). Le surcout Maxi est porte par le menu (price_maxi_cents), pas par la
|
||||
-- boisson : aucune incidence de prix sur ces bouteilles.
|
||||
--
|
||||
-- Phase : depend du schema 0006 (maxi_variant_product_id) ET du seed 0005 (les
|
||||
-- variantes 50 cl doivent exister). Joue donc APRES 0005 (ordre
|
||||
-- lexicographique du runner db/seed.sh).
|
||||
--
|
||||
-- Conventions:
|
||||
-- - Aucun id en dur : la cible est resolue structurellement (la variante 50 cl
|
||||
-- est la ligne dont base_product_id pointe la base et size_cl = 50).
|
||||
-- - IDEMPOTENT : UPDATE ... JOIN convergent (repositionne la meme valeur a chaque
|
||||
-- execution). MariaDB autorise le self-join en UPDATE multi-tables (l'erreur
|
||||
-- 1093 ne vise que les sous-requetes sur la table cible, pas les JOIN).
|
||||
-- =============================================================================
|
||||
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Lier chaque boisson de base (30 cl, base_product_id NULL) a sa variante 50 cl.
|
||||
-- La jointure ne matche que les produits ayant une variante de taille 50 cl :
|
||||
-- structurellement, les seules boissons fontaine. Les accompagnements (frites,
|
||||
-- deja relies par 0004) ne sont pas des variantes de taille -> non touches. Les
|
||||
-- bouteilles sans variante 50 cl ne matchent pas -> maxi_variant_product_id reste
|
||||
-- NULL.
|
||||
-- -----------------------------------------------------------------------------
|
||||
UPDATE product AS base
|
||||
JOIN product AS variant
|
||||
ON variant.base_product_id = base.id
|
||||
AND variant.size_cl = 50
|
||||
SET base.maxi_variant_product_id = variant.id
|
||||
WHERE base.base_product_id IS NULL;
|
||||
|
|
@ -63,8 +63,9 @@ final class MenuRepository
|
|||
* disponibles (is_available = 1) ET en categorie active (c.is_active = 1).
|
||||
* Projection enrichie (description, image_path) absente de all() back-office.
|
||||
* Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La
|
||||
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
|
||||
* RG-T21, differe au seed des recettes.
|
||||
* disponibilite du burger impose (B1, RG-T21) est calculee par CatalogueController
|
||||
* (croisement avec ProductRepository::autoUnavailableIds) et exposee en is_orderable :
|
||||
* un menu dont le burger est en rupture est grise par la borne (granularite burger seul).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -63,8 +63,10 @@ final class ProductRepository
|
|||
* (c.is_active = 1), pour ne jamais proposer un produit dont l'onglet de
|
||||
* categorie n'apparait pas. vat_rate n'est pas selectionne : le calcul fiscal
|
||||
* vit cote serveur a la commande, la borne ne l'affiche pas. Filtre de
|
||||
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
|
||||
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
|
||||
* disponibilite = flag is_available (retrait manuel) ; la dispo CALCULEE RG-T21
|
||||
* (rupture par stock) n'exclut PAS la ligne ici : CatalogueController la croise
|
||||
* avec autoUnavailableIds() pour exposer is_orderable, et la borne grise la tuile
|
||||
* (visible mais non commandable) au lieu de la masquer.
|
||||
*
|
||||
* base_product_id IS NULL (R4) : les VARIANTES de taille (ex. "Coca Cola 50cl")
|
||||
* ne sont jamais des tuiles catalogue autonomes ; elles sont atteintes via le
|
||||
|
|
|
|||
|
|
@ -56,8 +56,16 @@ class CatalogueController extends Controller
|
|||
// requete (sizesByBase), pas une par produit -> /api/products reste un seul
|
||||
// aller-retour cache-friendly cote borne (data.js memoise la liste).
|
||||
$sizesByBase = $repo->sizesByBase();
|
||||
// RG-T21 : rupture calculee par le stock, en UNE requete (set d'ids). Un
|
||||
// produit liste (is_available=1) mais en rupture devient non commandable ->
|
||||
// la borne le grise au lieu de laisser composer une commande vouee a echouer.
|
||||
$unavailable = array_fill_keys($repo->autoUnavailableIds(), true);
|
||||
$rows = array_map(
|
||||
fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []),
|
||||
fn (array $row): array => $this->presentProduct(
|
||||
$row,
|
||||
$sizesByBase[(int) ($row['id'] ?? 0)] ?? [],
|
||||
!isset($unavailable[(int) ($row['id'] ?? 0)]),
|
||||
),
|
||||
$repo->availableForCatalogue(),
|
||||
);
|
||||
|
||||
|
|
@ -84,8 +92,10 @@ class CatalogueController extends Controller
|
|||
// au moins une VARIANTE (sinon sizesForProduct ne remonte que la base, et la
|
||||
// base seule n'est pas une dimension de taille -> sizes vide cote presentation).
|
||||
$sizes = $repo->sizesForProduct($id);
|
||||
// RG-T21 : meme dispo calculee qu'en liste, pour ce produit (membership du set).
|
||||
$orderable = !in_array($id, $repo->autoUnavailableIds(), true);
|
||||
|
||||
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]);
|
||||
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [], $orderable)]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,8 +103,15 @@ class CatalogueController extends Controller
|
|||
*/
|
||||
public function menus(array $params = []): Response
|
||||
{
|
||||
// RG-T21 (granularite : burger impose seul) : un menu dont le burger principal
|
||||
// est en rupture calculee n'est plus commandable. Set d'ids produits en rupture
|
||||
// reutilise pour tous les menus (pas de N+1).
|
||||
$unavailable = array_fill_keys($this->productsRepo()->autoUnavailableIds(), true);
|
||||
$rows = array_map(
|
||||
fn (array $row): array => $this->presentMenu($row),
|
||||
fn (array $row): array => $this->presentMenu(
|
||||
$row,
|
||||
!isset($unavailable[(int) ($row['burger_product_id'] ?? 0)]),
|
||||
),
|
||||
$this->menusRepo()->availableForCatalogue(),
|
||||
);
|
||||
|
||||
|
|
@ -117,8 +134,10 @@ class CatalogueController extends Controller
|
|||
);
|
||||
}
|
||||
|
||||
// RG-T21 (burger impose seul) : dispo calculee du menu = burger non en rupture.
|
||||
$orderable = !in_array((int) ($row['burger_product_id'] ?? 0), $this->productsRepo()->autoUnavailableIds(), true);
|
||||
// Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi).
|
||||
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
|
||||
$menu = $this->presentMenu($row, $orderable) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
|
||||
|
||||
return $this->json(['data' => $menu]);
|
||||
}
|
||||
|
|
@ -200,9 +219,9 @@ class CatalogueController extends Controller
|
|||
* variantes ; vide si le produit n'a pas de dimension taille. Chaque entree
|
||||
* devient {product_id, size_cl, price_cents, label} ; le label humain est
|
||||
* derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran.
|
||||
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>}
|
||||
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>, is_orderable: bool}
|
||||
*/
|
||||
private function presentProduct(array $row, array $sizes = []): array
|
||||
private function presentProduct(array $row, array $sizes = [], bool $isOrderable = true): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
|
|
@ -229,14 +248,19 @@ class CatalogueController extends Controller
|
|||
},
|
||||
array_values($sizes),
|
||||
),
|
||||
// is_orderable : false si rupture calculee par le stock (RG-T21). La borne
|
||||
// grise la tuile (echo UX) ; l'enforcement qui fait foi est cote serveur a la
|
||||
// creation de commande (OrderRepository::resolveLine refuse un item en
|
||||
// rupture). Le retrait manuel (is_available=0) est deja exclu en amont.
|
||||
'is_orderable' => $isOrderable,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int}
|
||||
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int, is_orderable: bool}
|
||||
*/
|
||||
private function presentMenu(array $row): array
|
||||
private function presentMenu(array $row, bool $isOrderable = true): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
|
|
@ -248,6 +272,9 @@ class CatalogueController extends Controller
|
|||
'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0),
|
||||
'image_path' => $this->nullableString($row['image_path'] ?? null),
|
||||
'display_order' => (int) ($row['display_order'] ?? 0),
|
||||
// is_orderable : false si le burger impose est en rupture calculee (RG-T21,
|
||||
// granularite burger seul). La borne grise le menu.
|
||||
'is_orderable' => $isOrderable,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,8 +184,14 @@ class OrderRepository
|
|||
throw new OrderValidationException('EMPTY_ORDER');
|
||||
}
|
||||
|
||||
// RG-T21 : garde a la creation de commande. Un produit (ou le burger d'un menu)
|
||||
// en rupture calculee par le stock est REFUSE, quel que soit le canal et meme par
|
||||
// acces direct (la borne ne fait que griser l'affichage, F2). C'est la couche qui
|
||||
// fait foi. Set calcule UNE seule fois (pas de N+1 sur les lignes).
|
||||
$unavailable = array_fill_keys($this->products->autoUnavailableIds(), true);
|
||||
|
||||
// Resolution + calcul (lecture seule) AVANT la transaction d'ecriture.
|
||||
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
|
||||
$lines = array_map(fn (array $item): array => $this->resolveLine($item, $unavailable), $items);
|
||||
|
||||
$totalTtc = 0;
|
||||
$totalHt = 0;
|
||||
|
|
@ -597,9 +603,10 @@ class OrderRepository
|
|||
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
* @param array<int, bool> $unavailable set des product_id en rupture calculee (RG-T21)
|
||||
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
|
||||
*/
|
||||
private function resolveLine(array $item): array
|
||||
private function resolveLine(array $item, array $unavailable = []): array
|
||||
{
|
||||
$type = (string) ($item['type'] ?? '');
|
||||
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
||||
|
|
@ -610,6 +617,10 @@ class OrderRepository
|
|||
if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) {
|
||||
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
|
||||
}
|
||||
// RG-T21 : rupture calculee (ingredient requis sous la bande critique).
|
||||
if (isset($unavailable[(int) $product['id']])) {
|
||||
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
|
||||
}
|
||||
$unitBase = (int) $product['price_cents'];
|
||||
$vat = (int) $product['vat_rate'];
|
||||
$modifiers = $this->resolveModifiers($item, (int) $product['id']);
|
||||
|
|
@ -623,6 +634,14 @@ class OrderRepository
|
|||
if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) {
|
||||
throw new OrderValidationException('MENU_UNAVAILABLE');
|
||||
}
|
||||
// RG-T21, granularite "burger impose seul" (coherente avec l'affichage borne
|
||||
// F2) : si le burger principal est en rupture calculee, le menu n'est pas
|
||||
// commandable. La rupture d'un accompagnement/boisson requis n'est PAS
|
||||
// verifiee ici (decision produit : granularite burger seul, a elargir au
|
||||
// besoin via les produits des slots requis).
|
||||
if (isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)])) {
|
||||
throw new OrderValidationException('MENU_UNAVAILABLE');
|
||||
}
|
||||
$burger = $this->products->find((int) $menu['burger_product_id']);
|
||||
$vat = $burger !== null ? (int) $burger['vat_rate'] : 100;
|
||||
$unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents'];
|
||||
|
|
|
|||
|
|
@ -676,6 +676,35 @@ button {
|
|||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* Rupture de stock (RG-T21) : tuile visible mais non commandable. Grisee, non
|
||||
cliquable, sans effet de survol (l'image est attenuee, pas l'etat ne ment pas). */
|
||||
.product-card--unavailable {
|
||||
opacity: 0.55;
|
||||
filter: grayscale(0.6);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.product-card--unavailable:hover,
|
||||
.product-card--unavailable:focus-visible {
|
||||
border-color: var(--color-border-default);
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Badge "Indisponible" superpose a l'image d'une tuile en rupture. */
|
||||
.product-card__badge {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
z-index: 2;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-brand-dark);
|
||||
color: #fff;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.product-card__image-wrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
* Traduction panier borne -> contrat API :
|
||||
* - produit simple -> { type:'product', product_id, quantity }
|
||||
* - menu -> { type:'menu', menu_id, quantity, format, selections }
|
||||
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
|
||||
* format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
|
||||
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
|
||||
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
|
||||
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
|
||||
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
|
||||
|
|
@ -64,7 +65,10 @@ export function buildOrderItem(cartItem, menuSlotsById) {
|
|||
type: 'menu',
|
||||
menu_id: cartItem.id,
|
||||
quantity: cartItem.quantite,
|
||||
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
|
||||
// Format choisi par l'utilisateur, transporte explicitement. Repli sur
|
||||
// l'ancienne inference (supplement_cents>0) pour un panier serialise en
|
||||
// sessionStorage avant l'ajout du champ format.
|
||||
format: cartItem.format ?? ((cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal'),
|
||||
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,12 +89,16 @@ export function loadProducts() {
|
|||
// en a une, sinon null. Le composeur de menu l'affiche en format Maxi.
|
||||
maxiNom: p.maxi_variant_name ?? null,
|
||||
sizes: Array.isArray(p.sizes) ? p.sizes : [],
|
||||
// commandable : false si rupture de stock calculee (RG-T21, is_orderable
|
||||
// serveur) -> la borne grise la tuile et bloque le clic. Defaut true si
|
||||
// l'API ne porte pas le flag (compat).
|
||||
commandable: p.is_orderable !== false,
|
||||
});
|
||||
}
|
||||
for (const m of menus) {
|
||||
const slug = slugByCategoryId[m.category_id];
|
||||
if (slug === undefined) continue;
|
||||
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu' });
|
||||
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu', commandable: m.is_orderable !== false });
|
||||
}
|
||||
return bySlug;
|
||||
}).catch(e => { _productsPromise = null; throw e; });
|
||||
|
|
|
|||
|
|
@ -121,6 +121,11 @@ export function buildMenuCartItem(menu, model, { size, selections }) {
|
|||
quantite: 1,
|
||||
image: menu.image,
|
||||
supplement_cents: supplement,
|
||||
// format PORTE le choix Normal/Maxi de l'utilisateur, transporte tel quel
|
||||
// jusqu'au contrat API. Le serveur l'utilise pour le prix Maxi ET la
|
||||
// substitution des variantes (accompagnement Grande, boisson 50 cl). A NE
|
||||
// PAS re-deviner depuis supplement_cents (faux negatif si maxi == normal).
|
||||
format: isMaxi ? 'maxi' : 'normal',
|
||||
composition,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,10 +63,14 @@ async function renderProducts() {
|
|||
|
||||
grid.innerHTML = '';
|
||||
products.forEach(product => {
|
||||
// commandable : false = rupture de stock (RG-T21). La tuile reste visible
|
||||
// (le client voit le produit de la carte) mais grisee et non cliquable.
|
||||
const orderable = product.commandable !== false;
|
||||
const card = document.createElement('a');
|
||||
card.className = 'product-card';
|
||||
card.className = orderable ? 'product-card' : 'product-card product-card--unavailable';
|
||||
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
|
||||
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
|
||||
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`);
|
||||
if (!orderable) card.setAttribute('aria-disabled', 'true');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="product-card__image-wrap">
|
||||
|
|
@ -77,6 +81,7 @@ async function renderProducts() {
|
|||
loading="lazy"
|
||||
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||
>
|
||||
${orderable ? '' : '<span class="product-card__badge">Indisponible</span>'}
|
||||
</div>
|
||||
<div class="product-card__body">
|
||||
<span class="product-card__name">${escHtml(product.nom)}</span>
|
||||
|
|
@ -91,9 +96,11 @@ async function renderProducts() {
|
|||
|
||||
// Clic produit -> modale au-dessus de la grille (paradigme maquette) au lieu
|
||||
// de naviguer vers product.html : menu -> composeur (L2), produit -> options
|
||||
// (L3). Le <a href> reste un repli (lien direct / sans JS).
|
||||
// (L3). Le <a href> reste un repli (lien direct / sans JS). Une tuile en
|
||||
// rupture ne fait rien (ni navigation ni modale).
|
||||
card.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (!orderable) return;
|
||||
if (product.type === 'menu') openMenuComposer(product, categorySlug);
|
||||
else openProductOptions(product, categorySlug);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@ final class FakeCatalogueDatabase implements DatabaseInterface
|
|||
*/
|
||||
public array $productSizes = [];
|
||||
|
||||
/**
|
||||
* Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds()
|
||||
* (RG-T21 : produits en rupture calculee par le stock). Vide = rien en rupture.
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $autoUnavailableRows = [];
|
||||
|
||||
/**
|
||||
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
|
||||
*
|
||||
|
|
@ -109,6 +117,12 @@ final class FakeCatalogueDatabase implements DatabaseInterface
|
|||
return $this->categoriesRows;
|
||||
}
|
||||
|
||||
// RG-T21 : ids des produits en rupture calculee (autoUnavailableIds). Desambigue
|
||||
// de composition() (meme table) par SELECT DISTINCT, propre a cette requete.
|
||||
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
|
||||
return $this->autoUnavailableRows;
|
||||
}
|
||||
|
||||
// R4 : tailles groupees (sizesByBase) et tailles d'un produit (sizesForProduct).
|
||||
// Testees avant la branche catalogue : toutes deux lisent FROM product.
|
||||
if (str_contains($sql, 'AS base_id')) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ final class FakeOrderDatabase implements DatabaseInterface
|
|||
public array $slotRows = [];
|
||||
/** @var array<int, list<array<string,mixed>>> recettes (composition) par produit id. */
|
||||
public array $compositions = [];
|
||||
/** @var list<array<string,mixed>> ids produits en rupture calculee (autoUnavailableIds, RG-T21). */
|
||||
public array $autoUnavailableRows = [];
|
||||
|
||||
/** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */
|
||||
/** @var array<string,mixed>|null */
|
||||
|
|
@ -93,6 +95,11 @@ final class FakeOrderDatabase implements DatabaseInterface
|
|||
if (str_contains($sql, 'FROM menu_slot s')) {
|
||||
return $this->slotRows[(int) $params['id']] ?? [];
|
||||
}
|
||||
// RG-T21 : autoUnavailableIds() (sans param) AVANT composition() (avec :id) :
|
||||
// les deux lisent product_ingredient ; on desambiguise sur SELECT DISTINCT.
|
||||
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
|
||||
return $this->autoUnavailableRows;
|
||||
}
|
||||
if (str_contains($sql, 'FROM product_ingredient pi')) {
|
||||
return $this->compositions[(int) $params['id']] ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ final class CatalogueControllerTest extends TestCase
|
|||
|
||||
$product = $payload['data'][0];
|
||||
self::assertSame(
|
||||
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'],
|
||||
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes', 'is_orderable'],
|
||||
array_keys($product),
|
||||
);
|
||||
self::assertSame(12, $product['id']);
|
||||
|
|
@ -125,6 +125,41 @@ final class CatalogueControllerTest extends TestCase
|
|||
self::assertArrayNotHasKey('is_available', $product); // toujours dispo ici -> non expose
|
||||
self::assertNull($product['maxi_variant_name']); // pas de variante -> null
|
||||
self::assertSame([], $product['sizes']); // produit mono-taille -> sizes vide
|
||||
self::assertTrue($product['is_orderable']); // aucune rupture -> commandable
|
||||
}
|
||||
|
||||
public function testProductsMarksAutoUnavailableProductAsNotOrderable(): void
|
||||
{
|
||||
// RG-T21 : deux produits listes (is_available=1), mais l'un (id 12) est en
|
||||
// rupture calculee par le stock -> is_orderable false ; l'autre (id 13) true.
|
||||
$db = new FakeCatalogueDatabase();
|
||||
$db->productsRows = [
|
||||
['id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1', 'maxi_variant_name' => null],
|
||||
['id' => '13', 'category_id' => '3', 'name' => 'Hamburger', 'description' => null, 'price_cents' => '790', 'image_path' => null, 'display_order' => '2', 'maxi_variant_name' => null],
|
||||
];
|
||||
$db->autoUnavailableRows = [['product_id' => '12']]; // 12 en rupture
|
||||
|
||||
$response = $this->controller($db, '/api/products')->products();
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
$data = $this->decode($response->body())['data'];
|
||||
self::assertFalse($data[0]['is_orderable']); // 12 en rupture
|
||||
self::assertTrue($data[1]['is_orderable']); // 13 commandable
|
||||
}
|
||||
|
||||
public function testProductDetailAutoUnavailableIsNotOrderable(): void
|
||||
{
|
||||
$db = new FakeCatalogueDatabase();
|
||||
$db->productRow = [
|
||||
'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger',
|
||||
'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1',
|
||||
];
|
||||
$db->autoUnavailableRows = [['product_id' => '12']];
|
||||
|
||||
$response = $this->controller($db, '/api/products/12')->product(['id' => '12']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
|
||||
}
|
||||
|
||||
public function testProductsListExposesMaxiVariantName(): void
|
||||
|
|
@ -291,18 +326,52 @@ final class CatalogueControllerTest extends TestCase
|
|||
|
||||
$menu = $payload['data'][0];
|
||||
self::assertSame(
|
||||
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order'],
|
||||
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order', 'is_orderable'],
|
||||
array_keys($menu),
|
||||
);
|
||||
self::assertSame(1, $menu['id']);
|
||||
self::assertSame(5, $menu['burger_product_id']);
|
||||
self::assertSame(990, $menu['price_normal_cents']);
|
||||
self::assertSame(1190, $menu['price_maxi_cents']);
|
||||
self::assertTrue($menu['is_orderable']); // burger non en rupture
|
||||
self::assertArrayNotHasKey('slots', $menu); // liste legere : pas de slots
|
||||
self::assertArrayNotHasKey('is_available', $menu); // toujours dispo ici
|
||||
self::assertArrayNotHasKey('vat_rate', $menu);
|
||||
}
|
||||
|
||||
public function testMenuNotOrderableWhenBurgerAutoUnavailable(): void
|
||||
{
|
||||
// RG-T21 (granularite burger seul) : le burger impose (id 5) est en rupture
|
||||
// calculee -> le menu n'est plus commandable, meme s'il est is_available=1.
|
||||
$db = new FakeCatalogueDatabase();
|
||||
$db->menusRows = [
|
||||
['id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big', 'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190', 'image_path' => null, 'display_order' => '1'],
|
||||
];
|
||||
$db->autoUnavailableRows = [['product_id' => '5']]; // burger en rupture
|
||||
|
||||
$response = $this->controller($db, '/api/menus')->menus();
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertFalse($this->decode($response->body())['data'][0]['is_orderable']);
|
||||
}
|
||||
|
||||
public function testMenuDetailNotOrderableWhenBurgerAutoUnavailable(): void
|
||||
{
|
||||
$db = new FakeCatalogueDatabase();
|
||||
$db->menuRow = [
|
||||
'id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big',
|
||||
'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190',
|
||||
'image_path' => null, 'display_order' => '1',
|
||||
];
|
||||
$db->menuSlotRows = [];
|
||||
$db->autoUnavailableRows = [['product_id' => '5']];
|
||||
|
||||
$response = $this->controller($db, '/api/menus/1')->menu(['id' => '1']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
|
||||
}
|
||||
|
||||
public function testMenuDetailReturnsDataWithSlots(): void
|
||||
{
|
||||
$db = new FakeCatalogueDatabase();
|
||||
|
|
|
|||
|
|
@ -121,6 +121,86 @@ final class OrderRepositoryTest extends TestCase
|
|||
self::assertSame(8, $sel['slot']);
|
||||
}
|
||||
|
||||
public function testMenuMaxiSwapsDrinkSelectionToLargeVariant(): void
|
||||
{
|
||||
// Au format maxi, la boisson fontaine Coca Cola (variante = Coca Cola 50cl,
|
||||
// id 15) doit etre persistee comme la 50 cl : meme mecanique que l'accompagnement
|
||||
// Grande Frite (maxi_variant_product_id), pour que le stock decremente la 50 cl
|
||||
// et que le snapshot reflete "Coca Cola 50cl". Aucune garde sur le slot_type.
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->products[14] = ['id' => 14, 'name' => 'Coca Cola', 'price_cents' => 190, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => 15];
|
||||
$db->products[15] = ['id' => 15, 'name' => 'Coca Cola 50cl', 'price_cents' => 240, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
|
||||
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 14]];
|
||||
|
||||
$this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
|
||||
'selections' => [['menu_slot_id' => 9, 'product_id' => 14]]]], // borne envoie la 30 cl
|
||||
]);
|
||||
|
||||
$sel = $db->firstWrite('INSERT INTO order_item_selection');
|
||||
self::assertSame(15, $sel['pid']); // swap -> Coca Cola 50cl
|
||||
self::assertSame('Coca Cola 50cl', $sel['label']);
|
||||
self::assertSame(9, $sel['slot']);
|
||||
}
|
||||
|
||||
public function testMenuMaxiKeepsBottledDrinkWithoutVariant(): void
|
||||
{
|
||||
// Une boisson en bouteille (Eau) n'a pas de variante 50 cl : meme en Maxi la
|
||||
// selection reste l'Eau de base (degradation gracieuse, modele fast-food).
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->products[16] = ['id' => 16, 'name' => 'Eau', 'price_cents' => 150, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
|
||||
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 16]];
|
||||
|
||||
$this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
|
||||
'selections' => [['menu_slot_id' => 9, 'product_id' => 16]]]],
|
||||
]);
|
||||
|
||||
$sel = $db->firstWrite('INSERT INTO order_item_selection');
|
||||
self::assertSame(16, $sel['pid']); // pas de variante -> reste l'Eau
|
||||
self::assertSame('Eau', $sel['label']);
|
||||
}
|
||||
|
||||
public function testProductInStockRuptureRejectedAtOrderCreation(): void
|
||||
{
|
||||
// RG-T21 : un produit liste (is_available=1) mais en rupture calculee par le
|
||||
// stock est REFUSE a la creation de commande (garde serveur load-bearing, pas
|
||||
// seulement grise sur la borne). Couvre le bypass URL directe / repli sans-JS.
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->autoUnavailableRows = [['product_id' => 12]];
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('PRODUCT_UNAVAILABLE');
|
||||
$this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMenuRejectedAtOrderWhenBurgerInStockRupture(): void
|
||||
{
|
||||
// RG-T21 (granularite burger seul) : le burger impose en rupture calculee rend
|
||||
// le menu non commandable cote serveur, meme is_available=1.
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->autoUnavailableRows = [['product_id' => 12]];
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('MENU_UNAVAILABLE');
|
||||
$this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', 'selections' => []]],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMenuNormalKeepsBaseSideSelection(): void
|
||||
{
|
||||
// Format normal : aucune substitution, l'accompagnement reste la Moyenne
|
||||
|
|
|
|||
|
|
@ -60,6 +60,17 @@ test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
|
|||
assert.equal(maxi.format, 'maxi');
|
||||
});
|
||||
|
||||
test('buildOrderItem: format explicite prime sur l inference (maxi meme si supplement 0)', () => {
|
||||
// Le choix utilisateur est transporte dans cartItem.format ; il ne doit PAS etre
|
||||
// re-devine du prix (un menu maxi == normal serait sinon envoye en normal).
|
||||
const explicit = { id: 1, type: 'menu', quantite: 1, supplement_cents: 0, format: 'maxi',
|
||||
composition: { boisson: { id: 14 } } };
|
||||
assert.equal(buildOrderItem(explicit, { 1: slots() }).format, 'maxi');
|
||||
// Repli historique : un panier serialise sans champ format infere depuis supplement.
|
||||
const legacy = { id: 1, type: 'menu', quantite: 1, supplement_cents: 150, composition: {} };
|
||||
assert.equal(buildOrderItem(legacy, { 1: slots() }).format, 'maxi');
|
||||
});
|
||||
|
||||
/* --- buildOrderPayload --------------------------------------------------- */
|
||||
|
||||
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos
|
|||
assert.equal(item.type, 'menu');
|
||||
assert.equal(item.prix_cents, 880);
|
||||
assert.equal(item.supplement_cents, 0);
|
||||
assert.equal(item.format, 'normal'); // format explicite transporte
|
||||
assert.equal(item.composition.burger.libelle, 'Le 280');
|
||||
// Normal : l'accompagnement garde son nom de base (pas la variante Maxi).
|
||||
assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' });
|
||||
|
|
@ -83,6 +84,7 @@ test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drin
|
|||
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
|
||||
assert.equal(item.prix_cents, 880);
|
||||
assert.equal(item.supplement_cents, 150); // 1030 - 880
|
||||
assert.equal(item.format, 'maxi'); // format explicite transporte
|
||||
assert.equal(item.composition.accompagnement.taille, 'G');
|
||||
assert.equal(item.composition.boisson.taille, 'G');
|
||||
});
|
||||
|
|
@ -91,10 +93,21 @@ test('buildMenuCartItem Maxi: l accompagnement prend sa variante (Grande Frite),
|
|||
const m = buildComposerSteps(detail(), byId());
|
||||
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
|
||||
assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite"
|
||||
// Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas).
|
||||
// Boisson sans maxiNom : garde son nom de base meme en Maxi (cas bouteille).
|
||||
assert.equal(item.composition.boisson.libelle, 'Coca');
|
||||
});
|
||||
|
||||
test('buildMenuCartItem Maxi: la boisson AVEC variante (50cl) prend son nom agrandi', () => {
|
||||
// Apres le seed 0006, une boisson fontaine porte maxiNom (ex. "Coca Cola 50cl") :
|
||||
// en Maxi, le libelle et la taille refletent la grande boisson (meme regle que
|
||||
// l'accompagnement). Aucune logique borne specifique : maxiNom suffit.
|
||||
const byIdDrinkVariant = { ...byId(), 14: { id: 14, nom: 'Coca Cola', prix: 0, image: 'c.png', type: 'produit', maxiNom: 'Coca Cola 50cl' } };
|
||||
const m = buildComposerSteps(detail(), byIdDrinkVariant);
|
||||
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
|
||||
assert.equal(item.composition.boisson.libelle, 'Coca Cola 50cl');
|
||||
assert.equal(item.composition.boisson.taille, 'G');
|
||||
});
|
||||
|
||||
test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => {
|
||||
const m = buildComposerSteps(detail(), byId());
|
||||
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ test('loadProducts groupe les produits par slug a la forme borne (type produit)'
|
|||
assert.deepEqual(data.burgers, [
|
||||
// sizes (R4) : tableau vide par defaut quand l'API n'en renvoie pas.
|
||||
// maxiNom : null par defaut quand l'API n'envoie pas maxi_variant_name.
|
||||
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] },
|
||||
// commandable : true par defaut quand l'API n'envoie pas is_orderable.
|
||||
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [], commandable: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -100,10 +101,21 @@ test('loadProducts glisse les menus sous la cle menus (type menu, prix = price_n
|
|||
|
||||
const data = await loadProducts();
|
||||
assert.deepEqual(data.menus, [
|
||||
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' },
|
||||
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu', commandable: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('loadProducts: is_orderable=false -> commandable=false (rupture RG-T21)', async () => {
|
||||
const fx = fixtures();
|
||||
fx['/api/products'].data[0].is_orderable = false;
|
||||
fx['/api/menus'].data[0].is_orderable = false;
|
||||
const { loadProducts } = await freshData(fx);
|
||||
|
||||
const data = await loadProducts();
|
||||
assert.equal(data.burgers[0].commandable, false);
|
||||
assert.equal(data.menus[0].commandable, false);
|
||||
});
|
||||
|
||||
test('loadProducts consomme bien les trois endpoints /api/*', async () => {
|
||||
const calls = [];
|
||||
const { loadProducts } = await freshData(fixtures(), calls);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue