Compare commits
2 commits
a1e69d2f33
...
2f98168182
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f98168182 | ||
| 411b04d548 |
7 changed files with 141 additions and 7 deletions
|
|
@ -8,10 +8,15 @@
|
||||||
-- domaine commande facture deja par product_id : le flux de commande
|
-- domaine commande facture deja par product_id : le flux de commande
|
||||||
-- reste inchange, la borne resout juste la taille choisie en product_id.
|
-- reste inchange, la borne resout juste la taille choisie en product_id.
|
||||||
--
|
--
|
||||||
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) :
|
-- Grouping DEDIE (base_product_id), distinct de maxi_variant_product_id
|
||||||
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu
|
-- (migration 0006) : base_product_id pilote la selection de taille A LA
|
||||||
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une
|
-- CARTE (picker 30/50 cl) ; maxi_variant_product_id pilote la substitution
|
||||||
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu).
|
-- 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.
|
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
|
|
|
||||||
50
db/seeds/0006_drink_maxi_variant.sql
Normal file
50
db/seeds/0006_drink_maxi_variant.sql
Normal file
|
|
@ -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 "<soda> 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;
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
* Traduction panier borne -> contrat API :
|
* Traduction panier borne -> contrat API :
|
||||||
* - produit simple -> { type:'product', product_id, quantity }
|
* - produit simple -> { type:'product', product_id, quantity }
|
||||||
* - menu -> { type:'menu', menu_id, quantity, format, selections }
|
* - 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
|
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
|
||||||
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
|
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
|
||||||
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
|
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
|
||||||
|
|
@ -64,7 +65,10 @@ export function buildOrderItem(cartItem, menuSlotsById) {
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
menu_id: cartItem.id,
|
menu_id: cartItem.id,
|
||||||
quantity: cartItem.quantite,
|
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] || []),
|
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,11 @@ export function buildMenuCartItem(menu, model, { size, selections }) {
|
||||||
quantite: 1,
|
quantite: 1,
|
||||||
image: menu.image,
|
image: menu.image,
|
||||||
supplement_cents: supplement,
|
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,
|
composition,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,52 @@ final class OrderRepositoryTest extends TestCase
|
||||||
self::assertSame(8, $sel['slot']);
|
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 testProductInStockRuptureRejectedAtOrderCreation(): void
|
public function testProductInStockRuptureRejectedAtOrderCreation(): void
|
||||||
{
|
{
|
||||||
// RG-T21 : un produit liste (is_available=1) mais en rupture calculee par le
|
// RG-T21 : un produit liste (is_available=1) mais en rupture calculee par le
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,17 @@ test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
|
||||||
assert.equal(maxi.format, 'maxi');
|
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 --------------------------------------------------- */
|
/* --- buildOrderPayload --------------------------------------------------- */
|
||||||
|
|
||||||
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {
|
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos
|
||||||
assert.equal(item.type, 'menu');
|
assert.equal(item.type, 'menu');
|
||||||
assert.equal(item.prix_cents, 880);
|
assert.equal(item.prix_cents, 880);
|
||||||
assert.equal(item.supplement_cents, 0);
|
assert.equal(item.supplement_cents, 0);
|
||||||
|
assert.equal(item.format, 'normal'); // format explicite transporte
|
||||||
assert.equal(item.composition.burger.libelle, 'Le 280');
|
assert.equal(item.composition.burger.libelle, 'Le 280');
|
||||||
// Normal : l'accompagnement garde son nom de base (pas la variante Maxi).
|
// Normal : l'accompagnement garde son nom de base (pas la variante Maxi).
|
||||||
assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' });
|
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 } });
|
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
|
||||||
assert.equal(item.prix_cents, 880);
|
assert.equal(item.prix_cents, 880);
|
||||||
assert.equal(item.supplement_cents, 150); // 1030 - 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.accompagnement.taille, 'G');
|
||||||
assert.equal(item.composition.boisson.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 m = buildComposerSteps(detail(), byId());
|
||||||
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
|
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"
|
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');
|
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)', () => {
|
test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => {
|
||||||
const m = buildComposerSteps(detail(), byId());
|
const m = buildComposerSteps(detail(), byId());
|
||||||
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });
|
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue