feat(stock): enrichissement nutritionnel via API externe OpenFoodFacts (Cr 3.a.3)
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 19s
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 21s
CI / static-tests (push) Successful in 43s
CI / js-tests (push) Successful in 25s
CI / static-tests (pull_request) Successful in 48s
CI / js-tests (pull_request) Successful in 24s

This commit is contained in:
Imugiii 2026-06-22 07:03:03 +00:00
parent 66aaa77f87
commit 1bef859c21
10 changed files with 317 additions and 1 deletions

View file

@ -0,0 +1,25 @@
-- db/migrations/0005_ingredient_nutrition.sql
-- =============================================================================
-- Wakdo - Migration 0005 : enrichissement nutritionnel depuis une API EXTERNE
-- =============================================================================
-- Purpose : ajoute a `ingredient` des colonnes nullables pour stocker des donnees
-- nutritionnelles importees depuis une API TIERCE (OpenFoodFacts), a la
-- demande d'un manager/admin (action explicite, pas au runtime borne).
-- Demontre l'exploitation, DANS LE MODELE de donnees, d'informations
-- externes provenant d'une API (Cr 3.a.3). Egress maitrise et opt-in :
-- aucun appel automatique ; la passerelle (App\Catalogue\
-- OpenFoodFactsGateway) est invoquee seulement par IngredientController::enrich.
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE ingredient
ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label,
ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g,
ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source;
-- energy_kcal_100g : apport energetique pour 100 g (SMALLINT UNSIGNED suffit ; les
-- valeurs reelles restent < 1000). nutrition_source : provenance ("OpenFoodFacts").
-- nutrition_fetched_at : horodatage de l'import, pour tracer la fraicheur. Toutes
-- nullables : un ingredient non enrichi reste valide (donnee optionnelle).

View file

@ -310,7 +310,7 @@ Reseaux :
| Critere | Libelle court | Feature Wakdo couvrant | | Critere | Libelle court | Feature Wakdo couvrant |
|---|---|---| |---|---|---|
| Cr 3.a.1-4 | Analyse + modele donnees | Dictionnaire + MCD + cardinalites | | Cr 3.a.1-4 | Analyse + modele donnees | Dictionnaire + MCD + cardinalites |
| Cr 3.a.3 | Exploiter donnees externes d'API | API interne consommee par le front (auto-consommation) | | Cr 3.a.3 | Exploiter donnees externes d'API | Enrichissement nutritionnel depuis **OpenFoodFacts** (API tierce) importe DANS le modele (`ingredient.energy_kcal_100g`), a la demande admin (opt-in, sans egress runtime) ; + auto-consommation de l'API interne par la borne |
| Cr 3.b.1-3 | Construction BDD | MCD → MLD → DDL MariaDB, FK + typage coherent | | Cr 3.b.1-3 | Construction BDD | MCD → MLD → DDL MariaDB, FK + typage coherent |
| Cr 3.c.1-3 | Requetes SQL optimisees | PDO prepared, index sur FK, LIMIT/tri explicites | | Cr 3.c.1-3 | Requetes SQL optimisees | PDO prepared, index sur FK, LIMIT/tri explicites |
| Cr 3.d.1-4 | RGPD | hash mdp, droit acces/modif/suppr, info utilisation donnees | | Cr 3.d.1-4 | RGPD | hash mdp, droit acces/modif/suppr, info utilisation donnees |

View file

@ -44,6 +44,7 @@ final class IngredientRepository
{ {
$rows = $this->db->fetchAll( $rows = $this->db->fetchAll(
'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, '
. 'energy_kcal_100g, nutrition_source, nutrition_fetched_at, '
. 'low_stock_pct, critical_stock_pct, is_active FROM ingredient ORDER BY name', . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient ORDER BY name',
); );
@ -57,6 +58,7 @@ final class IngredientRepository
{ {
$row = $this->db->fetch( $row = $this->db->fetch(
'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, '
. 'energy_kcal_100g, nutrition_source, nutrition_fetched_at, '
. 'low_stock_pct, critical_stock_pct, is_active FROM ingredient WHERE id = :id', . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient WHERE id = :id',
['id' => $id], ['id' => $id],
); );
@ -148,6 +150,22 @@ final class IngredientRepository
* (product_ingredient) ou un mouvement de stock (stock_movement) ? Les deux * (product_ingredient) ou un mouvement de stock (stock_movement) ? Les deux
* FK sont RESTRICT, donc l'un ou l'autre bloque la suppression dure. * FK sont RESTRICT, donc l'un ou l'autre bloque la suppression dure.
*/ */
/**
* Enregistre les donnees nutritionnelles importees d'une source externe
* (Cr 3.a.3). Allowlist de colonnes (RG-T16) : seules energy / source /
* fetched_at sont ecrites ; l'horodatage est pose cote SQL (NOW()).
*
* @param array{energy_kcal_100g:int, source:string} $data
*/
public function setNutrition(int $id, array $data): int
{
return $this->db->execute(
'UPDATE ingredient SET energy_kcal_100g = :kcal, nutrition_source = :src, '
. 'nutrition_fetched_at = NOW() WHERE id = :id',
['kcal' => $data['energy_kcal_100g'], 'src' => $data['source'], 'id' => $id],
);
}
public function isReferenced(int $id): bool public function isReferenced(int $id): bool
{ {
if ($this->db->fetch('SELECT ingredient_id FROM product_ingredient WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null) { if ($this->db->fetch('SELECT ingredient_id FROM product_ingredient WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null) {

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Catalogue;
/**
* Passerelle vers une source nutritionnelle externe (Cr 3.a.3). Abstraction (seam)
* qui permet d'injecter un double en test sans appel reseau reel. L'implementation
* de production (OpenFoodFactsGateway) interroge une API tierce ; les tests
* utilisent un double deterministe.
*/
interface NutritionGateway
{
/**
* Recherche un aliment par son nom et renvoie son apport energetique.
*
* @return array{energy_kcal_100g:int, source:string}|null null si rien de
* trouve ou en cas d'erreur (reseau, format) : l'appelant le signale
* sans casser le flux.
*/
public function lookupByName(string $name): ?array;
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Catalogue;
/**
* Implementation de NutritionGateway sur l'API publique OpenFoodFacts (Cr 3.a.3 :
* exploiter dans le modele des informations externes provenant d'une API). Recherche
* un aliment par nom et renvoie son apport energetique (kcal / 100 g). Lecture seule,
* sans cle d'API.
*
* Egress maitrise : invoquee UNIQUEMENT sur action explicite d'un manager/admin
* (IngredientController::enrich), jamais au runtime de la borne. `allow_url_fopen`
* est desactive (php.ini durci) : on passe par cURL. Tolerante aux pannes : toute
* erreur (reseau, code HTTP, JSON, champ absent) renvoie null -> l'appelant affiche
* un message sans interrompre l'usage.
*/
final class OpenFoodFactsGateway implements NutritionGateway
{
private const ENDPOINT = 'https://world.openfoodfacts.org/cgi/search.pl';
public function __construct(private readonly int $timeoutSeconds = 5)
{
}
public function lookupByName(string $name): ?array
{
$name = trim($name);
if ($name === '' || !function_exists('curl_init')) {
return null;
}
$url = self::ENDPOINT . '?' . http_build_query([
'search_terms' => $name,
'search_simple' => 1,
'action' => 'process',
'json' => 1,
'page_size' => 1,
'fields' => 'product_name,nutriments',
]);
$handle = curl_init($url);
if ($handle === false) {
return null;
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_USERAGENT => 'Wakdo/1.0 (projet pedagogique RNCP)',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if (!is_string($body) || $status < 200 || $status >= 300) {
return null;
}
return $this->parse($body);
}
/**
* Extrait l'apport energetique du premier produit de la reponse OpenFoodFacts.
* Isole pour etre testable sans reseau.
*
* @return array{energy_kcal_100g:int, source:string}|null
*/
public function parse(string $body): ?array
{
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['products'][0]['nutriments'])
|| !is_array($data['products'][0]['nutriments'])) {
return null;
}
$kcal = $data['products'][0]['nutriments']['energy-kcal_100g'] ?? null;
if (!is_numeric($kcal)) {
return null;
}
$kcal = (int) round((float) $kcal);
if ($kcal < 0 || $kcal > 65535) {
return null;
}
return ['energy_kcal_100g' => $kcal, 'source' => 'OpenFoodFacts'];
}
}

View file

@ -11,6 +11,8 @@ use App\Auth\PasswordHasher;
use App\Auth\PinThrottle; use App\Auth\PinThrottle;
use App\Auth\PinVerifier; use App\Auth\PinVerifier;
use App\Catalogue\IngredientRepository; use App\Catalogue\IngredientRepository;
use App\Catalogue\NutritionGateway;
use App\Catalogue\OpenFoodFactsGateway;
use App\Core\DatabaseInterface; use App\Core\DatabaseInterface;
use App\Core\Response; use App\Core\Response;
@ -185,6 +187,52 @@ class IngredientController extends AdminController
return $this->redirect('/admin/ingredients'); return $this->redirect('/admin/ingredients');
} }
/**
* Enrichit un ingredient avec des donnees nutritionnelles importees d'une API
* EXTERNE (OpenFoodFacts, Cr 3.a.3). Action explicite (POST + CSRF), gardee par
* ingredient.manage, SANS PIN (hors ensemble sensible RG-T13). Tolerante : si la
* source ne renvoie rien, on le signale sans erreur (le flux reste utilisable).
*
* @param array<string, string> $params
*/
public function enrich(array $params): Response
{
$guard = $this->guard('ingredient.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 = $this->nutritionGateway()->lookupByName((string) ($ingredient['name'] ?? ''));
if ($data === null) {
$this->setFlash('Aucune donnee nutritionnelle trouvee pour cet ingredient (source externe).');
} else {
$this->ingredientRepository()->setNutrition($id, $data);
$this->setFlash('Donnees nutritionnelles importees depuis ' . $data['source'] . '.');
}
return $this->redirect('/admin/ingredients/' . $id . '/edit');
}
/**
* Passerelle nutritionnelle externe. Hook protege : les tests redefinissent ce
* seam pour injecter un double sans appel reseau.
*/
protected function nutritionGateway(): NutritionGateway
{
return new OpenFoodFactsGateway();
}
/** /**
* @param array<string, string> $params * @param array<string, string> $params
*/ */
@ -599,6 +647,11 @@ class IngredientController extends AdminController
'pack_label' => (string) ($values['pack_label'] ?? ''), 'pack_label' => (string) ($values['pack_label'] ?? ''),
'low_stock_pct' => (string) ($values['low_stock_pct'] ?? '10'), 'low_stock_pct' => (string) ($values['low_stock_pct'] ?? '10'),
'critical_stock_pct' => (string) ($values['critical_stock_pct'] ?? '5'), 'critical_stock_pct' => (string) ($values['critical_stock_pct'] ?? '5'),
// Nutrition (lecture seule) : transmise pour que le panneau d'enrichissement
// reflete la valeur importee (Cr 3.a.3). Absente sur create / re-rendu d'erreur.
'energy_kcal_100g' => (string) ($values['energy_kcal_100g'] ?? ''),
'nutrition_source' => (string) ($values['nutrition_source'] ?? ''),
'nutrition_fetched_at' => (string) ($values['nutrition_fetched_at'] ?? ''),
], ],
'errors' => $errors, 'errors' => $errors,
], $guard, $status); ], $guard, $status);

View file

@ -86,3 +86,21 @@ $err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k])
<a class="btn btn-secondary" href="/admin/ingredients">Annuler</a> <a class="btn btn-secondary" href="/admin/ingredients">Annuler</a>
</div> </div>
</form> </form>
<?php if ($id !== 0): ?>
<?php $kcal = $val('energy_kcal_100g'); ?>
<section class="card" aria-labelledby="nutrition-title">
<h2 id="nutrition-title">Valeur nutritionnelle</h2>
<?php if ($kcal !== ''): ?>
<p>Apport energetique : <strong><?= $kcal ?> kcal / 100 g</strong>
(source : <?= $val('nutrition_source') ?>, importe le <?= $val('nutrition_fetched_at') ?>)</p>
<?php else: ?>
<p>Aucune donnee nutritionnelle importee pour le moment.</p>
<?php endif; ?>
<!-- Import depuis une API externe (OpenFoodFacts), action explicite, POST + CSRF. -->
<form method="post" action="/admin/ingredients/<?= $id ?>/enrich">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-secondary" type="submit">Importer la valeur nutritionnelle (OpenFoodFacts)</button>
</form>
</section>
<?php endif; ?>

