feat(stock): reglage capacite+seuils depuis la page Stock (modale) #114

Merged
Corentin merged 1 commit from feat/stock-thresholds-dashboard into dev 2026-06-25 14:35:45 +02:00
9 changed files with 614 additions and 26 deletions
Showing only changes of commit fce6dae428 - Show all commits

View file

@ -135,6 +135,23 @@ final class IngredientRepository
);
}
/**
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Cible UNIQUEMENT
* les trois colonnes de calibrage (capacite = reference 100 %, seuils alerte/critique
* en %), distinctes de update() qui exige aussi name/unit/pack. stock_quantity n'est
* jamais touche : le niveau ne bouge que via restock/inventoryCount (ledger). Les
* bornes (capacite >= 1, % 0-100, critique < alerte strict) sont validees par
* l'appelant (controleur, RG-T18), pas ici.
*/
public function updateThresholds(int $id, int $capacity, int $low, int $critical): void
{
$this->db->execute(
'UPDATE ingredient SET stock_capacity = :cap, low_stock_pct = :low, '
. 'critical_stock_pct = :crit WHERE id = :id',
['cap' => $capacity, 'low' => $low, 'crit' => $critical, 'id' => $id],
);
}
/**
* Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement)
* des qu'une recette ou un mouvement reference l'ingredient ; le controleur

View file

@ -47,6 +47,17 @@ class IngredientController extends AdminController
return $guard;
}
return $this->renderIndex($guard);
}
/**
* Rend le tableau de bord stock. Factorise pour servir la lecture (index, 200) et le
* re-rendu en erreur du reglage rapide de seuils (updateThresholds, 422). $error est
* affiche en bandeau si present. Les drapeaux de permission pilotent l'affichage des
* actions (la garde reelle reste par-route).
*/
private function renderIndex(GuardResult $guard, ?string $error = null, int $status = 200): Response
{
$ingredients = $this->ingredientRepository()->all();
// Compteurs par bande pour le resume du tableau de bord (3 pastilles).
@ -59,14 +70,15 @@ class IngredientController extends AdminController
}
return $this->adminView('admin/ingredients/index', [
'title' => 'Stock - Wakdo Admin',
'activeNav' => 'stock',
'ingredients' => $ingredients,
'bandCounts' => $counts,
'canManage' => $this->may($guard, 'ingredient.manage'),
'canRestock' => $this->may($guard, 'stock.manage'),
'canCount' => $this->may($guard, 'stock.count'),
], $guard);
'title' => 'Stock - Wakdo Admin',
'activeNav' => 'stock',
'ingredients' => $ingredients,
'bandCounts' => $counts,
'canManage' => $this->may($guard, 'ingredient.manage'),
'canRestock' => $this->may($guard, 'stock.manage'),
'canCount' => $this->may($guard, 'stock.count'),
'thresholdError' => $error,
], $guard, $status);
}
/**
@ -303,6 +315,49 @@ class IngredientController extends AdminController
return $this->redirect('/admin/ingredients');
}
/**
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Endpoint LEGER :
* ne reutilise PAS update() (qui exige name/unit/pack), il ne valide et n'ecrit que
* capacite + seuils alerte/critique. Permission stock.manage (calibrage du stock, pas
* du catalogue), CSRF, SANS PIN (config, pas un comptage d'inventaire RG-T13). Succes
* -> redirect liste + flash ; erreur de validation -> 422 re-rendu de la liste avec un
* bandeau d'erreur (convention restock/inventory : 422 + message, pas de redirect muet).
*
* @param array<string, string> $params
*/
public function updateThresholds(array $params): Response
{
$guard = $this->guard('stock.manage');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$ingredient = $this->ingredientRepository()->find($id);
if ($ingredient === null) {
return $this->notFound($guard);
}
[$data, $errors] = $this->validateThresholds($form);
if ($errors !== []) {
// Premier message d'erreur en bandeau : la modale est rouverte cote client si
// besoin ; l'equipier voit la raison du rejet sans jargon de champ.
$messages = array_values($errors);
return $this->renderIndex($guard, $messages[0], 422);
}
$this->ingredientRepository()->updateThresholds($id, $data['stock_capacity'], $data['low_stock_pct'], $data['critical_stock_pct']);
$this->setFlash('Seuils mis a jour.');
return $this->redirect('/admin/ingredients');
}
/**
* @param array<string, string> $params
*/
@ -562,12 +617,6 @@ class IngredientController extends AdminController
$errors['unit'] = 'L unite est requise (40 caracteres max).';
}
$capRaw = trim($form['stock_capacity'] ?? '');
$capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647;
if (!$capValid) {
$errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.';
}
$packRaw = trim($form['pack_size'] ?? '');
$packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535;
if (!$packValid) {
@ -579,6 +628,45 @@ class IngredientController extends AdminController
$errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).';
}
// Capacite + seuils : meme regle (capacite >= 1, % 0-100, critique < alerte strict)
// que le reglage rapide F13 -> source unique validateThresholds(), pas de copie
// divergente. Les messages restent indexes par champ pour le formulaire complet.
[$thresholds, $thresholdErrors] = $this->validateThresholds($form);
$errors = array_merge($errors, $thresholdErrors);
$data = [
'name' => $name,
'unit' => $unit,
'stock_capacity' => $thresholds['stock_capacity'],
'pack_size' => $packValid ? (int) $packRaw : 0,
'pack_label' => $label !== '' ? $label : null,
'low_stock_pct' => $thresholds['low_stock_pct'],
'critical_stock_pct' => $thresholds['critical_stock_pct'],
];
return [$data, $errors];
}
/**
* Validation des trois reglages de calibrage du stock, partagee par le formulaire
* complet (validate) et l'endpoint leger F13 (updateThresholds) pour qu'une seule
* regle existe : capacite (reference 100 %) >= 1 ; seuils alerte/critique entiers
* 0-100 ; critique STRICTEMENT inferieur a alerte (RG-CREATE-ING, garanti aussi par
* un CHECK de table). Renvoie [valeurs normalisees, erreurs indexees par champ].
*
* @param array<string, string> $form
* @return array{0: array{stock_capacity: int, low_stock_pct: int, critical_stock_pct: int}, 1: array<string, string>}
*/
private function validateThresholds(array $form): array
{
$errors = [];
$capRaw = trim($form['stock_capacity'] ?? '');
$capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647;
if (!$capValid) {
$errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.';
}
$lowRaw = trim($form['low_stock_pct'] ?? '');
$lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100;
if (!$lowValid) {
@ -591,17 +679,12 @@ class IngredientController extends AdminController
$errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.';
}
// RG-CREATE-ING : critical_stock_pct < low_stock_pct (strict).
if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) {
$errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
}
$data = [
'name' => $name,
'unit' => $unit,
'stock_capacity' => $capValid ? (int) $capRaw : 0,
'pack_size' => $packValid ? (int) $packRaw : 0,
'pack_label' => $label !== '' ? $label : null,
'low_stock_pct' => $lowValid ? (int) $lowRaw : 0,
'critical_stock_pct' => $critValid ? (int) $critRaw : 0,
];

View file

@ -12,10 +12,11 @@ declare(strict_types=1);
*
* @var array<int, array<string, mixed>> $ingredients
* @var array<string, int> $bandCounts
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
* @var string $csrfToken
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
* @var string|null $thresholdError
* @var string $csrfToken
*/
/** @var array<int, array<string, mixed>> $rows */
@ -31,6 +32,29 @@ $count = (bool) ($canCount ?? false);
$nCritical = (int) ($counts['critical'] ?? 0);
$nLow = (int) ($counts['low'] ?? 0);
$nNormal = (int) ($counts['normal'] ?? 0);
$thresholdErr = isset($thresholdError) && is_string($thresholdError) ? $thresholdError : null;
/**
* Bouton "Regler les seuils" (F13). Ouvre la modale pre-remplie via des data-attributes
* (l'id pour l'action POST, le nom pour le titre, les trois valeurs courantes) ; le JS
* stock-thresholds.js intercepte le clic. Affiche seulement si le role peut calibrer le
* stock ($restock = stock.manage). Valeurs echappees (attributs HTML).
*
* @param array<string, mixed> $row
*/
$renderThresholdButton = static function (array $row) use ($esc, $restock): string {
if (!$restock) {
return '';
}
$id = (int) ($row['id'] ?? 0);
return '<button type="button" class="btn btn-ghost btn-sm" data-threshold-open'
. ' data-id="' . $id . '"'
. ' data-name="' . $esc($row['name'] ?? '') . '"'
. ' data-capacity="' . (int) ($row['stock_capacity'] ?? 0) . '"'
. ' data-low="' . (int) ($row['low_stock_pct'] ?? 0) . '"'
. ' data-critical="' . (int) ($row['critical_stock_pct'] ?? 0) . '">Regler les seuils</button>';
};
// Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste
// (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas.
@ -92,6 +116,10 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
<?php endif; ?>
</div>
<?php if ($thresholdErr !== null && $thresholdErr !== ''): ?>
<div class="flash flash-error" role="alert"><?= $esc($thresholdErr) ?></div>
<?php endif; ?>
<p class="stock-explainer">
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une
recette qui passe sous son seuil critique rend les produits qui l utilisent
@ -137,9 +165,12 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
<span class="<?= $bandPill ?>"><?= $bandText ?></span>
</div>
<?= $renderBar($row) ?>
<?php if ($restock): ?>
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
<?php endif; ?>
<div class="stock-card__actions">
<?php if ($restock): ?>
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
<?php endif; ?>
<?= $renderThresholdButton($row) ?>
</div>
</div>
<?php endforeach; ?>
</div>
@ -172,6 +203,7 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
<?php if ($count): ?>
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<?= $renderThresholdButton($row) ?>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($manage): ?>
<span class="stock-list__crud">
@ -189,3 +221,45 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
</ul>
<?php endif; ?>
</section>
<?php if ($restock): ?>
<?php /*
Modale de reglage rapide des seuils (F13), rendue serveur (VRAI form POST + CSRF,
comme restock/inventory ; pas de fetch). Une seule modale pour la page : le bouton
clique (data-threshold-open) y injecte l'action /admin/ingredients/{id}/thresholds
et pre-remplit les trois champs depuis ses data-attributes (stock-thresholds.js).
Reutilise les classes .pin-modal-* (overlay generique). Cachee par defaut (pas de
classe .open) : sans JS, elle reste invisible et les actions classiques fonctionnent.
*/ ?>
<div class="pin-modal-overlay" data-threshold-modal role="dialog" aria-modal="true" aria-label="Reglage des seuils de stock">
<div class="pin-modal">
<div class="pin-modal-head">
<div>
<h2 class="pin-modal-title">Regler les seuils</h2>
<p class="pin-modal-sub" data-threshold-name>Capacite de reference et seuils d alerte de l ingredient.</p>
</div>
</div>
<form method="post" action="" data-threshold-form>
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="th-capacity">Capacite (quantite consideree comme 100%)</label>
<input class="form-input" type="number" id="th-capacity" name="stock_capacity" min="1" step="1" required>
</div>
<div class="form-group">
<label class="form-label" for="th-low">Seuil d alerte (% du plein)</label>
<input class="form-input" type="number" id="th-low" name="low_stock_pct" min="0" max="100" step="1" required>
</div>
<div class="form-group">
<label class="form-label" for="th-critical">Seuil critique (% du plein)</label>
<input class="form-input" type="number" id="th-critical" name="critical_stock_pct" min="0" max="100" step="1" required>
<p class="form-hint">Le seuil critique doit etre inferieur au seuil d alerte. Sous le critique, les produits qui utilisent cet ingredient passent indisponibles sur la borne.</p>
</div>
<p class="form-error" data-threshold-error hidden></p>
<div class="pin-modal-actions">
<button class="btn btn-secondary" type="button" data-threshold-cancel>Annuler</button>
<button class="btn btn-primary" type="submit">Enregistrer les seuils</button>
</div>
</form>
</div>
</div>
<?php endif; ?>

