feat(menu): accompagnement Maxi en variante Grande automatique (variante en base) #84
5 changed files with 170 additions and 4 deletions
32
db/migrations/0006_product_maxi_variant.sql
Normal file
32
db/migrations/0006_product_maxi_variant.sql
Normal 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).
|
||||
61
db/seeds/0004_menu_side_maxi.sql
Normal file
61
db/seeds/0004_menu_side_maxi.sql
Normal 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')
|
||||
);
|
||||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] : ''];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue