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

114
api.py
View file

@ -914,21 +914,74 @@ 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:
@ -943,20 +996,27 @@ 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:
@ -970,16 +1030,17 @@ 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")
@ -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:
@ -3636,16 +3702,13 @@ async def creer_entree_stock(entree: EntreeStockRequest):
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:
@ -3674,16 +3737,13 @@ async def creer_sortie_stock(sortie: SortieStockRequest):
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,7 +3751,7 @@ 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)")
@ -3703,7 +3763,7 @@ async def lire_mouvement_stock(
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")
@ -3716,7 +3776,7 @@ async def lire_mouvement_stock(
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)}",
)