Compare commits
7 commits
67fe086581
...
2e1d2e3126
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1d2e3126 | ||
| 05eca6aea2 | |||
| 60ce3460a5 | |||
| a6ac3d6421 | |||
| 29a191e506 | |||
| 1697b94b62 | |||
| 60535bbe00 |
26 changed files with 1713 additions and 117 deletions
|
|
@ -1,6 +1,8 @@
|
|||
name: CI
|
||||
# CI Wakdo - Forgejo Actions (runner stark-wakdo, label `docker`).
|
||||
# Strategie solo dev : PR obligatoire + auto-merge sur CI verte (voir SECURITY.md).
|
||||
# Strategie solo dev : PR obligatoire ; l'auto-merge NATIF Forgejo
|
||||
# (merge_when_checks_succeed, programme a l'ouverture de la PR) fusionne en squash
|
||||
# des que les checks requis passent. Pas de job de merge dans le workflow.
|
||||
#
|
||||
# Etat des jobs selon la phase projet :
|
||||
# - secret-scan : fonctionnel des maintenant (gitleaks scanne tout le depot)
|
||||
|
|
@ -11,8 +13,7 @@ name: CI
|
|||
on:
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
# `labeled` : permet au job auto-merge de s'evaluer quand on pose le label.
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
# dev/main : porte de merge. feat|fix|ci|refactor : feedback avant la PR.
|
||||
branches: [dev, main, 'feat/**', 'fix/**', 'ci/**', 'refactor/**']
|
||||
|
|
@ -169,36 +170,3 @@ jobs:
|
|||
# qu'a tests/e2e via le conteneur officiel.)
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci
|
||||
npm run test:js
|
||||
|
||||
auto-merge:
|
||||
# Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR.
|
||||
# Ne s'execute que si tous les checks requis passent (needs).
|
||||
# IMPORTANT : le filtrage par label se fait DANS le step via l'API, pas dans
|
||||
# `if:` — l'expression contains(github.event.pull_request.labels.*.name, ...)
|
||||
# de Forgejo n'est pas fiable (elle s'evalue a vrai meme sans label, ce qui
|
||||
# fusionnait toute PR verte). La verification shell sur l'API est le vrai gate.
|
||||
needs: [secret-scan, php-lint, static-tests, js-tests]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Install curl
|
||||
run: apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null
|
||||
- name: Merge PR (squash) si label auto-merge present et CI verte
|
||||
run: |
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
TOKEN="${{ secrets.FORGEJO_TOKEN }}"
|
||||
labels=$(curl -s -H "Authorization: token $TOKEN" "$API/issues/$PR/labels")
|
||||
if ! printf '%s' "$labels" | grep -q '"name"[[:space:]]*:[[:space:]]*"auto-merge"'; then
|
||||
echo "Pas de label 'auto-merge' sur la PR #$PR -> relecture manuelle, pas de fusion auto."
|
||||
exit 0
|
||||
fi
|
||||
echo "Label 'auto-merge' present + CI verte -> fusion de la PR #$PR"
|
||||
code=$(curl -s -o /tmp/resp -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do":"squash","delete_branch_after_merge":true}' \
|
||||
"$API/pulls/$PR/merge")
|
||||
echo "merge HTTP $code"; cat /tmp/resp || true; echo
|
||||
[ "$code" = "200" ] || { echo "auto-merge failed (HTTP $code)"; exit 1; }
|
||||
echo "PR #$PR mergee."
|
||||
|
|
|
|||
17
db/migrations/0003_order_service_tag.sql
Normal file
17
db/migrations/0003_order_service_tag.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- db/migrations/0003_order_service_tag.sql
|
||||
-- =============================================================================
|
||||
-- Wakdo - Migration 0003 : service_tag (numero de chevalet) sur customer_order
|
||||
-- =============================================================================
|
||||
-- Purpose : numero de chevalet pour le service EN SALLE (mode dine_in / sur place).
|
||||
-- Saisi a la borne quand le client choisit "sur place" ; permet au
|
||||
-- service d'apporter la commande a la bonne table (B4). NULL pour
|
||||
-- takeaway / drive. Colonne additive nullable (aucune donnee existante
|
||||
-- a retro-remplir). Le runner applique *.sql dans l'ordre lexicographique
|
||||
-- via schema_migrations.
|
||||
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
|
||||
-- =============================================================================
|
||||
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE customer_order
|
||||
ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode;
|
||||
|
|
@ -41,7 +41,7 @@ commande = phase **P4**, schema en base mais workflow applicatif a venir).
|
|||
| Tests PHP | PHPUnit 11 (`.phar`, sans Composer) | unit + integration DB |
|
||||
| Tests front | node:test + jsdom | harnais kiosk (`tests/js/`) |
|
||||
| Analyse statique | PHPStan niveau 6 (`.phar`) | |
|
||||
| CI/CD | Forgejo Actions | secret-scan, lint, tests, auto-merge |
|
||||
| CI/CD | Forgejo Actions | secret-scan, lint, tests ; merge natif sur CI verte |
|
||||
| Versioning | Git + Forgejo (`git.acadenice.com`, miroir GitHub) | Conventional Commits |
|
||||
|
||||
Justifications (composer-less, from-scratch, etc.) : `docs/PROJECT_CONTEXT.md` section 6.
|
||||
|
|
@ -243,7 +243,8 @@ MCD / MLD / dictionnaire : `docs/merise/`.
|
|||
- **PHPStan niveau 6** (`.phar`).
|
||||
- **CI Forgejo Actions** (`.forgejo/workflows/ci.yml`) : `secret-scan` (gitleaks),
|
||||
`php-lint`, `static-tests` (PHPStan + PHPUnit avec service MariaDB ephemere migre +
|
||||
seede), `js-tests` (Node 20), `auto-merge` (squash sur label + CI verte).
|
||||
seede), `js-tests` (Node 20). Fusion par auto-merge NATIF Forgejo (squash,
|
||||
`merge_when_checks_succeed`) des que les checks requis sont verts — pas de job de merge.
|
||||
- **Branch protection** : `dev` et `main` proteges (PR requise, force-push bloque,
|
||||
checks requis).
|
||||
|
||||
|
|
|
|||
|
|
@ -120,9 +120,9 @@ Detail par entite : `docs/merise/` et `docs/domaines/` (a venir).
|
|||
- **Branches** depuis `dev` : `feat/*`, `fix/*`, `docs/*`, `chore/*`, `ci/*`, `db/*`,
|
||||
`refactor/*`, `test/*`. Merge vers `dev` par **PR squashee**. Periodiquement
|
||||
`dev -> main` avec tag semver.
|
||||
- **Auto-merge** : poser le label `auto-merge` sur la PR -> fusion automatique des que
|
||||
la CI Forgejo est verte (secret-scan, php-lint, static-tests, js-tests).
|
||||
Script : `scripts/forgejo-pr-automerge.sh`.
|
||||
- **Auto-merge** : l'ouverture de la PR programme la fusion squash automatique des que
|
||||
les checks requis passent (auto-merge NATIF Forgejo `merge_when_checks_succeed`, sans
|
||||
label ni job CI). Script : `scripts/forgejo-pr-automerge.sh`.
|
||||
- **Pas de trailer `Co-Authored-By`** : la transparence sur l'usage de l'IA vit dans le
|
||||
`README.md` et `docs/PROJECT_CONTEXT.md` section 17.
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ abstract class AdminController extends AuthenticatedController
|
|||
$context = [
|
||||
'currentUserName' => $info['name'],
|
||||
'currentUserRole' => $info['role_label'],
|
||||
'currentUserEmail' => $info['email'],
|
||||
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
||||
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||
'activeNav' => '',
|
||||
|
|
|
|||
|
|
@ -197,9 +197,9 @@ class CategoryController extends AdminController
|
|||
}
|
||||
|
||||
if ($slug === '' || mb_strlen($slug) > 60 || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) !== 1) {
|
||||
$errors['slug'] = 'Slug requis : minuscules, chiffres et tirets (60 max).';
|
||||
$errors['slug'] = 'Reference requise : minuscules, chiffres et tirets (60 max).';
|
||||
} elseif ($repo->slugExists($slug, $exceptId)) {
|
||||
$errors['slug'] = 'Ce slug existe deja.';
|
||||
$errors['slug'] = 'Cette reference existe deja.';
|
||||
}
|
||||
|
||||
if ($image !== '' && mb_strlen($image) > 255) {
|
||||
|
|
@ -258,7 +258,7 @@ class CategoryController extends AdminController
|
|||
// getCode() rend la chaine SQLSTATE pour une vraie PDOException ; le cast
|
||||
// couvre aussi un code entier (23000 = violation de contrainte d'integrite).
|
||||
if ((string) $exception->getCode() === '23000') {
|
||||
return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 409);
|
||||
return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou cette reference existe deja.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
|
|
|
|||
130
src/app/Controllers/OrderController.php
Normal file
130
src/app/Controllers/OrderController.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\Controller;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
use App\Order\OrderRepository;
|
||||
use App\Order\OrderValidationException;
|
||||
|
||||
/**
|
||||
* API publique de commande borne (P4, domaine 7). Anonyme : la borne kiosk poste
|
||||
* sans session ; l'idempotence (RG-T19, idempotency_key) tient lieu de garde-fou
|
||||
* anti double-clic / retry reseau. Deux operations :
|
||||
* - POST /api/orders : creation en pending_payment (RG-5 etapes 1-4) ;
|
||||
* - POST /api/orders/{number}/pay : encaissement -> paid + decrement stock (RG-T20).
|
||||
*
|
||||
* Les erreurs metier (OrderValidationException) sont mappees par code :
|
||||
* ORDER_NOT_FOUND -> 404, INVALID_TRANSITION -> 409, le reste (reference /
|
||||
* disponibilite / selection / modificateur) -> 422. Enveloppe standard
|
||||
* {data} / {data:null, error:{code, message}}.
|
||||
*
|
||||
* Non `final` a dessein : les tests sous-classent pour injecter un acces BDD double
|
||||
* (FakeOrderDatabase) via le hook protege db().
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function create(array $params = []): Response
|
||||
{
|
||||
try {
|
||||
$order = $this->orders()->createPending($this->request->json());
|
||||
} catch (OrderValidationException $exception) {
|
||||
return $this->orderError($exception);
|
||||
}
|
||||
|
||||
return $this->json(['data' => $this->present($order)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function pay(array $params = []): Response
|
||||
{
|
||||
try {
|
||||
$order = $this->orders()->pay((string) ($params['number'] ?? ''));
|
||||
} catch (OrderValidationException $exception) {
|
||||
return $this->orderError($exception);
|
||||
}
|
||||
|
||||
return $this->json(['data' => $this->present($order)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fabrique le repository de commande sur l'acces BDD courant. Hook de test
|
||||
* (sous-classe -> double) : redefinir db() suffit a injecter une base factice.
|
||||
*/
|
||||
protected function orders(): OrderRepository
|
||||
{
|
||||
$db = $this->db();
|
||||
|
||||
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
|
||||
}
|
||||
|
||||
/**
|
||||
* Acces BDD comme DatabaseInterface (seam de test). Database l'implemente.
|
||||
*/
|
||||
protected function db(): DatabaseInterface
|
||||
{
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order
|
||||
* @return array{id:int, order_number:string, status:string, total_ttc_cents:int}
|
||||
*/
|
||||
private function present(array $order): array
|
||||
{
|
||||
return [
|
||||
'id' => $order['id'],
|
||||
'order_number' => $order['order_number'],
|
||||
'status' => $order['status'],
|
||||
'total_ttc_cents' => $order['total_ttc_cents'],
|
||||
];
|
||||
}
|
||||
|
||||
private function orderError(OrderValidationException $exception): Response
|
||||
{
|
||||
$code = $exception->getMessage();
|
||||
$status = match ($code) {
|
||||
'ORDER_NOT_FOUND' => 404,
|
||||
'INVALID_TRANSITION' => 409,
|
||||
default => 422,
|
||||
};
|
||||
|
||||
return $this->json(
|
||||
['data' => null, 'error' => ['code' => $code, 'message' => $this->messageFor($code)]],
|
||||
$status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Message lisible par code metier. Reste cote serveur : la borne affiche un
|
||||
* libelle generique, ce texte sert au diagnostic / aux logs.
|
||||
*/
|
||||
private function messageFor(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
'ORDER_NOT_FOUND' => 'Commande introuvable.',
|
||||
'INVALID_TRANSITION' => 'Transition de statut invalide.',
|
||||
'EMPTY_ORDER' => 'La commande est vide.',
|
||||
'INVALID_SERVICE_MODE' => 'Mode de service invalide.',
|
||||
'INVALID_SERVICE_TAG' => 'Numero de chevalet invalide.',
|
||||
'INVALID_ITEM_TYPE' => 'Type d\'article invalide.',
|
||||
'PRODUCT_UNAVAILABLE' => 'Produit indisponible.',
|
||||
'MENU_UNAVAILABLE' => 'Menu indisponible.',
|
||||
'INVALID_SELECTION' => 'Choix invalide pour ce menu.',
|
||||
'INVALID_MODIFIER' => 'Modification d\'ingredient invalide.',
|
||||
'INGREDIENT_NOT_REMOVABLE' => 'Cet ingredient ne peut pas etre retire.',
|
||||
'INGREDIENT_NOT_ADDABLE' => 'Cet ingredient ne peut pas etre ajoute.',
|
||||
default => 'Requete invalide.',
|
||||
};
|
||||
}
|
||||
}
|
||||
482
src/app/Order/OrderRepository.php
Normal file
482
src/app/Order/OrderRepository.php
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Order;
|
||||
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Creation de commande (P4, chunk 1). Persiste une commande en `pending_payment`
|
||||
* (RG-5 etapes 1-4 : customer_order + order_item + order_item_selection +
|
||||
* order_item_modifier) dans UNE transaction. Le decrement de stock (RG-T20) et la
|
||||
* transition `paid` sont une operation distincte (pay(), 2 etapes — decision projet).
|
||||
*
|
||||
* Prix recalcules SERVEUR depuis la base (jamais le client, RG-T16) ; snapshots
|
||||
* figes (RG-T05/RG-7). order_number = "K" + id (decision utilisateur, diverge du
|
||||
* K-AAAA-MM-JJ-NNN de la spec : plus simple, pas de compteur jour). Idempotence
|
||||
* via idempotency_key (anti double-clic / retry reseau borne anonyme).
|
||||
*
|
||||
* Regles de calcul DOCUMENTEES (a confirmer en revue ; non explicitees par la spec) :
|
||||
* - produit a l'unite : toujours format `normal`, prix = product.price_cents, TVA = product.vat_rate ;
|
||||
* - menu : prix = price_maxi_cents si format `maxi` sinon price_normal_cents, TVA = vat_rate du BURGER du menu ;
|
||||
* - modifier `add` : ajoute extra_price_cents (snapshot product_ingredient) au prix de la ligne, au taux TVA de la ligne ;
|
||||
* - TVA par ligne (RG-4) : unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat)), unit_vat = unit_ttc - unit_ht ;
|
||||
* totaux = somme(unit_ttc * qty) ; total_ht = somme(unit_ht * qty) ; total_vat = total_ttc - total_ht.
|
||||
*/
|
||||
class OrderRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DatabaseInterface $db,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly MenuRepository $menus,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}|null
|
||||
*/
|
||||
public function findByIdempotencyKey(string $key): ?array
|
||||
{
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
$row = $this->db->fetch(
|
||||
'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE idempotency_key = :k',
|
||||
['k' => $key],
|
||||
);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'order_number' => (string) $row['order_number'],
|
||||
'total_ttc_cents' => (int) $row['total_ttc_cents'],
|
||||
'status' => (string) $row['status'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une commande borne en pending_payment. Idempotent sur idempotency_key.
|
||||
*
|
||||
* Tolerant sur la forme d'entree (corps JSON decode tel quel) : chaque cle est
|
||||
* relue defensivement et la validation metier leve OrderValidationException.
|
||||
*
|
||||
* @param array<string, mixed> $req
|
||||
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
|
||||
* @throws OrderValidationException si une reference est invalide / indisponible.
|
||||
*/
|
||||
public function createPending(array $req): array
|
||||
{
|
||||
$key = trim((string) ($req['idempotency_key'] ?? ''));
|
||||
$existing = $this->findByIdempotencyKey($key);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$serviceMode = (string) ($req['service_mode'] ?? '');
|
||||
if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) {
|
||||
throw new OrderValidationException('INVALID_SERVICE_MODE');
|
||||
}
|
||||
$serviceTag = $serviceMode === 'dine_in' ? trim((string) ($req['service_tag'] ?? '')) : '';
|
||||
if ($serviceTag !== '' && mb_strlen($serviceTag) > 20) {
|
||||
throw new OrderValidationException('INVALID_SERVICE_TAG');
|
||||
}
|
||||
|
||||
$items = isset($req['items']) && is_array($req['items']) ? $req['items'] : [];
|
||||
if ($items === []) {
|
||||
throw new OrderValidationException('EMPTY_ORDER');
|
||||
}
|
||||
|
||||
// Resolution + calcul (lecture seule) AVANT la transaction d'ecriture.
|
||||
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
|
||||
|
||||
$totalTtc = 0;
|
||||
$totalHt = 0;
|
||||
foreach ($lines as $l) {
|
||||
$totalTtc += $l['unit_ttc'] * $l['quantity'];
|
||||
$totalHt += $l['unit_ht'] * $l['quantity'];
|
||||
}
|
||||
$totalVat = $totalTtc - $totalHt;
|
||||
if ($totalTtc <= 0) {
|
||||
throw new OrderValidationException('EMPTY_ORDER');
|
||||
}
|
||||
|
||||
$result = ['id' => 0, 'order_number' => '', 'total_ttc_cents' => $totalTtc, 'status' => 'pending_payment'];
|
||||
|
||||
$this->db->transaction(function (DatabaseInterface $db) use ($key, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void {
|
||||
$db->execute(
|
||||
'INSERT INTO customer_order '
|
||||
. '(order_number, idempotency_key, source, service_mode, service_tag, status, '
|
||||
. ' total_ht_cents, total_vat_cents, total_ttc_cents) '
|
||||
. "VALUES ('', :idem, 'kiosk', :mode, :tag, 'pending_payment', :ht, :vat, :ttc)",
|
||||
[
|
||||
'idem' => $key !== '' ? $key : null,
|
||||
'mode' => $serviceMode,
|
||||
'tag' => $serviceTag !== '' ? $serviceTag : null,
|
||||
'ht' => $totalHt,
|
||||
'vat' => $totalVat,
|
||||
'ttc' => $totalTtc,
|
||||
],
|
||||
);
|
||||
$orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
||||
$orderNumber = 'K' . $orderId;
|
||||
$db->execute(
|
||||
'UPDATE customer_order SET order_number = :num WHERE id = :id',
|
||||
['num' => $orderNumber, 'id' => $orderId],
|
||||
);
|
||||
|
||||
foreach ($lines as $l) {
|
||||
$db->execute(
|
||||
'INSERT INTO order_item '
|
||||
. '(order_id, item_type, product_id, menu_id, format, label_snapshot, '
|
||||
. ' unit_price_cents_snapshot, vat_rate_snapshot, quantity) '
|
||||
. 'VALUES (:oid, :type, :pid, :mid, :fmt, :label, :price, :vat, :qty)',
|
||||
[
|
||||
'oid' => $orderId,
|
||||
'type' => $l['item_type'],
|
||||
'pid' => $l['product_id'],
|
||||
'mid' => $l['menu_id'],
|
||||
'fmt' => $l['format'],
|
||||
'label' => $l['label'],
|
||||
'price' => $l['unit_ttc'],
|
||||
'vat' => $l['vat_rate'],
|
||||
'qty' => $l['quantity'],
|
||||
],
|
||||
);
|
||||
$itemId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
||||
|
||||
foreach ($l['selections'] as $sel) {
|
||||
$db->execute(
|
||||
'INSERT INTO order_item_selection (order_item_id, menu_slot_id, product_id, label_snapshot) '
|
||||
. 'VALUES (:oiid, :slot, :pid, :label)',
|
||||
['oiid' => $itemId, 'slot' => $sel['menu_slot_id'], 'pid' => $sel['product_id'], 'label' => $sel['label']],
|
||||
);
|
||||
}
|
||||
foreach ($l['modifiers'] as $mod) {
|
||||
$db->execute(
|
||||
'INSERT INTO order_item_modifier (order_item_id, ingredient_id, action, extra_price_cents) '
|
||||
. 'VALUES (:oiid, :ing, :act, :extra)',
|
||||
['oiid' => $itemId, 'ing' => $mod['ingredient_id'], 'act' => $mod['action'], 'extra' => $mod['extra_price_cents']],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$result['id'] = $orderId;
|
||||
$result['order_number'] = $orderNumber;
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encaisse une commande pending_payment : transition -> paid ET decrement de
|
||||
* stock atomique (RG-5 etapes 5-6, RG-T11 / RG-T20) dans UNE transaction.
|
||||
*
|
||||
* Idempotent : une commande deja `paid` est renvoyee telle quelle sans
|
||||
* re-decrementer ; `delivered` / `cancelled` -> INVALID_TRANSITION ; numero
|
||||
* inconnu -> ORDER_NOT_FOUND. La transition est gardee par `status =
|
||||
* 'pending_payment'` dans l'UPDATE : sous une course concurrente, seul le
|
||||
* premier appel decremente (l'autre voit 0 ligne affectee et sort idempotent).
|
||||
*
|
||||
* Decrement (RG-5 etape 5) : par ingredient consomme, units =
|
||||
* (format maxi ? quantity_maxi : quantity_normal) * order_item.quantity, ajuste
|
||||
* par les modificateurs de la ligne (remove => pas de decrement pour cet
|
||||
* ingredient ; add => portion de base + supplement). Les unites sont AGREGEES
|
||||
* par ingredient sur toute la commande : un seul UPDATE auto-verrouillant et une
|
||||
* seule ligne stock_movement(sale) par ingredient affecte (POST-4). Les UPDATE
|
||||
* sont ordonnes par ingredient_id (ordre de verrou stable -> pas de deadlock
|
||||
* entre commandes concurrentes). stock_quantity est signe (survente possible,
|
||||
* RG-T20) : le decrement ne se conditionne a aucun plancher.
|
||||
*
|
||||
* NB : inerte tant que les recettes (product_ingredient) ne sont pas seedees —
|
||||
* la transition `paid` s'applique, mais aucun mouvement de stock n'est produit
|
||||
* faute de composition. La logique s'active des que les recettes existent.
|
||||
*
|
||||
* @param int|null $actingUserId acteur comptoir/drive (stock_movement.user_id +
|
||||
* customer_order.acting_user_id) ; NULL pour le kiosk.
|
||||
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
|
||||
* @throws OrderValidationException
|
||||
*/
|
||||
public function pay(string $orderNumber, ?int $actingUserId = null): array
|
||||
{
|
||||
$order = $this->db->fetch(
|
||||
'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n',
|
||||
['n' => $orderNumber],
|
||||
);
|
||||
if ($order === null) {
|
||||
throw new OrderValidationException('ORDER_NOT_FOUND');
|
||||
}
|
||||
|
||||
$result = [
|
||||
'id' => (int) $order['id'],
|
||||
'order_number' => (string) $order['order_number'],
|
||||
'total_ttc_cents' => (int) $order['total_ttc_cents'],
|
||||
'status' => 'paid',
|
||||
];
|
||||
|
||||
$status = (string) $order['status'];
|
||||
if ($status === 'paid') {
|
||||
return $result; // idempotent : deja encaissee, pas de re-decrement.
|
||||
}
|
||||
if ($status !== 'pending_payment') {
|
||||
throw new OrderValidationException('INVALID_TRANSITION'); // delivered / cancelled.
|
||||
}
|
||||
|
||||
$orderId = (int) $order['id'];
|
||||
$this->db->transaction(function (DatabaseInterface $db) use ($orderId, $actingUserId): void {
|
||||
$affected = $db->execute(
|
||||
'UPDATE customer_order SET status = \'paid\', paid_at = NOW(), '
|
||||
. 'acting_user_id = COALESCE(:uid, acting_user_id), updated_at = NOW() '
|
||||
. 'WHERE id = :id AND status = \'pending_payment\'',
|
||||
['uid' => $actingUserId, 'id' => $orderId],
|
||||
);
|
||||
if ($affected === 0) {
|
||||
// Course perdue : un autre appel a deja transite. S'il a abouti a
|
||||
// `paid`, il a fait le decrement -> on sort idempotent ; sinon la
|
||||
// transition est invalide (statut terminal).
|
||||
$current = (string) ($db->fetch('SELECT status FROM customer_order WHERE id = :id', ['id' => $orderId])['status'] ?? '');
|
||||
if ($current === 'paid') {
|
||||
return;
|
||||
}
|
||||
throw new OrderValidationException('INVALID_TRANSITION');
|
||||
}
|
||||
|
||||
foreach ($this->consumption($db, $orderId) as $ingredientId => $units) {
|
||||
$db->execute(
|
||||
'UPDATE ingredient SET stock_quantity = stock_quantity - :u WHERE id = :id',
|
||||
['u' => $units, 'id' => $ingredientId],
|
||||
);
|
||||
$db->execute(
|
||||
'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) '
|
||||
. 'VALUES (:ing, \'sale\', :delta, :oid, :uid, NULL)',
|
||||
['ing' => $ingredientId, 'delta' => -$units, 'oid' => $orderId, 'uid' => $actingUserId],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la
|
||||
* commande (lecture des lignes persistees + recettes des produits supports).
|
||||
* Cle = ingredient_id, triee croissant (ordre de verrou stable). Un ingredient
|
||||
* dont l'unite agregee retombe a 0 (entierement retire) n'est PAS retourne :
|
||||
* aucun mouvement n'est alors produit. Voir pay() pour la regle de calcul.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function consumption(DatabaseInterface $db, int $orderId): array
|
||||
{
|
||||
$items = $db->fetchAll(
|
||||
'SELECT id, item_type, product_id, menu_id, format, quantity FROM order_item WHERE order_id = :oid',
|
||||
['oid' => $orderId],
|
||||
);
|
||||
|
||||
/** @var array<int, int> $units */
|
||||
$units = [];
|
||||
foreach ($items as $item) {
|
||||
$itemId = (int) $item['id'];
|
||||
$quantity = max(1, (int) $item['quantity']);
|
||||
$maxi = ((string) $item['format']) === 'maxi';
|
||||
|
||||
// Produit(s) dont la recette est consommee : le produit pour une ligne
|
||||
// produit ; le burger + chaque selection pour une ligne menu.
|
||||
$productIds = [];
|
||||
if ((string) $item['item_type'] === 'product') {
|
||||
$productIds[] = (int) $item['product_id'];
|
||||
} else {
|
||||
$menu = $this->menus->find((int) $item['menu_id']);
|
||||
if ($menu !== null) {
|
||||
$productIds[] = (int) $menu['burger_product_id'];
|
||||
}
|
||||
foreach ($db->fetchAll('SELECT product_id FROM order_item_selection WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $sel) {
|
||||
$productIds[] = (int) $sel['product_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// Modificateurs de la ligne (ingredient_id => action). Ils s'appliquent a
|
||||
// toute recette de la ligne contenant l'ingredient ; en pratique ils
|
||||
// ciblent le produit support (burger), dont les ingredients ne recoupent
|
||||
// pas ceux des selections (boisson / accompagnement).
|
||||
$actions = [];
|
||||
foreach ($db->fetchAll('SELECT ingredient_id, action FROM order_item_modifier WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $mod) {
|
||||
$actions[(int) $mod['ingredient_id']] = (string) $mod['action'];
|
||||
}
|
||||
|
||||
foreach ($productIds as $productId) {
|
||||
foreach ($this->products->composition($productId) as $row) {
|
||||
$ingredientId = (int) $row['ingredient_id'];
|
||||
$perUnit = $maxi ? (int) $row['quantity_maxi'] : (int) $row['quantity_normal'];
|
||||
$base = $perUnit * $quantity;
|
||||
$consumed = match ($actions[$ingredientId] ?? null) {
|
||||
'remove' => 0,
|
||||
'add' => $base * 2, // portion de base + supplement (RG-5).
|
||||
default => $base,
|
||||
};
|
||||
if ($consumed > 0) {
|
||||
$units[$ingredientId] = ($units[$ingredientId] ?? 0) + $consumed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($units);
|
||||
|
||||
return $units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
|
||||
*/
|
||||
private function resolveLine(array $item): array
|
||||
{
|
||||
$type = (string) ($item['type'] ?? '');
|
||||
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
||||
$format = ($item['format'] ?? 'normal') === 'maxi' ? 'maxi' : 'normal';
|
||||
|
||||
if ($type === 'product') {
|
||||
$product = $this->products->find((int) ($item['product_id'] ?? 0));
|
||||
if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) {
|
||||
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
|
||||
}
|
||||
$unitBase = (int) $product['price_cents'];
|
||||
$vat = (int) $product['vat_rate'];
|
||||
$modifiers = $this->resolveModifiers($item, (int) $product['id']);
|
||||
$unitTtc = $unitBase + $this->modifiersExtra($modifiers);
|
||||
|
||||
return $this->line('product', (int) $product['id'], null, 'normal', (string) $product['name'], $unitTtc, $vat, $quantity, [], $modifiers);
|
||||
}
|
||||
|
||||
if ($type === 'menu') {
|
||||
$menu = $this->menus->find((int) ($item['menu_id'] ?? 0));
|
||||
if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) {
|
||||
throw new OrderValidationException('MENU_UNAVAILABLE');
|
||||
}
|
||||
$burger = $this->products->find((int) $menu['burger_product_id']);
|
||||
$vat = $burger !== null ? (int) $burger['vat_rate'] : 100;
|
||||
$unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents'];
|
||||
$selections = $this->resolveSelections($item, (int) $menu['id']);
|
||||
$modifiers = $this->resolveModifiers($item, (int) $menu['burger_product_id']);
|
||||
$unitTtc = $unitBase + $this->modifiersExtra($modifiers);
|
||||
|
||||
return $this->line('menu', null, (int) $menu['id'], $format, (string) $menu['name'], $unitTtc, $vat, $quantity, $selections, $modifiers);
|
||||
}
|
||||
|
||||
throw new OrderValidationException('INVALID_ITEM_TYPE');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{ingredient_id:int,action:string,extra_price_cents:int}> $modifiers
|
||||
*/
|
||||
private function modifiersExtra(array $modifiers): int
|
||||
{
|
||||
$extra = 0;
|
||||
foreach ($modifiers as $m) {
|
||||
if ($m['action'] === 'add') {
|
||||
$extra += $m['extra_price_cents'];
|
||||
}
|
||||
}
|
||||
|
||||
return $extra;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return list<array{menu_slot_id:int,product_id:int,label:string}>
|
||||
*/
|
||||
private function resolveSelections(array $item, int $menuId): array
|
||||
{
|
||||
$slots = $this->menus->slotsWithOptions($menuId);
|
||||
/** @var array<int, list<int>> $optionsBySlot */
|
||||
$optionsBySlot = [];
|
||||
foreach ($slots as $s) {
|
||||
$optionsBySlot[(int) $s['id']] = array_map('intval', $s['option_product_ids']);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$raw = isset($item['selections']) && is_array($item['selections']) ? $item['selections'] : [];
|
||||
foreach ($raw as $sel) {
|
||||
$slotId = (int) ($sel['menu_slot_id'] ?? 0);
|
||||
$pid = (int) ($sel['product_id'] ?? 0);
|
||||
if (!isset($optionsBySlot[$slotId]) || !in_array($pid, $optionsBySlot[$slotId], true)) {
|
||||
throw new OrderValidationException('INVALID_SELECTION');
|
||||
}
|
||||
$product = $this->products->find($pid);
|
||||
$out[] = ['menu_slot_id' => $slotId, 'product_id' => $pid, 'label' => $product !== null ? (string) $product['name'] : ''];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return list<array{ingredient_id:int,action:string,extra_price_cents:int}>
|
||||
*/
|
||||
private function resolveModifiers(array $item, int $productId): array
|
||||
{
|
||||
$raw = isset($item['modifiers']) && is_array($item['modifiers']) ? $item['modifiers'] : [];
|
||||
if ($raw === []) {
|
||||
return [];
|
||||
}
|
||||
// Recette du produit support : valide l'ingredient + figes l'extra_price (add).
|
||||
$recipe = [];
|
||||
foreach ($this->products->composition($productId) as $ing) {
|
||||
$recipe[(int) $ing['ingredient_id']] = $ing;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($raw as $mod) {
|
||||
$ingId = (int) ($mod['ingredient_id'] ?? 0);
|
||||
$action = ($mod['action'] ?? '') === 'add' ? 'add' : 'remove';
|
||||
if (!isset($recipe[$ingId])) {
|
||||
throw new OrderValidationException('INVALID_MODIFIER');
|
||||
}
|
||||
$row = $recipe[$ingId];
|
||||
if ($action === 'remove' && (int) ($row['is_removable'] ?? 0) !== 1) {
|
||||
throw new OrderValidationException('INGREDIENT_NOT_REMOVABLE');
|
||||
}
|
||||
if ($action === 'add' && (int) ($row['is_addable'] ?? 0) !== 1) {
|
||||
throw new OrderValidationException('INGREDIENT_NOT_ADDABLE');
|
||||
}
|
||||
$out[] = [
|
||||
'ingredient_id' => $ingId,
|
||||
'action' => $action,
|
||||
'extra_price_cents' => $action === 'add' ? (int) ($row['extra_price_cents'] ?? 0) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{menu_slot_id:int,product_id:int,label:string}> $selections
|
||||
* @param list<array{ingredient_id:int,action:string,extra_price_cents:int}> $modifiers
|
||||
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
|
||||
*/
|
||||
private function line(string $type, ?int $productId, ?int $menuId, string $format, string $label, int $unitTtc, int $vat, int $quantity, array $selections, array $modifiers): array
|
||||
{
|
||||
$unitHt = (int) round($unitTtc * 1000 / (1000 + $vat));
|
||||
|
||||
return [
|
||||
'item_type' => $type,
|
||||
'product_id' => $productId,
|
||||
'menu_id' => $menuId,
|
||||
'format' => $format,
|
||||
'label' => $label,
|
||||
'unit_ttc' => $unitTtc,
|
||||
'unit_ht' => $unitHt,
|
||||
'vat_rate' => $vat,
|
||||
'quantity' => $quantity,
|
||||
'selections' => $selections,
|
||||
'modifiers' => $modifiers,
|
||||
];
|
||||
}
|
||||
}
|
||||
14
src/app/Order/OrderValidationException.php
Normal file
14
src/app/Order/OrderValidationException.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Order;
|
||||
|
||||
/**
|
||||
* Erreur de validation d'une commande (reference invalide, indisponible, selection
|
||||
* hors slot, modifier interdit...). Le code machine (`$this->getMessage()`) sert de
|
||||
* code d'erreur API ; le controleur le traduit en reponse HTTP 422.
|
||||
*/
|
||||
final class OrderValidationException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ $err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k])
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="slug">Slug</label>
|
||||
<label class="form-label" for="slug">Reference</label>
|
||||
<input class="form-input" type="text" id="slug" name="slug" maxlength="60" value="<?= $val('slug') ?>" required>
|
||||
<?php if ($err('slug') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('slug'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Libelle</th>
|
||||
<th>Slug</th>
|
||||
<th>Reference</th>
|
||||
<th>Ordre</th>
|
||||
<th>Statut</th>
|
||||
<th style="width:160px;"></th>
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ $colspan = $withActor ? 5 : 4;
|
|||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Delta</th>
|
||||
<th>Variation</th>
|
||||
<th>Note</th>
|
||||
<?php if ($withActor): ?><th>Acteur</th><?php endif; ?>
|
||||
<?php if ($withActor): ?><th>Auteur</th><?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
139
src/public/admin/assets/js/pin-modal.js
Normal file
139
src/public/admin/assets/js/pin-modal.js
Normal 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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -19,6 +19,7 @@ use App\Controllers\HomeController;
|
|||
use App\Controllers\IngredientController;
|
||||
use App\Controllers\MeController;
|
||||
use App\Controllers\MenuController;
|
||||
use App\Controllers\OrderController;
|
||||
use App\Controllers\PasswordResetController;
|
||||
use App\Controllers\ProductController;
|
||||
use App\Controllers\ProfileController;
|
||||
|
|
@ -70,6 +71,13 @@ try {
|
|||
// RBAC : identite + permissions de la session courante (gardee par SessionGuard).
|
||||
$router->add('GET', '/api/me', [MeController::class, 'show']);
|
||||
|
||||
// Commandes borne (P4, domaine 7). API publique kiosk, ANONYME (pas de session) :
|
||||
// creation en pending_payment puis encaissement (paid + decrement stock RG-T20).
|
||||
// Idempotente sur idempotency_key (anti double-clic / retry reseau). {number} =
|
||||
// un seul segment (numero K+id), pas de collision avec un sous-chemin.
|
||||
$router->add('POST', '/api/orders', [OrderController::class, 'create']);
|
||||
$router->add('POST', '/api/orders/{number}/pay', [OrderController::class, 'pay']);
|
||||
|
||||
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
|
||||
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
|
||||
// Tableau de bord statistiques (stats.read) : landing du role manager. KPIs
|
||||
|
|
|
|||
157
tests/Support/FakeOrderDatabase.php
Normal file
157
tests/Support/FakeOrderDatabase.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Double DatabaseInterface dedie au domaine Commande (P4). Le double generique
|
||||
* FakeDatabase repond par boutons fixes (un seul produit/menu) ; une commande
|
||||
* mele plusieurs produits/menus distincts, d'ou ce double indexe par id.
|
||||
*
|
||||
* Couvre createPending (catalogue + idempotence) ET pay (lecture de la commande
|
||||
* persistee + recettes -> decrement). Les ecritures sont tracees pour assertion ;
|
||||
* payUpdateAffected simule l'issue de la transition gardee (0 = course perdue).
|
||||
*/
|
||||
final class FakeOrderDatabase implements DatabaseInterface
|
||||
{
|
||||
/** @var list<array{sql:string, params:array<string,mixed>}> */
|
||||
public array $writes = [];
|
||||
|
||||
/** @var array<int, array<string,mixed>> produits indexes par id (find). */
|
||||
public array $products = [];
|
||||
/** @var array<int, array<string,mixed>> menus indexes par id (find). */
|
||||
public array $menus = [];
|
||||
/** @var array<int, list<array<string,mixed>>> slots (slotsWithOptions) par menu id. */
|
||||
public array $slotRows = [];
|
||||
/** @var array<int, list<array<string,mixed>>> recettes (composition) par produit id. */
|
||||
public array $compositions = [];
|
||||
|
||||
/** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */
|
||||
/** @var array<string,mixed>|null */
|
||||
public ?array $existingByKey = null;
|
||||
|
||||
/** Commande renvoyee par la recherche order_number (pay) ; null = introuvable. */
|
||||
/** @var array<string,mixed>|null */
|
||||
public ?array $orderByNumber = null;
|
||||
|
||||
/** Statut relu apres une transition gardee a 0 ligne (course concurrente). */
|
||||
public string $recheckStatus = 'paid';
|
||||
|
||||
/** Lignes order_item renvoyees pour la commande encaissee. */
|
||||
/** @var list<array<string,mixed>> */
|
||||
public array $orderItems = [];
|
||||
|
||||
/** Selections (product_id) par order_item id. */
|
||||
/** @var array<int, list<array<string,mixed>>> */
|
||||
public array $selectionsByItem = [];
|
||||
|
||||
/** Modificateurs (ingredient_id, action) par order_item id. */
|
||||
/** @var array<int, list<array<string,mixed>>> */
|
||||
public array $modifiersByItem = [];
|
||||
|
||||
/** Lignes affectees par l'UPDATE de transition pending_payment -> paid. */
|
||||
public int $payUpdateAffected = 1;
|
||||
|
||||
private int $autoId = 99;
|
||||
|
||||
public function fetch(string $sql, array $params = []): ?array
|
||||
{
|
||||
if (str_contains($sql, 'LAST_INSERT_ID')) {
|
||||
return ['id' => $this->autoId];
|
||||
}
|
||||
if (str_contains($sql, 'FROM customer_order WHERE idempotency_key')) {
|
||||
return $this->existingByKey;
|
||||
}
|
||||
if (str_contains($sql, 'FROM customer_order WHERE order_number')) {
|
||||
return $this->orderByNumber;
|
||||
}
|
||||
if (str_contains($sql, 'SELECT status FROM customer_order WHERE id')) {
|
||||
return ['status' => $this->recheckStatus];
|
||||
}
|
||||
if (str_contains($sql, 'FROM product WHERE id = :id')) {
|
||||
return $this->products[(int) $params['id']] ?? null;
|
||||
}
|
||||
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
|
||||
return $this->menus[(int) $params['id']] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
if (str_contains($sql, 'FROM menu_slot s')) {
|
||||
return $this->slotRows[(int) $params['id']] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'FROM product_ingredient pi')) {
|
||||
return $this->compositions[(int) $params['id']] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item WHERE order_id')) {
|
||||
return $this->orderItems;
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item_selection WHERE order_item_id')) {
|
||||
return $this->selectionsByItem[(int) $params['oiid']] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item_modifier WHERE order_item_id')) {
|
||||
return $this->modifiersByItem[(int) $params['oiid']] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function execute(string $sql, array $params = []): int
|
||||
{
|
||||
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
||||
|
||||
if (str_contains($sql, 'INSERT INTO customer_order') || str_contains($sql, 'INSERT INTO order_item ')) {
|
||||
$this->autoId++;
|
||||
}
|
||||
if (str_contains($sql, 'UPDATE customer_order SET status')) {
|
||||
return $this->payUpdateAffected;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function transaction(callable $fn): void
|
||||
{
|
||||
$fn($this);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function firstWrite(string $needle): array
|
||||
{
|
||||
foreach ($this->writes as $write) {
|
||||
if (str_contains($write['sql'], $needle)) {
|
||||
return $write['params'];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function countWrites(string $needle): int
|
||||
{
|
||||
return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametres de toutes les ecritures dont le SQL contient $needle (ordre d'insertion).
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function allWrites(string $needle): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->writes as $write) {
|
||||
if (str_contains($write['sql'], $needle)) {
|
||||
$out[] = $write['params'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@ final class CategoryControllerTest extends TestCase
|
|||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertStringContainsString('Le libelle est requis', $response->body());
|
||||
self::assertStringContainsString('Slug requis', $response->body());
|
||||
self::assertStringContainsString('Reference requise', $response->body());
|
||||
self::assertFalse($this->wroteContaining($db, 'INSERT INTO category'));
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ final class CategoryControllerTest extends TestCase
|
|||
$response = $this->controller($request, $db)->store();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertStringContainsString('Ce slug existe deja', $response->body());
|
||||
self::assertStringContainsString('Cette reference existe deja', $response->body());
|
||||
self::assertFalse($this->wroteContaining($db, 'INSERT INTO category'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ final class IngredientControllerTest extends TestCase
|
|||
$response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertStringContainsString('Acteur', $response->body());
|
||||
self::assertStringContainsString('Auteur', $response->body());
|
||||
self::assertStringContainsString('Sam K', $response->body()); // nom resolu
|
||||
}
|
||||
|
||||
|
|
@ -438,6 +438,6 @@ final class IngredientControllerTest extends TestCase
|
|||
$response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertStringNotContainsString('Acteur', $response->body()); // colonne masquee (RG-4)
|
||||
self::assertStringNotContainsString('Auteur', $response->body()); // colonne masquee (RG-4)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
135
tests/Unit/Order/OrderControllerTest.php
Normal file
135
tests/Unit/Order/OrderControllerTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Order;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Controllers\OrderController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeOrderDatabase;
|
||||
|
||||
/**
|
||||
* Sous-classe de test : redefinit le hook db() pour injecter le double dedie, sans
|
||||
* base reelle. orders() construit alors le vrai OrderRepository sur ce double, ce
|
||||
* qui exerce le cablage complet controleur -> repository.
|
||||
*/
|
||||
final class TestOrderController extends OrderController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly FakeOrderDatabase $fakeDb,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function db(): DatabaseInterface
|
||||
{
|
||||
return $this->fakeDb;
|
||||
}
|
||||
}
|
||||
|
||||
final class OrderControllerTest extends TestCase
|
||||
{
|
||||
private function controller(FakeOrderDatabase $db, string $body = '', string $path = '/api/orders'): TestOrderController
|
||||
{
|
||||
$request = new Request('POST', $path, [], ['content-type' => 'application/json'], $body, '203.0.113.5');
|
||||
|
||||
return new TestOrderController($request, new Config(), new Database(new Config()), $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function jsonBody(array $payload): string
|
||||
{
|
||||
return (string) json_encode($payload);
|
||||
}
|
||||
|
||||
public function testCreateReturns201WithOrderNumber(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
|
||||
|
||||
$body = $this->jsonBody(['service_mode' => 'takeaway', 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]]]);
|
||||
$response = $this->controller($db, $body)->create();
|
||||
|
||||
self::assertSame(201, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('K100', $data['data']['order_number'] ?? null);
|
||||
self::assertSame('pending_payment', $data['data']['status'] ?? null);
|
||||
self::assertSame(890, $data['data']['total_ttc_cents'] ?? null);
|
||||
}
|
||||
|
||||
public function testCreateUnknownProductReturns422(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$body = $this->jsonBody(['service_mode' => 'takeaway', 'items' => [['type' => 'product', 'product_id' => 999, 'quantity' => 1]]]);
|
||||
|
||||
$response = $this->controller($db, $body)->create();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('PRODUCT_UNAVAILABLE', $data['error']['code'] ?? null);
|
||||
}
|
||||
|
||||
public function testCreateInvalidServiceModeReturns422(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$body = $this->jsonBody(['service_mode' => 'bogus', 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]]]);
|
||||
|
||||
$response = $this->controller($db, $body)->create();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('INVALID_SERVICE_MODE', $data['error']['code'] ?? null);
|
||||
}
|
||||
|
||||
public function testPayReturns200Paid(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
|
||||
$response = $this->controller($db, '', '/api/orders/K100/pay')->pay(['number' => 'K100']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('paid', $data['data']['status'] ?? null);
|
||||
self::assertSame('K100', $data['data']['order_number'] ?? null);
|
||||
}
|
||||
|
||||
public function testPayUnknownReturns404(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = null;
|
||||
|
||||
$response = $this->controller($db, '', '/api/orders/K404/pay')->pay(['number' => 'K404']);
|
||||
|
||||
self::assertSame(404, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('ORDER_NOT_FOUND', $data['error']['code'] ?? null);
|
||||
}
|
||||
|
||||
public function testPayTerminalStatusReturns409(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'delivered'];
|
||||
|
||||
$response = $this->controller($db, '', '/api/orders/K100/pay')->pay(['number' => 'K100']);
|
||||
|
||||
self::assertSame(409, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('INVALID_TRANSITION', $data['error']['code'] ?? null);
|
||||
}
|
||||
}
|
||||
288
tests/Unit/Order/OrderRepositoryTest.php
Normal file
288
tests/Unit/Order/OrderRepositoryTest.php
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Order;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Order\OrderRepository;
|
||||
use App\Order\OrderValidationException;
|
||||
use App\Tests\Support\FakeOrderDatabase;
|
||||
|
||||
/**
|
||||
* Couvre createPending (calcul RG-4, numero K+id, idempotence, validation) et pay
|
||||
* (transition gardee -> paid, decrement de stock atomique RG-T20, idempotence)
|
||||
* sur le double dedie FakeOrderDatabase, sans base reelle.
|
||||
*/
|
||||
final class OrderRepositoryTest extends TestCase
|
||||
{
|
||||
private function repo(FakeOrderDatabase $db): OrderRepository
|
||||
{
|
||||
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
|
||||
}
|
||||
|
||||
public function testProductOrderComputesLineVatAndKId(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
|
||||
|
||||
$res = $this->repo($db)->createPending([
|
||||
'idempotency_key' => 'abc',
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
|
||||
]);
|
||||
|
||||
// 890 TTC a 10% -> HT = round(890*1000/1100) = 809, TVA = 81.
|
||||
$order = $db->firstWrite('INSERT INTO customer_order');
|
||||
self::assertSame(890, $order['ttc']);
|
||||
self::assertSame(809, $order['ht']);
|
||||
self::assertSame(81, $order['vat']);
|
||||
self::assertSame('K100', $res['order_number']);
|
||||
self::assertSame('pending_payment', $res['status']);
|
||||
self::assertSame(890, $res['total_ttc_cents']);
|
||||
|
||||
$item = $db->firstWrite('INSERT INTO order_item ');
|
||||
self::assertSame('Cheeseburger', $item['label']);
|
||||
self::assertSame(890, $item['price']);
|
||||
self::assertSame(100, $item['vat']);
|
||||
}
|
||||
|
||||
public function testMenuMaxiUsesBurgerVatAndMaxiPrice(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu Best Of', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->products[20] = ['id' => 20, 'name' => 'Coca', 'price_cents' => 250, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->slotRows[5] = [['id' => 7, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'product_id' => 20]];
|
||||
|
||||
$res = $this->repo($db)->createPending([
|
||||
'service_mode' => 'dine_in',
|
||||
'service_tag' => '42',
|
||||
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
|
||||
'selections' => [['menu_slot_id' => 7, 'product_id' => 20]]]],
|
||||
]);
|
||||
|
||||
// 1200 TTC a 10% -> HT = round(1200*1000/1100) = 1091, TVA = 109.
|
||||
$order = $db->firstWrite('INSERT INTO customer_order');
|
||||
self::assertSame(1200, $order['ttc']);
|
||||
self::assertSame(1091, $order['ht']);
|
||||
self::assertSame('42', $order['tag']);
|
||||
$item = $db->firstWrite('INSERT INTO order_item ');
|
||||
self::assertSame('maxi', $item['fmt']);
|
||||
self::assertSame(1200, $item['price']);
|
||||
self::assertSame(1, $db->countWrites('INSERT INTO order_item_selection'));
|
||||
}
|
||||
|
||||
public function testAddModifierAddsExtraToLine(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->compositions[12] = [['ingredient_id' => 3, 'is_removable' => 1, 'is_addable' => 1, 'extra_price_cents' => 50, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1,
|
||||
'modifiers' => [['ingredient_id' => 3, 'action' => 'add']]]],
|
||||
]);
|
||||
|
||||
self::assertSame(940, $res['total_ttc_cents']); // 890 + 50
|
||||
self::assertSame(1, $db->countWrites('INSERT INTO order_item_modifier'));
|
||||
}
|
||||
|
||||
public function testIdempotentReturnsExistingWithoutInsert(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->existingByKey = ['id' => 7, 'order_number' => 'K7', 'total_ttc_cents' => 500, 'status' => 'pending_payment'];
|
||||
|
||||
$res = $this->repo($db)->createPending([
|
||||
'idempotency_key' => 'dup',
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
|
||||
]);
|
||||
|
||||
self::assertSame('K7', $res['order_number']);
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO customer_order'));
|
||||
}
|
||||
|
||||
public function testRejectsUnknownProduct(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'product', 'product_id' => 999, 'quantity' => 1]],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRejectsSelectionOutsideSlotOptions(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->slotRows[5] = [['id' => 7, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'product_id' => 20]];
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal',
|
||||
'selections' => [['menu_slot_id' => 7, 'product_id' => 999]]]], // 999 hors options
|
||||
]);
|
||||
}
|
||||
|
||||
// --- pay() : transition + decrement de stock (RG-5 etapes 5-6, RG-T20) ---
|
||||
|
||||
public function testPayTransitionsToPaidAndDecrementsProductRecipe(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->pay('K100');
|
||||
|
||||
self::assertSame('paid', $res['status']);
|
||||
self::assertSame('K100', $res['order_number']);
|
||||
self::assertSame(1, $db->countWrites('UPDATE customer_order SET status'));
|
||||
|
||||
// 2 unites consommees (qn 1 * quantite 2) -> stock -2 sur l'ingredient 5.
|
||||
$dec = $db->firstWrite('UPDATE ingredient SET stock_quantity');
|
||||
self::assertSame(2, $dec['u']);
|
||||
self::assertSame(5, $dec['id']);
|
||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||
self::assertSame(-2, $move['delta']);
|
||||
self::assertSame(100, $move['oid']);
|
||||
self::assertNull($move['uid']); // kiosk : pas d'acteur.
|
||||
}
|
||||
|
||||
public function testPayIsIdempotentWhenAlreadyPaid(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->pay('K100');
|
||||
|
||||
self::assertSame('paid', $res['status']);
|
||||
self::assertSame(0, $db->countWrites('UPDATE customer_order SET status'));
|
||||
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
|
||||
}
|
||||
|
||||
public function testPayRejectsUnknownOrder(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = null;
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('ORDER_NOT_FOUND');
|
||||
$this->repo($db)->pay('K404');
|
||||
}
|
||||
|
||||
public function testPayRejectsTerminalStatus(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'cancelled'];
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('INVALID_TRANSITION');
|
||||
$this->repo($db)->pay('K100');
|
||||
}
|
||||
|
||||
public function testPayLosesConcurrentRaceReturnsPaidWithoutDecrement(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->payUpdateAffected = 0; // un autre process a deja transite...
|
||||
$db->recheckStatus = 'paid'; // ...vers paid : on sort idempotent.
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->pay('K100');
|
||||
|
||||
self::assertSame('paid', $res['status']);
|
||||
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
|
||||
}
|
||||
|
||||
public function testPayMenuDecrementsBurgerAndSelectionRecipesAtMaxi(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 1200, 'status' => 'pending_payment'];
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'menu', 'product_id' => null, 'menu_id' => 5, 'format' => 'maxi', 'quantity' => 1]];
|
||||
$db->selectionsByItem[1] = [['product_id' => 20]];
|
||||
$db->compositions[12] = [['ingredient_id' => 3, 'quantity_normal' => 1, 'quantity_maxi' => 2]]; // burger : maxi -> 2
|
||||
$db->compositions[20] = [['ingredient_id' => 7, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; // boisson : 1
|
||||
|
||||
$this->repo($db)->pay('K100');
|
||||
|
||||
$decs = $db->allWrites('UPDATE ingredient SET stock_quantity');
|
||||
self::assertCount(2, $decs);
|
||||
// Ordonne par ingredient_id (ordre de verrou stable) : 3 puis 7.
|
||||
self::assertSame(3, $decs[0]['id']);
|
||||
self::assertSame(2, $decs[0]['u']);
|
||||
self::assertSame(7, $decs[1]['id']);
|
||||
self::assertSame(1, $decs[1]['u']);
|
||||
}
|
||||
|
||||
public function testPayAppliesRemoveAndAddModifiers(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1]];
|
||||
$db->compositions[12] = [
|
||||
['ingredient_id' => 3, 'quantity_normal' => 1, 'quantity_maxi' => 1], // retire
|
||||
['ingredient_id' => 9, 'quantity_normal' => 1, 'quantity_maxi' => 1], // ajoute
|
||||
];
|
||||
$db->modifiersByItem[1] = [
|
||||
['ingredient_id' => 3, 'action' => 'remove'],
|
||||
['ingredient_id' => 9, 'action' => 'add'],
|
||||
];
|
||||
|
||||
$this->repo($db)->pay('K100');
|
||||
|
||||
// ingredient 3 retire -> aucun mouvement ; ingredient 9 ajoute -> base + supplement = 2.
|
||||
$decs = $db->allWrites('UPDATE ingredient SET stock_quantity');
|
||||
self::assertCount(1, $decs);
|
||||
self::assertSame(9, $decs[0]['id']);
|
||||
self::assertSame(2, $decs[0]['u']);
|
||||
}
|
||||
|
||||
public function testPayAggregatesSharedIngredientIntoSingleMovement(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 1500, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [
|
||||
['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1],
|
||||
['id' => 2, 'item_type' => 'product', 'product_id' => 13, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1],
|
||||
];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
$db->compositions[13] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$this->repo($db)->pay('K100');
|
||||
|
||||
// Meme ingredient sur deux lignes -> un seul mouvement, delta agrege -2.
|
||||
self::assertSame(1, $db->countWrites('INSERT INTO stock_movement'));
|
||||
self::assertSame(1, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||
self::assertSame(-2, $move['delta']);
|
||||
}
|
||||
|
||||
public function testPayAttributesActingUserWhenProvided(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$this->repo($db)->pay('K100', 7);
|
||||
|
||||
$transition = $db->firstWrite('UPDATE customer_order SET status');
|
||||
self::assertSame(7, $transition['uid']);
|
||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||
self::assertSame(7, $move['uid']);
|
||||
}
|
||||
}
|
||||
83
tests/js/pin-modal.test.js
Normal file
83
tests/js/pin-modal.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue