feat(stock): enrichissement nutritionnel via API externe OpenFoodFacts (Cr 3.a.3) #79
10 changed files with 317 additions and 1 deletions
25
db/migrations/0005_ingredient_nutrition.sql
Normal file
25
db/migrations/0005_ingredient_nutrition.sql
Normal 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).
|
||||
|
|
@ -311,7 +311,7 @@ Reseaux :
|
|||
| Critere | Libelle court | Feature Wakdo couvrant |
|
||||
|---|---|---|
|
||||
| Cr 3.a.2-4 | Analyse + modele donnees (3 criteres ; le referentiel ne contient PAS de Cr 3.a.1) | 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.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 |
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ final class IngredientRepository
|
|||
{
|
||||
$rows = $this->db->fetchAll(
|
||||
'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',
|
||||
);
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ final class IngredientRepository
|
|||
{
|
||||
$row = $this->db->fetch(
|
||||
'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',
|
||||
['id' => $id],
|
||||
);
|
||||
|
|
@ -148,6 +150,22 @@ final class IngredientRepository
|
|||
* (product_ingredient) ou un mouvement de stock (stock_movement) ? Les deux
|
||||
* 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
|
||||
{
|
||||
if ($this->db->fetch('SELECT ingredient_id FROM product_ingredient WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null) {
|
||||
|
|
|
|||
23
src/app/Catalogue/NutritionGateway.php
Normal file
23
src/app/Catalogue/NutritionGateway.php
Normal 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;
|
||||
}
|
||||
91
src/app/Catalogue/OpenFoodFactsGateway.php
Normal file
91
src/app/Catalogue/OpenFoodFactsGateway.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ use App\Auth\PasswordHasher;
|
|||
use App\Auth\PinThrottle;
|
||||
use App\Auth\PinVerifier;
|
||||
use App\Catalogue\IngredientRepository;
|
||||
use App\Catalogue\NutritionGateway;
|
||||
use App\Catalogue\OpenFoodFactsGateway;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
|
||||
|
|
@ -185,6 +187,52 @@ class IngredientController extends AdminController
|
|||
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
|
||||
*/
|
||||
|
|
@ -599,6 +647,11 @@ class IngredientController extends AdminController
|
|||
'pack_label' => (string) ($values['pack_label'] ?? ''),
|
||||
'low_stock_pct' => (string) ($values['low_stock_pct'] ?? '10'),
|
||||
'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,
|
||||
], $guard, $status);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
</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; ?>
|
||||
|
|
|
|||
|
|
@ -199,6 +199,9 @@ try {
|
|||
$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']);
|
||||
// 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
|
||||
// routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse.
|
||||
|
|
|
|||
26
tests/Support/FakeNutritionGateway.php
Normal file
26
tests/Support/FakeNutritionGateway.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,14 @@ use PHPUnit\Framework\TestCase;
|
|||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Catalogue\NutritionGateway;
|
||||
use App\Controllers\IngredientController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
use App\Tests\Support\FakeNutritionGateway;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
public ?FakeNutritionGateway $fakeGateway = null;
|
||||
|
||||
protected function nutritionGateway(): NutritionGateway
|
||||
{
|
||||
return $this->fakeGateway ?? new FakeNutritionGateway();
|
||||
}
|
||||
}
|
||||
|
||||
final class IngredientControllerTest extends TestCase
|
||||
|
|
@ -289,6 +298,56 @@ final class IngredientControllerTest extends TestCase
|
|||
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
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue