From 2f98168182a06a5b4686e51bf354b64f60d5590a Mon Sep 17 00:00:00 2001 From: Imugiii Date: Wed, 24 Jun 2026 09:17:31 +0000 Subject: [PATCH] feat(borne): produit/menu en rupture stock non commandable (RG-T21) La rupture calculee (autoUnavailableIds) etait deja derivee mais pas appliquee au parcours de commande. Desormais : - CatalogueController expose is_orderable par produit/menu (menu = burger impose seul), en croisant le catalogue avec autoUnavailableIds en une requete (pas de N+1). La borne (data.js -> commandable) grise la tuile + badge "Indisponible" et bloque le clic (page-products.js + CSS). - Garde SERVEUR a la creation de commande (OrderRepository::resolveLine) : un produit, ou le burger d'un menu, en rupture est refuse quel que soit le canal, y compris par acces direct ou repli sans-JS. C'est la couche qui fait foi ; le grisage borne n'est qu'un echo UX. Tests : CatalogueControllerTest (is_orderable liste+detail, produits+menus), OrderRepositoryTest (refus a la commande produit + menu burger), data.test (commandable). Doubles desambiguises (autoUnavailableIds vs composition). PHPStan L6 propre. --- src/app/Catalogue/MenuRepository.php | 5 +- src/app/Catalogue/ProductRepository.php | 6 +- src/app/Controllers/CatalogueController.php | 43 +++++++++-- src/app/Order/OrderRepository.php | 23 +++++- src/public/borne/assets/css/style.css | 29 ++++++++ src/public/borne/assets/js/data.js | 6 +- src/public/borne/assets/js/page-products.js | 13 +++- tests/Support/FakeCatalogueDatabase.php | 14 ++++ tests/Support/FakeOrderDatabase.php | 7 ++ .../Catalogue/CatalogueControllerTest.php | 73 ++++++++++++++++++- tests/Unit/Order/OrderRepositoryTest.php | 34 +++++++++ tests/js/data.test.js | 16 +++- 12 files changed, 247 insertions(+), 22 deletions(-) diff --git a/src/app/Catalogue/MenuRepository.php b/src/app/Catalogue/MenuRepository.php index 18bf773..83c0b38 100644 --- a/src/app/Catalogue/MenuRepository.php +++ b/src/app/Catalogue/MenuRepository.php @@ -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> */ diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 98a46de..19769ac 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -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 diff --git a/src/app/Controllers/CatalogueController.php b/src/app/Controllers/CatalogueController.php index c549cf7..746e791 100644 --- a/src/app/Controllers/CatalogueController.php +++ b/src/app/Controllers/CatalogueController.php @@ -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} + * @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, 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 $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, ]; } diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php index 3cfc3e1..e0da7f7 100644 --- a/src/app/Order/OrderRepository.php +++ b/src/app/Order/OrderRepository.php @@ -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 $item + * @param array $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, modifiers:list} */ - 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']; diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index ba4e07b..ed68a70 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -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; diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index 741003b..bf13b4d 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -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; }); diff --git a/src/public/borne/assets/js/page-products.js b/src/public/borne/assets/js/page-products.js index 8c2f5a3..bffe0db 100644 --- a/src/public/borne/assets/js/page-products.js +++ b/src/public/borne/assets/js/page-products.js @@ -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 = `
@@ -77,6 +81,7 @@ async function renderProducts() { loading="lazy" onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';" > + ${orderable ? '' : 'Indisponible'}
${escHtml(product.nom)} @@ -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 reste un repli (lien direct / sans JS). + // (L3). Le 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); }); diff --git a/tests/Support/FakeCatalogueDatabase.php b/tests/Support/FakeCatalogueDatabase.php index 1c4d5d2..30eb3f6 100644 --- a/tests/Support/FakeCatalogueDatabase.php +++ b/tests/Support/FakeCatalogueDatabase.php @@ -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> + */ + 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')) { diff --git a/tests/Support/FakeOrderDatabase.php b/tests/Support/FakeOrderDatabase.php index 0886068..f09730f 100644 --- a/tests/Support/FakeOrderDatabase.php +++ b/tests/Support/FakeOrderDatabase.php @@ -28,6 +28,8 @@ final class FakeOrderDatabase implements DatabaseInterface public array $slotRows = []; /** @var array>> recettes (composition) par produit id. */ public array $compositions = []; + /** @var list> ids produits en rupture calculee (autoUnavailableIds, RG-T21). */ + public array $autoUnavailableRows = []; /** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */ /** @var array|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']] ?? []; } diff --git a/tests/Unit/Catalogue/CatalogueControllerTest.php b/tests/Unit/Catalogue/CatalogueControllerTest.php index 954c0df..b8576b8 100644 --- a/tests/Unit/Catalogue/CatalogueControllerTest.php +++ b/tests/Unit/Catalogue/CatalogueControllerTest.php @@ -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(); diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php index f464f42..0b91592 100644 --- a/tests/Unit/Order/OrderRepositoryTest.php +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -167,6 +167,40 @@ final class OrderRepositoryTest extends TestCase 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 diff --git a/tests/js/data.test.js b/tests/js/data.test.js index 325ee55..d04547a 100644 --- a/tests/js/data.test.js +++ b/tests/js/data.test.js @@ -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);