View file

@ -197,6 +197,9 @@ try {
$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']);
// Enrichissement nutritionnel depuis une API externe (OpenFoodFacts, Cr 3.a.3) :
// action explicite ingredient.manage, POST + CSRF, opt-in (pas d'egress automatique).
$router->add('POST', '/admin/ingredients/{id}/enrich', [IngredientController::class, 'enrich']);
// CORS (docs/api/conventions.md section 10) : preflight OPTIONS traite AVANT le // CORS (docs/api/conventions.md section 10) : preflight OPTIONS traite AVANT le
// routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse. // routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse.

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Catalogue\NutritionGateway;
/**
* Double de NutritionGateway : renvoie un resultat fixe et trace le nom recherche.
* Permet de tester IngredientController::enrich sans appel reseau reel.
*/
final class FakeNutritionGateway implements NutritionGateway
{
/** @var array{energy_kcal_100g:int, source:string}|null */
public ?array $result = null;
public ?string $lookedUp = null;
public function lookupByName(string $name): ?array
{
$this->lookedUp = $name;
return $this->result;
}
}

View file

@ -9,12 +9,14 @@ use PHPUnit\Framework\TestCase;
use App\Auth\Csrf; use App\Auth\Csrf;
use App\Auth\PasswordHasher; use App\Auth\PasswordHasher;
use App\Auth\SessionManager; use App\Auth\SessionManager;
use App\Catalogue\NutritionGateway;
use App\Controllers\IngredientController; use App\Controllers\IngredientController;
use App\Core\Config; use App\Core\Config;
use App\Core\Database; use App\Core\Database;
use App\Core\DatabaseInterface; use App\Core\DatabaseInterface;
use App\Core\Request; use App\Core\Request;
use App\Tests\Support\FakeDatabase; use App\Tests\Support\FakeDatabase;
use App\Tests\Support\FakeNutritionGateway;
/** /**
* Sous-classe de test : le seam db() injecte le double, sessionManager() la session. * Sous-classe de test : le seam db() injecte le double, sessionManager() la session.
@ -40,6 +42,13 @@ final class TestIngredientController extends IngredientController
{ {
return $this->fakeDb; return $this->fakeDb;
} }
public ?FakeNutritionGateway $fakeGateway = null;
protected function nutritionGateway(): NutritionGateway
{
return $this->fakeGateway ?? new FakeNutritionGateway();
}
} }
final class IngredientControllerTest extends TestCase final class IngredientControllerTest extends TestCase
@ -289,6 +298,56 @@ final class IngredientControllerTest extends TestCase
self::assertSame(0, $params['a']); self::assertSame(0, $params['a']);
} }
public function testEnrichStoresNutritionFromExternalApi(): void
{
$db = $this->permittedDb();
$c = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/enrich'), $db);
$c->fakeGateway = new FakeNutritionGateway();
$c->fakeGateway->result = ['energy_kcal_100g' => 402, 'source' => 'OpenFoodFacts'];
$response = $c->enrich(['id' => '5']);
self::assertSame(302, $response->status());
self::assertSame('/admin/ingredients/5/edit', $response->header('Location'));
// La source externe est interrogee avec le NOM de l'ingredient, et la donnee
// retournee est ecrite DANS LE MODELE (Cr 3.a.3).
self::assertSame('Cheddar', $c->fakeGateway->lookedUp);
$params = $this->writeParams($db, 'UPDATE ingredient SET energy_kcal_100g');
self::assertNotNull($params);
self::assertSame(402, $params['kcal']);
self::assertSame('OpenFoodFacts', $params['src']);
}
public function testEnrichWithoutResultDoesNotWrite(): void
{
$db = $this->permittedDb();
$c = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/enrich'), $db);
$c->fakeGateway = new FakeNutritionGateway(); // result reste null
$response = $c->enrich(['id' => '5']);
self::assertSame(302, $response->status());
// Aucune ecriture nutritionnelle si la source externe ne renvoie rien.
self::assertNull($this->writeParams($db, 'UPDATE ingredient SET energy_kcal_100g'));
}
public function testEditShowsImportedNutrition(): void
{
// Regression : renderForm doit transmettre la nutrition pour que le panneau
// d'enrichissement reflete la valeur importee (et pas "aucune donnee").
$db = $this->permittedDb();
$db->ingredientRow = $this->ingredient([
'energy_kcal_100g' => 402,
'nutrition_source' => 'OpenFoodFacts',
'nutrition_fetched_at' => '2026-06-22 10:00:00',
]);
$body = $this->controller($this->get('/admin/ingredients/5/edit'), $db)->edit(['id' => '5'])->body();
self::assertStringContainsString('402 kcal', $body);
self::assertStringContainsString('OpenFoodFacts', $body);
}
public function testDestroyUnreferencedDeletesWithoutPin(): void public function testDestroyUnreferencedDeletesWithoutPin(): void
{ {
$db = $this->permittedDb(); $db = $this->permittedDb();