""" Logique partagée d'import / mise à jour de ContractPost depuis un fichier Excel de type « Inventaire E24.143 » (format SPRB-GOB/Fonteinen). Structure des colonnes (0-indexé) : 0 – Nr. Post → order_number 1 – Code Fontaine → code 2 – Beschrijving NL → description_nl 3 – AO/TVS → is_amount_to_be_justified (TVS = True) 4 – Unité NL → fallback unit 5 – Description FR → description_fr 6 – NC (ignoré) 7 – U (unité FR) → unit (prioritaire) 8 – Quantité présumée (ignorée : pas mappée au modèle) 9 – Prix unitaire → unit_price 10 – Total par poste (ignoré : calculable) Utilisé depuis : - contracts/management/commands/import_contract_posts.py - contracts/admin.py (vue d'import) """ from decimal import Decimal, InvalidOperation import openpyxl from .models import Contract, ContractPost # --------------------------------------------------------------------------- # Utilitaires internes # --------------------------------------------------------------------------- def _to_decimal(value): if value is None: return None try: return Decimal(str(value)) except (InvalidOperation, ValueError): return None def _clean(value): if value is None: return '' return str(value).strip() # --------------------------------------------------------------------------- # Fonction principale # --------------------------------------------------------------------------- def import_contract_posts(contract: Contract, excel_source, *, dry_run: bool = False, update_existing: bool = False, log=None) -> dict: """ Importe (ou met à jour) les postes d'un contrat depuis un fichier Excel. Paramètres ---------- contract : instance Contract cible excel_source : chemin fichier (str/Path) ou objet fichier ouvert (bytes-like) dry_run : si True, ne persiste rien en base update_existing : si True, met à jour les postes existants (même order_number) ; si False, les ignore log : callable(str) pour afficher des messages ; défaut = print Retour ------ dict avec les clés : total_rows, created, updated, skipped_already_exists, skipped_no_price, errors """ if log is None: log = print stats = { 'total_rows': 0, 'created': 0, 'updated': 0, 'skipped_already_exists': 0, 'skipped_no_price': 0, 'errors': 0, } # ------------------------------------------------------------------ # Ouverture du classeur (chemin ou objet fichier) # ------------------------------------------------------------------ try: wb = openpyxl.load_workbook( excel_source, read_only=True, data_only=True, keep_vba=False ) except Exception as exc: log(f"ERREUR ouverture Excel : {exc}") stats['errors'] += 1 return stats ws = wb.active log(f"Feuille lue : {ws.title}") # ------------------------------------------------------------------ # Map order_number → ContractPost existants pour le contrat # ------------------------------------------------------------------ existing_map = { p.order_number: p for p in ContractPost.objects.filter(contract=contract) } posts_to_create = [] posts_to_update = [] fields_to_update = [ 'code', 'description_fr', 'description_nl', 'unit', 'unit_price', 'is_amount_to_be_justified', ] for row_num, row in enumerate(ws.iter_rows(values_only=True), start=1): # Ne garder que les lignes avec un entier en colonne A (Nr. Post) raw_num = row[0] if len(row) > 0 else None if not isinstance(raw_num, (int, float)) or raw_num != int(raw_num): continue stats['total_rows'] += 1 order_number = int(raw_num) code = _clean(row[1] if len(row) > 1 else None) description_nl = _clean(row[2] if len(row) > 2 else None) ao = _clean(row[3] if len(row) > 3 else None) unit_nl = _clean(row[4] if len(row) > 4 else None) description_fr = _clean(row[5] if len(row) > 5 else None) unit_fr = _clean(row[7] if len(row) > 7 else None) unit_price = _to_decimal(row[9] if len(row) > 9 else None) unit = unit_fr if unit_fr else unit_nl is_amount_to_be_justified = ao.upper() == 'TVS' if unit_price is None: log(f" Ligne {row_num}: poste {order_number} – prix unitaire manquant, ignoré") stats['skipped_no_price'] += 1 continue if order_number in existing_map: if not update_existing: log(f" Ligne {row_num}: poste {order_number} ({code}) – déjà présent, ignoré") stats['skipped_already_exists'] += 1 continue # Mise à jour post = existing_map[order_number] post.code = code post.description_fr = description_fr post.description_nl = description_nl post.unit = unit post.unit_price = unit_price post.is_amount_to_be_justified = is_amount_to_be_justified posts_to_update.append(post) log( f" [UPDATE] poste {order_number:3d} | {code:<12} | " f"{unit_price:>10} | {description_fr[:60]}" ) stats['updated'] += 1 else: posts_to_create.append(ContractPost( contract=contract, code=code, description_fr=description_fr, description_nl=description_nl, order_number=order_number, unit=unit, unit_price=unit_price, is_amount_to_be_justified=is_amount_to_be_justified, )) log( f" [CREATE] poste {order_number:3d} | {code:<12} | " f"{unit_price:>10} | {description_fr[:60]}" ) stats['created'] += 1 # ------------------------------------------------------------------ # Persistance # ------------------------------------------------------------------ if not dry_run: from django.db import transaction try: with transaction.atomic(): if posts_to_create: ContractPost.objects.bulk_create(posts_to_create) if posts_to_update: ContractPost.objects.bulk_update(posts_to_update, fields_to_update) except Exception as exc: import traceback log(f"\nERREUR lors de l'écriture en base : {exc}") traceback.print_exc() stats['errors'] += stats['created'] + stats['updated'] stats['created'] = 0 stats['updated'] = 0 return stats