# Intrants & Fabrications — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Ajouter la gestion des achats d'intrants (terreau, engrais, traitements) et des fabrications maison (compost, décoctions, purins) dans une vue unique avec deux onglets, liée aux jardins/plantations/tâches. **Architecture:** Deux tables SQLite (`achat_intrant` + `fabrication`), deux routers FastAPI, une vue Vue 3 `IntratsView.vue` avec onglets. Les ingrédients de fabrication sont stockés en JSON. Liaisons optionnelles vers garden/planting/task. **Tech Stack:** FastAPI + SQLModel + SQLite · Vue 3 + Pinia + Tailwind CSS Gruvbox Dark --- ### Task 1 : Modèles SQLModel backend **Files:** - Create: `backend/app/models/intrant.py` - Modify: `backend/app/models/__init__.py` **Step 1: Créer le fichier modèle** ```python # backend/app/models/intrant.py from datetime import datetime, timezone from typing import List, Optional from sqlalchemy import Column from sqlalchemy import JSON as SA_JSON from sqlmodel import Field, SQLModel class AchatIntrant(SQLModel, table=True): __tablename__ = "achat_intrant" id: Optional[int] = Field(default=None, primary_key=True) categorie: str # terreau | engrais | traitement | autre nom: str marque: Optional[str] = None boutique_nom: Optional[str] = None boutique_url: Optional[str] = None prix: Optional[float] = None poids: Optional[str] = None # "20L", "1kg", "500ml" date_achat: Optional[str] = None # ISO date dluo: Optional[str] = None # ISO date notes: Optional[str] = None jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id") plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id") tache_id: Optional[int] = Field(default=None, foreign_key="task.id") created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class Ingredient(SQLModel): """Modèle pour un ingrédient de fabrication (non persisté seul).""" nom: str quantite: str # "1kg", "10L" class Fabrication(SQLModel, table=True): __tablename__ = "fabrication" id: Optional[int] = Field(default=None, primary_key=True) type: str # compost | decoction | purin | autre nom: str ingredients: Optional[List[dict]] = Field( default=None, sa_column=Column("ingredients", SA_JSON, nullable=True), ) date_debut: Optional[str] = None # ISO date date_fin_prevue: Optional[str] = None # ISO date statut: str = "en_cours" # en_cours | pret | utilise | echec quantite_produite: Optional[str] = None # "8L", "50kg" notes: Optional[str] = None jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id") plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id") tache_id: Optional[int] = Field(default=None, foreign_key="task.id") created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class FabricationStatutUpdate(SQLModel): statut: str ``` **Step 2: Enregistrer dans `__init__.py`** Ajouter à la fin de `backend/app/models/__init__.py` : ```python from app.models.intrant import AchatIntrant, Fabrication # noqa ``` **Step 3: Vérifier que SQLModel crée bien les tables** ```bash cd backend python3 -c "from app.models.intrant import AchatIntrant, Fabrication; print('OK')" ``` Expected: `OK` **Step 4: Commit** ```bash git add backend/app/models/intrant.py backend/app/models/__init__.py git commit -m "feat(intrants): add AchatIntrant + Fabrication SQLModel" ``` --- ### Task 2 : Migration BDD **Files:** - Modify: `backend/app/migrate.py` **Step 1: Ajouter les sections dans `EXPECTED_COLUMNS`** Dans `backend/app/migrate.py`, après la section `"astuce"`, ajouter : ```python "achat_intrant": [ ("categorie", "TEXT", None), ("nom", "TEXT", None), ("marque", "TEXT", None), ("boutique_nom", "TEXT", None), ("boutique_url", "TEXT", None), ("prix", "REAL", None), ("poids", "TEXT", None), ("date_achat", "TEXT", None), ("dluo", "TEXT", None), ("notes", "TEXT", None), ("jardin_id", "INTEGER", None), ("plantation_id", "INTEGER", None), ("tache_id", "INTEGER", None), ], "fabrication": [ ("type", "TEXT", None), ("nom", "TEXT", None), ("ingredients", "TEXT", None), ("date_debut", "TEXT", None), ("date_fin_prevue", "TEXT", None), ("statut", "TEXT", "'en_cours'"), ("quantite_produite", "TEXT", None), ("notes", "TEXT", None), ("jardin_id", "INTEGER", None), ("plantation_id", "INTEGER", None), ("tache_id", "INTEGER", None), ], ``` **Step 2: Appliquer la migration manuellement** ```bash python3 - <<'EOF' import sqlite3 conn = sqlite3.connect('data/jardin.db') # Créer achat_intrant si absente conn.execute(""" CREATE TABLE IF NOT EXISTS achat_intrant ( id INTEGER PRIMARY KEY AUTOINCREMENT, categorie TEXT NOT NULL, nom TEXT NOT NULL, marque TEXT, boutique_nom TEXT, boutique_url TEXT, prix REAL, poids TEXT, date_achat TEXT, dluo TEXT, notes TEXT, jardin_id INTEGER REFERENCES garden(id), plantation_id INTEGER REFERENCES planting(id), tache_id INTEGER REFERENCES task(id), created_at TEXT DEFAULT (datetime('now')) ) """) # Créer fabrication si absente conn.execute(""" CREATE TABLE IF NOT EXISTS fabrication ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, nom TEXT NOT NULL, ingredients TEXT, date_debut TEXT, date_fin_prevue TEXT, statut TEXT DEFAULT 'en_cours', quantite_produite TEXT, notes TEXT, jardin_id INTEGER REFERENCES garden(id), plantation_id INTEGER REFERENCES planting(id), tache_id INTEGER REFERENCES task(id), created_at TEXT DEFAULT (datetime('now')) ) """) conn.commit() conn.close() print("Tables créées") EOF ``` Expected: `Tables créées` **Step 3: Commit** ```bash git add backend/app/migrate.py git commit -m "feat(intrants): add migration for achat_intrant + fabrication tables" ``` --- ### Task 3 : Router achats (CRUD) **Files:** - Create: `backend/app/routers/achats.py` **Step 1: Créer le router** ```python # backend/app/routers/achats.py from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import Session, select from app.database import get_session from app.models.intrant import AchatIntrant router = APIRouter(tags=["intrants"]) @router.get("/achats", response_model=List[AchatIntrant]) def list_achats( categorie: Optional[str] = Query(None), jardin_id: Optional[int] = Query(None), session: Session = Depends(get_session), ): q = select(AchatIntrant) if categorie: q = q.where(AchatIntrant.categorie == categorie) if jardin_id: q = q.where(AchatIntrant.jardin_id == jardin_id) return session.exec(q.order_by(AchatIntrant.created_at.desc())).all() @router.post("/achats", response_model=AchatIntrant, status_code=status.HTTP_201_CREATED) def create_achat(a: AchatIntrant, session: Session = Depends(get_session)): session.add(a) session.commit() session.refresh(a) return a @router.get("/achats/{id}", response_model=AchatIntrant) def get_achat(id: int, session: Session = Depends(get_session)): a = session.get(AchatIntrant, id) if not a: raise HTTPException(404, "Achat introuvable") return a @router.put("/achats/{id}", response_model=AchatIntrant) def update_achat(id: int, data: AchatIntrant, session: Session = Depends(get_session)): a = session.get(AchatIntrant, id) if not a: raise HTTPException(404, "Achat introuvable") for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items(): setattr(a, k, v) session.add(a) session.commit() session.refresh(a) return a @router.delete("/achats/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_achat(id: int, session: Session = Depends(get_session)): a = session.get(AchatIntrant, id) if not a: raise HTTPException(404, "Achat introuvable") session.delete(a) session.commit() ``` **Step 2: Commit** ```bash git add backend/app/routers/achats.py git commit -m "feat(intrants): CRUD router for achats" ``` --- ### Task 4 : Router fabrications (CRUD + PATCH statut) **Files:** - Create: `backend/app/routers/fabrications.py` **Step 1: Créer le router** ```python # backend/app/routers/fabrications.py from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import Session, select from app.database import get_session from app.models.intrant import Fabrication, FabricationStatutUpdate router = APIRouter(tags=["intrants"]) @router.get("/fabrications", response_model=List[Fabrication]) def list_fabrications( type: Optional[str] = Query(None), statut: Optional[str] = Query(None), jardin_id: Optional[int] = Query(None), session: Session = Depends(get_session), ): q = select(Fabrication) if type: q = q.where(Fabrication.type == type) if statut: q = q.where(Fabrication.statut == statut) if jardin_id: q = q.where(Fabrication.jardin_id == jardin_id) return session.exec(q.order_by(Fabrication.created_at.desc())).all() @router.post("/fabrications", response_model=Fabrication, status_code=status.HTTP_201_CREATED) def create_fabrication(f: Fabrication, session: Session = Depends(get_session)): session.add(f) session.commit() session.refresh(f) return f @router.get("/fabrications/{id}", response_model=Fabrication) def get_fabrication(id: int, session: Session = Depends(get_session)): f = session.get(Fabrication, id) if not f: raise HTTPException(404, "Fabrication introuvable") return f @router.put("/fabrications/{id}", response_model=Fabrication) def update_fabrication(id: int, data: Fabrication, session: Session = Depends(get_session)): f = session.get(Fabrication, id) if not f: raise HTTPException(404, "Fabrication introuvable") for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items(): setattr(f, k, v) session.add(f) session.commit() session.refresh(f) return f @router.patch("/fabrications/{id}/statut", response_model=Fabrication) def update_statut(id: int, data: FabricationStatutUpdate, session: Session = Depends(get_session)): f = session.get(Fabrication, id) if not f: raise HTTPException(404, "Fabrication introuvable") valid = {"en_cours", "pret", "utilise", "echec"} if data.statut not in valid: raise HTTPException(400, f"Statut invalide. Valeurs: {valid}") f.statut = data.statut session.add(f) session.commit() session.refresh(f) return f @router.delete("/fabrications/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_fabrication(id: int, session: Session = Depends(get_session)): f = session.get(Fabrication, id) if not f: raise HTTPException(404, "Fabrication introuvable") session.delete(f) session.commit() ``` **Step 2: Commit** ```bash git add backend/app/routers/fabrications.py git commit -m "feat(intrants): CRUD + statut router for fabrications" ``` --- ### Task 5 : Enregistrer les routers dans main.py **Files:** - Modify: `backend/app/main.py` **Step 1: Ajouter l'import** Dans le bloc `from app.routers import (`, ajouter après `identify,` : ```python achats, fabrications, ``` **Step 2: Ajouter les includes** Après `app.include_router(identify.router, prefix="/api")`, ajouter : ```python app.include_router(achats.router, prefix="/api") app.include_router(fabrications.router, prefix="/api") ``` **Step 3: Tester l'API** ```bash cd backend && uvicorn app.main:app --reload --port 8060 # Dans un autre terminal: curl http://localhost:8060/api/achats # Expected: [] curl http://localhost:8060/api/fabrications # Expected: [] ``` **Step 4: Commit** ```bash git add backend/app/main.py git commit -m "feat(intrants): register achats + fabrications routers" ``` --- ### Task 6 : API frontend (achats.ts + fabrications.ts) **Files:** - Create: `frontend/src/api/achats.ts` - Create: `frontend/src/api/fabrications.ts` **Step 1: Créer `achats.ts`** ```typescript // frontend/src/api/achats.ts import client from './client' export interface AchatIntrant { id?: number categorie: string // terreau | engrais | traitement | autre nom: string marque?: string boutique_nom?: string boutique_url?: string prix?: number poids?: string date_achat?: string dluo?: string notes?: string jardin_id?: number plantation_id?: number tache_id?: number } export const achatsApi = { list: (params?: { categorie?: string; jardin_id?: number }) => client.get('/api/achats', { params }).then(r => r.data), get: (id: number) => client.get(`/api/achats/${id}`).then(r => r.data), create: (a: Partial) => client.post('/api/achats', a).then(r => r.data), update: (id: number, a: Partial) => client.put(`/api/achats/${id}`, a).then(r => r.data), delete: (id: number) => client.delete(`/api/achats/${id}`), } ``` **Step 2: Créer `fabrications.ts`** ```typescript // frontend/src/api/fabrications.ts import client from './client' export interface Ingredient { nom: string quantite: string } export interface Fabrication { id?: number type: string // compost | decoction | purin | autre nom: string ingredients?: Ingredient[] date_debut?: string date_fin_prevue?: string statut?: string // en_cours | pret | utilise | echec quantite_produite?: string notes?: string jardin_id?: number plantation_id?: number tache_id?: number } export const fabricationsApi = { list: (params?: { type?: string; statut?: string; jardin_id?: number }) => client.get('/api/fabrications', { params }).then(r => r.data), get: (id: number) => client.get(`/api/fabrications/${id}`).then(r => r.data), create: (f: Partial) => client.post('/api/fabrications', f).then(r => r.data), update: (id: number, f: Partial) => client.put(`/api/fabrications/${id}`, f).then(r => r.data), updateStatut: (id: number, statut: string) => client.patch(`/api/fabrications/${id}/statut`, { statut }).then(r => r.data), delete: (id: number) => client.delete(`/api/fabrications/${id}`), } ``` **Step 3: Commit** ```bash git add frontend/src/api/achats.ts frontend/src/api/fabrications.ts git commit -m "feat(intrants): frontend API clients for achats + fabrications" ``` --- ### Task 7 : Stores Pinia **Files:** - Create: `frontend/src/stores/achats.ts` - Create: `frontend/src/stores/fabrications.ts` **Step 1: Créer `stores/achats.ts`** ```typescript // frontend/src/stores/achats.ts import { defineStore } from 'pinia' import { ref } from 'vue' import { achatsApi, type AchatIntrant } from '@/api/achats' export const useAchatsStore = defineStore('achats', () => { const achats = ref([]) const loading = ref(false) async function fetchAll(params?: { categorie?: string }) { loading.value = true try { achats.value = await achatsApi.list(params) } finally { loading.value = false } } async function create(a: Partial) { const created = await achatsApi.create(a) achats.value.unshift(created) return created } async function remove(id: number) { await achatsApi.delete(id) achats.value = achats.value.filter(a => a.id !== id) } return { achats, loading, fetchAll, create, remove } }) ``` **Step 2: Créer `stores/fabrications.ts`** ```typescript // frontend/src/stores/fabrications.ts import { defineStore } from 'pinia' import { ref } from 'vue' import { fabricationsApi, type Fabrication } from '@/api/fabrications' export const useFabricationsStore = defineStore('fabrications', () => { const fabrications = ref([]) const loading = ref(false) async function fetchAll(params?: { type?: string; statut?: string }) { loading.value = true try { fabrications.value = await fabricationsApi.list(params) } finally { loading.value = false } } async function create(f: Partial) { const created = await fabricationsApi.create(f) fabrications.value.unshift(created) return created } async function updateStatut(id: number, statut: string) { const updated = await fabricationsApi.updateStatut(id, statut) const idx = fabrications.value.findIndex(f => f.id === id) if (idx !== -1) fabrications.value[idx] = updated return updated } async function remove(id: number) { await fabricationsApi.delete(id) fabrications.value = fabrications.value.filter(f => f.id !== id) } return { fabrications, loading, fetchAll, create, updateStatut, remove } }) ``` **Step 3: Commit** ```bash git add frontend/src/stores/achats.ts frontend/src/stores/fabrications.ts git commit -m "feat(intrants): Pinia stores for achats + fabrications" ``` --- ### Task 8 : Route + Navigation **Files:** - Modify: `frontend/src/router/index.ts` - Modify: `frontend/src/App.vue` - Modify: `frontend/src/components/AppDrawer.vue` **Step 1: Ajouter la route dans `router/index.ts`** Après `{ path: '/outils', ... }` : ```typescript { path: '/intrants', component: () => import('@/views/IntratsView.vue') }, ``` **Step 2: Ajouter dans `App.vue` (liste `links`)** Après `{ to: '/outils', label: 'Outils', icon: '🔧' }` : ```typescript { to: '/intrants', label: 'Intrants', icon: '🧪' }, ``` **Step 3: Ajouter dans `AppDrawer.vue` (liste `links`)** Après `{ to: '/outils', label: 'Outils' }` : ```typescript { to: '/intrants', label: '🧪 Intrants' }, ``` **Step 4: Commit** ```bash git add frontend/src/router/index.ts frontend/src/App.vue frontend/src/components/AppDrawer.vue git commit -m "feat(intrants): add /intrants route + sidebar nav" ``` --- ### Task 9 : IntratsView.vue — structure + onglet Achats **Files:** - Create: `frontend/src/views/IntratsView.vue` **Step 1: Créer la vue complète** ```vue