From 90ab1028fb9a0ee32a5828282c89a8c8360f6c9c Mon Sep 17 00:00:00 2001 From: Imugiii Date: Thu, 25 Jun 2026 08:11:52 +0000 Subject: [PATCH] feat(back-office): POS comptoir/drive grise les tuiles en rupture RG-T21 (parite borne) --- .../Controllers/CounterOrderController.php | 21 ++++-- src/app/Views/admin/counter/new.php | 6 ++ src/public/admin/assets/css/admin.css | 22 ++++++ src/public/admin/assets/js/counter-order.js | 43 +++++++++-- .../Unit/Admin/CounterOrderControllerTest.php | 71 +++++++++++++++++++ tests/js/counter-order.test.js | 64 +++++++++++++++++ 6 files changed, 218 insertions(+), 9 deletions(-) diff --git a/src/app/Controllers/CounterOrderController.php b/src/app/Controllers/CounterOrderController.php index 73c035b..5357195 100644 --- a/src/app/Controllers/CounterOrderController.php +++ b/src/app/Controllers/CounterOrderController.php @@ -364,10 +364,18 @@ class CounterOrderController extends AdminController $productRepository = $this->productRepository(); $products = $productRepository->availableForCatalogue(); + // RG-T21 (parite borne) : rupture calculee par le stock, en UNE requete (set + // d'ids). availableForCatalogue() ne filtre que le retrait manuel (is_available) + // ; un produit liste mais en rupture devient non commandable -> le POS grise la + // tuile au lieu de laisser composer une commande vouee a echouer (meme echo UX + // que CatalogueController cote borne ; l'enforcement reste serveur a la commande). + $unavailable = array_fill_keys($productRepository->autoUnavailableIds(), true); + // Modificateurs proposables par produit a la carte : seuls les produits dont la // recette offre au moins un ingredient retirable/ajoutable portent une compo. - $products = array_map(function (array $product) use ($productRepository): array { + $products = array_map(function (array $product) use ($productRepository, $unavailable): array { $product['modifiers'] = $this->proposableModifiers($productRepository, (int) ($product['id'] ?? 0)); + $product['is_orderable'] = !isset($unavailable[(int) ($product['id'] ?? 0)]); return $product; }, $products); @@ -375,7 +383,7 @@ class CounterOrderController extends AdminController return $this->channelView('admin/counter/new', $source, [ 'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin', 'products' => $products, - 'menus' => $this->menusWithSlots($productRepository), + 'menus' => $this->menusWithSlots($productRepository, $unavailable), 'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')), 'serviceTag' => (string) ($values['service_tag'] ?? ''), 'error' => $error, @@ -392,16 +400,21 @@ class CounterOrderController extends AdminController * cote borne ; `burger_modifiers` calque proposableModifiers() (la selection de * modificateurs d'un menu cible le burger, comme resolveModifiers cote serveur). * + * @param array $unavailable set d'ids produits en rupture calculee (RG-T21), + * partage avec renderForm pour ne pas refaire la requete autoUnavailableIds. * @return list> */ - private function menusWithSlots(ProductRepository $productRepository): array + private function menusWithSlots(ProductRepository $productRepository, array $unavailable): array { $menuRepository = $this->menuRepository(); $menus = $menuRepository->availableForCatalogue(); - return array_map(function (array $menu) use ($menuRepository, $productRepository): array { + return array_map(function (array $menu) use ($menuRepository, $productRepository, $unavailable): array { $menu['slots'] = $menuRepository->slotsWithOptions((int) ($menu['id'] ?? 0)); $menu['burger_modifiers'] = $this->proposableModifiers($productRepository, (int) ($menu['burger_product_id'] ?? 0)); + // RG-T21 (granularite burger impose seul, parite borne) : un menu dont le + // burger principal est en rupture calculee n'est plus commandable -> grise. + $menu['is_orderable'] = !isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)]); return $menu; }, $menus); diff --git a/src/app/Views/admin/counter/new.php b/src/app/Views/admin/counter/new.php index 4ca0ab0..0f5fc61 100644 --- a/src/app/Views/admin/counter/new.php +++ b/src/app/Views/admin/counter/new.php @@ -82,6 +82,9 @@ $jsProducts = array_map( 'category_id' => (int) ($p['category_id'] ?? 0), 'category_name' => $catNameOf($p), 'modifiers' => $jsModifiers($p['modifiers'] ?? null), + // RG-T21 : false = rupture de stock calculee. La tuile reste visible (parite + // borne) mais grisee et non tappable cote JS. Absent => commandable par defaut. + 'commandable' => ($p['is_orderable'] ?? true) !== false, ], $productRows, ); @@ -98,6 +101,9 @@ $jsMenus = array_map( 'image' => (string) ($m['image_path'] ?? ''), 'category_id' => (int) ($m['category_id'] ?? 0), 'category_name' => $catNameOf($m), + // RG-T21 (granularite burger impose seul) : false = burger en rupture + // calculee. La tuile menu reste visible mais grisee et non tappable. + 'commandable' => ($m['is_orderable'] ?? true) !== false, // Modificateurs du burger support : la selection d'un menu cible le burger // (resolveModifiers cote serveur le resout sur burger_product_id). 'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null), diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index d563c17..849dfb7 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -1627,6 +1627,28 @@ tbody td.mono { font-weight: 700; } +/* Rupture de stock (RG-T21, parite borne product-card--unavailable) : tuile visible + mais non commandable. Grisee, curseur interdit, sans effet de survol (l'etat ne + ment pas) ; le tap est neutralise cote JS (return precoce). */ +.pos-tile--unavailable { + opacity: 0.55; + filter: grayscale(0.6); + cursor: not-allowed; +} +.pos-tile--unavailable:hover, +.pos-tile--unavailable:focus-visible { + border-color: var(--color-border); + box-shadow: var(--shadow-card); + transform: none; +} +/* Badge "Indisponible" : pose en haut a gauche pour ne pas chevaucher le badge + "Menu"/"A composer" (top-right) sur une tuile menu en rupture. */ +.pos-tile__badge--unavailable { + right: auto; + left: 8px; + background: var(--color-brand-dark, var(--color-text)); +} + /* Panneau commande persistant a droite (facon caisse). */ .pos__panel { flex: 0 0 340px; diff --git a/src/public/admin/assets/js/counter-order.js b/src/public/admin/assets/js/counter-order.js index 0c99421..4ee0731 100644 --- a/src/public/admin/assets/js/counter-order.js +++ b/src/public/admin/assets/js/counter-order.js @@ -854,13 +854,25 @@ var tile = el('button', 'pos-tile'); tile.type = 'button'; + // RG-T21 (parite borne, page-products.js) : commandable=false = rupture de + // stock calculee. La tuile reste visible (l'equipier voit le produit) mais + // grisee, marquee aria-disabled, badgee "Indisponible", et son tap ne fait + // rien (return precoce plus bas). L'enforcement qui fait foi reste serveur a + // la creation de commande ; ici c'est un echo UX. Absent => commandable. + var orderable = entry.commandable !== false; + if (!orderable) { + tile.className = 'pos-tile pos-tile--unavailable'; + tile.setAttribute('aria-disabled', 'true'); + } + // Une tuile qui ouvre la modale (menu ou produit a modificateurs) annonce // l'intention dans son nom accessible (D) et porte aria-haspopup=dialog : le // lecteur d'ecran sait qu'un tap ouvre une boite de dialogue de composition, - // pas un ajout sec. Le badge visuel "Menu"/"A composer" reste decoratif. - var opensModal = kind === 'menu' || (entry.modifiers && entry.modifiers.length); + // pas un ajout sec. Le badge visuel "Menu"/"A composer" reste decoratif. Une + // tuile en rupture n'ouvre aucune modale -> pas d'aria-haspopup. + var opensModal = orderable && (kind === 'menu' || (entry.modifiers && entry.modifiers.length)); var intent = opensModal ? (kind === 'menu' ? ', menu a composer' : ', a composer') : ''; - tile.setAttribute('aria-label', entry.name + ', ' + priceLabel + intent); + tile.setAttribute('aria-label', entry.name + ', ' + priceLabel + intent + (orderable ? '' : ', indisponible')); if (opensModal) { tile.setAttribute('aria-haspopup', 'dialog'); } @@ -895,15 +907,36 @@ tile.appendChild(body); // Badge visuel "Menu"/"A composer" (decoratif : l'intention est deja dans - // l'aria-label ci-dessus ; aria-hidden evite la double annonce). + // l'aria-label ci-dessus ; aria-hidden evite la double annonce). Une tuile en + // rupture porte plutot le badge "Indisponible" (l'intention de composer ne + // s'applique pas a une tuile non tappable). if (opensModal) { var badge = el('span', 'pos-tile__badge'); badge.setAttribute('aria-hidden', 'true'); badge.textContent = kind === 'menu' ? 'Menu' : 'A composer'; tile.appendChild(badge); } + if (!orderable) { + // Badge "Indisponible" (parite borne : product-card__badge). L'etat est + // deja dans l'aria-label ; aria-hidden evite la double annonce. + var unavailableBadge = el('span', 'pos-tile__badge pos-tile__badge--unavailable'); + unavailableBadge.setAttribute('aria-hidden', 'true'); + unavailableBadge.textContent = 'Indisponible'; + tile.appendChild(unavailableBadge); + } - tile.addEventListener('click', onTap); + tile.addEventListener('click', function (event) { + // Rupture (RG-T21) : le tap ne fait rien (ni ajout ni modale), comme la + // borne (page-products.js : if (!orderable) return). preventDefault pour + // un eventuel comportement par defaut du bouton. + if (!orderable) { + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + return; + } + onTap(event); + }); return tile; } diff --git a/tests/Unit/Admin/CounterOrderControllerTest.php b/tests/Unit/Admin/CounterOrderControllerTest.php index e3066bc..ecf467a 100644 --- a/tests/Unit/Admin/CounterOrderControllerTest.php +++ b/tests/Unit/Admin/CounterOrderControllerTest.php @@ -622,6 +622,77 @@ final class CounterOrderControllerTest extends TestCase self::assertStringContainsString('"price_maxi":1190', $body); } + public function testCreateMarksProductOutOfStockAsNotOrderable(): void + { + // RG-T21 (parite borne) : un produit liste (is_available=1) mais en rupture + // calculee par le stock (membre du set autoUnavailableIds) est expose + // commandable=false dans #pos-products ; un produit hors rupture reste + // commandable=true. Le POS grise la tuile non commandable (echo UX), comme la + // borne ; l'enforcement reste serveur a la creation de commande. + $db = $this->permittedDb(); + $db->productsRows = [ + ['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1], + ['id' => 22, 'category_id' => 2, 'category_name' => 'Accompagnements', 'name' => 'Frites', 'description' => null, 'price_cents' => 250, 'image_path' => null, 'display_order' => 1], + ]; + // autoUnavailableIds() (clause is_removable = 0) : le produit 12 est en rupture. + $db->autoUnavailableRows = [ + ['product_id' => 12], + ]; + + $response = $this->controller($this->get('/counter/orders/new'), $db)->create(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + // Le produit en rupture (12) porte commandable=false ; l'autre (22) reste true. + self::assertStringContainsString('"id":12', $body); + self::assertStringContainsString('"id":22', $body); + self::assertStringContainsString('"commandable":false', $body); + self::assertStringContainsString('"commandable":true', $body); + } + + public function testCreateMarksMenuWithRupturedBurgerAsNotOrderable(): void + { + // RG-T21 (granularite burger impose seul) : un menu dont le burger principal + // (burger_product_id) est en rupture calculee est expose commandable=false dans + // #pos-menus -> le POS grise la tuile menu (parite borne). + $db = $this->permittedDb(); + $db->menusRows = [ + ['id' => 5, 'category_id' => 1, 'burger_product_id' => 12, 'name' => 'Menu Cheeseburger', 'description' => null, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'image_path' => null, 'display_order' => 1], + ]; + // Le burger 12 du menu est en rupture (set autoUnavailableIds). + $db->autoUnavailableRows = [ + ['product_id' => 12], + ]; + + $response = $this->controller($this->get('/counter/orders/new'), $db)->create(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + self::assertStringContainsString('"id":5', $body); + self::assertStringContainsString('"commandable":false', $body); + } + + public function testCreateMarksAllOrderableWhenNoRupture(): void + { + // Contre-exemple : sans rupture (autoUnavailableIds vide), produits ET menus sont + // tous commandable=true (aucune tuile grisee). + $db = $this->permittedDb(); + $db->productsRows = [ + ['id' => 22, 'category_id' => 2, 'category_name' => 'Accompagnements', 'name' => 'Frites', 'description' => null, 'price_cents' => 250, 'image_path' => null, 'display_order' => 1], + ]; + $db->menusRows = [ + ['id' => 5, 'category_id' => 1, 'burger_product_id' => 12, 'name' => 'Menu Cheeseburger', 'description' => null, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'image_path' => null, 'display_order' => 1], + ]; + $db->autoUnavailableRows = []; + + $response = $this->controller($this->get('/counter/orders/new'), $db)->create(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + self::assertStringContainsString('"commandable":true', $body); + self::assertStringNotContainsString('"commandable":false', $body); + } + public function testStorePassesServiceTagInDineIn(): void { // 7a : un numero de table saisi en sur place est transmis a createStaffOrder et diff --git a/tests/js/counter-order.test.js b/tests/js/counter-order.test.js index a7320cb..8d5506e 100644 --- a/tests/js/counter-order.test.js +++ b/tests/js/counter-order.test.js @@ -513,6 +513,70 @@ test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', assert.equal(doc.querySelector('.order-cart__line'), null); }); +test('RG-T21 : tuile non commandable (rupture) -> grisee, aria-disabled, badge Indisponible, tap n ajoute rien', () => { + // commandable:false = rupture de stock calculee cote serveur (parite borne). La + // tuile reste visible mais desactivee ; un tap ne doit RIEN ajouter au panier. + const RUPTURE = [ + { id: 70, name: 'Frites', price: 250, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [], commandable: false }, + ]; + const dom = setup(RUPTURE, []); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Accompagnements'); + const tile = tileByName(dom, 'Frites'); + assert.ok(tile); + // Etat desactive : classe modifier + aria-disabled (echo UX de la rupture). + assert.equal(tile.classList.contains('pos-tile--unavailable'), true); + assert.equal(tile.getAttribute('aria-disabled'), 'true'); + // Badge "Indisponible" present (parite borne product-card__badge). + const badge = tile.querySelector('.pos-tile__badge--unavailable'); + assert.ok(badge); + assert.equal(badge.textContent, 'Indisponible'); + // L'aria-label porte l'etat indisponible (annonce lecteur d'ecran). + assert.match(tile.getAttribute('aria-label'), /indisponible/); + + // Tap : aucune ligne n'est creee, items_json reste vide. + click(dom, tile); + assert.equal(doc.querySelector('.order-cart__line'), null); + fireSubmit(dom); + assert.deepEqual(itemsJson(dom), []); +}); + +test('RG-T21 : menu non commandable (burger en rupture) -> tap n ouvre pas la modale', () => { + // Un menu dont le burger impose est en rupture (commandable:false) est grise et son + // tap ne doit pas ouvrir le composeur (granularite burger seul, parite borne). + const menuRupture = [{ + id: 9, + name: 'Menu Indispo', + price_normal: 990, + price_maxi: 1190, + image: '', + category_id: 4, + category_name: 'Menus', + commandable: false, + burger_modifiers: [], + slots: [ + { id: 1, name: 'Boisson', slot_type: 'drink', is_required: 1, display_order: 1, option_product_ids: [14] }, + ], + }]; + const dom = setup([{ id: 14, name: 'Coca', price: 200, image: '', category_id: 3, category_name: 'Boissons', modifiers: [] }], menuRupture); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Menus'); + const tile = tileByName(dom, 'Menu Indispo'); + assert.ok(tile); + assert.equal(tile.classList.contains('pos-tile--unavailable'), true); + assert.equal(tile.getAttribute('aria-disabled'), 'true'); + // Une tuile en rupture n'annonce pas l'intention de composer (pas d aria-haspopup). + assert.equal(tile.hasAttribute('aria-haspopup'), false); + + click(dom, tile); + const modal = doc.getElementById('menu-composer-modal'); + assert.equal(modal.hasAttribute('hidden'), true); // modale non ouverte +}); + test('tuile : pastille de repli quand aucune image (image vide)', () => { const dom = setup(); counterOrder.init(dom.window.document);