Compare commits

...

7 commits

Author SHA1 Message Date
Imugiii
2e1d2e3126 feat(admin): ecran Roles humanise (francais, droits groupes, listes deroulantes)
All checks were successful
CI / php-lint (push) Successful in 39s
CI / php-lint (pull_request) Successful in 28s
CI / secret-scan (push) Successful in 19s
CI / static-tests (push) Successful in 2m50s
CI / js-tests (push) Successful in 45s
CI / secret-scan (pull_request) Successful in 13s
CI / static-tests (pull_request) Successful in 1m20s
CI / js-tests (pull_request) Successful in 37s
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 12:49:32 +00:00
05eca6aea2 ci: retire le job auto-merge redondant (#58)
All checks were successful
CI / secret-scan (push) Successful in 15s
CI / php-lint (push) Successful in 31s
CI / static-tests (push) Successful in 1m9s
CI / js-tests (push) Successful in 32s
2026-06-18 14:44:25 +02:00
60ce3460a5 feat(api): P4 chunk 1b - encaissement + decrement stock (#57)
All checks were successful
CI / secret-scan (push) Successful in 18s
CI / php-lint (push) Successful in 27s
CI / static-tests (push) Successful in 55s
CI / js-tests (push) Successful in 31s
CI / auto-merge (push) Has been skipped
2026-06-18 14:29:22 +02:00
a6ac3d6421 fix(admin): logo reel dans la sidebar back-office (#56)
Some checks are pending
CI / secret-scan (push) Waiting to run
CI / php-lint (push) Waiting to run
CI / static-tests (push) Waiting to run
CI / js-tests (push) Waiting to run
CI / auto-merge (push) Blocked by required conditions
2026-06-18 14:24:34 +02:00
29a191e506 feat(api): P4 chunk 1a - creation de commande + chevalet (#55)
All checks were successful
CI / secret-scan (push) Successful in 10s
CI / js-tests (push) Successful in 35s
CI / auto-merge (push) Has been skipped
CI / php-lint (push) Successful in 29s
CI / static-tests (push) Successful in 1m5s
2026-06-18 14:09:35 +02:00
1697b94b62 feat(admin): humanise les libelles restants (Reference/Variation/Auteur) (#54)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 21s
CI / js-tests (push) Successful in 24s
CI / auto-merge (push) Has been skipped
CI / static-tests (push) Successful in 52s
2026-06-18 13:45:46 +02:00
60535bbe00 feat(admin): modal de re-autorisation PIN (#52)
All checks were successful
CI / secret-scan (push) Successful in 15s
CI / php-lint (push) Successful in 19s
CI / static-tests (push) Successful in 1m5s
CI / js-tests (push) Successful in 32s
CI / auto-merge (push) Has been skipped
2026-06-18 13:17:59 +02:00
26 changed files with 1713 additions and 117 deletions

View file

@ -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."

View 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;

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, 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).

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

View file

@ -18,12 +18,12 @@ final class UserDirectory
}
/**
* @return array{name: string, role_label: string}
* @return array{name: string, role_label: string, email: string}
*/
public function displayInfo(int $userId): array
{
$row = $this->db->fetch(
'SELECT u.first_name, u.last_name, r.label AS role_label '
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label '
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
['id' => $userId],
);
@ -35,6 +35,7 @@ final class UserDirectory
return [
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
];
}
}

View file

@ -62,9 +62,10 @@ abstract class AdminController extends AuthenticatedController
$info = $this->userDirectory()->displayInfo($userId);
$context = [
'currentUserName' => $info['name'],
'currentUserRole' => $info['role_label'],
'permissions' => $this->authorizer()->permissionsFor($roleId),
'currentUserName' => $info['name'],
'currentUserRole' => $info['role_label'],
'currentUserEmail' => $info['email'],
'permissions' => $this->authorizer()->permissionsFor($roleId),
'csrfToken' => Csrf::token($this->sessionManager()),
'activeNav' => '',
'flash' => $this->takeFlash(),

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'] = '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;

View 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.',
};
}
}

View 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,
];
}
}

View 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
{
}

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">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>

View file

@ -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>

View file

@ -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>

View file

@ -58,7 +58,7 @@ $navClass = static function (string $code, string $current): string {
<title><?= $pageTitle ?></title>
<link rel="stylesheet" href="/assets/css/admin.css">
</head>
<body>
<body data-user-email="<?= htmlspecialchars($currentUserEmail ?? '', ENT_QUOTES, 'UTF-8') ?>">
<div class="admin-layout">
<header class="topbar">
<div class="topbar-actions">
@ -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">
@ -151,5 +149,6 @@ $navClass = static function (string $code, string $current): string {
</main>
</div>
<script src="/assets/js/admin.js"></script>
<script src="/assets/js/pin-modal.js"></script>
</body>
</html>

View file

@ -9,6 +9,10 @@ declare(strict_types=1);
* donc pas de `name[]` ni de JS. Toute soumission exige le PIN equipier (RG-T13).
* Le `code` est editable a la creation, fige a l'edition (immuable).
*
* Presentation humanisee (option a) : les permissions sont regroupees par domaine
* et libellees en francais ICI (la base reste la source des codes) ; canal et page
* d'accueil sont des listes deroulantes. Les NOMS de champs postes sont inchanges.
*
* @var int $roleId
* @var bool $isAdminRole
* @var array<int, array<string, mixed>> $permissions catalogue {id, code, label}
@ -41,11 +45,79 @@ $val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ??
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : '';
$selectedSource = (string) ($vals['order_source'] ?? '');
$active = (bool) ($vals['is_active'] ?? true);
// --- Correspondances humaines (presentation seule) ---
// Canal de commande : enum technique -> libelle parlant.
$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'];
$canalLabel = static fn (string $enum): string => $canalLabels[$enum] ?? $enum;
// Pages proposees comme page d'accueil (liste deroulante) : chemin -> libelle.
$routeOptions = [
'/admin/dashboard' => 'Tableau de bord',
'/admin/stats' => 'Statistiques',
'/admin/products' => 'Produits',
'/admin/menus' => 'Menus',
'/admin/ingredients' => 'Stock',
'/admin/categories' => 'Categories',
'/admin/users' => 'Comptes',
'/admin/roles' => 'Roles',
];
$currentRoute = (string) ($vals['default_route'] ?? '');
// Toujours pouvoir reselectionner la valeur courante meme si hors liste (ex. seed).
if ($currentRoute !== '' && !isset($routeOptions[$currentRoute])) {
$routeOptions[$currentRoute] = $currentRoute;
}
// Permissions : code technique -> [groupe, action]. La base reste la source des codes.
$permMap = [
'product.read' => ['Produits', 'Voir'],
'product.create' => ['Produits', 'Creer'],
'product.update' => ['Produits', 'Modifier'],
'product.delete' => ['Produits', 'Supprimer'],
'menu.read' => ['Menus', 'Voir'],
'menu.create' => ['Menus', 'Creer'],
'menu.update' => ['Menus', 'Modifier'],
'menu.delete' => ['Menus', 'Supprimer'],
'category.manage' => ['Catalogue & recettes', 'Gerer les categories'],
'ingredient.manage' => ['Catalogue & recettes', 'Gerer les ingredients et recettes'],
'stock.read' => ['Stock', 'Voir'],
'stock.count' => ['Stock', "Faire l'inventaire"],
'stock.manage' => ['Stock', 'Reapprovisionner'],
'order.read' => ['Commandes', 'Voir'],
'order.create' => ['Commandes', 'Creer'],
'order.deliver' => ['Commandes', 'Livrer'],
'order.cancel' => ['Commandes', 'Annuler'],
'user.read' => ['Comptes', 'Voir'],
'user.create' => ['Comptes', 'Creer'],
'user.update' => ['Comptes', 'Modifier'],
'user.deactivate' => ['Comptes', 'Desactiver'],
'role.manage' => ['Roles & statistiques', 'Gerer les roles'],
'stats.read' => ['Roles & statistiques', 'Voir les statistiques'],
];
$groupOrder = ['Produits', 'Menus', 'Catalogue & recettes', 'Stock', 'Commandes', 'Comptes', 'Roles & statistiques', 'Autres'];
// Regroupe le catalogue recu par domaine humain.
$grouped = [];
foreach ($perms as $p) {
$code = (string) ($p['code'] ?? '');
$map = $permMap[$code] ?? ['Autres', (string) ($p['label'] ?? $code)];
$grouped[$map[0]][] = [
'id' => (int) ($p['id'] ?? 0),
'action' => $map[1],
'checked' => in_array((int) ($p['id'] ?? 0), $selPerms, true),
];
}
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le role' : 'Nouveau role' ?></h1>
<?php if ($isAdmin): ?><p class="page-subtitle">Role administrateur : il doit conserver <code>role.manage</code> et rester actif.</p><?php endif; ?>
<p class="page-subtitle">
<?php if ($isAdmin): ?>
Role administrateur : il doit garder le droit de gerer les roles et rester actif.
<?php else: ?>
Definissez ce que ce role peut faire dans le back-office.
<?php endif; ?>
</p>
</div>
</div>
@ -53,20 +125,21 @@ $active = (bool) ($vals['is_active'] ?? true);
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="code">Code</label>
<?php if ($id === 0): ?>
<input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required>
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
<?php else: ?>
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
<p><small class="muted">Le code est immuable apres creation.</small></p>
<?php endif; ?>
<label class="form-label" for="label">Nom du role</label>
<input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required>
<?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="label">Libelle</label>
<input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required>
<?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?>
<label class="form-label" for="code">Code interne</label>
<?php if ($id === 0): ?>
<input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required>
<p class="form-helper">Identifiant technique (sans espace), non modifiable apres creation.</p>
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
<?php else: ?>
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
<p class="form-helper">Identifiant technique, non modifiable apres creation.</p>
<?php endif; ?>
</div>
<div class="form-group">
@ -75,60 +148,69 @@ $active = (bool) ($vals['is_active'] ?? true);
</div>
<div class="form-group">
<label class="form-label" for="default_route">Route par defaut (landing)</label>
<input class="form-input" type="text" id="default_route" name="default_route" maxlength="120" value="<?= $val('default_route') ?>">
<label class="form-label" for="default_route">Page d'accueil apres connexion</label>
<select class="form-input" id="default_route" name="default_route">
<option value=""> Aucune </option>
<?php foreach ($routeOptions as $path => $pageLabel): ?>
<option value="<?= htmlspecialchars($path, ENT_QUOTES, 'UTF-8') ?>"<?= $path === $currentRoute ? ' selected' : '' ?>><?= htmlspecialchars($pageLabel, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<p class="form-helper">L'ecran affiche a cette personne quand elle se connecte.</p>
<?php if ($err('default_route') !== ''): ?><p class="form-error"><?= $err('default_route') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="order_source">Source de commande auto-taggee</label>
<label class="form-label" for="order_source">Canal de commande</label>
<select class="form-input" id="order_source" name="order_source">
<option value="">-- aucune (admin/manager) --</option>
<option value=""> Aucun (role de gestion) </option>
<?php foreach ($srcList as $src): ?>
<option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?></option>
<option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($canalLabel($src), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<p class="form-helper">Les commandes prises par ce role sont rattachees a ce canal.</p>
<?php if ($err('order_source') !== ''): ?><p class="form-error"><?= $err('order_source') ?></p><?php endif; ?>
</div>
<?php if ($id !== 0): ?>
<div class="form-group">
<label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Role actif</label>
<label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Ce role est actif</label>
</div>
<?php endif; ?>
<fieldset class="form-group">
<legend>Permissions</legend>
<legend>Droits d'acces</legend>
<p class="form-helper">Cochez ce que ce role est autorise a faire.</p>
<?php if ($err('permissions') !== ''): ?><p class="form-error"><?= $err('permissions') ?></p><?php endif; ?>
<div style="max-height:320px; overflow-y:auto;">
<?php foreach ($perms as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
$checked = in_array($pid, $selPerms, true);
?>
<label style="display:block; padding:2px 0;">
<input type="checkbox" name="perm_<?= $pid ?>" value="1"<?= $checked ? ' checked' : '' ?>>
<code><?= htmlspecialchars((string) ($p['code'] ?? ''), ENT_QUOTES, 'UTF-8') ?></code>
<span class="muted">- <?= htmlspecialchars((string) ($p['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
</label>
<div class="perm-grid">
<?php foreach ($groupOrder as $group): ?>
<?php if (empty($grouped[$group])): continue; endif; ?>
<div class="perm-group">
<h4 class="perm-group-title"><?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?></h4>
<?php foreach ($grouped[$group] as $item): ?>
<label class="perm-opt">
<input type="checkbox" name="perm_<?= $item['id'] ?>" value="1"<?= $item['checked'] ? ' checked' : '' ?>>
<?= htmlspecialchars($item['action'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</fieldset>
<fieldset class="form-group">
<legend>Sources de tableau de bord visibles</legend>
<legend>Canaux visibles sur le tableau de bord</legend>
<?php foreach ($srcList as $src): ?>
<label style="display:inline-block; margin-right:1rem;">
<label class="perm-opt">
<input type="checkbox" name="source_<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>" value="1"<?= in_array($src, $selSources, true) ? ' checked' : '' ?>>
<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>
<?= htmlspecialchars($canalLabel($src), ENT_QUOTES, 'UTF-8') ?>
</label>
<?php endforeach; ?>
</fieldset>
<fieldset class="form-group">
<legend>Re-autorisation (PIN equipier)</legend>
<legend>Confirmation par PIN</legend>
<div class="form-group">
<label class="form-label" for="pin_email">Email equipier</label>
<label class="form-label" for="pin_email">Email de l'equipier</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off">
</div>
<div class="form-group">

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
/**
* Liste des roles (RBAC, role.manage), injectee dans admin/layout.php. Texte echappe.
* Presentation humanisee : page d'accueil et canal affiches en clair (la base garde
* les chemins / enums techniques).
*
* @var array<int, array<string, mixed>> $roles
*/
@ -11,11 +13,25 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($roles) && is_array($roles) ? $roles : [];
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$routeLabels = [
'/admin/dashboard' => 'Tableau de bord',
'/admin/stats' => 'Statistiques',
'/admin/products' => 'Produits',
'/admin/menus' => 'Menus',
'/admin/ingredients' => 'Stock',
'/admin/categories' => 'Categories',
'/admin/users' => 'Comptes',
'/admin/roles' => 'Roles',
];
$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'];
$routeHuman = static fn (string $r): string => $r === '' ? '—' : ($routeLabels[$r] ?? $r);
$canalHuman = static fn (?string $s): string => ($s === null || $s === '') ? '—' : ($canalLabels[$s] ?? $s);
?>
<div class="page-header">
<div>
<h1 class="page-title">Roles et permissions</h1>
<p class="page-subtitle">Matrice RBAC. Modifier un role est une action sensible (PIN + audit).</p>
<h1 class="page-title">Roles et droits d'acces</h1>
<p class="page-subtitle">Modifier un role est une action sensible (confirmation par PIN).</p>
</div>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/roles/new">Nouveau role</a>
@ -27,10 +43,10 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
<table>
<thead>
<tr>
<th>Code</th>
<th>Libelle</th>
<th>Route par defaut</th>
<th>Source</th>
<th>Nom</th>
<th>Code interne</th>
<th>Page d'accueil</th>
<th>Canal</th>
<th>Statut</th>
<th style="width:120px;"></th>
</tr>
@ -43,12 +59,13 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
$src = isset($row['order_source']) && is_string($row['order_source']) ? $row['order_source'] : null;
?>
<tr>
<td class="fw-600"><?= $esc($row['code'] ?? '') ?></td>
<td><?= $esc($row['label'] ?? '') ?></td>
<td class="muted"><?= $esc($row['default_route'] ?? '') ?></td>
<td class="muted"><?= $esc($row['order_source'] ?? '-') ?></td>
<td class="fw-600"><?= $esc($row['label'] ?? '') ?></td>
<td class="muted"><?= $esc($row['code'] ?? '') ?></td>
<td class="muted"><?= $esc($routeHuman((string) ($row['default_route'] ?? ''))) ?></td>
<td class="muted"><?= $esc($canalHuman($src)) ?></td>
<td>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>

View file

@ -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;
}
@ -1358,3 +1352,82 @@ tbody td.mono {
.feed-text b { font-weight: 800; }
.feed-meta { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; }
.feed-time { font-size: 13px; color: var(--color-text-muted); font-weight: 600; white-space: nowrap; }
/* --- Modal PIN (re-autorisation au moment de l'action sensible) --- */
.pin-modal-overlay {
position: fixed;
inset: 0;
background: rgba(26, 26, 26, 0.45);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
}
.pin-modal-overlay.open { display: flex; }
.pin-modal {
background: var(--color-white);
border-radius: var(--radius-card);
border-top: 3px solid var(--color-yellow);
box-shadow: var(--shadow-card-hover);
width: 100%;
max-width: 400px;
padding: 28px;
}
.pin-modal-head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; }
.pin-modal-ico {
width: 44px; height: 44px; flex-shrink: 0;
border-radius: 13px;
background: var(--color-yellow-soft);
color: var(--color-yellow-ink);
display: flex; align-items: center; justify-content: center;
}
.pin-modal-title { font-size: 18px; font-weight: 800; letter-spacing: -0.3px; }
.pin-modal-sub { font-size: 13px; color: var(--color-text-muted); margin-top: 3px; }
.pin-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
/* --- Matrice de droits d'acces groupee (formulaire Roles humanise) --- */
.perm-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-top: 10px;
}
.perm-group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 14px 16px;
background: var(--color-surface);
}
.perm-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 8px;
color: var(--color-text);
}
.perm-opt {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--color-text-sec);
margin: 3px 14px 3px 0;
}
@media (max-width: 700px) {
.perm-grid { grid-template-columns: 1fr; }
}
/* Fieldsets de formulaire : pas de bordure native ; la legende = titre de section. */
.form-card fieldset {
border: none;
padding: 0;
margin: 0;
}
.form-card legend {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
padding: 0;
margin-bottom: 4px;
}

View file

@ -0,0 +1,139 @@
/**
* pin-modal.js Re-autorisation par PIN au moment de l'action sensible.
*
* Les formulaires d'action sensible portent un fieldset inline (email equipier + PIN,
* RG-T13). Plutot que ce bloc noye en bas du formulaire, on le masque et on le remplace
* par un MODAL clair qui surgit au clic sur "Enregistrer/Supprimer" : l'equipier confirme
* avec son email + PIN (ou ceux d'un responsable), on reinjecte dans les champs caches,
* puis on soumet. Le contrat serveur ne change pas (il lit toujours pin_email + pin).
*
* CSP 'self' : script externe, aucun handler inline, le DOM du modal est construit ici.
*/
(function () {
'use strict';
function init(doc) {
var emailInput = doc.getElementById('pin_email');
var pinInput = doc.getElementById('pin');
// Seuls les formulaires de RE-AUTORISATION ont pin_email (la page set-PIN ne
// l'a pas : on ne l'intercepte donc pas).
if (!emailInput || !pinInput) {
return;
}
var form = pinInput.closest('form');
if (!form) {
return;
}
var fieldset = pinInput.closest('fieldset');
if (fieldset) {
fieldset.hidden = true;
}
// Email de l'utilisateur connecte (expose sur <body data-user-email>) : pre-remplit
// le modal pour le cas courant ou l'on valide sa PROPRE action ; reste modifiable
// pour validation par un responsable.
var prefillEmail = (doc.body && doc.body.getAttribute('data-user-email')) || '';
var overlay = buildModal(doc);
doc.body.appendChild(overlay);
var modalEmail = overlay.querySelector('#pm-email');
var modalPin = overlay.querySelector('#pm-pin');
var modalError = overlay.querySelector('[data-pm-error]');
var confirmed = false;
form.addEventListener('submit', function (e) {
if (confirmed) {
return; // deja valide via le modal -> soumission reelle
}
e.preventDefault();
openModal();
});
overlay.querySelector('[data-pm-cancel]').addEventListener('click', closeModal);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) {
closeModal();
}
});
doc.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('open')) {
closeModal();
}
});
overlay.querySelector('[data-pm-form]').addEventListener('submit', function (e) {
e.preventDefault();
var email = modalEmail.value.trim();
var pin = modalPin.value;
if (email === '' || pin === '') {
modalError.textContent = 'Email et PIN requis pour confirmer.';
modalError.hidden = false;
return;
}
emailInput.value = email;
pinInput.value = pin;
confirmed = true;
closeModal();
form.submit();
});
function openModal() {
modalError.hidden = true;
modalEmail.value = emailInput.value || prefillEmail || '';
modalPin.value = '';
overlay.classList.add('open');
(modalEmail.value === '' ? modalEmail : modalPin).focus();
}
function closeModal() {
overlay.classList.remove('open');
}
}
function buildModal(doc) {
var overlay = doc.createElement('div');
overlay.className = 'pin-modal-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Confirmation par PIN');
overlay.innerHTML =
'<div class="pin-modal">' +
' <div class="pin-modal-head">' +
' <span class="pin-modal-ico" aria-hidden="true">' +
' <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>' +
' </span>' +
' <div>' +
' <h2 class="pin-modal-title">Action a confirmer</h2>' +
' <p class="pin-modal-sub">Saisissez vos identifiants equipier (ou ceux d\'un responsable).</p>' +
' </div>' +
' </div>' +
' <form data-pm-form novalidate>' +
' <div class="form-group">' +
' <label class="form-label" for="pm-email">Email equipier</label>' +
' <input class="form-input" type="email" id="pm-email" autocomplete="off">' +
' </div>' +
' <div class="form-group">' +
' <label class="form-label" for="pm-pin">PIN</label>' +
' <input class="form-input" type="password" id="pm-pin" inputmode="numeric" autocomplete="off">' +
' </div>' +
' <p class="form-error" data-pm-error hidden></p>' +
' <div class="pin-modal-actions">' +
' <button class="btn btn-secondary" type="button" data-pm-cancel>Annuler</button>' +
' <button class="btn btn-primary" type="submit">Confirmer</button>' +
' </div>' +
' </form>' +
'</div>';
return overlay;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { init: init, buildModal: buildModal };
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', function () {
init(document);
});
}
})();

