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 datetime import date, datetime
from utils import NomenclatureType, SuiviStockType, TypeArticle
from utils.enums import normalize_enum_to_int, normalize_string_field
class EmplacementStockModel(BaseModel):
"""Détail du stock dans un emplacement spécifique"""
@ -419,18 +422,28 @@ class ArticleResponse(BaseModel):
None, description="Stock disponible = réel - réservé"
)
emplacements: List[EmplacementStockModel] = Field(
default_factory=list,
description="Détail du stock par emplacement (F_ARTSTOCKEMPL + F_DEPOT + F_DEPOTEMPL)",
emplacements: List[dict] = Field(
default_factory=list, description="Détail du stock par emplacement"
)
nb_emplacements: int = Field(0, description="Nombre d'emplacements")
suivi_stock: Optional[bool] = Field(
None, description="Suivi de stock activé (AR_SuiviStock)"
# Champs énumérés normalisés
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(
None, description="Article avec nomenclature (AR_Nomencl)"
suivi_stock_libelle: Optional[str] = Field(
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(
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_2: Optional[str] = Field(None, description="Énumération gamme 2 (AR_Gamme2)")
gammes: List[GammeArticleModel] = Field(
default_factory=list,
description="Détail des gammes (F_ARTGAMME + F_ENUMGAMME + P_GAMME)",
)
gammes: List[dict] = Field(default_factory=list, description="Détail des gammes")
nb_gammes: int = Field(0, description="Nombre de gammes")
tarifs_clients: List[TarifClientModel] = Field(
default_factory=list,
description="Tarifs spécifiques par client/catégorie (F_ARTCLIENT)",
tarifs_clients: List[dict] = Field(
default_factory=list, description="Tarifs spécifiques par client/catégorie"
)
nb_tarifs_clients: int = Field(0, description="Nombre de tarifs clients")
composants: List[ComposantModel] = Field(
default_factory=list,
description="Composants/Opérations de production (F_ARTCOMPO)",
composants: List[dict] = Field(
default_factory=list, description="Composants/Opérations de production"
)
nb_composants: int = Field(0, description="Nombre de composants")
compta_vente: List[ComptaArticleModel] = Field(
default_factory=list, description="Comptabilité vente (F_ARTCOMPTA type 0)"
compta_vente: List[dict] = Field(
default_factory=list, description="Comptabilité vente"
)
compta_achat: List[ComptaArticleModel] = Field(
default_factory=list, description="Comptabilité achat (F_ARTCOMPTA type 1)"
compta_achat: List[dict] = Field(
default_factory=list, description="Comptabilité achat"
)
compta_stock: List[ComptaArticleModel] = Field(
default_factory=list, description="Comptabilité stock (F_ARTCOMPTA type 2)"
compta_stock: List[dict] = Field(
default_factory=list, description="Comptabilité stock"
)
fournisseurs: List[FournisseurArticleModel] = Field(
default_factory=list,
description="Tous les fournisseurs de l'article (F_ARTFOURNISS)",
fournisseurs: List[dict] = Field(
default_factory=list, description="Tous les fournisseurs de l'article"
)
nb_fournisseurs: int = Field(0, description="Nombre de fournisseurs")
refs_enumerees: List[ReferenceEnumereeModel] = Field(
default_factory=list, description="Références énumérées (F_ARTENUMREF)"
refs_enumerees: List[dict] = Field(
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")
medias: List[MediaArticleModel] = Field(
default_factory=list, description="Médias attachés (F_ARTICLEMEDIA)"
)
medias: List[dict] = Field(default_factory=list, description="Médias attachés")
nb_medias: int = Field(0, description="Nombre de médias")
prix_gammes: List[PrixGammeModel] = Field(
default_factory=list, description="Prix par combinaison de gammes (F_ARTPRIX)"
prix_gammes: List[dict] = Field(
default_factory=list, description="Prix par combinaison de 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(
None, max_length=20, description="Code famille (FA_CodeFamille)"
)
famille_libelle: Optional[str] = Field(
None, description="Libellé de la famille (F_FAMILLE.FA_Intitule)"
)
famille_libelle: Optional[str] = Field(None, description="Libellé de la famille")
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(
None, description="Unité de vente de la famille (F_FAMILLE.FA_UniteVen)"
)
famille_coef: Optional[float] = Field(
None, description="Coefficient de la famille (F_FAMILLE.FA_Coef)"
None, description="Unité de vente de la famille"
)
famille_coef: Optional[float] = Field(None, description="Coefficient de la famille")
famille_suivi_stock: Optional[bool] = Field(
None, description="Suivi stock de la famille (F_FAMILLE.FA_SuiviStock)"
)
famille_garantie: Optional[int] = Field(
None, description="Garantie de la famille (F_FAMILLE.FA_Garantie)"
None, description="Suivi stock de la famille"
)
famille_garantie: Optional[int] = Field(None, description="Garantie de la famille")
famille_unite_poids: Optional[str] = Field(
None, description="Unité de poids de la famille (F_FAMILLE.FA_UnitePoids)"
)
famille_delai: Optional[int] = Field(
None, description="Délai de la famille (F_FAMILLE.FA_Delai)"
None, description="Unité de poids de la famille"
)
famille_delai: Optional[int] = Field(None, description="Délai de la famille")
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(
None, description="Code fiscal de la famille (F_FAMILLE.FA_CodeFiscal)"
)
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)"
None, description="Code fiscal de la famille"
)
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(
None, description="Hors statistique famille (F_FAMILLE.FA_HorsStat)"
)
famille_pays: Optional[str] = Field(
None, description="Pays de la famille (F_FAMILLE.FA_Pays)"
None, description="Hors statistique famille"
)
famille_pays: Optional[str] = Field(None, description="Pays de la famille")
nature: Optional[int] = Field(None, description="Nature de l'article (AR_Nature)")
garantie: Optional[int] = Field(
@ -572,21 +561,20 @@ class ArticleResponse(BaseModel):
pays: Optional[str] = Field(None, description="Pays d'origine (AR_Pays)")
fournisseur_principal: Optional[int] = Field(
None,
description="N° compte du fournisseur principal (CO_No → F_COMPTET.CT_Num)",
None, description="N° compte du fournisseur principal"
)
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(
None, description="Conditionnement d'achat (AR_Condition)"
)
conditionnement_qte: Optional[float] = Field(
None, description="Quantité conditionnement (F_ENUMCOND.EC_Quantite)"
None, description="Quantité conditionnement"
)
conditionnement_edi: Optional[str] = Field(
None, description="Code EDI conditionnement (F_ENUMCOND.EC_EdiCode)"
None, description="Code EDI conditionnement"
)
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)")
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(
None, description="Dénomination frais 2 (AR_Frais02FR_Denomination)"
None, description="Dénomination frais 2"
)
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)")
@ -706,40 +694,54 @@ class ArticleResponse(BaseModel):
None, description="Date de dernière modification (AR_DateModif)"
)
marque_commerciale: Optional[str] = Field(
None, description="Marque commerciale (champ personnalisé)"
)
marque_commerciale: Optional[str] = Field(None, description="Marque commerciale")
objectif_qtes_vendues: Optional[str] = Field(
None, description="Objectif / Quantités vendues (champ personnalisé)"
)
pourcentage_or: Optional[str] = Field(
None, description="Pourcentage teneur en or (champ personnalisé)"
None, description="Objectif / Quantités vendues"
)
pourcentage_or: Optional[str] = Field(None, description="Pourcentage teneur en or")
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(
None, description="Interdire la commande (champ personnalisé)"
None, description="Interdire la commande"
)
exclure: Optional[bool] = Field(
None, description="Exclure de certains traitements (champ personnalisé)"
exclure: Optional[bool] = Field(None, description="Exclure de certains traitements")
# ===== 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
def convert_int_to_str(cls, v):
if v is None:
return None
if v == 0:
return ""
return str(v)
@field_validator('suivi_stock', mode='before')
def convert_string_fields(cls, v):
"""Convertit les champs string qui peuvent venir comme int depuis la DB"""
return normalize_string_field(v)
@field_validator("suivi_stock", "nomenclature", mode="before")
@classmethod
def convert_int_to_bool(cls, v):
if v is None:
return None
return bool(v)
def convert_enum_fields(cls, v):
"""Convertit les champs énumérés en int"""
return normalize_enum_to_int(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:
json_schema_extra = {
@ -748,23 +750,10 @@ class ArticleResponse(BaseModel):
"designation": "Bague Or 18K Diamant",
"prix_vente": 1299.00,
"stock_reel": 15.0,
"stock_disponible": 13.0,
"nb_emplacements": 2,
"nb_gammes": 2,
"nb_tarifs_clients": 3,
"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"}
],
"suivi_stock": 1,
"suivi_stock_libelle": "CMUP",
"nomenclature": 0,
"nomenclature_libelle": "Non",
}
}
@ -785,18 +774,6 @@ class ArticleListResponse(BaseModel):
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):
"""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)