loko/streetup/contracts/utils_import_posts.py
2026-07-22 14:48:40 +02:00

195 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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