From 29a191e50653342360ca6c5c2b99892fee8374c6 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 18 Jun 2026 14:09:35 +0200 Subject: [PATCH] feat(api): P4 chunk 1a - creation de commande + chevalet (#55) --- db/migrations/0003_order_service_tag.sql | 17 ++ src/app/Order/OrderRepository.php | 321 +++++++++++++++++++++ src/app/Order/OrderValidationException.php | 14 + tests/Unit/Order/OrderRepositoryTest.php | 212 ++++++++++++++ 4 files changed, 564 insertions(+) create mode 100644 db/migrations/0003_order_service_tag.sql create mode 100644 src/app/Order/OrderRepository.php create mode 100644 src/app/Order/OrderValidationException.php create mode 100644 tests/Unit/Order/OrderRepositoryTest.php diff --git a/db/migrations/0003_order_service_tag.sql b/db/migrations/0003_order_service_tag.sql new file mode 100644 index 0000000..13c5c98 --- /dev/null +++ b/db/migrations/0003_order_service_tag.sql @@ -0,0 +1,17 @@ +-- db/migrations/0003_order_service_tag.sql +-- ============================================================================= +-- Wakdo - Migration 0003 : service_tag (numero de chevalet) sur customer_order +-- ============================================================================= +-- Purpose : numero de chevalet pour le service EN SALLE (mode dine_in / sur place). +-- Saisi a la borne quand le client choisit "sur place" ; permet au +-- service d'apporter la commande a la bonne table (B4). NULL pour +-- takeaway / drive. Colonne additive nullable (aucune donnee existante +-- a retro-remplir). Le runner applique *.sql dans l'ordre lexicographique +-- via schema_migrations. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE customer_order + ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode; diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php new file mode 100644 index 0000000..6167b42 --- /dev/null +++ b/src/app/Order/OrderRepository.php @@ -0,0 +1,321 @@ +db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE idempotency_key = :k', + ['k' => $key], + ); + if ($row === null) { + return null; + } + + return [ + 'id' => (int) $row['id'], + 'order_number' => (string) $row['order_number'], + 'total_ttc_cents' => (int) $row['total_ttc_cents'], + 'status' => (string) $row['status'], + ]; + } + + /** + * 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 + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException si une reference est invalide / indisponible. + */ + public function createPending(array $req): array + { + $key = trim((string) ($req['idempotency_key'] ?? '')); + $existing = $this->findByIdempotencyKey($key); + if ($existing !== null) { + return $existing; + } + + $serviceMode = (string) ($req['service_mode'] ?? ''); + if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) { + throw new OrderValidationException('INVALID_SERVICE_MODE'); + } + $serviceTag = $serviceMode === 'dine_in' ? trim((string) ($req['service_tag'] ?? '')) : ''; + if ($serviceTag !== '' && mb_strlen($serviceTag) > 20) { + throw new OrderValidationException('INVALID_SERVICE_TAG'); + } + + $items = isset($req['items']) && is_array($req['items']) ? $req['items'] : []; + if ($items === []) { + throw new OrderValidationException('EMPTY_ORDER'); + } + + // Resolution + calcul (lecture seule) AVANT la transaction d'ecriture. + $lines = array_map(fn (array $item): array => $this->resolveLine($item), $items); + + $totalTtc = 0; + $totalHt = 0; + foreach ($lines as $l) { + $totalTtc += $l['unit_ttc'] * $l['quantity']; + $totalHt += $l['unit_ht'] * $l['quantity']; + } + $totalVat = $totalTtc - $totalHt; + if ($totalTtc <= 0) { + throw new OrderValidationException('EMPTY_ORDER'); + } + + $result = ['id' => 0, 'order_number' => '', 'total_ttc_cents' => $totalTtc, 'status' => 'pending_payment']; + + $this->db->transaction(function (DatabaseInterface $db) use ($key, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void { + $db->execute( + 'INSERT INTO customer_order ' + . '(order_number, idempotency_key, source, service_mode, service_tag, status, ' + . ' total_ht_cents, total_vat_cents, total_ttc_cents) ' + . "VALUES ('', :idem, 'kiosk', :mode, :tag, 'pending_payment', :ht, :vat, :ttc)", + [ + 'idem' => $key !== '' ? $key : null, + 'mode' => $serviceMode, + 'tag' => $serviceTag !== '' ? $serviceTag : null, + 'ht' => $totalHt, + 'vat' => $totalVat, + 'ttc' => $totalTtc, + ], + ); + $orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + $orderNumber = 'K' . $orderId; + $db->execute( + 'UPDATE customer_order SET order_number = :num WHERE id = :id', + ['num' => $orderNumber, 'id' => $orderId], + ); + + foreach ($lines as $l) { + $db->execute( + 'INSERT INTO order_item ' + . '(order_id, item_type, product_id, menu_id, format, label_snapshot, ' + . ' unit_price_cents_snapshot, vat_rate_snapshot, quantity) ' + . 'VALUES (:oid, :type, :pid, :mid, :fmt, :label, :price, :vat, :qty)', + [ + 'oid' => $orderId, + 'type' => $l['item_type'], + 'pid' => $l['product_id'], + 'mid' => $l['menu_id'], + 'fmt' => $l['format'], + 'label' => $l['label'], + 'price' => $l['unit_ttc'], + 'vat' => $l['vat_rate'], + 'qty' => $l['quantity'], + ], + ); + $itemId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + + foreach ($l['selections'] as $sel) { + $db->execute( + 'INSERT INTO order_item_selection (order_item_id, menu_slot_id, product_id, label_snapshot) ' + . 'VALUES (:oiid, :slot, :pid, :label)', + ['oiid' => $itemId, 'slot' => $sel['menu_slot_id'], 'pid' => $sel['product_id'], 'label' => $sel['label']], + ); + } + foreach ($l['modifiers'] as $mod) { + $db->execute( + 'INSERT INTO order_item_modifier (order_item_id, ingredient_id, action, extra_price_cents) ' + . 'VALUES (:oiid, :ing, :act, :extra)', + ['oiid' => $itemId, 'ing' => $mod['ingredient_id'], 'act' => $mod['action'], 'extra' => $mod['extra_price_cents']], + ); + } + } + + $result['id'] = $orderId; + $result['order_number'] = $orderNumber; + }); + + return $result; + } + + /** + * Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix. + * + * @param array $item + * @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list, modifiers:list} + */ + private function resolveLine(array $item): array + { + $type = (string) ($item['type'] ?? ''); + $quantity = max(1, (int) ($item['quantity'] ?? 1)); + $format = ($item['format'] ?? 'normal') === 'maxi' ? 'maxi' : 'normal'; + + if ($type === 'product') { + $product = $this->products->find((int) ($item['product_id'] ?? 0)); + if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) { + throw new OrderValidationException('PRODUCT_UNAVAILABLE'); + } + $unitBase = (int) $product['price_cents']; + $vat = (int) $product['vat_rate']; + $modifiers = $this->resolveModifiers($item, (int) $product['id']); + $unitTtc = $unitBase + $this->modifiersExtra($modifiers); + + return $this->line('product', (int) $product['id'], null, 'normal', (string) $product['name'], $unitTtc, $vat, $quantity, [], $modifiers); + } + + if ($type === 'menu') { + $menu = $this->menus->find((int) ($item['menu_id'] ?? 0)); + if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) { + throw new OrderValidationException('MENU_UNAVAILABLE'); + } + $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']); + $modifiers = $this->resolveModifiers($item, (int) $menu['burger_product_id']); + $unitTtc = $unitBase + $this->modifiersExtra($modifiers); + + return $this->line('menu', null, (int) $menu['id'], $format, (string) $menu['name'], $unitTtc, $vat, $quantity, $selections, $modifiers); + } + + throw new OrderValidationException('INVALID_ITEM_TYPE'); + } + + /** + * @param list $modifiers + */ + private function modifiersExtra(array $modifiers): int + { + $extra = 0; + foreach ($modifiers as $m) { + if ($m['action'] === 'add') { + $extra += $m['extra_price_cents']; + } + } + + return $extra; + } + + /** + * @param array $item + * @return list + */ + private function resolveSelections(array $item, int $menuId): array + { + $slots = $this->menus->slotsWithOptions($menuId); + /** @var array> $optionsBySlot */ + $optionsBySlot = []; + foreach ($slots as $s) { + $optionsBySlot[(int) $s['id']] = array_map('intval', $s['option_product_ids']); + } + + $out = []; + $raw = isset($item['selections']) && is_array($item['selections']) ? $item['selections'] : []; + foreach ($raw as $sel) { + $slotId = (int) ($sel['menu_slot_id'] ?? 0); + $pid = (int) ($sel['product_id'] ?? 0); + if (!isset($optionsBySlot[$slotId]) || !in_array($pid, $optionsBySlot[$slotId], true)) { + throw new OrderValidationException('INVALID_SELECTION'); + } + $product = $this->products->find($pid); + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $pid, 'label' => $product !== null ? (string) $product['name'] : '']; + } + + return $out; + } + + /** + * @param array $item + * @return list + */ + private function resolveModifiers(array $item, int $productId): array + { + $raw = isset($item['modifiers']) && is_array($item['modifiers']) ? $item['modifiers'] : []; + if ($raw === []) { + return []; + } + // Recette du produit support : valide l'ingredient + figes l'extra_price (add). + $recipe = []; + foreach ($this->products->composition($productId) as $ing) { + $recipe[(int) $ing['ingredient_id']] = $ing; + } + + $out = []; + foreach ($raw as $mod) { + $ingId = (int) ($mod['ingredient_id'] ?? 0); + $action = ($mod['action'] ?? '') === 'add' ? 'add' : 'remove'; + if (!isset($recipe[$ingId])) { + throw new OrderValidationException('INVALID_MODIFIER'); + } + $row = $recipe[$ingId]; + if ($action === 'remove' && (int) ($row['is_removable'] ?? 0) !== 1) { + throw new OrderValidationException('INGREDIENT_NOT_REMOVABLE'); + } + if ($action === 'add' && (int) ($row['is_addable'] ?? 0) !== 1) { + throw new OrderValidationException('INGREDIENT_NOT_ADDABLE'); + } + $out[] = [ + 'ingredient_id' => $ingId, + 'action' => $action, + 'extra_price_cents' => $action === 'add' ? (int) ($row['extra_price_cents'] ?? 0) : 0, + ]; + } + + return $out; + } + + /** + * @param list $selections + * @param list $modifiers + * @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list, modifiers:list} + */ + private function line(string $type, ?int $productId, ?int $menuId, string $format, string $label, int $unitTtc, int $vat, int $quantity, array $selections, array $modifiers): array + { + $unitHt = (int) round($unitTtc * 1000 / (1000 + $vat)); + + return [ + 'item_type' => $type, + 'product_id' => $productId, + 'menu_id' => $menuId, + 'format' => $format, + 'label' => $label, + 'unit_ttc' => $unitTtc, + 'unit_ht' => $unitHt, + 'vat_rate' => $vat, + 'quantity' => $quantity, + 'selections' => $selections, + 'modifiers' => $modifiers, + ]; + } +} diff --git a/src/app/Order/OrderValidationException.php b/src/app/Order/OrderValidationException.php new file mode 100644 index 0000000..e216893 --- /dev/null +++ b/src/app/Order/OrderValidationException.php @@ -0,0 +1,14 @@ +getMessage()`) sert de + * code d'erreur API ; le controleur le traduit en reponse HTTP 422. + */ +final class OrderValidationException extends \RuntimeException +{ +} diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php new file mode 100644 index 0000000..253f4ed --- /dev/null +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -0,0 +1,212 @@ +}> */ + 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 + { + return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db)); + } + + public function testProductOrderComputesLineVatAndKId(): void + { + $db = new OrderFakeDb(); + $db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1]; + + $res = $this->repo($db)->createPending([ + 'idempotency_key' => 'abc', + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]], + ]); + + // 890 TTC a 10% -> HT = round(890*1000/1100) = 809, TVA = 81. + $order = $db->firstWrite('INSERT INTO customer_order'); + self::assertSame(890, $order['ttc']); + self::assertSame(809, $order['ht']); + self::assertSame(81, $order['vat']); + self::assertSame('K100', $res['order_number']); + self::assertSame('pending_payment', $res['status']); + self::assertSame(890, $res['total_ttc_cents']); + + $item = $db->firstWrite('INSERT INTO order_item '); + self::assertSame('Cheeseburger', $item['label']); + self::assertSame(890, $item['price']); + self::assertSame(100, $item['vat']); + } + + public function testMenuMaxiUsesBurgerVatAndMaxiPrice(): void + { + $db = new OrderFakeDb(); + $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]; + $db->slotRows[5] = [['id' => 7, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'product_id' => 20]]; + + $res = $this->repo($db)->createPending([ + 'service_mode' => 'dine_in', + 'service_tag' => '42', + 'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi', + 'selections' => [['menu_slot_id' => 7, 'product_id' => 20]]]], + ]); + + // 1200 TTC a 10% -> HT = round(1200*1000/1100) = 1091, TVA = 109. + $order = $db->firstWrite('INSERT INTO customer_order'); + self::assertSame(1200, $order['ttc']); + self::assertSame(1091, $order['ht']); + self::assertSame('42', $order['tag']); + $item = $db->firstWrite('INSERT INTO order_item '); + self::assertSame('maxi', $item['fmt']); + self::assertSame(1200, $item['price']); + self::assertSame(1, $db->countWrites('INSERT INTO order_item_selection')); + } + + public function testAddModifierAddsExtraToLine(): void + { + $db = new OrderFakeDb(); + $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]]; + + $res = $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1, + 'modifiers' => [['ingredient_id' => 3, 'action' => 'add']]]], + ]); + + self::assertSame(940, $res['total_ttc_cents']); // 890 + 50 + self::assertSame(1, $db->countWrites('INSERT INTO order_item_modifier')); + } + + public function testIdempotentReturnsExistingWithoutInsert(): void + { + $db = new OrderFakeDb(); + $db->existingByKey = ['id' => 7, 'order_number' => 'K7', 'total_ttc_cents' => 500, 'status' => 'pending_payment']; + + $res = $this->repo($db)->createPending([ + 'idempotency_key' => 'dup', + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]], + ]); + + self::assertSame('K7', $res['order_number']); + self::assertSame(0, $db->countWrites('INSERT INTO customer_order')); + } + + public function testRejectsUnknownProduct(): void + { + $db = new OrderFakeDb(); + $this->expectException(OrderValidationException::class); + $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 999, 'quantity' => 1]], + ]); + } + + public function testRejectsSelectionOutsideSlotOptions(): void + { + $db = new OrderFakeDb(); + $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]]; + + $this->expectException(OrderValidationException::class); + $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', + 'selections' => [['menu_slot_id' => 7, 'product_id' => 999]]]], // 999 hors options + ]); + } +}