feat(articles): add enum support and field normalization for article schema

This commit is contained in:
Fanilo-Nantenaina 2026-01-02 11:07:02 +03:00
parent bd432a15b6
commit 1d4ea92e86
3 changed files with 253 additions and 126 deletions

View file

@ -2,6 +2,9 @@ from pydantic import BaseModel, Field, validator, field_validator
from typing import List, Optional from typing import List, Optional
from datetime import date, datetime from datetime import date, datetime
from utils import NomenclatureType, SuiviStockType, TypeArticle
from utils.enums import normalize_enum_to_int, normalize_string_field
class EmplacementStockModel(BaseModel): class EmplacementStockModel(BaseModel):
"""Détail du stock dans un emplacement spécifique""" """Détail du stock dans un emplacement spécifique"""
@ -419,18 +422,28 @@ class ArticleResponse(BaseModel):
None, description="Stock disponible = réel - réservé" None, description="Stock disponible = réel - réservé"
) )
emplacements: List[EmplacementStockModel] = Field( emplacements: List[dict] = Field(
default_factory=list, default_factory=list, description="Détail du stock par emplacement"
description="Détail du stock par emplacement (F_ARTSTOCKEMPL + F_DEPOT + F_DEPOTEMPL)",
) )
nb_emplacements: int = Field(0, description="Nombre d'emplacements") nb_emplacements: int = Field(0, description="Nombre d'emplacements")
suivi_stock: Optional[bool] = Field( # Champs énumérés normalisés
None, description="Suivi de stock activé (AR_SuiviStock)" suivi_stock: Optional[int] = Field(
None,
description="Type de suivi de stock (AR_SuiviStock): 0=Aucun, 1=CMUP, 2=FIFO/LIFO, 3=Sérialisé",
) )
nomenclature: Optional[bool] = Field( suivi_stock_libelle: Optional[str] = Field(
None, description="Article avec nomenclature (AR_Nomencl)" None, description="Libellé du type de suivi de stock"
) )
nomenclature: Optional[int] = Field(
None,
description="Type de nomenclature (AR_Nomencl): 0=Non, 1=Fabrication, 2=Commerciale",
)
nomenclature_libelle: Optional[str] = Field(
None, description="Libellé du type de nomenclature"
)
qte_composant: Optional[float] = Field( qte_composant: Optional[float] = Field(
None, description="Quantité de composant (AR_QteComp)" None, description="Quantité de composant (AR_QteComp)"
) )
@ -454,52 +467,44 @@ class ArticleResponse(BaseModel):
gamme_1: Optional[str] = Field(None, description="Énumération gamme 1 (AR_Gamme1)") gamme_1: Optional[str] = Field(None, description="Énumération gamme 1 (AR_Gamme1)")
gamme_2: Optional[str] = Field(None, description="Énumération gamme 2 (AR_Gamme2)") gamme_2: Optional[str] = Field(None, description="Énumération gamme 2 (AR_Gamme2)")
gammes: List[GammeArticleModel] = Field( gammes: List[dict] = Field(default_factory=list, description="Détail des gammes")
default_factory=list,
description="Détail des gammes (F_ARTGAMME + F_ENUMGAMME + P_GAMME)",
)
nb_gammes: int = Field(0, description="Nombre de gammes") nb_gammes: int = Field(0, description="Nombre de gammes")
tarifs_clients: List[TarifClientModel] = Field( tarifs_clients: List[dict] = Field(
default_factory=list, default_factory=list, description="Tarifs spécifiques par client/catégorie"
description="Tarifs spécifiques par client/catégorie (F_ARTCLIENT)",
) )
nb_tarifs_clients: int = Field(0, description="Nombre de tarifs clients") nb_tarifs_clients: int = Field(0, description="Nombre de tarifs clients")
composants: List[ComposantModel] = Field( composants: List[dict] = Field(
default_factory=list, default_factory=list, description="Composants/Opérations de production"
description="Composants/Opérations de production (F_ARTCOMPO)",
) )
nb_composants: int = Field(0, description="Nombre de composants") nb_composants: int = Field(0, description="Nombre de composants")
compta_vente: List[ComptaArticleModel] = Field( compta_vente: List[dict] = Field(
default_factory=list, description="Comptabilité vente (F_ARTCOMPTA type 0)" default_factory=list, description="Comptabilité vente"
) )
compta_achat: List[ComptaArticleModel] = Field( compta_achat: List[dict] = Field(
default_factory=list, description="Comptabilité achat (F_ARTCOMPTA type 1)" default_factory=list, description="Comptabilité achat"
) )
compta_stock: List[ComptaArticleModel] = Field( compta_stock: List[dict] = Field(
default_factory=list, description="Comptabilité stock (F_ARTCOMPTA type 2)" default_factory=list, description="Comptabilité stock"
) )
fournisseurs: List[FournisseurArticleModel] = Field( fournisseurs: List[dict] = Field(
default_factory=list, default_factory=list, description="Tous les fournisseurs de l'article"
description="Tous les fournisseurs de l'article (F_ARTFOURNISS)",
) )
nb_fournisseurs: int = Field(0, description="Nombre de fournisseurs") nb_fournisseurs: int = Field(0, description="Nombre de fournisseurs")
refs_enumerees: List[ReferenceEnumereeModel] = Field( refs_enumerees: List[dict] = Field(
default_factory=list, description="Références énumérées (F_ARTENUMREF)" default_factory=list, description="Références énumérées"
) )
nb_refs_enumerees: int = Field(0, description="Nombre de références énumérées") nb_refs_enumerees: int = Field(0, description="Nombre de références énumérées")
medias: List[MediaArticleModel] = Field( medias: List[dict] = Field(default_factory=list, description="Médias attachés")
default_factory=list, description="Médias attachés (F_ARTICLEMEDIA)"
)
nb_medias: int = Field(0, description="Nombre de médias") nb_medias: int = Field(0, description="Nombre de médias")
prix_gammes: List[PrixGammeModel] = Field( prix_gammes: List[dict] = Field(
default_factory=list, description="Prix par combinaison de gammes (F_ARTPRIX)" default_factory=list, description="Prix par combinaison de gammes"
) )
nb_prix_gammes: int = Field(0, description="Nombre de prix par gammes") nb_prix_gammes: int = Field(0, description="Nombre de prix par gammes")
@ -516,51 +521,35 @@ class ArticleResponse(BaseModel):
famille_code: Optional[str] = Field( famille_code: Optional[str] = Field(
None, max_length=20, description="Code famille (FA_CodeFamille)" None, max_length=20, description="Code famille (FA_CodeFamille)"
) )
famille_libelle: Optional[str] = Field( famille_libelle: Optional[str] = Field(None, description="Libellé de la famille")
None, description="Libellé de la famille (F_FAMILLE.FA_Intitule)"
)
famille_type: Optional[int] = Field( famille_type: Optional[int] = Field(
None, description="Type de famille : 0=Détail, 1=Total (F_FAMILLE.FA_Type)" None, description="Type de famille : 0=Détail, 1=Total"
) )
famille_unite_vente: Optional[str] = Field( famille_unite_vente: Optional[str] = Field(
None, description="Unité de vente de la famille (F_FAMILLE.FA_UniteVen)" None, description="Unité de vente de la famille"
)
famille_coef: Optional[float] = Field(
None, description="Coefficient de la famille (F_FAMILLE.FA_Coef)"
) )
famille_coef: Optional[float] = Field(None, description="Coefficient de la famille")
famille_suivi_stock: Optional[bool] = Field( famille_suivi_stock: Optional[bool] = Field(
None, description="Suivi stock de la famille (F_FAMILLE.FA_SuiviStock)" None, description="Suivi stock de la famille"
)
famille_garantie: Optional[int] = Field(
None, description="Garantie de la famille (F_FAMILLE.FA_Garantie)"
) )
famille_garantie: Optional[int] = Field(None, description="Garantie de la famille")
famille_unite_poids: Optional[str] = Field( famille_unite_poids: Optional[str] = Field(
None, description="Unité de poids de la famille (F_FAMILLE.FA_UnitePoids)" None, description="Unité de poids de la famille"
)
famille_delai: Optional[int] = Field(
None, description="Délai de la famille (F_FAMILLE.FA_Delai)"
) )
famille_delai: Optional[int] = Field(None, description="Délai de la famille")
famille_nb_colis: Optional[int] = Field( famille_nb_colis: Optional[int] = Field(
None, description="Nombre de colis de la famille (F_FAMILLE.FA_NbColis)" None, description="Nombre de colis de la famille"
) )
famille_code_fiscal: Optional[str] = Field( famille_code_fiscal: Optional[str] = Field(
None, description="Code fiscal de la famille (F_FAMILLE.FA_CodeFiscal)" None, description="Code fiscal de la famille"
)
famille_escompte: Optional[bool] = Field(
None, description="Escompte de la famille (F_FAMILLE.FA_Escompte)"
)
famille_centrale: Optional[bool] = Field(
None, description="Famille centrale (F_FAMILLE.FA_Central)"
)
famille_nature: Optional[int] = Field(
None, description="Nature de la famille (F_FAMILLE.FA_Nature)"
) )
famille_escompte: Optional[bool] = Field(None, description="Escompte de la famille")
famille_centrale: Optional[bool] = Field(None, description="Famille centrale")
famille_nature: Optional[int] = Field(None, description="Nature de la famille")
famille_hors_stat: Optional[bool] = Field( famille_hors_stat: Optional[bool] = Field(
None, description="Hors statistique famille (F_FAMILLE.FA_HorsStat)" None, description="Hors statistique famille"
)
famille_pays: Optional[str] = Field(
None, description="Pays de la famille (F_FAMILLE.FA_Pays)"
) )
famille_pays: Optional[str] = Field(None, description="Pays de la famille")
nature: Optional[int] = Field(None, description="Nature de l'article (AR_Nature)") nature: Optional[int] = Field(None, description="Nature de l'article (AR_Nature)")
garantie: Optional[int] = Field( garantie: Optional[int] = Field(
@ -572,21 +561,20 @@ class ArticleResponse(BaseModel):
pays: Optional[str] = Field(None, description="Pays d'origine (AR_Pays)") pays: Optional[str] = Field(None, description="Pays d'origine (AR_Pays)")
fournisseur_principal: Optional[int] = Field( fournisseur_principal: Optional[int] = Field(
None, None, description="N° compte du fournisseur principal"
description="N° compte du fournisseur principal (CO_No → F_COMPTET.CT_Num)",
) )
fournisseur_nom: Optional[str] = Field( fournisseur_nom: Optional[str] = Field(
None, description="Nom du fournisseur principal (F_COMPTET.CT_Intitule)" None, description="Nom du fournisseur principal"
) )
conditionnement: Optional[str] = Field( conditionnement: Optional[str] = Field(
None, description="Conditionnement d'achat (AR_Condition)" None, description="Conditionnement d'achat (AR_Condition)"
) )
conditionnement_qte: Optional[float] = Field( conditionnement_qte: Optional[float] = Field(
None, description="Quantité conditionnement (F_ENUMCOND.EC_Quantite)" None, description="Quantité conditionnement"
) )
conditionnement_edi: Optional[str] = Field( conditionnement_edi: Optional[str] = Field(
None, description="Code EDI conditionnement (F_ENUMCOND.EC_EdiCode)" None, description="Code EDI conditionnement"
) )
nb_colis: Optional[int] = Field( nb_colis: Optional[int] = Field(
@ -669,13 +657,13 @@ class ArticleResponse(BaseModel):
langue_2: Optional[str] = Field(None, description="Texte en langue 2 (AR_Langue2)") langue_2: Optional[str] = Field(None, description="Texte en langue 2 (AR_Langue2)")
frais_01_denomination: Optional[str] = Field( frais_01_denomination: Optional[str] = Field(
None, description="Dénomination frais 1 (AR_Frais01FR_Denomination)" None, description="Dénomination frais 1"
) )
frais_02_denomination: Optional[str] = Field( frais_02_denomination: Optional[str] = Field(
None, description="Dénomination frais 2 (AR_Frais02FR_Denomination)" None, description="Dénomination frais 2"
) )
frais_03_denomination: Optional[str] = Field( frais_03_denomination: Optional[str] = Field(
None, description="Dénomination frais 3 (AR_Frais03FR_Denomination)" None, description="Dénomination frais 3"
) )
tva_code: Optional[str] = Field(None, description="Code TVA (F_TAXE.TA_Code)") tva_code: Optional[str] = Field(None, description="Code TVA (F_TAXE.TA_Code)")
@ -706,40 +694,54 @@ class ArticleResponse(BaseModel):
None, description="Date de dernière modification (AR_DateModif)" None, description="Date de dernière modification (AR_DateModif)"
) )
marque_commerciale: Optional[str] = Field( marque_commerciale: Optional[str] = Field(None, description="Marque commerciale")
None, description="Marque commerciale (champ personnalisé)"
)
objectif_qtes_vendues: Optional[str] = Field( objectif_qtes_vendues: Optional[str] = Field(
None, description="Objectif / Quantités vendues (champ personnalisé)" None, description="Objectif / Quantités vendues"
)
pourcentage_or: Optional[str] = Field(
None, description="Pourcentage teneur en or (champ personnalisé)"
) )
pourcentage_or: Optional[str] = Field(None, description="Pourcentage teneur en or")
premiere_commercialisation: Optional[str] = Field( premiere_commercialisation: Optional[str] = Field(
None, description="Date de 1ère commercialisation (champ personnalisé)" None, description="Date de 1ère commercialisation"
) )
interdire_commande: Optional[bool] = Field( interdire_commande: Optional[bool] = Field(
None, description="Interdire la commande (champ personnalisé)" None, description="Interdire la commande"
) )
exclure: Optional[bool] = Field( exclure: Optional[bool] = Field(None, description="Exclure de certains traitements")
None, description="Exclure de certains traitements (champ personnalisé)"
# ===== VALIDATEURS =====
@field_validator(
"unite_vente",
"unite_poids",
"gamme_1",
"gamme_2",
"conditionnement",
"code_fiscal",
"pays",
"article_substitut",
"reprise_code_defaut",
mode="before",
) )
@field_validator('unite_vente', 'unite_poids', 'gamme_1', 'gamme_2', 'conditionnement', mode='before')
@classmethod @classmethod
def convert_int_to_str(cls, v): def convert_string_fields(cls, v):
if v is None: """Convertit les champs string qui peuvent venir comme int depuis la DB"""
return None return normalize_string_field(v)
if v == 0:
return "" @field_validator("suivi_stock", "nomenclature", mode="before")
return str(v)
@field_validator('suivi_stock', mode='before')
@classmethod @classmethod
def convert_int_to_bool(cls, v): def convert_enum_fields(cls, v):
if v is None: """Convertit les champs énumérés en int"""
return None return normalize_enum_to_int(v)
return bool(v)
def model_post_init(self, __context):
"""Génère automatiquement les libellés après l'initialisation"""
if self.suivi_stock is not None:
self.suivi_stock_libelle = SuiviStockType.get_label(self.suivi_stock)
if self.nomenclature is not None:
self.nomenclature_libelle = NomenclatureType.get_label(self.nomenclature)
if self.type_article is not None:
self.type_article_libelle = TypeArticle.get_label(self.type_article)
class Config: class Config:
json_schema_extra = { json_schema_extra = {
@ -748,23 +750,10 @@ class ArticleResponse(BaseModel):
"designation": "Bague Or 18K Diamant", "designation": "Bague Or 18K Diamant",
"prix_vente": 1299.00, "prix_vente": 1299.00,
"stock_reel": 15.0, "stock_reel": 15.0,
"stock_disponible": 13.0, "suivi_stock": 1,
"nb_emplacements": 2, "suivi_stock_libelle": "CMUP",
"nb_gammes": 2, "nomenclature": 0,
"nb_tarifs_clients": 3, "nomenclature_libelle": "Non",
"nb_fournisseurs": 2,
"nb_medias": 2,
"emplacements": [
{
"depot": "01",
"emplacement": "A1-01",
"qte_stockee": 10.0,
"depot_nom": "Dépôt principal",
}
],
"gammes": [
{"numero_gamme": 1, "enumere": "001", "gamme_nom": "Taille"}
],
} }
} }
@ -785,18 +774,6 @@ class ArticleListResponse(BaseModel):
False, description="Indique si tous les enrichissements sont activés" False, description="Indique si tous les enrichissements sont activés"
) )
class Config:
json_schema_extra = {
"example": {
"total": 1250,
"filtre_applique": "bague",
"avec_stock": True,
"avec_famille": True,
"avec_enrichissements_complets": True,
"articles": [],
}
}
class ArticleCreateRequest(BaseModel): class ArticleCreateRequest(BaseModel):
"""Schéma pour création d'article""" """Schéma pour création d'article"""

View file

@ -0,0 +1,21 @@
from enums import (
TypeArticle,
TypeCompta,
TypeRessource,
TypeTiers,
TypeEmplacement,
TypeFamille,
NomenclatureType,
SuiviStockType,
)
__all__ = [
"TypeArticle",
"TypeCompta",
"TypeRessource",
"TypeTiers",
"TypeEmplacement",
"TypeFamille",
"NomenclatureType",
"SuiviStockType",
]

129
utils/enums.py Normal file
View file

@ -0,0 +1,129 @@
from enum import IntEnum
from typing import Optional
class SuiviStockType(IntEnum):
AUCUN = 0
CMUP = 1
FIFO_LIFO = 2
SERIALISE = 3
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {0: "Aucun", 1: "CMUP", 2: "FIFO/LIFO", 3: "Sérialisé"}
return labels.get(value) if value is not None else None
class NomenclatureType(IntEnum):
NON = 0
FABRICATION = 1
COMMERCIALE = 2
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {0: "Non", 1: "Fabrication", 2: "Commerciale/Composé"}
return labels.get(value) if value is not None else None
class TypeArticle(IntEnum):
ARTICLE = 0
PRESTATION = 1
DIVERS = 2
NOMENCLATURE = 3
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {
0: "Article",
1: "Prestation de service",
2: "Divers / Frais",
3: "Nomenclature",
}
return labels.get(value) if value is not None else None
class TypeFamille(IntEnum):
DETAIL = 0
TOTAL = 1
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {0: "Détail", 1: "Total"}
return labels.get(value) if value is not None else None
class TypeCompta(IntEnum):
VENTE = 0
ACHAT = 1
STOCK = 2
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {0: "Vente", 1: "Achat", 2: "Stock"}
return labels.get(value) if value is not None else None
class TypeRessource(IntEnum):
MAIN_OEUVRE = 0
MACHINE = 1
SOUS_TRAITANCE = 2
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {0: "Main d'œuvre", 1: "Machine", 2: "Sous-traitance"}
return labels.get(value) if value is not None else None
class TypeTiers(IntEnum):
CLIENT = 0
FOURNISSEUR = 1
SALARIE = 2
AUTRE = 3
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {0: "Client", 1: "Fournisseur", 2: "Salarié", 3: "Autre"}
return labels.get(value) if value is not None else None
class TypeEmplacement(IntEnum):
NORMAL = 0
QUARANTAINE = 1
REBUT = 2
@classmethod
def get_label(cls, value: Optional[int]) -> Optional[str]:
labels = {0: "Normal", 1: "Quarantaine", 2: "Rebut"}
return labels.get(value) if value is not None else None
def normalize_enum_to_string(value, default="0") -> Optional[str]:
if value is None:
return None
if value == 0:
return None
return str(value)
def normalize_enum_to_int(value, default=0) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (ValueError, TypeError):
return default
def normalize_string_field(value) -> Optional[str]:
if value is None:
return None
if isinstance(value, int):
if value == 0:
return None
return str(value)
if isinstance(value, str):
stripped = value.strip()
if stripped in ("", "0"):
return None
return stripped
return str(value)