From 62e347969c171a58c840dec723cec3cf4ad4cf48 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 14:55:13 +0300 Subject: [PATCH] feat(stock): enhance stock movement models with lot tracking and min/max stock --- api.py | 154 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 107 insertions(+), 47 deletions(-) diff --git a/api.py b/api.py index 8bfd78b..9cfe688 100644 --- a/api.py +++ b/api.py @@ -914,23 +914,76 @@ class FamilleResponse(BaseModel): } } + class MouvementStockLigneRequest(BaseModel): - """Ligne de mouvement de stock""" article_ref: str = Field(..., description="Référence de l'article") quantite: float = Field(..., gt=0, description="Quantité (>0)") depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") - prix_unitaire: Optional[float] = Field(None, ge=0, description="Prix unitaire (optionnel)") + prix_unitaire: Optional[float] = Field( + None, ge=0, description="Prix unitaire (optionnel)" + ) commentaire: Optional[str] = Field(None, description="Commentaire ligne") + numero_lot: Optional[str] = Field( + None, description="Numéro de lot (pour FIFO/LIFO)" + ) + stock_mini: Optional[float] = Field( + None, + ge=0, + description="""Stock minimum à définir pour cet article. + Si fourni, met à jour AS_QteMini dans F_ARTSTOCK. + Laisser None pour ne pas modifier.""", + ) + stock_maxi: Optional[float] = Field( + None, + ge=0, + description="""Stock maximum à définir pour cet article. + Doit être > stock_mini si les deux sont fournis.""", + ) + + class Config: + schema_extra = { + "example": { + "article_ref": "ARTS-001", + "quantite": 50.0, + "depot_code": "01", + "prix_unitaire": 100.0, + "commentaire": "Réapprovisionnement", + "numero_lot": "LOT20241217", + "stock_mini": 10.0, + "stock_maxi": 200.0, + } + } + + @validator("stock_maxi") + def validate_stock_maxi(cls, v, values): + """Valide que stock_maxi > stock_mini si les deux sont fournis""" + if ( + v is not None + and "stock_mini" in values + and values["stock_mini"] is not None + ): + if v <= values["stock_mini"]: + raise ValueError( + "stock_maxi doit être strictement supérieur à stock_mini" + ) + return v class EntreeStockRequest(BaseModel): """Création d'un bon d'entrée en stock""" - date_entree: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + + date_entree: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) reference: Optional[str] = Field(None, description="Référence externe") - depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") - lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) commentaire: Optional[str] = Field(None, description="Commentaire général") - + class Config: json_schema_extra = { "example": { @@ -943,22 +996,29 @@ class EntreeStockRequest(BaseModel): "quantite": 50, "depot_code": "01", "prix_unitaire": 10.50, - "commentaire": "Réception fournisseur" + "commentaire": "Réception fournisseur", } ], - "commentaire": "Réception livraison fournisseur XYZ" + "commentaire": "Réception livraison fournisseur XYZ", } } class SortieStockRequest(BaseModel): """Création d'un bon de sortie de stock""" - date_sortie: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + + date_sortie: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) reference: Optional[str] = Field(None, description="Référence externe") - depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") - lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) commentaire: Optional[str] = Field(None, description="Commentaire général") - + class Config: json_schema_extra = { "example": { @@ -970,24 +1030,25 @@ class SortieStockRequest(BaseModel): "article_ref": "ART001", "quantite": 10, "depot_code": "01", - "commentaire": "Utilisation interne" + "commentaire": "Utilisation interne", } ], - "commentaire": "Consommation atelier" + "commentaire": "Consommation atelier", } } class MouvementStockResponse(BaseModel): """Réponse pour un mouvement de stock""" + numero: str = Field(..., description="Numéro du mouvement") type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") type_libelle: str = Field(..., description="Libellé du type") date: str = Field(..., description="Date du mouvement") reference: Optional[str] = Field(None, description="Référence externe") nb_lignes: int = Field(..., description="Nombre de lignes") - - + + async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str ) -> Dict: @@ -1102,6 +1163,7 @@ async def universign_statut(transaction_id: str) -> Dict: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} + @asynccontextmanager async def lifespan(app: FastAPI): # Init base de données @@ -1805,6 +1867,7 @@ async def changer_statut_devis( logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) + @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): try: @@ -2253,6 +2316,7 @@ async def envoyer_emails_lot( "details": resultats, } + @app.post( "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] ) @@ -2638,6 +2702,7 @@ async def relancer_facture( logger.error(f"Erreur relance facture: {e}") raise HTTPException(500, str(e)) + @app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), @@ -2733,6 +2798,7 @@ async def exporter_logs_csv( }, ) + class TemplateEmail(BaseModel): id: Optional[str] = None nom: str @@ -3616,7 +3682,7 @@ async def creer_famille(famille: FamilleCreateRequest): response_model=MouvementStockResponse, status_code=status.HTTP_201_CREATED, tags=["Stock"], - summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock" + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock", ) async def creer_entree_stock(entree: EntreeStockRequest): try: @@ -3624,28 +3690,25 @@ async def creer_entree_stock(entree: EntreeStockRequest): entree_data = entree.dict() if entree_data.get("date_entree"): entree_data["date_entree"] = entree_data["date_entree"].isoformat() - + logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") - + # Appel à la gateway Windows resultat = sage_client.creer_entree_stock(entree_data) - + logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") - + return MouvementStockResponse(**resultat) - + except ValueError as e: logger.warning(f"⚠️ Erreur métier entrée stock: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de l'entrée: {str(e)}" + detail=f"Erreur lors de la création de l'entrée: {str(e)}", ) @@ -3654,7 +3717,7 @@ async def creer_entree_stock(entree: EntreeStockRequest): response_model=MouvementStockResponse, status_code=status.HTTP_201_CREATED, tags=["Stock"], - summary="SORTIE DE STOCK : Retire des articles du stock" + summary="SORTIE DE STOCK : Retire des articles du stock", ) async def creer_sortie_stock(sortie: SortieStockRequest): try: @@ -3662,28 +3725,25 @@ async def creer_sortie_stock(sortie: SortieStockRequest): sortie_data = sortie.dict() if sortie_data.get("date_sortie"): sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() - + logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") - + # Appel à la gateway Windows resultat = sage_client.creer_sortie_stock(sortie_data) - + logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") - + return MouvementStockResponse(**resultat) - + except ValueError as e: logger.warning(f"⚠️ Erreur métier sortie stock: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de la sortie: {str(e)}" + detail=f"Erreur lors de la création de la sortie: {str(e)}", ) @@ -3691,32 +3751,32 @@ async def creer_sortie_stock(sortie: SortieStockRequest): "/stock/mouvement/{numero}", response_model=MouvementStockResponse, tags=["Stock"], - summary="Lecture d'un mouvement de stock" + summary="Lecture d'un mouvement de stock", ) async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)") ): try: mouvement = sage_client.lire_mouvement_stock(numero) - + if not mouvement: logger.warning(f"⚠️ Mouvement {numero} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Mouvement de stock {numero} introuvable" + detail=f"Mouvement de stock {numero} introuvable", ) - + logger.info(f"✅ Mouvement {numero} lu") - + return MouvementStockResponse(**mouvement) - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la lecture du mouvement: {str(e)}" + detail=f"Erreur lors de la lecture du mouvement: {str(e)}", )