feat(back-office): POS rupture RG-T21 non commandable (parite borne) (#107)
This commit is contained in:
parent
2e0d535b58
commit
1e5f930185
6 changed files with 218 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue