From a25043dc3f1e84ee528dd79de2ab57e797265e84 Mon Sep 17 00:00:00 2001
From: jfgiraud
Date: Sat, 8 Aug 2026 19:56:09 +0200
Subject: [PATCH 1/2] ajout arborescence.txt
---
.dockerignore | 4 +-
app/routes.py | 3 +
arborescence.txt | 381 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 386 insertions(+), 2 deletions(-)
create mode 100644 arborescence.txt
diff --git a/.dockerignore b/.dockerignore
index 243f487..416617c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -23,6 +23,6 @@ ENV/
.DS_Store
# Docker
-Dockerfile
-docker-compose.yml
+#Dockerfile
+#docker-compose.yml
.dockerignore
diff --git a/app/routes.py b/app/routes.py
index ddf0420..70dd869 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -1,3 +1,6 @@
+print("🔥 ROUTES.PY CHARGE DEPUIS LE VOLUME DOCKER 🔥")
+
+
from functools import wraps
from flask import (
diff --git a/arborescence.txt b/arborescence.txt
new file mode 100644
index 0000000..52cdb4a
--- /dev/null
+++ b/arborescence.txt
@@ -0,0 +1,381 @@
+.
+├── AGENTS.md
+├── app
+│  ├── __init__.py
+│  ├── models.py
+│  ├── __pycache__
+│  │  ├── __init__.cpython-311.pyc
+│  │  └── routes.cpython-311.pyc
+│  ├── routes.py
+│  ├── services
+│  │  ├── analysis.py
+│  │  ├── database.py
+│  │  ├── indicators.py
+│  │  ├── __init__.py
+│  │  └── __pycache__
+│  │  ├── analysis.cpython-311.pyc
+│  │  ├── database.cpython-311.pyc
+│  │  ├── indicators.cpython-311.pyc
+│  │  └── __init__.cpython-311.pyc
+│  ├── static
+│  │  ├── css
+│  │  ├── images
+│  │  │  ├── favicon.png
+│  │  │  └── GFBlancLogo.png
+│  │  └── js
+│  │  └── charts.js
+│  └── templates
+│  ├── analyse.html
+│  ├── base.html
+│  ├── gestion_actions.html
+│  ├── gestion_comptes.html
+│  ├── gestion_import_export_actions_csv.html
+│  ├── gestion_import_historique_csv.html
+│  ├── gestion_ordres.html
+│  ├── gestion_societes.html
+│  ├── gestion_utilisateurs.html
+│  ├── index.html
+│  └── menu.html
+├── arborescence.txt
+├── .devcontainer
+│  └── devcontainer.json
+├── docker-compose.yml
+├── Dockerfile
+├── .dockerignore
+├── .env
+├── .git
+│  ├── COMMIT_EDITMSG
+│  ├── config
+│  ├── description
+│  ├── HEAD
+│  ├── hooks
+│  │  ├── applypatch-msg.sample
+│  │  ├── commit-msg.sample
+│  │  ├── fsmonitor-watchman.sample
+│  │  ├── post-update.sample
+│  │  ├── pre-applypatch.sample
+│  │  ├── pre-commit.sample
+│  │  ├── pre-merge-commit.sample
+│  │  ├── prepare-commit-msg.sample
+│  │  ├── pre-push.sample
+│  │  ├── pre-rebase.sample
+│  │  ├── pre-receive.sample
+│  │  ├── push-to-checkout.sample
+│  │  ├── sendemail-validate.sample
+│  │  └── update.sample
+│  ├── index
+│  ├── info
+│  │  └── exclude
+│  ├── logs
+│  │  ├── HEAD
+│  │  └── refs
+│  │  ├── heads
+│  │  │  └── main
+│  │  └── remotes
+│  │  └── origin
+│  │  ├── HEAD
+│  │  └── main
+│  ├── objects
+│  │  ├── 00
+│  │  │  └── bd6ff6fdd29e050127343d217b47a267df712c
+│  │  ├── 01
+│  │  │  └── e0502fd1dd04a4689ab4093c65b049888d2848
+│  │  ├── 02
+│  │  │  └── 07cb7e5bc7d04931b6485cf180d1884e9689f9
+│  │  ├── 04
+│  │  │  ├── 08ddf18487e77856a3410c54a512343f0add37
+│  │  │  └── e6dccc12264d2ac6554b7120b5f3b73833f2a9
+│  │  ├── 07
+│  │  │  └── 9031ef84904e911299a303bfb5096834df5d70
+│  │  ├── 0a
+│  │  │  └── dc0e5948aba4d913acd5c47ea3084ef42072c8
+│  │  ├── 0c
+│  │  │  └── 134ee9dce2f8ecafb266fa9fcca840416135dd
+│  │  ├── 0d
+│  │  │  └── a3b25bfcd0156e1868a116b69e2ba57b14d001
+│  │  ├── 10
+│  │  │  └── 9ca2310b66a98ec003c85f0f1113cbde00b24d
+│  │  ├── 11
+│  │  │  └── 2ab5b90d0d231b89e053775c3a4abe3f7fb0ad
+│  │  ├── 12
+│  │  │  └── f8d7e25f2d157a304a8167bd16c888b37fa2fa
+│  │  ├── 15
+│  │  │  └── f531461d36bd5067dcf9b1d7df8d284682c700
+│  │  ├── 17
+│  │  │  └── 902472fb99110700c56b93b6edd5eed36e865f
+│  │  ├── 1b
+│  │  │  └── 21a3e17e1ecc4f961b8575ce0cd8ae0ac8e79f
+│  │  ├── 1e
+│  │  │  └── b57085067042a37dbb9960d814ca9bf4b89747
+│  │  ├── 1f
+│  │  │  ├── 07e48b3a620bf69ec029e531e3ebbcf9718a3e
+│  │  │  ├── 2089047f9abbd69b117158a36a2d8a529ca90b
+│  │  │  └── 47a7c5e161f973eefdc3f8074fb31e4c7ec92b
+│  │  ├── 20
+│  │  │  └── 57d693cd0b411a9c468ddec6f3732ec7117e03
+│  │  ├── 24
+│  │  │  └── 3f4879653fdb1f4b1b5ce5d06a1a7ebe4aea57
+│  │  ├── 26
+│  │  │  ├── b6cd9e153bd5a18968289008c4868e643700d7
+│  │  │  └── f28323844f6bfd9eec20261f44e3e57b6974b5
+│  │  ├── 28
+│  │  │  └── 7192f782efafb53ae63f2c879fb8c5e0cdf69e
+│  │  ├── 2a
+│  │  │  └── 1425272ff8d7a216715148ff59c7bb53ab3686
+│  │  ├── 34
+│  │  │  └── eba76fb863d7f19e15e72456dc97e8392d7fdd
+│  │  ├── 36
+│  │  │  └── 2ccfc03743c02d80b01ed9dfcd92caa35b14ca
+│  │  ├── 37
+│  │  │  └── e5ecfa8dbe2a7f89f1b2738ed01227cd8880e3
+│  │  ├── 3b
+│  │  │  ├── 0b0bad6147de77557e8edfbaca6194b2530c1f
+│  │  │  ├── 16a2452244bf4c7f34e7931a5d92523c85ffd6
+│  │  │  └── 517e20c82a2cbf1915dc8e31cf1b6fb86c7ebb
+│  │  ├── 3d
+│  │  │  └── e8bf9d59f8409f1dc3f72e95ee0156e5bedab8
+│  │  ├── 3f
+│  │  │  └── 1b0becfcf2ce89ce53dee705f727da43b57065
+│  │  ├── 40
+│  │  │  ├── cd5eed2a199166541eedd182945a7568546f03
+│  │  │  └── ed00b9c9a18bf78ea2806b05e4eb9f638018fe
+│  │  ├── 41
+│  │  │  └── 39ac631013376273c279668af6fce888eb61fb
+│  │  ├── 44
+│  │  │  └── 5b141a30570cf3c4c430f38aca3998e40c5365
+│  │  ├── 46
+│  │  │  └── 885453b18d8ff255ed537b1f06abc5de83f803
+│  │  ├── 49
+│  │  │  ├── 3740d03f7d957864dd6cd0c187a4bea57c0bab
+│  │  │  └── e440e17c5be6abc96049f11a244e2ea580ee52
+│  │  ├── 4c
+│  │  │  └── 78c73f7e5be001c434da09bd9ddfe5153fc25c
+│  │  ├── 4d
+│  │  │  ├── 3937c88f7b778219e034addb342d3b90b3ada2
+│  │  │  ├── 71371f2136c3ef10bf2e1376ebaa3f2958bca6
+│  │  │  └── e00820c85d27e1917915962b70d753364ea7fe
+│  │  ├── 50
+│  │  │  └── 5b9801c3db189ea858001626ae4741b3918bf6
+│  │  ├── 52
+│  │  │  ├── 1ae0f442e731579df467694823a050b3665f12
+│  │  │  └── 3957cd018743a763ec75e678d75b1c70b8ba82
+│  │  ├── 54
+│  │  │  └── 814d5f0580b785d5b8fe123ca89af21f3e11ac
+│  │  ├── 55
+│  │  │  ├── 636030fef0de78019f78e8d5ef947f67e12eb2
+│  │  │  └── f58e0a29126613c2d9517e1bebda9cc47b7802
+│  │  ├── 56
+│  │  │  └── 4f4846027c485e92cb280f84a470e40bd62447
+│  │  ├── 57
+│  │  │  └── 2e8acf0e6194567bd8d59b3ac342195f6d3966
+│  │  ├── 59
+│  │  │  └── af163824c44be62dfbb06df9e71769563a33e1
+│  │  ├── 5a
+│  │  │  └── 26a100fa4c7811822dd8dd3c895f60c364de15
+│  │  ├── 5b
+│  │  │  └── 6ac4bd24d2dfcc5dd2e94cd16b123e85bec09d
+│  │  ├── 60
+│  │  │  ├── 615fd6903be90071138712ae7175ae321d0645
+│  │  │  └── 7fe553d40f715855d6fc92058e190eb08f2669
+│  │  ├── 63
+│  │  │  └── 3368c1d7cd2077e27d9d3ceb03a4032a2cc9c9
+│  │  ├── 65
+│  │  │  └── 92ffbcc5cb696c191ef2b69194de0b810b19f8
+│  │  ├── 66
+│  │  │  └── e2180d741309c85885cff73321ae9b198fff92
+│  │  ├── 67
+│  │  │  └── a0147412bd348f67a3b168a1c7496b0b7082f3
+│  │  ├── 6b
+│  │  │  └── 0cea8b3fe318c88578bff8627532672199f8ba
+│  │  ├── 6d
+│  │  │  └── 018a514d3d9a98f2d3f4d5822be8a83b6deb8c
+│  │  ├── 6e
+│  │  │  └── 5d15c70803ba6c93947cd46f891524f9534ae9
+│  │  ├── 70
+│  │  │  └── 6bdce8292187fd421e751d94ee444632e7b945
+│  │  ├── 71
+│  │  │  └── 1ff3311b54b32fff3606cf4c635359d60f4c60
+│  │  ├── 72
+│  │  │  ├── 352c8c35a4cde1575d9b205785ba6474b8b637
+│  │  │  └── 47f112f4f746c8ae33031011a3b0af449fc5d4
+│  │  ├── 74
+│  │  │  └── 536771507e4561928a31c31e3cd1c9e5f89428
+│  │  ├── 75
+│  │  │  ├── 92ef5ca86e1351534de19233c580f0c259d4f0
+│  │  │  └── c801d89931e2c327fed73a95da59e6b830e089
+│  │  ├── 7e
+│  │  │  ├── 39538816f4b9dacf025dff4100efa246a44c0b
+│  │  │  └── 694b15fcebcd3ac03ddbc248154aac4d698206
+│  │  ├── 82
+│  │  │  └── bfeb65501ede38ef71f68d75161df39992917e
+│  │  ├── 83
+│  │  │  ├── 218e12f47b9ea37d97a9e71a138e0343a4ef41
+│  │  │  └── 7368aef4264810159f3951edbed316977aaa44
+│  │  ├── 85
+│  │  │  ├── 38f78cce5d41ea9e620668a243e160dda226e0
+│  │  │  ├── eb889e34fb76fde82c995c0e0d3496aeb21a09
+│  │  │  └── f8daca661ff70b9692ac0d02cee98d3215c7ec
+│  │  ├── 86
+│  │  │  └── 57696025f6bc6d93d2141302f7f746a0ded69b
+│  │  ├── 87
+│  │  │  └── b390931d5dd16697c7e346a91309843f1da5a0
+│  │  ├── 8b
+│  │  │  └── 084e5b50e0ce6a5a1a6349540fe8ef9daffcae
+│  │  ├── 8c
+│  │  │  └── 69706e4f8b5599372f65127837a1645debdabb
+│  │  ├── 8d
+│  │  │  └── 0bddd1b2f664f31617688340dbe10e67007e68
+│  │  ├── 90
+│  │  │  └── ebd9162d702e2ab49c4d5db4ab282997e65640
+│  │  ├── 94
+│  │  │  └── 8c55763940965d16649effe28b26f4ba1cbbbb
+│  │  ├── 97
+│  │  │  ├── 6a00e0fa6d49a08d7711fed68e6e01847c2865
+│  │  │  └── 7599c17a31a9a5e12a5a34bcbcc2ced5c2d641
+│  │  ├── 99
+│  │  │  └── 426023929b3e0ec04abf4a8b3f29ced2799a87
+│  │  ├── 9c
+│  │  │  └── 32e129e3dd47b2e5e09c61ea5f1b4bef7c2c33
+│  │  ├── a1
+│  │  │  └── 0d068b5d17148c5a6f9452e9ad0b39794fc9b2
+│  │  ├── a8
+│  │  │  ├── 37fe05c7e3115b53f8955f83d0c4a39d11ef10
+│  │  │  └── c06d8055c22bc855d46eee3f611dc95902e454
+│  │  ├── a9
+│  │  │  └── 0bec8d42e49980ab4f00d64decd060ebe70057
+│  │  ├── b0
+│  │  │  └── 6517db23c58555c268884c502e5020a94b4fa4
+│  │  ├── b1
+│  │  │  ├── 20c2bd728d41675a7be4d4497b1340c7472910
+│  │  │  └── 6b6eef9fc8833a6dc404fa3f6ec2228b396e37
+│  │  ├── b2
+│  │  │  └── c241797b5f9473c35ffeff236d8c2097c41002
+│  │  ├── b6
+│  │  │  └── 7ecfc5d115379a92ffa17747bb7c294b42a6cb
+│  │  ├── b8
+│  │  │  └── e0eba8c9f85c6ecf2fe3d353824f542fb57c15
+│  │  ├── b9
+│  │  │  └── 74611196cf8def7d9dcaa4ac270f2a0c863cc1
+│  │  ├── ba
+│  │  │  └── 4279c3c3b5125d11c5c025798fcd2a33d2fab4
+│  │  ├── bb
+│  │  │  └── e0360e7535ce1604e3f66451e0ab6f32dbac12
+│  │  ├── be
+│  │  │  ├── d31a9eeaf84dee7039b8809110286c469eee69
+│  │  │  └── ebbdc835054832a5b92348b8752f2622f88636
+│  │  ├── c1
+│  │  │  └── 54b83412cb4198a6f2e29deb09606ccc2d2a23
+│  │  ├── c2
+│  │  │  └── 2368611269a9fb12cfc034289cf671469704a1
+│  │  ├── c3
+│  │  │  └── 2a4fffdd5d82253a687a8c7c39d71f3cd889f7
+│  │  ├── c4
+│  │  │  └── 1f306b9abb67f5ccb24b3b35ab18b1524399ad
+│  │  ├── c8
+│  │  │  └── d18a6cef831ca56e18f7d4ff5d7c7ec58b3010
+│  │  ├── cb
+│  │  │  └── 33281ba30cd46880876234e8d2ea7065ecf3de
+│  │  ├── cd
+│  │  │  └── 902d78081683c69945b34ce67a14add6426b28
+│  │  ├── ce
+│  │  │  └── dab25514e00d824c2f9d345f64e756e473173a
+│  │  ├── cf
+│  │  │  └── ad237cf6df989cb1d54aad4bc5eaa55b8b5c98
+│  │  ├── d0
+│  │  │  └── c784fa00768246e4c95c168a38206f055c166f
+│  │  ├── d6
+│  │  │  └── b8bc493ff42da63649b8be6ec989a12319a567
+│  │  ├── d7
+│  │  │  └── d031b8e073f1e07f73ad468b8031e1950c6eff
+│  │  ├── d8
+│  │  │  └── 65d3501ada082e077cdaa32439dc22a4966561
+│  │  ├── d9
+│  │  │  └── d851c33add105e23ba383feb3cd131bfc6a8fc
+│  │  ├── dc
+│  │  │  ├── 14d536b88178d3d74870613cfc938fd90c793a
+│  │  │  └── 6abeb9e2c702591fe216dd10b8b04941e117f7
+│  │  ├── dd
+│  │  │  ├── 736b72374acab09a8e84bd1bda04327c63c804
+│  │  │  └── f0420841e632270a504ac90e7e8ae4837d56e8
+│  │  ├── df
+│  │  │  ├── 76b34a23247f8020fc8b7aefbde2074c812837
+│  │  │  └── 8b33f6a40ca1c710ebc114d16b9a548db16a37
+│  │  ├── e0
+│  │  │  └── e7d51df0cdcc54399d200e1c1604d0fa813378
+│  │  ├── e3
+│  │  │  ├── 1e4572cabd86823c7be31f034c223b1e57b3d7
+│  │  │  └── 8c247ebbc0189b3af17e3294e1429f22e5f61b
+│  │  ├── e6
+│  │  │  └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
+│  │  ├── ef
+│  │  │  └── 58a8a234dd56e699430061b86b005f73939464
+│  │  ├── f2
+│  │  │  └── fd513263d428a680a98a494a7492f8da0de43a
+│  │  ├── f3
+│  │  │  └── 7f5b1e4509262eb65122f91220f04f080182ed
+│  │  ├── f7
+│  │  │  └── 2cd45703375c00e975a09cee50da3619d3277e
+│  │  ├── f8
+│  │  │  └── 91e8a154f2d4357cbb7e4af8c1805dfa310baf
+│  │  ├── fa
+│  │  │  ├── e895d8ca270a7538d299e63ab2dd4663523f95
+│  │  │  └── f1bce15949cbe30f2cf840aa5e9e9e975829ab
+│  │  ├── fb
+│  │  │  └── 4fd6e9a038e3fd4660396a0afd78f0f272aa48
+│  │  ├── fc
+│  │  │  └── 1ca97cb1f9f2d092206a57833214b2fa28972a
+│  │  ├── ff
+│  │  │  └── 14f22d999bb7f217cc67c7a2cb02e5a3f1cf4b
+│  │  ├── info
+│  │  └── pack
+│  ├── opencode
+│  └── refs
+│  ├── heads
+│  │  └── main
+│  ├── remotes
+│  │  └── origin
+│  │  ├── HEAD
+│  │  └── main
+│  └── tags
+├── .gitignore
+├── migrations
+│  ├── apply_migrations.py
+│  └── schema.sql
+├── mysql_data
+│  ├── auto.cnf
+│  ├── binlog.000001
+│  ├── binlog.000002
+│  ├── binlog.000003
+│  ├── binlog.000004
+│  ├── binlog.000005
+│  ├── binlog.index
+│  ├── bourse_db [error opening dir]
+│  ├── ca-key.pem
+│  ├── ca.pem
+│  ├── client-cert.pem
+│  ├── client-key.pem
+│  ├── #ib_16384_0.dblwr
+│  ├── #ib_16384_1.dblwr
+│  ├── ib_buffer_pool
+│  ├── ibdata1
+│  ├── #innodb_redo [error opening dir]
+│  ├── #innodb_temp [error opening dir]
+│  ├── mysql [error opening dir]
+│  ├── mysql.ibd
+│  ├── mysql.sock -> /var/run/mysqld/mysqld.sock
+│  ├── performance_schema [error opening dir]
+│  ├── private_key.pem
+│  ├── public_key.pem
+│  ├── server-cert.pem
+│  ├── server-key.pem
+│  ├── sys [error opening dir]
+│  ├── undo_001
+│  └── undo_002
+├── requirements.txt
+├── run.py
+└── .vscode
+ └── settings.json
+
+147 directories, 230 files
+
+tree -a > arborescence.txt
From c2f97276b6e393383c39533e422d55deb77209fa Mon Sep 17 00:00:00 2001
From: jfgiraud
Date: Sun, 9 Aug 2026 09:50:49 +0200
Subject: [PATCH 2/2] Refactor routes Flask en Blueprints
---
app/__init__.py | 25 +-
app/decorators.py | 13 +
app/routes.py | 1654 -----------------
app/routes/__init__.py | 21 +
app/routes/actions.py | 98 +
app/routes/analyse.py | 73 +
app/routes/auth.py | 57 +
app/routes/comptes.py | 365 ++++
app/routes/historique.py | 381 ++++
app/routes/menu.py | 11 +
app/routes/ordres.py | 476 +++++
app/routes/societes.py | 118 ++
app/routes/utilisateurs.py | 107 ++
app/templates/analyse.html | 4 +-
app/templates/base.html | 4 +-
app/templates/gestion_actions.html | 2 +-
app/templates/gestion_comptes.html | 10 +-
.../gestion_import_export_actions_csv.html | 6 +-
.../gestion_import_historique_csv.html | 4 +-
app/templates/gestion_ordres.html | 10 +-
app/templates/gestion_societes.html | 2 +-
app/templates/gestion_utilisateurs.html | 2 +-
app/templates/index.html | 2 +-
app/templates/menu.html | 16 +-
24 files changed, 1773 insertions(+), 1688 deletions(-)
create mode 100644 app/decorators.py
delete mode 100644 app/routes.py
create mode 100644 app/routes/__init__.py
create mode 100644 app/routes/actions.py
create mode 100644 app/routes/analyse.py
create mode 100644 app/routes/auth.py
create mode 100644 app/routes/comptes.py
create mode 100644 app/routes/historique.py
create mode 100644 app/routes/menu.py
create mode 100644 app/routes/ordres.py
create mode 100644 app/routes/societes.py
create mode 100644 app/routes/utilisateurs.py
diff --git a/app/__init__.py b/app/__init__.py
index cfad237..4f82bd2 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -54,9 +54,28 @@ def create_app():
else:
raise Exception("Impossible d'initialiser la base de données après plusieurs tentatives.")
- # Enregistrement des routes (Blueprint)
- from app.routes import main
- app.register_blueprint(main)
+ # Enregistrement des routes (Blueprints)
+ from app.routes import (
+ actions_bp,
+ analyse_bp,
+ auth_bp,
+ comptes_bp,
+ historique_bp,
+ menu_bp,
+ ordres_bp,
+ societes_bp,
+ utilisateurs_bp,
+ )
+
+ app.register_blueprint(auth_bp)
+ app.register_blueprint(menu_bp)
+ app.register_blueprint(utilisateurs_bp)
+ app.register_blueprint(actions_bp)
+ app.register_blueprint(societes_bp)
+ app.register_blueprint(historique_bp)
+ app.register_blueprint(analyse_bp)
+ app.register_blueprint(ordres_bp)
+ app.register_blueprint(comptes_bp)
return app
diff --git a/app/decorators.py b/app/decorators.py
new file mode 100644
index 0000000..4e149dc
--- /dev/null
+++ b/app/decorators.py
@@ -0,0 +1,13 @@
+from functools import wraps
+
+from flask import redirect, session, url_for
+
+
+def login_required(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ if "user_id" not in session:
+ return redirect(url_for("auth.index"))
+ return f(*args, **kwargs)
+
+ return decorated
\ No newline at end of file
diff --git a/app/routes.py b/app/routes.py
deleted file mode 100644
index 70dd869..0000000
--- a/app/routes.py
+++ /dev/null
@@ -1,1654 +0,0 @@
-print("🔥 ROUTES.PY CHARGE DEPUIS LE VOLUME DOCKER 🔥")
-
-
-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 datetime import datetime
-from werkzeug.security import check_password_hash, generate_password_hash
-from sqlalchemy import text
-from app import db, csrf
-from app import models # noqa: F401 (assure l'enregistrement des modèles ORM)
-
-main = Blueprint("main", __name__)
-
-
-# ---------------------------------------------------------------------------
-# 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 "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")
-
- 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):
- # 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
- 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")
-
-
-# ---------------------------------------------------------------------------
-# Déconnexion
-# ---------------------------------------------------------------------------
-@main.route("/logout")
-def logout():
- session.clear()
- return redirect(url_for("main.index"))
-
-
-# ---------------------------------------------------------------------------
-# Menu principal
-# ---------------------------------------------------------------------------
-@main.route("/menu")
-@login_required
-def menu():
- return render_template("menu.html")
-
-
-# ---------------------------------------------------------------------------
-# Gestion des utilisateurs
-# ---------------------------------------------------------------------------
-@main.route("/gestion-utilisateurs", methods=["GET", "POST"])
-@login_required
-def gestion_utilisateurs():
- 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,
- },
- )
- 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:
- 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,
- },
- )
- 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,
- },
- )
- 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"
- )
- result = db.session.execute(users_query)
-
- 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
- ]
-
- return render_template("gestion_utilisateurs.html", users=users)
-
-
-# ---------------------------------------------------------------------------
-# Gestion des actions
-# ---------------------------------------------------------------------------
-@main.route("/gestion-actions", methods=["GET", "POST"])
-@login_required
-def gestion_actions():
- 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,
- },
- )
- db.session.commit()
- flash("Action créée avec succès", "success")
- except Exception:
- db.session.rollback()
- flash("Erreur lors de la création de l'action.", "danger")
-
- 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,
- },
- )
- db.session.commit()
- flash("Modification effectuée", "success")
- 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"
- )
- result = db.session.execute(actions_query)
-
- 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
- ]
-
- 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 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":
- 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 = [
- {
- "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
- ]
-
- 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():
- return render_template("gestion_import_export_actions_csv.html")
-
-
-@main.route("/export-actions-csv", methods=["GET"])
-@login_required
-def export_actions_csv():
- try:
- 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"]
- )
-
- 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 "",
- ]
- )
-
- output.seek(0)
- return Response(
- output.getvalue(),
- mimetype="text/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"))
-
-
-@main.route("/import-actions-csv", methods=["POST"])
-@login_required
-def import_actions_csv():
- 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 == "":
- flash("Aucun fichier sélectionné.", "danger")
- 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"))
-
- 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"))
-
-
-# ---------------------------------------------------------------------------
-# Import historique CSV (cotation d'une action)
-# ---------------------------------------------------------------------------
-@main.route("/gestion-import-historique-csv", methods=["GET"])
-@login_required
-def gestion_import_historique_csv():
- return render_template("gestion_import_historique_csv.html")
-
-
-def _convertir_vol(valeur):
- """Convertit une valeur de volume du CSV en entier.
- Suffixes gérés : 'K' (x1000), 'M' (x1 000 000). Sinon entier brut.
- """
- if valeur is None:
- return None
- s = str(valeur).strip()
- if s == "" or s.upper() == "N/A" or s.upper() == "NA":
- return None
- try:
- if s.upper().endswith("K"):
- return int(float(s[:-1]) * 1000)
- if s.upper().endswith("M"):
- return int(float(s[:-1]) * 1000000)
- return int(float(s))
- except (TypeError, ValueError):
- return None
-
-
-def _to_decimal(valeur):
- """Convertit une valeur CSV en float (Decimal), None si vide/invalide.
- Retire le suffixe '%' (ex: '0.7%' -> 0.7) et les séparateurs de milliers."""
- if valeur is None:
- return None
- s = str(valeur).strip()
- if s == "" or s.upper() in ("N/A", "NA"):
- return None
- # Suppression d'un éventuel suffixe '%' (et espaces autour)
- if s.endswith("%"):
- s = s[:-1].strip()
- # Suppression d'un éventuel séparateur de milliers (virgule ou espace)
- s = s.replace(" ", "").replace(",", "")
- try:
- return float(s)
- except (TypeError, ValueError):
- return None
-
-
-@main.route("/import-historique-csv", methods=["POST"])
-@login_required
-def import_historique_csv():
- isin = (request.form.get("isin") or "").strip().upper()
-
- # Vérification que l'ISIN existe bien dans le référentiel actions
- if not isin:
- flash("Veuillez saisir un code ISIN.", "danger")
- return redirect(url_for("main.gestion_import_historique_csv"))
-
- action = db.session.execute(
- text("SELECT company_name FROM actions WHERE isin = :isin"), {"isin": isin}
- ).fetchone()
- if not action:
- flash(f"L'ISIN {isin} n'existe pas dans le référentiel actions.", "danger")
- return redirect(url_for("main.gestion_import_historique_csv"))
-
- if "file" not in request.files:
- flash("Aucun fichier sélectionné.", "danger")
- return redirect(url_for("main.gestion_import_historique_csv"))
-
- file = request.files["file"]
- if file.filename == "":
- flash("Aucun fichier sélectionné.", "danger")
- return redirect(url_for("main.gestion_import_historique_csv"))
-
- if not file.filename.lower().endswith(".csv"):
- flash("Veuillez fournir un fichier au format .csv valide.", "warning")
- return redirect(url_for("main.gestion_import_historique_csv"))
-
- try:
- # Lecture du fichier brut avec fallback d'encodage (utf-8 puis latin-1)
- raw_bytes = file.stream.read()
- # Suppression d'un éventuel BOM UTF-8 (\xef\xbb\xbf) en début de fichier
- if raw_bytes.startswith(b"\xef\xbb\xbf"):
- raw_bytes = raw_bytes[3:]
- try:
- content = raw_bytes.decode("utf-8")
- except UnicodeDecodeError:
- content = raw_bytes.decode("latin-1")
- stream = io.StringIO(content)
-
- # Auto-détection du séparateur (, ; ou tabulation) sur la 1re ligne
- premiere_ligne = stream.readline()
- stream.seek(0)
- for delim in (",", ";", "\t"):
- nb = len(premiere_ligne.split(delim))
- if nb > 1:
- separateur = delim
- break
- else:
- separateur = ","
-
- csv_reader = csv.reader(stream, delimiter=separateur)
-
- header = next(csv_reader, None)
- if not header:
- flash("Le fichier CSV est vide.", "danger")
- return redirect(url_for("main.gestion_import_historique_csv"))
-
- # Normalisation des en-têtes : strip + suppression des quotes + lower
- def _norm_header(h):
- h = h.strip()
- if len(h) >= 2 and h[0] in ('"', "'") and h[-1] == h[0]:
- h = h[1:-1]
- return h.strip().lower()
-
- header_norm = [_norm_header(h) for h in header]
-
- # Mapping alias -> nom canonique (couvre les variantes EODHD, Yahoo, etc.)
- alias = {
- "date": "date",
- "price": "price", "close": "price", "last": "price",
- "adj_close": "price", "adjusted_close": "price", "adj. close": "price",
- "open": "open",
- "hight": "hight", "high": "hight",
- "low": "low",
- "vol": "vol", "vol.": "vol", "volume": "vol",
- "change": "change", "change %": "change", "change_pct": "change",
- "pct_change": "change", "change_percent": "change", "chg": "change",
- }
- # Construction du dictionnaire nom_canonique -> index
- idx = {}
- for i, h in enumerate(header_norm):
- canon = alias.get(h)
- if canon and canon not in idx:
- idx[canon] = i
-
- def col(row, name):
- """Récupère la valeur d'une colonne par son nom canonique."""
- if name not in idx:
- return None
- i = idx[name]
- return row[i] if i < len(row) else None
-
- # Vérification : la colonne 'date' est obligatoire
- if "date" not in idx:
- flash(
- f"Pas OK - Colonne 'date' introuvable dans l'en-tête CSV. "
- f"En-tête détecté : {','.join(header)}",
- "danger",
- )
- return redirect(url_for("main.gestion_import_historique_csv"))
-
- # Requête d'insertion (INSERT IGNORE grâce à l'unique key isin+date)
- insert = text(
- "INSERT IGNORE INTO historique "
- "(isin, date, price, `open`, hight, low, vol, `change`) "
- "VALUES (:isin, :date, :price, :open, :hight, :low, :vol, :change)"
- )
-
- count_insere = 0
- count_ignore = 0
- count_erreurs = 0
- exemples_erreurs = []
-
- for row in csv_reader:
- date_raw = col(row, "date")
- if not date_raw:
- count_erreurs += 1
- if len(exemples_erreurs) < 5:
- exemples_erreurs.append(f"Date vide | ligne: {row}")
- continue
-
- # Normalisation de la date -> objet date (pour insertion YYYY-MM-DD)
- # L'ordre compte : %Y/%d/%m (AAAA/JJ/MM) AVANT %Y/%m/%d pour éviter
- # l'ambiguïté (un jour >12 invaliderait %Y/%m/%d et tomberait juste sur %Y/%d/%m).
- date_str = date_raw.strip()[:10]
- date_val = None
- for fmt in ("%Y-%m-%d", "%Y/%d/%m", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y"):
- try:
- date_val = datetime.strptime(date_str, fmt).date()
- break
- except ValueError:
- continue
- if date_val is None:
- count_erreurs += 1
- if len(exemples_erreurs) < 10:
- exemples_erreurs.append(
- f"Date invalide '{date_raw}' (nb colonnes ligne={len(row)}) | ligne: {row}"
- )
- continue
-
- price = _to_decimal(col(row, "price"))
- open_ = _to_decimal(col(row, "open"))
- hight = _to_decimal(col(row, "hight"))
- low = _to_decimal(col(row, "low"))
- vol = _convertir_vol(col(row, "vol"))
- change = _to_decimal(col(row, "change"))
-
- result = db.session.execute(
- insert,
- {
- "isin": isin,
- "date": date_val,
- "price": price,
- "open": open_,
- "hight": hight,
- "low": low,
- "vol": vol,
- "change": change,
- },
- )
- # rowcount == 1 si réellement inséré, 0 si ignoré (doublon unique key)
- if result.rowcount == 1:
- count_insere += 1
- else:
- count_ignore += 1
-
- db.session.commit()
- detail = ""
- if exemples_erreurs:
- detail = " | Exemples d'erreurs: " + " ; ".join(exemples_erreurs)
- flash(
- f"OK - Importation réussie pour {action.company_name} ({isin}) : "
- f"{count_insere} ligne(s) insérée(s), "
- f"{count_ignore} ligne(s) déjà existante(s) ignorée(s), "
- f"{count_erreurs} ligne(s) en erreur. "
- f"[Séparateur: '{separateur}' | En-tête: {','.join(header)}]"
- f"{detail}",
- "success",
- )
- except Exception as e:
- db.session.rollback()
- current_app.logger.error("Erreur import historique CSV : %s", e, exc_info=True)
- flash(f"Pas OK - Erreur lors de l'importation du fichier : {str(e)}", "danger")
-
- return redirect(url_for("main.gestion_import_historique_csv"))
-
-
-# ---------------------------------------------------------------------------
-# Analyse technique
-# ---------------------------------------------------------------------------
-@main.route("/analyse", methods=["GET"])
-@login_required
-def analyse():
- """Page d'analyse technique. Si un ISIN est passé en paramètre (?isin=...),
- on l'analyse et on affiche la fiche complète ; sinon on affiche la page
- de recherche."""
- isin = request.args.get("isin", "").strip().upper()
-
- if not isin:
- return render_template("analyse.html", resultat=None)
-
- from app.services.analysis import analyse_action
-
- print(f"[analyse] ISIN demandé : {isin}", flush=True)
- resultat = analyse_action(isin)
-
- if resultat is None:
- # ISIN absent du référentiel actions
- return render_template(
- "analyse.html",
- resultat={
- "isin": isin,
- "action": None,
- "erreur": f"Aucune donnée trouvée pour l'ISIN {isin}. "
- f"Vérifiez le code ou importez d'abord l'historique.",
- },
- isin_recherche=isin,
- )
-
- if resultat.get("erreur"):
- # ISIN présent mais historique insuffisant
- print(f"[analyse] {isin} : {resultat['erreur']}", flush=True)
- else:
- print(
- f"[analyse] {isin} : cours={resultat.get('cours')} "
- f"score={resultat.get('score')} tendance={resultat.get('tendance')}",
- flush=True,
- )
-
- return render_template("analyse.html", resultat=resultat, isin_recherche=isin)
-
-
-@main.route("/analyse/", methods=["GET"])
-@login_required
-def analyse_isin(isin):
- """Page d'analyse technique complète pour un ISIN donné."""
- from app.services.analysis import analyse_action
-
- isin = isin.strip().upper()
- resultat = analyse_action(isin)
- return render_template("analyse.html", resultat=resultat, isin_recherche=isin)
-
-
-@main.route("/api/analyse/", methods=["GET"])
-@login_required
-def api_analyse(isin):
- """API JSON retournant l'analyse technique complète d'un ISIN."""
- from app.services.analysis import analyse_action
-
- isin = isin.strip().upper()
- resultat = analyse_action(isin)
- if resultat is None:
- return jsonify({"erreur": f"ISIN {isin} introuvable dans le référentiel actions."}), 404
- if resultat.get("erreur"):
- return jsonify(resultat), 200
- return jsonify(resultat)
-
-
-# ---------------------------------------------------------------------------
-# Gestion des ordres
-# ---------------------------------------------------------------------------
-@main.route("/gestion_ordres", methods=["GET", "POST"])
-@login_required
-def gestion_ordres():
- selected_societe_id = (
- request.form.get("societe_id")
- or request.args.get("societe_id")
- or session.get("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
-
- 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")
- )
- societes = [dict(row._mapping) for row in result_soc]
-
- 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"]
-
- session["selected_societe_id"] = selected_societe_id
-
- for soc in societes:
- if soc["id"] == selected_societe_id:
- selected_societe = soc["nom"]
- break
-
- # --- CALCUL DE LA PLUS-VALUE TOTALE ---
- annee_str = str(selected_annee).strip().lower()
-
- 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()
-
- if res_pv and res_pv.total_pv is not None:
- total_plus_value_globale = float(res_pv.total_pv)
-
- # --- 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"
- )
-
- result_ordres = connection.execute(
- query, {"soc_id": selected_societe_id}
- )
-
- for row in result_ordres:
- r = dict(row._mapping)
- ordre_id = r["ordre_id"]
- 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_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
- )
-
- 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"
-
- # Prix Net unitaire
- if sens_ordre == "vente":
- prix_net = prix_brut - frais_total
- else:
- prix_net = prix_brut + frais_total
-
- # Engagements ligne
- eng_brut = quantite * prix_brut
- eng_net = quantite * prix_net
-
- 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,
- )
-
-
-# ---------------------------------------------------------------------------
-# 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():
- return redirect(url_for("main.gestion_ordres"))
-
-
-# ---------------------------------------------------------------------------
-# 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(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()
-
- if action:
- return jsonify(
- {
- "exists": True,
- "company_name": action.company_name,
- "ticker": action.ticker,
- "currency": action.currency,
- }
- )
- return jsonify({"exists": False})
-
-
-# ---------------------------------------------------------------------------
-# 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():
- isin = request.form.get("isin")
- date_op = request.form.get("date_op")
- selected_annee = request.form.get("annee", "tout")
-
- try:
- quantite = float(request.form.get("quantite") or 0)
- prix_brut = float(request.form.get("prix_brut") 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",
- )
- return redirect(url_for("main.gestion_ordres", annee=selected_annee))
-
- sens = request.form.get("ordre")
- position = request.form.get("type_ordre")
- etat = request.form.get("etat")
-
- 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.",
- "danger",
- )
- return redirect(url_for("main.gestion_ordres", annee=selected_annee))
-
- try:
- query_ordre = text(
- "INSERT INTO ordre (id_societe, isin) VALUES (:id_societe, :isin)"
- )
- db.session.execute(query_ordre, {"id_societe": id_societe, "isin": isin})
-
- 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"
- ),
- {"isin": isin},
- ).scalar()
-
- 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": result_id,
- "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,
- },
- )
-
- db.session.commit()
- flash("Ordre créé avec succès !", "success")
- except Exception as e:
- db.session.rollback()
- 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))
-
-
-# ---------------------------------------------------------------------------
-# 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):
- selected_annee = request.args.get("annee", "tout")
-
- if request.method == "POST":
- selected_annee = request.form.get("annee", "tout")
- try:
- quantite = float(request.form.get("quantite") or 0)
- prix_brut = float(request.form.get("prix_brut") 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,
- )
- )
-
- try:
- 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()
- current_app.logger.error("Erreur ajout ligne : %s", e, exc_info=True)
- flash("Erreur lors de l'ajout de la ligne.", "danger")
-
- return redirect(url_for("main.gestion_ordres", annee=selected_annee))
-
-
-# ---------------------------------------------------------------------------
-# 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):
- selected_annee = request.args.get("annee", "tout")
-
- if request.method == "POST":
- selected_annee = request.form.get("annee", "tout")
- try:
- quantite = float(request.form.get("quantite") or 0)
- prix_brut = float(request.form.get("prix_brut") 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,
- )
- )
-
- try:
- 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()
- current_app.logger.error("Erreur modification ligne : %s", e, exc_info=True)
- flash("Erreur lors de la modification de la ligne.", "danger")
-
- 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/routes/__init__.py b/app/routes/__init__.py
new file mode 100644
index 0000000..8a7feaa
--- /dev/null
+++ b/app/routes/__init__.py
@@ -0,0 +1,21 @@
+from .actions import actions_bp
+from .analyse import analyse_bp
+from .auth import auth_bp
+from .comptes import comptes_bp
+from .historique import historique_bp
+from .menu import menu_bp
+from .ordres import ordres_bp
+from .societes import societes_bp
+from .utilisateurs import utilisateurs_bp
+
+__all__ = [
+ "auth_bp",
+ "menu_bp",
+ "utilisateurs_bp",
+ "actions_bp",
+ "societes_bp",
+ "historique_bp",
+ "analyse_bp",
+ "ordres_bp",
+ "comptes_bp",
+]
\ No newline at end of file
diff --git a/app/routes/actions.py b/app/routes/actions.py
new file mode 100644
index 0000000..9f58147
--- /dev/null
+++ b/app/routes/actions.py
@@ -0,0 +1,98 @@
+from flask import Blueprint, flash, redirect, render_template, request, url_for
+from sqlalchemy import text
+
+from app import db
+from app.decorators import login_required
+
+actions_bp = Blueprint("actions", __name__)
+
+
+@actions_bp.route("/gestion-actions", methods=["GET", "POST"])
+@login_required
+def gestion_actions():
+ 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,
+ },
+ )
+ db.session.commit()
+ flash("Action créée avec succès", "success")
+ except Exception:
+ db.session.rollback()
+ flash("Erreur lors de la création de l'action.", "danger")
+
+ 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,
+ },
+ )
+ db.session.commit()
+ flash("Modification effectuée", "success")
+ except Exception:
+ db.session.rollback()
+ flash("Erreur lors de la modification.", "danger")
+
+ return redirect(url_for("actions.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 = [
+ {
+ "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
+ ]
+
+ return render_template("gestion_actions.html", actions=actions_list)
\ No newline at end of file
diff --git a/app/routes/analyse.py b/app/routes/analyse.py
new file mode 100644
index 0000000..d0e1a65
--- /dev/null
+++ b/app/routes/analyse.py
@@ -0,0 +1,73 @@
+from flask import Blueprint, jsonify, render_template, request
+
+from app.decorators import login_required
+
+analyse_bp = Blueprint("analyse", __name__)
+
+
+@analyse_bp.route("/analyse", methods=["GET"])
+@login_required
+def analyse():
+ """Page d'analyse technique. Si un ISIN est passé en paramètre (?isin=...),
+ on l'analyse et on affiche la fiche complète ; sinon on affiche la page
+ de recherche."""
+ isin = request.args.get("isin", "").strip().upper()
+
+ if not isin:
+ return render_template("analyse.html", resultat=None)
+
+ from app.services.analysis import analyse_action
+
+ print(f"[analyse] ISIN demandé : {isin}", flush=True)
+ resultat = analyse_action(isin)
+
+ if resultat is None:
+ # ISIN absent du référentiel actions
+ return render_template(
+ "analyse.html",
+ resultat={
+ "isin": isin,
+ "action": None,
+ "erreur": f"Aucune donnée trouvée pour l'ISIN {isin}. "
+ f"Vérifiez le code ou importez d'abord l'historique.",
+ },
+ isin_recherche=isin,
+ )
+
+ if resultat.get("erreur"):
+ # ISIN présent mais historique insuffisant
+ print(f"[analyse] {isin} : {resultat['erreur']}", flush=True)
+ else:
+ print(
+ f"[analyse] {isin} : cours={resultat.get('cours')} "
+ f"score={resultat.get('score')} tendance={resultat.get('tendance')}",
+ flush=True,
+ )
+
+ return render_template("analyse.html", resultat=resultat, isin_recherche=isin)
+
+
+@analyse_bp.route("/analyse/", methods=["GET"])
+@login_required
+def analyse_isin(isin):
+ """Page d'analyse technique complète pour un ISIN donné."""
+ from app.services.analysis import analyse_action
+
+ isin = isin.strip().upper()
+ resultat = analyse_action(isin)
+ return render_template("analyse.html", resultat=resultat, isin_recherche=isin)
+
+
+@analyse_bp.route("/api/analyse/", methods=["GET"])
+@login_required
+def api_analyse(isin):
+ """API JSON retournant l'analyse technique complète d'un ISIN."""
+ from app.services.analysis import analyse_action
+
+ isin = isin.strip().upper()
+ resultat = analyse_action(isin)
+ if resultat is None:
+ return jsonify({"erreur": f"ISIN {isin} introuvable dans le référentiel actions."}), 404
+ if resultat.get("erreur"):
+ return jsonify(resultat), 200
+ return jsonify(resultat)
\ No newline at end of file
diff --git a/app/routes/auth.py b/app/routes/auth.py
new file mode 100644
index 0000000..84a32cd
--- /dev/null
+++ b/app/routes/auth.py
@@ -0,0 +1,57 @@
+from flask import Blueprint, flash, redirect, render_template, request, session, url_for
+from sqlalchemy import text
+from werkzeug.security import check_password_hash
+
+from app import db
+
+auth_bp = Blueprint("auth", __name__)
+
+
+@auth_bp.route("/", methods=["GET", "POST"])
+def index():
+ if "user_id" in session:
+ return redirect(url_for("menu.menu"))
+
+ 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")
+
+ 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):
+ # 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("menu.menu"))
+ else:
+ 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")
+
+
+@auth_bp.route("/logout")
+def logout():
+ session.clear()
+ return redirect(url_for("auth.index"))
\ No newline at end of file
diff --git a/app/routes/comptes.py b/app/routes/comptes.py
new file mode 100644
index 0000000..6c57862
--- /dev/null
+++ b/app/routes/comptes.py
@@ -0,0 +1,365 @@
+from flask import (
+ Blueprint,
+ current_app,
+ flash,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
+from sqlalchemy import text
+
+from app import db
+from app.decorators import login_required
+
+comptes_bp = Blueprint("comptes", __name__)
+
+
+# ---------------------------------------------------------------------------
+# Gestion des comptes
+# ---------------------------------------------------------------------------
+@comptes_bp.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
+# ---------------------------------------------------------------------------
+@comptes_bp.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("comptes.gestion_comptes"))
+
+ try:
+ id_societe_int = int(id_societe)
+ except (TypeError, ValueError):
+ flash("Identifiant de société invalide.", "danger")
+ return redirect(url_for("comptes.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("comptes.gestion_comptes", societe_id=id_societe_int))
+
+
+# ---------------------------------------------------------------------------
+# Création d'une écriture comptable
+# ---------------------------------------------------------------------------
+@comptes_bp.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("comptes.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("comptes.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("comptes.gestion_comptes", societe_id=id_societe_int))
+
+
+# ---------------------------------------------------------------------------
+# Modification d'une écriture comptable
+# ---------------------------------------------------------------------------
+@comptes_bp.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("comptes.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("comptes.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("comptes.gestion_comptes", societe_id=id_societe))
\ No newline at end of file
diff --git a/app/routes/historique.py b/app/routes/historique.py
new file mode 100644
index 0000000..2e6046e
--- /dev/null
+++ b/app/routes/historique.py
@@ -0,0 +1,381 @@
+import csv
+import io
+from datetime import datetime
+
+from flask import (
+ Blueprint,
+ Response,
+ current_app,
+ flash,
+ redirect,
+ render_template,
+ request,
+ url_for,
+)
+from sqlalchemy import text
+
+from app import db
+from app.decorators import login_required
+
+historique_bp = Blueprint("historique", __name__)
+
+
+# ---------------------------------------------------------------------------
+# Import / Export des actions CSV
+# ---------------------------------------------------------------------------
+@historique_bp.route("/gestion-import-export-actions-csv", methods=["GET"])
+@login_required
+def gestion_import_export_actions_csv():
+ return render_template("gestion_import_export_actions_csv.html")
+
+
+@historique_bp.route("/export-actions-csv", methods=["GET"])
+@login_required
+def export_actions_csv():
+ try:
+ 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"]
+ )
+
+ 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 "",
+ ]
+ )
+
+ output.seek(0)
+ return Response(
+ output.getvalue(),
+ mimetype="text/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("historique.gestion_import_export_actions_csv"))
+
+
+@historique_bp.route("/import-actions-csv", methods=["POST"])
+@login_required
+def import_actions_csv():
+ if "file" not in request.files:
+ flash("Aucun fichier sélectionné.", "danger")
+ return redirect(url_for("historique.gestion_import_export_actions_csv"))
+
+ file = request.files["file"]
+
+ if file.filename == "":
+ flash("Aucun fichier sélectionné.", "danger")
+ return redirect(url_for("historique.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("historique.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("historique.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("historique.gestion_import_export_actions_csv"))
+
+
+# ---------------------------------------------------------------------------
+# Import historique CSV (cotation d'une action)
+# ---------------------------------------------------------------------------
+@historique_bp.route("/gestion-import-historique-csv", methods=["GET"])
+@login_required
+def gestion_import_historique_csv():
+ return render_template("gestion_import_historique_csv.html")
+
+
+def _convertir_vol(valeur):
+ """Convertit une valeur de volume du CSV en entier.
+ Suffixes gérés : 'K' (x1000), 'M' (x1 000 000). Sinon entier brut.
+ """
+ if valeur is None:
+ return None
+ s = str(valeur).strip()
+ if s == "" or s.upper() == "N/A" or s.upper() == "NA":
+ return None
+ try:
+ if s.upper().endswith("K"):
+ return int(float(s[:-1]) * 1000)
+ if s.upper().endswith("M"):
+ return int(float(s[:-1]) * 1000000)
+ return int(float(s))
+ except (TypeError, ValueError):
+ return None
+
+
+def _to_decimal(valeur):
+ """Convertit une valeur CSV en float (Decimal), None si vide/invalide.
+ Retire le suffixe '%' (ex: '0.7%' -> 0.7) et les séparateurs de milliers."""
+ if valeur is None:
+ return None
+ s = str(valeur).strip()
+ if s == "" or s.upper() in ("N/A", "NA"):
+ return None
+ # Suppression d'un éventuel suffixe '%' (et espaces autour)
+ if s.endswith("%"):
+ s = s[:-1].strip()
+ # Suppression d'un éventuel séparateur de milliers (virgule ou espace)
+ s = s.replace(" ", "").replace(",", "")
+ try:
+ return float(s)
+ except (TypeError, ValueError):
+ return None
+
+
+@historique_bp.route("/import-historique-csv", methods=["POST"])
+@login_required
+def import_historique_csv():
+ isin = (request.form.get("isin") or "").strip().upper()
+
+ # Vérification que l'ISIN existe bien dans le référentiel actions
+ if not isin:
+ flash("Veuillez saisir un code ISIN.", "danger")
+ return redirect(url_for("historique.gestion_import_historique_csv"))
+
+ action = db.session.execute(
+ text("SELECT company_name FROM actions WHERE isin = :isin"), {"isin": isin}
+ ).fetchone()
+ if not action:
+ flash(f"L'ISIN {isin} n'existe pas dans le référentiel actions.", "danger")
+ return redirect(url_for("historique.gestion_import_historique_csv"))
+
+ if "file" not in request.files:
+ flash("Aucun fichier sélectionné.", "danger")
+ return redirect(url_for("historique.gestion_import_historique_csv"))
+
+ file = request.files["file"]
+ if file.filename == "":
+ flash("Aucun fichier sélectionné.", "danger")
+ return redirect(url_for("historique.gestion_import_historique_csv"))
+
+ if not file.filename.lower().endswith(".csv"):
+ flash("Veuillez fournir un fichier au format .csv valide.", "warning")
+ return redirect(url_for("historique.gestion_import_historique_csv"))
+
+ try:
+ # Lecture du fichier brut avec fallback d'encodage (utf-8 puis latin-1)
+ raw_bytes = file.stream.read()
+ # Suppression d'un éventuel BOM UTF-8 (\xef\xbb\xbf) en début de fichier
+ if raw_bytes.startswith(b"\xef\xbb\xbf"):
+ raw_bytes = raw_bytes[3:]
+ try:
+ content = raw_bytes.decode("utf-8")
+ except UnicodeDecodeError:
+ content = raw_bytes.decode("latin-1")
+ stream = io.StringIO(content)
+
+ # Auto-détection du séparateur (, ; ou tabulation) sur la 1re ligne
+ premiere_ligne = stream.readline()
+ stream.seek(0)
+ for delim in (",", ";", "\t"):
+ nb = len(premiere_ligne.split(delim))
+ if nb > 1:
+ separateur = delim
+ break
+ else:
+ separateur = ","
+
+ csv_reader = csv.reader(stream, delimiter=separateur)
+
+ header = next(csv_reader, None)
+ if not header:
+ flash("Le fichier CSV est vide.", "danger")
+ return redirect(url_for("historique.gestion_import_historique_csv"))
+
+ # Normalisation des en-têtes : strip + suppression des quotes + lower
+ def _norm_header(h):
+ h = h.strip()
+ if len(h) >= 2 and h[0] in ('"', "'") and h[-1] == h[0]:
+ h = h[1:-1]
+ return h.strip().lower()
+
+ header_norm = [_norm_header(h) for h in header]
+
+ # Mapping alias -> nom canonique (couvre les variantes EODHD, Yahoo, etc.)
+ alias = {
+ "date": "date",
+ "price": "price", "close": "price", "last": "price",
+ "adj_close": "price", "adjusted_close": "price", "adj. close": "price",
+ "open": "open",
+ "hight": "hight", "high": "hight",
+ "low": "low",
+ "vol": "vol", "vol.": "vol", "volume": "vol",
+ "change": "change", "change %": "change", "change_pct": "change",
+ "pct_change": "change", "change_percent": "change", "chg": "change",
+ }
+ # Construction du dictionnaire nom_canonique -> index
+ idx = {}
+ for i, h in enumerate(header_norm):
+ canon = alias.get(h)
+ if canon and canon not in idx:
+ idx[canon] = i
+
+ def col(row, name):
+ """Récupère la valeur d'une colonne par son nom canonique."""
+ if name not in idx:
+ return None
+ i = idx[name]
+ return row[i] if i < len(row) else None
+
+ # Vérification : la colonne 'date' est obligatoire
+ if "date" not in idx:
+ flash(
+ f"Pas OK - Colonne 'date' introuvable dans l'en-tête CSV. "
+ f"En-tête détecté : {','.join(header)}",
+ "danger",
+ )
+ return redirect(url_for("historique.gestion_import_historique_csv"))
+
+ # Requête d'insertion (INSERT IGNORE grâce à l'unique key isin+date)
+ insert = text(
+ "INSERT IGNORE INTO historique "
+ "(isin, date, price, `open`, hight, low, vol, `change`) "
+ "VALUES (:isin, :date, :price, :open, :hight, :low, :vol, :change)"
+ )
+
+ count_insere = 0
+ count_ignore = 0
+ count_erreurs = 0
+ exemples_erreurs = []
+
+ for row in csv_reader:
+ date_raw = col(row, "date")
+ if not date_raw:
+ count_erreurs += 1
+ if len(exemples_erreurs) < 5:
+ exemples_erreurs.append(f"Date vide | ligne: {row}")
+ continue
+
+ # Normalisation de la date -> objet date (pour insertion YYYY-MM-DD)
+ # L'ordre compte : %Y/%d/%m (AAAA/JJ/MM) AVANT %Y/%m/%d pour éviter
+ # l'ambiguïté (un jour >12 invaliderait %Y/%m/%d et tomberait juste sur %Y/%d/%m).
+ date_str = date_raw.strip()[:10]
+ date_val = None
+ for fmt in ("%Y-%m-%d", "%Y/%d/%m", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y"):
+ try:
+ date_val = datetime.strptime(date_str, fmt).date()
+ break
+ except ValueError:
+ continue
+ if date_val is None:
+ count_erreurs += 1
+ if len(exemples_erreurs) < 10:
+ exemples_erreurs.append(
+ f"Date invalide '{date_raw}' (nb colonnes ligne={len(row)}) | ligne: {row}"
+ )
+ continue
+
+ price = _to_decimal(col(row, "price"))
+ open_ = _to_decimal(col(row, "open"))
+ hight = _to_decimal(col(row, "hight"))
+ low = _to_decimal(col(row, "low"))
+ vol = _convertir_vol(col(row, "vol"))
+ change = _to_decimal(col(row, "change"))
+
+ result = db.session.execute(
+ insert,
+ {
+ "isin": isin,
+ "date": date_val,
+ "price": price,
+ "open": open_,
+ "hight": hight,
+ "low": low,
+ "vol": vol,
+ "change": change,
+ },
+ )
+ # rowcount == 1 si réellement inséré, 0 si ignoré (doublon unique key)
+ if result.rowcount == 1:
+ count_insere += 1
+ else:
+ count_ignore += 1
+
+ db.session.commit()
+ detail = ""
+ if exemples_erreurs:
+ detail = " | Exemples d'erreurs: " + " ; ".join(exemples_erreurs)
+ flash(
+ f"OK - Importation réussie pour {action.company_name} ({isin}) : "
+ f"{count_insere} ligne(s) insérée(s), "
+ f"{count_ignore} ligne(s) déjà existante(s) ignorée(s), "
+ f"{count_erreurs} ligne(s) en erreur. "
+ f"[Séparateur: '{separateur}' | En-tête: {','.join(header)}]"
+ f"{detail}",
+ "success",
+ )
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error("Erreur import historique CSV : %s", e, exc_info=True)
+ flash(f"Pas OK - Erreur lors de l'importation du fichier : {str(e)}", "danger")
+
+ return redirect(url_for("historique.gestion_import_historique_csv"))
\ No newline at end of file
diff --git a/app/routes/menu.py b/app/routes/menu.py
new file mode 100644
index 0000000..0b7381a
--- /dev/null
+++ b/app/routes/menu.py
@@ -0,0 +1,11 @@
+from flask import Blueprint, render_template
+
+from app.decorators import login_required
+
+menu_bp = Blueprint("menu", __name__)
+
+
+@menu_bp.route("/menu")
+@login_required
+def menu():
+ return render_template("menu.html")
\ No newline at end of file
diff --git a/app/routes/ordres.py b/app/routes/ordres.py
new file mode 100644
index 0000000..ef7e40c
--- /dev/null
+++ b/app/routes/ordres.py
@@ -0,0 +1,476 @@
+from flask import (
+ Blueprint,
+ current_app,
+ flash,
+ jsonify,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
+from sqlalchemy import text
+
+from app import csrf, db
+from app.decorators import login_required
+
+ordres_bp = Blueprint("ordres", __name__)
+
+
+# ---------------------------------------------------------------------------
+# Gestion des ordres
+# ---------------------------------------------------------------------------
+@ordres_bp.route("/gestion_ordres", methods=["GET", "POST"])
+@login_required
+def gestion_ordres():
+ selected_societe_id = (
+ request.form.get("societe_id")
+ or request.args.get("societe_id")
+ or session.get("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
+
+ 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")
+ )
+ societes = [dict(row._mapping) for row in result_soc]
+
+ 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"]
+
+ session["selected_societe_id"] = selected_societe_id
+
+ for soc in societes:
+ if soc["id"] == selected_societe_id:
+ selected_societe = soc["nom"]
+ break
+
+ # --- CALCUL DE LA PLUS-VALUE TOTALE ---
+ annee_str = str(selected_annee).strip().lower()
+
+ 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()
+
+ if res_pv and res_pv.total_pv is not None:
+ total_plus_value_globale = float(res_pv.total_pv)
+
+ # --- 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"
+ )
+
+ result_ordres = connection.execute(
+ query, {"soc_id": selected_societe_id}
+ )
+
+ for row in result_ordres:
+ r = dict(row._mapping)
+ ordre_id = r["ordre_id"]
+ 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_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
+ )
+
+ 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"
+
+ # Prix Net unitaire
+ if sens_ordre == "vente":
+ prix_net = prix_brut - frais_total
+ else:
+ prix_net = prix_brut + frais_total
+
+ # Engagements ligne
+ eng_brut = quantite * prix_brut
+ eng_net = quantite * prix_net
+
+ 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,
+ )
+
+
+# ---------------------------------------------------------------------------
+# 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)
+# ---------------------------------------------------------------------------
+@ordres_bp.route("/creer-ordre", methods=["GET", "POST"])
+@login_required
+def creer_ordre():
+ return redirect(url_for("ordres.gestion_ordres"))
+
+
+# ---------------------------------------------------------------------------
+# API : vérifier l'existence d'un ISIN (JSON, exemptée de CSRF)
+# ---------------------------------------------------------------------------
+@ordres_bp.route("/api/verifier_isin", methods=["POST"])
+@csrf.exempt
+def verifier_isin():
+ 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()
+
+ if action:
+ return jsonify(
+ {
+ "exists": True,
+ "company_name": action.company_name,
+ "ticker": action.ticker,
+ "currency": action.currency,
+ }
+ )
+ return jsonify({"exists": False})
+
+
+# ---------------------------------------------------------------------------
+# Traitement de la création d'un ordre (appelé par la modale)
+# ---------------------------------------------------------------------------
+@ordres_bp.route("/creer-ordre-traitement", methods=["POST"])
+@login_required
+def creer_ordre_traitement():
+ isin = request.form.get("isin")
+ date_op = request.form.get("date_op")
+ selected_annee = request.form.get("annee", "tout")
+
+ try:
+ quantite = float(request.form.get("quantite") or 0)
+ prix_brut = float(request.form.get("prix_brut") 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",
+ )
+ return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
+
+ sens = request.form.get("ordre")
+ position = request.form.get("type_ordre")
+ etat = request.form.get("etat")
+
+ 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.",
+ "danger",
+ )
+ return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
+
+ try:
+ query_ordre = text(
+ "INSERT INTO ordre (id_societe, isin) VALUES (:id_societe, :isin)"
+ )
+ db.session.execute(query_ordre, {"id_societe": id_societe, "isin": isin})
+
+ 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"
+ ),
+ {"isin": isin},
+ ).scalar()
+
+ 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": result_id,
+ "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,
+ },
+ )
+
+ db.session.commit()
+ flash("Ordre créé avec succès !", "success")
+ except Exception as e:
+ db.session.rollback()
+ 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("ordres.gestion_ordres", annee=selected_annee))
+
+
+# ---------------------------------------------------------------------------
+# Ajout d'une ligne à un ordre existant (POST depuis la modale)
+# ---------------------------------------------------------------------------
+@ordres_bp.route("/ordre//ajouter-ligne", methods=["GET", "POST"])
+@login_required
+def ajouter_ligne_ordre(ordre_id):
+ selected_annee = request.args.get("annee", "tout")
+
+ if request.method == "POST":
+ selected_annee = request.form.get("annee", "tout")
+ try:
+ quantite = float(request.form.get("quantite") or 0)
+ prix_brut = float(request.form.get("prix_brut") 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(
+ "ordres.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_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("ordres.gestion_ordres", annee=selected_annee))
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error("Erreur ajout ligne : %s", e, exc_info=True)
+ flash("Erreur lors de l'ajout de la ligne.", "danger")
+
+ return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
+
+
+# ---------------------------------------------------------------------------
+# Modification d'une ligne d'ordre (POST depuis la modale)
+# ---------------------------------------------------------------------------
+@ordres_bp.route("/modifier-ligne-ordre/", methods=["GET", "POST"])
+@login_required
+def modifier_ligne_ordre(pied_id):
+ selected_annee = request.args.get("annee", "tout")
+
+ if request.method == "POST":
+ selected_annee = request.form.get("annee", "tout")
+ try:
+ quantite = float(request.form.get("quantite") or 0)
+ prix_brut = float(request.form.get("prix_brut") 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(
+ "ordres.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_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("ordres.gestion_ordres", annee=selected_annee))
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error("Erreur modification ligne : %s", e, exc_info=True)
+ flash("Erreur lors de la modification de la ligne.", "danger")
+
+ return redirect(url_for("ordres.gestion_ordres", annee=selected_annee))
\ No newline at end of file
diff --git a/app/routes/societes.py b/app/routes/societes.py
new file mode 100644
index 0000000..6a94060
--- /dev/null
+++ b/app/routes/societes.py
@@ -0,0 +1,118 @@
+from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
+from sqlalchemy import text
+
+from app import db
+from app.decorators import login_required
+
+societes_bp = Blueprint("societes", __name__)
+
+
+@societes_bp.route("/gestion-societes", methods=["GET", "POST"])
+@login_required
+def gestion_societes():
+ 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":
+ 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("societes.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 = [
+ {
+ "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
+ ]
+
+ return render_template("gestion_societes.html", societes=societes)
\ No newline at end of file
diff --git a/app/routes/utilisateurs.py b/app/routes/utilisateurs.py
new file mode 100644
index 0000000..439034e
--- /dev/null
+++ b/app/routes/utilisateurs.py
@@ -0,0 +1,107 @@
+from flask import Blueprint, flash, redirect, render_template, request, url_for
+from sqlalchemy import text
+from werkzeug.security import generate_password_hash
+
+from app import db
+from app.decorators import login_required
+
+utilisateurs_bp = Blueprint("utilisateurs", __name__)
+
+
+@utilisateurs_bp.route("/gestion-utilisateurs", methods=["GET", "POST"])
+@login_required
+def gestion_utilisateurs():
+ 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,
+ },
+ )
+ 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:
+ 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,
+ },
+ )
+ 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,
+ },
+ )
+ db.session.commit()
+ flash("Utilisateur modifié avec succès.", "success")
+
+ return redirect(url_for("utilisateurs.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 = [
+ {
+ "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
+ ]
+
+ return render_template("gestion_utilisateurs.html", users=users)
\ No newline at end of file
diff --git a/app/templates/analyse.html b/app/templates/analyse.html
index 976a00e..cf65ad8 100644
--- a/app/templates/analyse.html
+++ b/app/templates/analyse.html
@@ -19,7 +19,7 @@
Retour au menu
diff --git a/app/templates/gestion_utilisateurs.html b/app/templates/gestion_utilisateurs.html
index 109ca23..ec20d5c 100644
--- a/app/templates/gestion_utilisateurs.html
+++ b/app/templates/gestion_utilisateurs.html
@@ -34,7 +34,7 @@ content %}
Retour au menu
diff --git a/app/templates/index.html b/app/templates/index.html
index dc6abeb..489b53f 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -17,7 +17,7 @@ block content %}
{% endif %} {% endwith %}
-