Compare commits
3 commits
4206452a7e
...
1c134f5b86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c134f5b86 | ||
|
|
cf182d6ac0 | ||
| 741cfdb02b |
12 changed files with 839 additions and 12 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],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@ declare(strict_types=1);
|
|||
namespace App\Controllers;
|
||||
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\GuardResult;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\PinThrottle;
|
||||
use App\Auth\PinVerifier;
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
use App\Order\OrderQueryRepository;
|
||||
use App\Order\OrderRepository;
|
||||
|
|
@ -15,11 +20,12 @@ use App\Order\OrderValidationException;
|
|||
/**
|
||||
* Domaine commande back-office (P4 + P3 operationnel). GET /admin/orders : liste
|
||||
* recente (order.read). POST /admin/orders/{number}/deliver : transition paid ->
|
||||
* delivered (DELIVER_ORDER, geste unique, order.deliver), NON PIN-gated. L'annulation
|
||||
* (CANCEL_ORDER, PIN + restock) et la file cuisine (KitchenController) sont traitees
|
||||
* ailleurs.
|
||||
* delivered (DELIVER_ORDER, geste unique, order.deliver), NON PIN-gated.
|
||||
* GET/POST /admin/orders/{number}/cancel : annulation (CANCEL_ORDER, mlt 7.1,
|
||||
* order.cancel) avec PIN equipier + audit + restock conditionnel (RG-T13/T14). La
|
||||
* file cuisine (KitchenController) est traitee ailleurs.
|
||||
*
|
||||
* Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()).
|
||||
* Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()/pin*()).
|
||||
*/
|
||||
class OrderAdminController extends AdminController
|
||||
{
|
||||
|
|
@ -37,6 +43,9 @@ class OrderAdminController extends AdminController
|
|||
'title' => 'Commandes - Wakdo Admin',
|
||||
'activeNav' => 'orders',
|
||||
'orders' => $this->orderQuery()->recent(50),
|
||||
// RG-T03 : adapte l'affichage (bouton Annuler) sans remplacer la garde
|
||||
// par-action de cancel(). manager n'a PAS order.cancel (decision D5).
|
||||
'canCancel' => $this->may($guard, 'order.cancel'),
|
||||
], $guard);
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +81,98 @@ class OrderAdminController extends AdminController
|
|||
return $this->redirect('/admin/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* Page de confirmation d'annulation (CANCEL_ORDER, mlt 7.1). Garde order.cancel.
|
||||
* Affiche numero/statut/total + le formulaire PIN equipier (modele RG-T13). La
|
||||
* commande est chargee en lecture seule (OrderRepository::findByNumber) ; statut
|
||||
* terminal (delivered/cancelled) -> message bloquant, pas de formulaire.
|
||||
*
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function confirmCancel(array $params): Response
|
||||
{
|
||||
$guard = $this->guard('order.cancel');
|
||||
if ($guard instanceof Response) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$order = $this->orders()->findByNumber((string) ($params['number'] ?? ''));
|
||||
if ($order === null) {
|
||||
return $this->notFound($guard);
|
||||
}
|
||||
|
||||
return $this->renderCancel($guard, $order, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annulation effective (CANCEL_ORDER, mlt 7.1). POST + CSRF + garde order.cancel,
|
||||
* puis flux PIN equipier IDENTIQUE a IngredientController::inventory (RG-T13/T22) :
|
||||
* verrou throttle par utilisateur AGISSANT evalue AVANT la verification (leurre de
|
||||
* timing, message generique) ; sur echec PIN -> pin.failed + increment throttle dans
|
||||
* UNE transaction. Sur PIN OK -> OrderRepository::cancel (transition + restock
|
||||
* conditionnel + audit dans sa propre transaction), reset du throttle, flash.
|
||||
*
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function cancel(array $params): Response
|
||||
{
|
||||
$guard = $this->guard('order.cancel');
|
||||
if ($guard instanceof Response) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$form = $this->request->formBody();
|
||||
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||
return $this->invalidCsrf();
|
||||
}
|
||||
|
||||
$number = (string) ($params['number'] ?? '');
|
||||
$order = $this->orders()->findByNumber($number);
|
||||
if ($order === null) {
|
||||
return $this->notFound($guard);
|
||||
}
|
||||
|
||||
// RG-T22 : verrou du throttle par utilisateur AGISSANT (session), evalue AVANT
|
||||
// la verification ; sous verrou, leurre de timing + message generique, pas de
|
||||
// nouvelle ligne pin.failed (les echecs ayant arme le verrou sont deja audites).
|
||||
$actorId = $guard->userId ?? 0;
|
||||
if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) {
|
||||
$this->pinVerifier()->payTimingDecoy($form['pin'] ?? '');
|
||||
|
||||
return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422);
|
||||
}
|
||||
|
||||
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
|
||||
if ($actor === null) {
|
||||
// RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans UNE
|
||||
// transaction. pin.failed est un evenement securite (pas l'effet metier).
|
||||
$email = trim($form['pin_email'] ?? '');
|
||||
$entityId = (int) ($order['id'] ?? 0);
|
||||
$this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void {
|
||||
$this->logFailedPin($db, $email, $entityId);
|
||||
$this->pinThrottle()->recordFailureWithin($db, $actorId);
|
||||
});
|
||||
|
||||
return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->orders()->cancel($number, (int) $actor['id'], (int) $actor['role_id']);
|
||||
// PIN valide : reinitialise le compteur de l'acteur de SESSION (RG-T22, cle
|
||||
// = $actorId), surtout pas $actor['id'] (l'equipier resolu par le PIN).
|
||||
$this->pinThrottle()->reset($actorId);
|
||||
$this->setFlash('Commande annulee.');
|
||||
} catch (OrderValidationException $exception) {
|
||||
$this->setFlash(match ($exception->getMessage()) {
|
||||
'ORDER_NOT_FOUND' => 'Commande introuvable.',
|
||||
'CANNOT_CANCEL_IN_STATE' => 'Annulation impossible : la commande est livree ou deja annulee.',
|
||||
default => 'Transition invalide : la commande a change d\'etat.',
|
||||
});
|
||||
}
|
||||
|
||||
return $this->redirect('/admin/orders');
|
||||
}
|
||||
|
||||
protected function orderQuery(): OrderQueryRepository
|
||||
{
|
||||
return new OrderQueryRepository($this->db());
|
||||
|
|
@ -84,6 +185,68 @@ class OrderAdminController extends AdminController
|
|||
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
|
||||
}
|
||||
|
||||
protected function pinVerifier(): PinVerifier
|
||||
{
|
||||
return new PinVerifier($this->db(), $this->config, $this->passwordHasher());
|
||||
}
|
||||
|
||||
protected function pinThrottle(): PinThrottle
|
||||
{
|
||||
return new PinThrottle($this->db(), $this->config);
|
||||
}
|
||||
|
||||
protected function passwordHasher(): PasswordHasher
|
||||
{
|
||||
return new PasswordHasher($this->config);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-T03 : la permission est-elle detenue par le role de la session courante ?
|
||||
* Sert a adapter l'affichage (bouton Annuler) sans remplacer la garde par-action.
|
||||
*/
|
||||
private function may(GuardResult $guard, string $permission): bool
|
||||
{
|
||||
return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une tentative de PIN echouee sur l'annulation (RG-T14) : rend le
|
||||
* brute-force d'attribution detectable. Acteur inconnu (PIN non resolu).
|
||||
*/
|
||||
private function logFailedPin(DatabaseInterface $db, string $email, int $orderId): void
|
||||
{
|
||||
$db->execute(
|
||||
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
|
||||
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
|
||||
[
|
||||
'uid' => null,
|
||||
'rid' => null,
|
||||
'code' => 'pin.failed',
|
||||
'etype' => 'customer_order',
|
||||
'eid' => $orderId,
|
||||
'summary' => 'Echec PIN annulation (email tente: ' . $email . ')',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order
|
||||
*/
|
||||
private function renderCancel(GuardResult $guard, array $order, ?string $error, int $status = 200): Response
|
||||
{
|
||||
return $this->adminView('admin/orders/cancel', [
|
||||
'title' => 'Annuler une commande - Wakdo Admin',
|
||||
'activeNav' => 'orders',
|
||||
'order' => $order,
|
||||
'error' => $error,
|
||||
], $guard, $status);
|
||||
}
|
||||
|
||||
private function notFound(GuardResult $guard): Response
|
||||
{
|
||||
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'orders'], $guard, 404);
|
||||
}
|
||||
|
||||
private function redirect(string $location): Response
|
||||
{
|
||||
return Response::make('', 302, ['Location' => $location]);
|
||||
|
|
|
|||
|
|
@ -341,6 +341,123 @@ class OrderRepository
|
|||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annulation d'une commande (CANCEL_ORDER, mlt 7.1). Transition gardee
|
||||
* pending_payment|paid -> cancelled, re-credit de stock CONDITIONNEL et ecriture
|
||||
* audit_log dans UNE transaction (RG-T07/T08/T11/T14).
|
||||
*
|
||||
* Le re-credit n'a lieu que si la commande etait `paid` AVANT l'annulation : une
|
||||
* commande `pending_payment` n'avait jamais decremente le stock (le decrement est
|
||||
* pose a la transition `paid`, cf. pay()), il n'y a donc rien a re-crediter. Le
|
||||
* re-credit reutilise consumption() (memes unites que le decrement de pay()),
|
||||
* inversees (delta positif) ; un ingredient entierement retire (modifieur remove)
|
||||
* n'a pas ete decremente -> consumption() ne le retourne pas -> pas de re-credit.
|
||||
*
|
||||
* Concurrence (RG-T07/RG-T20) : le statut est relu A L'INTERIEUR de la transaction
|
||||
* via l'UPDATE garde par `status IN ('pending_payment','paid')` ; 0 ligne affectee
|
||||
* = course perdue (un autre appel a deja transite) -> INVALID_TRANSITION. Le
|
||||
* re-credit se base sur le pre-status lu en entree (coherent : seul l'appel qui a
|
||||
* remporte la garde poursuit, et il n'y a pas de SELECT FOR UPDATE — RG-T20).
|
||||
*
|
||||
* @param int|null $actingUserId equipier resolu par PIN (audit_log.actor_user_id +
|
||||
* stock_movement.user_id) ; le controleur le fournit.
|
||||
* @param int|null $actingRoleId role de l'equipier resolu par PIN (audit_log.actor_role_id).
|
||||
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
|
||||
* @throws OrderValidationException
|
||||
*/
|
||||
public function cancel(string $orderNumber, ?int $actingUserId, ?int $actingRoleId): array
|
||||
{
|
||||
$order = $this->db->fetch(
|
||||
'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n',
|
||||
['n' => $orderNumber],
|
||||
);
|
||||
if ($order === null) {
|
||||
throw new OrderValidationException('ORDER_NOT_FOUND');
|
||||
}
|
||||
|
||||
$preStatus = (string) $order['status'];
|
||||
if (!in_array($preStatus, ['pending_payment', 'paid'], true)) {
|
||||
throw new OrderValidationException('CANNOT_CANCEL_IN_STATE'); // delivered / cancelled (statut terminal).
|
||||
}
|
||||
|
||||
$result = [
|
||||
'id' => (int) $order['id'],
|
||||
'order_number' => (string) $order['order_number'],
|
||||
'total_ttc_cents' => (int) $order['total_ttc_cents'],
|
||||
'status' => 'cancelled',
|
||||
];
|
||||
|
||||
$orderId = (int) $order['id'];
|
||||
$totalTtc = (int) $order['total_ttc_cents'];
|
||||
$this->db->transaction(function (DatabaseInterface $db) use ($orderId, $preStatus, $totalTtc, $actingUserId, $actingRoleId): void {
|
||||
$affected = $db->execute(
|
||||
'UPDATE customer_order SET status = \'cancelled\', cancelled_at = NOW(), updated_at = NOW() '
|
||||
. 'WHERE id = :id AND status IN (\'pending_payment\', \'paid\')',
|
||||
['id' => $orderId],
|
||||
);
|
||||
if ($affected === 0) {
|
||||
// Course perdue : la garde RG-T07 n'a affecte aucune ligne (un autre
|
||||
// appel a deja transite vers un statut terminal). Pas d'issue idempotente
|
||||
// pour l'annulation (a la difference de pay/deliver) : on signale la
|
||||
// transition invalide et la transaction est annulee (aucun re-credit).
|
||||
throw new OrderValidationException('INVALID_TRANSITION');
|
||||
}
|
||||
|
||||
// RG-3 : re-credit CONDITIONNEL. On le decide sur l'EXISTENCE de mouvements
|
||||
// 'sale' pour cette commande (poses au decrement de pay()), PAS sur le
|
||||
// pre-status lu hors transaction : insensible a la course
|
||||
// pending_payment -> paid -> cancel (sinon un pay() concurrent gagnant
|
||||
// laisserait le stock decremente sans re-credit, derive silencieuse). De
|
||||
// fait idempotent : sans mouvement 'sale', rien a re-crediter. Memes unites
|
||||
// que pay() (consumption), inversees (delta positif).
|
||||
$restocked = $this->hasSaleMovements($db, $orderId);
|
||||
if ($restocked) {
|
||||
foreach ($this->consumption($db, $orderId) as $ingredientId => $units) {
|
||||
$db->execute(
|
||||
'UPDATE ingredient SET stock_quantity = stock_quantity + :u WHERE id = :id',
|
||||
['u' => $units, 'id' => $ingredientId],
|
||||
);
|
||||
$db->execute(
|
||||
'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) '
|
||||
. 'VALUES (:ing, \'cancellation\', :delta, :oid, :uid, NULL)',
|
||||
['ing' => $ingredientId, 'delta' => $units, 'oid' => $orderId, 'uid' => $actingUserId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// RG-6/RG-T14 : trace d'audit immuable dans la meme transaction que l'effet.
|
||||
$recredit = $restocked ? $totalTtc : 0;
|
||||
$summary = 'Annulation depuis ' . $preStatus . ', re-credit ' . $recredit . 'c';
|
||||
$db->execute(
|
||||
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
|
||||
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
|
||||
[
|
||||
'uid' => $actingUserId,
|
||||
'rid' => $actingRoleId,
|
||||
'code' => 'order.cancel',
|
||||
'etype' => 'customer_order',
|
||||
'eid' => $orderId,
|
||||
'summary' => $summary,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la commande porte au moins un mouvement de stock `sale` (donc elle a
|
||||
* deja ete encaissee/decrementee par pay()). Sert a decider le re-credit a
|
||||
* l'annulation independamment du statut observe hors transaction (anti-course).
|
||||
*/
|
||||
private function hasSaleMovements(DatabaseInterface $db, int $orderId): bool
|
||||
{
|
||||
return $db->fetch(
|
||||
'SELECT 1 AS x FROM stock_movement WHERE order_id = :oid AND movement_type = \'sale\' LIMIT 1',
|
||||
['oid' => $orderId],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la
|
||||
* commande (lecture des lignes persistees + recettes des produits supports).
|
||||
|
|
@ -443,7 +560,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 +586,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 +617,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'] : ''];
|
||||
}
|
||||
|
||||
|
|
|
|||
79
src/app/Views/admin/orders/cancel.php
Normal file
79
src/app/Views/admin/orders/cancel.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Confirmation d'annulation d'une commande (CANCEL_ORDER 7.1), injectee dans
|
||||
* admin/layout.php. Action sensible de manipulation d'argent : exige le PIN
|
||||
* equipier (email + PIN, RG-T13). L'annulation est tracee (audit_log) et re-credite
|
||||
* le stock si la commande etait payee. CSRF cache. Tout texte echappe (RG-T15).
|
||||
*
|
||||
* @var array<string, mixed> $order
|
||||
* @var string|null $error
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||
/** @var array<string, mixed> $o */
|
||||
$o = isset($order) && is_array($order) ? $order : [];
|
||||
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||
|
||||
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
|
||||
|
||||
$number = (string) ($o['order_number'] ?? '');
|
||||
$status = (string) ($o['status'] ?? '');
|
||||
|
||||
$statusLabel = static fn (string $s): string => match ($s) {
|
||||
'pending_payment' => 'En attente',
|
||||
'paid' => 'Payee',
|
||||
'delivered' => 'Livree',
|
||||
'cancelled' => 'Annulee',
|
||||
default => $s,
|
||||
};
|
||||
|
||||
// PRE-3 (7.1) : seuls pending_payment / paid peuvent transiter vers cancelled.
|
||||
$cancellable = in_array($status, ['pending_payment', 'paid'], true);
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Annuler une commande</h1>
|
||||
<p class="page-subtitle">Commande <?= $esc($number) ?> - <?= $esc($statusLabel($status)) ?> - <?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<?php if ($errorMessage !== null): ?>
|
||||
<p class="form-error" role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$cancellable): ?>
|
||||
<p role="alert">Cette commande est livree ou deja annulee : elle ne peut plus etre annulee.</p>
|
||||
<div class="form-actions">
|
||||
<a class="btn btn-secondary" href="/admin/orders">Retour</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" action="/admin/orders/<?= rawurlencode($number) ?>/cancel" class="form-card">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
|
||||
<p><small>L'annulation est tracee (audit) et re-credite le stock si la commande etait payee. Renseignez votre email et votre PIN.</small></p>
|
||||
|
||||
<fieldset class="form-group">
|
||||
<legend>Confirmation par PIN equipier</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin_email">Votre email</label>
|
||||
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin">Votre PIN</label>
|
||||
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Annuler la commande</button>
|
||||
<a class="btn btn-secondary" href="/admin/orders">Retour</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
|
@ -4,12 +4,17 @@ declare(strict_types=1);
|
|||
|
||||
/**
|
||||
* Liste des commandes (order.read), injectee dans admin/layout.php. Lecture seule :
|
||||
* numero, mode, chevalet, statut, total ttc, date. Tri du plus recent au plus ancien
|
||||
* (cf. OrderQueryRepository::recent). Toute valeur est echappee (RG-T15).
|
||||
* numero, mode, chevalet, statut, total ttc, date. Une colonne d'actions affiche le
|
||||
* lien Annuler (CANCEL_ORDER 7.1) pour les commandes pending_payment/paid quand le
|
||||
* role detient order.cancel (manager ne l'a PAS, D5). Tri du plus recent au plus
|
||||
* ancien (cf. OrderQueryRepository::recent). Toute valeur est echappee (RG-T15).
|
||||
*
|
||||
* @var list<array<string, mixed>> $orders
|
||||
* @var bool $canCancel
|
||||
*/
|
||||
|
||||
$canCancelOrder = isset($canCancel) && $canCancel === true;
|
||||
|
||||
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
|
||||
|
||||
|
|
@ -54,17 +59,31 @@ $rows = isset($orders) && is_array($orders) ? $orders : [];
|
|||
<th>Statut</th>
|
||||
<th>Total</th>
|
||||
<th>Date</th>
|
||||
<?php if ($canCancelOrder): ?><th></th><?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $o): ?>
|
||||
<?php
|
||||
$number = (string) ($o['order_number'] ?? '');
|
||||
$status = (string) ($o['status'] ?? '');
|
||||
// PRE-3 (7.1) : seules les commandes pending_payment/paid sont annulables.
|
||||
$rowCancellable = in_array($status, ['pending_payment', 'paid'], true);
|
||||
?>
|
||||
<tr>
|
||||
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
|
||||
<td><strong><?= $esc($number) ?></strong></td>
|
||||
<td><?= $esc($modeLabel((string) ($o['service_mode'] ?? ''))) ?></td>
|
||||
<td><?= ($o['service_tag'] ?? '') !== '' ? $esc($o['service_tag']) : '—' ?></td>
|
||||
<td><span class="pill <?= $esc($statusPill((string) ($o['status'] ?? ''))) ?>"><?= $esc($statusLabel((string) ($o['status'] ?? ''))) ?></span></td>
|
||||
<td><span class="pill <?= $esc($statusPill($status)) ?>"><?= $esc($statusLabel($status)) ?></span></td>
|
||||
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
|
||||
<td><?= $esc($o['created_at'] ?? '') ?></td>
|
||||
<?php if ($canCancelOrder): ?>
|
||||
<td>
|
||||
<?php if ($rowCancellable): ?>
|
||||
<a class="btn btn-secondary" href="/admin/orders/<?= rawurlencode($number) ?>/cancel">Annuler</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ try {
|
|||
$router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']);
|
||||
// Remise au client : paid -> delivered (order.deliver, geste unique, POST + CSRF).
|
||||
$router->add('POST', '/admin/orders/{number}/deliver', [OrderAdminController::class, 'deliver']);
|
||||
// Annulation : pending_payment|paid -> cancelled (CANCEL_ORDER mlt 7.1, order.cancel).
|
||||
// PIN equipier + audit + restock conditionnel (RG-T13/T14). {number} = un seul
|
||||
// segment (numero K/C/D + id) ; /cancel ne chevauche ni /deliver ni la liste.
|
||||
$router->add('GET', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'confirmCancel']);
|
||||
$router->add('POST', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'cancel']);
|
||||
// Affichage cuisine (KDS) : file des commandes payees (order.read). Landing du role
|
||||
// kitchen (seed role.default_route = /kitchen/display) ; corrige le 404 d'apres-login.
|
||||
$router->add('GET', '/kitchen/display', [KitchenController::class, 'display']);
|
||||
|
|
|
|||
|
|
@ -141,6 +141,14 @@ final class FakeDatabase implements DatabaseInterface
|
|||
*/
|
||||
public ?array $menuRow = null;
|
||||
|
||||
/**
|
||||
* Ligne renvoyee par OrderRepository::findByNumber() / cancel() (lecture par
|
||||
* order_number) ; null = numero inconnu.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $orderByNumberRow = null;
|
||||
|
||||
/**
|
||||
* Lignes renvoyees par MenuRepository::all().
|
||||
*
|
||||
|
|
@ -428,6 +436,10 @@ final class FakeDatabase implements DatabaseInterface
|
|||
return $this->menuRow;
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM customer_order WHERE order_number')) {
|
||||
return $this->orderByNumberRow;
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM order_item WHERE menu_id')) {
|
||||
return $this->menuReferenced ? ['menu_id' => 1] : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ final class FakeOrderDatabase implements DatabaseInterface
|
|||
/** Statut relu apres une transition gardee a 0 ligne (course concurrente). */
|
||||
public string $recheckStatus = 'paid';
|
||||
|
||||
/** La commande porte-t-elle des mouvements 'sale' (= deja encaissee/decrementee) ?
|
||||
* Pilote le re-credit a l'annulation (OrderRepository::hasSaleMovements). */
|
||||
public bool $saleMovementsExist = false;
|
||||
|
||||
/** Lignes order_item renvoyees pour la commande encaissee. */
|
||||
/** @var list<array<string,mixed>> */
|
||||
public array $orderItems = [];
|
||||
|
|
@ -77,6 +81,9 @@ final class FakeOrderDatabase implements DatabaseInterface
|
|||
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
|
||||
return $this->menus[(int) $params['id']] ?? null;
|
||||
}
|
||||
if (str_contains($sql, 'FROM stock_movement WHERE order_id')) {
|
||||
return $this->saleMovementsExist ? ['x' => 1] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -133,6 +140,18 @@ final class FakeOrderDatabase implements DatabaseInterface
|
|||
return [];
|
||||
}
|
||||
|
||||
/** SQL de la premiere ecriture dont le texte contient $needle (chaine vide sinon). */
|
||||
public function firstWriteSql(string $needle): string
|
||||
{
|
||||
foreach ($this->writes as $write) {
|
||||
if (str_contains($write['sql'], $needle)) {
|
||||
return $write['sql'];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function countWrites(string $needle): int
|
||||
{
|
||||
return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle)));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||
namespace App\Tests\Unit\Admin;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Controllers\OrderAdminController;
|
||||
use App\Core\Config;
|
||||
|
|
@ -62,17 +64,24 @@ final class OrderAdminControllerTest extends TestCase
|
|||
/** @var list<string> */
|
||||
private array $touchedKeys = [];
|
||||
private SessionManager $session;
|
||||
private string $csrf = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
|
||||
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
|
||||
$this->setEnv('STAFF_PIN_MIN_LENGTH', '4');
|
||||
$this->setEnv('STAFF_PIN_MAX_LENGTH', '12');
|
||||
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||
$this->setEnv('ARGON2_THREADS', '1');
|
||||
$this->session = new SessionManager(new Config(), true);
|
||||
$now = time();
|
||||
$this->session->set('user_id', 1);
|
||||
$this->session->set('role_id', 2);
|
||||
$this->session->set('logged_in_at', $now - 100);
|
||||
$this->session->set('last_activity', $now - 50);
|
||||
$this->csrf = Csrf::token($this->session);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
|
|
@ -107,6 +116,33 @@ final class OrderAdminControllerTest extends TestCase
|
|||
return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db);
|
||||
}
|
||||
|
||||
private function controllerWith(Request $request, FakeDatabase $db): TestOrderAdminController
|
||||
{
|
||||
return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $form
|
||||
*/
|
||||
private function post(array $form, string $path): Request
|
||||
{
|
||||
return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5');
|
||||
}
|
||||
|
||||
private function cancelDb(): FakeDatabase
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$db->permissionCodes = ['order.read', 'order.cancel'];
|
||||
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'K42', 'total_ttc_cents' => 1990, 'status' => 'paid'];
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
private function actingPin(FakeDatabase $db): void
|
||||
{
|
||||
$db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')];
|
||||
}
|
||||
|
||||
public function testRequiresOrderRead(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
|
|
@ -146,4 +182,86 @@ final class OrderAdminControllerTest extends TestCase
|
|||
|
||||
self::assertSame(403, $response->status());
|
||||
}
|
||||
|
||||
// --- CANCEL_ORDER (7.1, order.cancel + PIN equipier RG-T13) ---
|
||||
|
||||
public function testCancelRequiresOrderCancelPermission(): void
|
||||
{
|
||||
$db = $this->cancelDb();
|
||||
$db->canResult = false; // pas de order.cancel -> 403 avant toute action
|
||||
|
||||
$request = $this->post([
|
||||
'_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
|
||||
], '/admin/orders/K42/cancel');
|
||||
|
||||
self::assertSame(403, $this->controllerWith($request, $db)->cancel(['number' => 'K42'])->status());
|
||||
}
|
||||
|
||||
public function testCancelRejectsInvalidCsrf(): void
|
||||
{
|
||||
// order.cancel accorde mais jeton CSRF absent -> 403 avant toute transition.
|
||||
$db = $this->cancelDb();
|
||||
$request = $this->post(['pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/orders/K42/cancel');
|
||||
|
||||
self::assertSame(403, $this->controllerWith($request, $db)->cancel(['number' => 'K42'])->status());
|
||||
}
|
||||
|
||||
public function testCancelWithBadPinLogsFailedAndDoesNotCancel(): void
|
||||
{
|
||||
$db = $this->cancelDb();
|
||||
$db->actingUserRow = null; // email/PIN non resolu
|
||||
|
||||
$request = $this->post([
|
||||
'_csrf' => $this->csrf, 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000',
|
||||
], '/admin/orders/K42/cancel');
|
||||
|
||||
$response = $this->controllerWith($request, $db)->cancel(['number' => 'K42']);
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertSame(['pin.failed'], $db->auditActions()); // trace detective (RG-T22)
|
||||
self::assertFalse($db->wrote('UPDATE customer_order SET status')); // aucune transition
|
||||
}
|
||||
|
||||
public function testCancelWithValidPinTransitionsToCancelled(): void
|
||||
{
|
||||
$db = $this->cancelDb();
|
||||
$this->actingPin($db); // equipier id 9, PIN 4729
|
||||
|
||||
$request = $this->post([
|
||||
'_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
|
||||
], '/admin/orders/K42/cancel');
|
||||
|
||||
$response = $this->controllerWith($request, $db)->cancel(['number' => 'K42']);
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
self::assertSame('/admin/orders', $response->header('Location'));
|
||||
self::assertTrue($db->wrote('UPDATE customer_order SET status'));
|
||||
// L'annulation est tracee avec l'acteur resolu par PIN (RG-T14).
|
||||
self::assertSame(['order.cancel'], $db->auditActions());
|
||||
}
|
||||
|
||||
public function testCancelUnknownOrderReturns404(): void
|
||||
{
|
||||
$db = $this->cancelDb();
|
||||
$db->orderByNumberRow = null; // numero inconnu
|
||||
|
||||
$request = $this->post([
|
||||
'_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
|
||||
], '/admin/orders/K99/cancel');
|
||||
|
||||
self::assertSame(404, $this->controllerWith($request, $db)->cancel(['number' => 'K99'])->status());
|
||||
}
|
||||
|
||||
public function testConfirmCancelRendersPinForm(): void
|
||||
{
|
||||
$db = $this->cancelDb();
|
||||
$request = new Request('GET', '/admin/orders/K42/cancel', [], [], '', '203.0.113.5');
|
||||
|
||||
$response = $this->controllerWith($request, $db)->confirmCancel(['number' => 'K42']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
$body = $response->body();
|
||||
self::assertStringContainsString('K42', $body);
|
||||
self::assertStringContainsString('PIN', $body);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -357,4 +403,133 @@ final class OrderRepositoryTest extends TestCase
|
|||
$this->expectExceptionMessage('INVALID_TRANSITION');
|
||||
$this->repo($db)->deliver('K100');
|
||||
}
|
||||
|
||||
// --- cancel() : transition gardee + re-credit conditionnel + audit (RG-T07/T11/T14) ---
|
||||
|
||||
public function testCancelPendingTransitionsWithoutRecredit(): void
|
||||
{
|
||||
// pending_payment n'avait jamais decremente le stock (le decrement est pose a
|
||||
// `paid`) : annulation = transition + audit, AUCUN re-credit (RG-3).
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->cancel('K100', 9, 4);
|
||||
|
||||
self::assertSame('cancelled', $res['status']);
|
||||
self::assertSame('K100', $res['order_number']);
|
||||
self::assertSame(1, $db->countWrites('UPDATE customer_order SET status'));
|
||||
// Aucun mouvement de stock (re-credit) : la commande n'etait pas payee.
|
||||
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
|
||||
// Trace d'audit ecrite avec l'acteur resolu par PIN.
|
||||
self::assertSame(1, $db->countWrites('INSERT INTO audit_log'));
|
||||
$audit = $db->firstWrite('INSERT INTO audit_log');
|
||||
self::assertSame('order.cancel', $audit['code']);
|
||||
self::assertSame('customer_order', $audit['etype']);
|
||||
self::assertSame(100, $audit['eid']);
|
||||
self::assertSame(9, $audit['uid']);
|
||||
self::assertSame(4, $audit['rid']);
|
||||
}
|
||||
|
||||
public function testCancelPaidRecreditsStockAndWritesMovementAndAudit(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid'];
|
||||
$db->saleMovementsExist = true; // payee -> mouvements 'sale' poses -> re-credit attendu
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->cancel('K100', 9, 4);
|
||||
|
||||
self::assertSame('cancelled', $res['status']);
|
||||
self::assertSame(1, $db->countWrites('UPDATE customer_order SET status'));
|
||||
// 2 unites consommees (qn 1 * quantite 2) -> re-credit +2 sur l'ingredient 5.
|
||||
$inc = $db->firstWrite('UPDATE ingredient SET stock_quantity');
|
||||
self::assertSame(2, $inc['u']);
|
||||
self::assertSame(5, $inc['id']);
|
||||
// Type 'cancellation' code en dur dans le SQL (cf. pay() qui code 'sale').
|
||||
self::assertStringContainsString("'cancellation'", $db->firstWriteSql('INSERT INTO stock_movement'));
|
||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||
self::assertSame(2, $move['delta']); // delta POSITIF (re-credit)
|
||||
self::assertSame(100, $move['oid']);
|
||||
self::assertSame(9, $move['uid']); // acteur resolu par PIN
|
||||
// Audit ecrit avec le montant re-credite (pre-status paid).
|
||||
self::assertSame(1, $db->countWrites('INSERT INTO audit_log'));
|
||||
$audit = $db->firstWrite('INSERT INTO audit_log');
|
||||
self::assertSame('order.cancel', $audit['code']);
|
||||
self::assertStringContainsString('paid', (string) $audit['summary']);
|
||||
self::assertStringContainsString('890c', (string) $audit['summary']);
|
||||
}
|
||||
|
||||
public function testCancelRecreditsWhenSaleMovementsExistEvenIfPreStatusPending(): void
|
||||
{
|
||||
// Anti-course pending_payment -> paid -> cancel : la commande lue en pending a
|
||||
// en realite ete payee (mouvements 'sale' poses par un pay() concurrent). Le
|
||||
// re-credit se decide sur l'existence des mouvements 'sale', pas sur le
|
||||
// pre-status -> il a bien lieu (pas de derive de stock silencieuse).
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->saleMovementsExist = true;
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$this->repo($db)->cancel('K100', 9, 4);
|
||||
|
||||
self::assertSame(2, $db->firstWrite('UPDATE ingredient SET stock_quantity')['u']);
|
||||
self::assertStringContainsString("'cancellation'", $db->firstWriteSql('INSERT INTO stock_movement'));
|
||||
}
|
||||
|
||||
public function testCancelRejectsUnknownOrder(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = null;
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('ORDER_NOT_FOUND');
|
||||
$this->repo($db)->cancel('K404', 9, 4);
|
||||
}
|
||||
|
||||
public function testCancelRejectsTerminalStatus(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'delivered'];
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('CANNOT_CANCEL_IN_STATE');
|
||||
$this->repo($db)->cancel('K100', 9, 4);
|
||||
}
|
||||
|
||||
public function testCancelAlreadyCancelledRejected(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'cancelled'];
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('CANNOT_CANCEL_IN_STATE');
|
||||
$this->repo($db)->cancel('K100', 9, 4);
|
||||
}
|
||||
|
||||
public function testCancelConcurrentRaceThrowsInvalidTransition(): void
|
||||
{
|
||||
// La garde RG-T07 (status IN (...)) n'affecte 0 ligne : un autre appel a deja
|
||||
// transite vers un statut terminal -> INVALID_TRANSITION, aucun re-credit.
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid'];
|
||||
$db->payUpdateAffected = 0;
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
try {
|
||||
$this->repo($db)->cancel('K100', 9, 4);
|
||||
self::fail('expected OrderValidationException');
|
||||
} catch (OrderValidationException $exception) {
|
||||
self::assertSame('INVALID_TRANSITION', $exception->getMessage());
|
||||
}
|
||||
|
||||
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO audit_log'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue