feat(stock): enhance stock movement models with lot tracking and min/max stock
This commit is contained in:
parent
421f4d24dc
commit
62e347969c
1 changed files with 107 additions and 47 deletions
154
api.py
154
api.py
|
|
@ -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)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue