Compare commits
7 commits
67fe086581
...
2e1d2e3126
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1d2e3126 | ||
| 05eca6aea2 | |||
| 60ce3460a5 | |||
| a6ac3d6421 | |||
| 29a191e506 | |||
| 1697b94b62 | |||
| 60535bbe00 |
19 changed files with 1256 additions and 64 deletions
|
|
@ -1,6 +1,8 @@
|
|||
name: CI
|
||||
# CI Wakdo - Forgejo Actions (runner stark-wakdo, label `docker`).
|
||||
# Strategie solo dev : PR obligatoire + auto-merge sur CI verte (voir SECURITY.md).
|
||||
# Strategie solo dev : PR obligatoire ; l'auto-merge NATIF Forgejo
|
||||
# (merge_when_checks_succeed, programme a l'ouverture de la PR) fusionne en squash
|
||||
# des que les checks requis passent. Pas de job de merge dans le workflow.
|
||||
#
|
||||
# Etat des jobs selon la phase projet :
|
||||
# - secret-scan : fonctionnel des maintenant (gitleaks scanne tout le depot)
|
||||
|
|
@ -11,8 +13,7 @@ name: CI
|
|||
on:
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
# `labeled` : permet au job auto-merge de s'evaluer quand on pose le label.
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
# dev/main : porte de merge. feat|fix|ci|refactor : feedback avant la PR.
|
||||
branches: [dev, main, 'feat/**', 'fix/**', 'ci/**', 'refactor/**']
|
||||
|
|
@ -169,36 +170,3 @@ jobs:
|
|||
# qu'a tests/e2e via le conteneur officiel.)
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci
|
||||
npm run test:js
|
||||
|
||||
auto-merge:
|
||||
# Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR.
|
||||
# Ne s'execute que si tous les checks requis passent (needs).
|
||||
# IMPORTANT : le filtrage par label se fait DANS le step via l'API, pas dans
|
||||
# `if:` — l'expression contains(github.event.pull_request.labels.*.name, ...)
|
||||
# de Forgejo n'est pas fiable (elle s'evalue a vrai meme sans label, ce qui
|
||||
# fusionnait toute PR verte). La verification shell sur l'API est le vrai gate.
|
||||
needs: [secret-scan, php-lint, static-tests, js-tests]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Install curl
|
||||
run: apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null
|
||||
- name: Merge PR (squash) si label auto-merge present et CI verte
|
||||
run: |
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
TOKEN="${{ secrets.FORGEJO_TOKEN }}"
|
||||
labels=$(curl -s -H "Authorization: token $TOKEN" "$API/issues/$PR/labels")
|
||||
if ! printf '%s' "$labels" | grep -q '"name"[[:space:]]*:[[:space:]]*"auto-merge"'; then
|
||||
echo "Pas de label 'auto-merge' sur la PR #$PR -> relecture manuelle, pas de fusion auto."
|
||||
exit 0
|
||||
fi
|
||||
echo "Label 'auto-merge' present + CI verte -> fusion de la PR #$PR"
|
||||
code=$(curl -s -o /tmp/resp -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do":"squash","delete_branch_after_merge":true}' \
|
||||
"$API/pulls/$PR/merge")
|
||||
echo "merge HTTP $code"; cat /tmp/resp || true; echo
|
||||
[ "$code" = "200" ] || { echo "auto-merge failed (HTTP $code)"; exit 1; }
|
||||
echo "PR #$PR mergee."
|
||||
|
|
|
|||
17
db/migrations/0003_order_service_tag.sql
Normal file
17
db/migrations/0003_order_service_tag.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -41,7 +41,7 @@ commande = phase **P4**, schema en base mais workflow applicatif a venir).
|
|||
| Tests PHP | PHPUnit 11 (`.phar`, sans Composer) | unit + integration DB |
|
||||
| Tests front | node:test + jsdom | harnais kiosk (`tests/js/`) |
|
||||
| Analyse statique | PHPStan niveau 6 (`.phar`) | |
|
||||
| CI/CD | Forgejo Actions | secret-scan, lint, tests, auto-merge |
|
||||
| CI/CD | Forgejo Actions | secret-scan, lint, tests ; merge natif sur CI verte |
|
||||
| Versioning | Git + Forgejo (`git.acadenice.com`, miroir GitHub) | Conventional Commits |
|
||||
|
||||
Justifications (composer-less, from-scratch, etc.) : `docs/PROJECT_CONTEXT.md` section 6.
|
||||
|
|
@ -243,7 +243,8 @@ MCD / MLD / dictionnaire : `docs/merise/`.
|
|||
- **PHPStan niveau 6** (`.phar`).
|
||||
- **CI Forgejo Actions** (`.forgejo/workflows/ci.yml`) : `secret-scan` (gitleaks),
|
||||
`php-lint`, `static-tests` (PHPStan + PHPUnit avec service MariaDB ephemere migre +
|
||||
seede), `js-tests` (Node 20), `auto-merge` (squash sur label + CI verte).
|
||||
seede), `js-tests` (Node 20). Fusion par auto-merge NATIF Forgejo (squash,
|
||||
`merge_when_checks_succeed`) des que les checks requis sont verts — pas de job de merge.
|
||||
- **Branch protection** : `dev` et `main` proteges (PR requise, force-push bloque,
|
||||
checks requis).
|
||||
|
||||
|
|
|
|||
|
|
@ -120,9 +120,9 @@ Detail par entite : `docs/merise/` et `docs/domaines/` (a venir).
|
|||
- **Branches** depuis `dev` : `feat/*`, `fix/*`, `docs/*`, `chore/*`, `ci/*`, `db/*`,
|
||||
`refactor/*`, `test/*`. Merge vers `dev` par **PR squashee**. Periodiquement
|
||||
`dev -> main` avec tag semver.
|
||||
- **Auto-merge** : poser le label `auto-merge` sur la PR -> fusion automatique des que
|
||||
la CI Forgejo est verte (secret-scan, php-lint, static-tests, js-tests).
|
||||
Script : `scripts/forgejo-pr-automerge.sh`.
|
||||
- **Auto-merge** : l'ouverture de la PR programme la fusion squash automatique des que
|
||||
les checks requis passent (auto-merge NATIF Forgejo `merge_when_checks_succeed`, sans
|
||||
label ni job CI). Script : `scripts/forgejo-pr-automerge.sh`.
|
||||
- **Pas de trailer `Co-Authored-By`** : la transparence sur l'usage de l'IA vit dans le
|
||||
`README.md` et `docs/PROJECT_CONTEXT.md` section 17.
|
||||
|
||||
|
|
|
|||
|
|
@ -197,9 +197,9 @@ class CategoryController extends AdminController
|
|||
}
|
||||
|
||||
if ($slug === '' || mb_strlen($slug) > 60 || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) !== 1) {
|
||||
$errors['slug'] = 'Slug requis : minuscules, chiffres et tirets (60 max).';
|
||||
$errors['slug'] = 'Reference requise : minuscules, chiffres et tirets (60 max).';
|
||||
} elseif ($repo->slugExists($slug, $exceptId)) {
|
||||
$errors['slug'] = 'Ce slug existe deja.';
|
||||
$errors['slug'] = 'Cette reference existe deja.';
|
||||
}
|
||||
|
||||
if ($image !== '' && mb_strlen($image) > 255) {
|
||||
|
|
@ -258,7 +258,7 @@ class CategoryController extends AdminController
|
|||
// getCode() rend la chaine SQLSTATE pour une vraie PDOException ; le cast
|
||||
// couvre aussi un code entier (23000 = violation de contrainte d'integrite).
|
||||
if ((string) $exception->getCode() === '23000') {
|
||||
return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 409);
|
||||
return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou cette reference existe deja.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
|
|
|
|||
130
src/app/Controllers/OrderController.php
Normal file
130
src/app/Controllers/OrderController.php
Normal 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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
482
src/app/Order/OrderRepository.php
Normal file
482
src/app/Order/OrderRepository.php
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Order;
|
||||
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Creation de commande (P4, chunk 1). Persiste une commande en `pending_payment`
|
||||
* (RG-5 etapes 1-4 : customer_order + order_item + order_item_selection +
|
||||
* order_item_modifier) dans UNE transaction. Le decrement de stock (RG-T20) et la
|
||||
* transition `paid` sont une operation distincte (pay(), 2 etapes — decision projet).
|
||||
*
|
||||
* Prix recalcules SERVEUR depuis la base (jamais le client, RG-T16) ; snapshots
|
||||
* figes (RG-T05/RG-7). order_number = "K" + id (decision utilisateur, diverge du
|
||||
* K-AAAA-MM-JJ-NNN de la spec : plus simple, pas de compteur jour). Idempotence
|
||||
* via idempotency_key (anti double-clic / retry reseau borne anonyme).
|
||||
*
|
||||
* Regles de calcul DOCUMENTEES (a confirmer en revue ; non explicitees par la spec) :
|
||||
* - produit a l'unite : toujours format `normal`, prix = product.price_cents, TVA = product.vat_rate ;
|
||||
* - menu : prix = price_maxi_cents si format `maxi` sinon price_normal_cents, TVA = vat_rate du BURGER du menu ;
|
||||
* - modifier `add` : ajoute extra_price_cents (snapshot product_ingredient) au prix de la ligne, au taux TVA de la ligne ;
|
||||
* - TVA par ligne (RG-4) : unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat)), unit_vat = unit_ttc - unit_ht ;
|
||||
* totaux = somme(unit_ttc * qty) ; total_ht = somme(unit_ht * qty) ; total_vat = total_ttc - total_ht.
|
||||
*/
|
||||
class OrderRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DatabaseInterface $db,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly MenuRepository $menus,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}|null
|
||||
*/
|
||||
public function findByIdempotencyKey(string $key): ?array
|
||||
{
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
$row = $this->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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param array<string, mixed> $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<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
|
||||
*/
|
||||
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<array{ingredient_id:int,action:string,extra_price_cents:int}> $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<string, mixed> $item
|
||||
* @return list<array{menu_slot_id:int,product_id:int,label:string}>
|
||||
*/
|
||||
private function resolveSelections(array $item, int $menuId): array
|
||||
{
|
||||
$slots = $this->menus->slotsWithOptions($menuId);
|
||||
/** @var array<int, list<int>> $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<string, mixed> $item
|
||||
* @return list<array{ingredient_id:int,action:string,extra_price_cents:int}>
|
||||
*/
|
||||
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<array{menu_slot_id:int,product_id:int,label:string}> $selections
|
||||
* @param list<array{ingredient_id:int,action:string,extra_price_cents:int}> $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<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
14
src/app/Order/OrderValidationException.php
Normal file
14
src/app/Order/OrderValidationException.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Order;
|
||||
|
||||
/**
|
||||
* Erreur de validation d'une commande (reference invalide, indisponible, selection
|
||||
* hors slot, modifier interdit...). Le code machine (`$this->getMessage()`) sert de
|
||||
* code d'erreur API ; le controleur le traduit en reponse HTTP 422.
|
||||
*/
|
||||
final class OrderValidationException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ $err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k])
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="slug">Slug</label>
|
||||
<label class="form-label" for="slug">Reference</label>
|
||||
<input class="form-input" type="text" id="slug" name="slug" maxlength="60" value="<?= $val('slug') ?>" required>
|
||||
<?php if ($err('slug') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('slug'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Libelle</th>
|
||||
<th>Slug</th>
|
||||
<th>Reference</th>
|
||||
<th>Ordre</th>
|
||||
<th>Statut</th>
|
||||
<th style="width:160px;"></th>
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ $colspan = $withActor ? 5 : 4;
|
|||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Delta</th>
|
||||
<th>Variation</th>
|
||||
<th>Note</th>
|
||||
<?php if ($withActor): ?><th>Acteur</th><?php endif; ?>
|
||||
<?php if ($withActor): ?><th>Auteur</th><?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -84,9 +84,7 @@ $navClass = static function (string $code, string $current): string {
|
|||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<span class="sidebar-brand-mark" aria-hidden="true">
|
||||
<svg width="27" height="27" viewBox="0 0 28 28" fill="none"><path d="M3 5 L7.5 23 L11.5 11 L14 19 L16.5 11 L20.5 23 L25 5" stroke="#1A1A1A" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
<img class="sidebar-brand-logo" src="/assets/images/logo.png" alt="Wakdo">
|
||||
<span class="sidebar-brand-name">Wak<span>do</span></span>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
|
|
|
|||
|
|
@ -294,15 +294,9 @@ button {
|
|||
padding: 20px 10px 14px;
|
||||
}
|
||||
|
||||
.sidebar-brand-mark {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 13px;
|
||||
background: var(--color-yellow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(255, 199, 44, 0.4);
|
||||
.sidebar-brand-logo {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
157
tests/Support/FakeOrderDatabase.php
Normal file
157
tests/Support/FakeOrderDatabase.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@ final class CategoryControllerTest extends TestCase
|
|||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertStringContainsString('Le libelle est requis', $response->body());
|
||||
self::assertStringContainsString('Slug requis', $response->body());
|
||||
self::assertStringContainsString('Reference requise', $response->body());
|
||||
self::assertFalse($this->wroteContaining($db, 'INSERT INTO category'));
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ final class CategoryControllerTest extends TestCase
|
|||
$response = $this->controller($request, $db)->store();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertStringContainsString('Ce slug existe deja', $response->body());
|
||||
self::assertStringContainsString('Cette reference existe deja', $response->body());
|
||||
self::assertFalse($this->wroteContaining($db, 'INSERT INTO category'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ final class IngredientControllerTest extends TestCase
|
|||
$response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertStringContainsString('Acteur', $response->body());
|
||||
self::assertStringContainsString('Auteur', $response->body());
|
||||
self::assertStringContainsString('Sam K', $response->body()); // nom resolu
|
||||
}
|
||||
|
||||
|
|
@ -438,6 +438,6 @@ final class IngredientControllerTest extends TestCase
|
|||
$response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertStringNotContainsString('Acteur', $response->body()); // colonne masquee (RG-4)
|
||||
self::assertStringNotContainsString('Auteur', $response->body()); // colonne masquee (RG-4)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
tests/Unit/Order/OrderControllerTest.php
Normal file
135
tests/Unit/Order/OrderControllerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
288
tests/Unit/Order/OrderRepositoryTest.php
Normal file
288
tests/Unit/Order/OrderRepositoryTest.php
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Order;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Order\OrderRepository;
|
||||
use App\Order\OrderValidationException;
|
||||
use App\Tests\Support\FakeOrderDatabase;
|
||||
|
||||
/**
|
||||
* 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 OrderRepositoryTest extends TestCase
|
||||
{
|
||||
private function repo(FakeOrderDatabase $db): OrderRepository
|
||||
{
|
||||
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
|
||||
}
|
||||
|
||||
public function testProductOrderComputesLineVatAndKId(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$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 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];
|
||||
$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 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]];
|
||||
|
||||
$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 FakeOrderDatabase();
|
||||
$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 FakeOrderDatabase();
|
||||
$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 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]];
|
||||
|
||||
$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
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue