feat(back-office): POS rupture RG-T21 non commandable (parite borne) (#107)
All checks were successful
CI / secret-scan (push) Successful in 22s
CI / php-lint (push) Successful in 30s
CI / static-tests (push) Successful in 59s
CI / js-tests (push) Successful in 31s

This commit is contained in:
Corentin JOGUET 2026-06-25 10:16:56 +02:00
parent 2e0d535b58
commit 1e5f930185
6 changed files with 218 additions and 9 deletions

View file

@ -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<int, true> $unavailable set d'ids produits en rupture calculee (RG-T21),
* partage avec renderForm pour ne pas refaire la requete autoUnavailableIds.
* @return list<array<string, mixed>>
*/
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);

View file

@ -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),

View file

@ -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;

View file

@ -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;
}

View file

@ -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

View file

@ -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);