View file

@ -169,5 +169,6 @@ $navClass = static function (string $code, string $current): string {
</div>
<script src="/assets/js/admin.js"></script>
<script src="/assets/js/pin-modal.js"></script>
<script src="/assets/js/stock-thresholds.js"></script>
</body>
</html>

View file

@ -2103,3 +2103,36 @@ tbody td.mono {
justify-content: flex-start;
}
}
/* Bandeau de confirmation (flash succes, pose par setFlash apres redirection) et sa
variante erreur (re-rendu 422 du reglage rapide de seuils, F13). */
.flash {
color: var(--color-success-text);
background: var(--color-success-bg);
border-radius: var(--radius-md);
padding: 10px 14px;
margin-bottom: 16px;
font-size: 14px;
}
.flash-error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
}
/* Aide sous un champ de formulaire (texte explicatif, pas une erreur). */
.form-hint {
color: var(--color-text-muted);
font-size: 12px;
margin-top: 4px;
}
/* Zone d'actions d'une carte a reapprovisionner : reappro + reglage des seuils empiles. */
.stock-card__actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.stock-card__actions .btn-sm {
width: 100%;
justify-content: center;
}

View file

@ -0,0 +1,130 @@
/**
* stock-thresholds.js Reglage rapide des seuils de stock depuis le tableau de bord (F13).
*
* Chaque carte/ligne ingredient porte un bouton "Regler les seuils" (data-threshold-open)
* decore de ses valeurs courantes (data-id, data-name, data-capacity, data-low,
* data-critical). Au clic, on pre-remplit l'unique modale rendue serveur (un VRAI form POST
* avec CSRF, pas de fetch), on pointe son action sur /admin/ingredients/{id}/thresholds, et
* on l'ouvre. La validation finale reste cote serveur (validateThresholds) ; on ajoute ici
* un garde-fou client leger (capacite >= 1, % 0-100, critique < alerte strict) pour eviter
* un aller-retour evident. Sans JS, la modale reste cachee et le reste de la page marche.
*
* CSP 'self' : script externe, aucun handler inline. Style CommonJS testable + browser-safe.
*/
(function () {
'use strict';
function init(doc) {
var overlay = doc.querySelector('[data-threshold-modal]');
if (!overlay) {
return; // role sans stock.manage : la modale n'est pas rendue.
}
var form = overlay.querySelector('[data-threshold-form]');
var inCapacity = doc.getElementById('th-capacity');
var inLow = doc.getElementById('th-low');
var inCritical = doc.getElementById('th-critical');
var nameLabel = overlay.querySelector('[data-threshold-name]');
var errorBox = overlay.querySelector('[data-threshold-error]');
if (!form || !inCapacity || !inLow || !inCritical) {
return;
}
var openers = doc.querySelectorAll('[data-threshold-open]');
for (var i = 0; i < openers.length; i++) {
openers[i].addEventListener('click', function (e) {
openModal(e.currentTarget);
});
}
overlay.querySelector('[data-threshold-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();
}
});
// Garde-fou client : on ne bloque que les cas evidents (le serveur reste l'autorite).
form.addEventListener('submit', function (e) {
var error = validate(inCapacity.value, inLow.value, inCritical.value);
if (error !== null) {
e.preventDefault();
if (errorBox) {
errorBox.textContent = error;
errorBox.hidden = false;
}
}
});
function openModal(button) {
var id = button.getAttribute('data-id') || '';
form.setAttribute('action', '/admin/ingredients/' + id + '/thresholds');
if (nameLabel) {
var name = button.getAttribute('data-name') || '';
nameLabel.textContent = name === '' ? '' : 'Ingredient : ' + name;
}
inCapacity.value = button.getAttribute('data-capacity') || '';
inLow.value = button.getAttribute('data-low') || '';
inCritical.value = button.getAttribute('data-critical') || '';
if (errorBox) {
errorBox.hidden = true;
}
overlay.classList.add('open');
inCapacity.focus();
}
function closeModal() {
overlay.classList.remove('open');
}
}
/**
* Validation cliente legere, miroir de validateThresholds() cote serveur :
* capacite entiere >= 1 ; seuils entiers 0-100 ; critique STRICTEMENT < alerte.
* Renvoie un message d'erreur (string) ou null si tout est coherent.
*/
function validate(capacityRaw, lowRaw, criticalRaw) {
var capacity = toInt(capacityRaw);
var low = toInt(lowRaw);
var critical = toInt(criticalRaw);
if (capacity === null || capacity < 1) {
return 'La capacite (reference 100%) doit etre un entier superieur ou egal a 1.';
}
if (low === null || low < 0 || low > 100) {
return 'Le seuil d alerte doit etre un entier entre 0 et 100.';
}
if (critical === null || critical < 0 || critical > 100) {
return 'Le seuil critique doit etre un entier entre 0 et 100.';
}
if (critical >= low) {
return 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
}
return null;
}
/** Entier strict (suite de chiffres) ou null : refuse "", " 5", "5.0", "abc". */
function toInt(raw) {
var value = String(raw === undefined || raw === null ? '' : raw).trim();
if (!/^[0-9]+$/.test(value)) {
return null;
}
return parseInt(value, 10);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { init: init, validate: validate };
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', function () {
init(document);
});
}
})();

