From f37f5b1e4509262eb65122f91220f04f080182ed Mon Sep 17 00:00:00 2001 From: jfgiraud Date: Mon, 3 Aug 2026 10:02:12 +0200 Subject: [PATCH] menu 1 2 3 4 5 7 operationnels --- .dockerignore | 28 + .gitignore | 4 + AGENTS.md | 231 +++ Dockerfile | 12 +- app/__init__.py | 43 +- app/models.py | 98 + app/routes.py | 1797 +++++++++++------ app/templates/base.html | 26 +- app/templates/gestion_actions.html | 1 + app/templates/gestion_comptes.html | 417 ++++ .../gestion_import_export_actions_csv.html | 1 + app/templates/gestion_ordres.html | 301 +-- app/templates/gestion_societes.html | 27 +- app/templates/gestion_utilisateurs.html | 3 +- app/templates/index.html | 1 + app/templates/menu.html | 2 +- create_db.py | 112 - create_isin.py | 115 -- create_user.py | 31 - create_user.txt | 5 - docker-compose.yml | 28 +- migrations/apply_migrations.py | 89 + migrations/schema.sql | 110 + requirements.txt | 5 + run.py | 6 +- 25 files changed, 2373 insertions(+), 1120 deletions(-) create mode 100644 .dockerignore create mode 100644 AGENTS.md create mode 100644 app/models.py create mode 100644 app/templates/gestion_comptes.html delete mode 100644 create_db.py delete mode 100644 create_isin.py delete mode 100644 create_user.py delete mode 100644 create_user.txt create mode 100644 migrations/apply_migrations.py create mode 100644 migrations/schema.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..243f487 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# VCSP +.git +.gitignore + +# Environnement & secrets +.env +.env.* + +# Bases de données locales +mysql_data/ +*.sqlite3 + +# Python +__pycache__/ +*.py[cod] +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +.DS_Store + +# Docker +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.gitignore b/.gitignore index 362ccfc..f891e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,9 @@ __pycache__/ # Fichiers de base de données locaux (si applicable) *.sqlite3 +# Fichiers de configuration locaux +.env +.env.* + # Base de données MySQL Docker mysql_data/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..445b141 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,231 @@ +# AGENTS.md — Projet Bourse + +## Description du projet + +Application de gestion boursière permettant : + +* le suivi des actions et titres financiers ; +* la gestion des ordres d'achat et de vente ; +* l'importation de données financières ; +* la consultation via une interface web et des API. + +Le projet doit rester maintenable, sécurisé et compatible avec l'environnement Docker existant. + +--- + +# Architecture technique + +## Environnement + +Système de développement : + +* Ubuntu Linux +* Python 3.x +* environnement virtuel `.venv` +* Docker / Docker Compose + +## Technologies principales + +Backend : + +* Python +* Flask +* API REST + +Base de données : + +* MySQL 8 +* phpMyAdmin pour administration + +Frontend : + +* HTML +* CSS  +* JavaScript +* Tailwind + +Déploiement : + +* Docker +* conteneurs séparés pour application et base de données + +--- + +# Règles générales de développement + +## Avant toute modification + +Toujours : + +1. analyser le code existant ; +2. expliquer la cause du problème ; +3. proposer une modification ; +4. attendre validation avant les changements importants. + +Ne jamais réécrire une partie complète du projet sans justification. + +--- + +# Python + +Respecter : + +* PEP8 ; +* code lisible et commenté en Français ; +* fonctions courtes ; +* gestion correcte des exceptions. + +Éviter : + +* duplication de code ; +* variables globales inutiles ; +* modifications qui cassent l'existant. + +Toujours utiliser l'environnement virtuel : + +``` +.venv +``` + +--- + +# Flask + +Respecter l'organisation actuelle du projet. + +Avant modification : + +* identifier les routes existantes ; +* vérifier les dépendances ; +* vérifier les appels frontend/API. + +Les nouvelles routes doivent : + +* avoir une validation des entrées ; +* retourner des erreurs explicites ; +* utiliser les codes HTTP appropriés. + +--- + +# Base de données MySQL + +Règles : + +* ne jamais supprimer une table sans confirmation ; +* ne jamais modifier une colonne existante sans vérifier les impacts ; +* toujours proposer une migration SQL. + +Respecter les conventions : + +* noms de tables en minuscules ; +* clés primaires explicites ; +* index sur les recherches fréquentes. + +Avant une requête SQL complexe : + +* expliquer son fonctionnement ; +* vérifier les performances. + +--- + +# Données financières + +Attention aux données : + +* ticker ; +* ISIN ; +* exchange ; +* pays ; +* devise ; +* cours ; +* ordres. + +Ne jamais modifier automatiquement une donnée financière sans conserver la valeur précédente si nécessaire. + +--- + +# Docker + +Avant modification : + +Vérifier : + +``` +docker ps +docker compose ps +``` + +Ne jamais : + +* supprimer un volume Docker ; +* supprimer une base ; +* reconstruire tous les conteneurs sans raison. + +Toujours expliquer : + +* quel conteneur est modifié ; +* pourquoi ; +* comment revenir en arrière. + +--- + +# Tests + +Après chaque modification importante : + +Tester : + +* démarrage de l'application ; +* connexion MySQL ; +* appels API ; +* affichage frontend. + +Vérifier les logs : + +``` +docker logs +``` + +--- + +# Style de réponse attendu de l'IA + +Quand tu analyses ce projet : + +1. commence par expliquer ce que tu observes ; +2. indique les fichiers concernés ; +3. propose une solution ; +4. donne les commandes exactes à exécuter. + +Ne pas modifier plusieurs fichiers simultanément sans expliquer pourquoi. + +--- + +# Priorités du projet + +Ordre des priorités : + +1. stabilité ; +2. conservation des données ; +3. sécurité ; +4. performances ; +5. nouvelles fonctionnalités. + +--- + +# Modèle IA utilisé + +Le projet utilise OpenCode avec Ollama local : + +Modèle principal : + +``` +qwen2.5-coder:32b +``` + +L'IA doit privilégier : + +* compréhension du code existant ; +* explications techniques ; +* modifications minimales ; +* solutions compatibles avec l'architecture actuelle. diff --git a/Dockerfile b/Dockerfile index fae895d..b67ecfc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,17 @@ FROM python:3.11-slim WORKDIR /app -RUN pip install --no-cache-dir flask flask-sqlalchemy pymysql cryptography requests mysql-connector-python +# Installation des dépendances (versions fixées dans requirements.txt) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copie du code applicatif +COPY run.py . +COPY app ./app + +# Utilisateur non-root pour l'exécution +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser EXPOSE 5000 diff --git a/app/__init__.py b/app/__init__.py index 26b6cd9..cfad237 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,19 +1,44 @@ +import os import time -from flask import Flask, render_template +from flask import Flask from flask_sqlalchemy import SQLAlchemy +from flask_wtf import CSRFProtect from sqlalchemy.exc import OperationalError +from dotenv import load_dotenv + +load_dotenv() db = SQLAlchemy() +csrf = CSRFProtect() + def create_app(): app = Flask(__name__) - app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://jfgiraud:lyon4@db/bolsa' - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['SECRET_KEY'] = 'votre_cle_secrete_tres_securisee_jfgiraud04051963++fin' + + # --- Configuration depuis l'environnement --- + app.config["SECRET_KEY"] = os.environ.get( + "SECRET_KEY", "change-me-in-production" + ) + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + db_user = os.environ.get("MYSQL_USER", "jfgiraud") + db_pass = os.environ.get("MYSQL_PASSWORD", "") + db_host = os.environ.get("MYSQL_HOST", "db") + db_port = os.environ.get("MYSQL_PORT", "3306") + db_name = os.environ.get("MYSQL_DATABASE", "bolsa") + # DATABASE_URL prend la priorité si défini + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + "DATABASE_URL", + f"mysql+pymysql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}", + ) db.init_app(app) + csrf.init_app(app) - # Tentative de connexion et création des tables au démarrage du conteneur + # Enregistrement des modèles ORM (nécessaire avant db.create_all) + from app import models # noqa: F401 + + # Tentative de connexion au démarrage du conteneur (MySQL peut mettre du temps à être prêt) with app.app_context(): max_retries = 15 delay = 2 @@ -22,8 +47,9 @@ def create_app(): db.create_all() print("Base de données connectée et tables initialisées avec succès.") break - except OperationalError as e: - print(f"Tentative {i+1}/{max_retries} : MySQL n'est pas encore prêt. Nouvelle tentative dans {delay}s...") + except OperationalError: + print(f"Tentative {i+1}/{max_retries} : MySQL n'est pas encore prêt. " + f"Nouvelle tentative dans {delay}s...") time.sleep(delay) else: raise Exception("Impossible d'initialiser la base de données après plusieurs tentatives.") @@ -34,5 +60,6 @@ def create_app(): return app + # Expose 'app' pour que run.py puisse l'importer directement -app = create_app() \ No newline at end of file +app = create_app() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..dd736b7 --- /dev/null +++ b/app/models.py @@ -0,0 +1,98 @@ +from datetime import datetime +from app import db + + +class Login(db.Model): + __tablename__ = "login" + + id = db.Column(db.Integer, primary_key=True) + login = db.Column(db.String(50), nullable=False, unique=True) + nom = db.Column(db.String(100), nullable=False) + prenom = db.Column(db.String(100), nullable=False) + mail = db.Column(db.String(255), nullable=False, unique=True) + pass_ = db.Column("pass", db.String(255), nullable=False) + actif = db.Column(db.Boolean, default=True) + creer_le = db.Column(db.DateTime, default=datetime.utcnow) + modifie_le = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + reset_token = db.Column(db.String(255), nullable=True) + reset_token_created_at = db.Column(db.DateTime, nullable=True) + + +class Action(db.Model): + __tablename__ = "actions" + + id = db.Column(db.Integer, primary_key=True) + isin = db.Column(db.String(50), nullable=False, unique=True) + ticker = db.Column(db.String(50)) + company_name = db.Column(db.String(255)) + exchange = db.Column(db.String(50), nullable=False) + pays = db.Column(db.String(50), nullable=False) + currency = db.Column(db.String(10), nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class Societe(db.Model): + __tablename__ = "societe" + + id = db.Column(db.Integer, primary_key=True) + nom = db.Column(db.String(60), nullable=False) + forme = db.Column(db.String(10), nullable=False) + capital = db.Column(db.Integer, nullable=False) + siren = db.Column(db.String(20), nullable=False) + siret = db.Column(db.String(20), nullable=False) + rcs = db.Column(db.String(50), nullable=False) + tva = db.Column(db.String(50), nullable=False) + naf = db.Column(db.String(10), nullable=False) + tel = db.Column(db.String(30), nullable=False) + web = db.Column(db.String(100), nullable=False) + mail = db.Column(db.String(100), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + comptes = db.relationship("Compte", backref="societe", lazy=True) + + +class Compte(db.Model): + __tablename__ = "compte" + + id = db.Column(db.Integer, primary_key=True) + id_societe = db.Column(db.Integer, db.ForeignKey("societe.id"), nullable=False) + date_operation = db.Column(db.Date, nullable=False) + debit = db.Column(db.Numeric(15, 2), default=0) + credit = db.Column(db.Numeric(15, 2), default=0) + libelle = db.Column(db.Text) + source = db.Column(db.Enum("manuel", "ordre"), nullable=False, default="manuel") + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class Ordre(db.Model): + __tablename__ = "ordre" + + id = db.Column(db.Integer, primary_key=True) + id_societe = db.Column(db.Integer, db.ForeignKey("societe.id"), nullable=False) + isin = db.Column(db.String(50), db.ForeignKey("actions.isin"), nullable=False) + plus_value = db.Column(db.Numeric(19, 4), nullable=False, default=0) + date_plus_value = db.Column(db.Date, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + pieds = db.relationship("OrdrePied", backref="ordre", lazy=True, cascade="all, delete-orphan") + + +class OrdrePied(db.Model): + __tablename__ = "ordre_pied" + + id = db.Column(db.Integer, primary_key=True) + id_entete = db.Column(db.Integer, db.ForeignKey("ordre.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + date_op = db.Column(db.Date, nullable=False) + quantite = db.Column(db.Integer, nullable=False) + prix_brut = db.Column(db.Numeric(19, 4)) + frais_broker = db.Column(db.Numeric(19, 4)) + frais_etat = db.Column(db.Numeric(19, 4)) + frais_autre = db.Column(db.Numeric(19, 4)) + sens = db.Column(db.Enum("achat", "vente"), nullable=False) + position = db.Column(db.Enum("long", "short"), nullable=False) + etat = db.Column(db.Enum("actif", "soldé", "annulé"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/app/routes.py b/app/routes.py index 87b3909..85eb889 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,673 +1,801 @@ -from flask import Blueprint, render_template, request, redirect, url_for, session, flash, Response, jsonify -import time -import requests -import csv +from functools import wraps + +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + session, + flash, + Response, + jsonify, + current_app, +) import io +import csv from werkzeug.security import check_password_hash, generate_password_hash from sqlalchemy import text -from app import db -from datetime import datetime +from app import db, csrf +from app import models # noqa: F401 (assure l'enregistrement des modèles ORM) -main = Blueprint('main', __name__) +main = Blueprint("main", __name__) -# Route pour la page d'accueil -@main.route('/', methods=['GET', 'POST']) + +# --------------------------------------------------------------------------- +# Décorateur d'authentification +# --------------------------------------------------------------------------- +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if "user_id" not in session: + return redirect(url_for("main.index")) + return f(*args, **kwargs) + + return decorated + + +# --------------------------------------------------------------------------- +# Route pour la page d'accueil (connexion) +# --------------------------------------------------------------------------- +@main.route("/", methods=["GET", "POST"]) def index(): - if 'user_id' in session: - return redirect(url_for('main.menu')) + if "user_id" in session: + return redirect(url_for("main.menu")) - if 'login_attempts' not in session: - session['login_attempts'] = 0 + if "login_attempts" not in session: + session["login_attempts"] = 0 - if request.method == 'POST': - user_login = request.form.get('login') - user_pass = request.form.get('pass') + if request.method == "POST": + user_login = request.form.get("login") + user_pass = request.form.get("pass") - query = text("SELECT id, pass AS user_pass, nom, prenom FROM login WHERE login = :login AND actif = 1") + query = text( + "SELECT id, pass AS user_pass, nom, prenom FROM login " + "WHERE login = :login AND actif = 1" + ) result = db.session.execute(query, {"login": user_login}).fetchone() if result and check_password_hash(result.user_pass, user_pass): - session.pop('login_attempts', None) - session['user_id'] = result.id - session['user_name'] = f"{result.prenom} {result.nom}" - return redirect(url_for('main.menu')) + # Régénération de session : protection contre la fixation de session + session.clear() + session["user_id"] = result.id + session["user_name"] = f"{result.prenom} {result.nom}" + session["login_attempts"] = 0 + return redirect(url_for("main.menu")) else: - session['login_attempts'] += 1 - - if session['login_attempts'] >= 3: - session.pop('login_attempts', None) - return redirect('https://giraud-finance.com') - - essais_restants = 3 - session['login_attempts'] - flash(f"Identifiant ou mot de passe incorrect. Il vous reste {essais_restants} essai(s).", "danger") + session["login_attempts"] += 1 + essais = session["login_attempts"] + if essais >= 3: + session["login_attempts"] = 0 + flash( + "Trop de tentatives. Réessayez dans quelques instants.", + "danger", + ) + else: + flash( + f"Identifiant ou mot de passe incorrect. " + f"Il vous reste {3 - essais} essai(s).", + "danger", + ) - return render_template('index.html') + return render_template("index.html") -# Route pour la déconnexion -@main.route('/logout') + +# --------------------------------------------------------------------------- +# Déconnexion +# --------------------------------------------------------------------------- +@main.route("/logout") def logout(): session.clear() - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) -# Route pour le menu principal -@main.route('/menu') + +# --------------------------------------------------------------------------- +# Menu principal +# --------------------------------------------------------------------------- +@main.route("/menu") +@login_required def menu(): - if 'user_id' not in session: - return redirect(url_for('main.index')) - return render_template('menu.html') + return render_template("menu.html") -# Route pour la gestion des utilisateurs -@main.route('/gestion-utilisateurs', methods=['GET', 'POST']) + +# --------------------------------------------------------------------------- +# Gestion des utilisateurs +# --------------------------------------------------------------------------- +@main.route("/gestion-utilisateurs", methods=["GET", "POST"]) +@login_required def gestion_utilisateurs(): - if 'user_id' not in session: - return redirect(url_for('main.index')) - - if request.method == 'POST': - action = request.form.get('action') - user_id = request.form.get('id') - user_actif = request.form.get('actif', '1') - user_prenom = request.form.get('prenom') - user_nom = request.form.get('nom') - user_login = request.form.get('login') - user_mail = request.form.get('mail') - user_pass = request.form.get('pass') - - if action == 'creer': + if request.method == "POST": + action = request.form.get("action") + user_id = request.form.get("id") + user_actif = request.form.get("actif", "1") + user_prenom = request.form.get("prenom") + user_nom = request.form.get("nom") + user_login = request.form.get("login") + user_mail = request.form.get("mail") + user_pass = request.form.get("pass") + + if action == "creer": if user_pass: hashed_pass = generate_password_hash(user_pass) - query = text(""" - INSERT INTO login (login, nom, prenom, mail, pass, actif) - VALUES (:login, :nom, :prenom, :mail, :pass, :actif) - """) - db.session.execute(query, { - "login": user_login, - "nom": user_nom, - "prenom": user_prenom, - "mail": user_mail, - "pass": hashed_pass, - "actif": user_actif - }) + query = text( + "INSERT INTO login (login, nom, prenom, mail, pass, actif) " + "VALUES (:login, :nom, :prenom, :mail, :pass, :actif)" + ) + db.session.execute( + query, + { + "login": user_login, + "nom": user_nom, + "prenom": user_prenom, + "mail": user_mail, + "pass": hashed_pass, + "actif": user_actif, + }, + ) db.session.commit() flash("Utilisateur créé avec succès.", "success") else: - flash("Le mot de passe est obligatoire pour créer un utilisateur.", "danger") - - elif action == 'modifier' and user_id: + flash( + "Le mot de passe est obligatoire pour créer un utilisateur.", + "danger", + ) + + elif action == "modifier" and user_id: if user_pass: hashed_pass = generate_password_hash(user_pass) - query = text(""" - UPDATE login - SET login = :login, nom = :nom, prenom = :prenom, mail = :mail, pass = :pass, actif = :actif - WHERE id = :id - """) - db.session.execute(query, { - "id": user_id, - "login": user_login, - "nom": user_nom, - "prenom": user_prenom, - "mail": user_mail, - "pass": hashed_pass, - "actif": user_actif - }) + query = text( + "UPDATE login SET login = :login, nom = :nom, prenom = :prenom, " + "mail = :mail, pass = :pass, actif = :actif WHERE id = :id" + ) + db.session.execute( + query, + { + "id": user_id, + "login": user_login, + "nom": user_nom, + "prenom": user_prenom, + "mail": user_mail, + "pass": hashed_pass, + "actif": user_actif, + }, + ) else: - query = text(""" - UPDATE login - SET login = :login, nom = :nom, prenom = :prenom, mail = :mail, actif = :actif - WHERE id = :id - """) - db.session.execute(query, { - "id": user_id, - "login": user_login, - "nom": user_nom, - "prenom": user_prenom, - "mail": user_mail, - "actif": user_actif - }) + query = text( + "UPDATE login SET login = :login, nom = :nom, prenom = :prenom, " + "mail = :mail, actif = :actif WHERE id = :id" + ) + db.session.execute( + query, + { + "id": user_id, + "login": user_login, + "nom": user_nom, + "prenom": user_prenom, + "mail": user_mail, + "actif": user_actif, + }, + ) db.session.commit() flash("Utilisateur modifié avec succès.", "success") - - return redirect(url_for('main.gestion_utilisateurs')) - users_query = text("SELECT id, login, nom, prenom, mail, actif FROM login ORDER BY id ASC") + return redirect(url_for("main.gestion_utilisateurs")) + + users_query = text( + "SELECT id, login, nom, prenom, mail, actif FROM login ORDER BY id ASC" + ) result = db.session.execute(users_query) - - users = [] - for row in result: - users.append({ - 'id': row.id, - 'login': row.login if row.login else '', - 'nom': row.nom if row.nom else '', - 'prenom': row.prenom if row.prenom else '', - 'mail': row.mail if row.mail else '', - 'actif': row.actif if row.actif is not None else 1 - }) - return render_template('gestion_utilisateurs.html', users=users) + users = [ + { + "id": row.id, + "login": row.login if row.login else "", + "nom": row.nom if row.nom else "", + "prenom": row.prenom if row.prenom else "", + "mail": row.mail if row.mail else "", + "actif": row.actif if row.actif is not None else 1, + } + for row in result + ] -# Route pour la gestion des actions -@main.route('/gestion-actions', methods=['GET', 'POST']) + return render_template("gestion_utilisateurs.html", users=users) + + +# --------------------------------------------------------------------------- +# Gestion des actions +# --------------------------------------------------------------------------- +@main.route("/gestion-actions", methods=["GET", "POST"]) +@login_required def gestion_actions(): - if 'user_id' not in session: - return redirect(url_for('main.index')) - - if request.method == 'POST': - action_type = request.form.get('action') - action_id = request.form.get('id') - isin = request.form.get('isin') or None - ticker = request.form.get('ticker') - company_name = request.form.get('company_name') - exchange = request.form.get('exchange') - pays = request.form.get('pays') - currency = request.form.get('currency') - - if action_type == 'creer': + if request.method == "POST": + action_type = request.form.get("action") + action_id = request.form.get("id") + isin = request.form.get("isin") or None + ticker = request.form.get("ticker") + company_name = request.form.get("company_name") + exchange = request.form.get("exchange") + pays = request.form.get("pays") + currency = request.form.get("currency") + + if action_type == "creer": try: - query = text(""" - INSERT INTO actions (isin, ticker, company_name, exchange, pays, currency, updated_at) - VALUES (:isin, :ticker, :company_name, :exchange, :pays, :currency, NOW()) - """) - db.session.execute(query, { - "isin": isin, "ticker": ticker, "company_name": company_name, - "exchange": exchange, "pays": pays, "currency": currency - }) + query = text( + "INSERT INTO actions (isin, ticker, company_name, exchange, " + "pays, currency, updated_at) " + "VALUES (:isin, :ticker, :company_name, :exchange, :pays, " + ":currency, NOW())" + ) + db.session.execute( + query, + { + "isin": isin, + "ticker": ticker, + "company_name": company_name, + "exchange": exchange, + "pays": pays, + "currency": currency, + }, + ) db.session.commit() flash("Action créée avec succès", "success") - except Exception as e: + except Exception: db.session.rollback() flash("Erreur lors de la création de l'action.", "danger") - - elif action_type == 'modifier' and action_id: + + elif action_type == "modifier" and action_id: try: - query = text(""" - UPDATE actions - SET isin = :isin, ticker = :ticker, company_name = :company_name, exchange = :exchange, - pays = :pays, currency = :currency, updated_at = NOW() - WHERE id = :id - """) - db.session.execute(query, { - "id": action_id, "isin": isin, "ticker": ticker, "company_name": company_name, - "exchange": exchange, "pays": pays, "currency": currency - }) + query = text( + "UPDATE actions SET isin = :isin, ticker = :ticker, " + "company_name = :company_name, exchange = :exchange, " + "pays = :pays, currency = :currency, updated_at = NOW() " + "WHERE id = :id" + ) + db.session.execute( + query, + { + "id": action_id, + "isin": isin, + "ticker": ticker, + "company_name": company_name, + "exchange": exchange, + "pays": pays, + "currency": currency, + }, + ) db.session.commit() flash("Modification effectuée", "success") - except Exception as e: + except Exception: db.session.rollback() flash("Erreur lors de la modification.", "danger") - - return redirect(url_for('main.gestion_actions')) - actions_query = text("SELECT id, isin, ticker, company_name, exchange, pays, currency, updated_at FROM actions ORDER BY company_name ASC") + return redirect(url_for("main.gestion_actions")) + + actions_query = text( + "SELECT id, isin, ticker, company_name, exchange, pays, currency, " + "updated_at FROM actions ORDER BY company_name ASC" + ) result = db.session.execute(actions_query) - - actions_list = [] - for row in result: - actions_list.append({ - 'id': row.id, - 'isin': row.isin if row.isin else '', - 'ticker': row.ticker if row.ticker else '', - 'company_name': row.company_name if row.company_name else 'Sans nom', - 'exchange': row.exchange if row.exchange else '', - 'pays': row.pays if row.pays else '', - 'currency': row.currency if row.currency else '', - 'updated_at': row.updated_at.strftime('%Y-%m-%d %H:%M:%S') if row.updated_at else '' - }) - return render_template('gestion_actions.html', actions=actions_list) + actions_list = [ + { + "id": row.id, + "isin": row.isin if row.isin else "", + "ticker": row.ticker if row.ticker else "", + "company_name": row.company_name if row.company_name else "Sans nom", + "exchange": row.exchange if row.exchange else "", + "pays": row.pays if row.pays else "", + "currency": row.currency if row.currency else "", + "updated_at": row.updated_at.strftime("%Y-%m-%d %H:%M:%S") + if row.updated_at + else "", + } + for row in result + ] -# Route pour la gestion des sociétés -@main.route('/gestion-societes', methods=['GET', 'POST']) + return render_template("gestion_actions.html", actions=actions_list) + + +# --------------------------------------------------------------------------- +# Gestion des sociétés +# --------------------------------------------------------------------------- +@main.route("/gestion-societes", methods=["GET", "POST"]) +@login_required def gestion_societes(): - if 'user_id' not in session: - return redirect(url_for('main.index')) - - if request.method == 'POST': - action = request.form.get('action') - soc_id = request.form.get('id') - - nom = request.form.get('Nom') - forme = request.form.get('Forme') - capital = request.form.get('Capital') - siren = request.form.get('siren') - siret = request.form.get('siret') - rcs = request.form.get('RCS') - tva = request.form.get('TVA') - naf = request.form.get('NAF') - tel = request.form.get('Tel') - web = request.form.get('Web') - mail = request.form.get('Mail') - - if action == 'creer': - query = text(""" - INSERT INTO societe (Nom, Forme, Capital, siren, siret, RCS, TVA, NAF, Tel, Web, Mail) - VALUES (:nom, :forme, :capital, :siren, :siret, :rcs, :tva, :naf, :tel, :web, :mail) - """) - db.session.execute(query, { - "nom": nom, "forme": forme, "capital": capital, - "siren": siren, "siret": siret, "rcs": rcs, - "tva": tva, "naf": naf, "tel": tel, - "web": web, "mail": mail - }) - db.session.commit() - flash("Société créée avec succès.", "success") - - elif action == 'modifier' and soc_id: - query = text(""" - UPDATE societe - SET Nom = :nom, Forme = :forme, Capital = :capital, siren = :siren, - siret = :siret, RCS = :rcs, TVA = :tva, NAF = :naf, - Tel = :tel, Web = :web, Mail = :mail - WHERE id = :id - """) - db.session.execute(query, { - "id": soc_id, "nom": nom, "forme": forme, "capital": capital, - "siren": siren, "siret": siret, "rcs": rcs, - "tva": tva, "naf": naf, "tel": tel, - "web": web, "mail": mail - }) - db.session.commit() - flash("Société modifiée avec succès.", "success") - - return redirect(url_for('main.gestion_societes')) + if request.method == "POST": + action = request.form.get("action") + soc_id = request.form.get("id") - societes_query = text("SELECT id, Nom, Forme, Capital, siren, siret, RCS, TVA, NAF, Tel, Web, Mail FROM societe ORDER BY id ASC") + nom = request.form.get("nom") + forme = request.form.get("forme") + capital = request.form.get("capital") + siren = request.form.get("siren") + siret = request.form.get("siret") + rcs = request.form.get("rcs") + tva = request.form.get("tva") + naf = request.form.get("naf") + tel = request.form.get("tel") + web = request.form.get("web") + mail = request.form.get("mail") + + if action == "creer": + try: + query = text( + "INSERT INTO societe (nom, forme, capital, siren, siret, rcs, " + "tva, naf, tel, web, mail) " + "VALUES (:nom, :forme, :capital, :siren, :siret, :rcs, :tva, " + ":naf, :tel, :web, :mail)" + ) + db.session.execute( + query, + { + "nom": nom, + "forme": forme, + "capital": capital, + "siren": siren, + "siret": siret, + "rcs": rcs, + "tva": tva, + "naf": naf, + "tel": tel, + "web": web, + "mail": mail, + }, + ) + db.session.commit() + flash("Société créée avec succès.", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur création société : %s", e, exc_info=True) + flash("Erreur lors de la création de la société.", "danger") + + elif action == "modifier" and soc_id: + try: + query = text( + "UPDATE societe SET nom = :nom, forme = :forme, " + "capital = :capital, siren = :siren, siret = :siret, " + "rcs = :rcs, tva = :tva, naf = :naf, tel = :tel, web = :web, " + "mail = :mail WHERE id = :id" + ) + db.session.execute( + query, + { + "id": soc_id, + "nom": nom, + "forme": forme, + "capital": capital, + "siren": siren, + "siret": siret, + "rcs": rcs, + "tva": tva, + "naf": naf, + "tel": tel, + "web": web, + "mail": mail, + }, + ) + db.session.commit() + flash("Société modifiée avec succès.", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur modification société : %s", e, exc_info=True) + flash("Erreur lors de la modification de la société.", "danger") + + return redirect(url_for("main.gestion_societes")) + + societes_query = text( + "SELECT id, nom, forme, capital, siren, siret, rcs, tva, naf, tel, web, " + "mail FROM societe ORDER BY id ASC" + ) result = db.session.execute(societes_query) - - societes = [] - for row in result: - societes.append({ - 'id': row.id, - 'Nom': row.Nom if row.Nom else '', - 'Forme': row.Forme if row.Forme else '', - 'Capital': row.Capital if row.Capital is not None else 0, - 'siren': row.siren if row.siren is not None else '', - 'siret': row.siret if row.siret is not None else '', - 'RCS': row.RCS if row.RCS else '', - 'TVA': row.TVA if row.TVA else '', - 'NAF': row.NAF if row.NAF else '', - 'Tel': row.Tel if row.Tel else '', - 'Web': row.Web if row.Web else '', - 'Mail': row.Mail if row.Mail else '' - }) - return render_template('gestion_societes.html', societes=societes) + societes = [ + { + "id": row.id, + "nom": row.nom if row.nom else "", + "forme": row.forme if row.forme else "", + "capital": row.capital if row.capital is not None else 0, + "siren": row.siren if row.siren is not None else "", + "siret": row.siret if row.siret is not None else "", + "rcs": row.rcs if row.rcs else "", + "tva": row.tva if row.tva else "", + "naf": row.naf if row.naf else "", + "tel": row.tel if row.tel else "", + "web": row.web if row.web else "", + "mail": row.mail if row.mail else "", + } + for row in result + ] -# Routes pour la gestion des imports/exports des actions CSV -@main.route('/gestion-import-export-actions-csv', methods=['GET']) + return render_template("gestion_societes.html", societes=societes) + + +# --------------------------------------------------------------------------- +# Import / Export des actions CSV +# --------------------------------------------------------------------------- +@main.route("/gestion-import-export-actions-csv", methods=["GET"]) +@login_required def gestion_import_export_actions_csv(): - if 'user_id' not in session: - return redirect(url_for('main.index')) - return render_template('gestion_import_export_actions_csv.html') + return render_template("gestion_import_export_actions_csv.html") -# Route pour l'exportation des actions vers un fichier CSV -@main.route('/export-actions-csv', methods=['GET']) +@main.route("/export-actions-csv", methods=["GET"]) +@login_required def export_actions_csv(): - if 'user_id' not in session: - return redirect(url_for('main.index')) - try: - query = text("SELECT id, isin, ticker, company_name, exchange, pays, currency, updated_at FROM actions") + query = text( + "SELECT id, isin, ticker, company_name, exchange, pays, currency, " + "updated_at FROM actions" + ) result = db.session.execute(query) - + output = io.StringIO() - writer = csv.writer(output, delimiter=';', quoting=csv.QUOTE_MINIMAL) - - writer.writerow(['id', 'isin', 'ticker', 'company_name', 'exchange', 'pays', 'currency', 'updated_at']) - + writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_MINIMAL) + writer.writerow( + ["id", "isin", "ticker", "company_name", "exchange", "pays", "currency", "updated_at"] + ) + for row in result: - writer.writerow([ - row.id, - row.isin or '', - row.ticker or '', - row.company_name or '', - row.exchange or '', - row.pays or '', - row.currency or '', - row.updated_at.strftime('%Y-%m-%d %H:%M:%S') if row.updated_at else '' - ]) - + writer.writerow( + [ + row.id, + row.isin or "", + row.ticker or "", + row.company_name or "", + row.exchange or "", + row.pays or "", + row.currency or "", + row.updated_at.strftime("%Y-%m-%d %H:%M:%S") + if row.updated_at + else "", + ] + ) + output.seek(0) - return Response( output.getvalue(), mimetype="text/csv", - headers={"Content-Disposition": "attachment;filename=actions_export.csv"} + headers={"Content-Disposition": "attachment;filename=actions_export.csv"}, ) except Exception as e: + current_app.logger.error("Erreur export CSV : %s", e, exc_info=True) flash("Erreur lors de l'exportation du fichier CSV.", "danger") - return redirect(url_for('main.gestion_import_export_actions_csv')) + return redirect(url_for("main.gestion_import_export_actions_csv")) -# Route pour l'importation des actions depuis un fichier CSV -@main.route('/import-actions-csv', methods=['POST']) +@main.route("/import-actions-csv", methods=["POST"]) +@login_required def import_actions_csv(): - if 'user_id' not in session: - return redirect(url_for('main.index')) - - if 'file' not in request.files: + if "file" not in request.files: flash("Aucun fichier sélectionné.", "danger") - return redirect(url_for('main.gestion_import_export_actions_csv')) - - file = request.files['file'] - - if file.filename == '': + return redirect(url_for("main.gestion_import_export_actions_csv")) + + file = request.files["file"] + + if file.filename == "": flash("Aucun fichier sélectionné.", "danger") - return redirect(url_for('main.gestion_import_export_actions_csv')) - - if file and file.filename.endswith('.csv'): - try: - stream = io.TextIOWrapper(file.stream, encoding="utf-8") - csv_reader = csv.reader(stream, delimiter=';') - - header = next(csv_reader, None) - if not header: - flash("Le fichier CSV est vide.", "danger") - return redirect(url_for('main.gestion_import_export_actions_csv')) - - count = 0 - for row in csv_reader: - if len(row) >= 6: - isin = row[0].strip() if row[0] != '' else None - ticker = row[1].strip() if row[1] != '' else None - company_name = row[2].strip() if row[2] != '' else None - exchange = row[3].strip() if row[3] != '' else '' - pays = row[4].strip() if row[4] != '' else '' - currency = row[5].strip() if row[5] != '' else '' - - if not isin or not ticker: - continue - - query = text(""" - INSERT INTO actions (isin, ticker, company_name, exchange, pays, currency, updated_at) - VALUES (:isin, :ticker, :company_name, :exchange, :pays, :currency, NOW()) - ON DUPLICATE KEY UPDATE - ticker = :ticker, company_name = :company_name, - exchange = :exchange, pays = :pays, currency = :currency, updated_at = NOW() - """) - db.session.execute(query, { - "isin": isin, "ticker": ticker, "company_name": company_name, - "exchange": exchange, "pays": pays, "currency": currency - }) - count += 1 - - db.session.commit() - flash(f"Importation réussie : {count} actions traitées.", "success") - except Exception as e: - db.session.rollback() - flash(f"Erreur lors de l'importation du fichier : {str(e)}", "danger") - - return redirect(url_for('main.gestion_import_export_actions_csv')) - else: + return redirect(url_for("main.gestion_import_export_actions_csv")) + + if not file.filename.endswith(".csv"): flash("Veuillez fournir un fichier au format .csv valide.", "warning") - return redirect(url_for('main.gestion_import_export_actions_csv')) + return redirect(url_for("main.gestion_import_export_actions_csv")) + + try: + stream = io.TextIOWrapper(file.stream, encoding="utf-8") + csv_reader = csv.reader(stream, delimiter=";") + + header = next(csv_reader, None) + if not header: + flash("Le fichier CSV est vide.", "danger") + return redirect(url_for("main.gestion_import_export_actions_csv")) + + count = 0 + for row in csv_reader: + if len(row) >= 6: + isin = row[0].strip() if row[0] != "" else None + ticker = row[1].strip() if row[1] != "" else None + company_name = row[2].strip() if row[2] != "" else None + exchange = row[3].strip() if row[3] != "" else "" + pays = row[4].strip() if row[4] != "" else "" + currency = row[5].strip() if row[5] != "" else "" + + if not isin or not ticker: + continue + + query = text( + "INSERT INTO actions (isin, ticker, company_name, exchange, " + "pays, currency, updated_at) " + "VALUES (:isin, :ticker, :company_name, :exchange, :pays, " + ":currency, NOW()) " + "ON DUPLICATE KEY UPDATE ticker = :ticker, " + "company_name = :company_name, exchange = :exchange, " + "pays = :pays, currency = :currency, updated_at = NOW()" + ) + db.session.execute( + query, + { + "isin": isin, + "ticker": ticker, + "company_name": company_name, + "exchange": exchange, + "pays": pays, + "currency": currency, + }, + ) + count += 1 + + db.session.commit() + flash(f"Importation réussie : {count} actions traitées.", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur import CSV : %s", e, exc_info=True) + flash(f"Erreur lors de l'importation du fichier : {str(e)}", "danger") + + return redirect(url_for("main.gestion_import_export_actions_csv")) -# Route pour la gestion des ordres +# --------------------------------------------------------------------------- +# Gestion des ordres +# --------------------------------------------------------------------------- @main.route("/gestion_ordres", methods=["GET", "POST"]) +@login_required def gestion_ordres(): - if "user_id" not in session: - return redirect(url_for("main.index")) - - selected_societe_id = ( - request.form.get("societe_id") - or request.args.get("societe_id") - or session.get("selected_societe_id") - ) - - # Récupération de l'année sélectionnée (par défaut "Tout") - selected_annee = ( - request.form.get("annee") - or request.args.get("annee") - or session.get("selected_annee", "Tout") - ) - session["selected_annee"] = selected_annee - - societes = [] - selected_societe = "" - groupes_dict = {} - total_plus_value_globale = 0.0 - - with db.engine.connect() as connection: - result_soc = connection.execute( - text("SELECT id, Nom FROM societe ORDER BY Nom ASC") + selected_societe_id = ( + request.form.get("societe_id") + or request.args.get("societe_id") + or session.get("selected_societe_id") ) - societes = [dict(row._mapping) for row in result_soc] - if societes: - if not selected_societe_id: - selected_societe_id = societes[0]["id"] - else: - selected_societe_id = int(selected_societe_id) + selected_annee = ( + request.form.get("annee") + or request.args.get("annee") + or session.get("selected_annee", "Tout") + ) + session["selected_annee"] = selected_annee - session["selected_societe_id"] = selected_societe_id + societes = [] + selected_societe = "" + groupes_dict = {} + total_plus_value_globale = 0.0 - for soc in societes: - if soc["id"] == selected_societe_id: - selected_societe = soc["Nom"] - break - - # --- CALCUL DE LA PLUS-VALUE TOTALE ( GLOBALE OU FILTRÉE PAR ANNÉE ) --- - annee_str = str(selected_annee).strip().lower() - - if not annee_str or annee_str == "tout": - query_pv = text(""" - SELECT SUM(PlusValue) as total_pv - FROM ordre - WHERE id_societe = :soc_id AND PlusValue IS NOT NULL - """) - res_pv = connection.execute(query_pv, {"soc_id": selected_societe_id}).fetchone() - else: - query_pv = text(""" - SELECT SUM(PlusValue) as total_pv - FROM ordre - WHERE id_societe = :soc_id - AND PlusValue IS NOT NULL - AND LEFT(CAST(date_plusvalue AS CHAR), 4) = :annee - """) - res_pv = connection.execute( - query_pv, {"soc_id": selected_societe_id, "annee": annee_str} - ).fetchone() - - if res_pv and res_pv.total_pv is not None: - total_plus_value_globale = float(res_pv.total_pv) - else: - total_plus_value_globale = 0.0 - - # --- CHARGEMENT DES ORDRES --- - query = text(""" - SELECT o.ID as ordre_id, o.isin, o.PlusValue, o.date_plusvalue, - op.ID as pied_id, op.Date_op, op.Quantite, op.Prix_brut, op.Frais_brocker, - op.Frais_etat, op.Frais_autre, op.Ordre, op.Type, op.Etat, - a.ticker, a.company_name, a.currency - FROM ordre o - JOIN ordre_pied op ON o.ID = op.Id_entete - JOIN actions a ON o.isin = a.isin - WHERE o.id_societe = :soc_id - ORDER BY o.ID DESC, op.Date_op DESC - """) - - result_ordres = connection.execute( - query, {"soc_id": selected_societe_id} - ) - - for row in result_ordres: - r = dict(row._mapping) - ordre_id = r["ordre_id"] # Utilisation de l'ID d'en-tête comme clé unique - isin = r["isin"] - - quantite = float(r["Quantite"]) if r["Quantite"] is not None else 0.0 - prix_brut = float(r["Prix_brut"]) if r["Prix_brut"] is not None else 0.0 - frais_brocker = ( - float(r["Frais_brocker"]) if r["Frais_brocker"] is not None else 0.0 - ) - frais_etat = ( - float(r["Frais_etat"]) if r["Frais_etat"] is not None else 0.0 - ) - frais_autre = ( - float(r["Frais_autre"]) if r["Frais_autre"] is not None else 0.0 + with db.engine.connect() as connection: + result_soc = connection.execute( + text("SELECT id, nom FROM societe ORDER BY nom ASC") ) + societes = [dict(row._mapping) for row in result_soc] - frais_total = frais_brocker + frais_etat + frais_autre - sens_ordre = str(r["Ordre"]).strip().lower() - type_ordre = ( - str(r["Type"]).strip().lower() - if r["Type"] - else "long" - ) + if societes: + if not selected_societe_id: + selected_societe_id = societes[0]["id"] + else: + try: + selected_societe_id = int(selected_societe_id) + except (TypeError, ValueError): + selected_societe_id = societes[0]["id"] - # Calcul Prix Net unitaire - if sens_ordre == "vente": - prix_net = prix_brut - frais_total - else: - prix_net = prix_brut + frais_total + session["selected_societe_id"] = selected_societe_id - # Calculs Ligne (Pied) - eng_brut = quantite * prix_brut - eng_net = quantite * prix_net + for soc in societes: + if soc["id"] == selected_societe_id: + selected_societe = soc["nom"] + break - pied_data = { - "id": r["pied_id"], - "date_op": r["Date_op"], - "quantite": quantite, - "prix_brut": prix_brut, - "frais": frais_total, - "frais_brocker": frais_brocker, - "frais_etat": frais_etat, - "frais_autre": frais_autre, - "prix_net": prix_net, - "eng_brut": eng_brut, - "eng_net": eng_net, - "ordre": r["Ordre"], - "type_ordre": r["Type"], - "etat": r["Etat"], - } + # --- CALCUL DE LA PLUS-VALUE TOTALE --- + annee_str = str(selected_annee).strip().lower() - # MODIFICATION ICI : On utilise ordre_id comme clé du dictionnaire pour séparer les blocs - if ordre_id not in groupes_dict: - groupes_dict[ordre_id] = { - "id": ordre_id, - "isin": isin, - "company_name": r["company_name"], - "ticker": r["ticker"], - "pieds": [], - "total_quantite": 0.0, - "total_prix_brut": 0.0, - "total_frais": 0.0, - "total_prix_net": 0.0, - "total_eng_brut": 0.0, - "total_eng_net": 0.0, - "plus_value": r["PlusValue"], - "date_plusvalue": r["date_plusvalue"], - } + if not annee_str or annee_str == "tout": + query_pv = text( + "SELECT SUM(plus_value) AS total_pv FROM ordre " + "WHERE id_societe = :soc_id AND plus_value IS NOT NULL" + ) + res_pv = connection.execute( + query_pv, {"soc_id": selected_societe_id} + ).fetchone() + else: + query_pv = text( + "SELECT SUM(plus_value) AS total_pv FROM ordre " + "WHERE id_societe = :soc_id AND plus_value IS NOT NULL " + "AND LEFT(CAST(date_plus_value AS CHAR), 4) = :annee" + ) + res_pv = connection.execute( + query_pv, {"soc_id": selected_societe_id, "annee": annee_str} + ).fetchone() - groupes_dict[ordre_id]["pieds"].append(pied_data) - groupes_dict[ordre_id]["total_prix_brut"] += prix_brut - groupes_dict[ordre_id]["total_frais"] += frais_total - groupes_dict[ordre_id]["total_prix_net"] += prix_net + if res_pv and res_pv.total_pv is not None: + total_plus_value_globale = float(res_pv.total_pv) - # --- APPLICATION DE VOS RÈGLES POUR LES TOTAUX --- + # --- CHARGEMENT DES ORDRES --- + query = text( + "SELECT o.id AS ordre_id, o.isin, o.plus_value, o.date_plus_value, " + "op.id AS pied_id, op.date_op, op.quantite, op.prix_brut, " + "op.frais_broker, op.frais_etat, op.frais_autre, " + "op.sens, op.position, op.etat, " + "a.ticker, a.company_name, a.currency " + "FROM ordre o " + "JOIN ordre_pied op ON o.id = op.id_entete " + "JOIN actions a ON o.isin = a.isin " + "WHERE o.id_societe = :soc_id " + "ORDER BY o.id DESC, op.date_op DESC" + ) - # 1. QUANTITÉ - if sens_ordre == "achat": - groupes_dict[ordre_id]["total_quantite"] += quantite - elif sens_ordre == "vente": - groupes_dict[ordre_id]["total_quantite"] -= quantite + result_ordres = connection.execute( + query, {"soc_id": selected_societe_id} + ) - # 2. ENGAGEMENT BRUT - if sens_ordre == "achat": - groupes_dict[ordre_id]["total_eng_brut"] -= eng_brut - elif sens_ordre == "vente": - groupes_dict[ordre_id]["total_eng_brut"] += eng_brut + for row in result_ordres: + r = dict(row._mapping) + ordre_id = r["ordre_id"] + isin = r["isin"] - # 3. ENGAGEMENT NET - if sens_ordre == "achat": - groupes_dict[ordre_id]["total_eng_net"] -= eng_net - elif sens_ordre == "vente": - groupes_dict[ordre_id]["total_eng_net"] += eng_net + quantite = float(r["quantite"]) if r["quantite"] is not None else 0.0 + prix_brut = float(r["prix_brut"]) if r["prix_brut"] is not None else 0.0 + frais_broker = ( + float(r["frais_broker"]) if r["frais_broker"] is not None else 0.0 + ) + frais_etat = ( + float(r["frais_etat"]) if r["frais_etat"] is not None else 0.0 + ) + frais_autre = ( + float(r["frais_autre"]) if r["frais_autre"] is not None else 0.0 + ) - # --- VÉRIFICATION ET MISE À JOUR AUTOMATIQUE (EN BASE ET EN MÉMOIRE) --- - try: - with db.engine.begin() as conn: - for ordre_id, groupe in groupes_dict.items(): - if abs(groupe["total_quantite"]) < 1e-9: - total_eng_net = groupe["total_eng_net"] - - max_date = max((p["date_op"] for p in groupe["pieds"] if p["date_op"] is not None), default=None) + frais_total = frais_broker + frais_etat + frais_autre + sens_ordre = str(r["sens"]).strip().lower() + type_ordre = str(r["position"]).strip().lower() if r["position"] else "long" - conn.execute( - text( - "UPDATE ordre_pied SET Etat = :nouvel_etat WHERE Id_entete = :ordre_id" - ), - {"nouvel_etat": "soldé", "ordre_id": ordre_id}, - ) - - conn.execute( - text( - "UPDATE ordre SET PlusValue = :plus_value, date_plusvalue = :date_pv WHERE ID = :ordre_id" - ), - {"plus_value": total_eng_net, "date_pv": max_date, "ordre_id": ordre_id}, - ) - - groupe["plus_value"] = total_eng_net - groupe["date_plusvalue"] = max_date - for pied in groupe["pieds"]: - pied["etat"] = "soldé" - - except Exception as e: - pass + # Prix Net unitaire + if sens_ordre == "vente": + prix_net = prix_brut - frais_total + else: + prix_net = prix_brut + frais_total - groupes_ordres = list(groupes_dict.values()) - - annees_combo = ["Tout"] + [str(an) for an in range(2020, 2041)] + # Engagements ligne + eng_brut = quantite * prix_brut + eng_net = quantite * prix_net - return render_template( - "gestion_ordres.html", - societes=societes, - selected_societe_id=selected_societe_id, - selected_societe=selected_societe, - groupes_ordres=groupes_ordres, - total_plus_value_globale=total_plus_value_globale, - annees_combo=annees_combo, - selected_annee=selected_annee, - ) + pied_data = { + "id": r["pied_id"], + "date_op": r["date_op"], + "quantite": quantite, + "prix_brut": prix_brut, + "frais": frais_total, + "frais_broker": frais_broker, + "frais_etat": frais_etat, + "frais_autre": frais_autre, + "prix_net": prix_net, + "eng_brut": eng_brut, + "eng_net": eng_net, + "ordre": r["sens"], + "type_ordre": r["position"], + "etat": r["etat"], + } + + if ordre_id not in groupes_dict: + groupes_dict[ordre_id] = { + "id": ordre_id, + "isin": isin, + "company_name": r["company_name"], + "ticker": r["ticker"], + "pieds": [], + "total_quantite": 0.0, + "total_prix_brut": 0.0, + "total_frais": 0.0, + "total_prix_net": 0.0, + "total_eng_brut": 0.0, + "total_eng_net": 0.0, + "plus_value": r["plus_value"], + "date_plusvalue": r["date_plus_value"], + } + + groupes_dict[ordre_id]["pieds"].append(pied_data) + groupes_dict[ordre_id]["total_prix_brut"] += prix_brut + groupes_dict[ordre_id]["total_frais"] += frais_total + groupes_dict[ordre_id]["total_prix_net"] += prix_net + + # Règles de totaux + if sens_ordre == "achat": + groupes_dict[ordre_id]["total_quantite"] += quantite + groupes_dict[ordre_id]["total_eng_brut"] -= eng_brut + groupes_dict[ordre_id]["total_eng_net"] -= eng_net + elif sens_ordre == "vente": + groupes_dict[ordre_id]["total_quantite"] -= quantite + groupes_dict[ordre_id]["total_eng_brut"] += eng_brut + groupes_dict[ordre_id]["total_eng_net"] += eng_net + + # --- MISE À JOUR AUTO DE LA PLUS-VALUE (ordres soldés) --- + try: + with db.engine.begin() as conn: + for ordre_id, groupe in groupes_dict.items(): + if abs(groupe["total_quantite"]) < 1e-9: + total_eng_net = groupe["total_eng_net"] + max_date = max( + (p["date_op"] for p in groupe["pieds"] if p["date_op"] is not None), + default=None, + ) + + conn.execute( + text( + "UPDATE ordre_pied SET etat = :nouvel_etat " + "WHERE id_entete = :ordre_id" + ), + {"nouvel_etat": "soldé", "ordre_id": ordre_id}, + ) + conn.execute( + text( + "UPDATE ordre SET plus_value = :plus_value, " + "date_plus_value = :date_pv WHERE id = :ordre_id" + ), + { + "plus_value": total_eng_net, + "date_pv": max_date, + "ordre_id": ordre_id, + }, + ) + + groupe["plus_value"] = total_eng_net + groupe["date_plusvalue"] = max_date + for pied in groupe["pieds"]: + pied["etat"] = "soldé" + except Exception as e: + current_app.logger.error("Erreur MAJ plus-value : %s", e, exc_info=True) + + groupes_ordres = list(groupes_dict.values()) + annees_combo = ["Tout"] + [str(an) for an in range(2020, 2041)] + + return render_template( + "gestion_ordres.html", + societes=societes, + selected_societe_id=selected_societe_id, + selected_societe=selected_societe, + groupes_ordres=groupes_ordres, + total_plus_value_globale=total_plus_value_globale, + annees_combo=annees_combo, + selected_annee=selected_annee, + ) -# Route pour la creation d'un ordre +# --------------------------------------------------------------------------- +# Création d'ordre (la saisie se fait via la modale de gestion_ordres ; +# cette route est conservée pour compatibilité et redirige vers la liste) +# --------------------------------------------------------------------------- @main.route("/creer-ordre", methods=["GET", "POST"]) +@login_required def creer_ordre(): - if "user_id" not in session: - return redirect(url_for("main.index")) - return render_template("creer_ordre.html") + return redirect(url_for("main.gestion_ordres")) -# Route pour vérifier l'existence d'un ISIN +# --------------------------------------------------------------------------- +# API : vérifier l'existence d'un ISIN (JSON, exemptée de CSRF) +# --------------------------------------------------------------------------- @main.route("/api/verifier_isin", methods=["POST"]) +@csrf.exempt def verifier_isin(): - data = request.get_json() - isin = data.get("isin", "").strip() + data = request.get_json(silent=True) or {} + isin = (data.get("isin") or "").strip() - query = text( - "SELECT company_name, ticker, currency FROM actions WHERE isin = :isin" - ) - action = db.session.execute(query, {"isin": isin}).fetchone() + query = text( + "SELECT company_name, ticker, currency FROM actions WHERE isin = :isin" + ) + action = db.session.execute(query, {"isin": isin}).fetchone() - if action: - return jsonify({ - "exists": True, - "company_name": action.company_name, - "ticker": action.ticker, - "currency": action.currency, - }) - else: + if action: + return jsonify( + { + "exists": True, + "company_name": action.company_name, + "ticker": action.ticker, + "currency": action.currency, + } + ) return jsonify({"exists": False}) -# Route pour la création d'un ordre de traitement +# --------------------------------------------------------------------------- +# Traitement de la création d'un ordre (appelé par la modale) +# --------------------------------------------------------------------------- @main.route("/creer-ordre-traitement", methods=["POST"]) +@login_required def creer_ordre_traitement(): - if "user_id" not in session: - return redirect(url_for("main.index")) - isin = request.form.get("isin") date_op = request.form.get("date_op") selected_annee = request.form.get("annee", "tout") @@ -675,20 +803,22 @@ def creer_ordre_traitement(): try: quantite = float(request.form.get("quantite") or 0) prix_brut = float(request.form.get("prix_brut") or 0) - frais_brocker = float(request.form.get("frais_brocker") or 0) + frais_broker = float(request.form.get("frais_broker") or 0) frais_etat = float(request.form.get("frais_etat") or 0) frais_autre = float(request.form.get("frais_autre") or 0) except ValueError: - flash("Veuillez saisir des valeurs numériques valides pour les quantités, prix et frais.", "danger") + flash( + "Veuillez saisir des valeurs numériques valides pour les quantités, " + "prix et frais.", + "danger", + ) return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - ordre = request.form.get("ordre") - type_ordre = request.form.get("type_ordre") + sens = request.form.get("ordre") + position = request.form.get("type_ordre") etat = request.form.get("etat") - # Récupération de l'id_societe courant stocké dans la session id_societe = session.get("selected_societe_id") - if not id_societe: flash( "Veuillez sélectionner une société active avant de créer un ordre.", @@ -697,29 +827,27 @@ def creer_ordre_traitement(): return redirect(url_for("main.gestion_ordres", annee=selected_annee)) try: - # 1. Insertion dans la table 'ordre' avec l'isin et l'id_societe query_ordre = text( "INSERT INTO ordre (id_societe, isin) VALUES (:id_societe, :isin)" ) db.session.execute(query_ordre, {"id_societe": id_societe, "isin": isin}) - # Récupérer l'ID généré pour l'en-tête (Id_entete) - result_id = db.session.execute( - text("SELECT LAST_INSERT_ID()") - ).scalar() + result_id = db.session.execute(text("SELECT LAST_INSERT_ID()")).scalar() if not result_id: result_id = db.session.execute( text( - "SELECT ID FROM ordre WHERE isin = :isin ORDER BY ID DESC LIMIT 1" + "SELECT id FROM ordre WHERE isin = :isin " + "ORDER BY id DESC LIMIT 1" ), {"isin": isin}, - ).fetchone()[0] + ).scalar() - # 2. Insertion dans la table 'ordre_pied' avec les 3 frais distincts - query_pied = text(""" - INSERT INTO ordre_pied (Id_entete, Date_op, Quantite, Prix_brut, Frais_brocker, Frais_etat, Frais_autre, Ordre, Type, Etat) - VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_brocker, :frais_etat, :frais_autre, :ordre, :type_ordre, :etat) - """) + query_pied = text( + "INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, " + "frais_broker, frais_etat, frais_autre, sens, position, etat) " + "VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, " + ":frais_etat, :frais_autre, :sens, :position, :etat)" + ) db.session.execute( query_pied, { @@ -727,11 +855,11 @@ def creer_ordre_traitement(): "date_op": date_op, "quantite": quantite, "prix_brut": prix_brut, - "frais_brocker": frais_brocker, + "frais_broker": frais_broker, "frais_etat": frais_etat, "frais_autre": frais_autre, - "ordre": ordre, - "type_ordre": type_ordre, + "sens": sens, + "position": position, "etat": etat, }, ) @@ -740,30 +868,18 @@ def creer_ordre_traitement(): flash("Ordre créé avec succès !", "success") except Exception as e: db.session.rollback() - flash(f"Erreur lors de la création de l'ordre : {e}", "danger") + current_app.logger.error("Erreur création ordre : %s", e, exc_info=True) + flash("Erreur lors de la création de l'ordre.", "danger") return redirect(url_for("main.gestion_ordres", annee=selected_annee)) -# Route pour créer une nouvelle ligne rattachée à un ordre existant (depuis le bouton "Créer") +# --------------------------------------------------------------------------- +# Ajout d'une ligne à un ordre existant (POST depuis la modale) +# --------------------------------------------------------------------------- @main.route("/ordre//ajouter-ligne", methods=["GET", "POST"]) +@login_required def ajouter_ligne_ordre(ordre_id): - if "user_id" not in session: - return redirect(url_for("main.index")) - - # Récupérer l'en-tête de l'ordre, l'ISIN et le nom de la société associée - query_info = text(""" - SELECT o.ID, o.isin, a.company_name - FROM ordre o - JOIN actions a ON o.isin = a.isin - WHERE o.ID = :ordre_id - """) - ordre_info = db.session.execute(query_info, {"ordre_id": ordre_id}).fetchone() - - if not ordre_info: - flash("Ordre introuvable.", "danger") - return redirect(url_for("main.gestion_ordres")) - selected_annee = request.args.get("annee", "tout") if request.method == "POST": @@ -771,66 +887,58 @@ def ajouter_ligne_ordre(ordre_id): try: quantite = float(request.form.get("quantite") or 0) prix_brut = float(request.form.get("prix_brut") or 0) - frais_brocker = float(request.form.get("frais_brocker") or 0) + frais_broker = float(request.form.get("frais_broker") or 0) frais_etat = float(request.form.get("frais_etat") or 0) frais_autre = float(request.form.get("frais_autre") or 0) except ValueError: flash("Veuillez saisir des valeurs numériques valides.", "danger") - return redirect(url_for("main.ajouter_ligne_ordre", ordre_id=ordre_id, annee=selected_annee)) + return redirect( + url_for( + "main.ajouter_ligne_ordre", + ordre_id=ordre_id, + annee=selected_annee, + ) + ) try: - query_pied = text(""" - INSERT INTO ordre_pied (Id_entete, Date_op, Quantite, Prix_brut, Frais_brocker, Frais_etat, Frais_autre, Ordre, Type, Etat) - VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_brocker, :frais_etat, :frais_autre, :ordre, :type_ordre, :etat) - """) - db.session.execute(query_pied, { - "id_entete": ordre_id, - "date_op": request.form.get("date_op"), - "quantite": quantite, - "prix_brut": prix_brut, - "frais_brocker": frais_brocker, - "frais_etat": frais_etat, - "frais_autre": frais_autre, - "ordre": request.form.get("ordre"), - "type_ordre": request.form.get("type_ordre"), - "etat": request.form.get("etat"), - }) + query_pied = text( + "INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, " + "frais_broker, frais_etat, frais_autre, sens, position, etat) " + "VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, " + ":frais_etat, :frais_autre, :sens, :position, :etat)" + ) + db.session.execute( + query_pied, + { + "id_entete": ordre_id, + "date_op": request.form.get("date_op"), + "quantite": quantite, + "prix_brut": prix_brut, + "frais_broker": frais_broker, + "frais_etat": frais_etat, + "frais_autre": frais_autre, + "sens": request.form.get("ordre"), + "position": request.form.get("type_ordre"), + "etat": request.form.get("etat"), + }, + ) db.session.commit() flash("Ligne ajoutée avec succès à l'ordre !", "success") return redirect(url_for("main.gestion_ordres", annee=selected_annee)) except Exception as e: db.session.rollback() - flash(f"Erreur lors de l'ajout : {e}", "danger") + current_app.logger.error("Erreur ajout ligne : %s", e, exc_info=True) + flash("Erreur lors de l'ajout de la ligne.", "danger") - contexte_form = { - "mode": "creer_ligne", - "isin": ordre_info.isin, - "company_name": ordre_info.company_name, - "action_url": url_for("main.ajouter_ligne_ordre", ordre_id=ordre_id) - } - return render_template("form_ordre_ligne.html", data=contexte_form, ligne=None) + return redirect(url_for("main.gestion_ordres", annee=selected_annee)) -# Route pour modifier une ligne spécifique (depuis le bouton "Modifier" de la ligne) +# --------------------------------------------------------------------------- +# Modification d'une ligne d'ordre (POST depuis la modale) +# --------------------------------------------------------------------------- @main.route("/modifier-ligne-ordre/", methods=["GET", "POST"]) +@login_required def modifier_ligne_ordre(pied_id): - if "user_id" not in session: - return redirect(url_for("main.index")) - - # Récupérer la ligne du pied, l'ISIN et le nom de l'entreprise - query_get = text(""" - SELECT op.*, o.isin, a.company_name - FROM ordre_pied op - JOIN ordre o ON op.Id_entete = o.ID - JOIN actions a ON o.isin = a.isin - WHERE op.ID = :pied_id - """) - ligne = db.session.execute(query_get, {"pied_id": pied_id}).fetchone() - - if not ligne: - flash("Ligne d'ordre introuvable.", "danger") - return redirect(url_for("main.gestion_ordres")) - selected_annee = request.args.get("annee", "tout") if request.method == "POST": @@ -838,44 +946,397 @@ def modifier_ligne_ordre(pied_id): try: quantite = float(request.form.get("quantite") or 0) prix_brut = float(request.form.get("prix_brut") or 0) - frais_brocker = float(request.form.get("frais_brocker") or 0) + frais_broker = float(request.form.get("frais_broker") or 0) frais_etat = float(request.form.get("frais_etat") or 0) frais_autre = float(request.form.get("frais_autre") or 0) except ValueError: flash("Veuillez saisir des valeurs numériques valides.", "danger") - return redirect(url_for("main.modifier_ligne_ordre", pied_id=pied_id, annee=selected_annee)) + return redirect( + url_for( + "main.modifier_ligne_ordre", + pied_id=pied_id, + annee=selected_annee, + ) + ) try: - query_update = text(""" - UPDATE ordre_pied - SET Date_op = :date_op, Quantite = :quantite, Prix_brut = :prix_brut, - Frais_brocker = :frais_brocker, Frais_etat = :frais_etat, Frais_autre = :frais_autre, - Ordre = :ordre, Type = :type_ordre, Etat = :etat - WHERE ID = :pied_id - """) - db.session.execute(query_update, { - "date_op": request.form.get("date_op"), - "quantite": quantite, - "prix_brut": prix_brut, - "frais_brocker": frais_brocker, - "frais_etat": frais_etat, - "frais_autre": frais_autre, - "ordre": request.form.get("ordre"), - "type_ordre": request.form.get("type_ordre"), - "etat": request.form.get("etat"), - "pied_id": pied_id - }) + query_update = text( + "UPDATE ordre_pied SET date_op = :date_op, quantite = :quantite, " + "prix_brut = :prix_brut, frais_broker = :frais_broker, " + "frais_etat = :frais_etat, frais_autre = :frais_autre, " + "sens = :sens, position = :position, etat = :etat " + "WHERE id = :pied_id" + ) + db.session.execute( + query_update, + { + "date_op": request.form.get("date_op"), + "quantite": quantite, + "prix_brut": prix_brut, + "frais_broker": frais_broker, + "frais_etat": frais_etat, + "frais_autre": frais_autre, + "sens": request.form.get("ordre"), + "position": request.form.get("type_ordre"), + "etat": request.form.get("etat"), + "pied_id": pied_id, + }, + ) db.session.commit() flash("Ligne modifiée avec succès !", "success") return redirect(url_for("main.gestion_ordres", annee=selected_annee)) except Exception as e: db.session.rollback() - flash(f"Erreur lors de la modification : {e}", "danger") + current_app.logger.error("Erreur modification ligne : %s", e, exc_info=True) + flash("Erreur lors de la modification de la ligne.", "danger") - contexte_form = { - "mode": "modifier_ligne", - "isin": ligne.isin, - "company_name": ligne.company_name, - "action_url": url_for("main.modifier_ligne_ordre", pied_id=pied_id) - } - return render_template("form_ordre_ligne.html", data=contexte_form, ligne=ligne) \ No newline at end of file + return redirect(url_for("main.gestion_ordres", annee=selected_annee)) + + +# --------------------------------------------------------------------------- +# Gestion des comptes +# --------------------------------------------------------------------------- +@main.route("/gestion_comptes", methods=["GET", "POST"]) +@login_required +def gestion_comptes(): + from app.models import Societe, Compte + + societes = Societe.query.order_by(Societe.nom.asc()).all() + + # La société sélectionnée vient du formulaire (changement manuel), + # ou d'un paramètre d'URL (redirection après création/modification), + # ou de la session (mémorisation entre navigations). + selected_societe_id = ( + request.form.get("societe_id") + or request.args.get("societe_id") + or session.get("selected_compte_societe_id") + ) + if not selected_societe_id and societes: + selected_societe_id = societes[0].id + else: + try: + selected_societe_id = int(selected_societe_id) if selected_societe_id else None + except ValueError: + selected_societe_id = societes[0].id if societes else None + + # Mémorisation en session : la société active ne change que manuellement + if selected_societe_id is not None: + session["selected_compte_societe_id"] = selected_societe_id + + selected_societe_nom = "" + ecritures = [] + total_credit = 0.0 + total_debit = 0.0 + # Agrégats financiers (voir règles de calcul dans la documentation) + tcredit = 0.0 # Depot : somme des crédits des écritures manuelles + tdebit = 0.0 # Retrait : somme des débits des écritures manuelles + engagement = 0.0 # ENGA. : positions actives (achat +, vente -) + pv = 0.0 # PV : positions soldées (achat -, vente +) + dispo = 0.0 # DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures) + solde = 0.0 # SOLDE = DISPO + ENGA. + + if selected_societe_id: + societe_obj = Societe.query.get(selected_societe_id) + if societe_obj: + selected_societe_nom = societe_obj.nom + + # Tri par date décroissante (du plus récent au plus ancien) + ecritures = ( + Compte.query.filter_by(id_societe=selected_societe_id) + .order_by(Compte.date_operation.desc(), Compte.id.desc()) + .all() + ) + # Totaux bruts du tableau (toutes écritures confondues) + total_credit = sum(float(e.credit or 0) for e in ecritures) + total_debit = sum(float(e.debit or 0) for e in ecritures) + + # --- Dépôts / Retraits : uniquement les écritures manuelles --- + for e in ecritures: + if e.source == "manuel": + tcredit += float(e.credit or 0) + tdebit += float(e.debit or 0) + + # --- DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures) --- + dispo = total_credit - total_debit + + # --- Engagement & PV : depuis les lignes d'ordres (ordre_pied) --- + # Règles : + # etat='actif' : achat -> engagement += qté*(prix_brut+frais) + # vente -> engagement -= qté*(prix_brut-frais) + # etat='soldé' : achat -> PV -= qté*(prix_brut+frais) + # vente -> PV += qté*(prix_brut-frais) + # etat='annulé' : ignoré (filtré en SQL) + # Filtres : société en cours + lignes non annulées + query_ordres = text( + "SELECT op.sens, op.etat, op.quantite, op.prix_brut, " + "op.frais_broker, op.frais_etat, op.frais_autre " + "FROM ordre o " + "JOIN ordre_pied op ON op.id_entete = o.id " + "WHERE o.id_societe = :soc_id " + "AND op.etat <> 'annulé'" + ) + for row in db.session.execute(query_ordres, {"soc_id": selected_societe_id}): + quantite = float(row.quantite or 0) + prix_brut = float(row.prix_brut or 0) + frais_broker = float(row.frais_broker or 0) + frais_etat = float(row.frais_etat or 0) + frais_autre = float(row.frais_autre or 0) + + # Achat : on paie le prix brut + les frais + montant_achat = quantite * (prix_brut + frais_broker + frais_etat + frais_autre) + # Vente : on encaisse le prix brut - les frais + montant_vente = quantite * (prix_brut - frais_broker - frais_etat - frais_autre) + + sens = (row.sens or "").strip().lower() + etat = (row.etat or "").strip().lower() + + if etat == "actif": + if sens == "achat": + engagement += montant_achat + elif sens == "vente": + engagement -= montant_vente + elif etat == "soldé" or etat == "solde": + if sens == "achat": + pv -= montant_achat + elif sens == "vente": + pv += montant_vente + # etat == 'annulé' : ne rien faire (déjà filtré en SQL) + + solde = dispo + engagement + + return render_template( + "gestion_comptes.html", + societes=societes, + selected_societe_id=selected_societe_id, + selected_societe=selected_societe_nom, + ecritures=ecritures, + total_credit=total_credit, + total_debit=total_debit, + tcredit=tcredit, + tdebit=tdebit, + engagement=engagement, + pv=pv, + dispo=dispo, + solde=solde, + ) + + +# --------------------------------------------------------------------------- +# Génération automatique des écritures de compte depuis les ordres +# --------------------------------------------------------------------------- +@main.route("/generer_ecritures_ordres", methods=["POST"]) +@login_required +def generer_ecritures_ordres(): + """Génère les écritures comptables (table `compte`) à partir des ordres + et de leurs lignes (`ordre_pied`) pour la société actuellement sélectionnée. + + Règles : + - achat : debit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), credit = 0 + - vente : credit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), debit = 0 + - libelle= "Achat/Vente actions {isin} {company_name}" + - source = 'ordre' (non modifiable depuis l'IHM) + Idempotent : supprime d'abord les écritures source='ordre' de la société. + """ + id_societe = request.form.get("id_societe") or session.get( + "selected_compte_societe_id" + ) + + if not id_societe: + flash("Aucune société sélectionnée pour la génération.", "danger") + return redirect(url_for("main.gestion_comptes")) + + try: + id_societe_int = int(id_societe) + except (TypeError, ValueError): + flash("Identifiant de société invalide.", "danger") + return redirect(url_for("main.gestion_comptes")) + + try: + # 1. Suppression des anciennes écritures générées pour cette société + db.session.execute( + text( + "DELETE FROM compte WHERE id_societe = :soc_id AND source = 'ordre'" + ), + {"soc_id": id_societe_int}, + ) + + # 2. Parcours des ordres de la société, puis de leurs lignes + # Filtres : société en cours + lignes non annulées + query = text( + "SELECT o.id, o.isin, a.company_name, " + "op.date_op, op.quantite, op.prix_brut, " + "op.frais_broker, op.frais_etat, op.frais_autre, op.sens " + "FROM ordre o " + "JOIN ordre_pied op ON op.id_entete = o.id " + "JOIN actions a ON o.isin = a.isin " + "WHERE o.id_societe = :soc_id " + "AND op.etat <> 'annulé' " + "ORDER BY o.id, op.date_op" + ) + lignes = db.session.execute(query, {"soc_id": id_societe_int}).fetchall() + + insert = text( + "INSERT INTO compte (id_societe, date_operation, debit, credit, " + "libelle, source) " + "VALUES (:id_societe, :date_op, :debit, :credit, :libelle, 'ordre')" + ) + + count = 0 + from markupsafe import escape + + for row in lignes: + quantite = float(row.quantite or 0) + prix_brut = float(row.prix_brut or 0) + frais_broker = float(row.frais_broker or 0) + frais_etat = float(row.frais_etat or 0) + frais_autre = float(row.frais_autre or 0) + + sens = (row.sens or "").strip().lower() + isin = row.isin or "" + # Nom de l'action échappé puis mis en gras (affichage |safe côté template) + company = f"{escape(row.company_name or '')}" + + if sens == "achat": + # Achat : on paie le prix brut + les frais + montant = quantite * (prix_brut + frais_broker + frais_etat + frais_autre) + debit = montant + credit = 0.0 + libelle = f"Achat actions {isin} {company}".strip() + elif sens == "vente": + # Vente : on encaisse le prix brut - les frais + montant = quantite * (prix_brut - frais_broker - frais_etat - frais_autre) + debit = 0.0 + credit = montant + libelle = f"Vente actions {isin} {company}".strip() + else: + # Sens inattendu : on ignore la ligne + continue + + db.session.execute( + insert, + { + "id_societe": id_societe_int, + "date_op": row.date_op, + "debit": debit, + "credit": credit, + "libelle": libelle, + }, + ) + count += 1 + + db.session.commit() + flash( + f"Génération terminée : {count} écriture(s) créée(s) pour " + f"la société.", + "success", + ) + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur génération écritures : %s", e, exc_info=True) + flash("Erreur lors de la génération des écritures.", "danger") + + return redirect(url_for("main.gestion_comptes", societe_id=id_societe_int)) + + +# --------------------------------------------------------------------------- +# Création d'une écriture comptable +# --------------------------------------------------------------------------- +@main.route("/creer_compte_traitement", methods=["POST"]) +@login_required +def creer_compte_traitement(): + id_societe = request.form.get("id_societe") + date_operation = request.form.get("date_operation") + libelle = request.form.get("libelle", "").strip() + debit = request.form.get("debit", "0") + credit = request.form.get("credit", "0") + + if not id_societe or not date_operation: + flash("Veuillez remplir tous les champs obligatoires.", "danger") + return redirect(url_for("main.gestion_comptes", societe_id=id_societe or "")) + + try: + debit_val = float(debit) if debit else 0.0 + credit_val = float(credit) if credit else 0.0 + id_societe_int = int(id_societe) + except ValueError: + flash("Veuillez saisir des valeurs numériques valides.", "danger") + return redirect(url_for("main.gestion_comptes", societe_id=id_societe or "")) + + try: + db.session.execute( + text( + "INSERT INTO compte (id_societe, date_operation, libelle, " + "debit, credit) VALUES (:id_societe, :date_op, :libelle, :debit, :credit)" + ), + { + "id_societe": id_societe_int, + "date_op": date_operation, + "libelle": libelle or None, + "debit": debit_val, + "credit": credit_val, + }, + ) + db.session.commit() + flash("Écriture créée avec succès !", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur création écriture : %s", e, exc_info=True) + flash("Erreur lors de la création de l'écriture.", "danger") + + # On reste sur la même société après validation + return redirect(url_for("main.gestion_comptes", societe_id=id_societe_int)) + + +# --------------------------------------------------------------------------- +# Modification d'une écriture comptable +# --------------------------------------------------------------------------- +@main.route("/modifier_compte/", methods=["POST"]) +@login_required +def modifier_compte(id): + date_operation = request.form.get("date_operation") + libelle = request.form.get("libelle", "").strip() + debit = request.form.get("debit", "0") + credit = request.form.get("credit", "0") + + # Récupération de la société de l'écriture avant modification (pour la redirection) + id_societe = request.form.get("id_societe") + if not id_societe: + row = db.session.execute( + text("SELECT id_societe FROM compte WHERE id = :id"), {"id": id} + ).fetchone() + id_societe = str(row.id_societe) if row else "" + + if not date_operation: + flash("Veuillez remplir tous les champs obligatoires.", "danger") + return redirect(url_for("main.gestion_comptes", societe_id=id_societe)) + + try: + debit_val = float(debit) if debit else 0.0 + credit_val = float(credit) if credit else 0.0 + except ValueError: + flash("Veuillez saisir des valeurs numériques valides.", "danger") + return redirect(url_for("main.gestion_comptes", societe_id=id_societe)) + + try: + db.session.execute( + text( + "UPDATE compte SET date_operation = :date_op, " + "libelle = :libelle, debit = :debit, credit = :credit WHERE id = :id" + ), + { + "date_op": date_operation, + "libelle": libelle or None, + "debit": debit_val, + "credit": credit_val, + "id": id, + }, + ) + db.session.commit() + flash("Écriture modifiée avec succès !", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur modification écriture : %s", e, exc_info=True) + flash("Erreur lors de la modification de l'écriture.", "danger") + + # On reste sur la même société après validation + return redirect(url_for("main.gestion_comptes", societe_id=id_societe)) diff --git a/app/templates/base.html b/app/templates/base.html index d7d031b..948c557 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,5 @@ - + @@ -14,15 +14,20 @@ + +
- + - +

Bolsa

@@ -72,13 +77,13 @@
-
+
{% block content %}{% endblock %}
@@ -101,7 +106,7 @@
- + {% if session.get('user_id') %} {% endif %} diff --git a/app/templates/gestion_actions.html b/app/templates/gestion_actions.html index 1790247..5563603 100644 --- a/app/templates/gestion_actions.html +++ b/app/templates/gestion_actions.html @@ -107,6 +107,7 @@ content %} action="" class="space-y-4 flex-1 flex flex-col justify-between" > +
diff --git a/app/templates/gestion_comptes.html b/app/templates/gestion_comptes.html new file mode 100644 index 0000000..633368c --- /dev/null +++ b/app/templates/gestion_comptes.html @@ -0,0 +1,417 @@ +{% extends "base.html" %} {% block title %}Gestion des Comptes - Bolsa{% +endblock %} {% block header_title %}Gestion des comptes{% endblock %} {% block +content %} +
+ +
+ {% set messages = get_flashed_messages(with_categories=true) %} {% if + messages %} {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} {% endif %} +
+ + +
+ + + + +
+
+ + + + + + + + + + + + {% if ecritures %} {% for e in ecritures %} + + + + + + + + {% endfor %} {% else %} + + + + {% endif %} + + + + + + + + + + + + + + + +
DateLibelléCréditDébit + +
{{ e.date_operation }} + {% if e.source == 'ordre' %}{{ e.libelle | safe }}{% else %}{{ e.libelle or '-' }}{% endif %} + + {% if e.credit and e.credit > 0 %} {{ "{:,.2f}".format(e.credit) + }} {% else %} - {% endif %} + + {% if e.debit and e.debit > 0 %} {{ "{:,.2f}".format(e.debit) }} + {% else %} - {% endif %} + + {% if e.source == 'manuel' %} + + {% else %} + Auto + {% endif %} +
+ Aucune écriture pour cette société. +
TOTAL : + {{ "{:,.2f}".format(total_credit or 0) }} + + {{ "{:,.2f}".format(total_debit or 0) }} +
+
+ + SOLDE : + + {{ "{:,.2f}".format(solde | abs) }} €{% if solde < 0 %} (-){% endif %} + + + | + + DISPO : + + {{ "{:,.2f}".format(dispo | abs) }} €{% if dispo < 0 %} (-){% endif %} + + + | + + ENGA. : + + + | + + Depot : + + {{ "{:,.2f}".format(tcredit | abs) }} €{% if tcredit < 0 %} (-){% endif %} + + + | + + Retrait : + + {{ "{:,.2f}".format(tdebit | abs) }} €{% if tdebit < 0 %} (-){% endif %} + + + | + + PV : + + {{ "{:,.2f}".format(pv | abs) }} €{% if pv < 0 %} (-){% endif %} + + +
+
+
+
+ + +
+ Comptes : Accès sécurisé. Veillez à fermer votre session après chaque + utilisation. +
+
+
+ + + + + +{% endblock %} diff --git a/app/templates/gestion_import_export_actions_csv.html b/app/templates/gestion_import_export_actions_csv.html index a837fe0..72352c8 100644 --- a/app/templates/gestion_import_export_actions_csv.html +++ b/app/templates/gestion_import_export_actions_csv.html @@ -150,6 +150,7 @@ endblock %} {% block header_title %}Gestion CSV{% endblock %} {% block content enctype="multipart/form-data" class="w-full sm:w-3/4 flex items-center gap-2" > +
- +
+

@@ -43,6 +44,7 @@ action="{{ url_for('main.gestion_ordres') }}" class="flex items-center gap-2 m-0" > + - {{ "{:,.2f}".format(total_plus_value_globale or 0) }} € + {{ "{:,.2f}".format(total_plus_value_globale | abs) }} €

@@ -81,6 +83,7 @@ action="{{ url_for('main.gestion_ordres') }}" class="m-0" > + {% for soc in societes %} {% if soc.id == selected_societe_id %} - + {% else %} - + {% endif %} {% endfor %} @@ -117,144 +120,163 @@
- -
- - - {% if groupes_ordres %} {% for groupe in groupes_ordres %} - - - + +
+ +
+
- {{ groupe.isin }} --- {{ groupe.company_name }} ( {{ groupe.ticker - }} ) -
+ + {% if groupes_ordres %} {% for groupe in groupes_ordres %} + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - {% for p in groupe.pieds %} - - - - - - - - - - - - - - {% endfor %} + {% for p in groupe.pieds %} + + + + + + + + + + + + + + {% endfor %} - - - - + + + + - {% if groupe.total_quantite != 0 %} {% set total_prix_net_moyen = - (groupe.total_eng_net / groupe.total_quantite) | abs %} {% set - total_prix_brut_moyen = (groupe.total_eng_brut / - groupe.total_quantite) | abs %} {% set total_frais_moyen = - (total_prix_net_moyen - total_prix_brut_moyen) | abs %} + {% if groupe.total_quantite != 0 %} {% set total_prix_net_moyen = + (groupe.total_eng_net / groupe.total_quantite) %} {% set + total_prix_brut_moyen = (groupe.total_eng_brut / + groupe.total_quantite) %} {% set total_frais_moyen = + (total_prix_net_moyen - total_prix_brut_moyen) %} - - - - - - {% else %} - - - - - + + + + + + {% else %} + + + + + + {% endif %} + + + + + + + + {% endfor %} {% else %} + + + {% endif %} + +
+ {{ groupe.isin }} --- {{ groupe.company_name }} ( {{ + groupe.ticker }} ) +
DateQtéPrix BrutFraisPrix NetEng. BrutEng. NetOrdreTypeÉtat - -
DateQtéPrix BrutFraisPrix NetEng. BrutEng. NetOrdreTypeÉtat + +
{{ p.date_op }} - {{ "{:,.0f}".format(p.quantite).replace(",", " ") }} - {{ "{:,.3f}".format(p.prix_brut) }}{{ "{:,.3f}".format(p.frais) }}{{ "{:,.3f}".format(p.prix_net) }}{{ "{:,.3f}".format(p.eng_brut) }}{{ "{:,.3f}".format(p.eng_net) }} - - {{ p.ordre | upper }} - - {{ p.type_ordre }} - - {{ p.etat }} - - - -
{{ p.date_op }} + {{ "{:,.0f}".format(p.quantite).replace(",", " ") }} + + {{ "{:,.3f}".format(p.prix_brut | abs) }} + {{ "{:,.3f}".format(p.frais | abs) }} + {{ "{:,.3f}".format(p.prix_net | abs) }} + {{ "{:,.3f}".format(p.eng_brut) }}{{ "{:,.3f}".format(p.eng_net) }} + + {{ p.ordre | upper }} + + {{ p.type_ordre }} + + {{ p.etat }} + + + +
TOTAL : - {{ "{:,.0f}".format(groupe.total_quantite).replace(",", " ") }} -
TOTAL : + {{ "{:,.0f}".format(groupe.total_quantite).replace(",", " ") }} + - {{ "{:,.3f}".format(total_prix_brut_moyen) }} - - {{ "{:,.3f}".format(total_frais_moyen) }} - - {{ "{:,.3f}".format(total_prix_net_moyen) }} - - {{ "{:,.3f}".format(groupe.total_eng_brut | abs) }} - - {{ "{:,.3f}".format(groupe.total_eng_net | abs) }} - --- - {{ "{:,.3f}".format(groupe.total_eng_brut | abs) }} - - - {{ "{:,.3f}".format(groupe.total_eng_net | abs) }} - - + {{ "{:,.3f}".format(total_prix_brut_moyen | abs) }} + + {{ "{:,.3f}".format(total_frais_moyen | abs) }} + + {{ "{:,.3f}".format(total_prix_net_moyen | abs) }} + + {{ "{:,.3f}".format(groupe.total_eng_brut) }} + + + {{ "{:,.3f}".format(groupe.total_eng_net) }} + + --- + {{ "{:,.3f}".format(groupe.total_eng_brut) }} + + + {{ "{:,.3f}".format(groupe.total_eng_net) }} + +
+ Aucun ordre trouvé pour cette société. +
+
+
- - - - - - - {% endfor %} {% else %} - - - Aucun ordre trouvé pour cette société. - - - {% endif %} - - + +
+ Positions en bourse : Accès sécurisé. Veillez à fermer votre session après + chaque utilisation.
@@ -285,6 +307,7 @@ action="{{ url_for('main.creer_ordre_traitement') }}" class="space-y-4" > + {% for s in societes %}
- {{ s.Nom }} + {{ s.nom }} - {{ s.Forme or 'N/A' }} + {{ s.forme or 'N/A' }}
{% endfor %} @@ -82,7 +82,8 @@ content %} action="" class="space-y-4 flex-1 flex flex-col justify-between" > - + +
@@ -101,7 +102,7 @@ content %} @@ -174,7 +175,7 @@ content %} - + +
diff --git a/app/templates/index.html b/app/templates/index.html index 711ff33..dc6abeb 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -18,6 +18,7 @@ block content %} {% endif %} {% endwith %}
+
3. Comptes diff --git a/create_db.py b/create_db.py deleted file mode 100644 index 3b16a24..0000000 --- a/create_db.py +++ /dev/null @@ -1,112 +0,0 @@ -import time -import requests -import mysql.connector - -def importer_actions(): - API_KEY = "6a6235c8a742b2.89238466" - MYSQL = { - "host": "db", - "port": 3306, - "user": "root", - "password": "sysadm-1963", - "database": "bourse" - } - - EXCHANGES = [ - "PA", "US", "BR", "KLSE", "LS", "XETRA", "LU", "OL", "ST", "CO", "IR", "KO", "AU", "SHE", "SHG", "AT", - "F", "STU", "MU", "HA", "DU", "HM", "VI", "SW", "TO", "AU", "HK", "TWO", "MC" - ] - - print("Connexion à la base de données MySQL...") - try: - db_conn = mysql.connector.connect(**MYSQL) - cursor = db_conn.cursor() - print("Connecté avec succès à la base MySQL : [bourse]") - except Exception as e: - print(f"Erreur de connexion MySQL : {e}") - return - - print("Vidage de la table les_actions...") - try: - cursor.execute("TRUNCATE TABLE les_actions;") - db_conn.commit() - except Exception as e: - print(f"Erreur lors du TRUNCATE : {e}") - - sql = """ - INSERT INTO les_actions (isin, ticker, name, exchange, pays, devise) - VALUES (%s, %s, %s, %s, %s, %s) - """ - total_global = 0 - - try: - for exchange in EXCHANGES: - url = f"https://eodhd.com/api/exchange-symbol-list/{exchange}?api_token={API_KEY}&fmt=json" - print(f"\n--- Traitement de la bourse : {exchange} ---") - - req_timeout = 300 if exchange == "US" else 60 - - try: - r = requests.get(url, timeout=req_timeout) - if r.status_code != 200: - print(f"[{exchange}] Erreur HTTP {r.status_code}") - continue - data = r.json() - except Exception as err: - print(f"[{exchange}] Erreur requête ou timeout : {err}") - continue - - if not isinstance(data, list): - print(f"[{exchange}] Le format reçu n'est pas une liste.") - continue - - print(f"[{exchange}] {len(data)} éléments reçus. Insertion en cours...") - - lignes = [] - for item in data: - isin = item.get("ISIN") or item.get("isin") - ticker = item.get("Code") or item.get("code") - name = item.get("Name") or item.get("name") - exch = item.get("Exchange") or item.get("exchange") or exchange - pays = item.get("Country") or item.get("country") - devise = item.get("Currency") or item.get("currency") - - if not ticker: - continue - - lignes.append(( - str(isin)[:12] if isin else None, - str(ticker)[:20] if ticker else None, - str(name)[:150] if name else None, - str(exch)[:50] if exch else None, - str(pays)[:50] if pays else None, - str(devise)[:10] if devise else None - )) - - if lignes: - # Utilisation d'executemany par paquets pour un maximum de performance - batch_size = 5000 - for i in range(0, len(lignes), batch_size): - batch = lignes[i:i + batch_size] - cursor.executemany(sql, batch) - db_conn.commit() - - total_global += len(lignes) - print(f"-> Succès : {len(lignes)} actions insérées pour {exchange}.") - - time.sleep(1) - - # Vérification finale du nombre de lignes dans la table - cursor.execute("SELECT COUNT(*) FROM les_actions;") - count = cursor.fetchone()[0] - print(f"\nTerminé ! Total global traité : {total_global} lignes. Comptage réel en base : {count} lignes.") - - except Exception as e: - print(f"Erreur générale : {e}") - finally: - cursor.close() - db_conn.close() - print("Connexion fermée.") - -if __name__ == "__main__": - importer_actions() \ No newline at end of file diff --git a/create_isin.py b/create_isin.py deleted file mode 100644 index 8657696..0000000 --- a/create_isin.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 - -import csv -import mysql.connector - -# ========================================== -# CONFIGURATION -# ========================================== - -CSV_FILE = "/app/isin.csv" - -# Pays associé au fichier CSV -PAYS = "France" - -MYSQL = { - "host": "db", - "port": 3306, - "user": "root", - "password": "sysadm-1963", - "database": "bourse" -} - -# ========================================== -# CONNEXION MYSQL -# ========================================== - -try: - db = mysql.connector.connect(**MYSQL) - print("Connexion MySQL OK") -except Exception as e: - print("Erreur MySQL :", e) - exit(1) - -cursor = db.cursor(dictionary=True) - -# ========================================== -# CHARGEMENT DU CSV -# ========================================== - -print("Chargement du CSV...") - -csv_data = {} - -with open(CSV_FILE, newline="", encoding="utf-8-sig") as f: - - reader = csv.reader(f, delimiter=";") - - for row in reader: - - if len(row) < 3: - continue - - isin = row[0].strip().upper() - ticker = row[2].strip().upper() - - if ticker: - csv_data[(ticker, PAYS.upper())] = isin - -print(f"{len(csv_data)} ISIN chargés.") - -# ========================================== -# LECTURE DE LA TABLE -# ========================================== - -cursor.execute(""" -SELECT - id, - ticker, - pays, - isin -FROM les_actions -""") - -lignes = cursor.fetchall() - -print(f"{len(lignes)} actions lues.") - -# ========================================== -# MISE A JOUR -# ========================================== - -sql_update = """ -UPDATE les_actions -SET isin = %s -WHERE id = %s -""" - -update_data = [] - -for ligne in lignes: - - ticker = (ligne["ticker"] or "").strip().upper() - pays = (ligne["pays"] or "").strip().upper() - - cle = (ticker, pays) - - if cle in csv_data: - - update_data.append(( - csv_data[cle], - ligne["id"] - )) - -print(f"{len(update_data)} lignes à mettre à jour.") - -if update_data: - cursor.executemany(sql_update, update_data) - db.commit() - -print("===================================") -print(f"{len(update_data)} ISIN mis à jour.") -print("===================================") - -cursor.close() -db.close() \ No newline at end of file diff --git a/create_user.py b/create_user.py deleted file mode 100644 index bbe0360..0000000 --- a/create_user.py +++ /dev/null @@ -1,31 +0,0 @@ -from werkzeug.security import generate_password_hash -from sqlalchemy import text -from app import app, db - -with app.app_context(): - # Définition des informations de l'utilisateur - login_name = "jfgiraud" - nom = "GIRAUD" - prenom = "Jean-François" - mail = "jfgiraud@giraud-finance.com" - # Génération du hash sécurisé pour le mot de passe "lyon4" - hashed_password = generate_password_hash("lyon4") - - # Requête d'insertion - query = text(""" - INSERT INTO login (login, nom, prenom, mail, pass, actif) - VALUES (:login, :nom, :prenom, :mail, :pass, 1) - ON DUPLICATE KEY UPDATE - pass = :pass, nom = :nom, prenom = :prenom, mail = :mail, actif = 1 - """) - - db.session.execute(query, { - "login": login_name, - "nom": nom, - "prenom": prenom, - "mail": mail, - "pass": hashed_password - }) - db.session.commit() - - print(f"Utilisateur '{login_name}' créé/mis à jour avec succès avec un mot de passe haché !") \ No newline at end of file diff --git a/create_user.txt b/create_user.txt deleted file mode 100644 index 01e0502..0000000 --- a/create_user.txt +++ /dev/null @@ -1,5 +0,0 @@ -Pour exécuter ce script directement à l'intérieur du conteneur web utiliser la commande suivante dans votre terminal : - -docker compose exec web python create_user.py - -Une fois cette commande passée, vous pourrez vous connecter sur votre page d'accueil avec le login jfgiraud et le mot de passe lyon4. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e0e7d51..df8b33f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,10 @@ services: db: image: mysql:8.0 - environment: - MYSQL_ROOT_PASSWORD: sysadm-1963 - MYSQL_DATABASE: bolsa - MYSQL_USER: jfgiraud - MYSQL_PASSWORD: lyon4 + env_file: .env volumes: - db_data:/var/lib/mysql + - ./migrations/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro healthcheck: test: [ @@ -18,34 +15,39 @@ services: "127.0.0.1", "-u", "root", - "-psysadm-1963", + "-p${MYSQL_ROOT_PASSWORD}", ] interval: 3s timeout: 3s retries: 10 + restart: unless-stopped web: build: . - command: python run.py + env_file: .env + environment: + - FLASK_ENV=${FLASK_ENV} volumes: - - .:/app + # Montage restreint au code applicatif (pas .git / mysql_data / .env) + - ./app:/app/app ports: - "5000:5000" - environment: - - DATABASE_URL=mysql+pymysql://jfgiraud:lyon4@db/bolsa depends_on: db: condition: service_healthy + restart: unless-stopped phpmyadmin: image: phpmyadmin/phpmyadmin - ports: - - "8080:80" + env_file: .env environment: PMA_HOST: db - MYSQL_ROOT_PASSWORD: sysadm-1963 + ports: + # Accès local uniquement (pas d'exposition publique) + - "127.0.0.1:8080:80" depends_on: - db + restart: unless-stopped volumes: db_data: diff --git a/migrations/apply_migrations.py b/migrations/apply_migrations.py new file mode 100644 index 0000000..1eb5708 --- /dev/null +++ b/migrations/apply_migrations.py @@ -0,0 +1,89 @@ +""" +Application séquentielle des migrations SQL du dossier `migrations/` +sur la base « bolsa ». À exécuter depuis la racine du projet : + + python migrations/apply_migrations.py + +Lit la configuration MySQL depuis les variables d'environnement (.env). +N'applique que les fichiers non déjà enregistrés dans la table +`__migrations__` (créée automatiquement). +""" +import os +import sys +from pathlib import Path + +import pymysql +from dotenv import load_dotenv + +load_dotenv() + +MIGRATIONS_DIR = Path(__file__).parent + + +def get_connection(): + return pymysql.connect( + host=os.environ.get("MYSQL_HOST", "db"), + port=int(os.environ.get("MYSQL_PORT", "3306")), + user=os.environ.get("MYSQL_USER", "jfgiraud"), + password=os.environ.get("MYSQL_PASSWORD", ""), + database=os.environ.get("MYSQL_DATABASE", "bolsa"), + charset="utf8mb4", + autocommit=False, + ) + + +def main(): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "CREATE TABLE IF NOT EXISTS `__migrations__` (" + " `id` int NOT NULL AUTO_INCREMENT," + " `filename` varchar(255) NOT NULL," + " `applied_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP," + " PRIMARY KEY (`id`)," + " UNIQUE KEY `idx_filename` (`filename`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ) + conn.commit() + + cur.execute("SELECT filename FROM `__migrations__`") + applied = {row[0] for row in cur.fetchall()} + + sql_files = sorted( + f for f in MIGRATIONS_DIR.glob("*.sql") + if f.name != "schema.sql" + ) + + if not sql_files: + print("Aucune migration à appliquer.") + return + + for sql_file in sql_files: + if sql_file.name in applied: + print(f"[déjà appliquée] {sql_file.name}") + continue + + print(f"[application] {sql_file.name}") + sql = sql_file.read_text(encoding="utf-8") + # Découpage sur les ';' pour exécuter instruction par instruction + statements = [s.strip() for s in sql.split(";") if s.strip()] + for stmt in statements: + cur.execute(stmt) + cur.execute( + "INSERT INTO `__migrations__` (filename) VALUES (%s)", + (sql_file.name,), + ) + conn.commit() + print(f"[OK] {sql_file.name}") + + except Exception as e: + conn.rollback() + print(f"[ERREUR] {e}", file=sys.stderr) + sys.exit(1) + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/migrations/schema.sql b/migrations/schema.sql new file mode 100644 index 0000000..37e5ecf --- /dev/null +++ b/migrations/schema.sql @@ -0,0 +1,110 @@ +-- ===================================================================== +# Schéma de la base « bolsa » — version de référence (snake_case) +-- ===================================================================== +# Tables : login, actions, societe, compte, ordre, ordre_pied +# Encodage : utf8mb4 / utf8mb4_0900_ai_ci +# Ce fichier est exécuté automatiquement par Docker sur une base vierge +# (monté dans /docker-entrypoint-initdb.d/). Sur une base existante, les +# CREATE TABLE IF NOT EXISTS sont sans effet. +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS `login` ( + `id` int NOT NULL AUTO_INCREMENT, + `login` varchar(50) NOT NULL, + `nom` varchar(100) NOT NULL, + `prenom` varchar(100) NOT NULL, + `mail` varchar(255) NOT NULL, + `pass` varchar(255) NOT NULL, + `actif` tinyint(1) DEFAULT NULL, + `creer_le` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `modifie_le` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `reset_token` varchar(255) DEFAULT NULL, + `reset_token_created_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_login` (`login`), + UNIQUE KEY `idx_mail` (`mail`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `actions` ( + `id` int NOT NULL AUTO_INCREMENT, + `isin` varchar(50) NOT NULL, + `ticker` varchar(50) DEFAULT NULL, + `company_name` varchar(255) DEFAULT NULL, + `exchange` varchar(50) NOT NULL, + `pays` varchar(50) NOT NULL, + `currency` varchar(10) NOT NULL, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `isin` (`isin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `societe` ( + `id` int NOT NULL AUTO_INCREMENT, + `nom` varchar(60) NOT NULL, + `forme` varchar(10) NOT NULL, + `capital` int NOT NULL, + `siren` varchar(20) NOT NULL, + `siret` varchar(20) NOT NULL, + `rcs` varchar(50) NOT NULL, + `tva` varchar(50) NOT NULL, + `naf` varchar(10) NOT NULL, + `tel` varchar(30) NOT NULL, + `web` varchar(100) NOT NULL, + `mail` varchar(100) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `compte` ( + `id` int NOT NULL AUTO_INCREMENT, + `id_societe` int NOT NULL, + `date_operation` date NOT NULL, + `debit` decimal(15,2) DEFAULT '0.00', + `credit` decimal(15,2) DEFAULT '0.00', + `libelle` text, + `source` enum('manuel','ordre') NOT NULL DEFAULT 'manuel', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_compte_societe` (`id_societe`), + CONSTRAINT `fk_compte_societe` FOREIGN KEY (`id_societe`) + REFERENCES `societe` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `ordre` ( + `id` int NOT NULL AUTO_INCREMENT, + `id_societe` int NOT NULL, + `isin` varchar(50) NOT NULL, + `plus_value` decimal(19,4) NOT NULL DEFAULT '0.0000', + `date_plus_value` date DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `fk_ordre_societe` (`id_societe`), + KEY `fk_ordre_actions` (`isin`), + CONSTRAINT `fk_ordre_societe` FOREIGN KEY (`id_societe`) + REFERENCES `societe` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `fk_ordre_actions` FOREIGN KEY (`isin`) + REFERENCES `actions` (`isin`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `ordre_pied` ( + `id` int NOT NULL AUTO_INCREMENT, + `id_entete` int NOT NULL, + `date_op` date NOT NULL, + `quantite` int NOT NULL, + `prix_brut` decimal(19,4) DEFAULT NULL, + `frais_broker` decimal(19,4) DEFAULT NULL, + `frais_etat` decimal(19,4) DEFAULT NULL, + `frais_autre` decimal(19,4) DEFAULT NULL, + `sens` enum('achat','vente') NOT NULL, + `position` enum('long','short') NOT NULL, + `etat` enum('actif','soldé','annulé') NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ordre_pied_entete` (`id_entete`), + CONSTRAINT `fk_ordre_pied_entete` FOREIGN KEY (`id_entete`) + REFERENCES `ordre` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/requirements.txt b/requirements.txt index 75c801d..493740d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,22 @@ blinker==1.9.0 certifi==2026.7.22 charset-normalizer==3.4.9 +cryptography click==8.4.2 Flask==3.1.3 Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.1 greenlet==3.5.4 idna==3.18 itsdangerous==2.2.0 Jinja2==3.1.6 MarkupSafe==3.0.3 mysql-connector-python==9.7.0 +python-dotenv==1.1.1 +PyMySQL==1.1.1 requests==2.34.2 SQLAlchemy==2.0.51 typing_extensions==4.16.0 urllib3==2.7.0 Werkzeug==3.1.8 +WTForms==3.1.2 diff --git a/run.py b/run.py index 7592ef5..1f07e48 100644 --- a/run.py +++ b/run.py @@ -1,4 +1,6 @@ +import os from app import app -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file +if __name__ == "__main__": + debug = os.environ.get("FLASK_ENV") == "development" + app.run(host="0.0.0.0", port=5000, debug=debug)