From f669c32c25617b76de1a14e260a9bf823fd97888 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Thu, 18 Jun 2026 12:20:20 +0000 Subject: [PATCH] feat(api): P4 chunk 1b - encaissement commande + decrement stock OrderController (POST /api/orders + /api/orders/{number}/pay) cable sur OrderRepository::pay : transition gardee pending_payment -> paid et decrement de stock atomique (RG-5 etapes 5-6, RG-T11/RG-T20) dans une transaction. Idempotent (deja paid -> renvoi sans re-decrement ; course concurrente perdue -> sortie idempotente) ; statut terminal -> 409. Decrement agrege par ingredient (un UPDATE auto-verrouillant + une ligne stock_movement(sale) par ingredient, ordre de verrou stable par id) ; modificateurs appliques (remove => 0, add => portion supplementaire) ; menu = recette du burger + recettes des selections, au format de la ligne. Inerte tant que les recettes (product_ingredient) ne sont pas seedees. Routes /api/orders anonymes (kiosk, pas de session). Double de test dedie extrait en tests/Support/FakeOrderDatabase. PHPUnit 290 + PHPStan L6 verts. --- src/app/Controllers/OrderController.php | 130 ++++++++++++ src/app/Order/OrderRepository.php | 163 ++++++++++++++- src/public/admin/index.php | 8 + tests/Support/FakeOrderDatabase.php | 157 ++++++++++++++ tests/Unit/Order/OrderControllerTest.php | 135 ++++++++++++ tests/Unit/Order/OrderRepositoryTest.php | 256 +++++++++++++++-------- 6 files changed, 758 insertions(+), 91 deletions(-) create mode 100644 src/app/Controllers/OrderController.php create mode 100644 tests/Support/FakeOrderDatabase.php create mode 100644 tests/Unit/Order/OrderControllerTest.php diff --git a/src/app/Controllers/OrderController.php b/src/app/Controllers/OrderController.php new file mode 100644 index 0000000..8308338 --- /dev/null +++ b/src/app/Controllers/OrderController.php @@ -0,0 +1,130 @@ + 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 $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 $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.', + }; + } +} diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php index 6167b42..a556b11 100644 --- a/src/app/Order/OrderRepository.php +++ b/src/app/Order/OrderRepository.php @@ -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>} $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 $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 + */ + 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 $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. * diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 796dc22..9b0ba13 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -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 diff --git a/tests/Support/FakeOrderDatabase.php b/tests/Support/FakeOrderDatabase.php new file mode 100644 index 0000000..34d4af7 --- /dev/null +++ b/tests/Support/FakeOrderDatabase.php @@ -0,0 +1,157 @@ + 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}> */ + public array $writes = []; + + /** @var array> produits indexes par id (find). */ + public array $products = []; + /** @var array> menus indexes par id (find). */ + public array $menus = []; + /** @var array>> slots (slotsWithOptions) par menu id. */ + public array $slotRows = []; + /** @var array>> recettes (composition) par produit id. */ + public array $compositions = []; + + /** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */ + /** @var array|null */ + public ?array $existingByKey = null; + + /** Commande renvoyee par la recherche order_number (pay) ; null = introuvable. */ + /** @var array|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> */ + public array $orderItems = []; + + /** Selections (product_id) par order_item id. */ + /** @var array>> */ + public array $selectionsByItem = []; + + /** Modificateurs (ingredient_id, action) par order_item id. */ + /** @var array>> */ + 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 */ + 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> + */ + public function allWrites(string $needle): array + { + $out = []; + foreach ($this->writes as $write) { + if (str_contains($write['sql'], $needle)) { + $out[] = $write['params']; + } + } + + return $out; + } +} diff --git a/tests/Unit/Order/OrderControllerTest.php b/tests/Unit/Order/OrderControllerTest.php new file mode 100644 index 0000000..e1a9a9d --- /dev/null +++ b/tests/Unit/Order/OrderControllerTest.php @@ -0,0 +1,135 @@ + 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 $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); + } +} diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php index 253f4ed..f917ec9 100644 --- a/tests/Unit/Order/OrderRepositoryTest.php +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -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}> */ - public array $writes = []; - /** @var array> */ - public array $products = []; - /** @var array> */ - public array $menus = []; - /** @var array>> */ - public array $slotRows = []; - /** @var array>> */ - public array $compositions = []; - /** @var array|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 */ - 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']); + } }