PR-B du lot P3 stock. Editeur de recette (composition product_ingredient) et
disponibilite produit calculee, sur la couche stock de PR-A.
- Composition (ProductRepository) : composition() (JOIN ingredient), setComposition()
en delete-and-reinsert dans UNE transaction (RG-2/RG-T08), ingredientExists()
+ dedup par PK composite, compositionCount(). FK product_id CASCADE,
ingredient_id RESTRICT.
- Editeur (ProductController) : recipeForm/saveRecipe gardes par ingredient.manage
(composition, DISTINCTE du CRUD produit), sans PIN (hors RG-T13). Revalidation
serveur RG-T18 + allowlist RG-T16 (ingredient inconnu filtre, bornes des CHECK).
Vue recipe.php + product-recipe.js (builder vanilla CSP-safe, data-*).
- Disponibilite calculee RG-T21 : isOrderable() = is_available ET chaque ingredient
NON RETIRABLE au-dessus de la bande critique (reutilise IngredientRepository::
stockBand). Un ingredient retirable en critique ne bloque pas ; un retrait manuel
prime. Badge "Rupture auto" dans la liste (autoUnavailableIds, distinct du retrait
manuel).
- Dette #27 close : la suppression dure d'un produit cascade product_ingredient ;
le nombre de lignes emportees est compte et trace dans le resume d'audit.
Tests : 259 / 777 assertions verts (WAKDO_DB_TESTS=1), PHPStan L6 propre.