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'], ]; } /** * Recherche une commande par son numero (prefixe canal K/C/D + id). Lecture * publique du statut cote borne (suivi apres encaissement). Renvoie null si le * numero est inconnu. Lecture seule : ne sert que des champs non sensibles * (la commande kiosk est anonyme, pas de PII). * * @return array{id:int, order_number:string, total_ttc_cents:int, status:string}|null */ public function findByNumber(string $number): ?array { if ($number === '') { return null; } $row = $this->db->fetch( 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n', ['n' => $number], ); 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. * * 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. */ 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; } /** * 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; } /** * Transition paid -> delivered (DELIVER_ORDER, geste unique de remise, mlt 6.1). * NON PIN-gated : operation routiniere, hors ensemble sensible RG-T13. Idempotente * (une commande deja delivered est renvoyee sans erreur). 404 si inconnue ; * INVALID_TRANSITION si la commande n'est pas au statut paid (pending / cancelled). * * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} * @throws OrderValidationException */ public function deliver(string $orderNumber): 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' => 'delivered', ]; $status = (string) $order['status']; if ($status === 'delivered') { return $result; // idempotent : remise deja actee. } if ($status !== 'paid') { throw new OrderValidationException('INVALID_TRANSITION'); // pending_payment / cancelled. } $affected = $this->db->execute( 'UPDATE customer_order SET status = \'delivered\', delivered_at = NOW(), ' . 'updated_at = NOW() WHERE id = :id AND status = \'paid\'', ['id' => (int) $order['id']], ); if ($affected === 0) { // Course perdue : un autre appel a deja transite. Idempotent si delivered. $current = (string) ($this->db->fetch('SELECT status FROM customer_order WHERE id = :id', ['id' => (int) $order['id']])['status'] ?? ''); if ($current === 'delivered') { return $result; } throw new OrderValidationException('INVALID_TRANSITION'); } 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. * * @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'], $format); $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; } /** * 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 $item * @return list */ private function resolveSelections(array $item, int $menuId, string $format): 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); // 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'] : '']; } 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, ]; } }