195 lines
6.9 KiB
Python
195 lines
6.9 KiB
Python
"""
|
||
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
|