feat(api): P4 chunk 1b - encaissement + decrement stock (#57)
All checks were successful
CI / secret-scan (push) Successful in 18s
CI / php-lint (push) Successful in 27s
CI / static-tests (push) Successful in 55s
CI / js-tests (push) Successful in 31s
CI / auto-merge (push) Has been skipped

This commit is contained in:
Corentin JOGUET 2026-06-18 14:29:22 +02:00
parent a6ac3d6421
commit 60ce3460a5
6 changed files with 758 additions and 91 deletions

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\Controller;
use App\Core\DatabaseInterface;
use App\Core\Response;
use App\Order\OrderRepository;
use App\Order\OrderValidationException;
/**
* API publique de commande borne (P4, domaine 7). Anonyme : la borne kiosk poste
* sans session ; l'idempotence (RG-T19, idempotency_key) tient lieu de garde-fou
* anti double-clic / retry reseau. Deux operations :
* - POST /api/orders : creation en pending_payment (RG-5 etapes 1-4) ;
* - POST /api/orders/{number}/pay : encaissement -> paid + decrement stock (RG-T20).
*
* Les erreurs metier (OrderValidationException) sont mappees par code :
* ORDER_NOT_FOUND -> 404, INVALID_TRANSITION -> 409, le reste (reference /
* disponibilite / selection / modificateur) -> 422. Enveloppe standard
* {data} / {data:null, error:{code, message}}.
*
* Non `final` a dessein : les tests sous-classent pour injecter un acces BDD double
* (FakeOrderDatabase) via le hook protege db().
*/
class OrderController extends Controller
{
/**
* @param array<string, string> $params
*/
public function create(array $params = []): Response
{
try {
$order = $this->orders()->createPending($this->request->json());
} catch (OrderValidationException $exception) {
return $this->orderError($exception);
}
return $this->json(['data' => $this->present($order)], 201);
}
/**
* @param array<string, string> $params
*/
public function pay(array $params = []): Response
{
try {
$order = $this->orders()->pay((string) ($params['number'] ?? ''));
} catch (OrderValidationException $exception) {
return $this->orderError($exception);
}
return $this->json(['data' => $this->present($order)]);
}
/**
* Fabrique le repository de commande sur l'acces BDD courant. Hook de test
* (sous-classe -> double) : redefinir db() suffit a injecter une base factice.
*/
protected function orders(): OrderRepository
{
$db = $this->db();
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
}
/**
* Acces BDD comme DatabaseInterface (seam de test). Database l'implemente.
*/
protected function db(): DatabaseInterface
{
return $this->database;
}
/**
* @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order
* @return array{id:int, order_number:string, status:string, total_ttc_cents:int}
*/
private function present(array $order): array
{
return [
'id' => $order['id'],
'order_number' => $order['order_number'],
'status' => $order['status'],
'total_ttc_cents' => $order['total_ttc_cents'],
];
}
private function orderError(OrderValidationException $exception): Response
{
$code = $exception->getMessage();
$status = match ($code) {
'ORDER_NOT_FOUND' => 404,
'INVALID_TRANSITION' => 409,
default => 422,
};
return $this->json(
['data' => null, 'error' => ['code' => $code, 'message' => $this->messageFor($code)]],
$status,
);
}
/**
* Message lisible par code metier. Reste cote serveur : la borne affiche un
* libelle generique, ce texte sert au diagnostic / aux logs.
*/
private function messageFor(string $code): string
{
return match ($code) {
'ORDER_NOT_FOUND' => 'Commande introuvable.',
'INVALID_TRANSITION' => 'Transition de statut invalide.',
'EMPTY_ORDER' => 'La commande est vide.',
'INVALID_SERVICE_MODE' => 'Mode de service invalide.',
'INVALID_SERVICE_TAG' => 'Numero de chevalet invalide.',
'INVALID_ITEM_TYPE' => 'Type d\'article invalide.',
'PRODUCT_UNAVAILABLE' => 'Produit indisponible.',
'MENU_UNAVAILABLE' => 'Menu indisponible.',
'INVALID_SELECTION' => 'Choix invalide pour ce menu.',
'INVALID_MODIFIER' => 'Modification d\'ingredient invalide.',
'INGREDIENT_NOT_REMOVABLE' => 'Cet ingredient ne peut pas etre retire.',
'INGREDIENT_NOT_ADDABLE' => 'Cet ingredient ne peut pas etre ajoute.',
default => 'Requete invalide.',
};
}
}

View file

@ -62,7 +62,10 @@ class OrderRepository
/**
* Cree une commande borne en pending_payment. Idempotent sur idempotency_key.
*
* @param array{idempotency_key?:string, service_mode:string, service_tag?:?string, items:list<array<string,mixed>>} $req
* Tolerant sur la forme d'entree (corps JSON decode tel quel) : chaque cle est
* relue defensivement et la validation metier leve OrderValidationException.
*
* @param array<string, mixed> $req
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
* @throws OrderValidationException si une reference est invalide / indisponible.
*/
@ -169,6 +172,164 @@ class OrderRepository
return $result;
}
/**
* Encaisse une commande pending_payment : transition -> paid ET decrement de
* stock atomique (RG-5 etapes 5-6, RG-T11 / RG-T20) dans UNE transaction.
*
* Idempotent : une commande deja `paid` est renvoyee telle quelle sans
* re-decrementer ; `delivered` / `cancelled` -> INVALID_TRANSITION ; numero
* inconnu -> ORDER_NOT_FOUND. La transition est gardee par `status =
* 'pending_payment'` dans l'UPDATE : sous une course concurrente, seul le
* premier appel decremente (l'autre voit 0 ligne affectee et sort idempotent).
*
* Decrement (RG-5 etape 5) : par ingredient consomme, units =
* (format maxi ? quantity_maxi : quantity_normal) * order_item.quantity, ajuste
* par les modificateurs de la ligne (remove => pas de decrement pour cet
* ingredient ; add => portion de base + supplement). Les unites sont AGREGEES
* par ingredient sur toute la commande : un seul UPDATE auto-verrouillant et une
* seule ligne stock_movement(sale) par ingredient affecte (POST-4). Les UPDATE
* sont ordonnes par ingredient_id (ordre de verrou stable -> pas de deadlock
* entre commandes concurrentes). stock_quantity est signe (survente possible,
* RG-T20) : le decrement ne se conditionne a aucun plancher.
*
* NB : inerte tant que les recettes (product_ingredient) ne sont pas seedees
* la transition `paid` s'applique, mais aucun mouvement de stock n'est produit
* faute de composition. La logique s'active des que les recettes existent.
*
* @param int|null $actingUserId acteur comptoir/drive (stock_movement.user_id +
* customer_order.acting_user_id) ; NULL pour le kiosk.
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
* @throws OrderValidationException
*/
public function pay(string $orderNumber, ?int $actingUserId = null): 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');
}
$result = [
'id' => (int) $order['id'],
'order_number' => (string) $order['order_number'],
'total_ttc_cents' => (int) $order['total_ttc_cents'],
'status' => 'paid',
];
$status = (string) $order['status'];
if ($status === 'paid') {
return $result; // idempotent : deja encaissee, pas de re-decrement.
}
if ($status !== 'pending_payment') {
throw new OrderValidationException('INVALID_TRANSITION'); // delivered / cancelled.
}
$orderId = (int) $order['id'];
$this->db->transaction(function (DatabaseInterface $db) use ($orderId, $actingUserId): void {
$affected = $db->execute(
'UPDATE customer_order SET status = \'paid\', paid_at = NOW(), '
. 'acting_user_id = COALESCE(:uid, acting_user_id), updated_at = NOW() '
. 'WHERE id = :id AND status = \'pending_payment\'',
['uid' => $actingUserId, 'id' => $orderId],
);
if ($affected === 0) {
// Course perdue : un autre appel a deja transite. S'il a abouti a
// `paid`, il a fait le decrement -> on sort idempotent ; sinon la
// transition est invalide (statut terminal).
$current = (string) ($db->fetch('SELECT status FROM customer_order WHERE id = :id', ['id' => $orderId])['status'] ?? '');
if ($current === 'paid') {
return;
}
throw new OrderValidationException('INVALID_TRANSITION');
}
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, \'sale\', :delta, :oid, :uid, NULL)',
['ing' => $ingredientId, 'delta' => -$units, 'oid' => $orderId, 'uid' => $actingUserId],
);
}
});
return $result;
}
/**
* Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la
* commande (lecture des lignes persistees + recettes des produits supports).
* Cle = ingredient_id, triee croissant (ordre de verrou stable). Un ingredient
* dont l'unite agregee retombe a 0 (entierement retire) n'est PAS retourne :
* aucun mouvement n'est alors produit. Voir pay() pour la regle de calcul.
*
* @return array<int, int>
*/
private function consumption(DatabaseInterface $db, int $orderId): array
{
$items = $db->fetchAll(
'SELECT id, item_type, product_id, menu_id, format, quantity FROM order_item WHERE order_id = :oid',
['oid' => $orderId],
);
/** @var array<int, int> $units */
$units = [];
foreach ($items as $item) {
$itemId = (int) $item['id'];
$quantity = max(1, (int) $item['quantity']);
$maxi = ((string) $item['format']) === 'maxi';
// Produit(s) dont la recette est consommee : le produit pour une ligne
// produit ; le burger + chaque selection pour une ligne menu.
$productIds = [];
if ((string) $item['item_type'] === 'product') {
$productIds[] = (int) $item['product_id'];
} else {
$menu = $this->menus->find((int) $item['menu_id']);
if ($menu !== null) {
$productIds[] = (int) $menu['burger_product_id'];
}
foreach ($db->fetchAll('SELECT product_id FROM order_item_selection WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $sel) {
$productIds[] = (int) $sel['product_id'];
}
}
// Modificateurs de la ligne (ingredient_id => action). Ils s'appliquent a
// toute recette de la ligne contenant l'ingredient ; en pratique ils
// ciblent le produit support (burger), dont les ingredients ne recoupent
// pas ceux des selections (boisson / accompagnement).
$actions = [];
foreach ($db->fetchAll('SELECT ingredient_id, action FROM order_item_modifier WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $mod) {
$actions[(int) $mod['ingredient_id']] = (string) $mod['action'];
}
foreach ($productIds as $productId) {
foreach ($this->products->composition($productId) as $row) {
$ingredientId = (int) $row['ingredient_id'];
$perUnit = $maxi ? (int) $row['quantity_maxi'] : (int) $row['quantity_normal'];
$base = $perUnit * $quantity;
$consumed = match ($actions[$ingredientId] ?? null) {
'remove' => 0,
'add' => $base * 2, // portion de base + supplement (RG-5).
default => $base,
};
if ($consumed > 0) {
$units[$ingredientId] = ($units[$ingredientId] ?? 0) + $consumed;
}
}
}
}
ksort($units);
return $units;
}
/**
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
*

View file

@ -19,6 +19,7 @@ use App\Controllers\HomeController;
use App\Controllers\IngredientController;
use App\Controllers\MeController;
use App\Controllers\MenuController;
use App\Controllers\OrderController;
use App\Controllers\PasswordResetController;
use App\Controllers\ProductController;
use App\Controllers\ProfileController;
@ -70,6 +71,13 @@ try {
// RBAC : identite + permissions de la session courante (gardee par SessionGuard).
$router->add('GET', '/api/me', [MeController::class, 'show']);
// Commandes borne (P4, domaine 7). API publique kiosk, ANONYME (pas de session) :
// creation en pending_payment puis encaissement (paid + decrement stock RG-T20).
// Idempotente sur idempotency_key (anti double-clic / retry reseau). {number} =
// un seul segment (numero K+id), pas de collision avec un sous-chemin.
$router->add('POST', '/api/orders', [OrderController::class, 'create']);
$router->add('POST', '/api/orders/{number}/pay', [OrderController::class, 'pay']);
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
// Tableau de bord statistiques (stats.read) : landing du role manager. KPIs

View file

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Core\DatabaseInterface;
/**
* Double DatabaseInterface dedie au domaine Commande (P4). Le double generique
* FakeDatabase repond par boutons fixes (un seul produit/menu) ; une commande
* mele plusieurs produits/menus distincts, d'ou ce double indexe par id.
*
* Couvre createPending (catalogue + idempotence) ET pay (lecture de la commande
* persistee + recettes -> decrement). Les ecritures sont tracees pour assertion ;
* payUpdateAffected simule l'issue de la transition gardee (0 = course perdue).
*/
final class FakeOrderDatabase implements DatabaseInterface
{
/** @var list<array{sql:string, params:array<string,mixed>}> */
public array $writes = [];
/** @var array<int, array<string,mixed>> produits indexes par id (find). */
public array $products = [];
/** @var array<int, array<string,mixed>> menus indexes par id (find). */
public array $menus = [];
/** @var array<int, list<array<string,mixed>>> slots (slotsWithOptions) par menu id. */
public array $slotRows = [];
/** @var array<int, list<array<string,mixed>>> recettes (composition) par produit id. */
public array $compositions = [];
/** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */
/** @var array<string,mixed>|null */
public ?array $existingByKey = null;
/** Commande renvoyee par la recherche order_number (pay) ; null = introuvable. */
/** @var array<string,mixed>|null */
public ?array $orderByNumber = null;
/** Statut relu apres une transition gardee a 0 ligne (course concurrente). */
public string $recheckStatus = 'paid';
/** Lignes order_item renvoyees pour la commande encaissee. */
/** @var list<array<string,mixed>> */
public array $orderItems = [];
/** Selections (product_id) par order_item id. */
/** @var array<int, list<array<string,mixed>>> */
public array $selectionsByItem = [];
/** Modificateurs (ingredient_id, action) par order_item id. */
/** @var array<int, list<array<string,mixed>>> */
public array $modifiersByItem = [];
/** Lignes affectees par l'UPDATE de transition pending_payment -> paid. */
public int $payUpdateAffected = 1;
private int $autoId = 99;
public function fetch(string $sql, array $params = []): ?array
{
if (str_contains($sql, 'LAST_INSERT_ID')) {
return ['id' => $this->autoId];
}
if (str_contains($sql, 'FROM customer_order WHERE idempotency_key')) {
return $this->existingByKey;
}
if (str_contains($sql, 'FROM customer_order WHERE order_number')) {
return $this->orderByNumber;
}
if (str_contains($sql, 'SELECT status FROM customer_order WHERE id')) {
return ['status' => $this->recheckStatus];
}
if (str_contains($sql, 'FROM product WHERE id = :id')) {
return $this->products[(int) $params['id']] ?? null;
}
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
return $this->menus[(int) $params['id']] ?? null;
}
return null;
}
public function fetchAll(string $sql, array $params = []): array
{
if (str_contains($sql, 'FROM menu_slot s')) {
return $this->slotRows[(int) $params['id']] ?? [];
}
if (str_contains($sql, 'FROM product_ingredient pi')) {
return $this->compositions[(int) $params['id']] ?? [];
}
if (str_contains($sql, 'FROM order_item WHERE order_id')) {
return $this->orderItems;
}
if (str_contains($sql, 'FROM order_item_selection WHERE order_item_id')) {
return $this->selectionsByItem[(int) $params['oiid']] ?? [];
}
if (str_contains($sql, 'FROM order_item_modifier WHERE order_item_id')) {
return $this->modifiersByItem[(int) $params['oiid']] ?? [];
}
return [];
}
public function execute(string $sql, array $params = []): int
{
$this->writes[] = ['sql' => $sql, 'params' => $params];
if (str_contains($sql, 'INSERT INTO customer_order') || str_contains($sql, 'INSERT INTO order_item ')) {
$this->autoId++;
}
if (str_contains($sql, 'UPDATE customer_order SET status')) {
return $this->payUpdateAffected;
}
return 1;
}
public function transaction(callable $fn): void
{
$fn($this);
}
/** @return array<string,mixed> */
public function firstWrite(string $needle): array
{
foreach ($this->writes as $write) {
if (str_contains($write['sql'], $needle)) {
return $write['params'];
}
}
return [];
}
public function countWrites(string $needle): int
{
return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle)));
}
/**
* Parametres de toutes les ecritures dont le SQL contient $needle (ordre d'insertion).
*
* @return list<array<string,mixed>>
*/
public function allWrites(string $needle): array
{
$out = [];
foreach ($this->writes as $write) {
if (str_contains($write['sql'], $needle)) {
$out[] = $write['params'];
}
}
return $out;
}
}

View file

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Order;
use PHPUnit\Framework\TestCase;
use App\Controllers\OrderController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeOrderDatabase;
/**
* Sous-classe de test : redefinit le hook db() pour injecter le double dedie, sans
* base reelle. orders() construit alors le vrai OrderRepository sur ce double, ce
* qui exerce le cablage complet controleur -> repository.
*/
final class TestOrderController extends OrderController
{
public function __construct(
Request $request,
Config $config,
Database $database,
private readonly FakeOrderDatabase $fakeDb,
) {
parent::__construct($request, $config, $database);
}
protected function db(): DatabaseInterface
{
return $this->fakeDb;
}
}
final class OrderControllerTest extends TestCase
{
private function controller(FakeOrderDatabase $db, string $body = '', string $path = '/api/orders'): TestOrderController
{
$request = new Request('POST', $path, [], ['content-type' => 'application/json'], $body, '203.0.113.5');
return new TestOrderController($request, new Config(), new Database(new Config()), $db);
}
/**
* @param array<string, mixed> $payload
*/
private function jsonBody(array $payload): string
{
return (string) json_encode($payload);
}
public function testCreateReturns201WithOrderNumber(): void
{
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$body = $this->jsonBody(['service_mode' => 'takeaway', 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]]]);
$response = $this->controller($db, $body)->create();
self::assertSame(201, $response->status());
$data = json_decode($response->body(), true);
self::assertIsArray($data);
self::assertSame('K100', $data['data']['order_number'] ?? null);
self::assertSame('pending_payment', $data['data']['status'] ?? null);
self::assertSame(890, $data['data']['total_ttc_cents'] ?? null);
}
public function testCreateUnknownProductReturns422(): void
{
$db = new FakeOrderDatabase();
$body = $this->jsonBody(['service_mode' => 'takeaway', 'items' => [['type' => 'product', 'product_id' => 999, 'quantity' => 1]]]);
$response = $this->controller($db, $body)->create();
self::assertSame(422, $response->status());
$data = json_decode($response->body(), true);
self::assertIsArray($data);
self::assertSame('PRODUCT_UNAVAILABLE', $data['error']['code'] ?? null);
}
public function testCreateInvalidServiceModeReturns422(): void
{
$db = new FakeOrderDatabase();
$body = $this->jsonBody(['service_mode' => 'bogus', 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]]]);
$response = $this->controller($db, $body)->create();
self::assertSame(422, $response->status());
$data = json_decode($response->body(), true);
self::assertIsArray($data);
self::assertSame('INVALID_SERVICE_MODE', $data['error']['code'] ?? null);
}
public function testPayReturns200Paid(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$response = $this->controller($db, '', '/api/orders/K100/pay')->pay(['number' => 'K100']);
self::assertSame(200, $response->status());
$data = json_decode($response->body(), true);
self::assertIsArray($data);
self::assertSame('paid', $data['data']['status'] ?? null);
self::assertSame('K100', $data['data']['order_number'] ?? null);
}
public function testPayUnknownReturns404(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = null;
$response = $this->controller($db, '', '/api/orders/K404/pay')->pay(['number' => 'K404']);
self::assertSame(404, $response->status());
$data = json_decode($response->body(), true);
self::assertIsArray($data);
self::assertSame('ORDER_NOT_FOUND', $data['error']['code'] ?? null);
}
public function testPayTerminalStatusReturns409(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'delivered'];
$response = $this->controller($db, '', '/api/orders/K100/pay')->pay(['number' => 'K100']);
self::assertSame(409, $response->status());
$data = json_decode($response->body(), true);
self::assertIsArray($data);
self::assertSame('INVALID_TRANSITION', $data['error']['code'] ?? null);
}
}

View file

@ -7,104 +7,25 @@ namespace App\Tests\Unit\Order;
use PHPUnit\Framework\TestCase;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\DatabaseInterface;
use App\Order\OrderRepository;
use App\Order\OrderValidationException;
use App\Tests\Support\FakeOrderDatabase;
/**
* Double DatabaseInterface dedie : catalogue canned + enregistrement des ecritures.
* Permet de tester le calcul de prix (RG-4), le numero K+id, l'idempotence et la
* validation de createPending sans base reelle.
* Couvre createPending (calcul RG-4, numero K+id, idempotence, validation) et pay
* (transition gardee -> paid, decrement de stock atomique RG-T20, idempotence)
* sur le double dedie FakeOrderDatabase, sans base reelle.
*/
final class OrderFakeDb implements DatabaseInterface
{
/** @var list<array{sql:string, params:array<string,mixed>}> */
public array $writes = [];
/** @var array<int, array<string,mixed>> */
public array $products = [];
/** @var array<int, array<string,mixed>> */
public array $menus = [];
/** @var array<int, list<array<string,mixed>>> */
public array $slotRows = [];
/** @var array<int, list<array<string,mixed>>> */
public array $compositions = [];
/** @var array<string,mixed>|null */
public ?array $existingByKey = null;
private int $autoId = 99;
public function fetch(string $sql, array $params = []): ?array
{
if (str_contains($sql, 'LAST_INSERT_ID')) {
return ['id' => $this->autoId];
}
if (str_contains($sql, 'FROM customer_order WHERE idempotency_key')) {
return $this->existingByKey;
}
if (str_contains($sql, 'FROM product WHERE id = :id')) {
return $this->products[(int) $params['id']] ?? null;
}
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
return $this->menus[(int) $params['id']] ?? null;
}
return null;
}
public function fetchAll(string $sql, array $params = []): array
{
if (str_contains($sql, 'FROM menu_slot s')) {
return $this->slotRows[(int) $params['id']] ?? [];
}
if (str_contains($sql, 'FROM product_ingredient pi')) {
return $this->compositions[(int) $params['id']] ?? [];
}
return [];
}
public function execute(string $sql, array $params = []): int
{
if (str_contains($sql, 'INSERT INTO customer_order') || str_contains($sql, 'INSERT INTO order_item ')) {
$this->autoId++;
}
$this->writes[] = ['sql' => $sql, 'params' => $params];
return 1;
}
public function transaction(callable $fn): void
{
$fn($this);
}
/** @return array<string,mixed> */
public function firstWrite(string $needle): array
{
foreach ($this->writes as $w) {
if (str_contains($w['sql'], $needle)) {
return $w['params'];
}
}
return [];
}
public function countWrites(string $needle): int
{
return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle)));
}
}
final class OrderRepositoryTest extends TestCase
{
private function repo(OrderFakeDb $db): OrderRepository
private function repo(FakeOrderDatabase $db): OrderRepository
{
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
}
public function testProductOrderComputesLineVatAndKId(): void
{
$db = new OrderFakeDb();
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$res = $this->repo($db)->createPending([
@ -130,7 +51,7 @@ final class OrderRepositoryTest extends TestCase
public function testMenuMaxiUsesBurgerVatAndMaxiPrice(): void
{
$db = new OrderFakeDb();
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu Best Of', '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[20] = ['id' => 20, 'name' => 'Coca', 'price_cents' => 250, 'vat_rate' => 100, 'is_available' => 1];
@ -156,7 +77,7 @@ final class OrderRepositoryTest extends TestCase
public function testAddModifierAddsExtraToLine(): void
{
$db = new OrderFakeDb();
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$db->compositions[12] = [['ingredient_id' => 3, 'is_removable' => 1, 'is_addable' => 1, 'extra_price_cents' => 50, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
@ -172,7 +93,7 @@ final class OrderRepositoryTest extends TestCase
public function testIdempotentReturnsExistingWithoutInsert(): void
{
$db = new OrderFakeDb();
$db = new FakeOrderDatabase();
$db->existingByKey = ['id' => 7, 'order_number' => 'K7', 'total_ttc_cents' => 500, 'status' => 'pending_payment'];
$res = $this->repo($db)->createPending([
@ -187,7 +108,7 @@ final class OrderRepositoryTest extends TestCase
public function testRejectsUnknownProduct(): void
{
$db = new OrderFakeDb();
$db = new FakeOrderDatabase();
$this->expectException(OrderValidationException::class);
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
@ -197,7 +118,7 @@ final class OrderRepositoryTest extends TestCase
public function testRejectsSelectionOutsideSlotOptions(): void
{
$db = new OrderFakeDb();
$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->slotRows[5] = [['id' => 7, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'product_id' => 20]];
@ -209,4 +130,159 @@ final class OrderRepositoryTest extends TestCase
'selections' => [['menu_slot_id' => 7, 'product_id' => 999]]]], // 999 hors options
]);
}
// --- pay() : transition + decrement de stock (RG-5 etapes 5-6, RG-T20) ---
public function testPayTransitionsToPaidAndDecrementsProductRecipe(): void
{
$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)->pay('K100');
self::assertSame('paid', $res['status']);
self::assertSame('K100', $res['order_number']);
self::assertSame(1, $db->countWrites('UPDATE customer_order SET status'));
// 2 unites consommees (qn 1 * quantite 2) -> stock -2 sur l'ingredient 5.
$dec = $db->firstWrite('UPDATE ingredient SET stock_quantity');
self::assertSame(2, $dec['u']);
self::assertSame(5, $dec['id']);
$move = $db->firstWrite('INSERT INTO stock_movement');
self::assertSame(-2, $move['delta']);
self::assertSame(100, $move['oid']);
self::assertNull($move['uid']); // kiosk : pas d'acteur.
}
public function testPayIsIdempotentWhenAlreadyPaid(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid'];
$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)->pay('K100');
self::assertSame('paid', $res['status']);
self::assertSame(0, $db->countWrites('UPDATE customer_order SET status'));
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
}
public function testPayRejectsUnknownOrder(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = null;
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('ORDER_NOT_FOUND');
$this->repo($db)->pay('K404');
}
public function testPayRejectsTerminalStatus(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'cancelled'];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('INVALID_TRANSITION');
$this->repo($db)->pay('K100');
}
public function testPayLosesConcurrentRaceReturnsPaidWithoutDecrement(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$db->payUpdateAffected = 0; // un autre process a deja transite...
$db->recheckStatus = 'paid'; // ...vers paid : on sort idempotent.
$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)->pay('K100');
self::assertSame('paid', $res['status']);
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
}
public function testPayMenuDecrementsBurgerAndSelectionRecipesAtMaxi(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 1200, 'status' => 'pending_payment'];
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->orderItems = [['id' => 1, 'item_type' => 'menu', 'product_id' => null, 'menu_id' => 5, 'format' => 'maxi', 'quantity' => 1]];
$db->selectionsByItem[1] = [['product_id' => 20]];
$db->compositions[12] = [['ingredient_id' => 3, 'quantity_normal' => 1, 'quantity_maxi' => 2]]; // burger : maxi -> 2
$db->compositions[20] = [['ingredient_id' => 7, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; // boisson : 1
$this->repo($db)->pay('K100');
$decs = $db->allWrites('UPDATE ingredient SET stock_quantity');
self::assertCount(2, $decs);
// Ordonne par ingredient_id (ordre de verrou stable) : 3 puis 7.
self::assertSame(3, $decs[0]['id']);
self::assertSame(2, $decs[0]['u']);
self::assertSame(7, $decs[1]['id']);
self::assertSame(1, $decs[1]['u']);
}
public function testPayAppliesRemoveAndAddModifiers(): void
{
$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' => 1]];
$db->compositions[12] = [
['ingredient_id' => 3, 'quantity_normal' => 1, 'quantity_maxi' => 1], // retire
['ingredient_id' => 9, 'quantity_normal' => 1, 'quantity_maxi' => 1], // ajoute
];
$db->modifiersByItem[1] = [
['ingredient_id' => 3, 'action' => 'remove'],
['ingredient_id' => 9, 'action' => 'add'],
];
$this->repo($db)->pay('K100');
// ingredient 3 retire -> aucun mouvement ; ingredient 9 ajoute -> base + supplement = 2.
$decs = $db->allWrites('UPDATE ingredient SET stock_quantity');
self::assertCount(1, $decs);
self::assertSame(9, $decs[0]['id']);
self::assertSame(2, $decs[0]['u']);
}
public function testPayAggregatesSharedIngredientIntoSingleMovement(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 1500, 'status' => 'pending_payment'];
$db->orderItems = [
['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1],
['id' => 2, 'item_type' => 'product', 'product_id' => 13, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1],
];
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
$db->compositions[13] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
$this->repo($db)->pay('K100');
// Meme ingredient sur deux lignes -> un seul mouvement, delta agrege -2.
self::assertSame(1, $db->countWrites('INSERT INTO stock_movement'));
self::assertSame(1, $db->countWrites('UPDATE ingredient SET stock_quantity'));
$move = $db->firstWrite('INSERT INTO stock_movement');
self::assertSame(-2, $move['delta']);
}
public function testPayAttributesActingUserWhenProvided(): void
{
$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' => 1]];
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
$this->repo($db)->pay('K100', 7);
$transition = $db->firstWrite('UPDATE customer_order SET status');
self::assertSame(7, $transition['uid']);
$move = $db->firstWrite('INSERT INTO stock_movement');
self::assertSame(7, $move['uid']);
}
}