From cf182d6ac049c7b126b016216bf587f804494cc4 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 22 Jun 2026 09:48:47 +0000 Subject: [PATCH] feat(menu): accompagnement Maxi en variante Grande automatique (variante en base) --- db/migrations/0006_product_maxi_variant.sql | 32 +++++++++++ db/seeds/0004_menu_side_maxi.sql | 61 +++++++++++++++++++++ src/app/Catalogue/ProductRepository.php | 7 ++- src/app/Order/OrderRepository.php | 28 +++++++++- tests/Unit/Order/OrderRepositoryTest.php | 46 ++++++++++++++++ 5 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 db/migrations/0006_product_maxi_variant.sql create mode 100644 db/seeds/0004_menu_side_maxi.sql diff --git a/db/migrations/0006_product_maxi_variant.sql b/db/migrations/0006_product_maxi_variant.sql new file mode 100644 index 0000000..605c71d --- /dev/null +++ b/db/migrations/0006_product_maxi_variant.sql @@ -0,0 +1,32 @@ +-- db/migrations/0006_product_maxi_variant.sql +-- ============================================================================= +-- Wakdo - Migration 0006 : variante Maxi d'un produit (accompagnement de menu) +-- ============================================================================= +-- Purpose : ajoute a `product` une auto-reference nullable vers la variante +-- servie quand un menu est commande au format Maxi. L'accompagnement +-- de menu (slot_type='side') propose la version standard (ex. Moyenne +-- Frite, Potatoes) ; au format Maxi, le serveur substitue la variante +-- Grande (Grande Frite / Grande Potatoes) sans choix supplementaire. +-- Approche data-driven : la regle vit dans la donnee, pas dans le code, +-- et le decrement de stock (consumption()) frappe alors le bon produit. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE product + ADD COLUMN maxi_variant_product_id INT UNSIGNED NULL AFTER price_cents, + ADD CONSTRAINT fk_product_maxi_variant_product_id FOREIGN KEY (maxi_variant_product_id) + REFERENCES product (id) ON DELETE SET NULL; + +-- maxi_variant_product_id : produit servi a la place de celui-ci quand le menu est +-- au format Maxi (ex. Moyenne Frite -> Grande Frite). Place AFTER price_cents : +-- regroupe avec les attributs de commercialisation du produit. Nullable : la +-- plupart des produits n'ont pas de variante Maxi (un produit sans variante reste +-- valide et n'est jamais substitue). +-- +-- ON DELETE SET NULL (et non RESTRICT) : si la variante Grande est supprimee du +-- catalogue, le produit de base reste vendable, il perd seulement sa substitution +-- Maxi (degradation gracieuse). RESTRICT bloquerait la suppression d'une Grande +-- referencee, ce qui n'est pas souhaitable : la reference est un confort metier, +-- pas une integrite forte de commande (les commandes figent deja leurs snapshots). diff --git a/db/seeds/0004_menu_side_maxi.sql b/db/seeds/0004_menu_side_maxi.sql new file mode 100644 index 0000000..79946a2 --- /dev/null +++ b/db/seeds/0004_menu_side_maxi.sql @@ -0,0 +1,61 @@ +-- ============================================================================= +-- Wakdo — Seed 0004 : accompagnement de menu = variante Maxi automatique +-- ============================================================================= +-- Purpose : cabler la regle metier "accompagnement Maxi" sur les donnees seedees +-- par 0002_catalogue.sql, sans toucher au code : +-- 1. lier chaque accompagnement standard a sa variante Grande +-- (Moyenne Frite -> Grande Frite, Potatoes -> Grande Potatoes) ; +-- 2. restreindre les options du slot 'side' des menus aux deux seuls +-- choix conformes a la maquette (ecran 4) : Moyenne Frite + Potatoes. +-- Phase : P4 — depend du schema 0006 (product.maxi_variant_product_id) et du +-- catalogue 0002 (produits frites + menu_slot 'side'). +-- +-- Etat initial (0002_catalogue.sql, section 5) : le slot 'side' recoit TOUS les +-- produits de la categorie 'frites', soit les 5 : Petite Frite, Moyenne Frite, +-- Grande Frite, Potatoes, Grande Potatoes. Ce seed retire Petite Frite, Grande +-- Frite et Grande Potatoes des options de menu (elles restent a la carte dans la +-- categorie frites) : le DELETE n'est donc PAS un no-op sur une base 0002. +-- +-- Conventions: +-- - Aucun id en dur : toutes les references sont resolues par sous-requete sur +-- le nom du produit / le type de slot (memes noms que 0002_catalogue.sql). +-- - IDEMPOTENT : UPDATE convergent (repositionne la meme valeur) et DELETE par +-- appartenance (re-supprimer des options deja absentes ne fait rien) ; rejouer +-- ce seed laisse la base dans le meme etat. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. Lier chaque accompagnement standard a sa variante Grande. +-- Le SELECT cible la table `product`, que l'UPDATE modifie aussi : MariaDB/ +-- MySQL interdit de lire et d'ecrire la meme table dans une seule requete +-- sans niveau de derivation. La sous-requete est donc enveloppee dans une +-- table derivee (SELECT ... FROM (SELECT ...) x) qui materialise l'id avant +-- l'UPDATE, contournant l'erreur "can't specify target table for update". +-- ----------------------------------------------------------------------------- +UPDATE product +SET maxi_variant_product_id = ( + SELECT id FROM (SELECT id FROM product WHERE name = 'Grande Frite') x +) +WHERE name = 'Moyenne Frite'; + +UPDATE product +SET maxi_variant_product_id = ( + SELECT id FROM (SELECT id FROM product WHERE name = 'Grande Potatoes') x +) +WHERE name = 'Potatoes'; + +-- ----------------------------------------------------------------------------- +-- 2. Restreindre les options du slot 'side' des menus aux deux choix de la +-- maquette. On supprime des slots 'side' toute option qui n'est ni Moyenne +-- Frite ni Potatoes (Petite Frite, Grande Frite, Grande Potatoes). Les autres +-- slots (drink, sauce) et les produits a la carte ne sont pas touches. +-- Idempotent : sur une base deja restreinte, ces lignes n'existent plus, le +-- DELETE affecte 0 ligne. +-- ----------------------------------------------------------------------------- +DELETE FROM menu_slot_option +WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE slot_type = 'side') + AND product_id IN ( + SELECT id FROM product WHERE name IN ('Petite Frite', 'Grande Frite', 'Grande Potatoes') + ); diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 0991400..2fafbaf 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -47,9 +47,12 @@ final class ProductRepository */ public function find(int $id): ?array { + // maxi_variant_product_id : expose la variante Grande de l'accompagnement + // pour que OrderRepository::resolveSelections puisse substituer au format + // Maxi (cote serveur uniquement ; la borne n'en a pas besoin). return $this->db->fetch( - 'SELECT id, category_id, name, description, price_cents, vat_rate, image_path, ' - . 'is_available, display_order FROM product WHERE id = :id', + 'SELECT id, category_id, name, description, price_cents, maxi_variant_product_id, ' + . 'vat_rate, image_path, is_available, display_order FROM product WHERE id = :id', ['id' => $id], ); } diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php index f2a5cc0..623c36e 100644 --- a/src/app/Order/OrderRepository.php +++ b/src/app/Order/OrderRepository.php @@ -443,7 +443,7 @@ class OrderRepository $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']; - $selections = $this->resolveSelections($item, (int) $menu['id']); + $selections = $this->resolveSelections($item, (int) $menu['id'], $format); $modifiers = $this->resolveModifiers($item, (int) $menu['burger_product_id']); $unitTtc = $unitBase + $this->modifiersExtra($modifiers); @@ -469,10 +469,20 @@ class OrderRepository } /** + * Valide chaque selection contre les options du slot (la selection BASE, telle + * qu'envoyee par la borne), puis applique la regle de variante Maxi : si le + * menu est au format 'maxi' ET que le produit choisi a une variante Maxi + * (maxi_variant_product_id non nul, ex. Moyenne Frite -> Grande Frite), c'est + * l'id ET le label de la VARIANTE qui sont persistes dans order_item_selection. + * Ainsi consumption() decremente le stock de la Grande variante et le snapshot + * de libelle reflete "Grande Frite". La validation porte toujours sur le + * produit de base : la borne ne propose que les accompagnements standard, la + * substitution est une mecanique serveur invisible. + * * @param array $item * @return list */ - private function resolveSelections(array $item, int $menuId): array + private function resolveSelections(array $item, int $menuId, string $format): array { $slots = $this->menus->slotsWithOptions($menuId); /** @var array> $optionsBySlot */ @@ -490,6 +500,20 @@ class OrderRepository throw new OrderValidationException('INVALID_SELECTION'); } $product = $this->products->find($pid); + + // Substitution Maxi : seuls les produits dotes d'une variante (les + // accompagnements standard) sont permutes ; les autres slots (boisson, + // sauce) n'ont pas de variante et restent inchanges, sans garde sur le + // slot_type. find() relit la variante (id + label) pour son snapshot. + $variantId = $product !== null ? (int) ($product['maxi_variant_product_id'] ?? 0) : 0; + if ($format === 'maxi' && $variantId > 0) { + $variant = $this->products->find($variantId); + if ($variant !== null) { + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $variantId, 'label' => (string) $variant['name']]; + continue; + } + } + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $pid, 'label' => $product !== null ? (string) $product['name'] : '']; } diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php index fabd774..8454a68 100644 --- a/tests/Unit/Order/OrderRepositoryTest.php +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -75,6 +75,52 @@ final class OrderRepositoryTest extends TestCase self::assertSame(1, $db->countWrites('INSERT INTO order_item_selection')); } + public function testMenuMaxiSwapsSideSelectionToGrandeVariant(): void + { + // Au format maxi, l'accompagnement Moyenne Frite (variante = Grande Frite, + // id 24) doit etre persiste comme Grande Frite : la selection stocke l'id + + // le label de la variante, pour que le decrement de stock frappe la Grande. + $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[23] = ['id' => 23, 'name' => 'Moyenne Frite', 'price_cents' => 275, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => 24]; + $db->products[24] = ['id' => 24, 'name' => 'Grande Frite', 'price_cents' => 350, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null]; + $db->slotRows[5] = [['id' => 8, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 2, 'product_id' => 23]]; + + $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi', + 'selections' => [['menu_slot_id' => 8, 'product_id' => 23]]]], // borne envoie la Moyenne + ]); + + $sel = $db->firstWrite('INSERT INTO order_item_selection'); + self::assertSame(24, $sel['pid']); // swap -> Grande Frite + self::assertSame('Grande Frite', $sel['label']); + self::assertSame(8, $sel['slot']); + } + + public function testMenuNormalKeepsBaseSideSelection(): void + { + // Format normal : aucune substitution, l'accompagnement reste la Moyenne + // Frite meme si une variante Maxi est definie sur le produit. + $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[23] = ['id' => 23, 'name' => 'Moyenne Frite', 'price_cents' => 275, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => 24]; + $db->products[24] = ['id' => 24, 'name' => 'Grande Frite', 'price_cents' => 350, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null]; + $db->slotRows[5] = [['id' => 8, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 2, 'product_id' => 23]]; + + $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', + 'selections' => [['menu_slot_id' => 8, 'product_id' => 23]]]], + ]); + + $sel = $db->firstWrite('INSERT INTO order_item_selection'); + self::assertSame(23, $sel['pid']); // pas de swap -> Moyenne Frite + self::assertSame('Moyenne Frite', $sel['label']); + } + public function testAddModifierAddsExtraToLine(): void { $db = new FakeOrderDatabase();