Menu 1 2 3 4 5 7 8 ok
This commit is contained in:
+238
@@ -14,6 +14,7 @@ from flask import (
|
||||
)
|
||||
import io
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from sqlalchemy import text
|
||||
from app import db, csrf
|
||||
@@ -534,6 +535,243 @@ def import_actions_csv():
|
||||
return redirect(url_for("main.gestion_import_export_actions_csv"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import historique CSV (cotation d'une action)
|
||||
# ---------------------------------------------------------------------------
|
||||
@main.route("/gestion-import-historique-csv", methods=["GET"])
|
||||
@login_required
|
||||
def gestion_import_historique_csv():
|
||||
return render_template("gestion_import_historique_csv.html")
|
||||
|
||||
|
||||
def _convertir_vol(valeur):
|
||||
"""Convertit une valeur de volume du CSV en entier.
|
||||
Suffixes gérés : 'K' (x1000), 'M' (x1 000 000). Sinon entier brut.
|
||||
"""
|
||||
if valeur is None:
|
||||
return None
|
||||
s = str(valeur).strip()
|
||||
if s == "" or s.upper() == "N/A" or s.upper() == "NA":
|
||||
return None
|
||||
try:
|
||||
if s.upper().endswith("K"):
|
||||
return int(float(s[:-1]) * 1000)
|
||||
if s.upper().endswith("M"):
|
||||
return int(float(s[:-1]) * 1000000)
|
||||
return int(float(s))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_decimal(valeur):
|
||||
"""Convertit une valeur CSV en float (Decimal), None si vide/invalide.
|
||||
Retire le suffixe '%' (ex: '0.7%' -> 0.7) et les séparateurs de milliers."""
|
||||
if valeur is None:
|
||||
return None
|
||||
s = str(valeur).strip()
|
||||
if s == "" or s.upper() in ("N/A", "NA"):
|
||||
return None
|
||||
# Suppression d'un éventuel suffixe '%' (et espaces autour)
|
||||
if s.endswith("%"):
|
||||
s = s[:-1].strip()
|
||||
# Suppression d'un éventuel séparateur de milliers (virgule ou espace)
|
||||
s = s.replace(" ", "").replace(",", "")
|
||||
try:
|
||||
return float(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@main.route("/import-historique-csv", methods=["POST"])
|
||||
@login_required
|
||||
def import_historique_csv():
|
||||
isin = (request.form.get("isin") or "").strip().upper()
|
||||
|
||||
# Vérification que l'ISIN existe bien dans le référentiel actions
|
||||
if not isin:
|
||||
flash("Veuillez saisir un code ISIN.", "danger")
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
action = db.session.execute(
|
||||
text("SELECT company_name FROM actions WHERE isin = :isin"), {"isin": isin}
|
||||
).fetchone()
|
||||
if not action:
|
||||
flash(f"L'ISIN {isin} n'existe pas dans le référentiel actions.", "danger")
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
if "file" not in request.files:
|
||||
flash("Aucun fichier sélectionné.", "danger")
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
file = request.files["file"]
|
||||
if file.filename == "":
|
||||
flash("Aucun fichier sélectionné.", "danger")
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
if not file.filename.lower().endswith(".csv"):
|
||||
flash("Veuillez fournir un fichier au format .csv valide.", "warning")
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
try:
|
||||
# Lecture du fichier brut avec fallback d'encodage (utf-8 puis latin-1)
|
||||
raw_bytes = file.stream.read()
|
||||
# Suppression d'un éventuel BOM UTF-8 (\xef\xbb\xbf) en début de fichier
|
||||
if raw_bytes.startswith(b"\xef\xbb\xbf"):
|
||||
raw_bytes = raw_bytes[3:]
|
||||
try:
|
||||
content = raw_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = raw_bytes.decode("latin-1")
|
||||
stream = io.StringIO(content)
|
||||
|
||||
# Auto-détection du séparateur (, ; ou tabulation) sur la 1re ligne
|
||||
premiere_ligne = stream.readline()
|
||||
stream.seek(0)
|
||||
for delim in (",", ";", "\t"):
|
||||
nb = len(premiere_ligne.split(delim))
|
||||
if nb > 1:
|
||||
separateur = delim
|
||||
break
|
||||
else:
|
||||
separateur = ","
|
||||
|
||||
csv_reader = csv.reader(stream, delimiter=separateur)
|
||||
|
||||
header = next(csv_reader, None)
|
||||
if not header:
|
||||
flash("Le fichier CSV est vide.", "danger")
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
# Normalisation des en-têtes : strip + suppression des quotes + lower
|
||||
def _norm_header(h):
|
||||
h = h.strip()
|
||||
if len(h) >= 2 and h[0] in ('"', "'") and h[-1] == h[0]:
|
||||
h = h[1:-1]
|
||||
return h.strip().lower()
|
||||
|
||||
header_norm = [_norm_header(h) for h in header]
|
||||
|
||||
# Mapping alias -> nom canonique (couvre les variantes EODHD, Yahoo, etc.)
|
||||
alias = {
|
||||
"date": "date",
|
||||
"price": "price", "close": "price", "last": "price",
|
||||
"adj_close": "price", "adjusted_close": "price", "adj. close": "price",
|
||||
"open": "open",
|
||||
"hight": "hight", "high": "hight",
|
||||
"low": "low",
|
||||
"vol": "vol", "vol.": "vol", "volume": "vol",
|
||||
"change": "change", "change %": "change", "change_pct": "change",
|
||||
"pct_change": "change", "change_percent": "change", "chg": "change",
|
||||
}
|
||||
# Construction du dictionnaire nom_canonique -> index
|
||||
idx = {}
|
||||
for i, h in enumerate(header_norm):
|
||||
canon = alias.get(h)
|
||||
if canon and canon not in idx:
|
||||
idx[canon] = i
|
||||
|
||||
def col(row, name):
|
||||
"""Récupère la valeur d'une colonne par son nom canonique."""
|
||||
if name not in idx:
|
||||
return None
|
||||
i = idx[name]
|
||||
return row[i] if i < len(row) else None
|
||||
|
||||
# Vérification : la colonne 'date' est obligatoire
|
||||
if "date" not in idx:
|
||||
flash(
|
||||
f"Pas OK - Colonne 'date' introuvable dans l'en-tête CSV. "
|
||||
f"En-tête détecté : {','.join(header)}",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
# Requête d'insertion (INSERT IGNORE grâce à l'unique key isin+date)
|
||||
insert = text(
|
||||
"INSERT IGNORE INTO historique "
|
||||
"(isin, date, price, `open`, hight, low, vol, `change`) "
|
||||
"VALUES (:isin, :date, :price, :open, :hight, :low, :vol, :change)"
|
||||
)
|
||||
|
||||
count_insere = 0
|
||||
count_ignore = 0
|
||||
count_erreurs = 0
|
||||
exemples_erreurs = []
|
||||
|
||||
for row in csv_reader:
|
||||
date_raw = col(row, "date")
|
||||
if not date_raw:
|
||||
count_erreurs += 1
|
||||
if len(exemples_erreurs) < 5:
|
||||
exemples_erreurs.append(f"Date vide | ligne: {row}")
|
||||
continue
|
||||
|
||||
# Normalisation de la date -> objet date (pour insertion YYYY-MM-DD)
|
||||
# L'ordre compte : %Y/%d/%m (AAAA/JJ/MM) AVANT %Y/%m/%d pour éviter
|
||||
# l'ambiguïté (un jour >12 invaliderait %Y/%m/%d et tomberait juste sur %Y/%d/%m).
|
||||
date_str = date_raw.strip()[:10]
|
||||
date_val = None
|
||||
for fmt in ("%Y-%m-%d", "%Y/%d/%m", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y"):
|
||||
try:
|
||||
date_val = datetime.strptime(date_str, fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if date_val is None:
|
||||
count_erreurs += 1
|
||||
if len(exemples_erreurs) < 10:
|
||||
exemples_erreurs.append(
|
||||
f"Date invalide '{date_raw}' (nb colonnes ligne={len(row)}) | ligne: {row}"
|
||||
)
|
||||
continue
|
||||
|
||||
price = _to_decimal(col(row, "price"))
|
||||
open_ = _to_decimal(col(row, "open"))
|
||||
hight = _to_decimal(col(row, "hight"))
|
||||
low = _to_decimal(col(row, "low"))
|
||||
vol = _convertir_vol(col(row, "vol"))
|
||||
change = _to_decimal(col(row, "change"))
|
||||
|
||||
result = db.session.execute(
|
||||
insert,
|
||||
{
|
||||
"isin": isin,
|
||||
"date": date_val,
|
||||
"price": price,
|
||||
"open": open_,
|
||||
"hight": hight,
|
||||
"low": low,
|
||||
"vol": vol,
|
||||
"change": change,
|
||||
},
|
||||
)
|
||||
# rowcount == 1 si réellement inséré, 0 si ignoré (doublon unique key)
|
||||
if result.rowcount == 1:
|
||||
count_insere += 1
|
||||
else:
|
||||
count_ignore += 1
|
||||
|
||||
db.session.commit()
|
||||
detail = ""
|
||||
if exemples_erreurs:
|
||||
detail = " | Exemples d'erreurs: " + " ; ".join(exemples_erreurs)
|
||||
flash(
|
||||
f"OK - Importation réussie pour {action.company_name} ({isin}) : "
|
||||
f"{count_insere} ligne(s) insérée(s), "
|
||||
f"{count_ignore} ligne(s) déjà existante(s) ignorée(s), "
|
||||
f"{count_erreurs} ligne(s) en erreur. "
|
||||
f"[Séparateur: '{separateur}' | En-tête: {','.join(header)}]"
|
||||
f"{detail}",
|
||||
"success",
|
||||
)
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error("Erreur import historique CSV : %s", e, exc_info=True)
|
||||
flash(f"Pas OK - Erreur lors de l'importation du fichier : {str(e)}", "danger")
|
||||
|
||||
return redirect(url_for("main.gestion_import_historique_csv"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gestion des ordres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user