Compare commits

..

2 commits

Author SHA1 Message Date
Imugiii
67fe086581 feat(admin): ecran Roles humanise (francais, droits groupes, listes deroulantes)
Some checks failed
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 53s
CI / js-tests (push) Successful in 32s
CI / secret-scan (pull_request) Successful in 10s
CI / php-lint (pull_request) Successful in 24s
CI / static-tests (pull_request) Successful in 47s
CI / js-tests (pull_request) Successful in 27s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Failing after 5s
Le formulaire et la liste des roles etaient tres techniques (libelles anglais, codes
de permission bruts, enums kiosk/counter/drive, 'route par defaut (landing)'). Refonte
de presentation (option a, la base garde les codes) :
- Champs relabeles + aides : Nom du role, Code interne, Page d'accueil (liste deroulante
  de pages), Canal de commande (Borne/Comptoir/Drive), Canaux visibles.
- Droits d'acces : matrice regroupee par domaine (Produits/Menus/Stock/Commandes/Comptes/
  Roles & statistiques...), libelles francais (Voir/Creer/Modifier/Supprimer...), codes masques.
- Liste des roles : entetes + valeurs (page d'accueil, canal) en clair.
- admin.css : .perm-grid/.perm-group + reset fieldset/legend.
Noms de champs postes inchanges (contrat serveur intact). PHPUnit 301 + PHPStan L6 verts.
2026-06-18 11:32:50 +00:00
Imugiii
b6dfc2a56c feat(admin): modal de re-autorisation PIN au moment de l'action sensible
Some checks failed
CI / static-tests (push) Successful in 2m18s
CI / js-tests (push) Successful in 49s
CI / secret-scan (pull_request) Successful in 18s
CI / secret-scan (push) Successful in 37s
CI / php-lint (push) Successful in 52s
CI / php-lint (pull_request) Successful in 22s
CI / static-tests (pull_request) Successful in 1m30s
CI / js-tests (pull_request) Successful in 36s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Failing after 5s
Les formulaires d'action sensible (RG-T13) portaient un fieldset PIN inline noye en bas
de page (email equipier + PIN), peu clair pour un equipier non technique. pin-modal.js
(CSP-safe, auto-detecte les formulaires via #pin_email) masque ce fieldset et fait surgir
un modal au clic sur l'action, avec l'email de l'utilisateur connecte pre-rempli (expose
via UserDirectory + <body data-user-email>) et le PIN a saisir. Contrat serveur inchange
(il lit toujours pin_email + pin), aucune modif des 8 formulaires concernes.

Tests : 3 tests jsdom (masquage, ouverture, prefill, confirmation, refus si vide) ;
UserDirectoryTest mis a jour (email). PHPUnit 301 + PHPStan L6 verts.
2026-06-18 11:04:53 +00:00
19 changed files with 64 additions and 1256 deletions

View file

@ -1,8 +1,6 @@
name: CI
# CI Wakdo - Forgejo Actions (runner stark-wakdo, label `docker`).
# 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.
# Strategie solo dev : PR obligatoire + auto-merge sur CI verte (voir SECURITY.md).
#
# Etat des jobs selon la phase projet :
# - secret-scan : fonctionnel des maintenant (gitleaks scanne tout le depot)
@ -13,7 +11,8 @@ name: CI
on:
pull_request:
branches: [dev, main]
types: [opened, synchronize, reopened]
# `labeled` : permet au job auto-merge de s'evaluer quand on pose le label.
types: [opened, synchronize, reopened, labeled]
push:
# dev/main : porte de merge. feat|fix|ci|refactor : feedback avant la PR.
branches: [dev, main, 'feat/**', 'fix/**', 'ci/**', 'refactor/**']
@ -170,3 +169,36 @@ 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."

View file

@ -1,17 +0,0 @@
-- 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;

View file

@ -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 ; merge natif sur CI verte |
| CI/CD | Forgejo Actions | secret-scan, lint, tests, auto-merge |
| Versioning | Git + Forgejo (`git.acadenice.com`, miroir GitHub) | Conventional Commits |
Justifications (composer-less, from-scratch, etc.) : `docs/PROJECT_CONTEXT.md` section 6.
@ -243,8 +243,7 @@ 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). Fusion par auto-merge NATIF Forgejo (squash,
`merge_when_checks_succeed`) des que les checks requis sont verts — pas de job de merge.
seede), `js-tests` (Node 20), `auto-merge` (squash sur label + CI verte).
- **Branch protection** : `dev` et `main` proteges (PR requise, force-push bloque,
checks requis).

View file

@ -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** : 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`.
- **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`.
- **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.

View file

@ -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'] = 'Reference requise : minuscules, chiffres et tirets (60 max).';
$errors['slug'] = 'Slug requis : minuscules, chiffres et tirets (60 max).';
} elseif ($repo->slugExists($slug, $exceptId)) {
$errors['slug'] = 'Cette reference existe deja.';
$errors['slug'] = 'Ce slug 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 cette reference existe deja.'], 409);
return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 409);
}
throw $exception;

View file

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

View file

@ -1,482 +0,0 @@
<?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,
];
}
}

View file

@ -1,14 +0,0 @@
<?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
{
}

View file

@ -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">Reference</label>
<label class="form-label" for="slug">Slug</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>

View file

@ -32,7 +32,7 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
<thead>
<tr>
<th>Libelle</th>
<th>Reference</th>
<th>Slug</th>
<th>Ordre</th>
<th>Statut</th>
<th style="width:160px;"></th>

View file

@ -49,9 +49,9 @@ $colspan = $withActor ? 5 : 4;
<tr>
<th>Date</th>
<th>Type</th>
<th>Variation</th>
<th>Delta</th>
<th>Note</th>
<?php if ($withActor): ?><th>Auteur</th><?php endif; ?>
<?php if ($withActor): ?><th>Acteur</th><?php endif; ?>
</tr>
</thead>
<tbody>

View file

@ -84,7 +84,9 @@ $navClass = static function (string $code, string $current): string {
<nav class="sidebar">
<div class="sidebar-brand">
<img class="sidebar-brand-logo" src="/assets/images/logo.png" alt="Wakdo">
<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>
<span class="sidebar-brand-name">Wak<span>do</span></span>
</div>
<div class="sidebar-section">

View file

@ -294,9 +294,15 @@ button {
padding: 20px 10px 14px;
}
.sidebar-brand-logo {
height: 34px;
width: auto;
.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);
flex-shrink: 0;
}

View file

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

View file

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

View file

@ -202,7 +202,7 @@ final class CategoryControllerTest extends TestCase
self::assertSame(422, $response->status());
self::assertStringContainsString('Le libelle est requis', $response->body());
self::assertStringContainsString('Reference requise', $response->body());
self::assertStringContainsString('Slug requis', $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('Cette reference existe deja', $response->body());
self::assertStringContainsString('Ce slug existe deja', $response->body());
self::assertFalse($this->wroteContaining($db, 'INSERT INTO category'));
}

View file

@ -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('Auteur', $response->body());
self::assertStringContainsString('Acteur', $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('Auteur', $response->body()); // colonne masquee (RG-4)
self::assertStringNotContainsString('Acteur', $response->body()); // colonne masquee (RG-4)
}
}

View file

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

View file

@ -1,288 +0,0 @@
<?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']);
}
}