feat(menu): accompagnement Maxi en variante Grande automatique (variante en base) #84

Merged
Corentin merged 2 commits from feat/r2-menu-maxi-variant into dev 2026-06-22 11:52:05 +02:00
5 changed files with 170 additions and 4 deletions
Showing only changes of commit cf182d6ac0 - Show all commits

View file

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

View file

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

View file

@ -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],
);
}

View file

@ -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<string, mixed> $item
* @return list<array{menu_slot_id:int,product_id:int,label:string}>
*/
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<int, list<int>> $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'] : ''];
}

View file

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