diff --git a/db/migrations/0007_product_size_variant.sql b/db/migrations/0007_product_size_variant.sql index 44235f7..a94f164 100644 --- a/db/migrations/0007_product_size_variant.sql +++ b/db/migrations/0007_product_size_variant.sql @@ -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. -- ============================================================================= diff --git a/db/seeds/0006_drink_maxi_variant.sql b/db/seeds/0006_drink_maxi_variant.sql new file mode 100644 index 0000000..edb4206 --- /dev/null +++ b/db/seeds/0006_drink_maxi_variant.sql @@ -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 " 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; diff --git a/src/public/borne/assets/js/checkout.js b/src/public/borne/assets/js/checkout.js index b758e3a..22c9169 100644 --- a/src/public/borne/assets/js/checkout.js +++ b/src/public/borne/assets/js/checkout.js @@ -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] || []), }; } diff --git a/src/public/borne/assets/js/page-product-menu.js b/src/public/borne/assets/js/page-product-menu.js index d395fa0..36b88dd 100644 --- a/src/public/borne/assets/js/page-product-menu.js +++ b/src/public/borne/assets/js/page-product-menu.js @@ -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, }; } diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php index ed55cf5..f464f42 100644 --- a/tests/Unit/Order/OrderRepositoryTest.php +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -121,6 +121,52 @@ 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 testMenuNormalKeepsBaseSideSelection(): void { // Format normal : aucune substitution, l'accompagnement reste la Moyenne diff --git a/tests/js/checkout.test.js b/tests/js/checkout.test.js index 174ce80..8b78fe9 100644 --- a/tests/js/checkout.test.js +++ b/tests/js/checkout.test.js @@ -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', () => { diff --git a/tests/js/composer-slots.test.js b/tests/js/composer-slots.test.js index ad6c3c9..c3315d5 100644 --- a/tests/js/composer-slots.test.js +++ b/tests/js/composer-slots.test.js @@ -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 } });