View file

@ -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

View 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;
}
}

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('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'));
}

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('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)
}
}

View file

@ -25,11 +25,12 @@ final class UserDirectoryTest extends TestCase
$this->db->userDisplayRow = [
'first_name' => 'Corentin',
'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur',
];
self::assertSame(
['name' => 'Corentin J', 'role_label' => 'Administrateur'],
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'],
(new UserDirectory($this->db))->displayInfo(7),
);
}
@ -39,7 +40,7 @@ final class UserDirectoryTest extends TestCase
$this->db->userDisplayRow = null;
self::assertSame(
['name' => 'Utilisateur', 'role_label' => ''],
['name' => 'Utilisateur', 'role_label' => '', 'email' => ''],
(new UserDirectory($this->db))->displayInfo(999),
);
}

View 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);
}
}

View 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']);
}
}

View file

@ -0,0 +1,83 @@
/*
* Tests du modal de re-autorisation PIN du back-office (node:test + jsdom).
*
* Couvre : masquage du fieldset inline, ouverture du modal a la soumission d'un
* formulaire d'action sensible (pas de soumission reelle), pre-remplissage de
* l'email depuis <body data-user-email>, et la confirmation qui reinjecte
* email + PIN dans les champs caches puis soumet. DOM simule par jsdom.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
// pin-modal.js est du CommonJS (admin = racine CommonJS) ; import par defaut.
import pinModal from '../../src/public/admin/assets/js/pin-modal.js';
function setup(email) {
const dom = new JSDOM(
'<!DOCTYPE html><html><body data-user-email="' + email + '">' +
'<form id="f" method="post" action="/admin/roles/1/update">' +
' <fieldset id="pinfs">' +
' <input type="email" id="pin_email" name="pin_email">' +
' <input type="password" id="pin" name="pin">' +
' </fieldset>' +
' <button type="submit">Enregistrer</button>' +
'</form></body></html>',
);
return dom;
}
function fireSubmit(dom, el) {
el.dispatchEvent(new dom.window.Event('submit', { cancelable: true, bubbles: true }));
}
test('init masque le fieldset inline et insere un modal ferme', () => {
const dom = setup('a@b.c');
pinModal.init(dom.window.document);
const doc = dom.window.document;
assert.equal(doc.getElementById('pinfs').hidden, true);
assert.ok(doc.querySelector('.pin-modal-overlay'));
assert.equal(doc.querySelector('.pin-modal-overlay.open'), null);
});
test('soumettre le formulaire ouvre le modal (sans soumission reelle) et pre-remplit l email', () => {
const dom = setup('manager@wakdo.local');
const doc = dom.window.document;
pinModal.init(doc);
const form = doc.getElementById('f');
let submitted = false;
form.submit = () => { submitted = true; };
fireSubmit(dom, form);
assert.equal(doc.querySelector('.pin-modal-overlay').classList.contains('open'), true);
assert.equal(submitted, false);
assert.equal(doc.getElementById('pm-email').value, 'manager@wakdo.local');
});
test('confirmer reinjecte email + PIN et soumet ; refuse si champ vide', () => {
const dom = setup('a@b.c');
const doc = dom.window.document;
pinModal.init(doc);
const form = doc.getElementById('f');
let submitted = false;
form.submit = () => { submitted = true; };
fireSubmit(dom, form);
const modalForm = doc.querySelector('[data-pm-form]');
// PIN vide -> pas de soumission, erreur affichee.
doc.getElementById('pm-pin').value = '';
fireSubmit(dom, modalForm);
assert.equal(submitted, false);
assert.equal(doc.querySelector('[data-pm-error]').hidden, false);
// Email + PIN -> reinjection + soumission.
doc.getElementById('pm-email').value = 'valid@wakdo.local';
doc.getElementById('pm-pin').value = '4729';
fireSubmit(dom, modalForm);
assert.equal(doc.getElementById('pin_email').value, 'valid@wakdo.local');
assert.equal(doc.getElementById('pin').value, '4729');
assert.equal(submitted, true);
assert.equal(doc.querySelector('.pin-modal-overlay').classList.contains('open'), false);
});