Compare commits

...

3 commits

Author SHA1 Message Date
Imugiii
1c134f5b86 Merge remote-tracking branch 'forgejo/dev' into feat/r2-menu-maxi-variant
All checks were successful
CI / static-tests (pull_request) Successful in 59s
CI / js-tests (pull_request) Successful in 30s
CI / php-lint (push) Successful in 25s
CI / js-tests (push) Successful in 30s
CI / secret-scan (pull_request) Successful in 10s
CI / php-lint (pull_request) Successful in 30s
CI / secret-scan (push) Successful in 13s
CI / static-tests (push) Successful in 55s
2026-06-22 09:48:47 +00:00
Imugiii
cf182d6ac0 feat(menu): accompagnement Maxi en variante Grande automatique (variante en base) 2026-06-22 09:48:47 +00:00
741cfdb02b feat(orders): annulation de commande (CANCEL_ORDER) - PIN + audit + restock (mlt 7.1) (#83)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 23s
CI / static-tests (push) Successful in 47s
CI / js-tests (push) Successful in 25s
2026-06-22 11:35:55 +02:00
12 changed files with 839 additions and 12 deletions

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

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

View file

@ -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'] : ''];
}

View 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>

View file

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

View file

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

View file

@ -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;
}

View file

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

View file

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

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