View file

@ -221,6 +221,11 @@ try {
$router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']);
$router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']);
$router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']);
// Reglage rapide des seuils (F13) : capacite/alerte/critique edites depuis la page
// Stock via une modale, sans passer par le formulaire complet. stock.manage (calibrage
// du stock), CSRF, SANS PIN (config, pas un comptage d'inventaire). {id} = un seul
// segment ; /thresholds ne chevauche ni /restock ni /inventory.
$router->add('POST', '/admin/ingredients/{id}/thresholds', [IngredientController::class, 'updateThresholds']);
$router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']);
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);

View file

@ -256,6 +256,41 @@ final class IngredientControllerTest extends TestCase
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">au-dessus du seuil/', $body);
}
public function testIndexExposesThresholdButtonWithCurrentValuesForStockManager(): void
{
// F13 : la page Stock porte un acces rapide "Regler les seuils" decore des
// valeurs courantes (data-attributes) qui pre-remplissent la modale, plus la
// modale rendue serveur (VRAI form POST). Visible pour stock.manage.
$db = $this->permittedDb();
$db->ingredientsRows = [$this->ingredient(['stock_capacity' => 100, 'low_stock_pct' => 12, 'critical_stock_pct' => 4])];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringContainsString('Regler les seuils', $body);
self::assertStringContainsString('data-threshold-open', $body);
self::assertStringContainsString('data-capacity="100"', $body);
self::assertStringContainsString('data-low="12"', $body);
self::assertStringContainsString('data-critical="4"', $body);
// Modale rendue serveur avec un VRAI form POST (pas de fetch) + champs cibles.
self::assertStringContainsString('data-threshold-modal', $body);
self::assertStringContainsString('name="stock_capacity"', $body);
self::assertStringContainsString('name="critical_stock_pct"', $body);
}
public function testIndexHidesThresholdAccessWithoutStockManage(): void
{
// Sans stock.manage (lecture seule), ni le bouton ni la modale ne sont rendus :
// l'acces rapide suit la permission de calibrage.
$db = $this->permittedDb();
$db->grantedCodes = ['stock.read'];
$db->ingredientsRows = [$this->ingredient()];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringNotContainsString('data-threshold-open', $body);
self::assertStringNotContainsString('data-threshold-modal', $body);
}
public function testIndexForbiddenWithoutStockRead(): void
{
$db = $this->permittedDb();
@ -427,6 +462,100 @@ final class IngredientControllerTest extends TestCase
self::assertStringContainsString('reference', $response->body());
}
// --- THRESHOLDS (F13, stock.manage, SANS PIN) : reglage rapide capacite + seuils ---
public function testUpdateThresholdsWritesOnlyTheThreeColumns(): void
{
$db = $this->permittedDb();
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '200', 'low_stock_pct' => '15', 'critical_stock_pct' => '5'];
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
self::assertSame(302, $response->status());
self::assertSame('/admin/ingredients', $response->header('Location'));
$params = $this->writeParams($db, 'UPDATE ingredient SET stock_capacity');
self::assertNotNull($params);
self::assertSame(200, $params['cap']);
self::assertSame(15, $params['low']);
self::assertSame(5, $params['crit']);
// Endpoint leger : ne touche ni le stock ni le catalogue (name/unit/pack/is_active).
$sql = $this->writeSql($db, 'UPDATE ingredient SET stock_capacity');
self::assertStringNotContainsString('stock_quantity', $sql);
self::assertStringNotContainsString('is_active', $sql);
self::assertStringNotContainsString('name', $sql);
self::assertStringNotContainsString('pack_size', $sql);
}
public function testUpdateThresholdsRejectsCapacityBelowOne(): void
{
$db = $this->permittedDb();
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '0', 'low_stock_pct' => '15', 'critical_stock_pct' => '5'];
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
self::assertStringContainsString('capacite', $response->body());
}
public function testUpdateThresholdsRejectsPercentageAboveHundred(): void
{
$db = $this->permittedDb();
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '120', 'critical_stock_pct' => '5'];
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
}
public function testUpdateThresholdsRejectsCriticalNotStrictlyBelowLow(): void
{
$db = $this->permittedDb();
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '10'];
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
self::assertStringContainsString('strictement inferieur', $response->body());
}
public function testUpdateThresholdsForbiddenWithoutStockManage(): void
{
$db = $this->permittedDb();
$db->grantedCodes = ['stock.read']; // lecture sans stock.manage
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5'];
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
self::assertSame(403, $response->status());
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
}
public function testUpdateThresholdsRejectsInvalidCsrf(): void
{
$db = $this->permittedDb();
$form = ['_csrf' => 'bad', 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5'];
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
self::assertSame(403, $response->status());
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
}
public function testUpdateThresholdsNotFound(): void
{
$db = $this->permittedDb();
$db->ingredientRow = null;
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5'];
$response = $this->controller($this->post($form, '/admin/ingredients/9/thresholds'), $db)->updateThresholds(['id' => '9']);
self::assertSame(404, $response->status());
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
}
// --- RESTOCK (9.1, stock.manage, SANS PIN) ---
public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void

View file

@ -0,0 +1,116 @@
/*
* Tests du reglage rapide des seuils de stock (F13) du back-office (node:test + jsdom).
*
* Couvre la logique testable du module : pre-remplissage de la modale depuis les
* data-attributes du bouton + pointage de l'action POST sur l'id, ouverture/fermeture,
* et le garde-fou client validate() (capacite >= 1, % 0-100, critique < alerte strict).
* La modale est rendue serveur (VRAI form POST) ; le module n'ajoute pas de fetch.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
// stock-thresholds.js est du CommonJS (admin = racine CommonJS) ; import par defaut.
import stockThresholds from '../../src/public/admin/assets/js/stock-thresholds.js';
function setup() {
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<button data-threshold-open data-id="7" data-name="Buns" ' +
' data-capacity="200" data-low="15" data-critical="5">Regler les seuils</button>' +
'<div class="pin-modal-overlay" data-threshold-modal>' +
' <form method="post" action="" data-threshold-form>' +
' <input type="hidden" name="_csrf" value="tok">' +
' <input type="number" id="th-capacity" name="stock_capacity">' +
' <input type="number" id="th-low" name="low_stock_pct">' +
' <input type="number" id="th-critical" name="critical_stock_pct">' +
' <p data-threshold-error hidden></p>' +
' <button type="button" data-threshold-cancel>Annuler</button>' +
' <button type="submit">Enregistrer les seuils</button>' +
' </form>' +
'</div>' +
'</body></html>',
);
return dom;
}
function fire(dom, el, type) {
el.dispatchEvent(new dom.window.Event(type, { cancelable: true, bubbles: true }));
}
test('validate accepte une configuration coherente et rejette les cas evidents', () => {
// Coherent.
assert.equal(stockThresholds.validate('200', '15', '5'), null);
// Capacite < 1.
assert.match(stockThresholds.validate('0', '15', '5'), /capacite/i);
// Pourcentage hors 0-100.
assert.match(stockThresholds.validate('100', '120', '5'), /alerte/i);
assert.match(stockThresholds.validate('100', '15', '200'), /critique/i);
// Critique non strictement inferieur a l'alerte.
assert.match(stockThresholds.validate('100', '10', '10'), /strictement inferieur/i);
// Saisies non entieres refusees (miroir de ctype_digit cote serveur).
assert.notEqual(stockThresholds.validate('', '15', '5'), null);
assert.notEqual(stockThresholds.validate('10.5', '15', '5'), null);
});
test('le clic sur un bouton pre-remplit la modale et pointe l action POST sur l id', () => {
const dom = setup();
const doc = dom.window.document;
stockThresholds.init(doc);
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
const overlay = doc.querySelector('[data-threshold-modal]');
assert.equal(overlay.classList.contains('open'), true);
assert.equal(doc.querySelector('[data-threshold-form]').getAttribute('action'), '/admin/ingredients/7/thresholds');
assert.equal(doc.getElementById('th-capacity').value, '200');
assert.equal(doc.getElementById('th-low').value, '15');
assert.equal(doc.getElementById('th-critical').value, '5');
});
test('soumettre une configuration incoherente bloque le POST et affiche l erreur', () => {
const dom = setup();
const doc = dom.window.document;
stockThresholds.init(doc);
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
// Critique >= alerte : le garde-fou client doit annuler la soumission.
doc.getElementById('th-critical').value = '15';
const form = doc.querySelector('[data-threshold-form]');
const evt = new dom.window.Event('submit', { cancelable: true, bubbles: true });
form.dispatchEvent(evt);
assert.equal(evt.defaultPrevented, true);
assert.equal(doc.querySelector('[data-threshold-error]').hidden, false);
});
test('soumettre une configuration coherente laisse le form POST partir', () => {
const dom = setup();
const doc = dom.window.document;
stockThresholds.init(doc);
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
const form = doc.querySelector('[data-threshold-form]');
const evt = new dom.window.Event('submit', { cancelable: true, bubbles: true });
form.dispatchEvent(evt);
// Valeurs pre-remplies coherentes (200/15/5) : pas de blocage client (POST reel).
assert.equal(evt.defaultPrevented, false);
});
test('Annuler ferme la modale', () => {
const dom = setup();
const doc = dom.window.document;
stockThresholds.init(doc);
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
assert.equal(doc.querySelector('[data-threshold-modal]').classList.contains('open'), true);
fire(dom, doc.querySelector('[data-threshold-cancel]'), 'click');
assert.equal(doc.querySelector('[data-threshold-modal]').classList.contains('open'), false);
});
test('init sans modale (role sans stock.manage) ne plante pas', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
assert.doesNotThrow(() => stockThresholds.init(dom.window.document));
});