feat(stock): enhance stock movement models with lot tracking and min/max stock

This commit is contained in:
Fanilo-Nantenaina 2025-12-17 14:55:13 +03:00
parent 421f4d24dc
commit 62e347969c

154
api.py
View file

@ -914,23 +914,76 @@ class FamilleResponse(BaseModel):
} }
} }
class MouvementStockLigneRequest(BaseModel): class MouvementStockLigneRequest(BaseModel):
"""Ligne de mouvement de stock"""
article_ref: str = Field(..., description="Référence de l'article") article_ref: str = Field(..., description="Référence de l'article")
quantite: float = Field(..., gt=0, description="Quantité (>0)") quantite: float = Field(..., gt=0, description="Quantité (>0)")
depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") 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") 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): class EntreeStockRequest(BaseModel):
"""Création d'un bon d'entrée en stock""" """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") reference: Optional[str] = Field(None, description="Référence externe")
depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") depot_code: Optional[str] = Field(
lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") 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") commentaire: Optional[str] = Field(None, description="Commentaire général")
class Config: class Config:
json_schema_extra = { json_schema_extra = {
"example": { "example": {
@ -943,22 +996,29 @@ class EntreeStockRequest(BaseModel):
"quantite": 50, "quantite": 50,
"depot_code": "01", "depot_code": "01",
"prix_unitaire": 10.50, "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): class SortieStockRequest(BaseModel):
"""Création d'un bon de sortie de stock""" """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") reference: Optional[str] = Field(None, description="Référence externe")
depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") depot_code: Optional[str] = Field(
lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") 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") commentaire: Optional[str] = Field(None, description="Commentaire général")
class Config: class Config:
json_schema_extra = { json_schema_extra = {
"example": { "example": {
@ -970,24 +1030,25 @@ class SortieStockRequest(BaseModel):
"article_ref": "ART001", "article_ref": "ART001",
"quantite": 10, "quantite": 10,
"depot_code": "01", "depot_code": "01",
"commentaire": "Utilisation interne" "commentaire": "Utilisation interne",
} }
], ],
"commentaire": "Consommation atelier" "commentaire": "Consommation atelier",
} }
} }
class MouvementStockResponse(BaseModel): class MouvementStockResponse(BaseModel):
"""Réponse pour un mouvement de stock""" """Réponse pour un mouvement de stock"""
numero: str = Field(..., description="Numéro du mouvement") numero: str = Field(..., description="Numéro du mouvement")
type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") type: int = Field(..., description="Type (0=Entrée, 1=Sortie)")
type_libelle: str = Field(..., description="Libellé du type") type_libelle: str = Field(..., description="Libellé du type")
date: str = Field(..., description="Date du mouvement") date: str = Field(..., description="Date du mouvement")
reference: Optional[str] = Field(None, description="Référence externe") reference: Optional[str] = Field(None, description="Référence externe")
nb_lignes: int = Field(..., description="Nombre de lignes") nb_lignes: int = Field(..., description="Nombre de lignes")
async def universign_envoyer( async def universign_envoyer(
doc_id: str, pdf_bytes: bytes, email: str, nom: str doc_id: str, pdf_bytes: bytes, email: str, nom: str
) -> Dict: ) -> Dict:
@ -1102,6 +1163,7 @@ async def universign_statut(transaction_id: str) -> Dict:
logger.error(f"Erreur statut Universign: {e}") logger.error(f"Erreur statut Universign: {e}")
return {"statut": "ERREUR", "error": str(e)} return {"statut": "ERREUR", "error": str(e)}
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Init base de données # Init base de données
@ -1805,6 +1867,7 @@ async def changer_statut_devis(
logger.error(f"Erreur changement statut devis {id}: {e}") logger.error(f"Erreur changement statut devis {id}: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.get("/commandes/{id}", tags=["Commandes"]) @app.get("/commandes/{id}", tags=["Commandes"])
async def lire_commande(id: str): async def lire_commande(id: str):
try: try:
@ -2253,6 +2316,7 @@ async def envoyer_emails_lot(
"details": resultats, "details": resultats,
} }
@app.post( @app.post(
"/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]
) )
@ -2638,6 +2702,7 @@ async def relancer_facture(
logger.error(f"Erreur relance facture: {e}") logger.error(f"Erreur relance facture: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.get("/emails/logs", tags=["Emails"]) @app.get("/emails/logs", tags=["Emails"])
async def journal_emails( async def journal_emails(
statut: Optional[StatutEmail] = Query(None), statut: Optional[StatutEmail] = Query(None),
@ -2733,6 +2798,7 @@ async def exporter_logs_csv(
}, },
) )
class TemplateEmail(BaseModel): class TemplateEmail(BaseModel):
id: Optional[str] = None id: Optional[str] = None
nom: str nom: str
@ -3616,7 +3682,7 @@ async def creer_famille(famille: FamilleCreateRequest):
response_model=MouvementStockResponse, response_model=MouvementStockResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
tags=["Stock"], 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): async def creer_entree_stock(entree: EntreeStockRequest):
try: try:
@ -3624,28 +3690,25 @@ async def creer_entree_stock(entree: EntreeStockRequest):
entree_data = entree.dict() entree_data = entree.dict()
if entree_data.get("date_entree"): if entree_data.get("date_entree"):
entree_data["date_entree"] = entree_data["date_entree"].isoformat() entree_data["date_entree"] = entree_data["date_entree"].isoformat()
logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)")
# Appel à la gateway Windows # Appel à la gateway Windows
resultat = sage_client.creer_entree_stock(entree_data) resultat = sage_client.creer_entree_stock(entree_data)
logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}")
return MouvementStockResponse(**resultat) return MouvementStockResponse(**resultat)
except ValueError as e: except ValueError as e:
logger.warning(f"⚠️ Erreur métier entrée stock: {e}") logger.warning(f"⚠️ Erreur métier entrée stock: {e}")
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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, response_model=MouvementStockResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
tags=["Stock"], 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): async def creer_sortie_stock(sortie: SortieStockRequest):
try: try:
@ -3662,28 +3725,25 @@ async def creer_sortie_stock(sortie: SortieStockRequest):
sortie_data = sortie.dict() sortie_data = sortie.dict()
if sortie_data.get("date_sortie"): if sortie_data.get("date_sortie"):
sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat()
logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)")
# Appel à la gateway Windows # Appel à la gateway Windows
resultat = sage_client.creer_sortie_stock(sortie_data) resultat = sage_client.creer_sortie_stock(sortie_data)
logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}")
return MouvementStockResponse(**resultat) return MouvementStockResponse(**resultat)
except ValueError as e: except ValueError as e:
logger.warning(f"⚠️ Erreur métier sortie stock: {e}") logger.warning(f"⚠️ Erreur métier sortie stock: {e}")
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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}", "/stock/mouvement/{numero}",
response_model=MouvementStockResponse, response_model=MouvementStockResponse,
tags=["Stock"], tags=["Stock"],
summary="Lecture d'un mouvement de stock" summary="Lecture d'un mouvement de stock",
) )
async def lire_mouvement_stock( async def lire_mouvement_stock(
numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)") numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)")
): ):
try: try:
mouvement = sage_client.lire_mouvement_stock(numero) mouvement = sage_client.lire_mouvement_stock(numero)
if not mouvement: if not mouvement:
logger.warning(f"⚠️ Mouvement {numero} introuvable") logger.warning(f"⚠️ Mouvement {numero} introuvable")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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") logger.info(f"✅ Mouvement {numero} lu")
return MouvementStockResponse(**mouvement) return MouvementStockResponse(**mouvement)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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)}",
) )