feat(stock): reglage rapide capacite + seuils depuis la page Stock (modale + endpoint dedie)
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 43s
CI / static-tests (push) Successful in 1m46s
CI / js-tests (push) Successful in 48s
CI / secret-scan (pull_request) Successful in 29s
CI / php-lint (pull_request) Successful in 44s
CI / static-tests (pull_request) Successful in 1m22s
CI / js-tests (pull_request) Successful in 47s

This commit is contained in:
Imugiii 2026-06-25 12:27:48 +00:00
parent be4585aeb2
commit fce6dae428
9 changed files with 614 additions and 26 deletions

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) * Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement)
* des qu'une recette ou un mouvement reference l'ingredient ; le controleur * 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 $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(); $ingredients = $this->ingredientRepository()->all();
// Compteurs par bande pour le resume du tableau de bord (3 pastilles). // 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', [ return $this->adminView('admin/ingredients/index', [
'title' => 'Stock - Wakdo Admin', 'title' => 'Stock - Wakdo Admin',
'activeNav' => 'stock', 'activeNav' => 'stock',
'ingredients' => $ingredients, 'ingredients' => $ingredients,
'bandCounts' => $counts, 'bandCounts' => $counts,
'canManage' => $this->may($guard, 'ingredient.manage'), 'canManage' => $this->may($guard, 'ingredient.manage'),
'canRestock' => $this->may($guard, 'stock.manage'), 'canRestock' => $this->may($guard, 'stock.manage'),
'canCount' => $this->may($guard, 'stock.count'), 'canCount' => $this->may($guard, 'stock.count'),
], $guard); 'thresholdError' => $error,
], $guard, $status);
} }
/** /**
@ -303,6 +315,49 @@ class IngredientController extends AdminController
return $this->redirect('/admin/ingredients'); 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 * @param array<string, string> $params
*/ */
@ -562,12 +617,6 @@ class IngredientController extends AdminController
$errors['unit'] = 'L unite est requise (40 caracteres max).'; $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'] ?? ''); $packRaw = trim($form['pack_size'] ?? '');
$packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535; $packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535;
if (!$packValid) { if (!$packValid) {
@ -579,6 +628,45 @@ class IngredientController extends AdminController
$errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).'; $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'] ?? ''); $lowRaw = trim($form['low_stock_pct'] ?? '');
$lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100; $lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100;
if (!$lowValid) { 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.'; $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) { if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) {
$errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.'; $errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
} }
$data = [ $data = [
'name' => $name,
'unit' => $unit,
'stock_capacity' => $capValid ? (int) $capRaw : 0, 'stock_capacity' => $capValid ? (int) $capRaw : 0,
'pack_size' => $packValid ? (int) $packRaw : 0,
'pack_label' => $label !== '' ? $label : null,
'low_stock_pct' => $lowValid ? (int) $lowRaw : 0, 'low_stock_pct' => $lowValid ? (int) $lowRaw : 0,
'critical_stock_pct' => $critValid ? (int) $critRaw : 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<int, array<string, mixed>> $ingredients
* @var array<string, int> $bandCounts * @var array<string, int> $bandCounts
* @var bool $canManage * @var bool $canManage
* @var bool $canRestock * @var bool $canRestock
* @var bool $canCount * @var bool $canCount
* @var string $csrfToken * @var string|null $thresholdError
* @var string $csrfToken
*/ */
/** @var array<int, array<string, mixed>> $rows */ /** @var array<int, array<string, mixed>> $rows */
@ -31,6 +32,29 @@ $count = (bool) ($canCount ?? false);
$nCritical = (int) ($counts['critical'] ?? 0); $nCritical = (int) ($counts['critical'] ?? 0);
$nLow = (int) ($counts['low'] ?? 0); $nLow = (int) ($counts['low'] ?? 0);
$nNormal = (int) ($counts['normal'] ?? 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 // 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. // (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; ?> <?php endif; ?>
</div> </div>
<?php if ($thresholdErr !== null && $thresholdErr !== ''): ?>
<div class="flash flash-error" role="alert"><?= $esc($thresholdErr) ?></div>
<?php endif; ?>
<p class="stock-explainer"> <p class="stock-explainer">
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une 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 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> <span class="<?= $bandPill ?>"><?= $bandText ?></span>
</div> </div>
<?= $renderBar($row) ?> <?= $renderBar($row) ?>
<?php if ($restock): ?> <div class="stock-card__actions">
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a> <?php if ($restock): ?>
<?php endif; ?> <a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
<?php endif; ?>
<?= $renderThresholdButton($row) ?>
</div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@ -172,6 +203,7 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
<?php if ($count): ?> <?php if ($count): ?>
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a> <a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?> <?php endif; ?>
<?= $renderThresholdButton($row) ?>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a> <a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($manage): ?> <?php if ($manage): ?>
<span class="stock-list__crud"> <span class="stock-list__crud">
@ -189,3 +221,45 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
</ul> </ul>
<?php endif; ?> <?php endif; ?>
</section> </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> </div>
<script src="/assets/js/admin.js"></script> <script src="/assets/js/admin.js"></script>
<script src="/assets/js/pin-modal.js"></script> <script src="/assets/js/pin-modal.js"></script>
<script src="/assets/js/stock-thresholds.js"></script>
</body> </body>
</html> </html>

View file

@ -2103,3 +2103,36 @@ tbody td.mono {
justify-content: flex-start; 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('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']);
$router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']); $router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']);
$router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']); $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('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']);
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); $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); 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 public function testIndexForbiddenWithoutStockRead(): void
{ {
$db = $this->permittedDb(); $db = $this->permittedDb();
@ -427,6 +462,100 @@ final class IngredientControllerTest extends TestCase
self::assertStringContainsString('reference', $response->body()); 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) --- // --- RESTOCK (9.1, stock.manage, SANS PIN) ---
public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void 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));
});