Compare commits

..

127 commits

Author SHA1 Message Date
3dd863accf Removed non-ASCII characters 2026-01-18 09:45:49 +01:00
31dec46226 Cleaned projects' files 2026-01-18 09:41:22 +01:00
9ffad8287d Simple cleaning of some files 2026-01-18 09:31:09 +01:00
fd60bd3bc7 Updated methods on gathering "reglements" 2026-01-17 09:25:31 +01:00
437ac743c8 feat(reglements): add endpoint to get payment details by invoice number 2026-01-16 16:01:26 +01:00
06a5fc8df4 mode règlement avec regroupement par règlement (je crois) 2026-01-16 15:19:31 +01:00
5ad54a2ff0 feat(reglements): add endpoints to list and get payment details 2026-01-16 13:47:20 +01:00
f5c5a87d0d validation facture et règlement effectuées avec succès, mais approche dangereuse ! 2026-01-15 15:22:28 +01:00
7dc5d03c4c Retaining SQL approach for the moment 2026-01-15 12:48:48 +01:00
5c80a5e912 validation method par SQL, dangereux actuellement mais "seem" functionnal 2026-01-15 12:22:04 +01:00
6b4b603aee valider_facture quasi fonctionelle 2026-01-15 12:18:12 +01:00
7d58a607f5 Trying again 2026-01-15 09:42:11 +01:00
68c70de7d9 Another try with introspection 2026-01-15 09:33:16 +01:00
b66525c00e Another try 2026-01-15 09:25:52 +01:00
bd48bf5aac Trying to get facture's validations to be functionnal 2026-01-15 09:13:22 +01:00
e281751c5e Adding facture validation 2026-01-15 09:00:58 +01:00
ff8c35fcfa settling invoice check 2026-01-15 08:14:32 +01:00
b6416487c0 Modified implicit error on converting type int into varchar in SQL Query in articles_data_sql
Added settle logics for invoice and trying to gather society's profile image
2026-01-15 06:38:49 +01:00
e47e14f1b4 Enriched society infos 2026-01-14 13:20:38 +01:00
2b15e7b3e2 (feat): Added method and route to gather actual society informations 2026-01-14 07:56:11 +01:00
3fd3b7c45a Removed static logics 2026-01-09 08:41:26 +01:00
317a7312cc Reviewed calculation for HT, HT NET, TVA and TTC amounts 2026-01-07 15:25:57 +01:00
4a642fa654 Tried to set hours manually 2026-01-07 01:53:29 +01:00
fd0385d417 Corrected method that caused error on modifying a document 2026-01-06 11:03:07 +01:00
97a2bc01f0 Added better handling for collaborator 2026-01-05 21:00:58 +01:00
69114ba0c3 name of enrichisment function refactored 2026-01-05 17:48:31 +01:00
57103d406d Unified document's modification function 2026-01-05 17:31:18 +01:00
f80ad1adee Added endpoints for collaborators/commercials/representants 2026-01-05 17:18:26 +01:00
96021205a4 Unified creating method for documents 2026-01-05 15:17:35 +01:00
6a346876aa Added commercial on tiers retrieving method 2026-01-05 08:40:05 +01:00
ea344b2669 article's management functionnal 2026-01-03 18:48:31 +01:00
3fa98a168c lire_article and prix_achat functionnal 2026-01-03 17:01:57 +01:00
4ae0178090 Create and update article functionnal but not, anymore, capable of handling "prix_achat" 2026-01-03 16:29:09 +01:00
cc1f3aa8b1 better handling of an article on create and modification state 2026-01-03 16:17:42 +01:00
a0f9eeedec Added better article's data handling and more enriched 2026-01-03 15:02:57 +01:00
c32a9171a5 Treat "fournisseur_principal" as string 2026-01-03 14:38:27 +01:00
06c9fbb929 Simplified pydantic models' name 2026-01-02 12:09:38 +01:00
5e4231e115 Refactored code readability, modularity and usability 2026-01-02 12:00:41 +01:00
0788f46bde Reviewed and refactored files' structures and indentation 2025-12-31 09:25:05 +01:00
3bb1aee4b4 Refactored files structures 2025-12-31 09:23:54 +01:00
935e43487f Generalized status permutation, reviewed some create and delocalized functions 2025-12-31 08:44:59 +01:00
2ea7fa8371 Extracted Pydantic schema 2025-12-29 15:07:04 +01:00
d6d01fee9f Finished firt time restructuration and reorganization 2025-12-29 14:34:57 +01:00
2f3b0ade5e Replacing tiers' schemas to correct place 2025-12-29 13:36:07 +01:00
2819578ca2 Restrucred and reorganized files for more consistency 2025-12-29 13:32:40 +01:00
6bb1253a1a Better usage and exploitation of contact_to_dict 2025-12-28 21:40:27 +01:00
09e3589132 méthodes de contact utilitaures 2025-12-28 21:34:59 +01:00
800d828f75 Delete contact successful 2025-12-28 21:33:31 +01:00
7f64a2a548 modifier_contact successful 2025-12-28 21:32:31 +01:00
3e617d070a Creating new contact successful 2025-12-28 21:31:47 +01:00
08686a7b2f Added contact handling 2025-12-28 19:21:28 +01:00
d9506337ff Mega enriched articles' data 2025-12-28 12:34:49 +01:00
659dac81c9 enriched familles data and cleaned main file 2025-12-27 03:04:50 +01:00
679ae6a0e4 enrich fournisseurs's returned data and included for both, client too, contacts' details for each "tiers" 2025-12-26 18:10:57 +01:00
e8ea774c41 Deleted all comments 2025-12-26 15:38:10 +01:00
5591bff7b3 enriched client's details data 2025-12-26 14:41:38 +01:00
c9497adad2 updated client create pydantic schema 2025-12-26 14:40:48 +01:00
fde2b4615e Align modifier_client with creer_client 2025-12-26 09:38:48 +01:00
557f43bd18 creer_client opérationelle 2025-12-26 09:35:25 +01:00
57c05082c0 functionnal cree_client 2025-12-24 21:00:49 +01:00
7b451223ef Deleted PDF logics 2025-12-24 09:19:16 +01:00
0f9b3dfa0d dates and reference correctly done. Now, testing PDF generation 2025-12-22 14:09:06 +01:00
5257be0680 removed "date_expedition" handling 2025-12-20 15:23:50 +01:00
f0c84c9cb6 edited pydantic schemas to permit date_livraison and date_expedition gathering 2025-12-20 14:06:04 +01:00
c840cd5035 Added handling for date livraison and date expedition 2025-12-20 13:44:30 +01:00
5ccf470167 Added better handling for "reference" row data 2025-12-20 12:55:05 +01:00
42048d11ee creer et modifier devis mis à jour 2025-12-19 18:55:15 +01:00
6a4f7aaf2c Updated transformation workflow 2025-12-19 18:45:54 +01:00
eba4011dd4 Forgot all modifications that were done ! 2025-12-18 13:47:09 +01:00
Fanilo-Nantenaina
a729b812eb feat(stock): add lot number and stock min/max validation to movement line 2025-12-17 17:08:37 +03:00
Fanilo-Nantenaina
b799efef75 refactor(sage_connector): remove redundant section comments and clean up code 2025-12-17 11:27:19 +03:00
Fanilo-Nantenaina
99eda1c127 Added "familles" routes, and corrected articles' creation ; removed cache and used SQL Server directly for massive reading 2025-12-17 11:15:25 +03:00
Fanilo-Nantenaina
b619915ac1 feat: Add caching for sales quotes, orders, invoices, and suppliers with associated refresh logic and concurrency locks. 2025-12-08 18:08:01 +03:00
Fanilo-Nantenaina
bf986bef5a style: Improve code readability and formatting in main.py by adding empty lines and trailing commas. 2025-12-08 17:59:18 +03:00
Fanilo-Nantenaina
51f49298c2 feat: add PDF generation endpoint and optimize list endpoints 2025-12-08 17:39:51 +03:00
Fanilo-Nantenaina
ec5a0f0089 feat(sage): add invoice creation and update endpoints 2025-12-08 09:51:52 +03:00
Fanilo-Nantenaina
5a5b6307b9 Created endpoints for "avoirs" 2025-12-08 09:30:57 +03:00
Fanilo-Nantenaina
5c374892d0 Added create, update and transformation workflow for livraisons 2025-12-08 09:00:01 +03:00
Fanilo-Nantenaina
fc7216fe2f Modified "modifier_commande" method to delete lines correctly 2025-12-08 08:42:03 +03:00
Fanilo-Nantenaina
cc2b86d533 Made update command 2025-12-08 00:00:54 +03:00
Fanilo-Nantenaina
c4ddf03ae5 Updated devis creation logics to not automatically be on status 0 2025-12-06 18:13:04 +03:00
Fanilo-Nantenaina
9d569a8e03 Retored missing article extraction method 2025-12-06 17:24:48 +03:00
Fanilo-Nantenaina
de1771749d Update devis, Create and Update Command 2025-12-06 17:02:30 +03:00
Fanilo-Nantenaina
876e050bff Update fournisseur 2025-12-06 15:17:06 +03:00
Fanilo-Nantenaina
c66280b305 Creating new fournisseur 2025-12-06 15:03:13 +03:00
Fanilo-Nantenaina
3aadc67abf Made GET fournisseurs 2025-12-06 14:53:16 +03:00
Fanilo-Nantenaina
7ca64e2ea6 Push simple 2025-12-06 12:57:19 +03:00
Fanilo-Nantenaina
be7bc287c0 Updates for PUT on client 2025-12-06 10:16:44 +03:00
Fanilo-Nantenaina
3013f6589c Success : create client made with success 2025-12-06 09:45:35 +03:00
Fanilo-Nantenaina
6aebf00653 Trying to resolve error on creating client 2 2025-12-06 08:21:29 +03:00
Fanilo-Nantenaina
015943bdfa Trying to resolve error on creating client 2025-12-06 08:15:37 +03:00
Fanilo-Nantenaina
9d4f620bdc Truncating too long fields 2025-12-06 08:01:50 +03:00
Fanilo-Nantenaina
53517136f2 fix(sage_connector): improve client creation with better error handling 2025-12-05 22:41:33 +03:00
Fanilo-Nantenaina
c48a3f033a fix(sage_connector): enforce mandatory casting after Create() in client creation 2025-12-05 22:37:51 +03:00
Fanilo-Nantenaina
49fdc8425b refactor(sage_connector): improve client creation with better error handling and logging 2025-12-05 20:39:56 +03:00
Fanilo-Nantenaina
914ea61243 refactored create client method to align IBOClientFactory3's requirement 2025-12-05 20:29:21 +03:00
Fanilo-Nantenaina
e8558e207b Modified create client context 2025-12-05 19:37:48 +03:00
Fanilo-Nantenaina
2e96cec20d Added create client logics 2025-12-05 19:12:45 +03:00
Fanilo-Nantenaina
cc56821c70 feat(sage_connector): enhance document reading with client and article details 2025-12-05 14:44:36 +03:00
Fanilo-Nantenaina
ae5fa9e0be feat: Add API endpoints and Sage connector methods for managing prospects, suppliers, credit notes, and delivery notes. 2025-12-04 13:55:38 +03:00
Fanilo-Nantenaina
53ecccd712 Modified main.py to accept JSON body 2025-12-03 14:47:03 +03:00
Fanilo-Nantenaina
cec8389302 Document transformation process back to normal
Further diagnotic with new routes
2025-11-28 22:50:06 +03:00
Fanilo-Nantenaina
de0053b98b Diagnostic transformation selective error 2025-11-28 22:30:06 +03:00
Fanilo-Nantenaina
e6c2ab6670 refactor: improve client association and validation by safeguarding the client object and adding re-association logic. 2025-11-28 12:05:06 +03:00
Fanilo-Nantenaina
a4dd2c40ba fix: Improve client association logic by using SetClient with fallback to SetDefaultClient and enhanced verification. 2025-11-28 11:56:14 +03:00
Fanilo-Nantenaina
b06720eace fix: Explicitly set and verify CT_Num after associating a client to a document. 2025-11-28 11:53:18 +03:00
Fanilo-Nantenaina
5abeaebf56 refactor: Revise mandatory invoice field assignment by removing payment and tax calculation fields, and adding VAT regime and transaction type. 2025-11-28 11:47:50 +03:00
Fanilo-Nantenaina
8b676f7195 feat: Enhance mandatory invoice field population by adding fallback logic for payment, journal, and numbering, and including tax type/code. 2025-11-28 11:33:19 +03:00
Fanilo-Nantenaina
0763a56b06 Testing BC to FA 2025-11-28 11:11:23 +03:00
Fanilo-Nantenaina
6733f506eb refactor: Replace native Sage TransformInto() with manual document transformation logic, including source data extraction and simplified transformation rules. 2025-11-28 09:00:59 +03:00
Fanilo-Nantenaina
6c1de3583c feat: Add settings and validate_settings import from config module 2025-11-28 08:53:05 +03:00
Fanilo-Nantenaina
9b17149b07 fix: Introduce Sage document type constants and update document transformation and listing endpoints to use correct Sage types. 2025-11-28 08:28:51 +03:00
Fanilo-Nantenaina
9d0c26b5d8 Diag + Check 2025-11-28 06:48:23 +03:00
Fanilo-Nantenaina
92c79f1362 Diagnostic document transformation error 2025-11-28 06:30:32 +03:00
Fanilo-Nantenaina
3505ecfd2b Diagnostic devis 2025-11-28 06:23:19 +03:00
Fanilo-Nantenaina
c522aa5a64 Resolving error on getting all command list and creating a "command" 2025-11-28 06:14:19 +03:00
Fanilo-Nantenaina
8ce32fe8df Refactorisation for better error log 2025-11-28 06:00:34 +03:00
Fanilo-Nantenaina
02b6780d3f updated devis to command logics transformation 2025-11-28 05:54:28 +03:00
Fanilo-Nantenaina
6e8aa332ce Document transformation logics updated 2025-11-28 05:44:56 +03:00
Fanilo-Nantenaina
d9fe626cbd Deleted duplicate logics 2025-11-28 05:41:42 +03:00
Fanilo-Nantenaina
62077b5862 Revert "feat: Refactor devis status update to sage_connector with an updated API route and enforce default status 0 for new devis."
This reverts commit c4d2185c22.
2025-11-28 05:20:43 +03:00
Fanilo-Nantenaina
c4d2185c22 feat: Refactor devis status update to sage_connector with an updated API route and enforce default status 0 for new devis. 2025-11-28 05:03:48 +03:00
Fanilo-Nantenaina
96c9c5e7df refactor: Add date to datetime import and apply minor formatting adjustments. 2025-11-27 18:09:23 +03:00
Fanilo-Nantenaina
efa4edcae0 feat: add optional inclusion of line items to the devis list endpoint. 2025-11-27 17:48:01 +03:00
Fanilo-Nantenaina
4da650e361 feat: Add endpoint to update document's "Dernière relance" field. 2025-11-27 17:27:21 +03:00
Fanilo-Nantenaina
a1f7026fd9 fix: Use win32com.client.CastTo for COM objects and enhance devis listing error handling and client data retrieval. 2025-11-27 13:29:49 +03:00
Fanilo-Nantenaina
7ee3751ee2 feat: add endpoint to list devis with filters and update devis status endpoint signature and logic. 2025-11-27 12:44:39 +03:00
51 changed files with 18736 additions and 1204 deletions

View file

@ -1,14 +1,9 @@
# ============================================================================
# SAGE 100 CLOUD - CONNEXION BOI/COM
# ============================================================================
CHEMIN_BASE=<CHEMIN_VERS_LE_FICHIER_GCM>
UTILISATEUR=<UTILISATEUR_SAGE100>
MOT_DE_PASSE=<MOT_DE_PASSE_SAGE100>
SAGE_GATEWAY_TOKEN=<TOKEN_SAGE_GATEWAY>
# ============================================================================
# API - CONFIGURATION SERVEUR
# ============================================================================
API_HOST=0.0.0.0
API_PORT=8000

7
.gitignore vendored
View file

@ -1,7 +1,3 @@
# ================================
# Python / FastAPI
# ================================
# Environnements virtuels
venv/
.env
@ -36,3 +32,6 @@ htmlcov/
*~
.build/
dist/
*clean*.py

View file

@ -1,42 +1,50 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional, List
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
)
# === SAGE 100c (Windows uniquement) ===
SAGE_TYPE_DEVIS: int = 0
SAGE_TYPE_BON_COMMANDE: int = 10
SAGE_TYPE_PREPARATION: int = 20
SAGE_TYPE_BON_LIVRAISON: int = 30
SAGE_TYPE_BON_RETOUR: int = 40
SAGE_TYPE_BON_AVOIR: int = 50
SAGE_TYPE_FACTURE: int = 60
chemin_base: str
utilisateur: str = "Administrateur"
mot_de_passe: str
sql_server_name: str
sql_server_database: str
# === Sécurité Gateway ===
sage_gateway_token: str # Token partagé avec le VPS Linux
sage_gateway_token: str
# === SMTP (optionnel sur Windows) ===
smtp_host: Optional[str] = None
smtp_port: int = 587
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
smtp_from: Optional[str] = None
# === API Windows ===
api_host: str = "0.0.0.0"
api_port: int = 8000
# === CORS ===
cors_origins: List[str] = ["*"]
settings = Settings()
def validate_settings():
"""Validation au démarrage"""
if not settings.chemin_base or not settings.mot_de_passe:
raise ValueError("❌ CHEMIN_BASE et MOT_DE_PASSE requis dans .env")
raise ValueError(" CHEMIN_BASE ou MOT_DE_PASSE requis dans .env")
if not settings.sql_server_database or not settings.sql_server_name:
raise ValueError(" Infos de la base de données requises dans .env")
if not settings.sage_gateway_token:
raise ValueError("❌ SAGE_GATEWAY_TOKEN requis (doit être identique sur Linux)")
return True
raise ValueError(" SAGE_GATEWAY_TOKEN requis (doit être identique sur Linux)")
return True

1805
main.py

File diff suppressed because it is too large Load diff

View file

@ -4,4 +4,6 @@ pydantic
pydantic-settings
python-multipart
python-dotenv
pywin32
pywin32
pyodbc
reportlab

File diff suppressed because it is too large Load diff

94
schemas/__init__.py Normal file
View file

@ -0,0 +1,94 @@
from schemas.tiers.tiers import TiersList, TypeTiers
from schemas.tiers.contact import (
ContactCreate,
ContactDelete,
ContactGet,
ContactList,
ContactUpdate,
)
from schemas.tiers.clients import ClientCreate, ClientUpdate
from schemas.others.general_schema import (
FiltreRequest,
ChampLibre,
CodeRequest,
StatutRequest,
)
from schemas.documents.documents import (
TransformationRequest,
TypeDocument,
DocumentGet,
PDFGeneration,
)
from schemas.documents.devis import DevisRequest, DevisUpdate
from schemas.tiers.fournisseurs import FournisseurCreate, FournisseurUpdate
from schemas.documents.avoirs import AvoirCreate, AvoirUpdate
from schemas.documents.commandes import CommandeCreate, CommandeUpdate
from schemas.documents.factures import FactureCreate, FactureUpdate
from schemas.documents.livraisons import LivraisonCreate, LivraisonUpdate
from schemas.articles.articles import (
ArticleCreate,
ArticleUpdate,
MouvementStockLigneRequest,
EntreeStock,
SortieStock,
)
from schemas.articles.famille_d_articles import FamilleCreate
from schemas.tiers.commercial import (
CollaborateurCreateRequest,
CollaborateurListRequest,
CollaborateurNumeroRequest,
CollaborateurUpdateRequest,
)
__all__ = [
"TiersList",
"ContactCreate",
"ContactDelete",
"ContactGet",
"ContactList",
"ContactUpdate",
"ClientCreate",
"ClientUpdate",
"FiltreRequest",
"ChampLibre",
"CodeRequest",
"TransformationRequest",
"TypeDocument",
"DevisRequest",
"DocumentGet",
"StatutRequest",
"TypeTiers",
"DevisUpdate",
"FournisseurCreate",
"FournisseurUpdate",
"AvoirCreate",
"AvoirUpdate",
"CommandeCreate",
"CommandeUpdate",
"FactureCreate",
"FactureUpdate",
"LivraisonCreate",
"LivraisonUpdate",
"ArticleCreate",
"ArticleUpdate",
"MouvementStockLigneRequest",
"EntreeStock",
"SortieStock",
"FamilleCreate",
"PDFGeneration",
"CollaborateurCreateRequest",
"CollaborateurListRequest",
"CollaborateurNumeroRequest",
"CollaborateurUpdateRequest",
]

View file

@ -0,0 +1,149 @@
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict
from datetime import date
class ArticleCreate(BaseModel):
reference: str = Field(..., description="Référence article (max 18 car)")
designation: str = Field(..., description="Désignation (max 69 car)")
famille: Optional[str] = Field(None, description="Code famille")
prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT")
prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT")
coef: Optional[float] = Field(None, ge=0, description="Coefficient")
stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial")
stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum")
stock_maxi: Optional[float] = Field(None, ge=0, description="Stock maximum")
code_ean: Optional[str] = Field(None, description="Code-barres EAN")
unite_vente: Optional[str] = Field("UN", description="Unité de vente")
tva_code: Optional[str] = Field(None, description="Code TVA")
code_fiscal: Optional[str] = Field(None, description="Code fiscal")
description: Optional[str] = Field(None, description="Description/Commentaire")
pays: Optional[str] = Field(None, description="Pays d'origine")
garantie: Optional[int] = Field(None, ge=0, description="Garantie en mois")
delai: Optional[int] = Field(None, ge=0, description="Délai livraison jours")
poids_net: Optional[float] = Field(None, ge=0, description="Poids net kg")
poids_brut: Optional[float] = Field(None, ge=0, description="Poids brut kg")
stat_01: Optional[str] = Field(None, description="Statistique 1")
stat_02: Optional[str] = Field(None, description="Statistique 2")
stat_03: Optional[str] = Field(None, description="Statistique 3")
stat_04: Optional[str] = Field(None, description="Statistique 4")
stat_05: Optional[str] = Field(None, description="Statistique 5")
soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte")
publie: Optional[bool] = Field(None, description="Publié web/catalogue")
en_sommeil: Optional[bool] = Field(None, description="Article en sommeil")
class ArticleUpdate(BaseModel):
reference: str = Field(..., description="Référence de l'article à modifier")
article_data: Dict = Field(..., description="Données à modifier")
class Config:
json_schema_extra = {
"example": {
"reference": "ART001",
"article_data": {
"designation": "Nouvelle désignation",
"prix_vente": 150.0,
"famille": "FAM01",
"stock_reel": 100.0,
"stock_mini": 10.0,
"code_fiscal": "V19",
"garantie": 24,
},
}
}
class MouvementStockLigneRequest(BaseModel):
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)"
)
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 EntreeStock(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)"
)
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"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")
class SortieStock(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)"
)
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"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")

View file

@ -0,0 +1,16 @@
from pydantic import BaseModel, Field
from typing import Optional
class FamilleCreate(BaseModel):
"""Modèle pour créer une famille d'articles"""
code: str = Field(..., description="Code famille (max 18 car)", max_length=18)
intitule: str = Field(..., description="Intitulé (max 69 car)", max_length=69)
type: int = Field(0, description="0=Détail, 1=Total")
compte_achat: Optional[str] = Field(
None, description="Compte général d'achat (ex: 607000)"
)
compte_vente: Optional[str] = Field(
None, description="Compte général de vente (ex: 707000)"
)

View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime
class AvoirCreate(BaseModel):
"""Création d'un avoir côté gateway"""
client_id: str
date_avoir: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[Dict]
reference: Optional[str] = None
class AvoirUpdate(BaseModel):
"""Modèle pour modification avoir côté gateway"""
numero: str
avoir_data: Dict

View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime
class CommandeCreate(BaseModel):
"""Création d'une commande"""
client_id: str
date_commande: Optional[datetime] = None
date_livraison: Optional[datetime] = None
reference: Optional[str] = None
lignes: List[Dict]
class CommandeUpdate(BaseModel):
"""Modèle pour modification commande côté gateway"""
numero: str
commande_data: Dict

View file

@ -0,0 +1,18 @@
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime
class DevisRequest(BaseModel):
client_id: str
date_devis: Optional[datetime] = None
date_livraison: Optional[datetime] = None
reference: Optional[str] = None
lignes: List[Dict]
class DevisUpdate(BaseModel):
"""Modèle pour modification devis côté gateway"""
numero: str
devis_data: Dict

View file

@ -0,0 +1,79 @@
from typing import Optional
from enum import Enum
from config import settings
class TypeDocumentVente(Enum):
"""Types de documents de vente supportés"""
DEVIS = 0
COMMANDE = 1
LIVRAISON = 3
FACTURE = 6
AVOIR = 5
class ConfigDocument:
"""Configuration spécifique pour chaque type de document"""
def __init__(self, type_doc: TypeDocumentVente):
self.type_doc = type_doc
self.type_sage = self._get_type_sage()
self.champ_date_principale = self._get_champ_date()
self.champ_numero = self._get_champ_numero()
self.nom_document = self._get_nom_document()
self.champ_date_secondaire = self._get_champ_date_secondaire()
def _get_type_sage(self) -> int:
mapping = {
TypeDocumentVente.DEVIS: settings.SAGE_TYPE_DEVIS,
TypeDocumentVente.COMMANDE: settings.SAGE_TYPE_BON_COMMANDE,
TypeDocumentVente.LIVRAISON: settings.SAGE_TYPE_BON_LIVRAISON,
TypeDocumentVente.FACTURE: settings.SAGE_TYPE_FACTURE,
TypeDocumentVente.AVOIR: settings.SAGE_TYPE_BON_AVOIR,
}
return mapping[self.type_doc]
def _get_champ_date(self) -> str:
"""Retourne le nom du champ principal dans les données"""
mapping = {
TypeDocumentVente.DEVIS: "date_devis",
TypeDocumentVente.COMMANDE: "date_commande",
TypeDocumentVente.LIVRAISON: "date_livraison",
TypeDocumentVente.FACTURE: "date_facture",
TypeDocumentVente.AVOIR: "date_avoir",
}
return mapping[self.type_doc]
def _get_champ_date_secondaire(self) -> Optional[str]:
"""Retourne le nom du champ secondaire (date de livraison, etc.)"""
mapping = {
TypeDocumentVente.DEVIS: "date_livraison",
TypeDocumentVente.COMMANDE: "date_livraison",
TypeDocumentVente.LIVRAISON: "date_livraison_prevue",
TypeDocumentVente.FACTURE: "date_livraison",
TypeDocumentVente.AVOIR: "date_livraison",
}
return mapping.get(self.type_doc)
def _get_champ_numero(self) -> str:
"""Retourne le nom du champ pour le numéro de document dans le résultat"""
mapping = {
TypeDocumentVente.DEVIS: "numero_devis",
TypeDocumentVente.COMMANDE: "numero_commande",
TypeDocumentVente.LIVRAISON: "numero_livraison",
TypeDocumentVente.FACTURE: "numero_facture",
TypeDocumentVente.AVOIR: "numero_avoir",
}
return mapping[self.type_doc]
def _get_nom_document(self) -> str:
"""Retourne le nom du document pour les logs"""
mapping = {
TypeDocumentVente.DEVIS: "devis",
TypeDocumentVente.COMMANDE: "commande",
TypeDocumentVente.LIVRAISON: "livraison",
TypeDocumentVente.FACTURE: "facture",
TypeDocumentVente.AVOIR: "avoir",
}
return mapping[self.type_doc]

View file

@ -0,0 +1,29 @@
from pydantic import BaseModel, Field
from enum import Enum
class TypeDocument(int, Enum):
DEVIS = 0
BON_LIVRAISON = 1
BON_RETOUR = 2
COMMANDE = 3
PREPARATION = 4
FACTURE = 5
class DocumentGet(BaseModel):
numero: str
type_doc: int
class TransformationRequest(BaseModel):
numero_source: str
type_source: int
type_cible: int
class PDFGeneration(BaseModel):
"""Modèle pour génération PDF"""
doc_id: str = Field(..., description="Numéro du document")
type_doc: int = Field(..., ge=0, le=60, description="Type de document Sage")

View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime
class FactureCreate(BaseModel):
"""Création d'une facture côté gateway"""
client_id: str
date_facture: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[Dict]
reference: Optional[str] = None
class FactureUpdate(BaseModel):
"""Modèle pour modification facture côté gateway"""
numero: str
facture_data: Dict

View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime
class LivraisonCreate(BaseModel):
"""Création d'une livraison côté gateway"""
client_id: str
date_livraison: Optional[datetime] = None
date_livraison_prevue: Optional[datetime] = None
lignes: List[Dict]
reference: Optional[str] = None
class LivraisonUpdate(BaseModel):
"""Modèle pour modification livraison côté gateway"""
numero: str
livraison_data: Dict

View file

@ -0,0 +1,238 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import datetime
from enum import IntEnum
class ModeReglement:
"""Modes de règlement Sage 100c"""
VIREMENT = 1
CHEQUE = 2
TRAITE = 3
CARTE_BANCAIRE = 4
LCR = 5
PRELEVEMENT = 6
ESPECES = 7
LIBELLES = {
1: "Virement",
2: "Chèque",
3: "Traite",
4: "Carte bancaire",
5: "LCR",
6: "Prélèvement",
7: "Espèces",
}
@classmethod
def get_libelle(cls, code: int) -> str:
return cls.LIBELLES.get(code, f"Mode {code}")
class ModeReglementEnum(IntEnum):
"""Modes de règlement Sage 100c"""
VIREMENT = 1
CHEQUE = 2
TRAITE = 3
CARTE_BANCAIRE = 4
LCR = 5
PRELEVEMENT = 6
ESPECES = 7
class ReglementFactureRequest(BaseModel):
"""Requête de règlement d'une facture"""
montant: float = Field(..., gt=0, description="Montant du règlement")
mode_reglement: int = Field(
default=2,
ge=1,
le=7,
description="Mode de règlement (1=Virement, 2=Chèque, 3=Traite, 4=CB, 5=LCR, 6=Prélèvement, 7=Espèces)",
)
date_reglement: Optional[datetime] = Field(
default=None, description="Date du règlement (défaut: aujourd'hui)"
)
reference: Optional[str] = Field(
default="", max_length=35, description="Référence du règlement"
)
libelle: Optional[str] = Field(
default="", max_length=69, description="Libellé du règlement"
)
code_journal: str = Field(
default="BEU", max_length=6, description="Code journal comptable"
)
devise_code: Optional[int] = Field(0)
cours_devise: Optional[float] = Field(1.0)
tva_encaissement: Optional[bool] = Field(False)
compte_general: Optional[str] = Field(None)
@field_validator("montant")
def validate_montant(cls, v):
if v <= 0:
raise ValueError("Le montant doit être positif")
return round(v, 2)
class Config:
json_schema_extra = {
"example": {
"montant": 375.12,
"mode_reglement": 2,
"date_reglement": "2026-01-06T00:00:00",
"reference": "CHQ-001",
"libelle": "Règlement facture",
"code_journal": "BEU",
}
}
class ReglementMultipleRequest(BaseModel):
"""Requête de règlement multiple pour un client"""
client_code: str = Field(..., description="Code client")
montant_total: float = Field(..., gt=0, description="Montant total à régler")
mode_reglement: int = Field(default=2, ge=1, le=7)
date_reglement: Optional[datetime] = None
reference: Optional[str] = Field(default="", max_length=35)
libelle: Optional[str] = Field(default="", max_length=69)
code_journal: str = Field(default="BEU", max_length=6)
numeros_factures: Optional[List[str]] = Field(
default=None,
description="Liste des factures à régler (sinon: plus anciennes d'abord)",
)
devise_code: Optional[int] = Field(0)
cours_devise: Optional[float] = Field(1.0)
tva_encaissement: Optional[bool] = Field(False)
@field_validator("client_code", mode="before")
def strip_client_code(cls, v):
return v.replace("\xa0", "").strip() if v else v
@field_validator("montant_total")
def validate_montant(cls, v):
if v <= 0:
raise ValueError("Le montant doit être positif")
return round(v, 2)
class Config:
json_schema_extra = {
"example": {
"client_code": "CLI000001",
"montant_total": 1000.00,
"mode_reglement": 2,
"reference": "VIR-MULTI-001",
"numeros_factures": ["FA00081", "FA00082"],
}
}
class ReglementResponse(BaseModel):
"""Réponse d'un règlement effectué"""
numero_facture: str
numero_reglement: Optional[str]
montant_regle: float
date_reglement: str
mode_reglement: int
mode_reglement_libelle: str
reference: str
libelle: str
code_journal: str
total_facture: float
solde_restant: float
facture_soldee: bool
client_code: str
class ReglementMultipleResponse(BaseModel):
"""Réponse d'un règlement multiple"""
client_code: str
montant_demande: float
montant_effectif: float
nb_factures_reglees: int
nb_factures_soldees: int
date_reglement: str
mode_reglement: int
mode_reglement_libelle: str
reference: str
reglements: List[ReglementResponse]
class ReglementDetail(BaseModel):
"""Détail d'un règlement"""
id: int
date: Optional[str]
montant: float
reference: str
libelle: str
mode_reglement: int
mode_reglement_libelle: str
code_journal: str
class ReglementsFactureResponse(BaseModel):
"""Réponse: tous les règlements d'une facture"""
numero_facture: str
client_code: str
date_facture: Optional[str]
reference: str
total_ttc: float
total_regle: float
solde_restant: float
est_soldee: bool
nb_reglements: int
reglements: List[ReglementDetail]
class FactureAvecReglements(BaseModel):
"""Facture avec ses règlements"""
numero_facture: str
date_facture: Optional[str]
total_ttc: float
reference: str
total_regle: float
solde_restant: float
est_soldee: bool
nb_reglements: int
reglements: List[ReglementDetail]
class ReglementsClientResponse(BaseModel):
"""Réponse: tous les règlements d'un client"""
client_code: str
client_intitule: str
nb_factures: int
nb_factures_soldees: int
nb_factures_en_cours: int
total_factures: float
total_regle: float
solde_global: float
factures: List[FactureAvecReglements]
class ModeReglementInfo(BaseModel):
"""Information sur un mode de règlement"""
code: int
libelle: str
class ModesReglementResponse(BaseModel):
"""Liste des modes de règlement disponibles"""
modes: List[ModeReglementInfo] = [
ModeReglementInfo(code=1, libelle="Virement"),
ModeReglementInfo(code=2, libelle="Chèque"),
ModeReglementInfo(code=3, libelle="Traite"),
ModeReglementInfo(code=4, libelle="Carte bancaire"),
ModeReglementInfo(code=5, libelle="LCR"),
ModeReglementInfo(code=6, libelle="Prélèvement"),
ModeReglementInfo(code=7, libelle="Espèces"),
]

View file

@ -0,0 +1,21 @@
from pydantic import BaseModel
from typing import Optional
class FiltreRequest(BaseModel):
filtre: Optional[str] = ""
class CodeRequest(BaseModel):
code: str
class ChampLibre(BaseModel):
doc_id: str
type_doc: int
nom_champ: str
valeur: str
class StatutRequest(BaseModel):
nouveau_statut: int

411
schemas/tiers/clients.py Normal file
View file

@ -0,0 +1,411 @@
from pydantic import BaseModel, Field, field_validator
from typing import Optional, Dict
class ClientCreate(BaseModel):
intitule: str = Field(
..., max_length=69, description="Nom du client (CT_Intitule) - OBLIGATOIRE"
)
numero: Optional[str] = Field(
None, max_length=17, description="Numéro client CT_Num (auto si None)"
)
type_tiers: int = Field(
0,
ge=0,
le=3,
description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre",
)
qualite: Optional[str] = Field(
"CLI", max_length=17, description="CT_Qualite: CLI/FOU/SAL/DIV/AUT"
)
classement: Optional[str] = Field(None, max_length=17, description="CT_Classement")
raccourci: Optional[str] = Field(
None, max_length=7, description="CT_Raccourci (7 chars max, unique)"
)
siret: Optional[str] = Field(
None, max_length=15, description="CT_Siret (14-15 chars)"
)
tva_intra: Optional[str] = Field(
None, max_length=25, description="CT_Identifiant (TVA intracommunautaire)"
)
code_naf: Optional[str] = Field(
None, max_length=7, description="CT_Ape (Code NAF/APE)"
)
contact: Optional[str] = Field(
None,
max_length=35,
description="CT_Contact (double affectation: client + adresse)",
)
adresse: Optional[str] = Field(None, max_length=35, description="Adresse.Adresse")
complement: Optional[str] = Field(
None, max_length=35, description="Adresse.Complement"
)
code_postal: Optional[str] = Field(
None, max_length=9, description="Adresse.CodePostal"
)
ville: Optional[str] = Field(None, max_length=35, description="Adresse.Ville")
region: Optional[str] = Field(None, max_length=25, description="Adresse.CodeRegion")
pays: Optional[str] = Field(None, max_length=35, description="Adresse.Pays")
telephone: Optional[str] = Field(
None, max_length=21, description="Telecom.Telephone"
)
telecopie: Optional[str] = Field(
None, max_length=21, description="Telecom.Telecopie (fax)"
)
email: Optional[str] = Field(None, max_length=69, description="Telecom.EMail")
site_web: Optional[str] = Field(None, max_length=69, description="Telecom.Site")
portable: Optional[str] = Field(None, max_length=21, description="Telecom.Portable")
facebook: Optional[str] = Field(
None, max_length=69, description="Telecom.Facebook ou CT_Facebook"
)
linkedin: Optional[str] = Field(
None, max_length=69, description="Telecom.LinkedIn ou CT_LinkedIn"
)
compte_general: Optional[str] = Field(
None,
max_length=13,
description="CompteGPrinc (défaut selon type_tiers: 4110000, 4010000, 421, 471)",
)
categorie_tarifaire: Optional[str] = Field(
None, description="N_CatTarif (ID catégorie tarifaire, défaut '0' ou '1')"
)
categorie_comptable: Optional[str] = Field(
None, description="N_CatCompta (ID catégorie comptable, défaut '0' ou '1')"
)
taux01: Optional[float] = Field(None, description="CT_Taux01")
taux02: Optional[float] = Field(None, description="CT_Taux02")
taux03: Optional[float] = Field(None, description="CT_Taux03")
taux04: Optional[float] = Field(None, description="CT_Taux04")
secteur: Optional[str] = Field(
None, max_length=21, description="Alias de statistique01 (CT_Statistique01)"
)
statistique01: Optional[str] = Field(
None, max_length=21, description="CT_Statistique01"
)
statistique02: Optional[str] = Field(
None, max_length=21, description="CT_Statistique02"
)
statistique03: Optional[str] = Field(
None, max_length=21, description="CT_Statistique03"
)
statistique04: Optional[str] = Field(
None, max_length=21, description="CT_Statistique04"
)
statistique05: Optional[str] = Field(
None, max_length=21, description="CT_Statistique05"
)
statistique06: Optional[str] = Field(
None, max_length=21, description="CT_Statistique06"
)
statistique07: Optional[str] = Field(
None, max_length=21, description="CT_Statistique07"
)
statistique08: Optional[str] = Field(
None, max_length=21, description="CT_Statistique08"
)
statistique09: Optional[str] = Field(
None, max_length=21, description="CT_Statistique09"
)
statistique10: Optional[str] = Field(
None, max_length=21, description="CT_Statistique10"
)
encours_autorise: Optional[float] = Field(
None, description="CT_Encours (montant max autorisé)"
)
assurance_credit: Optional[float] = Field(
None, description="CT_Assurance (montant assurance crédit)"
)
langue: Optional[int] = Field(
None, ge=0, description="CT_Langue (0=Français, 1=Anglais, etc.)"
)
commercial_code: Optional[int] = Field(
None, description="CO_No (ID du collaborateur commercial)"
)
lettrage_auto: Optional[bool] = Field(
True, description="CT_Lettrage (1=oui, 0=non)"
)
est_actif: Optional[bool] = Field(
True, description="Inverse de CT_Sommeil (True=actif, False=en sommeil)"
)
type_facture: Optional[int] = Field(
1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée"
)
est_prospect: Optional[bool] = Field(
False, description="CT_Prospect (1=oui, 0=non)"
)
bl_en_facture: Optional[int] = Field(
None, ge=0, le=1, description="CT_BLFact (impression BL sur facture)"
)
saut_page: Optional[int] = Field(
None, ge=0, le=1, description="CT_Saut (saut de page après impression)"
)
validation_echeance: Optional[int] = Field(
None, ge=0, le=1, description="CT_ValidEch"
)
controle_encours: Optional[int] = Field(
None, ge=0, le=1, description="CT_ControlEnc"
)
exclure_relance: Optional[int] = Field(None, ge=0, le=1, description="CT_NotRappel")
exclure_penalites: Optional[int] = Field(
None, ge=0, le=1, description="CT_NotPenal"
)
bon_a_payer: Optional[int] = Field(None, ge=0, le=1, description="CT_BonAPayer")
priorite_livraison: Optional[int] = Field(
None, ge=0, le=5, description="CT_PrioriteLivr"
)
livraison_partielle: Optional[int] = Field(
None, ge=0, le=1, description="CT_LivrPartielle"
)
delai_transport: Optional[int] = Field(
None, ge=0, description="CT_DelaiTransport (jours)"
)
delai_appro: Optional[int] = Field(None, ge=0, description="CT_DelaiAppro (jours)")
commentaire: Optional[str] = Field(
None, max_length=35, description="CT_Commentaire"
)
section_analytique: Optional[str] = Field(None, max_length=13, description="CA_Num")
mode_reglement_code: Optional[int] = Field(
None, description="MR_No (ID du mode de règlement)"
)
surveillance_active: Optional[int] = Field(
None, ge=0, le=1, description="CT_Surveillance (DOIT être défini AVANT coface)"
)
coface: Optional[str] = Field(
None, max_length=25, description="CT_Coface (code Coface)"
)
forme_juridique: Optional[str] = Field(
None, max_length=33, description="CT_SvFormeJuri (SARL, SA, etc.)"
)
effectif: Optional[str] = Field(None, max_length=11, description="CT_SvEffectif")
sv_regularite: Optional[str] = Field(None, max_length=3, description="CT_SvRegul")
sv_cotation: Optional[str] = Field(None, max_length=5, description="CT_SvCotation")
sv_objet_maj: Optional[str] = Field(
None, max_length=61, description="CT_SvObjetMaj"
)
ca_annuel: Optional[float] = Field(
None,
description="CT_SvCA (Chiffre d'affaires annuel) - alias: sv_chiffre_affaires",
)
sv_chiffre_affaires: Optional[float] = Field(
None, description="CT_SvCA (alias de ca_annuel)"
)
sv_resultat: Optional[float] = Field(None, description="CT_SvResultat")
@field_validator("siret")
@classmethod
def validate_siret(cls, v):
"""Valide et nettoie le SIRET"""
if v and v.lower() not in ("none", "null", ""):
cleaned = v.replace(" ", "").replace("-", "")
if len(cleaned) not in (14, 15):
raise ValueError("Le SIRET doit contenir 14 ou 15 caractères")
return cleaned
return None
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""Valide le format email"""
if v and v.lower() not in ("none", "null", ""):
v = v.strip()
if "@" not in v:
raise ValueError("Format email invalide")
return v
return None
@field_validator("raccourci")
@classmethod
def validate_raccourci(cls, v):
"""Force le raccourci en majuscules"""
if v and v.lower() not in ("none", "null", ""):
return v.upper().strip()[:7]
return None
@field_validator(
"adresse",
"code_postal",
"ville",
"pays",
"telephone",
"tva_intra",
"contact",
"complement",
mode="before",
)
@classmethod
def clean_none_strings(cls, v):
"""Convertit les chaînes 'None'/'null'/'' en None"""
if isinstance(v, str) and v.lower() in ("none", "null", ""):
return None
return v
def to_sage_dict(self) -> dict:
"""
Convertit le modèle en dictionnaire compatible avec creer_client()
Mapping 1:1 avec les paramètres réels de la fonction
"""
stat01 = self.statistique01 or self.secteur
ca = self.ca_annuel or self.sv_chiffre_affaires
return {
"intitule": self.intitule,
"numero": self.numero,
"type_tiers": self.type_tiers,
"qualite": self.qualite,
"classement": self.classement,
"raccourci": self.raccourci,
"siret": self.siret,
"tva_intra": self.tva_intra,
"code_naf": self.code_naf,
"contact": self.contact,
"adresse": self.adresse,
"complement": self.complement,
"code_postal": self.code_postal,
"ville": self.ville,
"region": self.region,
"pays": self.pays,
"telephone": self.telephone,
"telecopie": self.telecopie,
"email": self.email,
"site_web": self.site_web,
"portable": self.portable,
"facebook": self.facebook,
"linkedin": self.linkedin,
"compte_general": self.compte_general,
"categorie_tarifaire": self.categorie_tarifaire,
"categorie_comptable": self.categorie_comptable,
"taux01": self.taux01,
"taux02": self.taux02,
"taux03": self.taux03,
"taux04": self.taux04,
"statistique01": stat01,
"statistique02": self.statistique02,
"statistique03": self.statistique03,
"statistique04": self.statistique04,
"statistique05": self.statistique05,
"statistique06": self.statistique06,
"statistique07": self.statistique07,
"statistique08": self.statistique08,
"statistique09": self.statistique09,
"statistique10": self.statistique10,
"secteur": self.secteur,
"encours_autorise": self.encours_autorise,
"assurance_credit": self.assurance_credit,
"langue": self.langue,
"commercial_code": self.commercial_code,
"lettrage_auto": self.lettrage_auto,
"est_actif": self.est_actif,
"type_facture": self.type_facture,
"est_prospect": self.est_prospect,
"bl_en_facture": self.bl_en_facture,
"saut_page": self.saut_page,
"validation_echeance": self.validation_echeance,
"controle_encours": self.controle_encours,
"exclure_relance": self.exclure_relance,
"exclure_penalites": self.exclure_penalites,
"bon_a_payer": self.bon_a_payer,
"priorite_livraison": self.priorite_livraison,
"livraison_partielle": self.livraison_partielle,
"delai_transport": self.delai_transport,
"delai_appro": self.delai_appro,
"commentaire": self.commentaire,
"section_analytique": self.section_analytique,
"mode_reglement_code": self.mode_reglement_code,
"surveillance_active": self.surveillance_active,
"coface": self.coface,
"forme_juridique": self.forme_juridique,
"effectif": self.effectif,
"sv_regularite": self.sv_regularite,
"sv_cotation": self.sv_cotation,
"sv_objet_maj": self.sv_objet_maj,
"ca_annuel": ca,
"sv_chiffre_affaires": self.sv_chiffre_affaires,
"sv_resultat": self.sv_resultat,
}
class Config:
json_schema_extra = {
"example": {
"intitule": "ENTREPRISE EXEMPLE SARL",
"numero": "CLI00123",
"type_tiers": 0,
"qualite": "CLI",
"compte_general": "411000",
"est_prospect": False,
"est_actif": True,
"email": "contact@exemple.fr",
"telephone": "0123456789",
"adresse": "123 Rue de la Paix",
"code_postal": "75001",
"ville": "Paris",
"pays": "France",
}
}
class ClientUpdate(BaseModel):
"""Modèle pour modification client côté gateway"""
code: str
client_data: Dict

View file

@ -0,0 +1,65 @@
from pydantic import BaseModel
from typing import Optional
class CollaborateurListRequest(BaseModel):
filtre: str = ""
actifs_seulement: bool = True
class CollaborateurNumeroRequest(BaseModel):
numero: int
class CollaborateurCreateRequest(BaseModel):
nom: str
prenom: Optional[str] = None
fonction: Optional[str] = None
adresse: Optional[str] = None
complement: Optional[str] = None
code_postal: Optional[str] = None
ville: Optional[str] = None
code_region: Optional[str] = None
pays: Optional[str] = None
service: Optional[str] = None
vendeur: bool = False
caissier: bool = False
acheteur: bool = False
chef_ventes: bool = False
numero_chef_ventes: Optional[int] = None
telephone: Optional[str] = None
telecopie: Optional[str] = None
email: Optional[str] = None
tel_portable: Optional[str] = None
facebook: Optional[str] = None
linkedin: Optional[str] = None
skype: Optional[str] = None
matricule: Optional[str] = None
sommeil: bool = False
class CollaborateurUpdateRequest(CollaborateurNumeroRequest):
nom: Optional[str] = None
prenom: Optional[str] = None
fonction: Optional[str] = None
adresse: Optional[str] = None
complement: Optional[str] = None
code_postal: Optional[str] = None
ville: Optional[str] = None
code_region: Optional[str] = None
pays: Optional[str] = None
service: Optional[str] = None
vendeur: Optional[bool] = None
caissier: Optional[bool] = None
acheteur: Optional[bool] = None
chef_ventes: Optional[bool] = None
numero_chef_ventes: Optional[int] = None
telephone: Optional[str] = None
telecopie: Optional[str] = None
email: Optional[str] = None
tel_portable: Optional[str] = None
facebook: Optional[str] = None
linkedin: Optional[str] = None
skype: Optional[str] = None
matricule: Optional[str] = None
sommeil: Optional[bool] = None

48
schemas/tiers/contact.py Normal file
View file

@ -0,0 +1,48 @@
from pydantic import BaseModel
from typing import Optional, Dict
class ContactCreate(BaseModel):
"""Requête de création de contact"""
numero: str
civilite: Optional[str] = None
nom: str
prenom: Optional[str] = None
fonction: Optional[str] = None
service_code: Optional[int] = None
telephone: Optional[str] = None
portable: Optional[str] = None
telecopie: Optional[str] = None
email: Optional[str] = None
facebook: Optional[str] = None
linkedin: Optional[str] = None
skype: Optional[str] = None
class ContactList(BaseModel):
"""Requête de liste des contacts"""
numero: str
class ContactGet(BaseModel):
"""Requête de récupération d'un contact"""
numero: str
contact_numero: int
class ContactUpdate(BaseModel):
"""Requête de modification d'un contact"""
numero: str
contact_numero: int
updates: Dict
class ContactDelete(BaseModel):
"""Requête de suppression d'un contact"""
numero: str
contact_numero: int

View file

@ -0,0 +1,23 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict
class FournisseurCreate(BaseModel):
intitule: str = Field(..., description="Raison sociale du fournisseur")
compte_collectif: str = Field("401000", description="Compte général rattaché")
num: Optional[str] = Field(None, description="Code fournisseur (auto si vide)")
adresse: Optional[str] = None
code_postal: Optional[str] = None
ville: Optional[str] = None
pays: Optional[str] = None
email: Optional[str] = None
telephone: Optional[str] = None
siret: Optional[str] = None
tva_intra: Optional[str] = None
class FournisseurUpdate(BaseModel):
"""Modèle pour modification fournisseur côté gateway"""
code: str
fournisseur_data: Dict

21
schemas/tiers/tiers.py Normal file
View file

@ -0,0 +1,21 @@
from pydantic import BaseModel, Field
from typing import Optional
from enum import IntEnum
class TiersList(BaseModel):
"""Requête de listage des tiers"""
type_tiers: Optional[str] = Field(
None, description="Type: client, fournisseur, prospect, all"
)
filtre: str = Field("", description="Filtre sur code ou intitulé")
class TypeTiers(IntEnum):
"""CT_Type - Type de tiers"""
CLIENT = 0
FOURNISSEUR = 1
SALARIE = 2
AUTRE = 3

48
utils/__init__.py Normal file
View file

@ -0,0 +1,48 @@
from .enums import (
TypeArticle,
TypeCompta,
TypeRessource,
TypeTiers,
TypeEmplacement,
TypeFamille,
NomenclatureType,
SuiviStockType,
normalize_enum_to_string,
normalize_enum_to_int,
normalize_string_field,
)
from .article_fields import (
valider_donnees_creation,
mapper_champ_api_vers_sage,
CHAMPS_STOCK_INITIAL,
CHAMPS_ASSIGNABLES_CREATION,
CHAMPS_ASSIGNABLES_MODIFICATION,
CHAMPS_OBJETS_SPECIAUX,
valider_champ,
valider_donnees_modification,
obtenir_champs_assignables
)
__all__ = [
"TypeArticle",
"TypeCompta",
"TypeRessource",
"TypeTiers",
"TypeEmplacement",
"TypeFamille",
"NomenclatureType",
"SuiviStockType",
"normalize_enum_to_string",
"normalize_enum_to_int",
"normalize_string_field",
"valider_donnees_creation",
"mapper_champ_api_vers_sage",
"CHAMPS_STOCK_INITIAL",
"CHAMPS_ASSIGNABLES_MODIFICATION",
"CHAMPS_OBJETS_SPECIAUX",
"CHAMPS_ASSIGNABLES_CREATION",
"valider_champ",
"valider_donnees_modification",
"obtenir_champs_assignables"
]

170
utils/article_fields.py Normal file
View file

@ -0,0 +1,170 @@
from typing import Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
CHAMPS_ASSIGNABLES_CREATION = {
"AR_Design": {"max_length": 69, "required": True, "description": "Désignation"},
"AR_PrixVen": {"type": float, "min": 0, "description": "Prix de vente HT"},
"AR_PrixAch": {"type": float, "min": 0, "description": "Prix achat HT"},
"AR_PrixAchat": {"type": float, "min": 0, "description": "Prix achat HT (alias)"},
"AR_CodeBarre": {"max_length": 13, "description": "Code-barres EAN"},
"AR_Commentaire": {"max_length": 255, "description": "Description/Commentaire"},
"AR_UniteVen": {"max_length": 10, "description": "Unité de vente"},
"AR_CodeFiscal": {"max_length": 10, "description": "Code fiscal/TVA"},
"AR_Pays": {"max_length": 3, "description": "Pays d'origine"},
"AR_Garantie": {"type": int, "min": 0, "description": "Garantie en mois"},
"AR_Delai": {"type": int, "min": 0, "description": "Délai livraison jours"},
"AR_Coef": {"type": float, "min": 0, "description": "Coefficient"},
"AR_PoidsNet": {"type": float, "min": 0, "description": "Poids net kg"},
"AR_PoidsBrut": {"type": float, "min": 0, "description": "Poids brut kg"},
"AR_Stat01": {"max_length": 20, "description": "Statistique 1"},
"AR_Stat02": {"max_length": 20, "description": "Statistique 2"},
"AR_Stat03": {"max_length": 20, "description": "Statistique 3"},
"AR_Stat04": {"max_length": 20, "description": "Statistique 4"},
"AR_Stat05": {"max_length": 20, "description": "Statistique 5"},
"AR_Escompte": {"type": bool, "description": "Soumis à escompte"},
"AR_Publie": {"type": bool, "description": "Publié web/catalogue"},
"AR_Sommeil": {"type": int, "values": [0, 1], "description": "Actif/Sommeil"},
}
CHAMPS_ASSIGNABLES_MODIFICATION = {
**CHAMPS_ASSIGNABLES_CREATION,
"AR_Stock": {"type": float, "min": 0, "description": "Stock réel"},
"AR_StockMini": {"type": float, "min": 0, "description": "Stock minimum"},
"AR_StockMaxi": {"type": float, "min": 0, "description": "Stock maximum"},
}
CHAMPS_OBJETS_SPECIAUX = {
"Unite": {"description": "Unité de vente (objet)", "copie_modele": True},
"Famille": {"description": "Famille article (objet)", "validation_sql": True},
}
CHAMPS_STOCK_INITIAL = {
"stock_reel": {"type": float, "min": 0, "description": "Stock initial"},
"stock_mini": {"type": float, "min": 0, "description": "Stock minimum"},
"stock_maxi": {"type": float, "min": 0, "description": "Stock maximum"},
}
def valider_champ(
nom_champ: str, valeur: Any, config: Dict
) -> tuple[bool, Optional[str]]:
if valeur is None:
if config.get("required"):
return False, f"Le champ {nom_champ} est obligatoire"
return True, None
if "type" in config:
expected_type = config["type"]
try:
if expected_type is float:
valeur = float(valeur)
elif expected_type is int:
valeur = int(valeur)
elif expected_type is bool:
valeur = bool(valeur)
except (ValueError, TypeError):
return (
False,
f"Le champ {nom_champ} doit être de type {expected_type.__name__}",
)
if "min" in config:
if isinstance(valeur, (int, float)) and valeur < config["min"]:
return False, f"Le champ {nom_champ} doit être >= {config['min']}"
if "max_length" in config:
if isinstance(valeur, str) and len(valeur) > config["max_length"]:
return (
False,
f"Le champ {nom_champ} ne peut dépasser {config['max_length']} caractères",
)
if "values" in config:
if valeur not in config["values"]:
return False, f"Le champ {nom_champ} doit être parmi {config['values']}"
return True, None
def valider_donnees_creation(data: Dict) -> tuple[bool, Optional[str]]:
if "reference" not in data or not data["reference"]:
return False, "Le champ 'reference' est obligatoire"
if len(str(data["reference"])) > 18:
return False, "La référence ne peut dépasser 18 caractères"
if "designation" not in data or not data["designation"]:
return False, "Le champ 'designation' est obligatoire"
for champ, valeur in data.items():
if champ in CHAMPS_ASSIGNABLES_CREATION:
valide, erreur = valider_champ(
champ, valeur, CHAMPS_ASSIGNABLES_CREATION[champ]
)
if not valide:
return False, erreur
return True, None
def valider_donnees_modification(data: Dict) -> tuple[bool, Optional[str]]:
if not data:
return False, "Aucun champ à modifier"
for champ, valeur in data.items():
if champ in ["famille", "stock_reel", "stock_mini", "stock_maxi"]:
continue
if champ in CHAMPS_ASSIGNABLES_MODIFICATION:
valide, erreur = valider_champ(
champ, valeur, CHAMPS_ASSIGNABLES_MODIFICATION[champ]
)
if not valide:
return False, erreur
return True, None
def mapper_champ_api_vers_sage(champ_api: str) -> Optional[str]:
mapping = {
"designation": "AR_Design",
"prix_vente": "AR_PrixVen",
"prix_achat": "AR_PrixAch",
"code_ean": "AR_CodeBarre",
"code_barre": "AR_CodeBarre",
"description": "AR_Commentaire",
"unite_vente": "AR_UniteVen",
"code_fiscal": "AR_CodeFiscal",
"tva_code": "AR_CodeFiscal",
"pays": "AR_Pays",
"garantie": "AR_Garantie",
"delai": "AR_Delai",
"coef": "AR_Coef",
"coefficient": "AR_Coef",
"poids_net": "AR_PoidsNet",
"poids_brut": "AR_PoidsBrut",
"stat_01": "AR_Stat01",
"stat_02": "AR_Stat02",
"stat_03": "AR_Stat03",
"stat_04": "AR_Stat04",
"stat_05": "AR_Stat05",
"soumis_escompte": "AR_Escompte",
"publie": "AR_Publie",
"en_sommeil": "AR_Sommeil",
"stock_reel": "AR_Stock",
"stock_mini": "AR_StockMini",
"stock_maxi": "AR_StockMaxi",
}
return mapping.get(champ_api, champ_api)
def obtenir_champs_assignables() -> Dict[str, Any]:
return {
"creation": list(CHAMPS_ASSIGNABLES_CREATION.keys()),
"modification": list(CHAMPS_ASSIGNABLES_MODIFICATION.keys()),
"objets_speciaux": list(CHAMPS_OBJETS_SPECIAUX.keys()),
"stock_initial": list(CHAMPS_STOCK_INITIAL.keys()),
}

View file

View file

@ -0,0 +1,241 @@
import logging
logger = logging.getLogger(__name__)
def _extraire_article(article_obj):
try:
data = {
"reference": getattr(article_obj, "AR_Ref", "").strip(),
"designation": getattr(article_obj, "AR_Design", "").strip(),
}
data["code_ean"] = ""
data["code_barre"] = ""
try:
code_barre = getattr(article_obj, "AR_CodeBarre", "").strip()
if code_barre:
data["code_ean"] = code_barre
data["code_barre"] = code_barre
if not data["code_ean"]:
code_barre1 = getattr(article_obj, "AR_CodeBarre1", "").strip()
if code_barre1:
data["code_ean"] = code_barre1
data["code_barre"] = code_barre1
except Exception:
pass
try:
data["prix_vente"] = float(getattr(article_obj, "AR_PrixVen", 0.0))
except Exception:
data["prix_vente"] = 0.0
try:
data["prix_achat"] = float(getattr(article_obj, "AR_PrixAch", 0.0))
except Exception:
data["prix_achat"] = 0.0
try:
data["prix_revient"] = float(getattr(article_obj, "AR_PrixRevient", 0.0))
except Exception:
data["prix_revient"] = 0.0
try:
data["stock_reel"] = float(getattr(article_obj, "AR_Stock", 0.0))
except Exception:
data["stock_reel"] = 0.0
try:
data["stock_mini"] = float(getattr(article_obj, "AR_StockMini", 0.0))
except Exception:
data["stock_mini"] = 0.0
try:
data["stock_maxi"] = float(getattr(article_obj, "AR_StockMaxi", 0.0))
except Exception:
data["stock_maxi"] = 0.0
try:
data["stock_reserve"] = float(getattr(article_obj, "AR_QteCom", 0.0))
except Exception:
data["stock_reserve"] = 0.0
try:
data["stock_commande"] = float(getattr(article_obj, "AR_QteComFou", 0.0))
except Exception:
data["stock_commande"] = 0.0
try:
data["stock_disponible"] = data["stock_reel"] - data["stock_reserve"]
except Exception:
data["stock_disponible"] = data["stock_reel"]
try:
commentaire = getattr(article_obj, "AR_Commentaire", "").strip()
data["description"] = commentaire
except Exception:
data["description"] = ""
try:
design2 = getattr(article_obj, "AR_Design2", "").strip()
data["designation_complementaire"] = design2
except Exception:
data["designation_complementaire"] = ""
try:
type_art = getattr(article_obj, "AR_Type", 0)
data["type_article"] = type_art
data["type_article_libelle"] = {
0: "Article",
1: "Prestation",
2: "Divers",
}.get(type_art, "Inconnu")
except Exception:
data["type_article"] = 0
data["type_article_libelle"] = "Article"
try:
famille_code = getattr(article_obj, "FA_CodeFamille", "").strip()
data["famille_code"] = famille_code
if famille_code:
try:
famille_obj = getattr(article_obj, "Famille", None)
if famille_obj:
famille_obj.Read()
data["famille_libelle"] = getattr(
famille_obj, "FA_Intitule", ""
).strip()
else:
data["famille_libelle"] = ""
except Exception:
data["famille_libelle"] = ""
else:
data["famille_libelle"] = ""
except Exception:
data["famille_code"] = ""
data["famille_libelle"] = ""
try:
fournisseur_code = getattr(article_obj, "CT_Num", "").strip()
data["fournisseur_principal"] = fournisseur_code
if fournisseur_code:
try:
fourn_obj = getattr(article_obj, "Fournisseur", None)
if fourn_obj:
fourn_obj.Read()
data["fournisseur_nom"] = getattr(
fourn_obj, "CT_Intitule", ""
).strip()
else:
data["fournisseur_nom"] = ""
except Exception:
data["fournisseur_nom"] = ""
else:
data["fournisseur_nom"] = ""
except Exception:
data["fournisseur_principal"] = ""
data["fournisseur_nom"] = ""
try:
data["unite_vente"] = getattr(article_obj, "AR_UniteVen", "").strip()
except Exception:
data["unite_vente"] = ""
try:
data["unite_achat"] = getattr(article_obj, "AR_UniteAch", "").strip()
except Exception:
data["unite_achat"] = ""
try:
data["poids"] = float(getattr(article_obj, "AR_Poids", 0.0))
except Exception:
data["poids"] = 0.0
try:
data["volume"] = float(getattr(article_obj, "AR_Volume", 0.0))
except Exception:
data["volume"] = 0.0
try:
sommeil = getattr(article_obj, "AR_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["en_sommeil"] = sommeil == 1
except Exception:
data["est_actif"] = True
data["en_sommeil"] = False
try:
tva_code = getattr(article_obj, "TA_Code", "").strip()
data["tva_code"] = tva_code
try:
tva_obj = getattr(article_obj, "Taxe1", None)
if tva_obj:
tva_obj.Read()
data["tva_taux"] = float(getattr(tva_obj, "TA_Taux", 20.0))
else:
data["tva_taux"] = 20.0
except Exception:
data["tva_taux"] = 20.0
except Exception:
data["tva_code"] = ""
data["tva_taux"] = 20.0
try:
date_creation = getattr(article_obj, "AR_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except Exception:
data["date_creation"] = ""
try:
date_modif = getattr(article_obj, "AR_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except Exception:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f" Erreur extraction article: {e}", exc_info=True)
return {
"reference": getattr(article_obj, "AR_Ref", "").strip(),
"designation": getattr(article_obj, "AR_Design", "").strip(),
"prix_vente": 0.0,
"stock_reel": 0.0,
"code_ean": "",
"description": "",
"designation_complementaire": "",
"prix_achat": 0.0,
"prix_revient": 0.0,
"stock_mini": 0.0,
"stock_maxi": 0.0,
"stock_reserve": 0.0,
"stock_commande": 0.0,
"stock_disponible": 0.0,
"code_barre": "",
"type_article": 0,
"type_article_libelle": "Article",
"famille_code": "",
"famille_libelle": "",
"fournisseur_principal": "",
"fournisseur_nom": "",
"unite_vente": "",
"unite_achat": "",
"poids": 0.0,
"volume": 0.0,
"est_actif": True,
"en_sommeil": False,
"tva_code": "",
"tva_taux": 20.0,
"date_creation": "",
"date_modification": "",
}
__all__ = [
"_extraire_article",
]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
import logging
logger = logging.getLogger(__name__)
def verifier_stock_suffisant(article_ref, quantite, cursor, depot=None):
"""Version thread-safe avec lock SQL"""
try:
cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
cursor.execute("BEGIN TRANSACTION")
try:
cursor.execute(
"""
SELECT SUM(AS_QteSto)
FROM F_ARTSTOCK WITH (UPDLOCK, ROWLOCK)
WHERE AR_Ref = ?
""",
(article_ref.upper(),),
)
row = cursor.fetchone()
stock_dispo = float(row[0]) if row and row[0] else 0.0
suffisant = stock_dispo >= quantite
cursor.execute("COMMIT")
return {
"suffisant": suffisant,
"stock_disponible": stock_dispo,
"quantite_demandee": quantite,
}
except Exception:
cursor.execute("ROLLBACK")
raise
except Exception as e:
logger.error(f"Erreur vérification stock: {e}")
raise

View file

View file

@ -0,0 +1,61 @@
import win32com.client
import logging
logger = logging.getLogger(__name__)
def _rechercher_devis_dans_liste(numero_devis, factory_doc):
"""Recherche un devis dans les 100 premiers éléments de la liste."""
index = 1
while index < 100:
try:
persist_test = factory_doc.List(index)
if persist_test is None:
break
doc_test = win32com.client.CastTo(persist_test, "IBODocumentVente3")
doc_test.Read()
if (
getattr(doc_test, "DO_Type", -1) == 0
and getattr(doc_test, "DO_Piece", "") == numero_devis
):
logger.info(f" Document trouvé à l'index {index}")
return persist_test
index += 1
except Exception:
index += 1
return None
def _recuperer_numero_devis(process, doc):
"""Récupère le numéro du devis créé via plusieurs méthodes."""
numero_devis = None
try:
doc_result = process.DocumentResult
if doc_result:
doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3")
doc_result.Read()
numero_devis = getattr(doc_result, "DO_Piece", "")
except Exception:
pass
if not numero_devis:
numero_devis = getattr(doc, "DO_Piece", "")
if not numero_devis:
try:
doc.SetDefaultNumPiece()
doc.Write()
doc.Read()
numero_devis = getattr(doc, "DO_Piece", "")
except Exception:
pass
return numero_devis
__all__ = ["_recuperer_numero_devis", "_rechercher_devis_dans_liste"]

View file

@ -0,0 +1,51 @@
from typing import Dict
def _extraire_infos_devis(doc, numero: str, champs_modifies: list) -> Dict:
"""Extrait les informations complètes du devis."""
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
statut = getattr(doc, "DO_Statut", 0)
reference = getattr(doc, "DO_Ref", "")
date_devis = None
try:
date_doc = getattr(doc, "DO_Date", None)
if date_doc:
date_devis = date_doc.strftime("%Y-%m-%d")
except Exception:
pass
date_livraison = None
try:
date_livr = getattr(doc, "DO_DateLivr", None)
if date_livr:
date_livraison = date_livr.strftime("%Y-%m-%d")
except Exception:
pass
client_code = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "")
except Exception:
pass
return {
"numero": numero,
"total_ht": total_ht,
"total_ttc": total_ttc,
"reference": reference,
"date_devis": date_devis,
"date_livraison": date_livraison,
"champs_modifies": champs_modifies,
"statut": statut,
"client_code": client_code,
}
__all__ = [
"_extraire_infos_devis",
]

View file

@ -0,0 +1,793 @@
import win32com.client
import logging
from utils.functions.functions import (
_convertir_type_depuis_sql,
_convertir_type_pour_sql,
_safe_strip,
_combiner_date_heure,
)
logger = logging.getLogger(__name__)
def _afficher_etat_document(doc, titre: str):
"""Affiche l'état complet d'un document."""
logger.info("-" * 80)
logger.info(titre)
logger.info("-" * 80)
try:
logger.info(f" DO_Piece: {getattr(doc, 'DO_Piece', 'N/A')}")
logger.info(f" DO_Ref: '{getattr(doc, 'DO_Ref', 'N/A')}'")
logger.info(f" DO_Statut: {getattr(doc, 'DO_Statut', 'N/A')}")
date_doc = getattr(doc, "DO_Date", None)
date_str = date_doc.strftime("%Y-%m-%d") if date_doc else "None"
logger.info(f" DO_Date: {date_str}")
date_livr = getattr(doc, "DO_DateLivr", None)
date_livr_str = date_livr.strftime("%Y-%m-%d") if date_livr else "None"
logger.info(f" DO_DateLivr: {date_livr_str}")
logger.info(f" DO_TotalHT: {getattr(doc, 'DO_TotalHT', 0)}")
logger.info(f" DO_TotalTTC: {getattr(doc, 'DO_TotalTTC', 0)}")
except Exception as e:
logger.error(f" Erreur affichage état: {e}")
logger.info("-" * 80)
def _compter_lignes_document(doc) -> int:
"""Compte les lignes d'un document."""
try:
try:
factory_lignes = doc.FactoryDocumentLigne
except Exception:
factory_lignes = doc.FactoryDocumentVenteLigne
count = 0
index = 1
while index <= 100:
try:
ligne_p = factory_lignes.List(index)
if ligne_p is None:
break
count += 1
index += 1
except Exception:
break
return count
except Exception as e:
logger.warning(f" Erreur comptage lignes: {e}")
return 0
def _rechercher_devis_par_numero(numero: str, factory):
"""Recherche un devis par numéro dans la liste."""
logger.info(f" Recherche de {numero} dans la liste...")
index = 1
while index < 10000:
try:
persist_test = factory.List(index)
if persist_test is None:
break
doc_test = win32com.client.CastTo(persist_test, "IBODocumentVente3")
doc_test.Read()
if (
getattr(doc_test, "DO_Type", -1) == 0
and getattr(doc_test, "DO_Piece", "") == numero
):
logger.info(f" Trouvé à l'index {index}")
return persist_test
index += 1
except Exception:
index += 1
logger.error(f" Devis {numero} non trouvé dans la liste")
return None
def _lire_document_sql(cursor, numero: str, type_doc: int):
try:
query = """
SELECT
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.DO_Tiers, d.DO_DateLivr, d.DO_DateExpedition,
d.DO_Contact, d.DO_TotalHTNet, d.DO_NetAPayer,
d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte,
d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3,
d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3,
d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture,
d.DO_Transfere, d.DO_Souche, d.DO_PieceOrig, d.DO_GUID,
d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition,
d.DO_Tarif, d.DO_TypeFrais, d.DO_ValFrais,
d.DO_TypeFranco, d.DO_ValFranco,
c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal,
c.CT_Ville, c.CT_Telephone, c.CT_EMail,
d.DO_Heure
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
WHERE d.DO_Piece = ? AND d.DO_Type = ?
"""
logger.info(f"[SQL READ] Lecture directe de {numero} (type={type_doc})")
cursor.execute(query, (numero, type_doc))
row = cursor.fetchone()
if not row:
logger.warning(
f"[SQL READ] Document {numero} (type={type_doc}) INTROUVABLE"
)
return None
numero_piece = _safe_strip(row[0])
logger.info(f"[SQL READ] Document trouvé: {numero_piece}")
doc = {
"numero": numero_piece,
"reference": _safe_strip(row[2]),
"date": _combiner_date_heure(row[1], row[45]),
"date_livraison": str(row[7]) if row[7] else "",
"date_expedition": str(row[8]) if row[8] else "",
"client_code": _safe_strip(row[6]),
"client_intitule": _safe_strip(row[39]),
"client_adresse": _safe_strip(row[40]),
"client_code_postal": _safe_strip(row[41]),
"client_ville": _safe_strip(row[42]),
"client_telephone": _safe_strip(row[43]),
"client_email": _safe_strip(row[44]),
"contact": _safe_strip(row[9]),
"total_ht": float(row[3]) if row[3] else 0.0,
"total_ht_net": float(row[10]) if row[10] else 0.0,
"total_ttc": float(row[4]) if row[4] else 0.0,
"net_a_payer": float(row[11]) if row[11] else 0.0,
"montant_regle": float(row[12]) if row[12] else 0.0,
"reliquat": float(row[13]) if row[13] else 0.0,
"taux_escompte": float(row[14]) if row[14] else 0.0,
"escompte": float(row[15]) if row[15] else 0.0,
"taxe1": float(row[16]) if row[16] else 0.0,
"taxe2": float(row[17]) if row[17] else 0.0,
"taxe3": float(row[18]) if row[18] else 0.0,
"code_taxe1": _safe_strip(row[19]),
"code_taxe2": _safe_strip(row[20]),
"code_taxe3": _safe_strip(row[21]),
"statut": int(row[5]) if row[5] is not None else 0,
"statut_estatut": int(row[22]) if row[22] is not None else 0,
"imprime": int(row[23]) if row[23] is not None else 0,
"valide": int(row[24]) if row[24] is not None else 0,
"cloture": int(row[25]) if row[25] is not None else 0,
"transfere": int(row[26]) if row[26] is not None else 0,
"souche": int(row[27]) if row[27] is not None else 0,
"piece_origine": _safe_strip(row[28]),
"guid": _safe_strip(row[29]),
"ca_num": _safe_strip(row[30]),
"cg_num": _safe_strip(row[31]),
"expedition": int(row[32]) if row[32] is not None else 1,
"condition": int(row[33]) if row[33] is not None else 1,
"tarif": int(row[34]) if row[34] is not None else 1,
"type_frais": int(row[35]) if row[35] is not None else 0,
"valeur_frais": float(row[36]) if row[36] else 0.0,
"type_franco": int(row[37]) if row[37] is not None else 0,
"valeur_franco": float(row[38]) if row[38] else 0.0,
}
cursor.execute(
"""
SELECT
dl.*,
a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch,
a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd,
a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen,
a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie
FROM F_DOCLIGNE dl
LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref
WHERE dl.DO_Piece = ? AND dl.DO_Type = ?
ORDER BY dl.DL_Ligne
""",
(numero, type_doc),
)
lignes = []
for ligne_row in cursor.fetchall():
montant_ht = (
float(ligne_row.DL_MontantHT) if ligne_row.DL_MontantHT else 0.0
)
montant_net = (
float(ligne_row.DL_MontantNet)
if hasattr(ligne_row, "DL_MontantNet") and ligne_row.DL_MontantNet
else montant_ht
)
taux_taxe1 = (
float(ligne_row.DL_Taxe1)
if hasattr(ligne_row, "DL_Taxe1") and ligne_row.DL_Taxe1
else 0.0
)
taux_taxe2 = (
float(ligne_row.DL_Taxe2)
if hasattr(ligne_row, "DL_Taxe2") and ligne_row.DL_Taxe2
else 0.0
)
taux_taxe3 = (
float(ligne_row.DL_Taxe3)
if hasattr(ligne_row, "DL_Taxe3") and ligne_row.DL_Taxe3
else 0.0
)
total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3
montant_ttc = montant_net * (1 + total_taux_taxes / 100)
montant_taxe1 = montant_net * (taux_taxe1 / 100)
montant_taxe2 = montant_net * (taux_taxe2 / 100)
montant_taxe3 = montant_net * (taux_taxe3 / 100)
ligne = {
"numero_ligne": (int(ligne_row.DL_Ligne) if ligne_row.DL_Ligne else 0),
"article_code": _safe_strip(ligne_row.AR_Ref),
"designation": _safe_strip(ligne_row.DL_Design),
"designation_article": _safe_strip(ligne_row.AR_Design),
"quantite": (float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0),
"quantite_livree": (
float(ligne_row.DL_QteLiv)
if hasattr(ligne_row, "DL_QteLiv") and ligne_row.DL_QteLiv
else 0.0
),
"quantite_reservee": (
float(ligne_row.DL_QteRes)
if hasattr(ligne_row, "DL_QteRes") and ligne_row.DL_QteRes
else 0.0
),
"unite": (
_safe_strip(ligne_row.DL_Unite)
if hasattr(ligne_row, "DL_Unite")
else ""
),
"prix_unitaire_ht": (
float(ligne_row.DL_PrixUnitaire)
if ligne_row.DL_PrixUnitaire
else 0.0
),
"prix_unitaire_achat": (
float(ligne_row.AR_PrixAch) if ligne_row.AR_PrixAch else 0.0
),
"prix_unitaire_vente": (
float(ligne_row.AR_PrixVen) if ligne_row.AR_PrixVen else 0.0
),
"prix_unitaire_ttc": (
float(ligne_row.AR_PrixTTC) if ligne_row.AR_PrixTTC else 0.0
),
"montant_ligne_ht": montant_ht,
"montant_ligne_net": montant_net,
"montant_ligne_ttc": montant_ttc,
"remise_valeur1": (
float(ligne_row.DL_Remise01REM_Valeur)
if hasattr(ligne_row, "DL_Remise01REM_Valeur")
and ligne_row.DL_Remise01REM_Valeur
else 0.0
),
"remise_type1": (
int(ligne_row.DL_Remise01REM_Type)
if hasattr(ligne_row, "DL_Remise01REM_Type")
and ligne_row.DL_Remise01REM_Type
else 0
),
"remise_valeur2": (
float(ligne_row.DL_Remise02REM_Valeur)
if hasattr(ligne_row, "DL_Remise02REM_Valeur")
and ligne_row.DL_Remise02REM_Valeur
else 0.0
),
"remise_type2": (
int(ligne_row.DL_Remise02REM_Type)
if hasattr(ligne_row, "DL_Remise02REM_Type")
and ligne_row.DL_Remise02REM_Type
else 0
),
"remise_article": (
float(ligne_row.AR_Escompte) if ligne_row.AR_Escompte else 0.0
),
"taux_taxe1": taux_taxe1,
"montant_taxe1": montant_taxe1,
"taux_taxe2": taux_taxe2,
"montant_taxe2": montant_taxe2,
"taux_taxe3": taux_taxe3,
"montant_taxe3": montant_taxe3,
"total_taxes": montant_taxe1 + montant_taxe2 + montant_taxe3,
"famille_article": _safe_strip(ligne_row.FA_CodeFamille),
"gamme1": _safe_strip(ligne_row.AR_Gamme1),
"gamme2": _safe_strip(ligne_row.AR_Gamme2),
"code_barre": _safe_strip(ligne_row.AR_CodeBarre),
"type_article": _safe_strip(ligne_row.AR_Type),
"nature_article": _safe_strip(ligne_row.AR_Nature),
"garantie": _safe_strip(ligne_row.AR_Garantie),
"cout_standard": (
float(ligne_row.AR_CoutStd) if ligne_row.AR_CoutStd else 0.0
),
"poids_net": (
float(ligne_row.AR_PoidsNet) if ligne_row.AR_PoidsNet else 0.0
),
"poids_brut": (
float(ligne_row.AR_PoidsBrut) if ligne_row.AR_PoidsBrut else 0.0
),
"unite_vente": _safe_strip(ligne_row.AR_UniteVen),
"date_livraison_ligne": (
str(ligne_row.DL_DateLivr)
if hasattr(ligne_row, "DL_DateLivr") and ligne_row.DL_DateLivr
else ""
),
"statut_ligne": (
int(ligne_row.DL_Statut)
if hasattr(ligne_row, "DL_Statut")
and ligne_row.DL_Statut is not None
else 0
),
"depot": (
_safe_strip(ligne_row.DE_No) if hasattr(ligne_row, "DE_No") else ""
),
"numero_commande": (
_safe_strip(ligne_row.DL_NoColis)
if hasattr(ligne_row, "DL_NoColis")
else ""
),
"num_colis": (
_safe_strip(ligne_row.DL_Colis)
if hasattr(ligne_row, "DL_Colis")
else ""
),
}
lignes.append(ligne)
doc["lignes"] = lignes
doc["nb_lignes"] = len(lignes)
doc["total_ht_calcule"] = doc["total_ht_net"]
doc["total_ttc_calcule"] = doc["total_ttc"]
doc["total_taxes_calcule"] = doc["total_ttc"] - doc["total_ht_net"]
return doc
except Exception as e:
logger.error(f" Erreur SQL lecture document {numero}: {e}", exc_info=True)
return None
def _lister_documents_avec_lignes_sql(
cursor,
type_doc: int,
filtre: str = "",
limit: int = None,
):
"""Liste les documents avec leurs lignes."""
try:
type_doc_sql = _convertir_type_pour_sql(type_doc)
logger.info(f"[SQL LIST] ═══ Type COM {type_doc} → SQL {type_doc_sql} ═══")
query = """
SELECT DISTINCT
d.DO_Piece, d.DO_Type, d.DO_Date, d.DO_Ref, d.DO_Tiers,
d.DO_TotalHT, d.DO_TotalTTC, d.DO_NetAPayer, d.DO_Statut,
d.DO_DateLivr, d.DO_DateExpedition, d.DO_Contact, d.DO_TotalHTNet,
d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte,
d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3,
d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3,
d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture, d.DO_Transfere,
d.DO_Souche, d.DO_PieceOrig, d.DO_GUID,
d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition, d.DO_Tarif,
d.DO_TypeFrais, d.DO_ValFrais, d.DO_TypeFranco, d.DO_ValFranco,
c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal,
c.CT_Ville, c.CT_Telephone, c.CT_EMail,
d.DO_Heure
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
WHERE d.DO_Type = ?
"""
params = [type_doc_sql]
if filtre:
query += (
" AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ? OR d.DO_Ref LIKE ?)"
)
params.extend([f"%{filtre}%", f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY d.DO_Date DESC, d.DO_Heure DESC"
if limit:
query = f"SELECT TOP ({limit}) * FROM ({query}) AS subquery"
cursor.execute(query, params)
entetes = cursor.fetchall()
logger.info(f"[SQL LIST] {len(entetes)} documents SQL")
documents = []
stats = {
"total": len(entetes),
"exclus_prefixe": 0,
"erreur_construction": 0,
"erreur_lignes": 0,
"erreur_transformations": 0,
"erreur_liaisons": 0,
"succes": 0,
}
for idx, entete in enumerate(entetes):
numero = _safe_strip(entete.DO_Piece)
try:
prefixes_vente = {
0: ["DE"],
10: ["BC"],
30: ["BL"],
50: ["AV", "AR"],
60: ["FA", "FC"],
}
prefixes_acceptes = prefixes_vente.get(type_doc, [])
if prefixes_acceptes:
est_vente = any(
numero.upper().startswith(p) for p in prefixes_acceptes
)
if not est_vente:
logger.info(f"[SQL LIST] {numero} : exclu (préfixe achat)")
stats["exclus_prefixe"] += 1
continue
logger.debug(f"[SQL LIST] {numero} : préfixe OK")
try:
type_doc_depuis_sql = _convertir_type_depuis_sql(
int(entete.DO_Type)
)
doc = {
"numero": numero,
"type": type_doc_depuis_sql,
"reference": _safe_strip(entete.DO_Ref),
"date": _combiner_date_heure(entete.DO_Date, entete.DO_Heure),
"date_livraison": str(entete.DO_DateLivr)
if entete.DO_DateLivr
else "",
"date_expedition": str(entete.DO_DateExpedition)
if entete.DO_DateExpedition
else "",
"client_code": _safe_strip(entete.DO_Tiers),
"client_intitule": _safe_strip(entete.CT_Intitule),
"client_adresse": _safe_strip(entete.CT_Adresse),
"client_code_postal": _safe_strip(entete.CT_CodePostal),
"client_ville": _safe_strip(entete.CT_Ville),
"client_telephone": _safe_strip(entete.CT_Telephone),
"client_email": _safe_strip(entete.CT_EMail),
"contact": _safe_strip(entete.DO_Contact),
"total_ht": (
float(entete.DO_TotalHT) if entete.DO_TotalHT else 0.0
),
"total_ht_net": (
float(entete.DO_TotalHTNet) if entete.DO_TotalHTNet else 0.0
),
"total_ttc": (
float(entete.DO_TotalTTC) if entete.DO_TotalTTC else 0.0
),
"net_a_payer": (
float(entete.DO_NetAPayer) if entete.DO_NetAPayer else 0.0
),
"montant_regle": (
float(entete.DO_MontantRegle)
if entete.DO_MontantRegle
else 0.0
),
"reliquat": (
float(entete.DO_Reliquat) if entete.DO_Reliquat else 0.0
),
"taux_escompte": (
float(entete.DO_TxEscompte) if entete.DO_TxEscompte else 0.0
),
"escompte": (
float(entete.DO_Escompte) if entete.DO_Escompte else 0.0
),
"taxe1": (float(entete.DO_Taxe1) if entete.DO_Taxe1 else 0.0),
"taxe2": (float(entete.DO_Taxe2) if entete.DO_Taxe2 else 0.0),
"taxe3": (float(entete.DO_Taxe3) if entete.DO_Taxe3 else 0.0),
"code_taxe1": _safe_strip(entete.DO_CodeTaxe1),
"code_taxe2": _safe_strip(entete.DO_CodeTaxe2),
"code_taxe3": _safe_strip(entete.DO_CodeTaxe3),
"statut": (
int(entete.DO_Statut) if entete.DO_Statut is not None else 0
),
"statut_estatut": (
int(entete.DO_EStatut)
if entete.DO_EStatut is not None
else 0
),
"imprime": (
int(entete.DO_Imprim) if entete.DO_Imprim is not None else 0
),
"valide": (
int(entete.DO_Valide) if entete.DO_Valide is not None else 0
),
"cloture": (
int(entete.DO_Cloture)
if entete.DO_Cloture is not None
else 0
),
"transfere": (
int(entete.DO_Transfere)
if entete.DO_Transfere is not None
else 0
),
"souche": _safe_strip(entete.DO_Souche),
"piece_origine": _safe_strip(entete.DO_PieceOrig),
"guid": _safe_strip(entete.DO_GUID),
"ca_num": _safe_strip(entete.CA_Num),
"cg_num": _safe_strip(entete.CG_Num),
"expedition": _safe_strip(entete.DO_Expedit),
"condition": _safe_strip(entete.DO_Condition),
"tarif": _safe_strip(entete.DO_Tarif),
"type_frais": (
int(entete.DO_TypeFrais)
if entete.DO_TypeFrais is not None
else 0
),
"valeur_frais": (
float(entete.DO_ValFrais) if entete.DO_ValFrais else 0.0
),
"type_franco": (
int(entete.DO_TypeFranco)
if entete.DO_TypeFranco is not None
else 0
),
"valeur_franco": (
float(entete.DO_ValFranco) if entete.DO_ValFranco else 0.0
),
"lignes": [],
}
logger.debug(f"[SQL LIST] {numero} : document de base créé")
except Exception as e:
logger.error(
f"[SQL LIST] {numero} : ERREUR construction base: {e}",
exc_info=True,
)
stats["erreur_construction"] += 1
continue
try:
cursor.execute(
"""
SELECT dl.*,
a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch,
a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd,
a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen,
a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie
FROM F_DOCLIGNE dl
LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref
WHERE dl.DO_Piece = ? AND dl.DO_Type = ?
ORDER BY dl.DL_Ligne
""",
(numero, type_doc_sql),
)
for ligne_row in cursor.fetchall():
montant_ht = (
float(ligne_row.DL_MontantHT)
if ligne_row.DL_MontantHT
else 0.0
)
montant_net = (
float(ligne_row.DL_MontantNet)
if hasattr(ligne_row, "DL_MontantNet")
and ligne_row.DL_MontantNet
else montant_ht
)
taux_taxe1 = (
float(ligne_row.DL_Taxe1)
if hasattr(ligne_row, "DL_Taxe1") and ligne_row.DL_Taxe1
else 0.0
)
taux_taxe2 = (
float(ligne_row.DL_Taxe2)
if hasattr(ligne_row, "DL_Taxe2") and ligne_row.DL_Taxe2
else 0.0
)
taux_taxe3 = (
float(ligne_row.DL_Taxe3)
if hasattr(ligne_row, "DL_Taxe3") and ligne_row.DL_Taxe3
else 0.0
)
total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3
montant_ttc = montant_net * (1 + total_taux_taxes / 100)
montant_taxe1 = montant_net * (taux_taxe1 / 100)
montant_taxe2 = montant_net * (taux_taxe2 / 100)
montant_taxe3 = montant_net * (taux_taxe3 / 100)
ligne = {
"numero_ligne": (
int(ligne_row.DL_Ligne) if ligne_row.DL_Ligne else 0
),
"article_code": _safe_strip(ligne_row.AR_Ref),
"designation": _safe_strip(ligne_row.DL_Design),
"designation_article": _safe_strip(ligne_row.AR_Design),
"quantite": (
float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0
),
"quantite_livree": (
float(ligne_row.DL_QteLiv)
if hasattr(ligne_row, "DL_QteLiv")
and ligne_row.DL_QteLiv
else 0.0
),
"quantite_reservee": (
float(ligne_row.DL_QteRes)
if hasattr(ligne_row, "DL_QteRes")
and ligne_row.DL_QteRes
else 0.0
),
"unite": (
_safe_strip(ligne_row.DL_Unite)
if hasattr(ligne_row, "DL_Unite")
else ""
),
"prix_unitaire_ht": (
float(ligne_row.DL_PrixUnitaire)
if ligne_row.DL_PrixUnitaire
else 0.0
),
"prix_unitaire_achat": (
float(ligne_row.AR_PrixAch)
if ligne_row.AR_PrixAch
else 0.0
),
"prix_unitaire_vente": (
float(ligne_row.AR_PrixVen)
if ligne_row.AR_PrixVen
else 0.0
),
"prix_unitaire_ttc": (
float(ligne_row.AR_PrixTTC)
if ligne_row.AR_PrixTTC
else 0.0
),
"montant_ligne_ht": montant_ht,
"montant_ligne_net": montant_net,
"montant_ligne_ttc": montant_ttc,
"remise_valeur1": (
float(ligne_row.DL_Remise01REM_Valeur)
if hasattr(ligne_row, "DL_Remise01REM_Valeur")
and ligne_row.DL_Remise01REM_Valeur
else 0.0
),
"remise_type1": (
int(ligne_row.DL_Remise01REM_Type)
if hasattr(ligne_row, "DL_Remise01REM_Type")
and ligne_row.DL_Remise01REM_Type
else 0
),
"remise_valeur2": (
float(ligne_row.DL_Remise02REM_Valeur)
if hasattr(ligne_row, "DL_Remise02REM_Valeur")
and ligne_row.DL_Remise02REM_Valeur
else 0.0
),
"remise_type2": (
int(ligne_row.DL_Remise02REM_Type)
if hasattr(ligne_row, "DL_Remise02REM_Type")
and ligne_row.DL_Remise02REM_Type
else 0
),
"remise_article": (
float(ligne_row.AR_Escompte)
if ligne_row.AR_Escompte
else 0.0
),
"taux_taxe1": taux_taxe1,
"montant_taxe1": montant_taxe1,
"taux_taxe2": taux_taxe2,
"montant_taxe2": montant_taxe2,
"taux_taxe3": taux_taxe3,
"montant_taxe3": montant_taxe3,
"total_taxes": montant_taxe1
+ montant_taxe2
+ montant_taxe3,
"famille_article": _safe_strip(ligne_row.FA_CodeFamille),
"gamme1": _safe_strip(ligne_row.AR_Gamme1),
"gamme2": _safe_strip(ligne_row.AR_Gamme2),
"code_barre": _safe_strip(ligne_row.AR_CodeBarre),
"type_article": _safe_strip(ligne_row.AR_Type),
"nature_article": _safe_strip(ligne_row.AR_Nature),
"garantie": _safe_strip(ligne_row.AR_Garantie),
"cout_standard": (
float(ligne_row.AR_CoutStd)
if ligne_row.AR_CoutStd
else 0.0
),
"poids_net": (
float(ligne_row.AR_PoidsNet)
if ligne_row.AR_PoidsNet
else 0.0
),
"poids_brut": (
float(ligne_row.AR_PoidsBrut)
if ligne_row.AR_PoidsBrut
else 0.0
),
"unite_vente": _safe_strip(ligne_row.AR_UniteVen),
"date_livraison_ligne": (
str(ligne_row.DL_DateLivr)
if hasattr(ligne_row, "DL_DateLivr")
and ligne_row.DL_DateLivr
else ""
),
"statut_ligne": (
int(ligne_row.DL_Statut)
if hasattr(ligne_row, "DL_Statut")
and ligne_row.DL_Statut is not None
else 0
),
"depot": (
_safe_strip(ligne_row.DE_No)
if hasattr(ligne_row, "DE_No")
else ""
),
"numero_commande": (
_safe_strip(ligne_row.DL_NoColis)
if hasattr(ligne_row, "DL_NoColis")
else ""
),
"num_colis": (
_safe_strip(ligne_row.DL_Colis)
if hasattr(ligne_row, "DL_Colis")
else ""
),
}
doc["lignes"].append(ligne)
doc["nb_lignes"] = len(doc["lignes"])
doc["total_ht_calcule"] = doc["total_ht_net"]
doc["total_ttc_calcule"] = doc["total_ttc"]
doc["total_taxes_calcule"] = doc["total_ttc"] - doc["total_ht_net"]
logger.debug(
f"[SQL LIST] {numero} : {doc['nb_lignes']} lignes chargées"
)
except Exception as e:
logger.error(
f"[SQL LIST] {numero} : ERREUR lignes: {e}",
exc_info=True,
)
stats["erreur_lignes"] += 1
documents.append(doc)
stats["succes"] += 1
except Exception as e:
logger.error(
f"[SQL LIST] {numero} : EXCEPTION GLOBALE - DOCUMENT EXCLU: {e}",
exc_info=True,
)
continue
return documents
except Exception as e:
logger.error(f" Erreur GLOBALE listage: {e}", exc_info=True)
return []
__all__ = [
"_afficher_etat_document",
"_compter_lignes_document",
"_rechercher_devis_par_numero",
"_lire_document_sql",
"_lister_documents_avec_lignes_sql",
]

1829
utils/documents/settle.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,596 @@
from typing import Dict
import win32com.client
import logging
logger = logging.getLogger(__name__)
def get_statut_validation(connector, numero_facture: str) -> Dict:
"""Retourne le statut de validation d'une facture (SQL)"""
with connector._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
DO_Valide, DO_Statut, DO_TotalHT, DO_TotalTTC,
ISNULL(DO_MontantRegle, 0), CT_NumPayeur, DO_Date, DO_Ref
FROM F_DOCENTETE
WHERE DO_Piece = ? AND DO_Type = 6
""",
(numero_facture,),
)
row = cursor.fetchone()
if not row:
raise ValueError(f"Facture {numero_facture} introuvable")
valide = int(row[0]) if row[0] is not None else 0
statut = int(row[1]) if row[1] is not None else 0
total_ht = float(row[2]) if row[2] else 0.0
total_ttc = float(row[3]) if row[3] else 0.0
montant_regle = float(row[4]) if row[4] else 0.0
client_code = row[5].strip() if row[5] else ""
date_facture = row[6]
reference = row[7].strip() if row[7] else ""
solde = max(0.0, total_ttc - montant_regle)
return {
"numero_facture": numero_facture,
"est_validee": valide == 1,
"statut": statut,
"statut_libelle": _get_statut_libelle(statut),
"peut_etre_modifiee": valide == 0 and statut not in (5, 6),
"peut_etre_devalidee": valide == 1
and montant_regle < 0.01
and statut not in (5, 6),
"total_ht": total_ht,
"total_ttc": total_ttc,
"montant_regle": montant_regle,
"solde_restant": solde,
"client_code": client_code,
"date_facture": date_facture.strftime("%Y-%m-%d") if date_facture else None,
"reference": reference,
}
def _get_facture_info_sql(connector, numero_facture: str) -> Dict:
"""Récupère les infos d'une facture via SQL"""
with connector._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
DO_Valide, DO_Statut, DO_TotalHT, DO_TotalTTC,
ISNULL(DO_MontantRegle, 0), CT_NumPayeur
FROM F_DOCENTETE
WHERE DO_Piece = ? AND DO_Type = 6
""",
(numero_facture,),
)
row = cursor.fetchone()
if not row:
return None
return {
"valide": int(row[0]) if row[0] is not None else 0,
"statut": int(row[1]) if row[1] is not None else 0,
"total_ht": float(row[2]) if row[2] else 0.0,
"total_ttc": float(row[3]) if row[3] else 0.0,
"montant_regle": float(row[4]) if row[4] else 0.0,
"client_code": row[5].strip() if row[5] else "",
}
def introspecter_document_complet(connector, numero_facture: str) -> Dict:
if not connector.cial:
raise RuntimeError("Connexion Sage non établie")
result = {
"numero_facture": numero_facture,
"persist": {},
"IBODocumentVente3": {},
"IBODocument3": {},
"IPMDocument": {},
}
try:
with connector._com_context(), connector._lock_com:
factory = connector.cial.FactoryDocumentVente
if not factory.ExistPiece(60, numero_facture):
raise ValueError(f"Facture {numero_facture} introuvable")
persist = factory.ReadPiece(60, numero_facture)
persist_attrs = [a for a in dir(persist) if not a.startswith("_")]
result["persist"]["all_attrs"] = persist_attrs
result["persist"]["methods"] = []
result["persist"]["properties"] = []
for attr in persist_attrs:
try:
val = getattr(persist, attr, None)
if callable(val):
result["persist"]["methods"].append(attr)
else:
result["persist"]["properties"].append(
{
"name": attr,
"value": str(val)[:100] if val is not None else None,
}
)
except Exception as e:
result["persist"]["properties"].append(
{"name": attr, "error": str(e)[:50]}
)
result["persist"]["validation_related"] = [
a
for a in persist_attrs
if any(
x in a.lower()
for x in ["valid", "lock", "confirm", "statut", "etat"]
)
]
try:
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
doc_attrs = [a for a in dir(doc) if not a.startswith("_")]
result["IBODocumentVente3"]["all_attrs"] = doc_attrs
result["IBODocumentVente3"]["methods"] = []
result["IBODocumentVente3"]["properties_with_values"] = []
for attr in doc_attrs:
try:
val = getattr(doc, attr, None)
if callable(val):
result["IBODocumentVente3"]["methods"].append(attr)
except Exception:
pass
result["IBODocumentVente3"]["DO_properties"] = []
for attr in doc_attrs:
if attr.startswith("DO_"):
try:
val = getattr(doc, attr, "ERROR")
result["IBODocumentVente3"]["DO_properties"].append(
{"name": attr, "value": str(val)[:50]}
)
except Exception as e:
result["IBODocumentVente3"]["DO_properties"].append(
{"name": attr, "error": str(e)[:50]}
)
result["IBODocumentVente3"]["validation_related"] = [
a
for a in doc_attrs
if any(
x in a.lower()
for x in ["valid", "lock", "confirm", "statut", "etat"]
)
]
except Exception as e:
result["IBODocumentVente3"]["error"] = str(e)
try:
doc3 = win32com.client.CastTo(persist, "IBODocument3")
doc3.Read()
doc3_attrs = [a for a in dir(doc3) if not a.startswith("_")]
result["IBODocument3"]["all_attrs"] = doc3_attrs
result["IBODocument3"]["validation_related"] = [
a
for a in doc3_attrs
if any(
x in a.lower()
for x in ["valid", "lock", "confirm", "statut", "etat"]
)
]
except Exception as e:
result["IBODocument3"]["error"] = str(e)
try:
pmdoc = win32com.client.CastTo(persist, "IPMDocument")
pmdoc_attrs = [a for a in dir(pmdoc) if not a.startswith("_")]
result["IPMDocument"]["all_attrs"] = pmdoc_attrs
result["IPMDocument"]["methods"] = [
a for a in pmdoc_attrs if callable(getattr(pmdoc, a, None))
]
except Exception as e:
result["IPMDocument"]["error"] = str(e)
result["factories_on_doc"] = []
for attr in persist_attrs:
if "Factory" in attr:
result["factories_on_doc"].append(attr)
except Exception as e:
result["global_error"] = str(e)
return result
def introspecter_validation(connector, numero_facture: str = None) -> Dict:
if not connector.cial:
raise RuntimeError("Connexion Sage non établie")
result = {}
try:
with connector._com_context(), connector._lock_com:
cial_attrs = [a for a in dir(connector.cial) if not a.startswith("_")]
result["all_createprocess"] = [
a for a in cial_attrs if "CreateProcess" in a
]
for process_name in result["all_createprocess"]:
try:
process = getattr(connector.cial, process_name)()
process_attrs = [a for a in dir(process) if not a.startswith("_")]
result[process_name] = {
"attrs": process_attrs,
"has_valider": "Valider" in process_attrs
or "Valid" in str(process_attrs),
"has_document": "Document" in process_attrs,
}
except Exception as e:
result[process_name] = {"error": str(e)}
if numero_facture:
result["document"] = introspecter_document_complet(
connector, numero_facture
)
except Exception as e:
result["global_error"] = str(e)
return result
def valider_facture(connector, numero_facture: str) -> Dict:
logger.info(f" Validation facture {numero_facture} (SQL direct)")
with connector._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT DO_Valide, DO_Statut, DO_TotalTTC, DO_MontantRegle
FROM F_DOCENTETE
WHERE DO_Piece = ? AND DO_Type = 6
""",
(numero_facture,),
)
row = cursor.fetchone()
if not row:
raise ValueError(f"Facture {numero_facture} introuvable")
valide_avant, statut, total_ttc, montant_regle = row
if valide_avant == 1:
return {"numero_facture": numero_facture, "deja_valide": True}
if statut == 6: # Annulé
raise ValueError("Facture annulée, validation impossible")
cursor.execute(
"""
UPDATE F_DOCENTETE
SET DO_Valide = 1, DO_Imprim = 0
WHERE DO_Piece = ? AND DO_Type = 6
""",
(numero_facture,),
)
conn.commit()
cursor.execute(
"""
SELECT DO_Valide, DO_Imprim
FROM F_DOCENTETE
WHERE DO_Piece = ? AND DO_Type = 6
""",
(numero_facture,),
)
valide_apres, imprim_apres = cursor.fetchone()
logger.info(f" SQL: DO_Valide={valide_apres}, DO_Imprim={imprim_apres}")
return {
"numero_facture": numero_facture,
"methode": "SQL_DIRECT",
"DO_Valide": valide_apres == 1,
"DO_Imprim": imprim_apres == 1,
"success": valide_apres == 1,
"warning": "Validation SQL directe - règles métier Sage contournées",
}
def devalider_facture(connector, numero_facture: str) -> Dict:
if not connector.cial:
raise RuntimeError("Connexion Sage non établie")
logger.info(f" Dévalidation facture {numero_facture}")
info = _get_facture_info_sql(connector, numero_facture)
if not info:
raise ValueError(f"Facture {numero_facture} introuvable")
if info["statut"] == 6:
raise ValueError(f"Facture {numero_facture} annulée")
if info["statut"] == 5:
raise ValueError(
f"Facture {numero_facture} transformée, dévalidation impossible"
)
if info["montant_regle"] > 0.01:
raise ValueError(
f"Facture {numero_facture} partiellement réglée ({info['montant_regle']:.2f}€), "
"dévalidation impossible"
)
if info["valide"] == 0:
logger.info(f"Facture {numero_facture} déjà non validée")
return _build_response_sql(
connector, numero_facture, deja_valide=True, action="devalidation"
)
try:
with connector._com_context(), connector._lock_com:
success = _valider_document_com(connector, numero_facture, valider=False)
if not success:
raise RuntimeError("La dévalidation COM a échoué")
logger.info(f" Facture {numero_facture} dévalidée")
except ValueError:
raise
except Exception as e:
logger.error(f" Erreur COM dévalidation {numero_facture}: {e}", exc_info=True)
raise RuntimeError(f"Échec dévalidation: {str(e)}")
info_apres = _get_facture_info_sql(connector, numero_facture)
if info_apres and info_apres["valide"] != 0:
raise RuntimeError(
"Échec dévalidation: DO_Valide non modifié après l'opération COM"
)
return _build_response_sql(
connector, numero_facture, deja_valide=False, action="devalidation"
)
def _valider_document_com(connector, numero_facture: str, valider: bool = True) -> bool:
erreurs = []
action = "validation" if valider else "dévalidation"
valeur_cible = 1 if valider else 0
factory = connector.cial.FactoryDocumentVente
if not factory.ExistPiece(60, numero_facture):
raise ValueError(f"Facture {numero_facture} introuvable")
persist = factory.ReadPiece(60, numero_facture)
if not persist:
raise ValueError(f"Impossible de lire la facture {numero_facture}")
try:
logger.info(
" APPROCHE 1: Modification directe DO_Valide sur IBODocumentVente3..."
)
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
valeur_avant = getattr(doc, "DO_Valide", None)
logger.info(f" DO_Valide avant: {valeur_avant}")
doc.DO_Valide = valeur_cible
doc.Write()
doc.Read()
valeur_apres = getattr(doc, "DO_Valide", None)
logger.info(f" DO_Valide après: {valeur_apres}")
if valeur_apres == valeur_cible:
logger.info(" DO_Valide modifié avec succès!")
return True
else:
erreurs.append(
f"DO_Valide non modifié (avant={valeur_avant}, après={valeur_apres})"
)
except Exception as e:
erreurs.append(f"IBODocumentVente3.DO_Valide: {e}")
logger.warning(f" Erreur: {e}")
try:
logger.info(" APPROCHE 2: Via IBODocument3...")
doc3 = win32com.client.CastTo(persist, "IBODocument3")
doc3.Read()
if hasattr(doc3, "DO_Valide"):
doc3.DO_Valide = valeur_cible
doc3.Write()
logger.info(f" IBODocument3.DO_Valide = {valeur_cible} OK")
return True
else:
erreurs.append("IBODocument3 n'a pas DO_Valide")
except Exception as e:
erreurs.append(f"IBODocument3: {e}")
try:
logger.info(" APPROCHE 3: Recherche CreateProcess de validation...")
cial_attrs = [a for a in dir(connector.cial) if "CreateProcess" in a]
validation_processes = [
a
for a in cial_attrs
if any(x in a.lower() for x in ["valid", "confirm", "lock"])
]
logger.info(f" CreateProcess trouvés: {cial_attrs}")
logger.info(f" Liés à validation: {validation_processes}")
for proc_name in validation_processes:
try:
process = getattr(connector.cial, proc_name)()
proc_attrs = [a for a in dir(process) if not a.startswith("_")]
logger.info(f" {proc_name} attrs: {proc_attrs}")
if hasattr(process, "Document"):
process.Document = persist
if hasattr(process, "Valider"):
process.Valider = valider
if hasattr(process, "Process"):
process.Process()
logger.info(f" {proc_name}.Process() exécuté!")
return True
except Exception as e:
erreurs.append(f"{proc_name}: {e}")
except Exception as e:
erreurs.append(f"CreateProcess: {e}")
try:
logger.info(" APPROCHE 4: WriteDefault...")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
doc.DO_Valide = valeur_cible
if hasattr(doc, "WriteDefault"):
doc.WriteDefault()
logger.info(" WriteDefault() exécuté")
return True
except Exception as e:
erreurs.append(f"WriteDefault: {e}")
logger.error(f" Toutes les approches de {action} ont échoué: {erreurs}")
raise RuntimeError(f"Échec {action}: {'; '.join(erreurs[:5])}")
def explorer_toutes_interfaces_validation(connector, numero_facture: str) -> Dict:
"""Explorer TOUTES les interfaces possibles pour trouver un setter DO_Valide"""
result = {"numero_facture": numero_facture, "interfaces": {}}
with connector._com_context(), connector._lock_com:
factory = connector.cial.FactoryDocumentVente
persist = factory.ReadPiece(60, numero_facture)
interfaces = [
"IBODocumentVente3",
"IBODocument3",
"IBIPersistObject",
"IBIDocument",
"IPMDocument",
"IDispatch",
]
for iface_name in interfaces:
try:
obj = win32com.client.CastTo(persist, iface_name)
if hasattr(obj, "Read"):
obj.Read()
oleobj = obj._oleobj_
type_info = oleobj.GetTypeInfo()
type_attr = type_info.GetTypeAttr()
props = {}
for i in range(type_attr.cFuncs):
func_desc = type_info.GetFuncDesc(i)
names = type_info.GetNames(func_desc.memid)
if names and names[0] in (
"DO_Valide",
"DO_Imprim",
"Valider",
"Validate",
"Lock",
):
props[names[0]] = {
"memid": func_desc.memid,
"invkind": func_desc.invkind,
"has_setter": (func_desc.invkind & 4) == 4,
}
result["interfaces"][iface_name] = {
"success": True,
"properties": props,
}
except Exception as e:
result["interfaces"][iface_name] = {"error": str(e)[:100]}
try:
factory_attrs = [a for a in dir(factory) if not a.startswith("_")]
result["factory_methods"] = [
a
for a in factory_attrs
if any(x in a.lower() for x in ["valid", "lock", "confirm", "imprim"])
]
except Exception:
pass
return result
def _build_response_sql(
connector, numero_facture: str, deja_valide: bool, action: str
) -> Dict:
"""Construit la réponse via SQL"""
info = _get_facture_info_sql(connector, numero_facture)
if action == "validation":
message = (
"Facture déjà validée" if deja_valide else "Facture validée avec succès"
)
else:
message = (
"Facture déjà non validée"
if deja_valide
else "Facture dévalidée avec succès"
)
return {
"numero_facture": numero_facture,
"est_validee": info["valide"] == 1 if info else False,
"statut": info["statut"] if info else 0,
"statut_libelle": _get_statut_libelle(info["statut"]) if info else "Inconnu",
"total_ht": info["total_ht"] if info else 0.0,
"total_ttc": info["total_ttc"] if info else 0.0,
"client_code": info["client_code"] if info else "",
"message": message,
"action_effectuee": not deja_valide,
}
def _get_statut_libelle(statut: int) -> str:
"""Retourne le libellé d'un statut de document"""
statuts = {
0: "Brouillon",
1: "Confirmé",
2: "En cours",
3: "Imprimé",
4: "Suspendu",
5: "Transformé",
6: "Annulé",
}
return statuts.get(statut, f"Statut {statut}")
__all__ = [
"valider_facture",
"devalider_facture",
"get_statut_validation",
"introspecter_validation",
"introspecter_document_complet",
]

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)

View file

View file

View file

@ -0,0 +1,652 @@
from typing import Dict
import win32com.client
import pywintypes
import time
from schemas.documents.doc_config import TypeDocumentVente, ConfigDocument
import logging
from utils.functions.functions import normaliser_date
from utils.tiers.clients.clients_data import _cast_client
logger = logging.getLogger(__name__)
def creer_document_vente(
self, doc_data: dict, type_document: TypeDocumentVente
) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
config = ConfigDocument(type_document)
logger.info(
f" Début création {config.nom_document} pour client {doc_data['client']['code']}"
)
try:
with self._com_context(), self._lock_com:
transaction_active = False
try:
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug(" Transaction Sage démarrée")
except Exception as e:
logger.warning(f"BeginTrans échoué (non critique): {e}")
process = self.cial.CreateProcess_Document(config.type_sage)
doc = process.Document
try:
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
except Exception:
pass
logger.info(f" Document {config.nom_document} créé")
date_principale = normaliser_date(
doc_data.get(config.champ_date_principale)
)
doc.DO_Date = pywintypes.Time(date_principale)
try:
doc.DO_Heure = pywintypes.Time(date_principale)
logger.debug(
f"DO_Heure défini: {date_principale.strftime('%H:%M:%S')}"
)
except Exception as e:
logger.debug(f"DO_Heure non défini: {e}")
if config.champ_date_secondaire and doc_data.get(
config.champ_date_secondaire
):
doc.DO_DateLivr = pywintypes.Time(
normaliser_date(doc_data[config.champ_date_secondaire])
)
logger.info(
f" {config.champ_date_secondaire}: {doc_data[config.champ_date_secondaire]}"
)
factory_client = self.cial.CptaApplication.FactoryClient
persist_client = factory_client.ReadNumero(doc_data["client"]["code"])
if not persist_client:
raise ValueError(f"Client {doc_data['client']['code']} introuvable")
client_obj = _cast_client(persist_client)
if not client_obj:
raise ValueError("Impossible de charger le client")
doc.SetDefaultClient(client_obj)
doc.Write()
logger.info(f" Client {doc_data['client']['code']} associé")
if doc_data.get("reference"):
try:
doc.DO_Ref = doc_data["reference"]
logger.info(f" Référence: {doc_data['reference']}")
except Exception as e:
logger.warning(f"Référence non définie: {e}")
if type_document == TypeDocumentVente.FACTURE:
_configurer_facture(self, doc)
try:
factory_lignes = doc.FactoryDocumentLigne
except Exception:
factory_lignes = doc.FactoryDocumentVenteLigne
factory_article = self.cial.FactoryArticle
logger.info(f" Ajout de {len(doc_data['lignes'])} lignes...")
for idx, ligne_data in enumerate(doc_data["lignes"], 1):
_ajouter_ligne_document(
cial=self.cial,
factory_lignes=factory_lignes,
factory_article=factory_article,
ligne_data=ligne_data,
idx=idx,
doc=doc,
)
logger.info(" Validation du document...")
if type_document == TypeDocumentVente.FACTURE:
try:
doc.SetClient(client_obj)
logger.debug(" ↳ Client réassocié avant validation")
except Exception:
try:
doc.SetDefaultClient(client_obj)
except Exception:
pass
doc.Write()
if type_document != TypeDocumentVente.DEVIS:
process.Process()
logger.info(" Process() appelé")
else:
try:
process.Process()
logger.info(" Process() appelé (devis)")
except Exception:
logger.debug(" ↳ Process() ignoré pour devis brouillon")
if transaction_active:
try:
self.cial.CptaApplication.CommitTrans()
logger.info(" Transaction committée")
except Exception:
pass
time.sleep(2)
numero_document = _recuperer_numero_document(process, doc)
if not numero_document:
raise RuntimeError(
f"Numéro {config.nom_document} vide après création"
)
logger.info(f" Numéro: {numero_document}")
doc_final_data = _relire_document_final(
self,
config=config,
numero_document=numero_document,
doc_data=doc_data,
)
logger.info(
f" {config.nom_document.upper()} CRÉÉ: "
f"{numero_document} - {doc_final_data['total_ttc']}€ TTC"
)
return doc_final_data
except Exception:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
logger.error(" Transaction annulée (rollback)")
except Exception:
pass
raise
except Exception as e:
logger.error(
f" ERREUR CRÉATION {config.nom_document.upper()}: {e}", exc_info=True
)
raise RuntimeError(f"Échec création {config.nom_document}: {str(e)}")
def _appliquer_remise_ligne(ligne_obj, remise_pourcent: float) -> bool:
"""Applique la remise via FromString - SOLUTION FINALE"""
try:
import pythoncom
dispatch = ligne_obj._oleobj_
dispid = dispatch.GetIDsOfNames(0, "Remise")
remise_obj = dispatch.Invoke(dispid, 0, pythoncom.DISPATCH_PROPERTYGET, 1)
remise_wrapper = win32com.client.Dispatch(remise_obj)
remise_wrapper.FromString(f"{remise_pourcent}%")
try:
remise_wrapper.Calcul()
except Exception:
pass
ligne_obj.Write()
logger.info(f" Remise {remise_pourcent}% appliquée")
return True
except Exception as e:
logger.error(f" Erreur remise: {e}")
return False
def _ajouter_ligne_document(
cial, factory_lignes, factory_article, ligne_data: dict, idx: int, doc
) -> None:
"""VERSION FINALE AVEC REMISES FONCTIONNELLES"""
logger.info(f" ├─ Ligne {idx}: {ligne_data['article_code']}")
persist_article = factory_article.ReadReference(ligne_data["article_code"])
if not persist_article:
raise ValueError(f"Article {ligne_data['article_code']} introuvable")
article_obj = win32com.client.CastTo(persist_article, "IBOArticle3")
article_obj.Read()
prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0))
designation_sage = getattr(article_obj, "AR_Design", "")
ligne_persist = factory_lignes.Create()
try:
ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3")
except Exception:
ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3")
quantite = float(ligne_data["quantite"])
try:
ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite)
except Exception:
try:
ligne_obj.SetDefaultArticle(article_obj, quantite)
except Exception:
ligne_obj.DL_Design = designation_sage
ligne_obj.DL_Qte = quantite
prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
prix_perso = ligne_data.get("prix_unitaire_ht")
if prix_perso and prix_perso > 0:
ligne_obj.DL_PrixUnitaire = float(prix_perso)
elif prix_auto == 0 and prix_sage > 0:
ligne_obj.DL_PrixUnitaire = float(prix_sage)
prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0))
logger.info(f" Prix: {prix_final}")
ligne_obj.Write()
remise = ligne_data.get("remise_pourcentage", 0)
if remise and remise > 0:
logger.info(f" Application remise {remise}%...")
_appliquer_remise_ligne(ligne_obj, remise)
logger.info(f" Ligne {idx} terminée")
def _configurer_facture(self, doc) -> None:
"""Configuration spécifique pour les factures"""
logger.info(" Configuration spécifique facture...")
try:
if hasattr(doc, "DO_CodeJournal"):
try:
param_societe = self.cial.CptaApplication.ParametreSociete
journal_defaut = getattr(param_societe, "P_CodeJournalVte", "VTE")
doc.DO_CodeJournal = journal_defaut
logger.debug(f" Code journal: {journal_defaut}")
except Exception:
doc.DO_CodeJournal = "VTE"
logger.debug(" Code journal: VTE (défaut)")
except Exception as e:
logger.debug(f" Code journal: {e}")
try:
if hasattr(doc, "DO_Souche"):
doc.DO_Souche = 0
logger.debug(" Souche: 0")
except Exception:
pass
try:
if hasattr(doc, "DO_Regime"):
doc.DO_Regime = 0
logger.debug(" Régime: 0")
except Exception:
pass
def _recuperer_numero_document(process, doc) -> str:
"""Récupère le numéro du document créé"""
numero = None
try:
doc_result = process.DocumentResult
if doc_result:
doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3")
doc_result.Read()
numero = getattr(doc_result, "DO_Piece", "")
except Exception:
pass
if not numero:
numero = getattr(doc, "DO_Piece", "")
return numero
def _relire_document_final(
self,
config: ConfigDocument,
numero_document: str,
doc_data: dict,
client_code_fallback: str = None,
) -> Dict:
"""
Relit le document pour obtenir les totaux calculés par Sage
"""
factory_doc = self.cial.FactoryDocumentVente
persist_reread = factory_doc.ReadPiece(config.type_sage, numero_document)
client_code = None
date_secondaire_value = None
if persist_reread:
doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3")
doc_final.Read()
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
reference_finale = getattr(doc_final, "DO_Ref", "")
try:
client_obj = getattr(doc_final, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
except Exception:
pass
if config.champ_date_secondaire:
try:
date_livr = getattr(doc_final, "DO_DateLivr", None)
if date_livr:
date_secondaire_value = date_livr.strftime("%Y-%m-%d")
except Exception:
pass
else:
total_ht = 0.0
total_ttc = 0.0
reference_finale = doc_data.get("reference", "")
date_secondaire_value = doc_data.get(config.champ_date_secondaire)
if not client_code:
client_code = client_code_fallback or doc_data.get("client", {}).get("code", "")
resultat = {
config.champ_numero: numero_document,
"total_ht": total_ht,
"total_ttc": total_ttc,
"nb_lignes": len(doc_data.get("lignes", [])),
"client_code": client_code,
config.champ_date_principale: str(
normaliser_date(doc_data.get(config.champ_date_principale))
)
if doc_data.get(config.champ_date_principale)
else None,
"reference": reference_finale,
}
if config.champ_date_secondaire:
resultat[config.champ_date_secondaire] = date_secondaire_value
return resultat
def modifier_document_vente(
self, numero: str, doc_data: dict, type_document: TypeDocumentVente
) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
config = ConfigDocument(type_document)
logger.info(f" === MODIFICATION {config.nom_document.upper()} {numero} ===")
try:
with self._com_context(), self._lock_com:
logger.info(" Chargement document...")
factory = self.cial.FactoryDocumentVente
persist = None
for type_test in [config.type_sage, 50]:
try:
persist_test = factory.ReadPiece(type_test, numero)
if persist_test:
persist = persist_test
logger.info(f" Document trouvé (type={type_test})")
break
except Exception:
continue
if not persist:
raise ValueError(
f"{config.nom_document.capitalize()} {numero} introuvable"
)
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
statut_actuel = getattr(doc, "DO_Statut", 0)
if statut_actuel == 5:
raise ValueError(f"Le {config.nom_document} a déjà été transformé")
if statut_actuel == 6:
raise ValueError(f"Le {config.nom_document} est annulé")
client_code_initial = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code_initial = getattr(client_obj, "CT_Num", "").strip()
logger.info(f" Client initial: {client_code_initial}")
except Exception as e:
logger.error(f" Erreur lecture client: {e}")
if not client_code_initial:
raise ValueError("Client introuvable dans le document")
nb_lignes_initial = 0
try:
factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr(
doc, "FactoryDocumentVenteLigne", None
)
index = 1
while index <= 100:
try:
ligne_p = factory_lignes.List(index)
if ligne_p is None:
break
nb_lignes_initial += 1
index += 1
except Exception:
break
logger.info(f" Lignes existantes: {nb_lignes_initial}")
except Exception as e:
logger.warning(f" Erreur comptage lignes: {e}")
champs_modifies = []
modif_date = config.champ_date_principale in doc_data
modif_date_sec = (
config.champ_date_secondaire
and config.champ_date_secondaire in doc_data
)
modif_statut = "statut" in doc_data
modif_ref = "reference" in doc_data
modif_lignes = "lignes" in doc_data and doc_data["lignes"] is not None
logger.info(" Modifications demandées:")
logger.info(f" {config.champ_date_principale}: {modif_date}")
if config.champ_date_secondaire:
logger.info(f" {config.champ_date_secondaire}: {modif_date_sec}")
logger.info(f" Statut: {modif_statut}")
logger.info(f" Référence: {modif_ref}")
logger.info(f" Lignes: {modif_lignes}")
doc_data_temp = doc_data.copy()
reference_a_modifier = None
statut_a_modifier = None
if modif_lignes:
if modif_ref:
reference_a_modifier = doc_data_temp.pop("reference")
modif_ref = False
if modif_statut:
statut_a_modifier = doc_data_temp.pop("statut")
modif_statut = False
logger.info(" Test Write() basique...")
try:
doc.Write()
doc.Read()
logger.info(" Write() basique OK")
except Exception as e:
logger.error(f" Document verrouillé: {e}")
raise ValueError(f"Document verrouillé: {e}")
if not modif_lignes and (
modif_date or modif_date_sec or modif_statut or modif_ref
):
logger.info(" Modifications simples...")
if modif_date:
date_principale = normaliser_date(
doc_data_temp.get(config.champ_date_principale)
)
doc.DO_Date = pywintypes.Time(date_principale)
try:
doc.DO_Heure = pywintypes.Time(date_principale)
except Exception:
pass
champs_modifies.append(config.champ_date_principale)
if modif_date_sec:
doc.DO_DateLivr = pywintypes.Time(
normaliser_date(doc_data_temp[config.champ_date_secondaire])
)
champs_modifies.append(config.champ_date_secondaire)
if modif_statut:
doc.DO_Statut = doc_data_temp["statut"]
champs_modifies.append("statut")
if modif_ref:
doc.DO_Ref = doc_data_temp["reference"]
champs_modifies.append("reference")
if type_document == TypeDocumentVente.FACTURE:
_configurer_facture(self, doc)
doc.Write()
logger.info(" Modifications appliquées")
elif modif_lignes:
logger.info(" REMPLACEMENT COMPLET DES LIGNES...")
if modif_date:
doc.DO_Date = pywintypes.Time(
normaliser_date(doc_data_temp.get(config.champ_date_principale))
)
champs_modifies.append(config.champ_date_principale)
if modif_date_sec:
doc.DO_DateLivr = pywintypes.Time(
normaliser_date(doc_data_temp[config.champ_date_secondaire])
)
champs_modifies.append(config.champ_date_secondaire)
if type_document == TypeDocumentVente.FACTURE:
_configurer_facture(self, doc)
nouvelles_lignes = doc_data["lignes"]
nb_nouvelles = len(nouvelles_lignes)
logger.info(f" {nb_lignes_initial}{nb_nouvelles} lignes")
try:
factory_lignes = doc.FactoryDocumentLigne
except Exception:
factory_lignes = doc.FactoryDocumentVenteLigne
factory_article = self.cial.FactoryArticle
if nb_lignes_initial > 0:
logger.info(f" Suppression {nb_lignes_initial} lignes...")
for idx in range(nb_lignes_initial, 0, -1):
try:
ligne_p = factory_lignes.List(idx)
if ligne_p:
ligne = win32com.client.CastTo(
ligne_p, "IBODocumentLigne3"
)
ligne.Read()
ligne.Remove()
except Exception as e:
logger.warning(f" Ligne {idx}: {e}")
logger.info(" Lignes supprimées")
logger.info(f" Ajout {nb_nouvelles} lignes...")
for idx, ligne_data in enumerate(nouvelles_lignes, 1):
_ajouter_ligne_document(
cial=self.cial,
factory_lignes=factory_lignes,
factory_article=factory_article,
ligne_data=ligne_data,
idx=idx,
doc=doc,
)
logger.info(" Nouvelles lignes ajoutées avec remises")
doc.Write()
time.sleep(0.5)
doc.Read()
champs_modifies.append("lignes")
if reference_a_modifier is not None:
try:
logger.info(
f" Modification référence: '{reference_a_modifier}'"
)
doc.DO_Ref = (
str(reference_a_modifier) if reference_a_modifier else ""
)
doc.Write()
time.sleep(0.5)
doc.Read()
champs_modifies.append("reference")
except Exception as e:
logger.warning(f" Référence: {e}")
if statut_a_modifier is not None:
try:
logger.info(f" Modification statut: {statut_a_modifier}")
doc.DO_Statut = int(statut_a_modifier)
doc.Write()
time.sleep(0.5)
doc.Read()
champs_modifies.append("statut")
except Exception as e:
logger.warning(f" Statut: {e}")
resultat = _relire_document_final(
self, config, numero, doc_data, client_code_fallback=client_code_initial
)
resultat["champs_modifies"] = champs_modifies
logger.info(f" {config.nom_document.upper()} {numero} MODIFIÉ")
logger.info(
f" Totaux: {resultat['total_ht']}€ HT / {resultat['total_ttc']}€ TTC"
)
logger.info(f" Champs modifiés: {champs_modifies}")
return resultat
except ValueError as e:
logger.error(f" ERREUR MÉTIER: {e}")
raise
except Exception as e:
logger.error(f" ERREUR TECHNIQUE: {e}", exc_info=True)
raise RuntimeError(f"Erreur Sage: {str(e)}")
__all__ = [
"creer_document_vente",
"_ajouter_ligne_document",
"_configurer_facture",
"_recuperer_numero_document",
"_relire_document_final",
"modifier_document_vente",
]

View file

@ -0,0 +1,214 @@
from typing import Optional
from datetime import datetime, date
import logging
logger = logging.getLogger(__name__)
def _clean_str(value, max_len: int) -> str:
"""Nettoie et tronque une chaîne"""
if value is None or str(value).lower() in ("none", "null", ""):
return ""
return str(value)[:max_len].strip()
def _safe_strip(value) -> Optional[str]:
"""Nettoie une valeur string en toute sécurité"""
if value is None:
return None
if isinstance(value, str):
stripped = value.strip()
return stripped if stripped else None
return str(value).strip() or None
def _safe_int(value, default=None):
"""Conversion sécurisée en entier"""
if value is None:
return default
try:
return int(value)
except (ValueError, TypeError):
return default
def _try_set_attribute(obj, attr_name, value, variants=None):
"""Essaie de définir un attribut avec plusieurs variantes"""
if variants is None:
variants = [attr_name]
else:
variants = [attr_name] + variants
for variant in variants:
try:
if hasattr(obj, variant):
setattr(obj, variant, value)
return True
except Exception as e:
logger.debug(f" {variant} échec: {str(e)[:50]}")
return False
def _get_type_libelle(type_doc: int) -> str:
types_officiels = {
0: "Devis",
10: "Bon de commande",
20: "Préparation",
30: "Bon de livraison",
40: "Bon de retour",
50: "Bon d'avoir",
60: "Facture",
}
types_alternatifs = {
1: "Bon de commande",
2: "Préparation",
3: "Bon de livraison",
4: "Bon de retour",
5: "Bon d'avoir",
6: "Facture",
}
if type_doc in types_officiels:
return types_officiels[type_doc]
if type_doc in types_alternatifs:
return types_alternatifs[type_doc]
return f"Type {type_doc}"
def _convertir_type_pour_sql(type_doc: int) -> int:
"""COM → SQL : 0, 10, 20, 30... → 0, 1, 2, 3..."""
mapping = {0: 0, 10: 1, 20: 2, 30: 3, 40: 4, 50: 5, 60: 6}
return mapping.get(type_doc, type_doc)
def _convertir_type_depuis_sql(type_sql: int) -> int:
"""SQL → COM : 0, 1, 2, 3... → 0, 10, 20, 30..."""
mapping = {0: 0, 1: 10, 2: 20, 3: 30, 4: 40, 5: 50, 6: 60}
return mapping.get(type_sql, type_sql)
def _normaliser_type_document(type_doc: int) -> int:
logger.info(f"[INFO] TYPE RECU{type_doc}")
if type_doc in [0, 10, 20, 30, 40, 50, 60]:
return type_doc
mapping_normalisation = {
1: 10,
2: 20,
3: 30,
4: 40,
5: 50,
6: 60,
}
return mapping_normalisation.get(type_doc, type_doc)
def normaliser_date(valeur):
"""Parse flexible des dates - supporte ISO, datetime, YYYY-MM-DD HH:MM:SS"""
if valeur is None:
return datetime.now()
if isinstance(valeur, datetime):
return valeur
if isinstance(valeur, date):
return datetime.combine(valeur, datetime.min.time())
if isinstance(valeur, str):
valeur = valeur.strip()
if not valeur:
return datetime.now()
if valeur.endswith("Z"):
valeur = valeur[:-1] + "+00:00"
formats = [
"%Y-%m-%dT%H:%M:%S.%f%z",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
"%d/%m/%Y %H:%M:%S",
"%d/%m/%Y %H:%M",
"%d/%m/%Y",
]
for fmt in formats:
try:
dt = datetime.strptime(valeur, fmt)
return dt.replace(tzinfo=None) if dt.tzinfo else dt
except ValueError:
continue
try:
if "+" in valeur:
valeur = valeur.split("+")[0]
dt = datetime.fromisoformat(valeur)
return dt.replace(tzinfo=None) if dt.tzinfo else dt
except ValueError:
pass
return datetime.now()
def _parser_heure_sage(do_heure) -> str:
"""Parse DO_Heure format Sage (HHMMSS stocké en entier)"""
if not do_heure:
return "00:00:00"
try:
heure_int = int(str(do_heure).strip())
heure_str = str(heure_int).zfill(6)
hh = int(heure_str[0:2])
mm = int(heure_str[2:4])
ss = int(heure_str[4:6])
if 0 <= hh <= 23 and 0 <= mm <= 59 and 0 <= ss <= 59:
return f"{hh:02d}:{mm:02d}:{ss:02d}"
except (ValueError, TypeError):
pass
return "00:00:00"
def _combiner_date_heure(do_date, do_heure) -> str:
"""Combine DO_Date et DO_Heure en datetime string"""
if not do_date:
return ""
try:
date_str = (
do_date.strftime("%Y-%m-%d")
if hasattr(do_date, "strftime")
else str(do_date)[:10]
)
heure_str = _parser_heure_sage(do_heure)
return f"{date_str} {heure_str}"
except Exception:
return str(do_date) if do_date else ""
__all__ = [
"_clean_str",
"_safe_strip",
"_safe_int",
"_try_set_attribute",
"_get_type_libelle",
"_normaliser_type_document",
"_convertir_type_depuis_sql",
"_convertir_type_pour_sql",
"normaliser_date",
"_combiner_date_heure",
]

View file

@ -0,0 +1,275 @@
from typing import Dict, Optional
import logging
from utils.functions.functions import _safe_strip
logger = logging.getLogger(__name__)
def contacts_to_dict(
contact, numero_client=None, contact_numero=None, n_contact=None
) -> Dict:
try:
civilite_code = getattr(contact, "Civilite", None)
civilite_map = {0: "M.", 1: "Mme", 2: "Mlle", 3: "Société"}
civilite = (
civilite_map.get(civilite_code) if civilite_code is not None else None
)
telephone = None
portable = None
telecopie = None
email = None
if hasattr(contact, "Telecom"):
try:
telecom = contact.Telecom
telephone = _safe_strip(getattr(telecom, "Telephone", None))
portable = _safe_strip(getattr(telecom, "Portable", None))
telecopie = _safe_strip(getattr(telecom, "Telecopie", None))
email = _safe_strip(getattr(telecom, "EMail", None))
except Exception:
pass
return {
"numero": numero_client,
"contact_numero": contact_numero,
"n_contact": n_contact or contact_numero,
"civilite": civilite,
"nom": _safe_strip(getattr(contact, "Nom", None)),
"prenom": _safe_strip(getattr(contact, "Prenom", None)),
"fonction": _safe_strip(getattr(contact, "Fonction", None)),
"service_code": getattr(contact, "ServiceContact", None),
"telephone": telephone,
"portable": portable,
"telecopie": telecopie,
"email": email,
"facebook": _safe_strip(getattr(contact, "Facebook", None)),
"linkedin": _safe_strip(getattr(contact, "LinkedIn", None)),
"skype": _safe_strip(getattr(contact, "Skype", None)),
}
except Exception as e:
logger.warning(f"Erreur conversion contact: {e}")
return {}
def contact_to_dict(row) -> Dict:
"""Convertit une ligne SQL en dictionnaire contact"""
civilite_code = row.CT_Civilite
civilite_map = {0: "M.", 1: "Mme", 2: "Mlle", 3: "Société"}
return {
"numero": _safe_strip(row.CT_Num),
"contact_numero": row.CT_No,
"n_contact": row.N_Contact,
"civilite": civilite_map.get(civilite_code)
if civilite_code is not None
else None,
"nom": _safe_strip(row.CT_Nom),
"prenom": _safe_strip(row.CT_Prenom),
"fonction": _safe_strip(row.CT_Fonction),
"service_code": row.N_Service,
"telephone": _safe_strip(row.CT_Telephone),
"portable": _safe_strip(row.CT_TelPortable),
"telecopie": _safe_strip(row.CT_Telecopie),
"email": _safe_strip(row.CT_EMail),
"facebook": _safe_strip(row.CT_Facebook),
"linkedin": _safe_strip(row.CT_LinkedIn),
"skype": _safe_strip(row.CT_Skype),
}
def _collaborators_to_dict(row) -> Optional[dict]:
"""Convertit une ligne SQL en dictionnaire collaborateur"""
if not hasattr(row, "Collab_CO_No") or row.Collab_CO_No is None:
return None
return {
"numero": row.Collab_CO_No,
"nom": _safe_strip(row.Collab_CO_Nom),
"prenom": _safe_strip(row.Collab_CO_Prenom),
"fonction": _safe_strip(row.Collab_CO_Fonction),
"adresse": _safe_strip(row.Collab_CO_Adresse),
"complement": _safe_strip(row.Collab_CO_Complement),
"code_postal": _safe_strip(row.Collab_CO_CodePostal),
"ville": _safe_strip(row.Collab_CO_Ville),
"region": _safe_strip(row.Collab_CO_CodeRegion),
"pays": _safe_strip(row.Collab_CO_Pays),
"service": _safe_strip(row.Collab_CO_Service),
"est_vendeur": (row.Collab_CO_Vendeur == 1)
if row.Collab_CO_Vendeur is not None
else None,
"est_caissier": (row.Collab_CO_Caissier == 1)
if row.Collab_CO_Caissier is not None
else None,
"est_acheteur": (row.Collab_CO_Acheteur == 1)
if row.Collab_CO_Acheteur is not None
else None,
"telephone": _safe_strip(row.Collab_CO_Telephone),
"telecopie": _safe_strip(row.Collab_CO_Telecopie),
"email": _safe_strip(row.Collab_CO_EMail),
"tel_portable": _safe_strip(row.Collab_CO_TelPortable),
"matricule": _safe_strip(row.Collab_CO_Matricule),
"facebook": _safe_strip(row.Collab_CO_Facebook),
"linkedin": _safe_strip(row.Collab_CO_LinkedIn),
"skype": _safe_strip(row.Collab_CO_Skype),
"est_actif": (row.Collab_CO_Sommeil == 0)
if row.Collab_CO_Sommeil is not None
else None,
"est_chef_ventes": (row.Collab_CO_ChefVentes == 1)
if row.Collab_CO_ChefVentes is not None
else None,
"chef_ventes_numero": row.Collab_CO_NoChefVentes,
}
def collaborators_to_dict(row):
"""Convertit une ligne SQL en dictionnaire collaborateur"""
return {
"numero": row.CO_No,
"nom": _safe_strip(row.CO_Nom),
"prenom": _safe_strip(row.CO_Prenom),
"fonction": _safe_strip(row.CO_Fonction),
"adresse": _safe_strip(row.CO_Adresse),
"complement": _safe_strip(row.CO_Complement),
"code_postal": _safe_strip(row.CO_CodePostal),
"ville": _safe_strip(row.CO_Ville),
"code_region": _safe_strip(row.CO_CodeRegion),
"pays": _safe_strip(row.CO_Pays),
"service": _safe_strip(row.CO_Service),
"vendeur": bool(row.CO_Vendeur),
"caissier": bool(row.CO_Caissier),
"acheteur": bool(row.CO_Acheteur),
"telephone": _safe_strip(row.CO_Telephone),
"telecopie": _safe_strip(row.CO_Telecopie),
"email": _safe_strip(row.CO_EMail),
"tel_portable": _safe_strip(row.CO_TelPortable),
"matricule": _safe_strip(row.CO_Matricule),
"facebook": _safe_strip(row.CO_Facebook),
"linkedin": _safe_strip(row.CO_LinkedIn),
"skype": _safe_strip(row.CO_Skype),
"sommeil": bool(row.CO_Sommeil),
"chef_ventes": bool(row.CO_ChefVentes),
"numero_chef_ventes": row.CO_NoChefVentes if row.CO_NoChefVentes else None,
}
def tiers_to_dict(row) -> dict:
"""Convertit une ligne SQL en dictionnaire tiers"""
tiers = {
"numero": _safe_strip(row.CT_Num),
"intitule": _safe_strip(row.CT_Intitule),
"type_tiers": row.CT_Type,
"qualite": _safe_strip(row.CT_Qualite),
"classement": _safe_strip(row.CT_Classement),
"raccourci": _safe_strip(row.CT_Raccourci),
"siret": _safe_strip(row.CT_Siret),
"tva_intra": _safe_strip(row.CT_Identifiant),
"code_naf": _safe_strip(row.CT_Ape),
"contact": _safe_strip(row.CT_Contact),
"adresse": _safe_strip(row.CT_Adresse),
"complement": _safe_strip(row.CT_Complement),
"code_postal": _safe_strip(row.CT_CodePostal),
"ville": _safe_strip(row.CT_Ville),
"region": _safe_strip(row.CT_CodeRegion),
"pays": _safe_strip(row.CT_Pays),
"telephone": _safe_strip(row.CT_Telephone),
"telecopie": _safe_strip(row.CT_Telecopie),
"email": _safe_strip(row.CT_EMail),
"site_web": _safe_strip(row.CT_Site),
"facebook": _safe_strip(row.CT_Facebook),
"linkedin": _safe_strip(row.CT_LinkedIn),
"taux01": row.CT_Taux01,
"taux02": row.CT_Taux02,
"taux03": row.CT_Taux03,
"taux04": row.CT_Taux04,
"statistique01": _safe_strip(row.CT_Statistique01),
"statistique02": _safe_strip(row.CT_Statistique02),
"statistique03": _safe_strip(row.CT_Statistique03),
"statistique04": _safe_strip(row.CT_Statistique04),
"statistique05": _safe_strip(row.CT_Statistique05),
"statistique06": _safe_strip(row.CT_Statistique06),
"statistique07": _safe_strip(row.CT_Statistique07),
"statistique08": _safe_strip(row.CT_Statistique08),
"statistique09": _safe_strip(row.CT_Statistique09),
"statistique10": _safe_strip(row.CT_Statistique10),
"encours_autorise": row.CT_Encours,
"assurance_credit": row.CT_Assurance,
"langue": row.CT_Langue,
"commercial_code": row.CO_No,
"commercial": _collaborators_to_dict(row),
"lettrage_auto": (row.CT_Lettrage == 1),
"est_actif": (row.CT_Sommeil == 0),
"type_facture": row.CT_Facture,
"est_prospect": (row.CT_Prospect == 1),
"bl_en_facture": row.CT_BLFact,
"saut_page": row.CT_Saut,
"validation_echeance": row.CT_ValidEch,
"controle_encours": row.CT_ControlEnc,
"exclure_relance": (row.CT_NotRappel == 1),
"exclure_penalites": (row.CT_NotPenal == 1),
"bon_a_payer": row.CT_BonAPayer,
"priorite_livraison": row.CT_PrioriteLivr,
"livraison_partielle": row.CT_LivrPartielle,
"delai_transport": row.CT_DelaiTransport,
"delai_appro": row.CT_DelaiAppro,
"commentaire": _safe_strip(row.CT_Commentaire),
"section_analytique": _safe_strip(row.CA_Num),
"mode_reglement_code": row.MR_No,
"surveillance_active": (row.CT_Surveillance == 1),
"coface": _safe_strip(row.CT_Coface),
"forme_juridique": _safe_strip(row.CT_SvFormeJuri),
"effectif": _safe_strip(row.CT_SvEffectif),
"sv_regularite": _safe_strip(row.CT_SvRegul),
"sv_cotation": _safe_strip(row.CT_SvCotation),
"sv_objet_maj": _safe_strip(row.CT_SvObjetMaj),
"sv_chiffre_affaires": row.CT_SvCA,
"sv_resultat": row.CT_SvResultat,
"compte_general": _safe_strip(row.CG_NumPrinc),
"categorie_tarif": row.N_CatTarif,
"categorie_compta": row.N_CatCompta,
}
return tiers
def society_to_dict(row) -> dict:
if not row:
return None
return {
"raison_sociale": _safe_strip(row.D_RaisonSoc),
"numero_dossier": _safe_strip(row.D_NumDoss),
"siret": _safe_strip(row.D_Siret),
"code_ape": _safe_strip(row.D_Ape),
"numero_tva": _safe_strip(row.D_Identifiant),
"adresse": _safe_strip(row.D_Adresse),
"complement_adresse": _safe_strip(row.D_Complement),
"code_postal": _safe_strip(row.D_CodePostal),
"ville": _safe_strip(row.D_Ville),
"code_region": _safe_strip(row.D_CodeRegion),
"pays": _safe_strip(row.D_Pays),
"telephone": _safe_strip(row.D_Telephone),
"telecopie": _safe_strip(row.D_Telecopie),
"email": _safe_strip(row.D_EMail),
"email_societe": _safe_strip(row.D_EMailSoc),
"site_web": _safe_strip(row.D_Site),
"capital": float(row.D_Capital) if row.D_Capital else 0.0,
"forme_juridique": _safe_strip(row.D_FormeJuridique),
"devise_compte": row.N_DeviseCompte or 0,
"devise_equivalent": row.N_DeviseEquival or 0,
"longueur_compte_general": row.D_LgCg or 0,
"longueur_compte_analytique": row.D_LgAn or 0,
"regime_fec": row.D_RegimeFEC or 0,
"base_modele": _safe_strip(row.BM_Intitule),
"marqueur": row.cbMarq or 0,
"_logo_path": _safe_strip(row.D_Logo),
}
__all__ = [
"contacts_to_dict",
"contact_to_dict",
"tiers_to_dict",
"collaborators_to_dict",
"society_to_dict"
]

View file

@ -0,0 +1,232 @@
import logging
from utils.functions.functions import (
_convertir_type_depuis_sql,
_convertir_type_pour_sql,
_normaliser_type_document,
_get_type_libelle,
)
logger = logging.getLogger(__name__)
def _verifier_devis_non_transforme(numero: str, doc, cursor):
"""Vérifie que le devis n'est pas transformé."""
verification = verifier_si_deja_transforme_sql(numero, cursor, 0)
if verification["deja_transforme"]:
docs_cibles = verification["documents_cibles"]
nums = [d["numero"] for d in docs_cibles]
raise ValueError(
f" Devis {numero} déjà transformé en {len(docs_cibles)} document(s): {', '.join(nums)}"
)
statut_actuel = getattr(doc, "DO_Statut", 0)
if statut_actuel == 5:
raise ValueError(f" Devis {numero} déjà transformé (statut=5)")
def verifier_si_deja_transforme_sql(numero_source, cursor, type_source):
"""Version corrigée avec normalisation des types"""
logger.info(
f"[VERIF] Vérification transformations de {numero_source} (type {type_source})"
)
logger.info(
f"[VERIF] Vérification transformations de {numero_source} (type {type_source})"
)
logger.info(f"[DEBUG] Type source brut: {type_source}")
logger.info(
f"[DEBUG] Type source après normalisation: {_normaliser_type_document(type_source)}"
)
logger.info(
f"[DEBUG] Type source après normalisation SQL: {_convertir_type_pour_sql(type_source)}"
)
type_source = _convertir_type_pour_sql(type_source)
champ_liaison_mapping = {
0: "DL_PieceDE",
1: "DL_PieceBC",
3: "DL_PieceBL",
}
champ_liaison = champ_liaison_mapping.get(type_source)
if not champ_liaison:
logger.warning(f"[VERIF] Type source {type_source} non géré")
return {"deja_transforme": False, "documents_cibles": []}
try:
query = f"""
SELECT DISTINCT
dc.DO_Piece,
dc.DO_Type,
dc.DO_Statut,
(SELECT COUNT(*) FROM F_DOCLIGNE
WHERE DO_Piece = dc.DO_Piece AND DO_Type = dc.DO_Type) as NbLignes
FROM F_DOCENTETE dc
INNER JOIN F_DOCLIGNE dl ON dc.DO_Piece = dl.DO_Piece AND dc.DO_Type = dl.DO_Type
WHERE dl.{champ_liaison} = ?
ORDER BY dc.DO_Type, dc.DO_Piece
"""
cursor.execute(query, (numero_source,))
resultats = cursor.fetchall()
documents_cibles = []
for row in resultats:
type_brut = int(row.DO_Type)
type_normalise = _convertir_type_depuis_sql(type_brut)
doc = {
"numero": row.DO_Piece.strip() if row.DO_Piece else "",
"type": type_normalise,
"type_brut": type_brut,
"type_libelle": _get_type_libelle(type_brut),
"statut": int(row.DO_Statut) if row.DO_Statut else 0,
"nb_lignes": int(row.NbLignes) if row.NbLignes else 0,
}
documents_cibles.append(doc)
logger.info(
f"[VERIF] Trouvé: {doc['numero']} "
f"(type {type_brut}{type_normalise} - {doc['type_libelle']}) "
f"- {doc['nb_lignes']} lignes"
)
deja_transforme = len(documents_cibles) > 0
if deja_transforme:
logger.info(
f"[VERIF] Document {numero_source} a {len(documents_cibles)} transformation(s)"
)
else:
logger.info(f"[VERIF] Document {numero_source} pas encore transformé")
return {
"deja_transforme": deja_transforme,
"documents_cibles": documents_cibles,
}
except Exception as e:
logger.error(f"[VERIF] Erreur vérification: {e}")
return {"deja_transforme": False, "documents_cibles": []}
def peut_etre_transforme(cursor, numero_source, type_source, type_cible):
"""Version corrigée avec normalisation"""
type_source = _normaliser_type_document(type_source)
type_cible = _normaliser_type_document(type_cible)
logger.info(
f"[VERIF_TRANSFO] {numero_source} (type {type_source}) → type {type_cible}"
)
verif = verifier_si_deja_transforme_sql(cursor, numero_source, type_source)
docs_meme_type = [d for d in verif["documents_cibles"] if d["type"] == type_cible]
if docs_meme_type:
nums = [d["numero"] for d in docs_meme_type]
return {
"possible": False,
"raison": f"Document déjà transformé en {_get_type_libelle(type_cible)}",
"documents_existants": docs_meme_type,
"message_detaille": f"Document(s) existant(s): {', '.join(nums)}",
}
return {
"possible": True,
"raison": "Transformation possible",
"documents_existants": [],
}
def lire_erreurs_sage(obj, nom_obj=""):
erreurs = []
try:
if not hasattr(obj, "Errors") or obj.Errors is None:
return erreurs
nb_erreurs = 0
try:
nb_erreurs = obj.Errors.Count
except Exception:
return erreurs
if nb_erreurs == 0:
return erreurs
for i in range(1, nb_erreurs + 1):
try:
err = None
try:
err = obj.Errors.Item(i)
except Exception:
try:
err = obj.Errors(i)
except Exception:
try:
err = obj.Errors.Item(i - 1)
except Exception:
pass
if err is not None:
description = ""
field = ""
number = ""
for attr in ["Description", "Descr", "Message", "Text"]:
try:
val = getattr(err, attr, None)
if val:
description = str(val)
break
except Exception:
pass
for attr in ["Field", "FieldName", "Champ", "Property"]:
try:
val = getattr(err, attr, None)
if val:
field = str(val)
break
except Exception:
pass
for attr in ["Number", "Code", "ErrorCode", "Numero"]:
try:
val = getattr(err, attr, None)
if val is not None:
number = str(val)
break
except Exception:
pass
if description or field or number:
erreurs.append(
{
"source": nom_obj,
"index": i,
"description": description or "Erreur inconnue",
"field": field or "?",
"number": number or "?",
}
)
except Exception as e:
logger.debug(f"Erreur lecture erreur {i}: {e}")
continue
except Exception as e:
logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}")
return erreurs
__all__ = [
"_verifier_devis_non_transforme",
"verifier_si_deja_transforme_sql",
"peut_etre_transforme",
"lire_erreurs_sage",
]

View file

@ -0,0 +1,131 @@
from pathlib import Path
import base64
import logging
logger = logging.getLogger(__name__)
def get_societe_row(cursor):
"""Récupère la ligne P_DOSSIER"""
query = """
SELECT
D_RaisonSoc, D_NumDoss, D_Siret, D_Ape, D_Identifiant,
D_Adresse, D_Complement, D_CodePostal, D_Ville,
D_CodeRegion, D_Pays,
D_Telephone, D_Telecopie, D_EMail, D_EMailSoc, D_Site,
D_Capital, D_FormeJuridique,
D_DebutExo01, D_FinExo01,
D_DebutExo02, D_FinExo02,
D_DebutExo03, D_FinExo03,
D_DebutExo04, D_FinExo04,
D_DebutExo05, D_FinExo05,
N_DeviseCompte, N_DeviseEquival,
D_LgCg, D_LgAn,
D_RegimeFEC,
BM_Intitule,
cbMarq,
D_Logo
FROM P_DOSSIER
"""
cursor.execute(query)
return cursor.fetchone()
def build_exercices(row) -> list:
"""Construit la liste des exercices"""
exercices_data = [
(1, row.D_DebutExo01, row.D_FinExo01),
(2, row.D_DebutExo02, row.D_FinExo02),
(3, row.D_DebutExo03, row.D_FinExo03),
(4, row.D_DebutExo04, row.D_FinExo04),
(5, row.D_DebutExo05, row.D_FinExo05),
]
exercices = []
for numero, debut, fin in exercices_data:
if debut and debut.year > 1753:
exercices.append(
{
"numero": numero,
"debut": debut.isoformat(),
"fin": fin.isoformat() if fin and fin.year > 1753 else None,
}
)
return exercices
def add_logo(societe_dict: dict) -> None:
"""Ajoute le logo en base64 au dict"""
logo_path = societe_dict.pop("_logo_path", None)
if logo_path and Path(logo_path).exists():
try:
ext = Path(logo_path).suffix.lower()
content_type = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".bmp": "image/bmp",
".gif": "image/gif",
}.get(ext, "image/png")
with open(logo_path, "rb") as f:
societe_dict["logo_base64"] = base64.b64encode(f.read()).decode("utf-8")
societe_dict["logo_content_type"] = content_type
return
except Exception as e:
logger.warning(f"Erreur conversion logo: {e}")
societe_dict["logo_base64"] = None
societe_dict["logo_content_type"] = None
def recuperer_logo_com(sage_instance) -> dict:
"""Cherche le logo dans les répertoires standards"""
return _chercher_logo_standards()
def _chercher_logo_standards() -> dict:
"""Cherche dans les répertoires standards Sage"""
chemins = [
Path("C:/ProgramData/Sage/Logo"),
Path("C:/ProgramData/Sage/Sage 100/Logo"),
Path("C:/Users/Public/Documents/Sage"),
Path(r"C:\Program Files\Sage\Sage 100\Bitmap"),
Path(r"C:\Program Files (x86)\Sage\Sage 100\Bitmap"),
]
for repertoire in chemins:
if not repertoire.exists():
continue
for ext in [".bmp", ".jpg", ".jpeg", ".png", ".gif"]:
for fichier in repertoire.glob(f"*{ext}"):
logger.info(f"Logo trouvé: {fichier}")
return _convertir_fichier_logo(str(fichier))
logger.info("Aucun logo trouvé")
return {"logo_base64": None, "logo_content_type": None}
def _convertir_fichier_logo(chemin: str) -> dict:
"""Convertit image en base64"""
ext = Path(chemin).suffix.lower()
content_type = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".bmp": "image/bmp",
".gif": "image/gif",
}.get(ext, "image/png")
with open(chemin, "rb") as f:
return {
"logo_base64": base64.b64encode(f.read()).decode("utf-8"),
"logo_content_type": content_type,
}
__all__ = ["get_societe_row", "build_exercices", "add_logo", "recuperer_logo_com"]

0
utils/tiers/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,326 @@
import win32com.client
import logging
logger = logging.getLogger(__name__)
def _cast_client(persist_obj):
try:
obj = win32com.client.CastTo(persist_obj, "IBOClient3")
obj.Read()
return obj
except Exception as e:
logger.debug(f" _cast_client échoue: {e}")
return None
def _extraire_client(client_obj):
try:
try:
numero = getattr(client_obj, "CT_Num", "").strip()
if not numero:
logger.debug("Objet sans CT_Num, skip")
return None
except Exception as e:
logger.debug(f" Erreur lecture CT_Num: {e}")
return None
try:
intitule = getattr(client_obj, "CT_Intitule", "").strip()
if not intitule:
logger.debug(f"{numero} sans CT_Intitule")
except Exception as e:
logger.debug(f"Erreur CT_Intitule sur {numero}: {e}")
intitule = ""
data = {
"numero": numero,
"intitule": intitule,
}
try:
qualite_code = getattr(client_obj, "CT_Type", None)
qualite_map = {
0: "CLI",
1: "FOU",
2: "CLIFOU",
3: "SAL",
4: "PRO",
}
data["qualite"] = qualite_map.get(qualite_code, "CLI")
data["est_fournisseur"] = qualite_code in [1, 2]
except Exception:
data["qualite"] = "CLI"
data["est_fournisseur"] = False
try:
data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1
except Exception:
data["est_prospect"] = False
if data["est_prospect"]:
data["type_tiers"] = "prospect"
elif data["est_fournisseur"] and data["qualite"] != "CLIFOU":
data["type_tiers"] = "fournisseur"
elif data["qualite"] == "CLIFOU":
data["type_tiers"] = "client_fournisseur"
else:
data["type_tiers"] = "client"
try:
sommeil = getattr(client_obj, "CT_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["est_en_sommeil"] = sommeil == 1
except Exception:
data["est_actif"] = True
data["est_en_sommeil"] = False
try:
forme_juridique = getattr(client_obj, "CT_FormeJuridique", "").strip()
data["forme_juridique"] = forme_juridique
data["est_entreprise"] = bool(forme_juridique)
data["est_particulier"] = not bool(forme_juridique)
except Exception:
data["forme_juridique"] = ""
data["est_entreprise"] = False
data["est_particulier"] = True
try:
data["civilite"] = getattr(client_obj, "CT_Civilite", "").strip()
except Exception:
data["civilite"] = ""
try:
data["nom"] = getattr(client_obj, "CT_Nom", "").strip()
except Exception:
data["nom"] = ""
try:
data["prenom"] = getattr(client_obj, "CT_Prenom", "").strip()
except Exception:
data["prenom"] = ""
if data.get("nom") or data.get("prenom"):
parts = []
if data.get("civilite"):
parts.append(data["civilite"])
if data.get("prenom"):
parts.append(data["prenom"])
if data.get("nom"):
parts.append(data["nom"])
data["nom_complet"] = " ".join(parts)
else:
data["nom_complet"] = ""
try:
data["contact"] = getattr(client_obj, "CT_Contact", "").strip()
except Exception:
data["contact"] = ""
try:
adresse_obj = getattr(client_obj, "Adresse", None)
if adresse_obj:
try:
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
except Exception:
data["adresse"] = ""
try:
data["complement"] = getattr(adresse_obj, "Complement", "").strip()
except Exception:
data["complement"] = ""
try:
data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip()
except Exception:
data["code_postal"] = ""
try:
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
except Exception:
data["ville"] = ""
try:
data["region"] = getattr(adresse_obj, "Region", "").strip()
except Exception:
data["region"] = ""
try:
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
except Exception:
data["pays"] = ""
else:
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
except Exception as e:
logger.debug(f"Erreur adresse sur {numero}: {e}")
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
try:
telecom = getattr(client_obj, "Telecom", None)
if telecom:
try:
data["telephone"] = getattr(telecom, "Telephone", "").strip()
except Exception:
data["telephone"] = ""
try:
data["portable"] = getattr(telecom, "Portable", "").strip()
except Exception:
data["portable"] = ""
try:
data["telecopie"] = getattr(telecom, "Telecopie", "").strip()
except Exception:
data["telecopie"] = ""
try:
data["email"] = getattr(telecom, "EMail", "").strip()
except Exception:
data["email"] = ""
try:
site = (
getattr(telecom, "Site", None)
or getattr(telecom, "Web", None)
or getattr(telecom, "SiteWeb", "")
)
data["site_web"] = str(site).strip() if site else ""
except Exception:
data["site_web"] = ""
else:
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
except Exception as e:
logger.debug(f"Erreur telecom sur {numero}: {e}")
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
try:
data["siret"] = getattr(client_obj, "CT_Siret", "").strip()
except Exception:
data["siret"] = ""
try:
data["siren"] = getattr(client_obj, "CT_Siren", "").strip()
except Exception:
data["siren"] = ""
try:
data["tva_intra"] = getattr(client_obj, "CT_Identifiant", "").strip()
except Exception:
data["tva_intra"] = ""
try:
data["code_naf"] = (
getattr(client_obj, "CT_CodeNAF", "").strip()
or getattr(client_obj, "CT_APE", "").strip()
)
except Exception:
data["code_naf"] = ""
try:
data["secteur"] = getattr(client_obj, "CT_Secteur", "").strip()
except Exception:
data["secteur"] = ""
try:
effectif = getattr(client_obj, "CT_Effectif", None)
data["effectif"] = int(effectif) if effectif is not None else None
except Exception:
data["effectif"] = None
try:
ca = getattr(client_obj, "CT_ChiffreAffaire", None)
data["ca_annuel"] = float(ca) if ca is not None else None
except Exception:
data["ca_annuel"] = None
try:
data["commercial_code"] = getattr(client_obj, "CO_No", "").strip()
except Exception:
try:
data["commercial_code"] = getattr(
client_obj, "CT_Commercial", ""
).strip()
except Exception:
data["commercial_code"] = ""
if data.get("commercial_code"):
try:
commercial_obj = getattr(client_obj, "Commercial", None)
if commercial_obj:
commercial_obj.Read()
data["commercial_nom"] = getattr(
commercial_obj, "CO_Nom", ""
).strip()
else:
data["commercial_nom"] = ""
except Exception:
data["commercial_nom"] = ""
else:
data["commercial_nom"] = ""
try:
data["categorie_tarifaire"] = getattr(client_obj, "N_CatTarif", None)
except Exception:
data["categorie_tarifaire"] = None
try:
data["categorie_comptable"] = getattr(client_obj, "N_CatCompta", None)
except Exception:
data["categorie_comptable"] = None
try:
data["encours_autorise"] = float(getattr(client_obj, "CT_Encours", 0.0))
except Exception:
data["encours_autorise"] = 0.0
try:
data["assurance_credit"] = float(getattr(client_obj, "CT_Assurance", 0.0))
except Exception:
data["assurance_credit"] = 0.0
try:
data["compte_general"] = getattr(client_obj, "CG_Num", "").strip()
except Exception:
data["compte_general"] = ""
try:
date_creation = getattr(client_obj, "CT_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except Exception:
data["date_creation"] = ""
try:
date_modif = getattr(client_obj, "CT_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except Exception:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f" ERREUR GLOBALE _extraire_client: {e}", exc_info=True)
return None
__all__ = ["_extraire_client", "_cast_client"]

View file

View file

@ -0,0 +1,168 @@
from typing import Dict, Optional
from utils.functions.items_to_dict import contact_to_dict
import logging
from utils.functions.functions import _safe_strip
logger = logging.getLogger(__name__)
def _get_contacts(numero: str, conn) -> list:
try:
cursor = conn.cursor()
query = """
SELECT
CT_Num, CT_No, N_Contact,
CT_Civilite, CT_Nom, CT_Prenom, CT_Fonction,
N_Service,
CT_Telephone, CT_TelPortable, CT_Telecopie, CT_EMail,
CT_Facebook, CT_LinkedIn, CT_Skype
FROM F_CONTACTT
WHERE CT_Num = ?
ORDER BY N_Contact, CT_Nom, CT_Prenom
"""
cursor.execute(query, [numero])
rows = cursor.fetchall()
query_client = """
SELECT CT_Contact
FROM F_COMPTET
WHERE CT_Num = ?
"""
cursor.execute(query_client, [numero])
client_row = cursor.fetchone()
nom_contact_defaut = None
if client_row:
nom_contact_defaut = _safe_strip(client_row.CT_Contact)
contacts = []
for row in rows:
contact = contact_to_dict(row)
if nom_contact_defaut:
nom_complet = f"{contact.get('prenom', '')} {contact['nom']}".strip()
contact["est_defaut"] = (
nom_complet == nom_contact_defaut or
contact['nom'] == nom_contact_defaut
)
else:
contact["est_defaut"] = False
contacts.append(contact)
return contacts
except Exception as e:
logger.warning(f" Impossible de récupérer contacts pour {numero}: {e}")
return []
def _chercher_contact_en_base(
conn,
numero_client: str,
nom: str,
prenom: Optional[str] = None
) -> Optional[Dict]:
try:
cursor = conn.cursor()
if prenom:
query = """
SELECT TOP 1 CT_No, N_Contact
FROM F_CONTACTT
WHERE CT_Num = ?
AND LTRIM(RTRIM(CT_Nom)) = ?
AND LTRIM(RTRIM(CT_Prenom)) = ?
ORDER BY CT_No DESC
"""
cursor.execute(query, (numero_client.upper(), nom.strip(), prenom.strip()))
else:
query = """
SELECT TOP 1 CT_No, N_Contact
FROM F_CONTACTT
WHERE CT_Num = ?
AND LTRIM(RTRIM(CT_Nom)) = ?
AND (CT_Prenom IS NULL OR LTRIM(RTRIM(CT_Prenom)) = '')
ORDER BY CT_No DESC
"""
cursor.execute(query, (numero_client.upper(), nom.strip()))
row = cursor.fetchone()
if row:
return {
"contact_numero": row.CT_No,
"n_contact": row.N_Contact
}
return None
except Exception as e:
logger.warning(f"Erreur recherche contact en base: {e}")
return None
def _lire_contact_depuis_base(
conn,
numero_client: str,
contact_no: int
) -> Optional[Dict]:
try:
cursor = conn.cursor()
query = """
SELECT
CT_Num, CT_No, N_Contact,
CT_Civilite, CT_Nom, CT_Prenom, CT_Fonction,
N_Service,
CT_Telephone, CT_TelPortable, CT_Telecopie, CT_EMail,
CT_Facebook, CT_LinkedIn, CT_Skype
FROM F_CONTACTT
WHERE CT_Num = ? AND CT_No = ?
"""
logger.info(f" Execution SQL: CT_Num='{numero_client.upper()}', CT_No={contact_no}")
cursor.execute(query, (numero_client.upper(), contact_no))
row = cursor.fetchone()
if not row:
logger.warning(f" Aucune ligne retournée pour CT_Num={numero_client.upper()}, CT_No={contact_no}")
return None
logger.info(f" Ligne SQL trouvée: Nom={row.CT_Nom}, Prenom={row.CT_Prenom}")
civilite_map = {0: "M.", 1: "Mme", 2: "Mlle", 3: "Société"}
result = {
"numero": _safe_strip(row.CT_Num),
"contact_numero": row.CT_No,
"n_contact": row.N_Contact,
"civilite": civilite_map.get(row.CT_Civilite, None),
"nom": _safe_strip(row.CT_Nom),
"prenom": _safe_strip(row.CT_Prenom),
"fonction": _safe_strip(row.CT_Fonction),
"service_code": row.N_Service,
"telephone": _safe_strip(row.CT_Telephone),
"portable": _safe_strip(row.CT_TelPortable),
"telecopie": _safe_strip(row.CT_Telecopie),
"email": _safe_strip(row.CT_EMail),
"facebook": _safe_strip(row.CT_Facebook),
"linkedin": _safe_strip(row.CT_LinkedIn),
"skype": _safe_strip(row.CT_Skype),
"est_defaut": False,
}
logger.info(f" Dict construit: numero={result['numero']}, nom={result['nom']}")
return result
except Exception as e:
logger.error(f" Exception dans : {e}", exc_info=True)
return None
__all__ = [
"_get_contacts",
"_chercher_contact_en_base",
"_lire_contact_depuis_base"
]

View file

View file

@ -0,0 +1,362 @@
import win32com.client
import logging
logger = logging.getLogger(__name__)
def _extraire_fournisseur_enrichi(fourn_obj):
try:
numero = getattr(fourn_obj, "CT_Num", "").strip()
if not numero:
return None
intitule = getattr(fourn_obj, "CT_Intitule", "").strip()
data = {
"numero": numero,
"intitule": intitule,
"type": 1,
"est_fournisseur": True,
}
try:
sommeil = getattr(fourn_obj, "CT_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["en_sommeil"] = sommeil == 1
except Exception:
data["est_actif"] = True
data["en_sommeil"] = False
try:
adresse_obj = getattr(fourn_obj, "Adresse", None)
if adresse_obj:
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
data["complement"] = getattr(adresse_obj, "Complement", "").strip()
data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip()
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
data["region"] = getattr(adresse_obj, "Region", "").strip()
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
parties_adresse = []
if data["adresse"]:
parties_adresse.append(data["adresse"])
if data["complement"]:
parties_adresse.append(data["complement"])
if data["code_postal"] or data["ville"]:
ville_cp = f"{data['code_postal']} {data['ville']}".strip()
if ville_cp:
parties_adresse.append(ville_cp)
if data["pays"]:
parties_adresse.append(data["pays"])
data["adresse_complete"] = ", ".join(parties_adresse)
else:
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
data["adresse_complete"] = ""
except Exception as e:
logger.debug(f"Erreur adresse fournisseur {numero}: {e}")
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
data["adresse_complete"] = ""
try:
telecom_obj = getattr(fourn_obj, "Telecom", None)
if telecom_obj:
data["telephone"] = getattr(telecom_obj, "Telephone", "").strip()
data["portable"] = getattr(telecom_obj, "Portable", "").strip()
data["telecopie"] = getattr(telecom_obj, "Telecopie", "").strip()
data["email"] = getattr(telecom_obj, "EMail", "").strip()
try:
site = (
getattr(telecom_obj, "Site", None)
or getattr(telecom_obj, "Web", None)
or getattr(telecom_obj, "SiteWeb", "")
)
data["site_web"] = str(site).strip() if site else ""
except Exception:
data["site_web"] = ""
else:
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
except Exception as e:
logger.debug(f"Erreur telecom fournisseur {numero}: {e}")
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
try:
data["siret"] = getattr(fourn_obj, "CT_Siret", "").strip()
except Exception:
data["siret"] = ""
try:
if data["siret"] and len(data["siret"]) >= 9:
data["siren"] = data["siret"][:9]
else:
data["siren"] = getattr(fourn_obj, "CT_Siren", "").strip()
except Exception:
data["siren"] = ""
try:
data["tva_intra"] = getattr(fourn_obj, "CT_Identifiant", "").strip()
except Exception:
data["tva_intra"] = ""
try:
data["code_naf"] = (
getattr(fourn_obj, "CT_CodeNAF", "").strip()
or getattr(fourn_obj, "CT_APE", "").strip()
)
except Exception:
data["code_naf"] = ""
try:
data["forme_juridique"] = getattr(
fourn_obj, "CT_FormeJuridique", ""
).strip()
except Exception:
data["forme_juridique"] = ""
try:
cat_tarif = getattr(fourn_obj, "N_CatTarif", None)
data["categorie_tarifaire"] = (
int(cat_tarif) if cat_tarif is not None else None
)
except Exception:
data["categorie_tarifaire"] = None
try:
cat_compta = getattr(fourn_obj, "N_CatCompta", None)
data["categorie_comptable"] = (
int(cat_compta) if cat_compta is not None else None
)
except Exception:
data["categorie_comptable"] = None
try:
cond_regl = getattr(fourn_obj, "CT_CondRegl", "").strip()
data["conditions_reglement_code"] = cond_regl
if cond_regl:
try:
cond_obj = getattr(fourn_obj, "ConditionReglement", None)
if cond_obj:
cond_obj.Read()
data["conditions_reglement_libelle"] = getattr(
cond_obj, "C_Intitule", ""
).strip()
else:
data["conditions_reglement_libelle"] = ""
except Exception:
data["conditions_reglement_libelle"] = ""
else:
data["conditions_reglement_libelle"] = ""
except Exception:
data["conditions_reglement_code"] = ""
data["conditions_reglement_libelle"] = ""
try:
mode_regl = getattr(fourn_obj, "CT_ModeRegl", "").strip()
data["mode_reglement_code"] = mode_regl
if mode_regl:
try:
mode_obj = getattr(fourn_obj, "ModeReglement", None)
if mode_obj:
mode_obj.Read()
data["mode_reglement_libelle"] = getattr(
mode_obj, "M_Intitule", ""
).strip()
else:
data["mode_reglement_libelle"] = ""
except Exception:
data["mode_reglement_libelle"] = ""
else:
data["mode_reglement_libelle"] = ""
except Exception:
data["mode_reglement_code"] = ""
data["mode_reglement_libelle"] = ""
data["coordonnees_bancaires"] = []
try:
factory_banque = getattr(fourn_obj, "FactoryBanque", None)
if factory_banque:
index = 1
while index <= 5:
try:
banque_persist = factory_banque.List(index)
if banque_persist is None:
break
banque = win32com.client.CastTo(banque_persist, "IBOBanque3")
banque.Read()
compte_bancaire = {
"banque_nom": getattr(banque, "BI_Intitule", "").strip(),
"iban": getattr(banque, "RIB_Iban", "").strip(),
"bic": getattr(banque, "RIB_Bic", "").strip(),
"code_banque": getattr(banque, "RIB_Banque", "").strip(),
"code_guichet": getattr(banque, "RIB_Guichet", "").strip(),
"numero_compte": getattr(banque, "RIB_Compte", "").strip(),
"cle_rib": getattr(banque, "RIB_Cle", "").strip(),
}
if compte_bancaire["iban"] or compte_bancaire["numero_compte"]:
data["coordonnees_bancaires"].append(compte_bancaire)
index += 1
except Exception:
break
except Exception as e:
logger.debug(f"Erreur coordonnées bancaires fournisseur {numero}: {e}")
if data["coordonnees_bancaires"]:
data["iban_principal"] = data["coordonnees_bancaires"][0].get("iban", "")
data["bic_principal"] = data["coordonnees_bancaires"][0].get("bic", "")
else:
data["iban_principal"] = ""
data["bic_principal"] = ""
data["contacts"] = []
try:
factory_contact = getattr(fourn_obj, "FactoryContact", None)
if factory_contact:
index = 1
while index <= 20:
try:
contact_persist = factory_contact.List(index)
if contact_persist is None:
break
contact = win32com.client.CastTo(contact_persist, "IBOContact3")
contact.Read()
contact_data = {
"nom": getattr(contact, "CO_Nom", "").strip(),
"prenom": getattr(contact, "CO_Prenom", "").strip(),
"fonction": getattr(contact, "CO_Fonction", "").strip(),
"service": getattr(contact, "CO_Service", "").strip(),
"telephone": getattr(contact, "CO_Telephone", "").strip(),
"portable": getattr(contact, "CO_Portable", "").strip(),
"email": getattr(contact, "CO_EMail", "").strip(),
}
nom_complet = (
f"{contact_data['prenom']} {contact_data['nom']}".strip()
)
if nom_complet:
contact_data["nom_complet"] = nom_complet
else:
contact_data["nom_complet"] = contact_data["nom"]
if contact_data["nom"]:
data["contacts"].append(contact_data)
index += 1
except Exception:
break
except Exception as e:
logger.debug(f"Erreur contacts fournisseur {numero}: {e}")
data["nb_contacts"] = len(data["contacts"])
if data["contacts"]:
data["contact_principal"] = data["contacts"][0]
else:
data["contact_principal"] = None
try:
data["encours_autorise"] = float(getattr(fourn_obj, "CT_Encours", 0.0))
except Exception:
data["encours_autorise"] = 0.0
try:
data["ca_annuel"] = float(getattr(fourn_obj, "CT_ChiffreAffaire", 0.0))
except Exception:
data["ca_annuel"] = 0.0
try:
data["compte_general"] = getattr(fourn_obj, "CG_Num", "").strip()
except Exception:
data["compte_general"] = ""
try:
date_creation = getattr(fourn_obj, "CT_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except Exception:
data["date_creation"] = ""
try:
date_modif = getattr(fourn_obj, "CT_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except Exception:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f" Erreur extraction fournisseur: {e}", exc_info=True)
return {
"numero": getattr(fourn_obj, "CT_Num", "").strip(),
"intitule": getattr(fourn_obj, "CT_Intitule", "").strip(),
"type": 1,
"est_fournisseur": True,
"est_actif": True,
"en_sommeil": False,
"adresse": "",
"complement": "",
"code_postal": "",
"ville": "",
"region": "",
"pays": "",
"adresse_complete": "",
"telephone": "",
"portable": "",
"telecopie": "",
"email": "",
"site_web": "",
"siret": "",
"siren": "",
"tva_intra": "",
"code_naf": "",
"forme_juridique": "",
"categorie_tarifaire": None,
"categorie_comptable": None,
"conditions_reglement_code": "",
"conditions_reglement_libelle": "",
"mode_reglement_code": "",
"mode_reglement_libelle": "",
"iban_principal": "",
"bic_principal": "",
"coordonnees_bancaires": [],
"contacts": [],
"nb_contacts": 0,
"contact_principal": None,
"encours_autorise": 0.0,
"ca_annuel": 0.0,
"compte_general": "",
"date_creation": "",
"date_modification": "",
}
__all__ = ["_extraire_fournisseur_enrichi"]

View file

@ -0,0 +1,81 @@
def _build_tiers_select_query():
return """
SELECT
-- IDENTIFICATION TIERS (9)
t.CT_Num, t.CT_Intitule, t.CT_Type, t.CT_Qualite,
t.CT_Classement, t.CT_Raccourci, t.CT_Siret, t.CT_Identifiant,
t.CT_Ape,
-- ADRESSE TIERS (7)
t.CT_Contact, t.CT_Adresse, t.CT_Complement,
t.CT_CodePostal, t.CT_Ville, t.CT_CodeRegion, t.CT_Pays,
-- TELECOM TIERS (6)
t.CT_Telephone, t.CT_Telecopie, t.CT_EMail, t.CT_Site,
t.CT_Facebook, t.CT_LinkedIn,
-- TAUX TIERS (4)
t.CT_Taux01, t.CT_Taux02, t.CT_Taux03, t.CT_Taux04,
-- STATISTIQUES TIERS (10)
t.CT_Statistique01, t.CT_Statistique02, t.CT_Statistique03,
t.CT_Statistique04, t.CT_Statistique05, t.CT_Statistique06,
t.CT_Statistique07, t.CT_Statistique08, t.CT_Statistique09,
t.CT_Statistique10,
-- COMMERCIAL TIERS (4)
t.CT_Encours, t.CT_Assurance, t.CT_Langue, t.CO_No,
-- FACTURATION TIERS (11)
t.CT_Lettrage, t.CT_Sommeil, t.CT_Facture, t.CT_Prospect,
t.CT_BLFact, t.CT_Saut, t.CT_ValidEch, t.CT_ControlEnc,
t.CT_NotRappel, t.CT_NotPenal, t.CT_BonAPayer,
-- LOGISTIQUE TIERS (4)
t.CT_PrioriteLivr, t.CT_LivrPartielle,
t.CT_DelaiTransport, t.CT_DelaiAppro,
-- COMMENTAIRE TIERS (1)
t.CT_Commentaire,
-- ANALYTIQUE TIERS (1)
t.CA_Num,
-- ORGANISATION / SURVEILLANCE TIERS (10)
t.MR_No, t.CT_Surveillance, t.CT_Coface,
t.CT_SvFormeJuri, t.CT_SvEffectif, t.CT_SvRegul,
t.CT_SvCotation, t.CT_SvObjetMaj, t.CT_SvCA, t.CT_SvResultat,
-- COMPTE GENERAL ET CATEGORIES TIERS (3)
t.CG_NumPrinc, t.N_CatTarif, t.N_CatCompta,
-- COLLABORATEUR (23 champs)
c.CO_No AS Collab_CO_No,
c.CO_Nom AS Collab_CO_Nom,
c.CO_Prenom AS Collab_CO_Prenom,
c.CO_Fonction AS Collab_CO_Fonction,
c.CO_Adresse AS Collab_CO_Adresse,
c.CO_Complement AS Collab_CO_Complement,
c.CO_CodePostal AS Collab_CO_CodePostal,
c.CO_Ville AS Collab_CO_Ville,
c.CO_CodeRegion AS Collab_CO_CodeRegion,
c.CO_Pays AS Collab_CO_Pays,
c.CO_Service AS Collab_CO_Service,
c.CO_Vendeur AS Collab_CO_Vendeur,
c.CO_Caissier AS Collab_CO_Caissier,
c.CO_Acheteur AS Collab_CO_Acheteur,
c.CO_Telephone AS Collab_CO_Telephone,
c.CO_Telecopie AS Collab_CO_Telecopie,
c.CO_EMail AS Collab_CO_EMail,
c.CO_TelPortable AS Collab_CO_TelPortable,
c.CO_Matricule AS Collab_CO_Matricule,
c.CO_Facebook AS Collab_CO_Facebook,
c.CO_LinkedIn AS Collab_CO_LinkedIn,
c.CO_Skype AS Collab_CO_Skype,
c.CO_Sommeil AS Collab_CO_Sommeil,
c.CO_ChefVentes AS Collab_CO_ChefVentes,
c.CO_NoChefVentes AS Collab_CO_NoChefVentes
"""
__all__ = ["_build_tiers_select_query"]