From 3f69b1904ecf61a00852ee7ab1f98faeb1f942ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:56:23 +0000 Subject: [PATCH 01/19] Serveur Rust : socle (config, base, auth, sessions, journaux, cache FF, SPA) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- .gitignore | 5 + server/Cargo.lock | 3447 +++++++++++++++++++++++++++++++++ server/Cargo.toml | 53 + server/PORTING.md | 115 ++ server/sql/init.sql | 150 ++ server/src/admin/mod.rs | 22 + server/src/api_v1_response.rs | 81 + server/src/auth/api_key.rs | 149 ++ server/src/auth/authz.rs | 155 ++ server/src/auth/fallback.rs | 62 + server/src/auth/mod.rs | 191 ++ server/src/auth/password.rs | 60 + server/src/auth/session.rs | 124 ++ server/src/config.rs | 99 + server/src/db.rs | 202 ++ server/src/domain.rs | 52 + server/src/error.rs | 105 + server/src/ff/mod.rs | 19 + server/src/ff_cache.rs | 156 ++ server/src/grid/mod.rs | 19 + server/src/logcapture.rs | 225 +++ server/src/main.rs | 108 ++ server/src/migrations.rs | 19 + server/src/produits/mod.rs | 19 + server/src/qlik/mod.rs | 19 + server/src/routes/mod.rs | 19 + server/src/rpc.rs | 125 ++ server/src/spa.rs | 73 + 28 files changed, 5873 insertions(+) create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/PORTING.md create mode 100644 server/sql/init.sql create mode 100644 server/src/admin/mod.rs create mode 100644 server/src/api_v1_response.rs create mode 100644 server/src/auth/api_key.rs create mode 100644 server/src/auth/authz.rs create mode 100644 server/src/auth/fallback.rs create mode 100644 server/src/auth/mod.rs create mode 100644 server/src/auth/password.rs create mode 100644 server/src/auth/session.rs create mode 100644 server/src/config.rs create mode 100644 server/src/db.rs create mode 100644 server/src/domain.rs create mode 100644 server/src/error.rs create mode 100644 server/src/ff/mod.rs create mode 100644 server/src/ff_cache.rs create mode 100644 server/src/grid/mod.rs create mode 100644 server/src/logcapture.rs create mode 100644 server/src/main.rs create mode 100644 server/src/migrations.rs create mode 100644 server/src/produits/mod.rs create mode 100644 server/src/qlik/mod.rs create mode 100644 server/src/routes/mod.rs create mode 100644 server/src/rpc.rs create mode 100644 server/src/spa.rs diff --git a/.gitignore b/.gitignore index 7512808..ca7135e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ next-env.d.ts # données locales (configuration avec mots de passe, comptes de secours) /data/ + +# Rust +/server/target/ +# Vite +/dist/ diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..e2a50a6 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,3447 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chromiumoxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ed067eb6c1f660bdb87c05efb964421d2ca262bae0296cdfe38cf0cd949a3e" +dependencies = [ + "async-tungstenite", + "base64 0.22.1", + "bytes", + "chromiumoxide_cdp", + "chromiumoxide_types", + "dunce", + "fnv", + "futures", + "futures-timer", + "pin-project-lite", + "reqwest 0.13.5", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", + "which", + "windows-registry", +] + +[[package]] +name = "chromiumoxide_cdp" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a6a03a7ebac4ea85308f285d6959a3e6b2ce32a0c9465dc7a7b1db0144eec7" +dependencies = [ + "chromiumoxide_pdl", + "chromiumoxide_types", + "serde", + "serde_json", +] + +[[package]] +name = "chromiumoxide_pdl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c602dea92337bc4d824668d78c5b79c3b4ddb29b40dd7218282bbe8fd3fc2091" +dependencies = [ + "chromiumoxide_types", + "either", + "heck", + "once_cell", + "proc-macro2", + "quote", + "regex", + "serde_json", +] + +[[package]] +name = "chromiumoxide_types" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678d5146e74f16fc4a41978b275af572cd913de1f10270d2b93b6c276bc57d80" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "collectflow" +version = "0.2.0" +dependencies = [ + "anyhow", + "axum", + "axum-extra", + "base64 0.22.1", + "chromiumoxide", + "chrono", + "futures", + "hex", + "hmac", + "parking_lot", + "rand 0.8.8", + "regex", + "reqwest 0.12.28", + "rust_decimal", + "rust_xlsxwriter", + "scrypt", + "serde", + "serde_json", + "sha2", + "sqlx", + "subtle", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", +] + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "headers" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rust_xlsxwriter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f2d2cda7223168a14fb86bc7411dbc3379bd7d49749ecc53b91ed98585b6b0" +dependencies = [ + "zip", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash 0.2.0", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..314381d --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "collectflow" +version = "0.2.0" +edition = "2021" +description = "CollectFlow — serveur Rust (API, base, FF Nancy, Qlik, synchronisation)" +publish = false + +[dependencies] +anyhow = "1" +axum = { version = "0.8", features = ["macros"] } +axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } +base64 = "0.22" +chromiumoxide = "0.9.1" +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +hex = "0.4" +hmac = "0.12" +parking_lot = "0.12" +rand = "0.8" +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "cookies", "stream", "http2"] } +rust_decimal = "1" +rust_xlsxwriter = "0.99" +scrypt = { version = "0.11", default-features = false, features = ["std"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "postgres", "json", "chrono", "rust_decimal", "macros"] } +subtle = "2" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "compression-gzip", "trace", "set-header"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +urlencoding = "2" +uuid = { version = "1", features = ["v4"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 4 +strip = true + +# scrypt est lent sans optimisation : la connexion prenait plusieurs secondes en debug. +[profile.dev.package.scrypt] +opt-level = 3 +[profile.dev.package.salsa20] +opt-level = 3 +[profile.dev.package.pbkdf2] +opt-level = 3 +[profile.dev.package.sha2] +opt-level = 3 diff --git a/server/PORTING.md b/server/PORTING.md new file mode 100644 index 0000000..0b40e75 --- /dev/null +++ b/server/PORTING.md @@ -0,0 +1,115 @@ +# Portage Rust — conventions de travail + +Ce document fixe le contrat entre le serveur Rust (`server/`) et l'interface +React (`src/`, compilée en SPA par Vite). Le code TypeScript d'origine (Next.js) +reste la **référence fonctionnelle** : chaque module Rust en est la traduction +fidèle (mêmes requêtes SQL, mêmes règles, mêmes messages, mêmes formes JSON). + +## Socle déjà en place (ne pas réécrire) + +| Besoin | Rust | +|---|---| +| Pool PostgreSQL (base app + recopie FF) | `crate::db::pool()? -> PgPool` (remplaçable à chaud, `db::refresh()`) | +| Ligne → JSON générique | `db::row_to_json`, `db::rows_to_json` | +| Lecture tolérante (`Number(r.x) \|\| 0`, `String(r.x ?? "")`) | `db::get_f64`, `db::f64_or0`, `db::get_string`, `db::string_or_empty` | +| Date ISO façon `toISOString()` | `db::iso(NaiveDateTime)`, `db::iso_utc` | +| Réglages `data/.db-config.json` | `config::read_config()`, `config::write_config(patch)`, `config::data_dir()`, `config::env_number(...)` | +| Cache FF (`cachedFF`) | `ff_cache::cached(key, CacheOpts { ttl, cache_if }, \|\| async { … }).await`, `ff_cache::invalidate()` | +| Journaux de job (`startCapture`…) | `logcapture::{start_capture, stop_capture, read_capture, list_captures}` | +| Session (routes internes) | extracteurs `auth::SessionUser`, `auth::AdminUser` (401/403 `{ error }`) ; `auth::Token` (jeton brut) ; `auth::require_session(token)` / `require_admin(token)` | +| Auth `/api/v1` | extracteur `auth::ApiAuth` (clé d'API ou session, erreurs au format v1) ; `ctx.require_admin()` | +| Enveloppe `/api/v1` | `api_v1_response::{ok, fail, build_pagination, ApiErrorCode}` | +| Erreurs | `error::{AppError, AppResult}` — `AppError` implémente `IntoResponse` (`{ error }`) et `From` | +| Magasins / gammes | `domain::{MAGASINS, CODE_TOUS_MAGASINS, nom_magasin, est_gamme_valide}` | +| Actions / pages | `rpc::{Ctx, Handler, json}` + macro `handler!(fonction)` | + +## Organisation + +Chaque module possède son répertoire `server/src//` et expose dans son +`mod.rs` : + +```rust +pub fn router() -> axum::Router; // routes HTTP (chemins identiques à Next.js) +pub fn actions() -> Vec<(&'static str, rpc::Handler)>; // ex. ("getUsers", handler!(get_users)) +pub fn pages() -> Vec<(&'static str, rpc::Handler)>; // ex. ("dashboard", handler!(page_dashboard)) +``` + +Le routeur Axum n'a pas d'état (`Router<()>`) : tout passe par les accès globaux +ci-dessus. Syntaxe des chemins Axum 0.8 : `/api/v1/products/{codein}`. + +## Actions (ex-« server actions ») + +`POST /api/actions/{nom}` — corps : tableau JSON des arguments positionnels, +réponse : la valeur de retour de la fonction TS, sérialisée à l'identique. +Une exception TS (ex. `requireSession()` qui lève) devient une `AppError` +(statut 401/403/500, corps `{ error }`) ; le client la relève en `Error`. + +Une action s'écrit : + +```rust +async fn get_users(ctx: Ctx) -> AppResult { + ctx.admin().await?; // = await requireAdmin() + let page: i64 = ctx.arg(0)?; // argument n°0 (null si absent → Option) + … + json(resultat) +} +``` + +| Action | Source TS | Module Rust | +|---|---|---| +| `getFournisseurs`, `getMagasins` | `features/grid/actions.ts` | `ff` | +| `getFournisseursPourCadence`, `listCadences`, `upsertCadence`, `toggleCadence`, `removeCadence` | `features/commandes-auto/actions.ts` | `ff` | +| `getUsers`, `createUser`, `deleteUser`, `updatePassword` | `features/admin/api/user-actions.ts` | `admin` | +| `getApiKeys`, `createApiKey`, `revokeApiKey`, `deleteApiKey` | `features/admin/api/api-key-actions.ts` | `admin` | +| `getParametresAffichables`, `testDatabaseConnection`, `saveDatabaseSettings`, `saveQlikSettings`, `testQlikConnection`, `saveFfApiSettings`, `testFfApiConnection` | `features/settings/actions.ts` | `admin` | +| `readSyncSettings`, `saveSyncSettings` | `features/admin/api/sync-settings.ts` | `admin` | +| `getSnapshots`, `deleteSnapshot`, `saveSnapshot` | `features/snapshots/api/*.ts` | `grid` | +| `saveDraftChanges` | `features/grid/api/save-draft-changes.ts` | `grid` | +| `getGammesAValider`, `rejeterGammesAValider` | `features/grid/api/gammes-a-valider-actions.ts` | `grid` | + +(`loginAction` disparaît : `POST /api/auth/login`.) + +## Pages (données des anciennes pages serveur) + +`GET /api/pages/{nom}?…` renvoie **uniquement les données** que la page serveur +obtenait de ses appels d'accès aux données (fonctions `pg*`, API FF, actions), +sous la forme exacte de leur valeur de retour TS. Toute la mise en forme pure +(pivots, listes de mois, totaux…) reste côté interface. En cas d'échec là où la +page affichait un `ErrorState` : statut 500 `{ error: message }`. + +| Page | Paramètres | Réponse | Module | +|---|---|---|---| +| `dashboard` | — | `pgGetDashboardData()` (cache 30 min) | `ff` | +| `analytics` | `mode` (`fournisseur`\|`nomenclature`), `mois`, `moisN1` | lignes de `pgGetCaByFournisseur` ou `pgGetCaByNomenclature` (cachedFF, mêmes clés) | `ff` | +| `hit-parade` | `debut`, `fin` | lignes de `pgGetHitParade` (cachedFF) | `ff` | +| `stock-negatif` | `magasin` | `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` (cachedFF) | `ff` | +| `commandes-auto` | — | `{ rows, cadences, fournisseurs }` | `ff` | +| `publicites` | `statut`, `page` | `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }` | `ff` | +| `produit-fiche` | `codein` ou `cc` | `getProduitFiche(codein)` / `getProduitFicheByCodeCentrale(cc)` (ou `null`) | `produits` | + +## Routes HTTP conservées (mêmes chemins, mêmes réponses) + +`/api/ff-status`, `/api/diag`, `/api/diag/stock-fournisseur`, `/api/test-db`, +`/api/commandes-auto` → `ff` ; `/api/qlik/sync` → `qlik` ; +`/api/grid/rows`, `/api/grid/rows/store-patch`, `/api/export/excel`, +`/api/export/modified-gammes`, `/api/v1/*` → `grid` ; +`/api/produits/search`, `/api/produits/opportunites` → `produits` ; +`/api/admin/sync`, `/api/admin/sync/fournisseurs`, `/api/admin/grid-warmup`, +`/api/logs` → `admin`. Authentification : `/api/auth/{login,logout,session}`. + +## Règles de traduction + +- **Formes JSON identiques** : noms de champs exacts (`#[serde(rename_all = "camelCase")]` + ou `rename` explicite selon l'interface TS), champs optionnels TS absents ⇒ + `#[serde(skip_serializing_if = "Option::is_none")]` (JSON.stringify omet `undefined`, + mais garde `null`). Une `Map` TS renvoyée au client se sérialise comme le faisait le TS + (souvent `Object.fromEntries`). +- **Même SQL** : recopier les requêtes telles quelles (paramètres `$1…`, `QueryBuilder` + ou `format!` contrôlé pour les fragments dynamiques — jamais de valeur utilisateur + interpolée sans échappement). Les colonnes `numeric` se lisent avec `db::get_f64`. +- **Même tolérance aux pannes** : une fonction TS qui avale l'erreur et rend `[]` + fait de même ; une qui lève propage une erreur. +- **Journaux** : `tracing::info!/warn!/error!` avec les mêmes préfixes (`[pg-ff]`, `[qlik]`…). +- **Dates** : le conteneur tourne en UTC ; `new Date()` ⇒ `chrono::Local::now()`. +- **Commentaires** en français, comme le code d'origine : garder les « pourquoi ». +- `cargo build` sans avertissement dans son module ; `cargo test` pour la logique pure. diff --git a/server/sql/init.sql b/server/sql/init.sql new file mode 100644 index 0000000..b2e7866 --- /dev/null +++ b/server/sql/init.sql @@ -0,0 +1,150 @@ +-- Schéma CollectFlow : joué à chaque démarrage (idempotent). +-- Porté depuis scripts/db-init.js. + +CREATE TABLE IF NOT EXISTS "ai_supplier_context" ( + "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, + "context" text NOT NULL, + "updated_at" timestamp DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS "session_snapshots" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer, + "code_fournisseur" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "magasin" varchar(20) NOT NULL, + "changes" jsonb NOT NULL, + "summary_json" jsonb, + "label" text, + "type" varchar(20) DEFAULT 'snapshot', + "created_at" timestamp DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date" + ON "session_snapshots" ("code_fournisseur", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date" + ON "session_snapshots" ("user_id", "type", "created_at" DESC); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" serial PRIMARY KEY NOT NULL, + "username" varchar(50) NOT NULL UNIQUE, + "password_hash" text NOT NULL, + "role" varchar(20) NOT NULL DEFAULT 'user', + "created_at" timestamp DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS "commande_cadences" ( + "id" serial PRIMARY KEY NOT NULL, + "code_fournisseur" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "site" varchar(20) NOT NULL, + "intervalle_semaines" integer NOT NULL, + "actif" boolean NOT NULL DEFAULT true, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site" + ON "commande_cadences" ("code_fournisseur", "site"); + +CREATE INDEX IF NOT EXISTS "idx_cadence_site" + ON "commande_cadences" ("site"); + +CREATE TABLE IF NOT EXISTS "qlik_network_metrics" ( + "code_centrale" varchar(20) PRIMARY KEY NOT NULL, + "ca_reseau" numeric(16, 2), + "qte_reseau" numeric(14, 2), + "nb_magasins_reseau" integer, + "ca_par_magasin_reseau" numeric(16, 2), + "marge_pct_reseau" numeric(8, 4), + "periode" varchar(20), + "fetched_at" timestamp DEFAULT now() +); + +ALTER TABLE "qlik_network_metrics" + ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2), + ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4), + ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb, + ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb, + ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255), + ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255); +ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau"; +ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau"; + +CREATE TABLE IF NOT EXISTS "grid_rows" ( + "code_fournisseur" varchar(20) NOT NULL, + "codein" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "libelle1" varchar(500), + "gtin" varchar(30), + "reference" varchar(100), + "code_centrale" varchar(20), + "code1" varchar(20), + "code2" varchar(20), + "code3" varchar(20), + "code_gamme" varchar(20), + "code_gamme_init" varchar(20), + "total_ca" numeric(16, 2), + "total_quantite" numeric(14, 2), + "total_marge" numeric(16, 2), + "taux_marge" numeric(8, 4), + "stock_actuel" numeric(14, 2), + "ca_reseau" numeric(16, 2), + "qte_reseau" numeric(14, 2), + "nb_magasins_reseau" integer, + "ca_par_magasin_reseau" numeric(16, 2), + "marge_pct_reseau" numeric(8, 4), + "payload" jsonb NOT NULL, + "computed_at" timestamp DEFAULT now() NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein" + ON "grid_rows" ("code_fournisseur", "codein"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1"); + +CREATE TABLE IF NOT EXISTS "api_keys" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "key_prefix" varchar(20) NOT NULL, + "key_hash" text NOT NULL UNIQUE, + "role" varchar(20) DEFAULT 'user' NOT NULL, + "created_by" varchar(50), + "created_at" timestamp DEFAULT now(), + "last_used_at" timestamp, + "revoked_at" timestamp +); + +CREATE TABLE IF NOT EXISTS "sync_fournisseurs" ( + "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, + "nom_fournisseur" varchar(255), + "actif_sql" boolean NOT NULL DEFAULT true, + "actif_qlik" boolean NOT NULL DEFAULT true, + "desactive_motif" text, + "dernier_sql_at" timestamp, + "dernier_sql_statut" varchar(20), + "dernier_sql_lignes" integer, + "dernier_sql_erreur" text, + "dernier_sql_ms" integer, + "dernier_qlik_at" timestamp, + "dernier_qlik_statut" varchar(20), + "dernier_qlik_codes" integer, + "dernier_qlik_erreur" text, + "dernier_qlik_ms" integer, + "updated_at" timestamp DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at"); +CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at"); + +CREATE TABLE IF NOT EXISTS "gammes_a_valider" ( + "code_fournisseur" varchar(20) NOT NULL, + "codein" varchar(20) NOT NULL, + "gamme" varchar(20) NOT NULL, + "gamme_precedente" varchar(20), + "auteur" varchar(100), + "created_at" timestamp DEFAULT now() NOT NULL, + PRIMARY KEY ("code_fournisseur", "codein") +); diff --git a/server/src/admin/mod.rs b/server/src/admin/mod.rs new file mode 100644 index 0000000..2306925 --- /dev/null +++ b/server/src/admin/mod.rs @@ -0,0 +1,22 @@ +//! Module `admin` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Tâches de fond lancées au démarrage (minuteur de la synchro nocturne). +pub fn start_background_tasks() {} diff --git a/server/src/api_v1_response.rs b/server/src/api_v1_response.rs new file mode 100644 index 0000000..cdbca9b --- /dev/null +++ b/server/src/api_v1_response.rs @@ -0,0 +1,81 @@ +//! Enveloppe de réponse de l'API publique `/api/v1`. +//! +//! Une forme unique : `{ data, pagination?, meta }` en succès, +//! `{ error: { code, message, details? } }` en échec, avec de vrais statuts HTTP. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use serde_json::{json, Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiErrorCode { + Unauthorized, + Forbidden, + BadRequest, + NotFound, + /// Donnée pas encore calculée : 202, ce n'est pas un échec. + NotReady, + InternalError, +} + +impl ApiErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::Unauthorized => "unauthorized", + Self::Forbidden => "forbidden", + Self::BadRequest => "bad_request", + Self::NotFound => "not_found", + Self::NotReady => "not_ready", + Self::InternalError => "internal_error", + } + } + pub fn status(self) -> StatusCode { + match self { + Self::Unauthorized => StatusCode::UNAUTHORIZED, + Self::Forbidden => StatusCode::FORBIDDEN, + Self::BadRequest => StatusCode::BAD_REQUEST, + Self::NotFound => StatusCode::NOT_FOUND, + Self::NotReady => StatusCode::ACCEPTED, + Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Pagination { + pub page: i64, + pub limit: i64, + pub total: i64, + pub total_pages: i64, + /// `true` s'il reste des pages à lire. + pub has_more: bool, +} + +pub fn build_pagination(page: i64, limit: i64, total: i64) -> Pagination { + let limit = limit.max(1); + let total_pages = ((total as f64) / (limit as f64)).ceil().max(1.0) as i64; + Pagination { page, limit, total, total_pages, has_more: page < total_pages } +} + +/// Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). +pub fn ok(data: T, pagination: Option, meta: Option>) -> Response { + let mut body = Map::new(); + body.insert("data".into(), serde_json::to_value(data).unwrap_or(Value::Null)); + if let Some(p) = pagination { + body.insert("pagination".into(), serde_json::to_value(p).unwrap_or(Value::Null)); + } + body.insert("meta".into(), Value::Object(meta.unwrap_or_default())); + Json(Value::Object(body)).into_response() +} + +/// Réponse d'erreur. `details` sert notamment aux erreurs de validation. +pub fn fail(code: ApiErrorCode, message: impl Into, details: Option) -> Response { + let mut err = json!({ "code": code.as_str(), "message": message.into() }); + if let Some(d) = details { + err["details"] = d; + } + (code.status(), Json(json!({ "error": err }))).into_response() +} diff --git a/server/src/auth/api_key.rs b/server/src/auth/api_key.rs new file mode 100644 index 0000000..a3591b1 --- /dev/null +++ b/server/src/auth/api_key.rs @@ -0,0 +1,149 @@ +//! Authentification de l'API publique `/api/v1`. +//! +//! Deux voies : clé d'API (`X-API-Key` ou `Authorization: Bearer …`) pour les +//! appelants non-navigateur, ou session du navigateur (l'interface utilise les +//! mêmes routes). Échec → 401 JSON au format `/api/v1`, jamais de redirection. +//! +//! Seul le hachage SHA-256 des clés est stocké (table `api_keys`). + +use crate::api_v1_response::{fail, ApiErrorCode}; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::HeaderMap; +use axum::response::Response; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use parking_lot::Mutex; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; +use subtle::ConstantTimeEq; + +const KEY_PREFIX: &str = "cf_"; +const TRACE_USAGE: Duration = Duration::from_secs(60); +static DERNIERE_TRACE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone)] +pub struct AuthContext { + /// "api_key" | "session" + pub via: &'static str, + pub role: String, + /// Nom de la clé, ou nom de l'utilisateur connecté. + pub subject: String, +} + +impl AuthContext { + /// Restreint un endpoint aux appelants administrateurs. + pub fn require_admin(&self) -> Result<(), Response> { + if self.role != "admin" { + return Err(fail(ApiErrorCode::Forbidden, "Rôle administrateur requis pour cette opération.", None)); + } + Ok(()) + } +} + +pub fn hash_api_key(key: &str) -> String { + hex::encode(Sha256::digest(key.as_bytes())) +} + +pub struct GeneratedKey { + pub key: String, + pub key_hash: String, + pub key_prefix: String, +} + +/// Génère une clé : en clair (affichée une seule fois), son hachage, et un préfixe lisible. +pub fn generate_api_key() -> GeneratedKey { + let mut buf = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut buf); + let key = format!("{KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf)); + GeneratedKey { key_hash: hash_api_key(&key), key_prefix: key.chars().take(11).collect(), key } +} + +fn hashes_equal(a: &str, b: &str) -> bool { + match (hex::decode(a), hex::decode(b)) { + (Ok(x), Ok(y)) => !x.is_empty() && x.len() == y.len() && bool::from(x.as_slice().ct_eq(y.as_slice())), + _ => false, + } +} + +fn read_key(headers: &HeaderMap) -> Option { + if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + let authz = headers.get("authorization").and_then(|v| v.to_str().ok())?; + let re = regex::Regex::new(r"(?i)^Bearer\s+(.+)$").unwrap(); + re.captures(authz).map(|c| c[1].trim().to_string()) +} + +/// Authentifie une requête `/api/v1`. +pub async fn require_api_auth(headers: &HeaderMap) -> Result { + if let Some(presented) = read_key(headers) { + let presented_hash = hash_api_key(&presented); + let pool = crate::db::pool().map_err(|e| fail(ApiErrorCode::InternalError, e.message, None))?; + let row = sqlx::query("SELECT id, name, role, key_hash, revoked_at FROM api_keys WHERE key_hash = $1 LIMIT 1") + .bind(&presented_hash) + .fetch_optional(&pool) + .await + .map_err(|e| fail(ApiErrorCode::InternalError, e.to_string(), None))?; + let Some(row) = row else { + return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None)); + }; + let key_hash: String = row.try_get("key_hash").unwrap_or_default(); + if !hashes_equal(&key_hash, &presented_hash) { + return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None)); + } + let revoked: Option = row.try_get("revoked_at").ok().flatten(); + if revoked.is_some() { + return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API révoquée.", None)); + } + let id: i32 = row.try_get("id").unwrap_or_default(); + // Trace d'usage — jamais bloquante, au plus une écriture par minute et par clé. + let tracer = { + let mut m = DERNIERE_TRACE.lock(); + let due = m.get(&id).map(|t| t.elapsed() >= TRACE_USAGE).unwrap_or(true); + if due { + m.insert(id, Instant::now()); + } + due + }; + if tracer { + tokio::spawn(async move { + if let Err(e) = sqlx::query("UPDATE api_keys SET last_used_at = now() WHERE id = $1").bind(id).execute(&pool).await { + tracing::error!("[api-auth] lastUsedAt KO: {}", crate::error::short(&e, 120)); + } + }); + } + return Ok(AuthContext { + via: "api_key", + role: row.try_get("role").unwrap_or_else(|_| "user".into()), + subject: row.try_get("name").unwrap_or_default(), + }); + } + + // Repli sur la session du navigateur. + if let Some(c) = super::session::from_headers(headers) { + return Ok(AuthContext { via: "session", role: c.role, subject: c.name }); + } + + Err(fail( + ApiErrorCode::Unauthorized, + "Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.", + None, + )) +} + +/// Extracteur pour les routes `/api/v1`. +pub struct ApiAuth(pub AuthContext); + +impl FromRequestParts for ApiAuth { + type Rejection = Response; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(ApiAuth(require_api_auth(&parts.headers).await?)) + } +} diff --git a/server/src/auth/authz.rs b/server/src/auth/authz.rs new file mode 100644 index 0000000..f8d0229 --- /dev/null +++ b/server/src/auth/authz.rs @@ -0,0 +1,155 @@ +//! Contrôle d'accès commun aux actions, routes et pages. +//! +//! Le jeton de session seul ne suffit pas : il reste valide jusqu'à son +//! expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On +//! relit donc l'utilisateur dans `users` (cache court), et le rôle en base +//! l'emporte sur celui du jeton. +//! +//! Base injoignable : on se contente du jeton (mode dégradé), sans quoi le +//! compte de secours `data/users.json` ne pourrait plus rien faire — y compris +//! configurer la base. + +use super::session::{from_headers, Claims}; +use crate::error::AppError; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use parking_lot::Mutex; +use serde::Serialize; +use sqlx::Row; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; + +pub const MESSAGE_SESSION: &str = "Session expirée : reconnectez-vous."; +pub const MESSAGE_ADMIN: &str = "Action réservée aux administrateurs."; + +const CACHE_TTL: Duration = Duration::from_secs(60); +const PAUSE_BASE_INJOIGNABLE: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, Serialize)] +pub struct UtilisateurCourant { + pub id: String, + pub username: String, + pub role: String, +} + +impl UtilisateurCourant { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } + /// Identifiant numérique (`users.id`), `None` pour le compte de secours. + pub fn id_numerique(&self) -> Option { + self.id.parse::().ok().filter(|v| *v > 0) + } +} + +#[derive(Clone)] +struct Fiche { + exists: bool, + role: String, + username: String, + at: Instant, +} + +struct CacheState { + fiches: HashMap, + injoignable_depuis: Option, +} + +static CACHE: LazyLock> = + LazyLock::new(|| Mutex::new(CacheState { fiches: HashMap::new(), injoignable_depuis: None })); + +/// Oublie la fiche d'un utilisateur (après modification de son compte). +pub fn invalider(id: &str) { + CACHE.lock().fiches.remove(id); +} + +/// Fiche de l'utilisateur en base, ou `None` si la base n'a pas pu répondre. +async fn lire_fiche(id: &str) -> Option { + { + let c = CACHE.lock(); + if let Some(f) = c.fiches.get(id) { + if f.at.elapsed() < CACHE_TTL { + return Some(f.clone()); + } + } + if c.injoignable_depuis.map(|t| t.elapsed() < PAUSE_BASE_INJOIGNABLE).unwrap_or(false) { + return None; + } + } + let id_num: i32 = if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() { + id.parse::().unwrap_or(-1) + } else { + -1 + }; + let res = async { + let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + let row = sqlx::query("SELECT username, role FROM users WHERE id = $1 LIMIT 1") + .bind(id_num) + .fetch_optional(&pool) + .await?; + anyhow::Ok(row) + } + .await; + match res { + Ok(row) => { + let fiche = match row { + Some(r) => Fiche { + exists: true, + username: r.try_get("username").unwrap_or_default(), + role: r.try_get("role").unwrap_or_default(), + at: Instant::now(), + }, + None => Fiche { exists: false, username: String::new(), role: String::new(), at: Instant::now() }, + }; + CACHE.lock().fiches.insert(id.to_string(), fiche.clone()); + Some(fiche) + } + Err(e) => { + CACHE.lock().injoignable_depuis = Some(Instant::now()); + tracing::warn!("[authz] Base injoignable, contrôle sur le seul jeton de session : {}", crate::error::short(&e, 200)); + None + } + } +} + +/// Utilisateur connecté, relu en base. 401 sinon. +pub async fn require_session(token: Option<&Claims>) -> Result { + let jeton = token.ok_or_else(|| AppError::unauthorized(MESSAGE_SESSION))?; + match lire_fiche(&jeton.id).await { + None => Ok(UtilisateurCourant { id: jeton.id.clone(), username: jeton.name.clone(), role: jeton.role.clone() }), + Some(f) if !f.exists => Err(AppError::unauthorized(MESSAGE_SESSION)), + Some(f) => Ok(UtilisateurCourant { id: jeton.id.clone(), username: f.username, role: f.role }), + } +} + +/// Administrateur connecté. 401 ou 403 sinon. +pub async fn require_admin(token: Option<&Claims>) -> Result { + let u = require_session(token).await?; + if !u.is_admin() { + return Err(AppError::forbidden(MESSAGE_ADMIN)); + } + Ok(u) +} + +/// Extracteur : utilisateur connecté (sinon 401 JSON `{ error }`). +pub struct SessionUser(pub UtilisateurCourant); + +impl FromRequestParts for SessionUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let claims = from_headers(&parts.headers); + Ok(SessionUser(require_session(claims.as_ref()).await?)) + } +} + +/// Extracteur : administrateur connecté (sinon 401/403 JSON `{ error }`). +pub struct AdminUser(pub UtilisateurCourant); + +impl FromRequestParts for AdminUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let claims = from_headers(&parts.headers); + Ok(AdminUser(require_admin(claims.as_ref()).await?)) + } +} diff --git a/server/src/auth/fallback.rs b/server/src/auth/fallback.rs new file mode 100644 index 0000000..b019c7a --- /dev/null +++ b/server/src/auth/fallback.rs @@ -0,0 +1,62 @@ +//! Comptes de secours `data/users.json`, utilisés seulement quand la base est +//! injoignable (première installation, base pas encore configurée). + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserJson { + pub id: String, + pub username: String, + pub password_hash: String, + pub role: String, +} + +fn path() -> PathBuf { + crate::config::data_dir().join("users.json") +} + +fn default_admin() -> UserJson { + UserJson { + id: "0".into(), + username: "admin".into(), + password_hash: super::password::hash_password("admin"), + role: "admin".into(), + } +} + +/// Comptes de secours (créés avec admin/admin si le fichier n'existe pas). +pub fn get_fallback_users() -> Vec { + let p = path(); + if !p.exists() { + let admin = default_admin(); + save_fallback_users(&[admin.clone()]); + return vec![admin]; + } + match std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok()) { + Some(users) => users, + None => { + tracing::error!("[AUTH] Error reading users.json"); + vec![] + } + } +} + +pub fn save_fallback_users(users: &[UserJson]) { + let p = path(); + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Err(e) = std::fs::write(&p, serde_json::to_string_pretty(users).unwrap_or_default()) { + tracing::error!("[AUTH] Error writing users.json: {e}"); + } +} + +/// Base injoignable : le fichier de secours doit contenir au moins un administrateur. +pub fn assurer_admin_de_secours() { + if get_fallback_users().is_empty() { + tracing::info!("[AUTH] Initializing local JSON fallback with admin/admin..."); + save_fallback_users(&[default_admin()]); + } +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs new file mode 100644 index 0000000..a1da3d4 --- /dev/null +++ b/server/src/auth/mod.rs @@ -0,0 +1,191 @@ +//! Authentification : comptes, sessions, clés d'API. + +pub mod api_key; +pub mod authz; +pub mod fallback; +pub mod password; +pub mod session; + +pub use api_key::{ApiAuth, AuthContext}; +pub use authz::{require_admin, require_session, AdminUser, SessionUser, UtilisateurCourant}; +pub use session::{Claims, Token}; + +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; +use sqlx::Row; +use tokio::sync::Mutex; + +// --------------------------------------------------------------------------- +// Amorçage du compte admin +// --------------------------------------------------------------------------- + +/// `true` une fois l'amorçage réussi pour ce processus. +static AMORCE: Mutex = Mutex::const_new(false); + +/// Prépare les comptes au premier démarrage, une seule fois par processus. +/// +/// - Base joignable : crée la table `users` si besoin, puis admin/admin +/// **uniquement si la table est vide**. +/// - Base injoignable : s'assure que le fichier de secours contient un +/// administrateur ; on retentera la base à la prochaine connexion. +pub async fn ensure_admin_exists() -> bool { + let mut done = AMORCE.lock().await; + if *done { + return false; + } + match amorcer().await { + Ok(cree) => { + *done = true; + cree + } + Err(e) => { + tracing::warn!("[AUTH] Database seeding failed (expected if DB not configured yet): {e:#}"); + false + } + } +} + +async fn amorcer() -> anyhow::Result { + let pool = match crate::db::pool() { + Ok(p) => p, + Err(e) => { + fallback::assurer_admin_de_secours(); + anyhow::bail!(e.message); + } + }; + if let Err(e) = sqlx::query("SELECT 1").execute(&pool).await { + fallback::assurer_admin_de_secours(); + return Err(e.into()); + } + sqlx::raw_sql( + r#"CREATE TABLE IF NOT EXISTS "users" ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + created_at TIMESTAMP DEFAULT NOW() + )"#, + ) + .execute(&pool) + .await?; + let nb: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(&pool).await?; + if nb > 0 { + return Ok(false); + } + tracing::info!("[AUTH] Table users vide : création du compte admin/admin."); + let res = sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('admin', $1, 'admin') ON CONFLICT DO NOTHING") + .bind(password::hash_password("admin")) + .execute(&pool) + .await?; + Ok(res.rows_affected() > 0) +} + +// --------------------------------------------------------------------------- +// Connexion / déconnexion +// --------------------------------------------------------------------------- + +struct CompteTrouve { + id: String, + username: String, + password_hash: String, + role: String, +} + +/// Vérifie les identifiants. `None` si refusés. +async fn authorize(username: &str, password: &str) -> Option { + ensure_admin_exists().await; + if username.is_empty() || password.is_empty() { + return None; + } + tracing::info!("[AUTH] Checking user \"{username}\"..."); + + let db_lookup = async { + let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + let row = sqlx::query("SELECT id, username, password_hash, role FROM users WHERE username = $1") + .bind(username) + .fetch_optional(&pool) + .await?; + anyhow::Ok(row.map(|r| CompteTrouve { + id: r.try_get::("id").unwrap_or_default().to_string(), + username: r.try_get("username").unwrap_or_default(), + password_hash: r.try_get("password_hash").unwrap_or_default(), + role: r.try_get("role").unwrap_or_else(|_| "user".into()), + })) + }; + + let user = match db_lookup.await { + Ok(u) => { + tracing::info!("[AUTH] DB check: User found: {}", u.is_some()); + u + } + Err(_) => { + // Base injoignable uniquement : un compte absent de la base ne doit + // pas pouvoir se connecter avec le fichier de secours. + tracing::warn!("[AUTH] DB unreachable, falling back to JSON."); + fallback::get_fallback_users().into_iter().find(|u| u.username == username).map(|u| CompteTrouve { + id: u.id, + username: u.username, + password_hash: u.password_hash, + role: u.role, + }) + } + }?; + + let ok = password::verify_password(password, &user.password_hash); + tracing::info!("[AUTH] Password valid for \"{username}\": {ok}"); + ok.then_some(user) +} + +#[derive(Deserialize)] +struct LoginBody { + #[serde(default)] + username: String, + #[serde(default)] + password: String, +} + +async fn login(headers: HeaderMap, Json(body): Json) -> Response { + let Some(user) = authorize(body.username.trim(), &body.password).await else { + return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Utilisateur ou mot de passe incorrect." }))).into_response(); + }; + let claims = Claims { + id: user.id.clone(), + name: user.username.clone(), + role: user.role.clone(), + exp: chrono::Utc::now().timestamp() + session::MAX_AGE_SECS, + }; + ( + [(header::SET_COOKIE, session::set_cookie(&claims, &headers)), (header::CACHE_CONTROL, "no-store".into())], + Json(json!({ "ok": true, "user": { "id": user.id, "name": user.username, "role": user.role } })), + ) + .into_response() +} + +async fn logout() -> Response { + ([(header::SET_COOKIE, session::clear_cookie())], Json(json!({ "ok": true }))).into_response() +} + +/// Session courante pour l'interface (`useSession`). Relue en base : un compte +/// supprimé n'a plus de session. +async fn current_session(Token(token): Token) -> Response { + let user = match token.as_ref() { + Some(_) => authz::require_session(token.as_ref()).await.ok(), + None => None, + }; + let body = match user { + Some(u) => json!({ "user": { "id": u.id, "name": u.username, "role": u.role } }), + None => json!({ "user": null }), + }; + ([(header::CACHE_CONTROL, "no-store")], Json(body)).into_response() +} + +pub fn router() -> Router { + Router::new() + .route("/api/auth/login", post(login)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/session", get(current_session)) +} diff --git a/server/src/auth/password.rs b/server/src/auth/password.rs new file mode 100644 index 0000000..9a53311 --- /dev/null +++ b/server/src/auth/password.rs @@ -0,0 +1,60 @@ +//! Mots de passe : scrypt au format `sel:hachage` (hex), compatible avec les +//! comptes créés par l'ancienne version Node (`scryptSync(pwd, sel, 64)`, +//! N=16384, r=8, p=1, le sel hexadécimal étant utilisé tel quel comme octets). + +use rand::RngCore; +use subtle::ConstantTimeEq; + +fn derive(password: &str, salt: &str) -> Option<[u8; 64]> { + let params = scrypt::Params::new(14, 8, 1, 64).ok()?; + let mut out = [0u8; 64]; + scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut out).ok()?; + Some(out) +} + +pub fn hash_password(password: &str) -> String { + let mut salt = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut salt); + let salt = hex::encode(salt); + let hash = derive(password, &salt).map(hex::encode).unwrap_or_default(); + format!("{salt}:{hash}") +} + +pub fn verify_password(password: &str, stored: &str) -> bool { + let Some((salt, hash)) = stored.split_once(':') else { return false }; + if salt.is_empty() || hash.is_empty() { + return false; + } + let Ok(expected) = hex::decode(hash) else { return false }; + let Some(actual) = derive(password, salt) else { return false }; + expected.len() == actual.len() && bool::from(actual.as_slice().ct_eq(expected.as_slice())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let h = hash_password("admin"); + assert!(verify_password("admin", &h)); + assert!(!verify_password("nope", &h)); + } + + #[test] + fn compatible_node() { + // require("crypto").scryptSync("admin", "00112233445566778899aabbccddeeff", 64).toString("hex") + let stored = "00112233445566778899aabbccddeeff:".to_string() + + &hex::encode(derive("admin", "00112233445566778899aabbccddeeff").unwrap()); + assert!(verify_password("admin", &stored)); + } +} + +#[cfg(test)] +mod node_compat { + #[test] + fn hash_from_node() { + assert!(super::verify_password("s3cret", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa")); + assert!(!super::verify_password("s3cre", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa")); + } +} diff --git a/server/src/auth/session.rs b/server/src/auth/session.rs new file mode 100644 index 0000000..d90a945 --- /dev/null +++ b/server/src/auth/session.rs @@ -0,0 +1,124 @@ +//! Jeton de session signé (cookie HttpOnly), remplaçant le JWT NextAuth. +//! +//! Contenu : identifiant, nom et rôle de l'utilisateur + expiration, encodés en +//! base64url et signés HMAC-SHA256 avec `AUTH_SECRET` (ou un secret aléatoire +//! conservé dans `data/.auth-secret`). + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::HeaderMap; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::convert::Infallible; +use std::sync::LazyLock; + +pub const COOKIE_NAME: &str = "cf_session"; +/// Durée de vie d'une session : 30 jours (comme NextAuth). +pub const MAX_AGE_SECS: i64 = 30 * 24 * 3600; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + /// Identifiant (`users.id`, ou « 0 » pour le compte de secours). + pub id: String, + pub name: String, + pub role: String, + /// Expiration (secondes Unix). + pub exp: i64, +} + +static SECRET: LazyLock> = LazyLock::new(|| { + if let Ok(s) = std::env::var("AUTH_SECRET") { + if !s.trim().is_empty() { + return s.into_bytes(); + } + } + let path = crate::config::data_dir().join(".auth-secret"); + if let Ok(s) = std::fs::read_to_string(&path) { + if !s.trim().is_empty() { + return s.trim().as_bytes().to_vec(); + } + } + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + let s = hex::encode(buf); + let _ = std::fs::create_dir_all(crate::config::data_dir()); + if let Err(e) = std::fs::write(&path, &s) { + tracing::warn!("[auth] secret de session non persisté ({e}) : les sessions ne survivront pas au redémarrage"); + } + s.into_bytes() +}); + +fn sign(payload: &str) -> String { + let mut mac = Hmac::::new_from_slice(&SECRET).expect("hmac"); + mac.update(payload.as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} + +pub fn encode(claims: &Claims) -> String { + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default()); + let sig = sign(&payload); + format!("{payload}.{sig}") +} + +pub fn decode(token: &str) -> Option { + let (payload, sig) = token.split_once('.')?; + let mut mac = Hmac::::new_from_slice(&SECRET).ok()?; + mac.update(payload.as_bytes()); + let sig_bytes = URL_SAFE_NO_PAD.decode(sig).ok()?; + mac.verify_slice(&sig_bytes).ok()?; + let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?; + if claims.exp < chrono::Utc::now().timestamp() { + return None; + } + Some(claims) +} + +/// Jeton lu dans l'en-tête `Cookie`, s'il est présent et valide. +pub fn from_headers(headers: &HeaderMap) -> Option { + for value in headers.get_all(axum::http::header::COOKIE) { + let Ok(s) = value.to_str() else { continue }; + for part in s.split(';') { + let part = part.trim(); + if let Some(v) = part.strip_prefix(COOKIE_NAME).and_then(|r| r.strip_prefix('=')) { + if let Some(c) = decode(v) { + return Some(c); + } + } + } + } + None +} + +fn is_https(headers: &HeaderMap) -> bool { + headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("https")) + .unwrap_or(false) +} + +/// En-tête `Set-Cookie` qui ouvre la session. +pub fn set_cookie(claims: &Claims, req_headers: &HeaderMap) -> String { + let secure = if is_https(req_headers) { "; Secure" } else { "" }; + format!("{COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={MAX_AGE_SECS}{secure}", encode(claims)) +} + +/// En-tête `Set-Cookie` qui ferme la session. +pub fn clear_cookie() -> String { + format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") +} + +/// Jeton de la requête (jamais de rejet : `None` si absent ou invalide). +#[derive(Debug, Clone)] +pub struct Token(pub Option); + +impl FromRequestParts for Token { + type Rejection = Infallible; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(Token(from_headers(&parts.headers))) + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..d386c94 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,99 @@ +//! Fichier de configuration `data/.db-config.json` (réglages saisis dans Paramètres). +//! +//! Le fichier contient des mots de passe et des clés d'API : il ne repart jamais +//! tel quel vers le navigateur. Les clés inconnues sont conservées à l'écriture. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::path::PathBuf; + +/// Répertoire des données persistantes (volume Docker `/app/data`). +pub fn data_dir() -> PathBuf { + std::env::var("DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default().join("data")) +} + +pub fn config_file() -> PathBuf { + data_dir().join(".db-config.json") +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DbConfig { + #[serde(default)] + pub url: Option, + #[serde(default)] + pub open_router_key: Option, + #[serde(default)] + pub open_router_model: Option, + #[serde(default)] + pub ai_provider: Option, + #[serde(default)] + pub google_ai_key: Option, + #[serde(default)] + pub google_ai_model: Option, + /// Qlik Sense — données réseau. + #[serde(default)] + pub qlik_host: Option, + #[serde(default)] + pub qlik_user: Option, + #[serde(default)] + pub qlik_password: Option, + /// API REST FF Nancy (ex. https://api.ffnancy.fr). + #[serde(default)] + pub ff_api_base_url: Option, +} + +fn read_raw_sync() -> Map { + std::fs::read_to_string(config_file()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| match v { + Value::Object(m) => Some(m), + _ => None, + }) + .unwrap_or_default() +} + +/// Lit la config existante (vide si absente ou illisible). +pub fn read_config() -> DbConfig { + serde_json::from_value(Value::Object(read_raw_sync())).unwrap_or_default() +} + +/// Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont +/// conservées ; une clé passée à `null` est retirée. +pub fn write_config(patch: Map) -> std::io::Result<()> { + let mut existing = read_raw_sync(); + for (k, v) in patch { + if v.is_null() { + existing.remove(&k); + } else { + existing.insert(k, v); + } + } + std::fs::create_dir_all(data_dir())?; + let text = serde_json::to_string_pretty(&Value::Object(existing)).unwrap_or_else(|_| "{}".into()); + std::fs::write(config_file(), text) +} + +/// Chaîne non vide, sinon `None`. +pub fn non_empty(s: Option) -> Option { + s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +/// URL de connexion PostgreSQL : réglage enregistré, sinon `DATABASE_URL`. +pub fn database_url() -> Option { + non_empty(read_config().url).or_else(|| non_empty(std::env::var("DATABASE_URL").ok())) +} + +/// Variable d'environnement numérique bornée (équivalent de `envNumber` côté TS). +pub fn env_number(name: &str, default: i64, min: i64, max: i64) -> i64 { + match std::env::var(name) { + Ok(raw) if !raw.trim().is_empty() => match raw.trim().parse::() { + Ok(v) if v.is_finite() => (v.trunc() as i64).clamp(min, max), + _ => default, + }, + _ => default, + } +} diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..d163c06 --- /dev/null +++ b/server/src/db.rs @@ -0,0 +1,202 @@ +//! Pool PostgreSQL unique par processus, remplaçable à chaud. +//! +//! L'URL vient de `data/.db-config.json` (Paramètres), sinon de `DATABASE_URL`. +//! Enregistrer de nouveaux réglages appelle [`refresh`] : le pool suivant est +//! recréé à la première requête. +//! +//! La même base porte les tables de l'application et la recopie nocturne des +//! tables FF Nancy (`articles`, `mvtart`, `cube_stock`, `fouident`…). + +use crate::error::{AppError, AppResult}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; +use parking_lot::RwLock; +use serde_json::{Map, Value}; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgRow}; +use sqlx::{Column, Executor, PgPool, Row, TypeInfo, ValueRef}; +use std::str::FromStr; +use std::sync::LazyLock; +use std::time::Duration; + +static POOL: LazyLock>> = LazyLock::new(|| RwLock::new(None)); + +/// Masque le mot de passe d'une URL de connexion pour les journaux. +pub fn mask_url(url: &str) -> String { + let re = regex::Regex::new(r":([^@/]+)@").unwrap(); + re.replace(url, ":****@").into_owned() +} + +/// Options de connexion communes (pool applicatif et tests de connexion). +pub fn connect_options(url: &str) -> Result { + let mut opts = PgConnectOptions::from_str(url)?.application_name("collectflow"); + // Délai maximal par requête : opt-in (les calculs nocturnes des plus gros + // fournisseurs peuvent être longs). + if let Some(ms) = std::env::var("PG_STATEMENT_TIMEOUT_MS").ok().and_then(|v| v.parse::().ok()).filter(|v| *v > 0) { + opts = opts.options([("statement_timeout", ms.to_string())]); + } + Ok(opts) +} + +fn build_pool(url: &str) -> Result { + let opts = connect_options(url)?; + Ok(PgPoolOptions::new() + .max_connections(20) + .idle_timeout(Duration::from_secs(30)) + .acquire_timeout(Duration::from_secs(10)) + // Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop + // petit dans le conteneur, et font échouer les grosses agrégations. + .after_connect(|conn, _meta| { + Box::pin(async move { + if let Err(e) = conn.execute("SET max_parallel_workers_per_gather = 0").await { + tracing::error!("[DB] SET max_parallel_workers_per_gather failed: {e}"); + } + Ok(()) + }) + }) + .connect_lazy_with(opts)) +} + +/// Pool courant, créé à la demande. Erreur 500 si aucune base n'est configurée. +pub fn pool() -> AppResult { + if let Some(p) = POOL.read().as_ref() { + return Ok(p.clone()); + } + let mut guard = POOL.write(); + if let Some(p) = guard.as_ref() { + return Ok(p.clone()); + } + let url = crate::config::database_url().ok_or_else(|| { + tracing::error!("[DB] CRITICAL: No database connection string available!"); + AppError::internal("Base de données non configurée (Paramètres → Base de données).") + })?; + tracing::info!("[DB] Initializing connection with: {}", mask_url(&url)); + let p = build_pool(&url).map_err(|e| AppError::internal(format!("URL de base invalide : {e}")))?; + *guard = Some(p.clone()); + Ok(p) +} + +/// Oublie le pool courant (réglages modifiés dans Paramètres). +pub fn refresh() { + tracing::info!("[DB] Refreshing database connection..."); + if let Some(old) = POOL.write().take() { + tokio::spawn(async move { old.close().await }); + } +} + +// --------------------------------------------------------------------------- +// Conversion générique d'une ligne en JSON +// --------------------------------------------------------------------------- + +/// Horodatage au format JS `toISOString()` (`2026-01-31T12:34:56.789Z`). +pub fn iso(dt: NaiveDateTime) -> String { + dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +pub fn iso_utc(dt: DateTime) -> String { + iso(dt.naive_utc()) +} + +/// Convertit une ligne en objet JSON, colonne par colonne, selon le type SQL. +/// +/// Les `numeric` deviennent des nombres (le pilote Node les rendait en chaînes, +/// que le code convertissait avec `Number()`), les horodatages des chaînes ISO. +pub fn row_to_json(row: &PgRow) -> Value { + let mut obj = Map::new(); + for (i, col) in row.columns().iter().enumerate() { + obj.insert(col.name().to_string(), cell_to_json(row, i)); + } + Value::Object(obj) +} + +pub fn rows_to_json(rows: &[PgRow]) -> Vec { + rows.iter().map(row_to_json).collect() +} + +fn cell_to_json(row: &PgRow, i: usize) -> Value { + let raw = match row.try_get_raw(i) { + Ok(r) => r, + Err(_) => return Value::Null, + }; + if raw.is_null() { + return Value::Null; + } + let ty = raw.type_info().name().to_ascii_uppercase(); + macro_rules! get { + ($t:ty) => { + row.try_get::<$t, _>(i).ok() + }; + } + let num = |f: Option| f.and_then(serde_json::Number::from_f64).map(Value::Number).unwrap_or(Value::Null); + match ty.as_str() { + "BOOL" => get!(bool).map(Value::Bool).unwrap_or(Value::Null), + "INT2" => get!(i16).map(|v| Value::from(v)).unwrap_or(Value::Null), + "INT4" => get!(i32).map(|v| Value::from(v)).unwrap_or(Value::Null), + "INT8" => get!(i64).map(|v| Value::from(v)).unwrap_or(Value::Null), + "FLOAT4" => num(get!(f32).map(|v| v as f64)), + "FLOAT8" => num(get!(f64)), + "NUMERIC" => num(get!(rust_decimal::Decimal).and_then(|d| d.to_string().parse::().ok())), + "JSON" | "JSONB" => get!(Value).unwrap_or(Value::Null), + "TIMESTAMP" => get!(NaiveDateTime).map(|d| Value::String(iso(d))).unwrap_or(Value::Null), + "TIMESTAMPTZ" => get!(DateTime).map(|d| Value::String(iso_utc(d))).unwrap_or(Value::Null), + "DATE" => get!(NaiveDate) + .map(|d| Value::String(iso(d.and_hms_opt(0, 0, 0).unwrap()))) + .unwrap_or(Value::Null), + "TEXT[]" | "VARCHAR[]" | "_TEXT" | "_VARCHAR" => get!(Vec).map(Value::from).unwrap_or(Value::Null), + _ => get!(String).map(Value::String).unwrap_or(Value::Null), + } +} + +// --------------------------------------------------------------------------- +// Lecture tolérante de colonnes (équivalent des `Number(r.x) || 0` du code TS) +// --------------------------------------------------------------------------- + +/// Valeur numérique d'une colonne quel que soit son type SQL (int, numeric, +/// float, texte numérique). `None` si NULL ou illisible. +pub fn get_f64(row: &PgRow, col: &str) -> Option { + let raw = row.try_get_raw(col).ok()?; + if raw.is_null() { + return None; + } + let ty = raw.type_info().name().to_ascii_uppercase(); + match ty.as_str() { + "INT2" => row.try_get::(col).ok().map(|v| v as f64), + "INT4" => row.try_get::(col).ok().map(|v| v as f64), + "INT8" => row.try_get::(col).ok().map(|v| v as f64), + "FLOAT4" => row.try_get::(col).ok().map(|v| v as f64), + "FLOAT8" => row.try_get::(col).ok(), + "NUMERIC" => row + .try_get::(col) + .ok() + .and_then(|d| d.to_string().parse::().ok()), + _ => row.try_get::(col).ok().and_then(|s| s.trim().parse::().ok()), + } + .filter(|v| v.is_finite()) +} + +/// `Number(r.col) || 0`. +pub fn f64_or0(row: &PgRow, col: &str) -> f64 { + get_f64(row, col).unwrap_or(0.0) +} + +/// Valeur texte d'une colonne quel que soit son type (nombres et dates convertis). +pub fn get_string(row: &PgRow, col: &str) -> Option { + let idx = row.columns().iter().position(|c| c.name() == col)?; + match cell_to_json(row, idx) { + Value::Null => None, + Value::String(s) => Some(s), + Value::Number(n) => { + // 12.0 → "12", comme String(12) en JS. + let f = n.as_f64().unwrap_or_default(); + if f.fract() == 0.0 && f.abs() < 1e15 { + Some(format!("{}", f as i64)) + } else { + Some(n.to_string()) + } + } + other => Some(other.to_string()), + } +} + +/// `String(r.col ?? "")`. +pub fn string_or_empty(row: &PgRow, col: &str) -> String { + get_string(row, col).unwrap_or_default() +} diff --git a/server/src/domain.rs b/server/src/domain.rs new file mode 100644 index 0000000..da49d19 --- /dev/null +++ b/server/src/domain.rs @@ -0,0 +1,52 @@ +//! Référentiels partagés : magasins et gammes (miroir de `src/lib/magasins.ts` +//! et `src/lib/gammes.ts` côté interface). + +pub struct Magasin { + /// Code FF du site (clé de toutes les requêtes). + pub code: &'static str, + /// Nom complet. + pub nom: &'static str, + /// Nom court. + pub court: &'static str, +} + +pub const MAGASINS: [Magasin; 2] = [ + Magasin { code: "292", nom: "Frouard (Nancy)", court: "Frouard" }, + Magasin { code: "579", nom: "Houdemont", court: "Houdemont" }, +]; + +/// Code utilisé par la Grille et l'API pour « tous nos magasins ». +pub const CODE_TOUS_MAGASINS: &str = "TOTAL"; +pub const LIBELLE_TOUS_MAGASINS: &str = "Nos 2 magasins"; + +/// Anciens codes encore présents dans certaines réponses de l'API FF. +fn alias(code: &str) -> &str { + match code { + "F01" => "292", + "F02" => "579", + c => c, + } +} + +pub fn trouver_magasin(code: &str) -> Option<&'static Magasin> { + let c = alias(code.trim()); + MAGASINS.iter().find(|m| m.code == c) +} + +/// Nom lisible d'un magasin. `TOTAL` (ou vide) → « Nos 2 magasins ». +pub fn nom_magasin(code: Option<&str>, court: bool) -> String { + match code { + None | Some("") | Some(CODE_TOUS_MAGASINS) => LIBELLE_TOUS_MAGASINS.to_string(), + Some(c) => match trouver_magasin(c) { + Some(m) => (if court { m.court } else { m.nom }).to_string(), + None => c.to_string(), + }, + } +} + +/// Codes de gamme valides. +pub const CODES_GAMME: [&str; 5] = ["A", "B", "C", "Y", "Z"]; + +pub fn est_gamme_valide(code: &str) -> bool { + CODES_GAMME.contains(&code) +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..3e40518 --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,105 @@ +//! Erreurs HTTP communes. +//! +//! Les routes « internes » (appelées par l'interface) répondent `{ "error": "…" }` +//! avec le statut adéquat ; l'API publique `/api/v1` a sa propre enveloppe +//! (`crate::api_v1_response`). + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Value}; + +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub message: String, + /// Corps JSON complet à renvoyer à la place de `{ error }`. + pub body: Option, +} + +pub type AppResult = Result; + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { status, message: message.into(), body: None } + } + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + pub fn unauthorized(message: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, message) + } + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, message) + } + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, message) + } + pub fn internal(message: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, message) + } + /// Réponse au corps libre (ex. `{ success: false, error }` avec un statut 500). + pub fn with_body(status: StatusCode, body: Value) -> Self { + let message = body.get("error").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + Self { status, message, body: Some(body) } + } +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.status, self.message) + } +} + +impl std::error::Error for AppError {} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let body = self.body.unwrap_or_else(|| json!({ "error": self.message })); + (self.status, Json(body)).into_response() + } +} + +impl From for AppError { + fn from(e: anyhow::Error) -> Self { + // Une AppError enveloppée dans anyhow garde son statut. + match e.downcast::() { + Ok(app) => app, + Err(e) => { + tracing::error!("[error] {e:#}"); + Self::internal(format!("{e:#}")) + } + } + } +} + +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + tracing::error!("[db] {e}"); + Self::internal(e.to_string()) + } +} + +impl From for AppError { + fn from(e: serde_json::Error) -> Self { + Self::bad_request(e.to_string()) + } +} + +impl From for AppError { + fn from(e: std::io::Error) -> Self { + Self::internal(e.to_string()) + } +} + +impl From for AppError { + fn from(e: reqwest::Error) -> Self { + Self::internal(e.to_string()) + } +} + +/// Message d'une erreur quelconque tronqué à `n` caractères (pour les journaux). +pub fn short(e: &dyn std::fmt::Display, n: usize) -> String { + let s = e.to_string(); + s.chars().take(n).collect() +} diff --git a/server/src/ff/mod.rs b/server/src/ff/mod.rs new file mode 100644 index 0000000..d861561 --- /dev/null +++ b/server/src/ff/mod.rs @@ -0,0 +1,19 @@ +//! Module `ff` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/ff_cache.rs b/server/src/ff_cache.rs new file mode 100644 index 0000000..6741920 --- /dev/null +++ b/server/src/ff_cache.rs @@ -0,0 +1,156 @@ +//! Cache mémoire des lectures « FF » (base miroir et API FF Nancy). +//! +//! Les tables FF ne sont recopiées qu'une fois par nuit : recalculer à chaque +//! visite les mêmes agrégations lourdes coûtait plusieurs secondes par page. +//! - durée de vie bornée (`FF_CACHE_TTL_S`, 30 min par défaut) ; +//! - les appels simultanés pour la même clé partagent une seule requête ; +//! - une erreur n'est jamais mise en cache ; +//! - [`invalidate`] vide tout (fin de synchro nocturne). + +use futures::future::{BoxFuture, FutureExt, Shared}; +use parking_lot::Mutex; +use std::any::Any; +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; + +const MAX_ENTRIES: usize = 100; + +type AnyArc = Arc; +type Pending = Shared>>>; + +enum Entry { + Ready { value: AnyArc, expires_at: Instant, inserted: u64 }, + Pending(Pending), +} + +struct Store { + map: HashMap, + seq: u64, +} + +static STORE: LazyLock> = LazyLock::new(|| Mutex::new(Store { map: HashMap::new(), seq: 0 })); +static GENERATION: AtomicU64 = AtomicU64::new(0); + +pub fn default_ttl() -> Duration { + let s = std::env::var("FF_CACHE_TTL_S").ok().and_then(|v| v.parse::().ok()).filter(|v| *v > 0).unwrap_or(30 * 60); + Duration::from_secs(s) +} + +/// Options d'une lecture mise en cache. +pub struct CacheOpts { + /// Durée de vie propre à cette clé. + pub ttl: Option, + /// Ne garder le résultat que s'il passe ce test (ex. liste non vide : une + /// lecture qui avale ses erreurs ne doit pas figer une panne passagère). + pub cache_if: Option bool>, +} + +impl Default for CacheOpts { + fn default() -> Self { + Self { ttl: None, cache_if: None } + } +} + +/// Lecture mise en cache sous `key`. +pub async fn cached(key: &str, opts: CacheOpts, load: F) -> anyhow::Result +where + T: Clone + Send + Sync + 'static, + F: FnOnce() -> Fut, + Fut: Future> + Send + 'static, +{ + let pending = { + let mut store = STORE.lock(); + match store.map.get(key) { + Some(Entry::Ready { value, expires_at, .. }) if *expires_at > Instant::now() => { + if let Some(v) = value.downcast_ref::() { + return Ok(v.clone()); + } + None + } + Some(Entry::Pending(p)) => Some(p.clone()), + _ => None, + } + }; + + let pending = match pending { + Some(p) => p, + None => { + let generation = GENERATION.load(Ordering::SeqCst); + let key_owned = key.to_string(); + let ttl = opts.ttl.unwrap_or_else(default_ttl); + let cache_if = opts.cache_if; + let fut = load(); + let shared: Pending = async move { + let res = fut.await; + let mut store = STORE.lock(); + match res { + Ok(value) => { + let keep = cache_if.map(|f| f(&value)).unwrap_or(true); + let any: AnyArc = Arc::new(value); + if generation != GENERATION.load(Ordering::SeqCst) { + // Invalidation pendant le chargement : résultat rendu mais pas gardé. + } else if keep { + store.seq += 1; + let inserted = store.seq; + store.map.insert( + key_owned, + Entry::Ready { value: any.clone(), expires_at: Instant::now() + ttl, inserted }, + ); + evict(&mut store); + } else { + store.map.remove(&key_owned); + } + Ok(any) + } + Err(e) => { + if generation == GENERATION.load(Ordering::SeqCst) { + store.map.remove(&key_owned); + } + Err(Arc::new(format!("{e:#}"))) + } + } + } + .boxed() + .shared(); + STORE.lock().map.insert(key.to_string(), Entry::Pending(shared.clone())); + shared + } + }; + + match pending.await { + Ok(any) => any + .downcast_ref::() + .cloned() + .ok_or_else(|| anyhow::anyhow!("ff_cache: type inattendu pour la clé {key}")), + Err(msg) => Err(anyhow::anyhow!("{msg}")), + } +} + +fn evict(store: &mut Store) { + while store.map.len() > MAX_ENTRIES { + let oldest = store + .map + .iter() + .filter_map(|(k, e)| match e { + Entry::Ready { inserted, .. } => Some((k.clone(), *inserted)), + _ => None, + }) + .min_by_key(|(_, i)| *i) + .map(|(k, _)| k); + match oldest { + Some(k) => { + store.map.remove(&k); + } + None => break, + } + } +} + +/// Vide tout le cache FF (fin de synchro nocturne, données fraîches). +pub fn invalidate() { + GENERATION.fetch_add(1, Ordering::SeqCst); + STORE.lock().map.clear(); +} diff --git a/server/src/grid/mod.rs b/server/src/grid/mod.rs new file mode 100644 index 0000000..5687605 --- /dev/null +++ b/server/src/grid/mod.rs @@ -0,0 +1,19 @@ +//! Module `grid` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/logcapture.rs b/server/src/logcapture.rs new file mode 100644 index 0000000..be37e77 --- /dev/null +++ b/server/src/logcapture.rs @@ -0,0 +1,225 @@ +//! Capture des journaux serveur d'un job, pour téléchargement. +//! +//! Les extractions Qlik produisent des milliers de lignes ; la sortie standard +//! du conteneur les tronque. Tant qu'au moins une capture est ouverte, chaque +//! ligne journalisée (par n'importe quel code du processus) y est recopiée, puis +//! écrite sur disque au fil de l'eau (toutes les 2 s au plus). +//! +//! ⚠️ Deux jobs simultanés partagent les journaux du processus : chaque capture +//! ouverte reçoit TOUTES les lignes de la période. + +use chrono::Utc; +use parking_lot::Mutex; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::PathBuf; +use std::sync::LazyLock; +use std::time::{Instant, SystemTime}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +const MAX_LIGNES: usize = 200_000; +const MAX_LONGUEUR_LIGNE: usize = 8_000; +const MAX_FICHIERS: usize = 20; +const FLUSH_MS: u128 = 2_000; + +struct Capture { + lignes: Vec, + tronque: bool, + debut: Instant, + dernier_flush: Option, + ecrites: usize, +} + +static CAPTURES: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +fn log_dir() -> PathBuf { + std::env::temp_dir().join("collectflow-logs") +} + +fn nom_sur(id: &str) -> String { + id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect() +} + +fn fichier_pour(id: &str) -> PathBuf { + log_dir().join(format!("{}.log", nom_sur(id))) +} + +fn horodatage() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +/// Écrit sur disque les lignes pas encore écrites (synchrone, appelé sous verrou). +fn flush(id: &str, c: &mut Capture) { + let _ = std::fs::create_dir_all(log_dir()); + let total = c.lignes.len(); + if c.ecrites == 0 || total > c.ecrites { + let nouvelles = &c.lignes[c.ecrites..total]; + let mut texte = nouvelles.join("\n"); + texte.push('\n'); + let res = if c.ecrites == 0 { + std::fs::write(fichier_pour(id), texte) + } else { + std::fs::OpenOptions::new() + .append(true) + .open(fichier_pour(id)) + .and_then(|mut f| f.write_all(texte.as_bytes())) + }; + if res.is_ok() { + c.ecrites = total; + } + } + c.dernier_flush = Some(Instant::now()); +} + +/// Ajoute une ligne à toutes les captures ouvertes. +pub fn ajouter(ligne: &str) { + let mut captures = CAPTURES.lock(); + if captures.is_empty() { + return; + } + let texte = if ligne.len() > MAX_LONGUEUR_LIGNE { + let mut cut = MAX_LONGUEUR_LIGNE; + while !ligne.is_char_boundary(cut) { + cut -= 1; + } + format!("{} …[ligne tronquée]", &ligne[..cut]) + } else { + ligne.to_string() + }; + let prefixe = format!("[{}] ", horodatage()); + for (id, c) in captures.iter_mut() { + if c.lignes.len() >= MAX_LIGNES { + if !c.tronque { + c.tronque = true; + c.lignes.push(format!("{prefixe}[log-capture] limite de {MAX_LIGNES} lignes atteinte — suite non enregistrée")); + } + continue; + } + c.lignes.push(format!("{prefixe}{texte}")); + if c.dernier_flush.map(|t| t.elapsed().as_millis() >= FLUSH_MS).unwrap_or(true) { + flush(id, c); + } + } +} + +fn purger() { + let Ok(rd) = std::fs::read_dir(log_dir()) else { return }; + let mut fichiers: Vec<(PathBuf, SystemTime)> = rd + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".log")) + .map(|e| (e.path(), e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH))) + .collect(); + if fichiers.len() <= MAX_FICHIERS { + return; + } + fichiers.sort_by(|a, b| b.1.cmp(&a.1)); + for (p, _) in fichiers.into_iter().skip(MAX_FICHIERS) { + let _ = std::fs::remove_file(p); + } +} + +/// Ouvre une capture : toutes les lignes journalisées à partir de maintenant y +/// sont recopiées jusqu'à [`stop_capture`]. +pub fn start_capture(id: &str, entete: Option<&str>) { + let mut c = Capture { lignes: Vec::new(), tronque: false, debut: Instant::now(), dernier_flush: None, ecrites: 0 }; + c.lignes.push(format!("[{}] [log-capture] début de capture {id}", horodatage())); + if let Some(e) = entete { + c.lignes.push(format!("[{}] [log-capture] {e}", horodatage())); + } + flush(id, &mut c); + CAPTURES.lock().insert(id.to_string(), c); + purger(); +} + +/// Ferme la capture et écrit son état final sur disque. +pub fn stop_capture(id: &str, pied: Option<&str>) { + let Some(mut c) = CAPTURES.lock().remove(id) else { return }; + let duree = c.debut.elapsed().as_secs(); + let n = c.lignes.len(); + c.lignes.push(format!("[{}] [log-capture] fin de capture {id} — {n} lignes, {duree} s", horodatage())); + if let Some(p) = pied { + c.lignes.push(format!("[{}] [log-capture] {p}", horodatage())); + } + flush(id, &mut c); +} + +/// Contenu d'un journal : la capture en cours, sinon le fichier sur disque. +pub fn read_capture(id: &str) -> Option { + if let Some(c) = CAPTURES.lock().get(id) { + return Some(c.lignes.join("\n") + "\n"); + } + std::fs::read_to_string(fichier_pour(id)).ok() +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureInfo { + pub id: String, + pub octets: u64, + pub modifie_le: String, + pub en_cours: bool, +} + +/// Journaux disponibles, du plus récent au plus ancien. +pub fn list_captures() -> Vec { + let en_cours: Vec = CAPTURES.lock().keys().cloned().collect(); + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(log_dir()) { + for e in rd.filter_map(|e| e.ok()) { + let nom = e.file_name().to_string_lossy().to_string(); + let Some(id) = nom.strip_suffix(".log") else { continue }; + let Ok(meta) = e.metadata() else { continue }; + let modifie: chrono::DateTime = meta.modified().map(Into::into).unwrap_or_else(|_| Utc::now()); + out.push(CaptureInfo { + id: id.to_string(), + octets: meta.len(), + modifie_le: modifie.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + en_cours: en_cours.iter().any(|x| nom_sur(x) == id), + }); + } + } + out.sort_by(|a, b| b.modifie_le.cmp(&a.modifie_le)); + out +} + +// --------------------------------------------------------------------------- +// Couche `tracing` qui alimente les captures +// --------------------------------------------------------------------------- + +pub struct CaptureLayer; + +struct MessageVisitor(String); + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.0, "{value:?}"); + } else { + let _ = write!(self.0, " {}={value:?}", field.name()); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.0.push_str(value); + } else { + let _ = write!(self.0, " {}={value}", field.name()); + } + } +} + +impl Layer for CaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + // Aucune capture ouverte (le cas courant) : on ne formate rien. + if CAPTURES.lock().is_empty() { + return; + } + let mut v = MessageVisitor(String::new()); + event.record(&mut v); + ajouter(&v.0); + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..7e7f1cc --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,108 @@ +#![allow(dead_code)] +//! CollectFlow — serveur. +//! +//! Un binaire unique : API JSON (interface + `/api/v1` publique), accès à la +//! base PostgreSQL (tables de l'application et recopie FF Nancy), extraction +//! Qlik Sense, synchronisation nocturne, et service de l'interface compilée. + +mod admin; +mod api_v1_response; +mod auth; +mod config; +mod db; +mod domain; +mod error; +mod ff; +mod ff_cache; +mod grid; +mod logcapture; +mod migrations; +mod produits; +mod qlik; +mod routes; +mod rpc; +mod spa; + +use axum::extract::DefaultBodyLimit; +use axum::Router; +use rpc::Handler; +use tower_http::compression::CompressionLayer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; + +/// Toutes les actions déclarées par les modules. +pub fn modules_actions() -> Vec<(&'static str, Handler)> { + [ff::actions(), qlik::actions(), grid::actions(), produits::actions(), admin::actions(), routes::actions()].concat() +} + +/// Toutes les pages déclarées par les modules. +pub fn modules_pages() -> Vec<(&'static str, Handler)> { + [ff::pages(), qlik::pages(), grid::pages(), produits::pages(), admin::pages(), routes::pages()].concat() +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,chromiumoxide=warn,tungstenite=warn,hyper=warn")); + let fmt = tracing_subscriber::fmt::layer().with_target(false).with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())).with_filter(filter); + tracing_subscriber::registry().with(fmt).with(logcapture::CaptureLayer).init(); +} + +fn app() -> Router { + Router::new() + .merge(auth::router()) + .merge(rpc::router()) + .merge(ff::router()) + .merge(qlik::router()) + .merge(grid::router()) + .merge(produits::router()) + .merge(admin::router()) + .merge(routes::router()) + .merge(spa::router()) + // Les instantanés de la Grille peuvent peser plusieurs Mo. + .layer(DefaultBodyLimit::max(64 * 1024 * 1024)) + .layer(CompressionLayer::new()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + s.recv().await; + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("Arrêt demandé."); +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + tracing::info!("CollectFlow {} — démarrage", env!("CARGO_PKG_VERSION")); + let _ = std::fs::create_dir_all(config::data_dir()); + + migrations::run().await; + tokio::spawn(async { + auth::ensure_admin_exists().await; + }); + admin::start_background_tasks(); + + let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(5643); + let host = std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "0.0.0.0".into()); + let addr = format!("{host}:{port}"); + let listener = match tokio::net::TcpListener::bind(&addr).await { + Ok(l) => l, + // HOSTNAME est parfois le nom du conteneur : on retombe sur toutes les interfaces. + Err(_) => tokio::net::TcpListener::bind(("0.0.0.0", port)).await?, + }; + tracing::info!("Écoute sur http://{}", listener.local_addr()?); + axum::serve(listener, app()).with_graceful_shutdown(shutdown_signal()).await?; + Ok(()) +} diff --git a/server/src/migrations.rs b/server/src/migrations.rs new file mode 100644 index 0000000..b45835e --- /dev/null +++ b/server/src/migrations.rs @@ -0,0 +1,19 @@ +//! Schéma de la base, joué à chaque démarrage (idempotent). + +const INIT_SQL: &str = include_str!("../sql/init.sql"); + +pub async fn run() { + tracing::info!("[DB Init] Starting database migration & initialization..."); + let pool = match crate::db::pool() { + Ok(p) => p, + Err(_) => { + tracing::warn!("[DB Init] No DATABASE_URL found in env or config. Skipping."); + return; + } + }; + match sqlx::raw_sql(INIT_SQL).execute(&pool).await { + Ok(_) => tracing::info!("[DB Init] Initialization successful."), + // L'application reste utilisable (comptes de secours, Paramètres). + Err(e) => tracing::error!("[DB Init] Initialization failed: {e}"), + } +} diff --git a/server/src/produits/mod.rs b/server/src/produits/mod.rs new file mode 100644 index 0000000..05e86bf --- /dev/null +++ b/server/src/produits/mod.rs @@ -0,0 +1,19 @@ +//! Module `produits` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/qlik/mod.rs b/server/src/qlik/mod.rs new file mode 100644 index 0000000..c29a7c9 --- /dev/null +++ b/server/src/qlik/mod.rs @@ -0,0 +1,19 @@ +//! Module `qlik` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs new file mode 100644 index 0000000..c094245 --- /dev/null +++ b/server/src/routes/mod.rs @@ -0,0 +1,19 @@ +//! Module `routes` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/rpc.rs b/server/src/rpc.rs new file mode 100644 index 0000000..40fd73b --- /dev/null +++ b/server/src/rpc.rs @@ -0,0 +1,125 @@ +//! Appels de l'interface vers le serveur hors routes REST classiques. +//! +//! - **Actions** — `POST /api/actions/{nom}`, corps = tableau JSON des arguments. +//! Remplacent les « server actions » Next.js : l'interface garde les mêmes +//! fonctions (`getUsers()`, `saveSnapshot(…)`…), devenues de simples appels +//! HTTP (cf. `src/lib/rpc.ts`). Une erreur renvoie `{ error }` avec un statut +//! 4xx/5xx, que le client relève comme une exception. +//! - **Pages** — `GET /api/pages/{nom}?…` : les données qu'une page serveur +//! Next.js calculait avant rendu (tableau de bord, hit-parade…), paramètres +//! d'URL compris. +//! +//! Chaque module déclare les siennes via `actions()` / `pages()`. + +use crate::auth::{session, Claims, UtilisateurCourant}; +use crate::error::{AppError, AppResult}; +use axum::extract::{Path, Query}; +use axum::http::{header, HeaderMap}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures::future::BoxFuture; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::LazyLock; + +/// Contexte d'un appel : jeton de session, paramètres d'URL, arguments. +#[derive(Clone, Default)] +pub struct Ctx { + pub token: Option, + pub query: HashMap, + pub args: Vec, +} + +impl Ctx { + /// Utilisateur connecté (401 sinon). + pub async fn session(&self) -> AppResult { + crate::auth::require_session(self.token.as_ref()).await + } + /// Administrateur connecté (401/403 sinon). + pub async fn admin(&self) -> AppResult { + crate::auth::require_admin(self.token.as_ref()).await + } + /// Argument n° `i` désérialisé (`null` s'il est absent). + pub fn arg(&self, i: usize) -> AppResult { + let v = self.args.get(i).cloned().unwrap_or(Value::Null); + serde_json::from_value(v).map_err(|e| AppError::bad_request(format!("Argument {i} invalide : {e}"))) + } + /// Argument brut n° `i`. + pub fn raw(&self, i: usize) -> Value { + self.args.get(i).cloned().unwrap_or(Value::Null) + } + /// Paramètre d'URL (page), vide → `None`. + pub fn q(&self, name: &str) -> Option<&str> { + self.query.get(name).map(|s| s.as_str()).filter(|s| !s.is_empty()) + } +} + +pub type Handler = fn(Ctx) -> BoxFuture<'static, AppResult>; + +/// Sérialise un résultat en JSON. +pub fn json(v: T) -> AppResult { + serde_json::to_value(v).map_err(|e| AppError::internal(e.to_string())) +} + +/// Transforme une fonction `async fn(Ctx) -> AppResult` en [`Handler`]. +#[macro_export] +macro_rules! handler { + ($f:path) => { + (|c: $crate::rpc::Ctx| -> ::futures::future::BoxFuture<'static, $crate::error::AppResult<::serde_json::Value>> { + Box::pin($f(c)) + }) as $crate::rpc::Handler + }; +} + +static ACTIONS: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + for (name, h) in crate::modules_actions() { + if m.insert(name, h).is_some() { + panic!("action en double : {name}"); + } + } + m +}); + +static PAGES: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + for (name, h) in crate::modules_pages() { + if m.insert(name, h).is_some() { + panic!("page en double : {name}"); + } + } + m +}); + +fn no_store(v: Value) -> Response { + ([(header::CACHE_CONTROL, "no-store")], Json(v)).into_response() +} + +async fn call_action(Path(name): Path, headers: HeaderMap, body: Option>) -> AppResult { + let h = ACTIONS.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Action inconnue : {name}")))?; + let args = match body.map(|b| b.0) { + Some(Value::Array(a)) => a, + Some(Value::Null) | None => vec![], + Some(other) => vec![other], + }; + let ctx = Ctx { token: session::from_headers(&headers), query: HashMap::new(), args }; + Ok(no_store(h(ctx).await?)) +} + +async fn call_page(Path(name): Path, headers: HeaderMap, Query(query): Query>) -> AppResult { + let h = PAGES.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Page inconnue : {name}")))?; + let ctx = Ctx { token: session::from_headers(&headers), query, args: vec![] }; + Ok(no_store(h(ctx).await?)) +} + +pub fn router() -> Router { + // Force la construction des registres au démarrage (doublons détectés tôt). + LazyLock::force(&ACTIONS); + LazyLock::force(&PAGES); + Router::new() + .route("/api/actions/{name}", post(call_action)) + .route("/api/pages/{name}", get(call_page)) +} diff --git a/server/src/spa.rs b/server/src/spa.rs new file mode 100644 index 0000000..bc3d221 --- /dev/null +++ b/server/src/spa.rs @@ -0,0 +1,73 @@ +//! Service de l'interface (SPA React compilée par Vite) et garde des pages. +//! +//! Les fichiers existants du répertoire statique sont servis tels quels. Toute +//! autre URL hors `/api` renvoie `index.html` (routage côté navigateur), après +//! les mêmes contrôles que l'ancien middleware Next.js : +//! - non connecté → `/login` ; +//! - connecté sur `/login` ou `/` → `/dashboard` ; +//! - `/settings` et `/admin/*` réservés aux administrateurs. + +use crate::auth::session; +use axum::body::Body; +use axum::http::{header, HeaderValue, Request, StatusCode, Uri}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::Router; +use std::path::PathBuf; +use tower_http::services::ServeDir; +use tower_http::set_header::SetResponseHeaderLayer; + +pub fn static_dir() -> PathBuf { + std::env::var("STATIC_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("web")) +} + +async fn page(req: Request) -> Response { + let uri: Uri = req.uri().clone(); + let path = uri.path(); + if path.starts_with("/api/") || path == "/api" { + return (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "error": "Route inconnue" }))).into_response(); + } + + let claims = session::from_headers(req.headers()); + let is_login = path == "/login"; + let is_public = is_login || path.starts_with("/public/"); + + let user = match claims.as_ref() { + Some(_) => crate::auth::require_session(claims.as_ref()).await.ok(), + None => None, + }; + + match &user { + None if !is_public => return Redirect::to("/login").into_response(), + Some(_) if is_public || path == "/" => return Redirect::to("/dashboard").into_response(), + Some(u) if (path.starts_with("/settings") || path.starts_with("/admin")) && !u.is_admin() => { + return Redirect::to("/dashboard").into_response() + } + _ => {} + } + + match tokio::fs::read(static_dir().join("index.html")).await { + Ok(html) => ( + [ + (header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")), + (header::CACHE_CONTROL, HeaderValue::from_static("no-cache")), + ], + html, + ) + .into_response(), + Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "Interface non compilée (index.html introuvable).").into_response(), + } +} + +pub fn router() -> Router { + let dir = static_dir(); + let assets = Router::new() + .nest_service("/assets", ServeDir::new(dir.join("assets"))) + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + )); + let files = ServeDir::new(&dir) + .append_index_html_on_directories(false) + .fallback(tower::service_fn(|req: Request| async move { Ok::<_, std::convert::Infallible>(page(req).await) })); + assets.fallback_service(files) +} From 559e6ed714e621259bd4ba41f443506ce08e344c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:57:16 +0000 Subject: [PATCH 02/19] Contrat minimal ff::pg pour le module qlik Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/ff/mod.rs | 4 +++- server/src/ff/pg.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 server/src/ff/pg.rs diff --git a/server/src/ff/mod.rs b/server/src/ff/mod.rs index d861561..fd1f2fc 100644 --- a/server/src/ff/mod.rs +++ b/server/src/ff/mod.rs @@ -1,4 +1,6 @@ -//! Module `ff` — à porter. +//! Module `ff` — données FF Nancy (base miroir PostgreSQL et API REST) — à porter. + +pub mod pg; use crate::rpc::Handler; use axum::Router; diff --git a/server/src/ff/pg.rs b/server/src/ff/pg.rs new file mode 100644 index 0000000..fc481d7 --- /dev/null +++ b/server/src/ff/pg.rs @@ -0,0 +1,22 @@ +//! Client PostgreSQL FF Nancy (port de `src/lib/pg-ff-client.ts`) — À PORTER. +//! +//! Contrat minimal fixé d'avance (utilisé par le module `qlik`) : la structure +//! [`PgArticle`] et [`pg_get_articles_by_fournisseur`]. Le reste du fichier est +//! à porter ; garder ces deux signatures (champs ajoutables). + +use serde::Serialize; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgArticle { + pub no_id: i64, + pub codein: String, + /// Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik « Article Code ». + #[serde(rename = "codeCentrale", skip_serializing_if = "Option::is_none")] + pub code_centrale: Option, + pub codefou: String, +} + +/// Tous les articles d'un fournisseur (une requête). Erreur SQL propagée. +pub async fn pg_get_articles_by_fournisseur(_codefou: &str) -> anyhow::Result> { + anyhow::bail!("pg_get_articles_by_fournisseur : pas encore porté") +} From a87827008177a760423121907506fd8b2e9754f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:02:04 +0000 Subject: [PATCH 03/19] =?UTF-8?q?Serveur=20Rust=20:=20comptes,=20cl=C3=A9s?= =?UTF-8?q?=20d'API,=20Param=C3=A8tres,=20r=C3=A9glages=20de=20synchro,=20?= =?UTF-8?q?journaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- .gitignore | 1 + server/src/admin/api_keys.rs | 115 ++++++++++++ server/src/admin/logs.rs | 41 +++++ server/src/admin/mod.rs | 48 ++++- server/src/admin/qlik_bridge.rs | 17 ++ server/src/admin/settings.rs | 279 ++++++++++++++++++++++++++++++ server/src/admin/sync_settings.rs | 121 +++++++++++++ server/src/admin/users.rs | 91 ++++++++++ server/src/ff_cache.rs | 2 +- server/src/rpc.rs | 9 +- 10 files changed, 717 insertions(+), 7 deletions(-) create mode 100644 server/src/admin/api_keys.rs create mode 100644 server/src/admin/logs.rs create mode 100644 server/src/admin/qlik_bridge.rs create mode 100644 server/src/admin/settings.rs create mode 100644 server/src/admin/sync_settings.rs create mode 100644 server/src/admin/users.rs diff --git a/.gitignore b/.gitignore index ca7135e..eb4c8b0 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ next-env.d.ts /server/target/ # Vite /dist/ +/.claude/worktrees/ diff --git a/server/src/admin/api_keys.rs b/server/src/admin/api_keys.rs new file mode 100644 index 0000000..a85ac4e --- /dev/null +++ b/server/src/admin/api_keys.rs @@ -0,0 +1,115 @@ +//! Gestion des clés de l'API `/api/v1` (administrateurs) — port de `api-key-actions.ts`. +//! +//! La clé en clair n'existe qu'une fois : renvoyée par `createApiKey`, jamais +//! restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée +//! conserve sa ligne, pour garder la trace de son usage passé. + +use crate::auth::api_key::generate_api_key; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use chrono::NaiveDateTime; +use serde::Serialize; +use serde_json::{json as j, Value}; +use sqlx::Row; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApiKeyRow { + id: i32, + name: String, + key_prefix: String, + role: String, + created_by: Option, + created_at: Option, + last_used_at: Option, + revoked_at: Option, +} + +fn ts(r: &sqlx::postgres::PgRow, col: &str) -> Option { + r.try_get::, _>(col).ok().flatten().map(crate::db::iso) +} + +/// Liste les clés. Jamais de secret — seulement le préfixe lisible. +pub async fn get_api_keys(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let pool = crate::db::pool()?; + let rows = sqlx::query( + "SELECT id, name, key_prefix, role, created_by, created_at, last_used_at, revoked_at FROM api_keys ORDER BY created_at DESC", + ) + .fetch_all(&pool) + .await?; + let out: Vec = rows + .iter() + .map(|r| ApiKeyRow { + id: r.try_get("id").unwrap_or_default(), + name: r.try_get("name").unwrap_or_default(), + key_prefix: r.try_get("key_prefix").unwrap_or_default(), + role: r.try_get("role").unwrap_or_default(), + created_by: r.try_get("created_by").ok().flatten(), + created_at: ts(r, "created_at"), + last_used_at: ts(r, "last_used_at"), + revoked_at: ts(r, "revoked_at"), + }) + .collect(); + json(out) +} + +/// Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**. +pub async fn create_api_key(ctx: Ctx) -> AppResult { + let moi = ctx.admin().await?; + let name: String = ctx.arg::>(0)?.unwrap_or_default(); + let role: String = ctx.arg::>(1)?.unwrap_or_else(|| "user".into()); + let trimmed = name.trim(); + if trimmed.is_empty() { + return Ok(j!({ "success": false, "error": "Le nom de la clé est obligatoire." })); + } + if trimmed.chars().count() > 100 { + return Ok(j!({ "success": false, "error": "Nom trop long (100 caractères max)." })); + } + let k = generate_api_key(); + let pool = crate::db::pool()?; + let created_by = Some(moi.username.clone()).filter(|s| !s.is_empty()); + match sqlx::query("INSERT INTO api_keys (name, key_prefix, key_hash, role, created_by) VALUES ($1, $2, $3, $4, $5)") + .bind(trimmed) + .bind(&k.key_prefix) + .bind(&k.key_hash) + .bind(&role) + .bind(created_by) + .execute(&pool) + .await + { + Ok(_) => Ok(j!({ "success": true, "key": k.key, "keyPrefix": k.key_prefix })), + Err(e) => { + tracing::error!("[api-keys] création KO: {e}"); + Ok(j!({ "success": false, "error": "Erreur technique lors de la création de la clé." })) + } + } +} + +/// Révoque une clé : refusée dès l'appel suivant, mais toujours listée. +pub async fn revoke_api_key(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + let pool = crate::db::pool()?; + match sqlx::query("UPDATE api_keys SET revoked_at = now() WHERE id = $1").bind(id as i32).execute(&pool).await { + Ok(_) => Ok(j!({ "success": true })), + Err(e) => { + tracing::error!("[api-keys] révocation KO: {e}"); + Ok(j!({ "success": false, "error": "Erreur lors de la révocation." })) + } + } +} + +/// Supprime définitivement une clé (nettoyage de la liste). +pub async fn delete_api_key(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + let pool = crate::db::pool()?; + match sqlx::query("DELETE FROM api_keys WHERE id = $1").bind(id as i32).execute(&pool).await { + Ok(_) => Ok(j!({ "success": true })), + Err(e) => { + tracing::error!("[api-keys] suppression KO: {e}"); + Ok(j!({ "success": false, "error": "Erreur lors de la suppression." })) + } + } +} diff --git a/server/src/admin/logs.rs b/server/src/admin/logs.rs new file mode 100644 index 0000000..25723b0 --- /dev/null +++ b/server/src/admin/logs.rs @@ -0,0 +1,41 @@ +//! Journaux serveur des jobs (liste + téléchargement). +//! +//! `GET /api/logs` → liste des journaux disponibles (JSON) +//! `GET /api/logs?id=` → le journal complet en pièce jointe (text/plain) +//! +//! Réservé aux admins : un journal d'extraction contient les diagnostics du +//! modèle Qlik et les codes articles manipulés (jamais de secret). + +use crate::auth::AdminUser; +use crate::logcapture::{list_captures, read_capture}; +use axum::extract::Query; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::json; + +#[derive(Deserialize)] +pub struct LogsQuery { + id: Option, +} + +pub async fn get_logs(_admin: AdminUser, Query(q): Query) -> Response { + let Some(id) = q.id.filter(|s| !s.is_empty()) else { + return Json(json!({ "success": true, "logs": list_captures() })).into_response(); + }; + let Some(contenu) = read_capture(&id) else { + return (StatusCode::NOT_FOUND, Json(json!({ "error": "Journal introuvable ou expiré" }))).into_response(); + }; + // Nom de fichier assaini : l'id finit dans un en-tête HTTP. + let nom: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect(); + ( + [ + (header::CONTENT_TYPE, "text/plain; charset=utf-8".to_string()), + (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{nom}.log\"")), + (header::CACHE_CONTROL, "no-store".to_string()), + ], + contenu, + ) + .into_response() +} diff --git a/server/src/admin/mod.rs b/server/src/admin/mod.rs index 2306925..abca81b 100644 --- a/server/src/admin/mod.rs +++ b/server/src/admin/mod.rs @@ -1,16 +1,56 @@ -//! Module `admin` — à porter. +//! Module `admin` — comptes, clés d'API, Paramètres, synchronisation nocturne. -use crate::rpc::Handler; +pub mod api_keys; +pub mod logs; +mod qlik_bridge; +pub mod settings; +pub mod sync_settings; +pub mod users; + +use crate::error::AppResult; +use crate::handler; +use crate::rpc::{json, Ctx, Handler}; +use axum::routing::get; use axum::Router; +use serde_json::{Map, Value}; /// Routes HTTP du module. pub fn router() -> Router { - Router::new() + Router::new().route("/api/logs", get(logs::get_logs)) +} + +async fn read_sync_settings_action(ctx: Ctx) -> AppResult { + ctx.admin().await?; + json(sync_settings::read_sync_settings()) +} + +async fn save_sync_settings_action(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let patch: Map = ctx.arg::>>(0)?.unwrap_or_default(); + json(sync_settings::save_sync_settings(&patch)?) } /// Actions (`POST /api/actions/{nom}`) du module. pub fn actions() -> Vec<(&'static str, Handler)> { - vec![] + vec![ + ("getUsers", handler!(users::get_users)), + ("createUser", handler!(users::create_user)), + ("deleteUser", handler!(users::delete_user)), + ("updatePassword", handler!(users::update_password)), + ("getApiKeys", handler!(api_keys::get_api_keys)), + ("createApiKey", handler!(api_keys::create_api_key)), + ("revokeApiKey", handler!(api_keys::revoke_api_key)), + ("deleteApiKey", handler!(api_keys::delete_api_key)), + ("getParametresAffichables", handler!(settings::get_parametres_affichables)), + ("testDatabaseConnection", handler!(settings::test_database_connection)), + ("saveDatabaseSettings", handler!(settings::save_database_settings)), + ("saveQlikSettings", handler!(settings::save_qlik_settings)), + ("testQlikConnection", handler!(settings::test_qlik_connection)), + ("saveFfApiSettings", handler!(settings::save_ff_api_settings)), + ("testFfApiConnection", handler!(settings::test_ff_api_connection)), + ("readSyncSettings", handler!(read_sync_settings_action)), + ("saveSyncSettings", handler!(save_sync_settings_action)), + ] } /// Données de pages (`GET /api/pages/{nom}`) du module. diff --git a/server/src/admin/qlik_bridge.rs b/server/src/admin/qlik_bridge.rs new file mode 100644 index 0000000..5621280 --- /dev/null +++ b/server/src/admin/qlik_bridge.rs @@ -0,0 +1,17 @@ +//! Points d'appel vers les modules `qlik` et `ff` utilisés par les Paramètres. +//! Branchés après l'intégration de ces modules. + +use serde_json::{json, Value}; + +/// Ouvre une session Qlik (NTLM → ticket → cookie) avec les identifiants donnés. +pub async fn tester_session(_host: String, _user: String, _password: Option) -> anyhow::Result<()> { + anyhow::bail!("Test Qlik indisponible : module en cours de portage") +} + +/// Oublie l'URL mémorisée de l'API FF Nancy. +pub fn reset_ff_api_base() {} + +/// Diagnostic de l'API FF Nancy. +pub async fn diagnostiquer_api_ff(base: Option) -> Value { + json!({ "success": false, "url": base.unwrap_or_default(), "error": "Diagnostic indisponible : module en cours de portage" }) +} diff --git a/server/src/admin/settings.rs b/server/src/admin/settings.rs new file mode 100644 index 0000000..cec07fe --- /dev/null +++ b/server/src/admin/settings.rs @@ -0,0 +1,279 @@ +//! Actions des Paramètres — réservées aux administrateurs. +//! +//! Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) +//! et les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé +//! vide dans le formulaire signifie « conserver celui qui est enregistré ». + +use crate::config::{read_config, write_config}; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use serde::{Deserialize, Serialize}; +use serde_json::{json as j, Map, Value}; +use std::time::Duration; + +#[derive(Debug, Clone, Deserialize)] +pub struct ChampsBaseDeDonnees { + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: String, + #[serde(default)] + pub database: String, + #[serde(default)] + pub user: String, + /// Vide ou absent : le mot de passe enregistré est conservé. + #[serde(default)] + pub password: Option, + #[serde(default)] + pub ssl: bool, +} + +struct UrlPostgres { + host: String, + port: String, + database: String, + user: String, + password: String, + ssl: bool, +} + +fn decoder(v: &str) -> String { + urlencoding::decode(v).map(|c| c.into_owned()).unwrap_or_else(|_| v.to_string()) +} + +/// Découpe l'URL `postgres://` enregistrée en champs du formulaire. +fn lire_url_postgres(url: &str) -> Option { + let re = regex::Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*://(?:([^:@/]*)(?::([^@/]*))?@)?(\[[^\]]+\]|[^:/?#]*)(?::(\d+))?(?:/([^?#]*))?").unwrap(); + let Some(c) = re.captures(url) else { + tracing::error!("[Settings] URL de base enregistrée illisible"); + return None; + }; + Some(UrlPostgres { + host: c.get(3).map(|m| m.as_str().to_string()).unwrap_or_default(), + port: c.get(4).map(|m| m.as_str().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "5432".into()), + database: decoder(c.get(5).map(|m| m.as_str()).unwrap_or_default()), + user: decoder(c.get(1).map(|m| m.as_str()).unwrap_or_default()), + password: decoder(c.get(2).map(|m| m.as_str()).unwrap_or_default()), + ssl: url.contains("sslmode=require"), + }) +} + +fn construire_url_postgres(champs: &ChampsBaseDeDonnees, mot_de_passe: &str) -> String { + let user = urlencoding::encode(champs.user.trim()).into_owned(); + let identifiants = if mot_de_passe.is_empty() { + user + } else { + format!("{user}:{}", urlencoding::encode(mot_de_passe)) + }; + let port = Some(champs.port.trim()).filter(|p| !p.is_empty()).unwrap_or("5432"); + format!( + "postgres://{identifiants}@{}:{port}/{}{}", + champs.host.trim(), + champs.database.trim(), + if champs.ssl { "?sslmode=require" } else { "" } + ) +} + +/// Mot de passe saisi, sinon celui de l'URL enregistrée. +fn mot_de_passe_base(champs: &ChampsBaseDeDonnees) -> String { + if let Some(p) = champs.password.as_ref().filter(|p| !p.is_empty()) { + return p.clone(); + } + read_config().url.as_deref().and_then(lire_url_postgres).map(|u| u.password).unwrap_or_default() +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DbAffichable { + host: String, + port: String, + database: String, + user: String, + ssl: bool, + mot_de_passe_enregistre: bool, +} + +/// Réglages enregistrés, sans aucun secret. +pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let config = read_config(); + let base = config.url.as_deref().and_then(lire_url_postgres); + let db = base.map(|b| DbAffichable { + host: b.host, + port: b.port, + database: b.database, + user: b.user, + ssl: b.ssl, + mot_de_passe_enregistre: !b.password.is_empty(), + }); + let qlik_pwd_env = std::env::var("QLIK_PWD").map(|v| !v.is_empty()).unwrap_or(false); + json(j!({ + "db": db, + "qlik": { + "host": config.qlik_host.clone().unwrap_or_default(), + "user": config.qlik_user.clone().unwrap_or_default(), + // Même ordre de priorité que la config Qlik : réglage enregistré, puis variable d'environnement. + "motDePasseEnregistre": config.qlik_password.as_deref().map(|p| !p.is_empty()).unwrap_or(false) || qlik_pwd_env, + }, + "ffApiBaseUrl": config.ff_api_base_url.clone().unwrap_or_default(), + })) +} + +fn refus(e: crate::error::AppError) -> Value { + j!({ "success": false, "error": e.message }) +} + +pub async fn test_database_connection(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let champs: ChampsBaseDeDonnees = ctx.arg(0)?; + let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs)); + tracing::info!("Testing connection to: {}", crate::db::mask_url(&url)); + let essai = async { + let opts = crate::db::connect_options(&url)?; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(opts) + .await?; + sqlx::query("SELECT 1").execute(&pool).await?; + pool.close().await; + Ok::<_, sqlx::Error>(()) + }; + match tokio::time::timeout(Duration::from_secs(8), essai).await { + Ok(Ok(())) => Ok(j!({ "success": true })), + Ok(Err(e)) => { + tracing::error!("Database connection test failed: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + Err(_) => Ok(j!({ "success": false, "error": "timeout exceeded when trying to connect" })), + } +} + +pub async fn save_database_settings(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let champs: ChampsBaseDeDonnees = ctx.arg(0)?; + let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs)); + // Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés. + tracing::info!("[Settings] Saving config to {}", crate::config::config_file().display()); + let mut patch = Map::new(); + patch.insert("url".into(), Value::String(url)); + match write_config(patch) { + Ok(()) => { + tracing::info!("[Settings] Database configuration saved successfully."); + crate::db::refresh(); + // Nouvelle base : schéma et compte admin à (re)vérifier. + tokio::spawn(async { + crate::migrations::run().await; + crate::auth::ensure_admin_exists().await; + }); + Ok(j!({ "success": true })) + } + Err(e) => { + tracing::error!("Failed to save database configuration: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + } +} + +/// Mot de passe vide ou absent : celui qui est enregistré est conservé. +pub async fn save_qlik_settings(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let host: String = ctx.arg::>(0)?.unwrap_or_default(); + let user: String = ctx.arg::>(1)?.unwrap_or_default(); + let password: Option = ctx.arg::>(2)?.filter(|p| !p.is_empty()); + let mut patch = Map::new(); + patch.insert("qlikHost".into(), Value::String(host)); + patch.insert("qlikUser".into(), Value::String(user)); + if let Some(p) = password { + patch.insert("qlikPassword".into(), Value::String(p)); + } + match write_config(patch) { + Ok(()) => { + tracing::info!("[Settings] Qlik configuration saved."); + Ok(j!({ "success": true })) + } + Err(e) => { + tracing::error!("[Settings] Failed to save Qlik config: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + } +} + +/// Mot de passe vide ou absent : on teste avec celui qui est enregistré. +pub async fn test_qlik_connection(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let host: String = ctx.arg::>(0)?.unwrap_or_default(); + let user: String = ctx.arg::>(1)?.unwrap_or_default(); + let password: Option = ctx.arg::>(2)?.filter(|p| !p.is_empty()); + match super::qlik_bridge::tester_session(host, user, password).await { + Ok(()) => Ok(j!({ "success": true })), + Err(e) => Ok(j!({ "success": false, "error": format!("{e:#}") })), + } +} + +/// Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. +pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let brut: String = ctx.arg::>(0)?.unwrap_or_default(); + let cleaned = brut.trim().trim_end_matches('/').to_string(); + let lower = cleaned.to_ascii_lowercase(); + if !cleaned.is_empty() && !(lower.starts_with("http://") || lower.starts_with("https://")) { + return Ok(j!({ "success": false, "error": "L'URL doit commencer par http:// ou https://" })); + } + let mut patch = Map::new(); + patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) }); + match write_config(patch) { + Ok(()) => { + // Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. + super::qlik_bridge::reset_ff_api_base(); + tracing::info!("[Settings] FF API base URL saved: {}", if cleaned.is_empty() { "(défaut)" } else { &cleaned }); + Ok(j!({ "success": true })) + } + Err(e) => { + tracing::error!("[Settings] Failed to save FF API config: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + } +} + +/// Teste l'API FF Nancy. +pub async fn test_ff_api_connection(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(j!({ "success": false, "url": "", "error": e.message })); + } + let base: Option = ctx.arg(0)?; + Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_roundtrip() { + let champs = ChampsBaseDeDonnees { + host: "db".into(), + port: "".into(), + database: "cf".into(), + user: "u@x".into(), + password: Some("p:ss/w".into()), + ssl: true, + }; + let url = construire_url_postgres(&champs, "p:ss/w"); + assert_eq!(url, "postgres://u%40x:p%3Ass%2Fw@db:5432/cf?sslmode=require"); + let u = lire_url_postgres(&url).unwrap(); + assert_eq!((u.host.as_str(), u.port.as_str(), u.database.as_str(), u.user.as_str(), u.password.as_str(), u.ssl), ("db", "5432", "cf", "u@x", "p:ss/w", true)); + let u = lire_url_postgres("postgres://postgres:secret@postgresbase:5432/collectflow").unwrap(); + assert_eq!((u.host.as_str(), u.user.as_str(), u.password.as_str(), u.database.as_str()), ("postgresbase", "postgres", "secret", "collectflow")); + } +} diff --git a/server/src/admin/sync_settings.rs b/server/src/admin/sync_settings.rs new file mode 100644 index 0000000..f6168e7 --- /dev/null +++ b/server/src/admin/sync_settings.rs @@ -0,0 +1,121 @@ +//! Réglages de la synchronisation nocturne, rangés dans `data/.db-config.json` +//! (clé `syncNuit`) comme les autres réglages de l'application. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SyncSettings { + /// Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter + /// Qlik et la base la nuit suivante sans que personne l'ait décidé. + pub actif: bool, + /// Heure d'ouverture de la fenêtre (0-23). + pub heure_debut: i64, + /// Heure de fermeture. Aucun fournisseur n'est **démarré** après. + pub heure_fin: i64, + /// Nombre maximum d'extractions Qlik par nuit (chacune peut prendre 8 min). + pub qlik_par_nuit: i64, + /// Une extraction Qlik est intercalée tous les N fournisseurs SQL. + pub sql_par_qlik: i64, + /// Âge minimal des données Qlik avant de les refaire. + pub qlik_min_jours: i64, +} + +pub const SYNC_SETTINGS_DEFAUT: SyncSettings = + SyncSettings { actif: false, heure_debut: 0, heure_fin: 5, qlik_par_nuit: 6, sql_par_qlik: 20, qlik_min_jours: 7 }; + +fn borne(v: Option<&Value>, min: i64, max: i64, defaut: i64) -> i64 { + let n = match v { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => s.trim().parse::().ok(), + Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }), + Some(Value::Null) => Some(0.0), + _ => None, + }; + match n { + Some(f) if f.is_finite() => (f.trunc() as i64).clamp(min, max), + _ => defaut, + } +} + +fn truthy(v: Option<&Value>) -> bool { + match v { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false), + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +/// Normalise ce qui vient du fichier : il est éditable à la main. +pub fn normaliser(brut: Option<&Map>) -> SyncSettings { + let d = SYNC_SETTINGS_DEFAUT; + let g = |k: &str| brut.and_then(|m| m.get(k)); + let mut s = SyncSettings { + actif: truthy(g("actif")), + heure_debut: borne(g("heureDebut"), 0, 23, d.heure_debut), + heure_fin: borne(g("heureFin"), 0, 23, d.heure_fin), + qlik_par_nuit: borne(g("qlikParNuit"), 0, 200, d.qlik_par_nuit), + sql_par_qlik: borne(g("sqlParQlik"), 1, 500, d.sql_par_qlik), + qlik_min_jours: borne(g("qlikMinJours"), 0, 365, d.qlik_min_jours), + }; + // Une fenêtre de durée nulle ne s'ouvrirait jamais : retour au défaut. + if s.heure_debut == s.heure_fin { + s.heure_debut = d.heure_debut; + s.heure_fin = d.heure_fin; + } + s +} + +fn lire_fichier() -> Option> { + let raw = std::fs::read_to_string(crate::config::config_file()).ok()?; + match serde_json::from_str::(&raw).ok()? { + Value::Object(m) => Some(m), + _ => None, + } +} + +pub fn read_sync_settings() -> SyncSettings { + match lire_fichier() { + Some(cfg) => normaliser(cfg.get("syncNuit").and_then(|v| v.as_object())), + None => SYNC_SETTINGS_DEFAUT, + } +} + +/// Fusionne `patch` (champs camelCase partiels) dans les réglages enregistrés. +pub fn save_sync_settings(patch: &Map) -> std::io::Result { + let existant = lire_fichier().unwrap_or_default(); + let courant = normaliser(existant.get("syncNuit").and_then(|v| v.as_object())); + let mut fusion = match serde_json::to_value(courant) { + Ok(Value::Object(m)) => m, + _ => Map::new(), + }; + for (k, v) in patch { + fusion.insert(k.clone(), v.clone()); + } + let suivant = normaliser(Some(&fusion)); + let mut p = Map::new(); + p.insert("syncNuit".into(), serde_json::to_value(suivant).unwrap_or(Value::Null)); + crate::config::write_config(p)?; + tracing::info!("[sync-nuit] réglages enregistrés : {}", serde_json::to_string(&suivant).unwrap_or_default()); + Ok(suivant) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalise() { + let m = json!({ "actif": 1, "heureDebut": "2", "heureFin": 30, "qlikParNuit": -4, "sqlParQlik": 0 }); + let s = normaliser(m.as_object()); + assert!(s.actif); + assert_eq!((s.heure_debut, s.heure_fin, s.qlik_par_nuit, s.sql_par_qlik, s.qlik_min_jours), (2, 23, 0, 1, 7)); + let m = json!({ "heureDebut": 4, "heureFin": 4 }); + let s = normaliser(m.as_object()); + assert_eq!((s.heure_debut, s.heure_fin), (0, 5)); + } +} diff --git a/server/src/admin/users.rs b/server/src/admin/users.rs new file mode 100644 index 0000000..554c7c3 --- /dev/null +++ b/server/src/admin/users.rs @@ -0,0 +1,91 @@ +//! Gestion des comptes (administrateurs) — port de `user-actions.ts`. + +use crate::auth::password::hash_password; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use serde::Serialize; +use serde_json::{json as j, Value}; +use sqlx::Row; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UserRow { + id: i32, + username: String, + role: String, + created_at: Option, +} + +/// Tous les utilisateurs. +pub async fn get_users(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let pool = crate::db::pool()?; + let rows = sqlx::query("SELECT id, username, role, created_at FROM users").fetch_all(&pool).await?; + let users: Vec = rows + .iter() + .map(|r| UserRow { + id: r.try_get("id").unwrap_or_default(), + username: r.try_get("username").unwrap_or_default(), + role: r.try_get("role").unwrap_or_default(), + created_at: r.try_get::, _>("created_at").ok().flatten().map(crate::db::iso), + }) + .collect(); + json(users) +} + +/// Crée un utilisateur. +pub async fn create_user(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let username: String = ctx.arg(0)?; + let password: String = ctx.arg(1)?; + let role: Option = ctx.arg(2)?; + let role = role.unwrap_or_else(|| "user".into()); + let pool = crate::db::pool()?; + match sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)") + .bind(&username) + .bind(hash_password(&password)) + .bind(&role) + .execute(&pool) + .await + { + Ok(_) => Ok(j!({ "success": true })), + Err(e) => { + tracing::error!("CreateUser Error: {e}"); + Ok(j!({ "success": false, "error": "L'utilisateur existe déjà ou une erreur technique est survenue." })) + } + } +} + +/// Supprime un utilisateur (jamais son propre compte). +pub async fn delete_user(ctx: Ctx) -> AppResult { + let moi = ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + if moi.id.parse::().ok() == Some(id) { + return Ok(j!({ "success": false, "error": "Vous ne pouvez pas supprimer votre propre compte." })); + } + let pool = crate::db::pool()?; + match sqlx::query("DELETE FROM users WHERE id = $1").bind(id as i32).execute(&pool).await { + Ok(_) => { + crate::auth::authz::invalider(&id.to_string()); + Ok(j!({ "success": true })) + } + Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la suppression." })), + } +} + +/// Change le mot de passe d'un utilisateur. +pub async fn update_password(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + let new_password: String = ctx.arg(1)?; + let pool = crate::db::pool()?; + match sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") + .bind(hash_password(&new_password)) + .bind(id as i32) + .execute(&pool) + .await + { + Ok(_) => Ok(j!({ "success": true })), + Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la mise à jour du mot de passe." })), + } +} diff --git a/server/src/ff_cache.rs b/server/src/ff_cache.rs index 6741920..410d504 100644 --- a/server/src/ff_cache.rs +++ b/server/src/ff_cache.rs @@ -62,7 +62,7 @@ where Fut: Future> + Send + 'static, { let pending = { - let mut store = STORE.lock(); + let store = STORE.lock(); match store.map.get(key) { Some(Entry::Ready { value, expires_at, .. }) if *expires_at > Instant::now() => { if let Some(v) = value.downcast_ref::() { diff --git a/server/src/rpc.rs b/server/src/rpc.rs index 40fd73b..ab3e198 100644 --- a/server/src/rpc.rs +++ b/server/src/rpc.rs @@ -98,9 +98,14 @@ fn no_store(v: Value) -> Response { ([(header::CACHE_CONTROL, "no-store")], Json(v)).into_response() } -async fn call_action(Path(name): Path, headers: HeaderMap, body: Option>) -> AppResult { +async fn call_action(Path(name): Path, headers: HeaderMap, body: axum::body::Bytes) -> AppResult { let h = ACTIONS.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Action inconnue : {name}")))?; - let args = match body.map(|b| b.0) { + let body: Option = if body.iter().all(|b| b.is_ascii_whitespace()) { + None + } else { + Some(serde_json::from_slice(&body).map_err(|e| AppError::bad_request(format!("Corps JSON invalide : {e}")))?) + }; + let args = match body { Some(Value::Array(a)) => a, Some(Value::Null) | None => vec![], Some(other) => vec![other], From 87d5803b55addee40c2ea1546f35a44d7bb77a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:02:45 +0000 Subject: [PATCH 04/19] =?UTF-8?q?Docker=20:=20image=20Rust=20+=20SPA=20pub?= =?UTF-8?q?li=C3=A9e=20sur=20GHCR,=20tir=C3=A9e=20au=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- .dockerignore | 52 ++++---------- .github/workflows/ci.yml | 36 ++++++++++ .github/workflows/docker-publish.yml | 44 ++++++++++++ Dockerfile | 102 ++++++++++++--------------- docker-compose.yml | 13 ++-- 5 files changed, 146 insertions(+), 101 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-publish.yml diff --git a/.dockerignore b/.dockerignore index cc1bf4a..6a3e3e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,38 +1,14 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ -build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo - -# BMad and Antigravity -_bmad-output/ -.gemini/ -.antigravityignore +.git +.github +.claude +.agent +.agents +_bmad +_bmad-output +node_modules +dist +data +server/target +*.md +!README.md +.env* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6edbe89 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +# Vérifie le serveur Rust et l'interface sur chaque pull request. +on: + pull_request: + push: + branches: [main] + +jobs: + server: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: server + - run: cargo build --locked + - run: cargo test --locked + + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run build diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..676bf72 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,44 @@ +name: Image Docker + +# Construit l'image à chaque mise à jour de main et la publie sur GHCR : +# le serveur la récupère au prochain `docker compose up -d` (pull_policy: always). +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: docker-publish + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Nom de l'image (minuscules) + id: image + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.image.outputs.name }}:latest + ${{ steps.image.outputs.name }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index c75a901..6aac4a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,70 +1,58 @@ -FROM node:20-alpine AS base +# CollectFlow — image de production. +# +# 1. web : compile l'interface React (Vite) en fichiers statiques. +# 2. server : compile le serveur Rust (binaire unique, optimisé). +# 3. runner : Debian minimale + Chromium (extraction Qlik) + les deux livrables. +# +# L'image est construite et publiée par GitHub Actions à chaque push sur main +# (ghcr.io/r0m1k3/collectflow:latest) ; docker-compose la tire au démarrage. -# Install dependencies only when needed -FROM base AS deps -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +# --------------------------------------------------------------------------- +FROM node:22-alpine AS web WORKDIR /app - -# Install dependencies based on the preferred package manager -COPY package.json package-lock.json* ./ -RUN npm ci - -# Rebuild the source code only when needed -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund COPY . . - -# Next.js telemetry is disabled -ENV NEXT_TELEMETRY_DISABLED=1 -# Limit workers and increase memory to prevent OOM -ENV NODE_OPTIONS="--max-old-space-size=4096" -ENV NEXT_PRIVATE_WORKER_THREADS=0 - RUN npm run build -# Production image, copy all the files and run next -FROM base AS runner +# --------------------------------------------------------------------------- +FROM rust:1-bookworm AS server +WORKDIR /build +# Dépendances d'abord : couche réutilisée tant que Cargo.lock ne change pas. +COPY server/Cargo.toml server/Cargo.lock ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs \ + && cargo build --release --locked \ + && rm -rf src target/release/deps/collectflow* target/release/collectflow* +COPY server/ ./ +RUN cargo build --release --locked && cp target/release/collectflow /collectflow + +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS runner WORKDIR /app -ENV NODE_ENV=production -# Next.js telemetry is disabled -ENV NEXT_TELEMETRY_DISABLED=1 +# Chromium pour l'extraction Qlik (websocket Engine ouvert dans la page) + polices. +RUN apt-get update \ + && apt-get install -y --no-install-recommends chromium ca-certificates fonts-liberation fonts-freefont-ttf tzdata \ + && rm -rf /var/lib/apt/lists/* -# Chromium pour l'extraction Qlik via Playwright (ws in-page) + dépendances headless -RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont -ENV PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium-browser +# Même UID/GID que l'ancienne image Node : le volume /app/data existant reste lisible. +RUN groupadd --system --gid 1001 collectflow \ + && useradd --system --uid 1001 --gid collectflow --home-dir /app collectflow \ + && mkdir -p /app/data \ + && chown -R collectflow:collectflow /app -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +COPY --from=server /collectflow /app/collectflow +COPY --from=web --chown=collectflow:collectflow /app/dist /app/web -COPY --from=builder /app/public ./public - -# Set the correct permission for prerender cache and data storage -RUN mkdir .next data -RUN chown nextjs:nodejs .next data - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -# playwright-core n'est pas toujours tracé par le standalone — on le copie explicitement -COPY --from=deps --chown=nextjs:nodejs /app/node_modules/playwright-core ./node_modules/playwright-core - -# Grant write access to /app for settings persistence -RUN chown -R nextjs:nodejs /app - -COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts - -USER nextjs +ENV PORT=5643 \ + HOSTNAME=0.0.0.0 \ + DATA_DIR=/app/data \ + STATIC_DIR=/app/web \ + PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium \ + RUST_LOG=info +USER collectflow EXPOSE 5643 +VOLUME ["/app/data"] -ENV PORT=5643 -# set hostname to localhost -ENV HOSTNAME="0.0.0.0" - -# Run the DB init script, then start the standalone server -CMD node scripts/db-init.js && node server.js +CMD ["/app/collectflow"] diff --git a/docker-compose.yml b/docker-compose.yml index 3d4866e..18b0e2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,21 @@ -version: "3.8" - services: web: + # Image publiée par GitHub Actions à chaque mise à jour de main. + # `pull_policy: always` : chaque `docker compose up -d` récupère la + # dernière version avant de (re)démarrer le conteneur. + image: ghcr.io/r0m1k3/collectflow:latest + pull_policy: always + # Construction locale possible : `docker compose build` (utilise le Dockerfile). build: context: . dockerfile: Dockerfile ports: - "5643:5643" environment: - - NODE_ENV=production - - AUTH_TRUST_HOST=true - AUTH_SECRET=my-super-secret-key-that-should-be-changed-in-production - DATABASE_URL=postgres://postgres:MOT_DE_PASSE_ICI@postgresbase:5432/collectflow - FF_API_BASE_URL=https://api.ffnancy.fr - - # Mais comme CollectFlow permet de le configurer depuis l'interface (settings), ce n'est pas strictement obligatoire au lancement. + # La base, Qlik et l'API FF se règlent aussi depuis l'interface (Paramètres). volumes: - collectflow-data:/app/data restart: unless-stopped From b80e8f6f42a322f9ca48879a7a42f7fef38cf983 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:06:06 +0000 Subject: [PATCH 05/19] =?UTF-8?q?Synchro=20nocturne,=20pr=C3=A9chauffage?= =?UTF-8?q?=20de=20la=20grille=20(branchement=20=C3=A0=20venir)=20et=20REA?= =?UTF-8?q?DME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- README.md | 74 ++-- server/src/admin/grid_warmup.rs | 158 ++++++++ server/src/admin/mod.rs | 5 +- server/src/admin/sync_fournisseurs.rs | 148 ++++++++ server/src/admin/sync_routes.rs | 126 +++++++ server/src/admin/sync_scheduler.rs | 501 ++++++++++++++++++++++++++ 6 files changed, 976 insertions(+), 36 deletions(-) create mode 100644 server/src/admin/grid_warmup.rs create mode 100644 server/src/admin/sync_fournisseurs.rs create mode 100644 server/src/admin/sync_routes.rs create mode 100644 server/src/admin/sync_scheduler.rs diff --git a/README.md b/README.md index ed21264..e12980f 100644 --- a/README.md +++ b/README.md @@ -2,56 +2,60 @@ CollectFlow est une application de révision d'assortiment et d'analyse de performances des produits en point de vente, conçue pour consolider les données issues de plusieurs fournisseurs et magasins. +## Architecture + +| Partie | Technologie | Dossier | +|---|---|---| +| Serveur (API, base, FF Nancy, Qlik, synchro nocturne) | **Rust** — Axum, sqlx, chromiumoxide | `server/` | +| Interface | React 19 + Tailwind 4, compilée en fichiers statiques par Vite | `src/` | + +Un seul binaire Rust sert l'API JSON (`/api/...`, dont l'API publique `/api/v1`) et l'interface compilée. Plus de Node.js à l'exécution. + ## Prérequis -- **Node.js** 18+ (pour le développement local) -- **Docker** et **Docker Compose** (pour le déploiement conteneurisé) -- **PostgreSQL** 16+ +- **Docker** et **Docker Compose** (déploiement) +- **PostgreSQL** 16+ (base séparée, non fournie par le conteneur) +- Pour développer : **Rust** stable (`rustup`) et **Node.js** 22+ ## Lancer avec Docker -Le projet est configuré pour tourner dans un conteneur Docker optimisé (mode standalone de Next.js). -**Note :** Vous devez disposer d'une base de données PostgreSQL séparée (le conteneur ne lance que l'application web). +L'image est construite par GitHub Actions à chaque mise à jour de `main` et publiée sur `ghcr.io/r0m1k3/collectflow:latest`. Le `docker-compose.yml` la récupère à chaque démarrage (`pull_policy: always`) : **une nouvelle version s'installe au prochain `docker compose up -d`**, sans rien compiler sur le serveur. -### 1. Démarrer l'application -À la racine du projet, lancez : ```bash -npm run docker:build -# ou directement -docker-compose up -d --build -``` -L'application sera accessible sur [http://localhost:5643](http://localhost:5643). +# Une seule fois, si le paquet GHCR est privé (jeton GitHub avec le droit read:packages) : +echo | docker login ghcr.io -u --password-stdin -*L'application sera attachée au réseau Docker externe `nginx_default` afin d'être exposée derrière votre reverse proxy Nginx. Assurez-vous que ce réseau existe (`docker network create nginx_default`).* - -### 2. Arrêter l'application -```bash -npm run docker:down -# ou directement -docker-compose down +# Démarrer / mettre à jour : +docker compose up -d ``` -## Développement Local (Sans Docker) +L'application est accessible sur [http://localhost:5643](http://localhost:5643). Elle est attachée au réseau Docker externe `nginx_default` pour être exposée derrière le reverse proxy Nginx (`docker network create nginx_default` s'il n'existe pas). -Si vous préférez développer en local, vous devrez configurer votre propre base de données PostgreSQL. +Construire l'image localement plutôt que la télécharger : `docker compose build`. + +### Données et réglages + +Le volume `collectflow-data` (`/app/data`) conserve les réglages saisis dans **Paramètres** (`.db-config.json` : base, Qlik, API FF, synchro nocturne), les comptes de secours (`users.json`) et le secret de session (`.auth-secret`, si `AUTH_SECRET` n'est pas défini). Il est repris tel quel depuis l'ancienne version Node : comptes et mots de passe restent valables (seules les sessions ouvertes sont à refaire). + +Variables d'environnement utiles : `DATABASE_URL`, `AUTH_SECRET`, `FF_API_BASE_URL`, `QLIK_HOST` / `QLIK_USER` / `QLIK_PWD` (et les réglages fins `QLIK_*`), `PG_STATEMENT_TIMEOUT_MS`, `FF_CACHE_TTL_S`, `RUST_LOG` (niveau de journalisation, `info` par défaut). + +## Développement local -1. Installer les dépendances : ```bash +# Serveur (port 5643) — tables créées et compte admin/admin initialisé au premier démarrage +cd server +DATABASE_URL=postgres://postgres:postgres@localhost/collectflow cargo run + +# Interface (port 5173, /api relayé vers le serveur) npm install -``` - -2. Configurer la connexion DB : -Allez sur la page des Paramètres (`/settings`) dans l'application pour configurer l'accès à votre PostgreSQL local. - -3. Lancer le serveur de développement : -```bash npm run dev ``` -## Structure du Projet (BMAD) +Vérifications : `cargo test` (dans `server/`), `npm run lint`, `npm run build`. -Ce projet respecte l'architecture **BMAD** (Business, Model, Application/API, Data) : -- `src/features/*` : Logique métier (Business) isolée par feature (ex: `grid`, `snapshots`) -- `src/types/*` : Interfaces TypeScript (Model) -- `src/app/*` : Routeurs Next.js UI et endpoints d'API (Application/API) -- `src/db/*` : Schémas Drizzle ORM et connexions (Data) +## Structure + +- `server/src/` — serveur Rust, un module par domaine : `auth` (sessions, clés d'API), `ff` (base miroir et API FF Nancy), `qlik` (données réseau), `grid` (révision d'assortiment, API `/api/v1`), `produits`, `admin` (comptes, paramètres, synchronisation nocturne). +- `server/sql/init.sql` — schéma joué (idempotent) à chaque démarrage. +- `src/` — interface React : `features/*` par domaine, `components/` partagés, `pages/` et `router.tsx`. +- `server/PORTING.md` — contrat entre l'interface et le serveur (actions, données de pages, routes). diff --git a/server/src/admin/grid_warmup.rs b/server/src/admin/grid_warmup.rs new file mode 100644 index 0000000..71f4181 --- /dev/null +++ b/server/src/admin/grid_warmup.rs @@ -0,0 +1,158 @@ +//! Préchauffage de l'instantané de grille (`grid_rows`). +//! +//! L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la +//! Grille est ouverte. Sans préchauffage, un consommateur externe ne verrait que +//! les fournisseurs déjà consultés à la main. Ce job calcule chaque fournisseur +//! une fois, séquentiellement (le calcul est lourd en SQL). + +use crate::auth::AdminUser; +use axum::extract::Query; +use axum::Json; +use parking_lot::Mutex; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, LazyLock}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WarmupJob { + /// "idle" | "running" | "success" | "error" + status: &'static str, + total: usize, + done: usize, + skipped: usize, + failed: usize, + #[serde(skip_serializing_if = "Option::is_none")] + current_fournisseur: Option, + #[serde(skip_serializing_if = "Option::is_none")] + started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + finished_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + last_errors: Vec, +} + +impl WarmupJob { + fn idle() -> Self { + Self { + status: "idle", + total: 0, + done: 0, + skipped: 0, + failed: 0, + current_fournisseur: None, + started_at: None, + finished_at: None, + error: None, + last_errors: vec![], + } + } +} + +static JOB: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(None))); + +fn public_job() -> Value { + let job = JOB.lock().clone().unwrap_or_else(WarmupJob::idle); + let mut v = serde_json::to_value(job).unwrap_or(json!({})); + v["success"] = json!(true); + v +} + +fn maintenant() -> String { + crate::db::iso_utc(chrono::Utc::now()) +} + +fn maj(f: impl FnOnce(&mut WarmupJob)) { + if let Some(j) = JOB.lock().as_mut() { + f(j); + } +} + +async fn run_warmup(stale_hours: f64) { + let res: anyhow::Result<()> = async { + let fournisseurs = crate::ff::pg::pg_get_fournisseurs(None).await; + // Les fournisseurs calculés récemment sont sautés : un second passage ne + // coûte presque rien, le bouton est rejouable sans crainte. + let existing = crate::grid::store::list_grid_suppliers().await?; + let fresh_limit = chrono::Utc::now().naive_utc() - chrono::Duration::milliseconds((stale_hours * 3_600_000.0) as i64); + let fresh: HashSet = existing + .iter() + .filter(|s| s.computed_at_naive().map(|d| d > fresh_limit).unwrap_or(false)) + .map(|s| s.code_fournisseur.clone()) + .collect(); + maj(|j| j.total = fournisseurs.len()); + tracing::info!("[grid-warmup] {} fournisseurs, {} déjà à jour (< {}h)", fournisseurs.len(), fresh.len(), stale_hours); + + for f in &fournisseurs { + if fresh.contains(&f.code) { + maj(|j| j.skipped += 1); + continue; + } + maj(|j| j.current_fournisseur = Some(format!("{} — {}", f.code, f.nom))); + match crate::grid::product_rows::get_product_rows_for_sync(&f.code).await { + Ok(_) => maj(|j| j.done += 1), + Err(e) => { + let msg = format!("{}: {}", f.code, crate::error::short(&e, 120)); + tracing::error!("[grid-warmup] {msg}"); + maj(|j| { + j.failed += 1; + if j.last_errors.len() < 10 { + j.last_errors.push(msg); + } + }); + } + } + } + Ok(()) + } + .await; + match res { + Ok(()) => { + maj(|j| { + j.current_fournisseur = None; + j.status = "success"; + j.finished_at = Some(maintenant()); + }); + if let Some(j) = JOB.lock().as_ref() { + tracing::info!("[grid-warmup] terminé — {} calculés, {} à jour, {} en échec", j.done, j.skipped, j.failed); + } + } + Err(e) => { + let msg = format!("{e:#}"); + tracing::error!("[grid-warmup] échec global: {msg}"); + maj(|j| { + j.status = "error"; + j.error = Some(msg); + j.finished_at = Some(maintenant()); + }); + } + } +} + +/// GET /api/admin/grid-warmup — avancement du préchauffage. +pub async fn get(_admin: AdminUser) -> Json { + Json(public_job()) +} + +/// POST /api/admin/grid-warmup?staleHours=24 — démarre en arrière-plan. +pub async fn post(_admin: AdminUser, Query(q): Query>) -> Json { + { + let mut job = JOB.lock(); + if job.as_ref().map(|j| j.status == "running").unwrap_or(false) { + drop(job); + return Json(public_job()); + } + // Comme `Number(param)` : absent ou vide → 0, illisible → défaut 24 h. + let raw = q + .get("staleHours") + .map(|s| if s.trim().is_empty() { 0.0 } else { s.trim().parse::().unwrap_or(f64::NAN) }) + .unwrap_or(0.0); + let stale_hours = if raw.is_finite() { raw.clamp(0.0, 720.0) } else { 24.0 }; + *job = Some(WarmupJob { status: "running", started_at: Some(maintenant()), ..WarmupJob::idle() }); + tracing::info!("[grid-warmup] démarré (staleHours={stale_hours})"); + tokio::spawn(run_warmup(stale_hours)); + } + Json(public_job()) +} diff --git a/server/src/admin/mod.rs b/server/src/admin/mod.rs index abca81b..84e7e96 100644 --- a/server/src/admin/mod.rs +++ b/server/src/admin/mod.rs @@ -4,6 +4,7 @@ pub mod api_keys; pub mod logs; mod qlik_bridge; pub mod settings; +pub mod sync_fournisseurs; pub mod sync_settings; pub mod users; @@ -16,7 +17,9 @@ use serde_json::{Map, Value}; /// Routes HTTP du module. pub fn router() -> Router { - Router::new().route("/api/logs", get(logs::get_logs)) + Router::new() + .route("/api/logs", get(logs::get_logs)) + .route("/api/admin/sync/fournisseurs", get(sync_fournisseurs::get).patch(sync_fournisseurs::patch)) } async fn read_sync_settings_action(ctx: Ctx) -> AppResult { diff --git a/server/src/admin/sync_fournisseurs.rs b/server/src/admin/sync_fournisseurs.rs new file mode 100644 index 0000000..ce94899 --- /dev/null +++ b/server/src/admin/sync_fournisseurs.rs @@ -0,0 +1,148 @@ +//! `/api/admin/sync/fournisseurs` — paramétrage par fournisseur de la synchro nocturne. + +use crate::auth::AdminUser; +use crate::error::{AppError, AppResult}; +use axum::http::StatusCode; +use axum::Json; +use chrono::NaiveDateTime; +use serde::Serialize; +use serde_json::{json, Value}; +use sqlx::postgres::PgRow; +use sqlx::Row; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncFournisseurRow { + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub actif_sql: bool, + pub actif_qlik: bool, + pub desactive_motif: Option, + pub dernier_sql_at: Option, + pub dernier_sql_statut: Option, + pub dernier_sql_lignes: Option, + pub dernier_sql_erreur: Option, + pub dernier_sql_ms: Option, + pub dernier_qlik_at: Option, + pub dernier_qlik_statut: Option, + pub dernier_qlik_codes: Option, + pub dernier_qlik_erreur: Option, + pub dernier_qlik_ms: Option, + pub updated_at: Option, +} + +fn ts(r: &PgRow, col: &str) -> Option { + r.try_get::, _>(col).ok().flatten().map(crate::db::iso) +} + +pub fn from_row(r: &PgRow) -> SyncFournisseurRow { + SyncFournisseurRow { + code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(), + nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(), + actif_sql: r.try_get("actif_sql").unwrap_or(true), + actif_qlik: r.try_get("actif_qlik").unwrap_or(true), + desactive_motif: r.try_get("desactive_motif").ok().flatten(), + dernier_sql_at: ts(r, "dernier_sql_at"), + dernier_sql_statut: r.try_get("dernier_sql_statut").ok().flatten(), + dernier_sql_lignes: r.try_get("dernier_sql_lignes").ok().flatten(), + dernier_sql_erreur: r.try_get("dernier_sql_erreur").ok().flatten(), + dernier_sql_ms: r.try_get("dernier_sql_ms").ok().flatten(), + dernier_qlik_at: ts(r, "dernier_qlik_at"), + dernier_qlik_statut: r.try_get("dernier_qlik_statut").ok().flatten(), + dernier_qlik_codes: r.try_get("dernier_qlik_codes").ok().flatten(), + dernier_qlik_erreur: r.try_get("dernier_qlik_erreur").ok().flatten(), + dernier_qlik_ms: r.try_get("dernier_qlik_ms").ok().flatten(), + updated_at: ts(r, "updated_at"), + } +} + +/// GET — la liste complète des fournisseurs paramétrés, avec l'état de leur +/// dernière synchronisation (quelques centaines de lignes : pas de pagination). +pub async fn get(_admin: AdminUser) -> AppResult> { + let pool = crate::db::pool()?; + let rows = sqlx::query("SELECT * FROM sync_fournisseurs ORDER BY coalesce(nom_fournisseur, code_fournisseur) ASC") + .fetch_all(&pool) + .await?; + let rows: Vec = rows.iter().map(from_row).collect(); + let resume = json!({ + "total": rows.len(), + "actifsSql": rows.iter().filter(|r| r.actif_sql).count(), + "actifsQlik": rows.iter().filter(|r| r.actif_qlik).count(), + "desactivesAuto": rows.iter().filter(|r| r.desactive_motif.as_deref().map(|m| !m.is_empty()).unwrap_or(false)).count(), + "enEchecSql": rows.iter().filter(|r| r.dernier_sql_statut.as_deref() == Some("echec")).count(), + "enEchecQlik": rows.iter().filter(|r| r.dernier_qlik_statut.as_deref() == Some("echec")).count(), + "jamaisSql": rows.iter().filter(|r| r.dernier_sql_at.is_none()).count(), + }); + Ok(Json(json!({ "success": true, "fournisseurs": rows, "resume": resume }))) +} + +fn invalide(details: Vec) -> AppError { + AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Paramètres invalides", "details": details })) +} + +/// PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source. +/// +/// Réactiver lève le motif de désactivation automatique : un fournisseur remis +/// en service par l'admin ne doit pas garder un « aucun article » trompeur. +pub async fn patch(_admin: AdminUser, body: axum::body::Bytes) -> AppResult> { + let body: Value = serde_json::from_slice(&body) + .map_err(|_| AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Corps JSON invalide" })))?; + let mut details = Vec::new(); + let codes: Vec = match body.get("codes").and_then(|v| v.as_array()) { + Some(a) => { + let codes: Vec = a.iter().filter_map(|v| v.as_str().map(String::from)).collect(); + if codes.len() != a.len() || codes.iter().any(|c| c.is_empty()) { + details.push("Invalid input".to_string()); + } + if codes.is_empty() { + details.push("Too small: expected array to have >=1 items".to_string()); + } + if codes.len() > 2000 { + details.push("Too big: expected array to have <=2000 items".to_string()); + } + codes + } + None => { + details.push("Invalid input: expected array".to_string()); + vec![] + } + }; + let bool_opt = |k: &str, details: &mut Vec| match body.get(k) { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(*b), + Some(_) => { + details.push("Invalid input: expected boolean".to_string()); + None + } + }; + let actif_sql = bool_opt("actifSql", &mut details); + let actif_qlik = bool_opt("actifQlik", &mut details); + if details.is_empty() && actif_sql.is_none() && actif_qlik.is_none() { + details.push("Préciser au moins actifSql ou actifQlik".to_string()); + } + if !details.is_empty() { + return Err(invalide(details)); + } + + let reactivation = actif_sql == Some(true) || actif_qlik == Some(true); + let pool = crate::db::pool()?; + let mut qb = sqlx::QueryBuilder::::new("UPDATE sync_fournisseurs SET updated_at = now()"); + if let Some(v) = actif_sql { + qb.push(", actif_sql = ").push_bind(v); + } + if let Some(v) = actif_qlik { + qb.push(", actif_qlik = ").push_bind(v); + } + if reactivation { + qb.push(", desactive_motif = NULL"); + } + qb.push(" WHERE code_fournisseur = ANY(").push_bind(codes).push(") RETURNING code_fournisseur"); + let rows = qb.build().fetch_all(&pool).await?; + tracing::info!( + "[api/admin/sync] {} fournisseur(s) mis à jour (sql={} qlik={})", + rows.len(), + actif_sql.map(|b| b.to_string()).unwrap_or_else(|| "undefined".into()), + actif_qlik.map(|b| b.to_string()).unwrap_or_else(|| "undefined".into()) + ); + Ok(Json(json!({ "success": true, "misAJour": rows.len() }))) +} diff --git a/server/src/admin/sync_routes.rs b/server/src/admin/sync_routes.rs new file mode 100644 index 0000000..ee21b36 --- /dev/null +++ b/server/src/admin/sync_routes.rs @@ -0,0 +1,126 @@ +//! `/api/admin/sync` — réglages, état et pilotage de la synchronisation nocturne. + +use super::sync_scheduler::{debut_fenetre, demander_arret, get_sync_scheduler_state, lancer_round, seed_sync_fournisseurs}; +use super::sync_settings::{read_sync_settings, save_sync_settings}; +use crate::auth::AdminUser; +use crate::error::{AppError, AppResult}; +use axum::http::StatusCode; +use axum::Json; +use chrono::Local; +use serde_json::{json, Map, Value}; + +/// GET — réglages, état courant, et ouverture de fenêtre. +pub async fn get(_admin: AdminUser) -> AppResult> { + let settings = read_sync_settings(); + let maintenant = Local::now(); + let debut = debut_fenetre(&settings, maintenant); + Ok(Json(json!({ + "success": true, + "settings": settings, + "state": get_sync_scheduler_state(), + "dansLaFenetre": debut.is_some(), + "fenetreDebutAt": debut.map(|d| crate::db::iso_utc(d.with_timezone(&chrono::Utc))), + "maintenant": crate::db::iso_utc(maintenant.with_timezone(&chrono::Utc)), + }))) +} + +fn corps_json(body: &[u8]) -> AppResult { + serde_json::from_slice(body).map_err(|_| AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Corps JSON invalide" }))) +} + +/// Entier borné « coercé » comme `z.coerce.number().int().min().max()`. +fn coerce_int(v: &Value, min: i64, max: i64) -> Result { + let n = match v { + Value::Number(n) => n.as_f64(), + Value::String(s) if s.trim().is_empty() => Some(0.0), + Value::String(s) => s.trim().parse::().ok(), + Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }), + Value::Null => Some(0.0), + _ => None, + }; + let Some(n) = n.filter(|n| n.is_finite()) else { + return Err("Invalid input: expected number, received NaN".into()); + }; + if n.fract() != 0.0 { + return Err("Invalid input: expected int, received number".into()); + } + let n = n as i64; + if n < min { + return Err(format!("Too small: expected number to be >={min}")); + } + if n > max { + return Err(format!("Too big: expected number to be <={max}")); + } + Ok(n) +} + +/// PATCH — met à jour les réglages. +pub async fn patch(_admin: AdminUser, body: axum::body::Bytes) -> AppResult> { + let body = corps_json(&body)?; + let Some(obj) = body.as_object() else { + return Err(AppError::with_body( + StatusCode::BAD_REQUEST, + json!({ "error": "Paramètres invalides", "details": [": Invalid input: expected object"] }), + )); + }; + let mut patch = Map::new(); + let mut details = Vec::new(); + match obj.get("actif") { + None => {} + Some(Value::Bool(b)) => { + patch.insert("actif".into(), Value::Bool(*b)); + } + Some(_) => details.push("actif: Invalid input: expected boolean".to_string()), + } + for (k, min, max) in [("heureDebut", 0, 23), ("heureFin", 0, 23), ("qlikParNuit", 0, 200), ("sqlParQlik", 1, 500), ("qlikMinJours", 0, 365)] { + if let Some(v) = obj.get(k) { + match coerce_int(v, min, max) { + Ok(n) => { + patch.insert(k.into(), Value::from(n)); + } + Err(m) => details.push(format!("{k}: {m}")), + } + } + } + if !details.is_empty() { + return Err(AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Paramètres invalides", "details": details }))); + } + let settings = save_sync_settings(&patch)?; + Ok(Json(json!({ "success": true, "settings": settings }))) +} + +/// POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. +pub async fn post(_admin: AdminUser, body: axum::body::Bytes) -> AppResult<(StatusCode, Json)> { + let body = corps_json(&body)?; + let action = body.get("action").and_then(|v| v.as_str()).unwrap_or_default(); + let forcer = match body.get("forcer") { + None => Some(false), + Some(Value::Bool(b)) => Some(*b), + Some(_) => None, + }; + if !matches!(action, "start" | "stop" | "seed") || forcer.is_none() { + return Err(AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Action inconnue (start, stop ou seed)" }))); + } + match action { + "seed" => { + let n = seed_sync_fournisseurs().await?; + Ok((StatusCode::OK, Json(json!({ "success": true, "fournisseurs": n })))) + } + "stop" => { + demander_arret(); + Ok((StatusCode::OK, Json(json!({ "success": true, "state": get_sync_scheduler_state() })))) + } + _ => { + let etat = get_sync_scheduler_state(); + if etat.en_cours { + return Ok((StatusCode::CONFLICT, Json(json!({ "error": "Un round est déjà en cours.", "state": etat })))); + } + // Lancé en tâche de fond : le round dure des minutes, le client suit via GET. + let forcer = forcer.unwrap_or(false); + tokio::spawn(async move { lancer_round("manuel", forcer).await }); + // Laisse au round le temps de passer « en cours » avant de répondre. + tokio::task::yield_now().await; + Ok((StatusCode::OK, Json(json!({ "success": true, "state": get_sync_scheduler_state() })))) + } + } +} diff --git a/server/src/admin/sync_scheduler.rs b/server/src/admin/sync_scheduler.rs new file mode 100644 index 0000000..7e002f4 --- /dev/null +++ b/server/src/admin/sync_scheduler.rs @@ -0,0 +1,501 @@ +//! Synchronisation nocturne des fournisseurs. +//! +//! Deux sources aux coûts très différents, donc deux files distinctes : +//! • **SQL** — l'instantané de grille (`get_product_rows`), ~43 s sur le plus +//! gros fournisseur. Alimente la Grille et `/api/v1`. +//! • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un +//! serveur externe qui abandonne quand on le sollicite trop. Agrégats +//! mensuels : les rafraîchir chaque nuit n'apporterait rien. +//! +//! ORDRE DE PASSAGE — la file est « le plus ancien d'abord », en ignorant ceux +//! déjà traités depuis l'ouverture de la fenêtre courante. Sans curseur ni +//! compteur de tour : +//! • la nuit 2 reprend là où la nuit 1 s'est arrêtée ; +//! • quand plus personne n'est en attente, le tour est fini ; la nuit +//! suivante tout le monde redevient éligible ; +//! • un fournisseur en échec garde une date ancienne : il repasse en tête. +//! +//! La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie +//! « ne plus en **démarrer** après l'heure de fin ». + +use super::sync_settings::{read_sync_settings, SyncSettings}; +use chrono::{DateTime, Datelike, Duration, Local, NaiveDateTime, TimeZone, Timelike}; +use parking_lot::Mutex; +use regex::Regex; +use serde::Serialize; +use sqlx::Row; +use std::collections::HashSet; +use std::sync::LazyLock; +use std::time::Instant; + +// ─── État courant, en mémoire ──────────────────────────────────────────────── + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncSchedulerState { + pub en_cours: bool, + /// "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. + pub origine: Option<&'static str>, + pub debut_at: Option, + pub fin_at: Option, + pub fournisseur_courant: Option, + pub etape: Option<&'static str>, + pub sql_faits: u32, + pub sql_echecs: u32, + pub qlik_faits: u32, + pub qlik_echecs: u32, + /// Renseigné quand la file s'est vidée : le tour est bouclé. + pub tour_termine: bool, + pub arret_demande: bool, + pub derniere_erreur: Option, +} + +static ETAT: LazyLock> = LazyLock::new(|| Mutex::new(SyncSchedulerState::default())); + +pub fn get_sync_scheduler_state() -> SyncSchedulerState { + ETAT.lock().clone() +} + +pub fn demander_arret() { + let mut e = ETAT.lock(); + if e.en_cours { + e.arret_demande = true; + } +} + +fn iso_local(d: DateTime) -> String { + crate::db::iso_utc(d.with_timezone(&chrono::Utc)) +} + +// ─── Fenêtre horaire ───────────────────────────────────────────────────────── + +fn a_heure(maintenant: DateTime, heure: i64) -> DateTime { + let naive = maintenant.date_naive().and_hms_opt(heure.clamp(0, 23) as u32, 0, 0).unwrap(); + Local.from_local_datetime(&naive).earliest().unwrap_or(maintenant) +} + +/// Début de la fenêtre en cours, ou `None` si l'heure courante est en dehors. +/// +/// Gère le passage par minuit (ex. 22h → 5h) : entre 0h et l'heure de fin, la +/// fenêtre a commencé **la veille**. +pub fn debut_fenetre(s: &SyncSettings, maintenant: DateTime) -> Option> { + let h = maintenant.hour() as i64; + let debut_du_jour = a_heure(maintenant, s.heure_debut); + if s.heure_debut < s.heure_fin { + return (h >= s.heure_debut && h < s.heure_fin).then_some(debut_du_jour); + } + // Fenêtre à cheval sur minuit. + if h >= s.heure_debut { + return Some(debut_du_jour); + } + if h < s.heure_fin { + return Some(debut_du_jour - Duration::days(1)); + } + None +} + +// ─── Référentiel des fournisseurs ──────────────────────────────────────────── + +/// Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux +/// fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien. +pub async fn seed_sync_fournisseurs() -> anyhow::Result { + let fournisseurs = crate::ff::pg::pg_get_fournisseurs(None).await; + if fournisseurs.is_empty() { + return Ok(0); + } + let pool = crate::db::pool()?; + for lot in fournisseurs.chunks(500) { + let mut qb = sqlx::QueryBuilder::::new("INSERT INTO sync_fournisseurs (code_fournisseur, nom_fournisseur) "); + qb.push_values(lot, |mut b, f| { + b.push_bind(&f.code).push_bind(&f.nom); + }); + // Seul le nom est réaligné : les choix d'activation appartiennent à l'admin. + qb.push(" ON CONFLICT (code_fournisseur) DO UPDATE SET nom_fournisseur = excluded.nom_fournisseur"); + qb.build().execute(&pool).await?; + } + Ok(fournisseurs.len()) +} + +// ─── Files d'attente ───────────────────────────────────────────────────────── + +struct Cible { + code: String, + nom: Option, +} + +fn cible(r: &sqlx::postgres::PgRow) -> Cible { + Cible { code: r.try_get("code_fournisseur").unwrap_or_default(), nom: r.try_get("nom_fournisseur").ok().flatten() } +} + +fn naive(d: DateTime) -> NaiveDateTime { + // Colonnes `timestamp` sans fuseau, écrites par `now()` du serveur (UTC) : + // on compare en UTC. + d.with_timezone(&chrono::Utc).naive_utc() +} + +/// Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. +async fn prochain_sql(depuis: DateTime) -> anyhow::Result> { + let pool = crate::db::pool()?; + let row = sqlx::query( + "SELECT code_fournisseur, nom_fournisseur FROM sync_fournisseurs + WHERE actif_sql = true AND (dernier_sql_at IS NULL OR dernier_sql_at < $1) + ORDER BY dernier_sql_at ASC NULLS FIRST LIMIT 1", + ) + .bind(naive(depuis)) + .fetch_optional(&pool) + .await?; + Ok(row.as_ref().map(cible)) +} + +/// Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. +const QLIK_REPRISE_ECHEC_H: i64 = 18; + +/// Le plus ancien fournisseur Qlik à rafraîchir : données plus vieilles que +/// `qlikMinJours`, ou d'avant le mois en cours (la fenêtre de 12 mois a glissé), +/// ou dernière extraction en échec il y a plus de 18 h. +async fn prochain_qlik(qlik_min_jours: i64, maintenant: DateTime) -> anyhow::Result> { + let seuil_age = maintenant - Duration::days(qlik_min_jours); + let debut_mois = Local + .from_local_datetime(&maintenant.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap()) + .earliest() + .unwrap_or(maintenant); + let seuil = if seuil_age > debut_mois { seuil_age } else { debut_mois }; + let seuil_echec = maintenant - Duration::hours(QLIK_REPRISE_ECHEC_H); + let pool = crate::db::pool()?; + let row = sqlx::query( + "SELECT code_fournisseur, nom_fournisseur FROM sync_fournisseurs + WHERE actif_qlik = true AND ( + dernier_qlik_at IS NULL + OR dernier_qlik_at < $1 + OR (dernier_qlik_statut = 'echec' AND dernier_qlik_at < $2) + ) + ORDER BY dernier_qlik_at ASC NULLS FIRST LIMIT 1", + ) + .bind(naive(seuil)) + .bind(naive(seuil_echec)) + .fetch_optional(&pool) + .await?; + Ok(row.as_ref().map(cible)) +} + +// ─── Traitement d'un fournisseur ───────────────────────────────────────────── + +fn libelle(code: &str, nom: &Option) -> String { + match nom { + Some(n) if !n.is_empty() => format!("{code} — {n}"), + _ => code.to_string(), + } +} + +fn tronque(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +/// Instantané SQL. `dernier_sql_at` est renseigné **même en cas d'échec** : +/// sans cela, un fournisseur en échec resterait le plus ancien et bloquerait la file. +async fn traiter_sql(code: &str, nom: &Option) { + let t0 = Instant::now(); + { + let mut e = ETAT.lock(); + e.fournisseur_courant = Some(libelle(code, nom)); + e.etape = Some("sql"); + } + let res = crate::grid::product_rows::get_product_rows_for_sync(code).await; + let ms = t0.elapsed().as_millis() as i32; + let pool = match crate::db::pool() { + Ok(p) => p, + Err(_) => return, + }; + match res { + Ok(n) => { + let vide = n == 0; + // Un fournisseur sans article n'a rien à donner : on le sort des deux + // files plutôt que de le réessayer chaque nuit (motif visible par l'admin). + let sql = if vide { + "UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'vide', dernier_sql_lignes = $2, + dernier_sql_erreur = NULL, dernier_sql_ms = $3, actif_sql = false, actif_qlik = false, + desactive_motif = 'Aucun article — désactivé automatiquement', updated_at = now() WHERE code_fournisseur = $1" + } else { + "UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'succes', dernier_sql_lignes = $2, + dernier_sql_erreur = NULL, dernier_sql_ms = $3, updated_at = now() WHERE code_fournisseur = $1" + }; + if let Err(e) = sqlx::query(sql).bind(code).bind(n as i32).bind(ms).execute(&pool).await { + tracing::error!("[sync-nuit] suivi SQL {code} non écrit : {e}"); + } + if vide { + tracing::info!("[sync-nuit] {code} sans article — désactivé automatiquement"); + } else { + ETAT.lock().sql_faits += 1; + } + } + Err(err) => { + let msg = format!("{err:#}"); + { + let mut e = ETAT.lock(); + e.sql_echecs += 1; + e.derniere_erreur = Some(format!("{code} (SQL) : {msg}")); + } + tracing::error!("[sync-nuit] SQL {code} en échec : {msg}"); + let _ = sqlx::query( + "UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'echec', dernier_sql_erreur = $2, + dernier_sql_ms = $3, updated_at = now() WHERE code_fournisseur = $1", + ) + .bind(code) + .bind(tronque(&msg, 500)) + .bind(ms) + .execute(&pool) + .await; + } + } +} + +/// Format d'un code centrale plausible — même filtre que la synchro manuelle. +static CODE_CENTRALE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^[A-Z0-9][A-Z0-9_-]{1,29}$").unwrap()); + +/// Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. +async fn traiter_qlik(code: &str, nom: &Option) { + let t0 = Instant::now(); + { + let mut e = ETAT.lock(); + e.fournisseur_courant = Some(libelle(code, nom)); + e.etape = Some("qlik"); + } + let res: anyhow::Result> = async { + let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?; + let mut vus = HashSet::new(); + let codes: Vec = articles + .iter() + .map(|a| a.code_centrale.clone().unwrap_or_default().trim().to_string()) + .filter(|c| !c.is_empty() && c != "-" && CODE_CENTRALE.is_match(c)) + .filter(|c| vus.insert(c.clone())) + .collect(); + if codes.is_empty() { + return Ok(None); + } + let n = super::qlik_bridge::extraire_et_enregistrer(codes, code).await?; + Ok(Some(n)) + } + .await; + let ms = t0.elapsed().as_millis() as i32; + let Ok(pool) = crate::db::pool() else { return }; + match res { + Ok(None) => { + let _ = sqlx::query( + "UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'vide', dernier_qlik_codes = 0, + dernier_qlik_erreur = NULL, dernier_qlik_ms = $2, updated_at = now() WHERE code_fournisseur = $1", + ) + .bind(code) + .bind(ms) + .execute(&pool) + .await; + } + Ok(Some(n)) => { + let _ = sqlx::query( + "UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'succes', dernier_qlik_codes = $2, + dernier_qlik_erreur = NULL, dernier_qlik_ms = $3, updated_at = now() WHERE code_fournisseur = $1", + ) + .bind(code) + .bind(n as i32) + .bind(ms) + .execute(&pool) + .await; + ETAT.lock().qlik_faits += 1; + tracing::info!("[sync-nuit] Qlik {code} — {n} codes en {} s", t0.elapsed().as_secs()); + } + Err(err) => { + let msg = format!("{err:#}"); + { + let mut e = ETAT.lock(); + e.qlik_echecs += 1; + e.derniere_erreur = Some(format!("{code} (Qlik) : {msg}")); + } + tracing::error!("[sync-nuit] Qlik {code} en échec : {msg}"); + let _ = sqlx::query( + "UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'echec', dernier_qlik_erreur = $2, + dernier_qlik_ms = $3, updated_at = now() WHERE code_fournisseur = $1", + ) + .bind(code) + .bind(tronque(&msg, 500)) + .bind(ms) + .execute(&pool) + .await; + } + } +} + +// ─── Boucle de travail ─────────────────────────────────────────────────────── + +/// Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt. +/// +/// Les deux files avancent en parallèle : une extraction Qlik est intercalée +/// tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne +/// démarrerait jamais tant que la file SQL n'est pas vide. +pub async fn lancer_round(origine: &'static str, forcer_hors_fenetre: bool) { + let s = read_sync_settings(); + let maintenant = Local::now(); + let debut = debut_fenetre(&s, maintenant); + if !forcer_hors_fenetre && debut.is_none() { + return; + } + { + let mut e = ETAT.lock(); + if e.en_cours { + return; + } + *e = SyncSchedulerState { + en_cours: true, + origine: Some(origine), + debut_at: Some(iso_local(maintenant)), + ..Default::default() + }; + } + // Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ». + let depuis = debut.unwrap_or_else(|| a_heure(maintenant, 0)); + tracing::info!("[sync-nuit] round {origine} démarré (fenêtre {}h-{}h, Qlik ≤ {})", s.heure_debut, s.heure_fin, s.qlik_par_nuit); + + let res: anyhow::Result<()> = async { + seed_sync_fournisseurs().await?; + + // Garde anti-boucle : l'avancement de la file repose sur l'écriture de + // `dernier*_at`, volontairement non bloquante en cas d'erreur. + let mut tentes: HashSet = HashSet::new(); + let mut depuis_dernier_qlik: i64 = 0; + loop { + if ETAT.lock().arret_demande { + break; + } + if !forcer_hors_fenetre && debut_fenetre(&s, Local::now()).is_none() { + tracing::info!("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours"); + break; + } + let qlik_possible = { + let e = ETAT.lock(); + ((e.qlik_faits + e.qlik_echecs) as i64) < s.qlik_par_nuit + }; + + // Qlik prioritaire quand le quota d'entrelacement est atteint. + if qlik_possible && depuis_dernier_qlik >= s.sql_par_qlik { + if let Some(c) = prochain_qlik(s.qlik_min_jours, Local::now()).await? { + if tentes.insert(format!("qlik:{}", c.code)) { + traiter_qlik(&c.code, &c.nom).await; + depuis_dernier_qlik = 0; + continue; + } + } + } + + if let Some(c) = prochain_sql(depuis).await? { + if !tentes.insert(format!("sql:{}", c.code)) { + tracing::error!( + "[sync-nuit] {} ressort de la file après avoir été traité : le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.", + c.code + ); + break; + } + traiter_sql(&c.code, &c.nom).await; + depuis_dernier_qlik += 1; + continue; + } + + // File SQL vide : on consacre le temps restant à Qlik. + if qlik_possible { + if let Some(c) = prochain_qlik(s.qlik_min_jours, Local::now()).await? { + if tentes.insert(format!("qlik:{}", c.code)) { + traiter_qlik(&c.code, &c.nom).await; + depuis_dernier_qlik = 0; + continue; + } + } + } + + ETAT.lock().tour_termine = true; + tracing::info!("[sync-nuit] plus rien en attente — tour terminé"); + break; + } + Ok(()) + } + .await; + + if let Err(e) = res { + let msg = format!("{e:#}"); + tracing::error!("[sync-nuit] round interrompu : {msg}"); + ETAT.lock().derniere_erreur = Some(msg); + } + // Les pages (stock, hit-parade, CA…) relisent la base fraîche. + crate::ff_cache::invalidate(); + let e = { + let mut e = ETAT.lock(); + e.en_cours = false; + e.fournisseur_courant = None; + e.etape = None; + e.fin_at = Some(iso_local(Local::now())); + e.clone() + }; + tracing::info!( + "[sync-nuit] round terminé — SQL {} ok / {} en échec, Qlik {} ok / {} en échec, tour bouclé={}", + e.sql_faits, + e.sql_echecs, + e.qlik_faits, + e.qlik_echecs, + e.tour_termine + ); +} + +/// Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais +/// deux rounds à la fois. Silencieux hors fenêtre — appelé chaque minute. +pub async fn tick_scheduler() { + let s = read_sync_settings(); + if !s.actif || ETAT.lock().en_cours { + return; + } + if debut_fenetre(&s, Local::now()).is_none() { + return; + } + lancer_round("auto", false).await; +} + +/// Minuteur de la synchronisation nocturne : bat chaque minute et laisse +/// `tick_scheduler` décider s'il y a quelque chose à faire. Rien ne tourne si +/// l'application est arrêtée ; il se réarme tout seul au redémarrage. +pub fn start_sync_timer() { + tracing::info!("[sync-nuit] minuteur démarré (battement 60 s)"); + tokio::spawn(async { + let mut intervalle = tokio::time::interval(std::time::Duration::from_secs(60)); + intervalle.tick().await; + loop { + intervalle.tick().await; + // Un round dure longtemps : il tourne dans sa propre tâche pour qu'une + // panique éventuelle n'arrête pas le minuteur. + if let Err(e) = tokio::spawn(tick_scheduler()).await { + tracing::error!("[sync-nuit] battement en erreur : {e}"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::admin::sync_settings::SYNC_SETTINGS_DEFAUT; + + fn at(h: u32) -> DateTime { + Local.with_ymd_and_hms(2026, 3, 10, h, 30, 0).unwrap() + } + + #[test] + fn fenetre_simple() { + let s = SyncSettings { heure_debut: 1, heure_fin: 5, ..SYNC_SETTINGS_DEFAUT }; + assert!(debut_fenetre(&s, at(0)).is_none()); + assert_eq!(debut_fenetre(&s, at(3)).unwrap().hour(), 1); + assert!(debut_fenetre(&s, at(5)).is_none()); + } + + #[test] + fn fenetre_minuit() { + let s = SyncSettings { heure_debut: 22, heure_fin: 5, ..SYNC_SETTINGS_DEFAUT }; + assert_eq!(debut_fenetre(&s, at(23)).unwrap().day(), 10); + let d = debut_fenetre(&s, at(2)).unwrap(); + assert_eq!((d.day(), d.hour()), (9, 22)); + assert!(debut_fenetre(&s, at(12)).is_none()); + } +} From 7dd5919264adcd6afc7585ec39c5cc9cb8a77197 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:24:28 +0000 Subject: [PATCH 06/19] Serveur Rust : portage du module FF Nancy (base miroir, API, routes, actions, pages) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ff::pg : portage complet de pg-ff-client.ts (mêmes requêtes SQL, fragments dynamiques via QueryBuilder, lectures tolérantes, caches process de la colonne parent de nomenclature et du lien mvtart → fournisseur). - ff::api : client de l'API FF Nancy (URL résolue Paramètres → env → défaut, mémorisée 30 s ; délai FF_API_TIMEOUT_MS), pagination, mensuel, référentiel, commandes, normalisation du statut de synchro. - ff::cache : getFournisseursCached / getDerniereReceptionCached. - ff::diagnostic : diagnostiquer_api_ff (DNS, refus, délai, code HTTP). - Routes /api/ff-status, /api/diag, /api/diag/stock-fournisseur, /api/test-db, /api/commandes-auto ; actions de la Grille et du cadencier ; pages dashboard, analytics, hit-parade, stock-negatif, commandes-auto, publicites. - Test d'intégration (FF_TEST_DATABASE_URL) avec fausse API FF : toutes les requêtes et tous les appels HTTP joués sur une base de test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/ff/actions.rs | 311 ++++ server/src/ff/api.rs | 714 ++++++++ server/src/ff/cache.rs | 34 + server/src/ff/diagnostic.rs | 149 ++ server/src/ff/fixture_tests.rs | 292 ++++ server/src/ff/js.rs | 172 ++ server/src/ff/mod.rs | 40 +- server/src/ff/pages.rs | 341 ++++ server/src/ff/pg.rs | 2988 +++++++++++++++++++++++++++++++- server/src/ff/routes.rs | 516 ++++++ 10 files changed, 5547 insertions(+), 10 deletions(-) create mode 100644 server/src/ff/actions.rs create mode 100644 server/src/ff/api.rs create mode 100644 server/src/ff/cache.rs create mode 100644 server/src/ff/diagnostic.rs create mode 100644 server/src/ff/fixture_tests.rs create mode 100644 server/src/ff/js.rs create mode 100644 server/src/ff/pages.rs create mode 100644 server/src/ff/routes.rs diff --git a/server/src/ff/actions.rs b/server/src/ff/actions.rs new file mode 100644 index 0000000..397bbb5 --- /dev/null +++ b/server/src/ff/actions.rs @@ -0,0 +1,311 @@ +//! Actions du module FF : `getFournisseurs`, `getMagasins` +//! (`src/features/grid/actions.ts`) et le cadencier de commande +//! (`src/features/commandes-auto/actions.ts`). + +use super::api::get_sites_from_api; +use super::cache::{get_derniere_reception_cached, get_fournisseurs_cached}; +use super::js::{js_number, message_erreur}; +use crate::db::pool; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use chrono::{DateTime, Local, Utc}; +use serde::Serialize; +use serde_json::Value; +use sqlx::Row; + +// --------------------------------------------------------------------------- +// Grille +// --------------------------------------------------------------------------- + +/// Liste de tous les fournisseurs (PostgreSQL FF). +pub async fn get_fournisseurs(ctx: Ctx) -> AppResult { + ctx.session().await?; + // Relu à chaque navigation dans la Grille (changement de fournisseur, de + // magasin…) : le référentiel ne change qu'avec la recopie nocturne. + json(get_fournisseurs_cached().await?) +} + +/// Liste des magasins (sites). +pub async fn get_magasins(ctx: Ctx) -> AppResult { + ctx.session().await?; + json(get_sites_from_api().await) +} + +// --------------------------------------------------------------------------- +// Cadencier +// --------------------------------------------------------------------------- + +/// Statut d'une cadence : "a_commander" | "bientot" | "ok" | "inconnu". +pub type CadenceStatut = &'static str; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CadenceView { + pub id: i32, + pub codefou: String, + pub nomfou: String, + pub site: String, + pub intervalle_semaines: i32, + pub actif: bool, + /// Date ISO de la dernière réception, ou null si inconnue + pub derniere_reception: Option, + /// Date ISO d'échéance (dernière réception + X semaines), ou null + pub echeance: Option, + /// Jours restants avant l'échéance (négatif = en retard), ou null + pub jours_restants: Option, + pub statut: CadenceStatut, +} + +const MS_PAR_JOUR: i64 = 24 * 60 * 60 * 1000; +/// Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" +const SEUIL_BIENTOT_JOURS: i64 = 7; + +fn calc_statut(jours_restants: Option) -> CadenceStatut { + match jours_restants { + None => "inconnu", + Some(j) if j <= 0 => "a_commander", + Some(j) if j <= SEUIL_BIENTOT_JOURS => "bientot", + Some(_) => "ok", + } +} + +/// Liste des fournisseurs (référentiel) pour le sélecteur de cadence. +pub async fn get_fournisseurs_pour_cadence(ctx: Ctx) -> AppResult { + ctx.session().await?; + json(get_fournisseurs_cached().await?) +} + +struct CadenceRow { + id: i32, + code_fournisseur: String, + nom_fournisseur: Option, + site: String, + intervalle_semaines: i32, + actif: bool, +} + +/// Toutes les cadences enrichies du calcul d'échéance (sans contrôle d'accès). +/// Liste vide en cas d'erreur. +pub async fn lister_cadences() -> Vec { + let res = async { + let rows = sqlx::query( + "SELECT id, code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif FROM commande_cadences", + ) + .fetch_all(&pool()?) + .await?; + let rows: Vec = rows + .iter() + .map(|r| CadenceRow { + id: r.try_get("id").unwrap_or_default(), + code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(), + nom_fournisseur: r.try_get("nom_fournisseur").unwrap_or_default(), + site: r.try_get("site").unwrap_or_default(), + intervalle_semaines: r.try_get("intervalle_semaines").unwrap_or_default(), + actif: r.try_get("actif").unwrap_or_default(), + }) + .collect(); + // Réceptions des seuls fournisseurs du cadencier : la lecture sur tout + // l'historique de tous les fournisseurs était la plus lourde de la page. + let codes: Vec = rows.iter().map(|r| r.code_fournisseur.clone()).collect(); + let receptions = get_derniere_reception_cached(&codes).await?; + anyhow::Ok((rows, receptions)) + } + .await; + let (rows, receptions) = match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[cadence] listCadences error: {}", message_erreur(&e)); + return vec![]; + } + }; + + let now = Local::now().with_timezone(&Utc).timestamp_millis(); + + rows.into_iter() + .map(|r| { + let reception_iso = receptions.get(&format!("{}|{}", r.code_fournisseur, r.site)).cloned(); + + let mut echeance_iso = None; + let mut jours_restants = None; + if let Some(iso) = reception_iso.as_deref() { + if let Ok(d) = DateTime::parse_from_rfc3339(iso) { + let echeance = d.timestamp_millis() + r.intervalle_semaines as i64 * 7 * MS_PAR_JOUR; + echeance_iso = DateTime::::from_timestamp_millis(echeance).map(crate::db::iso_utc); + jours_restants = Some(((echeance - now) as f64 / MS_PAR_JOUR as f64).ceil() as i64); + } + } + + CadenceView { + id: r.id, + nomfou: r.nom_fournisseur.clone().unwrap_or_else(|| r.code_fournisseur.clone()), + codefou: r.code_fournisseur, + site: r.site, + intervalle_semaines: r.intervalle_semaines, + actif: r.actif, + derniere_reception: reception_iso, + echeance: echeance_iso, + jours_restants, + statut: if r.actif { calc_statut(jours_restants) } else { "inconnu" }, + } + }) + .collect() +} + +/// Retourne toutes les cadences enrichies du calcul d'échéance à partir +/// de la dernière réception (FF Nancy). +pub async fn list_cadences(ctx: Ctx) -> AppResult { + ctx.session().await?; + json(lister_cadences().await) +} + +/// Réponse des actions d'écriture du cadencier. +#[derive(Debug, Clone, Serialize)] +pub struct ResultatCadence { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ResultatCadence { + fn ok() -> Self { + Self { ok: true, error: None } + } + fn ko(error: Option) -> Self { + Self { ok: false, error } + } +} + +/// `Math.round` (arrondi vers +∞ pour les demis). +fn math_round(x: f64) -> f64 { + (x + 0.5).floor() +} + +/// Crée ou met à jour la cadence d'un fournisseur sur un site. +/// Entrée : `{ codefou, nomfou?, site, intervalleSemaines, actif? }`. +pub async fn upsert_cadence(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.session().await { + return json(ResultatCadence::ko(Some(e.message))); + } + let input = ctx.raw(0); + if !input.is_object() { + return Err(crate::error::AppError::internal("Cannot read properties of undefined (reading 'codefou')")); + } + + let texte = |k: &str| input.get(k).and_then(|v| v.as_str()).map(|s| s.trim().to_string()); + let codefou = texte("codefou").unwrap_or_default(); + let site = texte("site").unwrap_or_default(); + let intervalle = math_round(js_number(input.get("intervalleSemaines").unwrap_or(&Value::Null))); + // `input.intervalleSemaines` absent → Number(undefined) = NaN. + let intervalle = if input.get("intervalleSemaines").is_none() { f64::NAN } else { intervalle }; + + if codefou.is_empty() || site.is_empty() { + return json(ResultatCadence::ko(Some("Fournisseur et magasin requis.".into()))); + } + if !intervalle.is_finite() || !(1.0..=104.0).contains(&intervalle) { + return json(ResultatCadence::ko(Some("L'intervalle doit être compris entre 1 et 104 semaines.".into()))); + } + let nomfou: Option = input.get("nomfou").and_then(|v| v.as_str()).map(|s| s.to_string()); + let actif = input.get("actif").and_then(|v| v.as_bool()).unwrap_or(true); + + let res = async { + sqlx::query( + r#"INSERT INTO commande_cadences (code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (code_fournisseur, site) DO UPDATE SET + nom_fournisseur = $2, + intervalle_semaines = $4, + actif = $5, + updated_at = $6"#, + ) + .bind(&codefou) + .bind(&nomfou) + .bind(&site) + .bind(intervalle as i32) + .bind(actif) + .bind(Local::now().naive_local()) + .execute(&pool()?) + .await?; + anyhow::Ok(()) + } + .await; + match res { + Ok(()) => json(ResultatCadence::ok()), + Err(e) => { + tracing::error!("[cadence] upsertCadence error: {}", message_erreur(&e)); + json(ResultatCadence::ko(Some("Échec de l'enregistrement.".into()))) + } + } +} + +/// Active / désactive une cadence sans la supprimer. +/// Arguments : `(codefou, site, actif)`. +pub async fn toggle_cadence(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.session().await { + return json(ResultatCadence::ko(Some(e.message))); + } + let codefou: String = ctx.arg(0)?; + let site: String = ctx.arg(1)?; + let actif: bool = ctx.arg(2)?; + + let res = async { + sqlx::query("UPDATE commande_cadences SET actif = $1, updated_at = $2 WHERE code_fournisseur = $3 AND site = $4") + .bind(actif) + .bind(Local::now().naive_local()) + .bind(&codefou) + .bind(&site) + .execute(&pool()?) + .await?; + anyhow::Ok(()) + } + .await; + match res { + Ok(()) => json(ResultatCadence::ok()), + Err(e) => { + tracing::error!("[cadence] toggleCadence error: {}", message_erreur(&e)); + json(ResultatCadence::ko(None)) + } + } +} + +/// Supprime définitivement une cadence. +/// Arguments : `(codefou, site)`. +pub async fn remove_cadence(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.session().await { + return json(ResultatCadence::ko(Some(e.message))); + } + let codefou: String = ctx.arg(0)?; + let site: String = ctx.arg(1)?; + + let res = async { + sqlx::query("DELETE FROM commande_cadences WHERE code_fournisseur = $1 AND site = $2") + .bind(&codefou) + .bind(&site) + .execute(&pool()?) + .await?; + anyhow::Ok(()) + } + .await; + match res { + Ok(()) => json(ResultatCadence::ok()), + Err(e) => { + tracing::error!("[cadence] removeCadence error: {}", message_erreur(&e)); + json(ResultatCadence::ko(None)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn statuts() { + assert_eq!(calc_statut(None), "inconnu"); + assert_eq!(calc_statut(Some(0)), "a_commander"); + assert_eq!(calc_statut(Some(-3)), "a_commander"); + assert_eq!(calc_statut(Some(7)), "bientot"); + assert_eq!(calc_statut(Some(8)), "ok"); + assert_eq!(math_round(2.5), 3.0); + assert_eq!(math_round(-1.5), -1.0); + } +} diff --git a/server/src/ff/api.rs b/server/src/ff/api.rs new file mode 100644 index 0000000..99554bc --- /dev/null +++ b/server/src/ff/api.rs @@ -0,0 +1,714 @@ +//! CollectFlow — client de l'API FF Nancy (port de `src/lib/api-ff-client.ts`). +//! +//! Client HTTP centralisé pour l'API REST https://api.ffnancy.fr +//! Sync nuit SQL Server → données fraîches J-1 +//! +//! Stratégie mouvements : appeler SANS filtre genremvt (tous types) +//! - genremvt = 3 → alimenter sales12m (ventes) +//! - Tous types → lire qtestock pour reconstruire stock12m + +use super::js::{coalesce, encode, get, js_number, js_number_or0, js_string, message_erreur, opt_string, truthy}; +use chrono::{Datelike, Local, NaiveDate}; +use parking_lot::Mutex; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{LazyLock, OnceLock}; +use std::time::{Duration, Instant}; + +pub const FF_API_BASE_DEFAULT: &str = "https://api.ffnancy.fr"; +const FF_API_BASE_TTL: Duration = Duration::from_secs(30); + +/// Délai maximal d'un appel à l'API FF (`FF_API_TIMEOUT_MS`, 30 s par défaut). +/// Sans délai, une API muette figeait indéfiniment la page qui l'attendait. +pub fn ff_fetch_timeout() -> Duration { + let ms = std::env::var("FF_API_TIMEOUT_MS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| v.is_finite() && *v > 0.0) + .unwrap_or(30_000.0); + Duration::from_millis(ms as u64) +} + +/// Client HTTP de tout appel à l'API FF (équivalent de `ffFetchInit()`) : +/// jamais de cache HTTP, et un délai maximal. +pub fn ff_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(ff_fetch_timeout()) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +/// `fetch(url, ffFetchInit())`. +pub async fn ff_get(url: &str) -> reqwest::Result { + ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await +} + +/// Libellé HTTP d'un statut (équivalent de `res.statusText`). +pub fn status_text(status: reqwest::StatusCode) -> &'static str { + status.canonical_reason().unwrap_or("") +} + +static FF_API_BASE_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// URL de l'API FF Nancy, résolue **au moment de l'appel**. +/// +/// Ordre de priorité : réglage enregistré dans Paramètres → variable +/// d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était +/// figée au chargement du module depuis l'environnement seul : impossible de la +/// corriger depuis l'application quand le serveur changeait d'adresse. +/// +/// Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête. +pub async fn get_ff_api_base() -> String { + if let Some((value, at)) = FF_API_BASE_CACHE.lock().as_ref() { + if at.elapsed() < FF_API_BASE_TTL { + return value.clone(); + } + } + // Fichier absent ou illisible : on retombe sur l'environnement. + let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url) + .await + .ok() + .flatten() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let env = std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty()); + let value = configured + .or(env) + .unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string()) + .trim_end_matches('/') + .to_string(); + *FF_API_BASE_CACHE.lock() = Some((value.clone(), Instant::now())); + value +} + +/// Vide le cache — appelé après un enregistrement dans Paramètres. +pub fn reset_ff_api_base_cache() { + *FF_API_BASE_CACHE.lock() = None; +} + +// --------------------------------------------------------------------------- +// Types API (shapes des réponses brutes) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfFournisseur { + pub codefou: String, + pub nomfou: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfArticle { + pub codein: String, + pub libelle1: String, + pub codefou: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nomfou: Option, + /// conditionnement + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pcb: Option, + /// prix de vente central + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pv_central: Option, + /// SQL Server internal ID (pour endpoint /mensuel) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub noid: Option, + /// référence fournisseur (ref_fou_principale) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, + /// code-barres EAN/GTIN + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gtin: Option, + /// article actif + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actif: Option, + /// article suspendu + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suspendu: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMouvement { + pub codein: String, + /// 3 = vente client + pub genremvt: f64, + /// ISO date (YYYY-MM-DD) + pub datemvt: String, + pub qte: f64, + /// montant HT + pub montant: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marge: Option, + /// stock APRÈS ce mouvement — clé pour stock12m + pub qtestock: f64, + pub site: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codefou_reel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nom_fournisseur_reel: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfStock { + pub codein: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub site: Option, + pub stockdispo: f64, + pub qte: f64, + pub valstock: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prmp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pv: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dernieresventes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dernierereception: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nbjoursdernsventes: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfCommande { + pub codein: String, + pub qtecde: f64, +} + +/// Une table de `/api/sync/status`, forme normalisée. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FfSyncTable { + pub nom: String, + pub derniere_sync: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nb_lignes: Option, + /// "ok" ou état d'erreur renvoyé par la synchronisation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub statut: Option, + #[serde(default)] + pub erreur: Option, +} + +/// Statut de synchronisation. `tables` reste en JSON brut : la forme déjà +/// attendue (`{ lastSync, tables }`) était renvoyée telle quelle par le TS. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FfSyncStatus { + pub last_sync: String, + pub tables: Vec, +} + +/// Normalise la réponse de `/api/sync/status`. +/// +/// L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`, +/// alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`. +/// Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une +/// coquille vide sans la moindre erreur, puisque la requête HTTP réussissait. +/// +/// Les deux formes sont acceptées ici, pour rester compatible si l'API évolue. +pub fn normalize_sync_status(raw: &Value) -> Option { + let d = raw.as_object()?; + + // Forme déjà attendue par l'application. + if let Some(Value::Array(tables)) = d.get("tables") { + let last = d.get("lastSync").filter(|v| !v.is_null()).map(js_string).unwrap_or_default(); + return Some(FfSyncStatus { last_sync: last, tables: tables.clone() }); + } + + // Forme réelle de l'API FF Nancy. + let sync = match d.get("sync") { + Some(Value::Array(a)) => a, + _ => return None, + }; + + let tables: Vec = sync + .iter() + .map(|t| FfSyncTable { + nom: coalesce(t, &["table_name", "nom"]).map(js_string).unwrap_or_else(|| "—".into()), + derniere_sync: coalesce(t, &["last_sync", "derniereSync"]).map(js_string).unwrap_or_default(), + nb_lignes: Some(coalesce(t, &["rows_synced", "nbLignes"]).map(js_number_or0).unwrap_or(0.0)), + statut: get(t, "status").filter(|v| !v.is_null()).map(js_string), + erreur: get(t, "error_msg").filter(|v| !v.is_null()).map(js_string), + }) + .collect(); + + // Pas de date globale dans la réponse : on prend la plus récente des tables. + let mut dates: Vec<&String> = tables.iter().map(|t| &t.derniere_sync).filter(|s| !s.is_empty()).collect(); + dates.sort(); + let last_sync = dates.last().map(|s| s.to_string()).unwrap_or_default(); + + let tables = tables.into_iter().map(|t| serde_json::to_value(t).unwrap_or(Value::Null)).collect(); + Some(FfSyncStatus { last_sync, tables }) +} + +/// Stock agrégé sur tous les sites pour un codein +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AggregatedStock { + /// sum stockdispo + pub stock_actuel: f64, + /// sum qte + pub stock_total: f64, + /// sum valstock + pub stock_valeur: f64, + /// prmp (premier site non-zéro) + pub pa: f64, + /// pv premier site + pub prix_vente: f64, + /// max date tous sites + pub derniere_vente: String, + /// max dernierereception tous sites + pub derniere_livraison: String, + /// min tous sites + pub nb_jours_derniere_vente: f64, +} + +// --------------------------------------------------------------------------- +// Pagination générique +// --------------------------------------------------------------------------- + +/// Liste contenue dans une réponse : tableau direct, ou enveloppe courante. +pub fn extract_list(data: &Value) -> Vec { + if let Value::Array(a) = data { + return a.clone(); + } + let Some(d) = data.as_object() else { return vec![] }; + // Essaie les wrappers courants + for key in [ + "items", "data", "result", "results", "records", "list", "mouvements", "articles", "fournisseurs", "ventes", "stocks", + "commandes", + ] { + if let Some(Value::Array(a)) = d.get(key) { + return a.clone(); + } + } + // Cherche la première propriété qui est un tableau + for val in d.values() { + if let Value::Array(a) = val { + return a.clone(); + } + } + vec![] +} + +fn first_keys(items: &[Value]) -> String { + match items.first().and_then(|v| v.as_object()) { + Some(o) => o.keys().cloned().collect::>().join(","), + None => { + if items.is_empty() { + "empty".into() + } else { + String::new() + } + } + } +} + +async fn fetch_json(url: &str) -> anyhow::Result> { + let res = ff_get(url).await?; + if !res.status().is_success() { + return Ok(None); + } + Ok(Some(res.json::().await?)) +} + +/// Toutes les pages d'une liste paginée. Une erreur réseau est propagée ; une +/// réponse HTTP en échec donne une page vide. +pub async fn fetch_all_pages(build_url: impl Fn(u32) -> String, page_size: usize) -> anyhow::Result> { + // Page 1 — récupère les items et tente d'obtenir le total pour paralléliser + let url1 = build_url(1); + tracing::info!("[api-ff] GET {url1}"); + let res1 = ff_get(&url1).await?; + if !res1.status().is_success() { + tracing::error!("[api-ff] HTTP {} — {url1}", res1.status().as_u16()); + return Ok(vec![]); + } + let data1: Value = res1.json().await?; + let items1 = extract_list(&data1); + tracing::info!("[api-ff] page 1: {} items, keys: {}", items1.len(), first_keys(&items1)); + + // Si la première page est incomplète, pas besoin de continuer + if items1.len() < page_size { + return Ok(items1); + } + + // Cherche un total dans la réponse pour paralléliser les pages suivantes + let total = js_number(coalesce(&data1, &["count", "total", "nb_total", "totalCount"]).unwrap_or(&json!(0))); + + if total > page_size as f64 { + let total_pages = (total / page_size as f64).ceil() as u32; + let remaining: Vec = (2..=total_pages).collect(); + // Fetch en chunks de 10 pages en parallèle + let mut all = items1; + for chunk in remaining.chunks(10) { + let results = futures::future::try_join_all(chunk.iter().map(|&p| { + let url = build_url(p); + async move { + tracing::info!("[api-ff] GET {url}"); + anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default()) + } + })) + .await?; + for r in results { + all.extend(r); + } + } + return Ok(all); + } + + // Fallback parallèle sans total : fetch 10 pages à la fois, arrêt dès qu'une page est incomplète + let mut all = items1; + let mut start_page = 2u32; + loop { + let results = futures::future::try_join_all((0..10).map(|i| { + let p = start_page + i; + let url = build_url(p); + async move { + tracing::info!("[api-ff] GET {url}"); + anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default()) + } + })) + .await?; + let mut done = false; + for items in results { + let n = items.len(); + all.extend(items); + if n < page_size { + done = true; + break; + } + } + if done { + break; + } + start_page += 10; + } + Ok(all) +} + +// --------------------------------------------------------------------------- +// Helpers date +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DateRange { + pub date_debut: String, + pub date_fin: String, +} + +/// Retourne la fenêtre des 12 mois complets précédant le mois en cours. +/// En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" } +pub fn build_last12_months_range() -> DateRange { + build_last12_months_range_at(Local::now().date_naive()) +} + +pub fn build_last12_months_range_at(today: NaiveDate) -> DateRange { + let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + // Fin : dernier jour du mois précédent le mois courant + let end_month = first_of_month.pred_opt().unwrap_or(first_of_month); + // Début : 12 mois avant le mois courant + let start_month = first_of_month.checked_sub_months(chrono::Months::new(12)).unwrap_or(first_of_month); + DateRange { + date_debut: start_month.format("%Y-%m-%d").to_string(), + date_fin: end_month.format("%Y-%m-%d").to_string(), + } +} + +// --------------------------------------------------------------------------- +// Articles +// --------------------------------------------------------------------------- + +fn opt_f64(v: Option<&Value>) -> Option { + v.filter(|x| !x.is_null()).map(js_number).filter(|f| !f.is_nan()) +} + +pub async fn get_articles_by_fournisseur(codefou: &str) -> anyhow::Result> { + let base = get_ff_api_base().await; + let enc = encode(codefou); + let raw = fetch_all_pages(|page| format!("{base}/api/articles?codefou={enc}&page={page}&limit=500"), 500).await?; + Ok(raw + .iter() + .map(|r| FfArticle { + codein: coalesce(r, &["codein", "Codein", "code_article", "codeArticle"]) + .map(js_string) + .unwrap_or_else(|| get(r, "id").filter(|v| !v.is_null()).map(js_string).unwrap_or_default()), + libelle1: coalesce(r, &["libelle1", "Libelle1", "libelle", "designation", "nom"]).map(js_string).unwrap_or_default(), + codefou: coalesce(r, &["codefou_principal", "codefou", "Codefou", "code_fournisseur", "codeFournisseur"]) + .map(js_string) + .unwrap_or_else(|| codefou.to_string()), + nomfou: Some( + coalesce(r, &["nom_fou_principal", "nomfou", "Nomfou", "nom_fournisseur", "nomFournisseur"]) + .map(js_string) + .unwrap_or_default(), + ), + pcb: opt_f64(coalesce(r, &["pcb_principal", "pcb", "Pcb", "conditionnement", "colisage"])), + pv_central: opt_f64(coalesce(r, &["pv_central", "PvCentral", "prix_vente_mini", "pv", "prixVente"])), + noid: opt_f64(coalesce(r, &["no_id", "noid", "NoId"])).map(|f| f as i64), + reference: opt_string(coalesce(r, &["ref_fou_principale", "reference", "ref_fournisseur"])), + gtin: opt_string(coalesce(r, &["gtin", "Gtin", "ean", "barcode"])), + actif: get(r, "actif").filter(|v| !v.is_null()).map(|v| { + let n = js_number(v); + n != 0.0 && !n.is_nan() + }), + suspendu: get(r, "suspendu").filter(|v| !v.is_null()).map(|v| { + let n = js_number(v); + n != 0.0 && !n.is_nan() + }), + }) + .filter(|a| !a.codein.is_empty()) + .collect()) +} + +// --------------------------------------------------------------------------- +// Mensuel — stock fin de mois + ventes + réceptions par article et par site +// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin= +// --------------------------------------------------------------------------- + +/// Chaîne tolérante : un nombre devient sa forme texte, `null` une chaîne vide. +fn de_string_lenient<'de, D: Deserializer<'de>>(d: D) -> Result { + let v = Value::deserialize(d)?; + Ok(if v.is_null() { String::new() } else { js_string(&v) }) +} + +/// Ventes du mois. Valeurs telles que renvoyées par l'API (chaînes numériques, +/// ex. `"-2.000"`) : lire avec [`super::js::js_number`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMensuelVentes { + #[serde(default)] + pub nb_passages: Value, + /// négatif (ex: "-2.000") + #[serde(default)] + pub qte_vendue: Value, + /// négatif (ex: "-26.6600") + #[serde(default)] + pub ca_ht: Value, + #[serde(default)] + pub ca_ttc: Value, + /// positif (ex: "12.7600") + #[serde(default)] + pub marge: Value, + #[serde(default)] + pub taux_marge: Value, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMensuelReceptions { + #[serde(default)] + pub nb_receptions: Value, + #[serde(default)] + pub qte_recue: Value, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMensuelEntry { + /// "YYYY-MM" + #[serde(default, deserialize_with = "de_string_lenient")] + pub mois: String, + /// code site ("292", "579") + #[serde(default, deserialize_with = "de_string_lenient")] + pub site: String, + /// stock fin de mois + #[serde(default)] + pub stock_fin_mois: Value, + /// PRMP fin de mois + #[serde(default)] + pub prmp_fin_mois: Value, + #[serde(default)] + pub ventes: Option, + #[serde(default)] + pub receptions: Option, + /// Autres champs de la réponse, conservés tels quels. + #[serde(flatten, default)] + pub autres: serde_json::Map, +} + +/// Récupère les données mensuelles (stock + ventes + réceptions) pour un lot d'articles. +/// Utilise no_id comme identifiant (obligatoire pour l'API). +/// Retourne Map +pub async fn get_mensuel_by_articles( + articles: &[FfArticle], + date_debut: &str, + date_fin: &str, + batch_size: usize, +) -> HashMap> { + let mut result = HashMap::new(); + let base = get_ff_api_base().await; + + for batch in articles.chunks(batch_size.max(1)) { + let found = futures::future::join_all(batch.iter().map(|art| { + let base = base.clone(); + async move { + let no_id = art.noid.filter(|n| *n != 0)?; + let url = format!("{base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(&no_id.to_string())); + let res = async { + let res = ff_get(&url).await?; + if !res.status().is_success() { + return anyhow::Ok(None); + } + let data: Value = res.json().await?; + let entries: Vec = match get(&data, "data") { + Some(Value::Array(a)) => a.iter().filter_map(|e| serde_json::from_value(e.clone()).ok()).collect(), + _ => vec![], + }; + Ok(Some(entries)) + } + .await; + match res { + Ok(Some(entries)) if !entries.is_empty() => Some((art.codein.clone(), entries)), + Ok(_) => None, + Err(err) => { + tracing::error!("[api-ff] getMensuel error for {}: {}", art.codein, message_erreur(&err)); + None + } + } + } + })) + .await; + result.extend(found.into_iter().flatten()); + } + + result +} + +// --------------------------------------------------------------------------- +// Referentiel — article complet : nomenclature, gammes, stock, prix, fournisseurs +// GET /api/articles/:no_id/referentiel +// --------------------------------------------------------------------------- + +/// Référentiel complet d'un article, JSON brut de l'API : +/// `{ article: { no_id, codein, nom_code, nom_libelle, nom_niveau, nom_chemin_pere }, +/// gammes: [{ gamme_code, gamme_libelle, saison_code, saison_libelle }], +/// stock: [{ site, stockdispo, qte, prmp, valstock, pv, dernierevente, dernierereception, stockmort }], +/// prix: { achat, vente_par_site: [{ site, pv }] } | null, +/// fournisseurs: [{ codefou, ref_fou, pcb, prixachat }], +/// performance: { derniere_vente, derniere_entree, qte_totale_vendue, ca_ttc_total, marge_totale } | null }` +pub type FfReferentiel = Value; + +/// Récupère le référentiel complet (nomenclature, gamme, stock, prix) pour un lot d'articles. +/// Utilise no_id comme identifiant. +/// Retourne Map +pub async fn get_referentiel_by_articles(articles: &[FfArticle], batch_size: usize) -> HashMap { + let mut result = HashMap::new(); + let base = get_ff_api_base().await; + + for batch in articles.chunks(batch_size.max(1)) { + let found = futures::future::join_all(batch.iter().map(|art| { + let base = base.clone(); + async move { + let no_id = art.noid.filter(|n| *n != 0)?; + let url = format!("{base}/api/articles/{}/referentiel", encode(&no_id.to_string())); + let res = async { + let res = ff_get(&url).await?; + if !res.status().is_success() { + return anyhow::Ok(None); + } + let data: Value = res.json().await?; + Ok(get(&data, "article").filter(|a| truthy(a)).is_some().then_some(data)) + } + .await; + match res { + Ok(Some(data)) => Some((art.codein.clone(), data)), + Ok(None) => None, + Err(err) => { + tracing::error!("[api-ff] getReferentiel error for {}: {}", art.codein, message_erreur(&err)); + None + } + } + } + })) + .await; + result.extend(found.into_iter().flatten()); + } + + result +} + +// --------------------------------------------------------------------------- +// Commandes en cours +// --------------------------------------------------------------------------- + +pub async fn get_commandes_by_fournisseur(codefou: &str) -> HashMap { + let mut result: HashMap = HashMap::new(); + let base = get_ff_api_base().await; + let res = async { + let res = ff_get(&format!("{base}/api/commandes/articles?codefou={}", encode(codefou))).await?; + if !res.status().is_success() { + return anyhow::Ok(()); + } + let data: Value = res.json().await?; + let list = if data.is_array() { Some(&data) } else { coalesce(&data, &["items", "data"]) }; + let list = match list { + None => return Ok(()), + Some(Value::Array(a)) => a, + Some(_) => anyhow::bail!("list is not iterable"), + }; + for c in list { + let codein = get(c, "codein").map(js_string).unwrap_or_else(|| "undefined".into()); + let qte = get(c, "qtecde").filter(|v| !v.is_null()).map(js_number).unwrap_or(0.0); + *result.entry(codein).or_insert(0.0) += qte; + } + Ok(()) + } + .await; + if let Err(err) = res { + tracing::error!("[api-ff] getCommandesByFournisseur error: {}", message_erreur(&err)); + } + result +} + +// --------------------------------------------------------------------------- +// Sites (magasins) +// --------------------------------------------------------------------------- + +pub async fn get_sites_from_api() -> Vec { + vec![ + super::pg::CodeNom { code: "292".into(), nom: "Frouard (Nancy)".into() }, + super::pg::CodeNom { code: "579".into(), nom: "Houdemont".into() }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn last12_range() { + let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()); + assert_eq!(r, DateRange { date_debut: "2025-03-01".into(), date_fin: "2026-02-28".into() }); + let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()); + assert_eq!(r, DateRange { date_debut: "2025-01-01".into(), date_fin: "2025-12-31".into() }); + } + + #[test] + fn sync_status_forms() { + let api = json!({ "sync": [ + { "table_name": "mvtart", "last_sync": "2026-01-02T03:00:00Z", "rows_synced": "12", "status": "ok", "error_msg": null }, + { "table_name": "articles", "last_sync": "2026-01-03T03:00:00Z", "rows_synced": null, "status": null, "error_msg": "x" } + ]}); + let s = serde_json::to_value(normalize_sync_status(&api).unwrap()).unwrap(); + assert_eq!( + s, + json!({ "lastSync": "2026-01-03T03:00:00Z", "tables": [ + { "nom": "mvtart", "derniereSync": "2026-01-02T03:00:00Z", "nbLignes": 12.0, "statut": "ok", "erreur": null }, + { "nom": "articles", "derniereSync": "2026-01-03T03:00:00Z", "nbLignes": 0.0, "erreur": "x" } + ]}) + ); + let deja = json!({ "lastSync": "a", "tables": [{ "nom": "t" }] }); + let s = serde_json::to_value(normalize_sync_status(&deja).unwrap()).unwrap(); + assert_eq!(s, json!({ "lastSync": "a", "tables": [{ "nom": "t" }] })); + assert!(normalize_sync_status(&json!({ "x": 1 })).is_none()); + assert!(normalize_sync_status(&json!(null)).is_none()); + } + + #[test] + fn extract_list_wrappers() { + assert_eq!(extract_list(&json!([1, 2])).len(), 2); + assert_eq!(extract_list(&json!({ "articles": [1] })).len(), 1); + assert_eq!(extract_list(&json!({ "foo": 1, "bar": [1, 2, 3] })).len(), 3); + assert!(extract_list(&json!("x")).is_empty()); + } +} diff --git a/server/src/ff/cache.rs b/server/src/ff/cache.rs new file mode 100644 index 0000000..2b94f25 --- /dev/null +++ b/server/src/ff/cache.rs @@ -0,0 +1,34 @@ +//! Lectures FF partagées par plusieurs pages, mises en cache +//! (port de `getFournisseursCached` / `getDerniereReceptionCached` de `src/lib/ff-cache.ts`). + +use super::pg::{pg_get_derniere_reception_par_fournisseur, pg_get_fournisseurs, CodeNom}; +use crate::ff_cache::{cached, CacheOpts}; +use std::collections::{BTreeSet, HashMap}; + +/// Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé. +pub async fn get_fournisseurs_cached() -> anyhow::Result> { + cached( + "fournisseurs", + CacheOpts { ttl: None, cache_if: Some(|rows: &Vec| !rows.is_empty()) }, + || async { anyhow::Ok(pg_get_fournisseurs(None).await) }, + ) + .await +} + +/// Dernière réception par fournisseur et magasin, pour les fournisseurs du +/// cadencier seulement : de loin la lecture la plus lourde de la page Commandes. +/// Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée. +/// Vide = erreur avalée (ou aucune réception), non gardé. +pub async fn get_derniere_reception_cached(codes: &[String]) -> anyhow::Result> { + let liste: Vec = codes.iter().map(|c| c.trim().to_string()).collect::>().into_iter().collect(); + if liste.is_empty() { + return Ok(HashMap::new()); + } + let key = format!("derniere-reception:{}", liste.join(",")); + cached( + &key, + CacheOpts { ttl: None, cache_if: Some(|map: &HashMap| !map.is_empty()) }, + move || async move { anyhow::Ok(pg_get_derniere_reception_par_fournisseur(Some(&liste)).await) }, + ) + .await +} diff --git a/server/src/ff/diagnostic.rs b/server/src/ff/diagnostic.rs new file mode 100644 index 0000000..ca42fc2 --- /dev/null +++ b/server/src/ff/diagnostic.rs @@ -0,0 +1,149 @@ +//! CollectFlow — Diagnostic de l'API FF Nancy +//! (port de `src/features/settings/ff-api-diagnostic.ts`). +//! +//! Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la +//! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son +//! propre contrôle d'accès. + +use super::api::{normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT}; +use super::js::truthy; +use serde::Serialize; +use serde_json::Value; +use std::time::{Duration, Instant}; + +/// Résultat du diagnostic : `{ success: true, url, ms, status }` ou +/// `{ success: false, url, error }`. +#[derive(Debug, Clone, Serialize)] +pub struct DiagnosticApiFf { + pub success: bool, + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl DiagnosticApiFf { + fn echec(url: &str, error: String) -> Self { + Self { success: false, url: url.to_string(), ms: None, status: None, error: Some(error) } + } +} + +/// Cause réseau d'une erreur `reqwest` : (code façon Node, message). +fn cause_reseau(e: &reqwest::Error) -> Option<(&'static str, String)> { + let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(e); + let mut messages = vec![]; + while let Some(s) = source { + if let Some(io) = s.downcast_ref::() { + match io.kind() { + std::io::ErrorKind::ConnectionRefused => return Some(("ECONNREFUSED", io.to_string())), + std::io::ErrorKind::ConnectionReset => return Some(("ECONNRESET", io.to_string())), + std::io::ErrorKind::HostUnreachable => return Some(("EHOSTUNREACH", io.to_string())), + std::io::ErrorKind::NetworkUnreachable => return Some(("ENETUNREACH", io.to_string())), + _ => {} + } + } + messages.push(s.to_string()); + source = s.source(); + } + let tout = messages.join(" ").to_lowercase(); + if tout.contains("dns error") || tout.contains("failed to lookup address") || tout.contains("name or service not known") { + return Some(("ENOTFOUND", messages.last().cloned().unwrap_or_default())); + } + if tout.contains("connection refused") { + return Some(("ECONNREFUSED", messages.last().cloned().unwrap_or_default())); + } + None +} + +/// Message complet d'une erreur et de ses causes. +fn message_complet(e: &dyn std::error::Error) -> String { + let mut parts = vec![e.to_string()]; + let mut source = e.source(); + while let Some(s) = source { + let m = s.to_string(); + if !parts.iter().any(|p| p.contains(&m)) { + parts.push(m); + } + source = s.source(); + } + parts.join(": ") +} + +/// Teste l'API FF Nancy et renvoie un diagnostic **exploitable**. +/// +/// L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir +/// si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue +/// donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. +pub async fn diagnostiquer_api_ff(ff_api_base_url: Option) -> DiagnosticApiFf { + let saisie = ff_api_base_url.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let base = match saisie { + Some(b) => b, + None => { + let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url) + .await + .ok() + .flatten() + .filter(|s| !s.is_empty()); + configured + .or_else(|| std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty())) + .unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string()) + } + }; + let base = base.trim_end_matches('/').to_string(); + let url = format!("{base}/api/sync/status"); + let started = Instant::now(); + + let client = reqwest::Client::builder().timeout(Duration::from_millis(8000)).build().unwrap_or_default(); + match client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await { + Ok(res) => { + let ms = started.elapsed().as_millis() as u64; + if !res.status().is_success() { + let txt = status_text(res.status()); + return DiagnosticApiFf::echec( + &url, + format!( + "Le serveur a répondu HTTP {} ({}) en {ms} ms.", + res.status().as_u16(), + if txt.is_empty() { "sans message" } else { txt } + ), + ); + } + let body = res.json::().await.ok().filter(truthy); + let Some(body) = body else { + return DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais corps illisible (JSON attendu).".into()); + }; + // La réponse brute de l'API n'a pas la forme attendue par l'interface : + // sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200. + match normalize_sync_status(&body) { + None => DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).".into()), + Some(status) => DiagnosticApiFf { success: true, url, ms: Some(ms), status: Some(status), error: None }, + } + } + Err(e) => { + let ms = started.elapsed().as_millis() as u64; + let raw = message_complet(&e); + // La cause réelle se cache derrière l'erreur de haut niveau : on la déplie. + let hint = if e.is_timeout() { + format!("Aucune réponse en {ms} ms — serveur injoignable ou trop lent.") + } else { + match cause_reseau(&e) { + Some(("ENOTFOUND", _)) => "Nom d'hôte introuvable (DNS) — vérifiez l'URL.".to_string(), + Some(("ECONNREFUSED", _)) => "Connexion refusée — le service n'écoute pas sur cette adresse.".to_string(), + Some((code, msg)) => { + if msg.is_empty() { + code.to_string() + } else { + format!("{code} — {msg}") + } + } + None => raw.clone(), + } + }; + tracing::error!("[Settings] FF API test KO ({url}): {raw}"); + DiagnosticApiFf::echec(&url, hint) + } + } +} diff --git a/server/src/ff/fixture_tests.rs b/server/src/ff/fixture_tests.rs new file mode 100644 index 0000000..9efe6a8 --- /dev/null +++ b/server/src/ff/fixture_tests.rs @@ -0,0 +1,292 @@ +//! Test d'intégration du module FF sur une base de test. +//! +//! Ne s'exécute que si `FF_TEST_DATABASE_URL` désigne une base contenant les +//! tables de l'application et des tables FF minimales (articles, artfou1, +//! mvtart, cube_stock…). Une fausse API FF Nancy est servie localement : toutes +//! les requêtes SQL et tous les appels HTTP du module sont joués au moins une +//! fois (erreurs de syntaxe SQL, de décodage de types, formes JSON). +//! +//! FF_TEST_DATABASE_URL=postgres://…/ff_fixture cargo test ff::fixture_tests -- --nocapture + +use super::*; +use crate::auth::Claims; +use crate::rpc::Ctx; +use axum::body::Body; +use axum::extract::{Path, Query}; +use axum::http::Request; +use axum::Json; +use serde_json::{json, Value}; +use std::collections::HashMap; +use tower::ServiceExt; + +/// Fausse API FF Nancy (formes de réponse reprises du code d'origine). +async fn fausse_api() -> String { + async fn dashboard(Query(q): Query>) -> Json { + let date = q.get("date").cloned().unwrap_or_default(); + Json(json!({ + "date": date, + "sites": [ + { "site": "292", "ca_ttc": "1500.50", "ca_ttc_n1": 1200, "trafic": 80, "trafic_n1": "70" }, + { "site": "579", "ca_ttc": 900, "ca_ttc_n1": 0, "trafic": 40, "trafic_n1": 35 } + ], + "top10_ca": [ + { "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": "45.5", "qte_vendue": 3, "marge": 12 }, + { "codein": "100002", "libelle1": "CREME", "site": "292", "ca_ttc": 60, "qte_vendue": 1, "marge": 2 }, + { "codein": "100003", "libelle1": "BOUGIE", "site": "579", "ca_ttc": 10, "qte_vendue": 1, "marge": 1 } + ], + "top10_qte": [{ "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": 45.5, "qte_vendue": 3, "marge": 12 }], + "top10_marge": [] + })) + } + async fn articles(Query(q): Query>) -> Json { + if let Some(c) = q.get("codein") { + if c == "100003" { + return Json(json!({ "articles": [] })); + } + return Json(json!({ "articles": [{ "no_id": if c == "100001" { 10 } else { 11 }, "codein": c }] })); + } + let page: u32 = q.get("page").and_then(|p| p.parse().ok()).unwrap_or(1); + if page > 1 { + return Json(json!({ "articles": [] })); + } + Json(json!({ "count": 2, "articles": [ + { "codein": "100001", "libelle1": "TAPIS", "codefou_principal": "F001", "pcb_principal": "6", "no_id": 10, "actif": "1", "suspendu": 0 }, + { "Codein": "100002", "libelle": "CREME", "pv": 3.5, "noid": "11", "gtin": "300" } + ]})) + } + async fn referentiel(Path(id): Path) -> Json { + Json(json!({ + "article": { "no_id": id, "codein": "x" }, + "stock": [{ "site": "292", "qte": "4" }, { "site": "579", "qte": 2 }], + "fournisseurs": [{ "codefou": "F001", "nom_fou": " Fournisseur Un ", "preference": 0 }, { "codefou": "F002", "nom_fou": null, "preference": 1 }] + })) + } + async fn dernier(Path(id): Path) -> Json { + if id == "10" { + Json(json!({ "fournisseurs_par_site": [{ "site": "292", "nom_fournisseur": "" }, { "site": "579", "nom_fournisseur": " Dernier Fou " }] })) + } else { + Json(json!({ "fournisseurs_par_site": [] })) + } + } + async fn mensuel(Path(id): Path) -> Json { + Json(json!({ "data": [{ "mois": "2026-01", "site": 292, "stock_fin_mois": "5.000", "prmp_fin_mois": "1", + "ventes": { "nb_passages": "2", "qte_vendue": "-2.000", "ca_ht": "-26.66", "ca_ttc": "-30", "marge": "12.76", "taux_marge": "40" }, + "receptions": null, "id": id }] })) + } + async fn commandes_auto(Query(q): Query>) -> Json { + Json(json!({ "count": 3, "query": q, "propositions": [ + { "site": "292", "codefou": "F001", "nom_fou": "Fournisseur Un", "franco_ht": "150", "nb_articles": 4, "montant_propo_ht": 99.5, "franco_atteint": "NON", "ecart_franco": -50.5 }, + { "site": "579", "codefou": "F002", "nom_fou": "Fournisseur Deux", "franco_ht": null, "nb_articles": 1, "montant_propo_ht": 10, "franco_atteint": "N/A", "ecart_franco": 0 }, + { "site": "292", "codefou": "F009", "nomfou": "Sans franco", "franco": 0, "nb_articles": 1, "montant_cde": 5, "franco_atteint": 1 } + ]})) + } + async fn commandes_auto_detail(Path(codefou): Path, Query(q): Query>) -> Json { + match (codefou.as_str(), q.get("site").map(|s| s.as_str())) { + ("F002", None) => Json(json!({ "resume": [{ "franco_ht": "300" }] })), + ("F009", Some("000")) => Json(json!({ "summary": { "franco": 75 } })), + _ => Json(json!({ "error": "inconnu" })), + } + } + async fn commandes_articles() -> Json { + Json(json!([{ "codein": "100001", "qtecde": 5 }, { "codein": "100001", "qtecde": 3 }, { "codein": "100002" }])) + } + async fn sync_status() -> Json { + Json(json!({ "sync": [{ "table_name": "mvtart", "last_sync": "2026-10-12T02:00:00Z", "rows_synced": 42, "status": "ok", "error_msg": null }] })) + } + async fn publicites(Query(q): Query>) -> Json { + Json(json!({ "publicites": [{ "tcr_code": "P1", "intitule": "Promo", "query": q }], "total": "1", "pages": 1 })) + } + async fn historique() -> axum::http::StatusCode { + axum::http::StatusCode::BAD_GATEWAY + } + + use axum::routing::get; + let app = axum::Router::new() + .route("/api/performance/dashboard", get(dashboard)) + .route("/api/articles", get(articles)) + .route("/api/articles/{id}/referentiel", get(referentiel)) + .route("/api/articles/{id}/dernier-fournisseur", get(dernier)) + .route("/api/articles/{id}/mensuel", get(mensuel)) + .route("/api/commandes-auto", get(commandes_auto)) + .route("/api/commandes-auto/{codefou}", get(commandes_auto_detail)) + .route("/api/commandes/articles", get(commandes_articles)) + .route("/api/sync/status", get(sync_status)) + .route("/api/fournisseurs", get(|| async { Json(json!({ "fournisseurs": [{ "codefou": "F001" }] })) })) + .route("/api/publicites", get(publicites)) + .route("/api/publicites/historique", get(historique)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +fn montrer(nom: &str, v: impl serde::Serialize) -> Value { + let v = serde_json::to_value(v).unwrap(); + println!("── {nom}\n{}", serde_json::to_string_pretty(&v).unwrap()); + v +} + +async fn appel_route(token: &str, uri: &str) -> (u16, Value) { + let req = Request::builder() + .uri(uri) + .header("cookie", format!("{}={token}", crate::auth::session::COOKIE_NAME)) + .body(Body::empty()) + .unwrap(); + let res = router().oneshot(req).await.unwrap(); + let status = res.status().as_u16(); + let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) +} + +fn ctx(claims: &Claims, query: &[(&str, &str)], args: Vec) -> Ctx { + Ctx { + token: Some(claims.clone()), + query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + args, + } +} + +#[tokio::test] +async fn module_ff_sur_base_de_test() { + let Ok(url) = std::env::var("FF_TEST_DATABASE_URL") else { + eprintln!("FF_TEST_DATABASE_URL absent : test d'intégration FF ignoré."); + return; + }; + let data_dir = std::env::temp_dir().join(format!("cf-ff-test-{}", std::process::id())); + std::fs::create_dir_all(&data_dir).unwrap(); + std::env::set_var("DATA_DIR", &data_dir); + std::env::set_var("DATABASE_URL", &url); + std::env::set_var("AUTH_SECRET", "secret-de-test"); + let base = fausse_api().await; + std::env::set_var("FF_API_BASE_URL", &base); + api::reset_ff_api_base_cache(); + + let p = crate::db::pool().unwrap(); + sqlx::query("INSERT INTO users (id, username, password_hash, role) VALUES (9001, 'test-ff', 'x', 'admin') ON CONFLICT DO NOTHING") + .execute(&p) + .await + .unwrap(); + let claims = Claims { id: "9001".into(), name: "test-ff".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 }; + let token = crate::auth::session::encode(&claims); + let r12 = api::build_last12_months_range(); + let (d, f) = (r12.date_debut.as_str(), r12.date_fin.as_str()); + let codes = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + + // ── pg.rs + let fous = montrer("pg_get_fournisseurs", pg::pg_get_fournisseurs(None).await); + assert_eq!(fous.as_array().unwrap().len(), 2, "F003 (nom vide) écarté"); + montrer("pg_get_fournisseurs(search)", pg::pg_get_fournisseurs(Some("deux")).await); + let arts = pg::pg_get_articles_by_fournisseur("F001").await.unwrap(); + montrer("pg_get_articles_by_fournisseur", &arts); + assert!(arts.iter().all(|a| a.codein.trim() != "100004"), "article suspendu exclu"); + assert!(arts.iter().any(|a| a.code_centrale.as_deref() == Some("10000111111") && a.pa == Some(4.5))); + montrer("pg_get_mensuel_by_fournisseur", pg::pg_get_mensuel_by_fournisseur("F001", d, f).await.unwrap()); + montrer("pg_get_gammes_by_fournisseur", pg::pg_get_gammes_by_fournisseur("F001").await.unwrap()); + let g = pg::pg_get_gammes_by_codeins(&codes(&["100001", " 100002 ", "", "100001"])).await.unwrap(); + montrer("pg_get_gammes_by_codeins", &g); + assert_eq!(g.get("100001").map(|s| s.as_str()), Some("C")); + montrer("pg_get_nomenclature_by_fournisseur", pg::pg_get_nomenclature_by_fournisseur("F001").await.unwrap()); + montrer("pg_get_stock_by_fournisseur", pg::pg_get_stock_by_fournisseur("F001").await.unwrap()); + let pv = pg::pg_get_prix_vente_by_fournisseur("F001").await.unwrap(); + montrer("pg_get_prix_vente_by_fournisseur", &pv); + assert_eq!(pv.values().next().and_then(|m| m.get("292")).copied(), Some(12.5), "site élagué"); + montrer("pg_get_commandes_by_fournisseur", pg::pg_get_commandes_by_fournisseur("F001").await.unwrap()); + let mois = chrono::Local::now().date_naive().format("%Y-%m").to_string(); + let n1 = pages::get_mois_n1(&mois); + montrer("pg_get_ca_by_fournisseur", pg::pg_get_ca_by_fournisseur(&mois, &n1).await.unwrap()); + montrer("pg_get_ca_by_nomenclature", pg::pg_get_ca_by_nomenclature(&r12.date_fin[..7], &mois).await.unwrap()); + let hp = pg::pg_get_hit_parade(d, &chrono::Local::now().date_naive().to_string()).await.unwrap(); + montrer("pg_get_hit_parade", &hp); + assert!(!hp.is_empty()); + montrer("pg_get_stock_for_codeins", pg::pg_get_stock_for_codeins(&codes(&["100001", "100003"])).await.unwrap()); + montrer("pg_diag_fournisseur_derniere_entree", pg::pg_diag_fournisseur_derniere_entree().await.unwrap()); + montrer("pg_get_stock_sans_vente", pg::pg_get_stock_sans_vente(None).await.unwrap()); + montrer("pg_get_stock_sans_vente(292)", pg::pg_get_stock_sans_vente(Some("292")).await.unwrap()); + let neg = pg::pg_get_stock_negatif(None).await.unwrap(); + montrer("pg_get_stock_negatif", &neg); + assert!(!neg.is_empty()); + montrer("pg_get_stock_negatif(579)", pg::pg_get_stock_negatif(Some("579")).await.unwrap()); + montrer("pg_get_sans_vente_6_mois", pg::pg_get_sans_vente_6_mois(None).await.unwrap()); + montrer("pg_get_sans_vente_6_mois(579)", pg::pg_get_sans_vente_6_mois(Some("579")).await.unwrap()); + let rec = pg::pg_get_derniere_reception_par_fournisseur(None).await; + montrer("pg_get_derniere_reception_par_fournisseur(None)", &rec); + assert!(!rec.is_empty()); + montrer("pg_get_derniere_reception_par_fournisseur(F001)", pg::pg_get_derniere_reception_par_fournisseur(Some(&codes(&["F001 "]))).await); + assert!(pg::pg_get_derniere_reception_par_fournisseur(Some(&[])).await.is_empty()); + let search = pg::pg_search_produits("tapis poussiere", 50).await; + montrer("pg_search_produits", &search); + assert_eq!(search.len(), 1); + assert!(search[0].has_reseau); + montrer("pg_search_produits(numérique)", pg::pg_search_produits("100003", 50).await); + montrer("pg_search_produits(code centrale)", pg::pg_search_produits("10000222", 50).await); + montrer("pg_search_produits(!!!)", pg::pg_search_produits("!!!", 50).await); + montrer("pg_get_produits_by_code_centrale", pg::pg_get_produits_by_code_centrale(&codes(&["10000222222", "inconnu"])).await); + assert_eq!(pg::pg_get_codein_by_code_centrale("10000222222").await.as_deref(), Some("100002")); + let det = pg::pg_get_produit_detail("100001").await; + montrer("pg_get_produit_detail", &det); + assert_eq!(det.as_ref().and_then(|d| d.code1.clone()).as_deref(), Some("33"), "hiérarchie remontée"); + montrer("pg_get_fournisseurs_by_codein", pg::pg_get_fournisseurs_by_codein("100003").await); + montrer("pg_get_stock_by_codein", pg::pg_get_stock_by_codein("100001").await); + assert_eq!(pg::pg_get_commandes_by_codein("100001").await, 30.0); + montrer("pg_get_gamme_history_by_codein", pg::pg_get_gamme_history_by_codein("100001").await); + montrer("pg_get_mensuel_by_codein", pg::pg_get_mensuel_by_codein("100001", d, f).await); + let opp = pg::pg_get_opportunites_famille(3, d, f, 30).await; + montrer("pg_get_opportunites_famille", &opp); + assert_eq!(opp.len(), 2); + + // ── API FF (fausse) + let dash = pg::pg_get_dashboard_data().await.unwrap(); + let dv = montrer("pg_get_dashboard_data", &dash); + assert_eq!(dv["top10BySite"][0]["ca"][1]["fournisseur"], json!("Dernier Fou")); + assert_eq!(dv["top10BySite"][0]["ca"][0]["fournisseur"], json!("F002")); + let ca = montrer("pg_get_commandes_auto", pg::pg_get_commandes_auto().await); + assert_eq!(ca[1]["franco"], json!(300.0)); + assert_eq!(ca[2]["franco"], json!(75.0)); + let ffarts = api::get_articles_by_fournisseur("F001").await.unwrap(); + montrer("get_articles_by_fournisseur", &ffarts); + montrer("get_mensuel_by_articles", api::get_mensuel_by_articles(&ffarts, d, f, 50).await); + montrer("get_referentiel_by_articles", api::get_referentiel_by_articles(&ffarts, 50).await); + montrer("get_commandes_by_fournisseur", api::get_commandes_by_fournisseur("F001").await); + montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None).await); + montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into())).await); + + // ── cache.rs + montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap()); + + // ── Routes + for uri in ["/api/ff-status", "/api/diag/stock-fournisseur", "/api/test-db?codein=100001", "/api/commandes-auto?site=292", "/api/diag"] { + let (status, body) = appel_route(&token, uri).await; + montrer(&format!("GET {uri} → {status}"), &body); + assert_eq!(status, 200, "{uri}"); + } + + // ── Actions + let c = |args: Vec| ctx(&claims, &[], args); + montrer("getFournisseurs", actions::get_fournisseurs(c(vec![])).await.unwrap()); + montrer("getMagasins", actions::get_magasins(c(vec![])).await.unwrap()); + montrer("getFournisseursPourCadence", actions::get_fournisseurs_pour_cadence(c(vec![])).await.unwrap()); + montrer("upsertCadence", actions::upsert_cadence(c(vec![json!({ "codefou": " F002 ", "site": "292", "intervalleSemaines": "2.5" })])).await.unwrap()); + montrer("upsertCadence(KO)", actions::upsert_cadence(c(vec![json!({ "codefou": "F002", "site": "292", "intervalleSemaines": 0 })])).await.unwrap()); + montrer("toggleCadence", actions::toggle_cadence(c(vec![json!("F002"), json!("292"), json!(false)])).await.unwrap()); + let cad = montrer("listCadences", actions::list_cadences(c(vec![])).await.unwrap()); + assert!(cad.as_array().unwrap().iter().any(|x| x["derniereReception"].is_string() && x["joursRestants"].is_number())); + montrer("removeCadence", actions::remove_cadence(c(vec![json!("F002"), json!("292")])).await.unwrap()); + let sans = Ctx::default(); + assert_eq!(actions::get_fournisseurs(sans.clone()).await.unwrap_err().status.as_u16(), 401); + assert_eq!(actions::remove_cadence(sans.clone()).await.unwrap(), json!({ "ok": false, "error": "Session expirée : reconnectez-vous." })); + + // ── Pages + montrer("page dashboard", pages::page_dashboard(ctx(&claims, &[], vec![])).await.unwrap()); + montrer("page analytics", pages::page_analytics(ctx(&claims, &[("mode", "fournisseur")], vec![])).await.unwrap()); + montrer("page analytics (nomenclature)", pages::page_analytics(ctx(&claims, &[("mode", "x"), ("mois", &mois)], vec![])).await.unwrap()); + montrer("page hit-parade", pages::page_hit_parade(ctx(&claims, &[], vec![])).await.unwrap()); + montrer("page stock-negatif", pages::page_stock_negatif(ctx(&claims, &[("magasin", "292")], vec![])).await.unwrap()); + montrer("page commandes-auto", pages::page_commandes_auto(ctx(&claims, &[], vec![])).await.unwrap()); + let pubs = montrer("page publicites", pages::page_publicites(ctx(&claims, &[("statut", "en_cours"), ("page", "2")], vec![])).await.unwrap()); + assert_eq!(pubs["pageSize"], json!(50)); + assert!(pubs["erreurHistorique"].is_string()); + assert!(pubs.get("erreurPublicites").is_none()); + assert_eq!(pages::page_dashboard(sans).await.unwrap_err().status.as_u16(), 401); + + sqlx::query("DELETE FROM users WHERE id = 9001").execute(&p).await.unwrap(); +} diff --git a/server/src/ff/js.rs b/server/src/ff/js.rs new file mode 100644 index 0000000..f2a2ed9 --- /dev/null +++ b/server/src/ff/js.rs @@ -0,0 +1,172 @@ +//! Conversions « à la JavaScript » sur des valeurs JSON brutes de l'API FF. +//! +//! Le code TS d'origine lisait les réponses de l'API sans schéma et convertissait +//! au fil de l'eau (`Number(x) || 0`, `String(x)`, tests de vérité). Ces aides +//! reproduisent ces conversions pour garder exactement les mêmes résultats. + +use serde_json::Value; + +/// `Number(v)` : `NaN` si la valeur n'est pas convertible. +pub fn js_number(v: &Value) -> f64 { + match v { + Value::Null => 0.0, + Value::Bool(b) => { + if *b { + 1.0 + } else { + 0.0 + } + } + Value::Number(n) => n.as_f64().unwrap_or(f64::NAN), + Value::String(s) => { + let t = s.trim(); + if t.is_empty() { + 0.0 + } else { + // Rust accepte « inf » / « nan », pas JS (seul « Infinity » l'est, cas ignoré). + let l = t.to_ascii_lowercase(); + if l.contains("inf") || l.contains("nan") { + f64::NAN + } else { + t.parse::().unwrap_or(f64::NAN) + } + } + } + Value::Array(a) => match a.len() { + 0 => 0.0, + 1 => js_number(&a[0]), + _ => f64::NAN, + }, + Value::Object(_) => f64::NAN, + } +} + +/// `Number(v) || 0` (NaN et 0 ramenés à 0). +pub fn js_number_or0(v: &Value) -> f64 { + let n = js_number(v); + if n.is_nan() { + 0.0 + } else { + n + } +} + +/// `Number(v)` sur une valeur éventuellement absente (`undefined` → NaN). +pub fn js_number_opt(v: Option<&Value>) -> f64 { + match v { + None => f64::NAN, + Some(v) => js_number(v), + } +} + +/// Formatage d'un nombre comme `String(n)` en JS (12 → "12", 1.5 → "1.5"). +pub fn js_number_to_string(f: f64) -> String { + if f.is_nan() { + "NaN".into() + } else if f.is_infinite() { + if f > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + } + } else if f.fract() == 0.0 && f.abs() < 1e21 { + format!("{}", f as i128) + } else { + format!("{f}") + } +} + +/// `String(v)`. +pub fn js_string(v: &Value) -> String { + match v { + Value::Null => "null".into(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => match n.as_f64() { + Some(f) if n.is_f64() => js_number_to_string(f), + _ => n.to_string(), + }, + Value::String(s) => s.clone(), + Value::Array(a) => a + .iter() + .map(|x| if x.is_null() { String::new() } else { js_string(x) }) + .collect::>() + .join(","), + Value::Object(_) => "[object Object]".into(), + } +} + +/// Valeur « vraie » au sens JS (`if (v)`). +pub fn truthy(v: &Value) -> bool { + match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(true), + Value::String(s) => !s.is_empty(), + Value::Array(_) | Value::Object(_) => true, + } +} + +/// `v?.[key]` : `None` si absent (undefined). +pub fn get<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + v.as_object().and_then(|o| o.get(key)) +} + +/// `a ?? b ?? …` sur les clés d'un objet : première valeur ni absente ni `null`. +pub fn coalesce<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a Value> { + keys.iter().filter_map(|k| get(v, k)).find(|x| !x.is_null()) +} + +/// Chaîne d'une valeur JSON, `None` si absente ou `null` (`x ?? undefined`). +pub fn opt_string(v: Option<&Value>) -> Option { + match v { + None | Some(Value::Null) => None, + Some(x) => Some(js_string(x)), + } +} + +/// Message d'une erreur et de ses causes, sans répéter une cause déjà incluse +/// dans le message précédent (les erreurs SQL recopient leur cause). +pub fn message_erreur(e: &anyhow::Error) -> String { + let mut parts: Vec = vec![]; + for cause in e.chain() { + let m = cause.to_string(); + if !parts.iter().any(|p| p.contains(&m)) { + parts.push(m); + } + } + parts.join(": ") +} + +/// `encodeURIComponent(String(v))`. +pub fn encode(v: &str) -> String { + urlencoding::encode(v).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn number_conversions() { + assert_eq!(js_number(&json!("12.5")), 12.5); + assert_eq!(js_number(&json!(" ")), 0.0); + assert!(js_number(&json!("abc")).is_nan()); + assert_eq!(js_number_or0(&json!("abc")), 0.0); + assert_eq!(js_number(&json!(true)), 1.0); + assert_eq!(js_number(&Value::Null), 0.0); + assert!(js_number_opt(None).is_nan()); + } + + #[test] + fn string_conversions() { + assert_eq!(js_string(&json!(12)), "12"); + assert_eq!(js_string(&json!(12.0)), "12"); + assert_eq!(js_string(&json!(1.5)), "1.5"); + assert_eq!(js_string(&json!("x")), "x"); + assert_eq!(js_number_to_string(2.0), "2"); + assert!(truthy(&json!("a"))); + assert!(!truthy(&json!(""))); + assert!(!truthy(&json!(0))); + } +} diff --git a/server/src/ff/mod.rs b/server/src/ff/mod.rs index fd1f2fc..d3ccdf3 100644 --- a/server/src/ff/mod.rs +++ b/server/src/ff/mod.rs @@ -1,21 +1,55 @@ -//! Module `ff` — données FF Nancy (base miroir PostgreSQL et API REST) — à porter. +//! Module `ff` — données FF Nancy : base miroir PostgreSQL (`pg`), API REST +//! (`api`), lectures partagées en cache (`cache`), diagnostic de l'API +//! (`diagnostic`), routes, actions et données de pages. +pub mod actions; +pub mod api; +pub mod cache; +pub mod diagnostic; +pub mod js; +pub mod pages; pub mod pg; +pub mod routes; +#[cfg(test)] +mod fixture_tests; + +use crate::handler; use crate::rpc::Handler; +use axum::routing::get; use axum::Router; /// Routes HTTP du module. pub fn router() -> Router { Router::new() + .route("/api/ff-status", get(routes::ff_status)) + .route("/api/diag", get(routes::diag)) + .route("/api/diag/stock-fournisseur", get(routes::diag_stock_fournisseur)) + .route("/api/test-db", get(routes::test_db)) + .route("/api/commandes-auto", get(routes::commandes_auto)) } /// Actions (`POST /api/actions/{nom}`) du module. pub fn actions() -> Vec<(&'static str, Handler)> { - vec![] + vec![ + ("getFournisseurs", handler!(actions::get_fournisseurs)), + ("getMagasins", handler!(actions::get_magasins)), + ("getFournisseursPourCadence", handler!(actions::get_fournisseurs_pour_cadence)), + ("listCadences", handler!(actions::list_cadences)), + ("upsertCadence", handler!(actions::upsert_cadence)), + ("toggleCadence", handler!(actions::toggle_cadence)), + ("removeCadence", handler!(actions::remove_cadence)), + ] } /// Données de pages (`GET /api/pages/{nom}`) du module. pub fn pages() -> Vec<(&'static str, Handler)> { - vec![] + vec![ + ("dashboard", handler!(pages::page_dashboard)), + ("analytics", handler!(pages::page_analytics)), + ("hit-parade", handler!(pages::page_hit_parade)), + ("stock-negatif", handler!(pages::page_stock_negatif)), + ("commandes-auto", handler!(pages::page_commandes_auto)), + ("publicites", handler!(pages::page_publicites)), + ] } diff --git a/server/src/ff/pages.rs b/server/src/ff/pages.rs new file mode 100644 index 0000000..a21ad5f --- /dev/null +++ b/server/src/ff/pages.rs @@ -0,0 +1,341 @@ +//! Données des anciennes pages serveur du module FF (`GET /api/pages/{nom}`) : +//! accueil, ventes par mois, meilleures ventes, stocks à surveiller, +//! commandes fournisseurs et publicités. + +use super::actions::lister_cadences; +use super::api::get_ff_api_base; +use super::cache::get_fournisseurs_cached; +use super::js::{get, js_number, js_number_to_string, message_erreur}; +use super::pg::{ + pg_get_ca_by_fournisseur, pg_get_ca_by_nomenclature, pg_get_commandes_auto, pg_get_dashboard_data, pg_get_hit_parade, + pg_get_sans_vente_6_mois, pg_get_stock_negatif, pg_get_stock_sans_vente, PgCommandeAutoRow, +}; +use crate::error::{AppError, AppResult}; +use crate::ff_cache::{cached, CacheOpts}; +use crate::rpc::{json, Ctx}; +use chrono::{Datelike, Local, NaiveDate}; +use serde::Serialize; +use serde_json::{json as json_value, Value}; +use std::time::Duration; + +/// Message d'erreur affiché par l'ancien `ErrorState`. +fn erreur_page(e: anyhow::Error) -> AppError { + AppError::internal(message_erreur(&e)) +} + +// --------------------------------------------------------------------------- +// Accueil +// --------------------------------------------------------------------------- + +/// `pgGetDashboardData()`, gardé 30 minutes : ce sont les ventes de la veille, +/// elles ne changent pas dans la journée. +pub async fn page_dashboard(ctx: Ctx) -> AppResult { + ctx.session().await?; + let opts = CacheOpts { ttl: Some(Duration::from_secs(30 * 60)), cache_if: None }; + match cached("dashboard-data", opts, || async { pg_get_dashboard_data().await }).await { + Ok(d) => json(d), + Err(e) => { + tracing::error!("[Dashboard] pgGetDashboardData failed: {}", message_erreur(&e)); + let msg: String = message_erreur(&e).chars().take(500).collect(); + Err(AppError::internal(msg)) + } + } +} + +// --------------------------------------------------------------------------- +// Ventes par mois +// --------------------------------------------------------------------------- + +/// « YYYY-MM » d'une date. +fn format_mois(d: NaiveDate) -> String { + format!("{}-{:02}", d.year(), d.month()) +} + +/// Calcul du mois N-1 (même mois année précédente). +pub fn get_mois_n1(mois: &str) -> String { + let mut parts = mois.split('-').map(|p| p.trim().parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(year), Some(month)) => { + // new Date(year, month - 13) : -13 mois, débordements normalisés. + let total = year * 12 + (month - 13); + let (y, m0) = (total.div_euclid(12), total.rem_euclid(12)); + format!("{y}-{:02}", m0 + 1) + } + _ => "NaN-NaN".to_string(), + } +} + +/// Lignes de `pgGetCaByFournisseur` (mode `fournisseur`) ou +/// `pgGetCaByNomenclature` (toute autre valeur), mises en cache. +pub async fn page_analytics(ctx: Ctx) -> AppResult { + ctx.session().await?; + // Toute autre valeur que « fournisseur » affichait déjà la vue par famille. + let mode_fournisseur = ctx.q("mode").unwrap_or("fournisseur") == "fournisseur"; + + // Mois par défaut = dernier mois complet (le mois précédent) : le mois en + // cours, incomplet, se comparerait mal à un mois N-1 entier. + let today = Local::now().date_naive(); + let premier = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + let dernier_mois_complet = format_mois(premier.pred_opt().unwrap_or(premier)); + let mois = ctx.q("mois").map(|s| s.to_string()).unwrap_or(dernier_mois_complet); + let mois_n1 = ctx.q("moisN1").map(|s| s.to_string()).unwrap_or_else(|| get_mois_n1(&mois)); + + let res = if mode_fournisseur { + let (m, n1) = (mois.clone(), mois_n1.clone()); + cached(&format!("ca-fournisseur:{mois}:{mois_n1}"), CacheOpts::default(), move || async move { + pg_get_ca_by_fournisseur(&m, &n1).await + }) + .await + .and_then(|rows| Ok(serde_json::to_value(rows)?)) + } else { + let (m, n1) = (mois.clone(), mois_n1.clone()); + cached(&format!("ca-nomenclature:{mois}:{mois_n1}"), CacheOpts::default(), move || async move { + pg_get_ca_by_nomenclature(&m, &n1).await + }) + .await + .and_then(|rows| Ok(serde_json::to_value(rows)?)) + }; + res.map_err(|e| { + tracing::error!("[analytics] chargement impossible : {}", message_erreur(&e)); + erreur_page(e) + }) +} + +// --------------------------------------------------------------------------- +// Meilleures ventes +// --------------------------------------------------------------------------- + +/// Lignes de `pgGetHitParade(debut, fin)` (par défaut : du 1er du mois à aujourd'hui). +pub async fn page_hit_parade(ctx: Ctx) -> AppResult { + ctx.session().await?; + let today = Local::now().date_naive(); + let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + let date_debut = ctx.q("debut").map(|s| s.to_string()).unwrap_or_else(|| first_of_month.format("%Y-%m-%d").to_string()); + let date_fin = ctx.q("fin").map(|s| s.to_string()).unwrap_or_else(|| today.format("%Y-%m-%d").to_string()); + + let (d, f) = (date_debut.clone(), date_fin.clone()); + match cached(&format!("hit-parade:{date_debut}:{date_fin}"), CacheOpts::default(), move || async move { + pg_get_hit_parade(&d, &f).await + }) + .await + { + Ok(rows) => json(rows), + Err(e) => { + tracing::error!("[hit-parade] chargement impossible : {}", message_erreur(&e)); + Err(erreur_page(e)) + } + } +} + +// --------------------------------------------------------------------------- +// Stocks à surveiller +// --------------------------------------------------------------------------- + +/// `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` pour un magasin (ou tous). +pub async fn page_stock_negatif(ctx: Ctx) -> AppResult { + ctx.session().await?; + let magasin = ctx.q("magasin").unwrap_or("").to_string(); + + // Données FF recopiées chaque nuit : mises en cache (cf. ff_cache). + let site = if magasin.is_empty() { None } else { Some(magasin.clone()) }; + let (s1, s2, s3) = (site.clone(), site.clone(), site); + let (k1, k2, k3) = + (format!("stock-negatif:{magasin}"), format!("stock-sans-vente:{magasin}"), format!("stock-sans-vente-6-mois:{magasin}")); + let res = tokio::try_join!( + cached(&k1, CacheOpts::default(), move || async move { pg_get_stock_negatif(s1.as_deref()).await }), + cached(&k2, CacheOpts::default(), move || async move { pg_get_stock_sans_vente(s2.as_deref()).await }), + cached(&k3, CacheOpts::default(), move || async move { + pg_get_sans_vente_6_mois(s3.as_deref()).await + }), + ); + match res { + Ok((rows_negatif, rows_sans_vente, rows_sans_vente_6_mois)) => Ok(json_value!({ + "rowsNegatif": rows_negatif, + "rowsSansVente": rows_sans_vente, + "rowsSansVente6Mois": rows_sans_vente_6_mois, + })), + Err(e) => { + tracing::error!("[stock-negatif] chargement impossible : {}", message_erreur(&e)); + Err(erreur_page(e)) + } + } +} + +// --------------------------------------------------------------------------- +// Commandes fournisseurs +// --------------------------------------------------------------------------- + +/// `{ rows, cadences, fournisseurs }`. +pub async fn page_commandes_auto(ctx: Ctx) -> AppResult { + ctx.session().await?; + let (rows, cadences, fournisseurs) = tokio::join!( + // Propositions de l'API FF (un appel, plus un par fournisseur sans franco) : + // gardées 2 minutes, pour que chaque modification du cadencier — qui + // recharge la page — n'attende pas de nouveau toute l'API. + cached( + "commandes-auto", + CacheOpts { ttl: Some(Duration::from_secs(2 * 60)), cache_if: Some(|rows: &Vec| !rows.is_empty()) }, + || async { anyhow::Ok(pg_get_commandes_auto().await) }, + ), + lister_cadences(), + get_fournisseurs_cached(), + ); + Ok(json_value!({ + "rows": rows.map_err(erreur_page)?, + "cadences": cadences, + "fournisseurs": fournisseurs.map_err(erreur_page)?, + })) +} + +// --------------------------------------------------------------------------- +// Publicités +// --------------------------------------------------------------------------- + +const PAGE_SIZE: u32 = 50; +/// Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. +const FF_API_TIMEOUT: Duration = Duration::from_millis(15_000); +/// Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste +/// et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page. +const PUB_CACHE_TTL: Duration = Duration::from_secs(10 * 60); + +#[derive(Debug, Clone, Serialize)] +struct PublicitesResult { + data: Value, + total: f64, + pages: f64, + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct HistoriqueResult { + data: Value, + error: Option, +} + +fn client_publicites() -> reqwest::Client { + reqwest::Client::builder().timeout(FF_API_TIMEOUT).build().unwrap_or_default() +} + +async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult { + let mut params: Vec<(&str, String)> = vec![("page", page), ("limit", PAGE_SIZE.to_string())]; + if !statut.is_empty() && statut != "toutes" { + params.push(("statut", statut)); + } + let base = get_ff_api_base().await; + let res = async { + let res = client_publicites() + .get(format!("{base}/api/publicites")) + .query(¶ms) + .header(reqwest::header::CACHE_CONTROL, "no-store") + .send() + .await?; + if !res.status().is_success() { + return anyhow::Ok(PublicitesResult { + data: json_value!([]), + total: 0.0, + pages: 0.0, + error: Some(format!( + "Erreur API: {} {}", + res.status().as_u16(), + super::api::status_text(res.status()) + )), + }); + } + let body: Value = res.json().await?; + let champ = |k: &str| get(&body, k).filter(|v| !v.is_null()); + Ok(PublicitesResult { + data: champ("publicites").cloned().unwrap_or_else(|| json_value!([])), + total: champ("total").map(js_number).unwrap_or(0.0), + pages: champ("pages").map(js_number).unwrap_or(0.0), + error: None, + }) + } + .await; + res.unwrap_or_else(|e| PublicitesResult { + data: json_value!([]), + total: 0.0, + pages: 0.0, + error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))), + }) +} + +async fn fetch_historique_api() -> HistoriqueResult { + let base = get_ff_api_base().await; + let res = async { + let res = client_publicites() + .get(format!("{base}/api/publicites/historique")) + .header(reqwest::header::CACHE_CONTROL, "no-store") + .send() + .await?; + if !res.status().is_success() { + return anyhow::Ok(HistoriqueResult { + data: json_value!([]), + error: Some(format!("Erreur historique: {}", res.status().as_u16())), + }); + } + let body: Value = res.json().await?; + Ok(HistoriqueResult { + data: get(&body, "publicites").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json_value!([])), + error: None, + }) + } + .await; + res.unwrap_or_else(|e| HistoriqueResult { data: json_value!([]), error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))) }) +} + +/// `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }`. +pub async fn page_publicites(ctx: Ctx) -> AppResult { + ctx.session().await?; + let statut = ctx.query.get("statut").cloned().unwrap_or_else(|| "toutes".into()); + // Math.max(1, Number(page) || 1) + let page_num = { + let n = ctx.query.get("page").map(|p| js_number(&Value::String(p.clone()))).unwrap_or(f64::NAN); + let n = if n.is_nan() || n == 0.0 { 1.0 } else { n }; + n.max(1.0) + }; + let page = js_number_to_string(page_num); + + let (s, p) = (statut.clone(), page.clone()); + let cle = format!("publicites:{statut}:{page}"); + let (pub_result, histo_result) = tokio::join!( + cached( + &cle, + CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &PublicitesResult| r.error.is_none()) }, + move || async move { anyhow::Ok(fetch_publicites_api(s, p).await) }, + ), + cached( + "publicites-historique", + CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &HistoriqueResult| r.error.is_none()) }, + || async { anyhow::Ok(fetch_historique_api().await) }, + ), + ); + let pub_result = pub_result.map_err(erreur_page)?; + let histo_result = histo_result.map_err(erreur_page)?; + + let mut out = serde_json::Map::new(); + out.insert("publicites".into(), pub_result.data); + out.insert("total".into(), json_value!(pub_result.total)); + out.insert("pages".into(), json_value!(pub_result.pages)); + if let Some(e) = pub_result.error { + out.insert("erreurPublicites".into(), Value::String(e)); + } + out.insert("historique".into(), histo_result.data); + if let Some(e) = histo_result.error { + out.insert("erreurHistorique".into(), Value::String(e)); + } + out.insert("pageSize".into(), json_value!(PAGE_SIZE)); + Ok(Value::Object(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mois_n1() { + assert_eq!(get_mois_n1("2026-03"), "2025-03"); + assert_eq!(get_mois_n1("2026-12"), "2025-12"); + assert_eq!(get_mois_n1("2026-01"), "2025-01"); + assert_eq!(get_mois_n1("abc"), "NaN-NaN"); + } +} diff --git a/server/src/ff/pg.rs b/server/src/ff/pg.rs index fc481d7..574d37b 100644 --- a/server/src/ff/pg.rs +++ b/server/src/ff/pg.rs @@ -1,22 +1,2996 @@ -//! Client PostgreSQL FF Nancy (port de `src/lib/pg-ff-client.ts`) — À PORTER. +//! CollectFlow — Client PostgreSQL FF Nancy (port de `src/lib/pg-ff-client.ts`). //! -//! Contrat minimal fixé d'avance (utilisé par le module `qlik`) : la structure -//! [`PgArticle`] et [`pg_get_articles_by_fournisseur`]. Le reste du fichier est -//! à porter ; garder ces deux signatures (champs ajoutables). +//! Remplace les appels HTTP per-article vers api.ffnancy.fr par des requêtes +//! SQL bulk directes sur la base PostgreSQL FF Nancy. +//! +//! Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N). +//! +//! Le pilote Node rendait les `numeric`/`bigint` en chaînes : toutes les colonnes +//! sont lues ici de façon tolérante (`db::get_f64`, `db::get_string`), quel que +//! soit leur type SQL réel dans la recopie FF. +use super::api::{ff_get, get_ff_api_base, status_text}; +use super::js::{coalesce, encode, get, js_number, js_number_or0, js_string, message_erreur, truthy}; +use crate::db::{get_f64, get_string, pool, rows_to_json, string_or_empty}; +use crate::error::short; +use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, Utc}; +use futures::StreamExt; use serde::Serialize; +use serde_json::{json, Map, Value}; +use sqlx::postgres::PgRow; +use sqlx::{Postgres, QueryBuilder, Row, TypeInfo, ValueRef}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::LazyLock; +use tokio::sync::OnceCell; + +// --------------------------------------------------------------------------- +// Lecture tolérante des colonnes +// --------------------------------------------------------------------------- + +/// `String(r.col ?? "")`. +fn s(row: &PgRow, col: &str) -> String { + string_or_empty(row, col) +} + +/// Texte ou `null`. +fn os(row: &PgRow, col: &str) -> Option { + get_string(row, col) +} + +/// `Number(r.col) || 0`. +fn f(row: &PgRow, col: &str) -> f64 { + get_f64(row, col).unwrap_or(0.0) +} + +/// Nombre ou `null`. +fn of(row: &PgRow, col: &str) -> Option { + get_f64(row, col) +} + +/// Entier ou `null`. +fn oi(row: &PgRow, col: &str) -> Option { + get_f64(row, col).map(|v| v as i64) +} + +/// Booléen ou `null` (accepte aussi un entier 0/1). +fn ob(row: &PgRow, col: &str) -> Option { + match row.try_get::, _>(col) { + Ok(v) => v, + Err(_) => get_f64(row, col).map(|v| v != 0.0), + } +} + +/// `parLots` : `f` sur chaque élément, au plus `simultanes` à la fois, +/// résultats dans l'ordre. Évite de lancer des centaines d'appels à l'API FF +/// d'un coup, qui la saturent et finissent en délais dépassés. +async fn par_lots(items: Vec, simultanes: usize, f: F) -> Vec +where + F: Fn(T) -> Fut, + Fut: std::future::Future, +{ + futures::stream::iter(items.into_iter().map(f)).buffered(simultanes.max(1)).collect().await +} + +// --------------------------------------------------------------------------- +// Types résultats SQL +// --------------------------------------------------------------------------- + +/// Paire `{ code, nom }` (fournisseurs, magasins). +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct CodeNom { + pub code: String, + pub nom: String, +} #[derive(Debug, Clone, Default, Serialize)] pub struct PgArticle { pub no_id: i64, pub codein: String, /// Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik « Article Code ». + /// Vide pour les articles non référencés centralement. #[serde(rename = "codeCentrale", skip_serializing_if = "Option::is_none")] pub code_centrale: Option, pub codefou: String, + pub nomfou: Option, + pub libelle1: Option, + pub pcb: Option, + pub reference: Option, + pub ean13: Option, + /// art_gtin.gtin (préférentiel) + pub gtin: Option, + /// article_infosup.prix_vente_mini + pub pv_central: Option, + /// cube_pa.pa + pub pa: Option, + /// artfou1.code où preference=1 + pub codefou_principal: Option, + /// nom du fournisseur principal + pub nomfou_principal: Option, } -/// Tous les articles d'un fournisseur (une requête). Erreur SQL propagée. -pub async fn pg_get_articles_by_fournisseur(_codefou: &str) -> anyhow::Result> { - anyhow::bail!("pg_get_articles_by_fournisseur : pas encore porté") +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgMensuelRow { + pub codein: String, + pub site: String, + /// "YYYY-MM" + pub mois: String, + pub qte_vendue: f64, + /// mntmvtttc (à vérifier si HT ou TTC) + pub ca_ht: f64, + pub marge: f64, + /// qtestock du dernier mouvement du mois + pub stock_fin_mois: Option, + pub qte_recue: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgStockRow { + pub codein: String, + pub site: String, + pub stockdispo: Option, + pub qte: Option, + pub valstock: Option, + pub prmp: Option, + /// Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. + pub pv: Option, + pub dernierevente: Option, + pub dernierereception: Option, +} + +// --------------------------------------------------------------------------- +// 0. Liste des fournisseurs +// --------------------------------------------------------------------------- + +/// Retourne la liste des fournisseurs depuis fouident (vrais noms) + artfou1. +/// Liste vide en cas d'erreur. +pub async fn pg_get_fournisseurs(search: Option<&str>) -> Vec { + let res = async { + let mut qb = QueryBuilder::::new( + " + SELECT DISTINCT fi.code, fi.nom + FROM fouident fi + INNER JOIN artfou1 af ON af.code = fi.code + WHERE fi.nom IS NOT NULL + AND fi.nom != '' + ", + ); + if let Some(search) = search.filter(|s| !s.is_empty()) { + qb.push("AND (fi.nom ILIKE ") + .push_bind(format!("%{search}%")) + .push(" OR fi.code ILIKE ") + .push_bind(format!("%{search}%")) + .push(")"); + } + qb.push( + " + ORDER BY fi.nom + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + anyhow::Ok( + rows.iter() + .map(|r| CodeNom { code: s(r, "code"), nom: s(r, "nom") }) + .filter(|r| !r.code.is_empty() && !r.nom.is_empty()) + .collect(), + ) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetFournisseurs error: {}", short(&e, 200)); + vec![] + } + } +} + +// --------------------------------------------------------------------------- +// 1. Articles du fournisseur +// --------------------------------------------------------------------------- + +/// Tous les articles d'un fournisseur avec leurs métadonnées (une requête). +/// 1 seule requête SQL — remplace getArticlesByFournisseur() + per-article fetches. +/// Erreur SQL propagée. +pub async fn pg_get_articles_by_fournisseur(codefou: &str) -> anyhow::Result> { + // DISTINCT ON (a.no_id) car artfou1 peut avoir plusieurs lignes par article/fournisseur. + // Pas de filtre "actif" : on retourne tous les articles pour ne pas exclure les articles + // d'une session précédente (snapshot) qui n'ont pas de gamme DB ni de ventes récentes. + // Le filtrage gamme-Y sans ventes est fait en aval dans getProductRows (Phase 10). + // afpref : fournisseur préférentiel (preference=1) de l'article — peut différer du fournisseur consulté. + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (a.no_id) + a.no_id, + a.codein, + a.artcentrale AS "codeCentrale", + af.code AS codefou, + fi.nom AS nomfou, + a.libelle1, + af.pcb, + af.reference, + af.ean13, + COALESCE(ag.gtin, af.ean13) AS gtin, + ai.prix_vente_mini AS pv_central, + pa.pa, + last_fournisseur.codefou_dernier AS codefou_principal, + last_fournisseur.nomfou_dernier AS nomfou_principal + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id + LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id + LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 + LEFT JOIN LATERAL ( + SELECT af2.code AS codefou_dernier, fi2.nom AS nomfou_dernier + FROM artfou1 af2 + LEFT JOIN fouident fi2 ON fi2.code = af2.code + WHERE af2.art_no_id = a.no_id + ORDER BY af2.no_id DESC + LIMIT 1 + ) last_fournisseur ON true + WHERE af.code = $1 + AND a.codein IS NOT NULL + AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + ORDER BY a.no_id, af.no_id + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + tracing::info!("[pg-ff] Articles: {} pour {codefou}", rows.len()); + Ok(rows + .iter() + .map(|r| PgArticle { + no_id: oi(r, "no_id").unwrap_or(0), + codein: s(r, "codein"), + code_centrale: os(r, "codeCentrale"), + codefou: s(r, "codefou"), + nomfou: os(r, "nomfou"), + libelle1: os(r, "libelle1"), + pcb: of(r, "pcb"), + reference: os(r, "reference"), + ean13: os(r, "ean13"), + gtin: os(r, "gtin"), + pv_central: of(r, "pv_central"), + pa: of(r, "pa"), + codefou_principal: os(r, "codefou_principal"), + nomfou_principal: os(r, "nomfou_principal"), + }) + .filter(|a| !a.codein.is_empty()) + .collect()) +} + +// --------------------------------------------------------------------------- +// 2. Données mensuelles (ventes + stock + réceptions) +// --------------------------------------------------------------------------- + +fn mensuel_row(r: &PgRow) -> PgMensuelRow { + PgMensuelRow { + codein: s(r, "codein"), + site: s(r, "site"), + mois: s(r, "mois"), + qte_vendue: f(r, "qte_vendue"), + ca_ht: f(r, "ca_ht"), + marge: f(r, "marge"), + stock_fin_mois: of(r, "stock_fin_mois"), + qte_recue: f(r, "qte_recue"), + } +} + +/// Retourne les données mensuelles agrégées par (codein, site, mois). +/// 1 seule requête SQL — remplace getMensuelByArticles() (N appels HTTP). +/// +/// genremvt = 3 → ventes clients +/// genremvt 1/2 → réceptions (à confirmer selon nomenclature FF Nancy) +/// qtestock → stock après mouvement (MAX du mois ≈ stock fin de mois) +pub async fn pg_get_mensuel_by_fournisseur(codefou: &str, date_debut: &str, date_fin: &str) -> anyhow::Result> { + // IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements. + // Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents), + // ce qui multiplierait chaque mouvement et gonflerait les totaux. + // + // stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC). + // MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux. + let rows = sqlx::query( + r#" + WITH base AS ( + SELECT + a.codein, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + m.qtemvt, + m.mntmvtttc, + m.margemvt, + m.genremvt, + m.qtestock, + ROW_NUMBER() OVER ( + PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY m.datmvt DESC + ) AS rn_last + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af + ON af.art_no_id = a.no_id + WHERE m.datmvt BETWEEN $2::date AND $3::date + AND m.site IN ('292', '579') + ) + SELECT + codein, + site, + mois, + SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, + SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, + SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, + MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, + SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue + FROM base + GROUP BY codein, site, mois + ORDER BY codein, site, mois + "#, + ) + .bind(codefou) + .bind(date_debut) + .bind(date_fin) + .fetch_all(&pool()?) + .await?; + + Ok(rows.iter().map(mensuel_row).collect()) +} + +// --------------------------------------------------------------------------- +// 3. Gammes (saison active) +// --------------------------------------------------------------------------- + +/// Retourne la gamme courante (saison active) pour chaque article du fournisseur. +/// 1 requête SQL — remplace getReferentielByArticles() pour la partie gammes. +pub async fn pg_get_gammes_by_fournisseur(codefou: &str) -> anyhow::Result> { + // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier + // saisons n'a PAS de colonne "actif" → pas de filtre actif + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (a.codein) + a.codein, + g.code AS gamme_code + FROM art_gamme_saison ags + JOIN articles a ON a.no_id = ags.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.code = $1 + JOIN gammes g ON g.no_id = ags.idgamme + JOIN saisons s ON s.no_id = ags.idsaison + ORDER BY a.codein, s.no_id DESC + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map = HashMap::new(); + for r in &rows { + let codein = s(r, "codein"); + let gamme = s(r, "gamme_code"); + if !codein.is_empty() && !gamme.is_empty() { + map.insert(codein, gamme.trim().to_string()); + } + } + Ok(map) +} + +/// Gammes **serveur** pour une liste de codein, tous fournisseurs confondus. +/// +/// Variante de `pg_get_gammes_by_fournisseur` sans la jointure `artfou1` : la recherche +/// de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur. +/// C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des +/// snapshots de session qui peuvent surcharger `codeGamme` dans la Grille. +pub async fn pg_get_gammes_by_codeins(codeins: &[String]) -> anyhow::Result> { + let mut seen = HashSet::new(); + let unique: Vec = + codeins.iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty() && seen.insert(c.clone())).collect(); + if unique.is_empty() { + return Ok(HashMap::new()); + } + + // Découpage obligatoire : chaque codein devient un paramètre lié, or PostgreSQL + // en accepte 65535 au maximum. Un gros fournisseur dépasse 130 000 articles — + // la requête échouait alors purement et simplement. + const CHUNK: usize = 2000; + let mut map = HashMap::new(); + let pool = pool()?; + + for batch in unique.chunks(CHUNK) { + // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier. + let mut qb = QueryBuilder::::new( + " + SELECT DISTINCT ON (a.codein) + TRIM(a.codein::text) AS codein, + g.code AS gamme_code + FROM art_gamme_saison ags + JOIN articles a ON a.no_id = ags.artnoid + JOIN gammes g ON g.no_id = ags.idgamme + JOIN saisons s ON s.no_id = ags.idsaison + WHERE TRIM(a.codein::text) IN (", + ); + let mut sep = qb.separated(", "); + for c in batch { + sep.push_bind(c.clone()); + } + qb.push( + ") + ORDER BY a.codein, s.no_id DESC + ", + ); + let rows = qb.build().fetch_all(&pool).await?; + for r in &rows { + let codein = s(r, "codein"); + let gamme = s(r, "gamme_code"); + if !codein.is_empty() && !gamme.is_empty() { + map.insert(codein, gamme.trim().to_string()); + } + } + } + tracing::info!("[pg-ff] pgGetGammesByCodeins: {}/{} articles avec gamme", map.len(), unique.len()); + Ok(map) +} + +// --------------------------------------------------------------------------- +// 4. Nomenclature (famille / sous-famille) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgNomRow { + pub codein: String, + /// code feuille (ex: "360504") + pub code3: String, + /// libellé feuille + pub libelle3: String, + /// famille (4 chiffres) + #[serde(skip_serializing_if = "Option::is_none")] + pub code2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub libelle2: Option, + /// secteur (2 chiffres) + #[serde(skip_serializing_if = "Option::is_none")] + pub code1: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub libelle1nom: Option, +} + +/// Cache process : colonne « parent » de `nomenclature` (`None` = aucune). +/// Une erreur de lecture n'est pas mémorisée (nouvel essai au prochain appel). +static NOMENCLATURE_PARENT_COL: OnceCell> = OnceCell::const_new(); + +/// Découvre (une seule fois par process) le nom de la colonne "parent" de la +/// table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur. +/// +/// On ne peut pas la coder en dur : le nom varie selon les installations +/// ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK), +/// d'où le filtre explicite sur `data_type` entier. +async fn get_nomenclature_parent_col() -> anyhow::Result> { + NOMENCLATURE_PARENT_COL + .get_or_try_init(|| async { + let rows = sqlx::query( + r#" + SELECT column_name, data_type FROM information_schema.columns + WHERE table_name = 'nomenclature' ORDER BY ordinal_position + "#, + ) + .fetch_all(&pool()?) + .await?; + let nom_cols: Vec<(String, String)> = rows.iter().map(|r| (s(r, "column_name"), s(r, "data_type"))).collect(); + tracing::info!( + "[pg-ff] Nomenclature colonnes: {}", + nom_cols.iter().map(|(c, t)| format!("{c}({t})")).collect::>().join(", ") + ); + // Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent) + // "chemin_pere" = chemin vers le père en français + // On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé) + static RE_PARENT: LazyLock = LazyLock::new(|| regex::Regex::new(r"(?i)parent|pere|father").unwrap()); + static RE_NOID: LazyLock = LazyLock::new(|| regex::Regex::new(r"(?i)no_id$").unwrap()); + static RE_INT: LazyLock = LazyLock::new(|| regex::Regex::new(r"(?i)int|serial").unwrap()); + let parent = nom_cols + .iter() + .find(|(c, t)| (RE_PARENT.is_match(c) || (c != "no_id" && RE_NOID.is_match(c))) && RE_INT.is_match(t)) + .map(|(c, _)| c.clone()); + tracing::info!("[pg-ff] Nomenclature parentCol: {}", parent.as_deref().unwrap_or("null")); + anyhow::Ok(parent) + }) + .await + .cloned() +} + +/// Identifiant SQL entre guillemets (nom issu d'information_schema). +fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +/// Retourne la nomenclature (famille/sous-famille) pour chaque article du fournisseur. +/// FK confirmée : articles.nom_no_id → nomenclature.no_id +/// Colonnes confirmées : nomenclature.code, nomenclature.libelle +/// Colonne parent : découverte dynamiquement via information_schema +pub async fn pg_get_nomenclature_by_fournisseur(codefou: &str) -> anyhow::Result> { + let parent_col = get_nomenclature_parent_col().await?; + + let rows = if let Some(pc) = parent_col.as_deref() { + let pc = quote_ident(pc); + let sql = format!( + r#" + SELECT + a.codein, + n3.code AS code3, + n3.libelle AS libelle3, + n2.code AS code2, + n2.libelle AS libelle2, + n1.code AS code1, + n1.libelle AS libelle1nom + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id AND af.code = $1 + JOIN nomenclature n3 ON n3.no_id = a.nom_no_id + LEFT JOIN nomenclature n2 ON n2.no_id = n3.{pc} + LEFT JOIN nomenclature n1 ON n1.no_id = n2.{pc} + WHERE a.codein IS NOT NULL + "# + ); + sqlx::query(&sql).bind(codefou).fetch_all(&pool()?).await? + } else { + // Sans hiérarchie : juste le niveau feuille + sqlx::query( + r#" + SELECT + a.codein, + n3.code AS code3, + n3.libelle AS libelle3 + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id AND af.code = $1 + JOIN nomenclature n3 ON n3.no_id = a.nom_no_id + WHERE a.codein IS NOT NULL + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await? + }; + + tracing::info!("[pg-ff] Nomenclature: {} articles, parentCol=\"{}\"", rows.len(), parent_col.as_deref().unwrap_or("none")); + let mut map = HashMap::new(); + for r in &rows { + let row = PgNomRow { + codein: s(r, "codein"), + code3: s(r, "code3"), + libelle3: s(r, "libelle3"), + code2: os(r, "code2"), + libelle2: os(r, "libelle2"), + code1: os(r, "code1"), + libelle1nom: os(r, "libelle1nom"), + }; + if !row.codein.is_empty() { + map.insert(row.codein.clone(), row); + } + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// 5. Stock temps réel (cube_stock) +// --------------------------------------------------------------------------- + +/// Retourne le stock actuel par site pour chaque article du fournisseur. +/// 1 requête SQL — remplace la partie stock du référentiel. +pub async fn pg_get_stock_by_fournisseur(codefou: &str) -> anyhow::Result>> { + // `site` est élagué pour la même raison que dans pg_get_prix_vente_by_fournisseur : + // il sert de clé de recherche (derniereLivraisonByStore, prix de repli). + let rows = sqlx::query( + r#" + SELECT + a.codein, + TRIM(cs.site::text) AS site, + cs.stockdispo::float, + cs.qte::float, + cs.valstock::float, + cs.prmp::float, + cs.pv::float, + cs.dernierevente::text, + cs.dernierereception::text + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af + ON af.art_no_id = a.no_id + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map: HashMap> = HashMap::new(); + for r in &rows { + let row = PgStockRow { + codein: s(r, "codein"), + site: s(r, "site"), + stockdispo: of(r, "stockdispo"), + qte: of(r, "qte"), + valstock: of(r, "valstock"), + prmp: of(r, "prmp"), + pv: of(r, "pv"), + dernierevente: os(r, "dernierevente"), + dernierereception: os(r, "dernierereception"), + }; + map.entry(row.codein.clone()).or_default().push(row); + } + Ok(map) +} + +/// Prix de vente par article et par site (`cube_pv`). +/// +/// Pendant exact de `cube_pa` pour l'achat, rafraîchi chaque nuit depuis le cube +/// MSSQL `Cube_PV`. C'est LA source du prix de vente : `article_infosup`, seule +/// table lue jusqu'ici pour cette information, ne porte qu'un prix plancher de +/// paramétrage (`prix_vente_mini`) qui reste vide en pratique. +/// +/// Retour : codein → { site → PV }. Les prix nuls sont écartés — un article non +/// tarifé n'est pas un article à 0 €. +pub async fn pg_get_prix_vente_by_fournisseur(codefou: &str) -> anyhow::Result>> { + // Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où + // l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer + // par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur + // fixe remonterait complétée d'espaces, et la recherche échouerait en silence — + // le prix resterait visible en « tous magasins » et vide magasin par magasin. + let rows = sqlx::query( + r#" + SELECT + a.codein, + TRIM(cpv.site::text) AS site, + cpv.pv::float AS pv + FROM cube_pv cpv + JOIN articles a ON a.no_id = cpv.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af + ON af.art_no_id = a.no_id + WHERE cpv.pv IS NOT NULL AND cpv.pv > 0 + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map: HashMap> = HashMap::new(); + for r in &rows { + let codein = s(r, "codein"); + let site = s(r, "site"); + if codein.is_empty() || site.is_empty() { + continue; + } + map.entry(codein).or_default().insert(site, of(r, "pv").unwrap_or(f64::NAN)); + } + tracing::info!("[pg-ff] Prix de vente : {} articles tarifés pour {codefou}", map.len()); + Ok(map) +} + +// --------------------------------------------------------------------------- +// 6. Commandes en cours +// --------------------------------------------------------------------------- + +/// Retourne les quantités commandées en cours pour chaque article du fournisseur. +/// 1 requête SQL — remplace getCommandesByFournisseur(). +pub async fn pg_get_commandes_by_fournisseur(codefou: &str) -> anyhow::Result> { + // cdefou_vivant est une vue dénormalisée : articles_codein et cdefou_ligne_qtecde disponibles directement + let rows = sqlx::query( + r#" + SELECT + cv.articles_codein AS codein, + SUM(cv.cdefou_ligne_qtecde)::float AS qtecde + FROM cdefou_vivant cv + JOIN artfou1 af ON af.no_id = cv.artfou1_no_id AND af.code = $1 + WHERE cv.articles_codein IS NOT NULL + GROUP BY cv.articles_codein + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map = HashMap::new(); + for r in &rows { + let codein = s(r, "codein"); + if !codein.is_empty() { + map.insert(codein, f(r, "qtecde")); + } + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// 7. Dashboard quotidien (hit parade + évolution N/N-1) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DashboardSiteStats { + pub site: String, + pub ca_hier: f64, + pub ca_n1: f64, + pub tickets_hier: f64, + pub tickets_n1: f64, + pub qte_hier: f64, + pub marge_hier: f64, + pub lignes_hier: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DashboardTopItem { + pub codein: String, + pub libelle1: String, + pub ca: f64, + pub qte: f64, + pub marge: f64, + pub stock: f64, + pub fournisseur: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DashboardSiteTop10 { + pub site: String, + pub ca: Vec, + pub qte: Vec, + pub marge: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardData { + pub date_hier: String, + #[serde(rename = "dateN1")] + pub date_n1: String, + pub sites: Vec, + #[serde(rename = "top10BySite")] + pub top10_by_site: Vec, +} + +/// Éléments d'un tableau de la réponse (`x ?? []`). +fn arr<'a>(v: &'a Value, key: &str) -> &'a [Value] { + match get(v, key) { + Some(Value::Array(a)) => a, + _ => &[], + } +} + +/// Chaîne d'un champ texte (`null`/absent → vide). +fn str_field(v: &Value, key: &str) -> String { + match get(v, key) { + None | Some(Value::Null) => String::new(), + Some(x) => js_string(x), + } +} + +#[derive(Default, Clone, Copy)] +struct StockSites { + stock292: f64, + stock579: f64, + stock_total: f64, +} + +/// Enrichissement d'un article du dashboard : stock (référentiel) et +/// fournisseur réel (dernière entrée, sinon référentiel). Comme dans le TS, ce +/// qui a été lu avant une erreur reste acquis (le stock est enregistré avant la +/// lecture de `/dernier-fournisseur`). +#[derive(Default)] +struct EnrichissementDashboard { + stock: Option, + fournisseur: Option, +} + +async fn enrichir_article_dashboard(base: &str, codein: &str, out: &mut EnrichissementDashboard) -> anyhow::Result<()> { + let art_res = ff_get(&format!("{base}/api/articles?codein={}", encode(codein))).await?; + if !art_res.status().is_success() { + return Ok(()); + } + let art_data: Value = art_res.json().await?; + let first = match get(&art_data, "articles") { + Some(Value::Array(a)) if !a.is_empty() => a[0].clone(), + _ => return Ok(()), + }; + let no_id = match get(&first, "no_id") { + Some(v) if truthy(v) => js_string(v), + _ => return Ok(()), + }; + + let url_ref = format!("{base}/api/articles/{}/referentiel", encode(&no_id)); + let url_dernier = format!("{base}/api/articles/{}/dernier-fournisseur", encode(&no_id)); + let (ref_res, dernier_res) = tokio::join!(ff_get(&url_ref), ff_get(&url_dernier)); + let ref_res = ref_res?; + let dernier_res = dernier_res?; + + // Stock (toujours depuis referentiel) + let mut ref_fournisseur_fallback = String::new(); + if ref_res.status().is_success() { + let ref_data: Value = ref_res.json().await?; + let mut st = StockSites::default(); + if let Some(Value::Array(lignes)) = get(&ref_data, "stock") { + for l in lignes { + let qte = js_number_or0(get(l, "qte").unwrap_or(&Value::Null)); + match get(l, "site").and_then(|v| v.as_str()) { + Some("292") => st.stock292 += qte, + Some("579") => st.stock579 += qte, + _ => {} + } + st.stock_total += qte; + } + } + out.stock = Some(st); + + // Fallback fournisseur (pas d'entrée en stock) : + // - 1 seul fournisseur → on l'utilise directement + // - plusieurs → celui avec preference = 1 + if let Some(Value::Array(fous)) = get(&ref_data, "fournisseurs") { + if !fous.is_empty() { + let pick = if fous.len() == 1 { + &fous[0] + } else { + fous.iter() + .find(|f| get(f, "preference").and_then(|p| p.as_f64()) == Some(1.0)) + .unwrap_or(&fous[0]) + }; + let nom = get(pick, "nom_fou").and_then(|v| v.as_str()).map(|s| s.trim()).unwrap_or(""); + let code = get(pick, "codefou").and_then(|v| v.as_str()).map(|s| s.trim()).unwrap_or(""); + ref_fournisseur_fallback = if !nom.is_empty() { nom.to_string() } else { code.to_string() }; + } + } + } + + // Fournisseur réel : /dernier-fournisseur (dernière entrée en stock par site) + // Fallback : premier non-dépôt du référentiel + if dernier_res.status().is_success() { + let dernier_data: Value = dernier_res.json().await?; + // Prendre le fournisseur du premier site disponible + let first = arr(&dernier_data, "fournisseurs_par_site") + .iter() + .find(|e| get(e, "nom_fournisseur").map(truthy).unwrap_or(false)); + if let Some(e) = first { + out.fournisseur = Some(str_field(e, "nom_fournisseur").trim().to_string()); + } else if !ref_fournisseur_fallback.is_empty() { + out.fournisseur = Some(ref_fournisseur_fallback); + } + } else if !ref_fournisseur_fallback.is_empty() { + out.fournisseur = Some(ref_fournisseur_fallback); + } + Ok(()) +} + +/// Retourne toutes les données nécessaires pour le dashboard quotidien. +/// 100% via l'API REST api.ffnancy.fr — aucune requête PostgreSQL directe. +/// N-1 : même jour de semaine (52 semaines = 364 jours avant hier). +pub async fn pg_get_dashboard_data() -> anyhow::Result { + let yesterday = Local::now() - chrono::Duration::days(1); + let date_hier = yesterday.with_timezone(&Utc).format("%Y-%m-%d").to_string(); + + // N-1 : même jour de semaine, même semaine N-1 → 364 jours = 52 semaines exactes + let n1_date = yesterday - chrono::Duration::days(364); + let date_n1_param = n1_date.with_timezone(&Utc).format("%Y-%m-%d").to_string(); + + let ff_api_base = get_ff_api_base().await; + let api_base = format!("{ff_api_base}/api/performance"); + + // Deux appels parallèles : données hier + données même jour N-1 + let (api_result, api_n1_result) = tokio::join!( + async { + let r = ff_get(&format!("{api_base}/dashboard?date={date_hier}")).await?; + if !r.status().is_success() { + anyhow::bail!("API dashboard {}: {}", r.status().as_u16(), status_text(r.status())); + } + anyhow::Ok(r.json::().await?) + }, + async { + match ff_get(&format!("{api_base}/dashboard?date={date_n1_param}")).await { + Ok(r) if r.status().is_success() => r.json::().await.ok(), + _ => None, + } + }, + ); + let api_result = api_result?; + if !api_result.is_object() { + anyhow::bail!("API dashboard : réponse inattendue"); + } + + // Construire map N-1 par site depuis l'appel séparé + let mut n1_by_site: HashMap = HashMap::new(); + if let Some(n1) = api_n1_result.as_ref() { + for s in arr(n1, "sites") { + n1_by_site.insert( + str_field(s, "site"), + (js_number_or0(get(s, "ca_ttc").unwrap_or(&Value::Null)), js_number_or0(get(s, "trafic").unwrap_or(&Value::Null))), + ); + } + } + + let num = |v: &Value, k: &str| js_number_or0(get(v, k).unwrap_or(&Value::Null)); + + let sites: Vec = arr(&api_result, "sites") + .iter() + .map(|s| { + let site = str_field(s, "site"); + let n1 = n1_by_site.get(&site); + // Priorité : même jour de semaine N-1 (si > 0), sinon fallback sur n1 calendaire de l'API + let ca_n1 = match n1 { + Some((ca, _)) if *ca > 0.0 => *ca, + _ => num(s, "ca_ttc_n1"), + }; + let traffic_n1 = match n1 { + Some((_, t)) if *t > 0.0 => *t, + _ => num(s, "trafic_n1"), + }; + DashboardSiteStats { + site, + ca_hier: num(s, "ca_ttc"), + ca_n1, + tickets_hier: num(s, "trafic"), + tickets_n1: traffic_n1, + qte_hier: 0.0, + marge_hier: 0.0, + lignes_hier: 0.0, + } + }) + .collect(); + + // Top 10 par magasin : grouper les items par site, trier, garder top 10 + let all_items: Vec<&Value> = arr(&api_result, "top10_ca") + .iter() + .chain(arr(&api_result, "top10_qte")) + .chain(arr(&api_result, "top10_marge")) + .collect(); + let mut seen = HashSet::new(); + let all_codeins: Vec = all_items + .iter() + .filter_map(|i| get(i, "codein").filter(|v| truthy(v)).map(js_string)) + .filter(|c| seen.insert(c.clone())) + .collect(); + + // Fournisseur réel : un seul appel sur les mouvements du jour (codefou_reel / nom_fournisseur_reel). + // Enrichissement : fournisseur réel via /dernier-fournisseur (primaire) + /referentiel (stock + fallback fournisseur). + // Les deux appels sont faits en parallèle par article. + let mut fou_map: HashMap = HashMap::new(); // codein → nom_fournisseur résolu + let mut stock_map: HashMap = HashMap::new(); + + if !all_codeins.is_empty() { + // Vagues de 12 articles au lieu de 5 : chaque article coûte deux allers- + // retours successifs, et la page d'accueil attendait une douzaine de vagues. + const VAGUE: usize = 12; + for batch in all_codeins.chunks(VAGUE) { + let results = futures::future::join_all(batch.iter().map(|codein| { + let base = ff_api_base.clone(); + async move { + let mut out = EnrichissementDashboard::default(); + if let Err(err) = enrichir_article_dashboard(&base, codein, &mut out).await { + tracing::error!("[Dashboard enrichment] Error for codein {codein}: {}", message_erreur(&err)); + } + (codein.clone(), out) + } + })) + .await; + for (codein, out) in results { + if let Some(st) = out.stock { + stock_map.insert(codein.clone(), st); + } + if let Some(f) = out.fournisseur { + fou_map.insert(codein, f); + } + } + } + } + + let mut site_set: Vec = all_items + .iter() + .filter_map(|i| get(i, "site").filter(|v| truthy(v)).map(js_string)) + .collect::>() + .into_iter() + .collect(); + site_set.sort(); + + #[derive(Clone, Copy)] + enum Cle { + Ca, + Qte, + Marge, + } + let build_top10 = |items: Vec<&Value>, cle: Cle| -> Vec { + let mut v: Vec = items + .into_iter() + .map(|i| { + let codein = str_field(i, "codein"); + let site = get(i, "site").and_then(|v| v.as_str()); + let entry = stock_map.get(&codein); + let stock = match site { + Some("292") => entry.map(|e| e.stock292).unwrap_or(0.0), + Some("579") => entry.map(|e| e.stock579).unwrap_or(0.0), + _ => entry.map(|e| e.stock_total).unwrap_or(0.0), + }; + let fournisseur = fou_map.get(&codein).cloned().unwrap_or_else(|| "—".into()); + DashboardTopItem { + codein, + libelle1: str_field(i, "libelle1"), + ca: num(i, "ca_ttc"), + qte: num(i, "qte_vendue"), + marge: num(i, "marge"), + stock, + fournisseur, + } + }) + .collect(); + let key = |x: &DashboardTopItem| match cle { + Cle::Ca => x.ca, + Cle::Qte => x.qte, + Cle::Marge => x.marge, + }; + v.sort_by(|a, b| key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal)); + v.truncate(10); + v + }; + + let top10_by_site: Vec = site_set + .iter() + .map(|site| { + let by_s = |key: &str| -> Vec<&Value> { + arr(&api_result, key).iter().filter(|i| get(i, "site").and_then(|v| v.as_str()) == Some(site.as_str())).collect() + }; + DashboardSiteTop10 { + site: site.clone(), + ca: build_top10(by_s("top10_ca"), Cle::Ca), + qte: build_top10(by_s("top10_qte"), Cle::Qte), + marge: build_top10(by_s("top10_marge"), Cle::Marge), + } + }) + .collect(); + + tracing::info!( + "[pg-ff] Dashboard: hier={date_hier} n1={date_n1_param} sites={} n1_found={}", + sites.len(), + n1_by_site.len() + ); + + let date_of = |v: Option<&Value>, defaut: &str| match v.and_then(|v| get(v, "date")) { + None | Some(Value::Null) => defaut.to_string(), + Some(x) => js_string(x), + }; + Ok(DashboardData { + date_hier: date_of(Some(&api_result), &date_hier), + date_n1: date_of(api_n1_result.as_ref(), &date_n1_param), + sites, + top10_by_site, + }) +} + +// --------------------------------------------------------------------------- +// Analytics — CA TTC par fournisseur et nomenclature +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CaByFournisseurRow { + pub code: String, + pub nom: String, + pub site: String, + pub mois: String, + pub ca_ttc: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CaByNomenclatureRow { + pub code: String, + pub libelle: String, + pub site: String, + pub mois: String, + pub ca_ttc: f64, +} + +/// Bornes d'un mois « YYYY-MM » : [premier jour, premier jour du mois suivant[. +/// +/// Filtrer `datmvt` sur ces bornes plutôt que sur `TO_CHAR(datmvt, 'YYYY-MM')` +/// donne exactement les mêmes lignes, mais sans formater en texte la date de +/// chaque mouvement de l'historique (et reste utilisable par un index sur datmvt). +pub fn bornes_mois(mois: &str) -> (String, String) { + let mut parts = mois.split('-').map(|p| p.trim().parse::().ok()); + let annee = parts.next().flatten(); + let m = parts.next().flatten(); + let num = |v: Option| v.map(|x| x.to_string()).unwrap_or_else(|| "NaN".into()); + let suivant = if m == Some(12) { + format!("{}-01", num(annee.map(|a| a + 1))) + } else { + format!("{}-{:0>2}", num(annee), num(m.map(|x| x + 1))) + }; + (format!("{mois}-01"), format!("{suivant}-01")) +} + +/// Mouvements du mois OU du mois N-1 (cf. `bornes_mois`). +fn push_filtre_deux_mois(qb: &mut QueryBuilder<'_, Postgres>, mois: &str, mois_n1: &str) { + let (a_debut, a_fin) = bornes_mois(mois); + let (b_debut, b_fin) = bornes_mois(mois_n1); + qb.push("((m.datmvt >= ") + .push_bind(a_debut) + .push("::date AND m.datmvt < ") + .push_bind(a_fin) + .push("::date)\n OR (m.datmvt >= ") + .push_bind(b_debut) + .push("::date AND m.datmvt < ") + .push_bind(b_fin) + .push("::date))"); +} + +/// Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1). +/// Attribution via artfou1.preference = true (fournisseur principal de l'article). +/// CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention +/// que pg_get_mensuel_by_fournisseur / pg_get_hit_parade). Fournisseur préféré résolu +/// via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par +/// article, aucune duplication possible des mouvements dans la somme. +pub async fn pg_get_ca_by_fournisseur(mois: &str, mois_n1: &str) -> anyhow::Result> { + let mut qb = QueryBuilder::::new( + " + SELECT + COALESCE(af.code, 'SANS_FOURNISSEUR')::text AS code, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(-m.mntmvtttc)::float AS ca_ttc + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + LEFT JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + LEFT JOIN fouident fi ON fi.code = af.code + WHERE ", + ); + push_filtre_deux_mois(&mut qb, mois, mois_n1); + qb.push( + " + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY af.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + + tracing::info!("[pg-ff] CaByFournisseur: {} lignes pour {mois}/{mois_n1}", rows.len()); + Ok(rows + .iter() + .map(|r| CaByFournisseurRow { code: s(r, "code"), nom: s(r, "nom"), site: s(r, "site"), mois: s(r, "mois"), ca_ttc: f(r, "ca_ttc") }) + .collect()) +} + +/// Retourne le CA TTC par nomenclature et site pour deux mois donnés. +/// CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune). +pub async fn pg_get_ca_by_nomenclature(mois: &str, mois_n1: &str) -> anyhow::Result> { + let mut qb = QueryBuilder::::new( + " + SELECT + COALESCE(n.code, 'SANS_NOM')::text AS code, + COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(-m.mntmvtttc)::float AS ca_ttc + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id + WHERE ", + ); + push_filtre_deux_mois(&mut qb, mois, mois_n1); + qb.push( + " + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY n.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + + tracing::info!("[pg-ff] CaByNomenclature: {} lignes pour {mois}/{mois_n1}", rows.len()); + Ok(rows + .iter() + .map(|r| CaByNomenclatureRow { + code: s(r, "code"), + libelle: s(r, "libelle"), + site: s(r, "site"), + mois: s(r, "mois"), + ca_ttc: f(r, "ca_ttc"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Hit Parade — produits les plus vendus sur un mois donné +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct HitParadeRow { + pub codein: String, + pub libelle: String, + /// Référence fournisseur de l'article (artfou1.reference du fournisseur principal). + pub reference: String, + pub fournisseur: String, + pub nomenclature_code: String, + pub nomenclature: String, + pub site: String, + pub qte_vendue: f64, + pub ca_ttc: f64, + pub marge: f64, + pub stock292: f64, + pub stock579: f64, + #[serde(rename = "stockTotal")] + pub stock_total: f64, +} + +/// Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site. +/// +/// L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1), +/// exactement comme pg_get_mensuel_by_fournisseur (le calcul canonique de la Grille) : +/// qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3 +/// (ventes stockées en négatif — vérifié sur les mouvements réels de l'API). +/// Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont +/// joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les +/// mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que +/// par article vendu (et non par ligne de mouvement). +/// +/// Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes, +/// 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours +/// additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle. +pub async fn pg_get_hit_parade(date_debut: &str, date_fin: &str) -> anyhow::Result> { + let rows = sqlx::query( + r#" + WITH ventes AS ( + SELECT + TRIM(a.codein::text) AS codein, + m.site, + SUM(-m.qtemvt)::float AS qte_vendue, + SUM(-m.mntmvtttc)::float AS ca_ttc, + SUM(m.margemvt)::float AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt BETWEEN $1::date AND $2::date + AND m.site IN ('292', '579') + AND m.genremvt = 3 + AND a.codein IS NOT NULL + GROUP BY TRIM(a.codein::text), m.site + ), + attrs AS ( + SELECT DISTINCT ON (TRIM(a.codein::text)) + TRIM(a.codein::text) AS codein, + a.libelle1::text AS libelle, + COALESCE(TRIM(af.reference::text), '') AS reference, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + COALESCE(n.code, '')::text AS nomenclature_code, + COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature + FROM articles a + LEFT JOIN LATERAL ( + SELECT af1.code, af1.reference + FROM artfou1 af1 + WHERE af1.art_no_id = a.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id + WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes) + ORDER BY TRIM(a.codein::text), a.no_id DESC + ), + stock_agg AS ( + SELECT + TRIM(a2.codein::text) AS codein, + SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292, + SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579, + SUM(cs.qte)::float AS stockTotal + FROM cube_stock cs + JOIN articles a2 ON a2.no_id = cs.artnoid + WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes) + GROUP BY TRIM(a2.codein::text) + ) + SELECT + v.codein, + COALESCE(ar.libelle, '') AS libelle, + COALESCE(ar.reference, '') AS reference, + COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur, + COALESCE(ar.nomenclature_code, '') AS nomenclature_code, + COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature, + v.site, + v.qte_vendue, + v.ca_ttc, + v.marge, + COALESCE(s.stock292, 0) AS stock292, + COALESCE(s.stock579, 0) AS stock579, + COALESCE(s.stockTotal, 0) AS "stockTotal" + FROM ventes v + LEFT JOIN attrs ar ON ar.codein = v.codein + LEFT JOIN stock_agg s ON s.codein = v.codein + ORDER BY v.ca_ttc DESC + "#, + ) + .bind(date_debut) + .bind(date_fin) + .fetch_all(&pool()?) + .await?; + + tracing::info!("[pg-ff] HitParade: {} lignes pour {date_debut}→{date_fin}", rows.len()); + Ok(rows + .iter() + .map(|r| HitParadeRow { + codein: s(r, "codein"), + libelle: s(r, "libelle"), + reference: s(r, "reference"), + fournisseur: s(r, "fournisseur"), + nomenclature_code: s(r, "nomenclature_code"), + nomenclature: s(r, "nomenclature"), + site: s(r, "site"), + qte_vendue: f(r, "qte_vendue"), + ca_ttc: f(r, "ca_ttc"), + marge: f(r, "marge"), + stock292: f(r, "stock292"), + stock579: f(r, "stock579"), + stock_total: f(r, "stockTotal"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Stock par liste de codeins (pour Hit Parade) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct StockBySite { + pub stock292: f64, + pub stock579: f64, + #[serde(rename = "stockTotal")] + pub stock_total: f64, +} + +/// Retourne le stock actuel (stockdispo) par codein pour une liste de codeins. +/// 1 seule requête bulk — utilisé pour enrichir le Hit Parade. +pub async fn pg_get_stock_for_codeins(codeins: &[String]) -> anyhow::Result> { + if codeins.is_empty() { + return Ok(HashMap::new()); + } + + let mut qb = QueryBuilder::::new( + "SELECT TRIM(a.codein::text) AS codein, cs.site, COALESCE(cs.qte, 0)::float AS stockdispo + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + WHERE TRIM(a.codein::text) IN (", + ); + let mut sep = qb.separated(", "); + for c in codeins { + sep.push_bind(c.clone()); + } + qb.push(")"); + let rows = qb.build().fetch_all(&pool()?).await?; + + let mut map: HashMap = HashMap::new(); + for r in &rows { + let entry = map.entry(s(r, "codein")).or_default(); + let dispo = f(r, "stockdispo"); + match os(r, "site").as_deref() { + Some("292") => entry.stock292 += dispo, + Some("579") => entry.stock579 += dispo, + _ => {} + } + entry.stock_total += dispo; + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// Stock — fournisseur de la dernière entrée +// --------------------------------------------------------------------------- + +/// Colonne de `mvtart` reliant un mouvement de stock à un fournisseur. +/// - target "artfou1" : la colonne est une FK vers `artfou1.no_id` +/// - target "code" : la colonne contient directement le code fournisseur (`fouident.code`) +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct MvtartFouLink { + pub column: &'static str, + pub target: &'static str, +} + +/// Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur. +/// `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le +/// détecte une fois par process (même principe que `get_nomenclature_parent_col`). +pub const MVTART_FOU_CANDIDATES: [MvtartFouLink; 7] = [ + MvtartFouLink { column: "artfou1_no_id", target: "artfou1" }, + MvtartFouLink { column: "artfou_no_id", target: "artfou1" }, + MvtartFouLink { column: "afou_no_id", target: "artfou1" }, + MvtartFouLink { column: "codefou", target: "code" }, + MvtartFouLink { column: "code_fou", target: "code" }, + MvtartFouLink { column: "fou_code", target: "code" }, + MvtartFouLink { column: "fournisseur", target: "code" }, +]; + +static MVTART_FOU_LINK: OnceCell> = OnceCell::const_new(); + +async fn get_mvtart_fou_link() -> Option { + *MVTART_FOU_LINK + .get_or_init(|| async { + let res = async { + let rows = sqlx::query( + r#" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'mvtart' + "#, + ) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.iter().map(|r| s(r, "column_name").to_lowercase()).collect::>()) + } + .await; + // Le nom vient d'information_schema ET doit figurer dans la liste blanche + // ci-dessus : aucune valeur utilisateur n'atteint le SQL. + let link = match res { + Ok(cols) => MVTART_FOU_CANDIDATES.iter().find(|c| cols.contains(c.column)).copied(), + Err(e) => { + tracing::error!("[pg-ff] Détection mvtart.fournisseur impossible: {}", message_erreur(&e)); + None + } + }; + match link { + Some(l) => tracing::info!("[pg-ff] mvtart → fournisseur: {{ column: '{}', target: '{}' }}", l.column, l.target), + None => tracing::info!("[pg-ff] mvtart → fournisseur: aucune colonne (repli artfou1.preference=1)"), + } + link + }) + .await +} + +/// Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du +/// couple article/site : elle expose `lastentry.datmvt` (date) et +/// `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas +/// l'information). +/// +/// Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière +/// entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus +/// apparaître sous le précédent. +/// +/// La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie +/// dernière entrée, même si son fournisseur est inconnu. +/// +/// Les expressions passées sont des fragments SQL constants du code, et la +/// colonne vient de la liste blanche : aucune valeur utilisateur ici. +fn last_entry_join(link: Option, artnoid_expr: &str, site_expr: &str) -> String { + let code_expr = match link { + Some(l) if l.target == "artfou1" => "afe.code".to_string(), + Some(l) => format!("me.{}", l.column), + None => "NULL::text".to_string(), + }; + let join_artfou = match link { + Some(l) if l.target == "artfou1" => format!("LEFT JOIN artfou1 afe ON afe.no_id = me.{}", l.column), + _ => String::new(), + }; + let flast = if link.is_some() { "LEFT JOIN fouident flast ON flast.code = lastentry.code" } else { "" }; + format!( + " + LEFT JOIN LATERAL ( + SELECT me.datmvt, {code_expr} AS code + FROM mvtart me + {join_artfou} + WHERE me.artnoid = {artnoid_expr} + AND me.site = {site_expr} + AND me.genremvt IN (1, 2) + ORDER BY me.datmvt DESC, me.no_id DESC + LIMIT 1 + ) lastentry ON TRUE + {flast} + " + ) +} + +/// Libellé fournisseur affiché : celui de la dernière entrée quand la base le +/// permet, sinon repli documenté sur `artfou1.preference = 1`. +fn fournisseur_expr(link: Option) -> &'static str { + if link.is_some() { + "COALESCE(flast.nom, lastentry.code, f.nom, af.code)" + } else { + "COALESCE(f.nom, af.code)" + } +} + +/// Résultat du diagnostic « fournisseur de la dernière entrée ». +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagFournisseurDerniereEntree { + pub colonne_detectee: Option, + pub candidats_testes: Vec<&'static str>, + pub colonnes_mvtart: Vec, + pub echantillon: Vec, +} + +/// Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les +/// colonnes réellement présentes sur `mvtart` et un échantillon comparant le +/// fournisseur principal au fournisseur de la dernière entrée. +pub async fn pg_diag_fournisseur_derniere_entree() -> anyhow::Result { + let link = get_mvtart_fou_link().await; + let pool = pool()?; + let meta = sqlx::query( + r#" + SELECT column_name, data_type FROM information_schema.columns + WHERE table_name = 'mvtart' ORDER BY ordinal_position + "#, + ) + .fetch_all(&pool) + .await?; + let mut echantillon = vec![]; + if link.is_some() { + let sql = format!( + " + SELECT + a.codein, + cs.site, + COALESCE(f.nom, af.code) AS fournisseur_preference, + COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree, + lastentry.datmvt::text AS derniere_entree + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + LEFT JOIN fouident f ON f.code = af.code + {} + WHERE cs.qte < 0 + AND cs.site IN ('292', '579') + LIMIT 20 + ", + last_entry_join(link, "cs.artnoid", "cs.site") + ); + let r = sqlx::query(&sql).fetch_all(&pool).await?; + echantillon = rows_to_json(&r); + } + Ok(DiagFournisseurDerniereEntree { + colonne_detectee: link, + candidats_testes: MVTART_FOU_CANDIDATES.iter().map(|c| c.column).collect(), + colonnes_mvtart: meta + .iter() + .map(|r| json!({ "column_name": s(r, "column_name"), "data_type": s(r, "data_type") })) + .collect(), + echantillon, + }) +} + +// --------------------------------------------------------------------------- +// Stock négatif — tous magasins ou par site +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgStockNegatifRow { + pub codein: String, + pub libelle1: String, + pub fournisseur: String, + pub site: String, + pub stockdispo: f64, + pub dernierevente: Option, + pub derniereentree: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgStockSansVenteRow { + pub codein: String, + pub libelle1: String, + pub fournisseur: String, + pub site: String, + pub stock_actuel: f64, + pub derniere_entree: Option, +} + +/// Retourne les articles ayant des entrées en stock (hors mois courant) +/// mais aucune vente enregistrée sur ce site. +/// Les articles dont le stock actuel est nul sont exclus (rien à régulariser). +pub async fn pg_get_stock_sans_vente(site: Option<&str>) -> anyhow::Result> { + let link = get_mvtart_fou_link().await; + let fexpr = fournisseur_expr(link); + let mut qb = QueryBuilder::::new(format!( + " + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + {fexpr} AS fournisseur, + m.site, + COALESCE(cs.qte::float, 0) AS stock_actuel, + MAX(m.datmvt)::text AS derniere_entree + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + JOIN fouident f ON f.code = af.code + LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site + {} + WHERE m.genremvt IN (1, 2) + AND m.site IN ('292', '579') + AND m.datmvt < date_trunc('month', CURRENT_DATE) + ", + last_entry_join(link, "a.no_id", "m.site") + )); + if let Some(site) = site.filter(|s| !s.is_empty()) { + qb.push("AND m.site = ").push_bind(site.to_string()); + } + qb.push(format!( + " + AND NOT EXISTS ( + SELECT 1 FROM mvtart mv2 + WHERE mv2.artnoid = m.artnoid + AND mv2.site = m.site + AND mv2.genremvt = 3 + ) + GROUP BY a.codein, a.libelle1, {fexpr}, m.site, cs.qte + HAVING COALESCE(cs.qte::float, 0) <> 0 + ORDER BY MAX(m.datmvt) DESC + " + )); + let rows = qb.build().fetch_all(&pool()?).await?; + Ok(rows + .iter() + .map(|r| PgStockSansVenteRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + site: s(r, "site"), + stock_actuel: f(r, "stock_actuel"), + derniere_entree: os(r, "derniere_entree"), + }) + .collect()) +} + +pub async fn pg_get_stock_negatif(site: Option<&str>) -> anyhow::Result> { + let link = get_mvtart_fou_link().await; + let mut qb = QueryBuilder::::new(format!( + " + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + {} AS fournisseur, + cs.site, + cs.qte::float AS stockdispo, + cs.dernierevente::text, + lastentry.datmvt::text AS derniereentree + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + JOIN fouident f ON f.code = af.code + {} + WHERE cs.qte < 0 + AND cs.site IN ('292', '579') + ", + fournisseur_expr(link), + last_entry_join(link, "cs.artnoid", "cs.site") + )); + if let Some(site) = site.filter(|s| !s.is_empty()) { + qb.push("AND cs.site = ").push_bind(site.to_string()); + } + qb.push( + " + ORDER BY cs.qte ASC + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + Ok(rows + .iter() + .map(|r| PgStockNegatifRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + site: s(r, "site"), + stockdispo: f(r, "stockdispo"), + dernierevente: os(r, "dernierevente"), + derniereentree: os(r, "derniereentree"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Stock sans vente depuis 6 mois +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgSansVente6MoisRow { + pub codein: String, + pub libelle1: String, + pub fournisseur: String, + pub site: String, + pub stock_actuel: f64, + pub derniere_vente: Option, + pub derniere_entree: Option, + pub jours_sans_vente: Option, +} + +/// Retourne les articles ayant du stock disponible (qte > 0) +/// dont la dernière vente remonte à plus de 6 mois (ou jamais vendus) +/// ET dont la dernière entrée en stock remonte aussi à plus de 6 mois. +pub async fn pg_get_sans_vente_6_mois(site: Option<&str>) -> anyhow::Result> { + let link = get_mvtart_fou_link().await; + let mut qb = QueryBuilder::::new(format!( + " + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + {} AS fournisseur, + cs.site, + cs.qte::float AS stock_actuel, + cs.dernierevente::text AS derniere_vente, + CASE + WHEN cs.dernierevente IS NULL THEN NULL + ELSE (CURRENT_DATE - cs.dernierevente::date) + END AS jours_sans_vente, + lastentry.datmvt::text AS derniere_entree + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + JOIN fouident f ON f.code = af.code + {} + WHERE cs.site IN ('292', '579') + AND cs.qte > 0 + AND ( + cs.dernierevente IS NULL + OR cs.dernierevente < CURRENT_DATE - INTERVAL '6 months' + ) + AND NOT EXISTS ( + SELECT 1 FROM mvtart m2 + WHERE m2.artnoid = cs.artnoid + AND m2.site = cs.site + AND m2.genremvt IN (1, 2) + AND m2.datmvt >= CURRENT_DATE - INTERVAL '6 months' + ) + ", + fournisseur_expr(link), + last_entry_join(link, "cs.artnoid", "cs.site") + )); + if let Some(site) = site.filter(|s| !s.is_empty()) { + qb.push("AND cs.site = ").push_bind(site.to_string()); + } + qb.push( + " + ORDER BY cs.dernierevente ASC NULLS FIRST + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + Ok(rows + .iter() + .map(|r| PgSansVente6MoisRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + site: s(r, "site"), + stock_actuel: f(r, "stock_actuel"), + derniere_vente: os(r, "derniere_vente"), + derniere_entree: os(r, "derniere_entree"), + jours_sans_vente: oi(r, "jours_sans_vente"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Commandes automatiques — par site + fournisseur avec franco +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgCommandeAutoRow { + pub site: String, + pub codefou: String, + pub nomfou: String, + pub franco: f64, + pub nb_articles: f64, + pub montant_cde: f64, + pub franco_atteint: Option, + pub ecart_franco: f64, +} + +fn map_commande_auto_row(r: &Value) -> PgCommandeAutoRow { + let s_or = |keys: &[&str]| coalesce(r, keys).map(js_string).unwrap_or_default(); + let n_or = |keys: &[&str]| coalesce(r, keys).map(js_number).unwrap_or(0.0); + PgCommandeAutoRow { + site: s_or(&["site"]), + codefou: s_or(&["codefou"]), + nomfou: s_or(&["nom_fou", "nomfou"]), + franco: n_or(&["franco_ht", "franco"]), + nb_articles: n_or(&["nb_articles"]), + montant_cde: n_or(&["montant_propo_ht", "montant_cde"]), + franco_atteint: match get(r, "franco_atteint") { + Some(Value::String(v)) if v == "OUI" => Some(true), + Some(Value::String(v)) if v == "NON" => Some(false), + Some(Value::String(v)) if v == "N/A" => None, + Some(v) if !v.is_null() => Some(truthy(v)), + _ => None, + }, + ecart_franco: n_or(&["ecart_franco"]), + } +} + +/// Franco lu dans le détail d'un fournisseur (`resume[0].franco_ht`). +fn extract_franco(detail: &Value) -> f64 { + if !truthy(detail) || get(detail, "error").map(truthy).unwrap_or(false) { + return 0.0; + } + let resume_raw = coalesce(detail, &["resume", "summary"]).unwrap_or(detail); + let resume_item = match resume_raw { + Value::Array(a) => a.first(), + v => Some(v), + }; + match resume_item.and_then(|i| coalesce(i, &["franco_ht", "franco"])) { + Some(v) => js_number(v), + None => 0.0, + } +} + +/// Retourne la liste des fournisseurs en commande automatique via l'API Hostinger. +/// GET https://api.ffnancy.fr/api/commandes-auto +/// +/// Pour les fournisseurs dont le franco est absent de la réponse globale (franco_ht null), +/// on enrichit via le detail endpoint /api/commandes-auto/:codefou?site= qui contient +/// toujours le résumé franco du fournisseur. +/// +/// Liste vide en cas d'erreur. +pub async fn pg_get_commandes_auto() -> Vec { + let res = async { + let base = get_ff_api_base().await; + let res = ff_get(&format!("{base}/api/commandes-auto")).await?; + if !res.status().is_success() { + anyhow::bail!("HTTP {}", res.status().as_u16()); + } + let data: Value = res.json().await?; + // L'API retourne { count: N, propositions: [...] } ou un tableau direct + let rows: Vec = match &data { + Value::Array(a) => a.clone(), + d => match coalesce(d, &["propositions", "data", "items", "results"]) { + None => vec![], + Some(Value::Array(a)) => a.clone(), + Some(_) => anyhow::bail!("rows.map is not a function"), + }, + }; + + // Diagnostic : log les clés du premier row pour détecter les changements de format + if let Some(sample) = rows.first() { + let keys: Vec<&String> = sample.as_object().map(|o| o.keys().collect()).unwrap_or_default(); + tracing::info!("[pg-ff] pgGetCommandesAuto sample keys: {keys:?}"); + let champ = |k: &str| match get(sample, k) { + None => "undefined".to_string(), + Some(v) => js_string(v), + }; + tracing::info!( + "[pg-ff] pgGetCommandesAuto sample franco fields: franco_ht={} franco={} franco_atteint={}", + champ("franco_ht"), + champ("franco"), + champ("franco_atteint") + ); + } + + tracing::info!("[pg-ff] pgGetCommandesAuto: {} lignes (API Hostinger)", rows.len()); + let mapped: Vec = rows.iter().map(map_commande_auto_row).collect(); + + // Enrichissement : pour les fournisseurs sans franco dans la réponse globale, + // on appelle GET /api/commandes-auto/{codefou} (sans filtre site — franco est une + // propriété du fournisseur, pas du site) et on lit resume[0].franco_ht. + let missing: Vec<&PgCommandeAutoRow> = mapped.iter().filter(|r| r.franco == 0.0 && !r.codefou.is_empty()).collect(); + if missing.is_empty() { + return anyhow::Ok(mapped); + } + tracing::info!("[pg-ff] pgGetCommandesAuto: {} lignes sans franco → enrichissement", missing.len()); + // Dédupliquer par codefou (franco identique quel que soit le site) + let mut seen = HashSet::new(); + let unique_code_fous: Vec = + missing.iter().map(|r| r.codefou.clone()).filter(|c| seen.insert(c.clone())).collect(); + + // 6 fournisseurs à la fois : chacun peut coûter deux appels successifs. + let details = par_lots(unique_code_fous, 6, |codefou| { + let base = base.clone(); + async move { + let essai = |url: String| async move { + match ff_get(&url).await { + Ok(r) if r.status().is_success() => match r.json::().await { + Ok(detail) => extract_franco(&detail), + Err(_) => 0.0, + }, + _ => 0.0, + } + }; + // 1. Essai sans filtre site + let f1 = essai(format!("{base}/api/commandes-auto/{}", encode(&codefou))).await; + if f1 > 0.0 { + return (codefou, f1); + } + // 2. Fallback site=000 + let f2 = essai(format!("{base}/api/commandes-auto/{}?site=000", encode(&codefou))).await; + if f2 > 0.0 { + return (codefou, f2); + } + (codefou, 0.0) + } + }) + .await; + + // Map codefou → franco enrichi + let mut franco_map: Map = Map::new(); + for (codefou, franco) in details { + if franco > 0.0 { + franco_map.insert(codefou, json!(franco)); + } + } + let enrichis = serde_json::Value::Object(franco_map.clone()).to_string(); + tracing::info!("[pg-ff] pgGetCommandesAuto: francos enrichis: {enrichis}"); + + Ok(mapped + .into_iter() + .map(|mut r| { + if r.franco == 0.0 { + if let Some(enriched) = franco_map.get(&r.codefou).and_then(|v| v.as_f64()) { + if enriched > 0.0 { + r.franco = enriched; + } + } + } + r + }) + .collect()) + } + .await; + match res { + Ok(rows) => rows, + Err(e) => { + tracing::error!("[pg-ff] pgGetCommandesAuto error: {}", short(&message_erreur(&e), 300)); + vec![] + } + } +} + +// --------------------------------------------------------------------------- +// 12. Dernière réception par fournisseur ET par site (cadencier) +// --------------------------------------------------------------------------- + +/// Date ISO (`toISOString()`) d'une colonne date/horodatage, ou d'un texte de date. +fn colonne_iso(row: &PgRow, col: &str) -> anyhow::Result> { + let raw = row.try_get_raw(col)?; + if raw.is_null() { + return Ok(None); + } + let ty = raw.type_info().name().to_ascii_uppercase(); + if matches!(ty.as_str(), "DATE" | "TIMESTAMP" | "TIMESTAMPTZ") { + return Ok(get_string(row, col)); + } + // Texte : `new Date(texte).toISOString()`. + let txt = string_or_empty(row, col); + let t = txt.trim(); + if let Ok(d) = DateTime::parse_from_rfc3339(t) { + return Ok(Some(crate::db::iso_utc(d.with_timezone(&Utc)))); + } + for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] { + if let Ok(d) = NaiveDateTime::parse_from_str(t, fmt) { + return Ok(Some(crate::db::iso(d))); + } + } + if let Ok(d) = NaiveDate::parse_from_str(t, "%Y-%m-%d") { + return Ok(Some(crate::db::iso(d.and_hms_opt(0, 0, 0).unwrap_or_default()))); + } + anyhow::bail!("Invalid time value") +} + +/// Retourne la date de la dernière réception (entrée de marchandise) par +/// fournisseur et par site, pour piloter le cadencier de commande. +/// +/// Source : mvtart (genremvt IN (1, 2) = réceptions, voir pg_get_mensuel_by_fournisseur). +/// La réception est rattachée à l'article ; on l'attribue au fournisseur +/// principal de l'article (artfou1.preference = 1), comme partout ailleurs. +/// +/// Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur +/// (l'UI affichera alors « échéance inconnue »). +pub async fn pg_get_derniere_reception_par_fournisseur(codes: Option<&[String]>) -> HashMap { + let mut map = HashMap::new(); + // Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles, + // au lieu de tout l'historique de réceptions de tous les fournisseurs. + if codes.map(|c| c.is_empty()).unwrap_or(false) { + return map; + } + let mut qb = QueryBuilder::::new( + " + SELECT + af.code AS codefou, + m.site AS site, + MAX(m.datmvt) AS derniere + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + WHERE m.genremvt IN (1, 2) + AND m.site IN ('292', '579') + AND m.datmvt IS NOT NULL + ", + ); + if let Some(codes) = codes { + qb.push("AND TRIM(af.code) IN ("); + let mut sep = qb.separated(", "); + for c in codes { + sep.push_bind(c.trim().to_string()); + } + qb.push(")"); + } + qb.push( + " + GROUP BY af.code, m.site + ", + ); + let res = async { + let rows = qb.build().fetch_all(&pool()?).await?; + for r in &rows { + let codefou = s(r, "codefou"); + if codefou.is_empty() { + continue; + } + let Some(iso) = colonne_iso(r, "derniere")? else { continue }; + map.insert(format!("{}|{}", codefou.trim(), s(r, "site").trim()), iso); + } + tracing::info!("[pg-ff] pgGetDerniereReceptionParFournisseur: {} entrées (mvtart)", map.len()); + anyhow::Ok(()) + } + .await; + if let Err(e) = res { + tracing::error!("[pg-ff] pgGetDerniereReceptionParFournisseur error: {}", short(&message_erreur(&e), 250)); + } + map +} + +// --------------------------------------------------------------------------- +// 13. Recherche & fiche produit (page /produits) +// --------------------------------------------------------------------------- + +/// Une ligne de résultat de recherche produit. Volontairement **légère** : +/// aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût). +/// Le détail lourd n'est calculé qu'à l'ouverture de la fiche. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitSearchRow { + pub no_id: i64, + pub codein: String, + pub code_centrale: String, + pub libelle1: String, + /// Nom du fournisseur principal (artfou1.preference = 1), sinon son code. + pub fournisseur: String, + pub codefou: String, + pub nomenclature_code: String, + pub nomenclature: String, + pub stock_total: f64, + /// true si le produit a déjà des métriques réseau Qlik en cache. + pub has_reseau: bool, + pub qte_reseau: f64, + pub nb_magasins_reseau: i64, +} + +fn produit_search_row(r: &PgRow) -> PgProduitSearchRow { + PgProduitSearchRow { + no_id: oi(r, "no_id").unwrap_or(0), + codein: s(r, "codein"), + code_centrale: s(r, "code_centrale"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + codefou: s(r, "codefou"), + nomenclature_code: s(r, "nomenclature_code"), + nomenclature: s(r, "nomenclature"), + stock_total: f(r, "stock_total"), + has_reseau: ob(r, "has_reseau").unwrap_or(false), + qte_reseau: f(r, "qte_reseau"), + nb_magasins_reseau: oi(r, "nb_magasins_reseau").unwrap_or(0), + } +} + +/// Caractères accentués et leur équivalent non accentué, pour `translate()`. +/// Les deux chaînes DOIVENT rester de même longueur, caractère par caractère. +/// Postgres n'a pas `unaccent()` sans l'extension : on ne peut pas compter dessus +/// sur la base FF, d'où cette table de correspondance explicite. +const SQL_ACCENTS_FROM: &str = "àâäãáåéèêëíìîïóòôöõúùûüçñýÿ"; +const SQL_ACCENTS_TO: &str = "aaaaaaeeeeiiiiooooouuuucnyy"; + +/// Lettre de base d'un caractère latin accentué (équivalent de la +/// décomposition NFD suivie du retrait des diacritiques). +fn sans_accent(c: char) -> Option { + Some(match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' | 'ǎ' => 'a', + 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' | 'Ā' | 'Ă' | 'Ą' | 'Ǎ' => 'A', + 'ç' | 'ć' | 'ĉ' | 'ċ' | 'č' => 'c', + 'Ç' | 'Ć' | 'Ĉ' | 'Ċ' | 'Č' => 'C', + 'ď' => 'd', + 'Ď' => 'D', + 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => 'e', + 'È' | 'É' | 'Ê' | 'Ë' | 'Ē' | 'Ĕ' | 'Ė' | 'Ę' | 'Ě' => 'E', + 'ĝ' | 'ğ' | 'ġ' | 'ģ' => 'g', + 'Ĝ' | 'Ğ' | 'Ġ' | 'Ģ' => 'G', + 'ĥ' => 'h', + 'Ĥ' => 'H', + 'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' | 'ǐ' => 'i', + 'Ì' | 'Í' | 'Î' | 'Ï' | 'Ĩ' | 'Ī' | 'Ĭ' | 'Į' | 'İ' | 'Ǐ' => 'I', + 'ĵ' => 'j', + 'Ĵ' => 'J', + 'ķ' => 'k', + 'Ķ' => 'K', + 'ĺ' | 'ļ' | 'ľ' => 'l', + 'Ĺ' | 'Ļ' | 'Ľ' => 'L', + 'ñ' | 'ń' | 'ņ' | 'ň' => 'n', + 'Ñ' | 'Ń' | 'Ņ' | 'Ň' => 'N', + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ō' | 'ŏ' | 'ő' | 'ǒ' => 'o', + 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ō' | 'Ŏ' | 'Ő' | 'Ǒ' => 'O', + 'ŕ' | 'ŗ' | 'ř' => 'r', + 'Ŕ' | 'Ŗ' | 'Ř' => 'R', + 'ś' | 'ŝ' | 'ş' | 'š' | 'ș' => 's', + 'Ś' | 'Ŝ' | 'Ş' | 'Š' | 'Ș' => 'S', + 'ţ' | 'ť' | 'ț' => 't', + 'Ţ' | 'Ť' | 'Ț' => 'T', + 'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' | 'ǔ' => 'u', + 'Ù' | 'Ú' | 'Û' | 'Ü' | 'Ũ' | 'Ū' | 'Ŭ' | 'Ů' | 'Ű' | 'Ų' | 'Ǔ' => 'U', + 'ŵ' => 'w', + 'Ŵ' => 'W', + 'ý' | 'ÿ' | 'ŷ' => 'y', + 'Ý' | 'Ÿ' | 'Ŷ' => 'Y', + 'ź' | 'ż' | 'ž' => 'z', + 'Ź' | 'Ż' | 'Ž' => 'Z', + _ => return None, + }) +} + +/// Retire les diacritiques (lettres latines précomposées et marques combinantes). +fn retirer_accents(s: &str) -> String { + s.chars() + .filter(|c| !('\u{0300}'..='\u{036f}').contains(c)) + .map(|c| sans_accent(c).unwrap_or(c)) + .collect() +} + +/// Normalise un terme de recherche, de la même façon que +/// `normalized_libelle_sql()` le fait côté SQL : minuscules, accents retirés, +/// toute ponctuation ramenée à un espace. +pub fn normalize_search_term(term: &str) -> String { + static RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"[^a-z0-9]+").unwrap()); + let base = retirer_accents(&term.to_lowercase()); + RE.replace_all(&base, " ").trim().to_string() +} + +/// Comparaison de chaînes approchant `localeCompare(…, "fr")` : casse et accents +/// ignorés au premier niveau. +pub fn compare_fr(a: &str, b: &str) -> std::cmp::Ordering { + let pa = retirer_accents(&a.to_lowercase()); + let pb = retirer_accents(&b.to_lowercase()); + pa.cmp(&pb).then_with(|| a.to_lowercase().cmp(&b.to_lowercase())).then_with(|| b.cmp(a)) +} + +/// Recherche un produit par **libellé** ou par **code centrale** (préfixe). +/// Si le terme est entièrement numérique, on tente aussi une correspondance +/// exacte sur le `codein`. +/// +/// La recherche par libellé est **insensible aux accents et à la ponctuation**, +/// et fonctionne **mot à mot** : « tapis anti-poussière » retrouve +/// « TAPIS ANTI POUSSIERE », et « tapis poussiere » aussi (tous les mots doivent +/// être présents, dans n'importe quel ordre). Sans cette normalisation, un +/// `ILIKE` brut exigeait l'accent ET le tiret exacts et ne remontait rien. +/// +/// ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée +/// chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc +/// un seq-scan avec normalisation par ligne : l'appelant impose un minimum de +/// 3 caractères et une soumission explicite, jamais une recherche à la frappe. +/// +/// `limit` : 50 dans le code d'origine. Liste vide en cas d'erreur. +pub async fn pg_search_produits(term: &str, limit: i64) -> Vec { + let cleaned = term.trim(); + if cleaned.chars().count() < 3 { + return vec![]; + } + + let prefix = format!("{cleaned}%"); + // Mots de la recherche, normalisés. Tous doivent être présents (AND). + let normalized = normalize_search_term(cleaned); + let tokens: Vec<&str> = normalized.split(' ').filter(|t| !t.is_empty()).collect(); + + let mut qb = QueryBuilder::::new( + " + WITH raw AS ( + SELECT DISTINCT ON (TRIM(a.codein::text)) + a.no_id, + TRIM(a.codein::text) AS codein, + COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1, + a.nom_no_id + FROM articles a + WHERE a.codein IS NOT NULL + AND ( + ", + ); + if tokens.is_empty() { + qb.push("FALSE"); + } else { + qb.push("("); + for (i, t) in tokens.iter().enumerate() { + if i > 0 { + qb.push(" AND "); + } + // Expression SQL renvoyant le libellé normalisé (minuscules, sans accent ni ponctuation). + qb.push("regexp_replace(translate(lower(COALESCE(a.libelle1, '')), ") + .push_bind(SQL_ACCENTS_FROM) + .push(", ") + .push_bind(SQL_ACCENTS_TO) + .push("), '[^a-z0-9]+', ' ', 'g') LIKE ") + .push_bind(format!("%{t}%")); + } + qb.push(")"); + } + qb.push("\n OR TRIM(a.artcentrale::text) ILIKE ").push_bind(prefix).push("\n "); + // Un terme purement numérique peut être un codein : on l'ajoute au filtre + // (correspondance exacte, la colonne est space-padded d'où le TRIM). + if cleaned.chars().all(|c| c.is_ascii_digit()) { + qb.push("OR TRIM(a.codein::text) = ").push_bind(cleaned.to_string()); + } + qb.push( + " + ) + ORDER BY TRIM(a.codein::text), a.no_id DESC + ), + matched AS ( + SELECT * FROM raw ORDER BY libelle1 LIMIT ", + ); + qb.push_bind(limit); + qb.push( + " + ) + SELECT + m.no_id, + m.codein, + m.code_centrale, + m.libelle1, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + COALESCE(af.code, '')::text AS codefou, + COALESCE(n.code, '')::text AS nomenclature_code, + COALESCE(n.libelle, '')::text AS nomenclature, + COALESCE(st.stock_total, 0)::float AS stock_total, + (q.code_centrale IS NOT NULL) AS has_reseau, + COALESCE(q.qte_reseau, 0)::float AS qte_reseau, + COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau + FROM matched m + LEFT JOIN LATERAL ( + SELECT af1.code + FROM artfou1 af1 + WHERE af1.art_no_id = m.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id + LEFT JOIN LATERAL ( + SELECT SUM(cs.qte)::float AS stock_total + FROM cube_stock cs + WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') + ) st ON TRUE + LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale + ORDER BY m.libelle1 + ", + ); + + let res = async { anyhow::Ok(qb.build().fetch_all(&pool()?).await?) }.await; + match res { + Ok(rows) => { + tracing::info!("[pg-ff] pgSearchProduits(\"{cleaned}\"): {} résultats", rows.len()); + rows.iter().map(produit_search_row).collect() + } + Err(e) => { + tracing::error!("[pg-ff] pgSearchProduits error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy. +/// +/// C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau +/// travaille, cette requête dit lesquels nous référençons — et avec quel +/// `codein`, quel fournisseur et quel stock. Un code absent du résultat est un +/// produit réseau que Nancy ne référence pas (cas normal et intéressant). +/// +/// Les codeins sont renvoyés indexés par code centrale ; un même code centrale +/// peut porter plusieurs codeins (ré-créations d'article), on ne garde que le +/// `no_id` le plus récent. Map vide en cas d'erreur. +pub async fn pg_get_produits_by_code_centrale(codes_centraux: &[String]) -> HashMap { + let mut out = HashMap::new(); + let mut seen = HashSet::new(); + let codes: Vec = + codes_centraux.iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty() && seen.insert(c.clone())).collect(); + if codes.is_empty() { + return out; + } + + let mut qb = QueryBuilder::::new( + " + WITH matched AS ( + SELECT DISTINCT ON (TRIM(a.artcentrale::text)) + a.no_id, + TRIM(a.codein::text) AS codein, + TRIM(a.artcentrale::text) AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1, + a.nom_no_id + FROM articles a + WHERE TRIM(a.artcentrale::text) IN (", + ); + let mut sep = qb.separated(", "); + for c in &codes { + sep.push_bind(c.clone()); + } + qb.push( + ") + AND a.codein IS NOT NULL + ORDER BY TRIM(a.artcentrale::text), a.no_id DESC + ) + SELECT + m.no_id, + m.codein, + m.code_centrale, + m.libelle1, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + COALESCE(af.code, '')::text AS codefou, + COALESCE(n.code, '')::text AS nomenclature_code, + COALESCE(n.libelle, '')::text AS nomenclature, + COALESCE(st.stock_total, 0)::float AS stock_total, + TRUE AS has_reseau, + 0::float AS qte_reseau, + 0 AS nb_magasins_reseau + FROM matched m + LEFT JOIN LATERAL ( + SELECT af1.code + FROM artfou1 af1 + WHERE af1.art_no_id = m.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id + LEFT JOIN LATERAL ( + SELECT SUM(cs.qte)::float AS stock_total + FROM cube_stock cs + WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') + ) st ON TRUE + ", + ); + + let res = async { anyhow::Ok(qb.build().fetch_all(&pool()?).await?) }.await; + match res { + Ok(rows) => { + for r in &rows { + let row = produit_search_row(r); + out.insert(row.code_centrale.clone(), row); + } + tracing::info!( + "[pg-ff] pgGetProduitsByCodeCentrale: {} codes Qlik → {} trouvés dans le catalogue Nancy", + codes.len(), + out.len() + ); + out + } + Err(e) => { + tracing::error!("[pg-ff] pgGetProduitsByCodeCentrale error: {}", short(&message_erreur(&e), 250)); + out + } + } +} + +/// `codein` de l'article portant ce code centrale, ou `None` si Nancy ne le +/// référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik +/// (URL `?cc=…`), qui n'a que le code centrale. `None` en cas d'erreur. +pub async fn pg_get_codein_by_code_centrale(code_centrale: &str) -> Option { + let cleaned = code_centrale.trim(); + if cleaned.is_empty() { + return None; + } + let res = async { + let rows = sqlx::query( + r#" + SELECT TRIM(a.codein::text) AS codein + FROM articles a + WHERE TRIM(a.artcentrale::text) = $1 + AND a.codein IS NOT NULL + ORDER BY a.no_id DESC + LIMIT 1 + "#, + ) + .bind(cleaned) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.first().and_then(|r| os(r, "codein"))) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetCodeinByCodeCentrale error: {}", short(&message_erreur(&e), 250)); + None + } + } +} + +/// Identité, prix et nomenclature d'un produit (une seule ligne). +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitDetailRow { + pub no_id: i64, + pub codein: String, + pub code_centrale: String, + pub libelle1: String, + pub nom_no_id: Option, + pub gtin: String, + pub reference: String, + pub pcb: Option, + /// article_infosup.prix_vente_mini + pub pv_central: Option, + /// cube_pa.pa + pub pa: Option, + pub code3: Option, + pub libelle3: Option, + pub code2: Option, + pub libelle2: Option, + pub code1: Option, + pub libelle1nom: Option, +} + +/// Fiche d'identité complète d'un produit à partir de son `codein`. +/// Renvoie `None` si le codein est inconnu (ou en cas d'erreur). +pub async fn pg_get_produit_detail(codein: &str) -> Option { + let res = async { + let parent_col = get_nomenclature_parent_col().await?; + // Hiérarchie nomenclature — deux stratégies : + // + // 1. Colonne parent entière si elle existe (FK vers le no_id du niveau au-dessus). + // 2. Sinon, remontée par **préfixe de code** : la nomenclature FF est encodée + // hiérarchiquement, secteur = 2 chiffres, famille = 4, sous-famille = 6 + // (ex "330702" → "3307" → "33"). En production `nomenclature` n'expose que + // no_id / code / libelle / niveau / chemin_pere : aucune FK entière, et + // `chemin_pere` est un chemin texte. Sans ce repli, Secteur et Famille + // restaient vides sur la fiche. + let hierarchy = match parent_col.as_deref() { + Some(pc) => { + let pc = quote_ident(pc); + format!( + " + LEFT JOIN nomenclature n2 ON n2.no_id = n3.{pc} + LEFT JOIN nomenclature n1 ON n1.no_id = n2.{pc} + " + ) + } + None => " + LEFT JOIN LATERAL ( + SELECT nx.code, nx.libelle + FROM nomenclature nx + WHERE LENGTH(TRIM(n3.code)) >= 4 + AND TRIM(nx.code) = LEFT(TRIM(n3.code), 4) + LIMIT 1 + ) n2 ON TRUE + LEFT JOIN LATERAL ( + SELECT nx.code, nx.libelle + FROM nomenclature nx + WHERE LENGTH(TRIM(n3.code)) >= 2 + AND TRIM(nx.code) = LEFT(TRIM(n3.code), 2) + LIMIT 1 + ) n1 ON TRUE + " + .to_string(), + }; + // Les colonnes sont identiques dans les deux stratégies (alias n1 / n2). + let hierarchy_cols = ", + n2.code AS code2, + n2.libelle AS libelle2, + n1.code AS code1, + n1.libelle AS libelle1nom"; + + let sql = format!( + r#" + SELECT + a.no_id, + TRIM(a.codein::text) AS codein, + COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1, + a.nom_no_id, + COALESCE(ag.gtin, af.ean13, '')::text AS gtin, + COALESCE(af.reference, '')::text AS reference, + af.pcb::float AS pcb, + ai.prix_vente_mini::float AS pv_central, + pa.pa::float AS pa, + n3.code AS code3, + n3.libelle AS libelle3 + {hierarchy_cols} + FROM articles a + LEFT JOIN LATERAL ( + SELECT af1.code, af1.pcb, af1.reference, af1.ean13 + FROM artfou1 af1 + WHERE af1.art_no_id = a.no_id + ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC + LIMIT 1 + ) af ON TRUE + LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 + LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id + LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id + LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id + {hierarchy} + WHERE TRIM(a.codein::text) = $1 + ORDER BY a.no_id DESC + LIMIT 1 + "# + ); + let rows = sqlx::query(&sql).bind(codein).fetch_all(&pool()?).await?; + anyhow::Ok(rows.first().map(|r| PgProduitDetailRow { + no_id: oi(r, "no_id").unwrap_or(0), + codein: s(r, "codein"), + code_centrale: s(r, "code_centrale"), + libelle1: s(r, "libelle1"), + nom_no_id: oi(r, "nom_no_id"), + gtin: s(r, "gtin"), + reference: s(r, "reference"), + pcb: of(r, "pcb"), + pv_central: of(r, "pv_central"), + pa: of(r, "pa"), + code3: os(r, "code3"), + libelle3: os(r, "libelle3"), + code2: os(r, "code2"), + libelle2: os(r, "libelle2"), + code1: os(r, "code1"), + libelle1nom: os(r, "libelle1nom"), + })) + } + .await; + match res { + Ok(row) => { + tracing::info!("[pg-ff] pgGetProduitDetail({codein}): {}", if row.is_some() { "trouvé" } else { "introuvable" }); + row + } + Err(e) => { + tracing::error!("[pg-ff] pgGetProduitDetail error: {}", short(&message_erreur(&e), 250)); + None + } + } +} + +/// Un fournisseur référencé pour l'article. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitFournisseurRow { + pub codefou: String, + pub nomfou: String, + pub reference: String, + pub pcb: Option, + /// true si artfou1.preference = 1 (`null` si la préférence est vide) + pub principal: Option, +} + +/// Tous les fournisseurs référencés pour un article (le principal en premier). +/// Liste vide en cas d'erreur. +pub async fn pg_get_fournisseurs_by_codein(codein: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (af.code) + af.code::text AS codefou, + COALESCE(fi.nom, af.code)::text AS nomfou, + COALESCE(af.reference, '')::text AS reference, + af.pcb::float AS pcb, + (af.preference = 1) AS principal + FROM articles a + JOIN artfou1 af ON af.art_no_id = a.no_id + LEFT JOIN fouident fi ON fi.code = af.code + WHERE TRIM(a.codein::text) = $1 + ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + let mut rows: Vec = rows + .iter() + .map(|r| PgProduitFournisseurRow { + codefou: s(r, "codefou"), + nomfou: s(r, "nomfou"), + reference: s(r, "reference"), + pcb: of(r, "pcb"), + principal: ob(r, "principal"), + }) + .collect(); + // Le principal remonte en tête (le DISTINCT ON impose un tri par code). + let p = |x: &PgProduitFournisseurRow| if x.principal == Some(true) { 1 } else { 0 }; + rows.sort_by(|x, y| p(y).cmp(&p(x)).then_with(|| compare_fr(&x.nomfou, &y.nomfou))); + anyhow::Ok(rows) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetFournisseursByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Stock temps réel par site pour un article. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitStockRow { + pub site: String, + pub stockdispo: f64, + pub qte: f64, + pub valstock: f64, + pub prmp: f64, + pub dernierevente: Option, + pub dernierereception: Option, +} + +/// Stock par site d'un article. Liste vide en cas d'erreur. +pub async fn pg_get_stock_by_codein(codein: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + SELECT + cs.site, + COALESCE(cs.stockdispo, 0)::float AS stockdispo, + COALESCE(cs.qte, 0)::float AS qte, + COALESCE(cs.valstock, 0)::float AS valstock, + COALESCE(cs.prmp, 0)::float AS prmp, + cs.dernierevente::text AS dernierevente, + cs.dernierereception::text AS dernierereception + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + WHERE TRIM(a.codein::text) = $1 + AND cs.site IN ('292', '579') + ORDER BY cs.site + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + anyhow::Ok( + rows.iter() + .map(|r| PgProduitStockRow { + site: s(r, "site"), + stockdispo: f(r, "stockdispo"), + qte: f(r, "qte"), + valstock: f(r, "valstock"), + prmp: f(r, "prmp"), + dernierevente: os(r, "dernierevente"), + dernierereception: os(r, "dernierereception"), + }) + .collect(), + ) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetStockByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Quantité totale en commande fournisseur pour un article (0 en cas d'erreur). +pub async fn pg_get_commandes_by_codein(codein: &str) -> f64 { + let res = async { + let rows = sqlx::query( + r#" + SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde + FROM cdefou_vivant cv + WHERE TRIM(cv.articles_codein::text) = $1 + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.first().map(|r| f(r, "qtecde")).unwrap_or(0.0)) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetCommandesByCodein error: {}", short(&message_erreur(&e), 250)); + 0.0 + } + } +} + +/// Une gamme affectée à l'article pour une saison donnée. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgGammeHistoryRow { + pub saison_no_id: i64, + pub saison_code: Option, + pub saison_libelle: Option, + pub gamme_code: String, + pub gamme_libelle: Option, +} + +/// Historique complet des gammes de l'article, saison par saison (la plus +/// récente en premier). Le reste de l'application ne lit que la saison active +/// (`pg_get_gammes_by_fournisseur`) ; ici on veut la trajectoire d'arbitrage. +/// Liste vide en cas d'erreur. +pub async fn pg_get_gamme_history_by_codein(codein: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + SELECT + s.no_id AS saison_no_id, + s.code::text AS saison_code, + s.libelle::text AS saison_libelle, + g.code::text AS gamme_code, + g.libelle::text AS gamme_libelle + FROM art_gamme_saison ags + JOIN articles a ON a.no_id = ags.artnoid + JOIN gammes g ON g.no_id = ags.idgamme + JOIN saisons s ON s.no_id = ags.idsaison + WHERE TRIM(a.codein::text) = $1 + ORDER BY s.no_id DESC + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + anyhow::Ok( + rows.iter() + .map(|r| PgGammeHistoryRow { + saison_no_id: oi(r, "saison_no_id").unwrap_or(0), + saison_code: os(r, "saison_code"), + saison_libelle: os(r, "saison_libelle"), + gamme_code: s(r, "gamme_code"), + gamme_libelle: os(r, "gamme_libelle"), + }) + .collect(), + ) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetGammeHistoryByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions) +/// d'UN article, par site et par mois. +/// +/// Reprend **exactement** le calcul canonique de `pg_get_mensuel_by_fournisseur` +/// (la source de vérité de la Grille) : seule la sélection change — filtre sur +/// le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la +/// fiche produit sont donc identiques à ceux de la Grille, par construction. +/// Liste vide en cas d'erreur. +pub async fn pg_get_mensuel_by_codein(codein: &str, date_debut: &str, date_fin: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + WITH base AS ( + SELECT + TRIM(a.codein::text) AS codein, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + m.qtemvt, + m.mntmvtttc, + m.margemvt, + m.genremvt, + m.qtestock, + ROW_NUMBER() OVER ( + PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY m.datmvt DESC + ) AS rn_last + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE TRIM(a.codein::text) = $1 + AND m.datmvt BETWEEN $2::date AND $3::date + AND m.site IN ('292', '579') + ) + SELECT + codein, + site, + mois, + SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, + SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, + SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, + MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, + SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue + FROM base + GROUP BY codein, site, mois + ORDER BY site, mois + "#, + ) + .bind(codein) + .bind(date_debut) + .bind(date_fin) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.iter().map(mensuel_row).collect::>()) + } + .await; + match res { + Ok(v) => { + tracing::info!("[pg-ff] pgGetMensuelByCodein({codein}): {} lignes", v.len()); + v + } + Err(e) => { + tracing::error!("[pg-ff] pgGetMensuelByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Un produit de la même famille, comparé local vs réseau. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgOpportuniteRow { + pub codein: String, + pub libelle1: String, + pub code_centrale: String, + pub fournisseur: String, + pub qte_reseau: f64, + pub nb_magasins_reseau: f64, + pub ca_reseau: f64, + pub ca_par_magasin_reseau: f64, + pub qte_locale: f64, + pub nb_sites_locaux: f64, + pub qte_reseau_par_magasin: f64, + pub qte_locale_par_magasin: f64, + /// Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. + pub ecart_par_magasin: f64, +} + +/// Produits de la même nomenclature, classés par écart entre la performance +/// réseau et la performance locale (quantités **par magasin**, pour neutraliser +/// la différence d'échelle entre ~270 magasins et nos 2 sites). +/// +/// Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà +/// des métriques réseau en cache : la dimension Qlik interrogée est +/// `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas. +/// +/// `limit` : 30 dans le code d'origine. Liste vide en cas d'erreur. +pub async fn pg_get_opportunites_famille(nom_no_id: i64, date_debut: &str, date_fin: &str, limit: i64) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + WITH famille AS ( + SELECT DISTINCT ON (TRIM(a.codein::text)) + a.no_id, + TRIM(a.codein::text) AS codein, + TRIM(a.artcentrale::text) AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1 + FROM articles a + WHERE a.nom_no_id = $1 + AND a.codein IS NOT NULL + AND a.artcentrale IS NOT NULL + AND TRIM(a.artcentrale::text) <> '' + ORDER BY TRIM(a.codein::text), a.no_id DESC + ), + avec_reseau AS ( + SELECT + f.no_id, + f.codein, + f.code_centrale, + f.libelle1, + q.qte_reseau::float AS qte_reseau, + q.nb_magasins_reseau AS nb_magasins_reseau, + COALESCE(q.ca_reseau, 0)::float AS ca_reseau, + COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau + FROM famille f + JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale + WHERE q.nb_magasins_reseau > 0 + AND q.qte_reseau > 0 + ), + ventes AS ( + SELECT + TRIM(a.codein::text) AS codein, + SUM(-m.qtemvt)::float AS qte_locale, + COUNT(DISTINCT m.site) AS nb_sites_locaux + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.genremvt = 3 + AND m.site IN ('292', '579') + AND m.datmvt BETWEEN $2::date AND $3::date + AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau) + GROUP BY TRIM(a.codein::text) + ) + SELECT + r.codein, + r.libelle1, + r.code_centrale, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + r.qte_reseau, + r.nb_magasins_reseau, + r.ca_reseau, + r.ca_par_magasin_reseau, + COALESCE(v.qte_locale, 0)::float AS qte_locale, + COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux, + (r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin, + (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin, + ( + (r.qte_reseau / r.nb_magasins_reseau) + - (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1)) + )::float AS ecart_par_magasin + FROM avec_reseau r + LEFT JOIN ventes v ON v.codein = r.codein + LEFT JOIN LATERAL ( + SELECT af1.code + FROM artfou1 af1 + WHERE af1.art_no_id = r.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + ORDER BY ecart_par_magasin DESC + LIMIT $4 + "#, + ) + .bind(nom_no_id) + .bind(date_debut) + .bind(date_fin) + .bind(limit) + .fetch_all(&pool()?) + .await?; + anyhow::Ok( + rows.iter() + .map(|r| PgOpportuniteRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + code_centrale: s(r, "code_centrale"), + fournisseur: s(r, "fournisseur"), + qte_reseau: f(r, "qte_reseau"), + nb_magasins_reseau: f(r, "nb_magasins_reseau"), + ca_reseau: f(r, "ca_reseau"), + ca_par_magasin_reseau: f(r, "ca_par_magasin_reseau"), + qte_locale: f(r, "qte_locale"), + nb_sites_locaux: f(r, "nb_sites_locaux"), + qte_reseau_par_magasin: f(r, "qte_reseau_par_magasin"), + qte_locale_par_magasin: f(r, "qte_locale_par_magasin"), + ecart_par_magasin: f(r, "ecart_par_magasin"), + }) + .collect::>(), + ) + } + .await; + match res { + Ok(v) => { + tracing::info!("[pg-ff] pgGetOpportunitesFamille(nom={nom_no_id}): {} lignes", v.len()); + v + } + Err(e) => { + tracing::error!("[pg-ff] pgGetOpportunitesFamille error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bornes() { + assert_eq!(bornes_mois("2026-03"), ("2026-03-01".into(), "2026-04-01".into())); + assert_eq!(bornes_mois("2025-12"), ("2025-12-01".into(), "2026-01-01".into())); + assert_eq!(bornes_mois("2025-09"), ("2025-09-01".into(), "2025-10-01".into())); + } + + #[test] + fn normalisation_recherche() { + assert_eq!(normalize_search_term("Tapis anti-POUSSIÈRE !"), "tapis anti poussiere"); + assert_eq!(normalize_search_term(" Crème brûlée "), "creme brulee"); + assert_eq!(SQL_ACCENTS_FROM.chars().count(), SQL_ACCENTS_TO.chars().count()); + } + + #[test] + fn tri_fr() { + let mut v = vec!["Zèbre", "école", "Abeille", "Éclair"]; + v.sort_by(|a, b| compare_fr(a, b)); + assert_eq!(v, vec!["Abeille", "Éclair", "école", "Zèbre"]); + } + + #[test] + fn commande_auto_mapping() { + let r = json!({ "site": 292, "codefou": "N009", "nom_fou": "X", "franco_ht": "150.5", "nb_articles": 3, + "montant_propo_ht": null, "montant_cde": "12", "franco_atteint": "NON", "ecart_franco": -3 }); + let m = map_commande_auto_row(&r); + assert_eq!(m.site, "292"); + assert_eq!(m.franco, 150.5); + assert_eq!(m.montant_cde, 12.0); + assert_eq!(m.franco_atteint, Some(false)); + assert_eq!(extract_franco(&json!({ "resume": [{ "franco_ht": "200" }] })), 200.0); + assert_eq!(extract_franco(&json!({ "error": "x" })), 0.0); + assert_eq!(extract_franco(&json!({ "resume": [] })), 0.0); + } } diff --git a/server/src/ff/routes.rs b/server/src/ff/routes.rs new file mode 100644 index 0000000..67e0e63 --- /dev/null +++ b/server/src/ff/routes.rs @@ -0,0 +1,516 @@ +//! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`, +//! `/api/diag/stock-fournisseur`, `/api/test-db`, `/api/commandes-auto`. + +use super::api::{ff_client, get_ff_api_base}; +use super::diagnostic::diagnostiquer_api_ff; +use super::js::{encode, js_string, message_erreur}; +use super::pg::{pg_diag_fournisseur_derniere_entree, pg_get_stock_for_codeins}; +use crate::auth::{AdminUser, SessionUser}; +use crate::db::{pool, rows_to_json}; +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use chrono::{Datelike, Local, NaiveDate}; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; + +/// `String(e)` d'une erreur JS (`Error: message`). +fn string_erreur(e: &dyn std::fmt::Display) -> String { + format!("Error: {e}") +} + +// --------------------------------------------------------------------------- +// GET /api/ff-status +// --------------------------------------------------------------------------- + +/// GET /api/ff-status — état de l'API REST FF Nancy. +/// +/// S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin +/// qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP) +/// et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans +/// détail, impossible à distinguer d'une simple erreur d'URL. +pub async fn ff_status(_user: SessionUser) -> Response { + let res = diagnostiquer_api_ff(None).await; + if !res.success { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "API FF Nancy non disponible", "url": res.url, "detail": res.error })), + ) + .into_response(); + } + Json(res.status).into_response() +} + +// --------------------------------------------------------------------------- +// GET /api/diag/stock-fournisseur +// --------------------------------------------------------------------------- + +/// Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par +/// la page Gestion de Stock. +/// +/// `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes +/// fournisseur connues : la page retombe alors sur `artfou1.preference = 1`. +/// `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste +/// blanche `MVTART_FOU_CANDIDATES` si le nom diffère. +pub async fn diag_stock_fournisseur(_admin: AdminUser) -> Response { + match pg_diag_fournisseur_derniere_entree().await { + Ok(d) => Json(d).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": string_erreur(&message_erreur(&e)) }))).into_response(), + } +} + +// --------------------------------------------------------------------------- +// GET /api/test-db +// --------------------------------------------------------------------------- + +pub async fn test_db(_admin: AdminUser, Query(q): Query>) -> Response { + let codein = q.get("codein").filter(|c| !c.is_empty()).cloned().unwrap_or_else(|| "334152".into()); + let res = async { + let map = pg_get_stock_for_codeins(std::slice::from_ref(&codein)).await?; + let fou = sqlx::query( + r#" + SELECT + a.codein::text AS codein, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur + FROM articles a + LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + LEFT JOIN fouident fi ON fi.code = af.code + WHERE a.codein::text = $1 + "#, + ) + .bind(&codein) + .fetch_all(&pool()?) + .await?; + let stock: Map = map.into_iter().map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(Value::Null))).collect(); + anyhow::Ok(json!({ "stock": stock, "fou": rows_to_json(&fou) })) + } + .await; + match res { + Ok(v) => Json(v).into_response(), + Err(e) => { + // Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur. + tracing::error!("[api/test-db] {}", message_erreur(&e)); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": message_erreur(&e) }))).into_response() + } + } +} + +// --------------------------------------------------------------------------- +// GET /api/commandes-auto +// --------------------------------------------------------------------------- + +/// GET /api/commandes-auto[?site=292&codefou=N009] +/// +/// Proxy vers https://api.ffnancy.fr/api/commandes-auto +/// Transfère les query params site et codefou si présents. +pub async fn commandes_auto(_user: SessionUser, Query(q): Query>) -> Response { + let res = async { + let mut params: Vec<(&str, &str)> = vec![]; + if let Some(site) = q.get("site").filter(|s| !s.is_empty()) { + params.push(("site", site)); + } + if let Some(codefou) = q.get("codefou").filter(|s| !s.is_empty()) { + params.push(("codefou", codefou)); + } + let base = get_ff_api_base().await; + let url = format!("{base}/api/commandes-auto"); + let res = ff_client().get(&url).query(¶ms).send().await?; + if !res.status().is_success() { + let status = res.status(); + return anyhow::Ok( + (status, Json(json!({ "error": format!("API upstream HTTP {}", status.as_u16()) }))).into_response(), + ); + } + let data: Value = res.json().await?; + Ok(Json(data).into_response()) + } + .await; + match res { + Ok(r) => r, + Err(e) => { + tracing::error!("[api/commandes-auto] {}", message_erreur(&e)); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Erreur serveur" }))).into_response() + } + } +} + +// --------------------------------------------------------------------------- +// GET /api/diag +// --------------------------------------------------------------------------- + +/// Résumé d'une sonde : statut HTTP et aperçu du contenu. +async fn probe(url: String) -> Value { + let res = async { + let res = reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?; + let status = res.status().as_u16(); + let text = res.text().await?; + anyhow::Ok((status, text)) + } + .await; + let (status, text) = match res { + Ok(v) => v, + Err(e) => return json!({ "error": string_erreur(&message_erreur(&e)), "url": url }), + }; + let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.chars().take(800).collect())); + let keys_of = |v: Option<&Value>| -> Value { + match v.and_then(|x| x.as_object()) { + Some(o) => Value::from(o.keys().cloned().collect::>()), + None => json!([]), + } + }; + let summary = match &parsed { + Value::Array(a) => json!({ "count": a.len(), "firstKeys": keys_of(a.first()), "sample": a.iter().take(2).cloned().collect::>() }), + Value::Object(o) => { + let keys: Vec = o.keys().cloned().collect(); + match o.iter().find(|(_, v)| v.is_array()) { + Some((k, Value::Array(arr))) => json!({ + "wrapper": k, + "count": arr.len(), + "firstKeys": keys_of(arr.first()), + "sample": arr.iter().take(2).cloned().collect::>(), + }), + // Objet simple : montrer toutes les clés et le contenu complet + _ => json!({ "keys": keys, "full": parsed }), + } + } + other => other.clone(), + }; + json!({ "status": status, "url": url, "summary": summary }) +} + +/// Première liste d'une réponse : tableau direct ou première propriété tableau. +fn premiere_liste(data: &Value) -> Vec { + match data { + Value::Array(a) => a.clone(), + Value::Object(o) => o.values().find_map(|v| v.as_array().cloned()).unwrap_or_default(), + _ => vec![], + } +} + +async fn get_json(url: &str) -> anyhow::Result { + let res = reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?; + Ok(res.json::().await?) +} + +/// Champ texte d'un élément (`(x as Record).champ as string ?? null`). +fn champ(v: Option<&Value>, key: &str) -> Option { + v.and_then(|x| x.get(key)).filter(|x| !x.is_null()).map(js_string) +} + +pub async fn diag(_admin: AdminUser, Query(q): Query>) -> Response { + let ff_api_base = get_ff_api_base().await; + + // Date range: 12 derniers mois + let today = Local::now().date_naive(); + let first = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + let date_fin = first.pred_opt().unwrap_or(first).format("%Y-%m-%d").to_string(); + let date_debut = first.checked_sub_months(chrono::Months::new(12)).unwrap_or(first).format("%Y-%m-%d").to_string(); + + // Récupérer un codefou réel + un codein réel depuis l'API + let mut codefou: Option = q.get("codefou").filter(|s| !s.is_empty()).cloned(); + let mut first_codein: Option = q.get("codein").filter(|s| !s.is_empty()).cloned(); + let forced_noid: Option = q.get("noid").filter(|s| !s.is_empty()).cloned(); // ex: ?noid=129632 + + if codefou.is_none() { + if let Ok(data) = get_json(&format!("{ff_api_base}/api/fournisseurs?limit=1")).await { + let list = premiere_liste(&data); + codefou = champ(list.first(), "codefou"); + } + if codefou.is_none() { + codefou = Some("D005".into()); + } + } + let codefou = codefou.unwrap_or_default(); + let codefou_enc = encode(&codefou); + + if first_codein.is_none() { + if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await { + let list = premiere_liste(&data); + first_codein = champ(list.first(), "codein"); + } + } + + // Si un noid est forcé, on probe directement mensuel + referentiel pour cet article + let mensuel_noid_forced = async { + match &forced_noid { + Some(n) => { + probe(format!("{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(n))).await + } + None => json!({ "skipped": "no ?noid= param" }), + } + }; + let referentiel_noid_forced = async { + match &forced_noid { + Some(n) => probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(n))).await, + None => json!({ "skipped": "no ?noid= param" }), + } + }; + // Test mensuel avec no_id auto-résolu + let mensuel_noid = async { + if first_codein.is_none() { + return json!({ "skipped": "no article" }); + } + if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await { + let list = premiere_liste(&data); + if let Some(noid) = champ(list.first(), "no_id").filter(|s| !s.is_empty()) { + return probe(format!( + "{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", + encode(&noid) + )) + .await; + } + } + json!({ "skipped": "no no_id available" }) + }; + // Referentiel complet (nomenclature + gamme + stock + prix) + let referentiel = async { + if first_codein.is_none() { + return json!({ "skipped": "no codefou" }); + } + if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=5")).await { + let list = premiere_liste(&data); + let art = list.get(4).or(list.first()); + if let Some(no_id) = champ(art, "no_id").filter(|s| !s.is_empty()) { + return probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(&no_id))).await; + } + } + json!({ "skipped": "no no_id" }) + }; + + let (fournisseurs, articles, mvt_date_debut, mensuel_noid, referentiel, mensuel_forced, referentiel_forced) = tokio::join!( + probe(format!("{ff_api_base}/api/fournisseurs?limit=2")), + probe(format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=2")), + probe(format!( + "{ff_api_base}/api/mouvements/articles?codefou={codefou_enc}&dateDebut={date_debut}&dateFin={date_fin}&limit=2" + )), + mensuel_noid, + referentiel, + mensuel_noid_forced, + referentiel_noid_forced, + ); + + // Test PostgreSQL + diagnostic ventesProduits + let (postgresql, ventes_produits_diag) = match async { + let pool = pool()?; + let r = sqlx::query("SELECT COUNT(*) as count FROM session_snapshots").fetch_all(&pool).await?; + + // Diagnostic ventesProduits : cherche par les 2 codes possibles d'AUXENCE + let vp_by_name = sqlx::query( + r#" + SELECT code_fournisseur, COUNT(*) as nb_rows, + COUNT(code_gamme) as nb_gamme, + COUNT(code_gamme_init) as nb_gamme_init, + COUNT(code3) as nb_code3 + FROM ventes_produits + WHERE code_fournisseur IN ('AUXENCE', 'A025', $1) + GROUP BY code_fournisseur + "#, + ) + .bind(&codefou) + .fetch_all(&pool) + .await?; + + // Sample de quelques codeins dans ventesProduits pour ce fournisseur + let vp_sample = sqlx::query( + r#" + SELECT codein, code_fournisseur, code_gamme, code_gamme_init, code3, libelle3 + FROM ventes_produits + WHERE code_fournisseur IN ('AUXENCE', 'A025', $1) + LIMIT 3 + "#, + ) + .bind(&codefou) + .fetch_all(&pool) + .await?; + + let snapshots = r.first().map(|row| crate::db::f64_or0(row, "count")).unwrap_or(0.0); + anyhow::Ok(( + json!({ "ok": true, "snapshots": snapshots }), + json!({ "byFournisseur": rows_to_json(&vp_by_name), "sample": rows_to_json(&vp_sample) }), + )) + } + .await + { + Ok(v) => v, + Err(e) => { + let msg = string_erreur(&message_erreur(&e)); + (json!({ "ok": false, "error": msg }), json!({ "error": msg })) + } + }; + + // Diagnostic analytics CA TTC + let mvtart_diag = match async { + let pool = pool()?; + // 1. Totaux mars 2025 avec mntmvtht et mntmvtttc pour comparer avec API (HT=225k, TTC=269k) + let totaux = sqlx::query( + r#" + SELECT site, + ABS(SUM(mntmvtht))::float AS sum_ht, + ABS(SUM(mntmvtttc))::float AS sum_ttc, + COUNT(*) AS nb_lignes + FROM mvtart + WHERE genremvt = 3 + AND TO_CHAR(datmvt, 'YYYY-MM') = '2025-03' + AND site IN ('292', '579') + GROUP BY site + "#, + ) + .fetch_all(&pool) + .await?; + // 2. Distribution des valeurs de preference dans artfou1 + let pref_dist = sqlx::query( + r#" + SELECT preference, COUNT(*) AS nb + FROM artfou1 + GROUP BY preference + ORDER BY preference + "#, + ) + .fetch_all(&pool) + .await?; + // 3. Ventes mars 2025 non attribuées (aucune ligne artfou1 avec preference=1) + let sans_preference = sqlx::query( + r#" + SELECT COUNT(DISTINCT m.artnoid) AS nb_articles_sans_pref + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.genremvt = 3 + AND TO_CHAR(m.datmvt, 'YYYY-MM') = '2025-03' + AND m.site IN ('292','579') + AND NOT EXISTS ( + SELECT 1 FROM artfou1 af + WHERE af.art_no_id = a.no_id AND af.preference = 1 + ) + "#, + ) + .fetch_all(&pool) + .await?; + anyhow::Ok(json!({ + "totaux_202503": rows_to_json(&totaux), + "artfou1_preference_distribution": rows_to_json(&pref_dist), + "articles_vendus_sans_pref1": rows_to_json(&sans_preference), + })) + } + .await + { + Ok(v) => v, + Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }), + }; + + // Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto + let franco_diag = match async { + let pool = pool()?; + let franco_columns = sqlx::query( + r#" + SELECT table_name, column_name, data_type + FROM information_schema.columns + WHERE column_name ILIKE '%franco%' + OR column_name ILIKE '%min_cmd%' + OR column_name ILIKE '%mincde%' + OR column_name ILIKE '%min_commande%' + ORDER BY table_name, column_name + "#, + ) + .fetch_all(&pool) + .await?; + // Sample des 3 premières lignes de fouident pour voir toutes les colonnes + let fouident_sample = rows_to_json(&sqlx::query("SELECT * FROM fouident LIMIT 3").fetch_all(&pool).await?); + let foucad_sample = rows_to_json(&sqlx::query("SELECT * FROM foucad LIMIT 5").fetch_all(&pool).await?); + let cols = |rows: &[Value]| -> Value { + match rows.first().and_then(|r| r.as_object()) { + Some(o) => Value::from(o.keys().cloned().collect::>()), + None => json!([]), + } + }; + anyhow::Ok(json!({ + "francoColumns": rows_to_json(&franco_columns), + "fouidentColumns": cols(&fouident_sample), + "fouidentSample": fouident_sample, + "foucadColumns": cols(&foucad_sample), + "foucadSample": foucad_sample, + })) + } + .await + { + Ok(v) => v, + Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }), + }; + + // Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant + let commandes_auto_franco_audit = match get_json(&format!("{ff_api_base}/api/commandes-auto")).await { + Ok(ca_data) => { + let ca_rows: Vec = match &ca_data { + Value::Array(a) => a.clone(), + d => d + .get("propositions") + .filter(|v| !v.is_null()) + .or_else(|| d.get("data").filter(|v| !v.is_null())) + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(), + }; + let franco = |r: &Value| r.get("franco_ht").filter(|v| !v.is_null()).map(super::js::js_number); + let with_franco = ca_rows.iter().filter(|r| franco(r).map(|f| f > 0.0).unwrap_or(false)).count(); + let without: Vec<&Value> = ca_rows.iter().filter(|r| franco(r).map(|f| f == 0.0).unwrap_or(true)).collect(); + json!({ + "total": ca_rows.len(), + "avecFranco": with_franco, + "sansFranco": without.len(), + "lignesSansFranco": without.iter().map(|r| { + let mut o = Map::new(); + for k in ["site", "codefou", "nom_fou", "franco_ht", "franco_atteint"] { + if let Some(v) = r.get(k) { + o.insert(k.to_string(), v.clone()); + } + } + Value::Object(o) + }).collect::>(), + }) + } + Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }), + }; + + // Probe des fournisseurs N/A pour trouver où est leur franco + let (detail_m036_292, detail_m030_579, fournisseur_m036, fournisseur_m030) = tokio::join!( + probe(format!("{ff_api_base}/api/commandes-auto/M036?site=292")), + probe(format!("{ff_api_base}/api/commandes-auto/M030?site=579")), + probe(format!("{ff_api_base}/api/fournisseurs/M036")), + probe(format!("{ff_api_base}/api/fournisseurs/M030")), + ); + let franco_na_probe = json!({ + "detailM036_292": detail_m036_292, + "detailM030_579": detail_m030_579, + "fournisseurM036": fournisseur_m036, + "fournisseurM030": fournisseur_m030, + }); + + // Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API + let raw_commandes_auto = probe(format!("{ff_api_base}/api/commandes-auto")).await; + + Json(json!({ + "timestamp": crate::db::iso_utc(chrono::Utc::now()), + "dateRange": { "dateDebut": date_debut, "dateFin": date_fin }, + "codefouTested": codefou, + "firstCodeinTested": first_codein, + "forcedNoid": forced_noid.unwrap_or_else(|| "(none — pass ?noid=129632 to test a specific article)".into()), + "fournisseurs": fournisseurs, + "articles": articles, + "mouvements_dateDebut": mvt_date_debut, + "endpoints_auto": { + "articles/:no_id/mensuel": mensuel_noid, + "articles/:no_id/referentiel": referentiel, + }, + "endpoints_forced_noid": { + "articles/:noid/mensuel": mensuel_forced, + "articles/:noid/referentiel": referentiel_forced, + }, + "postgresql": postgresql, + "ventesProduitsDiag": ventes_produits_diag, + "mvtartDiag": mvtart_diag, + "francoDiag": franco_diag, + "rawCommandesAuto": raw_commandes_auto, + "commandesAutoFrancoAudit": commandes_auto_franco_audit, + "francoNaProbe": franco_na_probe, + })) + .into_response() +} From dc53bb000d82c9d628ebc884c73ed82a1ae1754e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:25:54 +0000 Subject: [PATCH 07/19] =?UTF-8?q?Serveur=20Rust=20:=20module=20qlik=20(NTL?= =?UTF-8?q?M,=20Chromium/CDP,=20extraction=20r=C3=A9seau,=20recherche,=20c?= =?UTF-8?q?ache,=20/api/qlik/sync)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port fidèle de l'intégration Qlik Sense : - client.rs : configuration (réglages > env > défauts), types NetworkMetric / QlikMonthMetrics / NetworkProduct (JSON identique au TS), xrfkey et flux NTLM -> ticket -> cookie X-Qlik-Session, l'échange type 1 / type 3 sur une seule connexion HTTP/1.1 dédiée (TLS permissif si tlsInsecure). - ntlm.rs : messages NTLM identiques octet pour octet à httpntlm/ntlm.js (NTLMv2, NTLM2 Session Response, v1 LM/NT), vérifiés contre Node. - browser.rs : Chromium partagé relancé après déconnexion, file unique des extractions, page en contexte isolé pilotée en CDP (cookies, identifiants HTTP, console, erreurs, websocket -> qlik-csrf-token, liaison cfCheckpoint, navigation domcontentloaded, évaluation JSON). - playwright.rs + js/extraction.js : extraction 12 mois (script in-page repris tel quel), reprises globales, agrégation, contrôles avant écriture. - search.rs + js/recherche.js : recherche produit dans Qlik. - date_range.rs, validation.rs : logique pure, tests portés. - network_cache.rs : lecture / upsert de qlik_network_metrics. - sync.rs : route /api/qlik/sync (jobs asynchrones, journaux capturés). Dépendances ajoutées : des, md4, md-5, hyper, hyper-util, http-body-util, rustls, tokio-rustls, webpki-roots, indexmap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/Cargo.lock | 30 + server/Cargo.toml | 10 + server/src/qlik/browser.rs | 356 +++++ server/src/qlik/client.rs | 503 +++++++ server/src/qlik/date_range.rs | 460 ++++++ server/src/qlik/js/extraction.js | 2036 +++++++++++++++++++++++++++ server/src/qlik/js/recherche.js | 412 ++++++ server/src/qlik/jsnum.rs | 110 ++ server/src/qlik/mod.rs | 45 +- server/src/qlik/network_cache.rs | 220 +++ server/src/qlik/ntlm.rs | 435 ++++++ server/src/qlik/playwright.rs | 669 +++++++++ server/src/qlik/search.rs | 305 ++++ server/src/qlik/sync.rs | 579 ++++++++ server/src/qlik/tests_navigateur.rs | 161 +++ server/src/qlik/validation.rs | 272 ++++ 16 files changed, 6601 insertions(+), 2 deletions(-) create mode 100644 server/src/qlik/browser.rs create mode 100644 server/src/qlik/client.rs create mode 100644 server/src/qlik/date_range.rs create mode 100644 server/src/qlik/js/extraction.js create mode 100644 server/src/qlik/js/recherche.js create mode 100644 server/src/qlik/jsnum.rs create mode 100644 server/src/qlik/network_cache.rs create mode 100644 server/src/qlik/ntlm.rs create mode 100644 server/src/qlik/playwright.rs create mode 100644 server/src/qlik/search.rs create mode 100644 server/src/qlik/sync.rs create mode 100644 server/src/qlik/tests_navigateur.rs create mode 100644 server/src/qlik/validation.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index e2a50a6..5a45699 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -395,15 +395,23 @@ dependencies = [ "base64 0.22.1", "chromiumoxide", "chrono", + "des", "futures", "hex", "hmac", + "http-body-util", + "hyper", + "hyper-util", + "indexmap", + "md-5", + "md4", "parking_lot", "rand 0.8.8", "regex", "reqwest 0.12.28", "rust_decimal", "rust_xlsxwriter", + "rustls", "scrypt", "serde", "serde_json", @@ -412,12 +420,14 @@ dependencies = [ "subtle", "thiserror", "tokio", + "tokio-rustls", "tower", "tower-http", "tracing", "tracing-subscriber", "urlencoding", "uuid", + "webpki-roots 1.0.9", ] [[package]] @@ -568,6 +578,15 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -1224,6 +1243,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1359,6 +1380,15 @@ dependencies = [ "digest", ] +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest", +] + [[package]] name = "memchr" version = "2.8.3" diff --git a/server/Cargo.toml b/server/Cargo.toml index 314381d..dacd3d2 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,15 +12,23 @@ axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } base64 = "0.22" chromiumoxide = "0.9.1" chrono = { version = "0.4", features = ["serde"] } +des = "0.8" futures = "0.3" hex = "0.4" hmac = "0.12" +http-body-util = "0.1" +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +indexmap = { version = "2", features = ["serde"] } +md-5 = "0.10" +md4 = "0.10" parking_lot = "0.12" rand = "0.8" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "cookies", "stream", "http2"] } rust_decimal = "1" rust_xlsxwriter = "0.99" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } scrypt = { version = "0.11", default-features = false, features = ["std"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } @@ -29,12 +37,14 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", subtle = "2" thiserror = "2" tokio = { version = "1", features = ["full"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } tower = "0.5" tower-http = { version = "0.6", features = ["fs", "compression-gzip", "trace", "set-header"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } +webpki-roots = "1" [profile.release] opt-level = 3 diff --git a/server/src/qlik/browser.rs b/server/src/qlik/browser.rs new file mode 100644 index 0000000..bd6bcb4 --- /dev/null +++ b/server/src/qlik/browser.rs @@ -0,0 +1,356 @@ +//! Chromium et file d'attente partagés par tous les accès Qlik +//! (port de `src/lib/qlik-browser.ts`) — extraction réseau (`playwright.rs`), +//! recherche produit (`search.rs`). +//! +//! Le pilotage passe par le protocole DevTools (chromiumoxide) ; [`PageQlik`] +//! regroupe l'équivalent des fonctions Playwright utilisées par le TS : +//! contexte isolé (`browser.newContext`), `ctx.addCookies`, `httpCredentials`, +//! `page.on("console" | "pageerror" | "websocket")`, `page.exposeFunction`, +//! `page.goto(…, domcontentloaded)` et `page.evaluate(fn, args)`. + +use anyhow::{anyhow, Context}; +use chromiumoxide::browser::BrowserConfig; +use chromiumoxide::cdp::browser_protocol::browser::BrowserContextId; +use chromiumoxide::cdp::browser_protocol::network::{CookieParam, EventWebSocketCreated, SetCookiesParams}; +use chromiumoxide::cdp::browser_protocol::page::{AddScriptToEvaluateOnNewDocumentParams, EventDomContentEventFired, NavigateParams}; +use chromiumoxide::cdp::browser_protocol::target::{CreateBrowserContextParams, CreateTargetParams}; +use chromiumoxide::cdp::js_protocol::runtime::{ + AddBindingParams, EvaluateParams, EventBindingCalled, EventConsoleApiCalled, EventExceptionThrown, RemoteObject, +}; +use chromiumoxide::{Browser, Page}; +use futures::StreamExt; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, LazyLock}; +use std::time::Duration; +use tokio::sync::{mpsc, watch, Mutex}; +use tokio::task::JoinHandle; + +// --------------------------------------------------------------------------- +// Chromium partagé +// --------------------------------------------------------------------------- + +struct Navigateur { + browser: Arc, + connecte: Arc, +} + +static NAVIGATEUR: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static LANCEMENTS: AtomicUsize = AtomicUsize::new(0); + +/// Recherche un exécutable dans le PATH. +fn dans_le_path(nom: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).map(|d| d.join(nom)).find(|p| p.is_file()) +} + +/// Exécutable Chromium : `PLAYWRIGHT_CHROMIUM_PATH`, puis `CHROMIUM_PATH`, puis +/// `chromium` / `chromium-browser` dans le PATH ; à défaut, détection de chromiumoxide. +pub fn chemin_chromium() -> Option { + for var in ["PLAYWRIGHT_CHROMIUM_PATH", "CHROMIUM_PATH"] { + if let Some(p) = std::env::var(var).ok().filter(|v| !v.trim().is_empty()) { + return Some(PathBuf::from(p)); + } + } + ["chromium", "chromium-browser"].into_iter().find_map(dans_le_path) +} + +async fn lancer() -> anyhow::Result { + let n = LANCEMENTS.fetch_add(1, Ordering::SeqCst); + // Profil propre à chaque lancement : un Chromium tombé laisse son verrou + // (SingletonLock) dans le profil et empêcherait la relance. + let profil = std::env::temp_dir().join(format!("collectflow-chromium-{}-{n}", std::process::id())); + let mut builder = BrowserConfig::builder() + .no_sandbox() + .arg("disable-dev-shm-usage") + .arg("ignore-certificate-errors") + .user_data_dir(&profil) + .launch_timeout(Duration::from_secs(60)) + // Une extraction complète tient dans UN `page.evaluate` (plusieurs + // minutes, parfois plus de 15) : pas de délai par commande CDP, comme + // Playwright. Les étapes bornées le sont explicitement (tokio::time::timeout). + .request_timeout(Duration::from_secs(24 * 3600)); + if let Some(chemin) = chemin_chromium() { + builder = builder.chrome_executable(chemin); + } + let config = builder.build().map_err(|e| anyhow!("[qlik] configuration Chromium : {e}"))?; + let (browser, mut handler) = Browser::launch(config).await.context("[qlik] lancement de Chromium")?; + let connecte = Arc::new(AtomicBool::new(true)); + let drapeau = connecte.clone(); + tokio::spawn(async move { + // Le gestionnaire CDP doit être consommé en continu ; sa fin = navigateur + // déconnecté (plantage, mémoire, arrêt du processus enfant). + while let Some(evt) = handler.next().await { + if let Err(e) = evt { + tracing::debug!("[qlik] événement CDP ignoré : {e}"); + } + } + drapeau.store(false, Ordering::SeqCst); + tracing::warn!("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction"); + let _ = std::fs::remove_dir_all(&profil); + }); + Ok(Navigateur { browser: Arc::new(browser), connecte }) +} + +/// Chromium partagé entre les extractions. +/// +/// Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait +/// mémorisé : toutes les extractions suivantes échouaient sur « Target closed » +/// jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté. +pub async fn get_qlik_browser() -> anyhow::Result> { + let mut garde = NAVIGATEUR.lock().await; + if let Some(n) = garde.as_ref() { + if n.connecte.load(Ordering::SeqCst) { + return Ok(n.browser.clone()); + } + } + if let Some(ancien) = garde.take() { + if let Ok(mut b) = Arc::try_unwrap(ancien.browser) { + let _ = b.kill().await; + } + } + let n = lancer().await?; + let b = n.browser.clone(); + *garde = Some(n); + Ok(b) +} + +// --------------------------------------------------------------------------- +// File unique des extractions +// --------------------------------------------------------------------------- + +/// Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une +/// synchro manuelle lancée pendant la synchro de nuit, ou deux recherches +/// produit simultanées, ouvraient deux sessions Engine en parallèle — et les +/// deux échouaient. Les extractions passent désormais l'une après l'autre +/// (le verrou tokio est équitable : premier arrivé, premier servi). +static FILE_EXTRACTIONS: Mutex<()> = Mutex::const_new(()); +static EXTRACTIONS_EN_ATTENTE: AtomicUsize = AtomicUsize::new(0); + +struct DecompteFile; + +impl Drop for DecompteFile { + fn drop(&mut self) { + EXTRACTIONS_EN_ATTENTE.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Exécute `tache` quand toutes les extractions Qlik précédentes sont terminées. +pub async fn dans_la_file_qlik(libelle: &str, tache: impl FnOnce() -> F) -> T +where + F: std::future::Future, +{ + let en_attente = EXTRACTIONS_EN_ATTENTE.fetch_add(1, Ordering::SeqCst); + let _decompte = DecompteFile; + if en_attente > 0 { + tracing::info!("[qlik] {libelle} : en attente de {en_attente} extraction(s) en cours"); + } + let _tour = FILE_EXTRACTIONS.lock().await; + tache().await +} + +// --------------------------------------------------------------------------- +// Page Qlik (contexte isolé + équivalents Playwright) +// --------------------------------------------------------------------------- + +/// Filtre/format d'une ligne de console de la page : `None` = ignorée. +pub type FiltreConsole = Box Option + Send + Sync>; + +/// Page Chromium dans un contexte de navigation isolé (≈ `browser.newContext()`). +pub struct PageQlik { + browser: Arc, + contexte: Option, + pub page: Page, + taches: Vec>, +} + +/// Texte d'un argument de console, comme `msg.text()` de Playwright. +fn texte_remote(o: &RemoteObject) -> String { + match &o.value { + Some(Value::String(s)) => s.clone(), + Some(v) if !v.is_null() => v.to_string(), + _ => o + .unserializable_value + .as_ref() + .map(|u| u.inner().to_string()) + .or_else(|| o.description.clone()) + .unwrap_or_else(|| format!("JSHandle@{:?}", o.r#type).to_lowercase()), + } +} + +impl PageQlik { + /// Ouvre une page vierge dans un nouveau contexte isolé et branche la + /// console (`filtre_console`) et les erreurs de page (`prefixe_erreur`). + pub async fn ouvrir(browser: Arc, filtre_console: FiltreConsole, prefixe_erreur: Option<&'static str>) -> anyhow::Result { + let contexte = browser.create_browser_context(CreateBrowserContextParams::default()).await.context("[qlik] création du contexte Chromium")?; + let params = CreateTargetParams::builder() + .url("about:blank") + .browser_context_id(contexte.clone()) + .build() + .map_err(|e| anyhow!("[qlik] paramètres de page : {e}"))?; + let page = match tokio::time::timeout(Duration::from_secs(60), browser.new_page(params)).await { + Ok(Ok(p)) => p, + Ok(Err(e)) => { + let _ = browser.dispose_browser_context(contexte).await; + return Err(anyhow!("[qlik] ouverture de page : {e}")); + } + Err(_) => { + let _ = browser.dispose_browser_context(contexte).await; + return Err(anyhow!("[qlik] ouverture de page : délai dépassé")); + } + }; + let mut moi = Self { browser, contexte: Some(contexte), page, taches: Vec::new() }; + + let mut console = moi.page.event_listener::().await?; + moi.taches.push(tokio::spawn(async move { + while let Some(evt) = console.next().await { + let texte = evt.args.iter().map(texte_remote).collect::>().join(" "); + if let Some(ligne) = filtre_console(&texte) { + tracing::info!("{ligne}"); + } + } + })); + if let Some(prefixe) = prefixe_erreur { + let mut erreurs = moi.page.event_listener::().await?; + moi.taches.push(tokio::spawn(async move { + while let Some(evt) = erreurs.next().await { + let d = &evt.exception_details; + let message = d.exception.as_ref().and_then(|e| e.description.clone()).unwrap_or_else(|| d.text.clone()); + tracing::info!("{prefixe} {message}"); + } + })); + } + Ok(moi) + } + + /// `ctx.addCookies([{ name, value, domain, path: "/", secure: true }])`. + pub async fn ajouter_cookie(&self, nom: &str, valeur: &str, domaine: &str) -> anyhow::Result<()> { + let cookie = CookieParam::builder() + .name(nom) + .value(valeur) + .domain(domaine) + .path("/") + .secure(true) + .build() + .map_err(|e| anyhow!("[qlik] cookie invalide : {e}"))?; + self.page.execute(SetCookiesParams::new(vec![cookie])).await.context("[qlik] Network.setCookies")?; + Ok(()) + } + + /// `httpCredentials` : identifiants fournis aux défis HTTP (NTLM / Basic) + /// que le navigateur rencontre. + pub async fn identifiants_http(&self, user: &str, password: &str) -> anyhow::Result<()> { + self.page.authenticate(chromiumoxide::auth::Credentials { username: user.to_string(), password: password.to_string() }).await?; + Ok(()) + } + + /// Surveille les websockets ouverts par la page (`page.on("websocket")`) et + /// publie la première valeur de `qlik-csrf-token` trouvée dans leur URL. + pub async fn capturer_jeton_csrf(&mut self) -> anyhow::Result>> { + let (tx, rx) = watch::channel(None); + let mut ws = self.page.event_listener::().await?; + let re = regex::Regex::new(r"qlik-csrf-token=([^&]+)").expect("regex"); + self.taches.push(tokio::spawn(async move { + while let Some(evt) = ws.next().await { + if tx.borrow().is_some() { + continue; + } + if let Some(c) = re.captures(&evt.url) { + let jeton = urlencoding::decode(&c[1]).map(|s| s.into_owned()).unwrap_or_else(|_| c[1].to_string()); + let _ = tx.send(Some(jeton)); + } + } + })); + Ok(rx) + } + + /// `page.exposeFunction(nom, …)` : `window[nom](payload)` transmet + /// `JSON.stringify(payload)` côté Rust (canal renvoyé) et se résout à `true`. + pub async fn exposer_fonction(&mut self, nom: &str) -> anyhow::Result> { + let liaison = format!("__cf_binding_{nom}"); + let (tx, rx) = mpsc::unbounded_channel(); + let mut appels = self.page.event_listener::().await?; + let attendu = liaison.clone(); + self.taches.push(tokio::spawn(async move { + while let Some(evt) = appels.next().await { + if evt.name == attendu { + let _ = tx.send(evt.payload.clone()); + } + } + })); + self.page.execute(AddBindingParams::new(liaison.clone())).await.context("[qlik] Runtime.addBinding")?; + let nom_js = serde_json::to_string(nom)?; + let liaison_js = serde_json::to_string(&liaison)?; + let shim = format!( + "(() => {{ const n = {nom_js}, b = {liaison_js}; window[n] = (p) => {{ const f = window[b]; \ + if (typeof f !== 'function') return Promise.reject(new Error('liaison ' + b + ' absente')); \ + f(JSON.stringify(p === undefined ? null : p)); return Promise.resolve(true); }}; }})()" + ); + // Documents futurs (navigations) puis document courant. + self.page.execute(AddScriptToEvaluateOnNewDocumentParams::new(shim.clone())).await?; + let _ = self.page.execute(EvaluateParams::new(shim)).await; + Ok(rx) + } + + /// `page.goto(url, { waitUntil: "domcontentloaded", timeout })`. + pub async fn aller(&self, url: &str, timeout: Duration) -> anyhow::Result<()> { + let mut dcl = self.page.event_listener::().await?; + let navigation = async { + let res = self.page.execute(NavigateParams::new(url)).await.context("[qlik] Page.navigate")?; + if let Some(err) = res.result.error_text.as_ref().filter(|e| !e.is_empty()) { + return Err(anyhow!("[qlik] navigation {url} : {err}")); + } + dcl.next().await; + Ok(()) + }; + tokio::time::timeout(timeout, navigation) + .await + .map_err(|_| anyhow!("[qlik] navigation {url} : délai de {} ms dépassé", timeout.as_millis()))? + } + + /// `page.evaluate(fn, args)` : `source` est le texte d'une fonction JS + /// (éventuellement async), appelée avec `args` ; la promesse est attendue et + /// le résultat rendu en JSON (`JSON.stringify` côté page, donc NaN → null). + pub async fn evaluer(&self, source: &str, args: &Value) -> anyhow::Result { + let expression = format!( + "(async () => {{ const __f = (\n{source}\n); const __r = await __f({}); return JSON.stringify(__r === undefined ? null : __r); }})()", + serde_json::to_string(args)? + ); + let params = EvaluateParams::builder() + .expression(expression) + .await_promise(true) + .return_by_value(true) + .build() + .map_err(|e| anyhow!("[qlik] paramètres d'évaluation : {e}"))?; + let res = self.page.execute(params).await.map_err(|e| anyhow!("{e}"))?; + if let Some(ex) = res.result.exception_details.as_ref() { + let message = ex.exception.as_ref().and_then(|e| e.description.clone()).unwrap_or_else(|| ex.text.clone()); + return Err(anyhow!("{message}")); + } + match &res.result.result.value { + Some(Value::String(s)) => Ok(serde_json::from_str(s)?), + Some(v) => Ok(v.clone()), + None => Ok(Value::Null), + } + } + + /// Ferme la page puis le contexte (`ctx.close()`). + pub async fn fermer(mut self) { + for t in self.taches.drain(..) { + t.abort(); + } + let _ = tokio::time::timeout(Duration::from_secs(10), self.page.clone().close()).await; + if let Some(c) = self.contexte.take() { + let _ = tokio::time::timeout(Duration::from_secs(10), self.browser.dispose_browser_context(c)).await; + } + } +} + +impl Drop for PageQlik { + fn drop(&mut self) { + for t in self.taches.drain(..) { + t.abort(); + } + } +} diff --git a/server/src/qlik/client.rs b/server/src/qlik/client.rs new file mode 100644 index 0000000..ff391eb --- /dev/null +++ b/server/src/qlik/client.rs @@ -0,0 +1,503 @@ +//! Client Qlik Sense — configuration, types partagés et authentification +//! (port de `src/lib/qlik-client.ts`). +//! +//! Récupère par produit (clé = code centrale via la dimension "Article Code") : +//! - CA réseau, Qté vendue réseau, Nb magasins travaillant le produit. +//! +//! Auth = NTLM (Qlik Sense Enterprise on Windows) via flux ticket SSO : +//! 1. GET /hub/ (non authentifié) -> 302 vers le proxy forms, on extrait le targetId +//! 2. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX +//! 3. GET /hub/?qlikTicket=XXX -> Qlik pose le cookie X-Qlik-Session +//! Le cookie + Xrfkey servent ensuite pour QRS (REST) et l'Engine API (websocket). +//! +//! L'extraction passe par Chromium (`playwright.rs`) : le proxy Qlik refuse un +//! websocket « raw » côté serveur (403). Le `fetchNetworkMetrics` par websocket +//! direct du TS n'était appelé nulle part (la Grille a sa propre fonction +//! homonyme, qui lit le cache) : il n'est pas porté. +//! +//! Tout est paramétrable par variables d'environnement (défauts = discovery 2026-06-20). + +use super::jsnum; +use super::ntlm; +use anyhow::{anyhow, bail, Context}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Config — défauts issus de la discovery +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikConfig { + pub host: String, + pub user: String, + #[serde(skip_serializing)] + pub password: String, + /// Vide par défaut (NTLM sans domaine, comme requests_ntlm). + pub domain: String, + pub workstation: String, + /// GUID app "Magasins Vision Consolidée" (article x réseau). + pub app_network: String, + /// Master dimension "Article Code". + pub dim_code_article_id: String, + /// Master measure "CA N". + pub meas_ca_id: String, + /// Master measure "Quantité N". + pub meas_qte_id: String, + /// Master measure "Magasin Ventes Nb N". + pub meas_nb_mag_id: String, + /// Master measure "CA par Magasin N". + pub meas_ca_mag_id: String, + /// Master measure "Marge % N". + pub meas_marge_pct_id: String, + pub tls_insecure: bool, + pub timeout_ms: u64, +} + +/// `process.env.X || défaut` (une valeur vide compte comme absente). +fn env_ou(nom: &str) -> Option { + std::env::var(nom).ok().filter(|v| !v.is_empty()) +} + +/// `process.env.X ?? défaut` (une valeur vide est conservée). +fn env_nul(nom: &str, defaut: &str) -> String { + std::env::var(nom).unwrap_or_else(|_| defaut.to_string()) +} + +/// Priorité : réglages UI (`data/.db-config.json`) > env > défaut. +pub fn get_qlik_config() -> QlikConfig { + let f = crate::config::read_config(); + let fichier = |v: Option| v.filter(|s| !s.is_empty()); + let timeout = super::date_range::js_number(&env_nul("QLIK_TIMEOUT_MS", "60000")); + QlikConfig { + host: fichier(f.qlik_host).or_else(|| env_ou("QLIK_HOST")).unwrap_or_else(|| "reporting-magasins.lafoirfouille.fr".into()), + user: fichier(f.qlik_user).or_else(|| env_ou("QLIK_USER")).unwrap_or_else(|| "FFSCH".into()), + password: fichier(f.qlik_password).or_else(|| env_ou("QLIK_PWD")).unwrap_or_default(), + domain: env_nul("QLIK_DOMAIN", ""), + workstation: env_nul("QLIK_WORKSTATION", ""), + app_network: env_nul("QLIK_APP_NETWORK", "9872ee6e-d64a-4b43-984a-076bf1f7f647"), + // qIds engine (master items de l'app "Magasins Vision Consolidée") + dim_code_article_id: env_nul("QLIK_DIM_CODE_ARTICLE_ID", "yesCP"), + meas_ca_id: env_nul("QLIK_MEAS_CA_ID", "JhqJ"), + meas_qte_id: env_nul("QLIK_MEAS_QTE_ID", "41516861-5997-4635-8187-3643a2bde422"), + meas_nb_mag_id: env_nul("QLIK_MEAS_NBMAG_ID", "yNBLjc"), + // Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine + // les refuse (qId court attendu), override via env après discovery. + meas_ca_mag_id: env_nul("QLIK_MEAS_CAMAG_ID", "16778c00-a4e0-41c7-ab1c-179810d78172"), + meas_marge_pct_id: env_nul("QLIK_MEAS_MARGEPCT_ID", "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222"), + tls_insecure: env_nul("QLIK_TLS_INSECURE", "true") == "true", + // `Number(...)` invalide donnait NaN côté TS : on retombe sur le défaut. + timeout_ms: if timeout.is_finite() && timeout > 0.0 { timeout as u64 } else { 60_000 }, + } +} + +// --------------------------------------------------------------------------- +// Types partagés +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkMetric { + pub code_centrale: String, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub nb_magasins_reseau: f64, + /// CA moyen par magasin (productivité normalisée présence) + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_par_magasin_reseau: f64, + /// Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub marge_pct_reseau: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub periode: Option, + /// Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_map", deserialize_with = "jsnum::de_map")] + pub qte_by_month: Option>, + /// Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. + /// + /// Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois). + /// Lors d'une synchronisation datée validée, les 12 mois complets sont + /// toujours présents ; un mois sans fait est explicitement représenté à 0. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics_by_month: Option>, + /// Libellé de l'article côté Qlik. Renseigné uniquement par la recherche + /// produit, qui interroge la dimension libellé ; la sync fournisseur ne le + /// connaît pas et le laisse absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub libelle_reseau: Option, + /// Fournisseur de l'article côté Qlik, même origine que `libelle_reseau`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fournisseur_reseau: Option, +} + +/// Les mesures réseau Qlik pour un produit sur un mois donné. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikMonthMetrics { + /// Quantité vendue réseau — toujours présente. + /// Absente du JSON (objet mensuel incomplet) : 0, comme `fini(undefined)` côté TS. + #[serde(default, serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte: f64, + /// CA réseau du mois. + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub ca: Option, + /// Nombre de magasins ayant vendu le produit ce mois-là. + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub nb_mag: Option, + /// CA moyen par magasin sur le mois (mesure Qlik brute). + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub ca_mag: Option, + /// Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub marge_pct: Option, +} + +/// Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau). +/// +/// Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik +/// du réseau, pas de notre catalogue local. Ils peuvent donc être totalement +/// inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkProduct { + #[serde(flatten)] + pub metric: NetworkMetric, + /// Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). + pub libelle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fournisseur: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub famille: Option, +} + +/// Clé anti-CSRF Qlik : 16 caractères alphanumériques. +pub fn make_xrfkey() -> String { + let octets: [u8; 12] = rand::random(); + let mut k: String = base64::engine::general_purpose::STANDARD.encode(octets).chars().filter(|c| c.is_ascii_alphanumeric()).take(16).collect(); + while k.len() < 16 { + k.push('0'); + } + k +} + +// --------------------------------------------------------------------------- +// 1. Auth NTLM -> ticket SSO -> cookie de session +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct QlikSession { + /// `X-Qlik-Session=…` (paires `nom=valeur` jointes par `; `). + pub cookie: String, + pub xrfkey: String, +} + +/// Réponse HTTP minimale (statut + en-têtes) d'un échange NTLM. +struct ReponseNtlm { + status: u16, + headers: hyper::HeaderMap, +} + +/// Vérificateur TLS qui accepte tout certificat (`tlsInsecure`, défaut true : +/// le serveur Qlik interne présente un certificat non reconnu). +#[derive(Debug)] +struct SansVerification(Arc); + +impl rustls::client::danger::ServerCertVerifier for SansVerification { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +fn config_tls(insecure: bool) -> rustls::ClientConfig { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = + rustls::ClientConfig::builder_with_provider(provider.clone()).with_safe_default_protocol_versions().expect("versions TLS par défaut"); + let mut cfg = if insecure { + builder.dangerous().with_custom_certificate_verifier(Arc::new(SansVerification(provider))).with_no_client_auth() + } else { + let roots = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() }; + builder.with_root_certificates(roots).with_no_client_auth() + }; + // HTTP/1.1 uniquement : NTLM authentifie la CONNEXION, pas la requête. + cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + cfg +} + +/// Connexion HTTP/1.1 dédiée (TLS si https) : le défi NTLM type 2 n'est valable +/// que sur la connexion TCP qui l'a reçu, le type 3 doit donc partir sur la même. +/// (`httpntlm` utilisait pour cela un agent keep-alive.) +async fn connexion_dediee( + url: &reqwest::Url, + cfg: &QlikConfig, +) -> anyhow::Result>> { + let host = url.host_str().ok_or_else(|| anyhow!("[qlik] URL sans hôte : {url}"))?.to_string(); + let port = url.port_or_known_default().unwrap_or(443); + let tcp = tokio::net::TcpStream::connect((host.as_str(), port)).await.with_context(|| format!("[qlik] connexion {host}:{port}"))?; + let _ = tcp.set_nodelay(true); + macro_rules! demarrer { + ($io:expr) => {{ + let (sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new($io)).await?; + tokio::spawn(async move { + let _ = conn.await; + }); + Ok(sender) + }}; + } + if url.scheme() == "https" { + let connector = tokio_rustls::TlsConnector::from(Arc::new(config_tls(cfg.tls_insecure))); + let nom = rustls::pki_types::ServerName::try_from(host.clone()).map_err(|e| anyhow!("[qlik] nom d'hôte invalide {host} : {e}"))?; + let tls = connector.connect(nom, tcp).await.with_context(|| format!("[qlik] TLS {host}"))?; + demarrer!(tls) + } else { + demarrer!(tcp) + } +} + +async fn envoyer( + sender: &mut hyper::client::conn::http1::SendRequest>, + url: &reqwest::Url, + entetes: &[(&str, String)], +) -> anyhow::Result { + use http_body_util::BodyExt; + sender.ready().await?; + let chemin = match url.query() { + Some(q) => format!("{}?{q}", url.path()), + None => url.path().to_string(), + }; + let hote = match url.port() { + Some(p) => format!("{}:{p}", url.host_str().unwrap_or_default()), + None => url.host_str().unwrap_or_default().to_string(), + }; + let mut req = hyper::Request::builder().method("GET").uri(chemin).header("Host", hote); + for (k, v) in entetes { + req = req.header(*k, v.as_str()); + } + let res = sender.send_request(req.body(http_body_util::Empty::new())?).await?; + let status = res.status().as_u16(); + let headers = res.headers().clone(); + // Corps lu en entier : la connexion n'est réutilisable qu'ensuite. + let _ = res.into_body().collect().await; + Ok(ReponseNtlm { status, headers }) +} + +/// Équivalent de `httpntlm.get` : type 1 (keep-alive) → type 2 → type 3, sur +/// une seule connexion. Une redirection reçue en réponse au type 1 est suivie +/// (nouvelle négociation sur la nouvelle URL), comme dans `httpntlm`. +async fn ntlm_get(cfg: &QlikConfig, url: &str) -> anyhow::Result { + let mut url = reqwest::Url::parse(url).with_context(|| format!("[qlik] URL invalide {url}"))?; + let timeout = Duration::from_millis(cfg.timeout_ms); + for _ in 0..10 { + let mut sender = + tokio::time::timeout(timeout, connexion_dediee(&url, cfg)).await.map_err(|_| anyhow!("[qlik] délai dépassé (connexion NTLM)"))??; + // Type 1 : seuls Connection et Authorization (httpntlm ne transmet les + // autres en-têtes qu'au dernier appel, où le serveur les consomme). + let type1 = ntlm::create_type1_message(&cfg.domain, &cfg.workstation); + let r1 = tokio::time::timeout(timeout, envoyer(&mut sender, &url, &[("Connection", "keep-alive".into()), ("Authorization", type1)])) + .await + .map_err(|_| anyhow!("[qlik] délai dépassé (NTLM type 1)"))??; + if let Some(loc) = r1.headers.get("location").and_then(|v| v.to_str().ok()) { + url = url.join(loc).with_context(|| format!("[qlik] redirection invalide {loc}"))?; + continue; + } + let defis: Vec = r1.headers.get_all("www-authenticate").iter().filter_map(|v| v.to_str().ok().map(String::from)).collect(); + if defis.is_empty() { + bail!("www-authenticate not found on response of second request"); + } + let defi = defis.iter().find(|d| d.contains("NTLM ")).cloned().unwrap_or_else(|| defis.join(", ")); + let msg2 = ntlm::parse_type2_message(&defi)?; + let type3 = ntlm::create_type3_message(&msg2, &cfg.user, &cfg.password, &cfg.domain, &cfg.workstation); + let entetes = [ + ("Connection", "Close".to_string()), + ("Authorization", type3), + ("User-Agent", "Mozilla/5.0 CollectFlow".to_string()), + ("X-Qlik-Xrfkey", String::new()), + ]; + return tokio::time::timeout(timeout, envoyer(&mut sender, &url, &entetes)) + .await + .map_err(|_| anyhow!("[qlik] délai dépassé (NTLM type 3)"))?; + } + bail!("[qlik] trop de redirections pendant l'authentification NTLM") +} + +fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option { + headers + .get_all("set-cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .map(|c| c.split(';').next().unwrap_or("").to_string()) + .find(|c| c.to_ascii_lowercase().contains("x-qlik-session")) +} + +/// Client HTTP sans redirection automatique (`redirect: "manual"`). +fn client_manuel(cfg: &QlikConfig) -> anyhow::Result { + Ok(reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .danger_accept_invalid_certs(cfg.tls_insecure) + .timeout(Duration::from_millis(cfg.timeout_ms)) + .build()?) +} + +/// Ouvre une session Qlik : cookie `X-Qlik-Session` + xrfkey. +pub async fn qlik_ntlm_session(cfg: &QlikConfig) -> anyhow::Result { + if cfg.user.is_empty() || cfg.password.is_empty() { + bail!("[qlik] QLIK_USER / QLIK_PWD manquants dans l'environnement"); + } + let xrfkey = make_xrfkey(); + let http = client_manuel(cfg)?; + + // 1. targetId + let hub = http.get(format!("https://{}/hub/", cfg.host)).send().await?; + let loc1 = hub.headers().get("location").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + let target_id = regex::Regex::new(r"(?i)targetId=([0-9a-f-]+)").expect("regex").captures(&loc1).map(|c| c[1].to_string()).unwrap_or_default(); + + // 2. NTLM -> ticket. Interne (réseau corp), /hub/ peut déjà renvoyer 401 NTLM + // et poser le cookie directement ; on gère les 2 cas. + let win_path = if !target_id.is_empty() { + format!("/internal_windows_authentication/?targetId={target_id}&xrfkey={xrfkey}") + } else { + format!("/hub/?xrfkey={xrfkey}") + }; + let ntlm = ntlm_get(cfg, &format!("https://{}{win_path}", cfg.host)).await?; + + // Cookie posé directement (cas interne) ? + if let Some(cookie) = extract_session_cookie(&ntlm.headers) { + return Ok(QlikSession { cookie, xrfkey }); + } + + // 3. Échange ticket -> cookie + let ticket_loc = ntlm.headers.get("location").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + if !ticket_loc.contains("qlikTicket=") { + bail!("[qlik] Auth NTLM sans ticket (HTTP {}) — verifier mot de passe", ntlm.status); + } + let ticket_url = reqwest::Url::parse(&format!("https://{}/", cfg.host))?.join(&ticket_loc)?; + let exch = http.get(ticket_url).send().await?; + let cookie = exch + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .map(|c| c.split(';').next().unwrap_or("").to_string()) + .filter(|c| c.to_ascii_lowercase().contains("x-qlik-session")) + .collect::>() + .join("; "); + if cookie.is_empty() { + bail!("[qlik] ticket non echange contre un cookie de session"); + } + Ok(QlikSession { cookie, xrfkey }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xrfkey_16_alphanumeriques() { + for _ in 0..50 { + let k = make_xrfkey(); + assert_eq!(k.len(), 16); + assert!(k.chars().all(|c| c.is_ascii_alphanumeric())); + } + } + + #[test] + fn json_identique_au_ts() { + let mut m = + NetworkMetric { code_centrale: "10000167303".into(), ca_reseau: 1234.5, qte_reseau: 12.0, nb_magasins_reseau: 3.0, ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert_eq!( + v.to_string(), + r#"{"codeCentrale":"10000167303","caReseau":1234.5,"qteReseau":12,"nbMagasinsReseau":3,"caParMagasinReseau":0,"margePctReseau":0}"# + ); + m.metrics_by_month = Some(BTreeMap::from([("2026-01".to_string(), QlikMonthMetrics { qte: 2.0, nb_mag: Some(1.0), ..Default::default() })])); + m.qte_by_month = Some(BTreeMap::from([("2026-01".to_string(), 2.0)])); + m.periode = Some("2025-02_2026-01".into()); + let texte = serde_json::to_string(&m).unwrap(); + assert!(texte.contains(r#""qteByMonth":{"2026-01":2}"#), "{texte}"); + assert!(texte.contains(r#""metricsByMonth":{"2026-01":{"qte":2,"nbMag":1}}"#), "{texte}"); + let relu: NetworkMetric = serde_json::from_str(&texte).unwrap(); + assert_eq!(relu.metrics_by_month, m.metrics_by_month); + } + + /// Lit une requête HTTP/1.1 (en-têtes seuls, pas de corps en GET). + async fn lire_requete(flux: &mut tokio::net::TcpStream) -> String { + use tokio::io::AsyncReadExt; + let mut buf = Vec::new(); + let mut octet = [0u8; 1024]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + let n = flux.read(&mut octet).await.unwrap(); + if n == 0 { + break; + } + buf.extend_from_slice(&octet[..n]); + } + String::from_utf8_lossy(&buf).to_string() + } + + /// Faux serveur NTLM : le type 3 DOIT arriver sur la connexion du type 1. + #[tokio::test] + async fn echange_ntlm_sur_une_seule_connexion() { + use tokio::io::AsyncWriteExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let serveur = tokio::spawn(async move { + let (mut c, _) = listener.accept().await.unwrap(); + let r1 = lire_requete(&mut c).await; + assert!(r1.starts_with("GET /internal_windows_authentication/?targetId=abc HTTP/1.1"), "{r1}"); + assert!(r1.contains("authorization: NTLM TlRMTVNTUAABAAAAB6IIogAAAAAoAAAAAAAAACgAAAAFASgKAAAADw=="), "{r1}"); + assert!(!r1.to_lowercase().contains("user-agent"), "{r1}"); + let type2 = "TlRMTVNTUAACAAAADgAOADAAAAAVgoniASNFZ4mrze8AAAAAAAAAACQAJAA+AAAARABPAE0AQQBJAE4ARQACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAA="; + let rep = format!("HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Negotiate\r\nWWW-Authenticate: NTLM {type2}\r\nContent-Length: 5\r\nConnection: keep-alive\r\n\r\nnon !"); + c.write_all(rep.as_bytes()).await.unwrap(); + let r2 = lire_requete(&mut c).await; + assert!(r2.contains("authorization: NTLM TlRMTVNTUAADAAAA"), "{r2}"); + assert!(r2.contains("user-agent: Mozilla/5.0 CollectFlow"), "{r2}"); + assert!(r2.contains("x-qlik-xrfkey: \r\n"), "{r2}"); + assert!(r2.contains("connection: Close"), "{r2}"); + let rep = "HTTP/1.1 302 Found\r\nLocation: https://qlik.local/hub/?qlikTicket=TICKET\r\nSet-Cookie: X-Qlik-Session=s3ss10n; Path=/; HttpOnly\r\nContent-Length: 0\r\n\r\n"; + c.write_all(rep.as_bytes()).await.unwrap(); + }); + let mut cfg = get_qlik_config(); + cfg.user = "FFSCH".into(); + cfg.password = "Mot2Passe!".into(); + cfg.domain = String::new(); + cfg.workstation = String::new(); + let r = ntlm_get(&cfg, &format!("http://127.0.0.1:{port}/internal_windows_authentication/?targetId=abc")).await.unwrap(); + serveur.await.unwrap(); + assert_eq!(r.status, 302); + assert_eq!(r.headers.get("location").unwrap(), "https://qlik.local/hub/?qlikTicket=TICKET"); + assert_eq!(extract_session_cookie(&r.headers).as_deref(), Some("X-Qlik-Session=s3ss10n")); + } +} diff --git a/server/src/qlik/date_range.rs b/server/src/qlik/date_range.rs new file mode 100644 index 0000000..3d805f5 --- /dev/null +++ b/server/src/qlik/date_range.rs @@ -0,0 +1,460 @@ +//! Helper de fenêtre temporelle pour Qlik réseau (port de `src/lib/qlik-date-range.ts`). +//! +//! Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille : +//! - `months_back` mois complets glissants (défaut : 12) +//! - exclut systématiquement le mois courant +//! +//! Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts` +//! (même source que la grille). Si la source officielle change, mettre à jour +//! ici aussi. +//! +//! Qlik référence les dates par un sérial numérique (jours écoulés depuis +//! l'epoch Qlik 1899-12-30). Repères validés : +//! 2025-01-01 → 45658 +//! 2025-06-19 → 45827 +//! 2026-01-01 → 46023 +//! 2026-06-19 → 46192 +//! +//! Le filtre côté Qlik s'applique via une sélection de champ `Date` : +//! - valeurs numériques = serials Qlik +//! - la sélection par défaut (`{}`) est héritée par toutes +//! les master measures qui référencent le champ `Date`. +//! +//! Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ? +//! - les master items sont référencés par `qLibraryId` (réutilisation sans +//! devoir dupliquer l'expression) +//! - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX +//! - sélectionner toutes les dates de la fenêtre via `SelectValues` revient +//! fonctionnellement à `{=…<=…"}>}` pour les mesures qui touchent +//! ce champ, sans dépendre de l'expression interne des master items +//! +//! Pas de secrets ici. Fonctions pures, testées par `cargo test`. + +use chrono::{Datelike, Duration, NaiveDate}; +use serde::{Deserialize, Serialize}; + +/// Bornes acceptées pour `months_back`. On reste sur 1..12 pour ne pas casser +/// la sémantique "12 mois glissants alignés sur la grille" : une valeur plus +/// grande n'apporterait pas de signal réseau supplémentaire (la grille +/// n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait +/// vide (mois courant exclu) ou incohérente. +pub const QLIK_MONTHS_BACK_MIN: i64 = 1; +pub const QLIK_MONTHS_BACK_MAX: i64 = 12; +pub const QLIK_MONTHS_BACK_DEFAULT: i64 = 12; + +/// Conversion d'une chaîne en nombre avec la sémantique de `Number(s)` en JS : +/// espaces ignorés, chaîne vide → 0, préfixes `0x` / `0o` / `0b`, sinon NaN. +pub fn js_number(raw: &str) -> f64 { + let s = raw.trim(); + if s.is_empty() { + return 0.0; + } + let lower = s.to_ascii_lowercase(); + for (prefixe, base) in [("0x", 16), ("0o", 8), ("0b", 2)] { + if let Some(reste) = lower.strip_prefix(prefixe) { + return u64::from_str_radix(reste, base).map(|v| v as f64).unwrap_or(f64::NAN); + } + } + match s { + "Infinity" | "+Infinity" => return f64::INFINITY, + "-Infinity" => return f64::NEG_INFINITY, + _ => {} + } + // Rust accepte « inf », « nan »… que JS refuse : on les écarte. + if lower.contains("inf") || lower.contains("nan") { + return f64::NAN; + } + s.parse::().unwrap_or(f64::NAN) +} + +/// Lit la variable d'environnement `name` et renvoie un entier borné entre +/// [`QLIK_MONTHS_BACK_MIN`] et [`QLIK_MONTHS_BACK_MAX`]. Renvoie `default_value` +/// si la variable est absente / vide / non numérique. +pub fn env_months_back(name: &str, default_value: i64) -> i64 { + months_back_from(std::env::var(name).ok().as_deref(), default_value) +} + +/// Cœur testable de [`env_months_back`]. +pub fn months_back_from(raw: Option<&str>, default_value: i64) -> i64 { + let Some(raw) = raw else { return default_value }; + if raw.trim().is_empty() { + return default_value; + } + let parsed = js_number(raw); + if !parsed.is_finite() { + return default_value; + } + (parsed.trunc() as i64).clamp(QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX) +} + +/// Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). +fn qlik_epoch() -> NaiveDate { + NaiveDate::from_ymd_opt(1899, 12, 30).expect("epoch Qlik") +} + +/// Repères de validation. +pub const QLIK_DATE_ANCHORS: [(&str, i64); 4] = [("2025-01-01", 45658), ("2025-06-19", 45827), ("2026-01-01", 46023), ("2026-06-19", 46192)]; + +fn parse_iso(iso_date: &str) -> Option<(i32, u32, u32)> { + let b = iso_date.as_bytes(); + if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { + return None; + } + let chiffres = |r: std::ops::Range| -> Option { + let part = &iso_date[r]; + if part.bytes().all(|c| c.is_ascii_digit()) { + part.parse().ok() + } else { + None + } + }; + Some((chiffres(0..4)? as i32, chiffres(5..7)?, chiffres(8..10)?)) +} + +/// Jours depuis l'epoch Qlik pour une date (y, m, d), avec le débordement de +/// `Date.UTC` (mois 13 → janvier suivant, jour 32 → mois suivant…). +fn serial_ymd(y: i32, mo: u32, d: u32) -> i64 { + let base = NaiveDate::from_ymd_opt(y, 1, 1).expect("année valide"); + let premier_du_mois = base.checked_add_months(chrono::Months::new(mo.saturating_sub(1))).unwrap_or(base); + let date = premier_du_mois + Duration::days(d as i64 - 1); + // Date.UTC(y, -1, …) n'arrive pas ici : le format impose deux chiffres ≥ 00. + let date = if mo == 0 { date - chrono::Months::new(1) } else { date }; + (date - qlik_epoch()).num_days() +} + +/// Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique. +/// Inverse documenté : Qlik epoch = 1899-12-30 (jour 0). +/// +/// Erreur si l'entrée n'est pas au format `YYYY-MM-DD`. +pub fn iso_date_to_qlik_serial(iso_date: &str) -> anyhow::Result { + let Some((y, mo, d)) = parse_iso(iso_date) else { + anyhow::bail!("isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu \"{iso_date}\""); + }; + Ok(serial_ymd(y, mo, d)) +} + +/// Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics). +pub fn qlik_serial_to_iso_date(serial: i64) -> String { + (qlik_epoch() + Duration::days(serial)).format("%Y-%m-%d").to_string() +} + +/// Sérial Qlik → entier `AAAAMMJJ` (format de `Date_Key` sur certains modèles). +pub fn qlik_serial_to_ymd(serial: i64) -> i64 { + let d = qlik_epoch() + Duration::days(serial); + d.year() as i64 * 10000 + d.month() as i64 * 100 + d.day() as i64 +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikDateFilter { + /// Date ISO du 1er jour de la fenêtre (inclus). + pub date_debut: String, + /// Date ISO du dernier jour de la fenêtre (inclus). + pub date_fin: String, + /// Sérial Qlik du 1er jour (= `Date >= …`). + pub q_start: i64, + /// Sérial Qlik du dernier jour (= `Date <= …`). + pub q_end: i64, + /// Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. + pub label: String, + /// Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. + pub daily_serials: Vec, + /// Expression set analysis prête à intégrer dans un `qDef` de mesure. + pub set_analysis: String, +} + +/// Dernier jour du mois (m: 1..12). +fn last_day_of_month(y: i32, m: u32) -> u32 { + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + NaiveDate::from_ymd_opt(ny, nm, 1).expect("mois valide").pred_opt().expect("veille").day() +} + +/// Calcule la fenêtre `months_back` mois complets (excluant le mois courant). +/// +/// `now` est la date LOCALE du jour (`chrono::Local::now().date_naive()`) : les +/// bornes sont des dates calendaires Qlik, jamais converties en UTC — minuit +/// local en Europe/Paris devenait la veille en UTC et transformait par exemple +/// 2025-07-01 en 2025-06-30, soit une extraction de 13 mois. +/// +/// Bornes : `months_back` est ramené dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]` +/// (1..12) sans erreur — on veut que la sync reste possible même si l'env est +/// mal configuré. +pub fn build_grid_network_qlik_date_filter(now: NaiveDate, months_back: i64) -> QlikDateFilter { + let months = months_back.clamp(QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX) as u32; + let premier_du_mois = NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("date valide"); + // Fin : dernier jour du mois précédent le mois courant + let end_month = premier_du_mois.pred_opt().expect("veille"); + // Début : `months` mois avant le mois courant (1er du mois) + let start_month = premier_du_mois - chrono::Months::new(months); + + let date_debut = start_month.format("%Y-%m-%d").to_string(); + let date_fin = end_month.format("%Y-%m-%d").to_string(); + let q_start = (start_month - qlik_epoch()).num_days(); + let q_end = (end_month - qlik_epoch()).num_days(); + // qEnd < qStart est impossible ici (au moins un mois complet) : le TS levait + // une exception pour ce cas, inatteignable avec des bornes ramenées à 1..12. + let daily_serials: Vec = (q_start..=q_end).collect(); + let label = format!("{}_{}", &date_debut[..7], &date_fin[..7]); + let set_analysis = format!("Date={{\">={q_start}<={q_end}\"}}"); + QlikDateFilter { date_debut, date_fin, q_start, q_end, label, daily_serials, set_analysis } +} + +/// Fenêtre du jour (date locale) — `buildGridNetworkQlikDateFilter()` sans argument. +pub fn build_grid_network_qlik_date_filter_now(months_back: i64) -> QlikDateFilter { + build_grid_network_qlik_date_filter(chrono::Local::now().date_naive(), months_back) +} + +/// Découpe les serials en lots pour appels `SelectValues` paginés. +/// Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête). +pub fn chunk_serials(serials: &[i64], batch_size: usize) -> Vec> { + serials.chunks(batch_size.max(1)).map(|c| c.to_vec()).collect() +} + +/// Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires +/// **inclusives et continues**. +/// +/// But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N +/// codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe, +/// chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine. +/// +/// Règles : +/// - Le 1er mois commence à `filter.date_debut` (peut être un jour quelconque). +/// - Chaque mois intermédiaire est un mois calendaire **complet**. +/// - Le dernier mois se termine à `filter.date_fin` (tronqué si pas fin de mois). +/// - Les mois sont contigus : fin d'un mois + 1 = début du suivant. +/// - Si début et fin tombent dans le même mois, on renvoie un seul mois. +/// +/// Chaque mois retourné est un `QlikDateFilter` autonome (`label` au format `YYYY-MM`). +pub fn get_month_ranges(filter: &QlikDateFilter) -> Vec { + let start = filter.date_debut.as_str(); + let end = filter.date_fin.as_str(); + let num = |s: &str, r: std::ops::Range| -> i64 { s.get(r).and_then(|x| x.parse().ok()).unwrap_or(0) }; + let sy = num(start, 0..4) as i32; + let sm = num(start, 5..7) as u32; + let ey = num(end, 0..4) as i32; + let em = num(end, 5..7) as u32; + let ed = num(end, 8..10) as u32; + + let mut out = Vec::new(); + let (mut cur_y, mut cur_m) = (sy, sm); + // Sécurité anti-boucle infinie (au cas où) : 100 ans, large. + const MAX_MONTHS: usize = 1200; + let mut safety = 0; + while safety < MAX_MONTHS && (1..=12).contains(&cur_m) { + safety += 1; + let is_first = cur_y == sy && cur_m == sm; + let is_last = cur_y == ey && cur_m == em; + + // Début : si c'est le premier mois, on respecte `date_debut` ; sinon le 1er. + let start_day = if is_first { num(start, 8..10) as u32 } else { 1 }; + let month_start = format!("{cur_y}-{cur_m:02}-{start_day:02}"); + // Fin : si c'est le dernier mois, on respecte `date_fin` ; sinon le dernier jour. + let end_day = if is_last { ed } else { last_day_of_month(cur_y, cur_m) }; + let month_end = format!("{cur_y}-{cur_m:02}-{end_day:02}"); + + let q_start = serial_ymd(cur_y, cur_m, start_day); + let q_end = serial_ymd(cur_y, cur_m, end_day); + let daily_serials: Vec = (q_start..=q_end).collect(); + out.push(QlikDateFilter { + date_debut: month_start, + date_fin: month_end, + q_start, + q_end, + label: format!("{cur_y}-{cur_m:02}"), + daily_serials, + set_analysis: format!("Date={{\">={q_start}<={q_end}\"}}"), + }); + if is_last { + break; + } + // Avance d'un mois calendaire (gère le passage décembre → janvier). + cur_m += 1; + if cur_m > 12 { + cur_m = 1; + cur_y += 1; + } + } + + if out.is_empty() { + // Fenêtre vide : on retourne un seul mois vide aligné sur date_debut. + out.push(QlikDateFilter { + date_debut: filter.date_debut.clone(), + date_fin: filter.date_fin.clone(), + q_start: filter.q_start, + q_end: filter.q_end, + label: start.get(0..7).unwrap_or(start).to_string(), + daily_serials: Vec::new(), + set_analysis: format!("Date={{\">={}<={}\"}}", filter.q_start, filter.q_end), + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() + } + + // [1] + [2] Repères Qlik et aller-retour. + #[test] + fn reperes_qlik() { + for (iso, serial) in QLIK_DATE_ANCHORS { + assert_eq!(iso_date_to_qlik_serial(iso).unwrap(), serial, "{iso}"); + assert_eq!(qlik_serial_to_iso_date(serial), iso); + } + } + + // [3] Fenêtre grille (now=2026-06-21) → 2025-06-01 .. 2026-05-31 + #[test] + fn fenetre_juin_2026() { + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), QLIK_MONTHS_BACK_DEFAULT); + assert_eq!(f.date_debut, "2025-06-01"); + assert_eq!(f.date_fin, "2026-05-31"); + assert_eq!(f.q_start, 45809); + assert_eq!(f.q_end, 46173); + assert_eq!(f.label, "2025-06_2026-05"); + assert_eq!(f.set_analysis, r#"Date={">=45809<=46173"}"#); + assert_eq!(f.daily_serials.len(), 365); + assert_eq!(f.daily_serials[0], 45809); + assert_eq!(f.daily_serials[364], 46173); + } + + // [4] et [5] + #[test] + fn fenetres_mars_et_bissextile() { + let f2 = build_grid_network_qlik_date_filter(d("2026-03-15"), 12); + assert_eq!((f2.date_debut.as_str(), f2.date_fin.as_str()), ("2025-03-01", "2026-02-28")); + assert_eq!((f2.q_start, f2.q_end), (45717, 46081)); + let f3 = build_grid_network_qlik_date_filter(d("2025-01-05"), 12); + assert_eq!((f3.date_debut.as_str(), f3.date_fin.as_str()), ("2024-01-01", "2024-12-31")); + assert_eq!(f3.daily_serials.len(), 366); + } + + // [6] chunkSerials + #[test] + fn decoupe_serials() { + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + let chunks = chunk_serials(&f.daily_serials, 100); + assert_eq!(chunks.len(), 4); + assert_eq!(chunks[3].len(), 65); + assert_eq!(chunks[0][0], 45809); + assert_eq!(chunks[3][64], 46173); + } + + // [7] Robustesse + #[test] + fn formats_invalides() { + for bad in ["2025/01/01", ""] { + let e = iso_date_to_qlik_serial(bad).unwrap_err().to_string(); + assert!(e.contains("YYYY-MM-DD"), "{e}"); + } + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + assert!(!f.label.is_empty()); + assert!(f.set_analysis.starts_with("Date=")); + } + + // [8] getMonthRanges — fenêtre 12 mois + #[test] + fn mois_de_la_fenetre() { + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + let months = get_month_ranges(&f); + assert_eq!(months.len(), 12); + assert_eq!(months[0].label, "2025-06"); + assert_eq!(months[0].date_debut, "2025-06-01"); + assert_eq!(months[0].date_fin, "2025-06-30"); + assert_eq!(months[0].daily_serials.len(), 30); + assert_eq!(months[0].q_start, 45809); + assert_eq!(months[0].q_end, 45838); + assert_eq!(months[11].label, "2026-05"); + assert_eq!(months[11].date_debut, "2026-05-01"); + assert_eq!(months[11].date_fin, "2026-05-31"); + assert_eq!(months[11].daily_serials.len(), 31); + let feb25 = get_month_ranges(&build_grid_network_qlik_date_filter(d("2025-06-15"), 12)); + assert!(feb25.iter().any(|m| m.label == "2025-02" && m.daily_serials.len() == 28)); + let biss = get_month_ranges(&build_grid_network_qlik_date_filter(d("2025-01-05"), 12)); + assert!(biss.iter().any(|m| m.label == "2024-02" && m.daily_serials.len() == 29)); + assert_eq!(months.iter().map(|m| m.daily_serials.len()).sum::(), 365); + } + + // [9] fenêtre courte partielle + #[test] + fn fenetre_partielle() { + let mut f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + f.date_debut = "2025-06-01".into(); + f.date_fin = "2025-08-15".into(); + f.q_start = 45809; + f.q_end = 45884; + f.daily_serials.clear(); + f.set_analysis.clear(); + let m = get_month_ranges(&f); + assert_eq!(m.len(), 3); + assert_eq!(m[2].daily_serials.len(), 15); + assert_eq!(m[2].date_fin, "2025-08-15"); + } + + // [10] monthsBack=6 + #[test] + fn six_mois() { + let f6 = build_grid_network_qlik_date_filter(d("2026-06-21"), 6); + assert_eq!(f6.date_debut, "2025-12-01"); + assert_eq!(f6.date_fin, "2026-05-31"); + assert_eq!((f6.q_start, f6.q_end), (45992, 46173)); + assert_eq!(f6.label, "2025-12_2026-05"); + assert_eq!(f6.daily_serials.len(), 182); + let m6 = get_month_ranges(&f6); + assert_eq!(m6.len(), 6); + assert_eq!(m6[0].label, "2025-12"); + assert_eq!(m6[5].label, "2026-05"); + assert_eq!(m6.iter().map(|m| m.daily_serials.len()).sum::(), 182); + } + + // [11] bornes et clamp + #[test] + fn bornes_months_back() { + let now = d("2026-06-21"); + let x = build_grid_network_qlik_date_filter(now, 1); + assert_eq!((x.date_debut.as_str(), x.date_fin.as_str()), ("2026-05-01", "2026-05-31")); + assert_eq!(build_grid_network_qlik_date_filter(now, 0).date_debut, "2026-05-01"); + let x = build_grid_network_qlik_date_filter(now, 24); + assert_eq!((x.date_debut.as_str(), x.date_fin.as_str()), ("2025-06-01", "2026-05-31")); + assert_eq!(build_grid_network_qlik_date_filter(now, -5).date_debut, "2026-05-01"); + assert_eq!((QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX, QLIK_MONTHS_BACK_DEFAULT), (1, 12, 12)); + } + + // [12] envMonthsBack — lecture env (cœur pur, sans toucher l'environnement du processus) + #[test] + fn lecture_env_months_back() { + let def = QLIK_MONTHS_BACK_DEFAULT; + assert_eq!(months_back_from(None, def), def); + assert_eq!(months_back_from(Some(""), def), def); + assert_eq!(months_back_from(Some("6"), def), 6); + assert_eq!(months_back_from(Some("abc"), def), def); + assert_eq!(months_back_from(Some("24"), def), QLIK_MONTHS_BACK_MAX); + assert_eq!(months_back_from(Some("-3"), def), QLIK_MONTHS_BACK_MIN); + assert_eq!(months_back_from(Some("0"), def), QLIK_MONTHS_BACK_MIN); + assert_eq!(months_back_from(Some("3.7"), def), 3); + assert_eq!(months_back_from(Some(" 8 "), def), 8); + assert_eq!(months_back_from(Some("NaN"), def), def); + assert_eq!(months_back_from(Some("Infinity"), def), def); + // Sur le vrai environnement : variable absente → défaut. + assert_eq!(env_months_back("QLIK_TEST_MONTHS_BACK_ABSENTE_XYZ", def), def); + } + + #[test] + fn nombres_facon_js() { + assert_eq!(js_number(""), 0.0); + assert_eq!(js_number(" 12 "), 12.0); + assert_eq!(js_number("0x10"), 16.0); + assert_eq!(js_number("1e2"), 100.0); + assert!(js_number("inf").is_nan()); + assert!(js_number("12abc").is_nan()); + } + + #[test] + fn serial_vers_aaaammjj() { + assert_eq!(qlik_serial_to_ymd(46192), 20260619); + } +} diff --git a/server/src/qlik/js/extraction.js b/server/src/qlik/js/extraction.js new file mode 100644 index 0000000..19d995d --- /dev/null +++ b/server/src/qlik/js/extraction.js @@ -0,0 +1,2036 @@ +// Script exécuté DANS la page Qlik (Chromium) par `playwright.rs`. +// Copie conforme de la fonction passée à `page.evaluate` dans +// `src/lib/qlik-playwright.ts` (types TypeScript retirés, code inchangé). +// Appelée avec un unique objet d'arguments ; résout un `InPageResult`. +({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats, fournisseurCode, champsFournisseurCandidats, couvertureFournisseurMin }) => new Promise((resolve) => { + const loc = window.location; + const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; + const ws = new WebSocket(url); + let id = 0; + const pend = new Map(); + const rpc = (method, params, handle = -1) => new Promise((res, rej) => { + const i = ++id; + pend.set(i, { res, rej }); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); + }); + const ready = new Promise((res, rej) => { + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.method === "OnConnected") + return res(); + if (m.id && pend.has(m.id)) { + const x = pend.get(m.id); + pend.delete(m.id); + if (m.error) + x.rej(new Error(JSON.stringify(m.error))); + else + x.res(m.result); + } + }; + ws.onerror = () => rej(new Error("ws error (403 ?)")); + setTimeout(() => rej(new Error("ws timeout")), 30000); + }); + // Qlik Engine code 15 = "Request aborted" : déclenché quand une requête + // (GetLayout / GetHyperCubeData / CreateSessionObject) entre en collision + // avec une évaluation de sélection encore en cours côté Engine. On retente + // quelques fois avec un court backoff et un log explicite de l'étape. + const isEngineCode15 = (e) => { + const msg = String(e?.message ?? e); + return /"code"\s*:\s*15\b/.test(msg); + }; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + const timed = async (etape, method, fn, timings) => { + const start = nowMs(); + const value = await fn(); + const ms = Math.round(nowMs() - start); + const label = `${etape}:${method}`; + if (timings) + timings[label] = (timings[label] ?? 0) + ms; + console.log(`[qlik-pw][timing] ${label} ms=${ms}`); + return { value, ms }; + }; + const timingSummary = (timings) => JSON.stringify(timings); + const rpcWithRetry = async (etape, method, params, handle, onCode15Retry) => { + let lastErr; + for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) { + try { + return await rpc(method, params, handle); + } + catch (e) { + lastErr = e; + if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) + throw e; + onCode15Retry?.(); + const backoff = qlikSettleMs * attempt; + console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`); + await sleep(backoff); + } + } + throw lastErr; + }; + // Accumulateurs déclarés au niveau de l'IIFE (et non dans le `try`) : + // le `catch` doit pouvoir remonter ce qui a déjà été extrait. + const out = []; + // Quantité réseau par code central et par mois (label "YYYY-MM"). + // Accumulée en parallèle du total, sans toucher l'agrégation existante. + const monthlyByCode = {}; + // Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait + // des milliers de lignes de timings, et ce qui explique un échec se + // trouve tout au début — là où personne ne va le chercher. + const diagnostics = []; + const diag = (msg) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); }; + (async () => { + try { + await ready; + console.log("ws connecté (OnConnected)"); + const open = await rpc("OpenDoc", { qDocName: app }); + const doc = open.qReturn.qHandle; + console.log("OpenDoc OK handle=" + doc); + // Handles des champs Date / Article Code. + let dfh = -1; + let fh = -1; + let yfh = -1; + /** + * Handle du CHAMP « Période ». + * + * ⚠️ `Clear` et `SelectValues` sont des méthodes de **champ**. Les + * appeler sur un objet de session renvoie + * `{"code":-32601,"parameter":"Clear","message":"Method not found"}`, + * ce qui a fait avorter en production la cartographie des périodes ET + * le chemin d'extraction par date brute. Le list object ne sert donc + * plus qu'à ÉNUMÉRER les valeurs ; les sélections passent par ici. + */ + let pfh = -1; + const df = await rpc("GetField", { qFieldName: "Date" }, doc); + dfh = df.qReturn.qHandle; + try { + const yf = await rpc("GetField", { qFieldName: "Année" }, doc); + yfh = yf.qReturn.qHandle; + } + catch { + yfh = -1; + } + try { + const pf = await rpc("GetField", { qFieldName: "Période" }, doc); + pfh = pf.qReturn.qHandle; + } + catch { + pfh = -1; + } + if (codes.length) { + const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); + fh = gf.qReturn.qHandle; + } + // Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS). + const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase(); + const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } } }, doc); + const mll = await rpc("GetLayout", {}, ml.qReturn.qHandle); + const items = ((mll.qLayout.qMeasureList?.qItems) ?? []); + const byTitle = new Map(); + for (const it of items) { + const t = it.qMeta?.title ?? it.qData?.title ?? ""; + if (t) + byTitle.set(norm(t), it.qInfo.qId); + } + const pickMeasure = (title, fallback) => byTitle.get(norm(title)) ?? fallback; + const rCamag = pickMeasure("CA par Magasin N", mcamag); + const rMarge = pickMeasure("Marge % N", mmarge); + // Résolution par TITRE aussi pour CA / Quantité / Nb magasins : évite tout + // décalage si un qId d'env ne pointe pas sur la mesure attendue. + const rCa = pickMeasure("CA N", mca); + const rQte = pickMeasure("Quantité N", mqte); + const rNbMag = pickMeasure("Magasin Ventes Nb N", mnb); + console.log("[qlik-pw] mesures utilisées: " + JSON.stringify({ + "CA N": rCa, "Quantité N": rQte, "Magasin Ventes Nb N": rNbMag, + "CA par Magasin N": rCamag, "Marge % N": rMarge, + env: { mca, mqte, mnb }, + })); + console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)"); + // DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour + // identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période" + // permettant un vrai découpage mensuel du réseau. + // L'EXPRESSION est indispensable au diagnostic : c'est elle qui dit + // si la mesure est bornée à une année (et comment), donc pourquoi des + // mois de la fenêtre glissante peuvent ressortir vides. + console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); + for (const it of items) { + const titre = it.qMeta?.title ?? it.qData?.title ?? ""; + if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(titre.trim())) { + console.log("[qlik-pw][dump] expression « " + titre + " » = " + (it.qData?.expr ?? "?")); + } + } + try { + const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc); + const dll = await rpc("GetLayout", {}, dl.qReturn.qHandle); + const ditems = ((dll.qLayout.qDimensionList?.qItems) ?? []); + console.log("[qlik-pw][dump] DIMENSIONS (" + ditems.length + "): " + JSON.stringify(ditems.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); + } + catch (e) { + console.log("[qlik-pw][dump] DimensionList error: " + String(e?.message || e)); + } + // L'hypercube session object est désormais créé JETABLE dans fetchCubeForSelection + // (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15) + // causé par la réutilisation du même objet sur des centaines de recalculs. + // (déclaré au niveau de l'IIFE — voir plus haut) + // (monthlyByCode est déclaré au niveau de l'IIFE — voir plus haut) + const accMonthly = (batchRows, monthLabel) => { + for (const r of batchRows) { + const c = String(r[0] ?? "").trim(); + if (!c || c === "-") + continue; + const q = Number(r[2]) || 0; + (monthlyByCode[c] ??= {}); + const cur = monthlyByCode[c][monthLabel]; + monthlyByCode[c][monthLabel] = { + qte: (cur?.qte ?? 0) + q, + ca: (cur?.ca ?? 0) + (Number(r[1]) || 0), + nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0), + caMag: Number(r[4]) || 0, + margePct: Number(r[5]) || 0, + }; + } + }; + // Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync). + let aggMonths = 0; + let aggBatches = 0; + let aggCode15Retries = 0; + let aggFallbackCount = 0; + // Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes + // brutes lues (avant dédoublonnage côté Node). + // + // IMPORTANT : crée un hypercube session object JETABLE par lot (Date + Article Code + // déjà sélectionnés) puis le détruit. Réutiliser le même objet sur des centaines de + // lots force Qlik à recalculer en boucle et finit par "Request aborted" (code 15). + const PAGE = 1400; + // Petite latence après chaque mutation de sélection : laisser l'Engine + // finaliser l'évaluation de la sélection avant la prochaine requête layout/cube. + const createCubeForBatch = async (timings, onCode15Retry) => { + const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }], + qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn; + return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + }; + const destroyCube = async (cube, timings) => { + if (!cube.id) + return; + try { + await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings); + } + catch (e) { + // Non fatal : l'objet est de toute façon scopé à la session websocket. + // Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort. + console.log("[qlik-pw] DestroySessionObject ignoré: " + String(e?.message || e)); + } + }; + const appendMatrix = (matrix, sink) => { + for (const r of matrix) + sink.push([ + String(r[0]?.qText ?? ""), + Number(r[1]?.qNum) || 0, + Number(r[2]?.qNum) || 0, + Number(r[3]?.qNum) || 0, + Number(r[4]?.qNum) || 0, + Number(r[5]?.qNum) || 0, + ]); + return matrix.length; + }; + const fetchCubeForSelection = async (sink, timings, onCode15Retry) => { + const cube = await createCubeForBatch(timings, onCode15Retry); + const oh = cube.handle; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hyperCube = layout.qLayout.qHyperCube; + const size = hyperCube.qSize.qcy; + const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0); + let got = 0; + let top = 0; + if (firstPage?.qMatrix?.length) { + got += appendMatrix(firstPage.qMatrix, sink); + top = PAGE; + } + for (; top < size; top += PAGE) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += appendMatrix(matrix, sink); + if (matrix.length < PAGE) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + // ─── Découpage mensuel réel via dimension "Mois" ──────────────────── + // Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca, + // qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM". + const normMois = (m) => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m); + const PAGEM = 1200; // 8 colonnes × 1200 = 9600 cellules < 10000 (limite Qlik) + const fetchMonthCube = async (onRow, timings, onCode15Retry) => { + const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const oh = cube.handle; + const process = (matrix) => { + for (const r of matrix) { + onRow(String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hc = layout.qLayout.qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); + if (firstPage?.qMatrix?.length) { + got += process(firstPage.qMatrix); + top = PAGEM; + } + for (; top < size; top += PAGEM) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGEM }] }, oh, onCode15Retry), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += process(matrix); + if (matrix.length < PAGEM) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + // ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ───── + // + // Constat de production : les master measures « CA N » / « Quantité N » + // **ignorent la sélection Date**. Vérifié arithmétiquement : avec août + // 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € — + // soit exactement la somme de janvier à juillet 2026. Ce sont des + // mesures « cumul année en cours », pas des mesures de période. + // + // Conséquences : les totaux réseau étaient un cumul année en cours + // (mois courant partiel inclus) et non 12 mois glissants, les mois de + // l'année précédente non couverts par « Quantité COMP » restaient + // vides, et la passe de rattrapage y recopiait le total de période. + // + // Aucune sélection ne peut corriger cela : on agrège donc directement + // les champs de faits, qui eux respectent les sélections. + const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 + const fetchMonthCubeExpr = async (eCa, eQte, eNbMag, eMarge, onRow, timings, onCode15Retry, + /** + * Inclure la master « Quantité N » (colonne de calibrage) ? + * + * ⚠️ Elle ne coûte rien quand elle vit, mais **annule tout le cube** + * quand elle est morte : mesuré en production, 4 expressions valides + * à 6 mois chacune et le cube complet à **0 ligne** après 25 s de + * calcul. L'appelant ne l'ajoute donc qu'après l'avoir sondée. + */ + avecMaster = true) => { + const largeur = avecMaster ? 7 : 6; + const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], + qMeasures: [ + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, + // Master « Quantité N », uniquement pour le calibrage : sur les + // mois de l'année en cours les deux doivent coïncider. + ...(avecMaster ? [{ qLibraryId: rQte }] : []), + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: PAGEX }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const oh = cube.handle; + const process = (matrix) => { + for (const r of matrix) { + onRow(String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, avecMaster ? Number(r[6]?.qNum) || 0 : 0); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hc = layout.qLayout.qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); + if (firstPage?.qMatrix?.length) { + got += process(firstPage.qMatrix); + top = PAGEX; + } + for (; top < size; top += PAGEX) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: largeur, qHeight: PAGEX }] }, oh, onCode15Retry), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += process(matrix); + if (matrix.length < PAGEX) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + /** + * Passe unique par expressions : sélectionne la fenêtre 12 mois et tous + * les codes, puis lit un seul cube [Article Code, Mois]. + * + * Renvoie false si le résultat est inexploitable (expression invalide, + * champ absent) — l'appelant repart alors sur les master measures. + */ + /** + * Total de contrôle : un cube 1 cellule, sans dimension, sur + * l'expression quantité. Sert à savoir si une sélection de dates + * FILTRE réellement les faits — ce que les master measures, qui + * l'ignorent, ne permettaient pas de vérifier. + */ + const totalDeControle = async () => { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ctrl" }, + qHyperCubeDef: { + qDimensions: [], + qMeasures: [{ qDef: { qDef: exprQte } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + }, + } }, doc); + const ret = o.qReturn; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const cell = lay.qLayout + .qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0]; + return Number(cell?.qNum) || 0; + } + finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * Sélectionne la fenêtre sur un champ date candidat. + * + * Renvoie le handle pour que l'appelant puisse impérativement + * effacer une sélection rejetée avant d'essayer le candidat + * suivant. Sans ce Clear, un premier champ qui ne matche aucune + * date maintient l'ensemble courant à zéro et condamne tous les + * essais suivants à renvoyer zéro eux aussi. + */ + const selectionnerFenetreSur = async (champ, valeurs) => { + let h; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = gf.qReturn.qHandle; + if (typeof h !== "number" || h < 0) + return null; + } + catch { + return null; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + for (let i = 0; i < valeurs.length; i += 500) { + await rpc("SelectValues", { + qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, h); + } + await sleep(qlikSettleMs); + return h; + }; + /** + * Sélectionne les 12 mois via la dimension maître « Mois ». + * + * Dans l'app Magasins Vision Consolidée, les champs Date exposés + * existent mais sont dissociés des faits de vente : leur sélection + * laisse Sum(quantite) à 100 %. La dimension maître utilisée par le + * cube, elle, porte les vraies valeurs associées. Un list object + * permet de sélectionner ses qElemNumber sans connaître le nom du + * champ sous-jacent ni le format dual exact. + */ + const selectionnerFenetreViaDimensionMois = async (moisAttendus) => { + let objet = null; + try { + const cree = await rpcWithRetry("createMonthList", "CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-month-list" }, + qListObjectDef: { + qLibraryId: moisDimId, + qShowAlternatives: true, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 100 }], + }, + }, + }, doc); + const ret = cree.qReturn; + objet = { + handle: ret.qHandle, + id: String(ret.qGenericId ?? ret.qId ?? ""), + }; + const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle); + const lo = layout.qLayout.qListObject; + const size = Number(lo?.qSize?.qcy) || 0; + const cellules = []; + for (const page of lo?.qDataPages ?? []) { + for (const row of page.qMatrix ?? []) + if (row[0]) + cellules.push(row[0]); + } + for (let top = cellules.length; top < size; top += 500) { + const data = await rpcWithRetry("monthListData", "GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(500, size - top) }], + }, objet.handle); + const matrix = data.qDataPages?.[0]?.qMatrix ?? []; + for (const row of matrix) + if (row[0]) + cellules.push(row[0]); + if (matrix.length === 0) + break; + } + const parMois = new Map(); + for (const cellule of cellules) { + const mois = normMois(String(cellule.qText ?? "").trim()); + const elem = Number(cellule.qElemNumber); + if (moisAttendus.has(mois) && Number.isInteger(elem) && elem >= 0) { + parMois.set(mois, elem); + } + } + const trouves = [...parMois.keys()].sort(); + console.log("[qlik-pw][expr] dimension Mois : " + trouves.length + "/" + moisAttendus.size + + " valeurs trouvées " + JSON.stringify(trouves)); + if (trouves.length !== moisAttendus.size) + return null; + const selection = await rpcWithRetry("selectMonthList", "SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [...parMois.values()], + qToggleMode: false, + qSoftLock: true, + }, objet.handle); + console.log("[qlik-pw][expr] sélection dimension Mois → " + JSON.stringify(selection.qReturn)); + await sleep(qlikSettleMs); + const total = await totalDeControle(); + return { total, trouves }; + } + catch (e) { + console.log("[qlik-pw][expr] sélection dimension Mois impossible : " + String(e?.message || e)); + return null; + } + finally { + if (objet?.id) { + try { + await rpc("DestroySessionObject", { qId: objet.id }, doc); + } + catch { /* objet limité à la session */ } + } + } + }; + /** + * Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants. + * + * L'app n'est pas un modèle « faits + calendrier » classique : TOUTES + * ses mesures sont bornées par un type de calendrier — + * + * « Quantité N » = Sum({} quantite) + * « Quantité COMP » = Sum({} quantite) + * + * `Type_Cal` marque les lignes de la période analysée (`N`) et celles + * de la période de comparaison (`COMP`), et c'est le champ `Période` + * (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. + * + * Cela explique tout ce qu'on observait : + * - sélectionner `Date` ne change RIEN (le périmètre vient du pont de + * périodes, pas du champ date) — mesuré : 100 % du total avant comme + * après ; + * - « Quantité N » ne renvoie que les mois de la période courante + * (janvier→juillet avec « année en cours »), d'où août→décembre + * vides ; + * - « Quantité COMP » ne couvre que les mois ayant un comparable dans + * cette période, d'où janvier→juillet de l'année précédente ; + * - `Sum(quantite)` brut mélange les lignes N et COMP : il double + * compte, et ne peut pas se substituer aux mesures. + * + * La solution n'est donc pas de contourner le modèle mais de le + * piloter : on essaie chaque valeur de `Période`, on mesure combien de + * mois de la fenêtre elle rend réellement disponibles, et on garde la + * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée + * qui décide. + */ + /** + * Vrai quand le périmètre articles est posé par une sélection + * fournisseur : dans ce cas, plus aucun `SelectValues` sur + * « Article Code » n'est émis (ni par passe annuelle, ni par + * l'agrégation directe) — c'est tout l'intérêt de la bascule. + */ + let parFournisseur = false; + /** + * Sélectionne LE fournisseur plutôt que ses dizaines de milliers de + * codes articles. + * + * `SelectValues` sur « Article Code » coûte 8 à 100 s **par appel**, + * quasi indépendamment du nombre de valeurs — le moteur balaie un + * symbole de plus d'un million d'entrées — et il faut le refaire à + * chaque passe annuelle. Une valeur de fournisseur produit le même + * périmètre en un appel trivial. + * + * La bascule n'est retenue QUE si la sélection couvre les codes + * demandés : un article rattaché à plusieurs fournisseurs, ou un + * référencement Qlik différent du référencement FF, doit faire + * revenir à la sélection par codes plutôt que perdre des articles. + */ + const selectionnerParFournisseur = async () => { + if (!fournisseurCode || codes.length === 0) + return false; + const attendus = new Set(codes.map((c) => c.trim())); + /** + * Au-delà de ce nombre de codes visibles, la sélection fournisseur + * ne restreint rien (elle laisse tout le catalogue) : inutile de + * finir la lecture, la réponse est déjà connue. + */ + const plafond = attendus.size * 3; + /** Codes articles visibles sous la sélection courante. */ + const lireCodesVisibles = async () => { + const vus = new Set(); + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-codes-fou" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim, qNullSuppression: true }], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 5000 }], + }, + } }, doc); + const ret = o.qReturn; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + const total = Number(hc?.qSize?.qcy) || 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") + vus.add(c); + } + for (let top = vus.size ? 5000 : 0; top < total; top += 5000) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(5000, total - top) }], + }, ret.qHandle); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (matrix.length === 0) + break; + for (const r of matrix) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") + vus.add(c); + } + // Inutile de lire tout le catalogue pour conclure : au-delà + // du plafond, la sélection ne restreint manifestement rien. + // Mesuré : 1 207 205 codes lus en 240 pages, ×3 champs = + // 87 s perdues avant un rejet joué d'avance. + if (vus.size > plafond) + break; + } + return vus; + } + finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + for (const champ of champsFournisseurCandidats) { + let h = -1; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = gf.qReturn.qHandle; + if (typeof h !== "number" || h < 0) + continue; + } + catch { + continue; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: fournisseurCode }], + qToggleMode: false, + qSoftLock: true, + }, h); + await sleep(qlikSettleMs); + if (sel.qReturn === false) { + diag(" champ « " + champ + " » : valeur « " + fournisseurCode + " » inconnue"); + await rpc("Clear", {}, h); + continue; + } + const visibles = await lireCodesVisibles(); + // Un vrai périmètre fournisseur est de l'ordre de grandeur des + // codes demandés. Mesuré en production : « J009 » laissait + // 1 207 205 articles visibles, soit tout le catalogue — la + // sélection n'avait rien restreint, et la « couverture » de 97 % + // ne faisait que constater que le catalogue contient nos codes. + if (visibles.size > plafond) { + diag(" champ « " + champ + " » = « " + fournisseurCode + " » → plus de " + plafond + + " articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée"); + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + continue; + } + let couverts = 0; + for (const c of attendus) + if (visibles.has(c)) + couverts++; + const couverture = attendus.size > 0 ? couverts / attendus.size : 0; + diag(" champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + + " articles Qlik, couvre " + couverts + "/" + attendus.size + + " codes demandés (" + Math.round(couverture * 100) + "%)"); + if (couverture >= couvertureFournisseurMin) { + diag("sélection PAR FOURNISSEUR retenue sur « " + champ + " » — " + attendus.size + " SelectValues économisés par passe"); + return true; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + diag("aucun champ fournisseur ne couvre les codes demandés → sélection par codes articles"); + return false; + }; + /** Valeur de `Période` à poser avant la passe d'une année donnée. */ + const periodeParAnnee = new Map(); + /** + * Pose (ou efface, si `texte` est nul) la sélection `Période`. + * + * Passe par le HANDLE DU CHAMP, comme pour `Année` et `Date` : c'est la + * seule API qui expose `Clear` et `SelectValues`. Ne propage jamais + * d'erreur — une `Période` qu'on n'arrive pas à poser doit dégrader le + * résultat, pas interrompre l'extraction (leçon de production : un + * `Clear` en échec a fait avorter deux chemins entiers). + */ + const appliquerPeriode = async (texte) => { + if (pfh === -1) + return texte == null; + try { + await rpc("Clear", {}, pfh); + await sleep(qlikSettleMs); + if (texte == null) + return true; + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: texte }], + qToggleMode: false, + qSoftLock: true, + }, pfh); + await sleep(qlikSettleMs); + return sel.qReturn !== false; + } + catch (e) { + diag(" sélection Période « " + (texte ?? "(aucune)") + " » impossible : " + String(e?.message || e)); + return false; + } + }; + /** + * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. + * + * CE QUE DIT LA MESURE (production, 2026-08-01, sonde paginée) : + * + * Période « (aucune) » → master exposée : 2024-01…2026-07 (31 mois) + * + * Sans aucune `Période` sélectionnée, « Quantité N » couvre **31 mois**, + * et une seule passe (`Année=2026`) sert les 12 mois de la fenêtre. + * L'état libre est donc le bon — encore fallait-il pouvoir le voir. + * + * Deux erreurs successives l'avaient masqué, toutes deux dans l'outil + * de mesure et non dans le modèle Qlik : + * 1. la sonde ne lisait que les 60 premières lignes du cube `[Mois]`, + * donc jamais les mois récents → « 0/12 » pour toute valeur ; + * 2. `Clear` était appelée sur le list object au lieu du champ, ce qui + * faisait avorter la cartographie entière avant son premier essai. + * + * D'où le principe retenu ici : **ne rien présumer, tout mesurer**. La + * couverture est évaluée par année et dans le contexte exact où elle + * sera exploitée (`monthDimPath` pose la même valeur), l'état sans + * `Période` étant un candidat comme un autre — et, en l'occurrence, le + * gagnant. + */ + const choisirPeriode = async (annees) => { + const fenetre = new Set(monthlyPayload.map((m) => m.label)); + let lo = null; + try { + const obj = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periodes" }, + qListObjectDef: { + qDef: { qFieldDefs: ["Période"] }, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }], + }, + } }, doc); + lo = obj.qReturn; + const lay = await rpc("GetLayout", {}, lo.qHandle); + const matrix = (lay.qLayout + .qListObject?.qDataPages?.[0]?.qMatrix) ?? []; + const valeurs = matrix + .map((r) => ({ texte: String(r[0]?.qText ?? ""), elem: r[0]?.qElemNumber ?? -1 })) + .filter((v) => v.texte && v.elem >= 0); + diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); + if (valeurs.length === 0) + return 0; + /** + * Couverture réelle : quels mois « Quantité N » rend-elle vivants ? + * + * ⚠️ Le cube est PAGINÉ intégralement. La version précédente ne + * lisait que les 60 premières lignes : la dimension Mois porte + * tout l'historique de l'app, les mois récents tombaient hors + * de la première page, et la sonde annonçait 0/12 quelle que + * soit la `Période` — alors que la master vivait (mesuré : + * total 9 721 153). C'est ce faux zéro qui faisait effacer + * `Période` et condamnait toute l'extraction. + * + * Renvoie les mois vivants DE LA FENÊTRE, et journalise aussi + * ce que la dimension expose hors fenêtre : sans cela on ne + * distingue pas « le modèle ne donne rien » de « le modèle + * donne autre chose que ce qu'on attend ». + */ + const mesurerCouverture = async () => { + const sonde = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periode-sonde" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 500 }], + }, + } }, doc); + const sRet = sonde.qReturn; + try { + const sl = await rpc("GetLayout", {}, sRet.qHandle); + const hc = sl.qLayout.qHyperCube; + const lignes = [...(hc?.qDataPages?.[0]?.qMatrix ?? [])]; + const total = Number(hc?.qSize?.qcy) || 0; + for (let top = lignes.length; top < total; top += 500) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 2, qHeight: Math.min(500, total - top) }], + }, sRet.qHandle); + const m = d.qDataPages?.[0]?.qMatrix ?? []; + if (m.length === 0) + break; + lignes.push(...m); + } + const vivants = lignes + .filter((r) => (Number(r[1]?.qNum) || 0) > 0) + .map((r) => normMois(String(r[0]?.qText ?? ""))) + .sort(); + return { fenetre: vivants.filter((m) => fenetre.has(m)), tous: vivants }; + } + finally { + const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); + if (sid) { + try { + await rpc("DestroySessionObject", { qId: sid }, doc); + } + catch { /* noop */ } + } + } + }; + /** Résumé compact d'une liste de mois : « 2025-01…2025-12 (12) ». */ + const resume = (mois) => mois.length === 0 ? "aucun" : mois[0] + "…" + mois[mois.length - 1] + " (" + mois.length + ")"; + /** + * Couverture d'une `Période` telle qu'elle sera RÉELLEMENT + * exploitée : une passe par année, comme `monthDimPath`, puis + * union. C'est le seul chiffre qui a un sens ici — mesurer + * sans année revient à juger « Année à date » sur la seule + * année en cours (6/12) et à la rejeter à tort. + */ + // ─── Une `Période` PAR ANNÉE ───────────────────────────── + // + // C'est le point que la version globale manquait : la bonne + // valeur dépend de l'année analysée. « Année à date » convient + // à 2026 (janvier → mois courant) mais pas à 2025, dont on veut + // les 12 mois. On cartographie donc, pour chaque année de la + // fenêtre, la valeur qui sert le PLUS de mois attendus de cette + // année-là — l'état sans `Période` étant un candidat comme un + // autre. + const couvertsGlobal = new Set(); + for (const annee of annees) { + const attendusAnnee = [...fenetre].filter((m) => m.startsWith(String(annee) + "-")); + diag(" année " + annee + " — mois attendus " + JSON.stringify(attendusAnnee.sort())); + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { + qFieldValues: [{ qNum: annee, qText: String(annee) }], + qToggleMode: false, + qSoftLock: true, + }, yfh); + await sleep(qlikSettleMs); + } + // `null` = état sans `Période`, candidat comme un autre. + const candidats = [null, ...valeurs.map((v) => v.texte)]; + let meilleure = null; + for (const c of candidats) { + const libelle = c ?? "(aucune)"; + // Un candidat qui échoue ne doit pas emporter toute la + // cartographie : on le note et on passe au suivant. + let servis = []; + try { + const pose = await appliquerPeriode(c); + if (!pose && c !== null) { + diag(" Période « " + libelle + " » → non sélectionnable, ignorée"); + continue; + } + const mois = await mesurerCouverture(); + servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); + diag(" Période « " + libelle + " » → sert " + servis.length + "/" + attendusAnnee.length + + " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous)); + } + catch (e) { + diag(" Période « " + libelle + " » → erreur " + String(e?.message || e)); + continue; + } + if (!meilleure || servis.length > meilleure.servis.length) { + meilleure = { texte: c, servis }; + } + if (servis.length >= attendusAnnee.length) + break; // année complète + } + if (meilleure) { + periodeParAnnee.set(annee, { texte: meilleure.texte }); + for (const m of meilleure.servis) + couvertsGlobal.add(m); + diag(" → année " + annee + " sera extraite sous Période « " + (meilleure.texte ?? "(aucune)") + " » (" + + meilleure.servis.length + "/" + attendusAnnee.length + " mois)"); + } + } + if (yfh !== -1) { + // Chaque passe pose sa propre année. + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + } + return couvertsGlobal.size; + } + catch (e) { + diag("choix de « Période » impossible : " + String(e?.message || e)); + return 0; + } + finally { + // Le list object est conservé jusqu'au bout : le détruire + // annulerait la sélection qu'il porte. + void lo; + } + }; + /** + * Teste UNE expression isolément sur un petit cube `[Mois]`. + * + * Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à + * tout un hypercube — observé en production : le cube à 5 mesures + * calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` seul + * donnait 35 424 324. Impossible de savoir laquelle sans les isoler. + * + * Renvoie l'expression si elle produit des lignes, sinon `"0"` — une + * colonne neutre qui préserve les indices sans casser le cube. + */ + const validerExpression = async (nom, expr) => { + let ret = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-expr" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + const matrix = hc?.qDataPages?.[0]?.qMatrix ?? []; + const lignes = Number(hc?.qSize?.qcy) || 0; + let total = 0; + let texteErreur = ""; + for (const r of matrix) { + total += Number(r[1]?.qNum) || 0; + const t = String(r[1]?.qText ?? ""); + if (!texteErreur && /error|erreur|garbage|^-$/i.test(t)) + texteErreur = t; + } + if (hc?.qError) + texteErreur = texteErreur || JSON.stringify(hc.qError); + if (lignes === 0 || texteErreur) { + diag(" mesure « " + nom + " » = " + expr + " → INVALIDE (" + (texteErreur || "cube vide") + ")"); + return "0"; + } + diag(" mesure « " + nom + " » = " + expr + " → " + lignes + " mois, total=" + Math.round(total)); + return expr; + } + catch (e) { + diag(" mesure « " + nom + " » = " + expr + " → ERREUR " + String(e?.message || e)); + return "0"; + } + finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * La master « Quantité N » produit-elle la moindre ligne sur `[Mois]` + * dans l'état de sélection courant ? Même sonde que + * `validerExpression`, mais par `qLibraryId`. + */ + const masterQteVivante = async () => { + let ret = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-master" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) + total += Number(r[1]?.qNum) || 0; + diag(" master « Quantité N » (calibrage) → total=" + Math.round(total)); + return total > 0; + } + catch (e) { + diag(" master « Quantité N » → ERREUR " + String(e?.message || e)); + return false; + } + finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * Somme de `expr` VENTILÉE par la dimension Mois. + * + * Comparée au total sans dimension, elle dit si la dimension Mois + * démultiplie les faits à travers le pont de périodes — auquel cas + * l'agrégation directe est inexploitable. + */ + const totalVentileParMois = async (expr) => { + let ret = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ventil-mois" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 200 }], + }, + } }, doc); + ret = o.qReturn; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) + total += Number(r[1]?.qNum) || 0; + return total; + } + catch { + return 0; + } + finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * Cube `[Article Code] × 4 expressions`, SANS dimension Mois. + * + * C'est tout l'intérêt : la dimension Mois passe par le pont de + * périodes et rattache une même ligne de fait à plusieurs contextes + * (mesuré : ×5,3). Sans elle, `Sum(quantite)` porte une fois et une + * seule sur les faits visibles — donc sur le mois sélectionné. + */ + const fetchCodeCubeExpr = async (eCa, eQte, eNbMag, eMarge, onRow, timings) => { + const LARGEUR = 5; + const PAGE = 1800; // 5 × 1800 = 9000 cellules < 10000 + const { value: obj } = await timed("createCubeCode", "CreateSessionObject", () => rpcWithRetry("createCubeCode", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-code-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }], + qMeasures: [ + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }], + } } }, doc), timings); + const qReturn = obj.qReturn; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const traiter = (matrix) => { + for (const r of matrix) { + onRow(String(r[0]?.qText ?? ""), Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, cube.handle), timings); + const hc = layout.qLayout.qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const p0 = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); + if (p0?.qMatrix?.length) { + got += traiter(p0.qMatrix); + top = PAGE; + } + for (; top < size; top += PAGE) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }] }, cube.handle), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += traiter(matrix); + if (matrix.length < PAGE) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + /** + * EXTRACTION MOIS PAR MOIS SUR LE CHAMP DATE BRUT. + * + * Le chemin que rien n'avait encore essayé, et le seul qui n'emprunte + * ni le pont de périodes ni les master measures : + * + * - on sélectionne les jours d'UN mois sur le champ date ; + * - on lit un cube `[Article Code]` sans dimension Mois, donc sans + * démultiplication ; + * - `Type_Cal`, `Période` et `Année` sont effacés : les expressions + * brutes n'ont pas besoin du pont, elles agrègent les faits visibles. + * + * Il n'y a donc plus de « mois inatteignable » : chaque mois est un + * filtre de dates, pas un contexte de calendrier. + * + * CONTRÔLE D'INTÉGRITÉ. La somme des 12 mois ne doit pas dépasser le + * total sans filtre de date : si la dimension ou le champ démultipliait + * les faits, elle exploserait (le pont donne ×5,3). Et chaque mois doit + * être servi. Sinon rien n'est écrit. + */ + const moisParDateBrute = async () => { + const timings = {}; + // Aucun contexte de calendrier : on veut les faits nus. + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + } + await appliquerPeriode(null); + if (dfh !== -1) { + await rpc("Clear", {}, dfh); + await sleep(qlikSettleMs); + } + if (codes.length && fh !== -1 && !parFournisseur) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh, () => { aggCode15Retries++; }), timings); + await sleep(qlikSettleMs); + } + const sansFiltre = await totalDeControle(); + diag("[date brute] contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + diag("[date brute] ÉCHEC : l'expression quantité ne renvoie rien (corriger QLIK_EXPR_QTE)"); + return false; + } + // ─── Quel champ date filtre les faits ? ────────────────── + // + // ⚠️ On teste avec UN SEUL MOIS, pas avec la fenêtre entière. + // Toute la table de faits tient dans les 12 mois (mesuré : 100 % + // du total) : sélectionner la fenêtre ne réduit donc RIEN, et + // l'ancien test « le total doit baisser » rejetait tous les + // champs, y compris les bons. + const moisTest = monthlyPayload[Math.floor(monthlyPayload.length / 2)]; + let champDate = null; + let handleDate = -1; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? moisTest.ymd : moisTest.serials; + const h = await selectionnerFenetreSur(cand, valeurs); + if (h == null) { + diag("[date brute] champ « " + cand + " » : inexistant"); + continue; + } + const t = await totalDeControle(); + const part = sansFiltre > 0 ? t / sansFiltre : 0; + diag("[date brute] champ « " + cand + " » sur le seul mois " + moisTest.label + " → " + + Math.round(t) + " (" + (part * 100).toFixed(1) + "% du total)"); + // Un mois isolé doit peser une fraction du total : ni zéro + // (le champ ne touche pas les faits) ni la totalité (il ne + // filtre pas). + if (t > 0 && part < 0.5) { + champDate = cand; + handleDate = h; + break; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + if (!champDate || handleDate === -1) { + diag("[date brute] ÉCHEC : aucun champ date ne filtre les faits mois par mois " + + "(essayés : " + JSON.stringify(champsDateCandidats) + ")"); + return false; + } + diag("[date brute] champ date retenu : « " + champDate + " »"); + // À partir d'ici la série sera reconstruite entièrement : les + // passes annuelles (master measures) et la série brute n'ont pas + // la même sémantique, on ne les mélange pas. + out.length = 0; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + const totauxMois = {}; + let sommeMois = 0; + for (let i = 0; i < monthlyPayload.length; i++) { + const m = monthlyPayload[i]; + const valeurs = /key/i.test(champDate) ? m.ymd : m.serials; + await rpc("Clear", {}, handleDate); + await sleep(qlikSettleMs); + await rpcWithRetry("selectDate", "SelectValues", { + qFieldValues: valeurs.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: false, + qSoftLock: true, + }, handleDate, () => { aggCode15Retries++; }); + await sleep(qlikSettleMs); + let totalDuMois = 0; + const lignes = await fetchCodeCubeExpr(exprCa, exprQte, exprNbMag, exprMarge, (code, ca, qte, nbMag, marge) => { + if (!code || code === "-") + return; + if (qte === 0 && ca === 0) + return; + totalDuMois += qte; + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + monthlyByCode[code][m.label] = { + qte, ca, nbMag, + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: ca > 0 ? marge / ca : 0, + }; + }, timings); + totauxMois[m.label] = totalDuMois; + sommeMois += totalDuMois; + aggBatches++; + console.log("[qlik-pw][date brute] mois " + (i + 1) + "/" + monthlyPayload.length + " " + m.label + + " → " + lignes + " articles, quantité=" + Math.round(totalDuMois)); + await pousserCheckpoint("date brute " + m.label); + } + aggMonths = monthlyPayload.length; + diag("[date brute] quantités par mois : " + JSON.stringify(Object.fromEntries(Object.entries(totauxMois).map(([k, v]) => [k, Math.round(v)])))); + // ─── Intégrité ─────────────────────────────────────────── + const vides = Object.entries(totauxMois).filter(([, v]) => v <= 0).map(([k]) => k); + if (vides.length) { + diag("[date brute] ÉCHEC : mois sans aucune vente " + JSON.stringify(vides)); + return false; + } + if (sommeMois > sansFiltre * 1.02) { + diag("[date brute] ÉCHEC : la somme des 12 mois (" + Math.round(sommeMois) + + ") dépasse le total sans filtre (" + Math.round(sansFiltre) + ") — les faits sont " + + "démultipliés, les ventes réseau seraient fausses"); + return false; + } + diag("[date brute] OK — somme des 12 mois = " + Math.round(sommeMois) + " pour un total sans filtre de " + + Math.round(sansFiltre) + " (" + Math.round((sommeMois / sansFiltre) * 100) + "%), " + + Object.keys(monthlyByCode).length + " articles"); + console.log("[qlik-pw][date brute] timings=" + timingSummary(timings)); + return true; + }; + const moisParExpressions = async () => { + const timings = {}; + const marqueOut = out.length; + try { + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + } + // Les codes d'abord : les totaux de contrôle portent alors sur + // le même périmètre que l'extraction. Sauf si le fournisseur + // pose déjà ce périmètre — le re-sélectionner par codes ne + // changerait rien au résultat et coûterait une minute. + if (codes.length && fh !== -1 && !parFournisseur) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh, () => { aggCode15Retries++; }), timings); + await sleep(qlikSettleMs); + } + // ─── Quel champ date filtre RÉELLEMENT les faits ? ─────── + // + // Les master measures ignorant la sélection Date, personne + // n'avait jamais pu vérifier que « Date » filtrait quoi que + // ce soit. Un champ valide doit donner un total non nul ET + // strictement inférieur au total sans filtre. + const sansFiltre = await totalDeControle(); + diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)"); + return false; + } + const allSerials = monthlyPayload.flatMap((m) => m.serials); + const allYmd = monthlyPayload.flatMap((m) => m.ymd); + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); + let champDate = null; + let totalFenetre = 0; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? allYmd : allSerials; + const handleCand = await selectionnerFenetreSur(cand, valeurs); + if (handleCand == null) { + diag(" champ « " + cand + " » : inexistant"); + continue; + } + const filtre = await totalDeControle(); + const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; + diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); + // ⚠️ 100 % ne veut PAS dire « ce champ ne filtre rien » : toute + // la table de faits tient dans les 12 mois (mesuré), donc + // sélectionner la fenêtre entière ne retire rien. Exiger une + // baisse rejetait donc les champs valides. Ici la sélection + // ne sert qu'à borner la fenêtre : un total non nul suffit, + // et on préfère simplement un champ qui filtre s'il existe. + if (filtre > 0) { + champDate = cand; + totalFenetre = filtre; + if (ratio < 0.995) + break; + diag(" (la fenêtre couvre 100 % des faits — normal si l'app ne porte que ces 12 mois)"); + break; + } + // Un candidat rejeté ne doit jamais polluer l'essai + // suivant (cas observé : Date=0 puis tous les autres=0). + await rpc("Clear", {}, handleCand); + await sleep(qlikSettleMs); + } + if (!champDate) { + // Les champs date exposés sont dissociés des faits : la + // dimension maître « Mois », elle, porte les vraies valeurs. + const selectionMois = await selectionnerFenetreViaDimensionMois(moisFenetre); + const ratio = selectionMois && sansFiltre > 0 ? selectionMois.total / sansFiltre : 0; + if (selectionMois) { + diag(" dimension « Mois » → " + Math.round(selectionMois.total) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); + } + if (selectionMois && selectionMois.total > 0 && ratio < 0.995) { + champDate = "dimension Mois"; + totalFenetre = selectionMois.total; + } + } + if (!champDate) { + diag("ÉCHEC : ni les champs date ni la dimension Mois ne filtrent les faits " + + "(champs essayés : " + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois."); + return false; + } + diag("champ date retenu : « " + champDate + " »"); + // Chaque expression est validée SÉPARÉMENT : une seule mesure + // invalide fait rendre zéro ligne à tout l'hypercube, sans + // qu'aucun message ne dise laquelle. + diag("validation des expressions, une par une :"); + const vQte = await validerExpression("quantité", exprQte); + if (vQte === "0") { + diag("ÉCHEC : l'expression quantité est inutilisable, rien à extraire"); + return false; + } + const vCa = await validerExpression("CA", exprCa); + const vNbMag = await validerExpression("magasins", exprNbMag); + const vMarge = await validerExpression("marge", exprMarge); + // La master « Quantité N » n'est là que pour le calibrage, mais + // une master morte suffit à vider tout l'hypercube. On la sonde + // donc comme les autres, et on l'omet plutôt que de perdre + // l'extraction entière pour une colonne de contrôle. + const avecMaster = await masterQteVivante(); + if (!avecMaster) { + diag(" master « Quantité N » morte sous cette sélection → colonne de calibrage retirée du cube"); + } + // ─── La dimension Mois démultiplie-t-elle les faits ? ──── + // + // Le pont de périodes rattache une même ligne de fait à + // plusieurs contextes. Ventilé par `Mois`, `Sum(quantite)` peut + // donc dépasser le total sans dimension — mesuré : 179 574 616 + // contre 33 945 285, soit ×5,3. Écrire cela dans le cache + // donnerait des ventes réseau cinq fois trop hautes : on refuse. + const totalVentile = await totalVentileParMois(vQte); + if (totalVentile > totalFenetre * 1.05) { + diag("ÉCHEC : ventilée par Mois, « " + vQte + " » totalise " + Math.round(totalVentile) + + " contre " + Math.round(totalFenetre) + " sans dimension (×" + + (totalFenetre > 0 ? (totalVentile / totalFenetre).toFixed(1) : "?") + + "). La dimension Mois passe par le pont de périodes et démultiplie les faits — " + + "l'agrégation directe donnerait des ventes réseau fausses, elle est refusée."); + return false; + } + // Seuls les mois de la fenêtre nous intéressent ; la dimension + // Mois peut en exposer d'autres si une sélection déborde. + let totalQte = 0; + let calibrees = 0, calibrOk = 0; + let horsFenetre = 0; + let echantillon = false; + const got = await timed("cube", "expr", () => fetchMonthCubeExpr(vCa, vQte, vNbMag, vMarge, (code, mois, ca, qte, nbMag, marge, qteMaster) => { + if (!code || code === "-") + return; + const mm = normMois(mois); + if (!echantillon) { + echantillon = true; + // Échantillon AVANT filtrage : si le mois ne tombe pas dans + // la fenêtre, c'est le format de la dimension Mois qui est + // en cause, pas les mesures. + diag("échantillon brut: " + JSON.stringify({ + code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), + ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, + })); + } + if (!moisFenetre.has(mm)) { + horsFenetre++; + return; + } + // Calibrage sur l'année en cours, seul périmètre où la master + // measure est censée être juste. + if (avecMaster && parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { + calibrees++; + if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) + calibrOk++; + } + totalQte += qte; + // Totaux = somme des mois de la fenêtre (et non un cumul année). + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + // Agrège au lieu d'écraser : protège contre deux valeurs + // brutes de la dimension Mois qui se normaliseraient vers + // la même clé YYYY-MM. + const precedent = monthlyByCode[code][mm]; + const qteCumulee = (precedent?.qte ?? 0) + qte; + const caCumule = (precedent?.ca ?? 0) + ca; + const margeMontant = ((precedent?.margePct ?? 0) * (precedent?.ca ?? 0)) + marge; + monthlyByCode[code][mm] = { + qte: qteCumulee, + ca: caCumule, + nbMag: Math.max(precedent?.nbMag ?? 0, nbMag), + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: caCumule > 0 ? margeMontant / caCumule : 0, + }; + }, timings, undefined, avecMaster), timings); + aggMonths = monthlyPayload.length; + aggBatches++; + diag(got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »"); + console.log("[qlik-pw][expr] timings=" + timingSummary(timings)); + if (totalQte <= 0) { + diag("ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " + + Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + + "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus."); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + return false; + } + // Contrôle global indépendant : le cube sans dimension, + // calculé sur exactement les mêmes sélections, doit être + // égal à la somme de toutes les lignes mensuelles paginées. + const ecartGlobal = Math.abs(totalQte - totalFenetre); + const toleranceGlobale = Math.max(0.001, Math.abs(totalFenetre) * 1e-9); + if (ecartGlobal > toleranceGlobale) { + console.error("[qlik-pw][expr] INTÉGRITÉ REFUSÉE : somme mensuelle=" + totalQte + + " mais total Qlik fenêtre=" + totalFenetre + " (écart=" + ecartGlobal + ")"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + return false; + } + // Contrôle et normalisation PAR ARTICLE. Chaque article + // retourné possède ensuite exactement les 12 clés de la + // fenêtre ; une clé absente du cube Qlik signifie qu'aucun + // fait n'existe pour ce mois, donc une vraie valeur 0. + const totauxParCode = {}; + for (let i = marqueOut; i < out.length; i++) { + const ligne = out[i]; + const code = String(ligne[0] ?? "").trim(); + if (code) + totauxParCode[code] = (totauxParCode[code] ?? 0) + (Number(ligne[2]) || 0); + } + for (const [code, mois] of Object.entries(monthlyByCode)) { + for (const label of moisFenetre) { + mois[label] ??= { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; + } + // Ne conserve aucune clé hors de la fenêtre glissante. + for (const label of Object.keys(mois)) { + if (!moisFenetre.has(label)) + delete mois[label]; + } + const somme12 = Array.from(moisFenetre).reduce((s, label) => s + (Number(mois[label]?.qte) || 0), 0); + const totalArticle = totauxParCode[code] ?? 0; + if (Math.abs(somme12 - totalArticle) > Math.max(0.001, Math.abs(totalArticle) * 1e-9)) { + console.error("[qlik-pw][expr] INTÉGRITÉ ARTICLE REFUSÉE code=" + code + + " somme12=" + somme12 + " total=" + totalArticle); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + return false; + } + } + if (calibrees > 20 && calibrOk / calibrees < 0.5) { + console.warn("[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + + " diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE)."); + } + return true; + } + catch (e) { + diag("ÉCHEC : " + String(e?.message || e) + " → repli sur les master measures"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + return false; + } + }; + // Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par + // lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup. + // selectYear = null → passe principale (année N + N-1 dérivée via COMP). + // selectYear = 2025 → passe complémentaire : on sélectionne l'année pour + // récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que + // COMP ne peut pas fournir puisque l'année N ne va pas jusque-là). + let loggedSampleRow = false; + const monthDimPath = async (selectYear) => { + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); + /** + * Traitement d'une ligne du cube [Article Code, Mois]. + * + * Une passe = UNE année explicitement sélectionnée, exactement ce + * que fait un utilisateur dans l'app (« je sélectionne 2026 et + * j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que + * les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces + * mois-là : deux années différentes ne peuvent donc pas se + * doubler, puisqu'elles ne partagent aucun mois. + */ + const onRowMois = (code, mois, ca, qte, nbMag, caMag, marge, qteComp) => { + if (!code || code === "-") + return; + const mm = normMois(mois); + if (!moisFenetre.has(mm)) + return; + out.push([code, ca, qte, nbMag, caMag, marge]); + const y = parseInt(mm.slice(0, 4), 10); + if (!loggedSampleRow) { + loggedSampleRow = true; + console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({ + code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte, + "Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp, + })); + } + (monthlyByCode[code] ??= {}); + // Les 5 mesures du mois courant de la ligne. Le cube les renvoie + // déjà toutes : les conserver ne coûte aucune requête Qlik en plus. + const full = { qte, ca, nbMag, caMag, margePct: marge }; + // « Quantité COMP » n'est plus nécessaire : chaque année de la + // fenêtre est extraite directement par sa propre passe, ce qui + // donne les 5 mesures et non la seule quantité. + void y; + void qteComp; + // Une passe ultérieure ne doit JAMAIS écraser un mois servi par + // un zéro : hors de son année, la passe voit la combinaison + // (article, mois) mais toutes ses mesures sont nulles. + const precedent = monthlyByCode[code][mm]; + if (precedent && (precedent.qte ?? 0) > 0 && qte <= 0) + return; + monthlyByCode[code][mm] = full; + }; + if (yfh === -1) + throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); + // La `Période` qui sert cette année-là — pas la même pour toutes : + // « Année à date » convient à l'année en cours, une autre valeur à + // une année révolue (cf. choisirPeriode). + const periodeAnnee = periodeParAnnee.get(selectYear); + if (periodeAnnee) { + console.log("[qlik-pw] (mois) Période « " + (periodeAnnee.texte ?? "(aucune)") + " » pour l'année " + selectYear); + await appliquerPeriode(periodeAnnee.texte); + } + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); + console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); + await sleep(qlikSettleMs); + const allSerials = monthlyPayload.flatMap((m) => m.serials); + if (dfh !== -1 && allSerials.length) { + await rpc("Clear", {}, dfh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh); + await sleep(qlikSettleMs); + } + aggMonths = monthlyPayload.length; + // ─── Chemin rapide : UNE seule sélection Article Code ──── + // + // Mesuré en production : `SelectValues` sur « Article Code » + // coûte 8 à 100 s **par appel**, quasi indépendamment du + // nombre de valeurs (le moteur balaie un symbole de plus d'un + // million d'entrées). Avec 7 200 codes en lots de 300, cela + // faisait 24 appels, ~15 min de sync, et la session Qlik + // mourait avant la fin (« Socket closed » puis « Execution + // context was destroyed »). + // + // La dimension Mois donne déjà tous les mois d'un coup : rien + // n'oblige à découper les codes. On tente donc un seul appel + // pour tous les codes et un seul cube paginé — la lecture des + // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine + // refuse (cube trop gros, code 15, saturation mémoire). + const marqueOut = out.length; + if ((codes.length || parFournisseur) && fh !== -1) { + const timingsAll = {}; + try { + // Le périmètre fournisseur est posé une fois pour toutes + // avant la première passe : il survit aux sélections + // Année/Date, qui portent sur d'autres champs. + if (!parFournisseur) { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timingsAll); + await sleep(qlikSettleMs); + } + const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); + aggBatches++; + console.log("[qlik-pw][timing] (mois) chemin rapide — " + + (parFournisseur ? "périmètre fournisseur (0 sélection code)" : codes.length + " codes en 1 sélection") + + ", rows=" + got.value + + " cumul=" + out.length + " timings=" + timingSummary(timingsAll)); + return; + } + catch (e) { + // Les lignes déjà lues seraient recomptées par le repli : + // on les retire (les écritures mensuelles, elles, sont + // des affectations idempotentes). + out.length = marqueOut; + console.log("[qlik-pw] (mois) chemin rapide échoué → repli par lots : " + String(e?.message || e)); + } + } + // Lots plus petits = cubes plus légers (moins de pression mémoire côté + // moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes). + const fallbackSizes = [300, 150, 75]; + const totalCodes = codes.length; + let codeIndex = 0, batchNumber = 0, fallbackIndex = 0; + while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { + const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + fallbackSizes[fallbackIndex], totalCodes)) : []; + const timings = {}; + let code15Retries = 0; + batchNumber++; + const bp = "[qlik-pw] (mois) lot " + batchNumber + " [" + codeIndex + "," + (codeIndex + batch.length) + ") size=" + batch.length; + try { + if (batch.length && fh !== -1) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); + await sleep(qlikSettleMs); + } + const got = await timed("cube", "batch", () => fetchMonthCube(onRowMois, timings, () => { code15Retries++; }), timings); + aggBatches++; + aggCode15Retries += code15Retries; + console.log("[qlik-pw][timing] " + bp + " — rows=" + got.value + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + if (!codes.length) + break; + codeIndex += batch.length; + fallbackIndex = 0; + } + catch (batchErr) { + const nextSize = fallbackSizes[fallbackIndex + 1]; + if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { + console.log("[qlik-pw] " + bp + " — fallback code15 " + batch.length + "→" + nextSize); + fallbackIndex += 1; + aggFallbackCount++; + continue; + } + throw new Error(bp + " — " + String(batchErr?.message || batchErr)); + } + } + }; + // Chemin rapide (flag QLIK_SELECT_ALL_CODES) : sélectionne TOUS les + // Article Code une seule fois, puis n'itère que la sélection Date par + // mois avec UN cube paginé (au lieu de re-sélectionner les codes par lots + // de 150 à chaque mois — la SelectValues répétée coûte ~22 s × 120). + // Renvoie true si le mois complet a réussi ; false si repli code 15 (les + // résultats partiels et sélections sont réinitialisés avant le chemin par lots). + const trySelectAllCodesPath = async () => { + const savedLen = out.length; + try { + if (codes.length && fh !== -1) { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), {}); + await sleep(qlikSettleMs); + const { value: sel } = await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), {}); + console.log("[qlik-pw] select-all — SelectValues Article Code (" + codes.length + " codes) → " + JSON.stringify(sel.qReturn)); + await sleep(qlikSettleMs); + } + const total = monthlyPayload.length; + console.log("[qlik-pw] chemin rapide select-all — " + total + " mois, 1 sélection Article Code globale"); + for (let mi = 0; mi < total; mi++) { + const m = monthlyPayload[mi]; + const prefix = "[qlik-pw] (rapide) mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; + const monthStart = nowMs(); + const monthTimings = {}; + aggMonths = mi + 1; + let code15Retries = 0; + // Sélection Date du mois uniquement (la sélection Article Code reste active). + const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { + qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), + qToggleMode: false, + qSoftLock: true, + }, dfh), monthTimings); + console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); + await sleep(qlikSettleMs); + // Un seul cube paginé pour tous les articles du mois. + const batchRows = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings); + out.push(...batchRows); + accMonthly(batchRows, m.label); + aggBatches++; + aggCode15Retries += code15Retries; + console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings)); + // Clear Date uniquement — on garde la sélection Article Code pour le mois suivant. + if (mi < total - 1) { + try { + await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); + } + catch (e) { + console.log(prefix + " — Clear Date ignoré: " + String(e?.message || e)); + } + } + console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " timings=" + timingSummary(monthTimings)); + } + return true; + } + catch (e) { + if (isEngineCode15(e)) { + console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots"); + out.length = savedLen; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + aggMonths = 0; + aggBatches = 0; + aggCode15Retries = 0; + try { + await rpc("Clear", {}, dfh); + if (fh !== -1) + await rpc("Clear", {}, fh); + } + catch { /* noop */ } + await sleep(qlikSettleMs); + return false; + } + throw e; + } + }; + // La passe de « rattrapage des mois vides » a été SUPPRIMÉE. + // + // Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. + // Or les master measures ignorent la sélection Date : le cube lui + // renvoyait le total de période, qu'elle recopiait dans chacun des + // mois manquants. D'où les plateaux identiques d'août à décembre + // (87 648 sur douze mois pour un article, etc.) et les tendances + // « -162 % » qu'ils fabriquaient. + // + // Un mois qu'on ne sait pas extraire doit rester ABSENT. + /** Pousse l'état courant vers Node (voir `cfCheckpoint`). */ + const pousserCheckpoint = async (etape) => { + try { + const fn = window.cfCheckpoint; + if (!fn) + return; + await fn({ rows: out, monthly: monthlyByCode }); + console.log("[qlik-pw] point de contrôle « " + etape + " » : " + out.length + " lignes"); + } + catch (e) { + console.log("[qlik-pw] point de contrôle « " + etape + " » ignoré : " + String(e?.message || e)); + } + }; + if (!noDateMode) { + // ÉTAPE 0 — poser le périmètre articles, une fois pour toutes. + // + // Le code fournisseur FF (base SQL / API) existe aussi dans Qlik : + // une seule valeur y désigne le même périmètre que les dizaines de + // milliers de codes articles qu'on envoyait jusqu'ici. La bascule + // n'est retenue que si la couverture mesurée le justifie, sinon on + // retombe sur la sélection par codes (cf. selectionnerParFournisseur). + parFournisseur = await selectionnerParFournisseur(); + const attendus = monthlyPayload.map((m) => m.label); + // Années de la fenêtre, la PLUS RÉCENTE D'ABORD — c'est l'usage de + // l'app (« je sélectionne 2026 »), et cela met les mois les plus + // utiles en tête des points de contrôle. + const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort((a, b) => b - a); + // ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre. + // + // Toutes les mesures de l'app sont bornées par `Type_Cal` + // (`Sum({} quantite)`), lui-même piloté par + // `Période`. C'est cette sélection — et elle seule — qui détermine + // quels mois sont atteignables. Sous la période par défaut, + // « Quantité N » ne couvre que l'année en cours : d'où les mois + // d'août à décembre vides quelle que soit la sélection de dates. + const couvertureMaster = await choisirPeriode(annees); + // ÉTAPE 2 — extraire, puis REFUSER tout résultat incomplet. + // + // Un cache partiel est pire que pas de cache : il se lit comme des + // ventes nulles. Les deux chemins sont donc validés sur le même + // critère — les 12 mois de la fenêtre, ou rien. + const moisManquants = () => { + const vus = new Set(); + for (const parMois of Object.values(monthlyByCode)) { + for (const [mois, v] of Object.entries(parMois)) { + if ((v?.qte ?? 0) > 0) + vus.add(mois); + } + } + return attendus.filter((m) => !vus.has(m)); + }; + const couvre12 = () => moisManquants().length === 0; + // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. + // + // Dans l'interface, on sélectionne « 2026 » et on obtient l'année + // plus sa comparaison N-1. C'est le mécanisme natif du modèle : + // `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc + // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis + // 2026 — pour reconstituer les 12 mois glissants. + // + // Chaque passe est faite SOUS la `Période` retenue : c'est elle qui + // donne son sens à `Type_Cal='N'`. Avec `Année=2025`, « Année à + // date » vaut l'année 2025 entière (elle est passée) ; avec + // `Année=2026`, elle s'arrête au mois courant. L'union des deux + // passes reconstitue donc bien les 12 mois glissants. + diag("extraction année par année : " + JSON.stringify(annees)); + let extraitParAnnee = false; + if (monthDim) { + try { + for (const annee of annees) { + const manquantsAvant = moisManquants(); + await monthDimPath(annee); + const manquantsApres = moisManquants(); + const gagnes = manquantsAvant.filter((m) => !manquantsApres.includes(m)); + diag(" année " + annee + " → mois de la fenêtre désormais servis : " + JSON.stringify(gagnes) + + " ; encore manquants : " + JSON.stringify(manquantsApres)); + await pousserCheckpoint("année " + annee); + if (manquantsApres.length === 0) + break; // fenêtre complète, inutile de continuer + } + extraitParAnnee = couvre12(); + if (!extraitParAnnee) { + diag("l'extraction année par année ne couvre pas les 12 mois (manquants : " + + JSON.stringify(moisManquants()) + ") — essai de l'agrégation directe"); + } + } + catch (e) { + diag("extraction année par année interrompue : " + String(e?.message || e)); + } + } + // REPLI 1 — mois par mois sur le champ date brut. + // + // Passe en premier car c'est le seul chemin sans pont de périodes : + // aucun mois n'y est « inatteignable », puisqu'un mois y est un + // filtre de dates et non un contexte de calendrier. + if (!extraitParAnnee && useExpr) { + try { + if (await moisParDateBrute()) { + extraitParAnnee = couvre12(); + if (extraitParAnnee) { + diag("extraction par date brute retenue — les " + attendus.length + " mois sont servis"); + await pousserCheckpoint("date brute complète"); + } + else { + diag("extraction par date brute incomplète (manquants : " + JSON.stringify(moisManquants()) + ")"); + } + } + } + catch (e) { + diag("extraction par date brute interrompue : " + String(e?.message || e)); + } + } + if (!extraitParAnnee) { + if (!(monthDim && useExpr && (await moisParExpressions()))) { + throw new Error("extraction 12 mois refusée : ni l'extraction année par année " + + "(couverture master annoncée = " + couvertureMaster + "/" + attendus.length + ") " + + "ni l'agrégation directe des faits n'ont couvert la fenêtre — mois manquants : " + + JSON.stringify(moisManquants())); + } + await pousserCheckpoint("expressions vérifiées"); + } + if (!couvre12()) { + throw new Error("extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) + + " n'est pas entièrement couverte (manquants : " + JSON.stringify(moisManquants()) + + ") — cache laissé inchangé"); + } + } + else if (noDateMode) { + // Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup). + // On sélectionne uniquement Article Code ; Date reste libre. + const timings = {}; + let code15Retries = 0; + console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois"); + if (codes.length && fh !== -1) { + const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); + console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn)); + } + const batchRows = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); + out.push(...batchRows); + aggMonths = 1; + aggBatches = 1; + aggCode15Retries = code15Retries; + console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + } + else if (selectAllCodes && (await trySelectAllCodesPath())) { + // Chemin rapide terminé avec succès (voir trySelectAllCodesPath). + // Si false (repli code 15), on tombe dans le chemin par lots ci-dessous. + } + else { + // Itération par mois × lots de codes : pour chaque mois, SelectValues Date + // (~30 serials), puis SelectValues Article Code par petits lots. Le cube + // reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik. + const total = monthlyPayload.length; + const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50]; + const fallbackLabel = fallbackSizes.join(">"); + const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1; + console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel); + for (let mi = 0; mi < total; mi++) { + const m = monthlyPayload[mi]; + const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; + const monthStart = nowMs(); + const monthTimings = {}; + aggMonths = mi + 1; + try { + // 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs). + const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { + qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), + qToggleMode: false, + qSoftLock: true, + }, dfh), monthTimings); + console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); + // Laisser l'Engine finaliser l'évaluation de la sélection Date + // avant la première SelectValues Article Code du 1er lot. + await sleep(qlikSettleMs); + // 2) Sélection Article Code par lots contrôlés. + let codeIndex = 0; + let batchNumber = 0; + let fallbackIndex = 0; + const totalCodes = codes.length || 0; + while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { + const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0; + const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : []; + const batchStart = codeIndex; + const batchEnd = codeIndex + batch.length; + const timings = {}; + let code15Retries = 0; + batchNumber++; + const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length; + try { + if (fh !== -1) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + // Laisser l'Engine appliquer le Clear avant la nouvelle sélection. + await sleep(qlikSettleMs); + const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { + qFieldValues: batch.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timings); + console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn)); + // Laisser l'Engine finaliser l'évaluation de la sélection + // avant de créer / interroger le cube (évite code 15 sur le 1er lot). + await sleep(qlikSettleMs); + } + const batchRows = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); + out.push(...batchRows); + accMonthly(batchRows, m.label); + aggBatches++; + aggCode15Retries += code15Retries; + console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + if (!codes.length) + break; + codeIndex = batchEnd; + fallbackIndex = 0; + } + catch (batchErr) { + const msg = String(batchErr?.message || batchErr); + const nextFallbackIndex = fallbackIndex + 1; + const nextSize = fallbackSizes[nextFallbackIndex]; + if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { + console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + fallbackIndex = nextFallbackIndex; + aggFallbackCount++; + continue; + } + console.error(batchPrefix + " — ABORT — " + msg); + throw new Error(batchPrefix + " — " + msg); + } + } + } + catch (monthErr) { + const msg = String(monthErr?.message || monthErr); + console.error(prefix + " — ABORT — " + msg); + throw new Error(prefix + " — " + msg); + } + // 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine). + if (mi < total - 1) { + try { + await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); + if (fh !== -1) + await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings); + } + catch (clearErr) { + console.log(prefix + " — Clear ignoré: " + String(clearErr?.message || clearErr)); + } + } + console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings)); + } + } + // La sonde de diagnostic du cube [Article Code, Mois] a été retirée : + // elle refaisait une sélection complète des codes et des 365 jours en + // fin de sync (des dizaines de secondes) alors que le découpage + // mensuel est validé et en production depuis. + ws.close(); + resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics }); + } + catch (e) { + try { + ws.close(); + } + catch { /* noop */ } + // On remonte ce qui a été extrait : les codes déjà traités ont + // toutes leurs données, seul le reliquat manque. + resolve({ ok: false, error: String(e?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics }); + } + })(); +}) diff --git a/server/src/qlik/js/recherche.js b/server/src/qlik/js/recherche.js new file mode 100644 index 0000000..1848efb --- /dev/null +++ b/server/src/qlik/js/recherche.js @@ -0,0 +1,412 @@ +// Script exécuté DANS la page Qlik (Chromium) par `search.rs`. +// Copie conforme de la fonction passée à `page.evaluate` dans +// `src/lib/qlik-search.ts` (types TypeScript retirés, code inchangé). +// Appelée avec un unique objet d'arguments ; résout un `InPageSearchResult`. +({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge, champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }) => new Promise((resolve) => { + /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ + const MAX_VALEURS_LUES = 10000; + /** + * Valeurs sélectionnées au maximum. + * + * Mesuré en production : sélectionner les 13 446 libellés contenant + * « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube + * (code 15). Le cube étant trié par quantité réseau décroissante et + * l'affichage plafonné à quelques dizaines de lignes, 300 libellés + * suffisent largement. + */ + const MAX_VALEURS_SELECTION = 300; + const PAGE_VALEURS = 5000; + const loc = window.location; + const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; + const ws = new WebSocket(url); + const trace = []; + let id = 0; + const pend = new Map(); + const rpc = (method, params, handle = -1) => new Promise((res, rej) => { + const i = ++id; + pend.set(i, { res, rej }); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); + }); + const ready = new Promise((res, rej) => { + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.method === "OnConnected") + return res(); + if (m.id && pend.has(m.id)) { + const x = pend.get(m.id); + pend.delete(m.id); + if (m.error) + x.rej(new Error(JSON.stringify(m.error))); + else + x.res(m.result); + } + }; + ws.onerror = () => rej(new Error("ws error (403 ?)")); + setTimeout(() => rej(new Error("ws timeout")), 30000); + }); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + /** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */ + const estCode15 = (e) => /"code"\s*:\s*15\b/.test(String(e?.message ?? e)); + const rpcRetry = async (method, params, handle, tentatives = 3) => { + let derniere; + for (let i = 1; i <= tentatives; i++) { + try { + return await rpc(method, params, handle); + } + catch (e) { + derniere = e; + if (!estCode15(e) || i === tentatives) + throw e; + trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives); + await sleep(settleMs * (i + 1)); + } + } + throw derniere; + }; + const normaliser = (s) => s + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const mots = normaliser(terme).split(" ").filter(Boolean); + (async () => { + try { + await ready; + const open = await rpc("OpenDoc", { qDocName: app }); + const doc = open.qReturn.qHandle; + // ─── 1. Inventaire des champs ──────────────────────────── + const fl = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "FieldList" }, + qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true }, + }, + }, doc); + const fll = await rpc("GetLayout", {}, fl.qReturn.qHandle); + const champs = (((fll.qLayout.qFieldList?.qItems) ?? []) + .map((f) => f.qName)); + const has = (nom, re) => re.test(nom); + const champCode = champs.find((c) => c === "Article Code") + ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) + ?? "Article Code"; + // Candidats libellé : surcharge d'env, puis noms connus de + // l'app FF, puis heuristique. On essaiera chacun dans l'ordre + // jusqu'à en trouver un qui ramène des valeurs. + const candidatsLibelle = [ + champLibelleForce, + ...champsLibellePreferes, + ...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)), + ...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)), + ].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i); + const champFournisseur = [ + champFournisseurForce, + ...champsFournisseurPreferes, + ...champs.filter((c) => has(c, /fournisseur/i)), + ...champs.filter((c) => has(c, /(fabricant|supplier)/i)), + ].find((c) => c && champs.includes(c)) ?? null; + trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur); + // ─── 2. Recherche + sélection sur le champ le plus adapté ─ + // + // `SearchListObjectFor` applique un OU entre les mots. On + // cherche donc mot par mot pour retenir le plus sélectif, + // puis on filtre en ET côté client avant de sélectionner. + const chercherEtSelectionner = async (champ) => { + const lo = await rpcRetry("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-search-lb" }, + qListObjectDef: { + qDef: { qFieldDefs: [champ] }, + qInitialDataFetch: [], + }, + }, + }, doc); + const ret = lo.qReturn; + const loId = String(ret.qGenericId ?? ret.qId ?? ""); + const lh = ret.qHandle; + try { + // 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur). + let meilleurMot = terme; + let meilleurNb = Number.POSITIVE_INFINITY; + for (const mot of (mots.length > 1 ? mots : [terme])) { + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh); + const lay = await rpcRetry("GetLayout", {}, lh); + const nb = (lay.qLayout.qListObject?.qSize?.qcy) ?? 0; + trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)"); + if (nb > 0 && nb < meilleurNb) { + meilleurNb = nb; + meilleurMot = mot; + } + } + if (!Number.isFinite(meilleurNb) || meilleurNb === 0) + return { retenues: 0, brut: 0, tropLarge: false }; + // 2b. Relecture des valeurs du mot retenu. On relance la + // recherche systématiquement : la boucle ci-dessus a pu + // laisser le list object sur un autre mot. + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh); + const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES); + const elems = []; + let lues = 0; + for (let top = 0; top < aLire; top += PAGE_VALEURS) { + const d = await rpcRetry("GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }], + }, lh); + const matrix = (d.qDataPages?.[0]?.qMatrix) ?? []; + if (matrix.length === 0) + break; + lues += matrix.length; + for (const ligne of matrix) { + const cell = ligne[0]; + const texte = String(cell?.qText ?? ""); + const elem = cell?.qElemNumber; + if (elem == null || elem < 0) + continue; + // Filtre ET : tous les mots du terme présents. + const n = normaliser(texte); + if (!mots.every((m) => n.includes(m))) + continue; + elems.push(elem); + if (elems.length >= MAX_VALEURS_SELECTION) + break; + } + if (elems.length >= MAX_VALEURS_SELECTION) + break; + } + trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET"); + if (elems.length === 0) { + return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; + } + // 2c. Sélection par numéro d'élément : exact, aucun + // ré-appariement de texte possible. + await rpcRetry("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: elems, + qToggleMode: false, + qSoftLock: true, + }, lh); + await sleep(settleMs); + return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; + } + finally { + if (loId) { + try { + await rpc("DestroySessionObject", { qId: loId }, doc); + } + catch { /* noop */ } + } + } + }; + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + let champChoisi = null; + let tropLarge = false; + for (const candidat of candidatsLibelle) { + const r = await chercherEtSelectionner(candidat); + tropLarge = tropLarge || r.tropLarge; + if (r.retenues > 0) { + champChoisi = candidat; + break; + } + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + } + if (!champChoisi) { + // Repli code centrale (terme saisi = un code). + const r = await chercherEtSelectionner(champCode); + if (r.retenues > 0) + champChoisi = champCode; + tropLarge = tropLarge || r.tropLarge; + } + if (!champChoisi) { + ws.close(); + return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace }); + } + trace.push("champ retenu = " + champChoisi); + // Trace des sélections réellement actives : si elle est vide, + // la sélection n'a pas pris côté Engine. + try { + const cs = await rpc("CreateSessionObject", { + qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} }, + }, doc); + const csl = await rpc("GetLayout", {}, cs.qReturn.qHandle); + const sel = ((csl.qLayout + .qSelectionObject?.qSelections) ?? []) + .map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`); + trace.push("sélections actives=" + JSON.stringify(sel)); + } + catch (e) { + trace.push("sélections actives: lecture impossible (" + String(e?.message || e) + ")"); + } + // ─── 3. Fenêtre 12 mois glissants ──────────────────────── + // Sélectionnée APRÈS les libellés : restreindre les dates avant + // la recherche masquerait les articles non vendus sur la période. + if (serialsDate.length > 0) { + try { + const gd = await rpc("GetField", { qFieldName: "Date" }, doc); + const dh = gd.qReturn.qHandle; + for (let i = 0; i < serialsDate.length; i += 500) { + const lot = serialsDate.slice(i, i + 500); + await rpcRetry("SelectValues", { + qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, dh); + } + await sleep(settleMs); + trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)"); + } + catch (e) { + trace.push("sélection Date impossible, mesures sur toute la période : " + String(e?.message || e)); + } + } + // ─── 4. Identité + mesures réseau, dans UN SEUL cube ───── + // + // Les mesures sont dans le même cube que l'identité, et donc + // dans la même session Qlik : une seconde session concurrente + // pour les extraire faisait abandonner le moteur (code 15). + // + // Les master measures sont résolues par TITRE : le qLibraryId + // fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même + // approche que l'extraction de la Grille. + const norm = (x) => x.replace(/\s+/g, " ").trim().toLowerCase(); + const ml = await rpcRetry("CreateSessionObject", { + qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } }, + }, doc); + const mll = await rpcRetry("GetLayout", {}, ml.qReturn.qHandle); + const items = ((mll.qLayout.qMeasureList?.qItems) ?? []); + const parTitre = new Map(); + for (const it of items) { + const t = it.qMeta?.title ?? it.qData?.title ?? ""; + if (t) + parTitre.set(norm(t), it.qInfo.qId); + } + const mesure = (titre, repli) => parTitre.get(norm(titre)) ?? repli; + const idCa = mesure("CA N", mCa); + const idQte = mesure("Quantité N", mQte); + const idNbMag = mesure("Magasin Ventes Nb N", mNbMag); + const idCaMag = mesure("CA par Magasin N", mCaMag); + const idMarge = mesure("Marge % N", mMarge); + const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; + const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null; + const dims = [{ qLibraryId: dim }]; + const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1; + const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1; + const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1; + const nbDims = dims.length; + // Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %. + const idxQteCol = nbDims + 1; + const largeur = nbDims + 5; + // Cube trié par quantité réseau décroissante : la première page + // contient donc directement les meilleures ventes du réseau, et + // on n'a pas besoin de lire tout l'ensemble sélectionné. + const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur))); + const defCube = (trie) => ({ + qProp: { + qInfo: { qType: "cf-search" }, + qHyperCubeDef: { + qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })), + qMeasures: [ + { qLibraryId: idCa }, + trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte }, + { qLibraryId: idNbMag }, + { qLibraryId: idCaMag }, + { qLibraryId: idMarge }, + ], + ...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}), + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }], + qSuppressZero: false, + qSuppressMissing: true, + }, + }, + }); + let cube; + try { + cube = await rpcRetry("CreateSessionObject", defCube(true), doc); + } + catch (e) { + // Le tri par mesure est un confort (meilleures ventes en + // tête) : si l'Engine refuse cette forme, on repart sans. + if (estCode15(e)) + throw e; + trace.push("tri par quantité refusé, cube non trié : " + String(e?.message || e)); + cube = await rpcRetry("CreateSessionObject", defCube(false), doc); + } + const ch = cube.qReturn.qHandle; + const layout = await rpcRetry("GetLayout", {}, ch); + const hc = layout.qLayout.qHyperCube; + const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; + // ─── 4. Garde-fou : le résultat DOIT correspondre au terme ── + const termeCode = terme.trim().toLowerCase(); + const correspond = (code, libelle) => { + if (mots.length === 0) + return true; + const c = code.toLowerCase(); + if (c === termeCode || c.startsWith(termeCode)) + return true; + if (!libelle) + return champChoisi === champCode; + const l = normaliser(libelle); + return mots.every((m) => l.includes(m)); + }; + const texte = (r, i) => { + if (i < 0) + return ""; + const v = String(r[i]?.qText ?? "").trim(); + return v === "-" ? "" : v; + }; + const parCode = new Map(); + let rejetees = 0; + for (const r of matrix) { + const code = String(r[0]?.qText ?? "").trim(); + if (!code || code === "-") + continue; + const codeAlt = texte(r, idxAlt); + const libelle = texte(r, idxLibelle); + const fournisseur = texte(r, idxFournisseur); + if (!correspond(code, libelle)) { + rejetees++; + continue; + } + const nombre = (i) => Number(r[nbDims + i]?.qNum) || 0; + const ligne = [ + code, codeAlt, libelle, fournisseur, + nombre(0), nombre(1), nombre(2), nombre(3), nombre(4), + ]; + const existant = parCode.get(code); + if (!existant) { + parCode.set(code, ligne); + continue; + } + // Même article chez plusieurs fournisseurs : une seule ligne. + // Les mesures sont identiques (elles ne dépendent pas de la + // dimension fournisseur), on complète juste les libellés. + if (!existant[1] && codeAlt) + existant[1] = codeAlt; + if (!existant[2] && libelle) + existant[2] = libelle; + if (!existant[3] && fournisseur) + existant[3] = fournisseur; + } + const uniques = [...parCode.values()]; + const rows = uniques.slice(0, limite); + trace.push("cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length + + " rejetées(hors terme)=" + rejetees + + " articles uniques=" + uniques.length + " retenus=" + rows.length); + if (rows.length > 0) { + trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3))); + } + ws.close(); + resolve({ + ok: true, rows, champUtilise: champChoisi, total: uniques.length, + lues: matrix.length, rejetees, tropLarge, trace, + }); + } + catch (e) { + try { + ws.close(); + } + catch { /* noop */ } + resolve({ ok: false, error: String(e?.message || e), trace }); + } + })(); +}) diff --git a/server/src/qlik/jsnum.rs b/server/src/qlik/jsnum.rs new file mode 100644 index 0000000..50448aa --- /dev/null +++ b/server/src/qlik/jsnum.rs @@ -0,0 +1,110 @@ +//! Nombres « à la JavaScript » pour la (dé)sérialisation des métriques Qlik. +//! +//! `JSON.stringify(12)` donne `12`, là où serde_json écrit `12.0` pour un `f64` ; +//! `NaN` / `Infinity` deviennent `null`. Les métriques partent telles quelles +//! vers l'interface et dans des colonnes `jsonb` : on garde l'écriture du TS. +//! En lecture, on tolère `null` (NaN côté page) et les chaînes numériques. + +use serde::{Deserialize, Deserializer, Serializer}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Valeur JSON d'un nombre, écrite comme `JSON.stringify`. +pub fn valeur(v: f64) -> Value { + if !v.is_finite() { + Value::Null + } else if v.fract() == 0.0 && v.abs() < 9.007_199_254_740_992e15 { + Value::from(v as i64) + } else { + serde_json::Number::from_f64(v).map(Value::Number).unwrap_or(Value::Null) + } +} + +/// `String(v)` en JS (journaux et messages). +pub fn texte(v: f64) -> String { + if v.is_nan() { + "NaN".into() + } else if v.is_infinite() { + if v > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + } + } else { + valeur(v).to_string() + } +} + +/// `Number(v)` sur une valeur JSON (NaN si non convertible). +pub fn nombre(v: &Value) -> f64 { + match v { + Value::Number(n) => n.as_f64().unwrap_or(f64::NAN), + Value::String(s) => super::date_range::js_number(s), + Value::Bool(b) => { + if *b { + 1.0 + } else { + 0.0 + } + } + Value::Null => 0.0, + _ => f64::NAN, + } +} + +/// `Number(v) || 0`. +pub fn nombre_ou_0(v: Option<&Value>) -> f64 { + let n = v.map(nombre).unwrap_or(f64::NAN); + if n.is_nan() { + 0.0 + } else { + n + } +} + +pub fn ser(v: &f64, s: S) -> Result { + serde::Serialize::serialize(&valeur(*v), s) +} + +pub fn ser_opt(v: &Option, s: S) -> Result { + match v { + Some(x) => ser(x, s), + None => s.serialize_none(), + } +} + +pub fn ser_map(v: &Option>, s: S) -> Result { + match v { + Some(m) => { + let obj: serde_json::Map = m.iter().map(|(k, x)| (k.clone(), valeur(*x))).collect(); + serde::Serialize::serialize(&obj, s) + } + None => s.serialize_none(), + } +} + +/// `null` → NaN (une valeur NaN de la page devient `null` en JSON). +fn depuis_valeur(v: &Value) -> f64 { + match v { + Value::Null => f64::NAN, + autre => nombre(autre), + } +} + +pub fn de<'de, D: Deserializer<'de>>(d: D) -> Result { + Ok(depuis_valeur(&Value::deserialize(d)?)) +} + +pub fn de_opt<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(match Value::deserialize(d)? { + Value::Null => None, + v => Some(nombre(&v)), + }) +} + +pub fn de_map<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + Ok(match Value::deserialize(d)? { + Value::Object(m) => Some(m.iter().map(|(k, v)| (k.clone(), depuis_valeur(v))).collect()), + _ => None, + }) +} diff --git a/server/src/qlik/mod.rs b/server/src/qlik/mod.rs index c29a7c9..3e9c138 100644 --- a/server/src/qlik/mod.rs +++ b/server/src/qlik/mod.rs @@ -1,11 +1,52 @@ -//! Module `qlik` — à porter. +//! Module `qlik` — intégration Qlik Sense : données réseau (~270 magasins). +//! +//! - [`client`] : configuration, types partagés (`NetworkMetric`…), session NTLM ; +//! - [`ntlm`] : messages NTLM identiques à `httpntlm` ; +//! - [`browser`] : Chromium partagé, file d'extraction unique, page pilotée en CDP ; +//! - [`playwright`] : extraction réseau 12 mois (script in-page `js/extraction.js`) ; +//! - [`search`] : recherche produit dans Qlik (script in-page `js/recherche.js`) ; +//! - [`date_range`], [`validation`] : logique pure (fenêtre, contrôles) ; +//! - [`network_cache`] : table `qlik_network_metrics` ; +//! - [`sync`] : route `/api/qlik/sync` (jobs asynchrones). + +pub mod browser; +pub mod client; +pub mod date_range; +pub mod jsnum; +pub mod network_cache; +pub mod ntlm; +pub mod playwright; +pub mod search; +pub mod sync; +pub mod validation; + +#[cfg(test)] +mod tests_navigateur; + +// Points d'entrée pour les autres modules (grid, produits, admin…). +#[allow(unused_imports)] +pub use client::{get_qlik_config, make_xrfkey, qlik_ntlm_session, NetworkMetric, NetworkProduct, QlikConfig, QlikMonthMetrics, QlikSession}; +#[allow(unused_imports)] +pub use date_range::{ + build_grid_network_qlik_date_filter, build_grid_network_qlik_date_filter_now, chunk_serials, env_months_back, get_month_ranges, + iso_date_to_qlik_serial, qlik_serial_to_iso_date, QlikDateFilter, QLIK_MONTHS_BACK_DEFAULT, QLIK_MONTHS_BACK_MAX, QLIK_MONTHS_BACK_MIN, +}; +#[allow(unused_imports)] +pub use network_cache::{get_network_metrics_by_code_centrale, upsert_network_metrics, NetworkMetricCached}; +#[allow(unused_imports)] +pub use playwright::fetch_network_metrics_playwright; +#[allow(unused_imports)] +pub use search::{search_qlik_articles, QlikArticleMatch, QlikSearchOutcome, QLIK_SEARCH_MAX_RESULTS}; +#[allow(unused_imports)] +pub use validation::{assainir_metrique, controler_extraction, ControleExtraction, OptionsControle}; use crate::rpc::Handler; +use axum::routing::get; use axum::Router; /// Routes HTTP du module. pub fn router() -> Router { - Router::new() + Router::new().route("/api/qlik/sync", get(sync::get).post(sync::post)) } /// Actions (`POST /api/actions/{nom}`) du module. diff --git a/server/src/qlik/network_cache.rs b/server/src/qlik/network_cache.rs new file mode 100644 index 0000000..1766119 --- /dev/null +++ b/server/src/qlik/network_cache.rs @@ -0,0 +1,220 @@ +//! Cache des métriques réseau Qlik, table `qlik_network_metrics` +//! (port de `src/lib/qlik-network-cache.ts`). +//! +//! Lecture par lot (jointure grille) + upsert en masse (route de sync). + +use super::client::{NetworkMetric, QlikMonthMetrics}; +use super::jsnum; +use crate::db; +use serde::{Deserialize, Serialize}; +use sqlx::{Postgres, QueryBuilder, Row}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkMetricCached { + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub nb_magasins_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_par_magasin_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub marge_pct_reseau: f64, + /// Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. + #[serde(serialize_with = "jsnum::ser_map", deserialize_with = "jsnum::de_map")] + pub qte_by_month: Option>, + /// Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. + /// null pour les lignes synchronisées avant l'ajout de la colonne — l'UI + /// retombe alors sur `qteByMonth`. + pub metrics_by_month: Option>, + /// Période couverte par l'extraction (ex "2025-03_2026-02"). + pub periode: Option, + /// Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. + pub libelle_reseau: Option, + /// Fournisseur de l'article côté Qlik, même origine que `libelle_reseau`. + pub fournisseur_reseau: Option, + pub fetched_at: Option, +} + +/// Lit les métriques réseau pour une liste de codes centraux. +/// +/// La `HashMap` se sérialise comme l'objet `Object.fromEntries(map)` du TS. +pub async fn get_network_metrics_by_code_centrale(codes: &[String]) -> anyhow::Result> { + let mut out = HashMap::new(); + let mut vus = HashSet::new(); + let uniques: Vec = codes.iter().filter(|c| !c.is_empty() && vus.insert(c.as_str())).cloned().collect(); + if uniques.is_empty() { + return Ok(out); + } + let pool = db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + + // Découpage conservé du TS : `inArray` y produisait un paramètre lié par + // valeur, or le protocole PostgreSQL en accepte 65535 au maximum — un gros + // fournisseur dépasse 130 000 codes. (Ici un seul tableau lié par lot.) + const CHUNK: usize = 2000; + for lot in uniques.chunks(CHUNK) { + let rows = sqlx::query( + r#"SELECT "code_centrale", "ca_reseau", "qte_reseau", "nb_magasins_reseau", "ca_par_magasin_reseau", + "marge_pct_reseau", "periode", "qte_by_month", "metrics_by_month", "libelle_reseau", + "fournisseur_reseau", "fetched_at" + FROM "qlik_network_metrics" WHERE "code_centrale" = ANY($1)"#, + ) + .bind(lot) + .fetch_all(&pool) + .await?; + for r in rows { + let code: String = r.try_get("code_centrale")?; + let json = |col: &str| r.try_get::, _>(col).ok().flatten(); + let qte_by_month = json("qte_by_month").and_then(|v| match v { + serde_json::Value::Object(m) => Some(m.iter().map(|(k, x)| (k.clone(), jsnum::nombre(x))).collect()), + _ => None, + }); + let metrics_by_month = json("metrics_by_month").and_then(|v| serde_json::from_value(v).ok()); + let fetched_at = r.try_get::, _>("fetched_at").ok().flatten().map(db::iso); + out.insert( + code, + NetworkMetricCached { + ca_reseau: db::f64_or0(&r, "ca_reseau"), + qte_reseau: db::f64_or0(&r, "qte_reseau"), + nb_magasins_reseau: db::f64_or0(&r, "nb_magasins_reseau"), + ca_par_magasin_reseau: db::f64_or0(&r, "ca_par_magasin_reseau"), + marge_pct_reseau: db::f64_or0(&r, "marge_pct_reseau"), + qte_by_month, + metrics_by_month, + periode: r.try_get("periode").ok().flatten(), + libelle_reseau: r.try_get("libelle_reseau").ok().flatten(), + fournisseur_reseau: r.try_get("fournisseur_reseau").ok().flatten(), + fetched_at, + }, + ); + } + } + Ok(out) +} + +/// Upsert en masse des métriques réseau (depuis Qlik). Renvoie le nombre de lignes envoyées. +pub async fn upsert_network_metrics(metrics: &[NetworkMetric]) -> anyhow::Result { + if metrics.is_empty() { + return Ok(0); + } + let pool = db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + let now = chrono::Utc::now().naive_utc(); + + // Upsert par paquets pour éviter les requêtes trop volumineuses. + const CHUNK: usize = 500; + let mut count = 0; + for lot in metrics.chunks(CHUNK) { + let mut qb: QueryBuilder = QueryBuilder::new( + r#"INSERT INTO "qlik_network_metrics" ("code_centrale", "ca_reseau", "qte_reseau", "nb_magasins_reseau", + "ca_par_magasin_reseau", "marge_pct_reseau", "periode", "qte_by_month", "metrics_by_month", + "libelle_reseau", "fournisseur_reseau", "fetched_at") "#, + ); + qb.push_values(lot, |mut b, m| { + let qte_by_month = + m.qte_by_month.as_ref().map(|q| serde_json::Value::Object(q.iter().map(|(k, v)| (k.clone(), jsnum::valeur(*v))).collect())); + let metrics_by_month = m.metrics_by_month.as_ref().and_then(|x| serde_json::to_value(x).ok()); + // Les nombres passent en texte vers `numeric`, comme `String(m.caReseau)` côté TS. + b.push_bind(m.code_centrale.clone()) + .push_bind(jsnum::texte(m.ca_reseau)) + .push_unseparated("::numeric") + .push_bind(jsnum::texte(m.qte_reseau)) + .push_unseparated("::numeric") + .push_bind(m.nb_magasins_reseau) + .push_unseparated("::float8::integer") + .push_bind(jsnum::texte(m.ca_par_magasin_reseau)) + .push_unseparated("::numeric") + .push_bind(jsnum::texte(m.marge_pct_reseau)) + .push_unseparated("::numeric") + .push_bind(m.periode.clone()) + .push_bind(qte_by_month) + .push_unseparated("::jsonb") + .push_bind(metrics_by_month) + .push_unseparated("::jsonb") + .push_bind(m.libelle_reseau.clone()) + .push_bind(m.fournisseur_reseau.clone()) + .push_bind(now); + }); + qb.push( + r#" ON CONFLICT ("code_centrale") DO UPDATE SET + "ca_reseau" = excluded.ca_reseau, + "qte_reseau" = excluded.qte_reseau, + "nb_magasins_reseau" = excluded.nb_magasins_reseau, + "ca_par_magasin_reseau" = excluded.ca_par_magasin_reseau, + "marge_pct_reseau" = excluded.marge_pct_reseau, + "periode" = excluded.periode, + "qte_by_month" = COALESCE(excluded.qte_by_month, "qlik_network_metrics"."qte_by_month"), + "metrics_by_month" = COALESCE(excluded.metrics_by_month, "qlik_network_metrics"."metrics_by_month"), + "libelle_reseau" = COALESCE(excluded.libelle_reseau, "qlik_network_metrics"."libelle_reseau"), + "fournisseur_reseau" = COALESCE(excluded.fournisseur_reseau, "qlik_network_metrics"."fournisseur_reseau"), + "fetched_at" = excluded.fetched_at"#, + ); + // Le détail mensuel n'est produit que par l'extraction complète + // (dimension Mois). La recherche produit, elle, n'a que les agrégats : + // elle ne doit pas effacer un mensuel déjà extrait (COALESCE ci-dessus). + // Seule la recherche produit connaît le libellé / le fournisseur réseau : + // une sync fournisseur ne doit pas les effacer. + qb.build().execute(&pool).await?; + count += lot.len(); + } + Ok(count) +} + +#[cfg(test)] +mod tests { + //! Contre la base locale : `DATABASE_URL=postgres://postgres:postgres@localhost/collectflow + //! cargo test -- --ignored cache_reseau`. + use super::*; + + #[tokio::test] + #[ignore] + async fn cache_reseau_upsert_et_lecture() { + if std::env::var("DATABASE_URL").is_err() { + std::env::set_var("DATABASE_URL", "postgres://postgres:postgres@localhost/collectflow"); + } + let code = format!("TEST{}", rand::random::() % 1_000_000); + let mut m = NetworkMetric { + code_centrale: code.clone(), + ca_reseau: 1234.567, + qte_reseau: 42.0, + nb_magasins_reseau: 12.0, + ca_par_magasin_reseau: 102.88, + marge_pct_reseau: 0.3215, + periode: Some("2025-10_2026-09".into()), + qte_by_month: Some(BTreeMap::from([("2026-08".into(), 20.0), ("2026-09".into(), 22.0)])), + metrics_by_month: Some(BTreeMap::from([( + "2026-09".into(), + QlikMonthMetrics { qte: 22.0, ca: Some(600.5), nb_mag: Some(7.0), ca_mag: Some(85.78), marge_pct: Some(0.31) }, + )])), + libelle_reseau: Some("TAPIS ANTIDERAPANT".into()), + fournisseur_reseau: None, + }; + assert_eq!(upsert_network_metrics(std::slice::from_ref(&m)).await.unwrap(), 1); + + // Une sync sans mensuel ni libellé ne doit pas effacer ceux déjà connus. + m.qte_by_month = None; + m.metrics_by_month = None; + m.libelle_reseau = None; + m.qte_reseau = 50.0; + upsert_network_metrics(std::slice::from_ref(&m)).await.unwrap(); + + let lu = get_network_metrics_by_code_centrale(&[code.clone(), code.clone(), String::new()]).await.unwrap(); + let c = &lu[&code]; + assert_eq!(c.ca_reseau, 1234.57); + assert_eq!(c.qte_reseau, 50.0); + assert_eq!(c.nb_magasins_reseau, 12.0); + assert_eq!(c.marge_pct_reseau, 0.3215); + assert_eq!(c.qte_by_month.as_ref().unwrap()["2026-09"], 22.0); + assert_eq!(c.metrics_by_month.as_ref().unwrap()["2026-09"].nb_mag, Some(7.0)); + assert_eq!(c.libelle_reseau.as_deref(), Some("TAPIS ANTIDERAPANT")); + assert!(c.fetched_at.as_deref().unwrap().ends_with('Z')); + let json = serde_json::to_string(c).unwrap(); + assert!(json.contains(r#""qteByMonth":{"2026-08":20,"2026-09":22}"#), "{json}"); + assert!(json.contains(r#""fournisseurReseau":null"#), "{json}"); + + let pool = db::pool().unwrap(); + sqlx::query("DELETE FROM qlik_network_metrics WHERE code_centrale = $1").bind(&code).execute(&pool).await.unwrap(); + } +} diff --git a/server/src/qlik/ntlm.rs b/server/src/qlik/ntlm.rs new file mode 100644 index 0000000..b30e095 --- /dev/null +++ b/server/src/qlik/ntlm.rs @@ -0,0 +1,435 @@ +//! Messages NTLM — traduction octet pour octet de `httpntlm/ntlm.js` (v1.8.13), +//! le module npm qu'utilisait `qlik-client.ts`. +//! +//! Le serveur Qlik a été validé avec exactement ces messages (login `FFSCH`, +//! domaine VIDE, comme `requests_ntlm("FFSCH", pwd)` ; forcer `FOIRFOUILLE` → 401). +//! On reproduit donc à l'identique : +//! - type 1 : drapeaux `NTLM_TYPE1_FLAGS` (moins « OEM domain supplied » sans +//! domaine), version 5.1 build 2600, domaine et poste passés par `escape()` +//! puis mis en majuscules ; +//! - type 3 : NTLMv2 dès que le serveur annonce « extended security » ET +//! fournit un `targetInfo` ; NTLM2 Session Response si « extended security » +//! sans `targetInfo` ; sinon réponses LM/NT v1 (DES). +//! +//! Les tests comparent la sortie aux messages produits par `ntlm.js` sous Node +//! avec aléa et horloge figés. + +use base64::Engine as _; +use des::cipher::{BlockEncrypt, KeyInit}; +use hmac::{Hmac, Mac}; +use md4::{Digest, Md4}; + +const NEGOTIATE_UNICODE: u32 = 0x0000_0001; +const NEGOTIATE_OEM: u32 = 0x0000_0002; +const REQUEST_TARGET: u32 = 0x0000_0004; +const NEGOTIATE_NTLM: u32 = 0x0000_0200; +const NEGOTIATE_OEM_DOMAIN_SUPPLIED: u32 = 0x0000_1000; +const NEGOTIATE_OEM_WORKSTATION_SUPPLIED: u32 = 0x0000_2000; +const NEGOTIATE_ALWAYS_SIGN: u32 = 0x0000_8000; +const NEGOTIATE_EXTENDED_SECURITY: u32 = 0x0008_0000; +const NEGOTIATE_TARGET_INFO: u32 = 0x0080_0000; +const NEGOTIATE_VERSION: u32 = 0x0200_0000; +const NEGOTIATE_128: u32 = 0x2000_0000; +const NEGOTIATE_56: u32 = 0x8000_0000; + +const TYPE1_FLAGS: u32 = NEGOTIATE_UNICODE + + NEGOTIATE_OEM + + REQUEST_TARGET + + NEGOTIATE_NTLM + + NEGOTIATE_OEM_DOMAIN_SUPPLIED + + NEGOTIATE_OEM_WORKSTATION_SUPPLIED + + NEGOTIATE_ALWAYS_SIGN + + NEGOTIATE_EXTENDED_SECURITY + + NEGOTIATE_VERSION + + NEGOTIATE_128 + + NEGOTIATE_56; + +const TYPE2_FLAGS: u32 = NEGOTIATE_UNICODE + + REQUEST_TARGET + + NEGOTIATE_NTLM + + NEGOTIATE_ALWAYS_SIGN + + NEGOTIATE_EXTENDED_SECURITY + + NEGOTIATE_TARGET_INFO + + NEGOTIATE_VERSION + + NEGOTIATE_128 + + NEGOTIATE_56; + +/// Version annoncée par `ntlm.js` : Windows XP (5.1 build 2600), révision NTLM 15. +const VERSION: [u8; 8] = [5, 1, 0x28, 0x0a, 0, 0, 0, 15]; + +/// `escape()` JavaScript (unités UTF-16, `%XX` / `%uXXXX`). +pub fn js_escape(s: &str) -> String { + let mut out = String::new(); + for u in s.encode_utf16() { + let c = char::from_u32(u as u32).filter(|c| c.is_ascii_alphanumeric() || "@*_+-./".contains(*c)); + match c { + Some(c) => out.push(c), + None if u < 256 => out.push_str(&format!("%{u:02X}")), + None => out.push_str(&format!("%u{u:04X}")), + } + } + out +} + +fn utf16le(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() +} + +/// Encodage `'ascii'` de Node : octet de poids faible de chaque unité UTF-16. +fn node_ascii(s: &str) -> Vec { + s.encode_utf16().map(|u| (u & 0xff) as u8).collect() +} + +fn push_u16(buf: &mut Vec, v: usize) { + buf.extend_from_slice(&(v as u16).to_le_bytes()); +} + +fn push_u32(buf: &mut Vec, v: usize) { + buf.extend_from_slice(&(v as u32).to_le_bytes()); +} + +/// Message de négociation (type 1), préfixé `NTLM `. +pub fn create_type1_message(domain: &str, workstation: &str) -> String { + let domain = js_escape(&domain.to_uppercase()); + let workstation = js_escape(&workstation.to_uppercase()); + const BODY_LENGTH: usize = 40; + let mut flags = TYPE1_FLAGS; + if domain.is_empty() { + flags -= NEGOTIATE_OEM_DOMAIN_SUPPLIED; + } + let mut buf = Vec::with_capacity(BODY_LENGTH + domain.len() + workstation.len()); + buf.extend_from_slice(b"NTLMSSP\0"); + push_u32(&mut buf, 1); + buf.extend_from_slice(&flags.to_le_bytes()); + push_u16(&mut buf, domain.len()); + push_u16(&mut buf, domain.len()); + push_u32(&mut buf, BODY_LENGTH + workstation.len()); + push_u16(&mut buf, workstation.len()); + push_u16(&mut buf, workstation.len()); + push_u32(&mut buf, BODY_LENGTH); + buf.extend_from_slice(&VERSION); + buf.extend_from_slice(workstation.as_bytes()); + buf.extend_from_slice(domain.as_bytes()); + format!("NTLM {}", base64::engine::general_purpose::STANDARD.encode(buf)) +} + +/// Message de défi (type 2) envoyé par le serveur. +#[derive(Debug, Clone)] +pub struct Type2Message { + pub negotiate_flags: u32, + pub server_challenge: [u8; 8], + /// Présent dès que le serveur annonce `NegotiateTargetInfo` (même vide : + /// un `Buffer` vide est « vrai » en JS, d'où le choix de NTLMv2). + pub target_info: Option>, +} + +fn slice_clamp(buf: &[u8], debut: usize, fin: usize) -> Vec { + let debut = debut.min(buf.len()); + let fin = fin.clamp(debut, buf.len()); + buf[debut..fin].to_vec() +} + +fn decode_base64_tolerant(s: &str) -> Vec { + // Node ignore les caractères hors alphabet et le remplissage manquant. + let propre: String = s.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '+' || *c == '/').collect(); + base64::engine::general_purpose::STANDARD_NO_PAD.decode(propre.as_bytes()).unwrap_or_default() +} + +/// Lit le message type 2 d'un en-tête `WWW-Authenticate` (`NTLM `). +pub fn parse_type2_message(raw: &str) -> anyhow::Result { + let Some(pos) = raw.find("NTLM ") else { + anyhow::bail!("Couldn't find NTLM in the message type2 coming from the server"); + }; + // Plusieurs défis joints par « , » : on s'arrête au premier séparateur. + let b64 = raw[pos + 5..].split([',', ' ']).next().unwrap_or("").trim(); + if b64.is_empty() { + anyhow::bail!("Couldn't find NTLM in the message type2 coming from the server"); + } + let buf = decode_base64_tolerant(b64); + let lire_u16 = |o: usize| buf.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize).unwrap_or(0); + let lire_u32 = |o: usize| buf.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0); + if buf.len() < 32 || lire_u16(8) != 2 { + anyhow::bail!("Server didn't return a type 2 message"); + } + let negotiate_flags = lire_u32(20); + let mut server_challenge = [0u8; 8]; + server_challenge.copy_from_slice(&buf[24..32]); + let target_info = if negotiate_flags & NEGOTIATE_TARGET_INFO != 0 { + let len = lire_u16(40); + let off = lire_u32(44) as usize; + Some(slice_clamp(&buf, off, off + len)) + } else { + None + }; + Ok(Type2Message { negotiate_flags, server_challenge, target_info }) +} + +/// 7 octets → clé DES de 8 octets (un bit nul inséré tous les 7 bits). +fn insert_zeros_every_7_bits(src: &[u8]) -> [u8; 8] { + let mut bits = Vec::with_capacity(64); + for b in src.iter().take(7) { + for i in (0..8).rev() { + bits.push((b >> i) & 1); + } + } + let mut out = [0u8; 8]; + for (k, octet) in out.iter_mut().enumerate() { + let mut v = 0u8; + for j in 0..7 { + v = (v << 1) | bits.get(k * 7 + j).copied().unwrap_or(0); + } + *octet = v << 1; + } + out +} + +fn des_encrypt(key7: &[u8], data: &[u8]) -> [u8; 8] { + let key = insert_zeros_every_7_bits(key7); + let cipher = des::Des::new_from_slice(&key).expect("clé DES de 8 octets"); + let mut block = des::cipher::generic_array::GenericArray::clone_from_slice(&data[..8]); + cipher.encrypt_block(&mut block); + block.into() +} + +fn create_lm_hashed_password_v1(password: &str) -> Vec { + let bytes = node_ascii(&password.to_uppercase()); + let mut padded = [0u8; 14]; + let n = bytes.len().min(14); + padded[..n].copy_from_slice(&bytes[..n]); + let mut out = Vec::with_capacity(16); + out.extend_from_slice(&des_encrypt(&padded[0..7], b"KGS!@#$%")); + out.extend_from_slice(&des_encrypt(&padded[7..14], b"KGS!@#$%")); + out +} + +fn create_nt_hashed_password_v1(password: &str) -> Vec { + Md4::digest(utf16le(password)).to_vec() +} + +fn calc_resp(password_hash: &[u8], server_challenge: &[u8]) -> Vec { + let mut padded = [0u8; 21]; + let n = password_hash.len().min(21); + padded[..n].copy_from_slice(&password_hash[..n]); + let mut out = Vec::with_capacity(24); + for k in 0..3 { + out.extend_from_slice(&des_encrypt(&padded[k * 7..k * 7 + 7], &server_challenge[..8])); + } + out +} + +fn hmac_md5(key: &[u8], data: &[u8]) -> Vec { + let mut mac = as Mac>::new_from_slice(key).expect("HMAC accepte toute clé"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn ntlm2sr_calc_resp(response_key_nt: &[u8], server_challenge: &[u8; 8], client_challenge: &[u8; 8]) -> (Vec, Vec) { + let mut lm = vec![0u8; 24]; + lm[..8].copy_from_slice(client_challenge); + let mut h = md5::Md5::new(); + h.update(server_challenge); + h.update(client_challenge); + let sess = h.finalize(); + let nt = calc_resp(response_key_nt, &sess[..8]); + (lm, nt) +} + +fn calc_ntlmv2_resp( + pwhash: &[u8], + username: &str, + domain: &str, + target_info: &[u8], + server_challenge: &[u8; 8], + client_challenge: &[u8; 8], + now_ms: u64, +) -> (Vec, Vec) { + // NTOWFv2 + let response_key = hmac_md5(pwhash, &utf16le(&format!("{}{}", username.to_uppercase(), domain))); + let mut lm = hmac_md5(&response_key, &[server_challenge.as_slice(), client_challenge.as_slice()].concat()); + lm.extend_from_slice(client_challenge); + + // 11644473600000 = écart en ms entre 1601 et 1970. + let timestamp = (now_ms + 11_644_473_600_000) * 10_000; + let mut temp = vec![0x01, 0x01, 0x00, 0x00, 0, 0, 0, 0]; + temp.extend_from_slice(×tamp.to_le_bytes()); + temp.extend_from_slice(client_challenge); + temp.extend_from_slice(&[0, 0, 0, 0]); + temp.extend_from_slice(target_info); + temp.extend_from_slice(&[0, 0, 0, 0]); + let mut nt = hmac_md5(&response_key, &[server_challenge.as_slice(), &temp].concat()); + nt.extend_from_slice(&temp); + (lm, nt) +} + +/// Message d'authentification (type 3), préfixé `NTLM ` — défi client aléatoire +/// et horodatage courant, comme `ntlm.js`. +pub fn create_type3_message(msg2: &Type2Message, username: &str, password: &str, domain: &str, workstation: &str) -> String { + let client_challenge: [u8; 8] = rand::random(); + let now_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + create_type3_message_with(msg2, username, password, domain, workstation, client_challenge, now_ms) +} + +/// Variante déterministe de [`create_type3_message`] (tests). +pub fn create_type3_message_with( + msg2: &Type2Message, + username: &str, + password: &str, + domain: &str, + workstation: &str, + client_challenge: [u8; 8], + now_ms: u64, +) -> String { + let nonce = &msg2.server_challenge; + let is_unicode = msg2.negotiate_flags & NEGOTIATE_UNICODE != 0; + let extended = msg2.negotiate_flags & NEGOTIATE_EXTENDED_SECURITY != 0; + const BODY_LENGTH: usize = 72; + + let domain_name = js_escape(&domain.to_uppercase()); + let workstation = js_escape(&workstation.to_uppercase()); + let enc = |s: &str| if is_unicode { utf16le(s) } else { node_ascii(s) }; + let workstation_bytes = enc(&workstation); + let domain_bytes = enc(&domain_name); + let username_bytes = enc(username); + let session_key_bytes: Vec = Vec::new(); + + let nt_hash = create_nt_hashed_password_v1(password); + let (lm_resp, nt_resp) = if extended { + // NTLMv2 dès que le serveur fournit le targetInfo (comme curl) ; sinon + // NTLMv1 avec « extended security » (NTLM2 Session Response). + match &msg2.target_info { + Some(ti) => calc_ntlmv2_resp(&nt_hash, username, &domain_name, ti, nonce, &client_challenge, now_ms), + None => ntlm2sr_calc_resp(&nt_hash, nonce, &client_challenge), + } + } else { + (calc_resp(&create_lm_hashed_password_v1(password), nonce), calc_resp(&nt_hash, nonce)) + }; + + let base = BODY_LENGTH + domain_bytes.len() + username_bytes.len() + workstation_bytes.len(); + let mut buf = Vec::with_capacity(base + lm_resp.len() + nt_resp.len()); + buf.extend_from_slice(b"NTLMSSP\0"); + push_u32(&mut buf, 3); + push_u16(&mut buf, lm_resp.len()); + push_u16(&mut buf, lm_resp.len()); + push_u32(&mut buf, base); + push_u16(&mut buf, nt_resp.len()); + push_u16(&mut buf, nt_resp.len()); + push_u32(&mut buf, base + lm_resp.len()); + push_u16(&mut buf, domain_bytes.len()); + push_u16(&mut buf, domain_bytes.len()); + push_u32(&mut buf, BODY_LENGTH); + push_u16(&mut buf, username_bytes.len()); + push_u16(&mut buf, username_bytes.len()); + push_u32(&mut buf, BODY_LENGTH + domain_bytes.len()); + push_u16(&mut buf, workstation_bytes.len()); + push_u16(&mut buf, workstation_bytes.len()); + push_u32(&mut buf, BODY_LENGTH + domain_bytes.len() + username_bytes.len()); + push_u16(&mut buf, session_key_bytes.len()); + push_u16(&mut buf, session_key_bytes.len()); + push_u32(&mut buf, base + lm_resp.len() + nt_resp.len()); + let flags = if is_unicode { TYPE2_FLAGS } else { TYPE2_FLAGS - NEGOTIATE_UNICODE }; + buf.extend_from_slice(&flags.to_le_bytes()); + buf.extend_from_slice(&VERSION); + buf.extend_from_slice(&domain_bytes); + buf.extend_from_slice(&username_bytes); + buf.extend_from_slice(&workstation_bytes); + buf.extend_from_slice(&lm_resp); + buf.extend_from_slice(&nt_resp); + buf.extend_from_slice(&session_key_bytes); + format!("NTLM {}", base64::engine::general_purpose::STANDARD.encode(buf)) +} + +#[cfg(test)] +mod tests { + //! Références produites par `node_modules/httpntlm/ntlm.js` (v1.8.13) avec + //! `Math.random` figé (octets 37·k mod 256) et `Date.now() = 1781600000123`. + use super::*; + + const CLIENT: [u8; 8] = [0x25, 0x4a, 0x6f, 0x94, 0xb9, 0xde, 0x03, 0x28]; + const NOW: u64 = 1_781_600_000_123; + + #[test] + fn type1_identique_a_ntlm_js() { + assert_eq!(create_type1_message("", ""), "NTLM TlRMTVNTUAABAAAAB6IIogAAAAAoAAAAAAAAACgAAAAFASgKAAAADw=="); + assert_eq!( + create_type1_message("foirfouille", "poste-1"), + "NTLM TlRMTVNTUAABAAAAB7IIogsACwAvAAAABwAHACgAAAAFASgKAAAAD1BPU1RFLTFGT0lSRk9VSUxMRQ==" + ); + } + + fn type3(type2: &str, user: &str, pwd: &str, dom: &str, ws: &str) -> String { + let msg2 = parse_type2_message(type2).unwrap(); + create_type3_message_with(&msg2, user, pwd, dom, ws, CLIENT, NOW) + } + + const T2_V2: &str = + "NTLM TlRMTVNTUAACAAAADgAOADAAAAAVgoniASNFZ4mrze8AAAAAAAAAACQAJAA+AAAARABPAE0AQQBJAE4ARQACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAA="; + + #[test] + fn type3_ntlmv2_identique_a_ntlm_js() { + assert_eq!( + type3(T2_V2, "FFSCH", "Mot2Passe!", "", ""), + "NTLM TlRMTVNTUAADAAAAGAAYAFIAAABUAFQAagAAAAAAAABIAAAACgAKAEgAAAAAAAAAUgAAAAAAAAC+AAAABYKIogUBKAoAAAAPRgBGAFMAQwBIAMebHM0wuVW53i9NaYVnDM0lSm+Uud4DKFKnk/6apESm+PHJ3XiP8I8BAQAAAAAAALDEopRt/dwBJUpvlLneAygAAAAAAgAOAEQATwBNAEEASQBOAAEADABTAEUAUgBWAEUAUgAAAAAAAAAAAA==" + ); + assert_eq!( + type3(T2_V2, "ffsch", "pässwörd", "foirFouille", "ws é"), + "NTLM TlRMTVNTUAADAAAAGAAYAHgAAABUAFQAkAAAABYAFgBIAAAACgAKAF4AAAAQABAAaAAAAAAAAADkAAAABYKIogUBKAoAAAAPRgBPAEkAUgBGAE8AVQBJAEwATABFAGYAZgBzAGMAaABXAFMAJQAyADAAJQBDADkA2c8BUzjri/Vbm9o3osFxDiVKb5S53gMoWbFIumwCV7VyC/+QpeFgkwEBAAAAAAAAsMSilG393AElSm+Uud4DKAAAAAACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAAAAAAA" + ); + } + + #[test] + fn type3_ntlm2_session_identique_a_ntlm_js() { + assert_eq!( + type3("NTLM TlRMTVNTUAACAAAADgAOADAAAAAVggjiASNFZ4mrze8AAAAAAAAAAAAAAAAAAAAARABPAE0AQQBJAE4ARQA=", "FFSCH", "Mot2Passe!", "", ""), + "NTLM TlRMTVNTUAADAAAAGAAYAFIAAAAYABgAagAAAAAAAABIAAAACgAKAEgAAAAAAAAAUgAAAAAAAACCAAAABYKIogUBKAoAAAAPRgBGAFMAQwBIACVKb5S53gMoAAAAAAAAAAAAAAAAAAAAAEkBa2FWIubP7jtR0YUCzhOojjrIJ/b0zg==" + ); + } + + #[test] + fn type3_ntlmv1_identique_a_ntlm_js() { + assert_eq!( + type3("NTLM TlRMTVNTUAACAAAADgAOADAAAAACAgAAASNFZ4mrze8AAAAAAAAAAAAAAAAAAAAARABPAE0AQQBJAE4ARQA=", "FFSCH", "Mot2Passe!", "dom", "ws"), + "NTLM TlRMTVNTUAADAAAAGAAYAFIAAAAYABgAagAAAAMAAwBIAAAABQAFAEsAAAACAAIAUAAAAAAAAACCAAAABIKIogUBKAoAAAAPRE9NRkZTQ0hXUxOnemDuiKIblbqFa0gPlHCgLteOCjDT8ybUs0Z210bpm9YSI+44nltXbx25eedv1g==" + ); + assert_eq!( + type3("NTLM TlRMTVNTUAACAAAADgAOADAAAAABAgAAASNFZ4mrze8AAAAAAAAAAAAAAAAAAAAARABPAE0AQQBJAE4ARQA=", "Utilisateur", "un mot de passe tres long", "", ""), + "NTLM TlRMTVNTUAADAAAAGAAYAF4AAAAYABgAdgAAAAAAAABIAAAAFgAWAEgAAAAAAAAAXgAAAAAAAACOAAAABYKIogUBKAoAAAAPVQB0AGkAbABpAHMAYQB0AGUAdQByAApLfYq11cQZCEfoqsrdSUT3L9f12IEFMjyUawPg7lXzmV17ourU8IkTxBWsCur8CA==" + ); + } + + #[test] + fn type2_invalide() { + assert!(parse_type2_message("Negotiate").is_err()); + assert!(parse_type2_message("NTLM TlRMTVNTUAABAAAAB6IIogAAAAAoAAAAAAAAACgAAAAFASgKAAAADw==").is_err()); + // Plusieurs défis dans le même en-tête (Node les joint par « , »). + assert!(parse_type2_message(&format!("Negotiate, {T2_V2}")).is_ok()); + } + + #[test] + fn escape_facon_js() { + assert_eq!(js_escape("WS É"), "WS%20%C9"); + assert_eq!(js_escape("a€b"), "a%u20ACb"); + assert_eq!(js_escape("@*_+-./"), "@*_+-./"); + } + + /// Recalcule les références avec Node quand il est disponible + /// (`cargo test -- --ignored ntlm_js_en_direct`). + #[test] + #[ignore] + fn ntlm_js_en_direct() { + let script = r#" + const ntlm = require(process.argv[1]); + let k = 0; Math.random = () => { k++; return ((k * 37) % 256) / 256 + 0.0001; }; + Date.now = () => 1781600000123; + const t2 = process.argv[2]; + const m = ntlm.parseType2Message(t2, (e) => { throw e; }); + console.log(ntlm.createType1Message({ domain: 'foirfouille', workstation: 'poste-1' })); + console.log(ntlm.createType3Message(m, { username: 'FFSCH', password: 'Mot2Passe!', domain: 'Dom', workstation: 'PC' })); + "#; + let module = std::env::var("HTTPNTLM_JS").unwrap_or_else(|_| "/home/user/CollectFlow/node_modules/httpntlm/ntlm.js".into()); + let out = std::process::Command::new("node").args(["-e", script, &module, T2_V2]).output().expect("node"); + let texte = String::from_utf8(out.stdout).unwrap(); + let lignes: Vec<&str> = texte.lines().collect(); + assert_eq!(lignes[0], create_type1_message("foirfouille", "poste-1")); + assert_eq!(lignes[1], type3(T2_V2, "FFSCH", "Mot2Passe!", "Dom", "PC")); + } +} diff --git a/server/src/qlik/playwright.rs b/server/src/qlik/playwright.rs new file mode 100644 index 0000000..6b86ae0 --- /dev/null +++ b/server/src/qlik/playwright.rs @@ -0,0 +1,669 @@ +//! Extraction Qlik via Chromium headless (port de `src/lib/qlik-playwright.ts`). +//! +//! Le proxy Qlik refuse un websocket "raw" côté serveur (403). On ouvre donc +//! l'app dans Chromium avec une session authentifiée (cookie X-Qlik-Session +//! injecté via le flux ticket NTLM), on récupère le **qlik-csrf-token** que le +//! client Qlik utilise pour SON websocket, puis on ouvre notre propre websocket +//! Engine **in-page** avec ce token (le proxy l'accepte). +//! +//! Toute la logique Engine (sélections, cubes, passes mensuelles, retries +//! code 15, sélection par fournisseur, contrôles de fenêtre…) vit dans le +//! script in-page `js/extraction.js`, copie conforme du TS. Ce fichier-ci garde +//! la partie serveur : réglages d'environnement, session, reprises globales, +//! agrégation finale et contrôles de plausibilité avant écriture. + +use super::browser::{dans_la_file_qlik, get_qlik_browser, PageQlik}; +use super::client::{get_qlik_config, qlik_ntlm_session, NetworkMetric, QlikConfig, QlikMonthMetrics}; +use super::date_range::{get_month_ranges, js_number, qlik_serial_to_ymd, QlikDateFilter}; +use super::jsnum; +use super::validation::{assainir_metrique, controler_extraction, OptionsControle}; +use anyhow::{anyhow, bail}; +use chrono::Datelike; +use indexmap::IndexMap; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Script exécuté dans la page (fonction de `page.evaluate` du TS). +const SCRIPT_EXTRACTION: &str = include_str!("js/extraction.js"); + +/// Résultat du script in-page. +#[derive(Debug, Default, Clone)] +struct InPageResult { + ok: bool, + rows: Vec>, + /// Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }. + monthly: BTreeMap>, + size: Option, + error: Option, + // Compteurs agrégés pour le résumé timing final. + months: Option, + batches: Option, + code15_retries: Option, + fallback_count: Option, + /// true si le résultat vient d'un point de contrôle (extraction interrompue). + partiel: bool, + /// Diagnostics clés du chemin par expressions, répétés en fin de sync. + diag_expr: Vec, +} + +fn lire_monthly(v: Option<&Value>) -> BTreeMap> { + let mut out = BTreeMap::new(); + if let Some(Value::Object(par_code)) = v { + for (code, par_mois) in par_code { + let mut mois_map = BTreeMap::new(); + if let Value::Object(pm) = par_mois { + for (mois, m) in pm { + if let Ok(mm) = serde_json::from_value::(m.clone()) { + mois_map.insert(mois.clone(), mm); + } + } + } + out.insert(code.clone(), mois_map); + } + } + out +} + +fn lire_rows(v: Option<&Value>) -> Vec> { + v.and_then(|r| r.as_array()).map(|a| a.iter().map(|l| l.as_array().cloned().unwrap_or_default()).collect()).unwrap_or_default() +} + +impl InPageResult { + fn depuis(v: &Value) -> Self { + let nombre = |k: &str| v.get(k).filter(|x| !x.is_null()).map(jsnum::nombre); + Self { + ok: v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false), + rows: lire_rows(v.get("rows")), + monthly: lire_monthly(v.get("monthly")), + size: nombre("size"), + error: v.get("error").map(|e| match e { + Value::String(s) => s.clone(), + autre => autre.to_string(), + }), + months: nombre("months"), + batches: nombre("batches"), + code15_retries: nombre("code15Retries"), + fallback_count: nombre("fallbackCount"), + partiel: v.get("partiel").and_then(|x| x.as_bool()).unwrap_or(false), + diag_expr: v + .get("diagExpr") + .and_then(|d| d.as_array()) + .map(|a| a.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()) + .unwrap_or_default(), + } + } +} + +/// `envNumber` : entier borné, défaut si absent / vide / non numérique. +fn env_number(name: &str, default: i64, min: i64, max: i64) -> i64 { + crate::config::env_number(name, default, min, max) +} + +/// `envNumberCsv` : liste d'entiers bornés (`Number("")` = 0, comme en JS). +fn env_number_csv(name: &str, min: i64, max: i64) -> Vec { + let Some(raw) = std::env::var(name).ok().filter(|v| !v.is_empty()) else { return Vec::new() }; + raw.split(',').map(|part| js_number(part.trim())).filter(|v| v.is_finite()).map(|v| (v.trunc() as i64).clamp(min, max)).collect() +} + +fn unique_ordered(values: &[i64]) -> Vec { + let mut seen = HashSet::new(); + values.iter().copied().filter(|v| seen.insert(*v)).collect() +} + +/// Option booléenne activée par défaut, coupée par `0` / `false` / `no` / `off`. +fn env_actif(name: &str) -> bool { + let v = std::env::var(name).unwrap_or_default().trim().to_lowercase(); + !["0", "false", "no", "off"].contains(&v.as_str()) +} + +fn env_trim(name: &str, defaut: &str) -> String { + std::env::var(name).unwrap_or_else(|_| defaut.to_string()).trim().to_string() +} + +/// `String(x)` sur une cellule JSON. +fn texte_js(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => jsnum::texte(n.as_f64().unwrap_or(f64::NAN)), + Value::Null => "null".into(), + autre => autre.to_string(), + } +} + +/// Extraction des métriques réseau (CA, quantités, magasins, marge et détail +/// mensuel) des codes centraux demandés. +/// +/// - `codes` : codes centraux (`None` = tous ceux que renvoie le cube). +/// - `cfg` : `None` = [`get_qlik_config`]. +/// - `date_filter` : fenêtre à extraire ; `None` = mode sans date (le `null` du TS). +/// Les appelants passent toujours une fenêtre (12 mois glissants). +/// - `fournisseur` : code fournisseur FF (ex. `J009`). Quand il est fourni, on +/// sélectionne LE fournisseur côté Qlik au lieu des dizaines de milliers de +/// codes articles : `SelectValues` sur « Article Code » coûte 8 à 100 s par +/// appel quel que soit le nombre de valeurs (le moteur balaie un symbole de +/// plus d'un million d'entrées), et il faut le refaire à chaque passe annuelle. +/// La bascule n'est retenue que si la sélection fournisseur **couvre** les +/// codes demandés — sinon on revient à la sélection par codes. +/// +/// Passe par la file Qlik unique : une seule session Engine à la fois. +pub async fn fetch_network_metrics_playwright( + codes: Option>, + cfg: Option, + date_filter: Option, + fournisseur: Option, +) -> anyhow::Result> { + let libelle = match &fournisseur { + Some(f) if !f.is_empty() => format!("fournisseur {f}"), + _ => format!("{} code(s)", codes.as_ref().map(|c| c.len()).unwrap_or(0)), + }; + let cfg = cfg.unwrap_or_else(get_qlik_config); + dans_la_file_qlik(&libelle, || extraire(codes, cfg, date_filter, fournisseur)).await +} + +async fn extraire( + codes: Option>, + cfg: QlikConfig, + date_filter: Option, + fournisseur: Option, +) -> anyhow::Result> { + if cfg.app_network.is_empty() { + bail!("[qlik-pw] QLIK_APP_NETWORK manquant"); + } + if cfg.user.is_empty() || cfg.password.is_empty() { + bail!("[qlik-pw] identifiants Qlik manquants"); + } + + // Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]). + let total_start = Instant::now(); + let nb_codes = codes.as_ref().map(|c| c.len()).unwrap_or(0); + tracing::info!("[qlik-pw] host={} app={} — {nb_codes} codes à sélectionner", cfg.host, cfg.app_network); + if let Some(df) = &date_filter { + tracing::info!("[qlik-pw] filtre Date={} ({} → {}, {})", df.label, df.date_debut, df.date_fin, df.set_analysis); + } + let sess = qlik_ntlm_session(&cfg).await?; + let cookie_name = sess.cookie.split('=').next().unwrap_or("").to_string(); + tracing::info!("[qlik-pw] session NTLM OK, cookie={cookie_name}=…"); + let cookie_value = sess.cookie.splitn(2, '=').nth(1).unwrap_or("").to_string(); + let qlik_settle_ms = env_number("QLIK_SETTLE_MS", 150, 0, 2000); + let qlik_code15_max_attempts = env_number("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8); + let qlik_article_batch_size = env_number("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500); + let csv_fallbacks = env_number_csv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500); + let mut tailles = vec![qlik_article_batch_size]; + tailles.extend(if csv_fallbacks.is_empty() { vec![75, 50] } else { csv_fallbacks }); + tailles.extend([75, 50]); + let qlik_article_batch_fallbacks = unique_ordered(&tailles); + // Chemin rapide : sélectionner TOUS les Article Code une seule fois, puis ne + // changer que la Date par mois (au lieu de re-sélectionner par lots de 150 chaque + // mois). Repli automatique sur le chemin par lots en cas de code 15. + // ACTIVÉ PAR DÉFAUT (validé fiable) — désactivable seulement avec QLIK_SELECT_ALL_CODES=0. + let qlik_select_all_codes = env_actif("QLIK_SELECT_ALL_CODES"); + // Découpage mensuel réel via la dimension "Mois" (cube [Article Code, Mois]) : donne + // les vraies quantités par mois ET le vrai total (au lieu de 12× la valeur annuelle). + // Désactiver (QLIK_MONTH_DIM=0) fait désormais échouer la sync datée. + let qlik_month_dim = env_actif("QLIK_MONTH_DIM"); + let qlik_mois_dim_id = env_trim("QLIK_MOIS_DIM_ID", "pfGAwTs"); + // Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente. + let qlik_meas_qte_comp_id = env_trim("QLIK_MEAS_QTE_COMP_ID", "96862880-76cd-4957-bf25-b96901f2ac5f"); + // Agrégations directes des champs de faits. Les master measures « N » ignorent + // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas + // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. + let qlik_use_expr = env_actif("QLIK_USE_EXPR"); + let qlik_expr_qte = env_trim("QLIK_EXPR_QTE", "Sum(quantite)"); + let qlik_expr_ca = env_trim("QLIK_EXPR_CA", "Sum(ca_ht)"); + let qlik_expr_nb_mag = env_trim("QLIK_EXPR_NBMAG", "Count(DISTINCT [Magasin Code])"); + let qlik_expr_marge = env_trim("QLIK_EXPR_MARGE", "Sum(marge)"); + // Champs Qlik susceptibles de porter le CODE fournisseur (et non son libellé) : + // c'est `code_fournisseur` qui remontait des valeurs de la forme « F005 », + // « A0491 » — même format que les codes de la base FF. + let mut qlik_supplier_fields: Vec = Vec::new(); + for c in [env_trim("QLIK_FIELD_CODE_FOURNISSEUR", ""), "code_fournisseur".into(), "Fournisseur".into(), "fournisseur_code_centrale".into()] { + if !c.is_empty() && !qlik_supplier_fields.contains(&c) { + qlik_supplier_fields.push(c); + } + } + // Part des codes demandés que la sélection fournisseur doit couvrir pour être retenue. + let couverture = js_number(&std::env::var("QLIK_SUPPLIER_MIN_COVERAGE").unwrap_or_else(|_| "0.99".into())); + let couverture = if couverture.is_nan() || couverture == 0.0 { 0.99 } else { couverture }; + let qlik_supplier_min_coverage = couverture.clamp(0.5, 1.0); + + // Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il + // FILTRE réellement les faits (total non nul et strictement inférieur au total + // sans filtre). + let mut qlik_date_fields: Vec = Vec::new(); + for c in [env_trim("QLIK_DATE_FIELD", ""), "Date".into(), "Date calendrier".into(), "Date_Key".into()] { + if !c.is_empty() && !qlik_date_fields.contains(&c) { + qlik_date_fields.push(c); + } + } + let year_n = chrono::Local::now().year(); + tracing::info!( + "[qlik-pw] tuning settle={qlik_settle_ms}ms code15Attempts={qlik_code15_max_attempts} batchFallbacks={} selectAllCodes={qlik_select_all_codes} monthDim={qlik_month_dim} useExpr={qlik_use_expr}", + qlik_article_batch_fallbacks.iter().map(|v| v.to_string()).collect::>().join(">") + ); + if qlik_use_expr { + tracing::info!("[qlik-pw] expressions mensuelles: qte=\"{qlik_expr_qte}\" ca=\"{qlik_expr_ca}\" nbMag=\"{qlik_expr_nb_mag}\" marge=\"{qlik_expr_marge}\""); + tracing::info!("[qlik-pw] champs date candidats: {}", serde_json::to_string(&qlik_date_fields)?); + } + + let browser = get_qlik_browser().await?; + tracing::info!("[qlik-pw] chromium lancé"); + let mut page = PageQlik::ouvrir(browser, Box::new(|t: &str| Some(format!("[qlik-pw][page] {t}"))), Some("[qlik-pw][pageerror]")).await?; + let resultat = async { + page.identifiants_http(&cfg.user, &cfg.password).await?; + page.ajouter_cookie(&cookie_name, &cookie_value, &cfg.host).await?; + extraire_dans_la_page( + &mut page, + &cfg, + codes, + date_filter, + fournisseur, + total_start, + json!({ + "qlikSettleMs": qlik_settle_ms, + "qlikCode15MaxAttempts": qlik_code15_max_attempts, + "qlikArticleBatchFallbacks": qlik_article_batch_fallbacks, + "selectAllCodes": qlik_select_all_codes, + "monthDim": qlik_month_dim, + "moisDimId": qlik_mois_dim_id, + "mqteComp": qlik_meas_qte_comp_id, + "yearN": year_n, + "useExpr": qlik_use_expr, + "exprQte": qlik_expr_qte, + "exprCa": qlik_expr_ca, + "exprNbMag": qlik_expr_nb_mag, + "exprMarge": qlik_expr_marge, + "champsDateCandidats": qlik_date_fields, + "champsFournisseurCandidats": qlik_supplier_fields, + "couvertureFournisseurMin": qlik_supplier_min_coverage, + }), + ) + .await + } + .await; + page.fermer().await; + resultat +} + +/// Jeton CSRF du client Qlik : attendu au plus 15 s après le chargement. +pub(super) async fn attendre_jeton(rx: &mut tokio::sync::watch::Receiver>) -> Option { + let _ = tokio::time::timeout(Duration::from_secs(15), rx.wait_for(|j| j.is_some())).await; + let jeton = rx.borrow().clone(); + jeton +} + +async fn extraire_dans_la_page( + page: &mut PageQlik, + cfg: &QlikConfig, + codes: Option>, + date_filter: Option, + fournisseur: Option, + total_start: Instant, + reglages: Value, +) -> anyhow::Result> { + // Capture le qlik-csrf-token depuis le websocket que le client Qlik ouvre. + let mut jeton_rx = page.capturer_jeton_csrf().await?; + // Le client ouvre son ws ensuite : une navigation lente ou en erreur n'est pas bloquante. + let _ = page.aller(&format!("https://{}/sense/app/{}", cfg.host, cfg.app_network), Duration::from_millis(cfg.timeout_ms)).await; + tracing::info!("[qlik-pw] page chargée, attente du qlik-csrf-token…"); + let Some(jeton) = attendre_jeton(&mut jeton_rx).await else { + bail!("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); + }; + tracing::info!("[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…"); + + // Pré-calcul des mois côté serveur (pour pouvoir logger X/Y et propager au script in-page). + let month_ranges: Vec = date_filter.as_ref().map(get_month_ranges).unwrap_or_default(); + let monthly_payload: Vec = month_ranges + .iter() + .map(|m| { + json!({ + "label": m.label, + "dateDebut": m.date_debut, + "dateFin": m.date_fin, + "serials": m.daily_serials, // ~30 valeurs → SelectValues en une fois + // Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik + // n'exposent la date que par une clé numérique (`Date_Key`), pas par + // un champ date sérialisé. + "ymd": m.daily_serials.iter().map(|s| qlik_serial_to_ymd(*s)).collect::>(), + }) + }) + .collect(); + + // Point de contrôle : le script in-page pousse ses résultats intermédiaires + // à chaque passe. Si la session Qlik meurt en cours de route (« Socket + // closed », « Execution context was destroyed »), on repart de là au lieu + // de tout perdre — les codes déjà traités ont des données complètes, la + // dimension Mois livrant tous leurs mois d'un coup. + let checkpoint: Arc>> = Arc::new(parking_lot::Mutex::new(None)); + let mut appels = page.exposer_fonction("cfCheckpoint").await?; + let cible = checkpoint.clone(); + let recepteur = tokio::spawn(async move { + while let Some(payload) = appels.recv().await { + if let Ok(v) = serde_json::from_str::(&payload) { + *cible.lock() = Some(v); + } + } + }); + + let mut args = Map::new(); + args.insert("app".into(), json!(cfg.app_network)); + args.insert("dim".into(), json!(cfg.dim_code_article_id)); + args.insert("mca".into(), json!(cfg.meas_ca_id)); + args.insert("mqte".into(), json!(cfg.meas_qte_id)); + args.insert("mnb".into(), json!(cfg.meas_nb_mag_id)); + args.insert("mcamag".into(), json!(cfg.meas_ca_mag_id)); + args.insert("mmarge".into(), json!(cfg.meas_marge_pct_id)); + args.insert("codes".into(), json!(codes.clone().unwrap_or_default())); + args.insert("token".into(), json!(jeton)); + args.insert("monthlyPayload".into(), Value::Array(monthly_payload)); + args.insert("noDateMode".into(), json!(date_filter.is_none())); + args.insert("fournisseurCode".into(), json!(fournisseur.as_deref().unwrap_or("").trim())); + if let Value::Object(r) = reglages { + args.extend(r); + } + let args = Value::Object(args); + + // Reprise globale sur erreur transitoire. + // + // Le moteur Qlik abandonne les requêtes longues quand il est chargé + // (« code 15 / Request aborted »). Observé sur un même fournisseur, avec + // le même code : réussite à 4 h du matin (OpenDoc en 78 s), échec à 6 h + // (OpenDoc en 233 s, puis abandon). Les reprises par requête de + // `rpcWithRetry` ne suffisent pas — c'est toute l'extraction qui tombe. + // + // La pause est volontairement longue : relancer aussitôt ne ferait + // qu'ajouter de la charge au serveur qui vient déjà de renoncer. + let extraction_essais = 1 + env_number("QLIK_EXTRACTION_RETRIES", 1, 0, 3); + let extraction_pause_ms = env_number("QLIK_EXTRACTION_RETRY_PAUSE_MS", 60_000, 0, 600_000) as u64; + let re15 = regex::Regex::new(r#""code"\s*:\s*15\b"#).expect("regex"); + let re_abort = regex::Regex::new(r"(?i)request aborted").expect("regex"); + let est_transitoire = |m: &str| re15.is_match(m) || re_abort.is_match(m); + let tronque = |s: &str, n: usize| s.chars().take(n).collect::(); + + let mut essai = 0; + let mut result = loop { + essai += 1; + let reste = essai < extraction_essais; + match page.evaluer(SCRIPT_EXTRACTION, &args).await { + Ok(v) => { + let candidat = InPageResult::depuis(&v); + let err = candidat.error.clone().unwrap_or_else(|| "undefined".into()); + if candidat.ok || !reste || !est_transitoire(&err) { + break candidat; + } + tracing::warn!( + "[qlik-pw] tentative {essai}/{extraction_essais} abandonnée par le moteur ({}) — nouvelle tentative dans {} s", + tronque(&err, 160), + (extraction_pause_ms as f64 / 1000.0).round() + ); + } + Err(e) => { + let msg = format!("{e:#}"); + if reste && est_transitoire(&msg) { + tracing::warn!( + "[qlik-pw] tentative {essai}/{extraction_essais} interrompue ({}) — nouvelle tentative dans {} s", + tronque(&msg, 160), + (extraction_pause_ms as f64 / 1000.0).round() + ); + } else { + recepteur.abort(); + // Comportement d'origine, une fois les reprises épuisées. + // Une fenêtre 12 mois n'est publiable que si l'extraction est + // complète et a passé tous les contrôles. Conserver un checkpoint + // partiel fabriquerait précisément des graphiques à 2 ou 3 mois. + if date_filter.is_some() { + bail!("[qlik-pw] extraction 12 mois interrompue, cache inchangé : {msg}"); + } + let secours = checkpoint.lock().take(); + let rows = lire_rows(secours.as_ref().and_then(|s| s.get("rows"))); + if !rows.is_empty() { + tracing::error!( + "[qlik-pw] extraction interrompue ({}) — reprise du dernier point de contrôle : {} lignes", + tronque(&msg, 200), + rows.len() + ); + let n = rows.len() as f64; + break InPageResult { + ok: true, + monthly: lire_monthly(secours.as_ref().and_then(|s| s.get("monthly"))), + rows, + size: Some(n), + partiel: true, + ..Default::default() + }; + } + return Err(e); + } + } + } + *checkpoint.lock() = None; // la nouvelle tentative repart d'une extraction vierge + tokio::time::sleep(Duration::from_millis(extraction_pause_ms)).await; + }; + recepteur.abort(); + + if !result.ok { + let err = result.error.clone().unwrap_or_else(|| "undefined".into()); + if date_filter.is_some() { + bail!("[qlik-pw] extraction 12 mois refusée, cache inchangé : {err}"); + } + // Même logique qu'un crash : un résultat partiel vaut mieux que rien. + if !result.rows.is_empty() { + tracing::error!("[qlik-pw] extraction en échec ({}) — {} lignes déjà extraites conservées", tronque(&err, 200), result.rows.len()); + result.ok = true; + result.partiel = true; + } else { + bail!("[qlik-pw] {err}"); + } + } + if result.partiel { + if date_filter.is_some() { + bail!("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé"); + } + tracing::warn!("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); + } + + // Résumé timing final : synthèse compacte de la sync pour audit/debug. + // Les logs détaillés par étape restent en place (timings batch/mois) ; + // ici on consolide les compteurs agrégés et la durée totale. + let total_ms = total_start.elapsed().as_millis(); + if !result.diag_expr.is_empty() { + tracing::info!("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───"); + for d in &result.diag_expr { + tracing::info!("[qlik-pw][diag] {d}"); + } + } + let n = |v: Option| jsnum::texte(v.unwrap_or(0.0)); + tracing::info!( + "[qlik-pw][summary] rows={} months={} batches={} code15Retries={} fallbackCount={} totalMs={total_ms}", + n(result.size), + n(result.months), + n(result.batches), + n(result.code15_retries), + n(result.fallback_count) + ); + + let wanted: Option> = codes.as_ref().map(|c| c.iter().map(|s| s.as_str()).collect()); + let mut out: IndexMap = IndexMap::new(); + let mut marge_weight: std::collections::HashMap = std::collections::HashMap::new(); + for r in &result.rows { + let code = r.first().map(texte_js).unwrap_or_else(|| "undefined".into()).trim().to_string(); + if code.is_empty() || code == "-" { + continue; + } + if let Some(w) = &wanted { + if !w.contains(code.as_str()) { + continue; + } + } + let col = |i: usize| jsnum::nombre_ou_0(r.get(i)); + let (ca, qte, nb_mag, marge_pct) = (col(1), col(2), col(3), col(5)); + let Some(prev) = out.get_mut(&code) else { + out.insert( + code.clone(), + NetworkMetric { + code_centrale: code.clone(), + ca_reseau: ca, + qte_reseau: qte, + nb_magasins_reseau: nb_mag, + ca_par_magasin_reseau: if nb_mag > 0.0 { ca / nb_mag } else { 0.0 }, + marge_pct_reseau: marge_pct, + periode: date_filter.as_ref().map(|d| d.label.clone()), + ..Default::default() + }, + ); + marge_weight.insert(code, ca); + continue; + }; + prev.ca_reseau += ca; + prev.qte_reseau += qte; + prev.nb_magasins_reseau = prev.nb_magasins_reseau.max(nb_mag); + let weight = marge_weight.get(&code).copied().unwrap_or(0.0); + let next_weight = weight + ca; + prev.marge_pct_reseau = + if next_weight > 0.0 { (prev.marge_pct_reseau * weight + marge_pct * ca) / next_weight } else { prev.marge_pct_reseau.max(marge_pct) }; + prev.ca_par_magasin_reseau = if prev.nb_magasins_reseau > 0.0 { prev.ca_reseau / prev.nb_magasins_reseau } else { 0.0 }; + marge_weight.insert(code, next_weight); + } + + // Rattache le détail mensuel (par code central) au métrique agrégé. + // On produit DEUX représentations : + // - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la + // colonne « Tendance / Réseau » de la Grille. Inchangé. + // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. + // + // Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur + // l'ensemble des codes. Distinguer « couvert, zéro vente » de « non + // extrait » est indispensable : sans cela, un article vendu seulement + // depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »), + // et un mois non extrait passait pour une absence de vente. + let mois_couverts: Vec = result.monthly.values().flat_map(|pm| pm.keys().cloned()).collect::>().into_iter().collect(); + tracing::info!("[qlik-pw] mois couverts par l'extraction ({}) : {}", mois_couverts.len(), serde_json::to_string(&mois_couverts)?); + + let vide = BTreeMap::new(); + for (code, metric) in out.iter_mut() { + let mm = result.monthly.get(code).unwrap_or(&vide); + let mut qte_by_month = BTreeMap::new(); + let mut metrics_by_month = BTreeMap::new(); + for mois in &mois_couverts { + let v = mm.get(mois); + // Zéro EXPLICITE sur un mois couvert sans vente : c'est une + // information, au même titre qu'une vente. Un mois non couvert + // reste absent et sera ignoré par la tendance. + let q = v.map(|x| x.qte).unwrap_or(0.0); + qte_by_month.insert(mois.clone(), if q.is_nan() { 0.0 } else { q }); + // ⚠️ Toutes les mesures à zéro, pas seulement la quantité. Écrire + // `{ qte: 0 }` seul laissait `nbMag` absent sur les mois sans + // vente : la courbe « magasins vendeurs » exigeant les 12 mois, + // elle disparaissait dès qu'un produit avait un mois creux. + metrics_by_month.insert( + mois.clone(), + v.cloned().unwrap_or(QlikMonthMetrics { qte: 0.0, ca: Some(0.0), nb_mag: Some(0.0), ca_mag: Some(0.0), marge_pct: Some(0.0) }), + ); + } + metric.qte_by_month = Some(qte_by_month); + metric.metrics_by_month = Some(metrics_by_month); + } + + // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. + // Si presque tous varient → la mesure respecte le mois (feature OK). + // Si tous identiques → la mesure Qlik ignore la sélection Date. + let (mut n_var, mut n_flat) = (0, 0); + let (mut ex_var, mut ex_flat): (Option<&NetworkMetric>, Option<&NetworkMetric>) = (None, None); + for m in out.values() { + let Some(bm) = &m.qte_by_month else { continue }; + let vals: Vec = bm.values().copied().collect(); + if vals.len() < 2 { + continue; + } + if vals.iter().all(|v| *v == vals[0]) { + n_flat += 1; + ex_flat.get_or_insert(m); + } else { + n_var += 1; + ex_var.get_or_insert(m); + } + } + let qbm = |m: &NetworkMetric| { + let v: Map = m.qte_by_month.iter().flatten().map(|(k, x)| (k.clone(), jsnum::valeur(*x))).collect(); + Value::Object(v).to_string() + }; + tracing::info!("[qlik-pw] qteByMonth distribution: {n_var} codes VARIENT / {n_flat} identiques (total {})", n_var + n_flat); + if let Some(m) = ex_var { + tracing::info!("[qlik-pw] exemple VARIE {}: {}", m.code_centrale, qbm(m)); + } + if let Some(m) = ex_flat { + tracing::info!("[qlik-pw] exemple IDENTIQUE {}: {}", m.code_centrale, qbm(m)); + } + tracing::info!("[qlik-pw] {} produits réseau (cube {})", out.len(), n(result.size)); + + // Contrôle de plausibilité AVANT toute écriture : des données complètes + // mais fausses (dernier mois pas encore chargé, mesure insensible au + // mois) laisseraient le cache faux jusqu'à la synchro suivante. + let corrections: Vec = out.values_mut().flat_map(assainir_metrique).collect(); + if !corrections.is_empty() { + tracing::warn!( + "[qlik-pw] {} valeur(s) impossible(s) corrigée(s) : {}", + corrections.len(), + corrections.iter().take(5).cloned().collect::>().join(" ; ") + ); + } + if date_filter.is_some() { + let fenetre: Vec = month_ranges.iter().map(|m| m.label.clone()).collect(); + let controle = controler_extraction(out.values(), &fenetre, chrono::Local::now().date_naive(), OptionsControle::default()); + for a in &controle.avertissements { + tracing::warn!("[qlik-pw][contrôle] {a}"); + } + if !controle.ok { + return Err(anyhow!("[qlik-pw] {} — cache inchangé", controle.motif.unwrap_or_default())); + } + tracing::info!("[qlik-pw][contrôle] extraction validée ({} codes, {} mois)", out.len(), month_ranges.len()); + } + // Échantillon brut pour vérifier que les mesures (cols 1-5) renvoient des valeurs + let sample: Vec = result + .rows + .iter() + .take(3) + .map(|r| { + let c = |i: usize| r.get(i).cloned().unwrap_or(Value::Null); + json!({ "code": c(0), "ca": c(1), "qte": c(2), "nbMag": c(3), "caMag": c(4), "margePct": c(5) }) + }) + .collect(); + let echantillon = Value::Array(sample).to_string(); + tracing::info!("[qlik-pw] échantillon lignes: {echantillon}"); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tailles_de_lots() { + assert_eq!(unique_ordered(&[150, 75, 50, 75, 50]), vec![150, 75, 50]); + assert_eq!(unique_ordered(&[100, 100, 25, 75, 50]), vec![100, 25, 75, 50]); + } + + #[test] + fn resultat_in_page() { + let v = json!({ + "ok": true, + "rows": [["C1", 10.5, 3, 2, 5.25, 0.3], ["C1", 1, 1, 4, null, 0.1]], + "monthly": { "C1": { "2026-01": { "qte": 3, "ca": 10.5 }, "2026-02": { "qte": null } } }, + "size": 2, "months": 12, "diagExpr": ["a", "b"] + }); + let r = InPageResult::depuis(&v); + assert!(r.ok); + assert_eq!(r.rows.len(), 2); + assert_eq!(r.monthly["C1"]["2026-01"].ca, Some(10.5)); + assert!(r.monthly["C1"]["2026-02"].qte.is_nan()); + assert_eq!(r.diag_expr, vec!["a", "b"]); + assert_eq!(texte_js(&json!(12.0)), "12"); + } + + #[test] + fn script_embarque() { + assert!(SCRIPT_EXTRACTION.contains("cfCheckpoint")); + assert!(SCRIPT_EXTRACTION.contains("qlik-csrf-token")); + } +} diff --git a/server/src/qlik/search.rs b/server/src/qlik/search.rs new file mode 100644 index 0000000..1ee4fa4 --- /dev/null +++ b/server/src/qlik/search.rs @@ -0,0 +1,305 @@ +//! Recherche produit **dans Qlik Sense** (source primaire) — port de +//! `src/lib/qlik-search.ts`. +//! +//! La recherche produit part du réseau, pas du catalogue Nancy : on interroge +//! d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles +//! qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant +//! rapproche les codes centraux trouvés de la base FF Nancy. +//! +//! Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de +//! produits que Nancy. Chercher dans la base locale d'abord masquait tous les +//! produits que le réseau travaille et que nous ne référençons pas — c'est +//! précisément ce qu'on veut voir. +//! +//! Technique : même plomberie que `playwright.rs` (Chromium headless + +//! websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse +//! un websocket « raw » côté serveur. Le script in-page est `js/recherche.js`. +//! +//! ## Comment la sélection est construite +//! +//! La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU** +//! entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont +//! l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs +//! faisait ensuite exploser le cube (`code 15 — Request aborted`). +//! +//! On procède donc en trois temps : +//! 1. une recherche par mot, pour retenir le **plus sélectif** ; +//! 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots +//! présents, insensible casse/accents) ; +//! 3. sélection des seules valeurs retenues, **par numéro d'élément** +//! (`SelectListObjectValues`) — exact, sans ré-appariement de texte. +//! +//! Le résultat final est revérifié une dernière fois avant d'être renvoyé : une +//! liste sans rapport avec la recherche est pire que pas de résultat du tout. + +use super::browser::{dans_la_file_qlik, get_qlik_browser, PageQlik}; +use super::client::{get_qlik_config, qlik_ntlm_session, QlikConfig}; +use super::date_range::{build_grid_network_qlik_date_filter_now, js_number, QLIK_MONTHS_BACK_DEFAULT}; +use super::jsnum; +use anyhow::bail; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::time::{Duration, Instant}; + +const SCRIPT_RECHERCHE: &str = include_str!("js/recherche.js"); + +/// Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**. +/// +/// Les mesures sortent du même cube que l'identité : une seule session Qlik pour +/// toute la recherche. Les extraire dans une seconde session concurrente faisait +/// abandonner le moteur (`code 15 — Request aborted`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikArticleMatch { + /// Valeur de la dimension « Article Code » — clé de jointure de la Grille. + pub code_centrale: String, + /// Champ `article_no_centrale` quand l'app l'expose : second candidat pour la + /// jointure avec `articles.artcentrale`, les deux ne portant pas toujours le + /// même format. + pub code_centrale_alt: String, + /// Libellé réseau, si l'app expose un champ libellé article. + pub libelle: String, + /// Fournisseur réseau, si l'app expose un champ fournisseur. + pub fournisseur: String, + /// CA réseau sur la fenêtre 12 mois glissants. + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub nb_magasins_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_par_magasin_reseau: f64, + /// Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub marge_pct_reseau: f64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikSearchOutcome { + pub matches: Vec, + /// Champ Qlik effectivement interrogé (diagnostic UI). + pub champ_utilise: Option, + /// true si Qlik a renvoyé plus de résultats que la limite demandée. + pub tronque: bool, + /// Message de diagnostic quand la recherche aboutit à zéro article alors que + /// Qlik a bien répondu (filtre non appliqué, terme trop large…). `None` en + /// fonctionnement nominal. + pub avertissement: Option, + /// Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). + pub periode: Option, +} + +/// Nombre maximum d'articles renvoyés par une recherche. +/// +/// Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà +/// de quelques dizaines, la seconde passe devient longue et le résultat +/// illisible. L'utilisateur affine son terme. +pub const QLIK_SEARCH_MAX_RESULTS: usize = 40; + +/// Champs candidats pour le libellé article, par ordre de préférence. +/// +/// Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le +/// libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des +/// résultats : si un candidat ne matche rien, on passe au suivant. +const CHAMPS_LIBELLE_PREFERES: [&str; 2] = ["Article", "article_libelle_ticket"]; + +/// Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). +const CHAMPS_FOURNISSEUR_PREFERES: [&str; 2] = ["Fournisseur", "code_fournisseur"]; + +/// Champ portant le code centrale « brut » de l'article, quand il existe. +/// Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la +/// dimension « Article Code » ne renvoie pas le même format. +const CHAMP_CODE_CENTRALE_ALT: &str = "article_no_centrale"; + +/// Longueur JS (unités UTF-16). +fn longueur_js(s: &str) -> usize { + s.encode_utf16().count() +} + +/// Cherche dans Qlik les articles correspondant à `term`. +/// +/// `limit` : `None` = [`QLIK_SEARCH_MAX_RESULTS`] ; `cfg` : `None` = [`get_qlik_config`]. +pub async fn search_qlik_articles(term: &str, limit: Option, cfg: Option) -> anyhow::Result { + let limit = limit.unwrap_or(QLIK_SEARCH_MAX_RESULTS); + let cfg = cfg.unwrap_or_else(get_qlik_config); + // Terme trop court : réponse vide immédiate, sans attendre la file. + if longueur_js(term.trim()) < 3 { + return rechercher(term, limit, &cfg).await; + } + // Même file que les extractions : deux sessions Engine simultanées font + // abandonner le serveur Qlik, et les deux requêtes échouent. + dans_la_file_qlik(&format!("recherche « {} »", term.trim()), || rechercher(term, limit, &cfg)).await +} + +async fn rechercher(term: &str, limit: usize, cfg: &QlikConfig) -> anyhow::Result { + let cleaned = term.trim().to_string(); + if longueur_js(&cleaned) < 3 { + return Ok(QlikSearchOutcome::default()); + } + if cfg.app_network.is_empty() { + bail!("[qlik-search] QLIK_APP_NETWORK manquant"); + } + if cfg.user.is_empty() || cfg.password.is_empty() { + bail!("[qlik-search] identifiants Qlik manquants"); + } + + let started = Instant::now(); + // Latence laissée à l'Engine après chaque mutation de sélection. Même réglage + // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après + // est calculé sur l'état de sélection précédent. + let settle = js_number(&std::env::var("QLIK_SETTLE_MS").unwrap_or_else(|_| "250".into())); + let settle_ms = if settle.is_nan() || settle == 0.0 { 250.0 } else { settle }.clamp(0.0, 2000.0); + // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. + let date_filter = build_grid_network_qlik_date_filter_now(QLIK_MONTHS_BACK_DEFAULT); + let sess = qlik_ntlm_session(cfg).await?; + let cookie_name = sess.cookie.split('=').next().unwrap_or("").to_string(); + let cookie_value = sess.cookie.splitn(2, '=').nth(1).unwrap_or("").to_string(); + + let browser = get_qlik_browser().await?; + let mut page = PageQlik::ouvrir( + browser, + // Le client Qlik est très bavard au chargement (thèmes, extensions) : + // on ne relaie que ce qui vient de notre script. + Box::new(|t: &str| t.starts_with("[cf]").then(|| format!("[qlik-search][page] {t}"))), + None, + ) + .await?; + let resultat = async { + page.identifiants_http(&cfg.user, &cfg.password).await?; + page.ajouter_cookie(&cookie_name, &cookie_value, &cfg.host).await?; + let mut jeton_rx = page.capturer_jeton_csrf().await?; + // Le client Qlik ouvre son ws juste après : une navigation en erreur n'est pas bloquante. + let _ = page.aller(&format!("https://{}/sense/app/{}", cfg.host, cfg.app_network), Duration::from_millis(cfg.timeout_ms)).await; + let Some(jeton) = super::playwright::attendre_jeton(&mut jeton_rx).await else { + bail!("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); + }; + + let args = json!({ + "app": cfg.app_network, + "dim": cfg.dim_code_article_id, + "token": jeton, + "terme": cleaned, + "limite": limit, + "settleMs": settle_ms, + "serialsDate": date_filter.daily_serials, + "mCa": cfg.meas_ca_id, + "mQte": cfg.meas_qte_id, + "mNbMag": cfg.meas_nb_mag_id, + "mCaMag": cfg.meas_ca_mag_id, + "mMarge": cfg.meas_marge_pct_id, + "champCodeAlt": CHAMP_CODE_CENTRALE_ALT, + "champsLibellePreferes": CHAMPS_LIBELLE_PREFERES, + "champsFournisseurPreferes": CHAMPS_FOURNISSEUR_PREFERES, + "champLibelleForce": std::env::var("QLIK_FIELD_ARTICLE_LIBELLE").unwrap_or_default().trim(), + "champFournisseurForce": std::env::var("QLIK_FIELD_FOURNISSEUR").unwrap_or_default().trim(), + }); + let result = page.evaluer(SCRIPT_RECHERCHE, &args).await?; + Ok(interpreter(result, &cleaned, started, date_filter.label.clone())) + } + .await; + page.fermer().await; + resultat? +} + +/// Traduit le résultat du script in-page en [`QlikSearchOutcome`]. +fn interpreter(result: Value, cleaned: &str, started: Instant, periode: String) -> anyhow::Result { + for t in result.get("trace").and_then(|t| t.as_array()).into_iter().flatten() { + tracing::info!("[qlik-search] {}", t.as_str().map(String::from).unwrap_or_else(|| t.to_string())); + } + if !result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let err = match result.get("error") { + Some(Value::String(s)) => s.clone(), + Some(v) => v.to_string(), + None => "undefined".into(), + }; + bail!("[qlik-search] {err}"); + } + + let chaine = |v: Option<&Value>| match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Null) | None => String::new(), + Some(autre) => autre.to_string(), + }; + let nombre = |v: Option<&Value>| v.map(jsnum::nombre).unwrap_or(f64::NAN); + // [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] + let matches: Vec = result + .get("rows") + .and_then(|r| r.as_array()) + .into_iter() + .flatten() + .filter_map(|l| l.as_array()) + .map(|l| QlikArticleMatch { + code_centrale: chaine(l.first()), + code_centrale_alt: chaine(l.get(1)), + libelle: chaine(l.get(2)), + fournisseur: chaine(l.get(3)), + ca_reseau: nombre(l.get(4)), + qte_reseau: nombre(l.get(5)), + nb_magasins_reseau: nombre(l.get(6)), + ca_par_magasin_reseau: nombre(l.get(7)), + marge_pct_reseau: nombre(l.get(8)), + }) + .collect(); + let total = result.get("total").map(jsnum::nombre).unwrap_or(0.0); + let champ_utilise = result.get("champUtilise").and_then(|c| c.as_str()).map(String::from); + tracing::info!( + "[qlik-search] \"{cleaned}\" → {} article(s) (total {}, champ={}) en {} ms", + matches.len(), + jsnum::texte(total), + champ_utilise.as_deref().unwrap_or("null"), + started.elapsed().as_millis() + ); + + // Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que + // d'afficher un « aucun résultat » qui laisserait croire à une absence + // réelle dans le réseau. + let rejetees = result.get("rejetees").map(jsnum::nombre).unwrap_or(0.0); + let trop_large = result.get("tropLarge").and_then(|v| v.as_bool()).unwrap_or(false); + let avertissement = if matches.is_empty() && rejetees > 0.0 { + Some("Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search].".to_string()) + } else if matches.is_empty() && trop_large { + Some("Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis.".to_string()) + } else { + None + }; + if let Some(a) = &avertissement { + tracing::warn!("[qlik-search] {a}"); + } + + Ok(QlikSearchOutcome { tronque: total > matches.len() as f64, matches, champ_utilise, avertissement, periode: Some(periode) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn terme_trop_court() { + let r = search_qlik_articles(" ab ", None, None).await.unwrap(); + assert!(r.matches.is_empty()); + assert_eq!(serde_json::to_string(&r).unwrap(), r#"{"matches":[],"champUtilise":null,"tronque":false,"avertissement":null,"periode":null}"#); + } + + #[test] + fn interpretation_du_resultat() { + let v = json!({ + "ok": true, "champUtilise": "Article", "total": 3, "rejetees": 0, "tropLarge": false, + "rows": [["10000167303", "", "TAPIS BAIN", "F005", 1500.5, 120, 80, 18.75, 0.32]], + "trace": ["code=Article Code"] + }); + let r = interpreter(v, "tapis", Instant::now(), "2025-10_2026-09".into()).unwrap(); + assert_eq!(r.matches.len(), 1); + assert!(r.tronque); + let j = serde_json::to_string(&r.matches[0]).unwrap(); + assert_eq!( + j, + r#"{"codeCentrale":"10000167303","codeCentraleAlt":"","libelle":"TAPIS BAIN","fournisseur":"F005","caReseau":1500.5,"qteReseau":120,"nbMagasinsReseau":80,"caParMagasinReseau":18.75,"margePctReseau":0.32}"# + ); + let vide = json!({ "ok": true, "rows": [], "rejetees": 4, "total": 0 }); + assert!(interpreter(vide, "x", Instant::now(), String::new()).unwrap().avertissement.unwrap().starts_with("Qlik a répondu sans appliquer")); + let ko = json!({ "ok": false, "error": "ws error (403 ?)" }); + assert_eq!(interpreter(ko, "x", Instant::now(), String::new()).unwrap_err().to_string(), "[qlik-search] ws error (403 ?)"); + } +} diff --git a/server/src/qlik/sync.rs b/server/src/qlik/sync.rs new file mode 100644 index 0000000..c97bea1 --- /dev/null +++ b/server/src/qlik/sync.rs @@ -0,0 +1,579 @@ +//! Route `/api/qlik/sync` (port de `src/app/api/qlik/sync/route.ts`). +//! +//! Extraction Qlik potentiellement très longue (plusieurs minutes) : la requête +//! HTTP rend la main tout de suite avec l'état du job, et le client interroge +//! `GET` toutes les 2 s. Une requête maintenue pendant toute l'extraction se +//! faisait couper par le reverse proxy. +//! +//! - Mode fournisseur (`?fournisseur=XXX`) : administrateurs uniquement. +//! - Mode produit (`?codeCentrale=YYY`, fiche produit) : tout utilisateur connecté. + +use super::date_range::{build_grid_network_qlik_date_filter_now, QLIK_MONTHS_BACK_DEFAULT}; +use super::network_cache::upsert_network_metrics; +use super::playwright::fetch_network_metrics_playwright; +use crate::auth::{require_admin, require_session, Token}; +use crate::error::{AppError, AppResult}; +use crate::logcapture::{start_capture, stop_capture}; +use axum::extract::Query; +use axum::http::StatusCode; +use axum::Json; +use parking_lot::Mutex; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; + +/// État d'un job de sync Qlik. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QlikSyncStatus { + Running, + Success, + Error, +} + +impl QlikSyncStatus { + fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Success => "success", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone)] +struct QlikSyncJob { + job_id: String, + /// Code fournisseur (mode fournisseur) — chaîne vide en mode produit. + fournisseur: String, + /// Code centrale (mode produit) — absent en mode fournisseur. + code_centrale: Option, + status: QlikSyncStatus, + requested: usize, + fetched: usize, + upserted: usize, + periode: Option, + date_debut: Option, + date_fin: Option, + started_at: String, + finished_at: Option, + error: Option, + message: Option, +} + +type JobPartage = Arc>; + +/// Registre en mémoire, une entrée par cible (on ne lance qu'une extraction à +/// la fois par fournisseur / par produit). Volontairement simple : si le +/// processus redémarre, les jobs en cours sont perdus → GET renverra idle et +/// l'utilisateur pourra relancer. +static JOBS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur +/// brut) ; le mode produit est préfixé pour ne jamais entrer en collision. +fn job_key_for_product(code_centrale: &str) -> String { + format!("cc:{}", code_centrale.to_uppercase()) +} + +/// Nombre maximum d'extractions produit simultanées. +/// +/// Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au +/// mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très +/// légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne +/// la concurrence plutôt que de lui laisser prendre N requêtes en parallèle. +const MAX_CONCURRENT_PRODUCT_JOBS: usize = 3; + +fn running_product_jobs() -> usize { + JOBS.lock().iter().filter(|(k, j)| k.starts_with("cc:") && j.lock().status == QlikSyncStatus::Running).count() +} + +fn base36(mut n: u128) -> String { + const CHIFFRES: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if n == 0 { + return "0".into(); + } + let mut out = Vec::new(); + while n > 0 { + out.push(CHIFFRES[(n % 36) as usize]); + n /= 36; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + +/// Construit un jobId court et lisible (`qlik__`). +fn new_job_id() -> String { + let ms = chrono::Utc::now().timestamp_millis().max(0) as u128; + let alea: String = (0..6).map(|_| base36(rand::random::() as u128 % 36)).collect(); + format!("qlik_{}_{alea}", base36(ms)) +} + +fn maintenant_iso() -> String { + crate::db::iso_utc(chrono::Utc::now()) +} + +/// Sérialise un job pour la réponse HTTP (champs absents omis, comme `undefined`). +fn public_job(job: &QlikSyncJob) -> Value { + let mut m = Map::new(); + m.insert("success".into(), Value::Bool(true)); + m.insert("jobId".into(), Value::from(job.job_id.clone())); + m.insert("fournisseur".into(), Value::from(job.fournisseur.clone())); + if let Some(c) = &job.code_centrale { + m.insert("codeCentrale".into(), Value::from(c.clone())); + } + m.insert("status".into(), Value::from(job.status.as_str())); + m.insert("requested".into(), Value::from(job.requested)); + m.insert("fetched".into(), Value::from(job.fetched)); + m.insert("upserted".into(), Value::from(job.upserted)); + for (k, v) in [("periode", &job.periode), ("dateDebut", &job.date_debut), ("dateFin", &job.date_fin)] { + if let Some(v) = v { + m.insert(k.into(), Value::from(v.clone())); + } + } + m.insert("startedAt".into(), Value::from(job.started_at.clone())); + for (k, v) in [("finishedAt", &job.finished_at), ("error", &job.error), ("message", &job.message)] { + if let Some(v) = v { + m.insert(k.into(), Value::from(v.clone())); + } + } + Value::Object(m) +} + +/// État "idle" sérialisé (pas de job connu pour cette cible). +fn idle_job(fournisseur: &str, code_centrale: Option<&str>) -> Value { + let mut m = Map::new(); + m.insert("success".into(), Value::Bool(true)); + m.insert("jobId".into(), Value::Null); + m.insert("fournisseur".into(), Value::from(fournisseur)); + if let Some(c) = code_centrale { + m.insert("codeCentrale".into(), Value::from(c)); + } + m.insert("status".into(), Value::from("idle")); + m.insert("requested".into(), Value::from(0)); + m.insert("fetched".into(), Value::from(0)); + m.insert("upserted".into(), Value::from(0)); + Value::Object(m) +} + +/// Codes centraux retenus et compteurs de rejet. +#[derive(Debug, Default, PartialEq)] +pub struct CodesFiltres { + pub accepted: Vec, + pub rejected: usize, + pub deduped: usize, +} + +/// Filtre strict des codes centraux avant d'envoyer à Qlik. +/// +/// Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`). +/// Pour rester large et ne pas casser d'éventuels codes alphanumériques +/// fournisseur, on autorise longueur 2..30, jeu `[A-Z0-9_-]`, premier caractère +/// alphanumérique. But : ne jamais envoyer à Qlik des chaînes vides, des `-`, +/// des espaces parasites ou des entrées manifestement invalides, qui +/// déclenchent des erreurs Engine. On ne touche pas au mapping. +/// +/// - trim + rejet des vides, de `-` et `_` +/// - doublons supprimés (ordre préservé, comparaison insensible à la casse) +/// - rejet des codes hors format +/// +/// Les journaux n'exposent que les compteurs, jamais les valeurs. +pub fn filter_central_codes(raw_codes: I) -> CodesFiltres +where + I: IntoIterator>, + S: AsRef, +{ + let re = regex::Regex::new(r"(?i)^[A-Z0-9][A-Z0-9_-]{1,29}$").expect("regex"); + let mut vus = std::collections::HashSet::new(); + let mut out = CodesFiltres::default(); + for raw in raw_codes { + let Some(raw) = raw else { + out.rejected += 1; + continue; + }; + let code = raw.as_ref().trim().to_string(); + let key = code.to_uppercase(); + if code.is_empty() || code == "-" || code == "_" { + out.rejected += 1; + continue; + } + if vus.contains(&key) { + out.deduped += 1; + continue; + } + if !re.is_match(&code) { + out.rejected += 1; + continue; + } + vus.insert(key); + out.accepted.push(code); + } + out +} + +/// Inscrit une erreur dans le job, en traduisant la saturation mémoire du +/// serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow : +/// l'app Qlik ne se charge même pas). +fn apply_job_error(job: &JobPartage, msg: &str) { + let mut j = job.lock(); + tracing::error!("[api/qlik/sync] job={} {msg}", j.job_id); + j.status = QlikSyncStatus::Error; + let lower = msg.to_lowercase(); + j.error = Some( + if lower.contains("out of memory") + || lower.contains("not enough memory") + || lower.contains("file corrupted") + || lower.contains("\"code\":6") + || lower.contains("\"code\":3002") + { + "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).".to_string() + } else { + msg.to_string() + }, + ); + j.finished_at = Some(maintenant_iso()); +} + +fn pied_capture(job: &JobPartage) -> String { + let j = job.lock(); + format!("statut final={}{}", j.status.as_str(), j.error.as_ref().map(|e| format!(" — {e}")).unwrap_or_default()) +} + +/// Extraction + upsert pour un fournisseur, en mettant à jour le job au fur et +/// à mesure (compteur fetched, puis upserted, puis finishedAt). +async fn run_job(job: JobPartage) { + let (job_id, fournisseur, started_at) = { + let j = job.lock(); + (j.job_id.clone(), j.fournisseur.clone(), j.started_at.clone()) + }; + // Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du + // job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) — + // c'est précisément la partie qu'un terminal tronque. + start_capture(&job_id, Some(&format!("sync fournisseur={fournisseur} démarrée le {started_at}"))); + let res: anyhow::Result<()> = async { + let articles = crate::ff::pg::pg_get_articles_by_fournisseur(&fournisseur).await?; + // Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects). + let filtres = filter_central_codes(articles.iter().map(|a| a.code_centrale.as_deref())); + let codes = filtres.accepted; + job.lock().requested = codes.len(); + tracing::info!( + "[api/qlik/sync] job={job_id} fournisseur={fournisseur} — {} articles, {} codes centraux retenus, {} rejetés (vide/-/anormaux), {} doublons supprimés", + articles.len(), + codes.len(), + filtres.rejected, + filtres.deduped + ); + if codes.is_empty() { + let mut j = job.lock(); + j.status = QlikSyncStatus::Success; + j.message = Some("Aucun code centrale valide pour ce fournisseur".into()); + j.finished_at = Some(maintenant_iso()); + return Ok(()); + } + + // Contrat métier strict : toujours 12 mois complets glissants. + // Une variable d'environnement ne doit pas pouvoir raccourcir la série. + let date_filter = build_grid_network_qlik_date_filter_now(QLIK_MONTHS_BACK_DEFAULT); + { + let mut j = job.lock(); + j.periode = Some(date_filter.label.clone()); + j.date_debut = Some(date_filter.date_debut.clone()); + j.date_fin = Some(date_filter.date_fin.clone()); + } + tracing::info!( + "[api/qlik/sync] job={job_id} → extraction Qlik pour {} codes — 12 mois complets {} ({} → {})…", + codes.len(), + date_filter.label, + date_filter.date_debut, + date_filter.date_fin + ); + // Le code fournisseur est transmis en plus des codes : s'il désigne dans + // Qlik le même périmètre (couverture vérifiée côté extracteur), une seule + // sélection remplace les dizaines de milliers de `SelectValues` par code. + // Les codes restent le repli et le contrôle de couverture. + let metrics = fetch_network_metrics_playwright(Some(codes), None, Some(date_filter), Some(fournisseur.clone())).await?; + job.lock().fetched = metrics.len(); + tracing::info!("[api/qlik/sync] job={job_id} ← Qlik a renvoyé {} produits réseau", metrics.len()); + let valeurs: Vec<_> = metrics.into_values().collect(); + let count = upsert_network_metrics(&valeurs).await?; + tracing::info!("[api/qlik/sync] job={job_id} {count} lignes upsert dans le cache"); + let mut j = job.lock(); + j.upserted = count; + j.status = QlikSyncStatus::Success; + j.finished_at = Some(maintenant_iso()); + Ok(()) + } + .await; + if let Err(e) = res { + apply_job_error(&job, &format!("{e:#}")); + } + stop_capture(&job_id, Some(&pied_capture(&job))); +} + +/// Extraction Qlik pour UN seul code centrale (fiche produit). +/// +/// Beaucoup plus légère que la sync fournisseur : un seul code au lieu de +/// plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise +/// exactement le même extracteur, donc le même détail mensuel. +async fn run_product_job(job: JobPartage) { + let (job_id, code, started_at) = { + let j = job.lock(); + (j.job_id.clone(), j.code_centrale.clone().unwrap_or_default(), j.started_at.clone()) + }; + start_capture(&job_id, Some(&format!("sync produit={code} démarrée le {started_at}"))); + let res: anyhow::Result<()> = async { + let date_filter = build_grid_network_qlik_date_filter_now(QLIK_MONTHS_BACK_DEFAULT); + { + let mut j = job.lock(); + j.requested = 1; + j.periode = Some(date_filter.label.clone()); + j.date_debut = Some(date_filter.date_debut.clone()); + j.date_fin = Some(date_filter.date_fin.clone()); + } + tracing::info!( + "[api/qlik/sync] job={job_id} → extraction Qlik produit (1 code) — fenêtre {} ({} → {})…", + date_filter.label, + date_filter.date_debut, + date_filter.date_fin + ); + let metrics = fetch_network_metrics_playwright(Some(vec![code.clone()]), None, Some(date_filter), None).await?; + job.lock().fetched = metrics.len(); + if metrics.is_empty() { + let mut j = job.lock(); + j.status = QlikSyncStatus::Success; + j.message = Some("Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)".into()); + j.finished_at = Some(maintenant_iso()); + return Ok(()); + } + let valeurs: Vec<_> = metrics.into_values().collect(); + let count = upsert_network_metrics(&valeurs).await?; + { + let mut j = job.lock(); + j.upserted = count; + j.status = QlikSyncStatus::Success; + j.message = Some("Données réseau récupérées".into()); + j.finished_at = Some(maintenant_iso()); + } + tracing::info!("[api/qlik/sync] job={job_id} produit {code} — {count} ligne(s) upsert"); + Ok(()) + } + .await; + if let Err(e) = res { + apply_job_error(&job, &format!("{e:#}")); + } + stop_capture(&job_id, Some(&pied_capture(&job))); +} + +/// Lancement « fire-and-forget » : on ne bloque pas la réponse HTTP. Filet de +/// sécurité : une panique du job ne doit jamais laisser le job « running ». +fn lancer(job: JobPartage, produit: bool) { + let suivi = job.clone(); + let tache = if produit { tokio::spawn(run_product_job(job)) } else { tokio::spawn(run_job(job)) }; + tokio::spawn(async move { + if let Err(e) = tache.await { + let msg = format!("crash non géré : {e}"); + tracing::error!("[api/qlik/sync] job={} {msg}", suivi.lock().job_id); + let mut j = suivi.lock(); + j.status = QlikSyncStatus::Error; + j.error = Some(msg); + j.finished_at = Some(maintenant_iso()); + } + }); +} + +fn param<'a>(q: &'a HashMap, nom: &str) -> Option<&'a str> { + q.get(nom).map(|s| s.as_str()).filter(|s| !s.is_empty()) +} + +const PARAM_REQUIS: &str = "Param 'fournisseur' ou 'codeCentrale' requis"; + +/// GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY +/// +/// Retourne l'état courant du job pour cette cible ; « idle » si aucun job +/// n'est connu. Permet au client de reprendre l'état après un rechargement et +/// de suivre l'avancement en arrière-plan. +pub async fn get(Token(token): Token, Query(q): Query>) -> AppResult> { + // Mode produit : ouvert à tout utilisateur connecté. + if let Some(code_centrale) = param(&q, "codeCentrale") { + require_session(token.as_ref()).await?; + let job = JOBS.lock().get(&job_key_for_product(code_centrale)).cloned(); + return Ok(Json(match job { + Some(j) => public_job(&j.lock()), + None => idle_job("", Some(code_centrale)), + })); + } + + // Mode fournisseur : admin uniquement. + require_admin(token.as_ref()).await?; + let Some(fournisseur) = param(&q, "fournisseur") else { + return Err(AppError::bad_request(PARAM_REQUIS)); + }; + let job = JOBS.lock().get(fournisseur).cloned(); + Ok(Json(match job { + Some(j) => public_job(&j.lock()), + None => idle_job(fournisseur, None), + })) +} + +/// POST /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY +/// +/// Démarre (ou réutilise s'il tourne déjà) un job d'extraction en arrière-plan +/// et retourne *immédiatement* son état. Le client suit ensuite via GET. +pub async fn post(Token(token): Token, Query(q): Query>) -> AppResult> { + // ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ─── + if let Some(code_param) = param(&q, "codeCentrale") { + require_session(token.as_ref()).await?; + // Même validation que la sync fournisseur (trim, rejet des vides / "-" / + // codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik. + let filtres = filter_central_codes([Some(code_param)]); + let Some(code) = filtres.accepted.into_iter().next() else { + return Err(AppError::bad_request("Code centrale invalide")); + }; + let key = job_key_for_product(&code); + if let Some(existant) = JOBS.lock().get(&key).cloned() { + let j = existant.lock(); + if j.status == QlikSyncStatus::Running { + return Ok(Json(public_job(&j))); + } + } + if running_product_jobs() >= MAX_CONCURRENT_PRODUCT_JOBS { + return Err(AppError::new(StatusCode::TOO_MANY_REQUESTS, "Trop d'extractions Qlik en cours. Réessayez dans quelques instants.")); + } + let job = Arc::new(Mutex::new(QlikSyncJob { + job_id: new_job_id(), + fournisseur: String::new(), + code_centrale: Some(code.clone()), + status: QlikSyncStatus::Running, + requested: 0, + fetched: 0, + upserted: 0, + periode: None, + date_debut: None, + date_fin: None, + started_at: maintenant_iso(), + finished_at: None, + error: None, + message: None, + })); + JOBS.lock().insert(key, job.clone()); + let corps = public_job(&job.lock()); + tracing::info!("[api/qlik/sync] job={} démarré pour codeCentrale={code}", job.lock().job_id); + lancer(job, true); + return Ok(Json(corps)); + } + + // ─── Mode fournisseur : admin uniquement ────────────────────────────────── + require_admin(token.as_ref()).await?; + let Some(fournisseur) = param(&q, "fournisseur") else { + return Err(AppError::bad_request(PARAM_REQUIS)); + }; + if let Some(existant) = JOBS.lock().get(fournisseur).cloned() { + let j = existant.lock(); + if j.status == QlikSyncStatus::Running { + // Un job tourne déjà pour ce fournisseur : on le réutilise. + return Ok(Json(public_job(&j))); + } + } + let job = Arc::new(Mutex::new(QlikSyncJob { + job_id: new_job_id(), + fournisseur: fournisseur.to_string(), + code_centrale: None, + status: QlikSyncStatus::Running, + requested: 0, + fetched: 0, + upserted: 0, + periode: None, + date_debut: None, + date_fin: None, + started_at: maintenant_iso(), + finished_at: None, + error: None, + message: None, + })); + JOBS.lock().insert(fournisseur.to_string(), job.clone()); + let corps = public_job(&job.lock()); + tracing::info!("[api/qlik/sync] job={} démarré pour fournisseur={fournisseur}", job.lock().job_id); + // Lancement fire-and-forget : on renvoie tout de suite au client, qui suit + // via GET. Les erreurs sont capturées dans le job. + lancer(job, false); + Ok(Json(corps)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filtre_des_codes_centraux() { + let long = "X".repeat(31); + let r = filter_central_codes([ + Some(" 10000167303 "), + None, + Some(""), + Some("-"), + Some("_"), + Some("10000167303"), + Some("abc"), + Some("ABC"), + Some("a"), + Some("_x1"), + Some("code avec espace"), + Some(long.as_str()), + ]); + assert_eq!(r.accepted, vec!["10000167303".to_string(), "abc".to_string()]); + assert_eq!(r.deduped, 2); + assert_eq!(r.rejected, 8); + } + + #[test] + fn formes_json() { + let idle = idle_job("", Some("100")); + assert_eq!( + idle.to_string(), + r#"{"success":true,"jobId":null,"fournisseur":"","codeCentrale":"100","status":"idle","requested":0,"fetched":0,"upserted":0}"# + ); + let job = QlikSyncJob { + job_id: "qlik_x_y".into(), + fournisseur: "J009".into(), + code_centrale: None, + status: QlikSyncStatus::Error, + requested: 3, + fetched: 0, + upserted: 0, + periode: Some("2025-10_2026-09".into()), + date_debut: Some("2025-10-01".into()), + date_fin: Some("2026-09-30".into()), + started_at: "2026-10-12T08:00:00.000Z".into(), + finished_at: Some("2026-10-12T08:01:00.000Z".into()), + error: Some("boom".into()), + message: None, + }; + assert_eq!( + public_job(&job).to_string(), + r#"{"success":true,"jobId":"qlik_x_y","fournisseur":"J009","status":"error","requested":3,"fetched":0,"upserted":0,"periode":"2025-10_2026-09","dateDebut":"2025-10-01","dateFin":"2026-09-30","startedAt":"2026-10-12T08:00:00.000Z","finishedAt":"2026-10-12T08:01:00.000Z","error":"boom"}"# + ); + assert!(new_job_id().starts_with("qlik_")); + assert_eq!(base36(35), "z"); + } + + #[test] + fn saturation_memoire_traduite() { + let job = Arc::new(Mutex::new(QlikSyncJob { + job_id: "j".into(), + fournisseur: "F".into(), + code_centrale: None, + status: QlikSyncStatus::Running, + requested: 0, + fetched: 0, + upserted: 0, + periode: None, + date_debut: None, + date_fin: None, + started_at: String::new(), + finished_at: None, + error: None, + message: None, + })); + apply_job_error(&job, r#"[qlik-pw] {"code":6,"message":"Not enough memory"}"#); + assert!(job.lock().error.as_deref().unwrap().starts_with("Serveur Qlik saturé")); + assert_eq!(job.lock().status, QlikSyncStatus::Error); + } +} diff --git a/server/src/qlik/tests_navigateur.rs b/server/src/qlik/tests_navigateur.rs new file mode 100644 index 0000000..ccffb0e --- /dev/null +++ b/server/src/qlik/tests_navigateur.rs @@ -0,0 +1,161 @@ +//! Tests de la plomberie Chromium/CDP (sans serveur Qlik). +//! +//! Le test principal est ignoré par défaut (il lance un vrai Chromium) : +//! `PLAYWRIGHT_CHROMIUM_PATH=/opt/pw-browsers/chromium-1194/chrome-linux/chrome \ +//! cargo test -- --ignored plomberie_cdp` + +use super::browser::{dans_la_file_qlik, get_qlik_browser, PageQlik}; +use axum::http::HeaderMap; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; + +const PAGE: &str = r#"faux Qlik + +"#; + +/// Défi HTTP Basic : la page doit répondre avec les identifiants (`httpCredentials`). +async fn protege(headers: HeaderMap) -> axum::response::Response { + use axum::response::IntoResponse; + match headers.get("authorization").and_then(|v| v.to_str().ok()) { + Some(a) => format!("autorisé {a}").into_response(), + None => (axum::http::StatusCode::UNAUTHORIZED, [("www-authenticate", "Basic realm=\"qlik\"")], "refusé").into_response(), + } +} + +async fn racine(headers: HeaderMap) -> Html { + let cookie = headers.get("cookie").and_then(|v| v.to_str().ok()).unwrap_or("(aucun)").to_string(); + Html(PAGE.replace("COOKIE", &cookie)) +} + +#[tokio::test] +#[ignore] +async fn plomberie_cdp() { + if std::env::var("PLAYWRIGHT_CHROMIUM_PATH").is_err() { + std::env::set_var("PLAYWRIGHT_CHROMIUM_PATH", "/opt/pw-browsers/chromium-1194/chrome-linux/chrome"); + } + // Petit serveur HTTP local. + let app = Router::new().route("/", get(racine)).route("/app/abc", get(|| async { "pas un websocket" })).route("/protege", get(protege)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let browser = get_qlik_browser().await.expect("lancement de Chromium"); + // Même navigateur tant qu'il est connecté. + assert!(Arc::ptr_eq(&browser, &get_qlik_browser().await.unwrap())); + + let lignes = Arc::new(parking_lot::Mutex::new(Vec::::new())); + let copie = lignes.clone(); + let mut page = PageQlik::ouvrir( + browser, + Box::new(move |t: &str| { + copie.lock().push(t.to_string()); + t.starts_with("[cf]").then(|| format!("[test][page] {t}")) + }), + Some("[test][pageerror]"), + ) + .await + .expect("page"); + + page.identifiants_http("user", "secret").await.unwrap(); + page.ajouter_cookie("X-Qlik-Session", "valeur-de-session", "localhost").await.unwrap(); + let mut jeton = page.capturer_jeton_csrf().await.unwrap(); + let mut appels = page.exposer_fonction("cfCheckpoint").await.unwrap(); + + page.aller(&format!("http://localhost:{port}/"), Duration::from_secs(20)).await.expect("navigation"); + let j = super::playwright::attendre_jeton(&mut jeton).await; + assert_eq!(j.as_deref(), Some("jeton/secret"), "jeton CSRF capturé depuis l'URL du websocket"); + + // Cookie posé avant navigation (Network.setCookies) et renvoyé au serveur. + let texte = page.evaluer("() => document.getElementById('cookie').textContent", &json!(null)).await.unwrap(); + assert_eq!(texte, json!("X-Qlik-Session=valeur-de-session")); + + // Liaison exposée : appelée par la page, reçue côté Rust ; la promesse se résout à true. + let r = page + .evaluer( + "async ({ n }) => { const ok = await window.cfCheckpoint({ rows: [[\"C1\", n]] }); return { ok, n: n * 2, nan: NaN }; }", + &json!({ "n": 21 }), + ) + .await + .unwrap(); + assert_eq!(r, json!({ "ok": true, "n": 42, "nan": null })); + let payload = tokio::time::timeout(Duration::from_secs(5), appels.recv()).await.unwrap().unwrap(); + assert_eq!(serde_json::from_str::(&payload).unwrap(), json!({ "rows": [["C1", 21]] })); + + // Exception in-page → erreur. + let err = page.evaluer("() => { throw new Error('boum'); }", &json!(null)).await.unwrap_err().to_string(); + assert!(err.contains("boum"), "{err}"); + + // Script embarqué de la recherche, avec ses vrais arguments JSON : le + // websocket Engine (wss) échoue ici faute de Qlik, et le script le dit. + let args = json!({ + "app": "abc", "dim": "yesCP", "token": "jeton", "terme": "tapis bain", "limite": 40, "settleMs": 10, + "serialsDate": [46000, 46001], "mCa": "a", "mQte": "b", "mNbMag": "c", "mCaMag": "d", "mMarge": "e", + "champCodeAlt": "article_no_centrale", "champsLibellePreferes": ["Article"], "champsFournisseurPreferes": ["Fournisseur"], + "champLibelleForce": "", "champFournisseurForce": "" + }); + let res = tokio::time::timeout(Duration::from_secs(40), page.evaluer(include_str!("js/recherche.js"), &args)).await.unwrap().unwrap(); + assert_eq!(res["ok"], json!(false), "{res}"); + assert!(res["error"].as_str().unwrap().starts_with("ws "), "{res}"); + + // Script d'extraction : même chose, avec un jeu d'arguments complet. + let args = json!({ + "app": "abc", "dim": "yesCP", "mca": "a", "mqte": "b", "mnb": "c", "mcamag": "d", "mmarge": "e", + "codes": ["10000167303"], "token": "jeton", "monthlyPayload": [], "noDateMode": true, + "qlikSettleMs": 10, "qlikCode15MaxAttempts": 1, "qlikArticleBatchFallbacks": [150, 75, 50], + "selectAllCodes": true, "monthDim": true, "moisDimId": "pfGAwTs", "mqteComp": "x", "yearN": 2026, + "useExpr": true, "exprQte": "Sum(quantite)", "exprCa": "Sum(ca_ht)", "exprNbMag": "Count(DISTINCT [Magasin Code])", + "exprMarge": "Sum(marge)", "champsDateCandidats": ["Date"], "fournisseurCode": "", "champsFournisseurCandidats": ["code_fournisseur"], + "couvertureFournisseurMin": 0.99 + }); + let res = tokio::time::timeout(Duration::from_secs(40), page.evaluer(include_str!("js/extraction.js"), &args)).await.unwrap().unwrap(); + assert_eq!(res["ok"], json!(false), "{res}"); + assert_eq!(res["rows"], json!([])); + + tokio::time::sleep(Duration::from_millis(300)).await; + let vues = lignes.lock().clone(); + assert!(vues.iter().any(|l| l == "[cf] bonjour depuis la page 42"), "{vues:?}"); + assert!(vues.iter().any(|l| l == "bruit du client Qlik"), "{vues:?}"); + + // Défi d'authentification HTTP : identifiants fournis automatiquement. + page.aller(&format!("http://localhost:{port}/protege"), Duration::from_secs(20)).await.expect("navigation protégée"); + let corps = page.evaluer("() => document.body.textContent", &json!(null)).await.unwrap(); + assert_eq!(corps, json!("autorisé Basic dXNlcjpzZWNyZXQ=")); + + page.fermer().await; +} + +#[tokio::test] +async fn file_qlik_sequentielle() { + let ordre = Arc::new(parking_lot::Mutex::new(Vec::new())); + let mut taches = Vec::new(); + for i in 0..4 { + let ordre = ordre.clone(); + taches.push(tokio::spawn(async move { + dans_la_file_qlik(&format!("tâche {i}"), || async move { + ordre.lock().push(format!("début {i}")); + tokio::time::sleep(Duration::from_millis(20)).await; + ordre.lock().push(format!("fin {i}")); + i + }) + .await + })); + tokio::time::sleep(Duration::from_millis(2)).await; + } + for (i, t) in taches.into_iter().enumerate() { + assert_eq!(t.await.unwrap(), i); + } + let o = ordre.lock().clone(); + // Jamais deux tâches en même temps, dans l'ordre d'arrivée. + let attendu: Vec = (0..4).flat_map(|i| [format!("début {i}"), format!("fin {i}")]).collect(); + assert_eq!(o, attendu); +} diff --git a/server/src/qlik/validation.rs b/server/src/qlik/validation.rs new file mode 100644 index 0000000..9d6d7c8 --- /dev/null +++ b/server/src/qlik/validation.rs @@ -0,0 +1,272 @@ +//! Contrôles de plausibilité d'une extraction réseau Qlik +//! (port de `src/lib/qlik-validation.ts`). +//! +//! L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne +//! peut pas voir deux pièges, observés ou attendus en production, qui donnent +//! des chiffres faux mais complets : +//! +//! 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois, +//! le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est +//! « couvert » (quelques ventes), donc accepté — et toutes les tendances du +//! fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine +//! ou plus après. +//! 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de +//! dates, chaque mois reçoit la même valeur (le total de l'année). Le +//! diagnostic existait dans les journaux ; rien n'empêchait l'écriture. +//! +//! Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des +//! données fausses : l'extraction est refusée, et le planificateur la reprend +//! une nuit suivante. +//! +//! Module pur : testé par `cargo test`. + +use super::client::{NetworkMetric, QlikMonthMetrics}; +use super::jsnum::texte; +use chrono::{Datelike, NaiveDate}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct ControleExtraction { + pub ok: bool, + /// Raison du refus, lisible par l'administrateur (statut de synchro). + #[serde(skip_serializing_if = "Option::is_none")] + pub motif: Option, + /// Anomalies corrigées ou tolérées, à journaliser. + pub avertissements: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct OptionsControle { + /// Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet. + /// Au-delà, une baisse est prise pour ce qu'elle est : une baisse. + pub jour_limite_dernier_mois: u32, + /// Part minimale du dernier mois par rapport à la moyenne des 3 précédents. + pub ratio_dernier_mois_min: f64, + /// Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. + pub volume_minimal: f64, + /// Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. + pub part_series_plates_max: f64, +} + +impl Default for OptionsControle { + fn default() -> Self { + Self { jour_limite_dernier_mois: 10, ratio_dernier_mois_min: 0.4, volume_minimal: 30.0, part_series_plates_max: 0.5 } + } +} + +fn fini(v: Option) -> f64 { + match v { + Some(n) if n.is_finite() => n, + _ => 0.0, + } +} + +/// `Math.round` JS (demi vers +∞). +fn arrondi_js(v: f64) -> f64 { + (v + 0.5).floor() +} + +/// Corrige sur place les valeurs impossibles d'une métrique (NaN, infini, +/// nombre de magasins négatif) et renvoie la liste des corrections. +pub fn assainir_metrique(m: &mut NetworkMetric) -> Vec { + let mut corrections = Vec::new(); + let code = m.code_centrale.clone(); + for (champ, valeur) in [ + ("caReseau", &mut m.ca_reseau), + ("qteReseau", &mut m.qte_reseau), + ("nbMagasinsReseau", &mut m.nb_magasins_reseau), + ("caParMagasinReseau", &mut m.ca_par_magasin_reseau), + ("margePctReseau", &mut m.marge_pct_reseau), + ] { + if !valeur.is_finite() { + corrections.push(format!("{code} : {champ} non numérique ({}) remplacé par 0", texte(*valeur))); + *valeur = 0.0; + } + } + if m.nb_magasins_reseau < 0.0 { + corrections.push(format!("{code} : nombre de magasins négatif ({}) remplacé par 0", texte(m.nb_magasins_reseau))); + m.nb_magasins_reseau = 0.0; + } + m.nb_magasins_reseau = arrondi_js(m.nb_magasins_reseau); + m.ca_par_magasin_reseau = if m.nb_magasins_reseau > 0.0 { m.ca_reseau / m.nb_magasins_reseau } else { 0.0 }; + + if let Some(q) = m.qte_by_month.as_mut() { + for (mois, v) in q.iter_mut() { + if !v.is_finite() { + corrections.push(format!("{code} : quantité {mois} non numérique remplacée par 0")); + *v = 0.0; + } + } + } + if let Some(mm) = m.metrics_by_month.as_mut() { + for v in mm.values_mut() { + *v = QlikMonthMetrics { + qte: fini(Some(v.qte)), + ca: Some(fini(v.ca)), + nb_mag: Some(arrondi_js(fini(v.nb_mag)).max(0.0)), + ca_mag: Some(fini(v.ca_mag)), + marge_pct: Some(fini(v.marge_pct)), + }; + } + } + corrections +} + +/// Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache. +/// +/// `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent. +/// `now` : date locale du jour. +pub fn controler_extraction<'a>( + metriques: impl IntoIterator, + fenetre: &[String], + now: NaiveDate, + o: OptionsControle, +) -> ControleExtraction { + let mut avertissements = Vec::new(); + let liste: Vec<&NetworkMetric> = metriques.into_iter().collect(); + if liste.is_empty() || fenetre.len() < 4 { + return ControleExtraction { ok: true, motif: None, avertissements }; + } + let qte = |m: &NetworkMetric, mois: &str| fini(m.qte_by_month.as_ref().and_then(|q| q.get(mois).copied())); + + // ── 1. Séries plates : la mesure a ignoré la sélection de mois ────────── + let mut vendues = 0usize; + let mut plates = 0usize; + for m in &liste { + let valeurs: Vec = fenetre.iter().map(|mois| qte(m, mois)).collect(); + if !valeurs.iter().any(|v| *v != 0.0) { + continue; + } + vendues += 1; + if valeurs.iter().all(|v| *v == valeurs[0]) { + plates += 1; + } + } + if vendues >= 5 && plates as f64 / vendues as f64 > o.part_series_plates_max { + return ControleExtraction { + ok: false, + motif: Some(format!("{plates} séries sur {vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées")), + avertissements, + }; + } + if plates > 0 { + avertissements.push(format!("{plates} série(s) identique(s) sur 12 mois parmi {vendues} vendues")); + } + + // ── 2. Dernier mois manifestement incomplet ───────────────────────────── + let totaux: Vec = fenetre.iter().map(|mois| liste.iter().map(|m| qte(m, mois)).sum()).collect(); + let dernier = totaux[totaux.len() - 1]; + let precedents = &totaux[totaux.len() - 4..totaux.len() - 1]; + let reference = precedents.iter().sum::() / precedents.len() as f64; + if now.day() <= o.jour_limite_dernier_mois && reference >= o.volume_minimal && dernier < reference * o.ratio_dernier_mois_min { + let pct = arrondi_js(dernier / reference * 100.0); + return ControleExtraction { + ok: false, + motif: Some(format!( + "le dernier mois ({}) ne pèse que {} % des 3 mois précédents : probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)", + fenetre[fenetre.len() - 1], + texte(pct) + )), + avertissements, + }; + } + + ControleExtraction { ok: true, motif: None, avertissements } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn fenetre() -> Vec { + (1..=12).map(|m| format!("2025-{m:02}")).collect() + } + + fn metrique(code: &str, qtes: &[f64]) -> NetworkMetric { + let f = fenetre(); + NetworkMetric { + code_centrale: code.into(), + qte_by_month: Some(f.iter().cloned().zip(qtes.iter().copied()).collect::>()), + ..Default::default() + } + } + + fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() + } + + #[test] + fn assainit_les_valeurs_impossibles() { + let mut m = NetworkMetric { + code_centrale: "C1".into(), + ca_reseau: 100.0, + qte_reseau: f64::NAN, + nb_magasins_reseau: -3.0, + marge_pct_reseau: f64::INFINITY, + qte_by_month: Some(BTreeMap::from([("2025-01".into(), f64::NAN), ("2025-02".into(), 4.0)])), + metrics_by_month: Some(BTreeMap::from([( + "2025-01".into(), + QlikMonthMetrics { qte: f64::NAN, ca: None, nb_mag: Some(2.5), ca_mag: Some(1.0), marge_pct: None }, + )])), + ..Default::default() + }; + let c = assainir_metrique(&mut m); + assert_eq!(c.len(), 4, "{c:?}"); + assert!(c[0].contains("qteReseau non numérique (NaN)")); + assert!(c[1].contains("margePctReseau non numérique (Infinity)")); + assert!(c[2].contains("nombre de magasins négatif (-3)")); + assert_eq!(m.qte_reseau, 0.0); + assert_eq!(m.nb_magasins_reseau, 0.0); + assert_eq!(m.ca_par_magasin_reseau, 0.0); + assert_eq!(m.qte_by_month.as_ref().unwrap()["2025-01"], 0.0); + let mm = &m.metrics_by_month.as_ref().unwrap()["2025-01"]; + assert_eq!(mm, &QlikMonthMetrics { qte: 0.0, ca: Some(0.0), nb_mag: Some(3.0), ca_mag: Some(1.0), marge_pct: Some(0.0) }); + + let mut m2 = NetworkMetric { code_centrale: "C2".into(), ca_reseau: 90.0, nb_magasins_reseau: 2.6, ..Default::default() }; + assert!(assainir_metrique(&mut m2).is_empty()); + assert_eq!(m2.nb_magasins_reseau, 3.0); + assert_eq!(m2.ca_par_magasin_reseau, 30.0); + } + + #[test] + fn refuse_les_series_plates() { + let liste: Vec = (0..6).map(|i| metrique(&format!("C{i}"), &[10.0; 12])).collect(); + let r = controler_extraction(&liste, &fenetre(), d("2026-01-20"), OptionsControle::default()); + assert!(!r.ok); + assert!(r.motif.unwrap().starts_with("6 séries sur 6 ont la même quantité")); + } + + #[test] + fn tolere_quelques_series_plates() { + let mut liste: Vec = + (0..5).map(|i| metrique(&format!("V{i}"), &[1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12.])).collect(); + liste.push(metrique("P", &[5.0; 12])); + let r = controler_extraction(&liste, &fenetre(), d("2026-01-20"), OptionsControle::default()); + assert!(r.ok); + assert_eq!(r.avertissements, vec!["1 série(s) identique(s) sur 12 mois parmi 6 vendues".to_string()]); + } + + #[test] + fn refuse_un_dernier_mois_incomplet_en_debut_de_mois() { + let mut q = [100.0; 12]; + q[11] = 20.0; + q[0] = 99.0; // pas plate + let liste = vec![metrique("C", &q)]; + let r = controler_extraction(&liste, &fenetre(), d("2026-01-05"), OptionsControle::default()); + assert!(!r.ok); + assert!(r.motif.unwrap().starts_with("le dernier mois (2025-12) ne pèse que 20 % des 3 mois précédents")); + // Après le 10 du mois : la baisse est réelle. + assert!(controler_extraction(&liste, &fenetre(), d("2026-01-11"), OptionsControle::default()).ok); + // Volume trop faible : pas de contrôle. + let petit = vec![metrique("C", &[10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 11.0, 1.0])]; + assert!(controler_extraction(&petit, &fenetre(), d("2026-01-05"), OptionsControle::default()).ok); + } + + #[test] + fn fenetre_courte_ou_vide_acceptee() { + assert!(controler_extraction(&[], &fenetre(), d("2026-01-05"), OptionsControle::default()).ok); + let liste = vec![metrique("C", &[1.0; 12])]; + assert!(controler_extraction(&liste, &fenetre()[..3], d("2026-01-05"), OptionsControle::default()).ok); + } +} From e3890c00d05e11bca1dbb5230afbecc9a3622c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:26:40 +0000 Subject: [PATCH 08/19] =?UTF-8?q?Param=C3=A8tres=20:=20diagnostic=20et=20U?= =?UTF-8?q?RL=20de=20l'API=20FF=20branch=C3=A9s=20sur=20le=20module=20ff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/admin/qlik_bridge.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/admin/qlik_bridge.rs b/server/src/admin/qlik_bridge.rs index 5621280..af373f1 100644 --- a/server/src/admin/qlik_bridge.rs +++ b/server/src/admin/qlik_bridge.rs @@ -1,7 +1,7 @@ //! Points d'appel vers les modules `qlik` et `ff` utilisés par les Paramètres. //! Branchés après l'intégration de ces modules. -use serde_json::{json, Value}; +use serde_json::Value; /// Ouvre une session Qlik (NTLM → ticket → cookie) avec les identifiants donnés. pub async fn tester_session(_host: String, _user: String, _password: Option) -> anyhow::Result<()> { @@ -9,9 +9,11 @@ pub async fn tester_session(_host: String, _user: String, _password: Option) -> Value { - json!({ "success": false, "url": base.unwrap_or_default(), "error": "Diagnostic indisponible : module en cours de portage" }) + serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base).await).unwrap_or(Value::Null) } From f104719054acf01b3e2e77dbc5a56130bf3cfeef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:30:20 +0000 Subject: [PATCH 09/19] =?UTF-8?q?Interface=20:=20passage=20de=20Next.js=20?= =?UTF-8?q?=C3=A0=20une=20SPA=20React=20(Vite=20+=20React=20Router)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur étant réécrit en Rust, l'interface devient une application React 19 côté navigateur, compilée par Vite dans dist/ et servie par le serveur Rust (STATIC_DIR). Composants, styles et logique cliente inchangés : seule la plomberie Next.js est remplacée. - Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS, React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/, proxy /api → localhost:5643 en développement, ESLint sans plugin Next. - Adaptations (src/shims) : next/link, next/navigation (useRouter avec refresh, usePathname/useSearchParams suivant aussi les replaceState de l'interface), next-auth/react (session lue sur /api/auth/session, déconnexion par /api/auth/logout). - Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms, mêmes signatures, mêmes types. La connexion passe par /api/auth/login. - Pages → table des routes (src/router.tsx) : données lues sur /api/pages/{nom} avec Suspense (loading.tsx au premier chargement, page conservée pendant une navigation ou un router.refresh()), error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet, pages d'administration réservées aux administrateurs. - Suppression du code serveur Next.js (routes /api, accès base, Qlik, authentification, middleware…) ; types utiles à l'interface recopiés dans src/types/ff.ts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- check_drizzle.ts | 19 - drizzle.config.ts | 13 - eslint.config.mjs | 42 +- index.html | 28 + next.config.ts | 15 - package-lock.json | 9850 ++--------------- package.json | 30 +- {src/app => public}/favicon.ico | Bin src/app/(auth)/login/page.tsx | 9 +- .../admin/synchronisation/client.tsx | 2 - .../admin/synchronisation/page.tsx | 19 +- src/app/(dashboard)/analytics/client.tsx | 2 - src/app/(dashboard)/analytics/page.tsx | 56 +- .../commandes-auto/cadencier-client.tsx | 7 +- src/app/(dashboard)/commandes-auto/client.tsx | 2 - src/app/(dashboard)/commandes-auto/page.tsx | 39 +- src/app/(dashboard)/commandes-auto/tabs.tsx | 2 - src/app/(dashboard)/dashboard/page.tsx | 29 +- src/app/(dashboard)/error.tsx | 2 - src/app/(dashboard)/exports/page.tsx | 4 +- src/app/(dashboard)/grid/page.tsx | 31 +- src/app/(dashboard)/historique/page.tsx | 8 +- src/app/(dashboard)/historique/tabs.tsx | 2 - src/app/(dashboard)/hit-parade/client.tsx | 2 - src/app/(dashboard)/hit-parade/page.tsx | 33 +- src/app/(dashboard)/layout.tsx | 14 +- src/app/(dashboard)/produits/fiche.tsx | 2 - src/app/(dashboard)/produits/opportunites.tsx | 4 +- src/app/(dashboard)/produits/page.tsx | 29 +- src/app/(dashboard)/produits/results.tsx | 2 - src/app/(dashboard)/produits/search-bar.tsx | 2 - src/app/(dashboard)/publicites/client.tsx | 2 - src/app/(dashboard)/publicites/page.tsx | 102 +- src/app/(dashboard)/settings/client.tsx | 2 - src/app/(dashboard)/settings/page.tsx | 24 +- src/app/(dashboard)/snapshots/page.tsx | 4 +- src/app/(dashboard)/stock-negatif/client.tsx | 2 - src/app/(dashboard)/stock-negatif/page.tsx | 39 +- src/app/api/admin/grid-warmup/route.ts | 135 - src/app/api/admin/sync/fournisseurs/route.ts | 91 - src/app/api/admin/sync/route.ts | 116 - src/app/api/auth/[...nextauth]/route.ts | 2 - src/app/api/commandes-auto/route.ts | 35 - src/app/api/diag/route.ts | 294 - src/app/api/diag/stock-fournisseur/route.ts | 25 - src/app/api/export/excel/route.ts | 112 - src/app/api/export/modified-gammes/route.ts | 80 - src/app/api/ff-status/route.ts | 28 - src/app/api/grid/rows/route.ts | 88 - src/app/api/grid/rows/store-patch/route.ts | 34 - src/app/api/logs/route.ts | 45 - src/app/api/produits/opportunites/route.ts | 34 - src/app/api/produits/search/route.ts | 65 - src/app/api/qlik/sync/route.ts | 429 - src/app/api/test-db/route.ts | 36 - src/app/api/v1/fournisseurs/route.ts | 62 - src/app/api/v1/gammes/route.ts | 49 - src/app/api/v1/grid/route.ts | 223 - .../api/v1/network/[codeCentrale]/route.ts | 40 - src/app/api/v1/nomenclatures/route.ts | 65 - src/app/api/v1/openapi.json/route.ts | 537 - .../api/v1/products/[codein]/gamme/route.ts | 72 - src/app/api/v1/products/[codein]/route.ts | 73 - src/app/api/v1/products/search/route.ts | 57 - src/app/layout.tsx | 35 - src/app/page.tsx | 4 +- src/app/providers.tsx | 27 +- src/components/shared/admin-only.tsx | 14 + src/components/shared/header.tsx | 2 - .../shared/route-error-boundary.tsx | 42 + src/components/shared/sidebar.tsx | 2 - src/components/shared/theme-provider.tsx | 2 - src/components/ui/data-table.tsx | 2 - src/components/ui/dialog.tsx | 2 - src/components/ui/dropdown-menu.tsx | 2 - src/components/ui/feedback.tsx | 2 - src/components/ui/form-controls.tsx | 2 - src/components/ui/pagination.tsx | 2 - src/components/ui/tabs.tsx | 2 - src/components/ui/tooltip.tsx | 2 - src/db/index.ts | 120 - src/db/schema.ts | 308 - src/features/admin/api/api-key-actions.ts | 75 +- src/features/admin/api/sync-scheduler.ts | 387 - src/features/admin/api/sync-settings.ts | 104 - src/features/admin/api/sync-timer.ts | 41 - src/features/admin/api/user-actions.ts | 78 +- .../admin/components/api-connection-info.tsx | 2 - .../admin/components/api-key-management.tsx | 2 - src/features/admin/components/grid-warmup.tsx | 2 - .../admin/components/user-management.tsx | 2 - src/features/auth/api/login-action.ts | 28 - src/features/auth/components/login-form.tsx | 37 +- src/features/auth/logic/auth-logic.ts | 76 - src/features/auth/logic/seed-admin.ts | 80 - src/features/commandes-auto/actions.ts | 139 +- src/features/grid/actions.ts | 21 +- src/features/grid/api/enregistrer-gammes.ts | 80 - .../grid/api/gammes-a-valider-actions.ts | 36 +- src/features/grid/api/get-product-rows.ts | 779 -- src/features/grid/api/save-draft-changes.ts | 54 +- .../grid/components/assortment-gaps-modal.tsx | 2 - .../grid/components/bulk-action-toolbar.tsx | 2 - .../grid/components/floating-summary-bar.tsx | 2 - src/features/grid/components/gamme-select.tsx | 2 - src/features/grid/components/grid-client.tsx | 2 - .../grid/components/grid-filter-bar.tsx | 2 - src/features/grid/components/heatmap-grid.tsx | 2 - .../grid/components/network-charts.tsx | 2 - .../grid/components/network-monthly-modal.tsx | 2 - .../grid/components/nomenclature-ca-modal.tsx | 2 - .../grid/components/nomenclature-filter.tsx | 2 - .../grid/components/product-monthly-modal.tsx | 2 - src/features/grid/components/stat-tile.tsx | 2 - .../grid/components/store-combobox.tsx | 2 - .../grid/components/supplier-combobox.tsx | 2 - .../components/supplier-selection-landing.tsx | 2 - .../grid/components/sync-qlik-button.tsx | 2 - src/features/grid/hooks/use-save-drafts.ts | 2 - src/features/grid/hooks/use-store-patch.ts | 2 - src/features/grid/lib/store-patch.ts | 2 +- src/features/grid/store/use-grid-store.ts | 2 - .../produits/api/get-produit-fiche.ts | 210 - src/features/produits/api/search-produits.ts | 445 - src/features/produits/types.ts | 4 +- src/features/qlik-sync/use-qlik-sync-job.ts | 2 - src/features/settings/actions.ts | 189 +- .../settings/components/server-logs.tsx | 2 - src/features/settings/config-file.ts | 55 - src/features/settings/ff-api-diagnostic.ts | 64 - .../settings/store/use-db-settings-store.ts | 2 - src/features/snapshots/api/delete-snapshot.ts | 43 +- src/features/snapshots/api/get-snapshots.ts | 52 +- src/features/snapshots/api/save-snapshot.ts | 135 +- .../snapshots/components/snapshot-list.tsx | 2 - src/instrumentation.ts | 20 - src/lib/api-auth.ts | 131 - src/lib/api-enrich.ts | 157 - src/lib/api-ff-client.ts | 517 - src/lib/api-gammes.ts | 127 - src/lib/api-response.ts | 72 - src/lib/api-schemas.ts | 168 - src/lib/auth.config.ts | 24 - src/lib/auth.ts | 73 - src/lib/authz.ts | 164 - src/lib/ff-cache.ts | 114 - src/lib/gammes-a-valider.ts | 104 - src/lib/grid-store.ts | 485 - src/lib/log-capture.ts | 243 - src/lib/page-data.ts | 128 + src/lib/pg-ff-client.ts | 2159 ---- src/lib/qlik-browser.ts | 59 - src/lib/qlik-client.ts | 386 - src/lib/qlik-date-range.ts | 280 - src/lib/qlik-network-cache.ts | 128 - src/lib/qlik-playwright.ts | 2500 ----- src/lib/qlik-search.ts | 662 -- src/lib/qlik-validation.ts | 156 - src/lib/rpc.ts | 78 + src/lib/throttled-json-storage.ts | 2 - src/main.tsx | 8 + src/middleware.ts | 48 - src/router.tsx | 134 + src/shims/next-auth-react.ts | 123 + src/shims/next-link.tsx | 32 + src/shims/next-navigation.ts | 156 + src/types/ff.ts | 306 + src/types/httpntlm.d.ts | 23 - tsconfig.json | 25 +- vite.config.mts | 38 + 170 files changed, 2528 insertions(+), 24445 deletions(-) delete mode 100644 check_drizzle.ts delete mode 100644 drizzle.config.ts create mode 100644 index.html delete mode 100644 next.config.ts rename {src/app => public}/favicon.ico (100%) delete mode 100644 src/app/api/admin/grid-warmup/route.ts delete mode 100644 src/app/api/admin/sync/fournisseurs/route.ts delete mode 100644 src/app/api/admin/sync/route.ts delete mode 100644 src/app/api/auth/[...nextauth]/route.ts delete mode 100644 src/app/api/commandes-auto/route.ts delete mode 100644 src/app/api/diag/route.ts delete mode 100644 src/app/api/diag/stock-fournisseur/route.ts delete mode 100644 src/app/api/export/excel/route.ts delete mode 100644 src/app/api/export/modified-gammes/route.ts delete mode 100644 src/app/api/ff-status/route.ts delete mode 100644 src/app/api/grid/rows/route.ts delete mode 100644 src/app/api/grid/rows/store-patch/route.ts delete mode 100644 src/app/api/logs/route.ts delete mode 100644 src/app/api/produits/opportunites/route.ts delete mode 100644 src/app/api/produits/search/route.ts delete mode 100644 src/app/api/qlik/sync/route.ts delete mode 100644 src/app/api/test-db/route.ts delete mode 100644 src/app/api/v1/fournisseurs/route.ts delete mode 100644 src/app/api/v1/gammes/route.ts delete mode 100644 src/app/api/v1/grid/route.ts delete mode 100644 src/app/api/v1/network/[codeCentrale]/route.ts delete mode 100644 src/app/api/v1/nomenclatures/route.ts delete mode 100644 src/app/api/v1/openapi.json/route.ts delete mode 100644 src/app/api/v1/products/[codein]/gamme/route.ts delete mode 100644 src/app/api/v1/products/[codein]/route.ts delete mode 100644 src/app/api/v1/products/search/route.ts delete mode 100644 src/app/layout.tsx create mode 100644 src/components/shared/admin-only.tsx create mode 100644 src/components/shared/route-error-boundary.tsx delete mode 100644 src/db/index.ts delete mode 100644 src/db/schema.ts delete mode 100644 src/features/admin/api/sync-scheduler.ts delete mode 100644 src/features/admin/api/sync-settings.ts delete mode 100644 src/features/admin/api/sync-timer.ts delete mode 100644 src/features/auth/api/login-action.ts delete mode 100644 src/features/auth/logic/auth-logic.ts delete mode 100644 src/features/auth/logic/seed-admin.ts delete mode 100644 src/features/grid/api/enregistrer-gammes.ts delete mode 100644 src/features/grid/api/get-product-rows.ts delete mode 100644 src/features/produits/api/get-produit-fiche.ts delete mode 100644 src/features/produits/api/search-produits.ts delete mode 100644 src/features/settings/config-file.ts delete mode 100644 src/features/settings/ff-api-diagnostic.ts delete mode 100644 src/instrumentation.ts delete mode 100644 src/lib/api-auth.ts delete mode 100644 src/lib/api-enrich.ts delete mode 100644 src/lib/api-ff-client.ts delete mode 100644 src/lib/api-gammes.ts delete mode 100644 src/lib/api-response.ts delete mode 100644 src/lib/api-schemas.ts delete mode 100644 src/lib/auth.config.ts delete mode 100644 src/lib/auth.ts delete mode 100644 src/lib/authz.ts delete mode 100644 src/lib/ff-cache.ts delete mode 100644 src/lib/gammes-a-valider.ts delete mode 100644 src/lib/grid-store.ts delete mode 100644 src/lib/log-capture.ts create mode 100644 src/lib/page-data.ts delete mode 100644 src/lib/pg-ff-client.ts delete mode 100644 src/lib/qlik-browser.ts delete mode 100644 src/lib/qlik-client.ts delete mode 100644 src/lib/qlik-date-range.ts delete mode 100644 src/lib/qlik-network-cache.ts delete mode 100644 src/lib/qlik-playwright.ts delete mode 100644 src/lib/qlik-search.ts delete mode 100644 src/lib/qlik-validation.ts create mode 100644 src/lib/rpc.ts create mode 100644 src/main.tsx delete mode 100644 src/middleware.ts create mode 100644 src/router.tsx create mode 100644 src/shims/next-auth-react.ts create mode 100644 src/shims/next-link.tsx create mode 100644 src/shims/next-navigation.ts create mode 100644 src/types/ff.ts delete mode 100644 src/types/httpntlm.d.ts create mode 100644 vite.config.mts diff --git a/check_drizzle.ts b/check_drizzle.ts deleted file mode 100644 index 44932d7..0000000 --- a/check_drizzle.ts +++ /dev/null @@ -1,19 +0,0 @@ - -import { db } from "./src/db"; -import { sql } from "drizzle-orm"; - -async function check() { - try { - const res = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits`); - console.log("Total rows in ventes_produits:", res.rows[0].count); - - const resDistinct = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits`); - console.log("Total distinct codein in ventes_produits:", resDistinct.rows[0].count); - - const resSupplier = await db.execute(sql`SELECT code_fournisseur, COUNT(*) as row_count, COUNT(DISTINCT codein) as prod_count FROM ventes_produits GROUP BY code_fournisseur`); - console.log("Counts per supplier:", resSupplier.rows); - } catch (e) { - console.error("Error:", e); - } -} -check(); diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 91d078b..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "drizzle-kit"; -import * as dotenv from "dotenv"; - -dotenv.config({ path: ".env.local" }); - -export default defineConfig({ - schema: "./src/db/schema.ts", - out: "./src/db/migrations", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL!, - }, -}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..8108e2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,18 +1,36 @@ import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +/** + * Lint de l'interface (SPA Vite). Seul `src/` est vérifié : `server/` est en + * Rust, `scripts/` et les fichiers .js de la racine sont des utilitaires Node. + */ const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), + globalIgnores(["dist/**", "server/**", "scripts/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]), + { + files: ["src/**/*.{ts,tsx}", "vite.config.mts"], + extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended], + plugins: { "react-refresh": reactRefresh }, + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + rules: { + // Exports utilitaires historiques des composants (rechargement à chaud + // seulement moins fin pour ces fichiers). + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true, + allowExportNames: ["buttonVariants", "toast", "confirmer", "useUrlTab", "PROFONDEUR_MAX"], + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/index.html b/index.html new file mode 100644 index 0000000..833e6c5 --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + + + + + CollectFlow + + + + + + + + + +
+ + + diff --git a/next.config.ts b/next.config.ts deleted file mode 100644 index c05866c..0000000 --- a/next.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - output: "standalone", - serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"], - experimental: { - serverActions: { - bodySizeLimit: "50mb", - }, - }, - typescript: { - ignoreBuildErrors: true, - }, -}; - -export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 3f5018a..7b63db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,39 +12,34 @@ "@tanstack/react-virtual": "^3.13.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "drizzle-orm": "^0.45.1", "exceljs": "^4.4.0", - "httpntlm": "^1.8.13", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "lucide-react": "^0.575.0", - "next": "16.1.6", - "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", - "pg": "^8.18.0", - "playwright-core": "^1.61.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-router": "^7.18.4", "tailwind-merge": "^3.5.0", - "ws": "^8.21.0", - "zod": "^4.3.6", "zustand": "^5.0.11" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@tailwindcss/postcss": "^4", "@types/node": "^20", - "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", - "drizzle-kit": "^0.31.9", + "@vitejs/plugin-react": "^6.1.2", "eslint": "^9", - "eslint-config-next": "16.1.6", - "shadcn": "^3.8.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "5.9.3" + "typescript": "5.9.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4" } }, "node_modules/@alloc/quick-lru": { @@ -60,65 +55,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@antfu/ni": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz", - "integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansis": "^4.0.0", - "fzf": "^0.5.2", - "package-manager-detector": "^1.3.0", - "tinyexec": "^1.0.1" - }, - "bin": { - "na": "bin/na.mjs", - "nci": "bin/nci.mjs", - "ni": "bin/ni.mjs", - "nlx": "bin/nlx.mjs", - "nr": "bin/nr.mjs", - "nun": "bin/nun.mjs", - "nup": "bin/nup.mjs" - } - }, - "node_modules/@auth/core": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", - "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", - "license": "ISC", - "dependencies": { - "@panva/hkdf": "^1.2.1", - "jose": "^6.0.6", - "oauth4webapi": "^3.3.0", - "preact": "10.24.3", - "preact-render-to-string": "6.5.11" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^6.8.0" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -127,9 +71,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -137,21 +81,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -168,14 +112,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -184,28 +128,15 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -214,76 +145,40 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -292,65 +187,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -358,9 +198,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -368,9 +208,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -378,27 +218,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -407,95 +247,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -506,33 +257,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -540,1149 +291,19 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@dotenvx/dotenvx": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", - "integrity": "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "commander": "^11.1.0", - "dotenv": "^17.2.1", - "eciesjs": "^0.4.10", - "execa": "^5.1.1", - "fdir": "^6.2.0", - "ignore": "^5.3.0", - "object-treeify": "1.1.33", - "picomatch": "^4.0.2", - "which": "^4.0.0" - }, - "bin": { - "dotenvx": "src/cli/dotenvx.js" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@ecies/ciphers": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", - "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", - "dev": true, - "license": "MIT", - "engines": { - "bun": ">=1", - "deno": ">=2", - "node": ">=16" - }, - "peerDependencies": { - "@noble/ciphers": "^1.0.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1790,10 +411,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1906,19 +540,6 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1971,560 +592,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2575,379 +642,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", - "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "3.3.1" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@panva/hkdf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", - "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", - "license": "MIT", "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@radix-ui/number": { @@ -4448,42 +2150,268 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -4879,98 +2807,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@ts-morph/common": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", - "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.3.3", - "minimatch": "^10.0.1", - "path-browserify": "^1.0.1" - } - }, - "node_modules/@ts-morph/common/node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@ts-morph/common/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4985,18 +2821,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5008,18 +2837,6 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, - "node_modules/@types/pg": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", - "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -5047,13 +2864,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5061,38 +2871,21 @@ "license": "MIT", "optional": true }, - "node_modules/@types/validate-npm-package-name": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", - "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5102,15 +2895,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/parser": "^8.71.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -5118,16 +2911,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -5139,18 +2932,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -5161,18 +2954,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5183,9 +2976,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -5196,21 +2989,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5221,13 +3014,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -5239,21 +3032,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5263,39 +3056,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5306,16 +3112,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5326,17 +3132,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5360,287 +3166,34 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", "dev": true, "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { - "node": ">= 0.6" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } } }, "node_modules/acorn": { @@ -5666,16 +3219,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5693,61 +3236,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5764,16 +3252,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", - "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - } - }, "node_modules/archiver": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", @@ -5868,248 +3346,12 @@ "node": ">=10" } }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6147,9 +3389,10 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6197,31 +3440,6 @@ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -6232,23 +3450,10 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -6266,11 +3471,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -6312,13 +3517,6 @@ "node": "*" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", @@ -6336,82 +3534,6 @@ "node": ">=0.2.0" } }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6423,9 +3545,10 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6503,129 +3626,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6635,13 +3635,6 @@ "node": ">=6" } }, - "node_modules/code-block-writer": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", - "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", - "dev": true, - "license": "MIT" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6662,16 +3655,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", @@ -6693,30 +3676,6 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6724,26 +3683,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/core-js": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", @@ -6762,51 +3701,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -6857,19 +3751,6 @@ "utrie": "^1.0.2" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6877,77 +3758,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -6972,21 +3782,6 @@ } } }, - "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6994,120 +3789,11 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7119,29 +3805,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dompurify": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", @@ -7155,175 +3818,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/drizzle-kit": { - "version": "0.31.9", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", - "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.4", - "esbuild-register": "^3.5.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, - "node_modules/drizzle-orm": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", - "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -7369,55 +3863,13 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/eciesjs": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz", - "integrity": "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ecies/ciphers": "^0.2.5", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0" - }, - "engines": { - "bun": ">=1", - "deno": ">=2", - "node": ">=16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -7441,258 +3893,6 @@ "node": ">=10.13.0" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7703,13 +3903,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -7783,238 +3976,6 @@ } } }, - "node_modules/eslint-config-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", - "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@next/eslint-plugin-next": "16.1.6", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsx-a11y": "^6.10.0", - "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", - "globals": "16.4.0", - "typescript-eslint": "^8.46.0" - }, - "peerDependencies": { - "eslint": ">=9.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-config-next/node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", - "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.4.0", - "get-tsconfig": "^4.10.0", - "is-bun-module": "^2.0.0", - "stable-hash": "^0.0.5", - "tinyglobby": "^0.2.13", - "unrs-resolver": "^1.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, "node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", @@ -8035,28 +3996,14 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "^9 || ^10" } }, "node_modules/eslint-scope": { @@ -8089,6 +4036,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -8107,20 +4067,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -8167,39 +4113,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -8220,96 +4133,6 @@ "node": ">=8.3.0" } }, - "node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -8330,36 +4153,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8391,55 +4184,22 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, "engines": { - "node": "^12.20 || >= 14.13" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fflate": { @@ -8448,22 +4208,6 @@ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "license": "MIT" }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8477,41 +4221,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8550,82 +4259,33 @@ "dev": true, "license": "ISC" }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fstream": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", @@ -8642,71 +4302,6 @@ "node": ">=0.6" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fuzzysort": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", - "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fzf": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", - "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8717,54 +4312,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -8774,81 +4321,6 @@ "node": ">=6" } }, - "node_modules/get-own-enumerable-keys": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", - "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8884,9 +4356,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -8896,65 +4368,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8965,84 +4384,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -9060,16 +4401,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -9084,101 +4415,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/httpntlm": { - "version": "1.8.13", - "resolved": "https://registry.npmjs.org/httpntlm/-/httpntlm-1.8.13.tgz", - "integrity": "sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==", - "funding": [ - { - "type": "paypal", - "url": "https://www.paypal.com/donate/?hosted_button_id=2CKNJLZJBW8ZC" - }, - { - "type": "buymeacoffee", - "url": "https://www.buymeacoffee.com/samdecrock" - } - ], - "dependencies": { - "des.js": "^1.0.1", - "httpreq": ">=0.4.22", - "js-md4": "^0.3.2", - "underscore": "~1.12.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/httpreq": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/httpreq/-/httpreq-1.1.1.tgz", - "integrity": "sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==", - "license": "MIT", - "engines": { - "node": ">= 6.15.1" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9259,228 +4495,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/iobuffer": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "license": "MIT" }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9491,52 +4511,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -9550,351 +4524,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", - "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regexp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", - "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9902,24 +4531,6 @@ "dev": true, "license": "ISC" }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -9930,21 +4541,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9985,13 +4581,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9999,13 +4588,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10026,19 +4608,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jspdf": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", @@ -10065,22 +4634,6 @@ "jspdf": "^2 || ^3 || ^4" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -10139,36 +4692,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -10501,13 +5024,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", @@ -10616,62 +5132,6 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10701,126 +5161,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10861,79 +5201,11 @@ "dev": true, "license": "MIT" }, - "node_modules/msw": { - "version": "2.12.10", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", - "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.41.2", - "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.6", - "cookie": "^1.0.2", - "graphql": "^16.12.0", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.10.1", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.0", - "type-fest": "^5.2.0", - "until-async": "^3.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, "funding": [ { "type": "github", @@ -10948,22 +5220,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10971,96 +5227,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", - "license": "MIT", - "dependencies": { - "@next/env": "16.1.6", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=20.9.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next-auth": { - "version": "5.0.0-beta.30", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", - "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.41.0" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", - "nodemailer": "^7.0.7", - "react": "^18.2.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -11071,99 +5237,15 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -11174,191 +5256,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/oauth4webapi": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", - "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object-treeify": { - "version": "1.1.33", - "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", - "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11368,43 +5265,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11423,68 +5283,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11517,13 +5315,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", - "dev": true, - "license": "MIT" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -11543,55 +5334,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11621,20 +5363,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -11642,150 +5370,30 @@ "license": "MIT", "optional": true }, - "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -11803,99 +5411,14 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/preact": { - "version": "10.24.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", - "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/preact-render-to-string": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", - "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", - "license": "MIT", - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11906,78 +5429,12 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/prompts/node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11988,43 +5445,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -12112,32 +5532,6 @@ "performance-now": "^2.1.0" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -12159,13 +5553,6 @@ "react": "^19.2.3" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -12213,6 +5600,41 @@ } } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -12279,46 +5701,6 @@ "node": ">=10" } }, - "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -12326,68 +5708,6 @@ "license": "MIT", "optional": true }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -12398,51 +5718,6 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rettime": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", - "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", - "dev": true, - "license": "MIT" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rgbcolor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", @@ -12466,89 +5741,38 @@ "rimraf": "bin.js" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/safe-buffer": { @@ -12571,48 +5795,6 @@ ], "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -12641,101 +5823,11 @@ "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" }, "node_modules/setimmediate": { "version": "1.0.5", @@ -12743,173 +5835,6 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shadcn": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.8.5.tgz", - "integrity": "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@antfu/ni": "^25.0.0", - "@babel/core": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/plugin-transform-typescript": "^7.28.0", - "@babel/preset-typescript": "^7.27.1", - "@dotenvx/dotenvx": "^1.48.4", - "@modelcontextprotocol/sdk": "^1.26.0", - "@types/validate-npm-package-name": "^4.0.2", - "browserslist": "^4.26.2", - "commander": "^14.0.0", - "cosmiconfig": "^9.0.0", - "dedent": "^1.6.0", - "deepmerge": "^4.3.1", - "diff": "^8.0.2", - "execa": "^9.6.0", - "fast-glob": "^3.3.3", - "fs-extra": "^11.3.1", - "fuzzysort": "^3.1.0", - "https-proxy-agent": "^7.0.6", - "kleur": "^4.1.5", - "msw": "^2.10.4", - "node-fetch": "^3.3.2", - "open": "^11.0.0", - "ora": "^8.2.0", - "postcss": "^8.5.6", - "postcss-selector-parser": "^7.1.0", - "prompts": "^2.4.2", - "recast": "^0.23.11", - "stringify-object": "^5.0.0", - "tailwind-merge": "^3.0.1", - "ts-morph": "^26.0.0", - "tsconfig-paths": "^4.2.0", - "validate-npm-package-name": "^7.0.1", - "zod": "^3.24.1", - "zod-to-json-schema": "^3.24.6" - }, - "bin": { - "shadcn": "dist/index.js" - } - }, - "node_modules/shadcn/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/shadcn/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/shadcn/node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/shadcn/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12933,148 +5858,16 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/stable-hash": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", - "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", - "dev": true, - "license": "MIT" - }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -13085,50 +5878,6 @@ "node": ">=0.1.14" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -13138,201 +5887,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/stringify-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", - "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "get-own-enumerable-keys": "^1.0.0", - "is-obj": "^3.0.0", - "is-regexp": "^3.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/yeoman/stringify-object?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13346,29 +5900,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -13382,19 +5913,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -13405,19 +5923,6 @@ "node": ">=12.0.0" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -13475,32 +5980,15 @@ "utrie": "^1.0.2" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -13509,57 +5997,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tldts": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", - "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.23" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", - "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", - "dev": true, - "license": "MIT" - }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13569,42 +6006,6 @@ "node": ">=14.14" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -13615,9 +6016,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -13627,43 +6028,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-morph": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", - "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ts-morph/common": "~0.27.0", - "code-block-writer": "^13.0.3" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -13693,115 +6057,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", - "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -13817,16 +6072,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13837,119 +6092,16 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", - "license": "MIT" - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, - "node_modules/until-async": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/kettanaito" - } - }, "node_modules/unzipper": { "version": "0.10.14", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", @@ -14005,9 +6157,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -14122,34 +6274,343 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/validate-npm-package-name": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">= 0.8" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">= 8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/which": { @@ -14168,95 +6629,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -14267,135 +6639,18 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -14403,80 +6658,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -14490,32 +6671,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", @@ -14552,24 +6707,15 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/package.json b/package.json index 16b7847..9571b2a 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", - "start": "node scripts/db-init.js && next start", + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", "lint": "eslint", + "typecheck": "tsc --noEmit", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down", "docker:build": "docker-compose up -d --build" @@ -16,38 +17,33 @@ "@tanstack/react-virtual": "^3.13.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "drizzle-orm": "^0.45.1", "exceljs": "^4.4.0", - "httpntlm": "^1.8.13", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "lucide-react": "^0.575.0", - "next": "16.1.6", - "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", - "pg": "^8.18.0", - "playwright-core": "^1.61.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-router": "^7.18.4", "tailwind-merge": "^3.5.0", - "ws": "^8.21.0", - "zod": "^4.3.6", "zustand": "^5.0.11" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@tailwindcss/postcss": "^4", "@types/node": "^20", - "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", - "drizzle-kit": "^0.31.9", + "@vitejs/plugin-react": "^6.1.2", "eslint": "^9", - "eslint-config-next": "16.1.6", - "shadcn": "^3.8.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "5.9.3" + "typescript": "5.9.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4" } } diff --git a/src/app/favicon.ico b/public/favicon.ico similarity index 100% rename from src/app/favicon.ico rename to public/favicon.ico diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index f31bd72..0397c8a 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,9 +1,12 @@ -import type { Metadata } from "next"; +import { Navigate } from "react-router"; +import { useSession } from "next-auth/react"; import { LoginForm } from "@/features/auth/components/login-form"; -export const metadata: Metadata = { title: "Connexion" }; - export default function LoginPage() { + // Déjà connecté : l'accueil (le serveur fait de même pour un accès direct). + const { status } = useSession(); + if (status === "authenticated") return ; + return (
diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx index 8171871..4f0dd8a 100644 --- a/src/app/(dashboard)/admin/synchronisation/client.tsx +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle, diff --git a/src/app/(dashboard)/admin/synchronisation/page.tsx b/src/app/(dashboard)/admin/synchronisation/page.tsx index 86b6644..5ddd4d0 100644 --- a/src/app/(dashboard)/admin/synchronisation/page.tsx +++ b/src/app/(dashboard)/admin/synchronisation/page.tsx @@ -1,17 +1,16 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; -import { verifierAdmin } from "@/lib/authz"; +import { AdminOnly } from "@/components/shared/admin-only"; import { SynchronisationClient } from "./client"; -export const metadata: Metadata = { title: "Synchronisation" }; - /** * Paramétrage de la synchronisation nocturne des fournisseurs. * - * L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle - * du jeton ; la page le revérifie en base, comme les routes /api/admin/sync. + * L'accès admin est déjà imposé par le serveur (préfixe /admin, rôle relu en + * base) ; la page le revérifie pour une navigation dans l'application. */ -export default async function SynchronisationPage() { - if (!(await verifierAdmin()).ok) redirect("/dashboard"); - return ; +export default function SynchronisationPage() { + return ( + + + + ); } diff --git a/src/app/(dashboard)/analytics/client.tsx b/src/app/(dashboard)/analytics/client.tsx index 81e30fb..244d594 100644 --- a/src/app/(dashboard)/analytics/client.tsx +++ b/src/app/(dashboard)/analytics/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useRouter } from "next/navigation"; import { useMemo, useTransition } from "react"; import { Loader2 } from "lucide-react"; diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx index 3f984df..5972ac5 100644 --- a/src/app/(dashboard)/analytics/page.tsx +++ b/src/app/(dashboard)/analytics/page.tsx @@ -1,15 +1,13 @@ -import type { Metadata } from "next"; import Link from "next/link"; import { CalendarRange, RotateCcw } from "lucide-react"; -import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client"; -import { cachedFF } from "@/lib/ff-cache"; +import type { CaByFournisseurRow, CaByNomenclatureRow } from "@/types/ff"; +import { fetchPage } from "@/lib/rpc"; +import { usePageResult, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { ErrorState } from "@/components/ui/states"; import { Button } from "@/components/ui/button"; import { AnalyticsClient, type AnalyticsRow, type ModeAnalytics, type OptionMois } from "./client"; -export const metadata: Metadata = { title: "Ventes par mois" }; - const NOMS_MOIS = [ "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre", @@ -82,10 +80,10 @@ function pivotData( return result; } -export default async function AnalyticsPage(props: { - searchParams: Promise>; -}) { - const searchParams = await props.searchParams; +type LigneCa = { code: string; label: string; site: string; mois: string; ca_ttc: number }; + +export default function AnalyticsPage() { + const searchParams = usePageSearchParams(); // Toute autre valeur que « fournisseur » affichait déjà la vue par famille. const mode: ModeAnalytics = ((searchParams.mode as string) || "fournisseur") === "fournisseur" ? "fournisseur" : "nomenclature"; const moisParam = (searchParams.mois as string) || ""; @@ -108,36 +106,34 @@ export default async function AnalyticsPage(props: { moisDisponibles.push({ value: mois, label: libelleMois(mois) }); } - let pivotted: AnalyticsRow[] | null = null; - let erreur: string | null = null; - try { - let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> = - []; - + // Lignes de la base FF, mises en cache par le serveur (mêmes clés qu'avant). + const resultat = usePageResult(`analytics?${mode}:${mois}:${moisN1}`, async (): Promise => { if (mode === "fournisseur") { - const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1)); - rows = result.map((r) => ({ + const result = await fetchPage("analytics", { mode, mois, moisN1 }); + return result.map((r) => ({ code: r.code, label: r.nom, site: r.site, mois: r.mois, ca_ttc: r.ca_ttc, })); - } else { - const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1)); - rows = result.map((r) => ({ - code: r.code, - label: `${r.code} — ${r.libelle}`, - site: r.site, - mois: r.mois, - ca_ttc: r.ca_ttc, - })); } + const result = await fetchPage("analytics", { mode, mois, moisN1 }); + return result.map((r) => ({ + code: r.code, + label: `${r.code} — ${r.libelle}`, + site: r.site, + mois: r.mois, + ca_ttc: r.ca_ttc, + })); + }); - pivotted = pivotData(rows, mois, moisN1, mode); - } catch (e) { - console.error("[analytics] chargement impossible :", e); - erreur = e instanceof Error ? e.message : String(e); + let pivotted: AnalyticsRow[] | null = null; + let erreur: string | null = null; + if (resultat.ok) { + pivotted = pivotData(resultat.data, mois, moisN1, mode); + } else { + erreur = resultat.error; } return ( diff --git a/src/app/(dashboard)/commandes-auto/cadencier-client.tsx b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx index 01d688a..4d71dd2 100644 --- a/src/app/(dashboard)/commandes-auto/cadencier-client.tsx +++ b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo, useState, useTransition, type FormEvent } from "react"; import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react"; import { @@ -217,8 +215,9 @@ function SectionMagasin({ /** * Exécute une action serveur et en rend compte : message de réussite, ou * message d'erreur (refus de l'action ou exception) — jamais d'échec muet. - * Pas de router.refresh() : les actions appellent déjà revalidatePath, dont - * la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois. + * Pas de router.refresh() : les actions rafraîchissent déjà la page après une + * réussite (cf. features/commandes-auto/actions.ts). Les deux ensemble + * rechargeaient tout deux fois. */ function executer(action: () => Promise, messages: { succes: string; echec: string }): Promise { return new Promise((resolve) => { diff --git a/src/app/(dashboard)/commandes-auto/client.tsx b/src/app/(dashboard)/commandes-auto/client.tsx index 6b0491a..6a66daf 100644 --- a/src/app/(dashboard)/commandes-auto/client.tsx +++ b/src/app/(dashboard)/commandes-auto/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo } from "react"; import { ShoppingCart } from "lucide-react"; import type { PgCommandeAutoRow } from "./page"; diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx index f74f669..2fe9941 100644 --- a/src/app/(dashboard)/commandes-auto/page.tsx +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -1,33 +1,30 @@ -import type { Metadata } from "next"; import { ShoppingCart } from "lucide-react"; -import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client"; -import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions"; +import type { PgCommandeAutoRow } from "@/types/ff"; +import type { CadenceView } from "@/features/commandes-auto/actions"; +import { fetchPage } from "@/lib/rpc"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { CommandesAutoTabs, type Onglet } from "./tabs"; -import { cachedFF } from "@/lib/ff-cache"; export type { PgCommandeAutoRow }; -export const metadata: Metadata = { title: "Commandes fournisseurs" }; +/** Données de la page : propositions de l'API FF, cadencier et référentiel fournisseurs. */ +interface DonneesCommandesAuto { + rows: PgCommandeAutoRow[]; + cadences: CadenceView[]; + fournisseurs: { code: string; nom: string }[]; +} -// Données live (DB + API FF Nancy) : rendu à la requête, jamais prérendu au build. -export const dynamic = "force-dynamic"; - -export default async function CommandesAutoPage(props: { searchParams: Promise<{ onglet?: string | string[] }> }) { - const { onglet } = await props.searchParams; +// Données live (DB + API FF Nancy) : relues à chaque affichage. Les propositions +// sont gardées 2 minutes par le serveur, pour que chaque modification du +// cadencier — qui rafraîchit la page — n'attende pas de nouveau toute l'API. +export default function CommandesAutoPage() { + const { onglet } = usePageSearchParams(); const ongletInitial: Onglet = onglet === "cadencier" ? "cadencier" : "propositions"; - const [rows, cadences, fournisseurs] = await Promise.all([ - // Propositions de l'API FF (un appel, plus un par fournisseur sans franco) : - // gardées 2 minutes, pour que chaque modification du cadencier — qui - // re-rend la page — n'attende pas de nouveau toute l'API. - cachedFF("commandes-auto", () => pgGetCommandesAuto(), { - ttlMs: 2 * 60 * 1000, - cacheIf: (rows) => rows.length > 0, - }), - listCadences(), - getFournisseursPourCadence(), - ]); + const { rows, cadences, fournisseurs } = usePageData("commandes-auto", () => + fetchPage("commandes-auto"), + ); return (
diff --git a/src/app/(dashboard)/commandes-auto/tabs.tsx b/src/app/(dashboard)/commandes-auto/tabs.tsx index 4ff1652..90e2d37 100644 --- a/src/app/(dashboard)/commandes-auto/tabs.tsx +++ b/src/app/(dashboard)/commandes-auto/tabs.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ShoppingCart, CalendarClock } from "lucide-react"; import { Tabs, useUrlTab } from "@/components/ui/tabs"; import { CommandesAutoClient } from "./client"; diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 75e21a9..4653769 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,8 +1,8 @@ -import type { Metadata } from "next"; import Link from "next/link"; -import { unstable_cache } from "next/cache"; import { BarChart3, Euro, House, Receipt, ShoppingBasket, ShoppingCart, Store } from "lucide-react"; -import { pgGetDashboardData, type DashboardSiteStats, type DashboardSiteTop10, type DashboardTopItem } from "@/lib/pg-ff-client"; +import type { DashboardData, DashboardSiteStats, DashboardSiteTop10, DashboardTopItem } from "@/types/ff"; +import { fetchPage } from "@/lib/rpc"; +import { usePageResult } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { StatCard } from "@/components/ui/stat-card"; import { Card, CardHeader } from "@/components/ui/card"; @@ -13,15 +13,6 @@ import { LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format"; import { couleurMarge } from "@/lib/marge"; -export const metadata: Metadata = { title: "Accueil" }; - -const getCachedDashboardData = unstable_cache( - pgGetDashboardData, - ["dashboard-data"], - // 30 minutes : ce sont les ventes de la veille, elles ne changent pas dans la journée. - { revalidate: 1800 } -); - // --------------------------------------------------------------------------- // Calculs // --------------------------------------------------------------------------- @@ -203,25 +194,23 @@ function TitreSection({ titre, description }: { titre: string; description?: Rea // Page // --------------------------------------------------------------------------- -export default async function DashboardPage() { - let data; - try { - data = await getCachedDashboardData(); - } catch (e) { - console.error("[Dashboard] pgGetDashboardData failed:", e); +export default function DashboardPage() { + // Mis en cache 30 minutes par le serveur : ce sont les ventes de la veille. + const resultat = usePageResult("dashboard", () => fetchPage("dashboard")); + if (!resultat.ok) { return (
); } - const { dateHier, dateN1, sites, top10BySite } = data; + const { dateHier, dateN1, sites, top10BySite } = resultat.data; const totalCa = sites.reduce((s, r) => s + r.ca_hier, 0); const totalCaN1 = sites.reduce((s, r) => s + r.ca_n1, 0); diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx index 133e216..278f3ae 100644 --- a/src/app/(dashboard)/error.tsx +++ b/src/app/(dashboard)/error.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect } from "react"; import { RotateCcw } from "lucide-react"; import { ErrorState } from "@/components/ui/states"; diff --git a/src/app/(dashboard)/exports/page.tsx b/src/app/(dashboard)/exports/page.tsx index 151dddf..c9c859a 100644 --- a/src/app/(dashboard)/exports/page.tsx +++ b/src/app/(dashboard)/exports/page.tsx @@ -1,6 +1,6 @@ -import { redirect } from "next/navigation"; +import { Navigate } from "react-router"; /** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function ExportsPage() { - redirect("/historique?vue=exports"); + return ; } diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index b8d30a3..53633e9 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -1,23 +1,11 @@ -import type { Metadata } from "next"; -import { auth } from "@/lib/auth"; +import { useSessionResolue } from "next-auth/react"; import { getFournisseurs, getMagasins } from "@/features/grid/actions"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { GridClient } from "@/features/grid/components/grid-client"; import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing"; -export const metadata: Metadata = { title: "Révision d'assortiment" }; - -interface GridPageProps { - searchParams: Promise<{ - fournisseur?: string; - magasin?: string; - code1?: string; - code2?: string; - code3?: string; - }>; -} - -export default async function GridPage({ searchParams }: GridPageProps) { - const params = await searchParams; +export default function GridPage() { + const params = usePageSearchParams(); const codeFournisseur = params.fournisseur; const magasin = params.magasin ?? "TOTAL"; const filters = { @@ -27,13 +15,10 @@ export default async function GridPage({ searchParams }: GridPageProps) { code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null, }; - // 1. Fetch available suppliers & stores (et les droits, lus une fois côté serveur) - const [fournisseurs, magasins, session] = await Promise.all([ - getFournisseurs(), - getMagasins(), - auth(), - ]); - const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin"; + // 1. Fetch available suppliers & stores (et les droits, lus une fois avant le rendu) + const [fournisseurs, magasins] = usePageData("grid", () => Promise.all([getFournisseurs(), getMagasins()])); + const session = useSessionResolue(); + const isAdmin = session?.user.role === "admin"; // 2. If no supplier selected, show compact selection UI if (!codeFournisseur) { diff --git a/src/app/(dashboard)/historique/page.tsx b/src/app/(dashboard)/historique/page.tsx index 2aefed6..0963805 100644 --- a/src/app/(dashboard)/historique/page.tsx +++ b/src/app/(dashboard)/historique/page.tsx @@ -1,10 +1,8 @@ -import type { Metadata } from "next"; import { History } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; +import { usePageSearchParams } from "@/lib/page-data"; import { HistoriqueTabs } from "./tabs"; -export const metadata: Metadata = { title: "Historique" }; - /** * Historique des sessions de révision et des exports Excel. * @@ -12,8 +10,8 @@ export const metadata: Metadata = { title: "Historique" }; * filtrée différemment (et dont la seconde s'intitulait « Historique d'arbitrage »). * Chacun ne voit que ses propres enregistrements. */ -export default async function HistoriquePage(props: { searchParams: Promise<{ vue?: string }> }) { - const { vue } = await props.searchParams; +export default function HistoriquePage() { + const { vue } = usePageSearchParams(); return (
>; -}) { - const searchParams = await props.searchParams; +export default function HitParadePage() { + const searchParams = usePageSearchParams(); const defaults = defaultDates(); - const dateDebut = (searchParams.debut as string) || defaults.debut; - const dateFin = (searchParams.fin as string) || defaults.fin; + const dateDebut = searchParams.debut || defaults.debut; + const dateFin = searchParams.fin || defaults.fin; + + // Lignes de la base FF, mises en cache par le serveur. + const resultat = usePageResult(`hit-parade?${dateDebut}:${dateFin}`, () => + fetchPage("hit-parade", { debut: dateDebut, fin: dateFin }), + ); let pivotted: HitParadePivotRow[] | null = null; let erreur: string | null = null; - try { - const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin)); - pivotted = pivotHitParade(rows); - } catch (e) { - console.error("[hit-parade] chargement impossible :", e); - erreur = e instanceof Error ? e.message : String(e); + if (resultat.ok) { + pivotted = pivotHitParade(resultat.data); + } else { + erreur = resultat.error; } return ( diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 929f166..e4cbc52 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,13 +1,23 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router"; import { Sidebar } from "@/components/shared/sidebar"; import { Header } from "@/components/shared/header"; -export default async function DashboardLayout({ children }: { children: React.ReactNode }) { +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const { pathname } = useLocation(); + const contenu = useRef(null); + + // Changement de page : on repart du haut, comme une navigation Next.js. + useEffect(() => { + contenu.current?.scrollTo({ top: 0 }); + }, [pathname]); + return (
-
+
{children}
diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index 65ba6b2..b14f871 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; diff --git a/src/app/(dashboard)/produits/opportunites.tsx b/src/app/(dashboard)/produits/opportunites.tsx index 5594d65..9d11cce 100644 --- a/src/app/(dashboard)/produits/opportunites.tsx +++ b/src/app/(dashboard)/produits/opportunites.tsx @@ -1,9 +1,7 @@ -"use client"; - import { useState } from "react"; import Link from "next/link"; import { Loader2, RefreshCw, TrendingUp } from "lucide-react"; -import type { PgOpportuniteRow } from "@/lib/pg-ff-client"; +import type { PgOpportuniteRow } from "@/types/ff"; import { Button } from "@/components/ui/button"; import { DataTable, type DataColumn } from "@/components/ui/data-table"; import { EmptyState, ErrorState } from "@/components/ui/states"; diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx index d9b4a75..422c33e 100644 --- a/src/app/(dashboard)/produits/page.tsx +++ b/src/app/(dashboard)/produits/page.tsx @@ -1,6 +1,7 @@ -import type { Metadata } from "next"; import { PackageSearch, SearchX } from "lucide-react"; -import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche"; +import type { ProduitFiche } from "@/features/produits/types"; +import { fetchPage } from "@/lib/rpc"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { EmptyState } from "@/components/ui/states"; import { Terme } from "@/components/ui/tooltip"; @@ -8,33 +9,29 @@ import { ProduitSearchBar } from "./search-bar"; import { ProduitResults } from "./results"; import { ProduitFicheView } from "./fiche"; -export const metadata: Metadata = { title: "Recherche produit" }; - -export const dynamic = "force-dynamic"; - /** * Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite. * * - `?q=…` → liste de résultats (chargée côté client : l'extraction Qlik - * prend plusieurs secondes, on ne bloque pas le rendu serveur) + * prend plusieurs secondes, on ne bloque pas l'affichage) * - `?codein=…` → fiche d'un produit de notre catalogue (URL partageable) * - `?cc=…` → fiche d'un produit du réseau par son code centrale, qu'il * soit référencé chez nous ou non */ -export default async function ProduitsPage(props: { - searchParams: Promise<{ q?: string; codein?: string; cc?: string }>; -}) { - const searchParams = await props.searchParams; +export default function ProduitsPage() { + const searchParams = usePageSearchParams(); const q = (searchParams.q ?? "").trim(); const codein = (searchParams.codein ?? "").trim(); const codeCentrale = (searchParams.cc ?? "").trim(); - const fiche = codein - ? await getProduitFiche(codein) - : codeCentrale - ? await getProduitFicheByCodeCentrale(codeCentrale) - : null; const ficheDemandee = Boolean(codein || codeCentrale); + const fiche = usePageData(`produit-fiche?${codein}:${codeCentrale}`, (): Promise => + codein + ? fetchPage("produit-fiche", { codein }) + : codeCentrale + ? fetchPage("produit-fiche", { cc: codeCentrale }) + : Promise.resolve(null), + ); return (
diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index ed8d4fe..d8b9142 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react"; diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx index dc55282..f5bb2d6 100644 --- a/src/app/(dashboard)/produits/search-bar.tsx +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState } from "react"; import { useRouter } from "next/navigation"; import { Search, X } from "lucide-react"; diff --git a/src/app/(dashboard)/publicites/client.tsx b/src/app/(dashboard)/publicites/client.tsx index 56337ea..5a05a54 100644 --- a/src/app/(dashboard)/publicites/client.tsx +++ b/src/app/(dashboard)/publicites/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo, useTransition } from "react"; import { useRouter } from "next/navigation"; import { History, Info, Megaphone, RotateCcw } from "lucide-react"; diff --git a/src/app/(dashboard)/publicites/page.tsx b/src/app/(dashboard)/publicites/page.tsx index 932b555..d760255 100644 --- a/src/app/(dashboard)/publicites/page.tsx +++ b/src/app/(dashboard)/publicites/page.tsx @@ -1,10 +1,8 @@ -import type { Metadata } from "next"; import { Megaphone } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; +import { fetchPage } from "@/lib/rpc"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { PublicitesClient } from "./client"; -import { cachedFF } from "@/lib/ff-cache"; - -export const metadata: Metadata = { title: "Publicités" }; export interface Publicite { tcr_code: string; @@ -44,80 +42,30 @@ export interface PubliciteHistorique { qte_totale: number; } -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; -const PAGE_SIZE = 50; -/** Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. */ -const FF_API_TIMEOUT_MS = 15_000; /** - * Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste - * et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page. + * Données de la page, lues par le serveur sur l'API FF (réponses gardées + * 10 minutes, jamais les erreurs) : une API en panne est signalée dans + * `erreurPublicites` / `erreurHistorique`, pas par une exception. */ -const PUB_CACHE_TTL_MS = 10 * 60 * 1000; - -type PublicitesResult = { data: Publicite[]; total: number; pages: number; error?: string }; - -function fetchPublicites(statut: string, page: number): Promise { - return cachedFF(`publicites:${statut}:${page}`, () => fetchPublicitesApi(statut, page), { - ttlMs: PUB_CACHE_TTL_MS, - cacheIf: (r) => !r.error, - }); +interface DonneesPublicites { + publicites: Publicite[]; + total: number; + pages: number; + erreurPublicites?: string; + historique: PubliciteHistorique[]; + erreurHistorique?: string; + pageSize: number; } -async function fetchPublicitesApi(statut: string, page: number): Promise { - const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); - if (statut && statut !== "toutes") params.set("statut", statut); - try { - const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { - cache: "no-store", - signal: AbortSignal.timeout(FF_API_TIMEOUT_MS), - }); - if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` }; - const json = await res.json(); - return { - data: (json.publicites ?? []) as Publicite[], - total: Number(json.total ?? 0), - pages: Number(json.pages ?? 0), - }; - } catch (e: unknown) { - return { data: [], total: 0, pages: 0, error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` }; - } -} - -type HistoriqueResult = { data: PubliciteHistorique[]; error?: string }; - -function fetchHistorique(): Promise { - return cachedFF("publicites-historique", fetchHistoriqueApi, { - ttlMs: PUB_CACHE_TTL_MS, - cacheIf: (r) => !r.error, - }); -} - -async function fetchHistoriqueApi(): Promise { - try { - const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, { - cache: "no-store", - signal: AbortSignal.timeout(FF_API_TIMEOUT_MS), - }); - if (!res.ok) return { data: [], error: `Erreur historique: ${res.status}` }; - const json = await res.json(); - return { data: (json.publicites ?? []) as PubliciteHistorique[] }; - } catch (e: unknown) { - return { data: [], error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` }; - } -} - -export default async function PublicitesPage(props: { - searchParams: Promise>; -}) { - const searchParams = await props.searchParams; +export default function PublicitesPage() { + const searchParams = usePageSearchParams(); const vue = String(searchParams.vue ?? "publicites"); const statut = String(searchParams.statut ?? "toutes"); const page = Math.max(1, Number(searchParams.page) || 1); - const [pubResult, histoResult] = await Promise.all([ - fetchPublicites(statut, page), - fetchHistorique(), - ]); + const donnees = usePageData(`publicites?${statut}:${page}`, () => + fetchPage("publicites", { statut, page }), + ); return (
@@ -130,13 +78,13 @@ export default async function PublicitesPage(props: { vueInitiale={vue === "historique" ? "historique" : "publicites"} statut={statut} page={page} - pageSize={PAGE_SIZE} - publicites={pubResult.data} - total={pubResult.total} - pages={pubResult.pages} - erreurPublicites={pubResult.error} - historique={histoResult.data} - erreurHistorique={histoResult.error} + pageSize={donnees.pageSize} + publicites={donnees.publicites} + total={donnees.total} + pages={donnees.pages} + erreurPublicites={donnees.erreurPublicites} + historique={donnees.historique} + erreurHistorique={donnees.erreurHistorique} />
); diff --git a/src/app/(dashboard)/settings/client.tsx b/src/app/(dashboard)/settings/client.tsx index 6a15b0c..0cb70b7 100644 --- a/src/app/(dashboard)/settings/client.tsx +++ b/src/app/(dashboard)/settings/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState, type ReactNode } from "react"; import { AlertCircle, diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index b6997ea..de20149 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,21 +1,19 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; -import { verifierAdmin } from "@/lib/authz"; +import { AdminOnly } from "@/components/shared/admin-only"; +import { usePageSearchParams } from "@/lib/page-data"; import { ParametresClient } from "./client"; -export const metadata: Metadata = { title: "Paramètres" }; - /** * Paramètres (administrateurs) : connexions aux sources de données, comptes * utilisateurs, accès à l'API et journal serveur, rangés en onglets. * - * Composant serveur minimal : il lit l'onglet demandé (`?onglet=`) pour que le - * lien se partage et survive au rechargement, puis rend la page cliente. + * Page minimale : elle lit l'onglet demandé (`?onglet=`) pour que le lien se + * partage et survive au rechargement, puis rend la page cliente. */ -export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) { - // Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base. - if (!(await verifierAdmin()).ok) redirect("/dashboard"); - - const { onglet } = await props.searchParams; - return ; +export default function SettingsPage() { + const { onglet } = usePageSearchParams(); + return ( + + + + ); } diff --git a/src/app/(dashboard)/snapshots/page.tsx b/src/app/(dashboard)/snapshots/page.tsx index 5b117e0..dbf24df 100644 --- a/src/app/(dashboard)/snapshots/page.tsx +++ b/src/app/(dashboard)/snapshots/page.tsx @@ -1,6 +1,6 @@ -import { redirect } from "next/navigation"; +import { Navigate } from "react-router"; /** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function SnapshotsPage() { - redirect("/historique"); + return ; } diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index 995d440..14e95c0 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useRouter } from "next/navigation"; import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react"; import { Loader2 } from "lucide-react"; diff --git a/src/app/(dashboard)/stock-negatif/page.tsx b/src/app/(dashboard)/stock-negatif/page.tsx index 19d70a4..dabe050 100644 --- a/src/app/(dashboard)/stock-negatif/page.tsx +++ b/src/app/(dashboard)/stock-negatif/page.tsx @@ -1,8 +1,8 @@ -import type { Metadata } from "next"; import Link from "next/link"; import { PackageMinus, RotateCcw } from "lucide-react"; -import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client"; -import { cachedFF } from "@/lib/ff-cache"; +import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "@/types/ff"; +import { fetchPage } from "@/lib/rpc"; +import { usePageResult, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { ErrorState } from "@/components/ui/states"; import { Button } from "@/components/ui/button"; @@ -10,36 +10,23 @@ import { GestionStockClient } from "./client"; export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow }; -export const metadata: Metadata = { title: "Stocks à surveiller" }; - interface DonneesStock { rowsNegatif: PgStockNegatifRow[]; rowsSansVente: PgStockSansVenteRow[]; rowsSansVente6Mois: PgSansVente6MoisRow[]; } -export default async function GestionStockPage(props: { - searchParams: Promise>; -}) { - const searchParams = await props.searchParams; - const magasin = (searchParams.magasin as string) || ""; - const tab = (searchParams.tab as string) || "negatif"; +export default function GestionStockPage() { + const searchParams = usePageSearchParams(); + const magasin = searchParams.magasin || ""; + const tab = searchParams.tab || "negatif"; - // Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts). - const site = magasin || undefined; - let donnees: DonneesStock | null = null; - let erreur: string | null = null; - try { - const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([ - cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)), - cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)), - cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)), - ]); - donnees = { rowsNegatif, rowsSansVente, rowsSansVente6Mois }; - } catch (e) { - console.error("[stock-negatif] chargement impossible :", e); - erreur = e instanceof Error ? e.message : String(e); - } + // Données FF recopiées chaque nuit : mises en cache par le serveur. + const resultat = usePageResult(`stock-negatif?${magasin}`, () => + fetchPage("stock-negatif", { magasin }), + ); + const donnees: DonneesStock | null = resultat.ok ? resultat.data : null; + const erreur: string | null = resultat.ok ? null : resultat.error; const params = new URLSearchParams(); if (magasin) params.set("magasin", magasin); diff --git a/src/app/api/admin/grid-warmup/route.ts b/src/app/api/admin/grid-warmup/route.ts deleted file mode 100644 index 6e56bbd..0000000 --- a/src/app/api/admin/grid-warmup/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { adminOuReponse } from "@/lib/authz"; -import { pgGetFournisseurs } from "@/lib/pg-ff-client"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { listGridSuppliers } from "@/lib/grid-store"; - -// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la -// main immédiatement et le client interroge GET pour suivre l'avancement. -export const maxDuration = 300; -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Préchauffage de l'instantané de grille (`grid_rows`). - * - * L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est - * ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne - * verrait donc que les fournisseurs déjà consultés à la main. - * - * Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait - * en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est - * lourd en SQL, le paralléliser saturerait PostgreSQL. - */ -type WarmupStatus = "idle" | "running" | "success" | "error"; - -interface WarmupJob { - status: WarmupStatus; - total: number; - done: number; - skipped: number; - failed: number; - currentFournisseur?: string; - startedAt: string; - finishedAt?: string; - error?: string; - lastErrors: string[]; -} - -let job: WarmupJob | null = null; - -function publicJob(j: WarmupJob | null) { - if (!j) { - return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] }; - } - return j; -} - -async function runWarmup(j: WarmupJob, staleHours: number): Promise { - try { - const fournisseurs = await pgGetFournisseurs(); - // Les fournisseurs déjà calculés récemment sont sautés : un second passage - // coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte. - const existing = await listGridSuppliers(); - const freshLimit = Date.now() - staleHours * 3600_000; - const fresh = new Set( - existing - .filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit) - .map((s) => s.codeFournisseur), - ); - - j.total = fournisseurs.length; - console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`); - - for (const f of fournisseurs) { - if (fresh.has(f.code)) { - j.skipped++; - continue; - } - j.currentFournisseur = `${f.code} — ${f.nom}`; - try { - await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true }); - j.done++; - } catch (e) { - j.failed++; - const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`; - // On garde une trace des derniers échecs sans faire exploser la mémoire. - if (j.lastErrors.length < 10) j.lastErrors.push(msg); - console.error("[grid-warmup]", msg); - } - } - - j.currentFournisseur = undefined; - j.status = "success"; - j.finishedAt = new Date().toISOString(); - console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`); - } catch (e) { - j.status = "error"; - j.error = e instanceof Error ? e.message : String(e); - j.finishedAt = new Date().toISOString(); - console.error("[grid-warmup] échec global:", j.error); - } -} - -/** GET /api/admin/grid-warmup — avancement du préchauffage. */ -export async function GET() { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - return NextResponse.json({ success: true, ...publicJob(job) }); -} - -/** - * POST /api/admin/grid-warmup?staleHours=24 - * Démarre le préchauffage en arrière-plan et répond immédiatement. - */ -export async function POST(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - if (job?.status === "running") { - return NextResponse.json({ success: true, ...publicJob(job) }); - } - - const raw = Number(req.nextUrl.searchParams.get("staleHours")); - const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24; - - job = { - status: "running", - total: 0, - done: 0, - skipped: 0, - failed: 0, - startedAt: new Date().toISOString(), - lastErrors: [], - }; - const current = job; - console.log(`[grid-warmup] démarré (staleHours=${staleHours})`); - - void runWarmup(current, staleHours).catch((e) => { - current.status = "error"; - current.error = e instanceof Error ? e.message : String(e); - current.finishedAt = new Date().toISOString(); - }); - - return NextResponse.json({ success: true, ...publicJob(job) }); -} diff --git a/src/app/api/admin/sync/fournisseurs/route.ts b/src/app/api/admin/sync/fournisseurs/route.ts deleted file mode 100644 index 292557e..0000000 --- a/src/app/api/admin/sync/fournisseurs/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { asc, eq, inArray, sql } from "drizzle-orm"; -import { adminOuReponse } from "@/lib/authz"; -import { db } from "@/db"; -import { syncFournisseurs } from "@/db/schema"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET — la liste complète des fournisseurs paramétrés, avec l'état de leur - * dernière synchronisation. - * - * Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a - * besoin de la vue d'ensemble pour décider qui cadencer. - */ -export async function GET() { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - const rows = await db - .select() - .from(syncFournisseurs) - .orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`)); - - return NextResponse.json({ - success: true, - fournisseurs: rows, - resume: { - total: rows.length, - actifsSql: rows.filter((r) => r.actifSql).length, - actifsQlik: rows.filter((r) => r.actifQlik).length, - desactivesAuto: rows.filter((r) => r.desactiveMotif).length, - enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length, - enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length, - jamaisSql: rows.filter((r) => !r.dernierSqlAt).length, - }, - }); -} - -const patchSchema = z.object({ - /** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */ - codes: z.array(z.string().min(1)).min(1).max(2000), - actifSql: z.boolean().optional(), - actifQlik: z.boolean().optional(), -}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, { - message: "Préciser au moins actifSql ou actifQlik", -}); - -/** - * PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source. - * - * Réactiver lève le motif de désactivation automatique : sans cela, un - * fournisseur remis en service par l'admin garderait un « aucun article » - * trompeur, alors même qu'il vient d'être rouvert volontairement. - */ -export async function PATCH(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); - } - const parsed = patchSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) }, - { status: 400 }, - ); - } - const { codes, actifSql, actifQlik } = parsed.data; - - const reactivation = actifSql === true || actifQlik === true; - const rows = await db - .update(syncFournisseurs) - .set({ - ...(actifSql !== undefined ? { actifSql } : {}), - ...(actifQlik !== undefined ? { actifQlik } : {}), - ...(reactivation ? { desactiveMotif: null } : {}), - updatedAt: new Date(), - }) - .where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes)) - .returning({ code: syncFournisseurs.codeFournisseur }); - - console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`); - return NextResponse.json({ success: true, misAJour: rows.length }); -} diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts deleted file mode 100644 index 9989685..0000000 --- a/src/app/api/admin/sync/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { adminOuReponse } from "@/lib/authz"; -import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings"; -import { - getSyncSchedulerState, - lancerRound, - demanderArret, - debutFenetre, - seedSyncFournisseurs, -} from "@/features/admin/api/sync-scheduler"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Un lancement manuel peut enchaîner plusieurs fournisseurs. -export const maxDuration = 300; - -/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */ -export async function GET() { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - const settings = await readSyncSettings(); - const maintenant = new Date(); - const debut = debutFenetre(settings, maintenant); - - return NextResponse.json({ - success: true, - settings, - state: getSyncSchedulerState(), - /** true = on est actuellement dans la fenêtre nocturne. */ - dansLaFenetre: debut !== null, - fenetreDebutAt: debut?.toISOString() ?? null, - maintenant: maintenant.toISOString(), - }); -} - -const settingsSchema = z.object({ - actif: z.boolean().optional(), - heureDebut: z.coerce.number().int().min(0).max(23).optional(), - heureFin: z.coerce.number().int().min(0).max(23).optional(), - qlikParNuit: z.coerce.number().int().min(0).max(200).optional(), - sqlParQlik: z.coerce.number().int().min(1).max(500).optional(), - qlikMinJours: z.coerce.number().int().min(0).max(365).optional(), -}); - -/** PATCH — met à jour les réglages. */ -export async function PATCH(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); - } - const parsed = settingsSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, - { status: 400 }, - ); - } - - const settings = await saveSyncSettings(parsed.data); - return NextResponse.json({ success: true, settings }); -} - -const actionSchema = z.object({ - action: z.enum(["start", "stop", "seed"]), - /** - * Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait - * ce qu'il fait : en pleine journée, la charge est visible par les magasins. - */ - forcer: z.boolean().optional(), -}); - -/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */ -export async function POST(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); - } - const parsed = actionSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 }); - } - - if (parsed.data.action === "seed") { - const n = await seedSyncFournisseurs(); - return NextResponse.json({ success: true, fournisseurs: n }); - } - - if (parsed.data.action === "stop") { - demanderArret(); - return NextResponse.json({ success: true, state: getSyncSchedulerState() }); - } - - const etat = getSyncSchedulerState(); - if (etat.enCours) { - return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 }); - } - - // Fire-and-forget : le round dure des minutes, le client suit via GET. - void lancerRound("manuel", parsed.data.forcer === true).catch((e) => { - console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e)); - }); - - return NextResponse.json({ success: true, state: getSyncSchedulerState() }); -} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 73228a0..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { handlers } from "@/lib/auth"; -export const { GET, POST } = handlers; diff --git a/src/app/api/commandes-auto/route.ts b/src/app/api/commandes-auto/route.ts deleted file mode 100644 index 8a7cf2b..0000000 --- a/src/app/api/commandes-auto/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sessionOuReponse } from "@/lib/authz"; - -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; - -/** - * GET /api/commandes-auto[?site=292&codefou=N009] - * - * Proxy vers https://api.ffnancy.fr/api/commandes-auto - * Transfère les query params site et codefou si présents. - */ -export async function GET(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - try { - const { searchParams } = req.nextUrl; - const params = new URLSearchParams(); - if (searchParams.get("site")) params.set("site", searchParams.get("site")!); - if (searchParams.get("codefou")) params.set("codefou", searchParams.get("codefou")!); - - const qs = params.toString(); - const url = `${FF_API_BASE}/api/commandes-auto${qs ? `?${qs}` : ""}`; - - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) { - return NextResponse.json({ error: `API upstream HTTP ${res.status}` }, { status: res.status }); - } - const data = await res.json(); - return NextResponse.json(data); - } catch (e) { - console.error("[api/commandes-auto]", e); - return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); - } -} diff --git a/src/app/api/diag/route.ts b/src/app/api/diag/route.ts deleted file mode 100644 index 6f6c796..0000000 --- a/src/app/api/diag/route.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/db"; -import { sql } from "drizzle-orm"; -import { adminOuReponse } from "@/lib/authz"; - -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; - -async function probe(url: string) { - try { - const res = await fetch(url, { cache: "no-store" }); - const text = await res.text(); - let parsed: unknown = null; - try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 800); } - let summary: unknown = parsed; - if (Array.isArray(parsed)) { - summary = { count: parsed.length, firstKeys: parsed[0] ? Object.keys(parsed[0]) : [], sample: parsed.slice(0, 2) }; - } else if (parsed && typeof parsed === "object") { - const keys = Object.keys(parsed as object); - const firstArrayKey = keys.find(k => Array.isArray((parsed as Record)[k])); - if (firstArrayKey) { - const arr = (parsed as Record)[firstArrayKey]; - summary = { wrapper: firstArrayKey, count: arr.length, firstKeys: arr[0] ? Object.keys(arr[0] as object) : [], sample: arr.slice(0, 2) }; - } else { - // Objet simple : montrer toutes les clés et le contenu complet - summary = { keys, full: parsed }; - } - } - return { status: res.status, url, summary }; - } catch (e) { - return { error: String(e), url }; - } -} - -export async function GET(req: NextRequest) { - const acces = await adminOuReponse(); - if (acces instanceof Response) return acces; - - const { searchParams } = new URL(req.url); - - // Date range: 12 derniers mois - const now = new Date(); - const dateFin = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10); - const dateDebut = new Date(now.getFullYear(), now.getMonth() - 12, 1).toISOString().slice(0, 10); - - // Récupérer un codefou réel + un codein réel depuis l'API - let codefou = searchParams.get("codefou"); - let firstCodein: string | null = searchParams.get("codein"); - const forcedNoid = searchParams.get("noid"); // ex: ?noid=129632 - - if (!codefou) { - try { - const res = await fetch(`${FF_API_BASE}/api/fournisseurs?limit=1`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - if (list.length > 0) codefou = (list[0] as Record).codefou as string ?? null; - } catch { /* ignore */ } - codefou ??= "D005"; - } - - if (!firstCodein) { - try { - const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - if (list.length > 0) firstCodein = (list[0] as Record).codein as string ?? null; - } catch { /* ignore */ } - } - - // Si un noid est forcé, on probe directement mensuel + referentiel pour cet article - const mensuelNoidForced = forcedNoid - ? probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(forcedNoid)}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`) - : Promise.resolve({ skipped: "no ?noid= param" }); - const referentielNoidForced = forcedNoid - ? probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(forcedNoid)}/referentiel`) - : Promise.resolve({ skipped: "no ?noid= param" }); - - const [ - fournisseurs, - articles, - mvt_dateDebut, - mensuel_noid, - referentiel, - mensuel_forced, - referentiel_forced, - ] = await Promise.all([ - probe(`${FF_API_BASE}/api/fournisseurs?limit=2`), - probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`), - probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`), - // Test mensuel avec no_id auto-résolu - firstCodein - ? (async () => { - try { - const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - const noid = list[0] ? (list[0] as Record).no_id as string : null; - if (noid) return probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(noid)}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`); - } catch { /* ignore */ } - return { skipped: "no no_id available" }; - })() - : Promise.resolve({ skipped: "no article" }), - // Referentiel complet (nomenclature + gamme + stock + prix) - firstCodein - ? (async () => { - try { - const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - const art = (list[4] ?? list[0]) as Record | undefined; - const noId = art?.no_id as string ?? null; - if (noId) return probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`); - } catch { /* ignore */ } - return { skipped: "no no_id" }; - })() - : Promise.resolve({ skipped: "no codefou" }), - mensuelNoidForced, - referentielNoidForced, - ]); - - // Test PostgreSQL + diagnostic ventesProduits - let postgresql: unknown; - let ventesProduitsDiag: unknown; - try { - const r = await db.execute(sql`SELECT COUNT(*) as count FROM session_snapshots`); - - // Diagnostic ventesProduits : cherche par les 2 codes possibles d'AUXENCE - const vpByName = await db.execute(sql` - SELECT code_fournisseur, COUNT(*) as nb_rows, - COUNT(code_gamme) as nb_gamme, - COUNT(code_gamme_init) as nb_gamme_init, - COUNT(code3) as nb_code3 - FROM ventes_produits - WHERE code_fournisseur IN ('AUXENCE', 'A025', ${codefou}) - GROUP BY code_fournisseur - `); - - // Sample de quelques codeins dans ventesProduits pour ce fournisseur - const vpSample = await db.execute(sql` - SELECT codein, code_fournisseur, code_gamme, code_gamme_init, code3, libelle3 - FROM ventes_produits - WHERE code_fournisseur IN ('AUXENCE', 'A025', ${codefou}) - LIMIT 3 - `); - - postgresql = { - ok: true, - snapshots: Number(r.rows[0]?.count ?? 0), - }; - ventesProduitsDiag = { - byFournisseur: vpByName.rows, - sample: vpSample.rows, - }; - } catch (e) { - postgresql = { ok: false, error: String(e) }; - ventesProduitsDiag = { error: String(e) }; - } - - // Diagnostic analytics CA TTC - let mvtartDiag: unknown; - try { - // 1. Totaux mars 2025 avec mntmvtht et mntmvtttc pour comparer avec API (HT=225k, TTC=269k) - const totaux = await db.execute(sql` - SELECT site, - ABS(SUM(mntmvtht))::float AS sum_ht, - ABS(SUM(mntmvtttc))::float AS sum_ttc, - COUNT(*) AS nb_lignes - FROM mvtart - WHERE genremvt = 3 - AND TO_CHAR(datmvt, 'YYYY-MM') = '2025-03' - AND site IN ('292', '579') - GROUP BY site - `); - // 2. Distribution des valeurs de preference dans artfou1 - const prefDist = await db.execute(sql` - SELECT preference, COUNT(*) AS nb - FROM artfou1 - GROUP BY preference - ORDER BY preference - `); - // 3. Ventes mars 2025 non attribuées (aucune ligne artfou1 avec preference=1) - const sansPreference = await db.execute(sql` - SELECT COUNT(DISTINCT m.artnoid) AS nb_articles_sans_pref - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE m.genremvt = 3 - AND TO_CHAR(m.datmvt, 'YYYY-MM') = '2025-03' - AND m.site IN ('292','579') - AND NOT EXISTS ( - SELECT 1 FROM artfou1 af - WHERE af.art_no_id = a.no_id AND af.preference = 1 - ) - `); - mvtartDiag = { - totaux_202503: totaux.rows, - artfou1_preference_distribution: prefDist.rows, - articles_vendus_sans_pref1: sansPreference.rows, - }; - } catch (e) { - mvtartDiag = { error: String(e) }; - } - - // Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto - let francoDiag: unknown; - try { - const francoColumns = await db.execute(sql` - SELECT table_name, column_name, data_type - FROM information_schema.columns - WHERE column_name ILIKE '%franco%' - OR column_name ILIKE '%min_cmd%' - OR column_name ILIKE '%mincde%' - OR column_name ILIKE '%min_commande%' - ORDER BY table_name, column_name - `); - // Sample des 3 premières lignes de fouident pour voir toutes les colonnes - const fouidentSample = await db.execute(sql`SELECT * FROM fouident LIMIT 3`); - const foucadSample = await db.execute(sql`SELECT * FROM foucad LIMIT 5`); - francoDiag = { - francoColumns: francoColumns.rows, - fouidentColumns: fouidentSample.rows.length > 0 ? Object.keys(fouidentSample.rows[0] as object) : [], - fouidentSample: fouidentSample.rows, - foucadColumns: foucadSample.rows.length > 0 ? Object.keys(foucadSample.rows[0] as object) : [], - foucadSample: foucadSample.rows, - }; - } catch (e) { - francoDiag = { error: String(e) }; - } - - // Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant - let commandesAutoFrancoAudit: unknown; - try { - const caRes = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" }); - const caData = await caRes.json(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const caRows: any[] = Array.isArray(caData) ? caData : (caData?.propositions ?? caData?.data ?? []); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const withFranco = caRows.filter((r: any) => r.franco_ht != null && Number(r.franco_ht) > 0); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const withoutFranco = caRows.filter((r: any) => r.franco_ht == null || Number(r.franco_ht) === 0); - commandesAutoFrancoAudit = { - total: caRows.length, - avecFranco: withFranco.length, - sansFranco: withoutFranco.length, - lignesSansFranco: withoutFranco.map((r: any) => ({ - site: r.site, codefou: r.codefou, nom_fou: r.nom_fou, - franco_ht: r.franco_ht, franco_atteint: r.franco_atteint, - })), - }; - } catch (e) { - commandesAutoFrancoAudit = { error: String(e) }; - } - - // Probe des fournisseurs N/A pour trouver où est leur franco - const [ - detailM036_292, - detailM030_579, - fournisseurM036, - fournisseurM030, - ] = await Promise.all([ - probe(`${FF_API_BASE}/api/commandes-auto/M036?site=292`), - probe(`${FF_API_BASE}/api/commandes-auto/M030?site=579`), - probe(`${FF_API_BASE}/api/fournisseurs/M036`), - probe(`${FF_API_BASE}/api/fournisseurs/M030`), - ]); - const francoNaProbe = { detailM036_292, detailM030_579, fournisseurM036, fournisseurM030 }; - - // Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API - const rawCommandesAuto = await probe(`${FF_API_BASE}/api/commandes-auto`); - - return NextResponse.json({ - timestamp: new Date().toISOString(), - dateRange: { dateDebut, dateFin }, - codefouTested: codefou, - firstCodeinTested: firstCodein, - forcedNoid: forcedNoid ?? "(none — pass ?noid=129632 to test a specific article)", - fournisseurs, - articles, - mouvements_dateDebut: mvt_dateDebut, - endpoints_auto: { - "articles/:no_id/mensuel": mensuel_noid, - "articles/:no_id/referentiel": referentiel, - }, - endpoints_forced_noid: { - "articles/:noid/mensuel": mensuel_forced, - "articles/:noid/referentiel": referentiel_forced, - }, - postgresql, - ventesProduitsDiag, - mvtartDiag, - francoDiag, - rawCommandesAuto, - commandesAutoFrancoAudit, - francoNaProbe, - }); -} diff --git a/src/app/api/diag/stock-fournisseur/route.ts b/src/app/api/diag/stock-fournisseur/route.ts deleted file mode 100644 index 44aa7c4..0000000 --- a/src/app/api/diag/stock-fournisseur/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from "next/server"; -import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client"; -import { adminOuReponse } from "@/lib/authz"; - -export const dynamic = "force-dynamic"; - -/** - * Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par - * la page Gestion de Stock. - * - * `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes - * fournisseur connues : la page retombe alors sur `artfou1.preference = 1`. - * `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste - * blanche `MVTART_FOU_CANDIDATES` si le nom diffère. - */ -export async function GET() { - const acces = await adminOuReponse(); - if (acces instanceof Response) return acces; - - try { - return NextResponse.json(await pgDiagFournisseurDerniereEntree()); - } catch (e) { - return NextResponse.json({ error: String(e) }, { status: 500 }); - } -} diff --git a/src/app/api/export/excel/route.ts b/src/app/api/export/excel/route.ts deleted file mode 100644 index 7eace0c..0000000 --- a/src/app/api/export/excel/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import ExcelJS from "exceljs"; -import { z } from "zod"; -import { sessionOuReponse } from "@/lib/authz"; - -const ExportSchema = z.object({ - nomFournisseur: z.string(), - rows: z.array(z.object({ - codein: z.string(), - libelle1: z.string(), - code3: z.string(), - libelle3: z.string(), - codeGamme: z.string().nullable(), - totalQuantite: z.number(), - totalCa: z.number(), - tauxMarge: z.number(), - // Before gamme for delta - gammeAvant: z.string().nullable().optional(), - })), -}); - -export async function POST(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const body = await req.json(); - const parsed = ExportSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); - } - - const { nomFournisseur, rows } = parsed.data; - const workbook = new ExcelJS.Workbook(); - workbook.creator = "CollectFlow"; - workbook.created = new Date(); - - const sheet = workbook.addWorksheet("Assortiment", { - views: [{ state: "frozen", ySplit: 1 }], - }); - - // Header style - const headerStyle: Partial = { - font: { bold: true, color: { argb: "FFFFFFFF" } }, - fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } }, - alignment: { horizontal: "center" }, - border: { bottom: { style: "thin", color: { argb: "FF334155" } } }, - }; - - sheet.columns = [ - { header: "Code", key: "codein", width: 14 }, - { header: "Désignation", key: "libelle1", width: 40 }, - { header: "Nomenclature", key: "libelle3", width: 25 }, - { header: "Gamme Avant", key: "gammeAvant", width: 14 }, - { header: "Gamme Après", key: "codeGamme", width: 14 }, - { header: "Delta", key: "delta", width: 10 }, - { header: "Vol. 12m", key: "totalQuantite", width: 12 }, - { header: "CA 12m (€)", key: "totalCa", width: 14 }, - { header: "Marge (%)", key: "tauxMarge", width: 12 }, - ]; - - // Apply header style - sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); }); - sheet.getRow(1).height = 22; - - // Gamme colours - const GAMME_COLORS: Record = { - A: "FF064E3B", B: "FF1E3A5F", C: "FF78350F", Z: "FF4C0519", - }; - - for (const row of rows) { - const delta = row.gammeAvant !== row.codeGamme ? `${row.gammeAvant ?? "—"} → ${row.codeGamme ?? "—"}` : ""; - const excelRow = sheet.addRow({ - codein: row.codein, - libelle1: row.libelle1, - libelle3: row.libelle3, - gammeAvant: row.gammeAvant ?? "—", - codeGamme: row.codeGamme ?? "—", - delta, - totalQuantite: Math.round(row.totalQuantite), - totalCa: parseFloat(row.totalCa.toFixed(2)), - tauxMarge: parseFloat(row.tauxMarge.toFixed(2)), - }); - - // Colour the "Gamme Après" cell - const gammeCell = excelRow.getCell("codeGamme"); - if (row.codeGamme && GAMME_COLORS[row.codeGamme]) { - gammeCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: GAMME_COLORS[row.codeGamme] } }; - gammeCell.font = { bold: true, color: { argb: "FFFFFFFF" } }; - } - - // Highlight changed rows - if (delta) { - excelRow.getCell("delta").font = { bold: true, color: { argb: "FFFBBF24" } }; - } - - // Format numbers - excelRow.getCell("totalCa").numFmt = '#,##0.00 "€"'; - excelRow.getCell("tauxMarge").numFmt = '0.00"%"'; - } - - // Generate buffer - const buffer = await workbook.xlsx.writeBuffer(); - - const filename = `Assortiment_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`; - - return new NextResponse(buffer, { - headers: { - "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); -} diff --git a/src/app/api/export/modified-gammes/route.ts b/src/app/api/export/modified-gammes/route.ts deleted file mode 100644 index 600817f..0000000 --- a/src/app/api/export/modified-gammes/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import ExcelJS from "exceljs"; -import { z } from "zod"; -import { sessionOuReponse } from "@/lib/authz"; - -const ExportModifiedGammesSchema = z.object({ - nomFournisseur: z.string(), - changes: z.array(z.object({ - codein: z.string(), - gtin: z.string(), - codeFournisseur: z.string().optional(), - gamme: z.string(), - })), -}); - -export async function POST(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const body = await req.json(); - const parsed = ExportModifiedGammesSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); - } - - const { nomFournisseur, changes } = parsed.data; - const workbook = new ExcelJS.Workbook(); - workbook.creator = "CollectFlow"; - workbook.created = new Date(); - - const sheet = workbook.addWorksheet("Gammes Modifiées", { - views: [{ state: "frozen", ySplit: 1 }], - }); - - // Header style - const headerStyle: Partial = { - font: { bold: true, color: { argb: "FFFFFFFF" } }, - fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } }, - alignment: { horizontal: "center" }, - border: { bottom: { style: "thin", color: { argb: "FF334155" } } }, - }; - - sheet.columns = [ - { header: "CODE FOURNISSEUR", key: "codeFournisseur", width: 22 }, - { header: "CODEIN", key: "codein", width: 18 }, - { header: "GAMME", key: "gamme", width: 12 }, - ]; - - // Apply header style - sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); }); - sheet.getRow(1).height = 22; - - for (const row of changes) { - sheet.addRow({ - codeFournisseur: row.codeFournisseur || "—", - codein: row.codein || "—", - gamme: row.gamme || "—", - }); - } - - // Centrer la colonne Gamme - const gammeCol = sheet.getColumn("gamme"); - gammeCol.alignment = { horizontal: "center" }; - - // Format en texte pour eviter la notation scientifique excel - sheet.getColumn("codeFournisseur").numFmt = "@"; - sheet.getColumn("codein").numFmt = "@"; - - // Generate buffer - const buffer = await workbook.xlsx.writeBuffer(); - - const filename = `Modifications_Gammes_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`; - - return new NextResponse(buffer, { - headers: { - "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); -} diff --git a/src/app/api/ff-status/route.ts b/src/app/api/ff-status/route.ts deleted file mode 100644 index 02dbe50..0000000 --- a/src/app/api/ff-status/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; -import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic"; -import { sessionOuReponse } from "@/lib/authz"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/ff-status — état de l'API REST FF Nancy. - * - * S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin - * qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP) - * et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans - * détail, impossible à distinguer d'une simple erreur d'URL. - */ -export async function GET() { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const res = await diagnostiquerApiFf(); - if (!res.success) { - return NextResponse.json( - { error: "API FF Nancy non disponible", url: res.url, detail: res.error }, - { status: 503 }, - ); - } - return NextResponse.json(res.status); -} diff --git a/src/app/api/grid/rows/route.ts b/src/app/api/grid/rows/route.ts deleted file mode 100644 index a12e9d6..0000000 --- a/src/app/api/grid/rows/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { NextRequest } from "next/server"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { sessionOuReponse } from "@/lib/authz"; -import type { GridFilters } from "@/types/grid"; - -export const dynamic = "force-dynamic"; - -const encoder = new TextEncoder(); - -function writeNdjson(controller: ReadableStreamDefaultController, value: unknown) { - controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`)); -} - -export async function GET(request: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const searchParams = request.nextUrl.searchParams; - const codeFournisseur = searchParams.get("fournisseur"); - - if (!codeFournisseur) { - return Response.json({ error: "Missing fournisseur" }, { status: 400 }); - } - - const magasin = searchParams.get("magasin") ?? "TOTAL"; - const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150))); - // `code3` n'est volontairement pas lu : la Grille applique ce filtre en local - // (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs. - const filters: Partial = { - code1: searchParams.get("code1"), - code2: searchParams.get("code2"), - }; - const forceRefresh = searchParams.get("refresh") === "1"; - - // Client parti (changement de fournisseur, page quittée) : on arrête de - // sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et - // mis en cache, la prochaine ouverture en profitera. - let cancelled = false; - request.signal.addEventListener("abort", () => { cancelled = true; }); - - const stream = new ReadableStream({ - cancel() { - cancelled = true; - }, - async start(controller) { - try { - writeNdjson(controller, { type: "start", loaded: 0, total: null }); - const rows = await getProductRows({ codeFournisseur, magasin, filters, forceRefresh }); - if (cancelled) return; - const total = rows.length; - - for (let start = 0; start < total; start += chunkSize) { - if (cancelled) return; - const chunk = rows.slice(start, start + chunkSize); - writeNdjson(controller, { - type: "chunk", - rows: chunk, - loaded: Math.min(start + chunk.length, total), - total, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - writeNdjson(controller, { type: "done", loaded: total, total }); - } catch (error) { - if (cancelled) return; - writeNdjson(controller, { - type: "error", - error: error instanceof Error ? error.message : "Unknown error", - }); - } finally { - try { - controller.close(); - } catch { - /* flux déjà fermé côté client */ - } - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "application/x-ndjson; charset=utf-8", - "Cache-Control": "no-store", - "X-Accel-Buffering": "no", - }, - }); -} diff --git a/src/app/api/grid/rows/store-patch/route.ts b/src/app/api/grid/rows/store-patch/route.ts deleted file mode 100644 index 35b2263..0000000 --- a/src/app/api/grid/rows/store-patch/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest } from "next/server"; -import { getStorePatch } from "@/features/grid/api/get-product-rows"; -import { MAGASINS } from "@/lib/magasins"; -import { sessionOuReponse } from "@/lib/authz"; - -export const dynamic = "force-dynamic"; - -/** - * GET /api/grid/rows/store-patch?fournisseur=…&magasin=… - * - * Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`). - * La Grille charge toujours les lignes « tous magasins » et applique ce - * complément à son arrivée : le changement de magasin n'attend plus rien. - */ -export async function GET(request: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const codeFournisseur = request.nextUrl.searchParams.get("fournisseur"); - const magasin = request.nextUrl.searchParams.get("magasin") ?? ""; - // Un code inconnu ferait de chaque article un candidat : autant d'appels - // inutiles à l'API FF. « Tous magasins » n'a pas de complément. - if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) { - return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 }); - } - - try { - const patch = await getStorePatch(codeFournisseur, magasin); - return Response.json(patch, { headers: { "Cache-Control": "no-store" } }); - } catch (error) { - console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200)); - return Response.json({ error: "Complément indisponible." }, { status: 502 }); - } -} diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts deleted file mode 100644 index 56adf2d..0000000 --- a/src/app/api/logs/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * CollectFlow — Journaux serveur des jobs (liste + téléchargement). - * - * `GET /api/logs` → liste des journaux disponibles (JSON) - * `GET /api/logs?id=` → le journal complet en pièce jointe (text/plain) - * - * Réservé aux admins : un journal d'extraction contient les diagnostics internes - * du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite — - * l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur, - * et jamais le mot de passe Qlik. - */ - -import { NextRequest, NextResponse } from "next/server"; -import { adminOuReponse } from "@/lib/authz"; -import { listCaptures, readCapture } from "@/lib/log-capture"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET(req: NextRequest) { - const refus = await adminOuReponse(); - if (refus instanceof Response) return refus; - - const id = req.nextUrl.searchParams.get("id"); - if (!id) { - return NextResponse.json({ success: true, logs: await listCaptures() }); - } - - const contenu = await readCapture(id); - if (contenu == null) { - return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 }); - } - - // Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans - // un en-tête HTTP. - const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`; - return new NextResponse(contenu, { - status: 200, - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Content-Disposition": `attachment; filename="${nom}"`, - "Cache-Control": "no-store", - }, - }); -} diff --git a/src/app/api/produits/opportunites/route.ts b/src/app/api/produits/opportunites/route.ts deleted file mode 100644 index 9d2ecea..0000000 --- a/src/app/api/produits/opportunites/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sessionOuReponse } from "@/lib/authz"; -import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client"; -import { buildLast12MonthsRange } from "@/lib/api-ff-client"; - -export const dynamic = "force-dynamic"; - -/** - * GET /api/produits/opportunites?nomNoId=123 - * - * Produits de la même nomenclature, classés par écart entre performance réseau - * et performance locale (quantités par magasin). Chargé à la demande depuis la - * fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille, - * on ne veut pas la payer au chargement de la page. - */ -export async function GET(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const raw = req.nextUrl.searchParams.get("nomNoId"); - const nomNoId = Number(raw); - if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) { - return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 }); - } - - try { - const { dateDebut, dateFin } = buildLast12MonthsRange(); - const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin); - return NextResponse.json({ success: true, rows }); - } catch (e) { - console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250)); - return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); - } -} diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts deleted file mode 100644 index d282d95..0000000 --- a/src/app/api/produits/search/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sessionOuReponse } from "@/lib/authz"; -import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits"; - -// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on -// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état -// des jobs vit dans la mémoire du process. -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Recherche produit **Qlik d'abord** — API asynchrone. - * - * `POST /api/produits/search?q=[&force=1]` démarre la recherche et rend la - * main tout de suite. `GET /api/produits/search?q=` renvoie l'avancement, - * puis le résultat. - * - * Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une - * extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue - * aussi longtemps se fait couper par le reverse proxy, qui répond une page - * d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is - * not valid JSON ». Même schéma que `POST /api/qlik/sync`. - * - * Contrôle de session explicite en plus du middleware : il répond ici en JSON, - * et relit le compte en base (cf. lib/authz). - */ - -function lireTerme(req: NextRequest): string | null { - const q = (req.nextUrl.searchParams.get("q") ?? "").trim(); - return q.length >= 3 ? q : null; -} - -export async function POST(req: NextRequest) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - - const q = lireTerme(req); - if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); - - const force = req.nextUrl.searchParams.get("force") === "1"; - try { - const job = demarrerRecherche(q, { force }); - return NextResponse.json({ success: true, ...job }); - } catch (e) { - const message = (e as Error).message ?? String(e); - console.error("[api/produits/search] POST", message.slice(0, 300)); - return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 }); - } -} - -export async function GET(req: NextRequest) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - - const q = lireTerme(req); - if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); - - try { - return NextResponse.json({ success: true, ...etatRecherche(q) }); - } catch (e) { - const message = (e as Error).message ?? String(e); - console.error("[api/produits/search] GET", message.slice(0, 300)); - return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 }); - } -} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts deleted file mode 100644 index b223f63..0000000 --- a/src/app/api/qlik/sync/route.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { adminOuReponse, sessionOuReponse } from "@/lib/authz"; -import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; -import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; -import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; -import { - buildGridNetworkQlikDateFilter, - QLIK_MONTHS_BACK_DEFAULT, -} from "@/lib/qlik-date-range"; -import { startCapture, stopCapture } from "@/lib/log-capture"; - -// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). -// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au -// client tout de suite via le state job ci-dessous. -export const maxDuration = 300; -// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a -// besoin d'un process persistant pour conserver l'état de job en mémoire. -export const runtime = "nodejs"; -// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST). -export const dynamic = "force-dynamic"; - -/** État d'un job de sync Qlik pour un fournisseur donné. */ -type QlikSyncStatus = "idle" | "running" | "success" | "error"; - -interface QlikSyncJob { - jobId: string; - /** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */ - fournisseur: string; - /** Code centrale (mode produit) — absent en mode fournisseur. */ - codeCentrale?: string; - status: QlikSyncStatus; - requested: number; - fetched: number; - upserted: number; - periode?: string; - dateDebut?: string; - dateFin?: string; - startedAt: string; - finishedAt?: string; - error?: string; - message?: string; -} - -/** - * Store en mémoire au niveau module. Une Map par fournisseur car on ne lance - * qu'une extraction à la fois par fournisseur. C'est volontairement simple : - * si le process redémarre, les jobs en cours sont perdus → GET renverra idle - * et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX. - */ -const jobs = new Map(); - -/** - * Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur - * brut) ; le mode produit est préfixé pour ne jamais entrer en collision. - */ -function jobKeyForProduct(codeCentrale: string): string { - return `cc:${codeCentrale.toUpperCase()}`; -} - -/** - * Nombre maximum d'extractions produit simultanées. - * - * Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au - * mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très - * légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne - * la concurrence plutôt que de lui laisser prendre N requêtes en parallèle. - */ -const MAX_CONCURRENT_PRODUCT_JOBS = 3; - -/** Compte les extractions produit actuellement en cours. */ -function runningProductJobs(): number { - let n = 0; - for (const [key, job] of jobs) { - if (key.startsWith("cc:") && job.status === "running") n++; - } - return n; -} - -/** Construit un jobId court et lisible. */ -function newJobId(): string { - return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; -} - -/** Sérialise un job pour la réponse HTTP. */ -function publicJob(job: QlikSyncJob) { - return { - jobId: job.jobId, - fournisseur: job.fournisseur, - codeCentrale: job.codeCentrale, - status: job.status, - requested: job.requested, - fetched: job.fetched, - upserted: job.upserted, - periode: job.periode, - dateDebut: job.dateDebut, - dateFin: job.dateFin, - startedAt: job.startedAt, - finishedAt: job.finishedAt, - error: job.error, - message: job.message, - }; -} - -/** État "idle" sérialisé (pas de job connu pour cette cible). */ -function idleJob(fournisseur: string, codeCentrale?: string) { - return { - jobId: null, - fournisseur, - codeCentrale, - status: "idle" as const, - requested: 0, - fetched: 0, - upserted: 0, - periode: undefined, - dateDebut: undefined, - dateFin: undefined, - startedAt: undefined, - finishedAt: undefined, - error: undefined, - message: undefined, - }; -} - -/** - * Regex de validation d'un code centrale "raisonnable". - * - * Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`), - * donc 11 caractères alphanumériques. Pour rester large et ne pas casser - * d'éventuels codes alphanumériques fournisseur (refs internes), on autorise - * - longueur 2..30 - * - jeu `[A-Z0-9_-]` - * - premier caractère alphanumérique (pas `_` ni `-`) - * - * But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces - * parasites, ou des entrées manifestement invalides qui déclenchent des - * codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** : - * on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à - * aucun code plausible. - */ -const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i; - -/** - * Filtre strict des codes centraux avant d'envoyer à Qlik. - * - trim + drop vides - * - drop "-" (placeholder déjà exclu côté agrégation mais on double-check) - * - drop doublons (préserve l'ordre, comparaison insensible à la casse) - * - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset) - * - * Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les - * compteurs, jamais les valeurs des codes. - */ -function filterCentralCodes(rawCodes: Array): { - accepted: string[]; - rejected: number; - deduped: number; -} { - const seen = new Set(); - const accepted: string[] = []; - let rejected = 0; - let deduped = 0; - for (const raw of rawCodes) { - if (raw == null) { - rejected++; - continue; - } - const code = String(raw).trim(); - const key = code.toUpperCase(); - if (!code || code === "-" || code === "_") { - rejected++; - continue; - } - if (seen.has(key)) { - deduped++; - continue; - } - if (!CENTRAL_CODE_REGEX.test(code)) { - rejected++; - continue; - } - seen.add(key); - accepted.push(code); - } - return { accepted, rejected, deduped }; -} - -/** - * Exécute réellement l'extraction + l'upsert, en mettant à jour le job - * au fur et à mesure (compteur fetched, puis upserted, puis finishedAt). - */ -async function runJob(job: QlikSyncJob): Promise { - // Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du - // job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) — - // c'est précisément la partie qu'un terminal tronque. - startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`); - try { - const articles = await pgGetArticlesByFournisseur(job.fournisseur); - // Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects). - const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes( - articles.map((a) => a.codeCentrale), - ); - job.requested = codes.length; - console.log( - `[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`, - ); - if (codes.length === 0) { - job.status = "success"; - job.message = "Aucun code centrale valide pour ce fournisseur"; - job.finishedAt = new Date().toISOString(); - return; - } - - // Contrat métier strict : toujours 12 mois complets glissants. - // Une variable d'environnement ne doit pas pouvoir raccourcir la série. - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); - job.periode = dateFilter.label; - job.dateDebut = dateFilter.dateDebut; - job.dateFin = dateFilter.dateFin; - console.log( - `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, - ); - // Le code fournisseur est transmis en plus des codes : s'il désigne dans - // Qlik le même périmètre (couverture vérifiée côté extracteur), une seule - // sélection remplace les dizaines de milliers de `SelectValues` par code. - // Les codes restent le repli et le contrôle de couverture. - const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur); - job.fetched = metrics.size; - console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`); - const count = await upsertNetworkMetrics([...metrics.values()]); - job.upserted = count; - console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`); - job.status = "success"; - job.finishedAt = new Date().toISOString(); - } catch (e) { - applyJobError(job, e); - } finally { - await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); - } -} - -/** - * Inscrit une erreur dans le job, en traduisant la saturation mémoire du - * serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow : - * l'app Qlik ne se charge même pas). - */ -function applyJobError(job: QlikSyncJob, e: unknown): void { - const msg = e instanceof Error ? e.message : String(e); - console.error(`[api/qlik/sync] job=${job.jobId}`, msg); - job.status = "error"; - const lower = msg.toLowerCase(); - if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) { - job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik)."; - } else { - job.error = msg; - } - job.finishedAt = new Date().toISOString(); -} - -/** - * Extraction Qlik pour UN seul code centrale (fiche produit). - * - * Beaucoup plus légère que la sync fournisseur : un seul code au lieu de - * plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise - * exactement le même extracteur, donc le même détail mensuel. - */ -async function runProductJob(job: QlikSyncJob): Promise { - startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`); - try { - const code = job.codeCentrale!; - job.requested = 1; - - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); - job.periode = dateFilter.label; - job.dateDebut = dateFilter.dateDebut; - job.dateFin = dateFilter.dateFin; - console.log( - `[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, - ); - - const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter); - job.fetched = metrics.size; - if (metrics.size === 0) { - job.status = "success"; - job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)"; - job.finishedAt = new Date().toISOString(); - return; - } - const count = await upsertNetworkMetrics([...metrics.values()]); - job.upserted = count; - job.status = "success"; - job.message = "Données réseau récupérées"; - job.finishedAt = new Date().toISOString(); - console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`); - } catch (e) { - applyJobError(job, e); - } finally { - await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); - } -} - -/** - * GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY - * Retourne l'état courant du job pour cette cible. Si aucun job n'est - * connu, renvoie un état "idle" (avec un message explicite). Permet au - * frontend de reprendre l'état après un reload et de poller en arrière-plan. - */ -export async function GET(req: NextRequest) { - const codeCentrale = req.nextUrl.searchParams.get("codeCentrale"); - const fournisseur = req.nextUrl.searchParams.get("fournisseur"); - - // Mode produit : ouvert à tout utilisateur connecté. - if (codeCentrale) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - const job = jobs.get(jobKeyForProduct(codeCentrale)); - if (!job) { - return NextResponse.json({ success: true, ...idleJob("", codeCentrale) }); - } - return NextResponse.json({ success: true, ...publicJob(job) }); - } - - // Mode fournisseur : admin uniquement (inchangé). - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - if (!fournisseur) { - return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 }); - } - const job = jobs.get(fournisseur); - if (!job) { - return NextResponse.json({ success: true, ...idleJob(fournisseur) }); - } - return NextResponse.json({ success: true, ...publicJob(job) }); -} - -/** - * POST /api/qlik/sync?fournisseur=XXX - * Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan - * et retourne *immédiatement* { success, status, jobId, ... }. Le client doit - * ensuite poller GET pour suivre l'avancement et être notifié de la fin. - */ -export async function POST(req: NextRequest) { - const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale"); - - // ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ─── - if (codeCentraleParam) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - - // Même validation que la sync fournisseur (trim, rejet des vides / "-" / - // codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik. - const { accepted } = filterCentralCodes([codeCentraleParam]); - if (accepted.length === 0) { - return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 }); - } - const code = accepted[0]; - const key = jobKeyForProduct(code); - - const existingProduct = jobs.get(key); - if (existingProduct && existingProduct.status === "running") { - return NextResponse.json({ success: true, ...publicJob(existingProduct) }); - } - - if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) { - return NextResponse.json( - { error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." }, - { status: 429 }, - ); - } - - const productJob: QlikSyncJob = { - jobId: newJobId(), - fournisseur: "", - codeCentrale: code, - status: "running", - requested: 0, - fetched: 0, - upserted: 0, - startedAt: new Date().toISOString(), - }; - jobs.set(key, productJob); - console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`); - - void runProductJob(productJob).catch((e) => applyJobError(productJob, e)); - - return NextResponse.json({ success: true, ...publicJob(productJob) }); - } - - // ─── Mode fournisseur : admin uniquement (inchangé) ────────────────────── - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - const fournisseur = req.nextUrl.searchParams.get("fournisseur"); - if (!fournisseur) { - return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 }); - } - - const existing = jobs.get(fournisseur); - if (existing && existing.status === "running") { - // Un job tourne déjà pour ce fournisseur : on le réutilise. - return NextResponse.json({ success: true, ...publicJob(existing) }); - } - - const job: QlikSyncJob = { - jobId: newJobId(), - fournisseur, - status: "running", - requested: 0, - fetched: 0, - upserted: 0, - startedAt: new Date().toISOString(), - }; - jobs.set(fournisseur, job); - console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`); - - // Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on - // renvoie tout de suite au client et il polle GET. Les erreurs sont - // capturées dans runJob et inscrites dans le job. - void runJob(job).catch((e) => { - // Filet de sécurité (runJob ne devrait pas throw, mais on ne veut - // jamais qu'une exception non capturée crashe le process). - const msg = e instanceof Error ? e.message : String(e); - console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg); - job.status = "error"; - job.error = msg; - job.finishedAt = new Date().toISOString(); - }); - - return NextResponse.json({ success: true, ...publicJob(job) }); -} diff --git a/src/app/api/test-db/route.ts b/src/app/api/test-db/route.ts deleted file mode 100644 index 35dbc20..0000000 --- a/src/app/api/test-db/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db } from '@/db'; -import { sql } from 'drizzle-orm'; -import { pgGetStockForCodeins } from '@/lib/pg-ff-client'; -import { adminOuReponse } from '@/lib/authz'; - -export async function GET(request: Request) { - const acces = await adminOuReponse(); - if (acces instanceof Response) return acces; - - try { - const { searchParams } = new URL(request.url); - const codein = searchParams.get('codein') || '334152'; - - const map = await pgGetStockForCodeins([codein]); - - const fouResult = await db.execute(sql` - SELECT - a.codein::text AS codein, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur - FROM articles a - LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 - LEFT JOIN fouident fi ON fi.code = af.code - WHERE a.codein::text = ${codein} - `); - - return NextResponse.json({ - stock: Object.fromEntries(map), - fou: fouResult.rows, - }); - } catch (e) { - // Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur. - console.error('[api/test-db]', e); - return NextResponse.json({ error: (e as Error).message }, { status: 500 }); - } -} diff --git a/src/app/api/v1/fournisseurs/route.ts b/src/app/api/v1/fournisseurs/route.ts deleted file mode 100644 index 44aba51..0000000 --- a/src/app/api/v1/fournisseurs/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { fournisseursQuerySchema } from "@/lib/api-schemas"; -import { listGridSuppliers } from "@/lib/grid-store"; -import { pgGetFournisseurs } from "@/lib/pg-ff-client"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/fournisseurs?search=…&withData=1 - * - * Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la - * fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de - * savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement. - * - * `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const { search, withData } = parsed.data; - - const snapshot = await listGridSuppliers(); - const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s])); - - if (withData === "1") { - const term = search?.toLowerCase(); - const data = snapshot - .filter((s) => !term - || s.codeFournisseur.toLowerCase().includes(term) - || (s.nomFournisseur ?? "").toLowerCase().includes(term)) - .map((s) => ({ - code: s.codeFournisseur, - nom: s.nomFournisseur, - hasSnapshot: true, - rowCount: s.rowCount, - computedAt: s.computedAt, - })); - return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } }); - } - - const fournisseurs = await pgGetFournisseurs(search); - const data = fournisseurs.map((f) => { - const snap = byCode.get(f.code); - return { - code: f.code, - nom: f.nom, - hasSnapshot: Boolean(snap), - rowCount: snap?.rowCount ?? 0, - computedAt: snap?.computedAt ?? null, - }; - }); - - return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } }); -} diff --git a/src/app/api/v1/gammes/route.ts b/src/app/api/v1/gammes/route.ts deleted file mode 100644 index 1c3d019..0000000 --- a/src/app/api/v1/gammes/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { gammesBatchBodySchema } from "@/lib/api-schemas"; -import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes. -export const maxDuration = 300; - -/** - * POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] } - * - * Propose la gamme de plusieurs articles d'un fournisseur en un appel. Les - * propositions restent **à valider** dans la Grille (cf. lib/api-gammes). - * Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404). - */ -export async function POST(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const corps = await lireCorpsJson(req); - if (corps instanceof Response) return corps; - const parsed = gammesBatchBodySchema.safeParse(corps); - if (!parsed.success) { - return fail("bad_request", "Corps de requête invalide.", parsed.error.issues); - } - const { fournisseur, changes, compute } = parsed.data; - - const res = await affecterGammes({ - codeFournisseur: fournisseur, - changes, - compute, - auteur: authCtx.subject, - }); - if (res instanceof Response) return res; - - return ok(res.resultats, { - meta: { - codeFournisseur: res.codeFournisseur, - nomFournisseur: res.nomFournisseur, - demandes: res.resultats.length, - modifies: res.resultats.filter((r) => r.modifie).length, - aValider: res.resultats.filter((r) => r.statut === "a_valider").length, - enregistreLe: new Date().toISOString(), - }, - }); -} diff --git a/src/app/api/v1/grid/route.ts b/src/app/api/v1/grid/route.ts deleted file mode 100644 index 5409d5b..0000000 --- a/src/app/api/v1/grid/route.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail, buildPagination } from "@/lib/api-response"; -import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas"; -import { queryGridRows, getGridFreshness } from "@/lib/grid-store"; -import { enrichRows } from "@/lib/api-enrich"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne -// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge. -export const maxDuration = 300; - -/** - * GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute= - * - * Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis - * l'instantané persisté (`grid_rows`). - * - * Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande** - * (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les - * fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le - * cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané - * au passage — les appels suivants repassent donc par le chemin rapide. - * - * `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const q = parsed.data; - - // Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide). - let freshness = await getGridFreshness(q.fournisseur); - let computedOnDemand = false; - - if (!freshness) { - if (q.compute === "0") { - return fail( - "not_ready", - `Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`, - { fournisseur: q.fournisseur }, - ); - } - - console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`); - try { - // magasin TOTAL : c'est la seule variante persistée par getProductRows, - // et ProductRow embarque déjà les ventilations par magasin. - await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg); - return fail( - "internal_error", - `Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`, - { fournisseur: q.fournisseur }, - ); - } - - freshness = await getGridFreshness(q.fournisseur); - if (!freshness) { - // Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou - // il n'a aucun article. À distinguer d'une panne. - return fail( - "not_found", - `Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`, - { fournisseur: q.fournisseur }, - ); - } - computedOnDemand = true; - } - - const filtres = { - codeFournisseur: q.fournisseur, - gamme: q.gamme, - code1: q.code1, - code2: q.code2, - code3: q.code3, - nomenclature: q.nomenclature, - search: q.search, - sort: toSortKey(q.sort), - order: q.order, - }; - - // Sans `limit`, la réponse peut porter tout un fournisseur — et certains en - // comptent plus de 130 000. Tout charger puis tout sérialiser d'un bloc ferait - // exploser la mémoire du processus : on diffuse par lots, à mémoire constante. - if (q.limit == null) { - return streamAllRows(req, filtres, q, freshness, computedOnDemand); - } - - const result = await queryGridRows({ ...filtres, page: q.page, limit: q.limit }); - - // Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich). - const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows; - - const pagination = buildPagination(q.page, q.limit, result.total); - - return ok( - rows.map((r) => pickFields(r, q.fields)), - { - pagination, - meta: { - fournisseur: q.fournisseur, - computedAt: result.computedAt, - snapshotComputedAt: freshness.computedAt, - snapshotRowCount: freshness.rowCount, - enrichi: q.enrich === "1", - /** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */ - computedOnDemand, - /** - * true = cette réponse contient **toutes** les lignes du fournisseur. - * Dit explicitement à un appelant — en particulier un agent — s'il - * peut s'arrêter là, au lieu de le laisser déduire d'une pagination - * qu'il risque d'ignorer. - */ - complet: !pagination.hasMore, - ...(pagination.hasMore - ? { - avertissement: - `Réponse partielle : ${rows.length} lignes sur ${result.total}, parce que « limit » a été précisé. ` - + `Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page=${q.page + 1}.`, - } - : {}), - }, - }, - ); -} - -/** Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. */ -const STREAM_BATCH = 1000; - -type Filtres = Parameters[0]; - -/** - * Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans - * jamais la construire entièrement en mémoire. - * - * La forme reste celle des autres réponses (`{ data, pagination, meta }`) : un - * client existant ne voit aucune différence, il reçoit simplement les octets au - * fil de l'eau. `pagination` et `meta` sont écrits en dernier, une fois le total - * connu — l'ordre des clés n'a aucune importance en JSON. - * - * Une erreur survenant après les premiers octets ne peut plus changer le code - * HTTP : on ferme alors le document avec `meta.erreur`, pour que l'appelant - * détecte une réponse tronquée au lieu de la croire complète. - */ -function streamAllRows( - req: NextRequest, - filtres: Filtres, - q: { enrich: string; fields?: string; fournisseur: string }, - freshness: { rowCount: number; computedAt: string | null }, - computedOnDemand: boolean, -): Response { - const encoder = new TextEncoder(); - - const stream = new ReadableStream({ - async start(controller) { - const write = (s: string) => controller.enqueue(encoder.encode(s)); - let envoyees = 0; - let total = 0; - let computedAt: string | null = null; - let erreur: string | null = null; - - write('{"data":['); - try { - for (let page = 1; ; page++) { - const lot = await queryGridRows({ ...filtres, page, limit: STREAM_BATCH }); - if (page === 1) { - total = lot.total; - computedAt = lot.computedAt; - } - if (lot.rows.length === 0) break; - - const enrichies = q.enrich === "1" ? await enrichRows(lot.rows) : lot.rows; - for (const r of enrichies) { - write((envoyees === 0 ? "" : ",") + JSON.stringify(pickFields(r, q.fields))); - envoyees++; - } - if (lot.rows.length < STREAM_BATCH) break; - } - } catch (e) { - erreur = e instanceof Error ? e.message : String(e); - console.error(`[api/v1/grid] diffusion interrompue pour ${q.fournisseur} après ${envoyees} lignes:`, erreur); - } - - const complet = erreur === null && envoyees === total; - write("]," + JSON.stringify({ - pagination: { page: 1, limit: envoyees, total, totalPages: 1, hasMore: !complet }, - meta: { - fournisseur: q.fournisseur, - computedAt, - snapshotComputedAt: freshness.computedAt, - snapshotRowCount: freshness.rowCount, - enrichi: q.enrich === "1", - computedOnDemand, - diffuse: true, - complet, - ...(erreur - ? { erreur: `Diffusion interrompue après ${envoyees} lignes sur ${total} : ${erreur}` } - : {}), - }, - }).slice(1)); - controller.close(); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - // Empêche un proxy de tamponner la réponse entière avant de la relayer. - "X-Accel-Buffering": "no", - }, - }); -} diff --git a/src/app/api/v1/network/[codeCentrale]/route.ts b/src/app/api/v1/network/[codeCentrale]/route.ts deleted file mode 100644 index ae715b1..0000000 --- a/src/app/api/v1/network/[codeCentrale]/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/network/:codeCentrale - * - * Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin, - * marge %) et sa courbe de quantités mensuelles sur 12 mois glissants. - * - * Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est - * déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne - * référençons pas), c'est la page Produits — bien plus lente, et volontairement - * séparé de cette API. - */ -export async function GET( - req: NextRequest, - ctx: { params: Promise<{ codeCentrale: string }> }, -) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const { codeCentrale } = await ctx.params; - if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis."); - - const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]); - const found = metrics.get(codeCentrale); - if (!found) { - return fail( - "not_found", - `Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`, - ); - } - - return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } }); -} diff --git a/src/app/api/v1/nomenclatures/route.ts b/src/app/api/v1/nomenclatures/route.ts deleted file mode 100644 index c760406..0000000 --- a/src/app/api/v1/nomenclatures/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { nomenclaturesQuerySchema } from "@/lib/api-schemas"; -import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=… - * - * Répartition des articles d'un fournisseur par poste de nomenclature, avec le - * nombre d'articles et le CA de chacun. - * - * C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent - * 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni - * pour une application. On liste d'abord les postes — quelques dizaines de - * lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste. - * - * Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) : - * niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte - * un préfixe pour descendre l'arborescence sans tout charger. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const { fournisseur, niveau, parent } = parsed.data; - - // Même distinction que /grid : « pas encore calculé » n'est pas « vide ». - const freshness = await getGridFreshness(fournisseur); - if (!freshness) { - return fail( - "not_ready", - `Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`, - { fournisseur }, - ); - } - - const postes = await listGridNomenclatures(fournisseur, niveau, parent); - const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0); - - return ok(postes, { - meta: { - fournisseur, - niveau, - parent: parent ?? null, - nbPostes: postes.length, - /** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */ - nbArticles, - snapshotRowCount: freshness.rowCount, - snapshotComputedAt: freshness.computedAt, - /** - * Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus. - * `nomenclature` filtre par préfixe et fonctionne aux trois niveaux. - */ - filtreGrid: "nomenclature", - }, - }); -} diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts deleted file mode 100644 index 3b2588d..0000000 --- a/src/app/api/v1/openapi.json/route.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { NextRequest } from "next/server"; -import { GRID_SORT_KEYS } from "@/lib/grid-store"; -import { GAMMES } from "@/lib/gammes"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow. - * - * **Volontairement accessible sans authentification.** Le document ne décrit que la - * structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée - * métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL, - * avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import - * impossible. - * - * `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les - * Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée. - */ -export async function GET(req: NextRequest) { - const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, ""); - const origin = configured || req.nextUrl.origin; - - const paginationParams = [ - { name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." }, - { name: "limit", in: "query", schema: { type: "integer", minimum: 1 }, description: "Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100." }, - ]; - const sortParams = [ - { name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." }, - { name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." }, - { - name: "fields", - in: "query", - schema: { type: "string" }, - description: - "Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). " - + "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les " - + "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.", - }, - { - name: "enrich", - in: "query", - schema: { type: "string", enum: ["0", "1"], default: "1" }, - description: - "1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur " - + "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.", - }, - ]; - - const codesGamme = GAMMES.map((g) => g.code); - const legendeGammes = GAMMES.map((g) => `${g.code} (${g.nom})`).join(", "); - const erreur = (description: string) => ({ - description, - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }); - - const spec = { - openapi: "3.1.0", - info: { - title: "CollectFlow API", - version: "1.0.0", - description: - "Données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, " - + "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n" - + "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies " - + "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule " - + "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. " - + "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n" - + "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache " - + "alimenté par une synchronisation séparée.\n\n" - + "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques " - + "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée " - + "(`codeGammeServeur`).\n\n" - + "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) " - + "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille " - + "comme une modification non enregistrée, pour voir quels produits changent de gamme, et " - + "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, " - + "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, " - + "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées.", - }, - servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }], - security: [{ ApiKeyAuth: [] }], - components: { - securitySchemes: { - ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" }, - }, - schemas: { - Error: { - type: "object", - properties: { - error: { - type: "object", - properties: { - code: { - type: "string", - enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"], - }, - message: { type: "string" }, - }, - }, - }, - }, - Pagination: { - type: "object", - properties: { - page: { type: "integer" }, - limit: { type: "integer" }, - total: { type: "integer" }, - totalPages: { type: "integer" }, - hasMore: { type: "boolean", description: "true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif." }, - }, - }, - Fournisseur: { - type: "object", - properties: { - code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." }, - nom: { type: ["string", "null"] }, - hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." }, - rowCount: { type: "integer" }, - computedAt: { type: ["string", "null"], format: "date-time" }, - }, - }, - NetworkMetrics: { - type: ["object", "null"], - description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.", - properties: { - caReseau: { type: "number" }, - qteReseau: { type: "number" }, - nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." }, - caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." }, - margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." }, - qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." }, - fetchedAt: { type: ["string", "null"], format: "date-time" }, - }, - }, - Product: { - type: "object", - description: - "Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous " - + "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine " - + "de champs). Utilisez `fields` pour n'en garder qu'une partie.", - properties: { - codein: { type: "string", description: "Identifiant interne de l'article." }, - codeFournisseur: { type: "string" }, - nomFournisseur: { type: "string" }, - libelle1: { type: "string", description: "Libellé de l'article." }, - gtin: { type: "string" }, - reference: { type: "string" }, - codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." }, - codeGammeServeur: { - type: ["string", "null"], - description: - "Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour " - + "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".", - }, - codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications validées dans CollectFlow comprises." }, - gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." }, - totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." }, - totalQuantite: { type: "number" }, - totalMarge: { type: "number" }, - tauxMarge: { type: "number" }, - stockActuel: { type: "number" }, - pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." }, - prixAchat: { type: "number" }, - prixVente: { type: "number" }, - derniereVente: { type: "string", format: "date" }, - nbJoursDerniereVente: { type: "integer" }, - sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." }, - stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." }, - receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." }, - sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." }, - stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." }, - receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." }, - qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." }, - network: { $ref: "#/components/schemas/NetworkMetrics" }, - trend: { $ref: "#/components/schemas/Trend" }, - }, - }, - ResultatGamme: { - type: "object", - properties: { - codein: { type: "string" }, - gamme: { type: "string", enum: codesGamme, description: "Gamme proposée." }, - gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de l'appel." }, - codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." }, - modifie: { type: "boolean", description: "true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée)." }, - statut: { type: "string", enum: ["a_valider", "inchangee"], description: "`a_valider` : en attente de validation dans la Grille." }, - }, - }, - Trend: { - type: ["object", "null"], - description: - "Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne " - + "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.", - properties: { - direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." }, - pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." }, - label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" }, - monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." }, - }, - }, - }, - }, - paths: { - "/fournisseurs": { - get: { - operationId: "listerFournisseurs", - summary: "Lister les fournisseurs", - description: - "Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données " - + "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.", - parameters: [ - { name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." }, - { name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." }, - ], - responses: { - "200": { - description: "Liste des fournisseurs", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/nomenclatures": { - get: { - operationId: "listerNomenclaturesFournisseur", - summary: "Postes de nomenclature d'un fournisseur", - description: - "Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre " - + "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : " - + "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), " - + "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` " - + "(code1, code2 ou code3).", - parameters: [ - { name: "fournisseur", in: "query", required: true, schema: { type: "string" } }, - { name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." }, - { name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." }, - ], - responses: { - "200": { - description: "Postes de nomenclature", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { - type: "array", - items: { - type: "object", - properties: { - code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." }, - libelle: { type: ["string", "null"] }, - nbArticles: { type: "integer" }, - totalCa: { type: "number" }, - totalQuantite: { type: "number" }, - }, - }, - }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/grid": { - get: { - operationId: "listerProduitsFournisseur", - summary: "Lister les produits d'un fournisseur", - description: - "Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques " - + "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été " - + "calculées, l'API les calcule à la demande (premier appel de quelques secondes, " - + "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas " - + "ou n'a aucun article — le signaler plutôt que de conclure à une panne.", - parameters: [ - { name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." }, - { name: "gamme", in: "query", schema: { type: "string" }, description: `Filtre sur la gamme (${codesGamme.join(", ")}).` }, - { name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." }, - { name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." }, - { name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." }, - { name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." }, - { name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." }, - { - name: "compute", - in: "query", - schema: { type: "string", enum: ["0", "1"], default: "1" }, - description: - "1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. " - + "0 : refuse le calcul et répond immédiatement 202 not_ready.", - }, - ...sortParams, - ...paginationParams, - ], - responses: { - "200": { - description: "Produits du fournisseur", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/Product" } }, - pagination: { $ref: "#/components/schemas/Pagination" }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "202": { description: "Données pas encore calculées (uniquement avec compute=0)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/products/search": { - get: { - operationId: "rechercherProduits", - summary: "Rechercher des produits, tous fournisseurs confondus", - description: - "Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans " - + "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.", - parameters: [ - { name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." }, - { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." }, - { name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." }, - ...sortParams, - ...paginationParams, - ], - responses: { - "200": { - description: "Produits correspondants", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/Product" } }, - pagination: { $ref: "#/components/schemas/Pagination" }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/products/{codein}": { - get: { - operationId: "obtenirProduit", - summary: "Fiche complète d'un produit", - description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.", - parameters: [ - { name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." }, - { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." }, - ], - responses: { - "200": { - description: "Fiche produit", - content: { - "application/json": { - schema: { - type: "object", - properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } }, - }, - }, - }, - }, - "404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/products/{codein}/gamme": { - put: { - operationId: "changerGammeProduit", - summary: "Proposer la gamme d'un produit (à valider)", - description: - "Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît " - + "comme une modification non enregistrée. Valeurs : " + legendeGammes + ". " - + "`fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs " - + "(réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. " - + "Pour plusieurs articles d'un même fournisseur, préférer changerGammes.", - parameters: [ - { name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." }, - ], - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - required: ["gamme"], - properties: { - gamme: { type: "string", enum: codesGamme }, - fournisseur: { type: "string", description: "Code fournisseur de l'article." }, - compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." }, - }, - }, - }, - }, - }, - responses: { - "200": { - description: "Gamme proposée, à valider", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { - allOf: [ - { $ref: "#/components/schemas/ResultatGamme" }, - { type: "object", properties: { codeFournisseur: { type: "string" }, nomFournisseur: { type: ["string", "null"] } } }, - ], - }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "400": erreur("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"), - "401": erreur("Clé d'API absente, invalide ou révoquée"), - "404": erreur("Article introuvable chez ce fournisseur"), - }, - }, - }, - "/gammes": { - post: { - operationId: "changerGammes", - summary: "Proposer la gamme de plusieurs produits (à valider)", - description: - "Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider " - + "dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé " - + "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. " - + "Valeurs : " + legendeGammes + ".", - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - required: ["fournisseur", "changes"], - properties: { - fournisseur: { type: "string", description: "Code fournisseur." }, - changes: { - type: "array", - minItems: 1, - maxItems: 10000, - items: { - type: "object", - required: ["codein", "gamme"], - properties: { - codein: { type: "string" }, - gamme: { type: "string", enum: codesGamme }, - }, - }, - }, - compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." }, - }, - }, - }, - }, - }, - responses: { - "200": { - description: "Gammes proposées, à valider", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/ResultatGamme" } }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "400": erreur("Corps de requête invalide"), - "401": erreur("Clé d'API absente, invalide ou révoquée"), - "404": erreur("Un ou plusieurs articles introuvables chez ce fournisseur"), - }, - }, - }, - "/network/{codeCentrale}": { - get: { - operationId: "obtenirMetriquesReseau", - summary: "Métriques du réseau Qlik pour un produit", - description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.", - parameters: [ - { name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." }, - ], - responses: { - "200": { - description: "Métriques réseau", - content: { - "application/json": { - schema: { - type: "object", - properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } }, - }, - }, - }, - }, - "404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - }, - }; - - // CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers. - return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } }); -} diff --git a/src/app/api/v1/products/[codein]/gamme/route.ts b/src/app/api/v1/products/[codein]/gamme/route.ts deleted file mode 100644 index 926ffc3..0000000 --- a/src/app/api/v1/products/[codein]/gamme/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { productGammeBodySchema } from "@/lib/api-schemas"; -import { listGridSuppliersForCodein } from "@/lib/grid-store"; -import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes. -export const maxDuration = 300; - -/** - * PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" } - * - * Propose une gamme pour un article. La proposition reste **à valider** : elle - * apparaît dans la Grille comme une modification non enregistrée, et `codeGamme` - * ne change qu'une fois validée (« Enregistrer » dans la Grille). - * - * `fournisseur` n'est requis que si l'article est référencé chez plusieurs - * fournisseurs, ou s'il n'apparaît pas encore dans l'instantané. - */ -export async function PUT( - req: NextRequest, - ctx: { params: Promise<{ codein: string }> }, -) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const { codein } = await ctx.params; - if (!codein) return fail("bad_request", "Paramètre 'codein' requis."); - - const corps = await lireCorpsJson(req); - if (corps instanceof Response) return corps; - const parsed = productGammeBodySchema.safeParse(corps); - if (!parsed.success) { - return fail("bad_request", "Corps de requête invalide.", parsed.error.issues); - } - const { gamme, compute } = parsed.data; - - let fournisseur = parsed.data.fournisseur; - if (!fournisseur) { - const fournisseurs = await listGridSuppliersForCodein(codein); - if (fournisseurs.length === 0) { - return fail( - "not_found", - `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande.`, - ); - } - if (fournisseurs.length > 1) { - return fail( - "bad_request", - `Le produit « ${codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur ».`, - { fournisseurs }, - ); - } - fournisseur = fournisseurs[0].codeFournisseur; - } - - const res = await affecterGammes({ - codeFournisseur: fournisseur, - changes: [{ codein, gamme }], - compute, - auteur: authCtx.subject, - }); - if (res instanceof Response) return res; - - return ok( - { ...res.resultats[0], codeFournisseur: res.codeFournisseur, nomFournisseur: res.nomFournisseur }, - { meta: { enregistreLe: new Date().toISOString() } }, - ); -} diff --git a/src/app/api/v1/products/[codein]/route.ts b/src/app/api/v1/products/[codein]/route.ts deleted file mode 100644 index d52cb5f..0000000 --- a/src/app/api/v1/products/[codein]/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { productDetailSchema } from "@/lib/api-schemas"; -import { getGridRowByCodein } from "@/lib/grid-store"; -import { enrichRows } from "@/lib/api-enrich"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes. -export const maxDuration = 300; - -/** - * GET /api/v1/products/:codein?fournisseur=… - * - * Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles, - * ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik - * lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik. - * - * `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ; - * sans lui, l'instantané le plus récent est renvoyé. - */ -export async function GET( - req: NextRequest, - ctx: { params: Promise<{ codein: string }> }, -) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const { codein } = await ctx.params; - if (!codein) return fail("bad_request", "Paramètre 'codein' requis."); - - const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - - let found = await getGridRowByCodein(codein, parsed.data.fournisseur); - - // Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a - // précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article. - if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) { - console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`); - try { - await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" }); - found = await getGridRowByCodein(codein, parsed.data.fournisseur); - } catch (e) { - console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e)); - } - } - - if (!found) { - return fail( - "not_found", - parsed.data.fournisseur - ? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».` - : `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`, - ); - } - - // Métriques réseau + gamme serveur relues maintenant : le payload date du dernier - // calcul de la grille, alors que ces deux données évoluent indépendamment. - const [enriched] = await enrichRows([found.row]); - - return ok(enriched, { - meta: { - computedAt: found.computedAt, - networkFetchedAt: enriched.network?.fetchedAt ?? null, - hasNetwork: enriched.network !== null, - }, - }); -} diff --git a/src/app/api/v1/products/search/route.ts b/src/app/api/v1/products/search/route.ts deleted file mode 100644 index 352a5f8..0000000 --- a/src/app/api/v1/products/search/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail, buildPagination } from "@/lib/api-response"; -import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas"; -import { queryGridRows } from "@/lib/grid-store"; -import { enrichRows } from "@/lib/api-enrich"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields= - * - * **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale — - * tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire : - * `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître - * était impossible. - * - * Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la - * Grille n'apparaît pas encore dans les résultats. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const q = parsed.data; - - const result = await queryGridRows({ - codeFournisseur: q.fournisseur, - gamme: q.gamme, - search: q.q, - sort: toSortKey(q.sort), - order: q.order, - page: q.page, - limit: q.limit, - }); - - // Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich). - const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows; - - return ok( - rows.map((r) => pickFields(r, q.fields)), - { - pagination: buildPagination(q.page, q.limit, result.total), - meta: { - query: q.q, - computedAt: result.computedAt, - scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs", - enrichi: q.enrich === "1", - }, - }, - ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index adec33a..0000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Metadata } from "next"; -import "./globals.css"; -import { Providers } from "./providers"; - -export const metadata: Metadata = { - title: { default: "CollectFlow", template: "%s · CollectFlow" }, - description: "Révision d'assortiment et suivi des ventes des magasins", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {/* Inter chargé côté navigateur (et non au build) pour éviter toute - dépendance réseau à Google Fonts pendant `next build` (Docker). */} - - - {/* eslint-disable-next-line @next/next/no-page-custom-font */} - - - - - {children} - - - - ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9ef1235..89ea8fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ -import { redirect } from "next/navigation"; +import { Navigate } from "react-router"; export default function HomePage() { - redirect("/dashboard"); + return ; } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 74e9a73..2959cfc 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,21 +1,24 @@ -"use client"; - import { SessionProvider } from "next-auth/react"; import { ThemeProvider } from "@/components/shared/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; import { FeedbackHost } from "@/components/ui/feedback"; +import { RefreshProvider } from "@/lib/page-data"; export function Providers({ children }: { children: React.ReactNode }) { return ( - // La session n'est pas relue à chaque retour sur l'onglet : elle repartait - // sur le réseau et redessinait toute la page (Grille comprise) à chaque fois. - - - - {children} - - - - + // Compteur de rafraîchissement (`router.refresh()`) : relu par les données + // des pages et par la session. + + {/* La session n'est pas relue à chaque retour sur l'onglet : elle repartait + sur le réseau et redessinait toute la page (Grille comprise) à chaque fois. */} + + + + {children} + + + + + ); } diff --git a/src/components/shared/admin-only.tsx b/src/components/shared/admin-only.tsx new file mode 100644 index 0000000..463a0a1 --- /dev/null +++ b/src/components/shared/admin-only.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router"; +import { useSessionResolue } from "next-auth/react"; + +/** + * Pages réservées aux administrateurs : le serveur filtre déjà l'accès direct + * (rôle relu en base) ; ici, la même règle pour une navigation dans + * l'application — retour à l'accueil sinon. + */ +export function AdminOnly({ children }: { children: ReactNode }) { + const session = useSessionResolue(); + if (session?.user.role !== "admin") return ; + return <>{children}; +} diff --git a/src/components/shared/header.tsx b/src/components/shared/header.tsx index 3a9bc0d..c591376 100644 --- a/src/components/shared/header.tsx +++ b/src/components/shared/header.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ChevronDown, LogOut, Moon, Rows3, Sun, User as UserIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useTheme } from "next-themes"; diff --git a/src/components/shared/route-error-boundary.tsx b/src/components/shared/route-error-boundary.tsx new file mode 100644 index 0000000..4810cb5 --- /dev/null +++ b/src/components/shared/route-error-boundary.tsx @@ -0,0 +1,42 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { refreshPage } from "@/lib/page-data"; + +interface Props { + /** Rendu à la place de la page en erreur ; `reset` relance son chargement. */ + fallback: (error: Error, reset: () => void) => ReactNode; + children: ReactNode; +} + +interface State { + error: Error | null; +} + +/** + * Frontière d'erreur d'une page (équivalent de `error.tsx`) : le menu et + * l'en-tête restent utilisables. Elle est remontée à chaque changement de page + * (clé posée par le routeur), comme celle de Next.js. + */ +export class RouteErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: unknown): State { + return { error: error instanceof Error ? error : new Error(String(error)) }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + void error; + void info; + } + + private reset = () => { + // Nouvelle clé de données (sans transition : la silhouette s'affiche), + // puis nouvel essai de rendu de la page. + refreshPage({ transition: false }); + this.setState({ error: null }); + }; + + render(): ReactNode { + if (this.state.error) return this.props.fallback(this.state.error, this.reset); + return this.props.children; + } +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index c355d18..0e068db 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ChevronLeft, ChevronRight, Package } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; diff --git a/src/components/shared/theme-provider.tsx b/src/components/shared/theme-provider.tsx index b7556ec..4d51e2f 100644 --- a/src/components/shared/theme-provider.tsx +++ b/src/components/shared/theme-provider.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ThemeProvider as NextThemesProvider } from "next-themes"; export function ThemeProvider({ children }: { children: React.ReactNode }) { diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index b12a722..9aea8bb 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * Tableau de données commun : recherche, filtres, tri par colonne, pagination, * export, état vide et ligne de totaux — identiques sur toutes les pages. diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index bf9459c..42c9ea8 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { XIcon } from "lucide-react" import { Dialog as DialogPrimitive } from "radix-ui" diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 1c9880c..ecc78c4 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" diff --git a/src/components/ui/feedback.tsx b/src/components/ui/feedback.tsx index 43214f0..b20b49d 100644 --- a/src/components/ui/feedback.tsx +++ b/src/components/ui/feedback.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * Retours à l'utilisateur, communs à toute l'application : * - `toast.succes / toast.erreur / toast.info` : message discret en bas à droite, diff --git a/src/components/ui/form-controls.tsx b/src/components/ui/form-controls.tsx index 8e9b6f8..eddc738 100644 --- a/src/components/ui/form-controls.tsx +++ b/src/components/ui/form-controls.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; import { Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx index 9ed0b15..ea471a7 100644 --- a/src/components/ui/pagination.tsx +++ b/src/components/ui/pagination.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; interface PaginationProps { diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index a74d3b3..efdcac2 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState, type ReactNode } from "react"; import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index a0f0a77..c2e5395 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { ReactNode } from "react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Info } from "lucide-react"; diff --git a/src/db/index.ts b/src/db/index.ts deleted file mode 100644 index a72a93a..0000000 --- a/src/db/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; -import * as schema from "./schema"; -import fs from "fs"; -import path from "path"; - -/** - * Pool unique par processus, rangé dans `globalThis` : l'instrumentation (synchro - * nocturne) et les routes peuvent être compilées dans des lots distincts, chacun - * avec sa propre copie de ce module — et donc son propre pool de 20 connexions. - * Même chose à chaque rechargement à chaud en développement. - */ -type DbState = { pool: Pool | null; db: NodePgDatabase | null }; -const globalForDb = globalThis as typeof globalThis & { __collectflowDb?: DbState }; -const state: DbState = (globalForDb.__collectflowDb ??= { pool: null, db: null }); - -export function getPool(): Pool { - getDb(); // ensure pool is initialized - return state.pool!; -} - -function maskUrl(url: string | undefined) { - if (!url) return "undefined"; - return url.replace(/:([^@]+)@/, ":****@"); -} - -/** - * Initializes or returns the current database instance. - */ -export function getDb() { - if (state.db) return state.db; - - let connectionString = ""; - - try { - const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); - if (fs.existsSync(CONFIG_PATH)) { - console.log("[DB] Found config file at:", CONFIG_PATH); - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); - if (config.url) { - connectionString = config.url; - console.log("[DB] Loaded saved database URL from config."); - } - } - } catch (err) { - console.error("[DB] Error reading config file:", err); - } - - // Use environment variable as fallback if no config is found - if (!connectionString && process.env.DATABASE_URL) { - connectionString = process.env.DATABASE_URL; - console.log("[DB] Using environment variable as fallback."); - } - - console.log("[DB] Initializing connection with:", maskUrl(connectionString)); - - if (!connectionString) { - console.error("[DB] CRITICAL: No database connection string available!"); - } - - const connectionHostname = connectionString.match(/@([^:/]+)/)?.[1] || "unknown"; - console.log(`[DB] Target Hostname: ${connectionHostname}`); - - // Délai maximal par requête : opt-in (les calculs nocturnes des plus gros - // fournisseurs peuvent être longs), pour qu'une requête emballée ne monopolise - // pas indéfiniment une des 20 connexions. - const statementTimeout = Number(process.env.PG_STATEMENT_TIMEOUT_MS) || undefined; - - const pool = new Pool({ - connectionString, - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 10000, - application_name: "collectflow", - ...(statementTimeout ? { statement_timeout: statementTimeout } : {}), - }); - - pool.on('error', (err) => { - console.error('[DB] Unexpected error on idle client', err); - }); - - // Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop petit - // dans le conteneur, et font échouer les grosses agrégations. Réglé une fois - // par connexion (la requête est mise en file avant toute autre sur ce client) - // au lieu d'une transaction BEGIN / SET LOCAL / COMMIT autour de chaque requête. - pool.on('connect', (client) => { - client.query("SET max_parallel_workers_per_gather = 0").catch((err) => { - console.error('[DB] SET max_parallel_workers_per_gather failed:', err?.message); - }); - }); - - state.pool = pool; - state.db = drizzle(pool, { schema }); - return state.db; -} - -/** - * Resets the current database connection. - * Called when settings are updated via the UI. - */ -export function refreshDb() { - console.log("[DB] Refreshing database connection..."); - if (state.pool) { - state.pool.end().catch(err => console.error("[DB] Error closing pool:", err)); - state.pool = null; - } - state.db = null; -} - -/** - * Proxy for the 'db' instance. - * Allows existing imports (import { db } from "@/db") to work - * while the underlying instance can be hot-swapped. - */ -export const db = new Proxy({} as NodePgDatabase, { - get(target, prop, receiver) { - const d = getDb(); - return Reflect.get(d, prop, receiver); - } -}); diff --git a/src/db/schema.ts b/src/db/schema.ts deleted file mode 100644 index a480879..0000000 --- a/src/db/schema.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core"; - -export const ventesProduits = pgTable("ventes_produits", { - id: serial("id").primaryKey(), - - // Identification produit - codein: varchar("codein", { length: 20 }).notNull(), - codeFournisseur: varchar("code_fournisseur", { length: 20 }), - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - libelle1: varchar("libelle1", { length: 500 }), - gtin: varchar("gtin", { length: 30 }), - reference: varchar("reference", { length: 100 }), - colisage: numeric("colisage", { precision: 10, scale: 5 }), - - // Gamme & Nomenclature - codeGamme: varchar("code_gamme", { length: 20 }), - codeGammeInit: varchar("code_gamme_init", { length: 20 }), - code3: varchar("code3", { length: 20 }), - libelle3: varchar("libelle3", { length: 500 }), - - // Dimension magasin & période - magasin: varchar("magasin", { length: 20 }).notNull(), - codeMagasin: varchar("code_magasin", { length: 10 }), - annee: smallint("annee"), - mois: smallint("mois"), - periode: varchar("periode", { length: 10 }).notNull(), - - // Métriques - quantite: numeric("quantite", { precision: 12, scale: 2 }), - montantMvt: numeric("montant_mvt", { precision: 14, scale: 4 }), - margeMvt: numeric("marge_mvt", { precision: 14, scale: 4 }), - - // Métadonnées - importedAt: timestamp("imported_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), -}, (table) => { - return [ - uniqueIndex("uq_ventes_produit_magasin_periode").on(table.codein, table.magasin, table.periode), - index("idx_ventes_codein").on(table.codein), - index("idx_ventes_fournisseur").on(table.codeFournisseur), - index("idx_ventes_gamme").on(table.codeGamme), - index("idx_ventes_code3").on(table.code3), - index("idx_ventes_magasin").on(table.magasin), - index("idx_ventes_periode").on(table.periode), - index("idx_ventes_annee_mois").on(table.annee, table.mois), - index("idx_ventes_updated_at").on(table.updatedAt), - ]; -}); - -/** User management (Epic 6) */ -export const users = pgTable("users", { - id: serial("id").primaryKey(), - username: varchar("username", { length: 50 }).notNull().unique(), - /** Hashed password */ - passwordHash: text("password_hash").notNull(), - /** 'admin' or 'user' */ - role: varchar("role", { length: 20 }).default("user").notNull(), - createdAt: timestamp("created_at").defaultNow(), -}); - -/** Snapshot of a complete arbitrage session (Epic 5) */ -export const sessionSnapshots = pgTable("session_snapshots", { - id: serial("id").primaryKey(), - /** Link to user who created the snapshot */ - userId: integer("user_id").references(() => users.id), - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - magasin: varchar("magasin", { length: 20 }).notNull(), - /** JSON map of codein → { before: GammeCode, after: GammeCode } */ - changes: jsonb("changes").notNull(), - /** Summary stats at time of snapshot */ - summaryJson: jsonb("summary_json"), - /** Label for the snapshot */ - label: text("label"), - /** snapshot vs export */ - type: varchar("type", { length: 20 }).default("snapshot"), - createdAt: timestamp("created_at").defaultNow(), -}, (table) => [ - // Créés par scripts/db-init.js (la migration réellement jouée au démarrage). - index("idx_snapshots_fournisseur_date").on(table.codeFournisseur, table.createdAt.desc()), - index("idx_snapshots_user_type_date").on(table.userId, table.type, table.createdAt.desc()), -]); - -/** - * Cadencier / alertes de commande par fournisseur ET par magasin. - * Un fournisseur peut être cadencé indépendamment sur chaque site (292/579) - * avec un intervalle propre (toutes les X semaines). L'échéance est calculée - * à partir de la dernière commande réelle (FF Nancy) + intervalle. - */ -export const commandeCadences = pgTable("commande_cadences", { - id: serial("id").primaryKey(), - /** Code fournisseur (fouident.code) */ - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - /** Nom fournisseur dénormalisé pour l'affichage */ - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - /** Site / magasin : "292" | "579" */ - site: varchar("site", { length: 20 }).notNull(), - /** Fréquence de commande, en semaines */ - intervalleSemaines: integer("intervalle_semaines").notNull(), - /** Cadence active ou non */ - actif: boolean("actif").default(true).notNull(), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), -}, (table) => { - return [ - uniqueIndex("uq_cadence_fou_site").on(table.codeFournisseur, table.site), - index("idx_cadence_site").on(table.site), - ]; -}); - -/** - * Cache des métriques réseau Qlik Sense (~270 magasins La Foir'Fouille). - * Clé = code centrale (format 10000XXXXXX). Rafraîchi par /api/qlik/sync. - */ -export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { - /** Code centrale article (clé jointure Qlik ↔ FF) */ - codeCentrale: varchar("code_centrale", { length: 20 }).primaryKey(), - /** CA réseau total du produit */ - caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }), - /** Quantité vendue réseau */ - qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }), - /** Nombre de magasins travaillant le produit (sur ~270) */ - nbMagasinsReseau: integer("nb_magasins_reseau"), - /** CA moyen par magasin (productivité normalisée présence) */ - caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }), - /** Taux de marge réseau (ratio brut Qlik) */ - margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }), - /** Période couverte (libre, ex "12m" ou "2025") */ - periode: varchar("periode", { length: 20 }), - /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */ - qteByMonth: jsonb("qte_by_month"), - /** - * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. - * Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne - * « Tendance / Réseau » de la Grille. - */ - metricsByMonth: jsonb("metrics_by_month"), - /** - * Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche - * produit (qui interroge la dimension libellé) ; la sync fournisseur ne - * l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le - * catalogue Nancy ne référence pas. - */ - libelleReseau: varchar("libelle_reseau", { length: 255 }), - /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ - fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }), - /** Dernière synchro depuis Qlik */ - fetchedAt: timestamp("fetched_at").defaultNow(), -}); - -/** - * Instantané persisté des lignes de grille (une ligne par fournisseur × article). - * - * Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde - * qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit - * jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord - * quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on - * arrête simplement de jeter le résultat. - * - * Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par - * magasin (sales12mByStore, caByStore…). - */ -export const gridRows = pgTable("grid_rows", { - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - codein: varchar("codein", { length: 20 }).notNull(), - - // Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL. - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - libelle1: varchar("libelle1", { length: 500 }), - gtin: varchar("gtin", { length: 30 }), - reference: varchar("reference", { length: 100 }), - codeCentrale: varchar("code_centrale", { length: 20 }), - code1: varchar("code1", { length: 20 }), - code2: varchar("code2", { length: 20 }), - code3: varchar("code3", { length: 20 }), - codeGamme: varchar("code_gamme", { length: 20 }), - codeGammeInit: varchar("code_gamme_init", { length: 20 }), - totalCa: numeric("total_ca", { precision: 16, scale: 2 }), - totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }), - totalMarge: numeric("total_marge", { precision: 16, scale: 2 }), - tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }), - stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }), - caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }), - qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }), - nbMagasinsReseau: integer("nb_magasins_reseau"), - caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }), - margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }), - - /** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */ - payload: jsonb("payload").notNull(), - /** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */ - computedAt: timestamp("computed_at").defaultNow().notNull(), -}, (table) => { - return [ - uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein), - index("idx_grid_rows_fournisseur").on(table.codeFournisseur), - index("idx_grid_rows_codein").on(table.codein), - index("idx_grid_rows_gamme").on(table.codeGamme), - index("idx_grid_rows_code_centrale").on(table.codeCentrale), - index("idx_grid_rows_total_ca").on(table.totalCa), - index("idx_grid_rows_libelle").on(table.libelle1), - ]; -}); - -/** - * Clés d'API pour les appelants non-navigateur (scripts, outils externes). - * - * Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois, - * à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`. - */ -export const apiKeys = pgTable("api_keys", { - id: serial("id").primaryKey(), - /** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */ - name: varchar("name", { length: 100 }).notNull(), - /** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */ - keyPrefix: varchar("key_prefix", { length: 20 }).notNull(), - /** SHA-256 hexadécimal de la clé complète. */ - keyHash: text("key_hash").notNull().unique(), - /** 'admin' ou 'user' — même sémantique que users.role. */ - role: varchar("role", { length: 20 }).default("user").notNull(), - createdBy: varchar("created_by", { length: 50 }), - createdAt: timestamp("created_at").defaultNow(), - lastUsedAt: timestamp("last_used_at"), - revokedAt: timestamp("revoked_at"), -}); - -/** - * Paramétrage et suivi de la synchronisation nocturne, un enregistrement par - * fournisseur. - * - * Deux sources aux coûts très différents, donc deux cadences distinctes : - * l'instantané SQL prend une minute au pire, l'extraction Qlik jusqu'à huit — - * et le serveur Qlik abandonne quand on le sollicite trop. D'où `actifSql` et - * `actifQlik` séparés, et deux dates de dernier passage. - * - * L'ordre de passage se déduit de `dernier*At` (le plus ancien d'abord) : la - * nuit suivante reprend donc naturellement là où la précédente s'est arrêtée, - * et repart d'elle-même pour un nouveau tour quand tout a été traité. Aucun - * curseur ni compteur de tour à maintenir. - */ -export const syncFournisseurs = pgTable("sync_fournisseurs", { - codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(), - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - /** Inclus dans la rotation de l'instantané SQL. */ - actifSql: boolean("actif_sql").default(true).notNull(), - /** Inclus dans la rotation Qlik (beaucoup plus coûteuse). */ - actifQlik: boolean("actif_qlik").default(true).notNull(), - /** - * Renseigné quand le planificateur a désactivé le fournisseur de lui-même — - * typiquement « aucun article ». Évite de réessayer chaque nuit un - * fournisseur vide, tout en gardant la raison visible pour le réactiver. - */ - desactiveMotif: text("desactive_motif"), - - dernierSqlAt: timestamp("dernier_sql_at"), - /** 'succes' | 'echec' | 'vide' */ - dernierSqlStatut: varchar("dernier_sql_statut", { length: 20 }), - dernierSqlLignes: integer("dernier_sql_lignes"), - dernierSqlErreur: text("dernier_sql_erreur"), - dernierSqlMs: integer("dernier_sql_ms"), - - dernierQlikAt: timestamp("dernier_qlik_at"), - dernierQlikStatut: varchar("dernier_qlik_statut", { length: 20 }), - dernierQlikCodes: integer("dernier_qlik_codes"), - dernierQlikErreur: text("dernier_qlik_erreur"), - dernierQlikMs: integer("dernier_qlik_ms"), - - updatedAt: timestamp("updated_at").defaultNow(), -}, (table) => { - return [ - // Index de file d'attente : « le plus ancien d'abord », par source. - index("idx_sync_fou_sql").on(table.actifSql, table.dernierSqlAt), - index("idx_sync_fou_qlik").on(table.actifQlik, table.dernierQlikAt), - ]; -}); - -/** AI Context rules per supplier (Epic: AI Context) */ -export const aiSupplierContext = pgTable("ai_supplier_context", { - /** Supplier code serving as the primary key */ - codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(), - /** The business rules text provided by the user */ - context: text("context").notNull(), - /** When it was last updated */ - updatedAt: timestamp("updated_at").defaultNow(), -}); - -/** - * Gammes proposées par l'API `/api/v1`, en attente de validation. - * - * L'API n'enregistre plus directement : chaque proposition attend ici qu'un - * utilisateur l'examine dans la Grille, où elle apparaît comme une modification - * non enregistrée. « Enregistrer » la valide (snapshot + `grid_rows`), « Annuler - * les modifications » la rejette ; dans les deux cas la ligne est supprimée. - * Une seule proposition par article et fournisseur : la dernière l'emporte. - */ -export const gammesAValider = pgTable("gammes_a_valider", { - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - codein: varchar("codein", { length: 20 }).notNull(), - /** Gamme proposée. */ - gamme: varchar("gamme", { length: 20 }).notNull(), - /** Gamme enregistrée au moment de la proposition. */ - gammePrecedente: varchar("gamme_precedente", { length: 20 }), - /** Nom de la clé d'API à l'origine de la proposition. */ - auteur: varchar("auteur", { length: 100 }), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => [ - primaryKey({ columns: [table.codeFournisseur, table.codein] }), -]); diff --git a/src/features/admin/api/api-key-actions.ts b/src/features/admin/api/api-key-actions.ts index 4277f26..5e068a4 100644 --- a/src/features/admin/api/api-key-actions.ts +++ b/src/features/admin/api/api-key-actions.ts @@ -1,19 +1,14 @@ -"use server"; - /** * CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs). * * La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et * jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée * conserve sa ligne, pour garder la trace de son usage passé. + * + * Actions exécutées par le serveur Rust : `POST /api/actions/{nom}`. */ -import { db } from "@/db"; -import { apiKeys } from "@/db/schema"; -import { desc, eq } from "drizzle-orm"; -import { requireAdmin } from "@/lib/authz"; -import { generateApiKey } from "@/lib/api-auth"; -import { revalidatePath } from "next/cache"; +import { callAction } from "@/lib/rpc"; export interface ApiKeyRow { id: number; @@ -28,27 +23,7 @@ export interface ApiKeyRow { /** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */ export async function getApiKeys(): Promise { - await requireAdmin(); - const rows = await db - .select({ - id: apiKeys.id, - name: apiKeys.name, - keyPrefix: apiKeys.keyPrefix, - role: apiKeys.role, - createdBy: apiKeys.createdBy, - createdAt: apiKeys.createdAt, - lastUsedAt: apiKeys.lastUsedAt, - revokedAt: apiKeys.revokedAt, - }) - .from(apiKeys) - .orderBy(desc(apiKeys.createdAt)); - - return rows.map((r) => ({ - ...r, - createdAt: r.createdAt ? r.createdAt.toISOString() : null, - lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null, - revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null, - })); + return callAction("getApiKeys", []); } /** @@ -59,51 +34,15 @@ export async function createApiKey( name: string, role: "admin" | "user" = "user", ): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> { - const moi = await requireAdmin(); - - const trimmed = name.trim(); - if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." }; - if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." }; - - try { - const { key, keyHash, keyPrefix } = generateApiKey(); - await db.insert(apiKeys).values({ - name: trimmed, - keyPrefix, - keyHash, - role, - createdBy: moi.username || null, - }); - revalidatePath("/settings"); - return { success: true, key, keyPrefix }; - } catch (err) { - console.error("[api-keys] création KO:", err); - return { success: false, error: "Erreur technique lors de la création de la clé." }; - } + return callAction("createApiKey", [name, role]); } /** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */ export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> { - await requireAdmin(); - try { - await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id)); - revalidatePath("/settings"); - return { success: true }; - } catch (err) { - console.error("[api-keys] révocation KO:", err); - return { success: false, error: "Erreur lors de la révocation." }; - } + return callAction("revokeApiKey", [id]); } /** Supprime définitivement une clé révoquée (nettoyage de la liste). */ export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> { - await requireAdmin(); - try { - await db.delete(apiKeys).where(eq(apiKeys.id, id)); - revalidatePath("/settings"); - return { success: true }; - } catch (err) { - console.error("[api-keys] suppression KO:", err); - return { success: false, error: "Erreur lors de la suppression." }; - } + return callAction("deleteApiKey", [id]); } diff --git a/src/features/admin/api/sync-scheduler.ts b/src/features/admin/api/sync-scheduler.ts deleted file mode 100644 index 7d80a44..0000000 --- a/src/features/admin/api/sync-scheduler.ts +++ /dev/null @@ -1,387 +0,0 @@ -/** - * CollectFlow — Synchronisation nocturne des fournisseurs. - * - * Deux sources aux coûts très différents, donc deux files distinctes : - * • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros - * fournisseur du catalogue. Alimente la Grille et `/api/v1`. - * • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un - * serveur externe qui abandonne quand on le sollicite trop. Ce sont des - * agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien. - * - * ORDRE DE PASSAGE — le point de conception central. La file est simplement - * « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture - * de la fenêtre courante. Il en découle, sans curseur ni compteur de tour : - * • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les - * plus anciens) ; - * • quand plus personne n'est en attente, le tour est fini ; la nuit suivante - * tout le monde redevient éligible et le tour recommence ; - * • un fournisseur en échec garde une date ancienne, donc il repasse en tête — - * le rattrapage est automatique. - * - * La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie - * « ne plus en **démarrer** après l'heure de fin ». - */ - -import "server-only"; - -import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm"; -import { db } from "@/db"; -import { syncFournisseurs } from "@/db/schema"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; -import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; -import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; -import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; -import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings"; -import { invalidateFFCache } from "@/lib/ff-cache"; - -export type { SyncSettings }; - -// ─── État courant, en mémoire ──────────────────────────────────────────────── - -export interface SyncSchedulerState { - enCours: boolean; - /** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */ - origine: "auto" | "manuel" | null; - debutAt: string | null; - finAt: string | null; - fournisseurCourant: string | null; - etape: "sql" | "qlik" | null; - sqlFaits: number; - sqlEchecs: number; - qlikFaits: number; - qlikEchecs: number; - /** Renseigné quand la file s'est vidée : le tour est bouclé. */ - tourTermine: boolean; - arretDemande: boolean; - derniereErreur: string | null; -} - -const etat: SyncSchedulerState = { - enCours: false, origine: null, debutAt: null, finAt: null, - fournisseurCourant: null, etape: null, - sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0, - tourTermine: false, arretDemande: false, derniereErreur: null, -}; - -export function getSyncSchedulerState(): SyncSchedulerState { - return { ...etat }; -} - -export function demanderArret(): void { - if (etat.enCours) etat.arretDemande = true; -} - -// ─── Fenêtre horaire ───────────────────────────────────────────────────────── - -/** - * Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors. - * - * Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure - * de fin, la fenêtre a commencé **la veille**. - */ -export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null { - const h = maintenant.getHours(); - const debutDuJour = new Date(maintenant); - debutDuJour.setHours(s.heureDebut, 0, 0, 0); - - if (s.heureDebut < s.heureFin) { - return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null; - } - // Fenêtre à cheval sur minuit. - if (h >= s.heureDebut) return debutDuJour; - if (h < s.heureFin) { - const veille = new Date(debutDuJour); - veille.setDate(veille.getDate() - 1); - return veille; - } - return null; -} - -// ─── Référentiel des fournisseurs ──────────────────────────────────────────── - -/** - * Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux - * fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien — - * l'historique de synchronisation d'un fournisseur disparu reste consultable. - */ -export async function seedSyncFournisseurs(): Promise { - const fournisseurs = await pgGetFournisseurs(); - if (fournisseurs.length === 0) return 0; - - const CHUNK = 500; - for (let i = 0; i < fournisseurs.length; i += CHUNK) { - const lot = fournisseurs.slice(i, i + CHUNK); - await db - .insert(syncFournisseurs) - .values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom }))) - .onConflictDoUpdate({ - target: syncFournisseurs.codeFournisseur, - // Seul le nom est réaligné : les choix d'activation appartiennent à l'admin. - set: { nomFournisseur: sql`excluded.nom_fournisseur` }, - }); - } - return fournisseurs.length; -} - -// ─── Files d'attente ───────────────────────────────────────────────────────── - -/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */ -async function prochainSql(depuis: Date) { - const [row] = await db - .select() - .from(syncFournisseurs) - .where(and( - eq(syncFournisseurs.actifSql, true), - or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL, - )) - .orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`)) - .limit(1); - return row ?? null; -} - -/** Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. */ -const QLIK_REPRISE_ECHEC_MS = 18 * 3600 * 1000; - -/** - * Le plus ancien fournisseur Qlik à rafraîchir. Il l'est si : - * - ses données dépassent `qlikMinJours` (agrégats mensuels : inutile de - * relancer chaque nuit une extraction de plusieurs minutes) ; - * - ou elles datent d'avant le mois en cours : la fenêtre de 12 mois a glissé, - * son dernier mois manque, sa tendance n'est plus à jour ; - * - ou sa dernière extraction a échoué il y a plus de 18 h. Un échec attendait - * jusqu'ici `qlikMinJours` (7 jours) avant un nouvel essai, alors que la - * plupart sont passagers (serveur Qlik chargé, dernier mois pas encore - * chargé). Le plus ancien passant en premier, un fournisseur qui échoue à - * chaque fois repart en fin de file et ne bloque personne. - */ -async function prochainQlik(qlikMinJours: number, maintenant: Date) { - const seuilAge = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000); - const debutMois = new Date(maintenant.getFullYear(), maintenant.getMonth(), 1); - const seuil = seuilAge > debutMois ? seuilAge : debutMois; - const seuilEchec = new Date(maintenant.getTime() - QLIK_REPRISE_ECHEC_MS); - const [row] = await db - .select() - .from(syncFournisseurs) - .where(and( - eq(syncFournisseurs.actifQlik, true), - or( - isNull(syncFournisseurs.dernierQlikAt), - lt(syncFournisseurs.dernierQlikAt, seuil), - and(eq(syncFournisseurs.dernierQlikStatut, "echec"), lt(syncFournisseurs.dernierQlikAt, seuilEchec)), - ) as SQL, - )) - .orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`)) - .limit(1); - return row ?? null; -} - -// ─── Traitement d'un fournisseur ───────────────────────────────────────────── - -/** - * Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans - * cela, un fournisseur qui échoue resterait éternellement le plus ancien et - * bloquerait la file derrière lui. - */ -async function traiterSql(code: string, nom: string | null): Promise { - const t0 = Date.now(); - etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`; - etat.etape = "sql"; - try { - const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true }); - const vide = rows.length === 0; - await db.update(syncFournisseurs).set({ - dernierSqlAt: new Date(), - dernierSqlStatut: vide ? "vide" : "succes", - dernierSqlLignes: rows.length, - dernierSqlErreur: null, - dernierSqlMs: Date.now() - t0, - // Un fournisseur sans article n'a rien à donner : on le sort des deux - // files plutôt que de le réessayer chaque nuit. L'admin voit le motif - // et peut le réactiver. - ...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}), - updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)); - if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`); - else etat.sqlFaits++; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - etat.sqlEchecs++; - etat.derniereErreur = `${code} (SQL) : ${msg}`; - console.error(`[sync-nuit] SQL ${code} en échec :`, msg); - await db.update(syncFournisseurs).set({ - dernierSqlAt: new Date(), - dernierSqlStatut: "echec", - dernierSqlErreur: msg.slice(0, 500), - dernierSqlMs: Date.now() - t0, - updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ }); - } -} - -/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */ -const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i; - -/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */ -async function traiterQlik(code: string, nom: string | null): Promise { - const t0 = Date.now(); - etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`; - etat.etape = "qlik"; - try { - const articles = await pgGetArticlesByFournisseur(code); - const codes = [...new Set( - articles - .map((a) => String(a.codeCentrale ?? "").trim()) - .filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)), - )]; - - if (codes.length === 0) { - await db.update(syncFournisseurs).set({ - dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0, - dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)); - return; - } - - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT)); - const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code); - const n = await upsertNetworkMetrics([...metrics.values()]); - - await db.update(syncFournisseurs).set({ - dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n, - dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)); - etat.qlikFaits++; - console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - etat.qlikEchecs++; - etat.derniereErreur = `${code} (Qlik) : ${msg}`; - console.error(`[sync-nuit] Qlik ${code} en échec :`, msg); - await db.update(syncFournisseurs).set({ - dernierQlikAt: new Date(), dernierQlikStatut: "echec", - dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ }); - } -} - -// ─── Boucle de travail ─────────────────────────────────────────────────────── - -/** - * Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt. - * - * Les deux files avancent en parallèle : une extraction Qlik est intercalée - * tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne - * démarrerait jamais tant que la file SQL n'est pas vide. - */ -export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise { - if (etat.enCours) return; - - const s = await readSyncSettings(); - const maintenant = new Date(); - const debut = debutFenetre(s, maintenant); - if (!forcerHorsFenetre && !debut) return; - // Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ». - const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0)); - - Object.assign(etat, { - enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null, - fournisseurCourant: null, etape: null, - sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0, - tourTermine: false, arretDemande: false, derniereErreur: null, - }); - console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`); - - try { - await seedSyncFournisseurs(); - - // Garde anti-boucle. L'avancement de la file repose sur l'écriture de - // `dernier*At`, et cette écriture est volontairement non bloquante en cas - // d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et - // serait repris sans fin : on retient donc ce qui a déjà été tenté. - const tentes = new Set(); - - let depuisDernierQlik = 0; - for (;;) { - if (etat.arretDemande) break; - if (!forcerHorsFenetre && !debutFenetre(s, new Date())) { - console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours"); - break; - } - - const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit; - - // Qlik prioritaire quand le quota d'entrelacement est atteint. - if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) { - const cible = await prochainQlik(s.qlikMinJours, new Date()); - if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) { - tentes.add(`qlik:${cible.codeFournisseur}`); - await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); - depuisDernierQlik = 0; - continue; - } - } - - const cibleSql = await prochainSql(depuis); - if (cibleSql) { - if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) { - console.error( - `[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :` - + " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.", - ); - break; - } - tentes.add(`sql:${cibleSql.codeFournisseur}`); - await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur); - depuisDernierQlik++; - continue; - } - - // File SQL vide : on consacre le temps restant à Qlik. - if (qlikPossible) { - const cible = await prochainQlik(s.qlikMinJours, new Date()); - if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) { - tentes.add(`qlik:${cible.codeFournisseur}`); - await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); - depuisDernierQlik = 0; - continue; - } - } - - etat.tourTermine = true; - console.log("[sync-nuit] plus rien en attente — tour terminé"); - break; - } - } catch (e) { - etat.derniereErreur = e instanceof Error ? e.message : String(e); - console.error("[sync-nuit] round interrompu :", etat.derniereErreur); - } finally { - // Les pages (stock, hit-parade, CA…) relisent la base fraîche au lieu - // d'attendre la fin de vie de leur cache. - invalidateFFCache(); - etat.enCours = false; - etat.fournisseurCourant = null; - etat.etape = null; - etat.finAt = new Date().toISOString(); - console.log( - `[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,` - + ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`, - ); - } -} - -/** - * Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais - * deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé - * chaque minute. - */ -export async function tickScheduler(): Promise { - try { - const s = await readSyncSettings(); - if (!s.actif || etat.enCours) return; - if (!debutFenetre(s)) return; - await lancerRound("auto"); - } catch (e) { - console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e)); - } -} diff --git a/src/features/admin/api/sync-settings.ts b/src/features/admin/api/sync-settings.ts deleted file mode 100644 index 541dbac..0000000 --- a/src/features/admin/api/sync-settings.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * CollectFlow — Réglages de la synchronisation nocturne. - * - * Rangés dans `data/.db-config.json`, comme les autres réglages de - * l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à - * sauvegarder et à monter dans le conteneur. - * - * Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs - * (les défauts, le type), ce que `"use server"` interdit. - */ - -import "server-only"; - -import fs from "fs/promises"; -import path from "path"; - -const DATA_DIR = path.join(process.cwd(), "data"); -const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json"); - -export interface SyncSettings { - /** - * Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter - * Qlik et la base la nuit suivante sans que personne l'ait décidé. - */ - actif: boolean; - /** Heure d'ouverture de la fenêtre (0-23). */ - heureDebut: number; - /** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */ - heureFin: number; - /** - * Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes - * et le serveur abandonne quand il est trop sollicité : ce plafond est la - * principale protection contre une nuit entièrement passée sur Qlik. - */ - qlikParNuit: number; - /** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */ - sqlParQlik: number; - /** - * Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats - * mensuels : les rafraîchir toutes les nuits coûterait cher pour rien. - */ - qlikMinJours: number; -} - -export const SYNC_SETTINGS_DEFAUT: SyncSettings = { - actif: false, - heureDebut: 0, - heureFin: 5, - qlikParNuit: 6, - sqlParQlik: 20, - qlikMinJours: 7, -}; - -function borne(v: unknown, min: number, max: number, defaut: number): number { - const n = Number(v); - if (!Number.isFinite(n)) return defaut; - return Math.min(max, Math.max(min, Math.trunc(n))); -} - -/** Normalise ce qui vient du fichier : il est éditable à la main. */ -function normaliser(brut: Partial | undefined): SyncSettings { - const d = SYNC_SETTINGS_DEFAUT; - const s: SyncSettings = { - actif: Boolean(brut?.actif), - heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut), - heureFin: borne(brut?.heureFin, 0, 23, d.heureFin), - qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit), - sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik), - qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours), - }; - // Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut - // plutôt que de laisser le planificateur muet sans explication. - if (s.heureDebut === s.heureFin) { - s.heureDebut = d.heureDebut; - s.heureFin = d.heureFin; - } - return s; -} - -export async function readSyncSettings(): Promise { - try { - const raw = await fs.readFile(CONFIG_FILE, "utf-8"); - const cfg = JSON.parse(raw) as { syncNuit?: Partial }; - return normaliser(cfg?.syncNuit); - } catch { - return { ...SYNC_SETTINGS_DEFAUT }; - } -} - -export async function saveSyncSettings(patch: Partial): Promise { - let existant: Record = {}; - try { - existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8")); - } catch { - // Pas encore de fichier : on le crée avec les seuls réglages de synchro. - } - const courant = normaliser((existant.syncNuit as Partial) ?? undefined); - const suivant = normaliser({ ...courant, ...patch }); - - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2)); - console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`); - return suivant; -} diff --git a/src/features/admin/api/sync-timer.ts b/src/features/admin/api/sync-timer.ts deleted file mode 100644 index ae1acd6..0000000 --- a/src/features/admin/api/sync-timer.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * CollectFlow — Minuteur de la synchronisation nocturne. - * - * Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute - * et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors - * fenêtre, ou si un round tourne déjà, le battement ne coûte rien. - * - * Conséquence assumée du choix « minuteur interne » plutôt que cron système : - * rien ne tourne si l'application est arrêtée. En contrepartie, aucune - * configuration côté hôte, et le minuteur se réarme tout seul au redémarrage. - */ - -import "server-only"; - -import { tickScheduler } from "@/features/admin/api/sync-scheduler"; - -const INTERVALLE_MS = 60_000; - -/** - * Garde au niveau du module : en développement, le rechargement à chaud peut - * réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de - * minuteurs empilés. - */ -let minuteur: ReturnType | null = null; - -export function startSyncTimer(): void { - if (minuteur) return; - - console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`); - minuteur = setInterval(() => { - // `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre - // qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non - // gérée capable d'abattre le processus. - void tickScheduler().catch((e) => { - console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e)); - }); - }, INTERVALLE_MS); - - // Ne pas maintenir le processus en vie uniquement pour ce minuteur. - minuteur.unref?.(); -} diff --git a/src/features/admin/api/user-actions.ts b/src/features/admin/api/user-actions.ts index f7a4e89..389957b 100644 --- a/src/features/admin/api/user-actions.ts +++ b/src/features/admin/api/user-actions.ts @@ -1,74 +1,46 @@ -"use server"; +/** + * Gestion des comptes (administrateurs) — actions exécutées par le serveur + * Rust : `POST /api/actions/{nom}`. + */ -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { hashPassword } from "@/features/auth/logic/auth-logic"; -import { requireAdmin } from "@/lib/authz"; -import { revalidatePath } from "next/cache"; +import { callAction } from "@/lib/rpc"; + +export interface UtilisateurListe { + id: number; + username: string; + role: string; + /** Date ISO de création. */ + createdAt: string | null; +} /** * Récupère tous les utilisateurs (Admin seulement). */ -export async function getUsers() { - await requireAdmin(); - return db.select({ - id: users.id, - username: users.username, - role: users.role, - createdAt: users.createdAt - }).from(users); +export async function getUsers(): Promise { + return callAction("getUsers", []); } /** * Crée un nouvel utilisateur. */ -export async function createUser(username: string, password: string, role: "admin" | "user" = "user") { - await requireAdmin(); - - try { - await db.insert(users).values({ - username, - passwordHash: hashPassword(password), - role, - }); - revalidatePath("/settings"); - return { success: true }; - } catch (err) { - console.error("CreateUser Error:", err); - return { success: false, error: "L'utilisateur existe déjà ou une erreur technique est survenue." }; - } +export async function createUser( + username: string, + password: string, + role: "admin" | "user" = "user", +): Promise<{ success: boolean; error?: string }> { + return callAction("createUser", [username, password, role]); } /** * Supprime un utilisateur. */ -export async function deleteUser(id: number) { - const moi = await requireAdmin(); - if (Number(moi.id) === id) { - return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." }; - } - - try { - await db.delete(users).where(eq(users.id, id)); - revalidatePath("/settings"); - return { success: true }; - } catch { - return { success: false, error: "Erreur lors de la suppression." }; - } +export async function deleteUser(id: number): Promise<{ success: boolean; error?: string }> { + return callAction("deleteUser", [id]); } /** * Met à jour le mot de passe d'un utilisateur. */ -export async function updatePassword(id: number, newPassword: string) { - await requireAdmin(); - try { - await db.update(users) - .set({ passwordHash: hashPassword(newPassword) }) - .where(eq(users.id, id)); - return { success: true }; - } catch { - return { success: false, error: "Erreur lors de la mise à jour du mot de passe." }; - } +export async function updatePassword(id: number, newPassword: string): Promise<{ success: boolean; error?: string }> { + return callAction("updatePassword", [id, newPassword]); } diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx index 7aee6bd..643637b 100644 --- a/src/features/admin/components/api-connection-info.tsx +++ b/src/features/admin/components/api-connection-info.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Informations de connexion à l'API `/api/v1`. * diff --git a/src/features/admin/components/api-key-management.tsx b/src/features/admin/components/api-key-management.tsx index dcc6335..8dfd917 100644 --- a/src/features/admin/components/api-key-management.tsx +++ b/src/features/admin/components/api-key-management.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Gestion des clés de l'API `/api/v1`. * diff --git a/src/features/admin/components/grid-warmup.tsx b/src/features/admin/components/grid-warmup.tsx index 614ac3e..73b1fe4 100644 --- a/src/features/admin/components/grid-warmup.tsx +++ b/src/features/admin/components/grid-warmup.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Préchauffage des données exposées par l'API. * diff --git a/src/features/admin/components/user-management.tsx b/src/features/admin/components/user-management.tsx index 13b9d48..75429e0 100644 --- a/src/features/admin/components/user-management.tsx +++ b/src/features/admin/components/user-management.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState, type FormEvent } from "react"; import { KeyRound, Loader2, Shield, Trash2, User, UserPlus } from "lucide-react"; import { getUsers, createUser, deleteUser, updatePassword } from "../api/user-actions"; diff --git a/src/features/auth/api/login-action.ts b/src/features/auth/api/login-action.ts deleted file mode 100644 index 9b514f9..0000000 --- a/src/features/auth/api/login-action.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; - -import { signIn } from "@/lib/auth"; -import { AuthError } from "next-auth"; - -/** - * Server action to handle user login. - */ -export async function loginAction(prevState: string | undefined, formData: FormData) { - try { - await signIn("credentials", { - username: formData.get("username"), - password: formData.get("password"), - redirectTo: "/dashboard" - }); - } catch (error) { - if (error instanceof AuthError) { - switch (error.type) { - case "CredentialsSignin": - return "Utilisateur ou mot de passe incorrect."; - default: - return "Une erreur technique est survenue."; - } - } - // Next.js redirect throws a special error that must be rethrown - throw error; - } -} diff --git a/src/features/auth/components/login-form.tsx b/src/features/auth/components/login-form.tsx index bd220d5..8aaab14 100644 --- a/src/features/auth/components/login-form.tsx +++ b/src/features/auth/components/login-form.tsx @@ -1,11 +1,42 @@ -"use client"; - import { useActionState } from "react"; -import { loginAction } from "../api/login-action"; import { AlertCircle, Loader2, Lock, ShieldCheck, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/form-controls"; +const MESSAGE_IDENTIFIANTS = "Utilisateur ou mot de passe incorrect."; +const MESSAGE_TECHNIQUE = "Une erreur technique est survenue."; + +/** + * Connexion : `POST /api/auth/login` (le serveur pose le cookie de session), + * puis chargement complet du tableau de bord pour repartir avec la session. + * Renvoie le message d'erreur à afficher, ou ne rend jamais la main en cas de + * réussite (le bouton reste « Connexion… » jusqu'au changement de page). + */ +async function loginAction(_prevState: string | undefined, formData: FormData): Promise { + let res: Response; + try { + res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + username: String(formData.get("username") ?? ""), + password: String(formData.get("password") ?? ""), + }), + }); + } catch { + return MESSAGE_TECHNIQUE; + } + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: unknown } | null; + if (typeof body?.error === "string" && body.error) return body.error; + return res.status === 401 ? MESSAGE_IDENTIFIANTS : MESSAGE_TECHNIQUE; + } + await res.text().catch(() => ""); + window.location.assign("/dashboard"); + return new Promise(() => {}); +} + export function LoginForm() { const [error, action, isPending] = useActionState(loginAction, undefined); diff --git a/src/features/auth/logic/auth-logic.ts b/src/features/auth/logic/auth-logic.ts deleted file mode 100644 index 100b79c..0000000 --- a/src/features/auth/logic/auth-logic.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { scryptSync, randomBytes, timingSafeEqual } from "crypto"; -import fs from "fs"; -import path from "path"; - -const USERS_JSON_PATH = path.join(process.cwd(), "data", "users.json"); - -export interface UserJson { - id: string; - username: string; - passwordHash: string; - role: string; -} - -/** - * Hashes a password using scrypt. - * Format: salt:hash - */ -export function hashPassword(password: string): string { - const salt = randomBytes(16).toString("hex"); - const hash = scryptSync(password, salt, 64).toString("hex"); - return `${salt}:${hash}`; -} - -/** - * Verifies a password against a stored salt:hash string. - */ -export function verifyPassword(password: string, storedHash: string): boolean { - try { - const [salt, hash] = storedHash.split(":"); - if (!salt || !hash) return false; - - const hashBuffer = scryptSync(password, salt, 64); - const storedHashBuffer = Buffer.from(hash, "hex"); - - return timingSafeEqual(hashBuffer, storedHashBuffer); - } catch (e) { - return false; - } -} - -/** - * Reads users from local JSON fallback. - */ -export function getFallbackUsers(): UserJson[] { - try { - if (!fs.existsSync(USERS_JSON_PATH)) { - // Initial seed if file doesn't exist - const defaultAdmin: UserJson = { - id: "0", - username: "admin", - passwordHash: hashPassword("admin"), - role: "admin" - }; - saveFallbackUsers([defaultAdmin]); - return [defaultAdmin]; - } - const data = fs.readFileSync(USERS_JSON_PATH, "utf-8"); - return JSON.parse(data); - } catch (e) { - console.error("[AUTH] Error reading users.json:", e); - return []; - } -} - -/** - * Saves users to local JSON fallback. - */ -export function saveFallbackUsers(users: UserJson[]) { - try { - const dir = path.dirname(USERS_JSON_PATH); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(USERS_JSON_PATH, JSON.stringify(users, null, 2)); - } catch (e) { - console.error("[AUTH] Error writing users.json:", e); - } -} diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts deleted file mode 100644 index 09ae497..0000000 --- a/src/features/auth/logic/seed-admin.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic"; -import { sql } from "drizzle-orm"; - -/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */ -let amorcage: Promise | null = null; - -/** - * Prépare les comptes au premier démarrage, une seule fois par processus. - * - * - Base joignable : crée la table `users` si besoin (auto-réparation), puis le - * compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou - * renommé n'est donc pas recréé à chaque connexion. Le fichier de secours - * `data/users.json` n'est pas touché. - * - Base injoignable (première installation, base pas encore configurée) : on - * s'assure que le fichier de secours contient un administrateur, et on - * retentera la base à la prochaine connexion. - * - * Renvoie true si le compte admin/admin vient d'être créé en base. - */ -export function ensureAdminExists(): Promise { - if (!amorcage) { - amorcage = amorcer().catch((err: unknown) => { - // Échec : on retentera à la prochaine connexion. - amorcage = null; - console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err); - return false; - }); - } - return amorcage; -} - -async function amorcer(): Promise { - console.log("[AUTH] Testing database connection for seeding..."); - try { - await db.execute(sql`SELECT 1`); - } catch (err) { - assurerAdminDeSecours(); - throw err; - } - - // Auto-repair: Ensure table exists - await db.execute(sql` - CREATE TABLE IF NOT EXISTS "users" ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role VARCHAR(20) NOT NULL DEFAULT 'user', - created_at TIMESTAMP DEFAULT NOW() - ); - `); - - const [{ nb }] = await db.select({ nb: sql`count(*)::int` }).from(users); - if (nb > 0) return false; - - console.log("[AUTH] Table users vide : création du compte admin/admin."); - // Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer - // l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage. - const crees = await db.insert(users).values({ - username: "admin", - passwordHash: hashPassword("admin"), - role: "admin", - }).onConflictDoNothing().returning({ id: users.id }); - return crees.length > 0; -} - -/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */ -function assurerAdminDeSecours() { - const fallbackUsers = getFallbackUsers(); - if (fallbackUsers.length === 0) { - console.log("[AUTH] Initializing local JSON fallback with admin/admin..."); - saveFallbackUsers([{ - id: "0", - username: "admin", - passwordHash: hashPassword("admin"), - role: "admin" - }]); - } -} diff --git a/src/features/commandes-auto/actions.ts b/src/features/commandes-auto/actions.ts index c4623b2..f9916fe 100644 --- a/src/features/commandes-auto/actions.ts +++ b/src/features/commandes-auto/actions.ts @@ -1,11 +1,10 @@ -"use server"; +/** + * Cadencier des commandes fournisseurs — actions exécutées par le serveur + * Rust : `POST /api/actions/{nom}`. + */ -import { revalidatePath } from "next/cache"; -import { and, eq } from "drizzle-orm"; -import { db } from "@/db"; -import { commandeCadences } from "@/db/schema"; -import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache"; -import { requireSession, verifierSession } from "@/lib/authz"; +import { callAction } from "@/lib/rpc"; +import { refreshPage } from "@/lib/page-data"; export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu"; @@ -25,21 +24,18 @@ export interface CadenceView { statut: CadenceStatut; } -const MS_PAR_JOUR = 24 * 60 * 60 * 1000; -/** Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" */ -const SEUIL_BIENTOT_JOURS = 7; - -function calcStatut(joursRestants: number | null): CadenceStatut { - if (joursRestants === null) return "inconnu"; - if (joursRestants <= 0) return "a_commander"; - if (joursRestants <= SEUIL_BIENTOT_JOURS) return "bientot"; - return "ok"; +/** + * Les actions Next.js de modification appelaient `revalidatePath("/commandes-auto")`, + * dont la réponse rafraîchissait la page : même effet ici après une réussite. + */ +function rafraichirSiOk(resultat: R): R { + if (resultat.ok) refreshPage(); + return resultat; } /** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */ export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> { - await requireSession(); - return getFournisseursCached(); + return callAction("getFournisseursPourCadence", []); } /** @@ -47,45 +43,7 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom: * de la dernière réception (FF Nancy). */ export async function listCadences(): Promise { - await requireSession(); - let rows: typeof commandeCadences.$inferSelect[] = []; - let receptions = new Map(); - try { - rows = await db.select().from(commandeCadences); - // Réceptions des seuls fournisseurs du cadencier : la lecture sur tout - // l'historique de tous les fournisseurs était la plus lourde de la page. - receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur)); - } catch (e) { - console.error("[cadence] listCadences error:", (e as Error).message); - return []; - } - - const now = Date.now(); - - return rows.map((r) => { - const receptionIso = receptions.get(`${r.codeFournisseur}|${r.site}`) ?? null; - - let echeanceIso: string | null = null; - let joursRestants: number | null = null; - if (receptionIso) { - const echeance = new Date(receptionIso).getTime() + r.intervalleSemaines * 7 * MS_PAR_JOUR; - echeanceIso = new Date(echeance).toISOString(); - joursRestants = Math.ceil((echeance - now) / MS_PAR_JOUR); - } - - return { - id: r.id, - codefou: r.codeFournisseur, - nomfou: r.nomFournisseur ?? r.codeFournisseur, - site: r.site, - intervalleSemaines: r.intervalleSemaines, - actif: r.actif, - derniereReception: receptionIso, - echeance: echeanceIso, - joursRestants, - statut: r.actif ? calcStatut(joursRestants) : "inconnu", - } satisfies CadenceView; - }); + return callAction("listCadences", []); } export interface UpsertCadenceInput { @@ -98,76 +56,15 @@ export interface UpsertCadenceInput { /** Crée ou met à jour la cadence d'un fournisseur sur un site. */ export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { ok: false, error: acces.message }; - - const codefou = input.codefou?.trim(); - const site = input.site?.trim(); - const intervalle = Math.round(Number(input.intervalleSemaines)); - - if (!codefou || !site) return { ok: false, error: "Fournisseur et magasin requis." }; - if (!Number.isFinite(intervalle) || intervalle < 1 || intervalle > 104) { - return { ok: false, error: "L'intervalle doit être compris entre 1 et 104 semaines." }; - } - - try { - await db - .insert(commandeCadences) - .values({ - codeFournisseur: codefou, - nomFournisseur: input.nomfou ?? null, - site, - intervalleSemaines: intervalle, - actif: input.actif ?? true, - }) - .onConflictDoUpdate({ - target: [commandeCadences.codeFournisseur, commandeCadences.site], - set: { - nomFournisseur: input.nomfou ?? null, - intervalleSemaines: intervalle, - actif: input.actif ?? true, - updatedAt: new Date(), - }, - }); - revalidatePath("/commandes-auto"); - return { ok: true }; - } catch (e) { - console.error("[cadence] upsertCadence error:", (e as Error).message); - return { ok: false, error: "Échec de l'enregistrement." }; - } + return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("upsertCadence", [input])); } /** Active / désactive une cadence sans la supprimer. */ export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { ok: false, error: acces.message }; - - try { - await db - .update(commandeCadences) - .set({ actif, updatedAt: new Date() }) - .where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site))); - revalidatePath("/commandes-auto"); - return { ok: true }; - } catch (e) { - console.error("[cadence] toggleCadence error:", (e as Error).message); - return { ok: false }; - } + return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("toggleCadence", [codefou, site, actif])); } /** Supprime définitivement une cadence. */ export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { ok: false, error: acces.message }; - - try { - await db - .delete(commandeCadences) - .where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site))); - revalidatePath("/commandes-auto"); - return { ok: true }; - } catch (e) { - console.error("[cadence] removeCadence error:", (e as Error).message); - return { ok: false }; - } + return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("removeCadence", [codefou, site])); } diff --git a/src/features/grid/actions.ts b/src/features/grid/actions.ts index 80b1fc4..07e4445 100644 --- a/src/features/grid/actions.ts +++ b/src/features/grid/actions.ts @@ -1,23 +1,20 @@ -"use server"; +/** + * Actions de la Grille (ex-« server actions » Next.js), exécutées par le + * serveur Rust : `POST /api/actions/{nom}`. + */ -import { getSitesFromApi } from "@/lib/api-ff-client"; -import { getFournisseursCached } from "@/lib/ff-cache"; -import { requireSession } from "@/lib/authz"; +import { callAction } from "@/lib/rpc"; /** * Get the list of all suppliers from PostgreSQL (fouadr1). */ -export async function getFournisseurs() { - await requireSession(); - // Relu à chaque navigation dans la Grille (changement de fournisseur, de - // magasin…) : le référentiel ne change qu'avec la recopie nocturne. - return getFournisseursCached(); +export async function getFournisseurs(): Promise<{ code: string; nom: string }[]> { + return callAction("getFournisseurs", []); } /** * Get the list of all stores (sites) from the FF Nancy API. */ -export async function getMagasins() { - await requireSession(); - return getSitesFromApi(); +export async function getMagasins(): Promise<{ code: string; nom: string }[]> { + return callAction("getMagasins", []); } diff --git a/src/features/grid/api/enregistrer-gammes.ts b/src/features/grid/api/enregistrer-gammes.ts deleted file mode 100644 index 8c09861..0000000 --- a/src/features/grid/api/enregistrer-gammes.ts +++ /dev/null @@ -1,80 +0,0 @@ -import "server-only"; - -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { eq, desc } from "drizzle-orm"; -import { patchGridRowsCache } from "./get-product-rows"; -import { updateGridRowsGamme } from "@/lib/grid-store"; - -export interface ChangementGamme { - codein: string; - /** Gamme avant la modification (état serveur), conservée pour l'historique. */ - codeGammeBefore: string | null; - codeGamme: string; -} - -export interface EnregistrementGammes { - codeFournisseur: string; - nomFournisseur?: string | null; - magasin: string; - changes: ChangementGamme[]; - userId: number | null; - /** Libellé du snapshot, « Draft — date » par défaut. */ - label?: string; -} - -/** - * Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1. - * - * Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur, - * puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`. - * Lève une erreur si l'écriture du snapshot échoue. - */ -export async function enregistrerGammes(input: EnregistrementGammes): Promise { - const { codeFournisseur, nomFournisseur, magasin, changes, userId } = input; - if (changes.length === 0) return 0; - - // Charger le dernier snapshot existant pour merger les changements - const existing = await db - .select() - .from(sessionSnapshots) - .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) - .orderBy(desc(sessionSnapshots.createdAt)) - .limit(1); - - const prevChanges: Record = - existing.length > 0 - ? (existing[0].changes as Record) - : {}; - - // Merger les nouveaux changements par-dessus l'existant - const mergedChanges = { ...prevChanges }; - for (const c of changes) { - mergedChanges[c.codein] = { - before: c.codeGammeBefore, - after: c.codeGamme, - }; - } - - // Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur - await db.insert(sessionSnapshots).values({ - userId, - codeFournisseur, - nomFournisseur: nomFournisseur ?? null, - magasin, - label: input.label ?? `Draft — ${new Date().toLocaleDateString("fr-FR")}`, - changes: mergedChanges, - summaryJson: null, - type: "snapshot", - }); - - // Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées : - // l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la - // réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer. - patchGridRowsCache(codeFournisseur, changes); - void updateGridRowsGamme(codeFournisseur, changes).catch((e) => - console.error("[enregistrerGammes] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)), - ); - - return changes.length; -} diff --git a/src/features/grid/api/gammes-a-valider-actions.ts b/src/features/grid/api/gammes-a-valider-actions.ts index 0a7a8f9..227923d 100644 --- a/src/features/grid/api/gammes-a-valider-actions.ts +++ b/src/features/grid/api/gammes-a-valider-actions.ts @@ -1,37 +1,21 @@ -"use server"; +/** + * Gammes proposées par l'API `/api/v1`, en attente de validation dans la + * Grille — actions exécutées par le serveur Rust : `POST /api/actions/{nom}`. + */ -import { z } from "zod"; -import { verifierSession } from "@/lib/authz"; -import { listerGammesAValider, supprimerGammesAValider, type GammeAValider } from "@/lib/gammes-a-valider"; +import { callAction } from "@/lib/rpc"; +import type { GammeAValider } from "@/types/ff"; + +export type { GammeAValider }; /** Gammes proposées par l'API pour un fournisseur, en attente de validation. */ export async function getGammesAValider( codeFournisseur: string, ): Promise<{ success: boolean; propositions: GammeAValider[]; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, propositions: [], error: acces.message }; - try { - return { success: true, propositions: await listerGammesAValider(String(codeFournisseur)) }; - } catch (err) { - return { success: false, propositions: [], error: err instanceof Error ? err.message : "Unknown error" }; - } + return callAction("getGammesAValider", [codeFournisseur]); } -const RejetSchema = z.object({ - codeFournisseur: z.string().min(1), - codeins: z.array(z.string()), -}); - /** Rejette des propositions de l'API (« Annuler les modifications » dans la Grille). */ export async function rejeterGammesAValider(raw: unknown): Promise<{ success: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, error: acces.message }; - const parsed = RejetSchema.safeParse(raw); - if (!parsed.success) return { success: false, error: "Validation failed: " + parsed.error.message }; - try { - await supprimerGammesAValider(parsed.data.codeFournisseur, parsed.data.codeins); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : "Unknown error" }; - } + return callAction("rejeterGammesAValider", [raw]); } diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts deleted file mode 100644 index c9f4a6f..0000000 --- a/src/features/grid/api/get-product-rows.ts +++ /dev/null @@ -1,779 +0,0 @@ -import "server-only"; - -import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; -import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client"; -import { - pgGetArticlesByFournisseur, - pgGetMensuelByFournisseur, - pgGetGammesByFournisseur, - pgGetNomenclatureByFournisseur, - pgGetStockByFournisseur, - pgGetCommandesByFournisseur, - pgGetPrixVenteByFournisseur, - type PgStockRow, -} from "@/lib/pg-ff-client"; -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { eq, desc } from "drizzle-orm"; -import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache"; -import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; -// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée. -import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store"; -import { - applyStorePatchInPlace, - buildStorePatchEntries, - storePatchCandidates, - type StorePatch, - type StorePatchEntry, -} from "@/features/grid/lib/store-patch"; - -interface GetProductRowsInput { - codeFournisseur: string; - magasin?: string; - filters?: Partial; - forceRefresh?: boolean; -} - -const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000; -/** - * Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par - * `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait - * par garder toutes les grilles en mémoire (plusieurs centaines de Mo), ce qui - * ralentissait toutes les requêtes (ramasse-miettes). Ordre d'insertion de la Map - * = ordre d'usage : on évince les entrées les moins récemment servies. - */ -const GRID_ROWS_CACHE_MAX_ENTRIES = Number(process.env.GRID_ROWS_CACHE_MAX_ENTRIES ?? 30); -const GRID_ROWS_CACHE_MAX_ROWS = Number(process.env.GRID_ROWS_CACHE_MAX_ROWS ?? 150_000); -const gridRowsCache = new Map(); -const gridRowsPending = new Map>(); -/** Nombre d'enregistrements de gammes par fournisseur (cf. patchGridRowsCache). */ -const gammeSaveVersion = new Map(); - -function cacheGridRows(key: string, rows: ProductRow[]): void { - gridRowsCache.delete(key); - gridRowsCache.set(key, { rows, createdAt: Date.now() }); - let totalRows = 0; - for (const entry of gridRowsCache.values()) totalRows += entry.rows.length; - for (const [oldKey, entry] of gridRowsCache) { - if (gridRowsCache.size <= 1) break; - if (gridRowsCache.size <= GRID_ROWS_CACHE_MAX_ENTRIES && totalRows <= GRID_ROWS_CACHE_MAX_ROWS) break; - gridRowsCache.delete(oldKey); - totalRows -= entry.rows.length; - } -} - -function gridRowsCacheKey(input: GetProductRowsInput): string { - return `${input.codeFournisseur}:${input.magasin ?? "TOTAL"}`; -} - -export function invalidateGridRowsCache(codeFournisseur?: string) { - if (codeFournisseur) { - for (const key of gridRowsCache.keys()) { - if (key.startsWith(`${codeFournisseur}:`)) gridRowsCache.delete(key); - } - for (const key of gridRowsPending.keys()) { - if (key.startsWith(`${codeFournisseur}:`)) gridRowsPending.delete(key); - } - return; - } - gridRowsCache.clear(); - gridRowsPending.clear(); -} - -/** - * Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter - * le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros - * fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné - * ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9), - * la colonne INIT reste l'état serveur. - */ -export function patchGridRowsCache( - codeFournisseur: string, - changes: ReadonlyArray<{ codein: string; codeGamme: string }>, -): void { - if (changes.length === 0) return; - // Un calcul en cours pour ce fournisseur a lu les gammes avant cet - // enregistrement : le compteur lui signale de les relire avant d'être caché. - gammeSaveVersion.set(codeFournisseur, (gammeSaveVersion.get(codeFournisseur) ?? 0) + 1); - const byCodein = new Map(changes.map((c) => [c.codein, c.codeGamme as GammeCode])); - for (const [key, entry] of gridRowsCache) { - if (!key.startsWith(`${codeFournisseur}:`)) continue; - for (const row of entry.rows) { - const gamme = byCodein.get(row.codein); - if (gamme !== undefined) row.codeGamme = gamme; - } - } -} - -/** - * Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante) - * sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas - * touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT) - * est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le - * marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif. - */ -async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise { - try { - const gammeMap = await pgGetGammesByFournisseur(codeFournisseur); - if (gammeMap.size === 0) return; - for (const row of rows) { - const live = gammeMap.get(row.codein); - if (live !== undefined) row.codeGammeInit = live as GammeCode; - } - } catch (e) { - console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200)); - } -} - -/** - * Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`). - * - * Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations - * par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes. - * Toute erreur est avalée : la persistance ne doit jamais casser l'affichage. - */ -async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise { - if ((input.magasin ?? "TOTAL") !== "TOTAL") return; - try { - const n = await upsertGridRows(input.codeFournisseur, rows); - console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`); - } catch (e) { - console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200)); - } -} - -export async function getProductRows(input: GetProductRowsInput): Promise { - const cacheKey = gridRowsCacheKey(input); - const cached = gridRowsCache.get(cacheKey); - if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) { - // Remonte l'entrée en tête de l'ordre d'usage (éviction LRU). - gridRowsCache.delete(cacheKey); - gridRowsCache.set(cacheKey, cached); - // La colonne INIT doit refléter l'état serveur à CHAQUE chargement, - // y compris sur un hit de cache (les données lourdes restent, elles, cachées). - await refreshGammeInit(cached.rows, input.codeFournisseur); - return cached.rows; - } - - // Un calcul déjà en cours est réutilisé même en rechargement forcé : il part - // de données fraîches, et en lancer un second en parallèle doublait la charge - // (double clic, onglet rouvert…) sans rien apporter. - const pending = gridRowsPending.get(cacheKey); - if (pending) { - return pending; - } - - // Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi - // en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement - // forcé — c'est précisément ce que l'utilisateur demande d'éviter. - if (!input.forceRefresh) { - const servi = await serveFromSnapshot(input, cacheKey); - if (servi) return servi; - } - - const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0; - const promise = buildProductRows(input).then(async (rows) => { - if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) { - // Gammes enregistrées pendant le calcul : on reprend le dernier snapshot. - applySnapshotChanges(rows, await loadLatestSnapshotChanges(input.codeFournisseur)); - } - cacheGridRows(cacheKey, rows); - gridRowsPending.delete(cacheKey); - // Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on - // arrête simplement de jeter le résultat. Volontairement NON bloquant — - // l'affichage de la Grille ne doit pas attendre l'écriture. - void persistGridSnapshot(input, rows); - return rows; - }).catch((error) => { - gridRowsPending.delete(cacheKey); - throw error; - }); - - gridRowsPending.set(cacheKey, promise); - return promise; -} - -/** Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan. */ -const SNAPSHOT_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000; - -/** - * Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le - * calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et - * les dernières gammes enregistrées reportées dessus. Hors « tous magasins », - * le rattrapage du magasin choisi est appliqué comme après un calcul. - * - * `null` = pas d'instantané utilisable (absent, ancien format, autre mois) : - * l'appelant recalcule. - */ -async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string): Promise { - const { codeFournisseur, magasin = "TOTAL" } = input; - let snapshot: Awaited>; - try { - snapshot = await readGridSnapshot(codeFournisseur); - } catch (e) { - console.error("[getProductRows] lecture grid_rows KO:", (e as Error).message?.slice(0, 200)); - return null; - } - if (!snapshot || snapshot.rows.length === 0) return null; - - const { rows } = snapshot; - // Les colonnes réseau de l'instantané datent de son calcul : on les relit dans - // le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite - // depuis (au changement de mois notamment) restait invisible jusqu'au - // prochain recalcul, et la tendance réseau disparaissait. - const [changes, network] = await Promise.all([ - loadLatestSnapshotChanges(codeFournisseur), - fetchNetworkMetrics(rows.map((r) => r.codeCentrale ?? "")), - refreshGammeInit(rows, codeFournisseur), - ]); - applySnapshotChanges(rows, changes); - enrichWithNetworkMetrics(rows, network); - - if (magasin !== "TOTAL") { - const { dateDebut, dateFin } = buildLast12MonthsRange(); - await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, last12Periods()); - } - - cacheGridRows(cacheKey, rows); - - // Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans - // faire attendre l'utilisateur (la prochaine ouverture en profitera). - // Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané. - if (Date.now() - snapshot.computedAt.getTime() > SNAPSHOT_REFRESH_AFTER_MS && !gridRowsPending.has(`${codeFournisseur}:TOTAL`)) { - void getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true }).catch((e) => - console.error("[getProductRows] rafraîchissement en arrière-plan KO:", (e as Error).message?.slice(0, 200)), - ); - } - return rows; -} - -/** Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul). */ -function last12Periods(): string[] { - const now = new Date(); - const periods: string[] = []; - for (let i = 12; i >= 1; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - periods.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); - } - return periods; -} - -async function buildProductRows(input: GetProductRowsInput): Promise { - const { codeFournisseur, magasin = "TOTAL" } = input; - console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`); - - try { - const { dateDebut, dateFin } = buildLast12MonthsRange(); - - // ─── Phase 1 : 7 requêtes SQL en parallèle ──────────────────────────── - // Remplace des centaines/milliers d'appels HTTP per-article. - const articlesPromise = pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }); - // Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des - // articles (codes centraux) ou le seul code fournisseur. Exécutées après la - // Phase 1, elles ajoutaient deux allers-retours en série à chaque calcul. - const networkPromise = articlesPromise.then((arts) => - fetchNetworkMetrics(arts.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))), - ); - const snapshotPromise = loadLatestSnapshotChanges(codeFournisseur); - - const [ - articles, - mensuelRows, - gammeMap, - nomMap, - stockMap, - commandesMap, - prixVenteMap, - ] = await Promise.all([ - articlesPromise, - pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }), - pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map(); }), - pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }), - pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map(); }), - pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map(); }), - pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map>(); }), - ]); - - console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`); - - // ─── Phase 2 : Fenêtre temporelle 12 mois complets ─────────────────── - const sortedPeriods = last12Periods(); - const allowedPeriods = new Set(sortedPeriods); - - // ─── Phase 3 : Seed productMap depuis articles ──────────────────────── - const productMap = new Map(); - for (const art of articles) { - if (!art.codein || productMap.has(art.codein)) continue; - productMap.set(art.codein, { - codein: art.codein, - codeFournisseur: art.codefou ?? codeFournisseur, - nomFournisseur: art.nomfou ?? "", - fournisseurPrincipalCode: art.codefou_principal ?? undefined, - fournisseurPrincipalNom: art.nomfou_principal ?? undefined, - libelle1: art.libelle1 ?? "", - gtin: art.gtin ?? "", - reference: art.reference ?? "", - codeCentrale: art.codeCentrale ? String(art.codeCentrale).trim() : undefined, - code1: "", libelleNiveau1: "", - code2: "", libelleNiveau2: "", - code3: "", libelle3: "", - codeGamme: null, - codeGammeInit: null, - codeGammeDraft: null, - sales12m: {}, - stock12m: {}, - receptions12m: {}, - totalQuantite: 0, - totalCa: 0, - totalMarge: 0, - tauxMarge: 0, - workingStores: [], - noid: art.no_id ? Number(art.no_id) : undefined, - pcb: art.pcb ? Number(art.pcb) : undefined, - prixVente: art.pv_central ? Number(art.pv_central) : undefined, - }); - } - - // ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ───────────── - // SQL a déjà fait l'agrégation par (codein, site, mois). - // On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side. - type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number }; - const mensuelByCodein = new Map>(); - const mensuelBySite = new Map>>(); // codein → site → periode → data - const storeMonthsByCodein = new Map>>(); - - for (const row of mensuelRows) { - const periode = row.mois.replace("-", ""); // "2026-02" → "202602" - if (!allowedPeriods.has(periode)) continue; - - // Accumulation TOTAL (toujours tous sites) - if (!mensuelByCodein.has(row.codein)) mensuelByCodein.set(row.codein, new Map()); - const byPeriod = mensuelByCodein.get(row.codein)!; - if (!byPeriod.has(periode)) byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 }); - const p = byPeriod.get(periode)!; - p.stock += Number(row.stock_fin_mois) || 0; - p.qty += Number(row.qte_vendue) || 0; - p.ca += Number(row.ca_ht) || 0; - p.marge += Number(row.marge) || 0; - p.receptions += Number(row.qte_recue) || 0; - - // Accumulation par site (pour switch client-side) - if (!mensuelBySite.has(row.codein)) mensuelBySite.set(row.codein, new Map()); - const bySite = mensuelBySite.get(row.codein)!; - if (!bySite.has(row.site)) bySite.set(row.site, new Map()); - const siteByPeriod = bySite.get(row.site)!; - if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 }); - const sp = siteByPeriod.get(periode)!; - sp.qty += Number(row.qte_vendue) || 0; - sp.ca += Number(row.ca_ht) || 0; - sp.marge += Number(row.marge) || 0; - sp.stock = Number(row.stock_fin_mois) || 0; // SQL donne déjà le dernier mouvement - sp.receptions += Number(row.qte_recue) || 0; - - // Suivi magasins actifs (pour workingStores) : 1 vente suffit - if (Number(row.qte_vendue) > 0) { - if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map()); - storeMonthsByCodein.get(row.codein)!.set(row.site, new Set()); - } - } - - // ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ───────────── - for (const [codein, byPeriod] of mensuelByCodein.entries()) { - const product = productMap.get(codein); - if (!product) continue; - - let lastStock = 0; - for (const periode of sortedPeriods) { - const p = byPeriod.get(periode); - if (p) { - product.sales12m[periode] = p.qty; - product.stock12m[periode] = p.stock; - product.receptions12m[periode] = p.receptions; - product.totalQuantite += p.qty; - product.totalCa += p.ca; - product.totalMarge += p.marge; - lastStock = p.stock; - } else { - product.sales12m[periode] = 0; - product.stock12m[periode] = lastStock; // carry-forward - product.receptions12m[periode] = 0; - } - } - - product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0; - - // Breakdowns par site pour le switch client-side - const siteMap = mensuelBySite.get(codein); - if (siteMap) { - product.sales12mByStore = {}; - product.stock12mByStore = {}; - product.receptions12mByStore = {}; - product.caByStore = {}; - product.quantiteByStore = {}; - product.margeByStore = {}; - for (const [site, siteByPeriod] of siteMap.entries()) { - product.sales12mByStore[site] = {}; - product.stock12mByStore[site] = {}; - product.receptions12mByStore![site] = {}; - let sQty = 0, sCa = 0, sMarge = 0; - let lastSiteStock = 0; - for (const periode of sortedPeriods) { - const sp = siteByPeriod.get(periode); - product.sales12mByStore[site][periode] = sp?.qty ?? 0; - product.receptions12mByStore![site][periode] = sp?.receptions ?? 0; - if (sp) { - product.stock12mByStore[site][periode] = sp.stock; - lastSiteStock = sp.stock; - } else { - product.stock12mByStore[site][periode] = lastSiteStock; // carry-forward - } - sQty += sp?.qty ?? 0; - sCa += sp?.ca ?? 0; - sMarge += sp?.marge ?? 0; - } - product.quantiteByStore[site] = sQty; - product.caByStore[site] = sCa; - product.margeByStore[site] = sMarge; - } - - // Le stock TOTAL se recalcule ici, à partir des séries par site. - // - // Il était sommé plus haut depuis les lignes mensuelles brutes, ce - // qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock - // est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu - // détient toujours sa marchandise. Sur un produit à faible rotation, - // « tous magasins » n'affichait donc que le site actif, en pratique - // Frouard. Les séries par site, elles, sont reportées d'un mois sur - // l'autre : leur somme est la bonne. - const sites = Object.keys(product.stock12mByStore); - if (sites.length > 0) { - for (const periode of sortedPeriods) { - product.stock12m[periode] = sites.reduce( - (total, site) => total + (product.stock12mByStore![site][periode] ?? 0), - 0, - ); - } - } - } - - // workingStores : sites avec au moins 1 vente sur les 12 derniers mois - const storeMonths = storeMonthsByCodein.get(codein); - if (storeMonths) { - product.workingStores = [...storeMonths.keys()].sort(); - } - - // Commandes en cours - const cmdQty = commandesMap.get(codein); - if (cmdQty) product.commandesEnCours = cmdQty; - } - - // ─── Phase 6 : Gammes (saison active) ──────────────────────────────── - for (const [codein, gammeCode] of gammeMap.entries()) { - const product = productMap.get(codein); - if (!product) continue; - product.codeGammeInit = gammeCode as GammeCode; - product.codeGamme = gammeCode as GammeCode; - } - - // ─── Phase 6b : Nomenclature (famille / sous-famille) ──────────────── - for (const [codein, nom] of nomMap.entries()) { - const product = productMap.get(codein); - if (!product) continue; - product.code3 = nom.code3 ?? ""; - product.libelle3 = nom.libelle3 ?? ""; - product.code2 = nom.code2 ?? ""; - product.libelleNiveau2 = nom.libelle2 ?? ""; - product.code1 = nom.code1 ?? ""; - product.libelleNiveau1 = nom.libelle1nom ?? ""; - } - - // ─── Phase 7 : Stock temps réel (cube_stock) ───────────────────────── - for (const [codein, stocks] of stockMap.entries()) { - const product = productMap.get(codein); - if (!product) continue; - - const sitesStock = stocks; - - product.stockActuel = sitesStock.reduce((s, r) => s + (Number(r.stockdispo) || 0), 0); - product.stockTotal = sitesStock.reduce((s, r) => s + (Number(r.qte) || 0), 0); - product.stockValeur = sitesStock.reduce((s, r) => s + (Number(r.valstock) || 0), 0); - - const withPrmp = sitesStock.find(r => Number(r.prmp) > 0); - if (withPrmp) { - product.pa = Number(withPrmp.prmp); - product.prixAchat = product.pa; - } - - const dernVente = sitesStock.reduce((best, r) => - r.dernierevente && r.dernierevente > best ? r.dernierevente : best, ""); - if (dernVente) product.derniereVente = dernVente; - - const dernLiv = sitesStock.reduce((best, r) => - r.dernierereception && r.dernierereception > best ? r.dernierereception : best, ""); - if (dernLiv) product.derniereLivraison = dernLiv; - - // Map store-specific delivery dates - product.derniereLivraisonByStore = {}; - for (const r of sitesStock) { - if (r.site && r.dernierereception) { - product.derniereLivraisonByStore[r.site] = r.dernierereception; - } - } - } - - // ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─ - // - // `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une - // borne de paramétrage : elle est vide en base et laissait la colonne PV - // entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par - // site. On garde le détail par magasin — deux magasins peuvent ne pas - // pratiquer le même prix, et l'afficher vaut mieux que le moyenner. - for (const product of productMap.values()) { - const parSite = prixVenteMap.get(product.codein); - if (parSite && Object.keys(parSite).length > 0) { - product.prixVenteByStore = parSite; - } else { - // Filet de secours : le cube de stock porte le même PV, mais - // seulement pour les articles qui ont une ligne de stock. - const depuisStock: Record = {}; - for (const s of stockMap.get(product.codein) ?? []) { - const pv = Number(s.pv); - const site = String(s.site ?? "").trim(); - if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv; - } - if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock; - } - - const prix = Object.values(product.prixVenteByStore ?? {}); - // Le plus élevé quand les magasins divergent : une moyenne inventerait - // un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran. - if (prix.length > 0) product.prixVente = Math.max(...prix); - } - - // ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ── - enrichWithNetworkMetrics(productMap.values(), await networkPromise); - - // ─── Phase 9 : Restaurer gammes depuis dernier snapshot ────────────── - // La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle. - // La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur - // rechargé en Phase 6 — jamais écrasée ici. - applySnapshotChanges(productMap.values(), await snapshotPromise); - - // ─── Phase 10 : Filtrer gamme Y sans ventes ────────────────────────── - const allRows = Array.from(productMap.values()); - const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0); - await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods); - const excludedY = allRows.length - rows.length; - console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`); - - return rows; - - } catch (error) { - console.error(`[getProductRows] Error for ${codeFournisseur}:`, error); - // Ne PAS renvoyer [] : une liste vide est indiscernable d'un fournisseur - // sans article. La Grille affichait une page blanche sans explication, et - // la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle - // désactivait alors automatiquement. La remonter laisse chaque appelant la - // traiter comme une erreur (bandeau rouge, statut « echec »). - throw error instanceof Error - ? error - : new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`); - } -} - -/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */ -function plafondComplement(): number { - return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300); -} - -/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */ -async function fetchStorePatchEntries( - rows: ProductRow[], - magasin: string, - dateDebut: string, - dateFin: string, - periods: string[], -): Promise { - const candidates = storePatchCandidates(rows, magasin); - if (candidates.length === 0) return []; - - // Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur, - // les candidats se comptent par milliers et le changement de magasin se fige - // plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté - // plutôt que de tronquer en silence. - const PLAFOND = plafondComplement(); - const retenus = candidates.slice(0, PLAFOND); - if (candidates.length > retenus.length) { - console.warn( - `[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles` - + ` sur ${candidates.length} : les autres gardent la valeur SQL.`, - ); - } - - const mensuelMap = await getMensuelByArticles( - retenus.map((row) => ({ - codein: row.codein, - libelle1: row.libelle1, - codefou: row.codeFournisseur, - noid: row.noid, - })), - dateDebut, - dateFin, - 25 - ); - return buildStorePatchEntries(retenus, magasin, periods, mensuelMap); -} - -async function reconcileSelectedStoreFromMensuelApi( - rows: ProductRow[], - magasin: string, - dateDebut: string, - dateFin: string, - sortedPeriods: string[] -) { - if (magasin === "TOTAL") return; - try { - const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods); - const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries); - if (fixedRows > 0) { - console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`); - } - } catch (error) { - console.error("[getProductRows] Mensuel API reconciliation error:", error); - } -} - -/** - * Compléments par magasin, rattachés au tableau de lignes « tous magasins » en - * cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel - * enregistrement), sans autre invalidation à gérer. - */ -const storePatches = new WeakMap>>(); - -/** - * Complément de l'API FF pour un magasin, calculé sur les lignes « tous - * magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans - * recharger les lignes. Les demandes simultanées partagent le même calcul, et - * un échec n'est jamais gardé. - */ -export async function getStorePatch(codeFournisseur: string, magasin: string): Promise { - const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`); - const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS - ? enCache.rows - : await getProductRows({ codeFournisseur, magasin: "TOTAL" }); - - let parMagasin = storePatches.get(rows); - if (!parMagasin) { - parMagasin = new Map(); - storePatches.set(rows, parMagasin); - } - let patch = parMagasin.get(magasin); - if (!patch) { - const periods = last12Periods(); - const { dateDebut, dateFin } = buildLast12MonthsRange(); - const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods) - .then((entries): StorePatch => ({ magasin, periods, entries })); - const table = parMagasin; - enCours.catch(() => { - if (table.get(magasin) === enCours) table.delete(magasin); - }); - parMagasin.set(magasin, enCours); - patch = enCours; - } - return patch; -} - -/** - * Extrait la série « nombre de magasins vendeurs » du détail mensuel complet. - * - * `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la - * deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide - * pour que l'UI distingue « pas de donnée » de « zéro magasin ». - */ -function nbMagParMois( - metricsByMonth: Record | null | undefined, -): Record | null { - if (!metricsByMonth) return null; - const parMois: Record = {}; - for (const [mois] of Object.entries(metricsByMonth)) { - // Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de - // `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme - // « manquant » amputait la série et faisait disparaître toute la courbe - // (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente). - const nb = Number(metricsByMonth[mois]?.nbMag); - parMois[mois] = Number.isFinite(nb) ? nb : 0; - } - return Object.keys(parMois).length > 0 ? parMois : null; -} - -type SnapshotChanges = Record; - -/** - * Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ; - * codeGammeInit reste figé (état serveur). - */ -function applySnapshotChanges(rows: Iterable, changes: SnapshotChanges | null): void { - if (!changes) return; - for (const row of rows) { - const change = changes[row.codein]; - if (change?.after) row.codeGamme = change.after as GammeCode; - } -} - -/** Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `null`. */ -async function loadLatestSnapshotChanges(codeFournisseur: string): Promise { - try { - const snaps = await db - .select({ changes: sessionSnapshots.changes }) - .from(sessionSnapshots) - .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) - .orderBy(desc(sessionSnapshots.createdAt)) - .limit(1); - return snaps.length > 0 ? (snaps[0].changes as SnapshotChanges) : null; - } catch (snapErr) { - console.error("[getProductRows] Snapshot restore error:", snapErr); - return null; - } -} - -/** - * Lit les metriques reseau Qlik (cache qlik_network_metrics) des codes centraux - * donnes. `null` en cas d'erreur : la grille s'affiche alors sans donnees reseau. - */ -async function fetchNetworkMetrics( - codesCentrale: string[], -): Promise | null> { - try { - const codes = [...new Set(codesCentrale.filter(Boolean))]; - if (codes.length === 0) return null; - return await getNetworkMetricsByCodeCentrale(codes); - } catch (error) { - console.error("[getProductRows] enrichWithNetworkMetrics error:", error); - return null; - } -} - -/** - * Enrichit les produits avec les metriques reseau Qlik, jointes par code centrale. - * Degradation propre si la sync Qlik n'a jamais tourne ou si le code centrale - * n'est pas encore disponible. - */ -function enrichWithNetworkMetrics( - products: Iterable, - metrics: Map | null, -): void { - if (!metrics || metrics.size === 0) return; - for (const product of products) { - const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined; - if (!m) continue; - product.caReseau = m.caReseau; - product.qteReseau = m.qteReseau; - product.nbMagasinsReseau = m.nbMagasinsReseau; - product.caParMagasinReseau = m.caParMagasinReseau; - product.margePctReseau = m.margePctReseau; - product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU; - product.qteReseauByMonth = m.qteByMonth ?? null; - product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth); - product.networkFetchedAt = m.fetchedAt ?? undefined; - } -} diff --git a/src/features/grid/api/save-draft-changes.ts b/src/features/grid/api/save-draft-changes.ts index 1c432cd..f04b3dc 100644 --- a/src/features/grid/api/save-draft-changes.ts +++ b/src/features/grid/api/save-draft-changes.ts @@ -1,51 +1,25 @@ -"use server"; +/** + * Enregistrement des gammes modifiées dans la Grille — action exécutée par le + * serveur Rust : `POST /api/actions/saveDraftChanges` (validation comprise). + */ -import { z } from "zod"; -import { verifierSession } from "@/lib/authz"; -import { enregistrerGammes } from "./enregistrer-gammes"; -import { supprimerGammesAValider } from "@/lib/gammes-a-valider"; +import { callAction } from "@/lib/rpc"; -const SaveDraftsSchema = z.object({ - codeFournisseur: z.string(), - nomFournisseur: z.string().optional(), - magasin: z.string(), - changes: z.array( - z.object({ - codein: z.string(), - codeGammeBefore: z.string().nullable(), - codeGamme: z.string(), - }) - ), +/** Données attendues par le serveur (ex-schéma zod `SaveDraftsSchema`). */ +export interface SaveDraftsInput { + codeFournisseur: string; + nomFournisseur?: string; + magasin: string; + changes: { codein: string; codeGammeBefore: string | null; codeGamme: string }[]; /** * Propositions de l'API examinées dans la Grille : validées (présentes dans * `changes`) ou écartées. Elles quittent la file « à valider ». */ - gammesAValiderTraitees: z.array(z.string()).optional(), -}); + gammesAValiderTraitees?: string[]; +} export async function saveDraftChanges( raw: unknown ): Promise<{ success: boolean; saved: number; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, saved: 0, error: acces.message }; - - const parsed = SaveDraftsSchema.safeParse(raw); - if (!parsed.success) { - return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message }; - } - - const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data; - - const rawUserId = acces.utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; - const finalUserId = userId && !isNaN(userId) ? userId : null; - - try { - const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId }); - await supprimerGammesAValider(codeFournisseur, gammesAValiderTraitees); - return { success: true, saved }; - } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - return { success: false, saved: 0, error: msg }; - } + return callAction("saveDraftChanges", [raw]); } diff --git a/src/features/grid/components/assortment-gaps-modal.tsx b/src/features/grid/components/assortment-gaps-modal.tsx index d691a23..6c5ae58 100644 --- a/src/features/grid/components/assortment-gaps-modal.tsx +++ b/src/features/grid/components/assortment-gaps-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas. * diff --git a/src/features/grid/components/bulk-action-toolbar.tsx b/src/features/grid/components/bulk-action-toolbar.tsx index 962845d..8d4d4c3 100644 --- a/src/features/grid/components/bulk-action-toolbar.tsx +++ b/src/features/grid/components/bulk-action-toolbar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useGridStore } from "@/features/grid/store/use-grid-store"; import { GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 448c3ba..20c69e9 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -1,5 +1,3 @@ -"use client"; - import React from "react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; diff --git a/src/features/grid/components/gamme-select.tsx b/src/features/grid/components/gamme-select.tsx index 37741c5..7963b97 100644 --- a/src/features/grid/components/gamme-select.tsx +++ b/src/features/grid/components/gamme-select.tsx @@ -1,5 +1,3 @@ -"use client"; - import { GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; import { CLASSES_SANS_GAMME, GAMMES, trouverGamme } from "@/lib/gammes"; diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index 34bf1ca..c8cccb5 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useRef, useState } from "react"; import { HeatmapGrid } from "@/features/grid/components/heatmap-grid"; import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar"; diff --git a/src/features/grid/components/grid-filter-bar.tsx b/src/features/grid/components/grid-filter-bar.tsx index ea1fea0..12c6c5f 100644 --- a/src/features/grid/components/grid-filter-bar.tsx +++ b/src/features/grid/components/grid-filter-bar.tsx @@ -1,5 +1,3 @@ -"use client"; - import React, { useMemo } from "react"; import { Search, X } from "lucide-react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 1f9c071..2b9d896 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -1,5 +1,3 @@ -"use client"; - import React, { useRef, useState, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import { diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 645999c..920b0b2 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts). * diff --git a/src/features/grid/components/network-monthly-modal.tsx b/src/features/grid/components/network-monthly-modal.tsx index 26d4499..2aa19ee 100644 --- a/src/features/grid/components/network-monthly-modal.tsx +++ b/src/features/grid/components/network-monthly-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Carte « tendance réseau », ouverte depuis la colonne Tendance. * diff --git a/src/features/grid/components/nomenclature-ca-modal.tsx b/src/features/grid/components/nomenclature-ca-modal.tsx index 5b7e7c6..b58aef9 100644 --- a/src/features/grid/components/nomenclature-ca-modal.tsx +++ b/src/features/grid/components/nomenclature-ca-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — CA par nomenclature : nos magasins face au réseau. * diff --git a/src/features/grid/components/nomenclature-filter.tsx b/src/features/grid/components/nomenclature-filter.tsx index 867c5da..74061d6 100644 --- a/src/features/grid/components/nomenclature-filter.tsx +++ b/src/features/grid/components/nomenclature-filter.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { cn } from "@/lib/utils"; import { ChevronDown, Filter, Search, X } from "lucide-react"; diff --git a/src/features/grid/components/product-monthly-modal.tsx b/src/features/grid/components/product-monthly-modal.tsx index 3a8f6d8..a0eb99b 100644 --- a/src/features/grid/components/product-monthly-modal.tsx +++ b/src/features/grid/components/product-monthly-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ». * diff --git a/src/features/grid/components/stat-tile.tsx b/src/features/grid/components/stat-tile.tsx index 7360a61..bbf6024 100644 --- a/src/features/grid/components/stat-tile.tsx +++ b/src/features/grid/components/stat-tile.tsx @@ -1,5 +1,3 @@ -"use client"; - import React from "react"; /** diff --git a/src/features/grid/components/store-combobox.tsx b/src/features/grid/components/store-combobox.tsx index db5949b..9c05719 100644 --- a/src/features/grid/components/store-combobox.tsx +++ b/src/features/grid/components/store-combobox.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo } from "react"; import { Segmented } from "@/components/ui/tabs"; import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; diff --git a/src/features/grid/components/supplier-combobox.tsx b/src/features/grid/components/supplier-combobox.tsx index fc50df9..ebad548 100644 --- a/src/features/grid/components/supplier-combobox.tsx +++ b/src/features/grid/components/supplier-combobox.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { Check, ChevronsUpDown, Search } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/src/features/grid/components/supplier-selection-landing.tsx b/src/features/grid/components/supplier-selection-landing.tsx index 7254862..d68190e 100644 --- a/src/features/grid/components/supplier-selection-landing.tsx +++ b/src/features/grid/components/supplier-selection-landing.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { LayoutGrid } from "lucide-react"; import { useRouter } from "next/navigation"; diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index 3387492..4ba5991 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useGridStore } from "@/features/grid/store/use-grid-store"; import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react"; import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; diff --git a/src/features/grid/hooks/use-save-drafts.ts b/src/features/grid/hooks/use-save-drafts.ts index 5cdc49f..2af1e64 100644 --- a/src/features/grid/hooks/use-save-drafts.ts +++ b/src/features/grid/hooks/use-save-drafts.ts @@ -1,5 +1,3 @@ -"use client"; - import { useCallback } from "react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import { saveDraftChanges } from "@/features/grid/api/save-draft-changes"; diff --git a/src/features/grid/hooks/use-store-patch.ts b/src/features/grid/hooks/use-store-patch.ts index 818c600..b72b812 100644 --- a/src/features/grid/hooks/use-store-patch.ts +++ b/src/features/grid/hooks/use-store-patch.ts @@ -1,5 +1,3 @@ -"use client"; - import { useEffect } from "react"; import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store"; import { CODE_TOUS_MAGASINS } from "@/lib/magasins"; diff --git a/src/features/grid/lib/store-patch.ts b/src/features/grid/lib/store-patch.ts index dd66ade..ceb619e 100644 --- a/src/features/grid/lib/store-patch.ts +++ b/src/features/grid/lib/store-patch.ts @@ -10,7 +10,7 @@ */ import type { ProductRow } from "@/types/grid"; -import type { FfMensuelEntry } from "@/lib/api-ff-client"; +import type { FfMensuelEntry } from "@/types/ff"; /** Chiffres d'un article pour le magasin complété. */ export interface StorePatchEntry { diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 2579754..b17e013 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -1,5 +1,3 @@ -"use client"; - import { create } from "zustand"; import { persist } from "zustand/middleware"; import { createThrottledJSONStorage } from "@/lib/throttled-json-storage"; diff --git a/src/features/produits/api/get-produit-fiche.ts b/src/features/produits/api/get-produit-fiche.ts deleted file mode 100644 index 90fd8a1..0000000 --- a/src/features/produits/api/get-produit-fiche.ts +++ /dev/null @@ -1,210 +0,0 @@ -import "server-only"; - -import { - pgGetProduitDetail, - pgGetCodeinByCodeCentrale, - pgGetFournisseursByCodein, - pgGetStockByCodein, - pgGetCommandesByCodein, - pgGetGammeHistoryByCodein, - pgGetMensuelByCodein, -} from "@/lib/pg-ff-client"; -import { buildLast12MonthsRange } from "@/lib/api-ff-client"; -import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; -import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types"; - -/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */ -function buildMonthKeys(): string[] { - const months: string[] = []; - const now = new Date(); - for (let i = 12; i >= 1; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); - } - return months; -} - -function emptyMois(mois: string): ProduitMois { - return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 }; -} - -function totauxFrom(rows: ProduitMois[]): ProduitTotaux { - const qte = rows.reduce((s, r) => s + r.qte, 0); - const ca = rows.reduce((s, r) => s + r.ca, 0); - const marge = rows.reduce((s, r) => s + r.marge, 0); - return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 }; -} - -/** - * Assemble la fiche d'un produit **à partir de son code centrale** : c'est la - * clé du réseau Qlik, seule identité disponible pour un produit que le réseau - * travaille mais que Nancy ne référence pas. - * - * Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie - * locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes). - * Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal - * depuis la recherche Qlik. - * - * Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code. - */ -export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise { - const cleaned = codeCentrale.trim(); - if (!cleaned) return null; - - const codein = await pgGetCodeinByCodeCentrale(cleaned); - if (codein) return getProduitFiche(codein); - - // Produit réseau pur : aucune donnée locale à assembler. - const map = await getNetworkMetricsByCodeCentrale([cleaned]); - const reseau = map.get(cleaned) ?? null; - if (!reseau) return null; - - const { dateDebut, dateFin } = buildLast12MonthsRange(); - const months = buildMonthKeys(); - const mensuelTotal: Record = {}; - for (const m of months) mensuelTotal[m] = emptyMois(m); - - return { - detail: null, - codeCentrale: cleaned, - libelleReseau: reseau.libelleReseau ?? "", - fournisseurReseau: reseau.fournisseurReseau ?? "", - fournisseurs: [], - stock: [], - commandesEnCours: 0, - gammes: [], - months, - mensuelTotal, - mensuelParSite: {}, - totaux: totauxFrom(months.map((m) => mensuelTotal[m])), - totauxParSite: {}, - sitesActifs: [], - reseau, - periode: { dateDebut, dateFin }, - }; -} - -/** - * Assemble la fiche complète d'un produit : identité, fournisseurs, stock, - * gammes, 12 mois glissants de ventes (total et par magasin) et métriques - * réseau Qlik. - * - * Renvoie `null` si le codein est inconnu du catalogue FF. - */ -export async function getProduitFiche(codein: string): Promise { - const cleaned = codein.trim(); - if (!cleaned) return null; - - const { dateDebut, dateFin } = buildLast12MonthsRange(); - - // Tout part en même temps : l'identité du produit était lue seule d'abord, - // puis les cinq autres lectures, puis les métriques réseau — trois attentes - // successives. Les métriques réseau n'attendent que l'identité (code centrale). - const detailPromise = pgGetProduitDetail(cleaned); - const reseauPromise = detailPromise.then(async (d) => { - if (!d?.code_centrale) return null; - const map = await getNetworkMetricsByCodeCentrale([d.code_centrale]); - return map.get(d.code_centrale) ?? null; - }); - const [detail, fournisseurs, stock, commandesEnCours, gammes, mensuelRows, reseau] = await Promise.all([ - detailPromise, - pgGetFournisseursByCodein(cleaned), - pgGetStockByCodein(cleaned), - pgGetCommandesByCodein(cleaned), - pgGetGammeHistoryByCodein(cleaned), - pgGetMensuelByCodein(cleaned, dateDebut, dateFin), - reseauPromise, - ]); - if (!detail) return null; - - const months = buildMonthKeys(); - const allowed = new Set(months); - - const mensuelTotal: Record = {}; - const mensuelParSite: Record> = {}; - for (const m of months) mensuelTotal[m] = emptyMois(m); - - // Mois réellement présents dans les mouvements. Indispensable pour distinguer - // « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock - // effectivement nul » — c'est la convention de la Grille. - const moisAvecDonnees = new Set(); - const moisAvecDonneesParSite = new Map>(); - - for (const row of mensuelRows) { - // SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM". - const mois = row.mois.replace("-", ""); - if (!allowed.has(mois)) continue; - const site = String(row.site).trim(); - - const qte = Number(row.qte_vendue) || 0; - const ca = Number(row.ca_ht) || 0; - const marge = Number(row.marge) || 0; - const stockFinMois = Number(row.stock_fin_mois) || 0; - const qteRecue = Number(row.qte_recue) || 0; - - moisAvecDonnees.add(mois); - const tot = mensuelTotal[mois]; - tot.qte += qte; - tot.ca += ca; - tot.marge += marge; - tot.stockFinMois += stockFinMois; - tot.qteRecue += qteRecue; - - mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)])); - if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set()); - moisAvecDonneesParSite.get(site)!.add(mois); - const cell = mensuelParSite[site][mois]; - cell.qte += qte; - cell.ca += ca; - cell.marge += marge; - // Le SQL donne déjà le stock du dernier mouvement du mois pour ce site. - cell.stockFinMois = stockFinMois; - cell.qteRecue += qteRecue; - } - - // Report du stock de fin de mois sur les mois SANS mouvement (carry-forward), - // même convention que la Grille : un mois sans mouvement conserve le stock du - // mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0. - const carryForward = (serie: Record, avecDonnees: Set) => { - let last = 0; - for (const m of months) { - if (avecDonnees.has(m)) last = serie[m].stockFinMois; - else serie[m].stockFinMois = last; - } - }; - carryForward(mensuelTotal, moisAvecDonnees); - for (const [site, bySite] of Object.entries(mensuelParSite)) { - carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set()); - } - - const totaux = totauxFrom(months.map((m) => mensuelTotal[m])); - const totauxParSite: Record = {}; - for (const [site, bySite] of Object.entries(mensuelParSite)) { - totauxParSite[site] = totauxFrom(months.map((m) => bySite[m])); - } - - const sitesActifs = Object.entries(totauxParSite) - .filter(([, t]) => t.qte > 0) - .map(([site]) => site) - .sort(); - - - return { - detail, - codeCentrale: detail.code_centrale ?? "", - libelleReseau: reseau?.libelleReseau ?? "", - fournisseurReseau: reseau?.fournisseurReseau ?? "", - fournisseurs, - stock, - commandesEnCours, - gammes, - months, - mensuelTotal, - mensuelParSite, - totaux, - totauxParSite, - sitesActifs, - reseau, - periode: { dateDebut, dateFin }, - }; -} diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts deleted file mode 100644 index 02c70a5..0000000 --- a/src/features/produits/api/search-produits.ts +++ /dev/null @@ -1,445 +0,0 @@ -import "server-only"; - -import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search"; -import type { NetworkMetric } from "@/lib/qlik-client"; -import { - getNetworkMetricsByCodeCentrale, - upsertNetworkMetrics, - type NetworkMetricCached, -} from "@/lib/qlik-network-cache"; -import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client"; -import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; -import { normalizeMargePct } from "@/features/produits/lib/compare-reseau"; -import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; - -/** - * CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite. - * - * Déroulé : - * 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET - * leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`). - * 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`) - * 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà - * extrait (le cube de recherche est agrégé, il ne le fournit pas). - * - * Une seconde session Qlik pour les mesures faisait abandonner le moteur - * (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app, - * dont une avec une grosse sélection, et l'Engine coupe les requêtes. - * - * Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne - * pas laisser l'utilisateur sans résultat — la réponse le signale explicitement - * (`source: "db"`). - */ - -/** Durée de validité d'un résultat de recherche en cache mémoire. */ -const CACHE_TTL_MS = 10 * 60 * 1000; - -/** Durée de conservation d'un job terminé (le client a le temps de le relire). */ -const JOB_TTL_MS = 5 * 60 * 1000; - -/** - * Série « magasins vendeurs » extraite du détail mensuel du cache. - * - * Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est - * qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la - * série et priverait la tendance de son dénominateur. - */ -function nbMagParMois( - metricsByMonth: Record | null | undefined, -): Record | null { - if (!metricsByMonth) return null; - const parMois: Record = {}; - for (const mois of Object.keys(metricsByMonth)) { - const nb = Number(metricsByMonth[mois]?.nbMag); - parMois[mois] = Number.isFinite(nb) ? nb : 0; - } - return Object.keys(parMois).length > 0 ? parMois : null; -} - -/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */ -function cacheReseauComplet( - cache: NetworkMetricCached | undefined, - periode: string | null, - now: Date = new Date(), -): cache is NetworkMetricCached { - const parMois = cache?.qteByMonth; - if (!parMois || !periode || cache.periode !== periode) return false; - return buildRolling12QlikMonths(now).every((mois) => - Object.prototype.hasOwnProperty.call(parMois, mois) && - Number.isFinite(Number(parMois[mois])), - ); -} - -/** - * Nombre maximum de recherches Qlik simultanées. - * - * Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on - * plafonne plutôt que de lui envoyer N extractions en parallèle. - */ -const MAX_RECHERCHES_SIMULTANEES = 1; - -/** - * Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik - * (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur - * ou un rafraîchissement relancerait toute l'extraction. - */ -const cache = new Map(); - -/** - * Jobs de recherche, indexés par terme normalisé. - * - * Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours - * Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps - * se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le - * client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail - * tourne donc en tâche de fond et le client interroge l'état. - */ -const jobs = new Map(); - -export interface RechercheJob { - jobId: string; - terme: string; - status: "running" | "success" | "error"; - /** Étape en cours, affichée pendant l'attente. */ - etape: string; - startedAt: string; - finishedAt?: string; - error?: string; - result?: ProduitRechercheResultat; -} - -/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */ -export type RechercheEtat = RechercheJob | { status: "idle"; terme: string }; - -function cacheKey(term: string): string { - return term.trim().toLowerCase().replace(/\s+/g, " "); -} - -/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */ -function purge(): void { - const now = Date.now(); - for (const [k, v] of cache) { - if (now - v.at > CACHE_TTL_MS) cache.delete(k); - } - for (const [k, j] of jobs) { - if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k); - } -} - -function jobsEnCours(): number { - let n = 0; - for (const j of jobs.values()) if (j.status === "running") n++; - return n; -} - -function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob { - return { - jobId: `cache_${cacheKey(terme)}`, - terme, - status: "success", - etape: "Terminé", - startedAt: new Date().toISOString(), - finishedAt: new Date().toISOString(), - result, - }; -} - -/** - * Démarre (ou réutilise) une recherche et rend la main **immédiatement**. - * - * - résultat déjà en cache et pas de `force` → job `success` directement ; - * - recherche déjà en cours sur le même terme → on renvoie ce job ; - * - sinon on lance le travail en tâche de fond. - */ -export function demarrerRecherche( - term: string, - options: { force?: boolean } = {}, -): RechercheJob { - const cleaned = term.trim(); - const key = cacheKey(cleaned); - purge(); - - if (!options.force) { - const hit = cache.get(key); - if (hit) { - console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`); - return jobTermine(cleaned, hit.result); - } - } - - const existant = jobs.get(key); - if (existant && existant.status === "running") return existant; - if (existant && !options.force && existant.status === "success" && existant.result) return existant; - - if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) { - return { - jobId: `refus_${Date.now().toString(36)}`, - terme: cleaned, - status: "error", - etape: "Refusé", - startedAt: new Date().toISOString(), - finishedAt: new Date().toISOString(), - error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.", - }; - } - - const job: RechercheJob = { - jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, - terme: cleaned, - status: "running", - etape: "Recherche des articles dans Qlik…", - startedAt: new Date().toISOString(), - }; - jobs.set(key, job); - console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`); - - // Fire-and-forget : le client suivra l'avancement via `etatRecherche`. - void executerRecherche(cleaned, (etape) => { job.etape = etape; }) - .then((result) => { - job.result = result; - job.status = "success"; - job.etape = "Terminé"; - job.finishedAt = new Date().toISOString(); - // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un - // repli catalogue (panne Qlik passagère) le figerait 10 minutes - // alors qu'un simple « Relancer » aurait suffi. - if (result.source === "qlik" && !result.qlikError) { - cache.set(key, { at: Date.now(), result }); - } - console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`); - }) - .catch((e) => { - const message = e instanceof Error ? e.message : String(e); - job.status = "error"; - job.etape = "Échec"; - job.error = messageQlikLisible(message); - job.finishedAt = new Date().toISOString(); - console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300)); - }); - - return job; -} - -/** État courant de la recherche pour ce terme (pour le polling client). */ -export function etatRecherche(term: string): RechercheEtat { - const cleaned = term.trim(); - const key = cacheKey(cleaned); - purge(); - const job = jobs.get(key); - if (job) return job; - const hit = cache.get(key); - if (hit) return jobTermine(cleaned, hit.result); - return { status: "idle", terme: cleaned }; -} - -async function executerRecherche( - term: string, - onEtape: (etape: string) => void = () => {}, -): Promise { - const started = Date.now(); - - // La recherche catalogue local tourne en parallèle : elle sert à la fois de - // repli si Qlik échoue, et de complément (produits Nancy sans code centrale, - // donc invisibles côté réseau). - const localPromise = pgSearchProduits(term).catch((e) => { - console.error("[produits/search] recherche locale échouée:", (e as Error).message); - return [] as PgProduitSearchRow[]; - }); - - let qlik; - try { - onEtape("Recherche des articles dans Qlik…"); - qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - console.error("[produits/search] Qlik indisponible, repli catalogue local:", message); - const locaux = await localPromise; - return { - rows: locaux.map(rowDepuisCatalogueSeul), - source: "db", - qlikError: messageQlikLisible(message), - tronque: locaux.length >= 50, - champUtilise: null, - locauxHorsReseau: [], - dureeMs: Date.now() - started, - }; - } - - const codes = qlik.matches.map((m) => m.codeCentrale); - if (codes.length === 0) { - const locaux = await localPromise; - return { - rows: [], - source: "qlik", - // `avertissement` n'est renseigné que si Qlik a répondu sans appliquer - // le filtre : l'utilisateur doit savoir que ce n'est pas « pas de - // produit » mais « recherche non appliquée ». - qlikError: qlik.avertissement, - tronque: false, - champUtilise: qlik.champUtilise, - locauxHorsReseau: locaux, - dureeMs: Date.now() - started, - }; - } - - // Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik). - // Les extraire dans une seconde session concurrente faisait abandonner le - // moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la - // recherche alors que la sync de la Grille, elle seule sur le serveur, - // fonctionnait. - // - // Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit - // dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche - // produit propose son bouton « Actualiser depuis Qlik ». - onEtape("Rapprochement avec notre catalogue…"); - const [cacheReseau, catalogue, locaux] = await Promise.all([ - getNetworkMetricsByCodeCentrale(codes).catch((e) => { - console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message); - return new Map(); - }), - // Deux clés candidates : la dimension « Article Code » et le champ - // `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et - // une seule joint avec `articles.artcentrale`. - pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]), - localPromise, - ]); - - // Le cube de recherche utilise encore les master measures annuelles pour - // classer rapidement les résultats. Il ne doit jamais écraser une extraction - // 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec - // leur mensuel. Sans cache complet, les valeurs de découverte restent - // temporaires jusqu'au bouton « Actualiser depuis Qlik ». - try { - const aPersister: NetworkMetric[] = qlik.matches.map((m) => { - const cache = cacheReseau.get(m.codeCentrale); - const fiable = cacheReseauComplet(cache, qlik.periode); - return { - codeCentrale: m.codeCentrale, - caReseau: fiable ? cache.caReseau : m.caReseau, - qteReseau: fiable ? cache.qteReseau : m.qteReseau, - nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau, - caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau, - margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau, - periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined, - qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined, - metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined, - libelleReseau: m.libelle || undefined, - fournisseurReseau: m.fournisseur || undefined, - }; - }); - const n = await upsertNetworkMetrics(aPersister); - console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); - } catch (e) { - console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message); - } - - const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { - const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined); - const cache = cacheReseau.get(m.codeCentrale); - const fiable = cacheReseauComplet(cache, qlik.periode); - const qteReseau = fiable ? cache.qteReseau : m.qteReseau; - const caReseau = fiable ? cache.caReseau : m.caReseau; - const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau; - const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau; - const margePct = fiable ? cache.margePctReseau : m.margePctReseau; - return { - codeCentrale: m.codeCentrale, - libelle: local?.libelle1 || m.libelle || m.codeCentrale, - libelleReseau: m.libelle, - fournisseur: local?.fournisseur || m.fournisseur || "", - codein: local?.codein ?? null, - nomenclature: local?.nomenclature ?? "", - stockLocal: local ? Number(local.stock_total) || 0 : null, - qteReseau, - caReseau, - nbMagasinsReseau: nbMag, - tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, - caParMagasinReseau: caParMagasin, - prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null, - margePctReseau: normalizeMargePct(margePct), - // Le mensuel ne vient que du cache : le cube de recherche est agrégé. - qteByMonth: fiable ? cache.qteByMonth : null, - nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null, - periode: qlik.periode, - }; - }); - - // Les plus vendus du réseau d'abord : c'est l'information utile pour - // arbitrer, un produit que le réseau ne vend pas n'intéresse personne. - rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr")); - - // Les produits Nancy déjà couverts par un résultat réseau ne doivent pas - // réapparaître dans la liste secondaire. On compare sur les deux clés - // candidates, la jointure pouvant se faire par l'une ou par l'autre. - const codesTrouves = new Set(codes); - for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt); - const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale)); - - // Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format - // de clé différent entre Qlik et `articles.artcentrale`. - if (catalogue.size === 0 && codes.length > 0) { - console.warn( - `[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` + - `échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` + - `article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`, - ); - } - - return { - rows, - source: "qlik", - qlikError: qlik.avertissement, - tronque: qlik.tronque, - champUtilise: qlik.champUtilise, - locauxHorsReseau, - dureeMs: Date.now() - started, - }; -} - -/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */ -function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow { - const qteReseau = Number(r.qte_reseau) || 0; - const nbMag = Number(r.nb_magasins_reseau) || 0; - return { - codeCentrale: r.code_centrale, - libelle: r.libelle1 || r.codein, - libelleReseau: "", - fournisseur: r.fournisseur, - codein: r.codein, - nomenclature: r.nomenclature, - stockLocal: Number(r.stock_total) || 0, - qteReseau, - caReseau: 0, - nbMagasinsReseau: nbMag, - tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, - caParMagasinReseau: 0, - prixMoyenReseau: null, - margePctReseau: null, - qteByMonth: null, - nbMagByMonth: null, - periode: null, - }; -} - -/** - * Traduit les pannes Qlik récurrentes en message actionnable. Les autres - * erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables. - */ -function messageQlikLisible(message: string): string { - const lower = message.toLowerCase(); - if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) { - return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard."; - } - if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) { - return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez."; - } - if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) { - return "Identifiants Qlik non configurés (Paramètres → Qlik)."; - } - if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) { - return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN)."; - } - return message.slice(0, 300); -} diff --git a/src/features/produits/types.ts b/src/features/produits/types.ts index 4aeb292..b070138 100644 --- a/src/features/produits/types.ts +++ b/src/features/produits/types.ts @@ -8,8 +8,8 @@ import type { PgProduitSearchRow, PgProduitStockRow, PgGammeHistoryRow, -} from "@/lib/pg-ff-client"; -import type { NetworkMetricCached } from "@/lib/qlik-network-cache"; + NetworkMetricCached, +} from "@/types/ff"; /** * Une ligne de résultat de recherche produit. diff --git a/src/features/qlik-sync/use-qlik-sync-job.ts b/src/features/qlik-sync/use-qlik-sync-job.ts index a2a0fa3..614131a 100644 --- a/src/features/qlik-sync/use-qlik-sync-job.ts +++ b/src/features/qlik-sync/use-qlik-sync-job.ts @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useRef, useState } from "react"; /** État serveur renvoyé par GET/POST /api/qlik/sync. */ diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index dea37f4..fb51b72 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -1,17 +1,14 @@ -"use server"; - /** - * Actions des Paramètres — réservées aux administrateurs. + * Actions des Paramètres — réservées aux administrateurs, exécutées par le + * serveur Rust : `POST /api/actions/{nom}`. * * Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et * les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide * dans le formulaire signifie « conserver celui qui est enregistré ». */ -import { Pool } from "pg"; -import { requireAdmin, verifierAdmin } from "@/lib/authz"; -import { CONFIG_FILE, readConfig, writeConfig } from "./config-file"; -import { diagnostiquerApiFf } from "./ff-api-diagnostic"; +import { callAction } from "@/lib/rpc"; +import type { FfSyncStatus } from "@/types/ff"; export interface ChampsBaseDeDonnees { host: string; @@ -37,181 +34,45 @@ export interface ParametresAffichables { ffApiBaseUrl: string; } -/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */ -function decoder(valeur: string): string { - try { - return decodeURIComponent(valeur); - } catch { - return valeur; - } +export interface ResultatAction { + success: boolean; + error?: string; } -/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */ -function lireUrlPostgres(url: string) { - try { - const u = new URL(url); - return { - host: u.hostname, - port: u.port || "5432", - database: decoder(u.pathname.slice(1)), - user: decoder(u.username), - password: decoder(u.password), - ssl: url.includes("sslmode=require"), - }; - } catch (e) { - console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message); - return null; - } -} - -function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string { - const user = encodeURIComponent(champs.user.trim()); - const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user; - const port = champs.port.trim() || "5432"; - return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`; -} - -/** Mot de passe saisi, sinon celui de l'URL enregistrée. */ -async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise { - if (champs.password) return champs.password; - const { url } = await readConfig(); - return (url && lireUrlPostgres(url)?.password) || ""; -} +/** Diagnostic de l'API FF Nancy (bouton « Tester »). */ +export type DiagnosticApiFf = + | { success: true; url: string; ms: number; status: FfSyncStatus } + | { success: false; url: string; error: string }; /** Réglages enregistrés, sans aucun secret. */ export async function getParametresAffichables(): Promise { - await requireAdmin(); - const config = await readConfig(); - const base = config.url ? lireUrlPostgres(config.url) : null; - return { - db: base - ? { - host: base.host, - port: base.port, - database: base.database, - user: base.user, - ssl: base.ssl, - motDePasseEnregistre: base.password !== "", - } - : null, - qlik: { - host: config.qlikHost ?? "", - user: config.qlikUser ?? "", - // Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement. - motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD), - }, - ffApiBaseUrl: config.ffApiBaseUrl ?? "", - }; + return callAction("getParametresAffichables", []); } -export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - const url = construireUrlPostgres(champs, await motDePasseBase(champs)); - console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@")); - const pool = new Pool({ - connectionString: url, - connectionTimeoutMillis: 5000, - }); - - try { - const client = await pool.connect(); - await client.query("SELECT 1"); - client.release(); - return { success: true }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Database connection test failed:", errorMessage); - return { success: false, error: errorMessage }; - } finally { - await pool.end(); - } +export async function testDatabaseConnection(champs: ChampsBaseDeDonnees): Promise { + return callAction("testDatabaseConnection", [champs]); } -export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - const url = construireUrlPostgres(champs, await motDePasseBase(champs)); - // Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés. - console.log(`[Settings] Saving config to ${CONFIG_FILE}`); - await writeConfig({ url }); - console.log("[Settings] Database configuration saved successfully."); - - // Refresh the shared DB instance immediately - const { refreshDb } = await import("@/db"); - refreshDb(); - - return { success: true }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Failed to save database configuration:", errorMessage); - return { success: false, error: errorMessage }; - } +export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees): Promise { + return callAction("saveDatabaseSettings", [champs]); } /** Mot de passe vide ou absent : celui qui est enregistré est conservé. */ -export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) }); - console.log("[Settings] Qlik configuration saved."); - return { success: true }; - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - console.error("[Settings] Failed to save Qlik config:", msg); - return { success: false, error: msg }; - } +export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string): Promise { + return callAction("saveQlikSettings", [qlikHost, qlikUser, qlikPassword]); } /** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */ -export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client"); - const enregistre = getQlikConfig(); - const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password }; - if (qlikHost) cfg.host = qlikHost; - await qlikNtlmSession(cfg); - return { success: true }; - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - return { success: false, error: msg }; - } +export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string): Promise { + return callAction("testQlikConnection", [qlikHost, qlikUser, qlikPassword]); } /** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */ -export async function saveFfApiSettings(ffApiBaseUrl: string) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, ""); - if (cleaned && !/^https?:\/\//i.test(cleaned)) { - return { success: false, error: "L'URL doit commencer par http:// ou https://" }; - } - await writeConfig({ ffApiBaseUrl: cleaned || undefined }); - // Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. - const { resetFfApiBaseCache } = await import("@/lib/api-ff-client"); - resetFfApiBaseCache(); - console.log(`[Settings] FF API base URL saved: ${cleaned || "(défaut)"}`); - return { success: true }; - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - console.error("[Settings] Failed to save FF API config:", msg); - return { success: false, error: msg }; - } +export async function saveFfApiSettings(ffApiBaseUrl: string): Promise { + return callAction("saveFfApiSettings", [ffApiBaseUrl]); } -/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */ -export async function testFfApiConnection(ffApiBaseUrl?: string): Promise>> { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, url: "", error: acces.message }; - return diagnostiquerApiFf(ffApiBaseUrl); +/** Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP). */ +export async function testFfApiConnection(ffApiBaseUrl?: string): Promise { + return callAction("testFfApiConnection", [ffApiBaseUrl]); } diff --git a/src/features/settings/components/server-logs.tsx b/src/features/settings/components/server-logs.tsx index 21c53a5..e2f2588 100644 --- a/src/features/settings/components/server-logs.tsx +++ b/src/features/settings/components/server-logs.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState } from "react"; import { Download, Loader2, RefreshCw, FileText } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; diff --git a/src/features/settings/config-file.ts b/src/features/settings/config-file.ts deleted file mode 100644 index b9745bb..0000000 --- a/src/features/settings/config-file.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * CollectFlow — Fichier de configuration `data/.db-config.json`. - * - * Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être - * appelables depuis le navigateur (le fichier contient des mots de passe et des - * clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle. - */ - -import "server-only"; - -import fs from "fs/promises"; -import path from "path"; - -export const DATA_DIR = path.join(process.cwd(), "data"); -export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json"); - -export interface DbConfig { - url: string; - openRouterKey?: string; - openRouterModel?: string; - aiProvider?: "openrouter" | "google"; - googleAiKey?: string; - googleAiModel?: string; - /** Qlik Sense — données réseau */ - qlikHost?: string; - qlikUser?: string; - qlikPassword?: string; - /** - * API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et - * au rattrapage des ventes par magasin dans la Grille. Configurable ici pour - * ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer - * sans redéployer. - */ - ffApiBaseUrl?: string; -} - -/** Lit la config existante (ou {} si absente). */ -export async function readConfig(): Promise> { - try { - const data = await fs.readFile(CONFIG_FILE, "utf-8"); - return JSON.parse(data); - } catch { - return {}; - } -} - -/** - * Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont - * conservées telles quelles (une clé passée à `undefined` est retirée). - */ -export async function writeConfig(patch: Partial): Promise { - const existing = await readConfig(); - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2)); -} diff --git a/src/features/settings/ff-api-diagnostic.ts b/src/features/settings/ff-api-diagnostic.ts deleted file mode 100644 index 50409a9..0000000 --- a/src/features/settings/ff-api-diagnostic.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * CollectFlow — Diagnostic de l'API FF Nancy. - * - * Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la - * route `/api/ff-status` (tout utilisateur connecté). Module serveur et non - * fichier d'actions : chaque appelant fait son propre contrôle d'accès. - */ - -import "server-only"; - -import { readConfig } from "./config-file"; - -/** - * Teste l'API FF Nancy et renvoie un diagnostic **exploitable**. - * - * L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir - * si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue - * donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. - */ -export async function diagnostiquerApiFf(ffApiBaseUrl?: string) { - const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, ""); - const url = `${base}/api/sync/status`; - const started = Date.now(); - try { - const res = await fetch(url, { - cache: "no-store", - signal: AbortSignal.timeout(8000), - }); - const ms = Date.now() - started; - if (!res.ok) { - return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` }; - } - const body = await res.json().catch(() => null); - if (!body) { - return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` }; - } - // La réponse brute de l'API n'a pas la forme attendue par l'interface : - // sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200. - const { normalizeSyncStatus } = await import("@/lib/api-ff-client"); - const status = normalizeSyncStatus(body); - if (!status) { - return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` }; - } - return { success: true, url, ms, status }; - } catch (error: unknown) { - const ms = Date.now() - started; - const raw = error instanceof Error ? error.message : String(error); - const name = error instanceof Error ? error.name : ""; - // fetch masque la cause réelle derrière « fetch failed » : on la déplie. - const cause = (error as { cause?: { code?: string; message?: string } })?.cause; - let hint = raw; - if (name === "TimeoutError" || name === "AbortError") { - hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`; - } else if (cause?.code === "ENOTFOUND") { - hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`; - } else if (cause?.code === "ECONNREFUSED") { - hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`; - } else if (cause?.code) { - hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`; - } - console.error(`[Settings] FF API test KO (${url}):`, raw); - return { success: false, url, error: hint }; - } -} diff --git a/src/features/settings/store/use-db-settings-store.ts b/src/features/settings/store/use-db-settings-store.ts index 3afdba7..990870c 100644 --- a/src/features/settings/store/use-db-settings-store.ts +++ b/src/features/settings/store/use-db-settings-store.ts @@ -1,5 +1,3 @@ -"use client"; - import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/src/features/snapshots/api/delete-snapshot.ts b/src/features/snapshots/api/delete-snapshot.ts index 09ce9de..1b4fd73 100644 --- a/src/features/snapshots/api/delete-snapshot.ts +++ b/src/features/snapshots/api/delete-snapshot.ts @@ -1,39 +1,10 @@ -"use server"; +/** + * Suppression d'un enregistrement de l'historique — action exécutée par le + * serveur Rust : `POST /api/actions/deleteSnapshot`. + */ -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; -import { verifierSession } from "@/lib/authz"; +import { callAction } from "@/lib/rpc"; -export async function deleteSnapshot(id: number) { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, error: acces.message }; - - const rawUserId = acces.utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; - - console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`); - - try { - // Condition de sécurité : match ID ET match le propriétaire (ou anonyme) - const userCondition = userId && !isNaN(userId) - ? eq(sessionSnapshots.userId, userId) - : isNull(sessionSnapshots.userId); - - const result = await db.delete(sessionSnapshots) - .where(and(eq(sessionSnapshots.id, id), userCondition)) - .returning({ deletedId: sessionSnapshots.id }); - - if (result.length === 0) { - console.warn(`[deleteSnapshot] No rows deleted for ID ${id}. Snapshot may not exist or belongs to another user.`); - return { success: false, error: "Snapshot non trouvé ou non autorisé" }; - } - - console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`); - return { success: true }; - } catch (err) { - // Le détail technique reste dans les journaux du serveur. - console.error("[deleteSnapshot] DB Error:", err); - return { success: false, error: "erreur technique sur le serveur" }; - } +export async function deleteSnapshot(id: number): Promise<{ success: boolean; error?: string }> { + return callAction("deleteSnapshot", [id]); } diff --git a/src/features/snapshots/api/get-snapshots.ts b/src/features/snapshots/api/get-snapshots.ts index e636a2b..8d6c84f 100644 --- a/src/features/snapshots/api/get-snapshots.ts +++ b/src/features/snapshots/api/get-snapshots.ts @@ -1,34 +1,26 @@ -"use server"; +/** + * Historique des sessions de révision et des exports — action exécutée par le + * serveur Rust : `POST /api/actions/getSnapshots`. + */ -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { requireSession } from "@/lib/authz"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { callAction } from "@/lib/rpc"; -export async function getSnapshots(type?: "snapshot" | "export") { - const utilisateur = await requireSession(); - try { - const rawUserId = utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; +/** Une ligne de `session_snapshots`, telle que le serveur la renvoie. */ +export interface SnapshotRow { + id: number; + userId: number | null; + codeFournisseur: string; + nomFournisseur: string | null; + magasin: string; + /** codein → { before, after } */ + changes: unknown; + summaryJson: unknown; + label: string | null; + type: string | null; + /** Date ISO de création. */ + createdAt: string | null; +} - // Si on a un userId, on filtre par celui-ci. - // Sinon, on filtre par userId IS NULL (mode anonyme/local) - const userCondition = userId && !isNaN(userId) - ? eq(sessionSnapshots.userId, userId) - : isNull(sessionSnapshots.userId); - - const conditions = [userCondition]; - if (type) conditions.push(eq(sessionSnapshots.type, type)); - - return await db.select() - .from(sessionSnapshots) - .where(and(...conditions)) - .orderBy(desc(sessionSnapshots.createdAt)); - } catch (err) { - const msg = ((err as Error)?.message || String(err)).split("\n")[0]; - console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err); - // Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot » - // au lieu de signaler que le chargement a échoué. - throw new Error("Chargement de l'historique impossible."); - } +export async function getSnapshots(type?: "snapshot" | "export"): Promise { + return callAction("getSnapshots", [type]); } diff --git a/src/features/snapshots/api/save-snapshot.ts b/src/features/snapshots/api/save-snapshot.ts index 81445fc..0b30d7e 100644 --- a/src/features/snapshots/api/save-snapshot.ts +++ b/src/features/snapshots/api/save-snapshot.ts @@ -1,114 +1,27 @@ -"use server"; +/** + * Enregistrement d'une session de révision ou d'un export — action exécutée par + * le serveur Rust : `POST /api/actions/saveSnapshot` (validation des données + * comprise). + */ -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { z } from "zod"; -import { sql, and, eq } from "drizzle-orm"; -import { verifierSession } from "@/lib/authz"; -import { revalidatePath } from "next/cache"; +import { callAction } from "@/lib/rpc"; -const SaveSnapshotSchema = z.object({ - codeFournisseur: z.string(), - nomFournisseur: z.string().optional(), - magasin: z.string(), - label: z.string().optional(), - changes: z.record(z.string(), z.object({ - before: z.string().nullable(), - after: z.string(), - })), - type: z.enum(["snapshot", "export"]).optional(), - summary: z.object({ - totalRows: z.number(), - totalCa: z.number(), - totalMarge: z.number(), - tauxMargeGlobal: z.number(), - }).optional(), -}); - -export async function saveSnapshot(raw: unknown) { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, error: acces.message }; - - const parsed = SaveSnapshotSchema.safeParse(raw); - if (!parsed.success) { - console.error("Snapshot validation failed:", parsed.error.format()); - return { success: false, error: "Validation failed: " + parsed.error.issues.map((i: any) => i.message).join(", ") }; - } - - const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data; - const rawUserId = acces.utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; - - console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`); - - const finalUserId = (userId && !isNaN(userId)) ? userId : null; - - try { - const [created] = await db - .insert(sessionSnapshots) - .values({ - userId: finalUserId, - codeFournisseur, - nomFournisseur: nomFournisseur ?? null, - magasin, - label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`, - changes, - summaryJson: summary ?? null, - type: type ?? "snapshot", - }) - .returning({ id: sessionSnapshots.id }); - - revalidatePath("/snapshots"); - revalidatePath("/exports"); - return { success: true, snapshotId: created?.id }; - } catch (err) { - console.error("Initial snapshot save failed, attempting auto-repair...", err); - - try { - // Tentative systématique de création de table (no-op si déjà là) - await db.execute(sql` - CREATE TABLE IF NOT EXISTS session_snapshots ( - id SERIAL PRIMARY KEY, - user_id INTEGER, - code_fournisseur VARCHAR(20) NOT NULL, - nom_fournisseur VARCHAR(255), - magasin VARCHAR(20) NOT NULL, - changes JSONB NOT NULL, - summary_json JSONB, - label TEXT, - type VARCHAR(20) DEFAULT 'snapshot', - created_at TIMESTAMP DEFAULT NOW() - ); - `); - - // Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute - try { - await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'`); - await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER`); - } catch (e) { - // Ignore - } - - // Deuxième tentative d'insertion - const [retryCreated] = await db - .insert(sessionSnapshots) - .values({ - userId: finalUserId, - codeFournisseur, - nomFournisseur: nomFournisseur ?? null, - magasin, - label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`, - changes, - summaryJson: summary ?? null, - type: type ?? "snapshot", - }) - .returning({ id: sessionSnapshots.id }); - - return { success: true, snapshotId: retryCreated?.id }; - } catch (repairErr) { - // Le détail technique (les deux erreurs) reste dans les journaux du serveur. - console.error("Snapshot auto-repair/retry failed:", repairErr); - return { success: false, error: "erreur technique sur le serveur" }; - } - } +/** Données attendues par le serveur (ex-schéma zod `SaveSnapshotSchema`). */ +export interface SaveSnapshotInput { + codeFournisseur: string; + nomFournisseur?: string; + magasin: string; + label?: string; + changes: Record; + type?: "snapshot" | "export"; + summary?: { + totalRows: number; + totalCa: number; + totalMarge: number; + tauxMargeGlobal: number; + }; +} + +export async function saveSnapshot(raw: unknown): Promise<{ success: boolean; snapshotId?: number; error?: string }> { + return callAction("saveSnapshot", [raw]); } diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx index 20aa972..70a3bc7 100644 --- a/src/features/snapshots/components/snapshot-list.tsx +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { diff --git a/src/instrumentation.ts b/src/instrumentation.ts deleted file mode 100644 index 4776c24..0000000 --- a/src/instrumentation.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * CollectFlow — Point d'entrée exécuté une fois au démarrage du serveur. - * - * Sert à armer le minuteur de la synchronisation nocturne. L'import est - * dynamique et conditionné au runtime Node : `register()` est aussi évalué - * dans le runtime Edge, où ni `setInterval` persistant ni l'accès à la base - * n'ont de sens. - */ -export async function register() { - if (process.env.NEXT_RUNTIME !== "nodejs") return; - - try { - const { startSyncTimer } = await import("@/features/admin/api/sync-timer"); - startSyncTimer(); - } catch (e) { - // Un démarrage réussi prime sur le planificateur : s'il ne s'arme pas, - // l'application doit rester utilisable et la synchro manuelle disponible. - console.error("[instrumentation] minuteur de synchro non démarré :", e instanceof Error ? e.message : String(e)); - } -} diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts deleted file mode 100644 index b5d3cbb..0000000 --- a/src/lib/api-auth.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * CollectFlow — Authentification de l'API publique `/api/v1`. - * - * Deux voies d'entrée : - * 1. **Clé d'API** (`X-API-Key` ou `Authorization: Bearer …`) — pour les appelants - * non-navigateur : scripts, outils externes, assistant IA. - * 2. **Session NextAuth** — le front de CollectFlow utilise donc les mêmes routes. - * - * En cas d'échec on renvoie un **401 JSON**, jamais une redirection : `src/middleware.ts` - * exempte `/api/v1/*` précisément pour que ce module puisse répondre proprement. - * - * Seul le hachage SHA-256 des clés est stocké (table `api_keys`) ; la clé en clair - * n'existe qu'au moment de sa création. - */ - -import "server-only"; - -import { createHash, randomBytes, timingSafeEqual } from "crypto"; -import type { NextRequest } from "next/server"; -import { db } from "@/db"; -import { apiKeys } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { auth } from "@/lib/auth"; -import { fail } from "@/lib/api-response"; - -export interface AuthContext { - /** Origine de l'authentification — utile pour les logs et l'audit. */ - via: "api_key" | "session"; - role: string; - /** Nom de la clé, ou identifiant de l'utilisateur connecté. */ - subject: string; -} - -/** Intervalle minimal entre deux mises à jour de `last_used_at` pour une même clé. */ -const TRACE_USAGE_MS = 60_000; -const derniereTrace = new Map(); - -/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */ -const KEY_PREFIX = "cf_"; - -export function hashApiKey(key: string): string { - return createHash("sha256").update(key, "utf8").digest("hex"); -} - -/** - * Génère une clé d'API. Renvoie la clé **en clair** (à afficher une seule fois), - * son hachage à stocker, et un préfixe lisible pour l'identifier dans l'UI. - */ -export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } { - const secret = randomBytes(24).toString("base64url"); - const key = `${KEY_PREFIX}${secret}`; - return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, 11) }; -} - -/** Comparaison à temps constant de deux hachages hexadécimaux. */ -function hashesEqual(a: string, b: string): boolean { - const ba = Buffer.from(a, "hex"); - const bb = Buffer.from(b, "hex"); - if (ba.length !== bb.length || ba.length === 0) return false; - return timingSafeEqual(ba, bb); -} - -/** Extrait la clé d'un en-tête `X-API-Key` ou `Authorization: Bearer …`. */ -function readKeyFromRequest(req: NextRequest): string | null { - const direct = req.headers.get("x-api-key"); - if (direct?.trim()) return direct.trim(); - const authz = req.headers.get("authorization"); - if (authz) { - const m = authz.match(/^Bearer\s+(.+)$/i); - if (m) return m[1].trim(); - } - return null; -} - -/** - * Authentifie une requête `/api/v1`. - * - * Renvoie un `AuthContext` en cas de succès, ou une réponse d'erreur JSON prête à - * être retournée par le handler. À appeler en première ligne de chaque endpoint : - * - * const authCtx = await requireApiAuth(req); - * if (authCtx instanceof NextResponse) return authCtx; - */ -export async function requireApiAuth(req: NextRequest): Promise { - const presented = readKeyFromRequest(req); - - if (presented) { - const presentedHash = hashApiKey(presented); - // La colonne key_hash est unique : la recherche par égalité suffit, et la - // comparaison à temps constant ci-dessous couvre le reste. - const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, presentedHash)).limit(1); - - if (!row || !hashesEqual(row.keyHash, presentedHash)) { - return fail("unauthorized", "Clé d'API invalide."); - } - if (row.revokedAt) { - return fail("unauthorized", "Clé d'API révoquée."); - } - - // Trace d'usage — jamais bloquante, et au plus une écriture par minute et - // par clé : un script qui pagine ne déclenche plus un UPDATE par requête. - const maintenant = Date.now(); - if (maintenant - (derniereTrace.get(row.id) ?? 0) >= TRACE_USAGE_MS) { - derniereTrace.set(row.id, maintenant); - void db.update(apiKeys).set({ lastUsedAt: new Date(maintenant) }).where(eq(apiKeys.id, row.id)) - .catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120))); - } - - return { via: "api_key", role: row.role, subject: row.name }; - } - - // Repli sur la session du navigateur (le front utilise les mêmes routes). - const session = await auth(); - if (session?.user) { - const user = session.user as { name?: string | null; role?: string }; - return { via: "session", role: user.role ?? "user", subject: user.name ?? "session" }; - } - - return fail( - "unauthorized", - "Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.", - ); -} - -/** Restreint un endpoint aux appelants administrateurs. */ -export function requireAdminRole(ctx: AuthContext): Response | null { - if (ctx.role !== "admin") { - return fail("forbidden", "Rôle administrateur requis pour cette opération."); - } - return null; -} diff --git a/src/lib/api-enrich.ts b/src/lib/api-enrich.ts deleted file mode 100644 index 143ca9d..0000000 --- a/src/lib/api-enrich.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * CollectFlow — Enrichissement des lignes servies par `/api/v1`. - * - * L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations - * doivent pourtant refléter l'état **courant** au moment de l'appel : - * - * 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par - * les synchronisations et les recherches réseau, indépendamment du calcul de la - * grille. On les relit donc à la lecture, et on les expose telles quelles quand - * elles existent (`network: null` sinon). - * - * 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session - * (modification locale non enregistrée côté serveur). L'API expose la gamme **non - * modifiée**, celle réellement présente en base PostgreSQL, relue à chaque appel. - * - * Ce n'est pas un recalcul : deux lectures indexées bornées au nombre de lignes de la - * page (500 au maximum), de l'ordre de quelques millisecondes. - */ - -import "server-only"; - -import type { ProductRow } from "@/types/grid"; -import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache"; -import { pgGetGammesByCodeins } from "@/lib/pg-ff-client"; -import { gammesAValiderParArticle } from "@/lib/gammes-a-valider"; -// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans -// l'application, pas une seconde implémentation qui divergerait. -import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend"; - -/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */ -export interface ProductTrend { - direction: "up" | "down" | "flat"; - /** Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. */ - pct: number | null; - /** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */ - label: string; - /** - * `true` = tendance calculée sur la quantité **par magasin vendeur**, ce qui - * neutralise l'effet d'un simple élargissement de la distribution. `false` = - * repli sur les quantités brutes, faute de nombre de magasins sur 12 mois. - */ - surQteParMagasin: boolean; - /** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */ - nouveau: boolean; - /** Nombre de mois de la série (12 quand elle est complète). */ - monthsUsed: number; -} - -export interface EnrichedProductRow extends ProductRow { - /** - * Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée. - * `null` si l'article n'a aucune gamme en base. - */ - codeGammeServeur: string | null; - /** - * Gamme proposée par l'API et pas encore validée dans la Grille. - * `null` = aucune proposition en attente. - */ - gammeAValider: string | null; - /** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */ - network: NetworkMetricCached | null; - /** - * Tendance réseau calculée par régression linéaire sur les 12 derniers mois. - * `null` quand il n'y a aucune série mensuelle. Fournie pour que l'appelant — - * en particulier un agent — n'ait pas à réimplémenter le calcul et à diverger - * de ce que montre l'application. - */ - trend: ProductTrend | null; -} - -/** - * Enrichit un lot de lignes. Les deux lectures sont indépendantes et lancées en - * parallèle ; un échec de l'une n'empêche pas l'autre (l'API reste servie, avec - * la valeur de l'instantané en repli). - */ -export async function enrichRows(rows: ProductRow[]): Promise { - if (rows.length === 0) return []; - - const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))]; - const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))]; - - const [network, gammes, aValider] = await Promise.all([ - codesCentraux.length - ? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => { - console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160)); - return new Map(); - }) - : Promise.resolve(new Map()), - codeins.length - ? pgGetGammesByCodeins(codeins).catch((e) => { - console.error("[api-enrich] gammes serveur KO:", (e as Error).message?.slice(0, 160)); - return new Map(); - }) - : Promise.resolve(new Map()), - gammesAValiderParArticle(codeins).catch((e) => { - console.error("[api-enrich] gammes à valider KO:", (e as Error).message?.slice(0, 160)); - return new Map(); - }), - ]); - - return rows.map((row) => { - const metrics = row.codeCentrale ? network.get(row.codeCentrale) ?? null : null; - // Gamme absente de la base = pas de gamme, et non « inconnue » : on distingue - // explicitement par `null` plutôt que de retomber sur la valeur du snapshot. - const gammeServeur = gammes.get(row.codein) ?? null; - - const enriched: EnrichedProductRow = { - ...row, - codeGammeServeur: gammeServeur, - gammeAValider: aValider.get(`${row.codeFournisseur}|${row.codein}`) ?? null, - network: metrics, - trend: null, // renseignée plus bas, une fois la série mensuelle connue - }; - - // `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille : - // on la garde alignée pour que les deux champs ne divergent jamais. - if (gammes.size > 0) { - enriched.codeGammeInit = gammeServeur as ProductRow["codeGammeInit"]; - } - - // Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le - // cache quand il a quelque chose à dire. - if (metrics) { - enriched.caReseau = metrics.caReseau; - enriched.qteReseau = metrics.qteReseau; - enriched.nbMagasinsReseau = metrics.nbMagasinsReseau; - enriched.caParMagasinReseau = metrics.caParMagasinReseau; - enriched.margePctReseau = metrics.margePctReseau; - enriched.qteReseauByMonth = metrics.qteByMonth; - enriched.networkFetchedAt = metrics.fetchedAt ?? undefined; - } - - // Tendance : mêmes entrées que la colonne « Tendance » de la Grille, à - // savoir la série des quantités **et** celle du nombre de magasins vendeurs - // — sans la seconde, le calcul se rabat sur les quantités brutes et - // confond « vend mieux » avec « est distribué plus largement ». - const serie = enriched.qteReseauByMonth; - const nbMagByMonth = metrics?.metricsByMonth - ? Object.fromEntries( - Object.entries(metrics.metricsByMonth).map(([mois, m]) => [mois, Number(m?.nbMag) || 0]), - ) - : null; - const t = computeNetworkTrend(serie, nbMagByMonth); - if (t.hasData) { - enriched.trend = { - direction: t.direction, - pct: t.pct, - label: trendLabel(t.pct, t.nouveau), - surQteParMagasin: t.surQteParMagasin, - nouveau: t.nouveau, - monthsUsed: t.values.length, - }; - } - - return enriched; - }); -} diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts deleted file mode 100644 index 3a3e0aa..0000000 --- a/src/lib/api-ff-client.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * CollectFlow — API FF Nancy Client - * - * Client HTTP centralisé pour l'API REST https://api.ffnancy.fr - * Sync nuit SQL Server → données fraîches J-1 - * - * Stratégie mouvements : appeler SANS filtre genremvt (tous types) - * - genremvt = 3 → alimenter sales12m (ventes) - * - Tous types → lire qtestock pour reconstruire stock12m - */ - -import fs from "fs/promises"; -import path from "path"; - -const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json"); -const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr"; -const FF_API_BASE_TTL_MS = 30_000; - -/** - * Options de tout appel à l'API FF : jamais de cache HTTP, et un délai maximal. - * Sans délai, une API muette figeait indéfiniment la page qui l'attendait. - */ -const FF_FETCH_TIMEOUT_MS = Number(process.env.FF_API_TIMEOUT_MS) || 30_000; -export function ffFetchInit(): RequestInit { - return { cache: "no-store", signal: AbortSignal.timeout(FF_FETCH_TIMEOUT_MS) }; -} - -let ffApiBaseCache: { value: string; at: number } | null = null; - -/** - * URL de l'API FF Nancy, résolue **au moment de l'appel**. - * - * Ordre de priorité : réglage enregistré dans Paramètres → variable - * d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était - * figée au chargement du module depuis l'environnement seul : impossible de la - * corriger depuis l'application quand le serveur changeait d'adresse. - * - * Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête. - */ -async function getFfApiBase(): Promise { - if (ffApiBaseCache && Date.now() - ffApiBaseCache.at < FF_API_BASE_TTL_MS) { - return ffApiBaseCache.value; - } - let configured: string | undefined; - try { - const raw = await fs.readFile(CONFIG_FILE, "utf-8"); - const parsed = JSON.parse(raw) as { ffApiBaseUrl?: string }; - configured = parsed?.ffApiBaseUrl?.trim() || undefined; - } catch { - // Fichier absent ou illisible : on retombe sur l'environnement. - } - const value = (configured || process.env.FF_API_BASE_URL || FF_API_BASE_DEFAULT).replace(/\/+$/, ""); - ffApiBaseCache = { value, at: Date.now() }; - return value; -} - -/** Vide le cache — appelé après un enregistrement dans Paramètres. */ -export function resetFfApiBaseCache(): void { - ffApiBaseCache = null; -} - -// --------------------------------------------------------------------------- -// Types API (shapes des réponses brutes) -// --------------------------------------------------------------------------- - -export interface FfFournisseur { - codefou: string; - nomfou: string; -} - -export interface FfArticle { - codein: string; - libelle1: string; - codefou: string; - nomfou?: string; - pcb?: number; // conditionnement - pv_central?: number; // prix de vente central - noid?: number; // SQL Server internal ID (pour endpoint /mensuel) - reference?: string; // référence fournisseur (ref_fou_principale) - gtin?: string; // code-barres EAN/GTIN - actif?: boolean; // article actif - suspendu?: boolean; // article suspendu -} - -export interface FfMouvement { - codein: string; - genremvt: number; // 3 = vente client - datemvt: string; // ISO date (YYYY-MM-DD) - qte: number; // quantité du mouvement - montant: number; // montant HT - marge?: number; // marge (si disponible) - qtestock: number; // stock APRÈS ce mouvement — clé pour stock12m - site: string; // code magasin / site - codefou_reel?: string; // code fournisseur réel (dernière entrée avant cette vente, ou code de l'entrée) - nom_fournisseur_reel?: string; // nom résolu du fournisseur réel -} - -export interface FfStock { - codein: string; - site?: string; - stockdispo: number; - qte: number; - valstock: number; - prmp?: number; // PRMP (prix de revient moyen pondéré) - pv?: number; // prix de vente - dernieresventes?: string; - dernierereception?: string; - nbjoursdernsventes?: number; -} - -export interface FfCommande { - codein: string; - qtecde: number; -} - -export interface FfSyncTable { - nom: string; - derniereSync: string; - nbLignes?: number; - /** "ok" ou état d'erreur renvoyé par la synchronisation. */ - statut?: string; - erreur?: string | null; -} - -export interface FfSyncStatus { - lastSync: string; - tables: FfSyncTable[]; -} - -/** - * Normalise la réponse de `/api/sync/status`. - * - * L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`, - * alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`. - * Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une - * coquille vide sans la moindre erreur, puisque la requête HTTP réussissait. - * - * Les deux formes sont acceptées ici, pour rester compatible si l'API évolue. - */ -export function normalizeSyncStatus(raw: unknown): FfSyncStatus | null { - if (!raw || typeof raw !== "object") return null; - const d = raw as Record; - - // Forme déjà attendue par l'application. - if (Array.isArray(d.tables)) { - return { - lastSync: String(d.lastSync ?? ""), - tables: d.tables as FfSyncTable[], - }; - } - - // Forme réelle de l'API FF Nancy. - const sync = Array.isArray(d.sync) ? (d.sync as Array>) : null; - if (!sync) return null; - - const tables: FfSyncTable[] = sync.map((t) => ({ - nom: String(t.table_name ?? t.nom ?? "—"), - derniereSync: String(t.last_sync ?? t.derniereSync ?? ""), - nbLignes: Number(t.rows_synced ?? t.nbLignes ?? 0) || 0, - statut: t.status != null ? String(t.status) : undefined, - erreur: t.error_msg != null ? String(t.error_msg) : null, - })); - - // Pas de date globale dans la réponse : on prend la plus récente des tables. - const lastSync = tables - .map((t) => t.derniereSync) - .filter(Boolean) - .sort() - .at(-1) ?? ""; - - return { lastSync, tables }; -} - -/** Stock agrégé sur tous les sites pour un codein */ -export interface AggregatedStock { - stockActuel: number; // sum stockdispo - stockTotal: number; // sum qte - stockValeur: number; // sum valstock - pa: number; // prmp (premier site non-zéro) - prixVente: number; // pv premier site - derniereVente: string; // max date tous sites - derniereLivraison: string; // max dernierereception tous sites - nbJoursDerniereVente: number; // min tous sites -} - -// --------------------------------------------------------------------------- -// Pagination générique -// --------------------------------------------------------------------------- - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractList(data: unknown): any[] { - if (Array.isArray(data)) return data; - const d = data as Record; - // Essaie les wrappers courants - for (const key of ["items", "data", "result", "results", "records", "list", "mouvements", "articles", "fournisseurs", "ventes", "stocks", "commandes"]) { - if (Array.isArray(d[key])) return d[key] as unknown[]; - } - // Cherche la première propriété qui est un tableau - for (const val of Object.values(d)) { - if (Array.isArray(val)) return val as unknown[]; - } - return []; -} - -async function fetchAllPages( - buildUrl: (page: number) => string, - _extractItems: (data: unknown) => T[], - pageSize = 500 -): Promise { - // Page 1 — récupère les items et tente d'obtenir le total pour paralléliser - const url1 = buildUrl(1); - console.log(`[api-ff] GET ${url1}`); - const res1 = await fetch(url1, ffFetchInit()); - if (!res1.ok) { - console.error(`[api-ff] HTTP ${res1.status} — ${url1}`); - return []; - } - const data1 = await res1.json(); - const items1 = extractList(data1) as T[]; - console.log(`[api-ff] page 1: ${items1.length} items, keys: ${items1[0] ? Object.keys(items1[0] as object).join(",") : "empty"}`); - - // Si la première page est incomplète, pas besoin de continuer - if (items1.length < pageSize) return items1; - - // Cherche un total dans la réponse pour paralléliser les pages suivantes - const d1 = data1 as Record; - const total = Number(d1.count ?? d1.total ?? d1.nb_total ?? d1.totalCount ?? 0); - - if (total > pageSize) { - const totalPages = Math.ceil(total / pageSize); - const remaining = Array.from({ length: totalPages - 1 }, (_, i) => i + 2); - // Fetch en chunks de 10 pages en parallèle - const all = [...items1]; - for (let i = 0; i < remaining.length; i += 10) { - const chunk = remaining.slice(i, i + 10); - const results = await Promise.all(chunk.map(async (p) => { - const url = buildUrl(p); - console.log(`[api-ff] GET ${url}`); - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return [] as T[]; - const data = await res.json(); - return extractList(data) as T[]; - })); - for (const r of results) all.push(...r); - } - return all; - } - - // Fallback parallèle sans total : fetch 10 pages à la fois, arrêt dès qu'une page est incomplète - const all = [...items1]; - let startPage = 2; - while (true) { - const chunk = Array.from({ length: 10 }, (_, i) => startPage + i); - const results = await Promise.all(chunk.map(async (p) => { - const url = buildUrl(p); - console.log(`[api-ff] GET ${url}`); - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return { p, items: [] as T[] }; - const data = await res.json(); - return { p, items: extractList(data) as T[] }; - })); - results.sort((a, b) => a.p - b.p); - let done = false; - for (const { items } of results) { - all.push(...items); - if (items.length < pageSize) { done = true; break; } - } - if (done) break; - startPage += 10; - } - return all; -} - -// --------------------------------------------------------------------------- -// Helpers date -// --------------------------------------------------------------------------- - -/** Retourne la fenêtre des 12 mois complets précédant le mois en cours. - * En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" } - */ -export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string } { - const now = new Date(); - // Fin : dernier jour du mois précédent le mois courant - const endMonth = new Date(now.getFullYear(), now.getMonth(), 0); - // Début : 12 mois avant le mois courant - const startMonth = new Date(now.getFullYear(), now.getMonth() - 12, 1); - - return { - dateDebut: startMonth.toISOString().slice(0, 10), - dateFin: endMonth.toISOString().slice(0, 10), - }; -} - -// --------------------------------------------------------------------------- -// Articles -// --------------------------------------------------------------------------- - -export async function getArticlesByFournisseur( - codefou: string -): Promise { - const FF_API_BASE = await getFfApiBase(); - const raw = await fetchAllPages( - (page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`, - extractList, - 500 - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return raw.map((r: any): FfArticle => ({ - codein: r.codein ?? r.Codein ?? r.code_article ?? r.codeArticle ?? String(r.id ?? ""), - libelle1: r.libelle1 ?? r.Libelle1 ?? r.libelle ?? r.designation ?? r.nom ?? "", - codefou: r.codefou_principal ?? r.codefou ?? r.Codefou ?? r.code_fournisseur ?? r.codeFournisseur ?? codefou, - nomfou: r.nom_fou_principal ?? r.nomfou ?? r.Nomfou ?? r.nom_fournisseur ?? r.nomFournisseur ?? "", - pcb: r.pcb_principal ?? r.pcb ?? r.Pcb ?? r.conditionnement ?? r.colisage, - pv_central: r.pv_central ?? r.PvCentral ?? r.prix_vente_mini ?? r.pv ?? r.prixVente, - noid: r.no_id ?? r.noid ?? r.NoId, - reference: r.ref_fou_principale ?? r.reference ?? r.ref_fournisseur ?? undefined, - gtin: r.gtin ?? r.Gtin ?? r.ean ?? r.barcode ?? undefined, - actif: r.actif != null ? Boolean(Number(r.actif)) : undefined, - suspendu: r.suspendu != null ? Boolean(Number(r.suspendu)) : undefined, - })).filter(a => a.codein); -} - -// --------------------------------------------------------------------------- -// Mouvements (ventes + stock12m) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Mensuel — stock fin de mois + ventes + réceptions par article et par site -// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin= -// --------------------------------------------------------------------------- - -export interface FfMensuelVentes { - nb_passages: string; - qte_vendue: string; // négatif (ex: "-2.000") - ca_ht: string; // négatif (ex: "-26.6600") - ca_ttc: string; - marge: string; // positif (ex: "12.7600") - taux_marge: string; -} - -export interface FfMensuelReceptions { - nb_receptions: string; - qte_recue: string; -} - -export interface FfMensuelEntry { - mois: string; // "YYYY-MM" - site: string; // code site ("292", "579") - stock_fin_mois: string; // stock fin de mois - prmp_fin_mois: string; // PRMP fin de mois - ventes: FfMensuelVentes | null; - receptions: FfMensuelReceptions | null; -} - -/** - * Récupère les données mensuelles (stock + ventes + réceptions) pour un lot d'articles. - * Utilise no_id comme identifiant (obligatoire pour l'API). - * Retourne Map - */ -export async function getMensuelByArticles( - articles: FfArticle[], - dateDebut: string, - dateFin: string, - batchSize = 50 -): Promise> { - const result = new Map(); - const FF_API_BASE = await getFfApiBase(); - - for (let i = 0; i < articles.length; i += batchSize) { - const batch = articles.slice(i, i + batchSize); - await Promise.all( - batch.map(async (art) => { - const noId = art.noid; - if (!noId) return; - try { - const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`; - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return; - const data = await res.json(); - const entries: FfMensuelEntry[] = Array.isArray(data.data) ? data.data : []; - if (entries.length > 0) { - result.set(art.codein, entries); - } - } catch (err) { - console.error(`[api-ff] getMensuel error for ${art.codein}:`, err); - } - }) - ); - } - - return result; -} - -// --------------------------------------------------------------------------- -// Referentiel — article complet : nomenclature, gammes, stock, prix, fournisseurs -// GET /api/articles/:no_id/referentiel -// --------------------------------------------------------------------------- - -export interface FfReferentiel { - article: { - no_id: string; - codein: string; - nom_code: string | null; // code nomenclature (ex: "360504") - nom_libelle: string | null; // libellé nomenclature - nom_niveau: number | null; - nom_chemin_pere: string | null; - }; - gammes: { - gamme_code: string; - gamme_libelle: string; - saison_code: string; - saison_libelle: string; - }[]; - stock: { - site: string; - stockdispo: string; - qte: string; - prmp: string; - valstock: string; - pv: string; - dernierevente: string; - dernierereception: string; - stockmort: boolean; - }[]; - prix: { - achat: string | null; - vente_par_site: { site: string; pv: string }[]; - } | null; - fournisseurs: { - codefou: string; - ref_fou: string; - pcb: string; - prixachat: string; - }[]; - performance: { - derniere_vente: string | null; - derniere_entree: string | null; - qte_totale_vendue: string | null; - ca_ttc_total: string | null; - marge_totale: string | null; - } | null; -} - -/** - * Récupère le référentiel complet (nomenclature, gamme, stock, prix) pour un lot d'articles. - * Utilise no_id comme identifiant. - * Retourne Map - */ -export async function getReferentielByArticles( - articles: FfArticle[], - batchSize = 50 -): Promise> { - const result = new Map(); - const FF_API_BASE = await getFfApiBase(); - - for (let i = 0; i < articles.length; i += batchSize) { - const batch = articles.slice(i, i + batchSize); - await Promise.all( - batch.map(async (art) => { - const noId = art.noid; - if (!noId) return; - try { - const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/referentiel`; - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return; - const data: FfReferentiel = await res.json(); - if (data?.article) result.set(art.codein, data); - } catch (err) { - console.error(`[api-ff] getReferentiel error for ${art.codein}:`, err); - } - }) - ); - } - - return result; -} - -// --------------------------------------------------------------------------- -// Commandes en cours -// --------------------------------------------------------------------------- - -export async function getCommandesByFournisseur( - codefou: string -): Promise> { - const result = new Map(); - const FF_API_BASE = await getFfApiBase(); - try { - const res = await fetch( - `${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`, - ffFetchInit() - ); - if (!res.ok) return result; - const data = await res.json(); - const list: FfCommande[] = Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []); - for (const c of list) { - result.set(c.codein, (result.get(c.codein) ?? 0) + (c.qtecde ?? 0)); - } - } catch (err) { - console.error("[api-ff] getCommandesByFournisseur error:", err); - } - return result; -} - -// --------------------------------------------------------------------------- -// Statut de synchronisation -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Sites (magasins) -// --------------------------------------------------------------------------- - -export async function getSitesFromApi(): Promise<{ code: string; nom: string }[]> { - return [ - { code: "292", nom: "Frouard (Nancy)" }, - { code: "579", nom: "Houdemont" }, - ]; -} diff --git a/src/lib/api-gammes.ts b/src/lib/api-gammes.ts deleted file mode 100644 index f6fb1ca..0000000 --- a/src/lib/api-gammes.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * CollectFlow — Affectation de gammes via l'API `/api/v1`. - * - * L'API n'enregistre pas la gamme : elle la **propose**. La proposition est - * rangée dans `gammes_a_valider` et apparaît dans la Grille comme une - * modification non enregistrée — on y voit donc quels produits changent de gamme - * avant de valider (« Enregistrer », même chemin que toute modification de la - * Grille) ou de rejeter (« Annuler les modifications »). Tant qu'elle n'est pas - * validée, `codeGamme` reste inchangée et la proposition est exposée dans - * `gammeAValider`. - * - * Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est - * proposé et la réponse liste les articles en cause. - */ - -import "server-only"; - -import type { NextRequest } from "next/server"; -import { fail } from "@/lib/api-response"; -import { getGridFreshness, getGridRowsGammes } from "@/lib/grid-store"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { proposerGammes, type PropositionGamme } from "@/lib/gammes-a-valider"; - -export interface ResultatGamme { - codein: string; - /** Gamme proposée. */ - gamme: string; - /** Gamme enregistrée au moment de l'appel (modifications validées comprises). */ - gammePrecedente: string | null; - /** Gamme présente en base FF, non modifiée par l'API. */ - codeGammeServeur: string | null; - /** - * `true` = la gamme change : proposition déposée, à valider dans la Grille. - * `false` = l'article a déjà cette gamme : rien à valider (une proposition - * antérieure en attente pour lui est retirée). - */ - modifie: boolean; - /** `a_valider` quand `modifie`, sinon `inchangee`. */ - statut: "a_valider" | "inchangee"; -} - -export interface AffectationGammes { - codeFournisseur: string; - nomFournisseur: string | null; - resultats: ResultatGamme[]; -} - -/** Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée. */ -export async function lireCorpsJson(req: NextRequest): Promise { - try { - return await req.json(); - } catch { - return fail("bad_request", "Corps de requête JSON attendu (Content-Type: application/json)."); - } -} - -/** - * Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille). - * - * Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la - * demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les - * articles lui appartiennent. - */ -export async function affecterGammes(input: { - codeFournisseur: string; - changes: ReadonlyArray<{ codein: string; gamme: string }>; - compute: boolean; - /** Nom de la clé d'API ou de l'utilisateur — affiché dans la Grille avec la proposition. */ - auteur: string; -}): Promise { - const { codeFournisseur, compute, auteur } = input; - - // Un même article cité deux fois : la dernière valeur l'emporte. - const demandes = new Map(input.changes.map((c) => [c.codein, c.gamme])); - const codeins = [...demandes.keys()]; - - if (compute && !(await getGridFreshness(codeFournisseur))) { - console.log(`[api/v1/gammes] instantané absent pour ${codeFournisseur} — calcul à la demande`); - try { - await getProductRows({ codeFournisseur, magasin: "TOTAL" }); - } catch (e) { - console.error(`[api/v1/gammes] calcul KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); - return fail("internal_error", `Le calcul de la grille a échoué pour le fournisseur « ${codeFournisseur} ».`); - } - } - - const actuelles = await getGridRowsGammes(codeFournisseur, codeins); - const inconnus = codeins.filter((c) => !actuelles.has(c)); - if (inconnus.length > 0) { - return fail( - "not_found", - `${inconnus.length} article(s) introuvable(s) chez le fournisseur « ${codeFournisseur} » : aucune gamme n'a été enregistrée.`, - { fournisseur: codeFournisseur, inconnus }, - ); - } - - const resultats: ResultatGamme[] = []; - const propositions: PropositionGamme[] = []; - const annules: string[] = []; - let nomFournisseur: string | null = null; - - for (const [codein, gamme] of demandes) { - const actuelle = actuelles.get(codein)!; - nomFournisseur ??= actuelle.nomFournisseur; - const modifie = actuelle.codeGamme !== gamme; - resultats.push({ - codein, - gamme, - gammePrecedente: actuelle.codeGamme, - codeGammeServeur: actuelle.codeGammeInit, - modifie, - statut: modifie ? "a_valider" : "inchangee", - }); - if (modifie) propositions.push({ codein, gamme, gammePrecedente: actuelle.codeGamme }); - else annules.push(codein); - } - - try { - await proposerGammes({ codeFournisseur, propositions, annules, auteur }); - } catch (e) { - console.error(`[api/v1/gammes] proposition KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); - return fail("internal_error", "L'enregistrement des gammes à valider a échoué."); - } - - console.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`); - return { codeFournisseur, nomFournisseur, resultats }; -} diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts deleted file mode 100644 index f4a821d..0000000 --- a/src/lib/api-response.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * CollectFlow — Enveloppe de réponse de l'API publique `/api/v1`. - * - * Une forme unique pour tout : `{ data, pagination?, meta }` en succès, - * `{ error: { code, message } }` en échec, avec de vrais statuts HTTP. - * Les consommateurs (scripts, IA, outils externes) peuvent ainsi traiter - * toutes les réponses de la même manière. - */ - -import { NextResponse } from "next/server"; - -export type ApiErrorCode = - | "unauthorized" - | "forbidden" - | "bad_request" - | "not_found" - | "not_ready" - | "internal_error"; - -const STATUS_BY_CODE: Record = { - unauthorized: 401, - forbidden: 403, - bad_request: 400, - not_found: 404, - not_ready: 202, - internal_error: 500, -}; - -export interface Pagination { - page: number; - limit: number; - total: number; - totalPages: number; - /** `true` s'il reste des pages à lire — évite d'avoir à comparer page et totalPages. */ - hasMore: boolean; -} - -export function buildPagination(page: number, limit: number, total: number): Pagination { - const totalPages = Math.max(1, Math.ceil(total / limit)); - // `hasMore` explicite : un agent qui doit comparer page et totalPages oublie - // souvent de le faire et conclut à tort qu'il a tout récupéré. - return { page, limit, total, totalPages, hasMore: page < totalPages }; -} - -/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */ -export function ok( - data: T, - opts: { pagination?: Pagination; meta?: Record } = {}, -): NextResponse { - return NextResponse.json({ - data, - ...(opts.pagination ? { pagination: opts.pagination } : {}), - meta: { ...(opts.meta ?? {}) }, - }); -} - -/** - * Réponse d'erreur. `details` sert notamment aux erreurs de validation zod. - * - * Note : `not_ready` renvoie un 202 — ce n'est pas un échec mais une donnée pas - * encore calculée (voir /api/v1/grid sur un fournisseur jamais ouvert). - */ -export function fail( - code: ApiErrorCode, - message: string, - details?: unknown, -): NextResponse { - return NextResponse.json( - { error: { code, message, ...(details !== undefined ? { details } : {}) } }, - { status: STATUS_BY_CODE[code] }, - ); -} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts deleted file mode 100644 index 7e0af74..0000000 --- a/src/lib/api-schemas.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * CollectFlow — Validation des paramètres de l'API `/api/v1` (zod). - * - * Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches - * de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec - * le détail zod, pour que l'appelant sache quoi corriger. - */ - -import { z } from "zod"; -import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store"; -import { GAMMES, type CodeGamme } from "@/lib/gammes"; - -const SORT_KEYS = GRID_SORT_KEYS as readonly string[]; - -export const paginationShape = { - page: z.coerce.number().int().min(1).default(1), - /** - * Aucun plafond : l'appelant décide de la taille qu'il veut recevoir. - * Le défaut reste modeste (100) pour que les endpoints de recherche, non - * bornés par un fournisseur, ne renvoient pas tout le catalogue par accident. - * `/grid` redéfinit ce paramètre : sans `limit`, il renvoie tout. - */ - limit: z.coerce.number().int().min(1).default(100), -}; - -const sortShape = { - sort: z - .string() - .refine((v) => SORT_KEYS.includes(v), { - message: `Tri inconnu. Valeurs acceptées : ${SORT_KEYS.join(", ")}`, - }) - .optional(), - order: z.enum(["asc", "desc"]).default("desc"), - /** Liste de champs de ProductRow à conserver, séparés par des virgules. */ - fields: z.string().optional(), - /** - * `1` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment - * de l'appel. `0` : sert l'instantané brut, un peu plus rapide. - */ - enrich: z.enum(["0", "1"]).default("1"), -}; - -/** `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). */ -export const gridQuerySchema = z.object({ - fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"), - gamme: z.string().min(1).optional(), - code1: z.string().min(1).optional(), - code2: z.string().min(1).optional(), - code3: z.string().min(1).optional(), - /** - * Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par - * /api/v1/nomenclatures. À préférer à code1/code2, qui peuvent être vides - * quand la hiérarchie n'a pas pu être remontée depuis la base FF. - */ - nomenclature: z.string().min(1).max(20).regex(/^[0-9]+$/, "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)").optional(), - search: z.string().min(1).optional(), - /** - * `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule - * à la demande au lieu de répondre `not_ready`. C'est ce qui permet à une app ou - * à un agent externe d'interroger n'importe quel fournisseur sans qu'un humain - * ait ouvert la Grille avant. Le premier appel est alors plus lent (plusieurs - * secondes) ; les suivants sont servis depuis l'instantané. - * `0` : comportement strict, échoue vite en `not_ready`. - */ - compute: z.enum(["0", "1"]).default("1"), - ...sortShape, - ...paginationShape, - /** - * **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid` - * renvoie *toutes* les lignes du fournisseur. - * - * C'est l'objectif de cet endpoint — qu'une app ou un agent récupère un - * fournisseur entier en un appel. Paginer par défaut condamnait l'appelant à - * boucler, ce que les agents font mal : ils s'arrêtent à la première page et - * raisonnent sur un catalogue tronqué. - * - * Réponse volumineuse : une ligne complète porte les séries mensuelles et les - * ventilations par magasin. Combiner avec `fields` est vivement conseillé. - */ - limit: z.coerce.number().int().min(1).optional(), -}); - -/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */ -export const productSearchSchema = z.object({ - q: z.string().min(1, "Paramètre 'q' requis").max(120), - fournisseur: z.string().min(1).optional(), - gamme: z.string().min(1).optional(), - ...sortShape, - ...paginationShape, -}); - -export const fournisseursQuerySchema = z.object({ - search: z.string().min(1).optional(), - /** true = uniquement les fournisseurs déjà présents dans l'instantané de grille. */ - withData: z.enum(["0", "1"]).optional(), -}); - -/** `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur. */ -export const nomenclaturesQuerySchema = z.object({ - fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"), - /** - * 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF). - * Le `transform` ne fait que redonner le type littéral : les bornes ont déjà - * été validées juste avant, la valeur ne peut être que 1, 2 ou 3. - */ - niveau: z.coerce.number().int().min(1).max(3).default(1).transform((n) => n as 1 | 2 | 3), - /** Restreint à un poste parent : un code1 pour niveau=2, un code2 pour niveau=3. */ - parent: z.string().min(1).optional(), -}); - -export const productDetailSchema = z.object({ - fournisseur: z.string().min(1).optional(), - /** - * `1` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas - * encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur. - */ - compute: z.enum(["0", "1"]).default("1"), -}); - -const CODES_GAMME = GAMMES.map((g) => g.code) as [CodeGamme, ...CodeGamme[]]; -const gammeShape = z.enum(CODES_GAMME, { - message: `Gamme inconnue. Valeurs acceptées : ${CODES_GAMME.join(", ")}`, -}); - -/** `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article. */ -export const productGammeBodySchema = z.object({ - gamme: gammeShape, - /** Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs. */ - fournisseur: z.string().min(1).optional(), - /** `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore. */ - compute: z.boolean().default(true), -}); - -/** `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur. */ -export const gammesBatchBodySchema = z.object({ - fournisseur: z.string().min(1, "Champ 'fournisseur' requis"), - changes: z - .array(z.object({ codein: z.string().min(1), gamme: gammeShape })) - .min(1, "Au moins un changement attendu") - .max(10_000, "10 000 changements au maximum par appel"), - compute: z.boolean().default(true), -}); - -/** - * Projection de champs : `?fields=codein,libelle1,totalCa`. - * - * Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles - * et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de - * lignes. `codein` est toujours conservé pour que la ligne reste identifiable. - */ -export function pickFields(row: T, fields?: string): Partial { - if (!fields) return row; - const wanted = new Set( - fields.split(",").map((f) => f.trim()).filter(Boolean), - ); - if (wanted.size === 0) return row; - wanted.add("codein"); - const out: Record = {}; - for (const key of wanted) { - if (key in row) out[key] = (row as unknown as Record)[key]; - } - return out as Partial; -} - -/** Normalise le tri validé vers la clé attendue par grid-store. */ -export function toSortKey(sort?: string): GridSortKey | undefined { - return sort as GridSortKey | undefined; -} diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts deleted file mode 100644 index 35703da..0000000 --- a/src/lib/auth.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NextAuthConfig } from "next-auth"; - -export const authConfig = { - pages: { - signIn: "/login", - }, - callbacks: { - async jwt({ token, user }) { - if (user) { - token.role = (user as any).role; - token.id = user.id; - } - return token; - }, - async session({ session, token }) { - if (session.user) { - (session.user as any).role = token.role as string; - (session.user as any).id = token.id as string; - } - return session; - }, - }, - providers: [], // Les providers seront ajoutés dans auth.ts -} satisfies NextAuthConfig; diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index 9f0927f..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -1,73 +0,0 @@ -import NextAuth from "next-auth"; -import Credentials from "next-auth/providers/credentials"; -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { verifyPassword, getFallbackUsers } from "@/features/auth/logic/auth-logic"; -import { ensureAdminExists } from "@/features/auth/logic/seed-admin"; -import { authConfig } from "./auth.config"; - -export const { handlers, auth, signIn, signOut } = NextAuth({ - ...authConfig, - providers: [ - Credentials({ - name: "Credentials", - credentials: { - username: { label: "Utilisateur", type: "text" }, - password: { label: "Mot de passe", type: "password" } - }, - async authorize(credentials) { - await ensureAdminExists(); - - if (!credentials?.username || !credentials?.password) return null; - - try { - console.log(`[AUTH] Checking user "${credentials.username}"...`); - - let user: { id: number | string; username: string; passwordHash: string; role: string } | undefined; - - // Try DB first - try { - const [dbUser] = await db.select() - .from(users) - .where(eq(users.username, credentials.username as string)); - user = dbUser; - console.log(`[AUTH] DB check: User found: ${!!user}`); - } catch { - // Base injoignable uniquement : un compte absent de la base ne doit - // pas pouvoir se connecter avec le fichier de secours (admin/admin). - console.warn("[AUTH] DB unreachable, falling back to JSON."); - const fallbackUsers = getFallbackUsers(); - user = fallbackUsers.find(u => u.username === credentials.username); - console.log(`[AUTH] JSON check: User found: ${!!user}`); - } - - if (!user) { - console.warn(`[AUTH] User "${credentials.username}" not found anywhere.`); - return null; - } - - const isValid = verifyPassword(credentials.password as string, user.passwordHash); - console.log(`[AUTH] Password valid for "${credentials.username}": ${isValid}`); - - if (!isValid) { - console.warn(`[AUTH] Invalid password for user "${credentials.username}".`); - return null; - } - - return { - id: user.id.toString(), - // `name` est repris dans la session : sans lui, l'en-tête ne - // pouvait pas afficher qui est connecté. - name: user.username, - username: user.username, - role: user.role, - }; - } catch (err: any) { - console.error("[AUTH] CRITICAL ERROR during authorize callback:", err.message || err); - return null; - } - } - }) - ], -}); diff --git a/src/lib/authz.ts b/src/lib/authz.ts deleted file mode 100644 index fc90420..0000000 --- a/src/lib/authz.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * CollectFlow — Contrôle d'accès commun aux actions serveur, routes et pages. - * - * Le jeton de session (JWT) seul ne suffit pas : il reste valide jusqu'à son - * expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On - * relit donc l'utilisateur dans la table `users` (avec un cache court), et le - * rôle enregistré en base l'emporte sur celui du jeton. - * - * Base injoignable : on se contente du jeton (mode dégradé), sans quoi le compte - * de secours `data/users.json` ne pourrait plus rien faire — y compris configurer - * la base. - */ - -import "server-only"; - -import { NextResponse } from "next/server"; -import { eq } from "drizzle-orm"; -import { auth } from "@/lib/auth"; -import { db } from "@/db"; -import { users } from "@/db/schema"; - -export interface UtilisateurCourant { - id: string; - username: string; - role: string; -} - -const MESSAGE_SESSION = "Session expirée : reconnectez-vous."; -const MESSAGE_ADMIN = "Action réservée aux administrateurs."; - -/** Refus d'accès. `statut` sert aux routes : 401 sans session valide, 403 sans le rôle requis. */ -export class AccesRefuse extends Error { - constructor(message: string, readonly statut: 401 | 403 = 403) { - super(message); - this.name = "AccesRefuse"; - } -} - -// ── Relecture du compte en base ────────────────────────────────────────────── - -/** Durée pendant laquelle une relecture de la table `users` est réutilisée. */ -const CACHE_TTL_MS = 60_000; -/** Après un échec de connexion, on ne réessaie pas avant ce délai (chaque essai peut durer 10 s). */ -const PAUSE_BASE_INJOIGNABLE_MS = 15_000; - -interface FicheUtilisateur { - exists: boolean; - role: string; - username: string; - at: number; -} - -const cache = new Map(); -let baseInjoignableDepuis = 0; - -/** Plus grand entier accepté par la colonne `users.id` (serial). */ -const ID_MAX = 2_147_483_647; - -/** - * Fiche de l'utilisateur en base, ou `null` si la base n'a pas pu répondre. - * Un identifiant qui ne peut pas exister (compte de secours « 0 », valeur non - * numérique) est cherché tel quel : introuvable si la base répond. - */ -async function lireFiche(id: string): Promise { - const maintenant = Date.now(); - const enCache = cache.get(id); - if (enCache && maintenant - enCache.at < CACHE_TTL_MS) return enCache; - if (maintenant - baseInjoignableDepuis < PAUSE_BASE_INJOIGNABLE_MS) return null; - - const n = /^\d+$/.test(id) ? Number(id) : -1; - const idNumerique = Number.isSafeInteger(n) && n <= ID_MAX ? n : -1; - - try { - const [ligne] = await db - .select({ username: users.username, role: users.role }) - .from(users) - .where(eq(users.id, idNumerique)) - .limit(1); - const fiche: FicheUtilisateur = ligne - ? { exists: true, role: ligne.role, username: ligne.username, at: maintenant } - : { exists: false, role: "", username: "", at: maintenant }; - cache.set(id, fiche); - return fiche; - } catch (e) { - baseInjoignableDepuis = maintenant; - console.warn("[authz] Base injoignable, contrôle sur le seul jeton de session :", (e as Error).message?.slice(0, 200)); - return null; - } -} - -// ── Contrôles ──────────────────────────────────────────────────────────────── - -/** Utilisateur connecté, relu en base. Lève `AccesRefuse` (401) sinon. */ -export async function requireSession(): Promise { - const session = await auth(); - const jeton = session?.user as { id?: string | number; name?: string | null; role?: string } | undefined; - if (!jeton) throw new AccesRefuse(MESSAGE_SESSION, 401); - - const id = jeton.id != null ? String(jeton.id) : ""; - const fiche = await lireFiche(id); - - if (!fiche) { - // Mode dégradé : le jeton fait foi. - return { id, username: jeton.name ?? "", role: jeton.role ?? "user" }; - } - if (!fiche.exists) throw new AccesRefuse(MESSAGE_SESSION, 401); - return { id, username: fiche.username, role: fiche.role }; -} - -/** Administrateur connecté. Lève `AccesRefuse` (401 ou 403) sinon. */ -export async function requireAdmin(): Promise { - const utilisateur = await requireSession(); - if (utilisateur.role !== "admin") throw new AccesRefuse(MESSAGE_ADMIN, 403); - return utilisateur; -} - -// ── Variantes sans exception ───────────────────────────────────────────────── - -export type Autorisation = - | { ok: true; utilisateur: UtilisateurCourant } - | { ok: false; message: string; statut: 401 | 403 }; - -async function verifier(controle: () => Promise): Promise { - try { - return { ok: true, utilisateur: await controle() }; - } catch (e) { - if (e instanceof AccesRefuse) return { ok: false, message: e.message, statut: e.statut }; - throw e; - } -} - -/** - * Pour les actions qui renvoient un résultat `{ success: false, error }` plutôt - * que de lever une exception, et pour les pages (le `redirect()` doit être appelé - * hors d'un try/catch). - */ -export function verifierSession(): Promise { - return verifier(requireSession); -} - -export function verifierAdmin(): Promise { - return verifier(requireAdmin); -} - -// ── Variantes pour les routes ──────────────────────────────────────────────── - -function reponse(a: Autorisation): UtilisateurCourant | Response { - return a.ok ? a.utilisateur : NextResponse.json({ error: a.message }, { status: a.statut }); -} - -/** - * Utilisateur connecté, ou réponse JSON 401 à renvoyer telle quelle : - * - * const acces = await sessionOuReponse(); - * if (acces instanceof Response) return acces; - */ -export async function sessionOuReponse(): Promise { - return reponse(await verifierSession()); -} - -/** Administrateur connecté, ou réponse JSON 401 / 403 à renvoyer telle quelle. */ -export async function adminOuReponse(): Promise { - return reponse(await verifierAdmin()); -} diff --git a/src/lib/ff-cache.ts b/src/lib/ff-cache.ts deleted file mode 100644 index be2c94c..0000000 --- a/src/lib/ff-cache.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * CollectFlow — Cache mémoire des lectures « FF » (base miroir et API FF Nancy). - * - * Les tables FF (`mvtart`, `cube_stock`, `articles`…) ne sont recopiées qu'une - * fois par nuit depuis SQL Server : recalculer à chaque visite les mêmes - * agrégations lourdes (stock, hit-parade, CA mensuel…) coûtait plusieurs - * secondes par page pour un résultat identique. - * - * Pourquoi pas `unstable_cache` : il sérialise en JSON (une `Map` devient `{}`, - * une `Date` une chaîne) et ne peut pas être invalidé hors d'une requête — or - * c'est la synchro nocturne, lancée par un minuteur, qui sait quand les données - * changent. Ici : - * - une durée de vie bornée (FF_CACHE_TTL_S, 30 min par défaut) ; - * - les appels simultanés pour la même clé partagent une seule requête ; - * - une erreur n'est jamais mise en cache ; - * - `invalidateFFCache()` vide tout (appelé en fin de synchro nocturne). - */ - -import "server-only"; - -import { pgGetDerniereReceptionParFournisseur, pgGetFournisseurs } from "@/lib/pg-ff-client"; - -const TTL_MS = (Number(process.env.FF_CACHE_TTL_S) || 30 * 60) * 1000; -/** Plafond d'entrées : les clés incluent des paramètres (dates, mois, magasin). */ -const MAX_ENTRIES = 100; - -interface Entry { - value?: unknown; - expiresAt: number; - pending?: Promise; -} - -// Rangé dans globalThis : un seul cache par processus, même si ce module est -// présent dans plusieurs lots compilés (routes, instrumentation). -const globalForFFCache = globalThis as typeof globalThis & { - __collectflowFFCache?: { store: Map; generation: number }; -}; -const cache = (globalForFFCache.__collectflowFFCache ??= { store: new Map(), generation: 0 }); -const store = cache.store; - -interface CachedOptions { - /** Durée de vie propre à cette clé, en millisecondes. */ - ttlMs?: number; - /** - * Ne garder le résultat que s'il passe ce test. Sert aux lectures qui - * avalent leurs erreurs et renvoient une liste vide : sans ce garde-fou, - * une panne passagère resterait affichée pendant toute la durée de vie. - */ - cacheIf?: (value: T) => boolean; -} - -export async function cachedFF(key: string, load: () => Promise, options: CachedOptions = {}): Promise { - const now = Date.now(); - const entry = store.get(key); - if (entry && "value" in entry && entry.expiresAt > now) return entry.value as T; - if (entry?.pending) return entry.pending as Promise; - - // Une invalidation pendant le chargement rend son résultat suspect : il est - // alors renvoyé à l'appelant, mais pas conservé. - const generation = cache.generation; - const pending = load().then( - (value) => { - if (generation !== cache.generation) return value; - if (options.cacheIf && !options.cacheIf(value)) { - store.delete(key); - } else { - store.delete(key); // réinsertion en fin : ordre d'éviction = ancienneté - store.set(key, { value, expiresAt: Date.now() + (options.ttlMs ?? TTL_MS) }); - while (store.size > MAX_ENTRIES) { - const oldest = store.keys().next().value; - if (oldest === undefined) break; - store.delete(oldest); - } - } - return value; - }, - (error) => { - if (generation === cache.generation) store.delete(key); - throw error; - }, - ); - // Une éventuelle valeur expirée est remplacée : seule la requête en cours compte. - store.set(key, { expiresAt: 0, pending }); - return pending; -} - -/** Vide tout le cache FF (fin de synchro nocturne, données fraîches). */ -export function invalidateFFCache(): void { - cache.generation++; - store.clear(); -} - -// --------------------------------------------------------------------------- -// Lectures partagées par plusieurs pages -// --------------------------------------------------------------------------- - -/** Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé. */ -export function getFournisseursCached(): Promise<{ code: string; nom: string }[]> { - return cachedFF("fournisseurs", () => pgGetFournisseurs(), { cacheIf: (rows) => rows.length > 0 }); -} - -/** - * Dernière réception par fournisseur et magasin, pour les fournisseurs du - * cadencier seulement : de loin la lecture la plus lourde de la page Commandes. - * Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée. - * Vide = erreur avalée (ou aucune réception), non gardé. - */ -export function getDerniereReceptionCached(codes: string[]): Promise> { - const liste = [...new Set(codes.map((c) => c.trim()))].sort(); - if (liste.length === 0) return Promise.resolve(new Map()); - return cachedFF(`derniere-reception:${liste.join(",")}`, () => pgGetDerniereReceptionParFournisseur(liste), { - cacheIf: (map) => map.size > 0, - }); -} diff --git a/src/lib/gammes-a-valider.ts b/src/lib/gammes-a-valider.ts deleted file mode 100644 index 4ba91d1..0000000 --- a/src/lib/gammes-a-valider.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * CollectFlow — Gammes proposées par l'API, en attente de validation. - * - * L'API `/api/v1` ne change plus une gamme directement : elle dépose une - * proposition, que la Grille affiche comme une modification non enregistrée. - * L'utilisateur voit ainsi quels produits changent de gamme, puis valide - * (« Enregistrer ») ou rejette (« Annuler les modifications »). - */ - -import "server-only"; - -import { and, eq, inArray, sql } from "drizzle-orm"; -import { db } from "@/db"; -import { gammesAValider } from "@/db/schema"; - -export interface PropositionGamme { - codein: string; - /** Gamme proposée. */ - gamme: string; - /** Gamme enregistrée au moment de la proposition. */ - gammePrecedente: string | null; -} - -export interface GammeAValider extends PropositionGamme { - auteur: string | null; - /** Date ISO de la proposition. */ - proposeeLe: string; -} - -/** - * Dépose des propositions (la dernière l'emporte pour un même article) et retire - * celles des articles `annules` — redemander la gamme déjà enregistrée annule la - * proposition en attente. - */ -export async function proposerGammes(input: { - codeFournisseur: string; - propositions: PropositionGamme[]; - annules: string[]; - auteur: string; -}): Promise { - const { codeFournisseur, propositions, annules, auteur } = input; - await db.transaction(async (tx) => { - if (annules.length > 0) { - await tx - .delete(gammesAValider) - .where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, annules))); - } - if (propositions.length > 0) { - const maintenant = new Date(); - await tx - .insert(gammesAValider) - .values(propositions.map((p) => ({ - codeFournisseur, - codein: p.codein, - gamme: p.gamme, - gammePrecedente: p.gammePrecedente, - auteur: auteur.slice(0, 100), - createdAt: maintenant, - }))) - .onConflictDoUpdate({ - target: [gammesAValider.codeFournisseur, gammesAValider.codein], - set: { - gamme: sql`excluded.gamme`, - gammePrecedente: sql`excluded.gamme_precedente`, - auteur: sql`excluded.auteur`, - createdAt: sql`excluded.created_at`, - }, - }); - } - }); -} - -/** Propositions en attente pour un fournisseur. */ -export async function listerGammesAValider(codeFournisseur: string): Promise { - const rows = await db - .select() - .from(gammesAValider) - .where(eq(gammesAValider.codeFournisseur, codeFournisseur)); - return rows.map((r) => ({ - codein: r.codein, - gamme: r.gamme, - gammePrecedente: r.gammePrecedente, - auteur: r.auteur, - proposeeLe: r.createdAt.toISOString(), - })); -} - -/** Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`. */ -export async function gammesAValiderParArticle(codeins: string[]): Promise> { - if (codeins.length === 0) return new Map(); - const rows = await db - .select({ codeFournisseur: gammesAValider.codeFournisseur, codein: gammesAValider.codein, gamme: gammesAValider.gamme }) - .from(gammesAValider) - .where(inArray(gammesAValider.codein, codeins)); - return new Map(rows.map((r) => [`${r.codeFournisseur}|${r.codein}`, r.gamme])); -} - -/** Retire des propositions, une fois validées ou rejetées dans la Grille. */ -export async function supprimerGammesAValider(codeFournisseur: string, codeins: string[]): Promise { - if (codeins.length === 0) return; - await db - .delete(gammesAValider) - .where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, codeins))); -} diff --git a/src/lib/grid-store.ts b/src/lib/grid-store.ts deleted file mode 100644 index db29e82..0000000 --- a/src/lib/grid-store.ts +++ /dev/null @@ -1,485 +0,0 @@ -/** - * CollectFlow — Instantané persisté de la grille (table `grid_rows`). - * - * `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base - * miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1` - * ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de - * bord quand quelqu'un ouvre la Grille. - * - * On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat - * qui existe déjà, et il survit désormais aux redémarrages. - */ - -import "server-only"; - -import { db } from "@/db"; -import { gridRows } from "@/db/schema"; -import { and, asc, desc, eq, ilike, inArray, or, sql, lt, type SQL } from "drizzle-orm"; -import type { ProductRow } from "@/types/grid"; - -/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */ -const SORTABLE = { - libelle1: gridRows.libelle1, - codein: gridRows.codein, - codeFournisseur: gridRows.codeFournisseur, - codeGamme: gridRows.codeGamme, - totalCa: gridRows.totalCa, - totalQuantite: gridRows.totalQuantite, - totalMarge: gridRows.totalMarge, - tauxMarge: gridRows.tauxMarge, - stockActuel: gridRows.stockActuel, - caReseau: gridRows.caReseau, - qteReseau: gridRows.qteReseau, - nbMagasinsReseau: gridRows.nbMagasinsReseau, - caParMagasinReseau: gridRows.caParMagasinReseau, - computedAt: gridRows.computedAt, -} as const; - -export type GridSortKey = keyof typeof SORTABLE; -export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[]; - -export interface GridQuery { - codeFournisseur?: string; - gamme?: string; - code1?: string; - code2?: string; - code3?: string; - /** Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. */ - nomenclature?: string; - /** Recherche libre : libellé, codein, GTIN, référence, code centrale. */ - search?: string; - sort?: GridSortKey; - order?: "asc" | "desc"; - page?: number; - limit?: number; -} - -export interface GridQueryResult { - rows: ProductRow[]; - total: number; - /** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */ - computedAt: string | null; -} - -/** - * Version du format des lignes persistées. À incrémenter dès qu'un champ de - * `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne - * seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1. - */ -export const GRID_PAYLOAD_VERSION = 1; - -/** - * Persiste les lignes calculées pour un fournisseur. - * - * Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime - * celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles - * disparus du catalogue sans avoir à passer des centaines de codein en paramètres. - */ -export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise { - if (!codeFournisseur || rows.length === 0) return 0; - const computedAt = new Date(); - - const values = rows - .filter((r) => r.codein) - .map((r) => ({ - codeFournisseur, - codein: r.codein, - nomFournisseur: r.nomFournisseur ?? null, - libelle1: r.libelle1 ?? null, - gtin: r.gtin ?? null, - reference: r.reference ?? null, - codeCentrale: r.codeCentrale ?? null, - code1: r.code1 ?? null, - code2: r.code2 ?? null, - code3: r.code3 ?? null, - codeGamme: r.codeGamme ?? null, - codeGammeInit: r.codeGammeInit ?? null, - totalCa: r.totalCa != null ? String(r.totalCa) : null, - totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null, - totalMarge: r.totalMarge != null ? String(r.totalMarge) : null, - tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null, - stockActuel: r.stockActuel != null ? String(r.stockActuel) : null, - caReseau: r.caReseau != null ? String(r.caReseau) : null, - qteReseau: r.qteReseau != null ? String(r.qteReseau) : null, - nbMagasinsReseau: r.nbMagasinsReseau ?? null, - caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null, - margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null, - payload: { ...r, payloadVersion: GRID_PAYLOAD_VERSION }, - computedAt, - })); - if (values.length === 0) return 0; - - const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires - let written = 0; - for (let i = 0; i < values.length; i += CHUNK) { - const batch = values.slice(i, i + CHUNK); - await db - .insert(gridRows) - .values(batch) - .onConflictDoUpdate({ - target: [gridRows.codeFournisseur, gridRows.codein], - set: { - nomFournisseur: sql`excluded.nom_fournisseur`, - libelle1: sql`excluded.libelle1`, - gtin: sql`excluded.gtin`, - reference: sql`excluded.reference`, - codeCentrale: sql`excluded.code_centrale`, - code1: sql`excluded.code1`, - code2: sql`excluded.code2`, - code3: sql`excluded.code3`, - codeGamme: sql`excluded.code_gamme`, - codeGammeInit: sql`excluded.code_gamme_init`, - totalCa: sql`excluded.total_ca`, - totalQuantite: sql`excluded.total_quantite`, - totalMarge: sql`excluded.total_marge`, - tauxMarge: sql`excluded.taux_marge`, - stockActuel: sql`excluded.stock_actuel`, - caReseau: sql`excluded.ca_reseau`, - qteReseau: sql`excluded.qte_reseau`, - nbMagasinsReseau: sql`excluded.nb_magasins_reseau`, - caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`, - margePctReseau: sql`excluded.marge_pct_reseau`, - payload: sql`excluded.payload`, - computedAt: sql`excluded.computed_at`, - }, - }); - written += batch.length; - } - - // Purge des articles qui ne font plus partie du catalogue du fournisseur. - await db.delete(gridRows).where( - and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)), - ); - - return written; -} - -/** - * Instantané complet d'un fournisseur, tel que persisté par le dernier calcul. - * - * Renvoie `null` (→ recalcul) si rien n'est persisté, si une ligne est d'une - * version de format antérieure, ou si le calcul date d'un autre mois que le mois - * en cours : la fenêtre des 12 mois glissants aurait changé entre-temps. - * Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article). - */ -export async function readGridSnapshot( - codeFournisseur: string, -): Promise<{ rows: ProductRow[]; computedAt: Date } | null> { - const found = await db - .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) - .from(gridRows) - .where(eq(gridRows.codeFournisseur, codeFournisseur)); - if (found.length === 0) return null; - - let oldest: Date | null = null; - const rows: ProductRow[] = []; - for (const r of found) { - const row = r.payload as ProductRow; - if (row.payloadVersion !== GRID_PAYLOAD_VERSION) return null; - if (!oldest || r.computedAt < oldest) oldest = r.computedAt; - rows.push(row); - } - const now = new Date(); - if (!oldest || oldest.getFullYear() !== now.getFullYear() || oldest.getMonth() !== now.getMonth()) { - return null; - } - rows.sort((a, b) => (a.noid ?? 0) - (b.noid ?? 0)); - return { rows, computedAt: oldest }; -} - -/** - * Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload), - * pour que /api/v1 les voie sans attendre un recalcul complet de la grille. - */ -export async function updateGridRowsGamme( - codeFournisseur: string, - changes: ReadonlyArray<{ codein: string; codeGamme: string }>, -): Promise { - const parGamme = new Map(); - for (const c of changes) { - const codeins = parGamme.get(c.codeGamme) ?? []; - codeins.push(c.codein); - parGamme.set(c.codeGamme, codeins); - } - const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL - for (const [gamme, codeins] of parGamme) { - for (let i = 0; i < codeins.length; i += CHUNK) { - await db - .update(gridRows) - .set({ - codeGamme: gamme, - payload: sql`jsonb_set(${gridRows.payload}, '{codeGamme}', to_jsonb(${gamme}::text))`, - }) - .where(and( - eq(gridRows.codeFournisseur, codeFournisseur), - inArray(gridRows.codein, codeins.slice(i, i + CHUNK)), - )); - } - } -} - -/** Construit la clause WHERE commune à la grille et à la recherche transversale. */ -function buildWhere(q: GridQuery): SQL | undefined { - const clauses: SQL[] = []; - if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur)); - if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme)); - if (q.code1) clauses.push(eq(gridRows.code1, q.code1)); - if (q.code2) clauses.push(eq(gridRows.code2, q.code2)); - if (q.code3) clauses.push(eq(gridRows.code3, q.code3)); - // Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ». - // C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de - // code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée. - if (q.nomenclature) clauses.push(sql`${gridRows.code3} like ${q.nomenclature + "%"}`); - const search = q.search?.trim(); - if (search) { - const pattern = `%${search}%`; - const alt = or( - ilike(gridRows.libelle1, pattern), - ilike(gridRows.codein, pattern), - ilike(gridRows.gtin, pattern), - ilike(gridRows.reference, pattern), - ilike(gridRows.codeCentrale, pattern), - ); - if (alt) clauses.push(alt); - } - return clauses.length ? and(...clauses) : undefined; -} - -/** - * Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL. - * Ne déclenche aucun recalcul et ne contacte jamais Qlik. - */ -export async function queryGridRows(q: GridQuery): Promise { - const page = Math.max(1, q.page ?? 1); - // `limit` absent = aucune limite : on renvoie toutes les lignes du filtre. - // Il y avait ici un Math.min(500, …) qui re-plafonnait en silence, quelle que - // soit la valeur demandée — la réponse était tronquée sans que rien ne le dise. - const limit = q.limit != null ? Math.max(1, q.limit) : null; - const where = buildWhere(q); - - const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa; - // NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête. - const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol); - - const selection = db - .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) - .from(gridRows) - .where(where) - .orderBy(orderBy, asc(gridRows.codein)); - - // Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente. - const [[countRow], found] = await Promise.all([ - db.select({ total: sql`count(*)::int` }).from(gridRows).where(where), - limit != null ? selection.limit(limit).offset((page - 1) * limit) : selection, - ]); - - let oldest: Date | null = null; - for (const r of found) { - if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt; - } - - return { - rows: found.map((r) => versLigneApi(r.payload)), - total: countRow?.total ?? 0, - computedAt: oldest ? oldest.toISOString() : null, - }; -} - -/** Ligne exposée par l'API : la version de format est un détail interne. */ -function versLigneApi(payload: unknown): ProductRow { - const ligne = { ...(payload as ProductRow) }; - delete ligne.payloadVersion; - return ligne; -} - -/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */ -export async function getGridRowByCodein( - codein: string, - codeFournisseur?: string, -): Promise<{ row: ProductRow; computedAt: string | null } | null> { - const clauses: SQL[] = [eq(gridRows.codein, codein)]; - if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur)); - - const [found] = await db - .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) - .from(gridRows) - .where(and(...clauses)) - .orderBy(desc(gridRows.computedAt)) - .limit(1); - - if (!found) return null; - return { - row: versLigneApi(found.payload), - computedAt: found.computedAt ? found.computedAt.toISOString() : null, - }; -} - -/** Fournisseurs chez lesquels un article figure dans l'instantané. */ -export async function listGridSuppliersForCodein( - codein: string, -): Promise> { - return db - .select({ codeFournisseur: gridRows.codeFournisseur, nomFournisseur: gridRows.nomFournisseur }) - .from(gridRows) - .where(eq(gridRows.codein, codein)) - .orderBy(asc(gridRows.codeFournisseur)); -} - -export interface GammeInstantane { - /** Gamme courante, modifications enregistrées comprises. */ - codeGamme: string | null; - /** Gamme serveur au moment du calcul de la grille. */ - codeGammeInit: string | null; - nomFournisseur: string | null; -} - -/** - * Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la - * carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé). - */ -export async function getGridRowsGammes( - codeFournisseur: string, - codeins: readonly string[], -): Promise> { - const out = new Map(); - const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL - for (let i = 0; i < codeins.length; i += CHUNK) { - const found = await db - .select({ - codein: gridRows.codein, - codeGamme: gridRows.codeGamme, - codeGammeInit: gridRows.codeGammeInit, - nomFournisseur: gridRows.nomFournisseur, - }) - .from(gridRows) - .where(and( - eq(gridRows.codeFournisseur, codeFournisseur), - inArray(gridRows.codein, codeins.slice(i, i + CHUNK)), - )); - for (const r of found) { - out.set(r.codein, { codeGamme: r.codeGamme, codeGammeInit: r.codeGammeInit, nomFournisseur: r.nomFournisseur }); - } - } - return out; -} - -/** - * Indique si un fournisseur a déjà été calculé au moins une fois. - * Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long. - */ -export async function getGridFreshness( - codeFournisseur: string, -): Promise<{ rowCount: number; computedAt: string | null } | null> { - const [found] = await db - .select({ - rowCount: sql`count(*)::int`, - computedAt: sql`max(${gridRows.computedAt})`, - }) - .from(gridRows) - .where(eq(gridRows.codeFournisseur, codeFournisseur)); - - if (!found || found.rowCount === 0) return null; - return { - rowCount: found.rowCount, - computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null, - }; -} - -/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */ -export async function listGridSuppliers(): Promise< - Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }> -> { - const found = await db - .select({ - codeFournisseur: gridRows.codeFournisseur, - nomFournisseur: sql`max(${gridRows.nomFournisseur})`, - rowCount: sql`count(*)::int`, - computedAt: sql`max(${gridRows.computedAt})`, - }) - .from(gridRows) - .groupBy(gridRows.codeFournisseur) - .orderBy(asc(gridRows.codeFournisseur)); - - return found.map((r) => ({ - codeFournisseur: r.codeFournisseur, - nomFournisseur: r.nomFournisseur, - rowCount: r.rowCount, - computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null, - })); -} - -// --------------------------------------------------------------------------- -// Inventaire des nomenclatures d'un fournisseur -// --------------------------------------------------------------------------- - -/** Un poste de nomenclature, avec son poids — de quoi décider par où découper. */ -export interface GridNomenclature { - code: string; - /** Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). */ - libelle: string | null; - nbArticles: number; - totalCa: number; - totalQuantite: number; -} - -/** Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». */ -export const NOMENCLATURE_LONGUEURS: Record<1 | 2 | 3, number> = { 1: 2, 2: 4, 3: 6 }; - -/** - * Répartition des articles d'un fournisseur par niveau de nomenclature. - * - * Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature - * est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles** - * valeurs existent — il devrait tout télécharger pour les découvrir, ce qui - * annule le bénéfice du découpage. - * - * Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6 - * chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci - * ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la - * hiérarchie, ce qui dépend d'une détection heuristique de la colonne parent de - * la table `nomenclature` ; quand elle échoue, seul `code3` est rempli. Le - * préfixe, lui, est toujours disponible. - * - * Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour - * un fournisseur de 130 000 articles. - */ -export async function listGridNomenclatures( - codeFournisseur: string, - niveau: 1 | 2 | 3, - parent?: string, -): Promise { - const longueur = NOMENCLATURE_LONGUEURS[niveau]; - const codeExpr = sql`left(${gridRows.code3}, ${longueur})`; - // Les libellés n'ont pas de colonne dédiée : on les lit dans le payload. - // Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ; - // le code et les compteurs, eux, restent justes. - const libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3"; - - const clauses: SQL[] = [ - eq(gridRows.codeFournisseur, codeFournisseur), - sql`${gridRows.code3} is not null and length(${gridRows.code3}) >= ${longueur}`, - ]; - // `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2. - if (parent) clauses.push(sql`${gridRows.code3} like ${parent + "%"}`); - - const rows = await db - .select({ - code: codeExpr, - libelle: sql`max(${gridRows.payload} ->> ${libelleKey})`, - nbArticles: sql`count(*)::int`, - totalCa: sql`coalesce(sum(${gridRows.totalCa}), 0)::float`, - totalQuantite: sql`coalesce(sum(${gridRows.totalQuantite}), 0)::float`, - }) - .from(gridRows) - .where(and(...clauses)) - .groupBy(codeExpr) - .orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`)); - - return rows.map((r) => ({ - code: String(r.code ?? ""), - libelle: r.libelle ?? null, - nbArticles: Number(r.nbArticles) || 0, - totalCa: Number(r.totalCa) || 0, - totalQuantite: Number(r.totalQuantite) || 0, - })); -} diff --git a/src/lib/log-capture.ts b/src/lib/log-capture.ts deleted file mode 100644 index e3ddf71..0000000 --- a/src/lib/log-capture.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * CollectFlow — Capture des logs serveur d'un job, pour téléchargement. - * - * POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes - * (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne - * vivent que dans la sortie standard du conteneur : un terminal les tronque, - * et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête, - * là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle, - * un diagnostic se fait à l'aveugle. - * - * COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du - * module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée - * dans les captures ouvertes, puis transmise telle quelle à la console - * d'origine (rien n'est retenu, rien ne change dans les logs du conteneur). - * - * ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs - * lignes ne portent pas d'identifiant permettant de les démêler. Chaque - * capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un - * peu de bruit qu'une ligne manquante dans un journal de diagnostic. - * - * Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau : - * un job qui fait tomber le process laisse quand même son journal. - */ - -import { appendFile, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */ -const LOG_DIR = join(tmpdir(), "collectflow-logs"); - -/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */ -const MAX_LIGNES = 200_000; -/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */ -const MAX_LONGUEUR_LIGNE = 8_000; -/** Nombre de journaux conservés sur disque. */ -const MAX_FICHIERS = 20; -/** Écriture disque au plus toutes les N ms (seules les nouvelles lignes sont ajoutées). */ -const FLUSH_MS = 2_000; - -interface Capture { - id: string; - lignes: string[]; - tronque: boolean; - debut: number; - flushEnCours: boolean; - flushDemande: boolean; - dernierFlush: number; - /** Nombre de lignes déjà écrites sur disque. */ - ecrites: number; -} - -const captures = new Map(); - -/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */ -function fichierPour(id: string): string { - return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`); -} - -function horodatage(): string { - return new Date().toISOString(); -} - -/** Rend une ligne à partir des arguments variadiques de `console.*`. */ -function formater(args: unknown[]): string { - return args - .map((a) => { - if (typeof a === "string") return a; - if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`; - try { - return JSON.stringify(a); - } catch { - return String(a); - } - }) - .join(" "); -} - -async function flush(capture: Capture): Promise { - if (capture.flushEnCours) { - capture.flushDemande = true; - return; - } - capture.flushEnCours = true; - try { - await mkdir(LOG_DIR, { recursive: true }); - // Ajout des seules nouvelles lignes : réécrire tout le journal (jusqu'à - // 200 000 lignes) toutes les 2 s bloquait le serveur pendant les synchros. - const total = capture.lignes.length; - const nouvelles = capture.lignes.slice(capture.ecrites, total); - if (capture.ecrites === 0) { - await writeFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8"); - } else if (nouvelles.length > 0) { - await appendFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8"); - } - capture.ecrites = total; - capture.dernierFlush = Date.now(); - } catch { - // Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe. - } finally { - capture.flushEnCours = false; - if (capture.flushDemande) { - capture.flushDemande = false; - void flush(capture); - } - } -} - -function ajouter(ligne: string): void { - if (captures.size === 0) return; - const texte = ligne.length > MAX_LONGUEUR_LIGNE - ? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]" - : ligne; - const prefixe = `[${horodatage()}] `; - for (const capture of captures.values()) { - if (capture.lignes.length >= MAX_LIGNES) { - if (!capture.tronque) { - capture.tronque = true; - capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`); - } - continue; - } - capture.lignes.push(prefixe + texte); - if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture); - } -} - -/** - * Instrumente `console` une seule fois, même en développement où le module peut - * être réévalué à chaud (le drapeau est porté par `globalThis`). - */ -function instrumenterConsole(): void { - const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean }; - if (g.__cfLogCaptureInstalle) return; - g.__cfLogCaptureInstalle = true; - - const methodes = ["log", "warn", "error", "info"] as const; - for (const methode of methodes) { - const origine = console[methode].bind(console); - console[methode] = (...args: unknown[]) => { - try { - // Aucune capture ouverte (le cas courant) : on ne formate rien. - // Sérialiser chaque argument de chaque log coûtait à toutes les requêtes. - if (captures.size > 0) ajouter(formater(args)); - } catch { - // idem : jamais au détriment du log réel - } - origine(...args); - }; - } -} - -instrumenterConsole(); - -/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */ -async function purger(): Promise { - try { - const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log")); - if (noms.length <= MAX_FICHIERS) return; - const avecDate = await Promise.all( - noms.map(async (nom) => { - const s = await stat(join(LOG_DIR, nom)).catch(() => null); - return { nom, mtime: s?.mtimeMs ?? 0 }; - }), - ); - avecDate.sort((a, b) => b.mtime - a.mtime); - for (const { nom } of avecDate.slice(MAX_FICHIERS)) { - await unlink(join(LOG_DIR, nom)).catch(() => undefined); - } - } catch { - // purge best-effort - } -} - -/** - * Ouvre une capture. Toutes les lignes écrites sur la console à partir de - * maintenant (par n'importe quel code du process, cf. avertissement en tête) - * y sont recopiées jusqu'à `stopCapture`. - */ -export function startCapture(id: string, entete?: string): void { - const capture: Capture = { - id, - lignes: [], - tronque: false, - debut: Date.now(), - flushEnCours: false, - flushDemande: false, - dernierFlush: 0, - ecrites: 0, - }; - captures.set(id, capture); - capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`); - if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`); - void flush(capture); - void purger(); -} - -/** Ferme la capture et écrit son état final sur disque. */ -export async function stopCapture(id: string, pied?: string): Promise { - const capture = captures.get(id); - if (!capture) return; - const duree = Math.round((Date.now() - capture.debut) / 1000); - capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`); - if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`); - captures.delete(id); - await flush(capture); -} - -/** - * Contenu d'un journal : la capture en cours si elle est ouverte, sinon le - * fichier laissé sur disque. `null` si aucun des deux n'existe. - */ -export async function readCapture(id: string): Promise { - const capture = captures.get(id); - if (capture) return capture.lignes.join("\n") + "\n"; - try { - return await readFile(fichierPour(id), "utf8"); - } catch { - return null; - } -} - -/** Journaux disponibles, du plus récent au plus ancien. */ -export async function listCaptures(): Promise> { - const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = []; - try { - for (const nom of await readdir(LOG_DIR)) { - if (!nom.endsWith(".log")) continue; - const s = await stat(join(LOG_DIR, nom)).catch(() => null); - if (!s) continue; - const id = nom.slice(0, -4); - resultats.push({ - id, - octets: s.size, - modifieLe: new Date(s.mtimeMs).toISOString(), - enCours: captures.has(id), - }); - } - } catch { - // répertoire pas encore créé - } - return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe)); -} diff --git a/src/lib/page-data.ts b/src/lib/page-data.ts new file mode 100644 index 0000000..d47665e --- /dev/null +++ b/src/lib/page-data.ts @@ -0,0 +1,128 @@ +/** + * CollectFlow — Données des pages côté navigateur. + * + * Les pages Next.js étaient des composants serveur : leurs données arrivaient + * avec le rendu, `loading.tsx` s'affichait pendant le premier chargement et une + * navigation (changement de paramètres, `router.refresh()`) gardait la page à + * l'écran jusqu'à l'arrivée des nouvelles données. + * + * Même fonctionnement ici, avec Suspense : `usePageData` suspend la page sur la + * promesse de ses données. Au premier affichage, la frontière Suspense de la + * route montre la silhouette (`loading.tsx`) ; lors d'une navigation ou d'un + * rafraîchissement — toujours faits en transition — l'ancienne page reste + * affichée jusqu'à la réponse. + * + * Les données sont relues à chaque navigation (clé d'historique de React + * Router) et à chaque `router.refresh()` (compteur de rafraîchissement), mais + * pas quand l'interface réécrit seulement l'URL (`history.replaceState`, ex. + * changement d'onglet) : comme avec Next.js. + */ + +import { createElement, createContext, startTransition, use, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useLocation } from "react-router"; + +// ─── Rafraîchissement (router.refresh) ────────────────────────────────────── + +const RefreshContext = createContext(0); + +let incrementer: (() => void) | null = null; + +/** Fournit le compteur de rafraîchissement dont dépendent les données des pages. */ +export function RefreshProvider({ children }: { children: ReactNode }) { + const [version, setVersion] = useState(0); + useEffect(() => { + incrementer = () => setVersion((v) => v + 1); + return () => { + incrementer = null; + }; + }, []); + return createElement(RefreshContext, { value: version }, children); +} + +/** Numéro de rafraîchissement courant. */ +export function useRefreshVersion(): number { + return useContext(RefreshContext); +} + +/** + * Relit les données de la page affichée (équivalent de `router.refresh()`). + * Par défaut en transition : la page reste visible pendant le chargement. + */ +export function refreshPage({ transition = true }: { transition?: boolean } = {}): void { + if (!incrementer) return; + if (transition) startTransition(() => incrementer?.()); + else incrementer(); +} + +// ─── Cache des promesses ──────────────────────────────────────────────────── + +const MAX_ENTREES = 40; +const cache = new Map>(); + +function memo(cle: string, charger: () => Promise): Promise { + let p = cache.get(cle) as Promise | undefined; + if (!p) { + p = charger(); + // Évite un rejet « non géré » dans la console : l'erreur est relevée par `use`. + p.catch(() => {}); + cache.set(cle, p); + while (cache.size > MAX_ENTREES) { + const plusAncienne = cache.keys().next().value; + if (plusAncienne === undefined) break; + cache.delete(plusAncienne); + } + } + return p; +} + +/** + * Clé de rendu de la page courante : change à chaque navigation (nouvelle + * entrée d'historique) et à chaque rafraîchissement, pas sur un simple + * `history.replaceState` de l'interface. + */ +export function usePageKey(): string { + const { key } = useLocation(); + const version = useRefreshVersion(); + return `${key}#${version}`; +} + +/** + * Données d'une page : suspend jusqu'à leur arrivée. Une erreur est relevée et + * remonte à la frontière d'erreur (`error.tsx`), comme l'exception d'une page + * serveur. + */ +export function usePageData(nom: string, charger: () => Promise): T { + const cle = `${nom}@${usePageKey()}`; + return use(memo(cle, charger)); +} + +export type Resultat = { ok: true; data: T } | { ok: false; error: string }; + +/** + * Comme `usePageData`, mais l'erreur est rendue sous forme de résultat : pour + * les pages qui affichaient un `ErrorState` avec le message de l'exception. + */ +export function usePageResult(nom: string, charger: () => Promise): Resultat { + return usePageData>(`${nom}:resultat`, () => + charger().then( + (data) => ({ ok: true as const, data }), + (e: unknown) => ({ ok: false as const, error: e instanceof Error ? e.message : String(e) }), + ), + ); +} + +/** + * Paramètres d'URL de la page, comme le `searchParams` d'une page serveur + * (première valeur de chaque clé). Ceux du routeur : une réécriture directe de + * l'URL par l'interface ne les change pas, comme avec Next.js. + */ +export function usePageSearchParams(): Record { + const { search } = useLocation(); + return useMemo(() => { + const o: Record = {}; + new URLSearchParams(search).forEach((v, k) => { + if (!(k in o)) o[k] = v; + }); + return o; + }, [search]); +} diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts deleted file mode 100644 index 5cbe396..0000000 --- a/src/lib/pg-ff-client.ts +++ /dev/null @@ -1,2159 +0,0 @@ -/** - * CollectFlow — Client PostgreSQL FF Nancy - * - * Remplace les appels HTTP per-article vers api.ffnancy.fr par des requêtes - * SQL bulk directes sur la base PostgreSQL FF Nancy. - * - * Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N). - */ - -import { db } from "@/db"; -import { sql, type SQL } from "drizzle-orm"; -import { ffFetchInit } from "@/lib/api-ff-client"; - -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; - - -// Cache module-level pour éviter les requêtes information_schema répétées -let _nomenclatureParentCol: string | null | undefined = undefined; // undefined = pas encore chargé - -// --------------------------------------------------------------------------- -// Utilitaire : exécuter une requête sans workers parallèles PostgreSQL. -// Les workers parallèles consomment /dev/shm (mémoire partagée Docker) et font -// échouer les requêtes si le conteneur a un shm_size insuffisant. -// Le réglage est désormais posé une fois par connexion du pool (src/db/index.ts) : -// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait -// trois allers-retours de plus à chacune. -// --------------------------------------------------------------------------- -/** - * `fn` sur chaque élément, au plus `simultanes` à la fois, résultats dans l'ordre - * (même forme que Promise.allSettled). Évite de lancer des centaines d'appels à - * l'API FF d'un coup, qui la saturent et finissent en délais dépassés. - */ -async function parLots(items: T[], simultanes: number, fn: (item: T) => Promise): Promise[]> { - const resultats: PromiseSettledResult[] = new Array(items.length); - let suivant = 0; - const ouvrier = async () => { - while (suivant < items.length) { - const i = suivant++; - try { - resultats[i] = { status: "fulfilled", value: await fn(items[i]) }; - } catch (reason) { - resultats[i] = { status: "rejected", reason }; - } - } - }; - await Promise.all(Array.from({ length: Math.min(simultanes, items.length) }, ouvrier)); - return resultats; -} - -async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> { - const r = await db.execute(query); - return { rows: r.rows }; -} - -// --------------------------------------------------------------------------- -// Types résultats SQL -// --------------------------------------------------------------------------- - -export interface PgArticle { - no_id: number; - codein: string; - /** Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik "Article Code". Vide pour les articles non référencés centralement. */ - codeCentrale?: string; - codefou: string; - nomfou?: string; - libelle1?: string; - pcb?: number; - reference?: string; - ean13?: string; - gtin?: string; // art_gtin.gtin (préférentiel) - pv_central?: number; // article_infosup.prix_vente_mini - pa?: number; // cube_pa.pa - codefou_principal?: string; // artfou1.code où preference=1 - nomfou_principal?: string; // nom du fournisseur principal -} - -export interface PgMensuelRow { - codein: string; - site: string; - mois: string; // "YYYY-MM" - qte_vendue: number; - ca_ht: number; // mntmvtttc (à vérifier si HT ou TTC) - marge: number; - stock_fin_mois: number; // MAX(qtestock) sur le mois - qte_recue: number; -} - -export interface PgStockRow { - codein: string; - site: string; - stockdispo: number; - qte: number; - valstock: number; - prmp: number; - /** Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. */ - pv?: number; - dernierevente?: string; - dernierereception?: string; -} - -// --------------------------------------------------------------------------- -// 0. Liste des fournisseurs -// --------------------------------------------------------------------------- - -/** - * Retourne la liste des fournisseurs depuis fouident (vrais noms) + artfou1. - */ -export async function pgGetFournisseurs(search?: string): Promise<{ code: string; nom: string }[]> { - try { - const result = await db.execute(sql` - SELECT DISTINCT fi.code, fi.nom - FROM fouident fi - INNER JOIN artfou1 af ON af.code = fi.code - WHERE fi.nom IS NOT NULL - AND fi.nom != '' - ${search ? sql`AND (fi.nom ILIKE ${'%' + search + '%'} OR fi.code ILIKE ${'%' + search + '%'})` : sql``} - ORDER BY fi.nom - `); - return (result.rows as unknown as { code: string; nom: string }[]).filter(r => r.code && r.nom); - } catch (e) { - console.error("[pg-ff] pgGetFournisseurs error:", (e as Error).message?.slice(0, 200)); - return []; - } -} - -// --------------------------------------------------------------------------- -// 1. Articles du fournisseur -// --------------------------------------------------------------------------- - -/** - * Retourne tous les articles d'un fournisseur avec leurs métadonnées. - * 1 seule requête SQL — remplace getArticlesByFournisseur() + per-article fetches. - */ -export async function pgGetArticlesByFournisseur(codefou: string): Promise { - // DISTINCT ON (a.no_id) car artfou1 peut avoir plusieurs lignes par article/fournisseur. - // Pas de filtre "actif" : on retourne tous les articles pour ne pas exclure les articles - // d'une session précédente (snapshot) qui n'ont pas de gamme DB ni de ventes récentes. - // Le filtrage gamme-Y sans ventes est fait en aval dans getProductRows (Phase 10). - // afpref : fournisseur préférentiel (preference=1) de l'article — peut différer du fournisseur consulté. - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (a.no_id) - a.no_id, - a.codein, - a.artcentrale AS "codeCentrale", - af.code AS codefou, - fi.nom AS nomfou, - a.libelle1, - af.pcb, - af.reference, - af.ean13, - COALESCE(ag.gtin, af.ean13) AS gtin, - ai.prix_vente_mini AS pv_central, - pa.pa, - last_fournisseur.codefou_dernier AS codefou_principal, - last_fournisseur.nomfou_dernier AS nomfou_principal - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id - LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id - LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 - LEFT JOIN LATERAL ( - SELECT af2.code AS codefou_dernier, fi2.nom AS nomfou_dernier - FROM artfou1 af2 - LEFT JOIN fouident fi2 ON fi2.code = af2.code - WHERE af2.art_no_id = a.no_id - ORDER BY af2.no_id DESC - LIMIT 1 - ) last_fournisseur ON true - WHERE af.code = ${codefou} - AND a.codein IS NOT NULL - AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) - AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) - ORDER BY a.no_id, af.no_id - `); - - console.log(`[pg-ff] Articles: ${result.rows.length} pour ${codefou}`); - return (result.rows as unknown as PgArticle[]).filter(r => r.codein); -} - -// --------------------------------------------------------------------------- -// 2. Données mensuelles (ventes + stock + réceptions) -// --------------------------------------------------------------------------- - -/** - * Retourne les données mensuelles agrégées par (codein, site, mois). - * 1 seule requête SQL — remplace getMensuelByArticles() (N appels HTTP). - * - * genremvt = 3 → ventes clients - * genremvt 1/2 → réceptions (à confirmer selon nomenclature FF Nancy) - * qtestock → stock après mouvement (MAX du mois ≈ stock fin de mois) - */ -export async function pgGetMensuelByFournisseur( - codefou: string, - dateDebut: string, - dateFin: string -): Promise { - // IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements. - // Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents), - // ce qui multiplierait chaque mouvement et gonflerait les totaux. - // - // stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC). - // MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux. - const result = await pgNoParallel(sql` - WITH base AS ( - SELECT - a.codein, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - m.qtemvt, - m.mntmvtttc, - m.margemvt, - m.genremvt, - m.qtestock, - ROW_NUMBER() OVER ( - PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY m.datmvt DESC - ) AS rn_last - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af - ON af.art_no_id = a.no_id - WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - ) - SELECT - codein, - site, - mois, - SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, - SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, - SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, - MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, - SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue - FROM base - GROUP BY codein, site, mois - ORDER BY codein, site, mois - `); - - return result.rows as unknown as PgMensuelRow[]; -} - -// --------------------------------------------------------------------------- -// 3. Gammes (saison active) -// --------------------------------------------------------------------------- - -/** - * Retourne la gamme courante (saison active) pour chaque article du fournisseur. - * 1 requête SQL — remplace getReferentielByArticles() pour la partie gammes. - */ -export async function pgGetGammesByFournisseur(codefou: string): Promise> { - // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier - // saisons n'a PAS de colonne "actif" → pas de filtre actif - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (a.codein) - a.codein, - g.code AS gamme_code - FROM art_gamme_saison ags - JOIN articles a ON a.no_id = ags.artnoid - JOIN artfou1 af ON af.art_no_id = a.no_id AND af.code = ${codefou} - JOIN gammes g ON g.no_id = ags.idgamme - JOIN saisons s ON s.no_id = ags.idsaison - ORDER BY a.codein, s.no_id DESC - `); - - const map = new Map(); - for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) { - if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim()); - } - return map; -} - -/** - * Gammes **serveur** pour une liste de codein, tous fournisseurs confondus. - * - * Variante de `pgGetGammesByFournisseur` sans la jointure `artfou1` : la recherche - * de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur. - * C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des - * snapshots de session qui peuvent surcharger `codeGamme` dans la Grille. - */ -export async function pgGetGammesByCodeins(codeins: string[]): Promise> { - const unique = [...new Set(codeins.map(c => String(c ?? "").trim()).filter(Boolean))]; - if (unique.length === 0) return new Map(); - - // Découpage obligatoire : chaque codein devient un paramètre lié, or PostgreSQL - // en accepte 65535 au maximum. Un gros fournisseur dépasse 130 000 articles — - // la requête échouait alors purement et simplement. - const CHUNK = 2000; - const map = new Map(); - - for (let i = 0; i < unique.length; i += CHUNK) { - const batch = unique.slice(i, i + CHUNK); - // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier. - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (a.codein) - TRIM(a.codein::text) AS codein, - g.code AS gamme_code - FROM art_gamme_saison ags - JOIN articles a ON a.no_id = ags.artnoid - JOIN gammes g ON g.no_id = ags.idgamme - JOIN saisons s ON s.no_id = ags.idsaison - WHERE TRIM(a.codein::text) IN (${sql.join(batch.map(c => sql`${c}`), sql`, `)}) - ORDER BY a.codein, s.no_id DESC - `); - for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) { - if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim()); - } - } - console.log(`[pg-ff] pgGetGammesByCodeins: ${map.size}/${unique.length} articles avec gamme`); - return map; -} - -// --------------------------------------------------------------------------- -// 4. Nomenclature (famille / sous-famille) -// --------------------------------------------------------------------------- - -export interface PgNomRow { - codein: string; - code3: string; // code feuille (ex: "360504") - libelle3: string; // libellé feuille - code2?: string; // famille (4 chiffres) - libelle2?: string; - code1?: string; // secteur (2 chiffres) - libelle1nom?: string; -} - -/** - * Retourne la nomenclature (famille/sous-famille) pour chaque article du fournisseur. - * FK confirmée : articles.nom_no_id → nomenclature.no_id - * Colonnes confirmées : nomenclature.code, nomenclature.libelle - * Colonne parent : découverte dynamiquement via information_schema - */ -/** - * Découvre (une seule fois par process) le nom de la colonne "parent" de la - * table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur. - * - * On ne peut pas la coder en dur : le nom varie selon les installations - * ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK), - * d'où le filtre explicite sur `data_type` entier. - */ -async function getNomenclatureParentCol(): Promise { - if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol; - - const metaResult = await db.execute(sql` - SELECT column_name, data_type FROM information_schema.columns - WHERE table_name = 'nomenclature' ORDER BY ordinal_position - `); - const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]); - console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", ")); - // Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent) - // "chemin_pere" = chemin vers le père en français - // On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé) - const parentRow = nomCols.find(r => - (/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name))) - && /int|serial/i.test(r.data_type) - ); - _nomenclatureParentCol = parentRow?.column_name ?? null; - console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol); - return _nomenclatureParentCol; -} - -export async function pgGetNomenclatureByFournisseur(codefou: string): Promise> { - const parentCol = await getNomenclatureParentCol(); - - let result; - if (parentCol) { - result = await pgNoParallel(sql` - SELECT - a.codein, - n3.code AS code3, - n3.libelle AS libelle3, - n2.code AS code2, - n2.libelle AS libelle2, - n1.code AS code1, - n1.libelle AS libelle1nom - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou} - JOIN nomenclature n3 ON n3.no_id = a.nom_no_id - LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)} - LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)} - WHERE a.codein IS NOT NULL - `); - } else { - // Sans hiérarchie : juste le niveau feuille - result = await pgNoParallel(sql` - SELECT - a.codein, - n3.code AS code3, - n3.libelle AS libelle3 - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou} - JOIN nomenclature n3 ON n3.no_id = a.nom_no_id - WHERE a.codein IS NOT NULL - `); - } - - console.log(`[pg-ff] Nomenclature: ${result.rows.length} articles, parentCol="${parentCol ?? "none"}"`); - return buildNomMap(result.rows as unknown as PgNomRow[]); -} - -function buildNomMap(rows: PgNomRow[]): Map { - const map = new Map(); - for (const r of rows) { - if (r.codein) map.set(r.codein, r); - } - return map; -} - -// --------------------------------------------------------------------------- -// 5. Stock temps réel (cube_stock) -// --------------------------------------------------------------------------- - -/** - * Retourne le stock actuel par site pour chaque article du fournisseur. - * 1 requête SQL — remplace la partie stock du référentiel. - */ -export async function pgGetStockByFournisseur(codefou: string): Promise> { - // `site` est élagué pour la même raison que dans pgGetPrixVenteByFournisseur : - // il sert de clé de recherche (derniereLivraisonByStore, prix de repli). - const result = await pgNoParallel(sql` - SELECT - a.codein, - TRIM(cs.site::text) AS site, - cs.stockdispo::float, - cs.qte::float, - cs.valstock::float, - cs.prmp::float, - cs.pv::float, - cs.dernierevente::text, - cs.dernierereception::text - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af - ON af.art_no_id = a.no_id - `); - - const map = new Map(); - for (const row of result.rows as unknown as PgStockRow[]) { - if (!map.has(row.codein)) map.set(row.codein, []); - map.get(row.codein)!.push(row); - } - return map; -} - -/** - * Prix de vente par article et par site (`cube_pv`). - * - * Pendant exact de `cube_pa` pour l'achat, rafraîchi chaque nuit depuis le cube - * MSSQL `Cube_PV`. C'est LA source du prix de vente : `article_infosup`, seule - * table lue jusqu'ici pour cette information, ne porte qu'un prix plancher de - * paramétrage (`prix_vente_mini`) qui reste vide en pratique. - * - * Retour : codein → { site → PV }. Les prix nuls sont écartés — un article non - * tarifé n'est pas un article à 0 €. - */ -export async function pgGetPrixVenteByFournisseur(codefou: string): Promise>> { - // Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où - // l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer - // par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur - // fixe remonterait complétée d'espaces, et la recherche échouerait en silence — - // le prix resterait visible en « tous magasins » et vide magasin par magasin. - const result = await pgNoParallel(sql` - SELECT - a.codein, - TRIM(cpv.site::text) AS site, - cpv.pv::float AS pv - FROM cube_pv cpv - JOIN articles a ON a.no_id = cpv.artnoid - JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af - ON af.art_no_id = a.no_id - WHERE cpv.pv IS NOT NULL AND cpv.pv > 0 - `); - - const map = new Map>(); - for (const row of result.rows as { codein: string; site: string; pv: number }[]) { - if (!row.codein || !row.site) continue; - const parSite = map.get(row.codein) ?? {}; - parSite[String(row.site)] = Number(row.pv); - map.set(row.codein, parSite); - } - console.log(`[pg-ff] Prix de vente : ${map.size} articles tarifés pour ${codefou}`); - return map; -} - -// --------------------------------------------------------------------------- -// 6. Commandes en cours -// --------------------------------------------------------------------------- - -/** - * Retourne les quantités commandées en cours pour chaque article du fournisseur. - * 1 requête SQL — remplace getCommandesByFournisseur(). - */ -export async function pgGetCommandesByFournisseur(codefou: string): Promise> { - // cdefou_vivant est une vue dénormalisée : articles_codein et cdefou_ligne_qtecde disponibles directement - const result = await pgNoParallel(sql` - SELECT - cv.articles_codein AS codein, - SUM(cv.cdefou_ligne_qtecde)::float AS qtecde - FROM cdefou_vivant cv - JOIN artfou1 af ON af.no_id = cv.artfou1_no_id AND af.code = ${codefou} - WHERE cv.articles_codein IS NOT NULL - GROUP BY cv.articles_codein - `); - - const map = new Map(); - for (const row of result.rows as unknown as { codein: string; qtecde: number }[]) { - if (row.codein) map.set(row.codein, Number(row.qtecde) || 0); - } - return map; -} - -// --------------------------------------------------------------------------- -// 7. Dashboard quotidien (hit parade + évolution N/N-1) -// --------------------------------------------------------------------------- - -export interface DashboardSiteStats { - site: string; - ca_hier: number; - ca_n1: number; - tickets_hier: number; - tickets_n1: number; - qte_hier: number; - marge_hier: number; - lignes_hier: number; -} - -export interface DashboardTopItem { - codein: string; - libelle1: string; - ca: number; - qte: number; - marge: number; - stock: number; - fournisseur: string; -} - -export interface DashboardSiteTop10 { - site: string; - ca: DashboardTopItem[]; - qte: DashboardTopItem[]; - marge: DashboardTopItem[]; -} - -export interface DashboardData { - dateHier: string; - dateN1: string; - sites: DashboardSiteStats[]; - top10BySite: DashboardSiteTop10[]; -} - -// --------------------------------------------------------------------------- -// Types internes API performance -// --------------------------------------------------------------------------- - -interface ApiPerfSite { - site: string; - ca_ttc: number; - ca_ttc_n1: number; - trafic: number; - trafic_n1: number; -} - -interface ApiPerfTopItem { - codein: string; - libelle1: string; - site: string; - codefou_reel?: string; - nom_fournisseur_reel?: string; - ca_ttc: number; - qte_vendue: number; - marge: number; -} - -interface ApiPerfDashboard { - date: string; - date_n1: string; - sites: ApiPerfSite[]; - top10_ca: ApiPerfTopItem[]; - top10_qte: ApiPerfTopItem[]; - top10_marge: ApiPerfTopItem[]; -} - -/** - * Retourne toutes les données nécessaires pour le dashboard quotidien. - * 100% via l'API REST api.ffnancy.fr — aucune requête PostgreSQL directe. - * N-1 : même jour de semaine (52 semaines = 364 jours avant hier). - */ -export async function pgGetDashboardData(): Promise { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - const dateHier = yesterday.toISOString().split("T")[0]; - - // N-1 : même jour de semaine, même semaine N-1 → 364 jours = 52 semaines exactes - const n1Date = new Date(yesterday); - n1Date.setDate(n1Date.getDate() - 364); - const dateN1Param = n1Date.toISOString().split("T")[0]; - - const apiBase = `${FF_API_BASE}/api/performance`; - - - // Deux appels parallèles : données hier + données même jour N-1 - const [apiResult, apiN1Result] = await Promise.all([ - fetch(`${apiBase}/dashboard?date=${dateHier}`, ffFetchInit()) - .then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise; }), - fetch(`${apiBase}/dashboard?date=${dateN1Param}`, ffFetchInit()) - .then(r => r.ok ? r.json() as Promise : null) - .catch(() => null), - ]); - - // Construire map N-1 par site depuis l'appel séparé - const n1BySite = new Map(); - for (const s of apiN1Result?.sites ?? []) { - n1BySite.set(s.site, { ca: Number(s.ca_ttc) || 0, trafic: Number(s.trafic) || 0 }); - } - - const sites: DashboardSiteStats[] = (apiResult.sites ?? []).map(s => { - const n1 = n1BySite.get(s.site); - // Priorité : même jour de semaine N-1 (si > 0), sinon fallback sur n1 calendaire de l'API - const caN1 = (n1?.ca && n1.ca > 0) ? n1.ca : (Number(s.ca_ttc_n1) || 0); - const trafficN1 = (n1?.trafic && n1.trafic > 0) ? n1.trafic : (Number(s.trafic_n1) || 0); - return { - site: s.site, - ca_hier: Number(s.ca_ttc) || 0, - ca_n1: caN1, - tickets_hier: Number(s.trafic) || 0, - tickets_n1: trafficN1, - qte_hier: 0, - marge_hier: 0, - lignes_hier: 0, - }; - }); - - // Top 10 par magasin : grouper les items par site, trier, garder top 10 - const allItems = [ - ...(apiResult.top10_ca ?? []), - ...(apiResult.top10_qte ?? []), - ...(apiResult.top10_marge ?? []), - ]; - const allCodeins = [...new Set(allItems.map(i => i.codein).filter(Boolean))]; - - // Fournisseur réel : un seul appel sur les mouvements du jour (codefou_reel / nom_fournisseur_reel). - // Enrichissement : fournisseur réel via /dernier-fournisseur (primaire) + /referentiel (stock + fallback fournisseur). - // Les deux appels sont faits en parallèle par article. - const fouMap = new Map(); // codein → nom_fournisseur résolu - const stockMap = new Map(); - - - if (allCodeins.length > 0) { - try { - // Vagues de 12 articles au lieu de 5 : chaque article coûte deux allers- - // retours successifs, et la page d'accueil attendait une douzaine de vagues. - const VAGUE = 12; - for (let i = 0; i < allCodeins.length; i += VAGUE) { - const batch = allCodeins.slice(i, i + VAGUE); - await Promise.all(batch.map(async (codein) => { - try { - const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit()); - if (!artRes.ok) return; - const artData = await artRes.json(); - if (!artData.articles || artData.articles.length === 0) return; - - const noId = artData.articles[0].no_id; - if (!noId) return; - - const [refRes, dernierRes] = await Promise.all([ - fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`, ffFetchInit()), - fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`, ffFetchInit()), - ]); - - // Stock (toujours depuis referentiel) - let refFournisseurFallback = ""; - if (refRes.ok) { - const refData = await refRes.json(); - let s292 = 0, s579 = 0, sTot = 0; - if (Array.isArray(refData.stock)) { - for (const s of refData.stock) { - const qte = Number(s.qte) || 0; - if (s.site === '292') s292 += qte; - else if (s.site === '579') s579 += qte; - sTot += qte; - } - } - stockMap.set(codein, { stock292: s292, stock579: s579, stockTotal: sTot }); - - // Fallback fournisseur (pas d'entrée en stock) : - // - 1 seul fournisseur → on l'utilise directement - // - plusieurs → celui avec preference = 1 - if (Array.isArray(refData.fournisseurs) && refData.fournisseurs.length > 0) { - type RefFou = { codefou: string; nom_fou: string | null; preference: number | null }; - const fous = refData.fournisseurs as RefFou[]; - const pick = fous.length === 1 - ? fous[0] - : (fous.find(f => f.preference === 1) ?? fous[0]); - refFournisseurFallback = pick.nom_fou?.trim() || pick.codefou?.trim() || ""; - } - } - - // Fournisseur réel : /dernier-fournisseur (dernière entrée en stock par site) - // Fallback : premier non-dépôt du référentiel - if (dernierRes.ok) { - const dernierData = await dernierRes.json(); - const entries: Array<{ site: string; nom_fournisseur: string }> = - dernierData.fournisseurs_par_site ?? []; - // Prendre le fournisseur du premier site disponible - const first = entries.find(e => e.nom_fournisseur); - if (first) { - fouMap.set(codein, String(first.nom_fournisseur).trim()); - } else if (refFournisseurFallback) { - fouMap.set(codein, refFournisseurFallback); - } - } else if (refFournisseurFallback) { - fouMap.set(codein, refFournisseurFallback); - } - } catch (err) { - console.error(`[Dashboard enrichment] Error for codein ${codein}:`, err); - } - })); - } - } catch (enrichErr: unknown) { - console.warn("[Dashboard] Enrichment unavailable:", enrichErr); - } - } - - const siteSet = [...new Set(allItems.map(i => i.site).filter(Boolean))].sort(); - - const buildTop10 = (items: ApiPerfTopItem[], sortKey: "ca" | "qte" | "marge"): DashboardTopItem[] => - items - .map(i => { - const stockEntry = stockMap.get(i.codein); - const stock = i.site === "292" ? (stockEntry?.stock292 ?? 0) - : i.site === "579" ? (stockEntry?.stock579 ?? 0) - : (stockEntry?.stockTotal ?? 0); - const fournisseur = fouMap.get(i.codein) ?? "—"; - return { - codein: i.codein, - libelle1: i.libelle1, - ca: Number(i.ca_ttc) || 0, - qte: Number(i.qte_vendue) || 0, - marge: Number(i.marge) || 0, - stock, - fournisseur, - }; - }) - .sort((a, b) => b[sortKey] - a[sortKey]) - .slice(0, 10); - - const top10BySite: DashboardSiteTop10[] = siteSet.map(site => { - const byS = (arr: ApiPerfTopItem[]) => arr.filter(i => i.site === site); - return { - site, - ca: buildTop10(byS(apiResult.top10_ca ?? []), "ca"), - qte: buildTop10(byS(apiResult.top10_qte ?? []), "qte"), - marge: buildTop10(byS(apiResult.top10_marge ?? []), "marge"), - }; - }); - - console.log(`[pg-ff] Dashboard: hier=${dateHier} n1=${dateN1Param} sites=${sites.length} n1_found=${n1BySite.size}`); - - return { - dateHier: apiResult.date ?? dateHier, - dateN1: apiN1Result?.date ?? dateN1Param, - sites, - top10BySite, - }; -} - -// --------------------------------------------------------------------------- -// Analytics — CA TTC par fournisseur et nomenclature -// --------------------------------------------------------------------------- - -export interface CaByFournisseurRow { - code: string; - nom: string; - site: string; - mois: string; - ca_ttc: number; -} - -export interface CaByNomenclatureRow { - code: string; - libelle: string; - site: string; - mois: string; - ca_ttc: number; -} - -/** - * Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1). - * Attribution via artfou1.preference = true (fournisseur principal de l'article). - * CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention - * que pgGetMensuelByFournisseur / pgGetHitParade). Fournisseur préféré résolu - * via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par - * article, aucune duplication possible des mouvements dans la somme. - */ -/** - * Bornes d'un mois « YYYY-MM » : [premier jour, premier jour du mois suivant[. - * - * Filtrer `datmvt` sur ces bornes plutôt que sur `TO_CHAR(datmvt, 'YYYY-MM')` - * donne exactement les mêmes lignes, mais sans formater en texte la date de - * chaque mouvement de l'historique (et reste utilisable par un index sur datmvt). - */ -function bornesMois(mois: string): { debut: string; fin: string } { - const [annee, m] = mois.split("-").map(Number); - const suivant = m === 12 ? `${annee + 1}-01` : `${annee}-${String(m + 1).padStart(2, "0")}`; - return { debut: `${mois}-01`, fin: `${suivant}-01` }; -} - -/** Mouvements du mois OU du mois N-1 (cf. `bornesMois`). */ -function filtreDeuxMois(mois: string, moisN1: string): SQL { - const a = bornesMois(mois); - const b = bornesMois(moisN1); - return sql`((m.datmvt >= ${a.debut}::date AND m.datmvt < ${a.fin}::date) - OR (m.datmvt >= ${b.debut}::date AND m.datmvt < ${b.fin}::date))`; -} - -export async function pgGetCaByFournisseur( - mois: string, - moisN1: string -): Promise { - const result = await pgNoParallel(sql` - SELECT - COALESCE(af.code, 'SANS_FOURNISSEUR')::text AS code, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - SUM(-m.mntmvtttc)::float AS ca_ttc - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - LEFT JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - LEFT JOIN fouident fi ON fi.code = af.code - WHERE ${filtreDeuxMois(mois, moisN1)} - AND m.site IN ('292', '579') - AND m.genremvt = 3 - GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY af.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - `); - - console.log(`[pg-ff] CaByFournisseur: ${result.rows.length} lignes pour ${mois}/${moisN1}`); - return result.rows as unknown as CaByFournisseurRow[]; -} - -/** - * Retourne le CA TTC par nomenclature et site pour deux mois donnés. - * CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune). - */ -export async function pgGetCaByNomenclature( - mois: string, - moisN1: string -): Promise { - const result = await pgNoParallel(sql` - SELECT - COALESCE(n.code, 'SANS_NOM')::text AS code, - COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - SUM(-m.mntmvtttc)::float AS ca_ttc - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id - WHERE ${filtreDeuxMois(mois, moisN1)} - AND m.site IN ('292', '579') - AND m.genremvt = 3 - GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY n.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - `); - - console.log(`[pg-ff] CaByNomenclature: ${result.rows.length} lignes pour ${mois}/${moisN1}`); - return result.rows as unknown as CaByNomenclatureRow[]; -} - -// --------------------------------------------------------------------------- -// Hit Parade — produits les plus vendus sur un mois donné -// --------------------------------------------------------------------------- - -export interface HitParadeRow { - codein: string; - libelle: string; - /** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */ - reference: string; - fournisseur: string; - nomenclature_code: string; - nomenclature: string; - site: string; - qte_vendue: number; - ca_ttc: number; - marge: number; - stock292: number; - stock579: number; - stockTotal: number; -} - -/** - * Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site. - * - * L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1), - * exactement comme pgGetMensuelByFournisseur (le calcul canonique de la Grille) : - * qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3 - * (ventes stockées en négatif — vérifié sur les mouvements réels de l'API). - * Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont - * joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les - * mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que - * par article vendu (et non par ligne de mouvement). - * - * Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes, - * 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours - * additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle. - */ -export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise { - const result = await pgNoParallel(sql` - WITH ventes AS ( - SELECT - TRIM(a.codein::text) AS codein, - m.site, - SUM(-m.qtemvt)::float AS qte_vendue, - SUM(-m.mntmvtttc)::float AS ca_ttc, - SUM(m.margemvt)::float AS marge - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - AND m.genremvt = 3 - AND a.codein IS NOT NULL - GROUP BY TRIM(a.codein::text), m.site - ), - attrs AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) - TRIM(a.codein::text) AS codein, - a.libelle1::text AS libelle, - COALESCE(TRIM(af.reference::text), '') AS reference, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - COALESCE(n.code, '')::text AS nomenclature_code, - COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature - FROM articles a - LEFT JOIN LATERAL ( - SELECT af1.code, af1.reference - FROM artfou1 af1 - WHERE af1.art_no_id = a.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id - WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes) - ORDER BY TRIM(a.codein::text), a.no_id DESC - ), - stock_agg AS ( - SELECT - TRIM(a2.codein::text) AS codein, - SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292, - SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579, - SUM(cs.qte)::float AS stockTotal - FROM cube_stock cs - JOIN articles a2 ON a2.no_id = cs.artnoid - WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes) - GROUP BY TRIM(a2.codein::text) - ) - SELECT - v.codein, - COALESCE(ar.libelle, '') AS libelle, - COALESCE(ar.reference, '') AS reference, - COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur, - COALESCE(ar.nomenclature_code, '') AS nomenclature_code, - COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature, - v.site, - v.qte_vendue, - v.ca_ttc, - v.marge, - COALESCE(s.stock292, 0) AS stock292, - COALESCE(s.stock579, 0) AS stock579, - COALESCE(s.stockTotal, 0) AS "stockTotal" - FROM ventes v - LEFT JOIN attrs ar ON ar.codein = v.codein - LEFT JOIN stock_agg s ON s.codein = v.codein - ORDER BY v.ca_ttc DESC - `); - - console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${dateDebut}→${dateFin}`); - return result.rows as unknown as HitParadeRow[]; -} - -// --------------------------------------------------------------------------- -// Stock par liste de codeins (pour Hit Parade) -// --------------------------------------------------------------------------- - -export interface StockBySite { - stock292: number; - stock579: number; - stockTotal: number; -} - -/** - * Retourne le stock actuel (stockdispo) par codein pour une liste de codeins. - * 1 seule requête bulk — utilisé pour enrichir le Hit Parade. - * - * On passe les codeins comme une SEULE chaîne CSV ($1) et on utilise - * string_to_array côté PG — cela évite l'expansion Drizzle en $1,$2,...$N - * qui déclencherait le PG error 54011 (ROW > 1664 entries). - */ -export async function pgGetStockForCodeins(codeins: string[]): Promise> { - if (codeins.length === 0) return new Map(); - - const result = await pgNoParallel( - sql`SELECT TRIM(a.codein::text) AS codein, cs.site, COALESCE(cs.qte, 0)::float AS stockdispo - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - WHERE TRIM(a.codein::text) IN (${sql.join(codeins.map(c => sql`${c}`), sql`, `)})` - ); - const rows = result.rows as { codein: string; site: string; stockdispo: number }[]; - - const map = new Map(); - for (const r of rows) { - if (!map.has(r.codein)) map.set(r.codein, { stock292: 0, stock579: 0, stockTotal: 0 }); - const entry = map.get(r.codein)!; - if (r.site === "292") entry.stock292 += r.stockdispo; - else if (r.site === "579") entry.stock579 += r.stockdispo; - entry.stockTotal += r.stockdispo; - } - return map; -} - -// --------------------------------------------------------------------------- -// Stock — fournisseur de la dernière entrée -// --------------------------------------------------------------------------- - -/** - * Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur. - * `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le - * détecte une fois par process (même principe que `getNomenclatureParentCol`). - * - target "artfou1" : la colonne est une FK vers `artfou1.no_id` - * - target "code" : la colonne contient directement le code fournisseur (`fouident.code`) - */ -const MVTART_FOU_CANDIDATES: { column: string; target: "artfou1" | "code" }[] = [ - { column: "artfou1_no_id", target: "artfou1" }, - { column: "artfou_no_id", target: "artfou1" }, - { column: "afou_no_id", target: "artfou1" }, - { column: "codefou", target: "code" }, - { column: "code_fou", target: "code" }, - { column: "fou_code", target: "code" }, - { column: "fournisseur", target: "code" }, -]; - -let _mvtartFouLink: { column: string; target: "artfou1" | "code" } | null | undefined = undefined; - -async function getMvtartFouLink() { - if (_mvtartFouLink !== undefined) return _mvtartFouLink; - try { - const meta = await db.execute(sql` - SELECT column_name FROM information_schema.columns - WHERE table_name = 'mvtart' - `); - const cols = new Set( - (meta.rows as { column_name: string }[]).map(r => r.column_name.toLowerCase()) - ); - // Le nom vient d'information_schema ET doit figurer dans la liste blanche - // ci-dessus : aucune valeur utilisateur n'atteint le SQL. - _mvtartFouLink = MVTART_FOU_CANDIDATES.find(c => cols.has(c.column)) ?? null; - } catch (e) { - console.error("[pg-ff] Détection mvtart.fournisseur impossible:", e); - _mvtartFouLink = null; - } - console.log("[pg-ff] mvtart → fournisseur:", _mvtartFouLink ?? "aucune colonne (repli artfou1.preference=1)"); - return _mvtartFouLink; -} - -/** - * Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du - * couple article/site : elle expose `lastentry.datmvt` (date) et - * `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas - * l'information). - * - * Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière - * entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus - * apparaître sous le précédent. - * - * La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie - * dernière entrée, même si son fournisseur est inconnu. - */ -function lastEntryJoin( - link: { column: string; target: "artfou1" | "code" } | null, - artnoidExpr: SQL, - siteExpr: SQL, -): SQL { - const codeExpr = link - ? (link.target === "artfou1" ? sql`afe.code` : sql.raw(`me.${link.column}`)) - : sql`NULL::text`; - const joinArtfou = link && link.target === "artfou1" - ? sql`LEFT JOIN artfou1 afe ON afe.no_id = ${sql.raw(`me.${link.column}`)}` - : sql``; - return sql` - LEFT JOIN LATERAL ( - SELECT me.datmvt, ${codeExpr} AS code - FROM mvtart me - ${joinArtfou} - WHERE me.artnoid = ${artnoidExpr} - AND me.site = ${siteExpr} - AND me.genremvt IN (1, 2) - ORDER BY me.datmvt DESC, me.no_id DESC - LIMIT 1 - ) lastentry ON TRUE - ${link ? sql`LEFT JOIN fouident flast ON flast.code = lastentry.code` : sql``} - `; -} - -/** - * Libellé fournisseur affiché : celui de la dernière entrée quand la base le - * permet, sinon repli documenté sur `artfou1.preference = 1`. - */ -function fournisseurExpr(link: { column: string; target: "artfou1" | "code" } | null): SQL { - return link - ? sql`COALESCE(flast.nom, lastentry.code, f.nom, af.code)` - : sql`COALESCE(f.nom, af.code)`; -} - -/** - * Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les - * colonnes réellement présentes sur `mvtart` et un échantillon comparant le - * fournisseur principal au fournisseur de la dernière entrée. - */ -export async function pgDiagFournisseurDerniereEntree() { - const link = await getMvtartFouLink(); - const meta = await db.execute(sql` - SELECT column_name, data_type FROM information_schema.columns - WHERE table_name = 'mvtart' ORDER BY ordinal_position - `); - let echantillon: unknown[] = []; - if (link) { - const r = await pgNoParallel(sql` - SELECT - a.codein, - cs.site, - COALESCE(f.nom, af.code) AS fournisseur_preference, - COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree, - lastentry.datmvt::text AS derniere_entree - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - LEFT JOIN fouident f ON f.code = af.code - ${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)} - WHERE cs.qte < 0 - AND cs.site IN ('292', '579') - LIMIT 20 - `); - echantillon = r.rows; - } - return { - colonneDetectee: link, - candidatsTestes: MVTART_FOU_CANDIDATES.map(c => c.column), - colonnesMvtart: meta.rows, - echantillon, - }; -} - -// --------------------------------------------------------------------------- -// Stock négatif — tous magasins ou par site -// --------------------------------------------------------------------------- - -export interface PgStockNegatifRow { - codein: string; - libelle1: string; - fournisseur: string; - site: string; - stockdispo: number; - dernierevente: string | null; - derniereentree: string | null; -} - -export interface PgStockSansVenteRow { - codein: string; - libelle1: string; - fournisseur: string; - site: string; - stock_actuel: number; - derniere_entree: string | null; -} - -/** - * Retourne les articles ayant des entrées en stock (hors mois courant) - * mais aucune vente enregistrée sur ce site. - * Les articles dont le stock actuel est nul sont exclus (rien à régulariser). - */ -export async function pgGetStockSansVente(site?: string): Promise { - const link = await getMvtartFouLink(); - const siteFilter = site ? sql`AND m.site = ${site}` : sql``; - const result = await pgNoParallel(sql` - SELECT - a.codein, - COALESCE(a.libelle1, '') AS libelle1, - ${fournisseurExpr(link)} AS fournisseur, - m.site, - COALESCE(cs.qte::float, 0) AS stock_actuel, - MAX(m.datmvt)::text AS derniere_entree - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - JOIN fouident f ON f.code = af.code - LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site - ${lastEntryJoin(link, sql`a.no_id`, sql`m.site`)} - WHERE m.genremvt IN (1, 2) - AND m.site IN ('292', '579') - AND m.datmvt < date_trunc('month', CURRENT_DATE) - ${siteFilter} - AND NOT EXISTS ( - SELECT 1 FROM mvtart mv2 - WHERE mv2.artnoid = m.artnoid - AND mv2.site = m.site - AND mv2.genremvt = 3 - ) - GROUP BY a.codein, a.libelle1, ${fournisseurExpr(link)}, m.site, cs.qte - HAVING COALESCE(cs.qte::float, 0) <> 0 - ORDER BY MAX(m.datmvt) DESC - `); - return result.rows as PgStockSansVenteRow[]; -} - -export async function pgGetStockNegatif(site?: string): Promise { - const link = await getMvtartFouLink(); - const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; - const result = await pgNoParallel(sql` - SELECT - a.codein, - COALESCE(a.libelle1, '') AS libelle1, - ${fournisseurExpr(link)} AS fournisseur, - cs.site, - cs.qte::float AS stockdispo, - cs.dernierevente::text, - lastentry.datmvt::text AS derniereentree - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - JOIN fouident f ON f.code = af.code - ${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)} - WHERE cs.qte < 0 - AND cs.site IN ('292', '579') - ${siteFilter} - ORDER BY cs.qte ASC - `); - return result.rows as PgStockNegatifRow[]; -} - -// --------------------------------------------------------------------------- -// Stock sans vente depuis 6 mois -// --------------------------------------------------------------------------- - -export interface PgSansVente6MoisRow { - codein: string; - libelle1: string; - fournisseur: string; - site: string; - stock_actuel: number; - derniere_vente: string | null; - derniere_entree: string | null; - jours_sans_vente: number | null; -} - -/** - * Retourne les articles ayant du stock disponible (qte > 0) - * dont la dernière vente remonte à plus de 6 mois (ou jamais vendus) - * ET dont la dernière entrée en stock remonte aussi à plus de 6 mois. - */ -export async function pgGetSansVente6Mois(site?: string): Promise { - const link = await getMvtartFouLink(); - const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; - const result = await pgNoParallel(sql` - SELECT - a.codein, - COALESCE(a.libelle1, '') AS libelle1, - ${fournisseurExpr(link)} AS fournisseur, - cs.site, - cs.qte::float AS stock_actuel, - cs.dernierevente::text AS derniere_vente, - CASE - WHEN cs.dernierevente IS NULL THEN NULL - ELSE (CURRENT_DATE - cs.dernierevente::date) - END AS jours_sans_vente, - lastentry.datmvt::text AS derniere_entree - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - JOIN fouident f ON f.code = af.code - ${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)} - WHERE cs.site IN ('292', '579') - AND cs.qte > 0 - AND ( - cs.dernierevente IS NULL - OR cs.dernierevente < CURRENT_DATE - INTERVAL '6 months' - ) - AND NOT EXISTS ( - SELECT 1 FROM mvtart m2 - WHERE m2.artnoid = cs.artnoid - AND m2.site = cs.site - AND m2.genremvt IN (1, 2) - AND m2.datmvt >= CURRENT_DATE - INTERVAL '6 months' - ) - ${siteFilter} - ORDER BY cs.dernierevente ASC NULLS FIRST - `); - return result.rows as PgSansVente6MoisRow[]; -} - -// --------------------------------------------------------------------------- -// Commandes automatiques — par site + fournisseur avec franco -// --------------------------------------------------------------------------- - -export interface PgCommandeAutoRow { - site: string; - codefou: string; - nomfou: string; - franco: number; - nb_articles: number; - montant_cde: number; - franco_atteint: boolean | null; - ecart_franco: number; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function mapCommandeAutoRow(r: any): PgCommandeAutoRow { - return { - site: String(r.site ?? ""), - codefou: String(r.codefou ?? ""), - nomfou: String(r.nom_fou ?? r.nomfou ?? ""), - franco: Number(r.franco_ht ?? r.franco ?? 0), - nb_articles: Number(r.nb_articles ?? 0), - montant_cde: Number(r.montant_propo_ht ?? r.montant_cde ?? 0), - franco_atteint: r.franco_atteint === "OUI" ? true - : r.franco_atteint === "NON" ? false - : r.franco_atteint === "N/A" ? null - : r.franco_atteint != null ? Boolean(r.franco_atteint) - : null, - ecart_franco: Number(r.ecart_franco ?? 0), - }; -} - -/** - * Retourne la liste des fournisseurs en commande automatique via l'API Hostinger. - * GET https://api.ffnancy.fr/api/commandes-auto - * - * Pour les fournisseurs dont le franco est absent de la réponse globale (franco_ht null), - * on enrichit via le detail endpoint /api/commandes-auto/:codefou?site= qui contient - * toujours le résumé franco du fournisseur. - */ -export async function pgGetCommandesAuto(): Promise { - - try { - const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, ffFetchInit()); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - // L'API retourne { count: N, propositions: [...] } ou un tableau direct - const rows: unknown[] = Array.isArray(data) - ? data - : (data?.propositions ?? data?.data ?? data?.items ?? data?.results ?? []); - - // Diagnostic : log les clés du premier row pour détecter les changements de format - if (rows.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sample = rows[0] as any; - console.log(`[pg-ff] pgGetCommandesAuto sample keys:`, Object.keys(sample)); - console.log(`[pg-ff] pgGetCommandesAuto sample franco fields: franco_ht=${sample.franco_ht} franco=${sample.franco} franco_atteint=${sample.franco_atteint}`); - } - - console.log(`[pg-ff] pgGetCommandesAuto: ${rows.length} lignes (API Hostinger)`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mapped = (rows as any[]).map(mapCommandeAutoRow); - - // Enrichissement : pour les fournisseurs sans franco dans la réponse globale, - // on appelle GET /api/commandes-auto/{codefou} (sans filtre site — franco est une - // propriété du fournisseur, pas du site) et on lit resume[0].franco_ht. - const missingFranco = mapped.filter(r => r.franco === 0 && r.codefou); - if (missingFranco.length > 0) { - console.log(`[pg-ff] pgGetCommandesAuto: ${missingFranco.length} lignes sans franco → enrichissement`); - // Dédupliquer par codefou (franco identique quel que soit le site) - const uniqueCodeFous = [...new Set(missingFranco.map(r => r.codefou))]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const extractFranco = (detail: any): number => { - if (!detail || detail.error) return 0; - const resumeRaw = detail?.resume ?? detail?.summary ?? detail; - const resumeItem = Array.isArray(resumeRaw) ? resumeRaw[0] : resumeRaw; - return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0); - }; - - // 6 fournisseurs à la fois : chacun peut coûter deux appels successifs. - const details = await parLots(uniqueCodeFous, 6, async (codefou) => { - // 1. Essai sans filtre site - try { - const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit()); - if (res2.ok) { - const detail = await res2.json(); - const f = extractFranco(detail); - if (f > 0) return { codefou, franco: f }; - } - } catch { /* ignore */ } - // 2. Fallback site=000 - try { - const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit()); - if (res3.ok) { - const detail = await res3.json(); - const f = extractFranco(detail); - if (f > 0) return { codefou, franco: f }; - } - } catch { /* ignore */ } - return { codefou, franco: 0 }; - }); - - // Map codefou → franco enrichi - const francoMap = new Map(); - for (const result of details) { - if (result.status === "fulfilled" && result.value.franco > 0) { - francoMap.set(result.value.codefou, result.value.franco); - } - } - console.log(`[pg-ff] pgGetCommandesAuto: francos enrichis:`, Object.fromEntries(francoMap)); - - return mapped.map(r => { - if (r.franco === 0) { - const enriched = francoMap.get(r.codefou); - if (enriched && enriched > 0) return { ...r, franco: enriched }; - } - return r; - }); - } - - return mapped; - } catch (e) { - console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300)); - return []; - } -} - -// --------------------------------------------------------------------------- -// 12. Dernière réception par fournisseur ET par site (cadencier) -// --------------------------------------------------------------------------- - -/** - * Retourne la date de la dernière réception (entrée de marchandise) par - * fournisseur et par site, pour piloter le cadencier de commande. - * - * Source : mvtart (genremvt IN (1, 2) = réceptions, voir pgGetMensuelByFournisseur). - * La réception est rattachée à l'article ; on l'attribue au fournisseur - * principal de l'article (artfou1.preference = 1), comme partout ailleurs. - * - * Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur - * (l'UI affichera alors « échéance inconnue »). - */ -export async function pgGetDerniereReceptionParFournisseur(codes?: string[]): Promise> { - const map = new Map(); - // Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles, - // au lieu de tout l'historique de réceptions de tous les fournisseurs. - if (codes && codes.length === 0) return map; - const filtreFournisseurs = codes - ? sql`AND TRIM(af.code) IN (${sql.join(codes.map((c) => sql`${c.trim()}`), sql`, `)})` - : sql``; - try { - const result = await pgNoParallel(sql` - SELECT - af.code AS codefou, - m.site AS site, - MAX(m.datmvt) AS derniere - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 - WHERE m.genremvt IN (1, 2) - AND m.site IN ('292', '579') - AND m.datmvt IS NOT NULL - ${filtreFournisseurs} - GROUP BY af.code, m.site - `); - - for (const row of result.rows as { codefou: string; site: string; derniere: string | Date }[]) { - if (!row.codefou || !row.derniere) continue; - const iso = row.derniere instanceof Date - ? row.derniere.toISOString() - : new Date(row.derniere).toISOString(); - map.set(`${String(row.codefou).trim()}|${String(row.site ?? "").trim()}`, iso); - } - console.log(`[pg-ff] pgGetDerniereReceptionParFournisseur: ${map.size} entrées (mvtart)`); - return map; - } catch (e) { - console.error("[pg-ff] pgGetDerniereReceptionParFournisseur error:", (e as Error).message?.slice(0, 250)); - return map; - } -} - -// --------------------------------------------------------------------------- -// 13. Recherche & fiche produit (page /produits) -// --------------------------------------------------------------------------- - -/** - * Une ligne de résultat de recherche produit. Volontairement **légère** : - * aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût). - * Le détail lourd n'est calculé qu'à l'ouverture de la fiche. - */ -export interface PgProduitSearchRow { - no_id: number; - codein: string; - code_centrale: string; - libelle1: string; - /** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */ - fournisseur: string; - codefou: string; - nomenclature_code: string; - nomenclature: string; - stock_total: number; - /** true si le produit a déjà des métriques réseau Qlik en cache. */ - has_reseau: boolean; - qte_reseau: number; - nb_magasins_reseau: number; -} - -/** - * Caractères accentués et leur équivalent non accentué, pour `translate()`. - * Les deux chaînes DOIVENT rester de même longueur, caractère par caractère. - * Postgres n'a pas `unaccent()` sans l'extension : on ne peut pas compter dessus - * sur la base FF, d'où cette table de correspondance explicite. - */ -const SQL_ACCENTS_FROM = "àâäãáåéèêëíìîïóòôöõúùûüçñýÿ"; -const SQL_ACCENTS_TO = "aaaaaaeeeeiiiiooooouuuucnyy"; - -/** - * Normalise un terme de recherche côté JS, de la même façon que - * `normalizedLibelleSql()` le fait côté SQL : minuscules, accents retirés, - * toute ponctuation ramenée à un espace. - */ -function normalizeSearchTerm(term: string): string { - return term - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") // retire les diacritiques - .replace(/[^a-z0-9]+/g, " ") - .trim(); -} - -/** Expression SQL renvoyant le libellé normalisé (minuscules, sans accent ni ponctuation). */ -function normalizedLibelleSql() { - return sql`regexp_replace(translate(lower(COALESCE(a.libelle1, '')), ${SQL_ACCENTS_FROM}, ${SQL_ACCENTS_TO}), '[^a-z0-9]+', ' ', 'g')`; -} - -/** - * Recherche un produit par **libellé** ou par **code centrale** (préfixe). - * Si le terme est entièrement numérique, on tente aussi une correspondance - * exacte sur le `codein`. - * - * La recherche par libellé est **insensible aux accents et à la ponctuation**, - * et fonctionne **mot à mot** : « tapis anti-poussière » retrouve - * « TAPIS ANTI POUSSIERE », et « tapis poussiere » aussi (tous les mots doivent - * être présents, dans n'importe quel ordre). Sans cette normalisation, un - * `ILIKE` brut exigeait l'accent ET le tiret exacts et ne remontait rien. - * - * ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée - * chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc - * un seq-scan avec normalisation par ligne : l'appelant impose un minimum de - * 3 caractères et une soumission explicite, jamais une recherche à la frappe. - */ -export async function pgSearchProduits(term: string, limit = 50): Promise { - const cleaned = term.trim(); - if (cleaned.length < 3) return []; - - const prefix = `${cleaned}%`; - // Mots de la recherche, normalisés. Tous doivent être présents (AND). - const tokens = normalizeSearchTerm(cleaned).split(" ").filter(Boolean); - const normLibelle = normalizedLibelleSql(); - const libelleClause = tokens.length > 0 - ? sql`(${sql.join(tokens.map((t) => sql`${normLibelle} LIKE ${`%${t}%`}`), sql` AND `)})` - : sql`FALSE`; - - // Un terme purement numérique peut être un codein : on l'ajoute au filtre - // (correspondance exacte, la colonne est space-padded d'où le TRIM). - const codeinClause = /^\d+$/.test(cleaned) - ? sql`OR TRIM(a.codein::text) = ${cleaned}` - : sql``; - - try { - const result = await pgNoParallel(sql` - WITH raw AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) - a.no_id, - TRIM(a.codein::text) AS codein, - COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1, - a.nom_no_id - FROM articles a - WHERE a.codein IS NOT NULL - AND ( - ${libelleClause} - OR TRIM(a.artcentrale::text) ILIKE ${prefix} - ${codeinClause} - ) - ORDER BY TRIM(a.codein::text), a.no_id DESC - ), - matched AS ( - SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit} - ) - SELECT - m.no_id, - m.codein, - m.code_centrale, - m.libelle1, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - COALESCE(af.code, '')::text AS codefou, - COALESCE(n.code, '')::text AS nomenclature_code, - COALESCE(n.libelle, '')::text AS nomenclature, - COALESCE(st.stock_total, 0)::float AS stock_total, - (q.code_centrale IS NOT NULL) AS has_reseau, - COALESCE(q.qte_reseau, 0)::float AS qte_reseau, - COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau - FROM matched m - LEFT JOIN LATERAL ( - SELECT af1.code - FROM artfou1 af1 - WHERE af1.art_no_id = m.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id - LEFT JOIN LATERAL ( - SELECT SUM(cs.qte)::float AS stock_total - FROM cube_stock cs - WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') - ) st ON TRUE - LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale - ORDER BY m.libelle1 - `); - - console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`); - return result.rows as unknown as PgProduitSearchRow[]; - } catch (e) { - console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** - * Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy. - * - * C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau - * travaille, cette requête dit lesquels nous référençons — et avec quel - * `codein`, quel fournisseur et quel stock. Un code absent du résultat est un - * produit réseau que Nancy ne référence pas (cas normal et intéressant). - * - * Les codeins sont renvoyés indexés par code centrale ; un même code centrale - * peut porter plusieurs codeins (ré-créations d'article), on ne garde que le - * `no_id` le plus récent. - */ -export async function pgGetProduitsByCodeCentrale( - codesCentraux: string[], -): Promise> { - const out = new Map(); - const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))]; - if (codes.length === 0) return out; - - try { - const result = await pgNoParallel(sql` - WITH matched AS ( - SELECT DISTINCT ON (TRIM(a.artcentrale::text)) - a.no_id, - TRIM(a.codein::text) AS codein, - TRIM(a.artcentrale::text) AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1, - a.nom_no_id - FROM articles a - WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)}) - AND a.codein IS NOT NULL - ORDER BY TRIM(a.artcentrale::text), a.no_id DESC - ) - SELECT - m.no_id, - m.codein, - m.code_centrale, - m.libelle1, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - COALESCE(af.code, '')::text AS codefou, - COALESCE(n.code, '')::text AS nomenclature_code, - COALESCE(n.libelle, '')::text AS nomenclature, - COALESCE(st.stock_total, 0)::float AS stock_total, - TRUE AS has_reseau, - 0::float AS qte_reseau, - 0 AS nb_magasins_reseau - FROM matched m - LEFT JOIN LATERAL ( - SELECT af1.code - FROM artfou1 af1 - WHERE af1.art_no_id = m.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id - LEFT JOIN LATERAL ( - SELECT SUM(cs.qte)::float AS stock_total - FROM cube_stock cs - WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') - ) st ON TRUE - `); - - for (const row of result.rows as unknown as PgProduitSearchRow[]) { - out.set(row.code_centrale, row); - } - console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`); - return out; - } catch (e) { - console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250)); - return out; - } -} - -/** - * `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le - * référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik - * (URL `?cc=…`), qui n'a que le code centrale. - */ -export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise { - const cleaned = codeCentrale.trim(); - if (!cleaned) return null; - try { - const result = await pgNoParallel(sql` - SELECT TRIM(a.codein::text) AS codein - FROM articles a - WHERE TRIM(a.artcentrale::text) = ${cleaned} - AND a.codein IS NOT NULL - ORDER BY a.no_id DESC - LIMIT 1 - `); - const row = (result.rows as unknown as { codein: string }[])[0]; - return row?.codein ?? null; - } catch (e) { - console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250)); - return null; - } -} - -/** Identité, prix et nomenclature d'un produit (une seule ligne). */ -export interface PgProduitDetailRow { - no_id: number; - codein: string; - code_centrale: string; - libelle1: string; - nom_no_id: number | null; - gtin: string; - reference: string; - pcb: number | null; - /** article_infosup.prix_vente_mini */ - pv_central: number | null; - /** cube_pa.pa */ - pa: number | null; - code3: string | null; - libelle3: string | null; - code2: string | null; - libelle2: string | null; - code1: string | null; - libelle1nom: string | null; -} - -/** - * Fiche d'identité complète d'un produit à partir de son `codein`. - * Renvoie `null` si le codein est inconnu. - */ -export async function pgGetProduitDetail(codein: string): Promise { - const parentCol = await getNomenclatureParentCol(); - // Hiérarchie nomenclature — deux stratégies : - // - // 1. Colonne parent entière si elle existe (FK vers le no_id du niveau au-dessus). - // 2. Sinon, remontée par **préfixe de code** : la nomenclature FF est encodée - // hiérarchiquement, secteur = 2 chiffres, famille = 4, sous-famille = 6 - // (ex "330702" → "3307" → "33"). En production `nomenclature` n'expose que - // no_id / code / libelle / niveau / chemin_pere : aucune FK entière, et - // `chemin_pere` est un chemin texte. Sans ce repli, Secteur et Famille - // restaient vides sur la fiche. - const hierarchy = parentCol - ? sql` - LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)} - LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)} - ` - : sql` - LEFT JOIN LATERAL ( - SELECT nx.code, nx.libelle - FROM nomenclature nx - WHERE LENGTH(TRIM(n3.code)) >= 4 - AND TRIM(nx.code) = LEFT(TRIM(n3.code), 4) - LIMIT 1 - ) n2 ON TRUE - LEFT JOIN LATERAL ( - SELECT nx.code, nx.libelle - FROM nomenclature nx - WHERE LENGTH(TRIM(n3.code)) >= 2 - AND TRIM(nx.code) = LEFT(TRIM(n3.code), 2) - LIMIT 1 - ) n1 ON TRUE - `; - // Les colonnes sont identiques dans les deux stratégies (alias n1 / n2). - const hierarchyCols = sql`, - n2.code AS code2, - n2.libelle AS libelle2, - n1.code AS code1, - n1.libelle AS libelle1nom`; - - try { - const result = await pgNoParallel(sql` - SELECT - a.no_id, - TRIM(a.codein::text) AS codein, - COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1, - a.nom_no_id, - COALESCE(ag.gtin, af.ean13, '')::text AS gtin, - COALESCE(af.reference, '')::text AS reference, - af.pcb::float AS pcb, - ai.prix_vente_mini::float AS pv_central, - pa.pa::float AS pa, - n3.code AS code3, - n3.libelle AS libelle3 - ${hierarchyCols} - FROM articles a - LEFT JOIN LATERAL ( - SELECT af1.code, af1.pcb, af1.reference, af1.ean13 - FROM artfou1 af1 - WHERE af1.art_no_id = a.no_id - ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC - LIMIT 1 - ) af ON TRUE - LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 - LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id - LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id - LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id - ${hierarchy} - WHERE TRIM(a.codein::text) = ${codein} - ORDER BY a.no_id DESC - LIMIT 1 - `); - - const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null; - console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`); - return row; - } catch (e) { - console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250)); - return null; - } -} - -/** Un fournisseur référencé pour l'article. */ -export interface PgProduitFournisseurRow { - codefou: string; - nomfou: string; - reference: string; - pcb: number | null; - /** true si artfou1.preference = 1 */ - principal: boolean; -} - -/** Tous les fournisseurs référencés pour un article (le principal en premier). */ -export async function pgGetFournisseursByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (af.code) - af.code::text AS codefou, - COALESCE(fi.nom, af.code)::text AS nomfou, - COALESCE(af.reference, '')::text AS reference, - af.pcb::float AS pcb, - (af.preference = 1) AS principal - FROM articles a - JOIN artfou1 af ON af.art_no_id = a.no_id - LEFT JOIN fouident fi ON fi.code = af.code - WHERE TRIM(a.codein::text) = ${codein} - ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC - `); - const rows = result.rows as unknown as PgProduitFournisseurRow[]; - // Le principal remonte en tête (le DISTINCT ON impose un tri par code). - rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr")); - return rows; - } catch (e) { - console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** Stock temps réel par site pour un article. */ -export interface PgProduitStockRow { - site: string; - stockdispo: number; - qte: number; - valstock: number; - prmp: number; - dernierevente: string | null; - dernierereception: string | null; -} - -export async function pgGetStockByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT - cs.site, - COALESCE(cs.stockdispo, 0)::float AS stockdispo, - COALESCE(cs.qte, 0)::float AS qte, - COALESCE(cs.valstock, 0)::float AS valstock, - COALESCE(cs.prmp, 0)::float AS prmp, - cs.dernierevente::text AS dernierevente, - cs.dernierereception::text AS dernierereception - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - WHERE TRIM(a.codein::text) = ${codein} - AND cs.site IN ('292', '579') - ORDER BY cs.site - `); - return result.rows as unknown as PgProduitStockRow[]; - } catch (e) { - console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** Quantité totale en commande fournisseur pour un article. */ -export async function pgGetCommandesByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde - FROM cdefou_vivant cv - WHERE TRIM(cv.articles_codein::text) = ${codein} - `); - const row = (result.rows as unknown as { qtecde: number }[])[0]; - return Number(row?.qtecde) || 0; - } catch (e) { - console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250)); - return 0; - } -} - -/** Une gamme affectée à l'article pour une saison donnée. */ -export interface PgGammeHistoryRow { - saison_no_id: number; - saison_code: string | null; - saison_libelle: string | null; - gamme_code: string; - gamme_libelle: string | null; -} - -/** - * Historique complet des gammes de l'article, saison par saison (la plus - * récente en premier). Le reste de l'application ne lit que la saison active - * (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage. - */ -export async function pgGetGammeHistoryByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT - s.no_id AS saison_no_id, - s.code::text AS saison_code, - s.libelle::text AS saison_libelle, - g.code::text AS gamme_code, - g.libelle::text AS gamme_libelle - FROM art_gamme_saison ags - JOIN articles a ON a.no_id = ags.artnoid - JOIN gammes g ON g.no_id = ags.idgamme - JOIN saisons s ON s.no_id = ags.idsaison - WHERE TRIM(a.codein::text) = ${codein} - ORDER BY s.no_id DESC - `); - return result.rows as unknown as PgGammeHistoryRow[]; - } catch (e) { - console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** - * Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions) - * d'UN article, par site et par mois. - * - * Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur` - * (la source de vérité de la Grille) : seule la sélection change — filtre sur - * le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la - * fiche produit sont donc identiques à ceux de la Grille, par construction. - */ -export async function pgGetMensuelByCodein( - codein: string, - dateDebut: string, - dateFin: string -): Promise { - try { - const result = await pgNoParallel(sql` - WITH base AS ( - SELECT - TRIM(a.codein::text) AS codein, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - m.qtemvt, - m.mntmvtttc, - m.margemvt, - m.genremvt, - m.qtestock, - ROW_NUMBER() OVER ( - PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY m.datmvt DESC - ) AS rn_last - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE TRIM(a.codein::text) = ${codein} - AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - ) - SELECT - codein, - site, - mois, - SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, - SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, - SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, - MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, - SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue - FROM base - GROUP BY codein, site, mois - ORDER BY site, mois - `); - - console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`); - return result.rows as unknown as PgMensuelRow[]; - } catch (e) { - console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** Un produit de la même famille, comparé local vs réseau. */ -export interface PgOpportuniteRow { - codein: string; - libelle1: string; - code_centrale: string; - fournisseur: string; - qte_reseau: number; - nb_magasins_reseau: number; - ca_reseau: number; - ca_par_magasin_reseau: number; - qte_locale: number; - nb_sites_locaux: number; - qte_reseau_par_magasin: number; - qte_locale_par_magasin: number; - /** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */ - ecart_par_magasin: number; -} - -/** - * Produits de la même nomenclature, classés par écart entre la performance - * réseau et la performance locale (quantités **par magasin**, pour neutraliser - * la différence d'échelle entre ~270 magasins et nos 2 sites). - * - * Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà - * des métriques réseau en cache : la dimension Qlik interrogée est - * `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas. - */ -export async function pgGetOpportunitesFamille( - nomNoId: number, - dateDebut: string, - dateFin: string, - limit = 30 -): Promise { - try { - const result = await pgNoParallel(sql` - WITH famille AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) - a.no_id, - TRIM(a.codein::text) AS codein, - TRIM(a.artcentrale::text) AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1 - FROM articles a - WHERE a.nom_no_id = ${nomNoId} - AND a.codein IS NOT NULL - AND a.artcentrale IS NOT NULL - AND TRIM(a.artcentrale::text) <> '' - ORDER BY TRIM(a.codein::text), a.no_id DESC - ), - avec_reseau AS ( - SELECT - f.no_id, - f.codein, - f.code_centrale, - f.libelle1, - q.qte_reseau::float AS qte_reseau, - q.nb_magasins_reseau AS nb_magasins_reseau, - COALESCE(q.ca_reseau, 0)::float AS ca_reseau, - COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau - FROM famille f - JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale - WHERE q.nb_magasins_reseau > 0 - AND q.qte_reseau > 0 - ), - ventes AS ( - SELECT - TRIM(a.codein::text) AS codein, - SUM(-m.qtemvt)::float AS qte_locale, - COUNT(DISTINCT m.site) AS nb_sites_locaux - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE m.genremvt = 3 - AND m.site IN ('292', '579') - AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau) - GROUP BY TRIM(a.codein::text) - ) - SELECT - r.codein, - r.libelle1, - r.code_centrale, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - r.qte_reseau, - r.nb_magasins_reseau, - r.ca_reseau, - r.ca_par_magasin_reseau, - COALESCE(v.qte_locale, 0)::float AS qte_locale, - COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux, - (r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin, - (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin, - ( - (r.qte_reseau / r.nb_magasins_reseau) - - (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1)) - )::float AS ecart_par_magasin - FROM avec_reseau r - LEFT JOIN ventes v ON v.codein = r.codein - LEFT JOIN LATERAL ( - SELECT af1.code - FROM artfou1 af1 - WHERE af1.art_no_id = r.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - ORDER BY ecart_par_magasin DESC - LIMIT ${limit} - `); - - console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`); - return result.rows as unknown as PgOpportuniteRow[]; - } catch (e) { - console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250)); - return []; - } -} diff --git a/src/lib/qlik-browser.ts b/src/lib/qlik-browser.ts deleted file mode 100644 index c91e360..0000000 --- a/src/lib/qlik-browser.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * CollectFlow — Chromium et file d'attente partagés par tous les accès Qlik - * (extraction réseau `qlik-playwright.ts`, recherche produit `qlik-search.ts`). - */ - -import "server-only"; -import { chromium, type Browser } from "playwright-core"; - -let browserPromise: Promise | null = null; - -/** - * Chromium partagé entre les extractions. - * - * Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait - * mémorisé : toutes les extractions suivantes échouaient sur « Target closed » - * jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté. - */ -export async function getQlikBrowser(): Promise { - if (browserPromise) { - const existant = await browserPromise.catch(() => null); - if (existant?.isConnected()) return existant; - browserPromise = null; - } - const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH; - const lancement = chromium.launch({ - headless: true, - executablePath: execPath || undefined, - args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], - }); - browserPromise = lancement; - lancement - .then((b) => b.on("disconnected", () => { - console.warn("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction"); - if (browserPromise === lancement) browserPromise = null; - })) - .catch(() => { if (browserPromise === lancement) browserPromise = null; }); - return lancement; -} - -/** - * File unique des extractions. - * - * Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une - * synchro manuelle lancée pendant la synchro de nuit, ou deux recherches - * produit simultanées, ouvraient deux sessions Engine en parallèle — et les - * deux échouaient. Les extractions passent désormais l'une après l'autre. - */ -let fileExtractions: Promise = Promise.resolve(); -let extractionsEnAttente = 0; - -export function dansLaFileQlik(libelle: string, tache: () => Promise): Promise { - if (extractionsEnAttente > 0) { - console.log(`[qlik] ${libelle} : en attente de ${extractionsEnAttente} extraction(s) en cours`); - } - extractionsEnAttente++; - const resultat = fileExtractions.then(tache, tache); - fileExtractions = resultat.then(() => undefined, () => undefined).finally(() => { extractionsEnAttente--; }); - return resultat; -} diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts deleted file mode 100644 index aef5300..0000000 --- a/src/lib/qlik-client.ts +++ /dev/null @@ -1,386 +0,0 @@ -/** - * CollectFlow — Client Qlik Sense (donnees reseau ~270 magasins) - * - * Recupere par produit (cle = code centrale via la dimension "Article Code") : - * - CA reseau, Qte vendue reseau, Nb magasins travaillant le produit. - * - * Auth = NTLM (Qlik Sense Enterprise on Windows) via flux ticket SSO : - * 1. GET /hub/ (non authentifie) -> 302 vers le proxy forms, on extrait le targetId - * 2. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX - * 3. GET /hub/?qlikTicket=XXX -> Qlik pose le cookie X-Qlik-Session - * Le cookie + Xrfkey servent ensuite pour QRS (REST) et l'Engine API (websocket). - * - * Extraction = Engine API (QIX) sur websocket, hypercube generique reference par - * master items (qLibraryId) -> pas besoin des expressions brutes. - * - * Tout est parametrable par variables d'environnement (defauts = discovery 2026-06-20). - */ - -import httpntlm from "httpntlm"; -import { WebSocket } from "ws"; -import { randomBytes } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range"; - -/** Lit les réglages Qlik depuis data/.db-config.json (configurés dans Paramètres). */ -function readQlikFileConfig(): { qlikHost?: string; qlikUser?: string; qlikPassword?: string } { - try { - const raw = readFileSync(join(process.cwd(), "data", ".db-config.json"), "utf-8"); - const c = JSON.parse(raw); - return { qlikHost: c.qlikHost, qlikUser: c.qlikUser, qlikPassword: c.qlikPassword }; - } catch { - return {}; - } -} - -// --------------------------------------------------------------------------- -// Config (env) — defauts issus de la discovery -// --------------------------------------------------------------------------- - -export interface QlikConfig { - host: string; - user: string; - password: string; - domain: string; // vide par defaut (NTLM sans domaine, comme requests_ntlm) - workstation: string; - appNetwork: string; // GUID app "Magasins Vision Consolidee" (article x reseau) - dimCodeArticleId: string; // master dimension "Article Code" - measCaId: string; // master measure "CA N" - measQteId: string; // master measure "Quantite N" - measNbMagId: string; // master measure "Magasin Ventes Nb N" - measCaMagId: string; // master measure "CA par Magasin N" - measMargePctId: string; // master measure "Marge % N" - tlsInsecure: boolean; - timeoutMs: number; -} - -export function getQlikConfig(): QlikConfig { - // Priorité : réglages UI (data/.db-config.json) > env > défaut - const f = readQlikFileConfig(); - return { - host: f.qlikHost || process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr", - user: f.qlikUser || process.env.QLIK_USER || "FFSCH", - password: f.qlikPassword || process.env.QLIK_PWD || "", - domain: process.env.QLIK_DOMAIN ?? "", - workstation: process.env.QLIK_WORKSTATION ?? "", - appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647", - // qIds engine (master items de l'app "Magasins Vision Consolidée") - dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "yesCP", - measCaId: process.env.QLIK_MEAS_CA_ID ?? "JhqJ", - measQteId: process.env.QLIK_MEAS_QTE_ID ?? "41516861-5997-4635-8187-3643a2bde422", - measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "yNBLjc", - // Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine - // les refuse (qId court attendu), override via env après discovery. - measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172", - measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222", - tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true", - timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"), - }; -} - -export interface NetworkMetric { - codeCentrale: string; - caReseau: number; - qteReseau: number; - nbMagasinsReseau: number; - /** CA moyen par magasin (productivité normalisée présence) */ - caParMagasinReseau: number; - /** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */ - margePctReseau: number; - periode?: string; - /** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */ - qteByMonth?: Record; - /** - * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. - * - * Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois). - * Lors d'une synchronisation datée validée, les 12 mois complets sont - * toujours présents ; un mois sans fait est explicitement représenté à 0. - */ - metricsByMonth?: Record; - /** - * Libellé de l'article côté Qlik. Renseigné uniquement par la recherche - * produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync - * fournisseur ne le connaît pas et le laisse `undefined`. - */ - libelleReseau?: string; - /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ - fournisseurReseau?: string; -} - -/** Les mesures réseau Qlik pour un produit sur un mois donné. */ -export interface QlikMonthMetrics { - /** Quantité vendue réseau — toujours présente. */ - qte: number; - /** CA réseau du mois. */ - ca?: number; - /** Nombre de magasins ayant vendu le produit ce mois-là. */ - nbMag?: number; - /** CA moyen par magasin sur le mois (mesure Qlik brute). */ - caMag?: number; - /** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */ - margePct?: number; -} - -/** - * Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau). - * - * Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik - * du réseau, pas de notre catalogue local. Ils peuvent donc être totalement - * inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base. - */ -export interface NetworkProduct extends NetworkMetric { - /** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */ - libelle: string; - fournisseur?: string; - famille?: string; -} - -function makeXrfkey(): string { - return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0"); -} - -// --------------------------------------------------------------------------- -// 1. Auth NTLM -> ticket SSO -> cookie de session -// --------------------------------------------------------------------------- - -interface QlikSession { - cookie: string; - xrfkey: string; -} - -function ntlmGet(cfg: QlikConfig, path: string): Promise<{ statusCode: number; headers: Record; body: string }> { - return new Promise((resolve, reject) => { - httpntlm.get( - { - url: `https://${cfg.host}${path}`, - username: cfg.user, - password: cfg.password, - domain: cfg.domain, - workstation: cfg.workstation, - rejectUnauthorized: !cfg.tlsInsecure, - headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": "" }, - } as unknown as Parameters[0], - (err: Error | null, res: { statusCode: number; headers: Record; body: string }) => - err ? reject(err) : resolve(res), - ); - }); -} - -export async function qlikNtlmSession(cfg = getQlikConfig()): Promise { - if (!cfg.user || !cfg.password) { - throw new Error("[qlik] QLIK_USER / QLIK_PWD manquants dans l'environnement"); - } - const xrfkey = makeXrfkey(); - const tlsRestore = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - try { - // 1. targetId - const hub = await fetch(`https://${cfg.host}/hub/`, { redirect: "manual" }); - const loc1 = hub.headers.get("location") ?? ""; - const targetId = (loc1.match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? ""; - - // 2. NTLM -> ticket. Interne (reseau corp), /hub/ peut deja renvoyer 401 NTLM - // et poser le cookie directement ; on gere les 2 cas. - const winPath = targetId - ? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}` - : `/hub/?xrfkey=${xrfkey}`; - const ntlm = await ntlmGet(cfg, winPath); - - // Cookie posé directement (cas interne) ? - const directCookie = extractSessionCookie(ntlm.headers["set-cookie"]); - if (directCookie) return { cookie: directCookie, xrfkey }; - - // 3. Échange ticket -> cookie - const ticketLoc = String(ntlm.headers["location"] ?? ""); - if (!/qlikTicket=/.test(ticketLoc)) { - throw new Error(`[qlik] Auth NTLM sans ticket (HTTP ${ntlm.statusCode}) — verifier mot de passe`); - } - const exch = await fetch(ticketLoc, { redirect: "manual" }); - const sc = typeof exch.headers.getSetCookie === "function" ? exch.headers.getSetCookie() : []; - const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; "); - if (!cookie) throw new Error("[qlik] ticket non echange contre un cookie de session"); - return { cookie, xrfkey }; - } finally { - if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = tlsRestore; - } -} - -function extractSessionCookie(setCookie: unknown): string | null { - const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : []; - const c = arr.map((x) => String(x).split(";")[0]).find((x) => /X-Qlik-Session/i.test(x)); - return c ?? null; -} - -// --------------------------------------------------------------------------- -// 2. Websocket Engine API (QIX) + JSON-RPC minimal -// --------------------------------------------------------------------------- - -interface RpcConn { - rpc: (method: string, params: unknown, handle?: number) => Promise>; - close: () => void; -} - -function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promise { - const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`; - return new Promise((resolve, reject) => { - const ws = new WebSocket(url, { - headers: { - Cookie: sess.cookie, - "X-Qlik-Xrfkey": sess.xrfkey, - // Le proxy Qlik valide l'Origin des websockets (whitelist) — un navigateur l'envoie toujours. - Origin: `https://${cfg.host}`, - "User-Agent": "Mozilla/5.0", - }, - rejectUnauthorized: !cfg.tlsInsecure, - handshakeTimeout: cfg.timeoutMs, - }); - let nextId = 1; - const pending = new Map) => void; reject: (e: Error) => void }>(); - const timer = setTimeout(() => { ws.terminate(); reject(new Error("[qlik] timeout engine")); }, cfg.timeoutMs); - - ws.on("message", (raw: Buffer) => { - let msg: Record; - try { msg = JSON.parse(raw.toString()); } catch { return; } - if (msg.method === "OnConnected") { clearTimeout(timer); resolve(conn); return; } - const id = msg.id as number | undefined; - if (id != null && pending.has(id)) { - const p = pending.get(id)!; - pending.delete(id); - if (msg.error) p.reject(new Error(`[qlik] RPC ${JSON.stringify(msg.error)}`)); - else p.resolve(msg.result as Record); - } - }); - ws.on("unexpected-response", (_req, res) => { - clearTimeout(timer); - let body = ""; - res.on("data", (d: Buffer) => (body += d.toString())); - res.on("end", () => reject(new Error( - `[qlik] ws HTTP ${res.statusCode} server=${res.headers["server"]} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 160)}`, - ))); - }); - ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); }); - ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); }); - - const rpc = (method: string, params: unknown, handle = -1) => - new Promise>((res, rej) => { - const id = nextId++; - pending.set(id, { resolve: res, reject: rej }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id, handle, method, params })); - }); - const conn: RpcConn = { rpc, close: () => ws.close() }; - }); -} - -// --------------------------------------------------------------------------- -// 3. Extraction hypercube (master items) -> NetworkMetric[] -// --------------------------------------------------------------------------- - -function hyperCubeDef(cfg: QlikConfig, height: number) { - return { - qInfo: { qType: "collectflow-network" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: cfg.dimCodeArticleId, qNullSuppression: true }], - qMeasures: [ - { qLibraryId: cfg.measCaId }, - { qLibraryId: cfg.measQteId }, - { qLibraryId: cfg.measNbMagId }, - { qLibraryId: cfg.measCaMagId }, - { qLibraryId: cfg.measMargePctId }, - ], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: height }], - qSuppressZero: false, - qSuppressMissing: true, - }, - }; -} - -type Matrix = Array>; - -export async function fetchNetworkMetrics( - codeCentraux?: string[], - cfg = getQlikConfig(), - dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), -): Promise> { - if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant"); - const wanted = codeCentraux ? new Set(codeCentraux) : null; - const out = new Map(); - - const sess = await qlikNtlmSession(cfg); - const conn = await openEngine(cfg.appNetwork, sess, cfg); - try { - const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork }); - const docHandle = (((openRes.qReturn as Record) ?? {}).qHandle as number) ?? 1; - - // Sélection du champ `Date` sur la fenêtre 12 mois complets glissants - // (alignement avec la timeline de la grille). Équivalent à un set - // analysis `{=…<=…"}>}` appliqué aux master measures. - let dateFieldHandle: number | null = null; - if (dateFilter) { - const gf = await conn.rpc("GetField", { qFieldName: "Date" }, docHandle); - dateFieldHandle = (((gf.qReturn as Record) ?? {}).qHandle as number); - if (typeof dateFieldHandle !== "number") { - console.warn(`[qlik] champ Date introuvable, extraction sans filtre date`); - dateFilter = null; - dateFieldHandle = null; - } else { - const batches = chunkSerials(dateFilter.dailySerials, 500); - for (let i = 0; i < batches.length; i++) { - const batch = batches[i]; - const sel = await conn.rpc( - "SelectValues", - { - qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })), - qToggleMode: false, - qSoftLock: true, - }, - dateFieldHandle, - ); - console.log(`[qlik] date filter lot ${i + 1}/${batches.length} (${batch.length} serials) — ${dateFilter.label}`); - // Si SelectValues renvoie une erreur qError, on continue quand même : - // l'absence de champ Date typé numérique lèverait une erreur Engine explicite. - void sel; - } - } - } - - const pageHeight = 1400; // 6 colonnes * 1400 = 8400 cellules max - const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle); - const objHandle = (((createRes.qReturn as Record) ?? {}).qHandle as number); - - let top = 0; - for (;;) { - const dataRes = await conn.rpc( - "GetHyperCubeData", - { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: pageHeight }] }, - objHandle, - ); - const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? []; - const matrix: Matrix = pages[0]?.qMatrix ?? []; - if (matrix.length === 0) break; - - for (const row of matrix) { - const codeCentrale = (row[0]?.qText ?? "").trim(); - if (!codeCentrale) continue; - if (wanted && !wanted.has(codeCentrale)) continue; - out.set(codeCentrale, { - codeCentrale, - caReseau: Number(row[1]?.qNum ?? 0) || 0, - qteReseau: Number(row[2]?.qNum ?? 0) || 0, - nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0, - caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0, - margePctReseau: Number(row[5]?.qNum ?? 0) || 0, - periode: dateFilter?.label, - }); - } - if (matrix.length < pageHeight) break; - top += matrix.length; - } - } finally { - conn.close(); - } - - console.log(`[qlik] fetchNetworkMetrics: ${out.size} produits reseau`); - return out; -} diff --git a/src/lib/qlik-date-range.ts b/src/lib/qlik-date-range.ts deleted file mode 100644 index 1de772c..0000000 --- a/src/lib/qlik-date-range.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * CollectFlow — Helper de fenêtre temporelle pour Qlik réseau. - * - * Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille : - * - `monthsBack` mois complets glissants (défaut : 12) - * - exclut systématiquement le mois courant - * - * Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts` - * (même source que la grille). On duplique la logique ici (sans dépendance - * Next.js) pour rester testable en CLI pur Node. - * - * Qlik référence les dates par un sérial numérique (jours écoulés depuis - * l'epoch Qlik 1899-12-30). Repères validés : - * 2025-01-01 → 45658 - * 2025-06-19 → 45827 - * 2026-01-01 → 46023 - * 2026-06-19 → 46192 - * - * Le filtre côté Qlik s'applique via une sélection de champ `Date` (skill qlik) : - * - valeurs numériques = serials Qlik - * - la sélection par défaut (`{}`) est héritée par toutes - * les master measures qui référencent le champ `Date` (cas de l'app - * "Magasins Vision Consolidée"). - * - * Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ? - * - les master items sont référencés par `qLibraryId` (réutilisation sans - * devoir dupliquer l'expression) - * - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX - * - sélectionner toutes les dates de la fenêtre via `SelectValues` revient - * fonctionnellement à `{=…<=…"}>}` pour les mesures qui touchent - * ce champ, sans dépendre de l'expression interne des master items - * - * Pas de secrets ici. Pure fonction utilitaire testable en CLI Node. - */ - -/** - * Bornes acceptées pour `monthsBack`. On reste sur 1..12 pour ne pas casser - * la sémantique "12 mois glissants alignés sur la grille" : une valeur plus - * grande n'apporterait pas de signal réseau supplémentaire (la grille - * n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait - * vide (mois courant exclu) ou incohérente. - */ -export const QLIK_MONTHS_BACK_MIN = 1; -export const QLIK_MONTHS_BACK_MAX = 12; -export const QLIK_MONTHS_BACK_DEFAULT = 12; - -/** - * Lit `process.env[name]` et renvoie un entier borné entre `min` et `max`. - * Renvoie `defaultValue` si la variable est absente / vide / non numérique. - * - * Volontairement exportée pour être réutilisée par `qlik-playwright.ts` et - * `sync/route.ts` (cohérence avec `envNumber` côté Playwright, mais en pur - * Node sans dépendance Next.js). - */ -export function envMonthsBack(name: string, defaultValue: number = QLIK_MONTHS_BACK_DEFAULT): number { - const raw = process.env[name]; - if (raw == null || raw.trim() === "") return defaultValue; - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return defaultValue; - const clamped = Math.min(QLIK_MONTHS_BACK_MAX, Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(parsed))); - return clamped; -} - -/** Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). */ -const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30T00:00:00Z - -/** Repères de validation (cf. skill qlik). */ -export const QLIK_DATE_ANCHORS: ReadonlyArray<{ iso: string; serial: number }> = [ - { iso: "2025-01-01", serial: 45658 }, - { iso: "2025-06-19", serial: 45827 }, - { iso: "2026-01-01", serial: 46023 }, - { iso: "2026-06-19", serial: 46192 }, -]; - -/** - * Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique. - * Inverse documenté : Qlik epoch = 1899-12-30 (jour 0). - * - * @throws si l'entrée n'est pas au format `YYYY-MM-DD`. - */ -export function isoDateToQlikSerial(isoDate: string): number { - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate); - if (!m) throw new Error(`isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu "${isoDate}"`); - const y = Number(m[1]); - const mo = Number(m[2]); - const d = Number(m[3]); - const ms = Date.UTC(y, mo - 1, d); - const serial = Math.round((ms - QLIK_EPOCH_MS) / 86_400_000); - return serial; -} - -/** - * Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics). - */ -export function qlikSerialToIsoDate(serial: number): string { - const ms = QLIK_EPOCH_MS + serial * 86_400_000; - const d = new Date(ms); - const y = d.getUTCFullYear(); - const mo = String(d.getUTCMonth() + 1).padStart(2, "0"); - const da = String(d.getUTCDate()).padStart(2, "0"); - return `${y}-${mo}-${da}`; -} - -export interface QlikDateFilter { - /** Date ISO du 1er jour de la fenêtre (inclus). */ - dateDebut: string; - /** Date ISO du dernier jour de la fenêtre (inclus). */ - dateFin: string; - /** Sérial Qlik du 1er jour (= `Date >= …`). */ - qStart: number; - /** Sérial Qlik du dernier jour (= `Date <= …`). */ - qEnd: number; - /** Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. */ - label: string; - /** Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. */ - dailySerials: number[]; - /** Expression set analysis prête à intégrer dans un `qDef` de mesure. */ - setAnalysis: string; -} - -/** - * Calcule la fenêtre `monthsBack` mois complets (excluant mois courant). - * Par défaut, utilise `now = new Date()` et `monthsBack = 12`. - * - * Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts` - * (défaut = 12 mois). Si la source officielle change, mettre à jour ici aussi - * (garder en sync). - * - * Bornes : `monthsBack` est clampé dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]` - * (1..12). Une valeur invalide (NaN, ≤ 0, > 12) est ramenée aux bornes sans - * exception — on veut que la sync reste possible même si l'env est mal - * configuré. - */ -export function buildGridNetworkQlikDateFilter( - now: Date = new Date(), - monthsBack: number = QLIK_MONTHS_BACK_DEFAULT, -): QlikDateFilter { - const months = Math.min( - QLIK_MONTHS_BACK_MAX, - Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(Number.isFinite(monthsBack) ? monthsBack : QLIK_MONTHS_BACK_DEFAULT)), - ); - // Fin : dernier jour du mois précédent le mois courant - const endMonth = new Date(now.getFullYear(), now.getMonth(), 0); - // Début : `months` mois avant le mois courant (1er du mois) - const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1); - - // Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris - // devient la veille en UTC et transformait par exemple 2025-07-01 en - // 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates - // calendaires Qlik, on les formate donc avec les composantes locales. - const localIso = (d: Date): string => - `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - const dateDebut = localIso(startMonth); - const dateFin = localIso(endMonth); - - const qStart = isoDateToQlikSerial(dateDebut); - const qEnd = isoDateToQlikSerial(dateFin); - - if (qEnd < qStart) { - throw new Error( - `buildGridNetworkQlikDateFilter: fenêtre inversée qEnd=${qEnd} < qStart=${qStart} (dateDebut=${dateDebut} dateFin=${dateFin})`, - ); - } - - const dailySerials: number[] = []; - for (let s = qStart; s <= qEnd; s++) dailySerials.push(s); - - const label = `${dateDebut.slice(0, 7)}_${dateFin.slice(0, 7)}`; - const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; - - return { dateDebut, dateFin, qStart, qEnd, label, dailySerials, setAnalysis }; -} - -/** - * Découpe les serials en lots pour appels `SelectValues` paginés. - * Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête). - */ -export function chunkSerials(serials: number[], batchSize = 500): number[][] { - const out: number[][] = []; - for (let i = 0; i < serials.length; i += batchSize) { - out.push(serials.slice(i, i + batchSize)); - } - return out; -} - -/** - * Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires - * **inclusives et continues**. - * - * But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N - * codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe, - * chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine. - * - * Règles : - * - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque, - * pas forcément le 1er calendaire). - * - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er - * au dernier jour du mois). - * - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois). - * - Les mois sont contigus : fin d'un mois + 1 = début du suivant. - * - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois, - * on renvoie un seul mois (identique à `filter` mais re-calculé). - * - * Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`, - * `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`. - * - * Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici, - * la validation est faite par `buildGridNetworkQlikDateFilter`). - */ -export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] { - const start = filter.dateDebut; // YYYY-MM-DD - const end = filter.dateFin; // YYYY-MM-DD - - // [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial). - const sy = Number(start.slice(0, 4)); - const sm = Number(start.slice(5, 7)); - // const sd = Number(start.slice(8, 10)); // non utilisé directement - const ey = Number(end.slice(0, 4)); - const em = Number(end.slice(5, 7)); - const ed = Number(end.slice(8, 10)); - - // Dernier jour du mois (0-ième du mois suivant en UTC local Date). - const lastDayOfMonth = (y: number, m: number): number => { - // m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant. - return new Date(y, m, 0).getDate(); - }; - - const pad = (n: number): string => String(n).padStart(2, "0"); - - const out: QlikDateFilter[] = []; - - // Itère mois par mois du mois de début au mois de fin (inclus). - let curY = sy; - let curM = sm; - // Sécurité anti-boucle infinie (au cas où). - const MAX_MONTHS = 1200; // 100 ans, large. - let safety = 0; - while (safety++ < MAX_MONTHS) { - const isFirst = curY === sy && curM === sm; - const isLast = curY === ey && curM === em; - - // Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ; - // sinon on prend le 1er calendaire. - const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1; - const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`; - - // Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ; - // sinon on prend le dernier jour calendaire du mois. - const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM); - const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`; - - const qStart = isoDateToQlikSerial(monthStartDate); - const qEnd = isoDateToQlikSerial(monthEndDate); - - const dailySerials: number[] = []; - for (let s = qStart; s <= qEnd; s++) dailySerials.push(s); - - const label = `${curY}-${pad(curM)}`; - const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; - - out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis }); - - if (isLast) break; - - // Avance d'un mois calendaire (gère le passage décembre → janvier). - curM += 1; - if (curM > 12) { curM = 1; curY += 1; } - } - - if (out.length === 0) { - // Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut. - const qStart = filter.qStart; - const qEnd = filter.qEnd; - const label = start.slice(0, 7); - const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; - out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis }); - } - - return out; -} diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts deleted file mode 100644 index a27b78b..0000000 --- a/src/lib/qlik-network-cache.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * CollectFlow — Cache des metriques reseau Qlik (table qlik_network_metrics). - * - * Lecture par lot (jointure grille) + upsert masse (route de sync). - */ - -import "server-only"; - -import { db } from "@/db"; -import { qlikNetworkMetrics } from "@/db/schema"; -import { inArray, sql } from "drizzle-orm"; -import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client"; - -export interface NetworkMetricCached { - caReseau: number; - qteReseau: number; - nbMagasinsReseau: number; - caParMagasinReseau: number; - margePctReseau: number; - /** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */ - qteByMonth: Record | null; - /** - * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. - * null pour les lignes synchronisées avant l'ajout de la colonne — l'UI - * retombe alors sur `qteByMonth`. - */ - metricsByMonth: Record | null; - /** Période couverte par l'extraction (ex "2025-03_2026-02"). */ - periode: string | null; - /** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */ - libelleReseau: string | null; - /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ - fournisseurReseau: string | null; - fetchedAt: string | null; -} - -/** Lit les metriques reseau pour une liste de codes centraux. */ -export async function getNetworkMetricsByCodeCentrale( - codes: string[], -): Promise> { - const out = new Map(); - const unique = [...new Set(codes.filter(Boolean))]; - if (unique.length === 0) return out; - - // Découpage obligatoire : `inArray` produit un paramètre lié par valeur, or le - // protocole PostgreSQL en accepte 65535 au maximum. Un gros fournisseur peut - // dépasser 130 000 codes — la requête échouait alors purement et simplement. - const CHUNK = 2000; - const rows: Array = []; - for (let i = 0; i < unique.length; i += CHUNK) { - const batch = unique.slice(i, i + CHUNK); - const part = await db - .select() - .from(qlikNetworkMetrics) - .where(inArray(qlikNetworkMetrics.codeCentrale, batch)); - rows.push(...part); - } - - for (const r of rows) { - out.set(r.codeCentrale, { - caReseau: Number(r.caReseau ?? 0) || 0, - qteReseau: Number(r.qteReseau ?? 0) || 0, - nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0, - caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0, - margePctReseau: Number(r.margePctReseau ?? 0) || 0, - qteByMonth: (r.qteByMonth as Record | null) ?? null, - metricsByMonth: (r.metricsByMonth as Record | null) ?? null, - periode: r.periode ?? null, - libelleReseau: r.libelleReseau ?? null, - fournisseurReseau: r.fournisseurReseau ?? null, - fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null, - }); - } - return out; -} - -/** Upsert en masse des metriques reseau (depuis Qlik). */ -export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise { - if (metrics.length === 0) return 0; - const now = new Date(); - const values = metrics.map((m) => ({ - codeCentrale: m.codeCentrale, - caReseau: String(m.caReseau), - qteReseau: String(m.qteReseau), - nbMagasinsReseau: m.nbMagasinsReseau, - caParMagasinReseau: String(m.caParMagasinReseau), - margePctReseau: String(m.margePctReseau), - periode: m.periode ?? null, - qteByMonth: m.qteByMonth ?? null, - metricsByMonth: m.metricsByMonth ?? null, - libelleReseau: m.libelleReseau ?? null, - fournisseurReseau: m.fournisseurReseau ?? null, - fetchedAt: now, - })); - - // Upsert par paquets pour eviter les requetes trop volumineuses. - const chunk = 500; - let count = 0; - for (let i = 0; i < values.length; i += chunk) { - const batch = values.slice(i, i + chunk); - await db - .insert(qlikNetworkMetrics) - .values(batch) - .onConflictDoUpdate({ - target: qlikNetworkMetrics.codeCentrale, - set: { - caReseau: sql`excluded.ca_reseau`, - qteReseau: sql`excluded.qte_reseau`, - nbMagasinsReseau: sql`excluded.nb_magasins_reseau`, - caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`, - margePctReseau: sql`excluded.marge_pct_reseau`, - periode: sql`excluded.periode`, - // Le détail mensuel n'est produit que par l'extraction complète - // (dimension Mois). La recherche produit, elle, n'a que les - // agrégats : elle ne doit pas effacer un mensuel déjà extrait. - qteByMonth: sql`COALESCE(excluded.qte_by_month, ${qlikNetworkMetrics.qteByMonth})`, - metricsByMonth: sql`COALESCE(excluded.metrics_by_month, ${qlikNetworkMetrics.metricsByMonth})`, - // Seule la recherche produit connaît le libellé / le fournisseur - // réseau : une sync fournisseur ne doit pas les effacer. - libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`, - fournisseurReseau: sql`COALESCE(excluded.fournisseur_reseau, ${qlikNetworkMetrics.fournisseurReseau})`, - fetchedAt: sql`excluded.fetched_at`, - }, - }); - count += batch.length; - } - return count; -} diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts deleted file mode 100644 index 3ac6926..0000000 --- a/src/lib/qlik-playwright.ts +++ /dev/null @@ -1,2500 +0,0 @@ -/** - * CollectFlow — Extraction Qlik via Playwright (Chromium headless). - * - * Le proxy Qlik refuse un websocket "raw" côté serveur (403). On ouvre donc - * l'app dans Chromium avec une session authentifiée (cookie X-Qlik-Session - * injecté via le flux ticket NTLM), on récupère le **qlik-csrf-token** que le - * client Qlik utilise pour SON websocket, puis on ouvre notre propre websocket - * Engine **in-page** avec ce token (le proxy l'accepte). - * - * Efficacité : on filtre d'abord Date par mois (~30 jours), puis on sélectionne - * les Article Code demandés par petits lots. Chaque cube reste borné à - * ~30 jours × batch d'articles, sans lire tout le catalogue Qlik. - */ - -import "server-only"; -import { getQlikBrowser as getBrowser, dansLaFileQlik as dansLaFile } from "@/lib/qlik-browser"; -import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client"; -import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; -import { assainirMetrique, controlerExtraction } from "@/lib/qlik-validation"; - -interface InPageResult { - ok: boolean; - rows?: Array>; - /** - * Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }. - * - * Le cube mensuel renvoie déjà les 6 mesures par (Article Code, Mois) ; on les - * conserve toutes. Seule `qte` est systématiquement présente : les mois N-1 - * dérivés de « Quantité COMP » n'ont que la quantité (COMP ne porte pas les - * autres mesures), et la passe complémentaire N-1 les complète quand le champ - * `Année` existe. - */ - monthly?: Record>; - size?: number; - error?: string; - // Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright). - months?: number; - batches?: number; - code15Retries?: number; - fallbackCount?: number; - /** true si le résultat vient d'un point de contrôle (extraction interrompue). */ - partiel?: boolean; - /** Diagnostics clés du chemin par expressions, répétés en fin de sync. */ - diagExpr?: string[]; -} - -function envNumber(name: string, defaultValue: number, min: number, max: number): number { - const raw = process.env[name]; - if (raw == null || raw.trim() === "") return defaultValue; - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return defaultValue; - return Math.min(max, Math.max(min, Math.trunc(parsed))); -} - -function envNumberCsv(name: string, min: number, max: number): number[] { - const raw = process.env[name]; - if (!raw) return []; - return raw - .split(",") - .map((part) => Number(part.trim())) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.min(max, Math.max(min, Math.trunc(value)))); -} - -function uniqueOrdered(values: number[]): number[] { - const seen = new Set(); - const out: number[] = []; - for (const value of values) { - if (seen.has(value)) continue; - seen.add(value); - out.push(value); - } - return out; -} - -export function fetchNetworkMetricsPlaywright( - codeCentraux?: string[], - cfg: QlikConfig = getQlikConfig(), - dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), - fournisseur?: string, -): Promise> { - const libelle = fournisseur ? `fournisseur ${fournisseur}` : `${(codeCentraux ?? []).length} code(s)`; - return dansLaFile(libelle, () => extraire(codeCentraux, cfg, dateFilter, fournisseur)); -} - -async function extraire( - codeCentraux?: string[], - cfg: QlikConfig = getQlikConfig(), - dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), - /** - * Code fournisseur FF (ex. `J009`), tel qu'il figure dans la base SQL / l'API. - * - * Quand il est fourni, on sélectionne LE fournisseur côté Qlik au lieu des - * dizaines de milliers de codes articles : `SelectValues` sur « Article Code » - * coûte 8 à 100 s par appel quel que soit le nombre de valeurs (le moteur - * balaie un symbole de plus d'un million d'entrées), et il faut le refaire à - * chaque passe annuelle. - * - * La bascule n'est retenue que si la sélection fournisseur **couvre** les - * codes demandés — sinon on revient à la sélection par codes. - */ - fournisseur?: string, -): Promise> { - if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant"); - if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants"); - - // Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]). - const totalStart = Date.now(); - console.log(`[qlik-pw] host=${cfg.host} app=${cfg.appNetwork} — ${(codeCentraux ?? []).length} codes à sélectionner`); - if (dateFilter) { - console.log(`[qlik-pw] filtre Date=${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, ${dateFilter.setAnalysis})`); - } - const sess = await qlikNtlmSession(cfg); - console.log(`[qlik-pw] session NTLM OK, cookie=${sess.cookie.split("=")[0]}=…`); - const [cookieName, ...rest] = sess.cookie.split("="); - const cookieValue = rest.join("="); - const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000); - const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8); - const qlikArticleBatchSize = envNumber("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500); - const csvFallbacks = envNumberCsv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500); - const qlikArticleBatchFallbacks = uniqueOrdered([qlikArticleBatchSize, ...(csvFallbacks.length ? csvFallbacks : [75, 50]), 75, 50]); - // Chemin rapide : sélectionner TOUS les Article Code une seule fois, puis ne - // changer que la Date par mois (au lieu de re-sélectionner par lots de 150 chaque - // mois). Repli automatique sur le chemin par lots en cas de code 15. - // ACTIVÉ PAR DÉFAUT (validé fiable) — désactivable seulement avec QLIK_SELECT_ALL_CODES=0. - const qlikSelectAllCodes = !["0", "false", "no", "off"].includes((process.env.QLIK_SELECT_ALL_CODES ?? "").trim().toLowerCase()); - // Découpage mensuel réel via la dimension "Mois" (cube [Article Code, Mois]) : donne - // les vraies quantités par mois ET le vrai total (au lieu de 12× la valeur annuelle). - // Activé par défaut ; désactivable via QLIK_MONTH_DIM=0 (revient à l'itération par mois). - const qlikMonthDim = !["0", "false", "no", "off"].includes((process.env.QLIK_MONTH_DIM ?? "").trim().toLowerCase()); - const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim(); - // Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente - // → 12 mois glissants (Quantité N ne couvre que l'année en cours). - const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim(); - // Agrégations directes des champs de faits. Les master measures « N » ignorent - // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas - // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. - // Agrégation directe des faits : chemin de SECOURS, utilisé quand aucune valeur - // de `Période` ne couvre les 12 mois. Activé par défaut, mais second choix : - // `Sum(quantite)` ignore `Type_Cal` et additionne les lignes de la période - // courante ET de la période de comparaison, alors que les master measures - // portent le bon filtre dès que `Période` est bien calé (cf. `choisirPeriode`). - const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); - const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); - const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); - const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); - const qlikExprMarge = (process.env.QLIK_EXPR_MARGE ?? "Sum(marge)").trim(); - // Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il - // FILTRE réellement les faits (total non nul et strictement inférieur au total - // sans filtre) : personne n'avait pu le vérifier tant que seules des mesures - // insensibles à la sélection étaient utilisées. - // Champs Qlik susceptibles de porter le CODE fournisseur (et non son libellé) : - // c'est `code_fournisseur` qui remontait des valeurs de la forme « F005 », - // « A0491 » — même format que les codes de la base FF. - const qlikSupplierFields = [ - (process.env.QLIK_FIELD_CODE_FOURNISSEUR ?? "").trim(), - "code_fournisseur", "Fournisseur", "fournisseur_code_centrale", - ].filter((c, i, arr) => c && arr.indexOf(c) === i); - /** Part des codes demandés que la sélection fournisseur doit couvrir pour être retenue. */ - const qlikSupplierMinCoverage = Math.min(1, Math.max(0.5, Number(process.env.QLIK_SUPPLIER_MIN_COVERAGE ?? "0.99") || 0.99)); - - const qlikDateFields = [ - (process.env.QLIK_DATE_FIELD ?? "").trim(), - "Date", "Date calendrier", "Date_Key", - ].filter((c, i, arr) => c && arr.indexOf(c) === i); - const yearN = new Date().getFullYear(); - console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim} useExpr=${qlikUseExpr}`); - if (qlikUseExpr) { - console.log(`[qlik-pw] expressions mensuelles: qte="${qlikExprQte}" ca="${qlikExprCa}" nbMag="${qlikExprNbMag}" marge="${qlikExprMarge}"`); - console.log(`[qlik-pw] champs date candidats: ${JSON.stringify(qlikDateFields)}`); - } - - const browser = await getBrowser(); - console.log(`[qlik-pw] chromium lancé`); - const ctx = await browser.newContext({ - httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` }, - ignoreHTTPSErrors: true, - }); - await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]); - - try { - const page = await ctx.newPage(); - page.on("console", (msg) => console.log(`[qlik-pw][page] ${msg.text()}`)); - page.on("pageerror", (e) => console.log(`[qlik-pw][pageerror] ${e.message}`)); - - // Capture le qlik-csrf-token depuis le websocket que le client Qlik ouvre - let csrfToken: string | null = null; - let resolveToken: (() => void) | null = null; - const tokenReady = new Promise((r) => { resolveToken = r; }); - page.on("websocket", (ws) => { - const m = ws.url().match(/qlik-csrf-token=([^&]+)/); - if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); } - }); - - await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs }) - .catch(() => { /* le client ouvre son ws ensuite */ }); - console.log(`[qlik-pw] page chargée, attente du qlik-csrf-token…`); - await Promise.race([tokenReady, page.waitForTimeout(15000)]); - if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); - const jeton: string = csrfToken; - console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`); - - // Pré-calcul des mois côté Node (pour pouvoir logger X/Y et propager au script in-page). - const monthRanges: QlikDateFilter[] = dateFilter ? getMonthRanges(dateFilter) : []; - const monthlyPayload = monthRanges.map((m) => ({ - label: m.label, - dateDebut: m.dateDebut, - dateFin: m.dateFin, - serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois - // Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik - // n'exposent la date que par une clé numérique (`Date_Key`), pas par - // un champ date sérialisé. - ymd: m.dailySerials.map((serial) => { - const d = new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000); - return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate(); - }), - })); - - // Point de contrôle : le script in-page pousse ses résultats intermédiaires - // à chaque passe. Si la session Qlik meurt en cours de route (« Socket - // closed », « Execution context was destroyed »), on repart de là au lieu - // de tout perdre — les codes déjà traités ont des données complètes, la - // dimension Mois livrant tous leurs mois d'un coup. - let checkpoint: { rows?: Array>; monthly?: InPageResult["monthly"] } | null = null; - await page.exposeFunction("cfCheckpoint", (payload: { rows?: Array>; monthly?: InPageResult["monthly"] }) => { - checkpoint = payload; - return true; - }); - - const evaluer = () => page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats, fournisseurCode, champsFournisseurCandidats, couvertureFournisseurMin }: { - app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; - codes: string[]; token: string; - monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[]; ymd: number[] }>; - noDateMode: boolean; - qlikSettleMs: number; - qlikCode15MaxAttempts: number; - qlikArticleBatchFallbacks: number[]; - selectAllCodes: boolean; - monthDim: boolean; - moisDimId: string; - mqteComp: string; - yearN: number; - useExpr: boolean; - exprQte: string; - exprCa: string; - exprNbMag: string; - exprMarge: string; - champsDateCandidats: string[]; - fournisseurCode: string; - champsFournisseurCandidats: string[]; - couvertureFournisseurMin: number; - }) => - new Promise((resolve) => { - const loc = (window as unknown as { location: Location }).location; - const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; - const ws = new WebSocket(url); - let id = 0; - const pend = new Map void; rej: (e: unknown) => void }>(); - const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => { - const i = ++id; pend.set(i, { res, rej }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); - }); - const ready = new Promise((res, rej) => { - ws.onmessage = (ev: MessageEvent) => { - const m = JSON.parse(ev.data as string); - if (m.method === "OnConnected") return res(); - if (m.id && pend.has(m.id)) { - const x = pend.get(m.id)!; - pend.delete(m.id); - if (m.error) x.rej(new Error(JSON.stringify(m.error))); - else x.res(m.result); - } - }; - ws.onerror = () => rej(new Error("ws error (403 ?)")); - setTimeout(() => rej(new Error("ws timeout")), 30000); - }); - // Qlik Engine code 15 = "Request aborted" : déclenché quand une requête - // (GetLayout / GetHyperCubeData / CreateSessionObject) entre en collision - // avec une évaluation de sélection encore en cours côté Engine. On retente - // quelques fois avec un court backoff et un log explicite de l'étape. - const isEngineCode15 = (e: unknown): boolean => { - const msg = String((e as Error)?.message ?? e); - return /"code"\s*:\s*15\b/.test(msg); - }; - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); - const timed = async (etape: string, method: string, fn: () => Promise, timings?: Record): Promise<{ value: T; ms: number }> => { - const start = nowMs(); - const value = await fn(); - const ms = Math.round(nowMs() - start); - const label = `${etape}:${method}`; - if (timings) timings[label] = (timings[label] ?? 0) + ms; - console.log(`[qlik-pw][timing] ${label} ms=${ms}`); - return { value, ms }; - }; - const timingSummary = (timings: Record) => JSON.stringify(timings); - const rpcWithRetry = async ( - etape: string, - method: string, - params: unknown, - handle: number, - onCode15Retry?: () => void, - ): Promise<{ [k: string]: unknown }> => { - let lastErr: unknown; - for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) { - try { - return await rpc(method, params, handle); - } catch (e) { - lastErr = e; - if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) throw e; - onCode15Retry?.(); - const backoff = qlikSettleMs * attempt; - console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`); - await sleep(backoff); - } - } - throw lastErr; - }; - - // Accumulateurs déclarés au niveau de l'IIFE (et non dans le `try`) : - // le `catch` doit pouvoir remonter ce qui a déjà été extrait. - const out: Array> = []; - // Quantité réseau par code central et par mois (label "YYYY-MM"). - // Accumulée en parallèle du total, sans toucher l'agrégation existante. - const monthlyByCode: Record> = {}; - // Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait - // des milliers de lignes de timings, et ce qui explique un échec se - // trouve tout au début — là où personne ne va le chercher. - const diagnostics: string[] = []; - const diag = (msg: string) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); }; - - (async () => { - try { - await ready; - console.log("ws connecté (OnConnected)"); - const open = await rpc("OpenDoc", { qDocName: app }); - const doc = (open.qReturn as { qHandle: number }).qHandle; - console.log("OpenDoc OK handle=" + doc); - - // Handles des champs Date / Article Code. - let dfh = -1; - let fh = -1; - let yfh = -1; - /** - * Handle du CHAMP « Période ». - * - * ⚠️ `Clear` et `SelectValues` sont des méthodes de **champ**. Les - * appeler sur un objet de session renvoie - * `{"code":-32601,"parameter":"Clear","message":"Method not found"}`, - * ce qui a fait avorter en production la cartographie des périodes ET - * le chemin d'extraction par date brute. Le list object ne sert donc - * plus qu'à ÉNUMÉRER les valeurs ; les sélections passent par ici. - */ - let pfh = -1; - const df = await rpc("GetField", { qFieldName: "Date" }, doc); - dfh = (df.qReturn as { qHandle: number }).qHandle; - try { - const yf = await rpc("GetField", { qFieldName: "Année" }, doc); - yfh = (yf.qReturn as { qHandle: number }).qHandle; - } catch { yfh = -1; } - try { - const pf = await rpc("GetField", { qFieldName: "Période" }, doc); - pfh = (pf.qReturn as { qHandle: number }).qHandle; - } catch { pfh = -1; } - if (codes.length) { - const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); - fh = (gf.qReturn as { qHandle: number }).qHandle; - } - - // Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS). - const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); - const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } } }, doc); - const mll = await rpc("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle); - const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string; expr?: string } }> } }).qMeasureList?.qItems) ?? []); - const byTitle = new Map(); - for (const it of items) { const t = it.qMeta?.title ?? it.qData?.title ?? ""; if (t) byTitle.set(norm(t), it.qInfo.qId); } - const pickMeasure = (title: string, fallback: string) => byTitle.get(norm(title)) ?? fallback; - const rCamag = pickMeasure("CA par Magasin N", mcamag); - const rMarge = pickMeasure("Marge % N", mmarge); - // Résolution par TITRE aussi pour CA / Quantité / Nb magasins : évite tout - // décalage si un qId d'env ne pointe pas sur la mesure attendue. - const rCa = pickMeasure("CA N", mca); - const rQte = pickMeasure("Quantité N", mqte); - const rNbMag = pickMeasure("Magasin Ventes Nb N", mnb); - console.log("[qlik-pw] mesures utilisées: " + JSON.stringify({ - "CA N": rCa, "Quantité N": rQte, "Magasin Ventes Nb N": rNbMag, - "CA par Magasin N": rCamag, "Marge % N": rMarge, - env: { mca, mqte, mnb }, - })); - console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)"); - // DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour - // identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période" - // permettant un vrai découpage mensuel du réseau. - // L'EXPRESSION est indispensable au diagnostic : c'est elle qui dit - // si la mesure est bornée à une année (et comment), donc pourquoi des - // mois de la fenêtre glissante peuvent ressortir vides. - console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); - for (const it of items) { - const titre = it.qMeta?.title ?? it.qData?.title ?? ""; - if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(titre.trim())) { - console.log("[qlik-pw][dump] expression « " + titre + " » = " + (it.qData?.expr ?? "?")); - } - } - try { - const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc); - const dll = await rpc("GetLayout", {}, (dl.qReturn as { qHandle: number }).qHandle); - const ditems = (((dll.qLayout as { qDimensionList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qDimensionList?.qItems) ?? []); - console.log("[qlik-pw][dump] DIMENSIONS (" + ditems.length + "): " + JSON.stringify(ditems.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); - } catch (e) { console.log("[qlik-pw][dump] DimensionList error: " + String((e as Error)?.message || e)); } - // L'hypercube session object est désormais créé JETABLE dans fetchCubeForSelection - // (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15) - // causé par la réutilisation du même objet sur des centaines de recalculs. - - // (déclaré au niveau de l'IIFE — voir plus haut) - // (monthlyByCode est déclaré au niveau de l'IIFE — voir plus haut) - const accMonthly = (batchRows: Array>, monthLabel: string) => { - for (const r of batchRows) { - const c = String(r[0] ?? "").trim(); - if (!c || c === "-") continue; - const q = Number(r[2]) || 0; - (monthlyByCode[c] ??= {}); - const cur = monthlyByCode[c][monthLabel]; - monthlyByCode[c][monthLabel] = { - qte: (cur?.qte ?? 0) + q, - ca: (cur?.ca ?? 0) + (Number(r[1]) || 0), - nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0), - caMag: Number(r[4]) || 0, - margePct: Number(r[5]) || 0, - }; - } - }; - // Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync). - let aggMonths = 0; - let aggBatches = 0; - let aggCode15Retries = 0; - let aggFallbackCount = 0; - - // Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes - // brutes lues (avant dédoublonnage côté Node). - // - // IMPORTANT : crée un hypercube session object JETABLE par lot (Date + Article Code - // déjà sélectionnés) puis le détruit. Réutiliser le même objet sur des centaines de - // lots force Qlik à recalculer en boucle et finit par "Request aborted" (code 15). - const PAGE = 1400; - // Petite latence après chaque mutation de sélection : laisser l'Engine - // finaliser l'évaluation de la sélection avant la prochaine requête layout/cube. - const createCubeForBatch = async (timings?: Record, onCode15Retry?: () => void): Promise<{ handle: number; id: string }> => { - const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }], - qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }], - } } }, doc, onCode15Retry), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - }; - const destroyCube = async (cube: { handle: number; id: string }, timings?: Record) => { - if (!cube.id) return; - try { - await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings); - } catch (e) { - // Non fatal : l'objet est de toute façon scopé à la session websocket. - // Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort. - console.log("[qlik-pw] DestroySessionObject ignoré: " + String((e as Error)?.message || e)); - } - }; - const appendMatrix = (matrix: Array>, sink: Array>): number => { - for (const r of matrix) sink.push([ - String(r[0]?.qText ?? ""), - Number(r[1]?.qNum) || 0, - Number(r[2]?.qNum) || 0, - Number(r[3]?.qNum) || 0, - Number(r[4]?.qNum) || 0, - Number(r[5]?.qNum) || 0, - ]); - return matrix.length; - }; - const fetchCubeForSelection = async (sink: Array>, timings?: Record, onCode15Retry?: () => void): Promise => { - const cube = await createCubeForBatch(timings, onCode15Retry); - const oh = cube.handle; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); - const hyperCube = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hyperCube.qSize.qcy; - const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0); - let got = 0; - let top = 0; - if (firstPage?.qMatrix?.length) { - got += appendMatrix(firstPage.qMatrix, sink); - top = PAGE; - } - for (; top < size; top += PAGE) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += appendMatrix(matrix, sink); - if (matrix.length < PAGE) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - // ─── Découpage mensuel réel via dimension "Mois" ──────────────────── - // Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca, - // qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM". - const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m); - const PAGEM = 1200; // 8 colonnes × 1200 = 9600 cellules < 10000 (limite Qlik) - const fetchMonthCube = async ( - onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => void, - timings?: Record, - onCode15Retry?: () => void, - ): Promise => { - const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }], - } } }, doc, onCode15Retry), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - const oh = cube.handle; - const process = (matrix: Array>): number => { - for (const r of matrix) { - onRow( - String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), - Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, - Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0, - ); - } - return matrix.length; - }; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); - const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hc.qSize.qcy; - let got = 0, top = 0; - const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); - if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEM; } - for (; top < size; top += PAGEM) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGEM }] }, oh, onCode15Retry), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += process(matrix); - if (matrix.length < PAGEM) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - // ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ───── - // - // Constat de production : les master measures « CA N » / « Quantité N » - // **ignorent la sélection Date**. Vérifié arithmétiquement : avec août - // 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € — - // soit exactement la somme de janvier à juillet 2026. Ce sont des - // mesures « cumul année en cours », pas des mesures de période. - // - // Conséquences : les totaux réseau étaient un cumul année en cours - // (mois courant partiel inclus) et non 12 mois glissants, les mois de - // l'année précédente non couverts par « Quantité COMP » restaient - // vides, et la passe de rattrapage y recopiait le total de période. - // - // Aucune sélection ne peut corriger cela : on agrège donc directement - // les champs de faits, qui eux respectent les sélections. - const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 - const fetchMonthCubeExpr = async ( - eCa: string, - eQte: string, - eNbMag: string, - eMarge: string, - onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void, - timings?: Record, - onCode15Retry?: () => void, - /** - * Inclure la master « Quantité N » (colonne de calibrage) ? - * - * ⚠️ Elle ne coûte rien quand elle vit, mais **annule tout le cube** - * quand elle est morte : mesuré en production, 4 expressions valides - * à 6 mois chacune et le cube complet à **0 ligne** après 25 s de - * calcul. L'appelant ne l'ajoute donc qu'après l'avoir sondée. - */ - avecMaster = true, - ): Promise => { - const largeur = avecMaster ? 7 : 6; - const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], - qMeasures: [ - { qDef: { qDef: eCa } }, - { qDef: { qDef: eQte } }, - { qDef: { qDef: eNbMag } }, - { qDef: { qDef: eMarge } }, - // Master « Quantité N », uniquement pour le calibrage : sur les - // mois de l'année en cours les deux doivent coïncider. - ...(avecMaster ? [{ qLibraryId: rQte }] : []), - ], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: PAGEX }], - } } }, doc, onCode15Retry), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - const oh = cube.handle; - const process = (matrix: Array>): number => { - for (const r of matrix) { - onRow( - String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), - Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, - Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, - avecMaster ? Number(r[6]?.qNum) || 0 : 0, - ); - } - return matrix.length; - }; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); - const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hc.qSize.qcy; - let got = 0, top = 0; - const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); - if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEX; } - for (; top < size; top += PAGEX) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: largeur, qHeight: PAGEX }] }, oh, onCode15Retry), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += process(matrix); - if (matrix.length < PAGEX) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - /** - * Passe unique par expressions : sélectionne la fenêtre 12 mois et tous - * les codes, puis lit un seul cube [Article Code, Mois]. - * - * Renvoie false si le résultat est inexploitable (expression invalide, - * champ absent) — l'appelant repart alors sur les master measures. - */ - /** - * Total de contrôle : un cube 1 cellule, sans dimension, sur - * l'expression quantité. Sert à savoir si une sélection de dates - * FILTRE réellement les faits — ce que les master measures, qui - * l'ignorent, ne permettaient pas de vérifier. - */ - const totalDeControle = async (): Promise => { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-ctrl" }, - qHyperCubeDef: { - qDimensions: [], - qMeasures: [{ qDef: { qDef: exprQte } }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], - }, - } }, doc); - const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - try { - const lay = await rpc("GetLayout", {}, ret.qHandle); - const cell = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) - .qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0]; - return Number(cell?.qNum) || 0; - } finally { - const id = String(ret.qGenericId ?? ret.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * Sélectionne la fenêtre sur un champ date candidat. - * - * Renvoie le handle pour que l'appelant puisse impérativement - * effacer une sélection rejetée avant d'essayer le candidat - * suivant. Sans ce Clear, un premier champ qui ne matche aucune - * date maintient l'ensemble courant à zéro et condamne tous les - * essais suivants à renvoyer zéro eux aussi. - */ - const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { - let h: number; - try { - const gf = await rpc("GetField", { qFieldName: champ }, doc); - h = (gf.qReturn as { qHandle: number }).qHandle; - if (typeof h !== "number" || h < 0) return null; - } catch { return null; } - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - for (let i = 0; i < valeurs.length; i += 500) { - await rpc("SelectValues", { - qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })), - qToggleMode: i > 0, - qSoftLock: true, - }, h); - } - await sleep(qlikSettleMs); - return h; - }; - - /** - * Sélectionne les 12 mois via la dimension maître « Mois ». - * - * Dans l'app Magasins Vision Consolidée, les champs Date exposés - * existent mais sont dissociés des faits de vente : leur sélection - * laisse Sum(quantite) à 100 %. La dimension maître utilisée par le - * cube, elle, porte les vraies valeurs associées. Un list object - * permet de sélectionner ses qElemNumber sans connaître le nom du - * champ sous-jacent ni le format dual exact. - */ - const selectionnerFenetreViaDimensionMois = async ( - moisAttendus: Set, - ): Promise<{ total: number; trouves: string[] } | null> => { - let objet: { handle: number; id: string } | null = null; - try { - const cree = await rpcWithRetry("createMonthList", "CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-month-list" }, - qListObjectDef: { - qLibraryId: moisDimId, - qShowAlternatives: true, - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 100 }], - }, - }, - }, doc); - const ret = cree.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - objet = { - handle: ret.qHandle, - id: String(ret.qGenericId ?? ret.qId ?? ""), - }; - - const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle); - const lo = (layout.qLayout as { - qListObject?: { - qSize?: { qcy?: number }; - qDataPages?: Array<{ - qArea?: { qTop?: number }; - qMatrix?: Array>; - }>; - }; - }).qListObject; - const size = Number(lo?.qSize?.qcy) || 0; - const cellules: Array<{ qText?: string; qElemNumber?: number }> = []; - for (const page of lo?.qDataPages ?? []) { - for (const row of page.qMatrix ?? []) if (row[0]) cellules.push(row[0]); - } - for (let top = cellules.length; top < size; top += 500) { - const data = await rpcWithRetry("monthListData", "GetListObjectData", { - qPath: "/qListObjectDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(500, size - top) }], - }, objet.handle); - const matrix = (data.qDataPages as Array<{ - qMatrix?: Array>; - }>)?.[0]?.qMatrix ?? []; - for (const row of matrix) if (row[0]) cellules.push(row[0]); - if (matrix.length === 0) break; - } - - const parMois = new Map(); - for (const cellule of cellules) { - const mois = normMois(String(cellule.qText ?? "").trim()); - const elem = Number(cellule.qElemNumber); - if (moisAttendus.has(mois) && Number.isInteger(elem) && elem >= 0) { - parMois.set(mois, elem); - } - } - const trouves = [...parMois.keys()].sort(); - console.log( - "[qlik-pw][expr] dimension Mois : " + trouves.length + "/" + moisAttendus.size + - " valeurs trouvées " + JSON.stringify(trouves), - ); - if (trouves.length !== moisAttendus.size) return null; - - const selection = await rpcWithRetry("selectMonthList", "SelectListObjectValues", { - qPath: "/qListObjectDef", - qValues: [...parMois.values()], - qToggleMode: false, - qSoftLock: true, - }, objet.handle); - console.log("[qlik-pw][expr] sélection dimension Mois → " + JSON.stringify(selection.qReturn)); - await sleep(qlikSettleMs); - const total = await totalDeControle(); - return { total, trouves }; - } catch (e) { - console.log("[qlik-pw][expr] sélection dimension Mois impossible : " + String((e as Error)?.message || e)); - return null; - } finally { - if (objet?.id) { - try { - await rpc("DestroySessionObject", { qId: objet.id }, doc); - } catch { /* objet limité à la session */ } - } - } - }; - - /** - * Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants. - * - * L'app n'est pas un modèle « faits + calendrier » classique : TOUTES - * ses mesures sont bornées par un type de calendrier — - * - * « Quantité N » = Sum({} quantite) - * « Quantité COMP » = Sum({} quantite) - * - * `Type_Cal` marque les lignes de la période analysée (`N`) et celles - * de la période de comparaison (`COMP`), et c'est le champ `Période` - * (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. - * - * Cela explique tout ce qu'on observait : - * - sélectionner `Date` ne change RIEN (le périmètre vient du pont de - * périodes, pas du champ date) — mesuré : 100 % du total avant comme - * après ; - * - « Quantité N » ne renvoie que les mois de la période courante - * (janvier→juillet avec « année en cours »), d'où août→décembre - * vides ; - * - « Quantité COMP » ne couvre que les mois ayant un comparable dans - * cette période, d'où janvier→juillet de l'année précédente ; - * - `Sum(quantite)` brut mélange les lignes N et COMP : il double - * compte, et ne peut pas se substituer aux mesures. - * - * La solution n'est donc pas de contourner le modèle mais de le - * piloter : on essaie chaque valeur de `Période`, on mesure combien de - * mois de la fenêtre elle rend réellement disponibles, et on garde la - * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée - * qui décide. - */ - /** - * Vrai quand le périmètre articles est posé par une sélection - * fournisseur : dans ce cas, plus aucun `SelectValues` sur - * « Article Code » n'est émis (ni par passe annuelle, ni par - * l'agrégation directe) — c'est tout l'intérêt de la bascule. - */ - let parFournisseur = false; - - /** - * Sélectionne LE fournisseur plutôt que ses dizaines de milliers de - * codes articles. - * - * `SelectValues` sur « Article Code » coûte 8 à 100 s **par appel**, - * quasi indépendamment du nombre de valeurs — le moteur balaie un - * symbole de plus d'un million d'entrées — et il faut le refaire à - * chaque passe annuelle. Une valeur de fournisseur produit le même - * périmètre en un appel trivial. - * - * La bascule n'est retenue QUE si la sélection couvre les codes - * demandés : un article rattaché à plusieurs fournisseurs, ou un - * référencement Qlik différent du référencement FF, doit faire - * revenir à la sélection par codes plutôt que perdre des articles. - */ - const selectionnerParFournisseur = async (): Promise => { - if (!fournisseurCode || codes.length === 0) return false; - const attendus = new Set(codes.map((c) => c.trim())); - /** - * Au-delà de ce nombre de codes visibles, la sélection fournisseur - * ne restreint rien (elle laisse tout le catalogue) : inutile de - * finir la lecture, la réponse est déjà connue. - */ - const plafond = attendus.size * 3; - - /** Codes articles visibles sous la sélection courante. */ - const lireCodesVisibles = async (): Promise> => { - const vus = new Set(); - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-codes-fou" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim, qNullSuppression: true }], - qMeasures: [], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 5000 }], - }, - } }, doc); - const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - try { - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - const total = Number(hc?.qSize?.qcy) || 0; - for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) { - const c = String(r[0]?.qText ?? "").trim(); - if (c && c !== "-") vus.add(c); - } - for (let top = vus.size ? 5000 : 0; top < total; top += 5000) { - const d = await rpc("GetHyperCubeData", { - qPath: "/qHyperCubeDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(5000, total - top) }], - }, ret.qHandle); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (matrix.length === 0) break; - for (const r of matrix) { - const c = String(r[0]?.qText ?? "").trim(); - if (c && c !== "-") vus.add(c); - } - // Inutile de lire tout le catalogue pour conclure : au-delà - // du plafond, la sélection ne restreint manifestement rien. - // Mesuré : 1 207 205 codes lus en 240 pages, ×3 champs = - // 87 s perdues avant un rejet joué d'avance. - if (vus.size > plafond) break; - } - return vus; - } finally { - const id = String(ret.qGenericId ?? ret.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - for (const champ of champsFournisseurCandidats) { - let h = -1; - try { - const gf = await rpc("GetField", { qFieldName: champ }, doc); - h = (gf.qReturn as { qHandle: number }).qHandle; - if (typeof h !== "number" || h < 0) continue; - } catch { continue; } - - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - const sel = await rpc("SelectValues", { - qFieldValues: [{ qText: fournisseurCode }], - qToggleMode: false, - qSoftLock: true, - }, h); - await sleep(qlikSettleMs); - if (sel.qReturn === false) { - diag(" champ « " + champ + " » : valeur « " + fournisseurCode + " » inconnue"); - await rpc("Clear", {}, h); - continue; - } - - const visibles = await lireCodesVisibles(); - // Un vrai périmètre fournisseur est de l'ordre de grandeur des - // codes demandés. Mesuré en production : « J009 » laissait - // 1 207 205 articles visibles, soit tout le catalogue — la - // sélection n'avait rien restreint, et la « couverture » de 97 % - // ne faisait que constater que le catalogue contient nos codes. - if (visibles.size > plafond) { - diag( - " champ « " + champ + " » = « " + fournisseurCode + " » → plus de " + plafond + - " articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée", - ); - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - continue; - } - let couverts = 0; - for (const c of attendus) if (visibles.has(c)) couverts++; - const couverture = attendus.size > 0 ? couverts / attendus.size : 0; - diag( - " champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + - " articles Qlik, couvre " + couverts + "/" + attendus.size + - " codes demandés (" + Math.round(couverture * 100) + "%)", - ); - if (couverture >= couvertureFournisseurMin) { - diag("sélection PAR FOURNISSEUR retenue sur « " + champ + " » — " + attendus.size + " SelectValues économisés par passe"); - return true; - } - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - } - diag("aucun champ fournisseur ne couvre les codes demandés → sélection par codes articles"); - return false; - }; - - /** Valeur de `Période` à poser avant la passe d'une année donnée. */ - const periodeParAnnee = new Map(); - - /** - * Pose (ou efface, si `texte` est nul) la sélection `Période`. - * - * Passe par le HANDLE DU CHAMP, comme pour `Année` et `Date` : c'est la - * seule API qui expose `Clear` et `SelectValues`. Ne propage jamais - * d'erreur — une `Période` qu'on n'arrive pas à poser doit dégrader le - * résultat, pas interrompre l'extraction (leçon de production : un - * `Clear` en échec a fait avorter deux chemins entiers). - */ - const appliquerPeriode = async (texte: string | null): Promise => { - if (pfh === -1) return texte == null; - try { - await rpc("Clear", {}, pfh); - await sleep(qlikSettleMs); - if (texte == null) return true; - const sel = await rpc("SelectValues", { - qFieldValues: [{ qText: texte }], - qToggleMode: false, - qSoftLock: true, - }, pfh); - await sleep(qlikSettleMs); - return sel.qReturn !== false; - } catch (e) { - diag(" sélection Période « " + (texte ?? "(aucune)") + " » impossible : " + String((e as Error)?.message || e)); - return false; - } - }; - - /** - * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. - * - * CE QUE DIT LA MESURE (production, 2026-08-01, sonde paginée) : - * - * Période « (aucune) » → master exposée : 2024-01…2026-07 (31 mois) - * - * Sans aucune `Période` sélectionnée, « Quantité N » couvre **31 mois**, - * et une seule passe (`Année=2026`) sert les 12 mois de la fenêtre. - * L'état libre est donc le bon — encore fallait-il pouvoir le voir. - * - * Deux erreurs successives l'avaient masqué, toutes deux dans l'outil - * de mesure et non dans le modèle Qlik : - * 1. la sonde ne lisait que les 60 premières lignes du cube `[Mois]`, - * donc jamais les mois récents → « 0/12 » pour toute valeur ; - * 2. `Clear` était appelée sur le list object au lieu du champ, ce qui - * faisait avorter la cartographie entière avant son premier essai. - * - * D'où le principe retenu ici : **ne rien présumer, tout mesurer**. La - * couverture est évaluée par année et dans le contexte exact où elle - * sera exploitée (`monthDimPath` pose la même valeur), l'état sans - * `Période` étant un candidat comme un autre — et, en l'occurrence, le - * gagnant. - */ - const choisirPeriode = async (annees: number[]): Promise => { - const fenetre = new Set(monthlyPayload.map((m) => m.label)); - let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const obj = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-periodes" }, - qListObjectDef: { - qDef: { qFieldDefs: ["Période"] }, - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }], - }, - } }, doc); - lo = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, lo.qHandle); - const matrix = ((lay.qLayout as { qListObject?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) - .qListObject?.qDataPages?.[0]?.qMatrix) ?? []; - const valeurs = matrix - .map((r) => ({ texte: String(r[0]?.qText ?? ""), elem: r[0]?.qElemNumber ?? -1 })) - .filter((v) => v.texte && v.elem >= 0); - diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); - if (valeurs.length === 0) return 0; - - /** - * Couverture réelle : quels mois « Quantité N » rend-elle vivants ? - * - * ⚠️ Le cube est PAGINÉ intégralement. La version précédente ne - * lisait que les 60 premières lignes : la dimension Mois porte - * tout l'historique de l'app, les mois récents tombaient hors - * de la première page, et la sonde annonçait 0/12 quelle que - * soit la `Période` — alors que la master vivait (mesuré : - * total 9 721 153). C'est ce faux zéro qui faisait effacer - * `Période` et condamnait toute l'extraction. - * - * Renvoie les mois vivants DE LA FENÊTRE, et journalise aussi - * ce que la dimension expose hors fenêtre : sans cela on ne - * distingue pas « le modèle ne donne rien » de « le modèle - * donne autre chose que ce qu'on attend ». - */ - const mesurerCouverture = async (): Promise<{ fenetre: string[]; tous: string[] }> => { - const sonde = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-periode-sonde" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rQte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 500 }], - }, - } }, doc); - const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - try { - const sl = await rpc("GetLayout", {}, sRet.qHandle); - const hc = (sl.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - const lignes: Array> = [...(hc?.qDataPages?.[0]?.qMatrix ?? [])]; - const total = Number(hc?.qSize?.qcy) || 0; - for (let top = lignes.length; top < total; top += 500) { - const d = await rpc("GetHyperCubeData", { - qPath: "/qHyperCubeDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 2, qHeight: Math.min(500, total - top) }], - }, sRet.qHandle); - const m = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (m.length === 0) break; - lignes.push(...m); - } - const vivants = lignes - .filter((r) => (Number(r[1]?.qNum) || 0) > 0) - .map((r) => normMois(String(r[0]?.qText ?? ""))) - .sort(); - return { fenetre: vivants.filter((m) => fenetre.has(m)), tous: vivants }; - } finally { - const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); - if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } - } - }; - - /** Résumé compact d'une liste de mois : « 2025-01…2025-12 (12) ». */ - const resume = (mois: string[]) => - mois.length === 0 ? "aucun" : mois[0] + "…" + mois[mois.length - 1] + " (" + mois.length + ")"; - - /** - * Couverture d'une `Période` telle qu'elle sera RÉELLEMENT - * exploitée : une passe par année, comme `monthDimPath`, puis - * union. C'est le seul chiffre qui a un sens ici — mesurer - * sans année revient à juger « Année à date » sur la seule - * année en cours (6/12) et à la rejeter à tort. - */ - // ─── Une `Période` PAR ANNÉE ───────────────────────────── - // - // C'est le point que la version globale manquait : la bonne - // valeur dépend de l'année analysée. « Année à date » convient - // à 2026 (janvier → mois courant) mais pas à 2025, dont on veut - // les 12 mois. On cartographie donc, pour chaque année de la - // fenêtre, la valeur qui sert le PLUS de mois attendus de cette - // année-là — l'état sans `Période` étant un candidat comme un - // autre. - const couvertsGlobal = new Set(); - for (const annee of annees) { - const attendusAnnee = [...fenetre].filter((m) => m.startsWith(String(annee) + "-")); - diag(" année " + annee + " — mois attendus " + JSON.stringify(attendusAnnee.sort())); - - if (yfh !== -1) { - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - await rpc("SelectValues", { - qFieldValues: [{ qNum: annee, qText: String(annee) }], - qToggleMode: false, - qSoftLock: true, - }, yfh); - await sleep(qlikSettleMs); - } - - // `null` = état sans `Période`, candidat comme un autre. - const candidats: Array = [null, ...valeurs.map((v) => v.texte)]; - let meilleure: { texte: string | null; servis: string[] } | null = null; - for (const c of candidats) { - const libelle = c ?? "(aucune)"; - // Un candidat qui échoue ne doit pas emporter toute la - // cartographie : on le note et on passe au suivant. - let servis: string[] = []; - try { - const pose = await appliquerPeriode(c); - if (!pose && c !== null) { - diag(" Période « " + libelle + " » → non sélectionnable, ignorée"); - continue; - } - const mois = await mesurerCouverture(); - servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); - diag( - " Période « " + libelle + " » → sert " + servis.length + "/" + attendusAnnee.length + - " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous), - ); - } catch (e) { - diag(" Période « " + libelle + " » → erreur " + String((e as Error)?.message || e)); - continue; - } - if (!meilleure || servis.length > meilleure.servis.length) { - meilleure = { texte: c, servis }; - } - if (servis.length >= attendusAnnee.length) break; // année complète - } - - if (meilleure) { - periodeParAnnee.set(annee, { texte: meilleure.texte }); - for (const m of meilleure.servis) couvertsGlobal.add(m); - diag( - " → année " + annee + " sera extraite sous Période « " + (meilleure.texte ?? "(aucune)") + " » (" + - meilleure.servis.length + "/" + attendusAnnee.length + " mois)", - ); - } - } - - if (yfh !== -1) { - // Chaque passe pose sa propre année. - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - } - return couvertsGlobal.size; - } catch (e) { - diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); - return 0; - } finally { - // Le list object est conservé jusqu'au bout : le détruire - // annulerait la sélection qu'il porte. - void lo; - } - }; - - /** - * Teste UNE expression isolément sur un petit cube `[Mois]`. - * - * Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à - * tout un hypercube — observé en production : le cube à 5 mesures - * calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` seul - * donnait 35 424 324. Impossible de savoir laquelle sans les isoler. - * - * Renvoie l'expression si elle produit des lignes, sinon `"0"` — une - * colonne neutre qui préserve les indices sans casser le cube. - */ - const validerExpression = async (nom: string, expr: string): Promise => { - let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-test-expr" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qDef: { qDef: expr } }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], - }, - } }, doc); - ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qError?: unknown; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - const matrix = hc?.qDataPages?.[0]?.qMatrix ?? []; - const lignes = Number(hc?.qSize?.qcy) || 0; - let total = 0; - let texteErreur = ""; - for (const r of matrix) { - total += Number(r[1]?.qNum) || 0; - const t = String(r[1]?.qText ?? ""); - if (!texteErreur && /error|erreur|garbage|^-$/i.test(t)) texteErreur = t; - } - if (hc?.qError) texteErreur = texteErreur || JSON.stringify(hc.qError); - if (lignes === 0 || texteErreur) { - diag(" mesure « " + nom + " » = " + expr + " → INVALIDE (" + (texteErreur || "cube vide") + ")"); - return "0"; - } - diag(" mesure « " + nom + " » = " + expr + " → " + lignes + " mois, total=" + Math.round(total)); - return expr; - } catch (e) { - diag(" mesure « " + nom + " » = " + expr + " → ERREUR " + String((e as Error)?.message || e)); - return "0"; - } finally { - const id = String(ret?.qGenericId ?? ret?.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * La master « Quantité N » produit-elle la moindre ligne sur `[Mois]` - * dans l'état de sélection courant ? Même sonde que - * `validerExpression`, mais par `qLibraryId`. - */ - const masterQteVivante = async (): Promise => { - let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-test-master" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rQte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], - }, - } }, doc); - ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - let total = 0; - for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; - diag(" master « Quantité N » (calibrage) → total=" + Math.round(total)); - return total > 0; - } catch (e) { - diag(" master « Quantité N » → ERREUR " + String((e as Error)?.message || e)); - return false; - } finally { - const id = String(ret?.qGenericId ?? ret?.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * Somme de `expr` VENTILÉE par la dimension Mois. - * - * Comparée au total sans dimension, elle dit si la dimension Mois - * démultiplie les faits à travers le pont de périodes — auquel cas - * l'agrégation directe est inexploitable. - */ - const totalVentileParMois = async (expr: string): Promise => { - let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-ventil-mois" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qDef: { qDef: expr } }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 200 }], - }, - } }, doc); - ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - let total = 0; - for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; - return total; - } catch { - return 0; - } finally { - const id = String(ret?.qGenericId ?? ret?.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * Cube `[Article Code] × 4 expressions`, SANS dimension Mois. - * - * C'est tout l'intérêt : la dimension Mois passe par le pont de - * périodes et rattache une même ligne de fait à plusieurs contextes - * (mesuré : ×5,3). Sans elle, `Sum(quantite)` porte une fois et une - * seule sur les faits visibles — donc sur le mois sélectionné. - */ - const fetchCodeCubeExpr = async ( - eCa: string, eQte: string, eNbMag: string, eMarge: string, - onRow: (code: string, ca: number, qte: number, nbMag: number, marge: number) => void, - timings?: Record, - ): Promise => { - const LARGEUR = 5; - const PAGE = 1800; // 5 × 1800 = 9000 cellules < 10000 - const { value: obj } = await timed("createCubeCode", "CreateSessionObject", () => rpcWithRetry("createCubeCode", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-code-expr" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }], - qMeasures: [ - { qDef: { qDef: eCa } }, - { qDef: { qDef: eQte } }, - { qDef: { qDef: eNbMag } }, - { qDef: { qDef: eMarge } }, - ], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }], - } } }, doc), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - const traiter = (matrix: Array>): number => { - for (const r of matrix) { - onRow( - String(r[0]?.qText ?? ""), - Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, - Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, - ); - } - return matrix.length; - }; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, cube.handle), timings); - const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hc.qSize.qcy; - let got = 0, top = 0; - const p0 = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); - if (p0?.qMatrix?.length) { got += traiter(p0.qMatrix); top = PAGE; } - for (; top < size; top += PAGE) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }] }, cube.handle), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += traiter(matrix); - if (matrix.length < PAGE) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - /** - * EXTRACTION MOIS PAR MOIS SUR LE CHAMP DATE BRUT. - * - * Le chemin que rien n'avait encore essayé, et le seul qui n'emprunte - * ni le pont de périodes ni les master measures : - * - * - on sélectionne les jours d'UN mois sur le champ date ; - * - on lit un cube `[Article Code]` sans dimension Mois, donc sans - * démultiplication ; - * - `Type_Cal`, `Période` et `Année` sont effacés : les expressions - * brutes n'ont pas besoin du pont, elles agrègent les faits visibles. - * - * Il n'y a donc plus de « mois inatteignable » : chaque mois est un - * filtre de dates, pas un contexte de calendrier. - * - * CONTRÔLE D'INTÉGRITÉ. La somme des 12 mois ne doit pas dépasser le - * total sans filtre de date : si la dimension ou le champ démultipliait - * les faits, elle exploserait (le pont donne ×5,3). Et chaque mois doit - * être servi. Sinon rien n'est écrit. - */ - const moisParDateBrute = async (): Promise => { - const timings: Record = {}; - - // Aucun contexte de calendrier : on veut les faits nus. - if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } - await appliquerPeriode(null); - if (dfh !== -1) { await rpc("Clear", {}, dfh); await sleep(qlikSettleMs); } - - if (codes.length && fh !== -1 && !parFournisseur) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh, () => { aggCode15Retries++; }), timings); - await sleep(qlikSettleMs); - } - - const sansFiltre = await totalDeControle(); - diag("[date brute] contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); - if (sansFiltre <= 0) { - diag("[date brute] ÉCHEC : l'expression quantité ne renvoie rien (corriger QLIK_EXPR_QTE)"); - return false; - } - - // ─── Quel champ date filtre les faits ? ────────────────── - // - // ⚠️ On teste avec UN SEUL MOIS, pas avec la fenêtre entière. - // Toute la table de faits tient dans les 12 mois (mesuré : 100 % - // du total) : sélectionner la fenêtre ne réduit donc RIEN, et - // l'ancien test « le total doit baisser » rejetait tous les - // champs, y compris les bons. - const moisTest = monthlyPayload[Math.floor(monthlyPayload.length / 2)]; - let champDate: string | null = null; - let handleDate = -1; - for (const cand of champsDateCandidats) { - const valeurs = /key/i.test(cand) ? moisTest.ymd : moisTest.serials; - const h = await selectionnerFenetreSur(cand, valeurs); - if (h == null) { diag("[date brute] champ « " + cand + " » : inexistant"); continue; } - const t = await totalDeControle(); - const part = sansFiltre > 0 ? t / sansFiltre : 0; - diag( - "[date brute] champ « " + cand + " » sur le seul mois " + moisTest.label + " → " + - Math.round(t) + " (" + (part * 100).toFixed(1) + "% du total)", - ); - // Un mois isolé doit peser une fraction du total : ni zéro - // (le champ ne touche pas les faits) ni la totalité (il ne - // filtre pas). - if (t > 0 && part < 0.5) { champDate = cand; handleDate = h; break; } - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - } - if (!champDate || handleDate === -1) { - diag( - "[date brute] ÉCHEC : aucun champ date ne filtre les faits mois par mois " + - "(essayés : " + JSON.stringify(champsDateCandidats) + ")", - ); - return false; - } - diag("[date brute] champ date retenu : « " + champDate + " »"); - - // À partir d'ici la série sera reconstruite entièrement : les - // passes annuelles (master measures) et la série brute n'ont pas - // la même sémantique, on ne les mélange pas. - out.length = 0; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - - const totauxMois: Record = {}; - let sommeMois = 0; - for (let i = 0; i < monthlyPayload.length; i++) { - const m = monthlyPayload[i]; - const valeurs = /key/i.test(champDate) ? m.ymd : m.serials; - await rpc("Clear", {}, handleDate); - await sleep(qlikSettleMs); - await rpcWithRetry("selectDate", "SelectValues", { - qFieldValues: valeurs.map((v) => ({ qNum: v, qText: String(v) })), - qToggleMode: false, - qSoftLock: true, - }, handleDate, () => { aggCode15Retries++; }); - await sleep(qlikSettleMs); - - let totalDuMois = 0; - const lignes = await fetchCodeCubeExpr(exprCa, exprQte, exprNbMag, exprMarge, (code, ca, qte, nbMag, marge) => { - if (!code || code === "-") return; - if (qte === 0 && ca === 0) return; - totalDuMois += qte; - out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); - (monthlyByCode[code] ??= {}); - monthlyByCode[code][m.label] = { - qte, ca, nbMag, - caMag: nbMag > 0 ? ca / nbMag : 0, - margePct: ca > 0 ? marge / ca : 0, - }; - }, timings); - totauxMois[m.label] = totalDuMois; - sommeMois += totalDuMois; - aggBatches++; - console.log( - "[qlik-pw][date brute] mois " + (i + 1) + "/" + monthlyPayload.length + " " + m.label + - " → " + lignes + " articles, quantité=" + Math.round(totalDuMois), - ); - await pousserCheckpoint("date brute " + m.label); - } - aggMonths = monthlyPayload.length; - - diag("[date brute] quantités par mois : " + JSON.stringify( - Object.fromEntries(Object.entries(totauxMois).map(([k, v]) => [k, Math.round(v)])), - )); - - // ─── Intégrité ─────────────────────────────────────────── - const vides = Object.entries(totauxMois).filter(([, v]) => v <= 0).map(([k]) => k); - if (vides.length) { - diag("[date brute] ÉCHEC : mois sans aucune vente " + JSON.stringify(vides)); - return false; - } - if (sommeMois > sansFiltre * 1.02) { - diag( - "[date brute] ÉCHEC : la somme des 12 mois (" + Math.round(sommeMois) + - ") dépasse le total sans filtre (" + Math.round(sansFiltre) + ") — les faits sont " + - "démultipliés, les ventes réseau seraient fausses", - ); - return false; - } - diag( - "[date brute] OK — somme des 12 mois = " + Math.round(sommeMois) + " pour un total sans filtre de " + - Math.round(sansFiltre) + " (" + Math.round((sommeMois / sansFiltre) * 100) + "%), " + - Object.keys(monthlyByCode).length + " articles", - ); - console.log("[qlik-pw][date brute] timings=" + timingSummary(timings)); - return true; - }; - - const moisParExpressions = async (): Promise => { - const timings: Record = {}; - const marqueOut = out.length; - try { - if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } - - // Les codes d'abord : les totaux de contrôle portent alors sur - // le même périmètre que l'extraction. Sauf si le fournisseur - // pose déjà ce périmètre — le re-sélectionner par codes ne - // changerait rien au résultat et coûterait une minute. - if (codes.length && fh !== -1 && !parFournisseur) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh, () => { aggCode15Retries++; }), timings); - await sleep(qlikSettleMs); - } - - // ─── Quel champ date filtre RÉELLEMENT les faits ? ─────── - // - // Les master measures ignorant la sélection Date, personne - // n'avait jamais pu vérifier que « Date » filtrait quoi que - // ce soit. Un champ valide doit donner un total non nul ET - // strictement inférieur au total sans filtre. - const sansFiltre = await totalDeControle(); - diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); - if (sansFiltre <= 0) { - diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)"); - return false; - } - - const allSerials = monthlyPayload.flatMap((m) => m.serials); - const allYmd = monthlyPayload.flatMap((m) => m.ymd); - const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); - let champDate: string | null = null; - let totalFenetre = 0; - for (const cand of champsDateCandidats) { - const valeurs = /key/i.test(cand) ? allYmd : allSerials; - const handleCand = await selectionnerFenetreSur(cand, valeurs); - if (handleCand == null) { - diag(" champ « " + cand + " » : inexistant"); - continue; - } - const filtre = await totalDeControle(); - const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; - diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); - // ⚠️ 100 % ne veut PAS dire « ce champ ne filtre rien » : toute - // la table de faits tient dans les 12 mois (mesuré), donc - // sélectionner la fenêtre entière ne retire rien. Exiger une - // baisse rejetait donc les champs valides. Ici la sélection - // ne sert qu'à borner la fenêtre : un total non nul suffit, - // et on préfère simplement un champ qui filtre s'il existe. - if (filtre > 0) { - champDate = cand; - totalFenetre = filtre; - if (ratio < 0.995) break; - diag(" (la fenêtre couvre 100 % des faits — normal si l'app ne porte que ces 12 mois)"); - break; - } - // Un candidat rejeté ne doit jamais polluer l'essai - // suivant (cas observé : Date=0 puis tous les autres=0). - await rpc("Clear", {}, handleCand); - await sleep(qlikSettleMs); - } - if (!champDate) { - // Les champs date exposés sont dissociés des faits : la - // dimension maître « Mois », elle, porte les vraies valeurs. - const selectionMois = await selectionnerFenetreViaDimensionMois(moisFenetre); - const ratio = selectionMois && sansFiltre > 0 ? selectionMois.total / sansFiltre : 0; - if (selectionMois) { - diag(" dimension « Mois » → " + Math.round(selectionMois.total) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); - } - if (selectionMois && selectionMois.total > 0 && ratio < 0.995) { - champDate = "dimension Mois"; - totalFenetre = selectionMois.total; - } - } - if (!champDate) { - diag( - "ÉCHEC : ni les champs date ni la dimension Mois ne filtrent les faits " + - "(champs essayés : " + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois.", - ); - return false; - } - diag("champ date retenu : « " + champDate + " »"); - - // Chaque expression est validée SÉPARÉMENT : une seule mesure - // invalide fait rendre zéro ligne à tout l'hypercube, sans - // qu'aucun message ne dise laquelle. - diag("validation des expressions, une par une :"); - const vQte = await validerExpression("quantité", exprQte); - if (vQte === "0") { - diag("ÉCHEC : l'expression quantité est inutilisable, rien à extraire"); - return false; - } - const vCa = await validerExpression("CA", exprCa); - const vNbMag = await validerExpression("magasins", exprNbMag); - const vMarge = await validerExpression("marge", exprMarge); - - // La master « Quantité N » n'est là que pour le calibrage, mais - // une master morte suffit à vider tout l'hypercube. On la sonde - // donc comme les autres, et on l'omet plutôt que de perdre - // l'extraction entière pour une colonne de contrôle. - const avecMaster = await masterQteVivante(); - if (!avecMaster) { - diag(" master « Quantité N » morte sous cette sélection → colonne de calibrage retirée du cube"); - } - - // ─── La dimension Mois démultiplie-t-elle les faits ? ──── - // - // Le pont de périodes rattache une même ligne de fait à - // plusieurs contextes. Ventilé par `Mois`, `Sum(quantite)` peut - // donc dépasser le total sans dimension — mesuré : 179 574 616 - // contre 33 945 285, soit ×5,3. Écrire cela dans le cache - // donnerait des ventes réseau cinq fois trop hautes : on refuse. - const totalVentile = await totalVentileParMois(vQte); - if (totalVentile > totalFenetre * 1.05) { - diag( - "ÉCHEC : ventilée par Mois, « " + vQte + " » totalise " + Math.round(totalVentile) + - " contre " + Math.round(totalFenetre) + " sans dimension (×" + - (totalFenetre > 0 ? (totalVentile / totalFenetre).toFixed(1) : "?") + - "). La dimension Mois passe par le pont de périodes et démultiplie les faits — " + - "l'agrégation directe donnerait des ventes réseau fausses, elle est refusée.", - ); - return false; - } - - // Seuls les mois de la fenêtre nous intéressent ; la dimension - // Mois peut en exposer d'autres si une sélection déborde. - let totalQte = 0; - let calibrees = 0, calibrOk = 0; - let horsFenetre = 0; - let echantillon = false; - - const got = await timed("cube", "expr", () => fetchMonthCubeExpr(vCa, vQte, vNbMag, vMarge, (code, mois, ca, qte, nbMag, marge, qteMaster) => { - if (!code || code === "-") return; - const mm = normMois(mois); - if (!echantillon) { - echantillon = true; - // Échantillon AVANT filtrage : si le mois ne tombe pas dans - // la fenêtre, c'est le format de la dimension Mois qui est - // en cause, pas les mesures. - diag("échantillon brut: " + JSON.stringify({ - code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), - ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, - })); - } - if (!moisFenetre.has(mm)) { horsFenetre++; return; } - // Calibrage sur l'année en cours, seul périmètre où la master - // measure est censée être juste. - if (avecMaster && parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { - calibrees++; - if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) calibrOk++; - } - totalQte += qte; - // Totaux = somme des mois de la fenêtre (et non un cumul année). - out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); - (monthlyByCode[code] ??= {}); - // Agrège au lieu d'écraser : protège contre deux valeurs - // brutes de la dimension Mois qui se normaliseraient vers - // la même clé YYYY-MM. - const precedent = monthlyByCode[code][mm]; - const qteCumulee = (precedent?.qte ?? 0) + qte; - const caCumule = (precedent?.ca ?? 0) + ca; - const margeMontant = ((precedent?.margePct ?? 0) * (precedent?.ca ?? 0)) + marge; - monthlyByCode[code][mm] = { - qte: qteCumulee, - ca: caCumule, - nbMag: Math.max(precedent?.nbMag ?? 0, nbMag), - caMag: nbMag > 0 ? ca / nbMag : 0, - margePct: caCumule > 0 ? margeMontant / caCumule : 0, - }; - }, timings, undefined, avecMaster), timings); - - aggMonths = monthlyPayload.length; - aggBatches++; - diag( - got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + - ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »", - ); - console.log("[qlik-pw][expr] timings=" + timingSummary(timings)); - - if (totalQte <= 0) { - diag( - "ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " + - Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + - "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.", - ); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - // Contrôle global indépendant : le cube sans dimension, - // calculé sur exactement les mêmes sélections, doit être - // égal à la somme de toutes les lignes mensuelles paginées. - const ecartGlobal = Math.abs(totalQte - totalFenetre); - const toleranceGlobale = Math.max(0.001, Math.abs(totalFenetre) * 1e-9); - if (ecartGlobal > toleranceGlobale) { - console.error( - "[qlik-pw][expr] INTÉGRITÉ REFUSÉE : somme mensuelle=" + totalQte + - " mais total Qlik fenêtre=" + totalFenetre + " (écart=" + ecartGlobal + ")", - ); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - - // Contrôle et normalisation PAR ARTICLE. Chaque article - // retourné possède ensuite exactement les 12 clés de la - // fenêtre ; une clé absente du cube Qlik signifie qu'aucun - // fait n'existe pour ce mois, donc une vraie valeur 0. - const totauxParCode: Record = {}; - for (let i = marqueOut; i < out.length; i++) { - const ligne = out[i]; - const code = String(ligne[0] ?? "").trim(); - if (code) totauxParCode[code] = (totauxParCode[code] ?? 0) + (Number(ligne[2]) || 0); - } - for (const [code, mois] of Object.entries(monthlyByCode)) { - for (const label of moisFenetre) { - mois[label] ??= { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; - } - // Ne conserve aucune clé hors de la fenêtre glissante. - for (const label of Object.keys(mois)) { - if (!moisFenetre.has(label)) delete mois[label]; - } - const somme12 = Array.from(moisFenetre).reduce((s, label) => s + (Number(mois[label]?.qte) || 0), 0); - const totalArticle = totauxParCode[code] ?? 0; - if (Math.abs(somme12 - totalArticle) > Math.max(0.001, Math.abs(totalArticle) * 1e-9)) { - console.error( - "[qlik-pw][expr] INTÉGRITÉ ARTICLE REFUSÉE code=" + code + - " somme12=" + somme12 + " total=" + totalArticle, - ); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - } - if (calibrees > 20 && calibrOk / calibrees < 0.5) { - console.warn( - "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + - " diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE).", - ); - } - return true; - } catch (e) { - diag("ÉCHEC : " + String((e as Error)?.message || e) + " → repli sur les master measures"); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - }; - - // Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par - // lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup. - // selectYear = null → passe principale (année N + N-1 dérivée via COMP). - // selectYear = 2025 → passe complémentaire : on sélectionne l'année pour - // récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que - // COMP ne peut pas fournir puisque l'année N ne va pas jusque-là). - let loggedSampleRow = false; - const monthDimPath = async (selectYear: number): Promise => { - const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); - - /** - * Traitement d'une ligne du cube [Article Code, Mois]. - * - * Une passe = UNE année explicitement sélectionnée, exactement ce - * que fait un utilisateur dans l'app (« je sélectionne 2026 et - * j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que - * les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces - * mois-là : deux années différentes ne peuvent donc pas se - * doubler, puisqu'elles ne partagent aucun mois. - */ - const onRowMois = (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => { - if (!code || code === "-") return; - const mm = normMois(mois); - if (!moisFenetre.has(mm)) return; - out.push([code, ca, qte, nbMag, caMag, marge]); - const y = parseInt(mm.slice(0, 4), 10); - if (!loggedSampleRow) { - loggedSampleRow = true; - console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({ - code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte, - "Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp, - })); - } - (monthlyByCode[code] ??= {}); - // Les 5 mesures du mois courant de la ligne. Le cube les renvoie - // déjà toutes : les conserver ne coûte aucune requête Qlik en plus. - const full = { qte, ca, nbMag, caMag, margePct: marge }; - // « Quantité COMP » n'est plus nécessaire : chaque année de la - // fenêtre est extraite directement par sa propre passe, ce qui - // donne les 5 mesures et non la seule quantité. - void y; void qteComp; - // Une passe ultérieure ne doit JAMAIS écraser un mois servi par - // un zéro : hors de son année, la passe voit la combinaison - // (article, mois) mais toutes ses mesures sont nulles. - const precedent = monthlyByCode[code][mm]; - if (precedent && (precedent.qte ?? 0) > 0 && qte <= 0) return; - monthlyByCode[code][mm] = full; - }; - - if (yfh === -1) throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); - - // La `Période` qui sert cette année-là — pas la même pour toutes : - // « Année à date » convient à l'année en cours, une autre valeur à - // une année révolue (cf. choisirPeriode). - const periodeAnnee = periodeParAnnee.get(selectYear); - if (periodeAnnee) { - console.log("[qlik-pw] (mois) Période « " + (periodeAnnee.texte ?? "(aucune)") + " » pour l'année " + selectYear); - await appliquerPeriode(periodeAnnee.texte); - } - - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); - console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); - await sleep(qlikSettleMs); - const allSerials = monthlyPayload.flatMap((m) => m.serials); - if (dfh !== -1 && allSerials.length) { - await rpc("Clear", {}, dfh); - await sleep(qlikSettleMs); - await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh); - await sleep(qlikSettleMs); - } - aggMonths = monthlyPayload.length; - - // ─── Chemin rapide : UNE seule sélection Article Code ──── - // - // Mesuré en production : `SelectValues` sur « Article Code » - // coûte 8 à 100 s **par appel**, quasi indépendamment du - // nombre de valeurs (le moteur balaie un symbole de plus d'un - // million d'entrées). Avec 7 200 codes en lots de 300, cela - // faisait 24 appels, ~15 min de sync, et la session Qlik - // mourait avant la fin (« Socket closed » puis « Execution - // context was destroyed »). - // - // La dimension Mois donne déjà tous les mois d'un coup : rien - // n'oblige à découper les codes. On tente donc un seul appel - // pour tous les codes et un seul cube paginé — la lecture des - // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine - // refuse (cube trop gros, code 15, saturation mémoire). - const marqueOut = out.length; - if ((codes.length || parFournisseur) && fh !== -1) { - const timingsAll: Record = {}; - try { - // Le périmètre fournisseur est posé une fois pour toutes - // avant la première passe : il survit aux sélections - // Année/Date, qui portent sur d'autres champs. - if (!parFournisseur) { - await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), timingsAll); - await sleep(qlikSettleMs); - } - const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); - aggBatches++; - console.log( - "[qlik-pw][timing] (mois) chemin rapide — " + - (parFournisseur ? "périmètre fournisseur (0 sélection code)" : codes.length + " codes en 1 sélection") + - ", rows=" + got.value + - " cumul=" + out.length + " timings=" + timingSummary(timingsAll), - ); - return; - } catch (e) { - // Les lignes déjà lues seraient recomptées par le repli : - // on les retire (les écritures mensuelles, elles, sont - // des affectations idempotentes). - out.length = marqueOut; - console.log("[qlik-pw] (mois) chemin rapide échoué → repli par lots : " + String((e as Error)?.message || e)); - } - } - - // Lots plus petits = cubes plus légers (moins de pression mémoire côté - // moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes). - const fallbackSizes = [300, 150, 75]; - const totalCodes = codes.length; - let codeIndex = 0, batchNumber = 0, fallbackIndex = 0; - while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { - const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + fallbackSizes[fallbackIndex], totalCodes)) : []; - const timings: Record = {}; - let code15Retries = 0; - batchNumber++; - const bp = "[qlik-pw] (mois) lot " + batchNumber + " [" + codeIndex + "," + (codeIndex + batch.length) + ") size=" + batch.length; - try { - if (batch.length && fh !== -1) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - await sleep(qlikSettleMs); - await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); - await sleep(qlikSettleMs); - } - const got = await timed("cube", "batch", () => fetchMonthCube(onRowMois, timings, () => { code15Retries++; }), timings); - aggBatches++; - aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + bp + " — rows=" + got.value + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - if (!codes.length) break; - codeIndex += batch.length; - fallbackIndex = 0; - } catch (batchErr) { - const nextSize = fallbackSizes[fallbackIndex + 1]; - if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { - console.log("[qlik-pw] " + bp + " — fallback code15 " + batch.length + "→" + nextSize); - fallbackIndex += 1; - aggFallbackCount++; - continue; - } - throw new Error(bp + " — " + String((batchErr as Error)?.message || batchErr)); - } - } - }; - - // Chemin rapide (flag QLIK_SELECT_ALL_CODES) : sélectionne TOUS les - // Article Code une seule fois, puis n'itère que la sélection Date par - // mois avec UN cube paginé (au lieu de re-sélectionner les codes par lots - // de 150 à chaque mois — la SelectValues répétée coûte ~22 s × 120). - // Renvoie true si le mois complet a réussi ; false si repli code 15 (les - // résultats partiels et sélections sont réinitialisés avant le chemin par lots). - const trySelectAllCodesPath = async (): Promise => { - const savedLen = out.length; - try { - if (codes.length && fh !== -1) { - await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), {}); - await sleep(qlikSettleMs); - const { value: sel } = await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), {}); - console.log("[qlik-pw] select-all — SelectValues Article Code (" + codes.length + " codes) → " + JSON.stringify(sel.qReturn)); - await sleep(qlikSettleMs); - } - const total = monthlyPayload.length; - console.log("[qlik-pw] chemin rapide select-all — " + total + " mois, 1 sélection Article Code globale"); - for (let mi = 0; mi < total; mi++) { - const m = monthlyPayload[mi]; - const prefix = "[qlik-pw] (rapide) mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; - const monthStart = nowMs(); - const monthTimings: Record = {}; - aggMonths = mi + 1; - let code15Retries = 0; - // Sélection Date du mois uniquement (la sélection Article Code reste active). - const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { - qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), - qToggleMode: false, - qSoftLock: true, - }, dfh), monthTimings); - console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); - await sleep(qlikSettleMs); - // Un seul cube paginé pour tous les articles du mois. - const batchRows: Array> = []; - const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings); - out.push(...batchRows); - accMonthly(batchRows, m.label); - aggBatches++; - aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings)); - // Clear Date uniquement — on garde la sélection Article Code pour le mois suivant. - if (mi < total - 1) { - try { await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); } - catch (e) { console.log(prefix + " — Clear Date ignoré: " + String((e as Error)?.message || e)); } - } - console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " timings=" + timingSummary(monthTimings)); - } - return true; - } catch (e) { - if (isEngineCode15(e)) { - console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots"); - out.length = savedLen; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - aggMonths = 0; aggBatches = 0; aggCode15Retries = 0; - try { await rpc("Clear", {}, dfh); if (fh !== -1) await rpc("Clear", {}, fh); } catch { /* noop */ } - await sleep(qlikSettleMs); - return false; - } - throw e; - } - }; - - // La passe de « rattrapage des mois vides » a été SUPPRIMÉE. - // - // Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. - // Or les master measures ignorent la sélection Date : le cube lui - // renvoyait le total de période, qu'elle recopiait dans chacun des - // mois manquants. D'où les plateaux identiques d'août à décembre - // (87 648 sur douze mois pour un article, etc.) et les tendances - // « -162 % » qu'ils fabriquaient. - // - // Un mois qu'on ne sait pas extraire doit rester ABSENT. - - /** Pousse l'état courant vers Node (voir `cfCheckpoint`). */ - const pousserCheckpoint = async (etape: string) => { - try { - const fn = (window as unknown as { cfCheckpoint?: (p: unknown) => Promise }).cfCheckpoint; - if (!fn) return; - await fn({ rows: out, monthly: monthlyByCode }); - console.log("[qlik-pw] point de contrôle « " + etape + " » : " + out.length + " lignes"); - } catch (e) { - console.log("[qlik-pw] point de contrôle « " + etape + " » ignoré : " + String((e as Error)?.message || e)); - } - }; - - if (!noDateMode) { - // ÉTAPE 0 — poser le périmètre articles, une fois pour toutes. - // - // Le code fournisseur FF (base SQL / API) existe aussi dans Qlik : - // une seule valeur y désigne le même périmètre que les dizaines de - // milliers de codes articles qu'on envoyait jusqu'ici. La bascule - // n'est retenue que si la couverture mesurée le justifie, sinon on - // retombe sur la sélection par codes (cf. selectionnerParFournisseur). - parFournisseur = await selectionnerParFournisseur(); - - const attendus = monthlyPayload.map((m) => m.label); - // Années de la fenêtre, la PLUS RÉCENTE D'ABORD — c'est l'usage de - // l'app (« je sélectionne 2026 »), et cela met les mois les plus - // utiles en tête des points de contrôle. - const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort((a, b) => b - a); - - // ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre. - // - // Toutes les mesures de l'app sont bornées par `Type_Cal` - // (`Sum({} quantite)`), lui-même piloté par - // `Période`. C'est cette sélection — et elle seule — qui détermine - // quels mois sont atteignables. Sous la période par défaut, - // « Quantité N » ne couvre que l'année en cours : d'où les mois - // d'août à décembre vides quelle que soit la sélection de dates. - const couvertureMaster = await choisirPeriode(annees); - - // ÉTAPE 2 — extraire, puis REFUSER tout résultat incomplet. - // - // Un cache partiel est pire que pas de cache : il se lit comme des - // ventes nulles. Les deux chemins sont donc validés sur le même - // critère — les 12 mois de la fenêtre, ou rien. - const moisManquants = () => { - const vus = new Set(); - for (const parMois of Object.values(monthlyByCode)) { - for (const [mois, v] of Object.entries(parMois)) { - if ((v?.qte ?? 0) > 0) vus.add(mois); - } - } - return attendus.filter((m) => !vus.has(m)); - }; - const couvre12 = () => moisManquants().length === 0; - - // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. - // - // Dans l'interface, on sélectionne « 2026 » et on obtient l'année - // plus sa comparaison N-1. C'est le mécanisme natif du modèle : - // `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc - // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis - // 2026 — pour reconstituer les 12 mois glissants. - // - // Chaque passe est faite SOUS la `Période` retenue : c'est elle qui - // donne son sens à `Type_Cal='N'`. Avec `Année=2025`, « Année à - // date » vaut l'année 2025 entière (elle est passée) ; avec - // `Année=2026`, elle s'arrête au mois courant. L'union des deux - // passes reconstitue donc bien les 12 mois glissants. - diag("extraction année par année : " + JSON.stringify(annees)); - - let extraitParAnnee = false; - if (monthDim) { - try { - for (const annee of annees) { - const manquantsAvant = moisManquants(); - await monthDimPath(annee); - const manquantsApres = moisManquants(); - const gagnes = manquantsAvant.filter((m) => !manquantsApres.includes(m)); - diag( - " année " + annee + " → mois de la fenêtre désormais servis : " + JSON.stringify(gagnes) + - " ; encore manquants : " + JSON.stringify(manquantsApres), - ); - await pousserCheckpoint("année " + annee); - if (manquantsApres.length === 0) break; // fenêtre complète, inutile de continuer - } - extraitParAnnee = couvre12(); - if (!extraitParAnnee) { - diag( - "l'extraction année par année ne couvre pas les 12 mois (manquants : " + - JSON.stringify(moisManquants()) + ") — essai de l'agrégation directe", - ); - } - } catch (e) { - diag("extraction année par année interrompue : " + String((e as Error)?.message || e)); - } - } - - // REPLI 1 — mois par mois sur le champ date brut. - // - // Passe en premier car c'est le seul chemin sans pont de périodes : - // aucun mois n'y est « inatteignable », puisqu'un mois y est un - // filtre de dates et non un contexte de calendrier. - if (!extraitParAnnee && useExpr) { - try { - if (await moisParDateBrute()) { - extraitParAnnee = couvre12(); - if (extraitParAnnee) { - diag("extraction par date brute retenue — les " + attendus.length + " mois sont servis"); - await pousserCheckpoint("date brute complète"); - } else { - diag("extraction par date brute incomplète (manquants : " + JSON.stringify(moisManquants()) + ")"); - } - } - } catch (e) { - diag("extraction par date brute interrompue : " + String((e as Error)?.message || e)); - } - } - - if (!extraitParAnnee) { - if (!(monthDim && useExpr && (await moisParExpressions()))) { - throw new Error( - "extraction 12 mois refusée : ni l'extraction année par année " + - "(couverture master annoncée = " + couvertureMaster + "/" + attendus.length + ") " + - "ni l'agrégation directe des faits n'ont couvert la fenêtre — mois manquants : " + - JSON.stringify(moisManquants()), - ); - } - await pousserCheckpoint("expressions vérifiées"); - } - - if (!couvre12()) { - throw new Error( - "extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) + - " n'est pas entièrement couverte (manquants : " + JSON.stringify(moisManquants()) + - ") — cache laissé inchangé", - ); - } - } else if (noDateMode) { - // Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup). - // On sélectionne uniquement Article Code ; Date reste libre. - const timings: Record = {}; - let code15Retries = 0; - console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois"); - if (codes.length && fh !== -1) { - const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); - console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn)); - } - const batchRows: Array> = []; - const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); - out.push(...batchRows); - aggMonths = 1; - aggBatches = 1; - aggCode15Retries = code15Retries; - console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - } else if (selectAllCodes && (await trySelectAllCodesPath())) { - // Chemin rapide terminé avec succès (voir trySelectAllCodesPath). - // Si false (repli code 15), on tombe dans le chemin par lots ci-dessous. - } else { - // Itération par mois × lots de codes : pour chaque mois, SelectValues Date - // (~30 serials), puis SelectValues Article Code par petits lots. Le cube - // reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik. - const total = monthlyPayload.length; - const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50]; - const fallbackLabel = fallbackSizes.join(">"); - const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1; - console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel); - for (let mi = 0; mi < total; mi++) { - const m = monthlyPayload[mi]; - const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; - const monthStart = nowMs(); - const monthTimings: Record = {}; - aggMonths = mi + 1; - try { - // 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs). - const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { - qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), - qToggleMode: false, - qSoftLock: true, - }, dfh), monthTimings); - console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); - // Laisser l'Engine finaliser l'évaluation de la sélection Date - // avant la première SelectValues Article Code du 1er lot. - await sleep(qlikSettleMs); - - // 2) Sélection Article Code par lots contrôlés. - let codeIndex = 0; - let batchNumber = 0; - let fallbackIndex = 0; - const totalCodes = codes.length || 0; - while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { - const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0; - const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : []; - const batchStart = codeIndex; - const batchEnd = codeIndex + batch.length; - const timings: Record = {}; - let code15Retries = 0; - batchNumber++; - const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length; - try { - if (fh !== -1) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - // Laisser l'Engine appliquer le Clear avant la nouvelle sélection. - await sleep(qlikSettleMs); - const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { - qFieldValues: batch.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), timings); - console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn)); - // Laisser l'Engine finaliser l'évaluation de la sélection - // avant de créer / interroger le cube (évite code 15 sur le 1er lot). - await sleep(qlikSettleMs); - } - const batchRows: Array> = []; - const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); - out.push(...batchRows); - accMonthly(batchRows, m.label); - aggBatches++; - aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - if (!codes.length) break; - codeIndex = batchEnd; - fallbackIndex = 0; - } catch (batchErr) { - const msg = String((batchErr as Error)?.message || batchErr); - const nextFallbackIndex = fallbackIndex + 1; - const nextSize = fallbackSizes[nextFallbackIndex]; - if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { - console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - fallbackIndex = nextFallbackIndex; - aggFallbackCount++; - continue; - } - console.error(batchPrefix + " — ABORT — " + msg); - throw new Error(batchPrefix + " — " + msg); - } - } - } catch (monthErr) { - const msg = String((monthErr as Error)?.message || monthErr); - console.error(prefix + " — ABORT — " + msg); - throw new Error(prefix + " — " + msg); - } - // 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine). - if (mi < total - 1) { - try { - await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); - if (fh !== -1) await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings); - } catch (clearErr) { - console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr)); - } - } - console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings)); - } - } - - // La sonde de diagnostic du cube [Article Code, Mois] a été retirée : - // elle refaisait une sélection complète des codes et des 365 jours en - // fin de sync (des dizaines de secondes) alors que le découpage - // mensuel est validé et en production depuis. - - ws.close(); - resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics }); - } catch (e) { - try { ws.close(); } catch { /* noop */ } - // On remonte ce qui a été extrait : les codes déjà traités ont - // toutes leurs données, seul le reliquat manque. - resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics }); - } - })(); - }), - { - app: cfg.appNetwork, - dim: cfg.dimCodeArticleId, - mca: cfg.measCaId, - mqte: cfg.measQteId, - mnb: cfg.measNbMagId, - mcamag: cfg.measCaMagId, - mmarge: cfg.measMargePctId, - codes: codeCentraux ?? [], - token: jeton, - monthlyPayload, - noDateMode: !dateFilter, - qlikSettleMs, - qlikCode15MaxAttempts, - qlikArticleBatchFallbacks, - selectAllCodes: qlikSelectAllCodes, - monthDim: qlikMonthDim, - moisDimId: qlikMoisDimId, - mqteComp: qlikMeasQteCompId, - yearN, - useExpr: qlikUseExpr, - exprQte: qlikExprQte, - exprCa: qlikExprCa, - exprNbMag: qlikExprNbMag, - exprMarge: qlikExprMarge, - champsDateCandidats: qlikDateFields, - fournisseurCode: (fournisseur ?? "").trim(), - champsFournisseurCandidats: qlikSupplierFields, - couvertureFournisseurMin: qlikSupplierMinCoverage, - }, - ); - - // Reprise globale sur erreur transitoire. - // - // Le moteur Qlik abandonne les requêtes longues quand il est chargé - // (« code 15 / Request aborted »). Observé sur un même fournisseur, avec - // le même code : réussite à 4 h du matin (OpenDoc en 78 s), échec à 6 h - // (OpenDoc en 233 s, puis abandon). Les reprises par requête de - // `rpcWithRetry` ne suffisent pas — c'est toute l'extraction qui tombe. - // - // La pause est volontairement longue : relancer aussitôt ne ferait - // qu'ajouter de la charge au serveur qui vient déjà de renoncer. - const extractionEssais = 1 + envNumber("QLIK_EXTRACTION_RETRIES", 1, 0, 3); - const extractionPauseMs = envNumber("QLIK_EXTRACTION_RETRY_PAUSE_MS", 60_000, 0, 600_000); - const estTransitoire = (m: string) => /"code"\s*:\s*15\b/.test(m) || /request aborted/i.test(m); - const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)); - - let result: InPageResult; - let essai = 0; - for (;;) { - essai++; - const reste = essai < extractionEssais; - try { - const candidat = (await evaluer()) as InPageResult; - if (candidat.ok || !reste || !estTransitoire(String(candidat.error ?? ""))) { - result = candidat; - break; - } - console.warn( - `[qlik-pw] tentative ${essai}/${extractionEssais} abandonnée par le moteur (${String(candidat.error).slice(0, 160)})` - + ` — nouvelle tentative dans ${Math.round(extractionPauseMs / 1000)} s`, - ); - } catch (e) { - const msg = String((e as Error)?.message || e); - if (reste && estTransitoire(msg)) { - console.warn( - `[qlik-pw] tentative ${essai}/${extractionEssais} interrompue (${msg.slice(0, 160)})` - + ` — nouvelle tentative dans ${Math.round(extractionPauseMs / 1000)} s`, - ); - } else { - // Comportement d'origine, une fois les reprises épuisées. - // Une fenêtre 12 mois n'est publiable que si l'extraction est - // complète et a passé tous les contrôles. Conserver un checkpoint - // partiel fabriquerait précisément des graphiques à 2 ou 3 mois. - if (dateFilter) { - throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`); - } - const secours = checkpoint as { rows?: Array>; monthly?: InPageResult["monthly"] } | null; - if (secours?.rows?.length) { - console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`); - result = { ok: true, rows: secours.rows, monthly: secours.monthly, size: secours.rows.length, partiel: true }; - break; - } - throw e; - } - } - checkpoint = null; // la nouvelle tentative repart d'une extraction vierge - await pause(extractionPauseMs); - } - - if (!result.ok) { - if (dateFilter) { - throw new Error(`[qlik-pw] extraction 12 mois refusée, cache inchangé : ${result.error}`); - } - // Même logique qu'un crash : un résultat partiel vaut mieux que rien. - if (result.rows?.length) { - console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`); - result = { ...result, ok: true, partiel: true }; - } else { - throw new Error(`[qlik-pw] ${result.error}`); - } - } - if (result.partiel) { - if (dateFilter) { - throw new Error("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé"); - } - console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); - } - - // Résumé timing final : synthèse compacte de la sync pour audit/debug. - // Les logs détaillés par étape restent en place (timings batch/mois) ; - // ici on consolide les compteurs agrégés et la durée totale. - const totalMs = Date.now() - totalStart; - if (result.diagExpr?.length) { - console.log("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───"); - for (const d of result.diagExpr) console.log("[qlik-pw][diag] " + d); - } - console.log( - `[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`, - ); - - const wanted = codeCentraux ? new Set(codeCentraux) : null; - const out = new Map(); - const margeWeight = new Map(); - for (const r of result.rows ?? []) { - const code = String(r[0]).trim(); - if (!code || code === "-") continue; - if (wanted && !wanted.has(code)) continue; - - const ca = Number(r[1]) || 0; - const qte = Number(r[2]) || 0; - const nbMag = Number(r[3]) || 0; - const margePct = Number(r[5]) || 0; - const prev = out.get(code); - if (!prev) { - out.set(code, { - codeCentrale: code, - caReseau: ca, - qteReseau: qte, - nbMagasinsReseau: nbMag, - caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0, - margePctReseau: margePct, - periode: dateFilter?.label, - }); - margeWeight.set(code, ca); - continue; - } - - prev.caReseau += ca; - prev.qteReseau += qte; - prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag); - const weight = margeWeight.get(code) ?? 0; - const nextWeight = weight + ca; - prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct); - prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0; - margeWeight.set(code, nextWeight); - } - // Rattache le détail mensuel (par code central) au métrique agrégé. - // On produit DEUX représentations : - // - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la - // colonne « Tendance / Réseau » de la Grille. Inchangé. - // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. - const monthly = result.monthly ?? {}; - - // Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur - // l'ensemble des codes. Distinguer « couvert, zéro vente » de « non - // extrait » est indispensable : sans cela, un article vendu seulement - // depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »), - // et un mois non extrait passait pour une absence de vente. - const couverts = new Set(); - for (const parMois of Object.values(monthly)) { - for (const mois of Object.keys(parMois)) couverts.add(mois); - } - const moisCouverts = [...couverts].sort(); - console.log(`[qlik-pw] mois couverts par l'extraction (${moisCouverts.length}) : ${JSON.stringify(moisCouverts)}`); - - for (const [code, metric] of out) { - const mm = monthly[code] ?? {}; - const qteByMonth: Record = {}; - const metricsByMonth: Record = {}; - for (const mois of moisCouverts) { - const v = mm[mois]; - // Zéro EXPLICITE sur un mois couvert sans vente : c'est une - // information, au même titre qu'une vente. Un mois non couvert - // reste absent et sera ignoré par la tendance. - qteByMonth[mois] = Number(v?.qte) || 0; - // ⚠️ Toutes les mesures à zéro, pas seulement la quantité. Écrire - // `{ qte: 0 }` seul laissait `nbMag` absent sur les mois sans - // vente : la courbe « magasins vendeurs » exigeant les 12 mois, - // elle disparaissait dès qu'un produit avait un mois creux. - metricsByMonth[mois] = v ?? { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; - } - metric.qteByMonth = qteByMonth; - metric.metricsByMonth = metricsByMonth; - } - // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. - // Si presque tous varient → la mesure respecte le mois (feature OK). - // Si tous identiques → la mesure Qlik ignore la sélection Date. - let nVar = 0, nFlat = 0; - let exVar: NetworkMetric | null = null, exFlat: NetworkMetric | null = null; - for (const m of out.values()) { - const bm = m.qteByMonth; - if (!bm) continue; - const vals = Object.values(bm); - if (vals.length < 2) continue; - const allEq = vals.every((v) => v === vals[0]); - if (allEq) { nFlat++; if (!exFlat) exFlat = m; } - else { nVar++; if (!exVar) exVar = m; } - } - console.log(`[qlik-pw] qteByMonth distribution: ${nVar} codes VARIENT / ${nFlat} identiques (total ${nVar + nFlat})`); - if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth)); - if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth)); - console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`); - - // Contrôle de plausibilité AVANT toute écriture : des données complètes - // mais fausses (dernier mois pas encore chargé, mesure insensible au - // mois) laisseraient le cache faux jusqu'à la synchro suivante. - const corrections = [...out.values()].flatMap(assainirMetrique); - if (corrections.length) { - console.warn(`[qlik-pw] ${corrections.length} valeur(s) impossible(s) corrigée(s) : ${corrections.slice(0, 5).join(" ; ")}`); - } - if (dateFilter) { - const controle = controlerExtraction(out.values(), monthRanges.map((m) => m.label)); - for (const a of controle.avertissements) console.warn(`[qlik-pw][contrôle] ${a}`); - if (!controle.ok) throw new Error(`[qlik-pw] ${controle.motif} — cache inchangé`); - console.log(`[qlik-pw][contrôle] extraction validée (${out.size} codes, ${monthRanges.length} mois)`); - } - // Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs - const sample = (result.rows ?? []).slice(0, 3).map((r) => ({ - code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5], - })); - console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample)); - return out; - } finally { - await ctx.close(); - } -} diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts deleted file mode 100644 index 5502798..0000000 --- a/src/lib/qlik-search.ts +++ /dev/null @@ -1,662 +0,0 @@ -/** - * CollectFlow — Recherche produit **dans Qlik Sense** (source primaire). - * - * La recherche produit part du réseau, pas du catalogue Nancy : on interroge - * d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles - * qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant - * rapproche les codes centraux trouvés de la base FF Nancy. - * - * Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de - * produits que Nancy. Chercher dans la base locale d'abord masquait tous les - * produits que le réseau travaille et que nous ne référençons pas — c'est - * précisément ce qu'on veut voir. - * - * Technique : même plomberie que `qlik-playwright.ts` (Chromium headless + - * websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse - * un websocket « raw » côté serveur. - * - * ## Comment la sélection est construite - * - * La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU** - * entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont - * l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs - * faisait ensuite exploser le cube (`code 15 — Request aborted`). - * - * On procède donc en trois temps : - * 1. une recherche par mot, pour retenir le **plus sélectif** (celui qui - * ramène le moins de valeurs) ; - * 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots - * présents, insensible casse/accents) ; - * 3. sélection des seules valeurs retenues, **par numéro d'élément** - * (`SelectListObjectValues`) — exact, sans ré-appariement de texte. - * - * Le résultat final est revérifié une dernière fois avant d'être renvoyé : une - * liste sans rapport avec la recherche est pire que pas de résultat du tout. - */ - -import "server-only"; -import { getQlikBrowser as getBrowser, dansLaFileQlik } from "@/lib/qlik-browser"; -import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client"; -import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; - -/** - * Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**. - * - * Les mesures sortent du même cube que l'identité : une seule session Qlik pour - * toute la recherche. Les extraire dans une seconde session concurrente faisait - * abandonner le moteur (`code 15 — Request aborted`). - */ -export interface QlikArticleMatch { - /** Valeur de la dimension « Article Code » — clé de jointure de la Grille. */ - codeCentrale: string; - /** - * Champ `article_no_centrale` quand l'app l'expose : second candidat pour la - * jointure avec `articles.artcentrale`, les deux ne portant pas toujours le - * même format. - */ - codeCentraleAlt: string; - /** Libellé réseau, si l'app expose un champ libellé article. */ - libelle: string; - /** Fournisseur réseau, si l'app expose un champ fournisseur. */ - fournisseur: string; - /** CA réseau sur la fenêtre 12 mois glissants. */ - caReseau: number; - qteReseau: number; - nbMagasinsReseau: number; - caParMagasinReseau: number; - /** Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). */ - margePctReseau: number; -} - -export interface QlikSearchOutcome { - matches: QlikArticleMatch[]; - /** Champ Qlik effectivement interrogé (diagnostic UI). */ - champUtilise: string | null; - /** true si Qlik a renvoyé plus de résultats que la limite demandée. */ - tronque: boolean; - /** - * Message de diagnostic quand la recherche aboutit à zéro article alors que - * Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en - * fonctionnement nominal. - */ - avertissement: string | null; - /** Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). */ - periode: string | null; -} - -/** - * Nombre maximum d'articles renvoyés par une recherche. - * - * Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà - * de quelques dizaines, la seconde passe devient longue et le résultat - * illisible. L'utilisateur affine son terme. - */ -export const QLIK_SEARCH_MAX_RESULTS = 40; - -/** - * Champs candidats pour le libellé article, par ordre de préférence. - * - * Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le - * libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des - * résultats : si un candidat ne matche rien, on passe au suivant. - */ -const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"]; - -/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */ -const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"]; - -/** - * Champ portant le code centrale « brut » de l'article, quand il existe. - * Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la - * dimension « Article Code » ne renvoie pas le même format. - */ -const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale"; - -interface InPageSearchResult { - ok: boolean; - error?: string; - /** [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] */ - rows?: Array<[string, string, string, string, number, number, number, number, number]>; - champUtilise?: string | null; - /** Nombre d'articles uniques correspondant au terme (avant plafonnement). */ - total?: number; - /** Lignes brutes lues dans le cube (avant garde-fou). */ - lues?: number; - /** Lignes écartées parce qu'elles ne correspondaient pas au terme. */ - rejetees?: number; - /** true si le terme ramenait trop de valeurs pour être traité intégralement. */ - tropLarge?: boolean; - /** Journal compact des étapes, remonté dans les logs serveur. */ - trace?: string[]; -} - -/** - * Cherche dans Qlik les articles correspondant à `term`. - * - * Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus - * libellé / fournisseur si l'app les expose). Les métriques réseau sont - * ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire - * le détail mensuel sur 12 mois glissants. - */ -export function searchQlikArticles( - term: string, - limit: number = QLIK_SEARCH_MAX_RESULTS, - cfg: QlikConfig = getQlikConfig(), -): Promise { - // Terme trop court : réponse vide immédiate, sans attendre la file. - if (term.trim().length < 3) return rechercher(term, limit, cfg); - // Même file que les extractions : deux sessions Engine simultanées font - // abandonner le serveur Qlik, et les deux requêtes échouent. - return dansLaFileQlik(`recherche « ${term.trim()} »`, () => rechercher(term, limit, cfg)); -} - -async function rechercher( - term: string, - limit: number, - cfg: QlikConfig, -): Promise { - const cleaned = term.trim(); - if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null }; - if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant"); - if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants"); - - const started = Date.now(); - // Latence laissée à l'Engine après chaque mutation de sélection. Même réglage - // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après - // est calculé sur l'état de sélection précédent. - const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250)); - // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. - const dateFilter = buildGridNetworkQlikDateFilter( - new Date(), - QLIK_MONTHS_BACK_DEFAULT, - ); - const sess = await qlikNtlmSession(cfg); - const [cookieName, ...rest] = sess.cookie.split("="); - const cookieValue = rest.join("="); - - const browser = await getBrowser(); - const ctx = await browser.newContext({ - httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` }, - ignoreHTTPSErrors: true, - }); - await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]); - - try { - const page = await ctx.newPage(); - page.on("console", (msg) => { - // Le client Qlik est très bavard au chargement (thèmes, extensions) : - // on ne relaie que ce qui vient de notre script. - const t = msg.text(); - if (t.startsWith("[cf]")) console.log(`[qlik-search][page] ${t}`); - }); - - let csrfToken: string | null = null; - let resolveToken: (() => void) | null = null; - const tokenReady = new Promise((r) => { resolveToken = r; }); - page.on("websocket", (ws) => { - const m = ws.url().match(/qlik-csrf-token=([^&]+)/); - if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); } - }); - - await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs }) - .catch(() => { /* le client Qlik ouvre son ws juste après */ }); - await Promise.race([tokenReady, page.waitForTimeout(15000)]); - if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); - - const result = (await page.evaluate( - ({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge, - champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { - app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; - serialsDate: number[]; - mCa: string; mQte: string; mNbMag: string; mCaMag: string; mMarge: string; - champCodeAlt: string; - champsLibellePreferes: string[]; champsFournisseurPreferes: string[]; - champLibelleForce: string; champFournisseurForce: string; - }) => - new Promise((resolve) => { - /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ - const MAX_VALEURS_LUES = 10000; - /** - * Valeurs sélectionnées au maximum. - * - * Mesuré en production : sélectionner les 13 446 libellés contenant - * « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube - * (code 15). Le cube étant trié par quantité réseau décroissante et - * l'affichage plafonné à quelques dizaines de lignes, 300 libellés - * suffisent largement. - */ - const MAX_VALEURS_SELECTION = 300; - const PAGE_VALEURS = 5000; - - const loc = (window as unknown as { location: Location }).location; - const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; - const ws = new WebSocket(url); - const trace: string[] = []; - let id = 0; - const pend = new Map void; rej: (e: unknown) => void }>(); - const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => { - const i = ++id; pend.set(i, { res, rej }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); - }); - const ready = new Promise((res, rej) => { - ws.onmessage = (ev: MessageEvent) => { - const m = JSON.parse(ev.data as string); - if (m.method === "OnConnected") return res(); - if (m.id && pend.has(m.id)) { - const x = pend.get(m.id)!; - pend.delete(m.id); - if (m.error) x.rej(new Error(JSON.stringify(m.error))); - else x.res(m.result); - } - }; - ws.onerror = () => rej(new Error("ws error (403 ?)")); - setTimeout(() => rej(new Error("ws timeout")), 30000); - }); - - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - /** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */ - const estCode15 = (e: unknown) => /"code"\s*:\s*15\b/.test(String((e as Error)?.message ?? e)); - const rpcRetry = async (method: string, params: unknown, handle: number, tentatives = 3) => { - let derniere: unknown; - for (let i = 1; i <= tentatives; i++) { - try { - return await rpc(method, params, handle); - } catch (e) { - derniere = e; - if (!estCode15(e) || i === tentatives) throw e; - trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives); - await sleep(settleMs * (i + 1)); - } - } - throw derniere; - }; - - const normaliser = (s: string) => s - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, " ") - .trim(); - const mots = normaliser(terme).split(" ").filter(Boolean); - - (async () => { - try { - await ready; - const open = await rpc("OpenDoc", { qDocName: app }); - const doc = (open.qReturn as { qHandle: number }).qHandle; - - // ─── 1. Inventaire des champs ──────────────────────────── - const fl = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "FieldList" }, - qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true }, - }, - }, doc); - const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle); - const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? []) - .map((f) => f.qName)); - - const has = (nom: string, re: RegExp) => re.test(nom); - const champCode = champs.find((c) => c === "Article Code") - ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) - ?? "Article Code"; - - // Candidats libellé : surcharge d'env, puis noms connus de - // l'app FF, puis heuristique. On essaiera chacun dans l'ordre - // jusqu'à en trouver un qui ramène des valeurs. - const candidatsLibelle = [ - champLibelleForce, - ...champsLibellePreferes, - ...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)), - ...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)), - ].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i); - - const champFournisseur = [ - champFournisseurForce, - ...champsFournisseurPreferes, - ...champs.filter((c) => has(c, /fournisseur/i)), - ...champs.filter((c) => has(c, /(fabricant|supplier)/i)), - ].find((c) => c && champs.includes(c)) ?? null; - - trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur); - - // ─── 2. Recherche + sélection sur le champ le plus adapté ─ - // - // `SearchListObjectFor` applique un OU entre les mots. On - // cherche donc mot par mot pour retenir le plus sélectif, - // puis on filtre en ET côté client avant de sélectionner. - const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => { - const lo = await rpcRetry("CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-search-lb" }, - qListObjectDef: { - qDef: { qFieldDefs: [champ] }, - qInitialDataFetch: [], - }, - }, - }, doc); - const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const loId = String(ret.qGenericId ?? ret.qId ?? ""); - const lh = ret.qHandle; - try { - // 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur). - let meilleurMot = terme; - let meilleurNb = Number.POSITIVE_INFINITY; - for (const mot of (mots.length > 1 ? mots : [terme])) { - await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh); - const lay = await rpcRetry("GetLayout", {}, lh); - const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0; - trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)"); - if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; } - } - if (!Number.isFinite(meilleurNb) || meilleurNb === 0) return { retenues: 0, brut: 0, tropLarge: false }; - - // 2b. Relecture des valeurs du mot retenu. On relance la - // recherche systématiquement : la boucle ci-dessus a pu - // laisser le list object sur un autre mot. - await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh); - const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES); - const elems: number[] = []; - let lues = 0; - for (let top = 0; top < aLire; top += PAGE_VALEURS) { - const d = await rpcRetry("GetListObjectData", { - qPath: "/qListObjectDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }], - }, lh); - const matrix = ((d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix) ?? []; - if (matrix.length === 0) break; - lues += matrix.length; - for (const ligne of matrix) { - const cell = ligne[0]; - const texte = String(cell?.qText ?? ""); - const elem = cell?.qElemNumber; - if (elem == null || elem < 0) continue; - // Filtre ET : tous les mots du terme présents. - const n = normaliser(texte); - if (!mots.every((m) => n.includes(m))) continue; - elems.push(elem); - if (elems.length >= MAX_VALEURS_SELECTION) break; - } - if (elems.length >= MAX_VALEURS_SELECTION) break; - } - trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET"); - if (elems.length === 0) { - return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; - } - - // 2c. Sélection par numéro d'élément : exact, aucun - // ré-appariement de texte possible. - await rpcRetry("SelectListObjectValues", { - qPath: "/qListObjectDef", - qValues: elems, - qToggleMode: false, - qSoftLock: true, - }, lh); - await sleep(settleMs); - return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; - } finally { - if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } } - } - }; - - await rpc("ClearAll", { qLockedAlso: false }, doc); - await sleep(settleMs); - - let champChoisi: string | null = null; - let tropLarge = false; - for (const candidat of candidatsLibelle) { - const r = await chercherEtSelectionner(candidat); - tropLarge = tropLarge || r.tropLarge; - if (r.retenues > 0) { champChoisi = candidat; break; } - await rpc("ClearAll", { qLockedAlso: false }, doc); - await sleep(settleMs); - } - if (!champChoisi) { - // Repli code centrale (terme saisi = un code). - const r = await chercherEtSelectionner(champCode); - if (r.retenues > 0) champChoisi = champCode; - tropLarge = tropLarge || r.tropLarge; - } - if (!champChoisi) { - ws.close(); - return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace }); - } - trace.push("champ retenu = " + champChoisi); - - // Trace des sélections réellement actives : si elle est vide, - // la sélection n'a pas pris côté Engine. - try { - const cs = await rpc("CreateSessionObject", { - qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} }, - }, doc); - const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle); - const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } }) - .qSelectionObject?.qSelections) ?? []) - .map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`); - trace.push("sélections actives=" + JSON.stringify(sel)); - } catch (e) { - trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")"); - } - - // ─── 3. Fenêtre 12 mois glissants ──────────────────────── - // Sélectionnée APRÈS les libellés : restreindre les dates avant - // la recherche masquerait les articles non vendus sur la période. - if (serialsDate.length > 0) { - try { - const gd = await rpc("GetField", { qFieldName: "Date" }, doc); - const dh = (gd.qReturn as { qHandle: number }).qHandle; - for (let i = 0; i < serialsDate.length; i += 500) { - const lot = serialsDate.slice(i, i + 500); - await rpcRetry("SelectValues", { - qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })), - qToggleMode: i > 0, - qSoftLock: true, - }, dh); - } - await sleep(settleMs); - trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)"); - } catch (e) { - trace.push("sélection Date impossible, mesures sur toute la période : " + String((e as Error)?.message || e)); - } - } - - // ─── 4. Identité + mesures réseau, dans UN SEUL cube ───── - // - // Les mesures sont dans le même cube que l'identité, et donc - // dans la même session Qlik : une seconde session concurrente - // pour les extraire faisait abandonner le moteur (code 15). - // - // Les master measures sont résolues par TITRE : le qLibraryId - // fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même - // approche que l'extraction de la Grille. - const norm = (x: string) => x.replace(/\s+/g, " ").trim().toLowerCase(); - const ml = await rpcRetry("CreateSessionObject", { - qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } }, - }, doc); - const mll = await rpcRetry("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle); - const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []); - const parTitre = new Map(); - for (const it of items) { - const t = it.qMeta?.title ?? it.qData?.title ?? ""; - if (t) parTitre.set(norm(t), it.qInfo.qId); - } - const mesure = (titre: string, repli: string) => parTitre.get(norm(titre)) ?? repli; - const idCa = mesure("CA N", mCa); - const idQte = mesure("Quantité N", mQte); - const idNbMag = mesure("Magasin Ventes Nb N", mNbMag); - const idCaMag = mesure("CA par Magasin N", mCaMag); - const idMarge = mesure("Marge % N", mMarge); - - const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; - const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null; - const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; - const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1; - const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1; - const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1; - - const nbDims = dims.length; - // Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %. - const idxQteCol = nbDims + 1; - const largeur = nbDims + 5; - // Cube trié par quantité réseau décroissante : la première page - // contient donc directement les meilleures ventes du réseau, et - // on n'a pas besoin de lire tout l'ensemble sélectionné. - const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur))); - - const defCube = (trie: boolean) => ({ - qProp: { - qInfo: { qType: "cf-search" }, - qHyperCubeDef: { - qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })), - qMeasures: [ - { qLibraryId: idCa }, - trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte }, - { qLibraryId: idNbMag }, - { qLibraryId: idCaMag }, - { qLibraryId: idMarge }, - ], - ...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}), - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }], - qSuppressZero: false, - qSuppressMissing: true, - }, - }, - }); - let cube: { [k: string]: unknown }; - try { - cube = await rpcRetry("CreateSessionObject", defCube(true), doc); - } catch (e) { - // Le tri par mesure est un confort (meilleures ventes en - // tête) : si l'Engine refuse cette forme, on repart sans. - if (estCode15(e)) throw e; - trace.push("tri par quantité refusé, cube non trié : " + String((e as Error)?.message || e)); - cube = await rpcRetry("CreateSessionObject", defCube(false), doc); - } - const ch = (cube.qReturn as { qHandle: number }).qHandle; - const layout = await rpcRetry("GetLayout", {}, ch); - const hc = (layout.qLayout as { - qHyperCube: { - qSize: { qcy: number }; - qDataPages?: Array<{ qMatrix?: Array> }>; - }; - }).qHyperCube; - const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; - - // ─── 4. Garde-fou : le résultat DOIT correspondre au terme ── - const termeCode = terme.trim().toLowerCase(); - const correspond = (code: string, libelle: string): boolean => { - if (mots.length === 0) return true; - const c = code.toLowerCase(); - if (c === termeCode || c.startsWith(termeCode)) return true; - if (!libelle) return champChoisi === champCode; - const l = normaliser(libelle); - return mots.every((m) => l.includes(m)); - }; - - type Ligne = [string, string, string, string, number, number, number, number, number]; - const texte = (r: Array<{ qText?: string }>, i: number) => { - if (i < 0) return ""; - const v = String(r[i]?.qText ?? "").trim(); - return v === "-" ? "" : v; - }; - const parCode = new Map(); - let rejetees = 0; - for (const r of matrix) { - const code = String(r[0]?.qText ?? "").trim(); - if (!code || code === "-") continue; - const codeAlt = texte(r, idxAlt); - const libelle = texte(r, idxLibelle); - const fournisseur = texte(r, idxFournisseur); - if (!correspond(code, libelle)) { rejetees++; continue; } - const nombre = (i: number) => Number(r[nbDims + i]?.qNum) || 0; - const ligne: Ligne = [ - code, codeAlt, libelle, fournisseur, - nombre(0), nombre(1), nombre(2), nombre(3), nombre(4), - ]; - const existant = parCode.get(code); - if (!existant) { parCode.set(code, ligne); continue; } - // Même article chez plusieurs fournisseurs : une seule ligne. - // Les mesures sont identiques (elles ne dépendent pas de la - // dimension fournisseur), on complète juste les libellés. - if (!existant[1] && codeAlt) existant[1] = codeAlt; - if (!existant[2] && libelle) existant[2] = libelle; - if (!existant[3] && fournisseur) existant[3] = fournisseur; - } - const uniques = [...parCode.values()]; - const rows = uniques.slice(0, limite); - trace.push( - "cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length + - " rejetées(hors terme)=" + rejetees + - " articles uniques=" + uniques.length + " retenus=" + rows.length, - ); - if (rows.length > 0) { - trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3))); - } - - ws.close(); - resolve({ - ok: true, rows, champUtilise: champChoisi, total: uniques.length, - lues: matrix.length, rejetees, tropLarge, trace, - }); - } catch (e) { - try { ws.close(); } catch { /* noop */ } - resolve({ ok: false, error: String((e as Error)?.message || e), trace }); - } - })(); - }), - { - app: cfg.appNetwork, - dim: cfg.dimCodeArticleId, - token: csrfToken, - terme: cleaned, - limite: limit, - settleMs, - serialsDate: dateFilter.dailySerials, - mCa: cfg.measCaId, - mQte: cfg.measQteId, - mNbMag: cfg.measNbMagId, - mCaMag: cfg.measCaMagId, - mMarge: cfg.measMargePctId, - champCodeAlt: CHAMP_CODE_CENTRALE_ALT, - champsLibellePreferes: CHAMPS_LIBELLE_PREFERES, - champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES, - champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), - champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(), - }, - )) as InPageSearchResult; - - for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`); - if (!result.ok) throw new Error(`[qlik-search] ${result.error}`); - - const matches: QlikArticleMatch[] = (result.rows ?? []).map( - ([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({ - codeCentrale, codeCentraleAlt, libelle, fournisseur, - caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau, - }), - ); - console.log( - `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, - ); - - // Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que - // d'afficher un « aucun résultat » qui laisserait croire à une absence - // réelle dans le réseau. - let avertissement: string | null = null; - if (matches.length === 0 && (result.rejetees ?? 0) > 0) { - avertissement = "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search]."; - } else if (matches.length === 0 && result.tropLarge) { - avertissement = "Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis."; - } - if (avertissement) console.warn(`[qlik-search] ${avertissement}`); - - return { - matches, - champUtilise: result.champUtilise ?? null, - tronque: (result.total ?? 0) > matches.length, - avertissement, - periode: dateFilter.label, - }; - } finally { - await ctx.close(); - } -} diff --git a/src/lib/qlik-validation.ts b/src/lib/qlik-validation.ts deleted file mode 100644 index 6140bcd..0000000 --- a/src/lib/qlik-validation.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * CollectFlow — Contrôles de plausibilité d'une extraction réseau Qlik. - * - * L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne - * peut pas voir deux pièges, observés ou attendus en production, qui donnent - * des chiffres faux mais complets : - * - * 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois, - * le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est - * « couvert » (quelques ventes), donc accepté — et toutes les tendances du - * fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine - * ou plus après. - * 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de - * dates, chaque mois reçoit la même valeur (le total de l'année). Le - * diagnostic existait dans les journaux ; rien n'empêchait l'écriture. - * - * Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des - * données fausses : l'extraction est refusée, et le planificateur la reprend - * une nuit suivante. - * - * Module pur (aucune dépendance d'exécution) : testable hors serveur. - */ - -import type { NetworkMetric } from "@/lib/qlik-client"; - -export interface ControleExtraction { - ok: boolean; - /** Raison du refus, lisible par l'administrateur (statut de synchro). */ - motif?: string; - /** Anomalies corrigées ou tolérées, à journaliser. */ - avertissements: string[]; -} - -export interface OptionsControle { - /** - * Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet. - * Au-delà, une baisse est prise pour ce qu'elle est : une baisse. - */ - jourLimiteDernierMois?: number; - /** Part minimale du dernier mois par rapport à la moyenne des 3 précédents. */ - ratioDernierMoisMin?: number; - /** Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. */ - volumeMinimal?: number; - /** Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. */ - partSeriesPlatesMax?: number; -} - -const DEFAUTS: Required = { - jourLimiteDernierMois: 10, - ratioDernierMoisMin: 0.4, - volumeMinimal: 30, - partSeriesPlatesMax: 0.5, -}; - -const fini = (v: unknown): number => { - const n = Number(v); - return Number.isFinite(n) ? n : 0; -}; - -/** - * Corrige sur place les valeurs impossibles d'une métrique (NaN, infini, - * nombre de magasins négatif) et renvoie la liste des corrections. - */ -export function assainirMetrique(m: NetworkMetric): string[] { - const corrections: string[] = []; - const champs = ["caReseau", "qteReseau", "nbMagasinsReseau", "caParMagasinReseau", "margePctReseau"] as const; - for (const champ of champs) { - if (!Number.isFinite(Number(m[champ]))) { - corrections.push(`${m.codeCentrale} : ${champ} non numérique (${String(m[champ])}) remplacé par 0`); - m[champ] = 0; - } - } - if (m.nbMagasinsReseau < 0) { - corrections.push(`${m.codeCentrale} : nombre de magasins négatif (${m.nbMagasinsReseau}) remplacé par 0`); - m.nbMagasinsReseau = 0; - } - m.nbMagasinsReseau = Math.round(m.nbMagasinsReseau); - m.caParMagasinReseau = m.nbMagasinsReseau > 0 ? m.caReseau / m.nbMagasinsReseau : 0; - - if (m.qteByMonth) { - for (const [mois, v] of Object.entries(m.qteByMonth)) { - if (!Number.isFinite(Number(v))) { - corrections.push(`${m.codeCentrale} : quantité ${mois} non numérique remplacée par 0`); - m.qteByMonth[mois] = 0; - } - } - } - if (m.metricsByMonth) { - for (const [mois, v] of Object.entries(m.metricsByMonth)) { - m.metricsByMonth[mois] = { - qte: fini(v?.qte), - ca: fini(v?.ca), - nbMag: Math.max(0, Math.round(fini(v?.nbMag))), - caMag: fini(v?.caMag), - margePct: fini(v?.margePct), - }; - } - } - return corrections; -} - -/** - * Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache. - * - * `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent. - */ -export function controlerExtraction( - metriques: Iterable, - fenetre: string[], - now: Date = new Date(), - options: OptionsControle = {}, -): ControleExtraction { - const o = { ...DEFAUTS, ...options }; - const avertissements: string[] = []; - const liste = [...metriques]; - if (liste.length === 0 || fenetre.length < 4) return { ok: true, avertissements }; - - // ── 1. Séries plates : la mesure a ignoré la sélection de mois ────────── - let vendues = 0; - let plates = 0; - for (const m of liste) { - const valeurs = fenetre.map((mois) => fini(m.qteByMonth?.[mois])); - if (!valeurs.some((v) => v !== 0)) continue; - vendues++; - if (valeurs.every((v) => v === valeurs[0])) plates++; - } - if (vendues >= 5 && plates / vendues > o.partSeriesPlatesMax) { - return { - ok: false, - motif: `${plates} séries sur ${vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées`, - avertissements, - }; - } - if (plates > 0) avertissements.push(`${plates} série(s) identique(s) sur 12 mois parmi ${vendues} vendues`); - - // ── 2. Dernier mois manifestement incomplet ───────────────────────────── - const totaux = fenetre.map((mois) => liste.reduce((t, m) => t + fini(m.qteByMonth?.[mois]), 0)); - const dernier = totaux[totaux.length - 1]; - const precedents = totaux.slice(-4, -1); - const reference = precedents.reduce((t, v) => t + v, 0) / precedents.length; - if ( - now.getDate() <= o.jourLimiteDernierMois && - reference >= o.volumeMinimal && - dernier < reference * o.ratioDernierMoisMin - ) { - const pct = Math.round((dernier / reference) * 100); - return { - ok: false, - motif: `le dernier mois (${fenetre[fenetre.length - 1]}) ne pèse que ${pct} % des 3 mois précédents : ` - + `probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)`, - avertissements, - }; - } - - return { ok: true, avertissements }; -} diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts new file mode 100644 index 0000000..5fc35a7 --- /dev/null +++ b/src/lib/rpc.ts @@ -0,0 +1,78 @@ +/** + * CollectFlow — Appels du navigateur vers le serveur Rust. + * + * - `callAction` remplace les anciennes « server actions » Next.js : chaque + * fonction garde son nom et sa signature, son corps devient + * `POST /api/actions/{nom}` avec le tableau des arguments. + * - `fetchPage` lit les données qu'une ancienne page serveur calculait avant + * rendu : `GET /api/pages/{nom}?…`. + * + * Une réponse en erreur (`{ error }`, statut 4xx/5xx) est relevée en `Error`, + * comme l'exception de l'action serveur l'était. Une session expirée (401) + * renvoie à la page de connexion, comme le faisait le middleware. + */ + +export type ParamsPage = Record | URLSearchParams; + +/** Redirige vers la connexion (session absente ou expirée). */ +function versConnexion(): void { + if (typeof window !== "undefined" && window.location.pathname !== "/login") { + window.location.assign("/login"); + } +} + +async function lireReponse(res: Response): Promise { + let body: unknown = null; + const texte = await res.text(); + if (texte) { + try { + body = JSON.parse(texte); + } catch { + body = null; + } + } + if (!res.ok) { + if (res.status === 401) versConnexion(); + const message = (body as { error?: unknown } | null)?.error; + throw new Error(typeof message === "string" && message ? message : res.statusText || `Erreur HTTP ${res.status}`); + } + return body as T; +} + +/** Appelle l'action serveur `name` avec ses arguments positionnels. */ +export async function callAction(name: string, args: unknown[] = []): Promise { + // JSON.stringify remplace `undefined` par `null` dans un tableau : on retire + // les arguments absents en fin de liste pour que le serveur voie « absent ». + const liste = [...args]; + while (liste.length > 0 && liste[liste.length - 1] === undefined) liste.pop(); + const res = await fetch(`/api/actions/${encodeURIComponent(name)}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + credentials: "same-origin", + body: JSON.stringify(liste), + }); + return lireReponse(res); +} + +/** Construit la chaîne de requête d'une page (valeurs vides omises). */ +export function queryString(params?: ParamsPage): string { + if (!params) return ""; + const sp = new URLSearchParams(); + const entrees = params instanceof URLSearchParams ? [...params.entries()] : Object.entries(params); + for (const [k, v] of entrees) { + if (v === undefined || v === null || v === "") continue; + sp.append(k, String(v)); + } + const s = sp.toString(); + return s ? `?${s}` : ""; +} + +/** Données de la page serveur `name` (cf. `server/PORTING.md`, section Pages). */ +export async function fetchPage(name: string, params?: ParamsPage): Promise { + const res = await fetch(`/api/pages/${encodeURIComponent(name)}${queryString(params)}`, { + headers: { Accept: "application/json" }, + credentials: "same-origin", + cache: "no-store", + }); + return lireReponse(res); +} diff --git a/src/lib/throttled-json-storage.ts b/src/lib/throttled-json-storage.ts index aff5cbc..5a1a467 100644 --- a/src/lib/throttled-json-storage.ts +++ b/src/lib/throttled-json-storage.ts @@ -1,5 +1,3 @@ -"use client"; - import { createJSONStorage, type PersistStorage, type StorageValue } from "zustand/middleware"; /** diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..b23d1fc --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,8 @@ +import { createRoot } from "react-dom/client"; +import "./app/globals.css"; +import { AppRouter } from "./router"; + +const racine = document.getElementById("root"); +if (!racine) throw new Error("Élément #root introuvable dans index.html."); + +createRoot(racine).render(); diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 6f4a357..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,48 +0,0 @@ -import NextAuth from "next-auth"; -import { authConfig } from "@/lib/auth.config"; -import { NextResponse } from "next/server"; - -const { auth } = NextAuth(authConfig); - -export default auth((req) => { - const isLoggedIn = !!req.auth; - const { nextUrl } = req; - - const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth"); - const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/"); - // L'API publique s'authentifie elle-même (clé d'API ou session) et rend ses - // propres erreurs JSON. Sans cette exemption, un script non authentifié - // recevrait une redirection 302 + la page HTML de login au lieu d'un 401. - const isPublicApiRoute = nextUrl.pathname.startsWith("/api/v1"); - - // 1. Laisser passer les requêtes d'auth API - if (isApiAuthRoute) return NextResponse.next(); - - // 1 bis. Laisser passer /api/v1 — l'authentification est faite dans les handlers - if (isPublicApiRoute) return NextResponse.next(); - - // 2. Rediriger vers /login si non connecté et route non publique - if (!isLoggedIn && !isPublicRoute) { - return NextResponse.redirect(new URL("/login", nextUrl)); - } - - // 3. Déjà connecté et sur /login : page d'accueil (la même que « / »). - if (isLoggedIn && isPublicRoute) { - return NextResponse.redirect(new URL("/dashboard", nextUrl)); - } - - // 4. Protection par rôle (Admin seulement pour les paramètres) - const isAdminRoute = nextUrl.pathname.startsWith("/settings") || nextUrl.pathname.startsWith("/admin"); - const userRole = (req.auth?.user as any)?.role; - - if (isAdminRoute && userRole !== "admin") { - return NextResponse.redirect(new URL("/dashboard", nextUrl)); - } - - return NextResponse.next(); -}); - -export const config = { - // Protège toutes les routes sauf fichiers statiques et assets - matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$).*)"], -}; diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 0000000..2ebaefd --- /dev/null +++ b/src/router.tsx @@ -0,0 +1,134 @@ +/** + * CollectFlow — Table des routes de la SPA. + * + * Reprend une à une les pages de l'ancien routeur Next.js (`src/app/**`) : + * même adresse, même titre d'onglet, même silhouette de chargement + * (`loading.tsx`), même mise en page du groupe `(dashboard)` (menu + en-tête) + * et même page d'erreur (`error.tsx`) ou « introuvable » (`not-found.tsx`). + * + * Le serveur Rust garde les accès : non connecté → /login, connecté sur / ou + * /login → /dashboard, /settings et /admin/* réservés aux administrateurs. + */ + +import { Suspense, useEffect, type ComponentType } from "react"; +import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from "react-router"; +import { isNotFoundError } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { Providers } from "@/app/providers"; +import { RouteErrorBoundary } from "@/components/shared/route-error-boundary"; + +import HomePage from "@/app/page"; +import NotFound from "@/app/not-found"; +import LoginPage from "@/app/(auth)/login/page"; +import DashboardLayout from "@/app/(dashboard)/layout"; +import DashboardError from "@/app/(dashboard)/error"; +import DashboardNotFound from "@/app/(dashboard)/not-found"; +import AdminSynchronisationPage from "@/app/(dashboard)/admin/synchronisation/page"; +import AnalyticsPage from "@/app/(dashboard)/analytics/page"; +import AnalyticsLoading from "@/app/(dashboard)/analytics/loading"; +import CommandesAutoPage from "@/app/(dashboard)/commandes-auto/page"; +import CommandesAutoLoading from "@/app/(dashboard)/commandes-auto/loading"; +import DashboardPage from "@/app/(dashboard)/dashboard/page"; +import DashboardLoading from "@/app/(dashboard)/dashboard/loading"; +import ExportsPage from "@/app/(dashboard)/exports/page"; +import GridPage from "@/app/(dashboard)/grid/page"; +import GridLoading from "@/app/(dashboard)/grid/loading"; +import HistoriquePage from "@/app/(dashboard)/historique/page"; +import HitParadePage from "@/app/(dashboard)/hit-parade/page"; +import HitParadeLoading from "@/app/(dashboard)/hit-parade/loading"; +import ProduitsPage from "@/app/(dashboard)/produits/page"; +import ProduitsLoading from "@/app/(dashboard)/produits/loading"; +import PublicitesPage from "@/app/(dashboard)/publicites/page"; +import PublicitesLoading from "@/app/(dashboard)/publicites/loading"; +import SettingsPage from "@/app/(dashboard)/settings/page"; +import SettingsLoading from "@/app/(dashboard)/settings/loading"; +import SnapshotsPage from "@/app/(dashboard)/snapshots/page"; +import StockNegatifPage from "@/app/(dashboard)/stock-negatif/page"; +import StockNegatifLoading from "@/app/(dashboard)/stock-negatif/loading"; + +interface PageRoute { + path: string; + /** Titre de l'onglet (« · CollectFlow ») ; absent : « CollectFlow ». */ + title?: string; + Page: ComponentType; + /** Silhouette affichée pendant le premier chargement des données (`loading.tsx`). */ + Loading?: ComponentType; +} + +/** Pages du groupe `(dashboard)` : menu latéral et en-tête autour. */ +const PAGES_TABLEAU_DE_BORD: PageRoute[] = [ + { path: "/dashboard", title: "Accueil", Page: DashboardPage, Loading: DashboardLoading }, + { path: "/grid", title: "Révision d'assortiment", Page: GridPage, Loading: GridLoading }, + { path: "/produits", title: "Recherche produit", Page: ProduitsPage, Loading: ProduitsLoading }, + { path: "/analytics", title: "Ventes par mois", Page: AnalyticsPage, Loading: AnalyticsLoading }, + { path: "/hit-parade", title: "Meilleures ventes", Page: HitParadePage, Loading: HitParadeLoading }, + { path: "/stock-negatif", title: "Stocks à surveiller", Page: StockNegatifPage, Loading: StockNegatifLoading }, + { path: "/commandes-auto", title: "Commandes fournisseurs", Page: CommandesAutoPage, Loading: CommandesAutoLoading }, + { path: "/publicites", title: "Publicités", Page: PublicitesPage, Loading: PublicitesLoading }, + { path: "/historique", title: "Historique", Page: HistoriquePage }, + { path: "/settings", title: "Paramètres", Page: SettingsPage, Loading: SettingsLoading }, + { path: "/admin/synchronisation", title: "Synchronisation", Page: AdminSynchronisationPage }, + // Anciennes adresses, conservées pour les favoris. + { path: "/snapshots", Page: SnapshotsPage }, + { path: "/exports", Page: ExportsPage }, +]; + +/** Titre de l'onglet du navigateur pour la page affichée. */ +function useDocumentTitle(title: string | undefined): void { + useEffect(() => { + document.title = title ? `${title} · CollectFlow` : "CollectFlow"; + }, [title]); +} + +/** Une page : son titre, et sa silhouette de chargement tant que ses données manquent. */ +function RoutePage({ title, Page, Loading }: Omit) { + useDocumentTitle(title); + return ( + : null}> + + + ); +} + +/** + * Mise en page `(dashboard)` : menu et en-tête, puis la frontière d'erreur de + * la page (`error.tsx`, ou `not-found.tsx` après `notFound()`), remise à zéro à + * chaque changement de page. + */ +function DashboardShell() { + const { pathname } = useLocation(); + const { status } = useSession(); + // Session absente ou expirée : la connexion, comme le faisait le middleware. + if (status === "unauthenticated") return ; + return ( + + + isNotFoundError(error) ? : + } + > + + + + ); +} + +export function AppRouter() { + return ( + + + + } /> + } /> + }> + {PAGES_TABLEAU_DE_BORD.map(({ path, ...page }) => ( + } /> + ))} + + } /> + + + + ); +} diff --git a/src/shims/next-auth-react.ts b/src/shims/next-auth-react.ts new file mode 100644 index 0000000..4068c2a --- /dev/null +++ b/src/shims/next-auth-react.ts @@ -0,0 +1,123 @@ +/** + * CollectFlow — Remplaçant de `next-auth/react` pour la SPA. + * + * La session est tenue par le serveur Rust (cookie HTTP-only) ; l'interface la + * lit sur `GET /api/auth/session` et se déconnecte par `POST /api/auth/logout`. + * Mêmes exports que ceux utilisés de next-auth : `SessionProvider`, + * `useSession`, `signOut`. + */ + +import { createContext, createElement, startTransition, use, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useRefreshVersion } from "@/lib/page-data"; + +export interface SessionUser { + id: string; + name: string; + role: string; +} + +export interface Session { + user: SessionUser; +} + +export type SessionStatus = "loading" | "authenticated" | "unauthenticated"; + +interface SessionContextValue { + data: Session | null; + status: SessionStatus; + /** Promesse de la session courante (pour suspendre une page en attendant). */ + promise: Promise; + update: () => Promise; +} + +/** + * Lit la session sur le serveur. Serveur injoignable ou réponse illisible : la + * session connue est conservée (une coupure réseau ne déconnecte pas). + */ +async function lireSession(connue: Session | null = null): Promise { + try { + const res = await fetch("/api/auth/session", { credentials: "same-origin", cache: "no-store" }); + if (!res.ok) return connue; + const body = (await res.json()) as { user?: SessionUser | null }; + return body?.user ? { user: body.user } : null; + } catch { + return connue; + } +} + +const SessionContext = createContext(null); + +/** Dernière session lue (une seule application par onglet). */ +let sessionConnue: Session | null = null; + +/** + * Charge la session au démarrage, puis la relit à chaque `router.refresh()` + * (sans repasser par l'état « loading » : l'interface ne clignote pas). + */ +export function SessionProvider({ children }: { children: ReactNode; refetchOnWindowFocus?: boolean }) { + const [promise, setPromise] = useState>(() => lireSession()); + const [etat, setEtat] = useState<{ data: Session | null; status: SessionStatus }>({ data: null, status: "loading" }); + const version = useRefreshVersion(); + const premiere = useRef(true); + + useEffect(() => { + let actif = true; + promise.then((s) => { + sessionConnue = s; + if (actif) setEtat({ data: s, status: s ? "authenticated" : "unauthenticated" }); + }); + return () => { + actif = false; + }; + }, [promise]); + + useEffect(() => { + if (premiere.current) { + premiere.current = false; + return; + } + // Rafraîchissement de la page : la session (rôle relu en base) aussi, en + // transition pour qu'une page qui l'attend reste affichée. + const p = lireSession(sessionConnue); + startTransition(() => setPromise(p)); + }, [version]); + + const update = useCallback(() => { + const p = lireSession(sessionConnue); + startTransition(() => setPromise(p)); + return p; + }, []); + + const value = useMemo(() => ({ ...etat, promise, update }), [etat, promise, update]); + return createElement(SessionContext, { value }, children); +} + +/** Session courante, comme `useSession()` de next-auth. */ +export function useSession(): Pick { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSession doit être utilisé dans ."); + return { data: ctx.data, status: ctx.status, update: ctx.update }; +} + +/** + * Session déjà chargée : suspend la page tant qu'elle ne l'est pas (les pages + * serveur la connaissaient avant leur rendu). + */ +export function useSessionResolue(): Session | null { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSessionResolue doit être utilisé dans ."); + return use(ctx.promise); +} + +/** Déconnexion : supprime le cookie de session puis, sauf `redirect: false`, va à la connexion. */ +export async function signOut(options: { redirect?: boolean; callbackUrl?: string } = {}): Promise { + try { + const res = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); + await res.text(); + } catch { + /* le cookie expirera de lui-même ; on quitte quand même l'application */ + } + if (options.redirect !== false) { + window.location.assign(options.callbackUrl ?? "/login"); + } +} diff --git a/src/shims/next-link.tsx b/src/shims/next-link.tsx new file mode 100644 index 0000000..4722d37 --- /dev/null +++ b/src/shims/next-link.tsx @@ -0,0 +1,32 @@ +/** + * CollectFlow — Remplaçant de `next/link` pour la SPA (React Router). + * + * Mêmes props que celles utilisées de `next/link` : `href`, `prefetch`, + * `replace`, `scroll`, plus tous les attributs d'un ``. + */ + +import type { AnchorHTMLAttributes, Ref } from "react"; +import { Link as RouterLink } from "react-router"; + +export interface LinkProps extends Omit, "href"> { + href: string; + /** Sans effet : la SPA a déjà tout le code des pages. */ + prefetch?: boolean | null; + replace?: boolean; + /** `false` : garder la position de défilement. */ + scroll?: boolean; + ref?: Ref; +} + +/** Adresse hors application (autre origine, mailto:, fichier à télécharger…). */ +function estExterne(href: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//"); +} + +export default function Link({ href, prefetch: _prefetch, replace, scroll, ref, ...rest }: LinkProps) { + void _prefetch; + if (estExterne(href) || rest.download !== undefined) { + return ; + } + return ; +} diff --git a/src/shims/next-navigation.ts b/src/shims/next-navigation.ts new file mode 100644 index 0000000..59515f5 --- /dev/null +++ b/src/shims/next-navigation.ts @@ -0,0 +1,156 @@ +/** + * CollectFlow — Remplaçant de `next/navigation` pour la SPA (React Router). + * + * - `useRouter()` : push / replace / back / forward / refresh / prefetch. + * `refresh()` relit les données de la page affichée (cf. `lib/page-data`). + * - `usePathname()`, `useSearchParams()` : suivent aussi les + * `history.replaceState` / `pushState` faits directement par l'interface + * (changement d'onglet, de magasin…), que Next.js répercutait sur ces hooks + * sans recharger la page. React Router, lui, ne les voit pas. + * - `redirect()`, `notFound()` : pour un composant en cours de rendu. + */ + +import { useMemo, useSyncExternalStore } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { refreshPage } from "@/lib/page-data"; + +// ─── Écritures directes dans l'historique ─────────────────────────────────── + +interface EtatRouteur { + key: string; + idx: number; +} + +function estEtatRouteur(v: unknown): v is EtatRouteur { + return !!v && typeof v === "object" && "key" in v && "idx" in v; +} + +/** Dernière URL écrite par l'interface, rattachée à l'entrée d'historique du routeur. */ +let urlDirecte: { key: string; pathname: string; search: string } | null = null; +let versionDirecte = 0; +const abonnes = new Set<() => void>(); + +function patcherHistorique(methode: "pushState" | "replaceState"): void { + const original = window.history[methode]; + window.history[methode] = function (this: History, data: unknown, unused: string, url?: string | URL | null) { + const precedent = this.state as unknown; + // React Router écrit un état { usr, key, idx } avec une clé neuve : ce + // n'est pas une écriture « directe » de l'interface. + const parRouteur = estEtatRouteur(data) && (!estEtatRouteur(precedent) || data.key !== precedent.key); + let etat = data; + if (!parRouteur && data == null && estEtatRouteur(precedent)) { + // `replaceState(null, …)` effaçait l'index du routeur : on le garde. + etat = precedent; + } + original.call(this, etat, unused, url); + if (!parRouteur) { + const cle = estEtatRouteur(etat) ? etat.key : "default"; + urlDirecte = { key: cle, pathname: window.location.pathname, search: window.location.search }; + versionDirecte++; + abonnes.forEach((f) => f()); + } + } as History[typeof methode]; +} + +if (typeof window !== "undefined" && !(window as { __cfHistoryPatched?: boolean }).__cfHistoryPatched) { + (window as { __cfHistoryPatched?: boolean }).__cfHistoryPatched = true; + patcherHistorique("pushState"); + patcherHistorique("replaceState"); +} + +function sAbonner(f: () => void): () => void { + abonnes.add(f); + return () => abonnes.delete(f); +} + +/** Emplacement affiché : celui du routeur, ou la dernière réécriture directe de cette entrée. */ +function useEmplacement(): { pathname: string; search: string } { + const location = useLocation(); + useSyncExternalStore(sAbonner, () => versionDirecte, () => 0); + if (urlDirecte && urlDirecte.key === location.key) { + return { pathname: urlDirecte.pathname, search: urlDirecte.search }; + } + return { pathname: location.pathname, search: location.search }; +} + +// ─── Hooks ────────────────────────────────────────────────────────────────── + +export type ReadonlyURLSearchParams = Omit; + +export function usePathname(): string { + return useEmplacement().pathname; +} + +export function useSearchParams(): ReadonlyURLSearchParams { + const { search } = useEmplacement(); + return useMemo(() => new URLSearchParams(search), [search]); +} + +export interface NavigateOptions { + scroll?: boolean; +} + +export interface AppRouterInstance { + push(href: string, options?: NavigateOptions): void; + replace(href: string, options?: NavigateOptions): void; + back(): void; + forward(): void; + refresh(): void; + prefetch(href: string): void; +} + +export function useRouter(): AppRouterInstance { + const navigate = useNavigate(); + return useMemo( + () => ({ + push: (href, options) => { + void navigate(href, { preventScrollReset: options?.scroll === false }); + }, + replace: (href, options) => { + void navigate(href, { replace: true, preventScrollReset: options?.scroll === false }); + }, + back: () => { + void navigate(-1); + }, + forward: () => { + void navigate(1); + }, + refresh: () => refreshPage(), + prefetch: () => {}, + }), + [navigate], + ); +} + +// ─── redirect / notFound ──────────────────────────────────────────────────── + +/** Erreur levée par `notFound()` : la frontière d'erreur affiche « Page introuvable ». */ +export class NotFoundError extends Error { + constructor() { + super("NEXT_NOT_FOUND"); + this.name = "NotFoundError"; + } +} + +export function isNotFoundError(e: unknown): e is NotFoundError { + return e instanceof NotFoundError; +} + +/** + * Redirection depuis un rendu : remplace l'entrée d'historique puis prévient le + * routeur (événement `popstate`), et interrompt le rendu en cours. + */ +export function redirect(url: string): never { + window.history.replaceState(null, "", url); + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + // Suspend le composant jusqu'au changement de page (jamais résolue). + throw new Promise(() => {}); +} + +export function permanentRedirect(url: string): never { + return redirect(url); +} + +export function notFound(): never { + throw new NotFoundError(); +} diff --git a/src/types/ff.ts b/src/types/ff.ts new file mode 100644 index 0000000..c65aaaa --- /dev/null +++ b/src/types/ff.ts @@ -0,0 +1,306 @@ +/** + * CollectFlow — Formes des données renvoyées par le serveur (pages et actions). + * + * Recopiées des anciens modules serveur Next.js (`lib/pg-ff-client.ts`, + * `lib/qlik-network-cache.ts`, `lib/api-ff-client.ts`, `lib/gammes-a-valider.ts`) + * lors du passage au serveur Rust : l'interface n'en garde que les types, le + * serveur renvoie exactement ces formes JSON. + */ + +// ─── Base FF (recopie PostgreSQL) ──────────────────────────────────────────── + +export interface DashboardSiteStats { + site: string; + ca_hier: number; + ca_n1: number; + tickets_hier: number; + tickets_n1: number; + qte_hier: number; + marge_hier: number; + lignes_hier: number; +} + +export interface DashboardTopItem { + codein: string; + libelle1: string; + ca: number; + qte: number; + marge: number; + stock: number; + fournisseur: string; +} + +export interface DashboardSiteTop10 { + site: string; + ca: DashboardTopItem[]; + qte: DashboardTopItem[]; + marge: DashboardTopItem[]; +} + +export interface DashboardData { + dateHier: string; + dateN1: string; + sites: DashboardSiteStats[]; + top10BySite: DashboardSiteTop10[]; +} + +export interface CaByFournisseurRow { + code: string; + nom: string; + site: string; + mois: string; + ca_ttc: number; +} + +export interface CaByNomenclatureRow { + code: string; + libelle: string; + site: string; + mois: string; + ca_ttc: number; +} + +export interface HitParadeRow { + codein: string; + libelle: string; + /** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */ + reference: string; + fournisseur: string; + nomenclature_code: string; + nomenclature: string; + site: string; + qte_vendue: number; + ca_ttc: number; + marge: number; + stock292: number; + stock579: number; + stockTotal: number; +} + +export interface PgStockNegatifRow { + codein: string; + libelle1: string; + fournisseur: string; + site: string; + stockdispo: number; + dernierevente: string | null; + derniereentree: string | null; +} + +export interface PgStockSansVenteRow { + codein: string; + libelle1: string; + fournisseur: string; + site: string; + stock_actuel: number; + derniere_entree: string | null; +} + +export interface PgSansVente6MoisRow { + codein: string; + libelle1: string; + fournisseur: string; + site: string; + stock_actuel: number; + derniere_vente: string | null; + derniere_entree: string | null; + jours_sans_vente: number | null; +} + +export interface PgCommandeAutoRow { + site: string; + codefou: string; + nomfou: string; + franco: number; + nb_articles: number; + montant_cde: number; + franco_atteint: boolean | null; + ecart_franco: number; +} + +/** + * Une ligne de résultat de recherche produit. Volontairement **légère** : + * aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût). + * Le détail lourd n'est calculé qu'à l'ouverture de la fiche. + */ +export interface PgProduitSearchRow { + no_id: number; + codein: string; + code_centrale: string; + libelle1: string; + /** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */ + fournisseur: string; + codefou: string; + nomenclature_code: string; + nomenclature: string; + stock_total: number; + /** true si le produit a déjà des métriques réseau Qlik en cache. */ + has_reseau: boolean; + qte_reseau: number; + nb_magasins_reseau: number; +} + +/** Identité, prix et nomenclature d'un produit (une seule ligne). */ +export interface PgProduitDetailRow { + no_id: number; + codein: string; + code_centrale: string; + libelle1: string; + nom_no_id: number | null; + gtin: string; + reference: string; + pcb: number | null; + /** article_infosup.prix_vente_mini */ + pv_central: number | null; + /** cube_pa.pa */ + pa: number | null; + code3: string | null; + libelle3: string | null; + code2: string | null; + libelle2: string | null; + code1: string | null; + libelle1nom: string | null; +} + +/** Un fournisseur référencé pour l'article. */ +export interface PgProduitFournisseurRow { + codefou: string; + nomfou: string; + reference: string; + pcb: number | null; + /** true si artfou1.preference = 1 */ + principal: boolean; +} + +/** Stock temps réel par site pour un article. */ +export interface PgProduitStockRow { + site: string; + stockdispo: number; + qte: number; + valstock: number; + prmp: number; + dernierevente: string | null; + dernierereception: string | null; +} + +/** Une gamme affectée à l'article pour une saison donnée. */ +export interface PgGammeHistoryRow { + saison_no_id: number; + saison_code: string | null; + saison_libelle: string | null; + gamme_code: string; + gamme_libelle: string | null; +} + +/** Un produit de la même famille, comparé local vs réseau. */ +export interface PgOpportuniteRow { + codein: string; + libelle1: string; + code_centrale: string; + fournisseur: string; + qte_reseau: number; + nb_magasins_reseau: number; + ca_reseau: number; + ca_par_magasin_reseau: number; + qte_locale: number; + nb_sites_locaux: number; + qte_reseau_par_magasin: number; + qte_locale_par_magasin: number; + /** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */ + ecart_par_magasin: number; +} + +// ─── Réseau Qlik ───────────────────────────────────────────────────────────── + +/** Les mesures réseau Qlik pour un produit sur un mois donné. */ +export interface QlikMonthMetrics { + /** Quantité vendue réseau — toujours présente. */ + qte: number; + /** CA réseau du mois. */ + ca?: number; + /** Nombre de magasins ayant vendu le produit ce mois-là. */ + nbMag?: number; + /** CA moyen par magasin sur le mois (mesure Qlik brute). */ + caMag?: number; + /** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */ + margePct?: number; +} + +export interface NetworkMetricCached { + caReseau: number; + qteReseau: number; + nbMagasinsReseau: number; + caParMagasinReseau: number; + margePctReseau: number; + /** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */ + qteByMonth: Record | null; + /** + * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. + * null pour les lignes synchronisées avant l'ajout de la colonne — l'UI + * retombe alors sur `qteByMonth`. + */ + metricsByMonth: Record | null; + /** Période couverte par l'extraction (ex "2025-03_2026-02"). */ + periode: string | null; + /** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */ + libelleReseau: string | null; + /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ + fournisseurReseau: string | null; + fetchedAt: string | null; +} + +// ─── API FF Nancy ──────────────────────────────────────────────────────────── + +export interface FfMensuelVentes { + nb_passages: string; + qte_vendue: string; // négatif (ex: "-2.000") + ca_ht: string; // négatif (ex: "-26.6600") + ca_ttc: string; + marge: string; // positif (ex: "12.7600") + taux_marge: string; +} + +export interface FfMensuelReceptions { + nb_receptions: string; + qte_recue: string; +} + +export interface FfMensuelEntry { + mois: string; // "YYYY-MM" + site: string; // code site ("292", "579") + stock_fin_mois: string; // stock fin de mois + prmp_fin_mois: string; // PRMP fin de mois + ventes: FfMensuelVentes | null; + receptions: FfMensuelReceptions | null; +} + +export interface FfSyncTable { + nom: string; + derniereSync: string; + nbLignes?: number; + /** "ok" ou état d'erreur renvoyé par la synchronisation. */ + statut?: string; + erreur?: string | null; +} + +export interface FfSyncStatus { + lastSync: string; + tables: FfSyncTable[]; +} + +// ─── Gammes proposées par l'API, à valider ─────────────────────────────────── + +export interface PropositionGamme { + codein: string; + /** Gamme proposée. */ + gamme: string; + /** Gamme enregistrée au moment de la proposition. */ + gammePrecedente: string | null; +} + +export interface GammeAValider extends PropositionGamme { + auteur: string | null; + /** Date ISO de la proposition. */ + proposeeLe: string; +} + diff --git a/src/types/httpntlm.d.ts b/src/types/httpntlm.d.ts deleted file mode 100644 index 0191dc0..0000000 --- a/src/types/httpntlm.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare module "httpntlm" { - interface NtlmOptions { - url: string; - username: string; - password: string; - domain?: string; - workstation?: string; - headers?: Record; - rejectUnauthorized?: boolean; - [key: string]: unknown; - } - interface NtlmResponse { - statusCode: number; - headers: Record; - body: string; - } - type Cb = (err: Error | null, res: NtlmResponse) => void; - const httpntlm: { - get(opts: NtlmOptions, cb: Cb): void; - post(opts: NtlmOptions, cb: Cb): void; - }; - export default httpntlm; -} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..8ff1d11 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, + "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, @@ -12,23 +12,14 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], + "types": ["vite/client"], "paths": { + "next/link": ["./src/shims/next-link.tsx"], + "next/navigation": ["./src/shims/next-navigation.ts"], + "next-auth/react": ["./src/shims/next-auth-react.ts"], "@/*": ["./src/*"] } }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules"] + "include": ["src", "vite.config.mts"], + "exclude": ["node_modules", "dist", "server"] } diff --git a/vite.config.mts b/vite.config.mts new file mode 100644 index 0000000..b08e4c6 --- /dev/null +++ b/vite.config.mts @@ -0,0 +1,38 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const src = (chemin: string) => fileURLToPath(new URL(`./src/${chemin}`, import.meta.url)); + +/** + * Interface CollectFlow : SPA React compilée dans `dist/`, servie par le + * serveur Rust (`STATIC_DIR`). Tailwind 4 passe par PostCSS (postcss.config.mjs). + * + * Les modules `next/link`, `next/navigation` et `next-auth/react` sont + * remplacés par de petites adaptations à React Router et à l'authentification + * du serveur Rust (src/shims) : les composants restent inchangés. + */ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: [ + { find: /^next\/link$/, replacement: src("shims/next-link.tsx") }, + { find: /^next\/navigation$/, replacement: src("shims/next-navigation.ts") }, + { find: /^next-auth\/react$/, replacement: src("shims/next-auth-react.ts") }, + { find: /^@\//, replacement: src("") }, + ], + }, + server: { + port: 3000, + proxy: { + "/api": { target: "http://localhost:5643", changeOrigin: false }, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: false, + // exceljs et jspdf (chargés à la demande) dépassent le seuil par défaut. + chunkSizeWarningLimit: 1000, + }, +}); From 12948248635da005835554f7c25bacf24d55cf37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:30:42 +0000 Subject: [PATCH 10/19] =?UTF-8?q?Param=C3=A8tres=20:=20test=20de=20connexi?= =?UTF-8?q?on=20Qlik=20branch=C3=A9=20sur=20le=20module=20qlik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/admin/qlik_bridge.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/src/admin/qlik_bridge.rs b/server/src/admin/qlik_bridge.rs index af373f1..a6a8ef4 100644 --- a/server/src/admin/qlik_bridge.rs +++ b/server/src/admin/qlik_bridge.rs @@ -4,8 +4,16 @@ use serde_json::Value; /// Ouvre une session Qlik (NTLM → ticket → cookie) avec les identifiants donnés. -pub async fn tester_session(_host: String, _user: String, _password: Option) -> anyhow::Result<()> { - anyhow::bail!("Test Qlik indisponible : module en cours de portage") +pub async fn tester_session(host: String, user: String, password: Option) -> anyhow::Result<()> { + let enregistre = crate::qlik::client::get_qlik_config(); + let mut cfg = enregistre.clone(); + cfg.user = user; + cfg.password = password.unwrap_or(enregistre.password); + if !host.is_empty() { + cfg.host = host; + } + crate::qlik::client::qlik_ntlm_session(&cfg).await?; + Ok(()) } /// Oublie l'URL mémorisée de l'API FF Nancy. From 56bbfaebe080182065c859e491c34f367d455cbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:33:00 +0000 Subject: [PATCH 11/19] =?UTF-8?q?Nettoyage=20:=20scripts=20Node=20et=20res?= =?UTF-8?q?sources=20Next.js=20obsol=C3=A8tes=20supprim=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- .viz-bundle.mjs | 229 --------------------- check_counts.js | 31 --- eslint.config.mjs | 4 +- package.json | 6 +- public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - scripts/check-api-articles.js | 15 -- scripts/check-dashboard-api.js | 7 - scripts/db-init.js | 239 --------------------- scripts/diagnose-db.js | 48 ----- scripts/diagnose-db.ts | 27 --- scripts/diagnostic-ff.js | 232 --------------------- scripts/qlik-discover.mjs | 190 ----------------- scripts/qlik-validate.mjs | 354 -------------------------------- scripts/repro-500.js | 39 ---- scripts/test-db-cmd.js | 25 --- scripts/test-qlik-date-range.ts | 194 ----------------- verify-logic.js | 32 --- verify-parser.js | 59 ------ verify_limit.js | 37 ---- 23 files changed, 5 insertions(+), 1768 deletions(-) delete mode 100644 .viz-bundle.mjs delete mode 100644 check_counts.js delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg delete mode 100644 scripts/check-api-articles.js delete mode 100644 scripts/check-dashboard-api.js delete mode 100644 scripts/db-init.js delete mode 100644 scripts/diagnose-db.js delete mode 100644 scripts/diagnose-db.ts delete mode 100644 scripts/diagnostic-ff.js delete mode 100644 scripts/qlik-discover.mjs delete mode 100644 scripts/qlik-validate.mjs delete mode 100644 scripts/repro-500.js delete mode 100644 scripts/test-db-cmd.js delete mode 100644 scripts/test-qlik-date-range.ts delete mode 100644 verify-logic.js delete mode 100644 verify-parser.js delete mode 100644 verify_limit.js diff --git a/.viz-bundle.mjs b/.viz-bundle.mjs deleted file mode 100644 index be35014..0000000 --- a/.viz-bundle.mjs +++ /dev/null @@ -1,229 +0,0 @@ -// -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -// src/features/grid/components/network-charts.tsx -import { useState } from "react"; - -// src/features/grid/lib/months.ts -function fmtMonthShort(lab) { - const [y, m] = lab.split("-"); - return `${m ?? lab}/${(y ?? "").slice(2)}`; -} - -// src/features/grid/components/network-charts.tsx -import { jsx, jsxs } from "react/jsx-runtime"; -var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"]; -function NetworkLineChart({ - labels: labels2, - values: values2, - stores: stores2, - perStore: perStore2 -}) { - const [survol, setSurvol] = useState(null); - const n = values2.length; - const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0; - const avecParMag = memeTaille(perStore2); - const avecMagasins = memeTaille(stores2); - const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); - const facettes = []; - if (avecParMag) { - facettes.push({ - titre: "Qt\xE9 / magasin", - valeurs: perStore2, - couleur: SERIE_COULEURS[0], - hauteur: 92, - aire: true, - decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0 - }); - facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 }); - } else { - facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true }); - } - if (avecMagasins) { - facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, couleur: SERIE_COULEURS[2], hauteur: 46 }); - } - const W = 520; - const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12; - const plotW = W - padL - padR; - const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH; - const x = (i) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2; - const ancrage = (i) => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; - let curseur = 0; - const disposees = facettes.map((f) => { - const hautEntete = curseur; - const haut = curseur + enteteH; - curseur = haut + f.hauteur + ecart; - const min = Math.min(0, ...f.valeurs); - const max = Math.max(...f.valeurs, min + 1); - const etendue = max - min || 1; - const y = (v) => haut + f.hauteur - (v - min) / etendue * f.hauteur; - const iMax = f.valeurs.indexOf(Math.max(...f.valeurs)); - return { ...f, hautEntete, haut, base: y(min), max, y, iMax }; - }); - const basPlots = curseur - ecart; - return /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [ - /* @__PURE__ */ jsxs( - "svg", - { - viewBox: `0 0 ${W} ${H}`, - className: "w-full", - style: { minWidth: 420 }, - role: "img", - "aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "), - onMouseLeave: () => setSurvol(null), - children: [ - disposees.map((f) => { - const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" "); - const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])]; - return /* @__PURE__ */ jsxs("g", { children: [ - /* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }), - /* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }), - /* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [ - "max ", - fmt(f.max, f.decimales ?? 0), - f.unite ?? "" - ] }), - /* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }), - f.aire && n > 1 && /* @__PURE__ */ jsx( - "polygon", - { - points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`, - fill: f.couleur, - opacity: 0.1 - } - ), - n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }), - aEtiqueter.map((i) => /* @__PURE__ */ jsx( - "circle", - { - cx: x(i), - cy: f.y(f.valeurs[i]), - r: 4, - fill: f.couleur, - stroke: "var(--bg-surface)", - strokeWidth: 2 - }, - `pt-${f.titre}-${i}` - )), - aEtiqueter.map((i) => { - const yPoint = f.y(f.valeurs[i]); - const dessus = yPoint - 8 >= f.haut + 8; - return /* @__PURE__ */ jsx( - "text", - { - x: x(i), - y: dessus ? yPoint - 8 : yPoint + 13, - textAnchor: ancrage(i), - fontSize: 9, - fontWeight: 700, - fill: "var(--text-primary)", - children: fmt(f.valeurs[i], f.decimales ?? 0) - }, - `lab-${f.titre}-${i}` - ); - }), - survol != null && /* @__PURE__ */ jsx( - "circle", - { - cx: x(survol), - cy: f.y(f.valeurs[survol]), - r: 3.5, - fill: f.couleur, - stroke: "var(--bg-surface)", - strokeWidth: 2 - } - ) - ] }, f.titre); - }), - survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }), - labels2.map((lab, i) => /* @__PURE__ */ jsx( - "text", - { - x: x(i), - y: H - 5, - textAnchor: ancrage(i), - fontSize: 8.5, - fill: survol === i ? "var(--text-primary)" : "var(--text-muted)", - fontWeight: survol === i ? 700 : 400, - children: fmtMonthShort(lab) - }, - `mois-${lab}` - )), - survol != null && (() => { - const largeur = 158; - const hauteur = 16 + disposees.length * 13 + 8; - const gauche = x(survol) + 10 + largeur > W - 4; - const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10; - return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [ - /* @__PURE__ */ jsx( - "rect", - { - x: bx, - y: 4, - width: largeur, - height: hauteur, - rx: 8, - fill: "var(--bg-elevated)", - stroke: "var(--border)", - strokeWidth: 1 - } - ), - /* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }), - disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [ - /* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }), - /* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }), - /* @__PURE__ */ jsx( - "text", - { - x: bx + largeur - 9, - y: 30 + k * 13, - textAnchor: "end", - fontSize: 9, - fontWeight: 700, - fill: "var(--text-primary)", - children: fmt(f.valeurs[survol], f.decimales ?? 0) - } - ) - ] }, `bulle-${f.titre}`)) - ] }); - })(), - labels2.map((lab, i) => /* @__PURE__ */ jsx( - "rect", - { - x: x(i) - plotW / (2 * Math.max(1, n - 1)), - y: 0, - width: plotW / Math.max(1, n - 1), - height: H, - fill: "transparent", - onMouseEnter: () => setSurvol(i) - }, - `survol-${lab}` - )) - ] - } - ), - /* @__PURE__ */ jsxs("details", { className: "mt-2", children: [ - /* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }), - /* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [ - /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [ - /* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }), - disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`)) - ] }) }), - /* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [ - /* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }), - disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`)) - ] }, `tr-${lab}`)) }) - ] }) }) - ] }) - ] }); -} - -// -var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"]; -var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470]; -var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170]; -var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0); -globalThis.__HTML__ = renderToStaticMarkup( - React.createElement(NetworkLineChart, { labels, values, stores, perStore }) -); diff --git a/check_counts.js b/check_counts.js deleted file mode 100644 index a8daf4e..0000000 --- a/check_counts.js +++ /dev/null @@ -1,31 +0,0 @@ - -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); -let url = ""; -if (fs.existsSync(CONFIG_PATH)) { - url = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")).url; -} - -const pool = new Pool({ connectionString: url || process.env.DATABASE_URL }); - -async function check() { - try { - const resSuppliers = await pool.query(\`SELECT DISTINCT code_fournisseur FROM ventes_produits LIMIT 5\`); - console.log("Suppliers in DB:", resSuppliers.rows.map(r => r.code_fournisseur)); - - for (const row of resSuppliers.rows) { - const supplier = row.code_fournisseur; - const countRes = await pool.query(\`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]); - const distinctCodein = await pool.query(\`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]); - console.log(\`Supplier \${supplier}: Total Rows = \${countRes.rows[0].count}, Distinct Products = \${distinctCodein.rows[0].count}\`); - } - } catch (e) { - console.error("Error:", e); - } finally { - await pool.end(); - } -} -check(); diff --git a/eslint.config.mjs b/eslint.config.mjs index 8108e2e..294df75 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,10 +7,10 @@ import reactRefresh from "eslint-plugin-react-refresh"; /** * Lint de l'interface (SPA Vite). Seul `src/` est vérifié : `server/` est en - * Rust, `scripts/` et les fichiers .js de la racine sont des utilitaires Node. + * Rust. */ const eslintConfig = defineConfig([ - globalIgnores(["dist/**", "server/**", "scripts/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]), + globalIgnores(["dist/**", "server/**", ".claude/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]), { files: ["src/**/*.{ts,tsx}", "vite.config.mts"], extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended], diff --git a/package.json b/package.json index 9571b2a..516d039 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "preview": "vite preview", "lint": "eslint", "typecheck": "tsc --noEmit", - "docker:up": "docker-compose up -d", - "docker:down": "docker-compose down", - "docker:build": "docker-compose up -d --build" + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:build": "docker compose build" }, "dependencies": { "@tanstack/react-table": "^8.21.3", diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/check-api-articles.js b/scripts/check-api-articles.js deleted file mode 100644 index adf7de4..0000000 --- a/scripts/check-api-articles.js +++ /dev/null @@ -1,15 +0,0 @@ -const fs = require('fs'); -async function test() { - try { - const res = await fetch("https://api.ffnancy.fr/api/articles?search=334152"); - const data = await res.json(); - console.log("ARTICLES SEARCH:", JSON.stringify(data, null, 2)); - - const res2 = await fetch("https://api.ffnancy.fr/api/articles/334152/referentiel"); - const data2 = await res2.json(); - console.log("ARTICLES REFERENTIEL BY CODEIN:", JSON.stringify(data2, null, 2)); - } catch(e) { - console.log(e.message); - } -} -test(); diff --git a/scripts/check-dashboard-api.js b/scripts/check-dashboard-api.js deleted file mode 100644 index a3614ac..0000000 --- a/scripts/check-dashboard-api.js +++ /dev/null @@ -1,7 +0,0 @@ -const fs = require('fs'); -async function test() { - const res = await fetch("https://api.ffnancy.fr/api/performance/dashboard?date=2024-04-20"); - const data = await res.json(); - console.log(JSON.stringify(data.top10_ca?.[0] || {}, null, 2)); -} -test(); diff --git a/scripts/db-init.js b/scripts/db-init.js deleted file mode 100644 index c4290e2..0000000 --- a/scripts/db-init.js +++ /dev/null @@ -1,239 +0,0 @@ -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -const cwd = path.resolve(__dirname, '..'); // get app root when inside /scripts - -async function main() { - console.log("[DB Init] Starting database migration & initialization..."); - let connectionString = ""; - - try { - const CONFIG_PATH = path.join(cwd, "data", ".db-config.json"); - if (fs.existsSync(CONFIG_PATH)) { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); - if (config.url) { - connectionString = config.url; - console.log("[DB Init] Found connection string in data/.db-config.json"); - } - } - } catch (e) { - console.error("[DB Init] Error reading .db-config.json", e); - } - - // Use environment variable as default IF NOT already loaded from config - if (!connectionString && process.env.DATABASE_URL) { - connectionString = process.env.DATABASE_URL; - console.log("[DB Init] Using environment variable DATABASE_URL as fallback."); - } - - if (!connectionString) { - console.warn("[DB Init] No DATABASE_URL found in env or config. Skipping push."); - process.exit(0); - } - - try { - const tempPool = new Pool({ connectionString, max: 1 }); - - console.log("[DB Init] Connection established. Running migrations via raw SQL..."); - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "ai_supplier_context" ( - "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, - "context" text NOT NULL, - "updated_at" timestamp DEFAULT now() - ); - `); - console.log("[DB Init] Table ai_supplier_context is verified/created."); - - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "session_snapshots" ( - "id" serial PRIMARY KEY NOT NULL, - "user_id" integer, - "code_fournisseur" varchar(20) NOT NULL, - "nom_fournisseur" varchar(255), - "magasin" varchar(20) NOT NULL, - "changes" jsonb NOT NULL, - "summary_json" jsonb, - "label" text, - "type" varchar(20) DEFAULT 'snapshot', - "created_at" timestamp DEFAULT now() - ); - `); - // Index : la Grille relit le dernier snapshot du fournisseur à chaque calcul - // et à chaque enregistrement, et l'historique liste ceux de l'utilisateur. - // Sans eux, chaque lecture parcourait toute la table (qui ne fait que grossir). - await tempPool.query(` - CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date" - ON "session_snapshots" ("code_fournisseur", "created_at" DESC); - CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date" - ON "session_snapshots" ("user_id", "type", "created_at" DESC); - `); - console.log("[DB Init] Table session_snapshots is verified/created."); - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "users" ( - "id" serial PRIMARY KEY NOT NULL, - "username" varchar(50) NOT NULL UNIQUE, - "password_hash" text NOT NULL, - "role" varchar(20) NOT NULL DEFAULT 'user', - "created_at" timestamp DEFAULT now() - ); - `); - console.log("[DB Init] Table users is verified/created."); - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "commande_cadences" ( - "id" serial PRIMARY KEY NOT NULL, - "code_fournisseur" varchar(20) NOT NULL, - "nom_fournisseur" varchar(255), - "site" varchar(20) NOT NULL, - "intervalle_semaines" integer NOT NULL, - "actif" boolean NOT NULL DEFAULT true, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() - ); - `); - await tempPool.query(` - CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site" - ON "commande_cadences" ("code_fournisseur", "site"); - `); - await tempPool.query(` - CREATE INDEX IF NOT EXISTS "idx_cadence_site" - ON "commande_cadences" ("site"); - `); - console.log("[DB Init] Table commande_cadences is verified/created."); - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "qlik_network_metrics" ( - "code_centrale" varchar(20) PRIMARY KEY NOT NULL, - "ca_reseau" numeric(16, 2), - "qte_reseau" numeric(14, 2), - "nb_magasins_reseau" integer, - "ca_par_magasin_reseau" numeric(16, 2), - "marge_pct_reseau" numeric(8, 4), - "periode" varchar(20), - "fetched_at" timestamp DEFAULT now() - ); - `); - // Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas. - await tempPool.query(` - ALTER TABLE "qlik_network_metrics" - ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2), - ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4), - ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb, - ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb, - ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255), - ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255); - ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau"; - ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau"; - `); - console.log("[DB Init] Table qlik_network_metrics is verified/created."); - - // Instantané persisté de la grille : lu par /api/v1 pour ne jamais - // déclencher de recalcul. Rempli en effet de bord par getProductRows(). - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "grid_rows" ( - "code_fournisseur" varchar(20) NOT NULL, - "codein" varchar(20) NOT NULL, - "nom_fournisseur" varchar(255), - "libelle1" varchar(500), - "gtin" varchar(30), - "reference" varchar(100), - "code_centrale" varchar(20), - "code1" varchar(20), - "code2" varchar(20), - "code3" varchar(20), - "code_gamme" varchar(20), - "code_gamme_init" varchar(20), - "total_ca" numeric(16, 2), - "total_quantite" numeric(14, 2), - "total_marge" numeric(16, 2), - "taux_marge" numeric(8, 4), - "stock_actuel" numeric(14, 2), - "ca_reseau" numeric(16, 2), - "qte_reseau" numeric(14, 2), - "nb_magasins_reseau" integer, - "ca_par_magasin_reseau" numeric(16, 2), - "marge_pct_reseau" numeric(8, 4), - "payload" jsonb NOT NULL, - "computed_at" timestamp DEFAULT now() NOT NULL - ); - CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein" - ON "grid_rows" ("code_fournisseur", "codein"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1"); - `); - console.log("[DB Init] Table grid_rows is verified/created."); - - // Clés d'API : seul le hachage SHA-256 est stocké. - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "api_keys" ( - "id" serial PRIMARY KEY NOT NULL, - "name" varchar(100) NOT NULL, - "key_prefix" varchar(20) NOT NULL, - "key_hash" text NOT NULL UNIQUE, - "role" varchar(20) DEFAULT 'user' NOT NULL, - "created_by" varchar(50), - "created_at" timestamp DEFAULT now(), - "last_used_at" timestamp, - "revoked_at" timestamp - ); - `); - console.log("[DB Init] Table api_keys is verified/created."); - - // Paramétrage et suivi de la synchronisation nocturne, par fournisseur. - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "sync_fournisseurs" ( - "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, - "nom_fournisseur" varchar(255), - "actif_sql" boolean NOT NULL DEFAULT true, - "actif_qlik" boolean NOT NULL DEFAULT true, - "desactive_motif" text, - "dernier_sql_at" timestamp, - "dernier_sql_statut" varchar(20), - "dernier_sql_lignes" integer, - "dernier_sql_erreur" text, - "dernier_sql_ms" integer, - "dernier_qlik_at" timestamp, - "dernier_qlik_statut" varchar(20), - "dernier_qlik_codes" integer, - "dernier_qlik_erreur" text, - "dernier_qlik_ms" integer, - "updated_at" timestamp DEFAULT now() - ); - CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at"); - CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at"); - `); - console.log("[DB Init] Table sync_fournisseurs is verified/created."); - - // Gammes proposées par l'API /api/v1 : en attente de validation dans la Grille. - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "gammes_a_valider" ( - "code_fournisseur" varchar(20) NOT NULL, - "codein" varchar(20) NOT NULL, - "gamme" varchar(20) NOT NULL, - "gamme_precedente" varchar(20), - "auteur" varchar(100), - "created_at" timestamp DEFAULT now() NOT NULL, - PRIMARY KEY ("code_fournisseur", "codein") - ); - `); - console.log("[DB Init] Table gammes_a_valider is verified/created."); - - await tempPool.end(); - console.log("[DB Init] Initialization successful. Exiting."); - process.exit(0); - } catch (error) { - console.error("[DB Init] Initialization failed:", error); - // Do not crash the app, just let it run (it uses fallback JSON anyway) - process.exit(0); - } -} - -main(); diff --git a/scripts/diagnose-db.js b/scripts/diagnose-db.js deleted file mode 100644 index ad30ec5..0000000 --- a/scripts/diagnose-db.js +++ /dev/null @@ -1,48 +0,0 @@ - -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -async function main() { - console.log("--- DB DIAGNOSIS Start (Node) ---"); - - let connectionString = process.env.DATABASE_URL; - - try { - const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); - if (fs.existsSync(CONFIG_PATH)) { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); - if (config.url) { - connectionString = config.url; - } - } - } catch (err) {} - - if (!connectionString) { - console.error("No connection string found."); - return; - } - - const pool = new Pool({ connectionString }); - - try { - const res = await pool.query(` - SELECT - code_fournisseur, - nom_fournisseur, - COUNT(*) as total_rows, - COUNT(DISTINCT codein) as distinct_products - FROM ventes_produits - GROUP BY code_fournisseur, nom_fournisseur - ORDER BY total_rows DESC - `); - - console.table(res.rows); - } catch (error) { - console.error("Diagnosis failed:", error); - } finally { - await pool.end(); - } -} - -main(); diff --git a/scripts/diagnose-db.ts b/scripts/diagnose-db.ts deleted file mode 100644 index 197e27b..0000000 --- a/scripts/diagnose-db.ts +++ /dev/null @@ -1,27 +0,0 @@ - -import { db } from "@/db"; -import { ventesProduits } from "@/db/schema"; -import { sql } from "drizzle-orm"; - -async function main() { - console.log("--- DB DIAGNOSIS Start ---"); - try { - const results = await db.execute(sql` - SELECT - code_fournisseur, - nom_fournisseur, - COUNT(*) as total_rows, - COUNT(DISTINCT codein) as distinct_products - FROM ventes_produits - GROUP BY code_fournisseur, nom_fournisseur - ORDER BY total_rows DESC - `); - - console.table(results.rows); - } catch (error) { - console.error("Diagnosis failed:", error); - } - console.log("--- DB DIAGNOSIS End ---"); -} - -main(); diff --git a/scripts/diagnostic-ff.js b/scripts/diagnostic-ff.js deleted file mode 100644 index 9a672c7..0000000 --- a/scripts/diagnostic-ff.js +++ /dev/null @@ -1,232 +0,0 @@ -/** - * CollectFlow — Diagnostic des requêtes sur la base FF (lecture seule). - * - * Sert à préparer l'optimisation des requêtes lentes (Meilleures ventes, fiche - * produit, stocks, commandes) : le schéma et les index des tables FF ne sont pas - * dans le dépôt, il faut donc les relever sur la vraie base avant de réécrire - * quoi que ce soit. - * - * Usage (sur le serveur, ou dans le conteneur Docker de l'application) : - * - * node scripts/diagnostic-ff.js > diagnostic-ff.txt - * node scripts/diagnostic-ff.js --analyze > diagnostic-ff.txt - * - * Sans option, rien n'est exécuté : seuls les plans prévus (EXPLAIN) sont lus. - * `--analyze` exécute réellement les requêtes analysées pour mesurer leur durée - * (EXPLAIN ANALYZE) : à lancer de préférence en dehors des heures d'ouverture. - * La session est en lecture seule et chaque requête est coupée au-delà de 2 min. - * - * Connexion : `data/.db-config.json` (réglée dans Paramètres), sinon DATABASE_URL. - */ -const { Pool } = require("pg"); -const fs = require("fs"); -const path = require("path"); - -const ANALYZE = process.argv.includes("--analyze"); -const TABLES = ["articles", "mvtart", "cube_stock", "artfou1", "fouident", "nomenclature", "grid_rows", "session_snapshots"]; -const COLONNES = { - articles: ["no_id", "codein", "artcentrale", "nom_no_id", "libelle1"], - mvtart: ["no_id", "artnoid", "site", "datmvt", "genremvt"], - cube_stock: ["artnoid", "site", "qte"], - artfou1: ["no_id", "art_no_id", "code", "preference"], - fouident: ["code"], -}; - -function connexion() { - try { - const fichier = path.join(path.resolve(__dirname, ".."), "data", ".db-config.json"); - if (fs.existsSync(fichier)) { - const config = JSON.parse(fs.readFileSync(fichier, "utf-8")); - if (config.url) return config.url; - } - } catch { - /* configuration illisible : on tente la variable d'environnement */ - } - return process.env.DATABASE_URL || ""; -} - -function titre(texte) { - console.log(`\n${"=".repeat(78)}\n${texte}\n${"=".repeat(78)}`); -} - -async function section(nom, fn) { - titre(nom); - const debut = Date.now(); - try { - await fn(); - } catch (e) { - console.log(` (indisponible : ${e.message})`); - } - console.log(` [${Date.now() - debut} ms]`); -} - -async function plan(client, nom, requete, params = []) { - await section(`PLAN — ${nom}`, async () => { - const options = ANALYZE ? "ANALYZE, BUFFERS, FORMAT TEXT" : "FORMAT TEXT"; - const { rows } = await client.query(`EXPLAIN (${options}) ${requete}`, params); - for (const r of rows) console.log(` ${r["QUERY PLAN"]}`); - }); -} - -// Requêtes reprises de src/lib/pg-ff-client.ts (même forme, paramètres d'exemple). -const HIT_PARADE = ` -WITH ventes AS ( - SELECT TRIM(a.codein::text) AS codein, m.site, - SUM(-m.qtemvt)::float AS qte_vendue, SUM(-m.mntmvtttc)::float AS ca_ttc, SUM(m.margemvt)::float AS marge - FROM mvtart m JOIN articles a ON a.no_id = m.artnoid - WHERE m.datmvt BETWEEN $1::date AND $2::date AND m.site IN ('292', '579') AND m.genremvt = 3 AND a.codein IS NOT NULL - GROUP BY TRIM(a.codein::text), m.site -), -attrs AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur - FROM articles a - LEFT JOIN LATERAL (SELECT af1.code FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code LIMIT 1) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes) - ORDER BY TRIM(a.codein::text), a.no_id DESC -), -stock_agg AS ( - SELECT TRIM(a2.codein::text) AS codein, SUM(cs.qte)::float AS stock_total - FROM cube_stock cs JOIN articles a2 ON a2.no_id = cs.artnoid - WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes) - GROUP BY TRIM(a2.codein::text) -) -SELECT v.*, ar.libelle, ar.fournisseur, s.stock_total -FROM ventes v LEFT JOIN attrs ar ON ar.codein = v.codein LEFT JOIN stock_agg s ON s.codein = v.codein -ORDER BY v.ca_ttc DESC`; - -const DERNIERE_RECEPTION = ` -SELECT af.code AS codefou, m.site, MAX(m.datmvt) AS derniere -FROM mvtart m -JOIN articles a ON a.no_id = m.artnoid -JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 -WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt IS NOT NULL -GROUP BY af.code, m.site`; - -const MENSUEL_FOURNISSEUR = ` -SELECT a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(m.qtemvt) -FROM mvtart m -JOIN articles a ON a.no_id = m.artnoid -JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af ON af.art_no_id = a.no_id -WHERE m.datmvt BETWEEN $2::date AND $3::date AND m.site IN ('292', '579') -GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')`; - -const STOCK_SANS_VENTE = ` -SELECT a.codein, m.site, MAX(m.datmvt) -FROM mvtart m -JOIN articles a ON a.no_id = m.artnoid -LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site -WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt < date_trunc('month', CURRENT_DATE) - AND NOT EXISTS (SELECT 1 FROM mvtart mv2 WHERE mv2.artnoid = m.artnoid AND mv2.site = m.site AND mv2.genremvt = 3) -GROUP BY a.codein, m.site, cs.qte -HAVING COALESCE(cs.qte::float, 0) <> 0`; - -async function main() { - const url = connexion(); - if (!url) { - console.error("Aucune connexion : ni data/.db-config.json ni DATABASE_URL."); - process.exit(1); - } - const pool = new Pool({ connectionString: url, max: 1, application_name: "collectflow-diagnostic" }); - const client = await pool.connect(); - try { - await client.query("SET default_transaction_read_only = on"); - await client.query("SET statement_timeout = '120s'"); - await client.query("SET max_parallel_workers_per_gather = 0"); // comme l'application - - console.log(`Diagnostic FF — ${new Date().toISOString()} — mode ${ANALYZE ? "EXPLAIN ANALYZE (requêtes exécutées)" : "EXPLAIN (plans seuls)"}`); - - await section("Serveur PostgreSQL et réglages", async () => { - const { rows } = await client.query(` - SELECT name, setting, unit FROM pg_settings - WHERE name IN ('server_version', 'work_mem', 'shared_buffers', 'effective_cache_size', - 'random_page_cost', 'max_parallel_workers_per_gather', 'default_statistics_target') - ORDER BY name`); - for (const r of rows) console.log(` ${r.name.padEnd(32)} ${r.setting}${r.unit ? ` ${r.unit}` : ""}`); - }); - - await section("Taille des tables (lignes estimées, taille totale avec index)", async () => { - const { rows } = await client.query(` - SELECT c.relname, c.reltuples::bigint AS lignes, pg_size_pretty(pg_total_relation_size(c.oid)) AS taille, - s.last_analyze, s.last_autoanalyze - FROM pg_class c LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid - WHERE c.relkind IN ('r', 'p', 'm', 'v') AND c.relname = ANY($1) - ORDER BY c.relname`, [TABLES]); - const trouvees = new Set(rows.map((r) => r.relname)); - for (const r of rows) { - const analyse = r.last_analyze || r.last_autoanalyze; - console.log(` ${r.relname.padEnd(20)} ${String(r.lignes).padStart(12)} lignes ${String(r.taille).padStart(10)} dernière analyse : ${analyse ? new Date(analyse).toISOString() : "jamais"}`); - } - for (const t of TABLES) if (!trouvees.has(t)) console.log(` ${t.padEnd(20)} (absente)`); - }); - - await section("Types des colonnes utilisées dans les jointures et filtres", async () => { - for (const [table, colonnes] of Object.entries(COLONNES)) { - const { rows } = await client.query(` - SELECT column_name, data_type, character_maximum_length, table_schema - FROM information_schema.columns WHERE table_name = $1 AND column_name = ANY($2) - ORDER BY column_name`, [table, colonnes]); - for (const r of rows) { - console.log(` ${`${r.table_schema}.${table}.${r.column_name}`.padEnd(36)} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ""}`); - } - } - }); - - await section("Index existants", async () => { - const { rows } = await client.query(` - SELECT tablename, indexname, indexdef FROM pg_indexes - WHERE tablename = ANY($1) ORDER BY tablename, indexname`, [TABLES]); - for (const r of rows) console.log(` ${r.tablename.padEnd(18)} ${r.indexdef}`); - if (rows.length === 0) console.log(" (aucun index trouvé sur ces tables)"); - }); - - await section("Codes article avec espaces (rend TRIM() nécessaire ou non)", async () => { - const { rows } = await client.query(` - SELECT count(*) AS total, - count(*) FILTER (WHERE codein::text <> TRIM(codein::text)) AS avec_espaces, - count(DISTINCT TRIM(codein::text)) AS distincts - FROM articles`); - const r = rows[0]; - console.log(` codein : ${r.total} articles — ${r.avec_espaces} avec espaces — ${r.distincts} codes distincts`); - try { - const { rows: c } = await client.query(` - SELECT count(*) FILTER (WHERE artcentrale::text <> TRIM(artcentrale::text)) AS avec_espaces - FROM articles WHERE artcentrale IS NOT NULL`); - console.log(` artcentrale : ${c[0].avec_espaces} avec espaces`); - } catch (e) { - console.log(` artcentrale : (indisponible : ${e.message})`); - } - }); - - // Paramètres d'exemple tirés de la base elle-même. - let codein = null; - let codefou = null; - try { - codein = (await client.query("SELECT TRIM(codein::text) AS c FROM articles WHERE codein IS NOT NULL LIMIT 1")).rows[0]?.c ?? null; - codefou = (await client.query("SELECT code FROM artfou1 WHERE preference = 1 GROUP BY code ORDER BY count(*) DESC LIMIT 1")).rows[0]?.code ?? null; - } catch { - /* tables absentes : les plans concernés le signaleront */ - } - const hier = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); - const ilYa7Jours = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10); - const ilYa1An = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10); - - await plan(client, `article par code, avec TRIM (codein ${codein})`, - "SELECT a.no_id FROM articles a WHERE TRIM(a.codein::text) = $1", [codein ?? ""]); - await plan(client, `article par code, sans TRIM (codein ${codein})`, - "SELECT a.no_id FROM articles a WHERE a.codein::text = $1", [codein ?? ""]); - await plan(client, `Meilleures ventes, 7 derniers jours (${ilYa7Jours} → ${hier})`, HIT_PARADE, [ilYa7Jours, hier]); - await plan(client, "Dernière réception par fournisseur (Commandes)", DERNIERE_RECEPTION); - await plan(client, `Ventes mensuelles d'un fournisseur sur 12 mois (fournisseur ${codefou})`, MENSUEL_FOURNISSEUR, [codefou ?? "", ilYa1An, hier]); - await plan(client, "Stock sans vente (forme simplifiée)", STOCK_SANS_VENTE); - } finally { - client.release(); - await pool.end(); - } -} - -main().catch((e) => { - console.error("Diagnostic interrompu :", e.message); - process.exit(1); -}); diff --git a/scripts/qlik-discover.mjs b/scripts/qlik-discover.mjs deleted file mode 100644 index 8dcfca9..0000000 --- a/scripts/qlik-discover.mjs +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Discovery Qlik Sense — autonome, à lancer à la main : - * - * QLIK_PWD='' node scripts/qlik-discover.mjs # liste les apps - * QLIK_PWD='' node scripts/qlik-discover.mjs # + champs & mesures de l'app - * - * Env (défauts adaptés à La Foir'Fouille) : - * QLIK_HOST=reporting-magasins.lafoirfouille.fr - * QLIK_USER=FFSCH - * QLIK_DOMAIN=FOIRFOUILLE - * QLIK_PWD= - * - * But : récupérer le GUID de l'app réseau (~270 magasins) + les noms exacts du champ - * "code article" et des mesures (CA / Qté / nb magasins) pour remplir .env.local : - * QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_CA / QLIK_MEAS_QTE / QLIK_MEAS_NB_MAG - */ - -import httpntlm from "httpntlm"; -import { WebSocket } from "ws"; -import { randomBytes } from "node:crypto"; - -process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certif Qlik interne - -const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr"; -const USER = process.env.QLIK_USER || "FFSCH"; -// Domaine vide par défaut : requests_ntlm("FFSCH", pwd) marche sans domaine explicite. -const DOMAIN = process.env.QLIK_DOMAIN || ""; -const PWD = process.env.QLIK_PWD || ""; -const APP = process.argv[2] || process.env.QLIK_APP_NETWORK || ""; - -if (!PWD) { - console.error("ERREUR: définis QLIK_PWD. Ex: QLIK_PWD='xxx' node scripts/qlik-discover.mjs"); - process.exit(1); -} - -const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0"); - -function ntlmGet(path) { - return new Promise((resolve, reject) => { - httpntlm.get( - { - url: `https://${HOST}${path}`, - username: USER, - password: PWD, - domain: DOMAIN, - workstation: "", - rejectUnauthorized: false, - headers: { "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0 CollectFlow" }, - }, - (err, res) => (err ? reject(err) : resolve(res)), - ); - }); -} - -// Récupère un targetId en initiant une requête non authentifiée vers /hub/. -async function getTargetId() { - const res = await fetch(`https://${HOST}/hub/`, { redirect: "manual" }); - const loc = res.headers.get("location") || ""; - const m = loc.match(/targetId=([0-9a-f-]+)/i); - return m ? m[1] : ""; -} - -// Flux SSO ticket Qlik : -// 1. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX -// 2. GET /hub/?qlikTicket=XXX -> Qlik valide le ticket et pose le cookie X-Qlik-Session -async function authCookie() { - const tid = await getTargetId(); - const res = await ntlmGet(`/internal_windows_authentication/?targetId=${tid}&xrfkey=${xrfkey}`); - const loc = res.headers.location || ""; - const ticket = (loc.match(/qlikTicket=([^&\s]+)/) || [])[1]; - console.log(` [auth] NTLM HTTP ${res.statusCode}, ticket=${ticket ? "oui" : "non"}`); - if (!ticket) throw new Error(`pas de qlikTicket (HTTP ${res.statusCode})`); - - // Échange ticket -> cookies de session (on garde TOUS les cookies posés) - const r2 = await fetch(loc, { redirect: "manual" }); - const sc = typeof r2.headers.getSetCookie === "function" - ? r2.headers.getSetCookie() - : (r2.headers.get("set-cookie") ? [r2.headers.get("set-cookie")] : []); - const pairs = sc.map((c) => c.split(";")[0]); - const hasSession = pairs.some((c) => /X-Qlik-Session/i.test(c)); - console.log(` [auth] échange ticket HTTP ${r2.status}, cookies=${JSON.stringify(pairs.map(p=>p.split("=")[0]))}`); - if (!hasSession) throw new Error("ticket non échangé contre un cookie de session"); - return pairs.join("; "); -} - -// QRS via cookie de session + Xrfkey. -async function qrsGet(path, cookie) { - const sep = path.includes("?") ? "&" : "?"; - const res = await fetch(`https://${HOST}${path}${sep}xrfkey=${xrfkey}`, { - headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, Accept: "application/json" }, - }); - if (res.status !== 200) throw new Error(`QRS ${path} HTTP ${res.status}`); - return res.json(); -} - -async function qrsApps(cookie) { - const apps = await qrsGet("/qrs/app", cookie); - return apps.map((a) => ({ id: a.id, name: a.name, published: a.published, stream: a.stream?.name })); -} - -function engine(appGuid, cookie) { - const ident = randomBytes(8).toString("hex"); - const url = `wss://${HOST}/app/${appGuid}/identity/${ident}?Xrfkey=${xrfkey}`; - const ws = new WebSocket(url, { - headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0" }, - rejectUnauthorized: false, - }); - let id = 1; - const pending = new Map(); - const ready = new Promise((resolve, reject) => { - ws.on("message", (raw) => { - const m = JSON.parse(raw.toString()); - if (m.method === "OnConnected") return resolve(); - if (m.id != null && pending.has(m.id)) { - const p = pending.get(m.id); - pending.delete(m.id); - m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); - } - }); - ws.on("unexpected-response", (_req, res) => { - let body = ""; - res.on("data", (d) => (body += d)); - res.on("end", () => reject(new Error(`ws ${res.statusCode} server=${res.headers.server} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 120)}`))); - }); - ws.on("error", reject); - }); - const rpc = (method, params, handle = -1) => - new Promise((resolve, reject) => { - const i = id++; - pending.set(i, { resolve, reject }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); - }); - return { ready, rpc, close: () => ws.close() }; -} - -async function dumpFieldsMeasures(appGuid, cookie) { - const e = engine(appGuid, cookie); - await e.ready; - const open = await e.rpc("OpenDoc", { qDocName: appGuid }); - const doc = open.qReturn.qHandle; - - // Liste des champs - const fl = await e.rpc("CreateSessionObject", { - qProp: { qInfo: { qType: "FieldList" }, qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true } }, - }, doc); - const flLayout = await e.rpc("GetLayout", {}, fl.qReturn.qHandle); - const fields = (flLayout.qLayout.qFieldList.qItems || []).map((f) => f.qName); - - // Liste des mesures (master items) - const ml = await e.rpc("CreateSessionObject", { - qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } }, - }, doc); - const mlLayout = await e.rpc("GetLayout", {}, ml.qReturn.qHandle); - const measures = (mlLayout.qLayout.qMeasureList.qItems || []).map((m) => ({ - title: m.qMeta?.title ?? m.qData?.title, - expr: m.qData?.expr, - })); - - e.close(); - return { fields, measures }; -} - -(async () => { - try { - console.log(`Auth NTLM ${DOMAIN}\\${USER} @ ${HOST} ...`); - const cookie = await authCookie(); - console.log("✓ Authentifié.\n"); - - const apps = await qrsApps(cookie); - console.log(`=== ${apps.length} apps ===`); - for (const a of apps) console.log(` ${a.id} [${a.stream || "-"}] ${a.name}`); - - if (APP) { - console.log(`\n=== Champs & mesures de l'app ${APP} ===`); - const { fields, measures } = await dumpFieldsMeasures(APP, cookie); - console.log(`\n-- Champs (${fields.length}) --`); - for (const f of fields) console.log(` ${f}`); - console.log(`\n-- Mesures master (${measures.length}) --`); - for (const m of measures) console.log(` ${m.title} = ${m.expr}`); - console.log(`\n→ Repère le champ "code article", et les expressions CA / Qté / nb magasins,`); - console.log(` puis remplis QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_* dans .env.local.`); - } else { - console.log(`\n→ Relance avec le GUID de l'app réseau pour lister ses champs & mesures :`); - console.log(` QLIK_PWD='...' node scripts/qlik-discover.mjs `); - } - } catch (e) { - console.error("ERREUR:", e.message); - process.exit(1); - } -})(); diff --git a/scripts/qlik-validate.mjs b/scripts/qlik-validate.mjs deleted file mode 100644 index 86e3a46..0000000 --- a/scripts/qlik-validate.mjs +++ /dev/null @@ -1,354 +0,0 @@ -/** - * CollectFlow — Validation des expressions Qlik et de la clé de jointure. - * - * À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) : - * - * docker exec -e QLIK_PWD='' -it node scripts/qlik-validate.mjs - * - * Répond aux deux questions encore ouvertes : - * - * 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection - * Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles - * un substitut fidèle ? Le script affiche, pour quelques articles réellement - * vendus, un tableau mois par mois comparant les deux — dont les mois d'août à - * décembre qui ressortaient vides. - * - * 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article - * Code » ou le champ `article_no_centrale` ? Le script affiche les deux - * côte à côte. - * - * Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement. - * Le script est en LECTURE SEULE (sélections en soft lock, objets de session - * détruits) — il ne modifie rien dans Qlik. - */ - -import httpntlm from "httpntlm"; -import { randomBytes } from "node:crypto"; -import { chromium } from "playwright-core"; - -process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne - -const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr"; -const USER = process.env.QLIK_USER || "FFSCH"; -const PWD = process.env.QLIK_PWD || ""; -const DOMAIN = process.env.QLIK_DOMAIN ?? ""; -const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647"; -const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP"; -const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs"; -const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3"); - -if (!PWD) { - console.error("ERREUR : définir QLIK_PWD dans l'environnement."); - console.error(" docker exec -e QLIK_PWD='…' -it node scripts/qlik-validate.mjs"); - process.exit(1); -} - -const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0"); - -// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ──── - -const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); -const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000); - -function fenetre12Mois(now = new Date()) { - const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1); - const fin = new Date(now.getFullYear(), now.getMonth(), 0); - const serials = []; - const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate()); - const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate()); - for (let s = s0; s <= s1; s++) serials.push(s); - const labels = []; - for (let i = 12; i >= 1; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); - } - return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) }; -} - -// ─── Auth NTLM → cookie de session ────────────────────────────────────────── - -function ntlmGet(path) { - return new Promise((resolve, reject) => { - httpntlm.get( - { - url: `https://${HOST}${path}`, - username: USER, - password: PWD, - domain: DOMAIN, - workstation: "", - rejectUnauthorized: false, - headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey }, - }, - (err, res) => (err ? reject(err) : resolve(res)), - ); - }); -} - -function cookieDepuis(setCookie) { - const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : []; - return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null; -} - -async function session() { - const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" }); - const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? ""; - const chemin = targetId - ? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}` - : `/hub/?xrfkey=${xrfkey}`; - const res = await ntlmGet(chemin); - - const direct = cookieDepuis(res.headers["set-cookie"]); - if (direct) return direct; - - const loc = String(res.headers["location"] ?? ""); - if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`); - const ech = await fetch(loc, { redirect: "manual" }); - const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : []; - const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; "); - if (!cookie) throw new Error("ticket non échangé contre un cookie de session"); - return cookie; -} - -// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ──── - -function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) { - return new Promise((resolve) => { - const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`; - const ws = new WebSocket(url); - const sortie = []; - const log = (l) => { sortie.push(l); console.log("[cf] " + l); }; - let id = 0; - const pend = new Map(); - const rpc = (method, params, handle = -1) => new Promise((res, rej) => { - const i = ++id; pend.set(i, { res, rej }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); - }); - const pret = new Promise((res, rej) => { - ws.onmessage = (ev) => { - const m = JSON.parse(ev.data); - if (m.method === "OnConnected") return res(); - if (m.id && pend.has(m.id)) { - const p = pend.get(m.id); pend.delete(m.id); - if (m.error) p.rej(new Error(JSON.stringify(m.error))); - else p.res(m.result); - } - }; - ws.onerror = () => rej(new Error("websocket refusé (403 ?)")); - setTimeout(() => rej(new Error("timeout websocket")), 30000); - }); - const dormir = (ms) => new Promise((r) => setTimeout(r, ms)); - - (async () => { - try { - await pret; - const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle; - - // ─── 1. Expressions des master measures ────────────────────── - const ml = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "MeasureList" }, - qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } }, - }, - }, doc); - const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? []; - const parTitre = new Map(); - log("=== EXPRESSIONS DES MASTER MEASURES ==="); - for (const it of items) { - const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim(); - if (!t) continue; - parTitre.set(t.toLowerCase(), it.qInfo.qId); - if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) { - log(` « ${t} » = ${it.qData?.expr ?? "?"}`); - } - } - const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null; - - // ─── 2. Sélection de la fenêtre 12 mois ────────────────────── - const champDate = await rpc("GetField", { qFieldName: "Date" }, doc); - const dh = champDate.qReturn.qHandle; - await rpc("Clear", {}, dh); - await dormir(300); - for (let i = 0; i < serials.length; i += 500) { - await rpc("SelectValues", { - qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })), - qToggleMode: i > 0, - qSoftLock: true, - }, dh); - } - await dormir(500); - log(""); - log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`); - - // ─── 3. Articles les plus vendus (par agrégation directe) ──── - const cubeTop = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-val-top" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: dimArticle }], - qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }], - qInterColumnSortOrder: [1, 0], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }], - }, - }, - }, doc); - const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle)) - .qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? []; - const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean); - log(`articles testés (top ventes) : ${JSON.stringify(codes)}`); - if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); } - - const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc); - const ah = champArticle.qReturn.qHandle; - await rpc("Clear", {}, ah); - await dormir(300); - await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah); - await dormir(500); - - // ─── 4. Comparaison mois par mois ──────────────────────────── - const mesures = [ - { qDef: { qDef: "Sum(quantite)" } }, - { qDef: { qDef: "Sum(ca_ht)" } }, - { qDef: { qDef: "Sum(ca_ttc)" } }, - { qDef: { qDef: "Count(DISTINCT [Magasin Code])" } }, - { qDef: { qDef: "Sum(marge)" } }, - ]; - const idQte = mesure("Quantité N"); - const idCa = mesure("CA N"); - const idNbMag = mesure("Magasin Ventes Nb N"); - if (idQte) mesures.push({ qLibraryId: idQte }); - if (idCa) mesures.push({ qLibraryId: idCa }); - if (idNbMag) mesures.push({ qLibraryId: idNbMag }); - const largeur = 2 + mesures.length; - - const cube = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-val-mois" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }], - qMeasures: mesures, - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }], - }, - }, - }, doc); - const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube; - const lignes = hc.qDataPages?.[0]?.qMatrix ?? []; - - log(""); - log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ==="); - log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N"); - const pad = (v, n) => String(v).padStart(n); - for (const r of lignes) { - const code = String(r[0]?.qText ?? ""); - const moisBrut = String(r[1]?.qText ?? ""); - const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut; - const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0; - log( - code.padEnd(13) + mois.padEnd(9) + - pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" + - pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" + - pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10), - ); - } - log(""); - log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que"); - log(" Sum(quantite) est renseigné, la master measure est bien inutilisable"); - log(" pour une fenêtre glissante — et l'agrégation directe la remplace."); - log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,"); - log(" l'expression par défaut de CollectFlow est validée."); - log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir"); - log(" QLIK_EXPR_CA."); - - // ─── 5. Clé de jointure : Article Code vs article_no_centrale ─ - const cubeCle = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-val-cle" }, - qHyperCubeDef: { - qDimensions: [ - { qLibraryId: dimArticle }, - { qDef: { qFieldDefs: ["article_no_centrale"] } }, - { qDef: { qFieldDefs: ["article_codein"] } }, - ], - qMeasures: [], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }], - }, - }, - }, doc); - const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle)) - .qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? []; - log(""); - log("=== CLÉ DE JOINTURE ==="); - log("Article Code article_no_centrale article_codein"); - for (const r of cles) { - log( - String(r[0]?.qText ?? "").padEnd(20) + - String(r[1]?.qText ?? "").padEnd(22) + - String(r[2]?.qText ?? ""), - ); - } - log(""); - log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`"); - log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres)."); - - ws.close(); - resolve({ ok: true, sortie }); - } catch (e) { - try { ws.close(); } catch { /* noop */ } - resolve({ ok: false, error: String(e?.message || e), sortie }); - } - })(); - }); -} - -// ─── Orchestration ────────────────────────────────────────────────────────── - -(async () => { - const f = fenetre12Mois(); - console.log(`Qlik ${HOST} — app ${APP}`); - console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`); - - const cookie = await session(); - console.log("✓ authentifié (NTLM + ticket)\n"); - const [nom, ...reste] = cookie.split("="); - - const navigateur = await chromium.launch({ - headless: true, - executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined, - args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], - }); - const ctx = await navigateur.newContext({ - httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` }, - ignoreHTTPSErrors: true, - }); - await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]); - - try { - const page = await ctx.newPage(); - let token = null; - let pret; - const attente = new Promise((r) => { pret = r; }); - page.on("websocket", (ws) => { - const m = ws.url().match(/qlik-csrf-token=([^&]+)/); - if (m && !token) { token = decodeURIComponent(m[1]); pret(); } - }); - await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {}); - await Promise.race([attente, page.waitForTimeout(20000)]); - if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)"); - - const res = await page.evaluate(scriptInPage, { - app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS, - serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES, - }); - - console.log(res.sortie.join("\n")); - if (!res.ok) { - console.error("\nÉCHEC : " + res.error); - process.exitCode = 1; - } - } finally { - await ctx.close(); - await navigateur.close(); - } -})().catch((e) => { - console.error("ERREUR :", e?.message || e); - process.exit(1); -}); diff --git a/scripts/repro-500.js b/scripts/repro-500.js deleted file mode 100644 index 59b38df..0000000 --- a/scripts/repro-500.js +++ /dev/null @@ -1,39 +0,0 @@ -const { computeProductScores } = require('./src/lib/score-engine'); - -// Simulate 50,000 rows -const largeDataset = Array.from({ length: 50000 }, (_, i) => ({ - codein: `item-${i}`, - codeFournisseur: 'A032', - nomFournisseur: 'ACCESS DECO', - libelle1: `Product ${i}`, - gtin: '123456789', - reference: 'REF', - code1: '20', - libelleNiveau1: 'LIQUIDES', - code2: '2001', - libelleNiveau2: 'EAUX', - code3: '200101', - libelle3: 'EAU SOURCE', - codeGamme: 'A', - codeGammeInit: 'A', - codeGammeDraft: null, - sales12m: {}, - totalQuantite: Math.random() * 1000, - totalCa: Math.random() * 5000, - totalMarge: Math.random() * 1000, - tauxMarge: 20, - score: 0, - workingStores: ['MAG1', 'MAG2'] -})); - -console.log(`Starting test with ${largeDataset.length} rows...`); - -try { - const start = Date.now(); - const result = computeProductScores(largeDataset); - const end = Date.now(); - console.log(`Success! Processed ${result.length} rows in ${end - start}ms.`); -} catch (error) { - console.error('Failed!', error); - process.exit(1); -} diff --git a/scripts/test-db-cmd.js b/scripts/test-db-cmd.js deleted file mode 100644 index 98cc6ec..0000000 --- a/scripts/test-db-cmd.js +++ /dev/null @@ -1,25 +0,0 @@ -require('dotenv').config({ path: 'c:/GIT/CollectFlow/.env.local' }); -const { Pool } = require('pg'); - -async function test() { - const pool = new Pool({ - connectionString: process.env.DATABASE_URL - }); - - try { - const res = await pool.query("SELECT * FROM cube_stock WHERE cs.site = '292' LIMIT 5"); - console.log("CUBE_STOCK:", res.rows); - } catch(e) { - console.log("ERROR cube_stock:", e.message); - } - - try { - const res2 = await pool.query("SELECT a.codein, a.no_id FROM articles a LIMIT 5"); - console.log("ARTICLES:", res2.rows); - } catch(e) { - console.log("ERROR articles:", e.message); - } - - pool.end(); -} -test(); diff --git a/scripts/test-qlik-date-range.ts b/scripts/test-qlik-date-range.ts deleted file mode 100644 index 43876e3..0000000 --- a/scripts/test-qlik-date-range.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Test CLI pour `src/lib/qlik-date-range.ts` (aucune dépendance Next.js). - * Usage : npx tsx scripts/test-qlik-date-range.ts - * - * Vérifie : - * - les repères Qlik (2025-01-01→45658, etc.) - * - la fenêtre 12 mois complets pour `now=2026-06-21` (doit donner - * dateDebut=2025-06-01, dateFin=2026-05-31, qStart=45809, qEnd=46173, - * label=2025-06_2026-05, 365 serials) - * - exemples supplémentaires (1er du mois, fin d'année, année bissextile) - * - erreurs attendues sur entrée mal formée - * - option `monthsBack` : 6 mois, bornes (clamp 1..12) - * - helper `envMonthsBack` : valeur absente, valide, hors bornes, non numérique - * - * Aucun framework : assertions manuelles, exit code 0/1. - */ - -import { - isoDateToQlikSerial, - qlikSerialToIsoDate, - buildGridNetworkQlikDateFilter, - chunkSerials, - getMonthRanges, - envMonthsBack, - QLIK_DATE_ANCHORS, - QLIK_MONTHS_BACK_MIN, - QLIK_MONTHS_BACK_MAX, - QLIK_MONTHS_BACK_DEFAULT, -} from "../src/lib/qlik-date-range"; - -let pass = 0; -let fail = 0; - -function eq(label: string, got: unknown, want: unknown): void { - const ok = JSON.stringify(got) === JSON.stringify(want); - if (ok) { - pass++; - console.log(` \u2713 ${label}`); - } else { - fail++; - console.log(` \u2717 ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`); - } -} - -function truthy(label: string, got: unknown): void { - if (got) { - pass++; - console.log(` \u2713 ${label}`); - } else { - fail++; - console.log(` \u2717 ${label} \u2014 falsy: ${JSON.stringify(got)}`); - } -} - -function throws(label: string, fn: () => unknown, re?: RegExp): void { - try { - fn(); - fail++; - console.log(` \u2717 ${label} \u2014 n'a pas lanc\u00e9 d'exception`); - } catch (e) { - const msg = String((e as Error)?.message ?? e); - if (!re || re.test(msg)) { - pass++; - console.log(` \u2713 ${label}`); - } else { - fail++; - console.log(` \u2717 ${label} \u2014 exception inattendue: ${msg}`); - } - } -} - -console.log("\n[1] Rep\u00e8res Qlik (skill qlik)"); -for (const a of QLIK_DATE_ANCHORS) { - eq(`isoDateToQlikSerial(${a.iso}) = ${a.serial}`, isoDateToQlikSerial(a.iso), a.serial); -} - -console.log("\n[2] Round-trip s\u00e9rial \u2194 ISO"); -for (const a of QLIK_DATE_ANCHORS) { - eq(`qlikSerialToIsoDate(${a.serial}) = ${a.iso}`, qlikSerialToIsoDate(a.serial), a.iso); -} - -console.log("\n[3] Fen\u00eatre grille (now=2026-06-21) \u2192 2025-06-01 .. 2026-05-31"); -const f = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); -eq("dateDebut", f.dateDebut, "2025-06-01"); -eq("dateFin", f.dateFin, "2026-05-31"); -eq("qStart", f.qStart, 45809); -eq("qEnd", f.qEnd, 46173); -eq("label", f.label, "2025-06_2026-05"); -eq("setAnalysis", f.setAnalysis, `Date={">=45809<=46173"}`); -eq("dailySerials.length = 365", f.dailySerials.length, 365); -eq("dailySerials[0]", f.dailySerials[0], 45809); -eq("dailySerials[364]", f.dailySerials[364], 46173); - -console.log("\n[4] Fen\u00eatre grille (now=2026-03-15) \u2192 2025-03-01 .. 2026-02-28"); -const f2 = buildGridNetworkQlikDateFilter(new Date("2026-03-15T08:00:00Z")); -eq("dateDebut", f2.dateDebut, "2025-03-01"); -eq("dateFin", f2.dateFin, "2026-02-28"); -eq("qStart", f2.qStart, 45717); -eq("qEnd", f2.qEnd, 46081); - -console.log("\n[5] Fen\u00eatre grille (now=2025-01-05) \u2192 2024-01-01 .. 2024-12-31 (ann\u00e9e bissextile, 366 j)"); -const f3 = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z")); -eq("dateDebut", f3.dateDebut, "2024-01-01"); -eq("dateFin", f3.dateFin, "2024-12-31"); -eq("dailySerials.length = 366", f3.dailySerials.length, 366); - -console.log("\n[6] chunkSerials"); -const chunks = chunkSerials(f.dailySerials, 100); -eq("chunks.length pour 365/100", chunks.length, 4); -eq("chunks[3].length = 65", chunks[3].length, 65); -eq("chunks[0][0]", chunks[0][0], 45809); -eq("chunks[3][64]", chunks[3][64], 46173); - -console.log("\n[7] Robustesse"); -throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/); -throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/); -truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0); -truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date=")); - -console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)"); -const months = getMonthRanges(f); -eq("12 mois exactement", months.length, 12); -eq("premier mois label", months[0].label, "2025-06"); -eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01"); -eq("premier mois dateFin", months[0].dateFin, "2025-06-30"); -eq("premier mois serials.length = 30", months[0].dailySerials.length, 30); -eq("premier mois qStart", months[0].qStart, 45809); -eq("premier mois qEnd", months[0].qEnd, 45838); -eq("dernier mois label", months[11].label, "2026-05"); -eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01"); -eq("dernier mois dateFin", months[11].dateFin, "2026-05-31"); -eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31); -// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05 -const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z")); -truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28)); -// Couvre février d'une année bissextile (2024) -const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z")); -const monthsBiss = getMonthRanges(fBiss); -truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29)); -// Total jours = 365 (somme des serials) -const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0); -eq("somme des jours = 365", totalDays, 365); - -console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)"); -const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" }; -const monthsShort = getMonthRanges(fShort); -eq("3 mois (juin, juillet, août)", monthsShort.length, 3); -eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15); -eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15"); - -console.log("\n[10] monthsBack=6 (now=2026-06-21) → 2025-12-01 .. 2026-05-31"); -const f6 = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 6); -eq("dateDebut", f6.dateDebut, "2025-12-01"); -eq("dateFin", f6.dateFin, "2026-05-31"); -eq("qStart", f6.qStart, 45992); -eq("qEnd", f6.qEnd, 46173); -eq("label", f6.label, "2025-12_2026-05"); -eq("dailySerials.length = 182 (déc 2025:31 + jan:31 + fév:28 + mar:31 + avr:30 + mai:31)", f6.dailySerials.length, 182); -const months6 = getMonthRanges(f6); -eq("getMonthRanges: 6 mois exactement", months6.length, 6); -eq("premier mois label", months6[0].label, "2025-12"); -eq("dernier mois label", months6[5].label, "2026-05"); -const totalDays6 = months6.reduce((acc, m) => acc + m.dailySerials.length, 0); -eq("somme des jours = 182", totalDays6, 182); - -console.log("\n[11] monthsBack — bornes et clamp"); -eq("monthsBack=1 (now=2026-06-21) → 2026-05-01 .. 2026-05-31 (1 mois complet)", - (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 1); return x.dateDebut === "2026-05-01" && x.dateFin === "2026-05-31"; })(), - true); -eq("monthsBack=0 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 0); return x.dateDebut === "2026-05-01"; })(), true); -eq("monthsBack=24 clampé à 12 (max)", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 24); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); -eq("monthsBack=-5 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), -5); return x.dateDebut === "2026-05-01"; })(), true); -eq("monthsBack=NaN ramené au défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), Number.NaN); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); -eq("monthsBack=undefined (par défaut) = défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); -eq("bornes exportées MIN=1", QLIK_MONTHS_BACK_MIN, 1); -eq("bornes exportées MAX=12", QLIK_MONTHS_BACK_MAX, 12); -eq("défaut exporté = 12", QLIK_MONTHS_BACK_DEFAULT, 12); - -console.log("\n[12] envMonthsBack — lecture env"); -const PREV = process.env.QLIK_TEST_MONTHS_BACK; -delete process.env.QLIK_TEST_MONTHS_BACK; -eq("env absent → défaut", envMonthsBack("QLIK_TEST_MONTHS_BACK"), QLIK_MONTHS_BACK_DEFAULT); -eq("env vide → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = ""; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT); -eq("env '6' → 6", (() => { process.env.QLIK_TEST_MONTHS_BACK = "6"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 6); -eq("env 'abc' → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = "abc"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT); -eq("env '24' clampé à MAX=12", (() => { process.env.QLIK_TEST_MONTHS_BACK = "24"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MAX); -eq("env '-3' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "-3"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN); -eq("env '0' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "0"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN); -eq("env '3.7' troncé à 3", (() => { process.env.QLIK_TEST_MONTHS_BACK = "3.7"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 3); -eq("env ' 8 ' trimé puis parsé → 8", (() => { process.env.QLIK_TEST_MONTHS_BACK = " 8 "; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 8); -if (PREV === undefined) delete process.env.QLIK_TEST_MONTHS_BACK; else process.env.QLIK_TEST_MONTHS_BACK = PREV; - -console.log(`\n=== ${pass} passés, ${fail} échoués ===`); -if (fail > 0) process.exit(1); diff --git a/verify-logic.js b/verify-logic.js deleted file mode 100644 index 72ae854..0000000 --- a/verify-logic.js +++ /dev/null @@ -1,32 +0,0 @@ - -// Mocking the simplified logic for validation without imports hitting environment issues -function isRecentLogic(regularityScore, inactivityMonths) { - return (regularityScore || 0) < 3 && (inactivityMonths || 0) <= 1; -} - -const testCases = [ - { name: "Nouveau Récent", regularity: 1, inactivity: 0, expected: true }, - { name: "Nouveau Limite", regularity: 2, inactivity: 1, expected: true }, - { name: "Nouveau Ancien (6m)", regularity: 1, inactivity: 6, expected: false }, - { name: "Nouveau Hors Limite (2m)", regularity: 2, inactivity: 2, expected: false }, - { name: "Ancien Actif", regularity: 12, inactivity: 0, expected: false }, - { name: "VRAIMENT Nouveau (0 activité)", regularity: 0, inactivity: 0, expected: true } -]; - -console.log("--- Testing isRecent Logic (Simplified) ---"); -let allPassed = true; - -testCases.forEach(tc => { - const actual = isRecentLogic(tc.regularity, tc.inactivity); - const passed = actual === tc.expected; - console.log(`${passed ? "✅" : "❌"} [${tc.name}]: expected ${tc.expected}, got ${actual}`); - if (!passed) allPassed = false; -}); - -if (allPassed) { - console.log("\nALL LOGIC TESTS PASSED"); - process.exit(0); -} else { - console.log("\nSOME TESTS FAILED"); - process.exit(1); -} diff --git a/verify-parser.js b/verify-parser.js deleted file mode 100644 index 58d88df..0000000 --- a/verify-parser.js +++ /dev/null @@ -1,59 +0,0 @@ -const fs = require('fs'); - -function extractRecommendation(content) { - const match = content.match(/\b([ABCDZ])\b/i); - if (match) return match[1].toUpperCase(); - return null; -} - -function simulateParse(content) { - let reco = null; - let ruleApplies = false; - - try { - const jsonMatch = content.match(/\{[\s\S]*\}/); - const jsonText = jsonMatch ? jsonMatch[0] : content; - const parsed = JSON.parse(jsonText); - - reco = parsed.recommendation; - ruleApplies = parsed.rule_applies === true; - - if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; - - if (!reco || !["A", "B", "C", "D", "Z"].includes(reco)) { - reco = extractRecommendation(parsed.justification || "") || "A"; - if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; - } - } catch (e) { - reco = extractRecommendation(content) || "A"; - if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; - } - return reco; -} - -const tests = [ - { name: "Valid Rule B", json: '{"rule_applies": true, "recommendation": "B", "justification": "blabla"}', expected: "B" }, - { name: "Valid Rule C", json: '{"rule_applies": true, "recommendation": "C", "justification": "blabla"}', expected: "C" }, - { name: "Valid Rule D", json: '{"rule_applies": true, "recommendation": "D", "justification": "blabla"}', expected: "D" }, - { name: "Invalid Rule B (hallucinated)", json: '{"rule_applies": false, "recommendation": "B", "justification": "blabla"}', expected: "A" }, - { name: "Invalid Rule D (hallucinated)", json: '{"rule_applies": false, "recommendation": "D", "justification": "blabla"}', expected: "A" }, - { name: "Valid Rule A", json: '{"rule_applies": true, "recommendation": "A", "justification": "blabla"}', expected: "A" }, - { name: "Valid Extraction D", json: '{"rule_applies": true, "recommendation": "INVALID", "justification": "Je choisis D."}', expected: "D" }, - { name: "Invalid Extraction D", json: '{"rule_applies": false, "recommendation": "INVALID", "justification": "Je choisis D."}', expected: "A" } -]; - -let allPassed = true; -tests.forEach(t => { - const res = simulateParse(t.json); - const pass = res === t.expected; - console.log(`${pass ? "✅" : "❌"} ${t.name}: expected ${t.expected}, got ${res}`); - if (!pass) allPassed = false; -}); - -if (allPassed) { - console.log("All parsing tests passed."); - process.exit(0); -} else { - console.log("Some tests failed."); - process.exit(1); -} diff --git a/verify_limit.js b/verify_limit.js deleted file mode 100644 index 00ea176..0000000 --- a/verify_limit.js +++ /dev/null @@ -1,37 +0,0 @@ - -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); -let url = ""; -if (fs.existsSync(CONFIG_PATH)) { - url = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")).url; -} -if (!url) url = process.env.DATABASE_URL; - -const pool = new Pool({ connectionString: url }); - -async function check() { - try { - console.log("Checking DB URL:", url.replace(/:([^@]+)@/, ":****@")); - - const resTotal = await pool.query('SELECT COUNT(*) FROM ventes_produits'); - console.log("Total rows in table:", resTotal.rows[0].count); - - const resSuppliers = await pool.query('SELECT code_fournisseur, COUNT(*) as count FROM ventes_produits GROUP BY code_fournisseur ORDER BY count DESC LIMIT 10'); - console.log("Top suppliers by row count:", resSuppliers.rows); - - // Check for specific supplier in user screenshot (if any, but I'll check all) - for (const s of resSuppliers.rows) { - const resDistinct = await pool.query('SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = $1', [s.code_fournisseur]); - console.log(`Supplier ${s.code_fournisseur}: Distinct Products = ${resDistinct.rows[0].count}`); - } - - } catch (e) { - console.error("DB Error:", e); - } finally { - await pool.end(); - } -} -check(); From 22a6c594882f5b6d5b13ab12b6f949d04dc2ab77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:33:53 +0000 Subject: [PATCH 12/19] =?UTF-8?q?Serveur=20Rust=20:=20module=20grid,=20pha?= =?UTF-8?q?se=201=20(instantan=C3=A9,=20snapshots,=20gammes,=20exports,=20?= =?UTF-8?q?API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portage de tout ce qui, dans la Grille, ne dépend ni de la base FF ni de Qlik : - types.rs : ProductRow et types associés, JSON identique à JSON.stringify (camelCase, champs facultatifs omis, entiers sans décimale, NaN → null) et relecture sans perte des payloads grid_rows écrits par la version Node ; - store.rs : grid_rows (upsert par lots de 200 + purge, instantané, report des gammes, requêtes filtrées/triées/paginées via QueryBuilder et liste blanche de tri, fiche, fournisseurs, fraîcheur, nomenclatures) ; - rows_cache.rs : cache mémoire 10 min des lignes calculées (clé, LRU, patchGridRowsCache, invalidation, calculs en cours, compléments par magasin) ; - network_trend.rs, months.rs, store_patch.rs : logique pure testée ; - gammes_a_valider.rs, enregistrer.rs, snapshots.rs : actions getSnapshots, deleteSnapshot, saveSnapshot, saveDraftChanges, getGammesAValider, rejeterGammesAValider (validation zod reproduite, mêmes messages) ; - exports.rs : /api/export/excel et /api/export/modified-gammes (rust_xlsxwriter, mêmes feuilles, styles, largeurs et formats) ; - api_schemas.rs, zod.rs : schémas /api/v1, issues zod 4 à l'identique ; - api_v1.rs, openapi.rs : /api/v1/nomenclatures et /api/v1/openapi.json (document identique octet pour octet à celui de la version Node). Écart volontaire : listGridNomenclatures groupe par position (GROUP BY 1) ; la requête drizzle répétait left(code3, $n) avec un autre paramètre et échouait systématiquement sous PostgreSQL. Test d'intégration (grid_rows, snapshots, gammes à valider) activé par GRID_TEST_DATABASE_URL. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/grid/api_schemas.rs | 488 ++++++++++++ server/src/grid/api_v1.rs | 68 ++ server/src/grid/enregistrer.rs | 293 +++++++ server/src/grid/exports.rs | 402 ++++++++++ server/src/grid/gammes.rs | 70 ++ server/src/grid/gammes_a_valider.rs | 129 +++ server/src/grid/js.rs | 472 +++++++++++ server/src/grid/mod.rs | 42 +- server/src/grid/months.rs | 97 +++ server/src/grid/network_trend.rs | 350 +++++++++ server/src/grid/openapi.rs | 1133 +++++++++++++++++++++++++++ server/src/grid/rows_cache.rs | 273 +++++++ server/src/grid/session.rs | 95 +++ server/src/grid/snapshots.rs | 407 ++++++++++ server/src/grid/store.rs | 699 +++++++++++++++++ server/src/grid/store_patch.rs | 109 +++ server/src/grid/tests_db.rs | 298 +++++++ server/src/grid/types.rs | 354 +++++++++ server/src/grid/zod.rs | 362 +++++++++ 19 files changed, 6139 insertions(+), 2 deletions(-) create mode 100644 server/src/grid/api_schemas.rs create mode 100644 server/src/grid/api_v1.rs create mode 100644 server/src/grid/enregistrer.rs create mode 100644 server/src/grid/exports.rs create mode 100644 server/src/grid/gammes.rs create mode 100644 server/src/grid/gammes_a_valider.rs create mode 100644 server/src/grid/js.rs create mode 100644 server/src/grid/months.rs create mode 100644 server/src/grid/network_trend.rs create mode 100644 server/src/grid/openapi.rs create mode 100644 server/src/grid/rows_cache.rs create mode 100644 server/src/grid/session.rs create mode 100644 server/src/grid/snapshots.rs create mode 100644 server/src/grid/store.rs create mode 100644 server/src/grid/store_patch.rs create mode 100644 server/src/grid/tests_db.rs create mode 100644 server/src/grid/types.rs create mode 100644 server/src/grid/zod.rs diff --git a/server/src/grid/api_schemas.rs b/server/src/grid/api_schemas.rs new file mode 100644 index 0000000..fa14d5a --- /dev/null +++ b/server/src/grid/api_schemas.rs @@ -0,0 +1,488 @@ +//! CollectFlow — Validation des paramètres de l'API `/api/v1` +//! (port de `src/lib/api-schemas.ts`, schémas zod). +//! +//! Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches +//! de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec +//! le détail des issues zod (`parsed.error.issues`), pour que l'appelant sache +//! quoi corriger — voir [`Issues::to_json`]. +//! +//! Les paramètres d'URL arrivent comme `Object.fromEntries(searchParams)` : une +//! table nom → valeur (la dernière occurrence l'emporte), cf. [`query_map`]. + +use super::gammes::GAMMES; +use super::store::{GridSortKey, GRID_SORT_KEYS}; +use super::zod::{self, child, Issues, Seg}; +use serde_json::{Map, Value}; +use std::collections::HashMap; + +/// `Object.fromEntries(req.nextUrl.searchParams)`. +pub fn query_map(pairs: Vec<(String, String)>) -> HashMap { + pairs.into_iter().collect() +} + +fn p(k: &str) -> Vec { + vec![Seg::from(k)] +} + +/// `z.string().min(1[, message]).optional()` sur un paramètre d'URL. +fn opt_non_vide(q: &HashMap, k: &str, message: Option<&str>, issues: &mut Issues) -> Option { + let v = q.get(k)?; + zod::check_len(v, &p(k), Some((1, message)), None, issues).then(|| v.clone()) +} + +/// `z.string().min(1, message)` obligatoire sur un paramètre d'URL. +fn requis(q: &HashMap, k: &str, message: &str, issues: &mut Issues) -> Option { + match q.get(k) { + None => { + issues.invalid_type(&p(k), "string", None); + None + } + Some(v) => zod::check_len(v, &p(k), Some((1, Some(message))), None, issues).then(|| v.clone()), + } +} + +/// `z.enum([...]).default(d)` / `.optional()` sur un paramètre d'URL. +fn enum_param(q: &HashMap, k: &str, values: &[&str], defaut: Option<&str>, issues: &mut Issues) -> Option { + match q.get(k) { + None => defaut.map(str::to_string), + Some(v) if values.contains(&v.as_str()) => Some(v.clone()), + Some(_) => { + issues.invalid_value(&p(k), values, None); + None + } + } +} + +/// `z.coerce.number().int().min(1)` avec défaut éventuel. +fn int_param(q: &HashMap, k: &str, defaut: Option, max: Option, issues: &mut Issues) -> Option { + match q.get(k) { + None => defaut, + Some(v) => zod::coerce_int(v, &p(k), 1.0, max, issues), + } +} + +/// Message du tri inconnu (liste des clés acceptées). +pub fn message_tri_inconnu() -> String { + format!("Tri inconnu. Valeurs acceptées : {}", GRID_SORT_KEYS.join(", ")) +} + +/// Paramètres de tri et de projection communs (`sortShape`). +#[derive(Debug, Clone, PartialEq)] +pub struct SortParams { + pub sort: Option, + /// `"asc"` | `"desc"` (défaut `"desc"`). + pub order: String, + /// Liste de champs de ProductRow à conserver, séparés par des virgules. + pub fields: Option, + /// `"1"` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment + /// de l'appel. `"0"` : sert l'instantané brut, un peu plus rapide. + pub enrich: String, +} + +fn sort_shape(q: &HashMap, issues: &mut Issues) -> SortParams { + let sort = q.get("sort").and_then(|v| { + if GRID_SORT_KEYS.contains(&v.as_str()) { + Some(v.clone()) + } else { + issues.custom(&p("sort"), &message_tri_inconnu()); + None + } + }); + let order = enum_param(q, "order", &["asc", "desc"], Some("desc"), issues).unwrap_or_default(); + let fields = q.get("fields").cloned(); + let enrich = enum_param(q, "enrich", &["0", "1"], Some("1"), issues).unwrap_or_default(); + SortParams { sort, order, fields, enrich } +} + +/// `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). +#[derive(Debug, Clone, PartialEq)] +pub struct GridQueryParams { + pub fournisseur: String, + pub gamme: Option, + pub code1: Option, + pub code2: Option, + pub code3: Option, + /// Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par + /// /api/v1/nomenclatures. À préférer à code1/code2. + pub nomenclature: Option, + pub search: Option, + /// `"1"` (défaut) : calcule l'instantané à la demande s'il n'existe pas encore ; + /// `"0"` : comportement strict, échoue vite en `not_ready`. + pub compute: String, + pub sort: SortParams, + pub page: i64, + /// **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid` + /// renvoie *toutes* les lignes du fournisseur. + pub limit: Option, +} + +const MESSAGE_NOMENCLATURE: &str = "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"; + +/// `gridQuerySchema.safeParse(...)`. +pub fn parse_grid_query(q: &HashMap) -> Result { + let mut issues = Issues::default(); + let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues); + let gamme = opt_non_vide(q, "gamme", None, &mut issues); + let code1 = opt_non_vide(q, "code1", None, &mut issues); + let code2 = opt_non_vide(q, "code2", None, &mut issues); + let code3 = opt_non_vide(q, "code3", None, &mut issues); + let nomenclature = q.get("nomenclature").and_then(|v| { + let chemin = p("nomenclature"); + let mut ok = zod::check_len(v, &chemin, Some((1, None)), Some((20, None)), &mut issues); + if v.is_empty() || !v.bytes().all(|b| b.is_ascii_digit()) { + issues.invalid_regex(&chemin, "/^[0-9]+$/", MESSAGE_NOMENCLATURE); + ok = false; + } + ok.then(|| v.clone()) + }); + let search = opt_non_vide(q, "search", None, &mut issues); + let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default(); + let sort = sort_shape(q, &mut issues); + let page = int_param(q, "page", Some(1), None, &mut issues); + let limit = int_param(q, "limit", None, None, &mut issues); + if !issues.is_empty() { + return Err(issues); + } + Ok(GridQueryParams { + fournisseur: fournisseur.unwrap_or_default(), + gamme, + code1, + code2, + code3, + nomenclature, + search, + compute, + sort, + page: page.unwrap_or(1), + limit, + }) +} + +/// `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. +#[derive(Debug, Clone, PartialEq)] +pub struct ProductSearchParams { + pub q: String, + pub fournisseur: Option, + pub gamme: Option, + pub sort: SortParams, + pub page: i64, + /// Défaut 100, sans plafond. + pub limit: i64, +} + +/// `productSearchSchema.safeParse(...)`. +pub fn parse_product_search(q: &HashMap) -> Result { + let mut issues = Issues::default(); + let texte = match q.get("q") { + None => { + issues.invalid_type(&p("q"), "string", None); + None + } + Some(v) => zod::check_len(v, &p("q"), Some((1, Some("Paramètre 'q' requis"))), Some((120, None)), &mut issues).then(|| v.clone()), + }; + let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues); + let gamme = opt_non_vide(q, "gamme", None, &mut issues); + let sort = sort_shape(q, &mut issues); + let page = int_param(q, "page", Some(1), None, &mut issues); + let limit = int_param(q, "limit", Some(100), None, &mut issues); + if !issues.is_empty() { + return Err(issues); + } + Ok(ProductSearchParams { q: texte.unwrap_or_default(), fournisseur, gamme, sort, page: page.unwrap_or(1), limit: limit.unwrap_or(100) }) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FournisseursQueryParams { + pub search: Option, + /// `"1"` = uniquement les fournisseurs déjà présents dans l'instantané de grille. + pub with_data: Option, +} + +/// `fournisseursQuerySchema.safeParse(...)`. +pub fn parse_fournisseurs_query(q: &HashMap) -> Result { + let mut issues = Issues::default(); + let search = opt_non_vide(q, "search", None, &mut issues); + let with_data = enum_param(q, "withData", &["0", "1"], None, &mut issues); + if !issues.is_empty() { + return Err(issues); + } + Ok(FournisseursQueryParams { search, with_data }) +} + +/// `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur. +#[derive(Debug, Clone, PartialEq)] +pub struct NomenclaturesQueryParams { + pub fournisseur: String, + /// 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF). + pub niveau: u8, + /// Restreint à un poste parent (préfixe). + pub parent: Option, +} + +/// `nomenclaturesQuerySchema.safeParse(...)`. +pub fn parse_nomenclatures_query(q: &HashMap) -> Result { + let mut issues = Issues::default(); + let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues); + let niveau = int_param(q, "niveau", Some(1), Some(3.0), &mut issues); + let parent = opt_non_vide(q, "parent", None, &mut issues); + if !issues.is_empty() { + return Err(issues); + } + Ok(NomenclaturesQueryParams { fournisseur: fournisseur.unwrap_or_default(), niveau: niveau.unwrap_or(1).clamp(1, 3) as u8, parent }) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProductDetailParams { + pub fournisseur: Option, + /// `"1"` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas + /// encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur. + pub compute: String, +} + +/// `productDetailSchema.safeParse(...)`. +pub fn parse_product_detail(q: &HashMap) -> Result { + let mut issues = Issues::default(); + let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues); + let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default(); + if !issues.is_empty() { + return Err(issues); + } + Ok(ProductDetailParams { fournisseur, compute }) +} + +/// Message des gammes inconnues (`gammeShape`). +pub fn message_gamme_inconnue() -> String { + let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect(); + format!("Gamme inconnue. Valeurs acceptées : {}", codes.join(", ")) +} + +fn gamme_shape(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option { + let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect(); + zod::enum_value(v, &codes, chemin, Some(&message_gamme_inconnue()), issues) +} + +/// `z.boolean().default(true)`. +fn bool_defaut_vrai(o: &Map, k: &str, issues: &mut Issues) -> Option { + match o.get(k) { + None => Some(true), + v => zod::boolean(v, &p(k), issues), + } +} + +/// `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article. +#[derive(Debug, Clone, PartialEq)] +pub struct ProductGammeBody { + pub gamme: String, + /// Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs. + pub fournisseur: Option, + /// `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore. + pub compute: bool, +} + +/// `productGammeBodySchema.safeParse(corps)`. +pub fn parse_product_gamme_body(corps: &Value) -> Result { + let mut issues = Issues::default(); + let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) }; + let gamme = gamme_shape(o.get("gamme"), &p("gamme"), &mut issues); + let fournisseur = match o.get("fournisseur") { + None => Some(None), + v => zod::string(v, &p("fournisseur"), &mut issues) + .and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, None)), None, &mut issues).then_some(Some(s))), + }; + let compute = bool_defaut_vrai(o, "compute", &mut issues); + if !issues.is_empty() { + return Err(issues); + } + Ok(ProductGammeBody { gamme: gamme.unwrap_or_default(), fournisseur: fournisseur.flatten(), compute: compute.unwrap_or(true) }) +} + +/// Un changement de `POST /api/v1/gammes`. +#[derive(Debug, Clone, PartialEq)] +pub struct GammeChange { + pub codein: String, + pub gamme: String, +} + +/// `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur. +#[derive(Debug, Clone, PartialEq)] +pub struct GammesBatchBody { + pub fournisseur: String, + pub changes: Vec, + pub compute: bool, +} + +/// `gammesBatchBodySchema.safeParse(corps)`. +pub fn parse_gammes_batch_body(corps: &Value) -> Result { + let mut issues = Issues::default(); + let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) }; + let fournisseur = zod::string(o.get("fournisseur"), &p("fournisseur"), &mut issues) + .and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, Some("Champ 'fournisseur' requis"))), None, &mut issues).then_some(s)); + let chemin = p("changes"); + let mut changes = Vec::new(); + match o.get("changes") { + Some(Value::Array(a)) => { + for (i, x) in a.iter().enumerate() { + let ci = child(&chemin, i); + let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue }; + let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues) + .and_then(|s| zod::check_len(&s, &child(&ci, "codein"), Some((1, None)), None, &mut issues).then_some(s)); + let gamme = gamme_shape(c.get("gamme"), &child(&ci, "gamme"), &mut issues); + if let (Some(codein), Some(gamme)) = (codein, gamme) { + changes.push(GammeChange { codein, gamme }); + } + } + if a.is_empty() { + issues.too_small(&chemin, "array", 1.0, Some("Au moins un changement attendu")); + } + if a.len() > 10_000 { + issues.too_big(&chemin, "array", 10_000.0, Some("10 000 changements au maximum par appel")); + } + } + autre => issues.invalid_type(&chemin, "array", autre), + } + let compute = bool_defaut_vrai(o, "compute", &mut issues); + if !issues.is_empty() { + return Err(issues); + } + Ok(GammesBatchBody { fournisseur: fournisseur.unwrap_or_default(), changes, compute: compute.unwrap_or(true) }) +} + +/// Projection de champs : `?fields=codein,libelle1,totalCa`. +/// +/// Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles +/// et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de +/// lignes. `codein` est toujours conservé pour que la ligne reste identifiable. +pub fn pick_fields(row: Value, fields: Option<&str>) -> Value { + let Some(fields) = fields.filter(|f| !f.is_empty()) else { return row }; + let mut wanted: Vec<&str> = Vec::new(); + for f in fields.split(',').map(super::js::js_trim).filter(|f| !f.is_empty()) { + if !wanted.contains(&f) { + wanted.push(f); + } + } + if wanted.is_empty() { + return row; + } + if !wanted.contains(&"codein") { + wanted.push("codein"); + } + let Value::Object(source) = row else { return row }; + let mut out = Map::new(); + for key in wanted { + if let Some(v) = source.get(key) { + out.insert(key.to_string(), v.clone()); + } + } + Value::Object(out) +} + +/// Normalise le tri validé vers la clé attendue par grid-store. +pub fn to_sort_key(sort: Option<&str>) -> Option { + sort.and_then(GridSortKey::from_key) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn q(pairs: &[(&str, &str)]) -> HashMap { + query_map(pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()) + } + + fn issues_json(i: Issues) -> String { + serde_json::to_string(&i.to_json()).unwrap() + } + + #[test] + fn grid_query_comme_zod() { + let err = parse_grid_query(&q(&[ + ("fournisseur", "x"), + ("nomenclature", ""), + ("sort", "bad"), + ("order", "up"), + ("enrich", "2"), + ("compute", "x"), + ("page", "0"), + ("limit", "-1"), + ])) + .unwrap_err(); + assert_eq!( + issues_json(err), + r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["nomenclature"],"message":"Too small: expected string to have >=1 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"},{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"code":"custom","path":["sort"],"message":"Tri inconnu. Valeurs acceptées : libelle1, codein, codeFournisseur, codeGamme, totalCa, totalQuantite, totalMarge, tauxMarge, stockActuel, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, computedAt"},{"code":"invalid_value","values":["asc","desc"],"path":["order"],"message":"Invalid option: expected one of \"asc\"|\"desc\""},{"code":"invalid_value","values":["0","1"],"path":["enrich"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["page"],"message":"Too small: expected number to be >=1"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"# + ); + let err = parse_grid_query(&q(&[("fournisseur", "x"), ("nomenclature", "123456789012345678901a")])).unwrap_err(); + assert_eq!( + issues_json(err), + r#"[{"origin":"string","code":"too_big","maximum":20,"inclusive":true,"path":["nomenclature"],"message":"Too big: expected string to have <=20 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"}]"# + ); + let ok = parse_grid_query(&q(&[("fournisseur", "x")])).unwrap(); + assert_eq!((ok.compute.as_str(), ok.sort.order.as_str(), ok.sort.enrich.as_str(), ok.page, ok.limit), ("1", "desc", "1", 1, None)); + let ok = parse_grid_query(&q(&[("fournisseur", "x"), ("page", "2"), ("limit", "50"), ("sort", "codein"), ("fields", "")])).unwrap(); + assert_eq!((ok.page, ok.limit, ok.sort.sort.as_deref(), ok.sort.fields.as_deref()), (2, Some(50), Some("codein"), Some(""))); + } + + #[test] + fn autres_schemas_comme_zod() { + assert_eq!( + issues_json(parse_nomenclatures_query(&q(&[])).unwrap_err()), + r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"}]"# + ); + assert_eq!( + issues_json(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "4"), ("parent", "")])).unwrap_err()), + r#"[{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["parent"],"message":"Too small: expected string to have >=1 characters"}]"# + ); + assert_eq!(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "2.0")])).unwrap().niveau, 2); + assert_eq!( + issues_json(parse_fournisseurs_query(&q(&[("withData", "2"), ("search", "")])).unwrap_err()), + r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["search"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["0","1"],"path":["withData"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"# + ); + assert_eq!( + issues_json(parse_product_search(&q(&[("q", "x"), ("page", "1.5"), ("limit", "0")])).unwrap_err()), + r#"[{"expected":"int","format":"safeint","code":"invalid_type","path":["page"],"message":"Invalid input: expected int, received number"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"# + ); + assert_eq!(parse_product_search(&q(&[("q", "x")])).unwrap().limit, 100); + assert_eq!( + issues_json(parse_product_detail(&q(&[("compute", "2")])).unwrap_err()), + r#"[{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"# + ); + } + + #[test] + fn corps_comme_zod() { + assert_eq!( + issues_json(parse_product_gamme_body(&json!({ "gamme": "Q", "compute": "yes", "fournisseur": 3 })).unwrap_err()), + r#"[{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received number"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received string"}]"# + ); + assert_eq!( + issues_json(parse_product_gamme_body(&json!([])).unwrap_err()), + r#"[{"expected":"object","code":"invalid_type","path":[],"message":"Invalid input: expected object, received array"}]"# + ); + assert_eq!( + issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "", "changes": [] })).unwrap_err()), + r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["fournisseur"],"message":"Champ 'fournisseur' requis"},{"origin":"array","code":"too_small","minimum":1,"inclusive":true,"path":["changes"],"message":"Au moins un changement attendu"}]"# + ); + assert_eq!( + issues_json(parse_gammes_batch_body(&json!({ "changes": [{ "codein": "", "gamme": "Q" }, { "gamme": 1 }, 5] })).unwrap_err()), + r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["changes",0,"codein"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",0,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["changes",1,"codein"],"message":"Invalid input: expected string, received undefined"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",1,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"object","code":"invalid_type","path":["changes",2],"message":"Invalid input: expected object, received number"}]"# + ); + assert_eq!( + issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": "x", "compute": null })).unwrap_err()), + r#"[{"expected":"array","code":"invalid_type","path":["changes"],"message":"Invalid input: expected array, received string"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received null"}]"# + ); + let ok = parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": [{ "codein": "1", "gamme": "A" }] })).unwrap(); + assert!(ok.compute); + assert_eq!(ok.changes, vec![GammeChange { codein: "1".into(), gamme: "A".into() }]); + } + + #[test] + fn projection_de_champs() { + let row = json!({ "codein": "1", "libelle1": "L", "totalCa": 3, "gtin": "G" }); + assert_eq!(pick_fields(row.clone(), None), row); + assert_eq!(pick_fields(row.clone(), Some(" , ")), row); + let p = pick_fields(row, Some("totalCa, libelle1,absent")); + assert_eq!(serde_json::to_string(&p).unwrap(), r#"{"totalCa":3,"libelle1":"L","codein":"1"}"#); + assert_eq!(to_sort_key(Some("caReseau")), Some(GridSortKey::CaReseau)); + assert_eq!(to_sort_key(Some("x")), None); + } +} diff --git a/server/src/grid/api_v1.rs b/server/src/grid/api_v1.rs new file mode 100644 index 0000000..b22f845 --- /dev/null +++ b/server/src/grid/api_v1.rs @@ -0,0 +1,68 @@ +//! Routes `/api/v1` du module qui ne dépendent que de l'instantané `grid_rows` +//! (phase 1) : `GET /api/v1/nomenclatures`. + +use super::api_schemas::{parse_nomenclatures_query, query_map}; +use super::store::{get_grid_freshness, list_grid_nomenclatures}; +use crate::api_v1_response::{fail, ok, ApiErrorCode}; +use crate::auth::ApiAuth; +use axum::extract::Query; +use axum::response::Response; +use serde_json::{json, Map, Value}; + +/// `GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…` +/// +/// Répartition des articles d'un fournisseur par poste de nomenclature, avec le +/// nombre d'articles et le CA de chacun. +/// +/// C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent +/// 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni +/// pour une application. On liste d'abord les postes — quelques dizaines de +/// lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste. +/// +/// Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) : +/// niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte +/// un préfixe pour descendre l'arborescence sans tout charger. +pub async fn get_nomenclatures(ApiAuth(_auth): ApiAuth, Query(params): Query>) -> Response { + let params = query_map(params); + let parsed = match parse_nomenclatures_query(¶ms) { + Ok(p) => p, + Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())), + }; + let fournisseur = parsed.fournisseur; + + // Même distinction que /grid : « pas encore calculé » n'est pas « vide ». + let freshness = match get_grid_freshness(&fournisseur).await { + Ok(f) => f, + Err(e) => return fail(ApiErrorCode::InternalError, e.message, None), + }; + let Some(freshness) = freshness else { + return fail( + ApiErrorCode::NotReady, + format!( + "Aucun instantané pour le fournisseur « {fournisseur} ». Appelez /api/v1/grid?fournisseur={fournisseur} une fois pour le calculer." + ), + Some(json!({ "fournisseur": fournisseur })), + ); + }; + + let postes = match list_grid_nomenclatures(&fournisseur, parsed.niveau, parsed.parent.as_deref()).await { + Ok(p) => p, + Err(e) => return fail(ApiErrorCode::InternalError, e.message, None), + }; + let nb_articles: i64 = postes.iter().map(|p| p.nb_articles).sum(); + + let mut meta = Map::new(); + meta.insert("fournisseur".into(), json!(fournisseur)); + meta.insert("niveau".into(), json!(parsed.niveau)); + meta.insert("parent".into(), parsed.parent.map(Value::String).unwrap_or(Value::Null)); + meta.insert("nbPostes".into(), json!(postes.len())); + // Total couvert par les postes listés — à rapprocher de snapshotRowCount. + meta.insert("nbArticles".into(), json!(nb_articles)); + meta.insert("snapshotRowCount".into(), json!(freshness.row_count)); + meta.insert("snapshotComputedAt".into(), json!(freshness.computed_at)); + // Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus. + // `nomenclature` filtre par préfixe et fonctionne aux trois niveaux. + meta.insert("filtreGrid".into(), json!("nomenclature")); + + ok(postes, None, Some(meta)) +} diff --git a/server/src/grid/enregistrer.rs b/server/src/grid/enregistrer.rs new file mode 100644 index 0000000..e4ed60e --- /dev/null +++ b/server/src/grid/enregistrer.rs @@ -0,0 +1,293 @@ +//! Enregistrement des gammes (port de `src/features/grid/api/enregistrer-gammes.ts`, +//! `save-draft-changes.ts` et `gammes-a-valider-actions.ts`). + +use super::gammes_a_valider::{lister_gammes_a_valider, supprimer_gammes_a_valider}; +use super::session::{final_user_id, verifier_session}; +use super::snapshots::date_fr; +use super::zod::{self, child, Issues, Seg}; +use super::{rows_cache, store}; +use crate::db; +use crate::error::AppResult; +use crate::rpc::Ctx; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sqlx::Row; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangementGamme { + pub codein: String, + /// Gamme avant la modification (état serveur), conservée pour l'historique. + pub code_gamme_before: Option, + pub code_gamme: String, +} + +#[derive(Debug, Clone, Default)] +pub struct EnregistrementGammes { + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub magasin: String, + pub changes: Vec, + pub user_id: Option, + /// Libellé du snapshot, « Draft — date » par défaut. + pub label: Option, +} + +/// Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1. +/// +/// Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur, +/// puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`. +/// Lève une erreur si l'écriture du snapshot échoue. +pub async fn enregistrer_gammes(input: &EnregistrementGammes) -> AppResult { + let EnregistrementGammes { code_fournisseur, nom_fournisseur, magasin, changes, user_id, label } = input; + if changes.is_empty() { + return Ok(0); + } + let pool = db::pool()?; + + // Charger le dernier snapshot existant pour merger les changements + let existing = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1") + .bind(code_fournisseur) + .fetch_optional(&pool) + .await?; + let prev_changes: Map = match existing.map(|r| r.try_get::("changes")) { + Some(Ok(Value::Object(m))) => m, + _ => Map::new(), + }; + + // Merger les nouveaux changements par-dessus l'existant + let mut merged_changes = prev_changes; + for c in changes { + merged_changes.insert(c.codein.clone(), json!({ "before": c.code_gamme_before, "after": c.code_gamme })); + } + + // Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur + sqlx::query( + "insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \ + values ($1, $2, $3, $4, $5, $6, null, 'snapshot')", + ) + .bind(user_id) + .bind(code_fournisseur) + .bind(nom_fournisseur) + .bind(magasin) + .bind(label.clone().unwrap_or_else(|| format!("Draft — {}", date_fr()))) + .bind(Value::Object(merged_changes)) + .execute(&pool) + .await?; + + // Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées : + // l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la + // réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer. + rows_cache::patch_grid_rows_cache(code_fournisseur, changes.iter().map(|c| (c.codein.as_str(), c.code_gamme.as_str()))); + let code = code_fournisseur.clone(); + let paires: Vec<(String, String)> = changes.iter().map(|c| (c.codein.clone(), c.code_gamme.clone())).collect(); + tokio::spawn(async move { + if let Err(e) = store::update_grid_rows_gamme(&code, paires.iter().map(|(c, g)| (c.as_str(), g.as_str()))).await { + tracing::error!("[enregistrerGammes] mise à jour grid_rows KO: {}", crate::error::short(&e.message, 200)); + } + }); + + Ok(changes.len()) +} + +// --------------------------------------------------------------------------- +// saveDraftChanges +// --------------------------------------------------------------------------- + +/// Données validées de `SaveDraftsSchema`. +#[derive(Debug, Clone)] +pub struct SaveDraftsInput { + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub magasin: String, + pub changes: Vec, + /// Propositions de l'API examinées dans la Grille : validées (présentes dans + /// `changes`) ou écartées. Elles quittent la file « à valider ». + pub gammes_a_valider_traitees: Vec, +} + +/// `z.array(z.string())`. +fn tableau_de_chaines(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option> { + let Some(Value::Array(a)) = v else { + issues.invalid_type(chemin, "array", v); + return None; + }; + let mut out = Vec::with_capacity(a.len()); + let mut ok = true; + for (i, x) in a.iter().enumerate() { + match zod::string(Some(x), &child(chemin, i), issues) { + Some(s) => out.push(s), + None => ok = false, + } + } + ok.then_some(out) +} + +/// `SaveDraftsSchema.safeParse(raw)`. +pub fn parse_save_drafts(raw: &Value) -> Result { + let mut issues = Issues::default(); + let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) }; + let p = |k: &str| vec![Seg::from(k)]; + + let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues); + let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues); + let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues); + + let chemin = p("changes"); + let mut changes = Vec::new(); + match o.get("changes") { + Some(Value::Array(a)) => { + for (i, x) in a.iter().enumerate() { + let ci = child(&chemin, i); + let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue }; + let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues); + let before = zod::nullable_string(c.get("codeGammeBefore"), &child(&ci, "codeGammeBefore"), &mut issues); + let gamme = zod::string(c.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues); + if let (Some(codein), Some(code_gamme_before), Some(code_gamme)) = (codein, before, gamme) { + changes.push(ChangementGamme { codein, code_gamme_before, code_gamme }); + } + } + } + autre => issues.invalid_type(&chemin, "array", autre), + } + + let traitees = match o.get("gammesAValiderTraitees") { + None => Some(Vec::new()), + v => tableau_de_chaines(v, &p("gammesAValiderTraitees"), &mut issues), + }; + + if !issues.is_empty() { + return Err(issues); + } + Ok(SaveDraftsInput { + code_fournisseur: code_fournisseur.unwrap_or_default(), + nom_fournisseur: nom_fournisseur.flatten(), + magasin: magasin.unwrap_or_default(), + changes, + gammes_a_valider_traitees: traitees.unwrap_or_default(), + }) +} + +/// `saveDraftChanges` une fois la session vérifiée : `{ success, saved, error? }`. +pub async fn enregistrer_brouillon(user_raw_id: &str, raw: &Value) -> Value { + let d = match parse_save_drafts(raw) { + Ok(d) => d, + Err(issues) => return json!({ "success": false, "saved": 0, "error": format!("Validation failed: {}", issues.message()) }), + }; + let user_id = final_user_id(user_raw_id).map(|v| v as i64); + let res: AppResult = async { + let saved = enregistrer_gammes(&EnregistrementGammes { + code_fournisseur: d.code_fournisseur.clone(), + nom_fournisseur: d.nom_fournisseur.clone(), + magasin: d.magasin.clone(), + changes: d.changes.clone(), + user_id, + label: None, + }) + .await?; + supprimer_gammes_a_valider(&d.code_fournisseur, &d.gammes_a_valider_traitees).await?; + Ok(saved) + } + .await; + match res { + Ok(saved) => json!({ "success": true, "saved": saved }), + Err(e) => json!({ "success": false, "saved": 0, "error": e.message }), + } +} + +/// Action `saveDraftChanges(raw)`. +async fn save_draft_changes(ctx: Ctx) -> AppResult { + let utilisateur = match verifier_session(&ctx).await? { + Ok(u) => u, + Err(message) => return Ok(json!({ "success": false, "saved": 0, "error": message })), + }; + Ok(enregistrer_brouillon(&utilisateur.id, &ctx.raw(0)).await) +} + +// --------------------------------------------------------------------------- +// Gammes à valider (actions) +// --------------------------------------------------------------------------- + +/// Action `getGammesAValider(codeFournisseur)` : gammes proposées par l'API pour +/// un fournisseur, en attente de validation. +async fn get_gammes_a_valider(ctx: Ctx) -> AppResult { + if let Err(message) = verifier_session(&ctx).await? { + return Ok(json!({ "success": false, "propositions": [], "error": message })); + } + let code = super::js::js_string(ctx.args.first()); + match lister_gammes_a_valider(&code).await { + Ok(propositions) => Ok(json!({ "success": true, "propositions": propositions })), + Err(e) => Ok(json!({ "success": false, "propositions": [], "error": e.message })), + } +} + +/// `RejetSchema.safeParse(raw)` → `(codeFournisseur, codeins)`. +pub fn parse_rejet(raw: &Value) -> Result<(String, Vec), Issues> { + let mut issues = Issues::default(); + let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) }; + let chemin = vec![Seg::from("codeFournisseur")]; + let code = zod::string(o.get("codeFournisseur"), &chemin, &mut issues); + if let Some(c) = &code { + zod::check_len(c, &chemin, Some((1, None)), None, &mut issues); + } + let codeins = tableau_de_chaines(o.get("codeins"), &[Seg::from("codeins")], &mut issues); + if !issues.is_empty() { + return Err(issues); + } + Ok((code.unwrap_or_default(), codeins.unwrap_or_default())) +} + +/// Action `rejeterGammesAValider(raw)` : rejette des propositions de l'API +/// (« Annuler les modifications » dans la Grille). +async fn rejeter_gammes_a_valider(ctx: Ctx) -> AppResult { + if let Err(message) = verifier_session(&ctx).await? { + return Ok(json!({ "success": false, "error": message })); + } + let (code, codeins) = match parse_rejet(&ctx.raw(0)) { + Ok(v) => v, + Err(issues) => return Ok(json!({ "success": false, "error": format!("Validation failed: {}", issues.message()) })), + }; + match supprimer_gammes_a_valider(&code, &codeins).await { + Ok(()) => Ok(json!({ "success": true })), + Err(e) => Ok(json!({ "success": false, "error": e.message })), + } +} + +pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> { + vec![ + ("saveDraftChanges", crate::handler!(save_draft_changes)), + ("getGammesAValider", crate::handler!(get_gammes_a_valider)), + ("rejeterGammesAValider", crate::handler!(rejeter_gammes_a_valider)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_brouillon_comme_zod() { + let raw = json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a" }], "gammesAValiderTraitees": "x" }); + let issues = parse_save_drafts(&raw).unwrap_err(); + assert_eq!( + serde_json::to_string(&issues.to_json()).unwrap(), + r#"[{"expected":"string","code":"invalid_type","path":["changes",0,"codeGammeBefore"],"message":"Invalid input: expected string, received undefined"},{"expected":"string","code":"invalid_type","path":["changes",0,"codeGamme"],"message":"Invalid input: expected string, received undefined"},{"expected":"array","code":"invalid_type","path":["gammesAValiderTraitees"],"message":"Invalid input: expected array, received string"}]"# + ); + let ok = parse_save_drafts( + &json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a", "codeGammeBefore": null, "codeGamme": "B" }] }), + ) + .unwrap(); + assert_eq!(ok.changes[0].code_gamme, "B"); + assert!(ok.gammes_a_valider_traitees.is_empty()); + } + + #[test] + fn validation_rejet_comme_zod() { + let issues = parse_rejet(&json!({ "codeFournisseur": "", "codeins": [1] })).unwrap_err(); + assert_eq!( + issues.message(), + "[\n {\n \"origin\": \"string\",\n \"code\": \"too_small\",\n \"minimum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"codeFournisseur\"\n ],\n \"message\": \"Too small: expected string to have >=1 characters\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"codeins\",\n 0\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]" + ); + assert_eq!(parse_rejet(&json!(5)).unwrap_err().0.len(), 1); + } +} diff --git a/server/src/grid/exports.rs b/server/src/grid/exports.rs new file mode 100644 index 0000000..c38ffe7 --- /dev/null +++ b/server/src/grid/exports.rs @@ -0,0 +1,402 @@ +//! Exports Excel de la Grille (port de `src/app/api/export/excel/route.ts` et +//! `src/app/api/export/modified-gammes/route.ts`, ExcelJS → rust_xlsxwriter). +//! +//! Mêmes feuilles, colonnes, largeurs, styles d'en-tête, formats et noms de +//! fichier. Le découpage du fichier d'import des gammes en lots de 5 000 lignes +//! (`LIGNES_MAX_PAR_FICHIER_GAMMES`) se fait côté interface, qui appelle +//! `/api/export/modified-gammes` une fois par lot : la route ne fait que mettre en +//! forme les lignes reçues. + +use super::js; +use super::zod::{self, child, Issues, Seg}; +use crate::auth::SessionUser; +use crate::error::{AppError, AppResult}; +use axum::body::Bytes; +use axum::http::{header, HeaderValue}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use rust_xlsxwriter::{Color, Format, FormatAlign, FormatBorder, FormatPattern, Workbook, Worksheet, XlsxError}; +use serde_json::{json, Value}; + +const CONTENT_TYPE_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + +/// Largeur ExcelJS (`width: 14`) : valeur écrite telle quelle dans ``. +/// rust_xlsxwriter ajoute la marge de cellule à une largeur en caractères ; en +/// pixels (7 px par caractère), il écrit exactement `width`. +fn largeur(ws: &mut Worksheet, col: u16, width: u32) -> Result<(), XlsxError> { + ws.set_column_width_pixels(col, width * 7)?; + Ok(()) +} + +/// Style d'en-tête commun aux deux exports. +fn style_entete() -> Format { + Format::new() + .set_bold() + .set_font_color(Color::RGB(0xFFFFFF)) + .set_pattern(FormatPattern::Solid) + .set_background_color(Color::RGB(0x0F172A)) + .set_align(FormatAlign::Center) + .set_border_bottom(FormatBorder::Thin) + .set_border_bottom_color(Color::RGB(0x334155)) +} + +fn xlsx_err(e: XlsxError) -> AppError { + AppError::internal(e.to_string()) +} + +/// `await req.json()` : un corps illisible lève une exception (500 côté Next). +fn lire_json(body: &Bytes) -> AppResult { + serde_json::from_slice(body).map_err(|e| AppError::internal(format!("Corps JSON illisible : {e}"))) +} + +fn payload_invalide() -> Response { + (axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid payload" }))).into_response() +} + +/// `${prefixe}_${nomFournisseur.replace(/\s+/g, "_")}_${YYYY-MM-DD}.xlsx` (date UTC, +/// comme `toISOString().slice(0, 10)`). +pub fn nom_fichier(prefixe: &str, nom_fournisseur: &str) -> String { + format!("{prefixe}_{}_{}.xlsx", js::replace_whitespace_runs(nom_fournisseur, "_"), chrono::Utc::now().format("%Y-%m-%d")) +} + +/// `attachment; filename="…"`. Comme Node, l'en-tête est écrit octet par octet en +/// Latin-1 : un caractère au-delà de U+00FF (ou un caractère de contrôle) est +/// refusé, ce qui faisait échouer la route Next en 500. +pub fn content_disposition(filename: &str) -> AppResult { + let mut octets = Vec::with_capacity(filename.len() + 32); + octets.extend_from_slice(b"attachment; filename=\""); + for c in filename.chars() { + let v = c as u32; + if v > 0xFF || (v < 0x20 && v != 0x09) || v == 0x7F { + return Err(AppError::internal(format!("Invalid character in header content [\"Content-Disposition\"] : {c:?}"))); + } + octets.push(v as u8); + } + octets.push(b'"'); + HeaderValue::from_bytes(&octets).map_err(|e| AppError::internal(e.to_string())) +} + +fn reponse_xlsx(buffer: Vec, filename: &str) -> AppResult { + let disposition = content_disposition(filename)?; + Ok(([(header::CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_XLSX)), (header::CONTENT_DISPOSITION, disposition)], buffer) + .into_response()) +} + +// --------------------------------------------------------------------------- +// POST /api/export/excel +// --------------------------------------------------------------------------- + +/// Ligne de `ExportSchema`. +#[derive(Debug, Clone, PartialEq)] +pub struct ExportRow { + pub codein: String, + pub libelle1: String, + pub code3: String, + pub libelle3: String, + pub code_gamme: Option, + pub total_quantite: f64, + pub total_ca: f64, + pub taux_marge: f64, + /// Before gamme for delta + pub gamme_avant: Option, +} + +/// `ExportSchema.safeParse(body)` → `(nomFournisseur, rows)`. +pub fn parse_export(body: &Value) -> Result<(String, Vec), Issues> { + let mut issues = Issues::default(); + let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) }; + let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues); + let chemin = vec![Seg::from("rows")]; + let mut rows = Vec::new(); + match o.get("rows") { + Some(Value::Array(a)) => { + for (i, x) in a.iter().enumerate() { + let ci = child(&chemin, i); + let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue }; + let s = |k: &str, issues: &mut Issues| zod::string(r.get(k), &child(&ci, k), issues); + let codein = s("codein", &mut issues); + let libelle1 = s("libelle1", &mut issues); + let code3 = s("code3", &mut issues); + let libelle3 = s("libelle3", &mut issues); + let code_gamme = zod::nullable_string(r.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues); + let n = |k: &str, issues: &mut Issues| zod::number(r.get(k), &child(&ci, k), issues); + let total_quantite = n("totalQuantite", &mut issues); + let total_ca = n("totalCa", &mut issues); + let taux_marge = n("tauxMarge", &mut issues); + let gamme_avant = match r.get("gammeAvant") { + None => Some(None), + v => zod::nullable_string(v, &child(&ci, "gammeAvant"), &mut issues), + }; + if let ( + Some(codein), + Some(libelle1), + Some(code3), + Some(libelle3), + Some(code_gamme), + Some(total_quantite), + Some(total_ca), + Some(taux_marge), + Some(gamme_avant), + ) = (codein, libelle1, code3, libelle3, code_gamme, total_quantite, total_ca, taux_marge, gamme_avant) + { + rows.push(ExportRow { + codein, + libelle1, + code3, + libelle3, + code_gamme, + total_quantite, + total_ca, + taux_marge, + gamme_avant, + }); + } + } + } + autre => issues.invalid_type(&chemin, "array", autre), + } + if !issues.is_empty() { + return Err(issues); + } + Ok((nom.unwrap_or_default(), rows)) +} + +/// Gamme colours +fn couleur_gamme(code: &str) -> Option { + match code { + "A" => Some(0x064E3B), + "B" => Some(0x1E3A5F), + "C" => Some(0x78350F), + "Z" => Some(0x4C0519), + _ => None, + } +} + +/// Classeur « Assortiment » (tous les produits, gamme avant/après, chiffres 12 mois). +pub fn classeur_assortiment(rows: &[ExportRow]) -> Result, XlsxError> { + let mut workbook = Workbook::new(); + workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow")); + let sheet = workbook.add_worksheet(); + sheet.set_name("Assortiment")?; + sheet.set_freeze_panes(1, 0)?; + + let entete = style_entete(); + let colonnes: [(&str, u32); 9] = [ + ("Code", 14), + ("Désignation", 40), + ("Nomenclature", 25), + ("Gamme Avant", 14), + ("Gamme Après", 14), + ("Delta", 10), + ("Vol. 12m", 12), + ("CA 12m (€)", 14), + ("Marge (%)", 12), + ]; + for (i, (titre, w)) in colonnes.iter().enumerate() { + largeur(sheet, i as u16, *w)?; + sheet.write_string_with_format(0, i as u16, *titre, &entete)?; + } + sheet.set_row_height(0, 22)?; + + let fmt_ca = Format::new().set_num_format("#,##0.00 \"€\""); + let fmt_marge = Format::new().set_num_format("0.00\"%\""); + let fmt_delta = Format::new().set_bold().set_font_color(Color::RGB(0xFBBF24)); + + for (i, row) in rows.iter().enumerate() { + let r = (i + 1) as u32; + let delta = if row.gamme_avant != row.code_gamme { + format!("{} → {}", row.gamme_avant.as_deref().unwrap_or("—"), row.code_gamme.as_deref().unwrap_or("—")) + } else { + String::new() + }; + sheet.write_string(r, 0, &row.codein)?; + sheet.write_string(r, 1, &row.libelle1)?; + sheet.write_string(r, 2, &row.libelle3)?; + sheet.write_string(r, 3, row.gamme_avant.as_deref().unwrap_or("—"))?; + + // Colour the "Gamme Après" cell + let gamme_apres = row.code_gamme.as_deref().unwrap_or("—"); + match row.code_gamme.as_deref().and_then(couleur_gamme) { + Some(c) => { + let f = Format::new() + .set_pattern(FormatPattern::Solid) + .set_background_color(Color::RGB(c)) + .set_bold() + .set_font_color(Color::RGB(0xFFFFFF)); + sheet.write_string_with_format(r, 4, gamme_apres, &f)?; + } + None => { + sheet.write_string(r, 4, gamme_apres)?; + } + } + + // Highlight changed rows + if delta.is_empty() { + sheet.write_string(r, 5, &delta)?; + } else { + sheet.write_string_with_format(r, 5, &delta, &fmt_delta)?; + } + + sheet.write_number(r, 6, js::js_round(row.total_quantite))?; + // Format numbers + sheet.write_number_with_format(r, 7, js::js_to_fixed2(row.total_ca), &fmt_ca)?; + sheet.write_number_with_format(r, 8, js::js_to_fixed2(row.taux_marge), &fmt_marge)?; + } + + workbook.save_to_buffer() +} + +/// `POST /api/export/excel`. +pub async fn export_excel(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult { + let body = lire_json(&body)?; + let Ok((nom_fournisseur, rows)) = parse_export(&body) else { return Ok(payload_invalide()) }; + let buffer = classeur_assortiment(&rows).map_err(xlsx_err)?; + reponse_xlsx(buffer, &nom_fichier("Assortiment", &nom_fournisseur)) +} + +// --------------------------------------------------------------------------- +// POST /api/export/modified-gammes +// --------------------------------------------------------------------------- + +/// Ligne de `ExportModifiedGammesSchema`. +#[derive(Debug, Clone, PartialEq)] +pub struct GammeModifiee { + pub codein: String, + pub gtin: String, + pub code_fournisseur: Option, + pub gamme: String, +} + +/// `ExportModifiedGammesSchema.safeParse(body)` → `(nomFournisseur, changes)`. +pub fn parse_modified_gammes(body: &Value) -> Result<(String, Vec), Issues> { + let mut issues = Issues::default(); + let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) }; + let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues); + let chemin = vec![Seg::from("changes")]; + let mut changes = Vec::new(); + match o.get("changes") { + Some(Value::Array(a)) => { + for (i, x) in a.iter().enumerate() { + let ci = child(&chemin, i); + let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue }; + let codein = zod::string(r.get("codein"), &child(&ci, "codein"), &mut issues); + let gtin = zod::string(r.get("gtin"), &child(&ci, "gtin"), &mut issues); + let code_fournisseur = zod::opt_string(r.get("codeFournisseur"), &child(&ci, "codeFournisseur"), &mut issues); + let gamme = zod::string(r.get("gamme"), &child(&ci, "gamme"), &mut issues); + if let (Some(codein), Some(gtin), Some(code_fournisseur), Some(gamme)) = (codein, gtin, code_fournisseur, gamme) { + changes.push(GammeModifiee { codein, gtin, code_fournisseur, gamme }); + } + } + } + autre => issues.invalid_type(&chemin, "array", autre), + } + if !issues.is_empty() { + return Err(issues); + } + Ok((nom.unwrap_or_default(), changes)) +} + +/// `x || "—"`. +fn ou_tiret(s: &str) -> &str { + if s.is_empty() { + "—" + } else { + s + } +} + +/// Classeur « Gammes Modifiées » (CODE FOURNISSEUR, CODEIN, GAMME) : le fichier +/// d'import des gammes. +pub fn classeur_gammes_modifiees(changes: &[GammeModifiee]) -> Result, XlsxError> { + let mut workbook = Workbook::new(); + workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow")); + let sheet = workbook.add_worksheet(); + sheet.set_name("Gammes Modifiées")?; + sheet.set_freeze_panes(1, 0)?; + + // Format en texte pour eviter la notation scientifique excel ; centrer la + // colonne Gamme. ExcelJS applique ces styles de colonne à toutes les cellules + // déjà écrites, en-tête compris. « @ » = format intégré n° 49, comme ExcelJS. + let texte = Format::new().set_num_format_index(49); + let centre = Format::new().set_align(FormatAlign::Center); + let entete = style_entete(); + let entete_texte = style_entete().set_num_format_index(49); + + largeur(sheet, 0, 22)?; + largeur(sheet, 1, 18)?; + largeur(sheet, 2, 12)?; + sheet.set_column_format(0, &texte)?; + sheet.set_column_format(1, &texte)?; + sheet.set_column_format(2, ¢re)?; + + sheet.write_string_with_format(0, 0, "CODE FOURNISSEUR", &entete_texte)?; + sheet.write_string_with_format(0, 1, "CODEIN", &entete_texte)?; + sheet.write_string_with_format(0, 2, "GAMME", &entete)?; + sheet.set_row_height(0, 22)?; + + for (i, row) in changes.iter().enumerate() { + let r = (i + 1) as u32; + sheet.write_string_with_format(r, 0, ou_tiret(row.code_fournisseur.as_deref().unwrap_or("")), &texte)?; + sheet.write_string_with_format(r, 1, ou_tiret(&row.codein), &texte)?; + sheet.write_string_with_format(r, 2, ou_tiret(&row.gamme), ¢re)?; + } + + workbook.save_to_buffer() +} + +/// `POST /api/export/modified-gammes`. +pub async fn export_modified_gammes(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult { + let body = lire_json(&body)?; + let Ok((nom_fournisseur, changes)) = parse_modified_gammes(&body) else { return Ok(payload_invalide()) }; + let buffer = classeur_gammes_modifiees(&changes).map_err(xlsx_err)?; + reponse_xlsx(buffer, &nom_fichier("Modifications_Gammes", &nom_fournisseur)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Écrit les deux classeurs d'exemple dans `GRID_EXPORT_DIR` (pour inspection). + #[test] + fn classeurs_d_exemple() { + let (_, rows) = parse_export(&json!({ + "nomFournisseur": "Le Fournisseur", + "rows": [ + { "codein": "123456", "libelle1": "Bougie parfumée", "code3": "320211", "libelle3": "Bougies", "codeGamme": "A", "totalQuantite": 12.5, "totalCa": 1234.125, "tauxMarge": 41.2345, "gammeAvant": "B" }, + { "codein": "000789", "libelle1": "Vase", "code3": "320212", "libelle3": "Vases", "codeGamme": null, "totalQuantite": -2.5, "totalCa": 10, "tauxMarge": 0, "gammeAvant": null }, + { "codein": "42", "libelle1": "", "code3": "", "libelle3": "", "codeGamme": "Y", "totalQuantite": 3, "totalCa": 0.125, "tauxMarge": 1.005, "gammeAvant": "Y" } + ] + })) + .unwrap(); + let a = classeur_assortiment(&rows).unwrap(); + let (_, changes) = parse_modified_gammes(&json!({ + "nomFournisseur": "X", + "changes": [ + { "codein": "000123", "gtin": "3700", "codeFournisseur": "F001", "gamme": "A" }, + { "codein": "", "gtin": "", "gamme": "" } + ] + })) + .unwrap(); + let b = classeur_gammes_modifiees(&changes).unwrap(); + assert!(a.starts_with(b"PK") && b.starts_with(b"PK")); + if let Ok(dir) = std::env::var("GRID_EXPORT_DIR") { + std::fs::write(format!("{dir}/rust_excel.xlsx"), a).unwrap(); + std::fs::write(format!("{dir}/rust_modified.xlsx"), b).unwrap(); + } + } + + #[test] + fn validation_et_entetes() { + assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [{ "codein": "a" }] })).is_err()); + assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [] })).is_ok()); + assert!(parse_modified_gammes( + &json!({ "nomFournisseur": "x", "changes": [{ "codein": "a", "gtin": "g", "gamme": "A", "codeFournisseur": 3 }] }) + ) + .is_err()); + let nom = nom_fichier("Assortiment", "Le Fournisseur\tÉté"); + assert!(nom.starts_with("Assortiment_Le_Fournisseur_Été_") && nom.ends_with(".xlsx")); + let h = content_disposition("Assortiment_Été.xlsx").unwrap(); + assert_eq!(h.as_bytes(), b"attachment; filename=\"Assortiment_\xc9t\xe9.xlsx\""); + assert!(content_disposition("Fournisseur_œ.xlsx").is_err()); + } +} diff --git a/server/src/grid/gammes.rs b/server/src/grid/gammes.rs new file mode 100644 index 0000000..6293ad9 --- /dev/null +++ b/server/src/grid/gammes.rs @@ -0,0 +1,70 @@ +//! Les gammes d'assortiment : code, nom et couleurs — source unique +//! (port de `src/lib/gammes.ts`, utilisé par la validation `/api/v1` et le +//! document OpenAPI). + +pub struct Gamme { + pub code: &'static str, + /// Nom court : « Cœur », « Sortie »… + pub nom: &'static str, + /// Phrase d'explication pour les infobulles. + pub description: &'static str, + /// Classes de pastille (fond, bordure, texte) lisibles en clair comme en sombre. + pub classes: &'static str, +} + +pub const GAMMES: [Gamme; 5] = [ + Gamme { + code: "A", + nom: "Cœur", + description: "Produit permanent, au cœur de l'assortiment.", + classes: "border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300", + }, + Gamme { + code: "B", + nom: "Complémentaire", + description: "Produit qui complète l'offre du rayon.", + classes: "border-blue-500/40 bg-blue-500/10 text-blue-800 dark:text-blue-300", + }, + Gamme { + code: "C", + nom: "Saisonnier", + description: "Produit présent selon la saison.", + classes: "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300", + }, + Gamme { + code: "Y", + nom: "En veille", + description: "Produit mis en attente : il n'est plus commandé pour l'instant.", + classes: "border-violet-500/40 bg-violet-500/10 text-violet-800 dark:text-violet-300", + }, + Gamme { + code: "Z", + nom: "Sortie", + description: "Produit à retirer de l'assortiment.", + classes: "border-rose-500/40 bg-rose-500/10 text-rose-800 dark:text-rose-300", + }, +]; + +/// Pastille d'un produit sans gamme. +pub const CLASSES_SANS_GAMME: &str = "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300"; + +/// Codes de gamme, dans l'ordre de [`GAMMES`]. +pub fn codes_gamme() -> Vec<&'static str> { + GAMMES.iter().map(|g| g.code).collect() +} + +pub fn trouver_gamme(code: Option<&str>) -> Option<&'static Gamme> { + let code = code.filter(|c| !c.is_empty())?; + let c = super::js::js_trim(code); + GAMMES.iter().find(|g| g.code == c) +} + +#[cfg(test)] +mod tests { + #[test] + fn coherent_avec_le_socle() { + assert_eq!(super::codes_gamme(), crate::domain::CODES_GAMME.to_vec()); + assert_eq!(super::trouver_gamme(Some(" Z ")).unwrap().nom, "Sortie"); + assert!(super::trouver_gamme(None).is_none()); + } +} diff --git a/server/src/grid/gammes_a_valider.rs b/server/src/grid/gammes_a_valider.rs new file mode 100644 index 0000000..ae69276 --- /dev/null +++ b/server/src/grid/gammes_a_valider.rs @@ -0,0 +1,129 @@ +//! CollectFlow — Gammes proposées par l'API, en attente de validation +//! (port de `src/lib/gammes-a-valider.ts`). +//! +//! L'API `/api/v1` ne change plus une gamme directement : elle dépose une +//! proposition, que la Grille affiche comme une modification non enregistrée. +//! L'utilisateur voit ainsi quels produits changent de gamme, puis valide +//! (« Enregistrer ») ou rejette (« Annuler les modifications »). + +use crate::db; +use crate::error::AppResult; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::{Postgres, QueryBuilder, Row}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PropositionGamme { + pub codein: String, + /// Gamme proposée. + pub gamme: String, + /// Gamme enregistrée au moment de la proposition. + pub gamme_precedente: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GammeAValider { + pub codein: String, + /// Gamme proposée. + pub gamme: String, + /// Gamme enregistrée au moment de la proposition. + pub gamme_precedente: Option, + pub auteur: Option, + /// Date ISO de la proposition. + pub proposee_le: String, +} + +/// Dépose des propositions (la dernière l'emporte pour un même article) et retire +/// celles des articles `annules` — redemander la gamme déjà enregistrée annule la +/// proposition en attente. +pub async fn proposer_gammes(code_fournisseur: &str, propositions: &[PropositionGamme], annules: &[String], auteur: &str) -> AppResult<()> { + let pool = db::pool()?; + let mut tx = pool.begin().await?; + if !annules.is_empty() { + sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)") + .bind(code_fournisseur) + .bind(annules) + .execute(&mut *tx) + .await?; + } + if !propositions.is_empty() { + let maintenant = chrono::Utc::now().naive_utc(); + let auteur: String = auteur.chars().take(100).collect(); + let mut qb: QueryBuilder = + QueryBuilder::new("insert into gammes_a_valider (code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at) "); + qb.push_values(propositions, |mut b, p| { + b.push_bind(code_fournisseur.to_string()) + .push_bind(p.codein.clone()) + .push_bind(p.gamme.clone()) + .push_bind(p.gamme_precedente.clone()) + .push_bind(auteur.clone()) + .push_bind(maintenant); + }); + qb.push( + " on conflict (code_fournisseur, codein) do update set gamme = excluded.gamme, \ + gamme_precedente = excluded.gamme_precedente, auteur = excluded.auteur, created_at = excluded.created_at", + ); + qb.build().execute(&mut *tx).await?; + } + tx.commit().await?; + Ok(()) +} + +/// Propositions en attente pour un fournisseur. +pub async fn lister_gammes_a_valider(code_fournisseur: &str) -> AppResult> { + let pool = db::pool()?; + let rows = sqlx::query( + "select code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at from gammes_a_valider \ + where code_fournisseur = $1", + ) + .bind(code_fournisseur) + .fetch_all(&pool) + .await?; + Ok(rows + .iter() + .map(|r| GammeAValider { + codein: r.try_get("codein").unwrap_or_default(), + gamme: r.try_get("gamme").unwrap_or_default(), + gamme_precedente: r.try_get("gamme_precedente").ok().flatten(), + auteur: r.try_get("auteur").ok().flatten(), + proposee_le: r.try_get::("created_at").map(db::iso).unwrap_or_default(), + }) + .collect()) +} + +/// Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`. +pub async fn gammes_a_valider_par_article(codeins: &[String]) -> AppResult> { + if codeins.is_empty() { + return Ok(HashMap::new()); + } + let pool = db::pool()?; + let rows = sqlx::query("select code_fournisseur, codein, gamme from gammes_a_valider where codein = any($1)") + .bind(codeins) + .fetch_all(&pool) + .await?; + Ok(rows + .iter() + .map(|r| { + let f: String = r.try_get("code_fournisseur").unwrap_or_default(); + let c: String = r.try_get("codein").unwrap_or_default(); + (format!("{f}|{c}"), r.try_get("gamme").unwrap_or_default()) + }) + .collect()) +} + +/// Retire des propositions, une fois validées ou rejetées dans la Grille. +pub async fn supprimer_gammes_a_valider(code_fournisseur: &str, codeins: &[String]) -> AppResult<()> { + if codeins.is_empty() { + return Ok(()); + } + let pool = db::pool()?; + sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)") + .bind(code_fournisseur) + .bind(codeins) + .execute(&pool) + .await?; + Ok(()) +} diff --git a/server/src/grid/js.rs b/server/src/grid/js.rs new file mode 100644 index 0000000..f47f43f --- /dev/null +++ b/server/src/grid/js.rs @@ -0,0 +1,472 @@ +//! Équivalents des conversions JavaScript dont dépend le comportement du code TS +//! d'origine : `Number(…)`, `parseInt(…, 10)`, `String(nombre)`, `Math.round`, +//! `toFixed`, et la sérialisation JSON des nombres (`12` et non `12.0`). +//! +//! Les nombres de `ProductRow` sont des `f64` ; `JSON.stringify` écrit un entier +//! sans décimale et `NaN` en `null`. Les modules de sérialisation ci-dessous +//! reproduisent ces deux règles, et relisent un `null` en `NaN` pour qu'une ligne +//! persistée par la version Node fasse l'aller-retour à l'identique. + +use serde::de::Deserializer; +use serde::ser::{SerializeMap, Serializer}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::sync::LazyLock; + +/// Espace blanc au sens de JS (`trim`, `\s`). +pub fn is_js_whitespace(c: char) -> bool { + matches!( + c, + '\u{9}'..='\u{d}' + | ' ' + | '\u{a0}' + | '\u{1680}' + | '\u{2000}'..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +/// `s.trim()`. +pub fn js_trim(s: &str) -> &str { + s.trim_matches(is_js_whitespace) +} + +/// `s.replace(/\s+/g, remplacement)`. +pub fn replace_whitespace_runs(s: &str, remplacement: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut dans_blanc = false; + for c in s.chars() { + if is_js_whitespace(c) { + if !dans_blanc { + out.push_str(remplacement); + dans_blanc = true; + } + } else { + dans_blanc = false; + out.push(c); + } + } + out +} + +static DECIMAL: LazyLock = + LazyLock::new(|| regex::Regex::new(r"^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$").unwrap()); + +/// `Number(s)` pour une chaîne (coercition de `z.coerce.number()`). +pub fn js_number_from_str(s: &str) -> f64 { + let t = js_trim(s); + if t.is_empty() { + return 0.0; + } + match t { + "Infinity" | "+Infinity" => return f64::INFINITY, + "-Infinity" => return f64::NEG_INFINITY, + _ => {} + } + let base = |chiffres: &str, radix: u32| -> f64 { + if chiffres.is_empty() { + return f64::NAN; + } + let mut v = 0f64; + for c in chiffres.chars() { + match c.to_digit(radix) { + Some(d) => v = v * radix as f64 + d as f64, + None => return f64::NAN, + } + } + v + }; + let prefixe = |p: &[&str]| p.iter().find_map(|x| t.strip_prefix(x)); + if let Some(r) = prefixe(&["0x", "0X"]) { + return base(r, 16); + } + if let Some(r) = prefixe(&["0o", "0O"]) { + return base(r, 8); + } + if let Some(r) = prefixe(&["0b", "0B"]) { + return base(r, 2); + } + if !DECIMAL.is_match(t) { + return f64::NAN; + } + t.parse::().unwrap_or(f64::NAN) +} + +/// `parseInt(s, 10)` : préfixe numérique, `NaN` s'il n'y en a pas. +pub fn js_parse_int(s: &str) -> f64 { + let t = s.trim_start_matches(is_js_whitespace); + let (negatif, reste) = match t.as_bytes().first() { + Some(b'-') => (true, &t[1..]), + Some(b'+') => (false, &t[1..]), + _ => (false, t), + }; + let fin = reste.bytes().take_while(|b| b.is_ascii_digit()).count(); + if fin == 0 { + return f64::NAN; + } + let v: f64 = reste[..fin].parse().unwrap_or(f64::NAN); + if negatif { + -v + } else { + v + } +} + +/// `String(x)` pour un nombre (`Number.prototype.toString()`). +pub fn js_number_to_string(x: f64) -> String { + if x.is_nan() { + return "NaN".into(); + } + if x == 0.0 { + return "0".into(); + } + if x.is_infinite() { + return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() }; + } + let signe = if x < 0.0 { "-" } else { "" }; + // `{:e}` donne les chiffres les plus courts qui relisent la même valeur, + // exactement ceux que retient l'algorithme de JS. + let e = format!("{:e}", x.abs()); + let (mantisse, exposant) = e.split_once('e').unwrap_or((&e, "0")); + let chiffres: String = mantisse.chars().filter(|c| *c != '.').collect(); + let k = chiffres.len() as i64; + let n = exposant.parse::().unwrap_or(0) + 1; + let corps = if k <= n && n <= 21 { + format!("{chiffres}{}", "0".repeat((n - k) as usize)) + } else if 0 < n && n <= 21 { + format!("{}.{}", &chiffres[..n as usize], &chiffres[n as usize..]) + } else if -6 < n && n <= 0 { + format!("0.{}{chiffres}", "0".repeat((-n) as usize)) + } else { + let reste = if k > 1 { format!(".{}", &chiffres[1..]) } else { String::new() }; + let e = n - 1; + format!("{}{reste}e{}{}", &chiffres[..1], if e < 0 { "-" } else { "+" }, e.abs()) + }; + format!("{signe}{corps}") +} + +/// `String(v)` pour une valeur JSON quelconque (argument d'action non typé). +pub fn js_string(v: Option<&Value>) -> String { + match v { + None => "undefined".into(), + Some(Value::Null) => "null".into(), + Some(Value::Bool(b)) => b.to_string(), + Some(Value::Number(n)) => js_number_to_string(n.as_f64().unwrap_or(f64::NAN)), + Some(Value::String(s)) => s.clone(), + Some(Value::Array(a)) => a + .iter() + .map(|x| match x { + Value::Null => String::new(), + autre => js_string(Some(autre)), + }) + .collect::>() + .join(","), + Some(Value::Object(_)) => "[object Object]".into(), + } +} + +/// Valeur « vraie » au sens de JS. +pub fn js_truthy(v: Option<&Value>) -> bool { + match v { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false), + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +/// `Math.round(x)` : demi arrondi vers +∞ (`Math.round(-2.5) === -2`). +pub fn js_round(x: f64) -> f64 { + if !x.is_finite() { + return x; + } + let f = x.floor(); + if x - f >= 0.5 { + f + 1.0 + } else { + f + } +} + +/// `parseFloat(x.toFixed(2))`. +/// +/// `toFixed` arrondit la valeur binaire exacte ; en cas d'égalité parfaite +/// (0,125 → « 0.13 »), il retient le plus grand entier en valeur absolue, là où +/// le formatage de Rust arrondit au pair. +pub fn js_to_fixed2(x: f64) -> f64 { + if !x.is_finite() || x.abs() >= 1e21 { + return x; + } + let a = x.abs(); + // Une égalité exacte au millième n'est possible que pour un multiple de 1/8. + let huitiemes = a * 8.0; + let r = if huitiemes.fract() == 0.0 && huitiemes < 9.0e15 { + let centiemes = a * 100.0; // exact : multiple de 12,5 + let f = centiemes.floor(); + let c = if centiemes - f >= 0.5 { f + 1.0 } else { f }; + c / 100.0 + } else { + format!("{a:.2}").parse::().unwrap_or(a) + }; + if x < 0.0 { + -r + } else { + r + } +} + +/// Clé d'objet qui est un indice de tableau pour JS (`"0"`, `"404"`, `"123456"`) : +/// ces clés sont énumérées avant les autres, par ordre croissant. +pub fn js_array_index(k: &str) -> Option { + if k.is_empty() || k.len() > 10 || (k.len() > 1 && k.starts_with('0')) || !k.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + k.parse::().ok().filter(|v| *v < u32::MAX as u64) +} + +/// Réordonne un objet comme JS énumère ses clés : indices croissants, puis les +/// autres clés dans l'ordre d'insertion. +pub fn js_object_order(m: Map) -> Map { + if !m.keys().any(|k| js_array_index(k).is_some()) { + return m; + } + let mut indices: Vec<(u64, String, Value)> = Vec::new(); + let mut autres: Vec<(String, Value)> = Vec::new(); + for (k, v) in m { + match js_array_index(&k) { + Some(i) => indices.push((i, k, v)), + None => autres.push((k, v)), + } + } + indices.sort_by_key(|(i, _, _)| *i); + indices.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect() +} + +/// Texte d'un nombre pour une colonne `numeric` (équivalent de `String(n)`). +pub fn numeric_text(x: f64) -> String { + js_number_to_string(x) +} + +// --------------------------------------------------------------------------- +// Sérialisation des nombres façon `JSON.stringify` +// --------------------------------------------------------------------------- + +/// Nombre sérialisé comme le ferait `JSON.stringify` : entier sans décimale, +/// `null` pour `NaN`/`Infinity`. +pub struct JsNum(pub f64); + +impl serde::Serialize for JsNum { + fn serialize(&self, s: S) -> Result { + let v = self.0; + if !v.is_finite() { + s.serialize_unit() + } else if v.fract() == 0.0 && v.abs() < 9.0e15 { + s.serialize_i64(v as i64) + } else { + s.serialize_f64(v) + } + } +} + +/// `f64` (un `null` relu vaut `NaN`). +pub mod num { + use super::*; + pub fn serialize(v: &f64, s: S) -> Result { + serde::Serialize::serialize(&JsNum(*v), s) + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) + } +} + +/// `Option` d'un champ facultatif (présent à `null` → `Some(NaN)`). +pub mod opt_num { + use super::*; + pub fn serialize(v: &Option, s: S) -> Result { + match v { + Some(x) => serde::Serialize::serialize(&JsNum(*x), s), + None => s.serialize_none(), + } + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Some(Option::::deserialize(d)?.unwrap_or(f64::NAN))) + } +} + +fn ser_map(m: &BTreeMap, s: S) -> Result { + let mut out = s.serialize_map(Some(m.len()))?; + for (k, v) in m { + out.serialize_entry(k, &JsNum(*v))?; + } + out.end() +} + +fn de_map<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let brut = Option::>>::deserialize(d)?.unwrap_or_default(); + Ok(brut.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect()) +} + +/// `Record` obligatoire. +pub mod map { + use super::*; + pub fn serialize(m: &BTreeMap, s: S) -> Result { + ser_map(m, s) + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + de_map(d) + } +} + +/// `Record` facultatif. +pub mod opt_map { + use super::*; + pub fn serialize(m: &Option>, s: S) -> Result { + match m { + Some(m) => ser_map(m, s), + None => s.serialize_none(), + } + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let brut = Option::>>::deserialize(d)?; + Ok(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())) + } +} + +/// `Record | null` facultatif : absent (`None`), `null` +/// (`Some(None)`) ou renseigné. +pub mod opt_null_map { + use super::*; + pub fn serialize(m: &Option>>, s: S) -> Result { + match m { + Some(Some(m)) => ser_map(m, s), + _ => s.serialize_none(), + } + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>>, D::Error> { + let brut = Option::>>::deserialize(d)?; + Ok(Some(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect()))) + } +} + +/// `Record>` facultatif (ventilations par magasin). +pub mod opt_nested_map { + use super::*; + pub type Nested = BTreeMap>; + + struct Interne<'a>(&'a BTreeMap); + impl serde::Serialize for Interne<'_> { + fn serialize(&self, s: S) -> Result { + ser_map(self.0, s) + } + } + + pub fn serialize(m: &Option, s: S) -> Result { + match m { + Some(m) => { + let mut out = s.serialize_map(Some(m.len()))?; + for (k, v) in m { + out.serialize_entry(k, &Interne(v))?; + } + out.end() + } + None => s.serialize_none(), + } + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let brut = Option::>>>>::deserialize(d)?; + Ok(brut.map(|m| { + m.into_iter().map(|(k, v)| (k, v.unwrap_or_default().into_iter().map(|(p, x)| (p, x.unwrap_or(f64::NAN))).collect())).collect() + })) + } +} + +/// Chaîne obligatoire : un `null` relu devient `""`. +pub mod string { + use super::*; + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn number_from_str_comme_js() { + assert_eq!(js_number_from_str(""), 0.0); + assert_eq!(js_number_from_str(" "), 0.0); + assert_eq!(js_number_from_str(" 0x2 "), 2.0); + assert_eq!(js_number_from_str("0b11"), 3.0); + assert_eq!(js_number_from_str("0o2"), 2.0); + assert!(js_number_from_str("-0x2").is_nan()); + assert!(js_number_from_str("1_000").is_nan()); + assert!(js_number_from_str("abc").is_nan()); + assert!(js_number_from_str("inf").is_nan()); + assert_eq!(js_number_from_str("+3"), 3.0); + assert_eq!(js_number_from_str(".2e1"), 2.0); + assert_eq!(js_number_from_str("1."), 1.0); + assert_eq!(js_number_from_str("\t2\n"), 2.0); + assert_eq!(js_number_from_str("-Infinity"), f64::NEG_INFINITY); + assert_eq!(js_number_from_str("1e400"), f64::INFINITY); + } + + #[test] + fn parse_int_comme_js() { + assert_eq!(js_parse_int("12abc"), 12.0); + assert_eq!(js_parse_int(" -5"), -5.0); + assert_eq!(js_parse_int("0"), 0.0); + assert!(js_parse_int("abc").is_nan()); + assert!(js_parse_int("").is_nan()); + assert_eq!(js_parse_int("0x10"), 0.0); + } + + #[test] + fn number_to_string_comme_js() { + assert_eq!(js_number_to_string(123.0), "123"); + assert_eq!(js_number_to_string(-1.5), "-1.5"); + assert_eq!(js_number_to_string(0.1 + 0.2), "0.30000000000000004"); + assert_eq!(js_number_to_string(1e21), "1e+21"); + assert_eq!(js_number_to_string(1e20), "100000000000000000000"); + assert_eq!(js_number_to_string(1e-7), "1e-7"); + assert_eq!(js_number_to_string(0.000001), "0.000001"); + assert_eq!(js_number_to_string(1.5e-10), "1.5e-10"); + assert_eq!(js_number_to_string(f64::NAN), "NaN"); + } + + #[test] + fn arrondis_comme_js() { + assert_eq!(js_round(-2.5), -2.0); + assert_eq!(js_round(2.5), 3.0); + assert_eq!(js_round(0.49999999999999994), 0.0); + assert_eq!(js_to_fixed2(0.125), 0.13); + assert_eq!(js_to_fixed2(-0.125), -0.13); + assert_eq!(js_to_fixed2(1.005), 1.0); + assert_eq!(js_to_fixed2(1234.5678), 1234.57); + } + + #[test] + fn nombres_json_comme_js() { + assert_eq!(serde_json::to_string(&JsNum(12.0)).unwrap(), "12"); + assert_eq!(serde_json::to_string(&JsNum(-0.0)).unwrap(), "0"); + assert_eq!(serde_json::to_string(&JsNum(12.5)).unwrap(), "12.5"); + assert_eq!(serde_json::to_string(&JsNum(f64::NAN)).unwrap(), "null"); + } + + #[test] + fn string_et_truthy() { + assert_eq!(js_string(Some(&serde_json::json!(123))), "123"); + assert_eq!(js_string(Some(&serde_json::json!(null))), "null"); + assert_eq!(js_string(None), "undefined"); + assert!(!js_truthy(Some(&serde_json::json!("")))); + assert!(js_truthy(Some(&serde_json::json!("export")))); + assert_eq!(replace_whitespace_runs("Le Fournisseur\tX", "_"), "Le_Fournisseur_X"); + } +} diff --git a/server/src/grid/mod.rs b/server/src/grid/mod.rs index 5687605..75a9cef 100644 --- a/server/src/grid/mod.rs +++ b/server/src/grid/mod.rs @@ -1,16 +1,54 @@ -//! Module `grid` — à porter. +//! Module `grid` — grille d'assortiment. +//! +//! Phase 1 (ce qui ne dépend ni de la base FF ni de Qlik) : +//! - [`types`] : `ProductRow` et types associés (JSON identique au TS) ; +//! - [`store`] : instantané persisté `grid_rows` (lu par `/api/v1`) ; +//! - [`rows_cache`] : cache mémoire des lignes calculées (10 min) ; +//! - [`network_trend`], [`months`], [`store_patch`] : logique pure ; +//! - [`gammes_a_valider`], [`enregistrer`], [`snapshots`] : gammes et snapshots ; +//! - [`exports`] : exports Excel ; [`api_schemas`], [`api_v1`], [`openapi`] : API publique. +//! +//! Phase 2 : calcul de la grille (`getProductRows`), enrichissement `/api/v1` +//! (`api-enrich`), routes `/api/v1/grid`, `/products/*`, `/gammes`, +//! `/fournisseurs`, `/network/*` et `/api/grid/rows*`. + +pub mod api_schemas; +pub mod api_v1; +pub mod enregistrer; +pub mod exports; +pub mod gammes; +pub mod gammes_a_valider; +pub mod js; +pub mod months; +pub mod network_trend; +pub mod openapi; +pub mod rows_cache; +pub mod session; +pub mod snapshots; +pub mod store; +pub mod store_patch; +pub mod types; +pub mod zod; + +#[cfg(test)] +mod tests_db; use crate::rpc::Handler; +use axum::routing::{get, post}; use axum::Router; /// Routes HTTP du module. pub fn router() -> Router { Router::new() + .route("/api/export/excel", post(exports::export_excel)) + .route("/api/export/modified-gammes", post(exports::export_modified_gammes)) + .route("/api/v1/nomenclatures", get(api_v1::get_nomenclatures)) + .route("/api/v1/openapi.json", get(openapi::get_openapi)) } /// Actions (`POST /api/actions/{nom}`) du module. pub fn actions() -> Vec<(&'static str, Handler)> { - vec![] + [snapshots::actions(), enregistrer::actions()].concat() } /// Données de pages (`GET /api/pages/{nom}`) du module. diff --git a/server/src/grid/months.rs b/server/src/grid/months.rs new file mode 100644 index 0000000..a93ebab --- /dev/null +++ b/server/src/grid/months.rs @@ -0,0 +1,97 @@ +//! CollectFlow — Helpers de fenêtre mensuelle (port des fonctions de +//! `src/features/grid/lib/months.ts` utilisées côté serveur). +//! +//! ⚠️ Deux conventions de clé de mois cohabitent dans l'application : +//! - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret) +//! - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret) +//! +//! Utiliser [`ff_month_to_qlik`] / [`qlik_month_to_ff`] pour passer de l'une à l'autre. + +use super::types::ProductRow; +use chrono::{Datelike, NaiveDate}; + +/// Date du jour, heure locale du serveur (`new Date()`). +pub fn today() -> NaiveDate { + chrono::Local::now().date_naive() +} + +/// Les 12 mois complets glissants, du plus ancien au plus récent, au format +/// `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1). +/// +/// Même fenêtre que `last12Periods()` dans `get-product-rows.ts`. +pub fn get_last12_months(now: NaiveDate) -> Vec { + (1..=12) + .rev() + .map(|i: i32| { + let total = now.year() * 12 + now.month0() as i32 - i; + format!("{}{:02}", total.div_euclid(12), total.rem_euclid(12) + 1) + }) + .collect() +} + +/// Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des +/// séries mensuelles des lignes reçues. `None` si aucune ligne ne porte de série +/// complète (l'appelant retombe alors sur [`get_last12_months`]). +pub fn get_months_from_rows(rows: &[ProductRow]) -> Option> { + for r in rows { + let mut keys: Vec<&String> = r.sales12m.keys().filter(|k| k.len() == 6 && k.bytes().all(|b| b.is_ascii_digit())).collect(); + if keys.len() < 12 { + continue; + } + // Toutes les lignes d'un même calcul partagent la même fenêtre : la + // première ligne complète suffit. + keys.sort(); + return Some(keys[keys.len() - 12..].iter().map(|k| k.to_string()).collect()); + } + None +} + +/// `"202601"` → `"Jan 26"`. +pub fn format_month_label(key: &str) -> String { + const NOMS: [&str; 12] = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"]; + let m: usize = key.get(4..6).and_then(|s| s.parse().ok()).unwrap_or(0); + let nom = m.checked_sub(1).and_then(|i| NOMS.get(i)).copied().unwrap_or("undefined"); + format!("{nom} {}", key.get(2..4).unwrap_or("")) +} + +/// Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. +pub fn ff_month_to_qlik(key: &str) -> String { + format!("{}-{}", key.get(..4).unwrap_or(key), key.get(4..6).unwrap_or("")) +} + +/// Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. +pub fn qlik_month_to_ff(key: &str) -> String { + key.replacen('-', "", 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn douze_mois_hors_mois_en_cours() { + let m = get_last12_months(NaiveDate::from_ymd_opt(2026, 1, 31).unwrap()); + assert_eq!(m.first().unwrap(), "202501"); + assert_eq!(m.last().unwrap(), "202512"); + assert_eq!(m.len(), 12); + } + + #[test] + fn conversions_de_cles() { + assert_eq!(ff_month_to_qlik("202601"), "2026-01"); + assert_eq!(qlik_month_to_ff("2026-01"), "202601"); + assert_eq!(format_month_label("202602"), "Fév 26"); + } + + #[test] + fn fenetre_depuis_les_lignes() { + let mut r = ProductRow::default(); + for k in get_last12_months(NaiveDate::from_ymd_opt(2026, 10, 1).unwrap()) { + r.sales12m.insert(k, 1.0); + } + r.sales12m.insert("202409".into(), 0.0); + let m = get_months_from_rows(&[ProductRow::default(), r]).unwrap(); + assert_eq!(m.first().unwrap(), "202510"); + assert_eq!(m.last().unwrap(), "202609"); + } +} diff --git a/server/src/grid/network_trend.rs b/server/src/grid/network_trend.rs new file mode 100644 index 0000000..ed79bde --- /dev/null +++ b/server/src/grid/network_trend.rs @@ -0,0 +1,350 @@ +//! CollectFlow — Tendance des ventes réseau Qlik sur 12 mois +//! (port de `src/features/grid/lib/network-trend.ts`). +//! +//! Partagé entre la Grille, la fiche produit et l'API `/api/v1` (champ `trend`). +//! Aucun changement de comportement. + +use super::js; +use chrono::{Datelike, NaiveDate}; +use serde::Serialize; +use std::collections::BTreeMap; + +/// Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux +/// de présence. Constante métier : source unique pour la Grille et la fiche produit. +pub const NB_MAGASINS_RESEAU: f64 = 270.0; + +/// Nombre de mois comparés à chaque bout de la fenêtre. +/// +/// Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute +/// la tendance de deux points, donc du hasard d'un réassort ou d'une opération. +pub const TREND_WINDOW: usize = 4; + +/// Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. +pub const TREND_STRONG: f64 = 0.25; + +/// Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). +pub const TREND_FLAT: f64 = 0.08; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + Up, + Down, + Flat, +} + +impl Direction { + pub fn as_str(self) -> &'static str { + match self { + Direction::Up => "up", + Direction::Down => "down", + Direction::Flat => "flat", + } + } +} + +/// Couleur de chaque direction (`TREND_COLOR`). +pub fn trend_color(d: Direction) -> &'static str { + match d { + Direction::Up => "#22c55e", + Direction::Down => "#ef4444", + Direction::Flat => "#94a3b8", + } +} + +struct Nums<'a>(&'a [f64]); +impl Serialize for Nums<'_> { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.0.iter().map(|v| js::JsNum(*v))) + } +} + +fn ser_nums(v: &[f64], s: S) -> Result { + Nums(v).serialize(s) +} +fn ser_opt_nums(v: &Option>, s: S) -> Result { + match v { + Some(v) => Nums(v).serialize(s), + None => s.serialize_none(), + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkTrend { + /// quantités mensuelles, ordre chronologique + #[serde(serialize_with = "ser_nums")] + pub values: Vec, + /// "YYYY-MM" + pub labels: Vec, + /// Quantité moyenne par magasin vendeur, mois par mois. `None` si le nombre + /// de magasins n'est pas connu sur les 12 mois. + #[serde(serialize_with = "ser_opt_nums")] + pub per_store: Option>, + /// `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se + /// rabattre sur les quantités brutes faute de nombre de magasins. + pub sur_qte_par_magasin: bool, + pub direction: Direction, + /// Variation des 4 derniers mois vs les 4 premiers. `None` si aucune base. + #[serde(serialize_with = "js::opt_num::serialize")] + pub pct: Option, + /// Rien sur les 4 premiers mois, des ventes sur les 4 derniers. + pub nouveau: bool, + /// `true` si la fenêtre s'arrête un mois plus tôt que prévu : le cache Qlik + /// n'a pas encore été resynchronisé depuis le changement de mois. + pub en_retard: bool, + pub has_data: bool, +} + +/// Premier jour du mois décalé de `delta` mois (`new Date(y, m + delta, 1)`). +fn mois_decale(now: NaiveDate, delta: i32) -> (i32, u32) { + let total = now.year() * 12 + now.month0() as i32 + delta; + (total.div_euclid(12), total.rem_euclid(12) as u32 + 1) +} + +/// Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au +/// plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer +/// écraserait systématiquement la tendance vers le bas. +/// +/// Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que +/// `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent +/// rester alignés. +pub fn build_rolling12_qlik_months(now: NaiveDate) -> Vec { + (1..=12) + .rev() + .map(|i| { + let (y, m) = mois_decale(now, -i); + format!("{y}-{m:02}") + }) + .collect() +} + +/// Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ? +fn fenetre_complete(serie: &BTreeMap, labels: &[String]) -> bool { + labels.iter().all(|l| serie.get(l).map(|v| v.is_finite()).unwrap_or(false)) +} + +/// Les 12 mois réellement disponibles dans une série Qlik : la fenêtre du jour, +/// sinon celle qui s'arrête un mois plus tôt. +/// +/// Le repli couvre les jours qui suivent un changement de mois : la fenêtre a +/// glissé, mais le cache Qlik n'est resynchronisé que fournisseur par +/// fournisseur, la nuit. Sans lui, toutes les tendances disparaissaient d'un +/// coup le 1er du mois. Ce sont toujours 12 mois complets et extraits — aucun +/// mois n'est inventé — simplement décalés d'un cran. `None` si aucune des deux +/// fenêtres n'est complète. +pub fn fenetre_disponible(serie: Option<&BTreeMap>, now: NaiveDate) -> Option<(Vec, bool)> { + let serie = serie?; + let labels = build_rolling12_qlik_months(now); + if fenetre_complete(serie, &labels) { + return Some((labels, false)); + } + let (y, m) = mois_decale(now, -1); + let precedente = build_rolling12_qlik_months(NaiveDate::from_ymd_opt(y, m, 1)?); + if fenetre_complete(serie, &precedente) { + return Some((precedente, true)); + } + None +} + +/// Tendance réseau : variation entre les **4 premiers** et les **4 derniers** +/// mois de la fenêtre de 12 mois glissants, mois en cours exclu. +/// +/// Une série n'est retenue que si les **12 clés sont explicitement présentes** +/// (tout ou rien) ; la tendance porte sur la **quantité par magasin vendeur** +/// quand le nombre de magasins est connu sur les 12 mois, sinon sur les +/// quantités brutes (`surQteParMagasin = false`). Sans base de comparaison (rien +/// vendu sur les 4 premiers mois), aucun pourcentage n'est inventé : le produit +/// est signalé « nouveau ». +/// +/// Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. +pub fn compute_network_trend( + qte_by_month: Option<&BTreeMap>, + nb_mag_by_month: Option<&BTreeMap>, + now: NaiveDate, +) -> NetworkTrend { + let empty = NetworkTrend { + values: vec![], + labels: vec![], + per_store: None, + sur_qte_par_magasin: false, + direction: Direction::Flat, + pct: None, + nouveau: false, + en_retard: false, + has_data: false, + }; + let (Some(qte), Some((labels, en_retard))) = (qte_by_month, fenetre_disponible(qte_by_month, now)) else { + return empty; + }; + + let values: Vec = labels.iter().map(|l| qte.get(l).copied().unwrap_or(f64::NAN)).collect(); + let n = values.len(); + + // Quantité par magasin vendeur : l'indicateur qui isole la performance du + // produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le + // seul choix cohérent avec une quantité elle-même nulle. + let magasins = compute_stores_series(nb_mag_by_month, Some(&labels)); + let per_store: Option> = + magasins.map(|(mags, _)| values.iter().zip(mags.iter()).map(|(v, m)| if *m > 0.0 { v / m } else { 0.0 }).collect()); + let serie: &[f64] = per_store.as_deref().unwrap_or(&values); + + let taille = TREND_WINDOW.min(n / 2); + let moyenne = |xs: &[f64]| if xs.is_empty() { 0.0 } else { xs.iter().sum::() / xs.len() as f64 }; + let debut = moyenne(&serie[..taille]); + let fin = moyenne(&serie[n - taille..]); + + let mut pct = None; + let mut nouveau = false; + let mut direction = Direction::Flat; + + if debut > 0.0 { + // Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans + // retours massifs, et la division par une base positive garde le sens. + let p = (fin - debut) / debut; + direction = if p > TREND_FLAT { + Direction::Up + } else if p < -TREND_FLAT { + Direction::Down + } else { + Direction::Flat + }; + pct = Some(p); + } else if fin > 0.0 { + // Rien au départ, des ventes à l'arrivée : la variation relative n'existe + // pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé. + nouveau = true; + direction = Direction::Up; + } + + let sur_qte_par_magasin = per_store.is_some(); + NetworkTrend { values, labels, per_store, sur_qte_par_magasin, direction, pct, nouveau, en_retard, has_data: true } +} + +/// Série « nombre de magasins vendeurs » sur la MÊME fenêtre que +/// [`compute_network_trend`], pour la superposer à la courbe des quantités. +/// +/// Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent +/// être explicitement présentes, sinon `None` — un mois non extrait ne doit pas +/// se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion. +/// Renvoie `(values, labels)`. +pub fn compute_stores_series( + nb_mag_by_month: Option<&BTreeMap>, + labels: Option<&[String]>, +) -> Option<(Vec, Vec)> { + let serie = nb_mag_by_month?; + let labels = labels.filter(|l| !l.is_empty())?; + if !fenetre_complete(serie, labels) { + return None; + } + Some((labels.iter().map(|l| serie[l]).collect(), labels.to_vec())) +} + +/// Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… +pub fn trend_label(pct: Option, nouveau: bool) -> &'static str { + // Un produit sans base de comparaison n'est pas « stable » : il est nouveau. + if nouveau { + return "Nouveau"; + } + let Some(p) = pct else { return "Stable" }; + if p > TREND_STRONG { + "Forte hausse" + } else if p > TREND_FLAT { + "Hausse" + } else if p < -TREND_STRONG { + "Forte baisse" + } else if p < -TREND_FLAT { + "Baisse" + } else { + "Stable" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(y: i32, m: u32, j: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, j).unwrap() + } + + fn serie(labels: &[String], f: impl Fn(usize) -> f64) -> BTreeMap { + labels.iter().enumerate().map(|(i, l)| (l.clone(), f(i))).collect() + } + + #[test] + fn fenetre_glissante_hors_mois_en_cours() { + let m = build_rolling12_qlik_months(d(2026, 1, 15)); + assert_eq!(m.len(), 12); + assert_eq!(m.first().unwrap(), "2025-01"); + assert_eq!(m.last().unwrap(), "2025-12"); + let m = build_rolling12_qlik_months(d(2026, 10, 1)); + assert_eq!(m.first().unwrap(), "2025-10"); + assert_eq!(m.last().unwrap(), "2026-09"); + } + + #[test] + fn tendance_hausse_par_magasin() { + let now = d(2026, 10, 12); + let labels = build_rolling12_qlik_months(now); + let qte = serie(&labels, |i| { + if i < 4 { + 10.0 + } else if i >= 8 { + 20.0 + } else { + 15.0 + } + }); + let nb = serie(&labels, |_| 5.0); + let t = compute_network_trend(Some(&qte), Some(&nb), now); + assert!(t.has_data && t.sur_qte_par_magasin && !t.en_retard); + assert_eq!(t.direction, Direction::Up); + assert!((t.pct.unwrap() - 1.0).abs() < 1e-12); + assert_eq!(trend_label(t.pct, t.nouveau), "Forte hausse"); + // Même quantité, mais diffusée dans deux fois plus de magasins : stable. + let nb2 = serie(&labels, |i| { + if i < 4 { + 5.0 + } else if i >= 8 { + 10.0 + } else { + 7.5 + } + }); + let t2 = compute_network_trend(Some(&qte), Some(&nb2), now); + assert_eq!(t2.direction, Direction::Flat); + assert_eq!(trend_label(t2.pct, t2.nouveau), "Stable"); + } + + #[test] + fn tendance_nouveau_retard_et_incomplet() { + let now = d(2026, 10, 2); + // Cache pas encore resynchronisé : fenêtre d'un mois plus tôt. + let anciens = build_rolling12_qlik_months(d(2026, 9, 1)); + let qte = serie(&anciens, |i| if i < 4 { 0.0 } else { 3.0 }); + let t = compute_network_trend(Some(&qte), None, now); + assert!(t.has_data && t.en_retard && t.nouveau && !t.sur_qte_par_magasin); + assert_eq!(t.pct, None); + assert_eq!(t.direction, Direction::Up); + assert_eq!(trend_label(t.pct, t.nouveau), "Nouveau"); + // Un mois manquant : aucune tendance. + let mut trou = qte.clone(); + trou.remove(&anciens[5]); + let t = compute_network_trend(Some(&trou), None, now); + assert!(!t.has_data); + assert_eq!( + serde_json::to_string(&t).unwrap(), + r#"{"values":[],"labels":[],"perStore":null,"surQteParMagasin":false,"direction":"flat","pct":null,"nouveau":false,"enRetard":false,"hasData":false}"# + ); + } + + #[test] + fn libelles() { + assert_eq!(trend_label(None, false), "Stable"); + assert_eq!(trend_label(Some(0.1), false), "Hausse"); + assert_eq!(trend_label(Some(-0.1), false), "Baisse"); + assert_eq!(trend_label(Some(-0.3), false), "Forte baisse"); + assert_eq!(trend_label(Some(0.08), false), "Stable"); + } +} diff --git a/server/src/grid/openapi.rs b/server/src/grid/openapi.rs new file mode 100644 index 0000000..6d1e444 --- /dev/null +++ b/server/src/grid/openapi.rs @@ -0,0 +1,1133 @@ +//! `GET /api/v1/openapi.json` — spécification OpenAPI 3.1 de l'API CollectFlow +//! (port de `src/app/api/v1/openapi.json/route.ts`). +//! +//! **Volontairement accessible sans authentification.** Le document ne décrit que la +//! structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée +//! métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL, +//! avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import +//! impossible. +//! +//! `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les +//! Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée. +//! +//! Le document est construit comme en TS (mêmes fragments partagés : +//! `paginationParams`, `sortParams`, `erreur`), avec des objets à clés ordonnées +//! pour que le JSON produit soit identique à celui de la version Node. + +use super::gammes::GAMMES; +use super::store::GRID_SORT_KEYS; +use axum::http::{header, HeaderMap}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{Map, Value}; + +/// Objet JSON aux clés ordonnées comme un objet JS : indices entiers croissants +/// d'abord (les codes de réponse « 200 », « 401 », « 404 »…), puis les autres clés +/// dans l'ordre d'écriture. +fn o(entrees: [(&str, Value); N]) -> Value { + let mut m = Map::new(); + for (k, v) in entrees { + m.insert(k.to_string(), v); + } + Value::Object(super::js::js_object_order(m)) +} + +/// Origine publique : `COLLECTFLOW_PUBLIC_URL` (sans `/` final), sinon celle de la +/// requête (`req.nextUrl.origin`), reconstruite depuis les en-têtes — ceux du +/// mandataire inverse en priorité. +pub fn origine(headers: &HeaderMap) -> String { + let configured = std::env::var("COLLECTFLOW_PUBLIC_URL") + .ok() + .map(|v| super::js::js_trim(&v).trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()); + if let Some(c) = configured { + return c; + } + let premier = |nom: &str| { + headers + .get(nom) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }; + let proto = premier("x-forwarded-proto").unwrap_or_else(|| "http".into()); + let hote = premier("x-forwarded-host").or_else(|| premier("host")).unwrap_or_else(|| "localhost".into()); + format!("{proto}://{hote}") +} + +/// Document OpenAPI complet pour une origine donnée. +pub fn build_spec(origin: &str) -> Value { + let grid_sort_keys: Vec<&str> = GRID_SORT_KEYS.to_vec(); + let pagination_params: Vec = vec![ + o([ + ("name", Value::from("page")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("integer")), + ("minimum", Value::from(1)), + ("default", Value::from(1)), + ])), + ("description", Value::from("Numéro de page.")), + ]), + o([ + ("name", Value::from("limit")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("integer")), + ("minimum", Value::from(1)), + ])), + ("description", Value::from("Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100.")), + ]), + ]; + + let sort_params: Vec = vec![ + o([ + ("name", Value::from("sort")), + ("in", Value::from("query")), + ("schema", o([("type", Value::from("string")), ("enum", Value::from(grid_sort_keys.clone()))])), + ("description", Value::from("Colonne de tri.")), + ]), + o([ + ("name", Value::from("order")), + ("in", Value::from("query")), + ( + "schema", + o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("asc"), Value::from("desc")])), + ("default", Value::from("desc")), + ]), + ), + ("description", Value::from("Sens du tri.")), + ]), + o([ + ("name", Value::from("fields")), + ("in", Value::from("query")), + ("schema", o([("type", Value::from("string"))])), + ( + "description", + Value::from(concat!( + "Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). ", + "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les ", + "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus." + )), + ), + ]), + o([ + ("name", Value::from("enrich")), + ("in", Value::from("query")), + ( + "schema", + o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("0"), Value::from("1")])), + ("default", Value::from("1")), + ]), + ), + ( + "description", + Value::from(concat!( + "1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur ", + "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut." + )), + ), + ]), + ]; + + let codes_gamme: Vec<&str> = GAMMES.iter().map(|g| g.code).collect(); + let legende_gammes = GAMMES.iter().map(|g| format!("{} ({})", g.code, g.nom)).collect::>().join(", "); + let erreur = |description: Value| { + o([ + ("description", description), + ("content", o([("application/json", o([("schema", o([("$ref", Value::from("#/components/schemas/Error"))]))]))])), + ]) + }; + + let spec = o([ + ("openapi", Value::from("3.1.0")), + ("info", o([ + ("title", Value::from("CollectFlow API")), + ("version", Value::from("1.0.0")), + ("description", Value::from(concat!("Données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, ", "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n", "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies ", "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule ", "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. ", "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n", "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache ", "alimenté par une synchronisation séparée.\n\n", "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques ", "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée ", "(`codeGammeServeur`).\n\n", "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) ", "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille ", "comme une modification non enregistrée, pour voir quels produits changent de gamme, et ", "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, ", "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, ", "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées."))), + ])), + ("servers", Value::Array(vec![ + o([ + ("url", Value::from(format!("{}/api/v1", origin))), + ("description", Value::from("API CollectFlow")), + ]), + ])), + ("security", Value::Array(vec![ + o([ + ("ApiKeyAuth", Value::Array(vec![])), + ]), + ])), + ("components", o([ + ("securitySchemes", o([ + ("ApiKeyAuth", o([ + ("type", Value::from("apiKey")), + ("in", Value::from("header")), + ("name", Value::from("X-API-Key")), + ])), + ])), + ("schemas", o([ + ("Error", o([ + ("type", Value::from("object")), + ("properties", o([ + ("error", o([ + ("type", Value::from("object")), + ("properties", o([ + ("code", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![ + Value::from("unauthorized"), + Value::from("forbidden"), + Value::from("bad_request"), + Value::from("not_found"), + Value::from("not_ready"), + Value::from("internal_error"), + ])), + ])), + ("message", o([ + ("type", Value::from("string")), + ])), + ])), + ])), + ])), + ])), + ("Pagination", o([ + ("type", Value::from("object")), + ("properties", o([ + ("page", o([ + ("type", Value::from("integer")), + ])), + ("limit", o([ + ("type", Value::from("integer")), + ])), + ("total", o([ + ("type", Value::from("integer")), + ])), + ("totalPages", o([ + ("type", Value::from("integer")), + ])), + ("hasMore", o([ + ("type", Value::from("boolean")), + ("description", Value::from("true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif.")), + ])), + ])), + ])), + ("Fournisseur", o([ + ("type", Value::from("object")), + ("properties", o([ + ("code", o([ + ("type", Value::from("string")), + ("description", Value::from("Code fournisseur, à passer au paramètre `fournisseur`.")), + ])), + ("nom", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ])), + ("hasSnapshot", o([ + ("type", Value::from("boolean")), + ("description", Value::from("false = données pas encore disponibles pour ce fournisseur.")), + ])), + ("rowCount", o([ + ("type", Value::from("integer")), + ])), + ("computedAt", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("format", Value::from("date-time")), + ])), + ])), + ])), + ("NetworkMetrics", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from("Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.")), + ("properties", o([ + ("caReseau", o([ + ("type", Value::from("number")), + ])), + ("qteReseau", o([ + ("type", Value::from("number")), + ])), + ("nbMagasinsReseau", o([ + ("type", Value::from("integer")), + ("description", Value::from("Nombre de magasins du réseau vendant le produit (sur ~270).")), + ])), + ("caParMagasinReseau", o([ + ("type", Value::from("number")), + ("description", Value::from("CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer.")), + ])), + ("margePctReseau", o([ + ("type", Value::from("number")), + ("description", Value::from("Taux de marge, ratio brut (0.32 = 32 %).")), + ])), + ("qteByMonth", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from("Quantités vendues par mois, clés « YYYY-MM ».")), + ])), + ("fetchedAt", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("format", Value::from("date-time")), + ])), + ])), + ])), + ("Product", o([ + ("type", Value::from("object")), + ("description", Value::from(concat!("Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous ", "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine ", "de champs). Utilisez `fields` pour n'en garder qu'une partie."))), + ("properties", o([ + ("codein", o([ + ("type", Value::from("string")), + ("description", Value::from("Identifiant interne de l'article.")), + ])), + ("codeFournisseur", o([ + ("type", Value::from("string")), + ])), + ("nomFournisseur", o([ + ("type", Value::from("string")), + ])), + ("libelle1", o([ + ("type", Value::from("string")), + ("description", Value::from("Libellé de l'article.")), + ])), + ("gtin", o([ + ("type", Value::from("string")), + ])), + ("reference", o([ + ("type", Value::from("string")), + ])), + ("codeCentrale", o([ + ("type", Value::from("string")), + ("description", Value::from("Code centrale — clé de jointure avec le réseau Qlik.")), + ])), + ("codeGammeServeur", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from(format!("Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour raisonner. `null` = aucune gamme. Valeurs : {}.", legende_gammes))), + ])), + ("codeGamme", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme courante, modifications validées dans CollectFlow comprises.")), + ])), + ("gammeAValider", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente.")), + ])), + ("totalCa", o([ + ("type", Value::from("number")), + ("description", Value::from("CA sur 12 mois, nos magasins.")), + ])), + ("totalQuantite", o([ + ("type", Value::from("number")), + ])), + ("totalMarge", o([ + ("type", Value::from("number")), + ])), + ("tauxMarge", o([ + ("type", Value::from("number")), + ])), + ("stockActuel", o([ + ("type", Value::from("number")), + ])), + ("pcb", o([ + ("type", Value::from("number")), + ("description", Value::from("Conditionnement (nombre d'unités par colis).")), + ])), + ("prixAchat", o([ + ("type", Value::from("number")), + ])), + ("prixVente", o([ + ("type", Value::from("number")), + ])), + ("derniereVente", o([ + ("type", Value::from("string")), + ("format", Value::from("date")), + ])), + ("nbJoursDerniereVente", o([ + ("type", Value::from("integer")), + ])), + ("sales12m", o([ + ("type", Value::from("object")), + ("description", Value::from("Quantités vendues par mois, clés « YYYYMM ».")), + ])), + ("stock12m", o([ + ("type", Value::from("object")), + ("description", Value::from("Stock de fin de mois, clés « YYYYMM ».")), + ])), + ("receptions12m", o([ + ("type", Value::from("object")), + ("description", Value::from("Quantités reçues par mois, clés « YYYYMM ».")), + ])), + ("sales12mByStore", o([ + ("type", Value::from("object")), + ("description", Value::from("Ventes par magasin (292, 579) puis par mois.")), + ])), + ("stock12mByStore", o([ + ("type", Value::from("object")), + ("description", Value::from("Stock de fin de mois par magasin puis par mois.")), + ])), + ("receptions12mByStore", o([ + ("type", Value::from("object")), + ("description", Value::from("Réceptions par magasin puis par mois.")), + ])), + ("qteReseauByMonth", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from("Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance.")), + ])), + ("network", o([ + ("$ref", Value::from("#/components/schemas/NetworkMetrics")), + ])), + ("trend", o([ + ("$ref", Value::from("#/components/schemas/Trend")), + ])), + ])), + ])), + ("ResultatGamme", o([ + ("type", Value::from("object")), + ("properties", o([ + ("codein", o([ + ("type", Value::from("string")), + ])), + ("gamme", o([ + ("type", Value::from("string")), + ("enum", Value::from(codes_gamme.clone())), + ("description", Value::from("Gamme proposée.")), + ])), + ("gammePrecedente", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme enregistrée au moment de l'appel.")), + ])), + ("codeGammeServeur", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme en base FF, non modifiée par l'API.")), + ])), + ("modifie", o([ + ("type", Value::from("boolean")), + ("description", Value::from("true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée).")), + ])), + ("statut", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("a_valider"), Value::from("inchangee")])), + ("description", Value::from("`a_valider` : en attente de validation dans la Grille.")), + ])), + ])), + ])), + ("Trend", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from(concat!("Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne ", "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible."))), + ("properties", o([ + ("direction", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("up"), Value::from("down"), Value::from("flat")])), + ("description", Value::from("Seuil à ±8 % de variation modélisée.")), + ])), + ("pct", o([ + ("type", Value::Array(vec![Value::from("number"), Value::from("null")])), + ("description", Value::from("Variation modélisée sur la période (0.23 = +23 %).")), + ])), + ("label", o([ + ("type", Value::from("string")), + ("description", Value::from("Libellé prêt à afficher : « Forte hausse », « Stable »…")), + ])), + ("monthsUsed", o([ + ("type", Value::from("integer")), + ("description", Value::from("Nombre de mois réellement utilisés (12 au maximum).")), + ])), + ])), + ])), + ])), + ])), + ("paths", o([ + ("/fournisseurs", o([ + ("get", o([ + ("operationId", Value::from("listerFournisseurs")), + ("summary", Value::from("Lister les fournisseurs")), + ("description", Value::from(concat!("Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données ", "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu."))), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("search")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre sur le nom ou le code.")), + ]), + o([ + ("name", Value::from("withData")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("0"), Value::from("1")])), + ])), + ("description", Value::from("1 = uniquement ceux dont les données sont disponibles.")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Liste des fournisseurs")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/Fournisseur")), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/nomenclatures", o([ + ("get", o([ + ("operationId", Value::from("listerNomenclaturesFournisseur")), + ("summary", Value::from("Postes de nomenclature d'un fournisseur")), + ("description", Value::from(concat!("Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre ", "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : ", "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), ", "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` ", "(code1, code2 ou code3)."))), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ]), + o([ + ("name", Value::from("niveau")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("integer")), + ("enum", Value::Array(vec![Value::from(1), Value::from(2), Value::from(3)])), + ("default", Value::from(1)), + ])), + ("description", Value::from("1 = univers, 2 = famille, 3 = sous-famille.")), + ]), + o([ + ("name", Value::from("parent")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Préfixe du poste parent, ex. « 32 » pour lister ses familles.")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Postes de nomenclature")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("type", Value::from("object")), + ("properties", o([ + ("code", o([ + ("type", Value::from("string")), + ("description", Value::from("À passer au filtre code1/code2/code3 de /grid.")), + ])), + ("libelle", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ])), + ("nbArticles", o([ + ("type", Value::from("integer")), + ])), + ("totalCa", o([ + ("type", Value::from("number")), + ])), + ("totalQuantite", o([ + ("type", Value::from("number")), + ])), + ])), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("202", o([ + ("description", Value::from("Fournisseur pas encore calculé")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("400", o([ + ("description", Value::from("Paramètres invalides")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/grid", o([ + ("get", o([ + ("operationId", Value::from("listerProduitsFournisseur")), + ("summary", Value::from("Lister les produits d'un fournisseur")), + ("description", Value::from(concat!("Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques ", "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été ", "calculées, l'API les calcule à la demande (premier appel de quelques secondes, ", "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas ", "ou n'a aucun article — le signaler plutôt que de conclure à une panne."))), + ("parameters", Value::Array([vec![ + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Code fournisseur (voir listerFournisseurs).")), + ]), + o([ + ("name", Value::from("gamme")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from(format!("Filtre sur la gamme ({}).", codes_gamme.join(", ")))), + ]), + o([ + ("name", Value::from("code1")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre nomenclature niveau 1.")), + ]), + o([ + ("name", Value::from("code2")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre nomenclature niveau 2.")), + ]), + o([ + ("name", Value::from("code3")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre nomenclature niveau 3 (code exact).")), + ]), + o([ + ("name", Value::from("nomenclature")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides.")), + ]), + o([ + ("name", Value::from("search")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre texte : libellé, codein, GTIN, référence ou code centrale.")), + ]), + o([ + ("name", Value::from("compute")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("0"), Value::from("1")])), + ("default", Value::from("1")), + ])), + ("description", Value::from(concat!("1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. ", "0 : refuse le calcul et répond immédiatement 202 not_ready."))), + ]), + ], sort_params.clone(), pagination_params.clone()].concat())), + ("responses", o([ + ("200", o([ + ("description", Value::from("Produits du fournisseur")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/Product")), + ])), + ])), + ("pagination", o([ + ("$ref", Value::from("#/components/schemas/Pagination")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("202", o([ + ("description", Value::from("Données pas encore calculées (uniquement avec compute=0)")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("400", o([ + ("description", Value::from("Paramètres invalides")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("404", o([ + ("description", Value::from("Fournisseur inconnu ou sans article")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/products/search", o([ + ("get", o([ + ("operationId", Value::from("rechercherProduits")), + ("summary", Value::from("Rechercher des produits, tous fournisseurs confondus")), + ("description", Value::from(concat!("Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans ", "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom."))), + ("parameters", Value::Array([vec![ + o([ + ("name", Value::from("q")), + ("in", Value::from("query")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ("maxLength", Value::from(120)), + ])), + ("description", Value::from("Termes recherchés.")), + ]), + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Restreint à un fournisseur.")), + ]), + o([ + ("name", Value::from("gamme")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre sur la gamme.")), + ]), + ], sort_params.clone(), pagination_params.clone()].concat())), + ("responses", o([ + ("200", o([ + ("description", Value::from("Produits correspondants")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/Product")), + ])), + ])), + ("pagination", o([ + ("$ref", Value::from("#/components/schemas/Pagination")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/products/{codein}", o([ + ("get", o([ + ("operationId", Value::from("obtenirProduit")), + ("summary", Value::from("Fiche complète d'un produit")), + ("description", Value::from("Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.")), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("codein")), + ("in", Value::from("path")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Identifiant interne de l'article.")), + ]), + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs.")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Fiche produit")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("$ref", Value::from("#/components/schemas/Product")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("404", o([ + ("description", Value::from("Produit absent de l'instantané")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/products/{codein}/gamme", o([ + ("put", o([ + ("operationId", Value::from("changerGammeProduit")), + ("summary", Value::from("Proposer la gamme d'un produit (à valider)")), + ("description", Value::from(format!("Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît comme une modification non enregistrée. Valeurs : {}. `fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs (réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. Pour plusieurs articles d'un même fournisseur, préférer changerGammes.", legende_gammes))), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("codein")), + ("in", Value::from("path")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Identifiant interne de l'article.")), + ]), + ])), + ("requestBody", o([ + ("required", Value::from(true)), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("required", Value::Array(vec![Value::from("gamme")])), + ("properties", o([ + ("gamme", o([ + ("type", Value::from("string")), + ("enum", Value::from(codes_gamme.clone())), + ])), + ("fournisseur", o([ + ("type", Value::from("string")), + ("description", Value::from("Code fournisseur de l'article.")), + ])), + ("compute", o([ + ("type", Value::from("boolean")), + ("default", Value::from(true)), + ("description", Value::from("Calcule le fournisseur à la demande s'il n'a jamais été calculé.")), + ])), + ])), + ])), + ])), + ])), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Gamme proposée, à valider")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("allOf", Value::Array(vec![ + o([ + ("$ref", Value::from("#/components/schemas/ResultatGamme")), + ]), + o([ + ("type", Value::from("object")), + ("properties", o([ + ("codeFournisseur", o([ + ("type", Value::from("string")), + ])), + ("nomFournisseur", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ])), + ])), + ]), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("400", erreur(Value::from("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"))), + ("401", erreur(Value::from("Clé d'API absente, invalide ou révoquée"))), + ("404", erreur(Value::from("Article introuvable chez ce fournisseur"))), + ])), + ])), + ])), + ("/gammes", o([ + ("post", o([ + ("operationId", Value::from("changerGammes")), + ("summary", Value::from("Proposer la gamme de plusieurs produits (à valider)")), + ("description", Value::from(format!("Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé et la réponse 404 liste les articles en cause dans `error.details.inconnus`. Valeurs : {}.", legende_gammes))), + ("requestBody", o([ + ("required", Value::from(true)), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("required", Value::Array(vec![Value::from("fournisseur"), Value::from("changes")])), + ("properties", o([ + ("fournisseur", o([ + ("type", Value::from("string")), + ("description", Value::from("Code fournisseur.")), + ])), + ("changes", o([ + ("type", Value::from("array")), + ("minItems", Value::from(1)), + ("maxItems", Value::from(10000)), + ("items", o([ + ("type", Value::from("object")), + ("required", Value::Array(vec![Value::from("codein"), Value::from("gamme")])), + ("properties", o([ + ("codein", o([ + ("type", Value::from("string")), + ])), + ("gamme", o([ + ("type", Value::from("string")), + ("enum", Value::from(codes_gamme.clone())), + ])), + ])), + ])), + ])), + ("compute", o([ + ("type", Value::from("boolean")), + ("default", Value::from(true)), + ("description", Value::from("Calcule le fournisseur à la demande s'il n'a jamais été calculé.")), + ])), + ])), + ])), + ])), + ])), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Gammes proposées, à valider")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/ResultatGamme")), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("400", erreur(Value::from("Corps de requête invalide"))), + ("401", erreur(Value::from("Clé d'API absente, invalide ou révoquée"))), + ("404", erreur(Value::from("Un ou plusieurs articles introuvables chez ce fournisseur"))), + ])), + ])), + ])), + ("/network/{codeCentrale}", o([ + ("get", o([ + ("operationId", Value::from("obtenirMetriquesReseau")), + ("summary", Value::from("Métriques du réseau Qlik pour un produit")), + ("description", Value::from("Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.")), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("codeCentrale")), + ("in", Value::from("path")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Code centrale du produit (format 10000XXXXXX).")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Métriques réseau")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("$ref", Value::from("#/components/schemas/NetworkMetrics")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("404", o([ + ("description", Value::from("Aucune donnée réseau pour ce code centrale")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ]); + + spec +} + +/// `GET /api/v1/openapi.json`. +pub async fn get_openapi(headers: HeaderMap) -> Response { + let spec = build_spec(&origine(&headers)); + // CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers. + ([(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")], Json(spec)).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_complet() { + let spec = build_spec("http://exemple.test:5643"); + assert_eq!(spec["servers"][0]["url"], "http://exemple.test:5643/api/v1"); + assert_eq!(spec["paths"]["/grid"]["get"]["parameters"].as_array().unwrap().len(), 14); + assert_eq!( + spec["components"]["schemas"]["ResultatGamme"]["properties"]["gamme"]["enum"], + serde_json::json!(["A", "B", "C", "Y", "Z"]) + ); + // Comparaison octet par octet avec le document produit par la version Node + // (`OPENAPI_REFERENCE` : chemin du JSON de référence, origine ci-dessus). + if let Ok(chemin) = std::env::var("OPENAPI_REFERENCE") { + let reference = std::fs::read_to_string(&chemin).unwrap(); + let produit = serde_json::to_string(&spec).unwrap(); + if produit != reference.trim() { + std::fs::write(format!("{chemin}.rust"), &produit).unwrap(); + } + assert_eq!(produit, reference.trim()); + } + } + + #[test] + fn origine_depuis_les_en_tetes() { + let mut h = HeaderMap::new(); + h.insert("host", "collectflow.local:5643".parse().unwrap()); + assert_eq!(origine(&h), "http://collectflow.local:5643"); + h.insert("x-forwarded-proto", "https".parse().unwrap()); + h.insert("x-forwarded-host", "cf.exemple.fr".parse().unwrap()); + assert_eq!(origine(&h), "https://cf.exemple.fr"); + } +} diff --git a/server/src/grid/rows_cache.rs b/server/src/grid/rows_cache.rs new file mode 100644 index 0000000..bad8051 --- /dev/null +++ b/server/src/grid/rows_cache.rs @@ -0,0 +1,273 @@ +//! Cache mémoire des lignes de grille calculées (partie « cache » de +//! `src/features/grid/api/get-product-rows.ts`). +//! +//! Clé : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`. Durée de vie : 10 minutes. +//! Le calcul lui-même (`getProductRows`, `buildProductRows`, `serveFromSnapshot`, +//! `getStorePatch`) viendra en phase 2 et s'appuie sur les briques ci-dessous : +//! +//! - [`get_fresh`] : lecture d'une entrée de moins de 10 min, remontée en tête de +//! l'ordre d'usage (éviction LRU) ; [`peek_fresh`] la lit sans la remonter +//! (`getStorePatch`) ; +//! - [`cache_grid_rows`] : insertion avec éviction (`cacheGridRows`) ; +//! - [`pending_get`] / [`pending_insert`] / [`pending_remove`] : calculs en cours, +//! partagés entre appels simultanés (`gridRowsPending`) ; +//! - [`gamme_save_version`] : compteur d'enregistrements de gammes par fournisseur, +//! qu'un calcul compare avant/après pour relire le dernier snapshot ; +//! - [`invalidate_grid_rows_cache`], [`patch_grid_rows_cache`]. +//! +//! Les lignes d'une entrée sont partagées ([`SharedRows`]) et modifiées sur place, +//! comme le tableau JS : `patchGridRowsCache` et `refreshGammeInit` écrivent dans +//! les lignes déjà servies. Les compléments par magasin (`storePatches`, une +//! `WeakMap` indexée par le tableau en TS) sont rattachés au même objet : ils +//! vivent et meurent avec lui. + +use super::types::{GammeCode, ProductRow, StorePatch}; +use futures::future::{BoxFuture, Shared}; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; + +pub const GRID_ROWS_CACHE_TTL: Duration = Duration::from_secs(10 * 60); + +/// Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par +/// `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait +/// par garder toutes les grilles en mémoire (plusieurs centaines de Mo). Ordre +/// d'insertion = ordre d'usage : on évince les entrées les moins récemment servies. +/// +/// `Number(process.env.X ?? défaut)` : une valeur illisible donne `NaN`, et toute +/// comparaison avec `NaN` est fausse — comportement conservé. +fn borne(nom: &str, defaut: f64) -> f64 { + match std::env::var(nom) { + Ok(v) => super::js::js_number_from_str(&v), + Err(_) => defaut, + } +} +fn max_entries() -> f64 { + borne("GRID_ROWS_CACHE_MAX_ENTRIES", 30.0) +} +fn max_rows() -> f64 { + borne("GRID_ROWS_CACHE_MAX_ROWS", 150_000.0) +} + +/// Complément par magasin en cours de calcul ou calculé (partagé entre appelants). +pub type StorePatchFuture = Shared, String>>>; + +/// Lignes d'une entrée du cache, partagées et modifiables sur place. +#[derive(Default)] +pub struct GridRows { + pub rows: RwLock>, + /// `storePatches` : magasin → complément, rattaché à ce tableau de lignes. + pub store_patches: Mutex>, +} + +impl GridRows { + pub fn new(rows: Vec) -> SharedRows { + Arc::new(GridRows { rows: RwLock::new(rows), store_patches: Mutex::new(HashMap::new()) }) + } + pub fn len(&self) -> usize { + self.rows.read().len() + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Copie des lignes (pour les sérialiser hors verrou). + pub fn snapshot(&self) -> Vec { + self.rows.read().clone() + } +} + +pub type SharedRows = Arc; + +/// Calcul en cours, partagé : l'erreur est un message (le calcul TS relance l'erreur +/// à chaque appelant). +pub type RowsFuture = Shared>>; + +struct Entree { + rows: SharedRows, + created_at: Instant, +} + +#[derive(Default)] +struct Etat { + /// Entrées dans l'ordre d'usage (la plus ancienne en tête). + cache: Vec<(String, Entree)>, + pending: HashMap, + /// Nombre d'enregistrements de gammes par fournisseur (cf. [`patch_grid_rows_cache`]). + gamme_save_version: HashMap, +} + +static ETAT: LazyLock> = LazyLock::new(|| Mutex::new(Etat::default())); + +/// `gridRowsCacheKey` : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`. +pub fn grid_rows_cache_key(code_fournisseur: &str, magasin: Option<&str>) -> String { + format!("{code_fournisseur}:{}", magasin.unwrap_or("TOTAL")) +} + +fn prefixe(code_fournisseur: &str) -> String { + format!("{code_fournisseur}:") +} + +/// `cacheGridRows` : (ré)insère l'entrée en fin d'ordre d'usage, puis évince les +/// plus anciennes tant que les bornes sont dépassées (jamais la dernière). +pub fn cache_grid_rows(key: &str, rows: SharedRows) { + let (max_e, max_r) = (max_entries(), max_rows()); + let mut etat = ETAT.lock(); + etat.cache.retain(|(k, _)| k != key); + etat.cache.push((key.to_string(), Entree { rows, created_at: Instant::now() })); + let mut total_rows: f64 = etat.cache.iter().map(|(_, e)| e.rows.len() as f64).sum(); + while etat.cache.len() > 1 { + let taille = etat.cache.len() as f64; + if taille <= max_e && total_rows <= max_r { + break; + } + let (_, e) = etat.cache.remove(0); + total_rows -= e.rows.len() as f64; + } +} + +/// Entrée de moins de 10 min, remontée en tête de l'ordre d'usage (hit de +/// `getProductRows`). `None` si absente ou expirée. +pub fn get_fresh(key: &str) -> Option { + let mut etat = ETAT.lock(); + let pos = etat.cache.iter().position(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL)?; + // Remonte l'entrée en tête de l'ordre d'usage (éviction LRU), sans changer + // sa date de création. + let entree = etat.cache.remove(pos); + let rows = entree.1.rows.clone(); + etat.cache.push(entree); + Some(rows) +} + +/// Entrée de moins de 10 min, sans toucher à l'ordre d'usage (`getStorePatch`). +pub fn peek_fresh(key: &str) -> Option { + let etat = ETAT.lock(); + etat.cache.iter().find(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL).map(|(_, e)| e.rows.clone()) +} + +/// Calcul en cours pour cette clé. +pub fn pending_get(key: &str) -> Option { + ETAT.lock().pending.get(key).cloned() +} + +pub fn pending_contains(key: &str) -> bool { + ETAT.lock().pending.contains_key(key) +} + +pub fn pending_insert(key: &str, fut: RowsFuture) { + ETAT.lock().pending.insert(key.to_string(), fut); +} + +pub fn pending_remove(key: &str) { + ETAT.lock().pending.remove(key); +} + +/// Compteur d'enregistrements de gammes du fournisseur. +pub fn gamme_save_version(code_fournisseur: &str) -> u64 { + ETAT.lock().gamme_save_version.get(code_fournisseur).copied().unwrap_or(0) +} + +/// `invalidateGridRowsCache` : un fournisseur (toutes ses vues magasin), ou tout. +pub fn invalidate_grid_rows_cache(code_fournisseur: Option<&str>) { + let mut etat = ETAT.lock(); + match code_fournisseur.filter(|c| !c.is_empty()) { + Some(code) => { + let p = prefixe(code); + etat.cache.retain(|(k, _)| !k.starts_with(&p)); + etat.pending.retain(|k, _| !k.starts_with(&p)); + } + None => { + etat.cache.clear(); + etat.pending.clear(); + } + } +} + +/// Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter +/// le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros +/// fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné +/// ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9), +/// la colonne INIT reste l'état serveur. +pub fn patch_grid_rows_cache<'a>(code_fournisseur: &str, changes: impl IntoIterator) { + let by_codein: HashMap<&str, GammeCode> = changes.into_iter().map(|(c, g)| (c, g.to_string())).collect(); + if by_codein.is_empty() { + return; + } + let entrees: Vec = { + let mut etat = ETAT.lock(); + // Un calcul en cours pour ce fournisseur a lu les gammes avant cet + // enregistrement : le compteur lui signale de les relire avant d'être caché. + *etat.gamme_save_version.entry(code_fournisseur.to_string()).or_insert(0) += 1; + let p = prefixe(code_fournisseur); + etat.cache.iter().filter(|(k, _)| k.starts_with(&p)).map(|(_, e)| e.rows.clone()).collect() + }; + for entree in entrees { + for row in entree.rows.write().iter_mut() { + if let Some(g) = by_codein.get(row.codein.as_str()) { + row.code_gamme = Some(g.clone()); + } + } + } +} + +/// Nombre d'entrées en cache (diagnostic). +pub fn cache_len() -> usize { + ETAT.lock().cache.len() +} + +/// Sérialise les tests qui touchent à l'état global du cache. +#[cfg(test)] +pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(test)] +pub(crate) fn reset_for_tests() { + *ETAT.lock() = Etat::default(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lignes(n: usize) -> SharedRows { + GridRows::new((0..n).map(|i| ProductRow { codein: format!("C{i}"), ..Default::default() }).collect()) + } + + /// Un seul test : l'état est global au processus. + #[test] + fn cache_patch_invalidation_eviction() { + let _verrou = TEST_LOCK.lock(); + reset_for_tests(); + assert_eq!(grid_rows_cache_key("F1", None), "F1:TOTAL"); + assert_eq!(grid_rows_cache_key("F1", Some("292")), "F1:292"); + + let total = lignes(3); + cache_grid_rows("F1:TOTAL", total.clone()); + cache_grid_rows("F1:292", lignes(3)); + cache_grid_rows("F10:TOTAL", lignes(3)); + assert!(get_fresh("F1:TOTAL").is_some()); + assert!(peek_fresh("absent").is_none()); + + // Patch : seules les entrées du fournisseur F1 (pas F10) reçoivent la gamme. + patch_grid_rows_cache("F1", [("C1", "Z")]); + assert_eq!(gamme_save_version("F1"), 1); + assert_eq!(gamme_save_version("F10"), 0); + assert_eq!(total.rows.read()[1].code_gamme.as_deref(), Some("Z")); + assert_eq!(get_fresh("F1:292").unwrap().rows.read()[1].code_gamme.as_deref(), Some("Z")); + assert_eq!(get_fresh("F10:TOTAL").unwrap().rows.read()[1].code_gamme, None); + + // Invalidation d'un fournisseur : « F1: » ne touche pas « F10: ». + invalidate_grid_rows_cache(Some("F1")); + assert!(get_fresh("F1:TOTAL").is_none() && get_fresh("F1:292").is_none()); + assert!(get_fresh("F10:TOTAL").is_some()); + + // Éviction LRU par nombre de lignes (borne 150 000 par défaut). + cache_grid_rows("GROS:TOTAL", lignes(150_000)); + assert!(get_fresh("F10:TOTAL").is_none(), "l'entrée la moins récente est évincée"); + assert!(get_fresh("GROS:TOTAL").is_some()); + // Jamais la dernière, même au-delà des bornes. + cache_grid_rows("ENORME:TOTAL", lignes(150_001)); + assert_eq!(cache_len(), 1); + invalidate_grid_rows_cache(None); + assert_eq!(cache_len(), 0); + } +} diff --git a/server/src/grid/session.rs b/server/src/grid/session.rs new file mode 100644 index 0000000..e481962 --- /dev/null +++ b/server/src/grid/session.rs @@ -0,0 +1,95 @@ +//! Accès et identifiant utilisateur, tels que les actions TS les manipulent. + +use super::js; +use crate::auth::UtilisateurCourant; +use crate::error::AppResult; +use crate::rpc::Ctx; +use axum::http::StatusCode; +use serde_json::{Map, Value}; + +/// `verifierSession()` : `Ok(Ok(u))` si connecté, `Ok(Err(message))` pour un +/// refus d'accès (401/403) que l'action renvoie dans `{ success: false, error }`. +/// Toute autre erreur est propagée, comme le `throw e` du TS. +pub async fn verifier_session(ctx: &Ctx) -> AppResult> { + match ctx.session().await { + Ok(u) => Ok(Ok(u)), + Err(e) if e.status == StatusCode::UNAUTHORIZED || e.status == StatusCode::FORBIDDEN => Ok(Err(e.message)), + Err(e) => Err(e), + } +} + +/// `rawUserId ? parseInt(rawUserId, 10) : null` — `None` pour `null`, sinon le +/// nombre (éventuellement `NaN`). +pub fn parse_user_id(raw: &str) -> Option { + if raw.is_empty() { + None + } else { + Some(js::js_parse_int(raw)) + } +} + +/// `userId && !isNaN(userId) ? userId : null` : le compte de secours « 0 » et les +/// identifiants non numériques valent `null` (snapshots « anonymes »). +pub fn final_user_id(raw: &str) -> Option { + parse_user_id(raw).filter(|v| *v != 0.0 && !v.is_nan()) +} + +/// Représentation de `${userId}` dans les journaux. +pub fn user_id_log(raw: &str) -> String { + match parse_user_id(raw) { + None => "null".into(), + Some(v) => js::js_number_to_string(v), + } +} + +/// Valeur liée à une colonne `integer` : hors bornes, PostgreSQL refuse la +/// requête (« value out of range for type integer ») — même issue qu'en TS. +pub fn as_pg_integer(v: f64) -> Result { + if v.fract() == 0.0 && v >= i32::MIN as f64 && v <= i32::MAX as f64 { + Ok(v as i32) + } else { + Err(format!("value \"{}\" is out of range for type integer", js::js_number_to_string(v))) + } +} + +/// Clés d'un objet dans l'ordre où JS les énumère : indices entiers croissants +/// d'abord (« 123456 » est un indice), puis les autres dans l'ordre d'insertion. +pub fn js_key_order(m: &Map) -> Vec<(&String, &Value)> { + let indice = js::js_array_index; + let mut entiers: Vec<(u64, &String, &Value)> = Vec::new(); + let mut autres = Vec::new(); + for (k, v) in m { + match indice(k) { + Some(i) => entiers.push((i, k, v)), + None => autres.push((k, v)), + } + } + entiers.sort_by_key(|(i, _, _)| *i); + entiers.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifiants_comme_en_ts() { + assert_eq!(final_user_id("12"), Some(12.0)); + assert_eq!(final_user_id("12abc"), Some(12.0)); + assert_eq!(final_user_id("0"), None); + assert_eq!(final_user_id("abc"), None); + assert_eq!(final_user_id(""), None); + assert_eq!(user_id_log("abc"), "NaN"); + assert_eq!(user_id_log(""), "null"); + assert_eq!(user_id_log("0"), "0"); + assert!(as_pg_integer(99_999_999_999.0).is_err()); + assert_eq!(as_pg_integer(-5.0), Ok(-5)); + } + + #[test] + fn ordre_des_cles_js() { + let v: Value = serde_json::from_str(r#"{"b":1,"20":2,"a":3,"3":4,"007":5}"#).unwrap(); + let ordre: Vec<&str> = js_key_order(v.as_object().unwrap()).into_iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(ordre, vec!["3", "20", "b", "a", "007"]); + } +} diff --git a/server/src/grid/snapshots.rs b/server/src/grid/snapshots.rs new file mode 100644 index 0000000..b95e1f7 --- /dev/null +++ b/server/src/grid/snapshots.rs @@ -0,0 +1,407 @@ +//! Snapshots de la Grille (port de `src/features/snapshots/api/*.ts`) : +//! actions `getSnapshots`, `deleteSnapshot`, `saveSnapshot`. +//! +//! `session_snapshots.user_id` est un entier : l'identifiant de session est lu +//! comme le faisait le TS (`parseInt`), et le compte de secours « 0 » ou un +//! identifiant non numérique travaillent sur les snapshots « anonymes » +//! (`user_id IS NULL`). Les `revalidatePath` de Next.js n'ont plus d'objet. + +use super::session::{as_pg_integer, final_user_id, js_key_order, user_id_log, verifier_session}; +use super::zod::{self, child, Issues, Seg}; +use crate::db; +use crate::error::{AppError, AppResult}; +use crate::rpc::{json, Ctx}; +use chrono::NaiveDateTime; +use serde::Serialize; +use serde_json::{json as j, Map, Value}; +use sqlx::{PgPool, Row}; + +/// Ligne de `session_snapshots`, telle que `db.select().from(sessionSnapshots)`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSnapshot { + pub id: i32, + pub user_id: Option, + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub magasin: String, + pub changes: Value, + pub summary_json: Option, + pub label: Option, + #[serde(rename = "type")] + pub type_: Option, + pub created_at: Option, +} + +fn ligne_snapshot(r: &sqlx::postgres::PgRow) -> SessionSnapshot { + SessionSnapshot { + id: r.try_get("id").unwrap_or_default(), + user_id: r.try_get("user_id").ok().flatten(), + code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(), + nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(), + magasin: r.try_get("magasin").unwrap_or_default(), + changes: r.try_get("changes").unwrap_or(Value::Null), + summary_json: r.try_get("summary_json").ok().flatten(), + label: r.try_get("label").ok().flatten(), + type_: r.try_get("type").ok().flatten(), + created_at: r.try_get::, _>("created_at").ok().flatten().map(db::iso), + } +} + +// --------------------------------------------------------------------------- +// getSnapshots +// --------------------------------------------------------------------------- + +/// Snapshots de l'utilisateur connecté (ou anonymes), du plus récent au plus ancien. +pub async fn lister_snapshots(user_raw_id: &str, type_: Option<&str>) -> Result, String> { + let pool = db::pool().map_err(|e| e.message)?; + // Si on a un userId, on filtre par celui-ci. + // Sinon, on filtre par userId IS NULL (mode anonyme/local) + let mut sql = String::from( + "select id, user_id, code_fournisseur, nom_fournisseur, magasin, changes, summary_json, label, type, created_at \ + from session_snapshots where ", + ); + let user_id = match final_user_id(user_raw_id) { + Some(v) => Some(as_pg_integer(v)?), + None => None, + }; + sql.push_str(if user_id.is_some() { "user_id = $1" } else { "user_id is null" }); + if type_.is_some() { + sql.push_str(if user_id.is_some() { " and type = $2" } else { " and type = $1" }); + } + sql.push_str(" order by created_at desc"); + let mut q = sqlx::query(&sql); + if let Some(id) = user_id { + q = q.bind(id); + } + if let Some(t) = type_ { + q = q.bind(t); + } + let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?; + Ok(rows.iter().map(ligne_snapshot).collect()) +} + +/// Action `getSnapshots(type?)`. +async fn get_snapshots(ctx: Ctx) -> AppResult { + let utilisateur = ctx.session().await?; + let arg = ctx.args.first(); + // `if (type)` : seule une valeur « vraie » filtre. + let type_ = super::js::js_truthy(arg).then(|| super::js::js_string(arg)); + match lister_snapshots(&utilisateur.id, type_.as_deref()).await { + Ok(rows) => json(rows), + Err(msg) => { + let premiere_ligne = msg.lines().next().unwrap_or_default().to_string(); + tracing::error!("[getSnapshots] ERROR (type={}): {premiere_ligne}", type_.as_deref().unwrap_or("undefined")); + // Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot » + // au lieu de signaler que le chargement a échoué. + Err(AppError::internal("Chargement de l'historique impossible.")) + } + } +} + +// --------------------------------------------------------------------------- +// deleteSnapshot +// --------------------------------------------------------------------------- + +/// Supprime un snapshot de l'utilisateur (ou anonyme). Résultat `{ success, error? }`. +pub async fn supprimer_snapshot(user_raw_id: &str, id: &Value) -> Value { + tracing::info!("[deleteSnapshot] Request for ID: {}, Session UserId: {}", super::js::js_string(Some(id)), user_id_log(user_raw_id)); + + let res: Result = async { + let pool = db::pool().map_err(|e| e.message)?; + // Condition de sécurité : match ID ET match le propriétaire (ou anonyme) + let user_id = match final_user_id(user_raw_id) { + Some(v) => Some(as_pg_integer(v)?), + None => None, + }; + let id_sql: Option = match id { + Value::Null => None, + Value::Number(n) => Some(as_pg_integer(n.as_f64().unwrap_or(f64::NAN))?), + Value::String(s) => Some(s.trim().parse::().map_err(|_| format!("invalid input syntax for type integer: \"{s}\""))?), + autre => return Err(format!("invalid input syntax for type integer: {autre}")), + }; + let sql = if user_id.is_some() { + "delete from session_snapshots where (id = $1 and user_id = $2) returning id" + } else { + "delete from session_snapshots where (id = $1 and user_id is null) returning id" + }; + let mut q = sqlx::query(sql).bind(id_sql); + if let Some(u) = user_id { + q = q.bind(u); + } + let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?; + Ok(rows.len() as u64) + } + .await; + + match res { + Ok(0) => { + tracing::warn!( + "[deleteSnapshot] No rows deleted for ID {}. Snapshot may not exist or belongs to another user.", + super::js::js_string(Some(id)) + ); + j!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" }) + } + Ok(_) => { + tracing::info!("[deleteSnapshot] Successfully deleted ID: {}", super::js::js_string(Some(id))); + j!({ "success": true }) + } + Err(e) => { + // Le détail technique reste dans les journaux du serveur. + tracing::error!("[deleteSnapshot] DB Error: {e}"); + j!({ "success": false, "error": "erreur technique sur le serveur" }) + } + } +} + +/// Action `deleteSnapshot(id)`. +async fn delete_snapshot(ctx: Ctx) -> AppResult { + let utilisateur = match verifier_session(&ctx).await? { + Ok(u) => u, + Err(message) => return Ok(j!({ "success": false, "error": message })), + }; + Ok(supprimer_snapshot(&utilisateur.id, &ctx.raw(0)).await) +} + +// --------------------------------------------------------------------------- +// saveSnapshot +// --------------------------------------------------------------------------- + +/// Données validées de `SaveSnapshotSchema` (clés inconnues retirées, comme zod). +#[derive(Debug, Clone)] +pub struct SaveSnapshotInput { + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub magasin: String, + pub label: Option, + /// codein → `{ before, after }`. + pub changes: Map, + pub type_: Option, + /// `{ totalRows, totalCa, totalMarge, tauxMargeGlobal }`. + pub summary: Option, +} + +/// `SaveSnapshotSchema.safeParse(raw)`. +pub fn parse_save_snapshot(raw: &Value) -> Result { + let mut issues = Issues::default(); + let racine: Vec = vec![]; + let Some(o) = zod::object(Some(raw), &racine, &mut issues) else { return Err(issues) }; + let p = |k: &str| vec![Seg::from(k)]; + + let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues); + let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues); + let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues); + let label = zod::opt_string(o.get("label"), &p("label"), &mut issues); + + let mut changes = Map::new(); + let chemin_changes = p("changes"); + match o.get("changes") { + Some(Value::Object(m)) => { + for (k, v) in js_key_order(m) { + let chemin = child(&chemin_changes, k.as_str()); + let Some(entree) = zod::object(Some(v), &chemin, &mut issues) else { continue }; + let before = zod::nullable_string(entree.get("before"), &child(&chemin, "before"), &mut issues); + let after = zod::string(entree.get("after"), &child(&chemin, "after"), &mut issues); + if let (Some(before), Some(after)) = (before, after) { + changes.insert(k.clone(), j!({ "before": before, "after": after })); + } + } + } + autre => issues.invalid_type(&chemin_changes, "record", autre), + } + + let type_ = match o.get("type") { + None => Some(None), + v => zod::enum_value(v, &["snapshot", "export"], &p("type"), None, &mut issues).map(Some), + }; + + let summary = match o.get("summary") { + None => Some(None), + v => { + let chemin = p("summary"); + match zod::object(v, &chemin, &mut issues) { + Some(s) => { + let mut out = Map::new(); + let mut complet = true; + for k in ["totalRows", "totalCa", "totalMarge", "tauxMargeGlobal"] { + match zod::number(s.get(k), &child(&chemin, k), &mut issues) { + Some(_) => { + out.insert(k.to_string(), s[k].clone()); + } + None => complet = false, + } + } + complet.then_some(Some(Value::Object(out))) + } + None => None, + } + } + }; + + if !issues.is_empty() { + return Err(issues); + } + Ok(SaveSnapshotInput { + code_fournisseur: code_fournisseur.unwrap_or_default(), + nom_fournisseur: nom_fournisseur.flatten(), + magasin: magasin.unwrap_or_default(), + label: label.flatten(), + changes, + type_: type_.flatten(), + summary: summary.flatten(), + }) +} + +/// Date du jour façon `toLocaleDateString("fr-FR")`. +pub fn date_fr() -> String { + chrono::Local::now().format("%d/%m/%Y").to_string() +} + +async fn inserer_snapshot(pool: &PgPool, user_id: Option, d: &SaveSnapshotInput) -> Result { + let label = d + .label + .clone() + .unwrap_or_else(|| format!("{} — {}", if d.type_.as_deref() == Some("export") { "Export" } else { "Snapshot" }, date_fr())); + // L'identifiant est lié en entier 64 bits : hors bornes de la colonne + // `integer`, PostgreSQL refuse l'insertion, comme pour la version Node. + let row = sqlx::query( + "insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \ + values ($1, $2, $3, $4, $5, $6, $7, $8) returning id", + ) + .bind(user_id.map(|v| v as i64)) + .bind(&d.code_fournisseur) + .bind(&d.nom_fournisseur) + .bind(&d.magasin) + .bind(label) + .bind(Value::Object(d.changes.clone())) + .bind(d.summary.clone()) + .bind(d.type_.clone().unwrap_or_else(|| "snapshot".into())) + .fetch_one(pool) + .await?; + row.try_get("id") +} + +/// Enregistre un snapshot pour l'utilisateur. Résultat `{ success, snapshotId?, error? }`. +pub async fn enregistrer_snapshot(user_raw_id: &str, raw: &Value) -> Value { + let d = match parse_save_snapshot(raw) { + Ok(d) => d, + Err(issues) => { + tracing::error!("Snapshot validation failed: {}", serde_json::to_string(&issues.to_json()).unwrap_or_default()); + return j!({ "success": false, "error": format!("Validation failed: {}", issues.messages_joined()) }); + } + }; + tracing::info!( + "[saveSnapshot] User: {} (raw: {user_raw_id}), Supplier: {}, Type: {}", + user_id_log(user_raw_id), + d.code_fournisseur, + d.type_.as_deref().unwrap_or("undefined") + ); + let final_id = final_user_id(user_raw_id); + + let pool = match db::pool() { + Ok(p) => p, + Err(e) => { + tracing::error!("Snapshot auto-repair/retry failed: {}", e.message); + return j!({ "success": false, "error": "erreur technique sur le serveur" }); + } + }; + + match inserer_snapshot(&pool, final_id, &d).await { + Ok(id) => j!({ "success": true, "snapshotId": id }), + Err(err) => { + tracing::error!("Initial snapshot save failed, attempting auto-repair... {err}"); + let reparation = async { + // Tentative systématique de création de table (no-op si déjà là) + sqlx::raw_sql( + "CREATE TABLE IF NOT EXISTS session_snapshots ( + id SERIAL PRIMARY KEY, + user_id INTEGER, + code_fournisseur VARCHAR(20) NOT NULL, + nom_fournisseur VARCHAR(255), + magasin VARCHAR(20) NOT NULL, + changes JSONB NOT NULL, + summary_json JSONB, + label TEXT, + type VARCHAR(20) DEFAULT 'snapshot', + created_at TIMESTAMP DEFAULT NOW() + );", + ) + .execute(&pool) + .await?; + // Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute + let alter = async { + sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'") + .execute(&pool) + .await?; + sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER").execute(&pool).await + }; + let _ = alter.await; // Ignore + // Deuxième tentative d'insertion + inserer_snapshot(&pool, final_id, &d).await + }; + match reparation.await { + Ok(id) => j!({ "success": true, "snapshotId": id }), + Err(e) => { + // Le détail technique (les deux erreurs) reste dans les journaux du serveur. + tracing::error!("Snapshot auto-repair/retry failed: {e}"); + j!({ "success": false, "error": "erreur technique sur le serveur" }) + } + } + } + } +} + +/// Action `saveSnapshot(raw)`. +async fn save_snapshot(ctx: Ctx) -> AppResult { + let utilisateur = match verifier_session(&ctx).await? { + Ok(u) => u, + Err(message) => return Ok(j!({ "success": false, "error": message })), + }; + Ok(enregistrer_snapshot(&utilisateur.id, &ctx.raw(0)).await) +} + +pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> { + vec![ + ("getSnapshots", crate::handler!(get_snapshots)), + ("deleteSnapshot", crate::handler!(delete_snapshot)), + ("saveSnapshot", crate::handler!(save_snapshot)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_comme_zod() { + let raw: Value = serde_json::from_str( + r#"{"codeFournisseur":"1","magasin":"T","changes":{"a":{"before":1},"b":3},"type":"x","summary":{"totalRows":"1"}}"#, + ) + .unwrap(); + let issues = parse_save_snapshot(&raw).unwrap_err(); + assert_eq!( + serde_json::to_string(&issues.to_json()).unwrap(), + r#"[{"expected":"string","code":"invalid_type","path":["changes","a","before"],"message":"Invalid input: expected string, received number"},{"expected":"string","code":"invalid_type","path":["changes","a","after"],"message":"Invalid input: expected string, received undefined"},{"expected":"object","code":"invalid_type","path":["changes","b"],"message":"Invalid input: expected object, received number"},{"code":"invalid_value","values":["snapshot","export"],"path":["type"],"message":"Invalid option: expected one of \"snapshot\"|\"export\""},{"expected":"number","code":"invalid_type","path":["summary","totalRows"],"message":"Invalid input: expected number, received string"},{"expected":"number","code":"invalid_type","path":["summary","totalCa"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","totalMarge"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","tauxMargeGlobal"],"message":"Invalid input: expected number, received undefined"}]"# + ); + let vide = parse_save_snapshot(&j!({})).unwrap_err(); + assert_eq!( + vide.messages_joined(), + "Invalid input: expected string, received undefined, Invalid input: expected string, received undefined, Invalid input: expected record, received undefined" + ); + assert!(parse_save_snapshot(&j!({ "codeFournisseur": "1", "magasin": "T", "changes": [] })).is_err()); + } + + #[test] + fn donnees_nettoyees() { + let raw = j!({ + "codeFournisseur": "F1", "magasin": "TOTAL", "inconnu": true, + "changes": { "200": { "before": null, "after": "A", "x": 1 }, "100": { "before": "B", "after": "Z" } }, + "summary": { "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5, "y": 0 } + }); + let d = parse_save_snapshot(&raw).unwrap(); + assert_eq!(serde_json::to_string(&d.changes).unwrap(), r#"{"100":{"before":"B","after":"Z"},"200":{"before":null,"after":"A"}}"#); + assert_eq!(d.summary.unwrap(), j!({ "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5 })); + assert_eq!(d.type_, None); + assert_eq!(d.label, None); + } +} diff --git a/server/src/grid/store.rs b/server/src/grid/store.rs new file mode 100644 index 0000000..7c37fe6 --- /dev/null +++ b/server/src/grid/store.rs @@ -0,0 +1,699 @@ +//! CollectFlow — Instantané persisté de la grille (table `grid_rows`), +//! port de `src/lib/grid-store.ts`. +//! +//! `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base +//! miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1` +//! ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de +//! bord quand quelqu'un ouvre la Grille. +//! +//! On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat +//! qui existe déjà, et il survit désormais aux redémarrages. + +use super::js; +use super::types::ProductRow; +use crate::db; +use crate::error::{AppError, AppResult}; +use chrono::{Datelike, NaiveDateTime, TimeZone}; +use serde::Serialize; +use serde_json::Value; +use sqlx::{Postgres, QueryBuilder, Row}; +use std::collections::HashMap; + +/// Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GridSortKey { + Libelle1, + Codein, + CodeFournisseur, + CodeGamme, + TotalCa, + TotalQuantite, + TotalMarge, + TauxMarge, + StockActuel, + CaReseau, + QteReseau, + NbMagasinsReseau, + CaParMagasinReseau, + ComputedAt, +} + +/// `SORTABLE` : clé exposée → colonne SQL. +const SORTABLE: [(GridSortKey, &str, &str); 14] = [ + (GridSortKey::Libelle1, "libelle1", "libelle1"), + (GridSortKey::Codein, "codein", "codein"), + (GridSortKey::CodeFournisseur, "codeFournisseur", "code_fournisseur"), + (GridSortKey::CodeGamme, "codeGamme", "code_gamme"), + (GridSortKey::TotalCa, "totalCa", "total_ca"), + (GridSortKey::TotalQuantite, "totalQuantite", "total_quantite"), + (GridSortKey::TotalMarge, "totalMarge", "total_marge"), + (GridSortKey::TauxMarge, "tauxMarge", "taux_marge"), + (GridSortKey::StockActuel, "stockActuel", "stock_actuel"), + (GridSortKey::CaReseau, "caReseau", "ca_reseau"), + (GridSortKey::QteReseau, "qteReseau", "qte_reseau"), + (GridSortKey::NbMagasinsReseau, "nbMagasinsReseau", "nb_magasins_reseau"), + (GridSortKey::CaParMagasinReseau, "caParMagasinReseau", "ca_par_magasin_reseau"), + (GridSortKey::ComputedAt, "computedAt", "computed_at"), +]; + +/// `GRID_SORT_KEYS`, dans l'ordre de `SORTABLE`. +pub const GRID_SORT_KEYS: [&str; 14] = [ + "libelle1", + "codein", + "codeFournisseur", + "codeGamme", + "totalCa", + "totalQuantite", + "totalMarge", + "tauxMarge", + "stockActuel", + "caReseau", + "qteReseau", + "nbMagasinsReseau", + "caParMagasinReseau", + "computedAt", +]; + +impl GridSortKey { + /// Clé de tri depuis son nom exposé ; `None` hors liste blanche. + pub fn from_key(key: &str) -> Option { + SORTABLE.iter().find(|(_, k, _)| *k == key).map(|(s, _, _)| *s) + } + pub fn as_str(self) -> &'static str { + SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, k, _)| *k).unwrap_or("totalCa") + } + /// Colonne SQL (constante du code, jamais l'entrée utilisateur). + fn column(self) -> &'static str { + SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, _, c)| *c).unwrap_or("total_ca") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SortOrder { + Asc, + #[default] + Desc, +} + +#[derive(Debug, Clone, Default)] +pub struct GridQuery { + pub code_fournisseur: Option, + pub gamme: Option, + pub code1: Option, + pub code2: Option, + pub code3: Option, + /// Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. + pub nomenclature: Option, + /// Recherche libre : libellé, codein, GTIN, référence, code centrale. + pub search: Option, + pub sort: Option, + pub order: Option, + pub page: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GridQueryResult { + pub rows: Vec, + pub total: i64, + /// Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. + pub computed_at: Option, +} + +/// Version du format des lignes persistées. À incrémenter dès qu'un champ de +/// `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne +/// seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1. +pub const GRID_PAYLOAD_VERSION: f64 = 1.0; + +/// Lots d'écriture : payload jsonb volumineux, lots plus petits que pour des scalaires. +const CHUNK_UPSERT: usize = 200; +/// Bien en deçà des 65 535 paramètres liés de PostgreSQL. +const CHUNK_CODEINS: usize = 5000; + +/// Chaîne non vide (`if (q.x)` côté TS). +fn renseigne(s: &Option) -> Option<&str> { + s.as_deref().filter(|v| !v.is_empty()) +} + +/// Horodatage façon `new Date()` stocké par drizzle (UTC, à la milliseconde). +fn maintenant_utc_ms() -> NaiveDateTime { + use chrono::Timelike; + let now = chrono::Utc::now().naive_utc(); + now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000).unwrap_or(now) +} + +/// Colonne `numeric` : le TS passe `String(n)` ; on fait de même (texte casté). +fn push_numeric(b: &mut sqlx::query_builder::Separated<'_, '_, Postgres, &'static str>, v: Option) { + b.push("CAST("); + b.push_bind_unseparated(v.map(js::numeric_text)); + b.push_unseparated(" AS numeric)"); +} + +/// Persiste les lignes calculées pour un fournisseur. +/// +/// Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime +/// celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles +/// disparus du catalogue sans avoir à passer des centaines de codein en paramètres. +pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> AppResult { + if code_fournisseur.is_empty() || rows.is_empty() { + return Ok(0); + } + let pool = db::pool()?; + let computed_at = maintenant_utc_ms(); + + let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect(); + if values.is_empty() { + return Ok(0); + } + + let mut written = 0usize; + for batch in values.chunks(CHUNK_UPSERT) { + let mut qb: QueryBuilder = QueryBuilder::new( + "insert into grid_rows (code_fournisseur, codein, nom_fournisseur, libelle1, gtin, reference, \ + code_centrale, code1, code2, code3, code_gamme, code_gamme_init, total_ca, total_quantite, \ + total_marge, taux_marge, stock_actuel, ca_reseau, qte_reseau, nb_magasins_reseau, \ + ca_par_magasin_reseau, marge_pct_reseau, payload, computed_at) ", + ); + let mut erreur_payload = None; + qb.push_values(batch.iter(), |mut b, r| { + let mut payload = (*r).clone(); + payload.payload_version = Some(GRID_PAYLOAD_VERSION); + let payload = serde_json::to_value(&payload).unwrap_or_else(|e| { + erreur_payload = Some(e.to_string()); + Value::Null + }); + b.push_bind(code_fournisseur.to_string()) + .push_bind(r.codein.clone()) + .push_bind(r.nom_fournisseur.clone()) + .push_bind(r.libelle1.clone()) + .push_bind(r.gtin.clone()) + .push_bind(r.reference.clone()) + .push_bind(r.code_centrale.clone()) + .push_bind(r.code1.clone()) + .push_bind(r.code2.clone()) + .push_bind(r.code3.clone()) + .push_bind(r.code_gamme.clone()) + .push_bind(r.code_gamme_init.clone()); + push_numeric(&mut b, Some(r.total_ca)); + push_numeric(&mut b, Some(r.total_quantite)); + push_numeric(&mut b, Some(r.total_marge)); + push_numeric(&mut b, Some(r.taux_marge)); + push_numeric(&mut b, r.stock_actuel); + push_numeric(&mut b, r.ca_reseau); + push_numeric(&mut b, r.qte_reseau); + // Colonne integer : le TS passe le nombre tel quel. + b.push("CAST("); + b.push_bind_unseparated(r.nb_magasins_reseau.map(js::numeric_text)); + b.push_unseparated(" AS integer)"); + push_numeric(&mut b, r.ca_par_magasin_reseau); + push_numeric(&mut b, r.marge_pct_reseau); + b.push_bind(payload).push_bind(computed_at); + }); + if let Some(e) = erreur_payload { + return Err(AppError::internal(e)); + } + qb.push( + " on conflict (code_fournisseur, codein) do update set \ + nom_fournisseur = excluded.nom_fournisseur, libelle1 = excluded.libelle1, gtin = excluded.gtin, \ + reference = excluded.reference, code_centrale = excluded.code_centrale, code1 = excluded.code1, \ + code2 = excluded.code2, code3 = excluded.code3, code_gamme = excluded.code_gamme, \ + code_gamme_init = excluded.code_gamme_init, total_ca = excluded.total_ca, \ + total_quantite = excluded.total_quantite, total_marge = excluded.total_marge, \ + taux_marge = excluded.taux_marge, stock_actuel = excluded.stock_actuel, ca_reseau = excluded.ca_reseau, \ + qte_reseau = excluded.qte_reseau, nb_magasins_reseau = excluded.nb_magasins_reseau, \ + ca_par_magasin_reseau = excluded.ca_par_magasin_reseau, marge_pct_reseau = excluded.marge_pct_reseau, \ + payload = excluded.payload, computed_at = excluded.computed_at", + ); + qb.build().execute(&pool).await?; + written += batch.len(); + } + + // Purge des articles qui ne font plus partie du catalogue du fournisseur. + sqlx::query("delete from grid_rows where code_fournisseur = $1 and computed_at < $2") + .bind(code_fournisseur) + .bind(computed_at) + .execute(&pool) + .await?; + + Ok(written) +} + +/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul. +pub struct GridSnapshot { + pub rows: Vec, + /// Calcul le plus ancien des lignes (UTC). + pub computed_at: NaiveDateTime, +} + +/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul. +/// +/// Renvoie `None` (→ recalcul) si rien n'est persisté, si une ligne est d'une +/// version de format antérieure, ou si le calcul date d'un autre mois que le mois +/// en cours : la fenêtre des 12 mois glissants aurait changé entre-temps. +/// Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article). +pub async fn read_grid_snapshot(code_fournisseur: &str) -> AppResult> { + let pool = db::pool()?; + let found = sqlx::query("select payload, computed_at from grid_rows where code_fournisseur = $1") + .bind(code_fournisseur) + .fetch_all(&pool) + .await?; + if found.is_empty() { + return Ok(None); + } + + let mut oldest: Option = None; + let mut rows = Vec::with_capacity(found.len()); + for r in &found { + let payload: Value = r.try_get("payload")?; + if payload.get("payloadVersion").and_then(Value::as_f64) != Some(GRID_PAYLOAD_VERSION) { + return Ok(None); + } + let row: ProductRow = match serde_json::from_value(payload) { + Ok(row) => row, + Err(e) => { + tracing::warn!("[grid-store] payload illisible pour {code_fournisseur} : {}", crate::error::short(&e, 200)); + return Ok(None); + } + }; + let computed_at: NaiveDateTime = r.try_get("computed_at")?; + if oldest.map(|o| computed_at < o).unwrap_or(true) { + oldest = Some(computed_at); + } + rows.push(row); + } + let Some(oldest) = oldest else { return Ok(None) }; + // Comparaison en heure locale, comme `getFullYear()` / `getMonth()`. + let local = chrono::Local.from_utc_datetime(&oldest); + let now = chrono::Local::now(); + if local.year() != now.year() || local.month() != now.month() { + return Ok(None); + } + rows.sort_by(|a, b| { + let (x, y) = (a.noid.unwrap_or(0.0), b.noid.unwrap_or(0.0)); + (x - y).partial_cmp(&0.0).unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(Some(GridSnapshot { rows, computed_at: oldest })) +} + +/// Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload), +/// pour que /api/v1 les voie sans attendre un recalcul complet de la grille. +pub async fn update_grid_rows_gamme<'a>(code_fournisseur: &str, changes: impl IntoIterator) -> AppResult<()> { + // Map TS : ordre de première apparition de chaque gamme. + let mut par_gamme: Vec<(String, Vec)> = Vec::new(); + for (codein, gamme) in changes { + match par_gamme.iter_mut().find(|(g, _)| g == gamme) { + Some((_, codeins)) => codeins.push(codein.to_string()), + None => par_gamme.push((gamme.to_string(), vec![codein.to_string()])), + } + } + if par_gamme.is_empty() { + return Ok(()); + } + let pool = db::pool()?; + for (gamme, codeins) in &par_gamme { + for lot in codeins.chunks(CHUNK_CODEINS) { + sqlx::query( + "update grid_rows set code_gamme = $1, payload = jsonb_set(payload, '{codeGamme}', to_jsonb($2::text)) \ + where code_fournisseur = $3 and codein = any($4)", + ) + .bind(gamme) + .bind(gamme) + .bind(code_fournisseur) + .bind(lot) + .execute(&pool) + .await?; + } + } + Ok(()) +} + +/// Construit la clause WHERE commune à la grille et à la recherche transversale. +fn push_where(qb: &mut QueryBuilder<'_, Postgres>, q: &GridQuery) { + let mut premiere = true; + let mut et = |qb: &mut QueryBuilder<'_, Postgres>| { + qb.push(if premiere { " where (" } else { " and " }); + premiere = false; + }; + if let Some(v) = renseigne(&q.code_fournisseur) { + et(qb); + qb.push("code_fournisseur = ").push_bind(v.to_string()); + } + if let Some(v) = renseigne(&q.gamme) { + et(qb); + qb.push("code_gamme = ").push_bind(v.to_string()); + } + if let Some(v) = renseigne(&q.code1) { + et(qb); + qb.push("code1 = ").push_bind(v.to_string()); + } + if let Some(v) = renseigne(&q.code2) { + et(qb); + qb.push("code2 = ").push_bind(v.to_string()); + } + if let Some(v) = renseigne(&q.code3) { + et(qb); + qb.push("code3 = ").push_bind(v.to_string()); + } + // Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ». + // C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de + // code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée. + if let Some(v) = renseigne(&q.nomenclature) { + et(qb); + qb.push("code3 like ").push_bind(format!("{v}%")); + } + if let Some(search) = q.search.as_deref().map(js::js_trim).filter(|s| !s.is_empty()) { + let pattern = format!("%{search}%"); + et(qb); + qb.push("(libelle1 ilike ") + .push_bind(pattern.clone()) + .push(" or codein ilike ") + .push_bind(pattern.clone()) + .push(" or gtin ilike ") + .push_bind(pattern.clone()) + .push(" or reference ilike ") + .push_bind(pattern.clone()) + .push(" or code_centrale ilike ") + .push_bind(pattern) + .push(")"); + } + if !premiere { + qb.push(")"); + } +} + +/// Ligne exposée par l'API : la version de format est un détail interne. +fn vers_ligne_api(payload: Value) -> AppResult { + let mut ligne: ProductRow = + serde_json::from_value(payload).map_err(|e| AppError::internal(format!("payload grid_rows illisible : {e}")))?; + ligne.payload_version = None; + Ok(ligne) +} + +/// Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL. +/// Ne déclenche aucun recalcul et ne contacte jamais Qlik. +pub async fn query_grid_rows(q: &GridQuery) -> AppResult { + let pool = db::pool()?; + let page = q.page.unwrap_or(1).max(1); + // `limit` absent = aucune limite : on renvoie toutes les lignes du filtre. + let limit = q.limit.map(|l| l.max(1)); + + let sort_col = q.sort.unwrap_or(GridSortKey::TotalCa).column(); + let sens = if q.order == Some(SortOrder::Asc) { "asc" } else { "desc" }; + + let mut selection: QueryBuilder = QueryBuilder::new("select payload, computed_at from grid_rows"); + push_where(&mut selection, q); + // Mêmes ORDER BY que drizzle (`desc(col)`, `asc(codein)`). + selection.push(format!(" order by {sort_col} {sens}, codein asc")); + if let Some(l) = limit { + selection.push(" limit ").push_bind(l).push(" offset ").push_bind((page - 1) * l); + } + + let mut comptage: QueryBuilder = QueryBuilder::new("select count(*)::int as total from grid_rows"); + push_where(&mut comptage, q); + + // Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente. + let (count_row, found) = tokio::try_join!(comptage.build().fetch_one(&pool), selection.build().fetch_all(&pool))?; + + let mut oldest: Option = None; + let mut rows = Vec::with_capacity(found.len()); + for r in &found { + if let Ok(Some(c)) = r.try_get::, _>("computed_at") { + if oldest.map(|o| c < o).unwrap_or(true) { + oldest = Some(c); + } + } + rows.push(vers_ligne_api(r.try_get("payload")?)?); + } + + Ok(GridQueryResult { rows, total: count_row.try_get::("total").map(i64::from).unwrap_or(0), computed_at: oldest.map(db::iso) }) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GridRowFound { + pub row: ProductRow, + pub computed_at: Option, +} + +/// Fiche complète d'un produit. `code_fournisseur` lève l'ambiguïté d'un article multi-fournisseurs. +pub async fn get_grid_row_by_codein(codein: &str, code_fournisseur: Option<&str>) -> AppResult> { + let pool = db::pool()?; + let mut qb: QueryBuilder = QueryBuilder::new("select payload, computed_at from grid_rows where (codein = "); + qb.push_bind(codein.to_string()); + if let Some(f) = code_fournisseur.filter(|f| !f.is_empty()) { + qb.push(" and code_fournisseur = ").push_bind(f.to_string()); + } + qb.push(") order by computed_at desc limit 1"); + let Some(found) = qb.build().fetch_optional(&pool).await? else { + return Ok(None); + }; + let computed_at: Option = found.try_get("computed_at").ok().flatten(); + Ok(Some(GridRowFound { row: vers_ligne_api(found.try_get("payload")?)?, computed_at: computed_at.map(db::iso) })) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GridSupplierRef { + pub code_fournisseur: String, + pub nom_fournisseur: Option, +} + +/// Fournisseurs chez lesquels un article figure dans l'instantané. +pub async fn list_grid_suppliers_for_codein(codein: &str) -> AppResult> { + let pool = db::pool()?; + let rows = sqlx::query("select code_fournisseur, nom_fournisseur from grid_rows where codein = $1 order by code_fournisseur asc") + .bind(codein) + .fetch_all(&pool) + .await?; + Ok(rows + .iter() + .map(|r| GridSupplierRef { + code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(), + nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(), + }) + .collect()) +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GammeInstantane { + /// Gamme courante, modifications enregistrées comprises. + pub code_gamme: Option, + /// Gamme serveur au moment du calcul de la grille. + pub code_gamme_init: Option, + pub nom_fournisseur: Option, +} + +/// Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la +/// carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé). +pub async fn get_grid_rows_gammes(code_fournisseur: &str, codeins: &[String]) -> AppResult> { + let mut out = HashMap::new(); + if codeins.is_empty() { + return Ok(out); + } + let pool = db::pool()?; + for lot in codeins.chunks(CHUNK_CODEINS) { + let found = sqlx::query( + "select codein, code_gamme, code_gamme_init, nom_fournisseur from grid_rows \ + where code_fournisseur = $1 and codein = any($2)", + ) + .bind(code_fournisseur) + .bind(lot) + .fetch_all(&pool) + .await?; + for r in &found { + out.insert( + r.try_get::("codein").unwrap_or_default(), + GammeInstantane { + code_gamme: r.try_get("code_gamme").ok().flatten(), + code_gamme_init: r.try_get("code_gamme_init").ok().flatten(), + nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(), + }, + ); + } + } + Ok(out) +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GridFreshness { + pub row_count: i64, + pub computed_at: Option, +} + +/// Indique si un fournisseur a déjà été calculé au moins une fois. +/// Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long. +pub async fn get_grid_freshness(code_fournisseur: &str) -> AppResult> { + let pool = db::pool()?; + let found = + sqlx::query("select count(*)::int as row_count, max(computed_at) as computed_at from grid_rows where code_fournisseur = $1") + .bind(code_fournisseur) + .fetch_one(&pool) + .await?; + let row_count = found.try_get::("row_count").map(i64::from).unwrap_or(0); + if row_count == 0 { + return Ok(None); + } + let computed_at: Option = found.try_get("computed_at").ok().flatten(); + Ok(Some(GridFreshness { row_count, computed_at: computed_at.map(db::iso) })) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GridSupplier { + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub row_count: i64, + pub computed_at: Option, +} + +/// Fournisseurs présents dans l'instantané, avec leur fraîcheur. +pub async fn list_grid_suppliers() -> AppResult> { + let pool = db::pool()?; + let found = sqlx::query( + "select code_fournisseur, max(nom_fournisseur) as nom_fournisseur, count(*)::int as row_count, \ + max(computed_at) as computed_at from grid_rows group by code_fournisseur order by code_fournisseur asc", + ) + .fetch_all(&pool) + .await?; + Ok(found + .iter() + .map(|r| GridSupplier { + code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(), + nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(), + row_count: r.try_get::("row_count").map(i64::from).unwrap_or(0), + computed_at: r.try_get::, _>("computed_at").ok().flatten().map(db::iso), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Inventaire des nomenclatures d'un fournisseur +// --------------------------------------------------------------------------- + +/// Un poste de nomenclature, avec son poids — de quoi décider par où découper. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GridNomenclature { + pub code: String, + /// Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). + pub libelle: Option, + pub nb_articles: i64, + #[serde(serialize_with = "js::num::serialize")] + pub total_ca: f64, + #[serde(serialize_with = "js::num::serialize")] + pub total_quantite: f64, +} + +/// Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». +pub const NOMENCLATURE_LONGUEURS: [(u8, i32); 3] = [(1, 2), (2, 4), (3, 6)]; + +/// Longueur du code d'un niveau (1, 2 ou 3). +pub fn nomenclature_longueur(niveau: u8) -> i32 { + NOMENCLATURE_LONGUEURS.iter().find(|(n, _)| *n == niveau).map(|(_, l)| *l).unwrap_or(2) +} + +/// Répartition des articles d'un fournisseur par niveau de nomenclature. +/// +/// Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature +/// est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles** +/// valeurs existent — il devrait tout télécharger pour les découvrir, ce qui +/// annule le bénéfice du découpage. +/// +/// Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6 +/// chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci +/// ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la +/// hiérarchie ; quand elle échoue, seul `code3` est rempli. Le préfixe, lui, est +/// toujours disponible. +/// +/// Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour +/// un fournisseur de 130 000 articles. +/// +/// Écart volontaire avec le TS : drizzle répétait `left(code3, $n)` dans le +/// GROUP BY avec un **autre** paramètre, ce que PostgreSQL refuse (« must appear +/// in the GROUP BY clause ») — la requête échouait systématiquement. On groupe +/// ici par position (`group by 1`), même résultat attendu. +pub async fn list_grid_nomenclatures(code_fournisseur: &str, niveau: u8, parent: Option<&str>) -> AppResult> { + let pool = db::pool()?; + let longueur = nomenclature_longueur(niveau); + // Les libellés n'ont pas de colonne dédiée : on les lit dans le payload. + // Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ; + // le code et les compteurs, eux, restent justes. + let libelle_key = match niveau { + 1 => "libelleNiveau1", + 2 => "libelleNiveau2", + _ => "libelle3", + }; + + let mut qb: QueryBuilder = QueryBuilder::new("select left(code3, "); + qb.push_bind(longueur) + .push(") as code, max(payload ->> ") + .push_bind(libelle_key) + .push(") as libelle, count(*)::int as nb_articles, coalesce(sum(total_ca), 0)::float as total_ca, ") + .push("coalesce(sum(total_quantite), 0)::float as total_quantite from grid_rows where (code_fournisseur = ") + .push_bind(code_fournisseur.to_string()) + .push(" and code3 is not null and length(code3) >= ") + .push_bind(longueur); + // `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2. + if let Some(p) = parent.filter(|p| !p.is_empty()) { + qb.push(" and code3 like ").push_bind(format!("{p}%")); + } + qb.push(") group by 1 order by coalesce(sum(total_ca), 0) desc"); + + let rows = qb.build().fetch_all(&pool).await?; + Ok(rows + .iter() + .map(|r| GridNomenclature { + code: db::string_or_empty(r, "code"), + libelle: r.try_get("libelle").ok().flatten(), + nb_articles: db::f64_or0(r, "nb_articles") as i64, + total_ca: db::f64_or0(r, "total_ca"), + total_quantite: db::f64_or0(r, "total_quantite"), + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn liste_blanche_du_tri() { + assert_eq!(GRID_SORT_KEYS.len(), SORTABLE.len()); + for (i, (_, k, _)) in SORTABLE.iter().enumerate() { + assert_eq!(GRID_SORT_KEYS[i], *k); + assert_eq!(GridSortKey::from_key(k).unwrap().as_str(), *k); + } + assert_eq!(GridSortKey::from_key("total_ca; drop table x"), None); + assert_eq!(GridSortKey::TotalCa.column(), "total_ca"); + } + + #[test] + fn clause_where() { + let q = GridQuery { + code_fournisseur: Some("F1".into()), + gamme: Some(String::new()), + nomenclature: Some("32".into()), + search: Some(" bougie ".into()), + ..Default::default() + }; + let mut qb: QueryBuilder = QueryBuilder::new("select 1 from grid_rows"); + push_where(&mut qb, &q); + assert_eq!( + qb.sql(), + "select 1 from grid_rows where (code_fournisseur = $1 and code3 like $2 and (libelle1 ilike $3 or codein ilike $4 or gtin ilike $5 or reference ilike $6 or code_centrale ilike $7))" + ); + let mut vide: QueryBuilder = QueryBuilder::new("select 1 from grid_rows"); + push_where(&mut vide, &GridQuery::default()); + assert_eq!(vide.sql(), "select 1 from grid_rows"); + } + + #[test] + fn longueurs_de_nomenclature() { + assert_eq!(nomenclature_longueur(1), 2); + assert_eq!(nomenclature_longueur(2), 4); + assert_eq!(nomenclature_longueur(3), 6); + } +} diff --git a/server/src/grid/store_patch.rs b/server/src/grid/store_patch.rs new file mode 100644 index 0000000..bb94270 --- /dev/null +++ b/server/src/grid/store_patch.rs @@ -0,0 +1,109 @@ +//! Complément des chiffres d'un magasin par l'API FF (ventes mensuelles) — partie +//! pure de `src/features/grid/lib/store-patch.ts`. +//! +//! Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains +//! articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire, +//! pour ces articles seulement, les séries et totaux du magasin. +//! +//! `buildStorePatchEntries` (qui lit les entrées mensuelles de l'API FF) viendra +//! avec le calcul de la grille (phase 2). + +use super::types::{ProductRow, StorePatchEntry}; +use std::collections::HashMap; + +/// Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. +pub fn store_patch_candidates<'a>(rows: &'a [ProductRow], magasin: &str) -> Vec<&'a ProductRow> { + rows.iter() + .filter(|row| { + row.noid.map(|n| n != 0.0 && !n.is_nan()).unwrap_or(false) + && row.quantite_by_store.as_ref().and_then(|m| m.get(magasin)).copied().unwrap_or(0.0) == 0.0 + }) + .collect() +} + +/// Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le +/// magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne. +/// Renvoie le nombre de lignes corrigées. +pub fn apply_store_patch_in_place(rows: &mut [ProductRow], magasin: &str, periods: &[String], entries: &[StorePatchEntry]) -> usize { + let par_code: HashMap<&str, &StorePatchEntry> = entries.iter().map(|e| (e.codein.as_str(), e)).collect(); + let mut corrigees = 0; + for row in rows.iter_mut() { + let Some(e) = par_code.get(row.codein.as_str()) else { continue }; + + let sales = row.sales12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default(); + let mut deltas_ventes = Vec::with_capacity(periods.len()); + for period in periods { + let current = sales.get(period).copied().unwrap_or(0.0); + let next = e.sales.get(period).copied().unwrap_or(0.0); + sales.insert(period.clone(), next); + deltas_ventes.push((period, next - current)); + } + let receptions = row.receptions12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default(); + for period in periods { + receptions.insert(period.clone(), e.receptions.get(period).copied().unwrap_or(0.0)); + } + let stock = row.stock12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default(); + for period in periods { + stock.insert(period.clone(), e.stock.get(period).copied().unwrap_or(0.0)); + } + for (period, delta) in deltas_ventes { + let v = row.sales12m.get(period).copied().unwrap_or(0.0); + row.sales12m.insert(period.clone(), v + delta); + } + + let ca_by_store = row.ca_by_store.get_or_insert_with(Default::default); + let quantite_by_store = row.quantite_by_store.get_or_insert_with(Default::default); + let marge_by_store = row.marge_by_store.get_or_insert_with(Default::default); + let delta_qty = e.quantite - quantite_by_store.get(magasin).copied().unwrap_or(0.0); + let delta_ca = e.ca - ca_by_store.get(magasin).copied().unwrap_or(0.0); + let delta_marge = e.marge - marge_by_store.get(magasin).copied().unwrap_or(0.0); + + quantite_by_store.insert(magasin.to_string(), e.quantite); + ca_by_store.insert(magasin.to_string(), e.ca); + marge_by_store.insert(magasin.to_string(), e.marge); + row.total_quantite += delta_qty; + row.total_ca += delta_ca; + row.total_marge += delta_marge; + row.taux_marge = if row.total_ca > 0.0 { (row.total_marge / row.total_ca) * 100.0 } else { 0.0 }; + if !row.working_stores.iter().any(|s| s == magasin) { + row.working_stores.push(magasin.to_string()); + } + corrigees += 1; + } + corrigees +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn complement_reporte_sur_les_totaux() { + let periods = vec!["202601".to_string(), "202602".to_string()]; + let mut row = ProductRow { codein: "A1".into(), noid: Some(7.0), ..Default::default() }; + row.sales12m.insert("202601".into(), 4.0); + row.total_quantite = 4.0; + row.total_ca = 40.0; + row.total_marge = 10.0; + row.working_stores = vec!["292".into()]; + let mut q = std::collections::BTreeMap::new(); + q.insert("292".to_string(), 4.0); + row.quantite_by_store = Some(q); + assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "579").len(), 1); + assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "292").len(), 0); + + let mut e = StorePatchEntry { codein: "A1".into(), quantite: 3.0, ca: 30.0, marge: 6.0, ..Default::default() }; + e.sales.insert("202602".into(), 3.0); + e.stock.insert("202601".into(), 5.0); + let mut rows = vec![row]; + assert_eq!(apply_store_patch_in_place(&mut rows, "579", &periods, &[e]), 1); + let r = &rows[0]; + assert_eq!(r.total_quantite, 7.0); + assert_eq!(r.total_ca, 70.0); + assert!((r.taux_marge - 16.0 / 70.0 * 100.0).abs() < 1e-9); + assert_eq!(r.sales12m["202602"], 3.0); + assert_eq!(r.sales12m_by_store.as_ref().unwrap()["579"]["202601"], 0.0); + assert_eq!(r.stock12m_by_store.as_ref().unwrap()["579"]["202601"], 5.0); + assert_eq!(r.working_stores, vec!["292".to_string(), "579".to_string()]); + } +} diff --git a/server/src/grid/tests_db.rs b/server/src/grid/tests_db.rs new file mode 100644 index 0000000..61583b7 --- /dev/null +++ b/server/src/grid/tests_db.rs @@ -0,0 +1,298 @@ +//! Test d'intégration contre une vraie base PostgreSQL : `grid_rows`, +//! `session_snapshots`, `gammes_a_valider`. +//! +//! Désactivé par défaut. Pour le lancer : +//! +//! ```sh +//! GRID_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/collectflow cargo test grid::tests_db +//! ``` +//! +//! Les données de test portent des codes fournisseur `TSTGRID…`, supprimés avant +//! et après le passage. + +use super::api_schemas::query_map; +use super::enregistrer::{enregistrer_brouillon, enregistrer_gammes, ChangementGamme, EnregistrementGammes}; +use super::gammes_a_valider::*; +use super::rows_cache; +use super::snapshots::{enregistrer_snapshot, lister_snapshots, supprimer_snapshot}; +use super::store::*; +use super::types::ProductRow; +use serde_json::{json, Value}; +use std::time::Duration; + +const F1: &str = "TSTGRID1"; +const F2: &str = "TSTGRID2"; + +fn ligne(codein: &str, noid: f64, libelle: &str, code3: &str, ca: f64) -> ProductRow { + let mut r: ProductRow = serde_json::from_value(json!({ + "codein": codein, "codeFournisseur": F1, "nomFournisseur": "FOURNISSEUR TEST", + "libelle1": libelle, "gtin": format!("370{codein}"), "reference": format!("REF-{codein}"), + "codeCentrale": format!("10000{codein}"), + "code1": &code3[..2], "libelleNiveau1": "Univers", "code2": &code3[..4], "libelleNiveau2": "Famille", + "code3": code3, "libelle3": format!("Sous-famille {code3}"), + "codeGamme": "B", "codeGammeInit": "B", "codeGammeDraft": null, + "sales12m": { "202509": 1, "202510": 2 }, "stock12m": { "202509": 5, "202510": 4 }, + "receptions12m": { "202509": 0, "202510": 0 }, + "totalQuantite": 3, "totalCa": ca, "totalMarge": ca / 4.0, "tauxMarge": 25, + "workingStores": ["292"], "noid": noid, "stockActuel": 4.5, "nbMagasinsReseau": 120, + "qteReseauByMonth": null, "champInconnu": { "garde": true } + })) + .unwrap(); + r.libelle1 = libelle.to_string(); + r +} + +async fn nettoyer(pool: &sqlx::PgPool) { + for t in ["grid_rows", "session_snapshots", "gammes_a_valider"] { + sqlx::query(&format!("delete from {t} where code_fournisseur like 'TSTGRID%'")).execute(pool).await.unwrap(); + } +} + +#[tokio::test] +async fn grid_rows_snapshots_gammes_a_valider() { + let Ok(url) = std::env::var("GRID_TEST_DATABASE_URL") else { + eprintln!("GRID_TEST_DATABASE_URL absent : test d'intégration ignoré."); + return; + }; + let _verrou = rows_cache::TEST_LOCK.lock(); + // La configuration lue par `db::pool()` : pas de fichier de réglages, l'URL de test. + let dir = std::env::temp_dir().join(format!("grid-tests-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("DATA_DIR", &dir); + std::env::set_var("DATABASE_URL", &url); + let pool = crate::db::pool().unwrap(); + sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap(); + nettoyer(&pool).await; + + // ── upsert / lecture de l'instantané ──────────────────────────────────── + let rows = vec![ + ligne("900002", 20.0, "Vase en verre", "320212", 50.0), + ligne("900001", 10.0, "Bougie parfumée", "320211", 150.0), + ProductRow { codein: String::new(), ..Default::default() }, + ligne("900003", 5.0, "Cadre photo", "330101", 0.125), + ]; + assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3); + assert_eq!(upsert_grid_rows("", &rows).await.unwrap(), 0); + + let snap = read_grid_snapshot(F1).await.unwrap().expect("instantané"); + let codes: Vec<&str> = snap.rows.iter().map(|r| r.codein.as_str()).collect(); + assert_eq!(codes, vec!["900003", "900001", "900002"], "tri par noid"); + assert_eq!(snap.rows[1].payload_version, Some(GRID_PAYLOAD_VERSION)); + let mut relu = serde_json::to_value(&snap.rows[1]).unwrap(); + relu.as_object_mut().unwrap().remove("payloadVersion"); + assert_eq!(relu, serde_json::to_value(&rows[1]).unwrap(), "aller-retour du payload"); + assert!(read_grid_snapshot("TSTGRID-ABSENT").await.unwrap().is_none()); + + // Colonnes scalaires (numeric arrondi par PostgreSQL). + let (ca, nb): (f64, Option) = + sqlx::query_as("select total_ca::float8, nb_magasins_reseau from grid_rows where code_fournisseur = $1 and codein = '900003'") + .bind(F1) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!((ca, nb), (0.13, Some(120))); + + // Une ligne d'un ancien format n'est pas servie à la Grille. + sqlx::query("update grid_rows set payload = payload - 'payloadVersion' where code_fournisseur = $1 and codein = '900002'") + .bind(F1) + .execute(&pool) + .await + .unwrap(); + assert!(read_grid_snapshot(F1).await.unwrap().is_none()); + assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3); + + // ── requêtes filtrées / triées / paginées ────────────────────────────── + let tout = query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), ..Default::default() }).await.unwrap(); + assert_eq!(tout.total, 3); + assert_eq!(tout.rows.iter().map(|r| r.codein.as_str()).collect::>(), vec!["900001", "900002", "900003"]); + assert!(tout.rows.iter().all(|r| r.payload_version.is_none())); + assert_eq!(tout.rows[0].extra.get("champInconnu"), Some(&json!({ "garde": true }))); + assert!(tout.computed_at.as_deref().unwrap().ends_with('Z')); + + let page2 = query_grid_rows(&GridQuery { + code_fournisseur: Some(F1.into()), + sort: Some(GridSortKey::Codein), + order: Some(SortOrder::Asc), + page: Some(2), + limit: Some(1), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!((page2.total, page2.rows.len(), page2.rows[0].codein.as_str()), (3, 1, "900002")); + + let recherche = + query_grid_rows(&GridQuery { search: Some(" BOUGIE ".into()), code_fournisseur: Some(F1.into()), ..Default::default() }) + .await + .unwrap(); + assert_eq!(recherche.total, 1); + let par_centrale = query_grid_rows(&GridQuery { search: Some("10000900003".into()), ..Default::default() }).await.unwrap(); + assert_eq!(par_centrale.rows[0].codein, "900003"); + let nomenclature = + query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), nomenclature: Some("3202".into()), ..Default::default() }) + .await + .unwrap(); + assert_eq!(nomenclature.total, 2); + // Une valeur hostile reste une valeur liée. + let hostile = query_grid_rows(&GridQuery { code_fournisseur: Some("x' or '1'='1".into()), ..Default::default() }).await.unwrap(); + assert_eq!(hostile.total, 0); + + // ── gammes, fiches, fournisseurs, fraîcheur, nomenclatures ───────────── + update_grid_rows_gamme(F1, [("900001", "A"), ("900002", "Z"), ("900003", "A")]).await.unwrap(); + let gammes = get_grid_rows_gammes(F1, &["900001".into(), "900002".into(), "inconnu".into()]).await.unwrap(); + assert_eq!(gammes.len(), 2); + assert_eq!(gammes["900001"].code_gamme.as_deref(), Some("A")); + assert_eq!(gammes["900001"].code_gamme_init.as_deref(), Some("B")); + let fiche = get_grid_row_by_codein("900002", Some(F1)).await.unwrap().unwrap(); + assert_eq!(fiche.row.code_gamme.as_deref(), Some("Z"), "payload mis à jour"); + assert!(get_grid_row_by_codein("900002", Some("TSTGRID-ABSENT")).await.unwrap().is_none()); + + let mut chez_f2 = ligne("900001", 1.0, "Bougie parfumée", "320211", 1.0); + chez_f2.code_fournisseur = F2.into(); + upsert_grid_rows(F2, &[chez_f2]).await.unwrap(); + let fournisseurs: Vec = + list_grid_suppliers_for_codein("900001").await.unwrap().into_iter().map(|f| f.code_fournisseur).collect(); + assert!(fournisseurs.contains(&F1.to_string()) && fournisseurs.contains(&F2.to_string())); + let tous = list_grid_suppliers().await.unwrap(); + let f1 = tous.iter().find(|s| s.code_fournisseur == F1).unwrap(); + assert_eq!((f1.row_count, f1.nom_fournisseur.as_deref()), (3, Some("FOURNISSEUR TEST"))); + + let fraicheur = get_grid_freshness(F1).await.unwrap().unwrap(); + assert_eq!(fraicheur.row_count, 3); + assert!(get_grid_freshness("TSTGRID-ABSENT").await.unwrap().is_none()); + + let n1 = list_grid_nomenclatures(F1, 1, None).await.unwrap(); + assert_eq!(n1.iter().map(|n| (n.code.as_str(), n.nb_articles)).collect::>(), vec![("32", 2), ("33", 1)]); + assert_eq!(n1[0].libelle.as_deref(), Some("Univers")); + assert_eq!(n1[0].total_ca, 200.0); + let n3 = list_grid_nomenclatures(F1, 3, Some("3202")).await.unwrap(); + assert_eq!(n3.iter().map(|n| n.code.as_str()).collect::>(), vec!["320211", "320212"]); + assert_eq!(n3[0].libelle.as_deref(), Some("Sous-famille 320211")); + + // Purge : un nouveau calcul sans l'article 900003 le retire. + tokio::time::sleep(Duration::from_millis(5)).await; + assert_eq!(upsert_grid_rows(F1, &rows[..2]).await.unwrap(), 2); + assert_eq!(get_grid_freshness(F1).await.unwrap().unwrap().row_count, 2); + + // ── snapshots (actions) ──────────────────────────────────────────────── + let brut = json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": { "900001": { "before": "B", "after": "A" } } }); + let anonyme = enregistrer_snapshot("0", &brut).await; + assert_eq!(anonyme["success"], true, "{anonyme}"); + let id_anonyme = anonyme["snapshotId"].as_i64().unwrap(); + let a_moi = enregistrer_snapshot("987654", &json!({ "codeFournisseur": F1, "magasin": "292", "type": "export", "label": "Mon export", "changes": {}, "summary": { "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 } })).await; + assert_eq!(a_moi["success"], true, "{a_moi}"); + let id_a_moi = a_moi["snapshotId"].as_i64().unwrap(); + + let ids = |v: Vec| -> Vec { + v.into_iter().filter(|s| s.code_fournisseur == F1).map(|s| s.id as i64).collect() + }; + assert_eq!(ids(lister_snapshots("0", None).await.unwrap()), vec![id_anonyme]); + assert_eq!(ids(lister_snapshots("abc", None).await.unwrap()), vec![id_anonyme], "identifiant non numérique = anonyme"); + assert_eq!(ids(lister_snapshots("987654", Some("export")).await.unwrap()), vec![id_a_moi]); + assert!(ids(lister_snapshots("987654", Some("snapshot")).await.unwrap()).is_empty()); + let detail = lister_snapshots("987654", None).await.unwrap().into_iter().find(|s| s.id as i64 == id_a_moi).unwrap(); + let detail = serde_json::to_value(detail).unwrap(); + assert_eq!(detail["label"], "Mon export"); + assert_eq!(detail["userId"], 987654); + assert_eq!(detail["summaryJson"], json!({ "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 })); + assert!(detail["createdAt"].as_str().unwrap().ends_with('Z')); + assert!(lister_snapshots("99999999999", None).await.is_err(), "hors bornes integer : erreur SQL comme en TS"); + + let label_defaut: (String,) = + sqlx::query_as("select label from session_snapshots where id = $1").bind(id_anonyme as i32).fetch_one(&pool).await.unwrap(); + assert!(label_defaut.0.starts_with("Snapshot — ")); + + let invalide = enregistrer_snapshot("0", &json!({ "codeFournisseur": F1 })).await; + assert_eq!( + invalide, + json!({ "success": false, "error": "Validation failed: Invalid input: expected string, received undefined, Invalid input: expected record, received undefined" }) + ); + let trop_grand = enregistrer_snapshot("99999999999", &brut).await; + assert_eq!(trop_grand, json!({ "success": false, "error": "erreur technique sur le serveur" })); + + assert_eq!( + supprimer_snapshot("0", &json!(id_a_moi)).await, + json!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" }) + ); + assert_eq!(supprimer_snapshot("987654", &json!(id_a_moi)).await, json!({ "success": true })); + assert_eq!(supprimer_snapshot("0", &json!("abc")).await, json!({ "success": false, "error": "erreur technique sur le serveur" })); + + // ── enregistrerGammes / saveDraftChanges ─────────────────────────────── + rows_cache::invalidate_grid_rows_cache(None); + let en_cache = rows_cache::GridRows::new(rows[..2].to_vec()); + rows_cache::cache_grid_rows(&rows_cache::grid_rows_cache_key(F1, None), en_cache.clone()); + let n = enregistrer_gammes(&EnregistrementGammes { + code_fournisseur: F1.into(), + nom_fournisseur: Some("FOURNISSEUR TEST".into()), + magasin: "TOTAL".into(), + changes: vec![ChangementGamme { codein: "900002".into(), code_gamme_before: Some("B".into()), code_gamme: "C".into() }], + user_id: None, + label: None, + }) + .await + .unwrap(); + assert_eq!(n, 1); + let dernier: (Value, Option) = + sqlx::query_as("select changes, label from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1") + .bind(F1) + .fetch_one(&pool) + .await + .unwrap(); + // Fusion par-dessus le dernier snapshot du fournisseur (celui de 987654 a été supprimé). + assert_eq!(dernier.0, json!({ "900001": { "before": "B", "after": "A" }, "900002": { "before": "B", "after": "C" } })); + assert!(dernier.1.unwrap().starts_with("Draft — ")); + assert_eq!(en_cache.rows.read()[0].code_gamme.as_deref(), Some("C"), "cache patché (900002)"); + // Le report dans grid_rows est asynchrone. + let mut vu = false; + for _ in 0..50 { + let g = get_grid_rows_gammes(F1, &["900002".into()]).await.unwrap(); + if g["900002"].code_gamme.as_deref() == Some("C") { + vu = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(vu, "grid_rows mis à jour en arrière-plan"); + + // ── gammes à valider ─────────────────────────────────────────────────── + let p = |c: &str, g: &str| PropositionGamme { codein: c.into(), gamme: g.into(), gamme_precedente: Some("B".into()) }; + proposer_gammes(F1, &[p("900001", "Z"), p("900002", "A")], &[], &"x".repeat(150)).await.unwrap(); + proposer_gammes(F1, &[p("900002", "Y")], &["900001".into()], "api-key").await.unwrap(); + let liste = lister_gammes_a_valider(F1).await.unwrap(); + assert_eq!(liste.len(), 1); + assert_eq!((liste[0].codein.as_str(), liste[0].gamme.as_str(), liste[0].auteur.as_deref()), ("900002", "Y", Some("api-key"))); + assert!(liste[0].proposee_le.ends_with('Z')); + let par_article = gammes_a_valider_par_article(&["900002".into(), "900009".into()]).await.unwrap(); + assert_eq!(par_article.get(&format!("{F1}|900002")).map(String::as_str), Some("Y")); + proposer_gammes(F1, &[p("900001", "Z")], &[], &"x".repeat(150)).await.unwrap(); + let auteur: (String,) = sqlx::query_as("select auteur from gammes_a_valider where code_fournisseur = $1 and codein = '900001'") + .bind(F1) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(auteur.0.len(), 100); + + // saveDraftChanges : les propositions traitées quittent la file. + let res = enregistrer_brouillon( + "987654", + &json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": [{ "codein": "900002", "codeGammeBefore": "C", "codeGamme": "Y" }], "gammesAValiderTraitees": ["900002"] }), + ) + .await; + assert_eq!(res, json!({ "success": true, "saved": 1 })); + let restantes: Vec = lister_gammes_a_valider(F1).await.unwrap().into_iter().map(|g| g.codein).collect(); + assert_eq!(restantes, vec!["900001".to_string()]); + supprimer_gammes_a_valider(F1, &["900001".into()]).await.unwrap(); + assert!(lister_gammes_a_valider(F1).await.unwrap().is_empty()); + let invalide = enregistrer_brouillon("0", &json!({ "codeFournisseur": F1 })).await; + assert_eq!(invalide["success"], false); + assert!(invalide["error"].as_str().unwrap().starts_with("Validation failed: [\n {\n \"expected\": \"string\"")); + + // ── route /api/v1/nomenclatures (sans HTTP : schéma + requêtes) ──────── + let params = query_map(vec![("fournisseur".into(), F1.into()), ("niveau".into(), "2".into())]); + let parsed = super::api_schemas::parse_nomenclatures_query(¶ms).unwrap(); + let postes = list_grid_nomenclatures(&parsed.fournisseur, parsed.niveau, None).await.unwrap(); + assert_eq!(postes.iter().map(|p| p.code.as_str()).collect::>(), vec!["3202"]); + + nettoyer(&pool).await; + rows_cache::invalidate_grid_rows_cache(None); +} diff --git a/server/src/grid/types.rs b/server/src/grid/types.rs new file mode 100644 index 0000000..6478d05 --- /dev/null +++ b/server/src/grid/types.rs @@ -0,0 +1,354 @@ +//! CollectFlow - Core Grid Types (port de `src/types/grid.ts`). +//! +//! `ProductRow` circule tel quel en JSON : vers la Grille, dans `grid_rows.payload` +//! (jsonb) et dans l'API `/api/v1`. Les attributs serde reproduisent +//! `JSON.stringify` : noms camelCase, champs facultatifs omis quand ils sont +//! `undefined`, `null` conservé pour les champs `| null`, entiers sans décimale. +//! +//! Lecture tolérante : un payload écrit par la version Node se relit sans perte — +//! champ manquant → valeur par défaut, `null` numérique → `NaN` (réécrit `null`), +//! champ inconnu conservé dans `extra` et réécrit à l'identique. + +use super::js; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// A/B/C/Z status codes for product categories, but can be any string from DB (like 'Aucune') +pub type GammeCode = String; + +/// Série mensuelle `{ "YYYYMM": valeur }` (ou `"YYYY-MM"` pour le réseau Qlik). +pub type Serie = BTreeMap; +/// Ventilation par magasin puis par mois. +pub type SerieParMagasin = BTreeMap; + +/// A product row with its 12-month sales history and computed KPIs. +/// +/// Ordre des champs = ordre de construction dans `getProductRows` (donc celui du +/// JSON produit par le calcul en direct). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ProductRow { + #[serde(deserialize_with = "js::string::deserialize")] + pub codein: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub code_fournisseur: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub nom_fournisseur: String, + /// Dernier fournisseur enregistré pour cet article (artfou1.no_id DESC). Peut différer du fournisseur consulté. + #[serde(skip_serializing_if = "Option::is_none")] + pub fournisseur_principal_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fournisseur_principal_nom: Option, + #[serde(deserialize_with = "js::string::deserialize")] + pub libelle1: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub gtin: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub reference: String, + /// Code centrale (clé jointure Qlik, format 10000XXXXXX) + #[serde(skip_serializing_if = "Option::is_none")] + pub code_centrale: Option, + #[serde(deserialize_with = "js::string::deserialize")] + pub code1: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub libelle_niveau1: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub code2: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub libelle_niveau2: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub code3: String, + #[serde(deserialize_with = "js::string::deserialize")] + pub libelle3: String, + pub code_gamme: Option, + pub code_gamme_init: Option, + /// Draft gamme value before saving + pub code_gamme_draft: Option, + /// 12-month sales quantities, indexed by YYYYMM key + #[serde(with = "js::map")] + pub sales12m: Serie, + /// End-of-month stock levels, indexed by YYYYMM key + #[serde(with = "js::map")] + pub stock12m: Serie, + /// Monthly reception quantities, indexed by YYYYMM key + #[serde(with = "js::map")] + pub receptions12m: Serie, + #[serde(with = "js::num")] + pub total_quantite: f64, + #[serde(with = "js::num")] + pub total_ca: f64, + #[serde(with = "js::num")] + pub total_marge: f64, + #[serde(with = "js::num")] + pub taux_marge: f64, + pub working_stores: Vec, + /// SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub noid: Option, + /// Conditionnement (pack size) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub pcb: Option, + /// Prix de vente pratiqué, en euros (le plus élevé quand les magasins divergent : + /// `prixVenteByStore` porte le détail). + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub prix_vente: Option, + /// Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")] + pub sales12m_by_store: Option, + /// Per-store stock breakdown (site → YYYYMM → stock fin de mois) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")] + pub stock12m_by_store: Option, + /// Per-store reception breakdown (site → YYYYMM → qté reçue) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")] + pub receptions12m_by_store: Option, + /// Per-store CA totals + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")] + pub ca_by_store: Option, + /// Per-store quantity totals + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")] + pub quantite_by_store: Option, + /// Per-store margin totals + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")] + pub marge_by_store: Option, + /// Qté en commande fournisseur + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub commandes_en_cours: Option, + /// Stock dispo agrégé tous sites + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub stock_actuel: Option, + /// Qte en stock agrégé + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub stock_total: Option, + /// Valeur stock agrégée + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub stock_valeur: Option, + /// PRMP (alias prixAchat) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub pa: Option, + /// PA / PRMP + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub prix_achat: Option, + /// ISO date de dernière vente + #[serde(skip_serializing_if = "Option::is_none")] + pub derniere_vente: Option, + /// ISO date de dernière réception + #[serde(skip_serializing_if = "Option::is_none")] + pub derniere_livraison: Option, + /// Per-store last delivery/reception date (site → ISO date string) + #[serde(skip_serializing_if = "Option::is_none")] + pub derniere_livraison_by_store: Option>, + /// Prix de vente par magasin (site → PV) — `cube_pv`. + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")] + pub prix_vente_by_store: Option, + /// Données réseau Qlik (~270 magasins) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub ca_reseau: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub qte_reseau: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub nb_magasins_reseau: Option, + /// CA moyen par magasin réseau (productivité normalisée présence) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub ca_par_magasin_reseau: Option, + /// Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub marge_pct_reseau: Option, + /// Taux de présence réseau = nbMagasinsReseau / 270 + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub taux_presence_reseau: Option, + /// Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). + /// `None` = absent, `Some(None)` = `null`. + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")] + pub qte_reseau_by_month: Option>, + /// Nombre de magasins vendeurs par mois : { "YYYY-MM": nb } (deuxième courbe de + /// la carte tendance). `None` = absent, `Some(None)` = `null`. + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")] + pub nb_mag_reseau_by_month: Option>, + /// Fraîcheur des données réseau (ISO) + #[serde(skip_serializing_if = "Option::is_none")] + pub network_fetched_at: Option, + /// Rotation normalisée : unités vendues / magasin / mois + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub units_per_store_per_month: Option, + /// CA normalisé : € / magasin / an + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub ca_per_store_per_year: Option, + /// Produit récent (≤ 3 mois de données) + #[serde(skip_serializing_if = "Option::is_none")] + pub is_recent: Option, + /// Seul produit du fournisseur dans le lot + #[serde(skip_serializing_if = "Option::is_none")] + pub is_last_product: Option, + /// Top 30% CA du lot fournisseur + #[serde(skip_serializing_if = "Option::is_none")] + pub is_top30_supplier: Option, + /// Moyennes de comparaison pour l'IA + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub avg_qty_fournisseur: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub avg_qty_rayon: Option, + /// Poids relatifs (%) du produit chez le fournisseur + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub share_ca: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub share_marge: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub share_qty: Option, + /// Référentiels globaux + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub total_fournisseur_ca: Option, + /// Version du format de la ligne, posée à l'écriture dans `grid_rows`. Une + /// ligne d'une version antérieure n'est pas servie telle quelle à la Grille + /// (des champs récents lui manqueraient) : elle est recalculée. + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub payload_version: Option, + /// Champs inconnus de ce type (payload écrit par une autre version) : conservés + /// et réécrits tels quels. + #[serde(flatten)] + pub extra: Map, +} + +/// Summary bar totals for the currently visible/filtered rows +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GridSummary { + #[serde(with = "js::num")] + pub total_products: f64, + #[serde(with = "js::num")] + pub total_rows: f64, + #[serde(with = "js::num")] + pub total_quantite: f64, + #[serde(with = "js::num")] + pub total_ca: f64, + #[serde(with = "js::num")] + pub total_marge: f64, + #[serde(with = "js::num")] + pub taux_marge_global: f64, +} + +/// Filters applied to the grid +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GridFilters { + pub magasin: Option, + pub code_fournisseur: Option, + pub code1: Option, + pub code2: Option, + /// Nomenclatures retenues (codes `code3`). + /// `None` = aucun filtre (tout s'affiche) ; `Some(vec![])` = rien de coché. + pub code3: Option>, + pub code_gamme: Option, + pub search: String, +} + +/// One row in the data grid, representing a product at a store for one period. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct VenteProduit { + #[serde(with = "js::num")] + pub id: f64, + pub codein: String, + pub code_fournisseur: Option, + pub nom_fournisseur: Option, + pub libelle1: Option, + pub gtin: Option, + pub reference: Option, + pub code_gamme: Option, + pub code_gamme_init: Option, + pub code3: Option, + pub libelle3: Option, + pub magasin: String, + pub code_magasin: Option, + pub annee: Option, + pub mois: Option, + pub periode: String, + pub quantite: Option, + pub montant_mvt: Option, + pub marge_mvt: Option, +} + +// --------------------------------------------------------------------------- +// Complément par magasin (`src/features/grid/lib/store-patch.ts`) +// --------------------------------------------------------------------------- + +/// Chiffres d'un article pour le magasin complété. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct StorePatchEntry { + pub codein: String, + /// Ventes par mois (« YYYYMM »). + #[serde(with = "js::map")] + pub sales: Serie, + /// Stock de fin de mois, reporté sur les mois sans donnée. + #[serde(with = "js::map")] + pub stock: Serie, + #[serde(with = "js::map")] + pub receptions: Serie, + #[serde(with = "js::num")] + pub quantite: f64, + #[serde(with = "js::num")] + pub ca: f64, + #[serde(with = "js::num")] + pub marge: f64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct StorePatch { + pub magasin: String, + /// Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré. + pub periods: Vec, + pub entries: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Ligne telle que l'écrit la version Node (ordre jsonb, null, champ inconnu). + fn payload_node() -> Value { + json!({ + "pa": 2.5, "pcb": 6, "gtin": "3700000000001", "noid": 4242, + "code1": "32", "code2": "3202", "code3": "320211", + "codein": "123456", "libelle1": "Bougie parfumée", "libelle3": "Bougies", + "sales12m": { "202510": 3, "202511": 0 }, "stock12m": { "202510": 10, "202511": 10 }, + "totalCa": 37.5, "caByStore": { "292": 37.5 }, "codeGamme": "A", + "reference": "REF-1", "tauxMarge": 41.2, "prixAchat": 2.5, "totalMarge": 15.45, + "codeCentrale": "10000123456", "codeGammeInit": "B", "codeGammeDraft": null, + "nomFournisseur": "FOURNISSEUR TEST", "receptions12m": { "202510": 0, "202511": 0 }, + "totalQuantite": 3, "workingStores": ["292"], "libelleNiveau1": "Déco", "libelleNiveau2": "Senteurs", + "codeFournisseur": "F001", "payloadVersion": 1, "sales12mByStore": { "292": { "202510": 3 }, "579": {} }, + "qteReseauByMonth": null, "margePctReseau": null, "champFutur": { "x": [1, 2] }, + "derniereLivraisonByStore": { "292": "2025-10-03" } + }) + } + + #[test] + fn aller_retour_payload_node() { + let brut = payload_node(); + let row: ProductRow = serde_json::from_value(brut.clone()).unwrap(); + assert_eq!(row.codein, "123456"); + assert_eq!(row.noid, Some(4242.0)); + assert_eq!(row.code_gamme_draft, None); + assert_eq!(row.qte_reseau_by_month, Some(None)); + assert!(row.marge_pct_reseau.unwrap().is_nan()); + assert!(row.nb_mag_reseau_by_month.is_none()); + assert_eq!(row.extra.get("champFutur"), Some(&json!({ "x": [1, 2] }))); + // Même contenu JSON après relecture et réécriture. + let reecrit = serde_json::to_value(&row).unwrap(); + assert_eq!(reecrit, brut); + } + + #[test] + fn champs_facultatifs_omis_et_entiers_sans_decimale() { + let row = ProductRow { codein: "1".into(), total_quantite: 12.0, total_ca: 10.5, ..Default::default() }; + let s = serde_json::to_string(&row).unwrap(); + assert!(s.starts_with(r#"{"codein":"1","codeFournisseur":"","nomFournisseur":"","libelle1":"""#)); + assert!(s.contains(r#""codeGamme":null,"codeGammeInit":null,"codeGammeDraft":null"#)); + assert!(s.contains(r#""totalQuantite":12,"totalCa":10.5"#)); + assert!(!s.contains("noid")); + assert!(!s.contains("payloadVersion")); + } +} diff --git a/server/src/grid/zod.rs b/server/src/grid/zod.rs new file mode 100644 index 0000000..41b585f --- /dev/null +++ b/server/src/grid/zod.rs @@ -0,0 +1,362 @@ +//! Validation façon zod (v4) : mêmes contrôles, mêmes « issues ». +//! +//! Les routes `/api/v1` renvoient `parsed.error.issues` dans `error.details`, et +//! certaines actions recopient `parsed.error.message` (le JSON indenté des issues) +//! dans leur message d'erreur. Pour que l'appelant reçoive exactement le même +//! détail, on reproduit la forme des issues de zod 4 : clés dans le même ordre, +//! mêmes messages par défaut, même ordre de parcours (celui du schéma). + +use super::js; +use serde_json::{json, Map, Value}; + +/// Plus grand entier « sûr » de JS (`Number.MAX_SAFE_INTEGER`). +pub const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + +/// Segment de chemin d'une issue : clé d'objet ou indice de tableau. +#[derive(Debug, Clone)] +pub enum Seg { + K(String), + I(usize), +} + +impl From<&str> for Seg { + fn from(s: &str) -> Self { + Seg::K(s.to_string()) + } +} +impl From for Seg { + fn from(i: usize) -> Self { + Seg::I(i) + } +} + +pub type Path = Vec; + +pub fn path_json(path: &[Seg]) -> Value { + Value::Array( + path.iter() + .map(|s| match s { + Seg::K(k) => Value::String(k.clone()), + Seg::I(i) => Value::from(*i), + }) + .collect(), + ) +} + +pub fn child(path: &[Seg], seg: impl Into) -> Path { + let mut p = path.to_vec(); + p.push(seg.into()); + p +} + +/// Type reçu, tel que zod le nomme dans ses messages. +pub fn received(v: Option<&Value>) -> &'static str { + match v { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::Bool(_)) => "boolean", + Some(Value::Number(_)) => "number", + Some(Value::String(_)) => "string", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +/// Nombre affiché dans un message (`String(n)`). +fn n(x: f64) -> String { + js::js_number_to_string(x) +} + +fn num_json(x: f64) -> Value { + if x.fract() == 0.0 && x.abs() <= MAX_SAFE_INTEGER { + Value::from(x as i64) + } else { + serde_json::Number::from_f64(x).map(Value::Number).unwrap_or(Value::Null) + } +} + +/// Liste d'issues, dans l'ordre où zod les produit. +#[derive(Debug, Default, Clone)] +pub struct Issues(pub Vec); + +impl Issues { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn push(&mut self, mut issue: Map, path: &[Seg], message: String) { + issue.insert("path".into(), path_json(path)); + issue.insert("message".into(), Value::String(message)); + self.0.push(Value::Object(issue)); + } + + fn obj(v: Value) -> Map { + match v { + Value::Object(m) => m, + _ => Map::new(), + } + } + + /// `invalid_type` : type attendu absent ou différent. + pub fn invalid_type(&mut self, path: &[Seg], expected: &str, got: Option<&Value>) { + let issue = Self::obj(json!({ "expected": expected, "code": "invalid_type" })); + self.push(issue, path, format!("Invalid input: expected {expected}, received {}", received(got))); + } + + /// `invalid_type` d'un nombre non fini (`NaN`, `Infinity`) après coercition. + pub fn invalid_number(&mut self, path: &[Seg], x: f64) { + let recu = if x.is_nan() { "NaN" } else { "Infinity" }; + let issue = Self::obj(json!({ "expected": "number", "code": "invalid_type", "received": recu })); + let msg = if x.is_nan() { "received NaN" } else { "received number" }; + self.push(issue, path, format!("Invalid input: expected number, {msg}")); + } + + /// `.int()` sur un nombre à décimales. + pub fn not_int(&mut self, path: &[Seg]) { + let issue = Self::obj(json!({ "expected": "int", "format": "safeint", "code": "invalid_type" })); + self.push(issue, path, "Invalid input: expected int, received number".into()); + } + + /// `.int()` hors de l'intervalle des entiers sûrs. + pub fn unsafe_int(&mut self, path: &[Seg], trop_grand: bool) { + let (code, cle, borne, sens, op) = if trop_grand { + ("too_big", "maximum", MAX_SAFE_INTEGER, "Too big", "<=") + } else { + ("too_small", "minimum", -MAX_SAFE_INTEGER, "Too small", ">=") + }; + let mut issue = Map::new(); + issue.insert("code".into(), json!(code)); + issue.insert(cle.into(), num_json(borne)); + issue.insert("note".into(), json!("Integers must be within the safe integer range.")); + issue.insert("origin".into(), json!("int")); + issue.insert("inclusive".into(), json!(true)); + self.push(issue, path, format!("{sens}: expected int to be {op}{}", n(borne))); + } + + /// `too_small` (`origin` : `string`, `number` ou `array`). + pub fn too_small(&mut self, path: &[Seg], origin: &str, minimum: f64, message: Option<&str>) { + let mut issue = Map::new(); + issue.insert("origin".into(), json!(origin)); + issue.insert("code".into(), json!("too_small")); + issue.insert("minimum".into(), num_json(minimum)); + issue.insert("inclusive".into(), json!(true)); + let defaut = match origin { + "string" => format!("Too small: expected string to have >={} characters", n(minimum)), + "array" => format!("Too small: expected array to have >={} items", n(minimum)), + _ => format!("Too small: expected {origin} to be >={}", n(minimum)), + }; + self.push(issue, path, message.map(str::to_string).unwrap_or(defaut)); + } + + /// `too_big` (`origin` : `string`, `number` ou `array`). + pub fn too_big(&mut self, path: &[Seg], origin: &str, maximum: f64, message: Option<&str>) { + let mut issue = Map::new(); + issue.insert("origin".into(), json!(origin)); + issue.insert("code".into(), json!("too_big")); + issue.insert("maximum".into(), num_json(maximum)); + issue.insert("inclusive".into(), json!(true)); + let defaut = match origin { + "string" => format!("Too big: expected string to have <={} characters", n(maximum)), + "array" => format!("Too big: expected array to have <={} items", n(maximum)), + _ => format!("Too big: expected {origin} to be <={}", n(maximum)), + }; + self.push(issue, path, message.map(str::to_string).unwrap_or(defaut)); + } + + /// `invalid_value` d'un `z.enum([...])`. + pub fn invalid_value(&mut self, path: &[Seg], values: &[&str], message: Option<&str>) { + let issue = Self::obj(json!({ "code": "invalid_value", "values": values })); + let defaut = format!("Invalid option: expected one of {}", values.iter().map(|v| format!("\"{v}\"")).collect::>().join("|")); + self.push(issue, path, message.map(str::to_string).unwrap_or(defaut)); + } + + /// `invalid_format` d'un `.regex(...)`. + pub fn invalid_regex(&mut self, path: &[Seg], pattern: &str, message: &str) { + let issue = Self::obj(json!({ "origin": "string", "code": "invalid_format", "format": "regex", "pattern": pattern })); + self.push(issue, path, message.to_string()); + } + + /// `custom` d'un `.refine(...)`. + pub fn custom(&mut self, path: &[Seg], message: &str) { + let issue = Self::obj(json!({ "code": "custom" })); + self.push(issue, path, message.to_string()); + } + + /// Les issues, telles que `parsed.error.issues`. + pub fn to_json(&self) -> Value { + Value::Array(self.0.clone()) + } + + /// `parsed.error.message` : `JSON.stringify(issues, null, 2)`. + pub fn message(&self) -> String { + serde_json::to_string_pretty(&self.0).unwrap_or_default() + } + + /// `issues.map((i) => i.message).join(", ")`. + pub fn messages_joined(&self) -> String { + self.0.iter().map(|i| i.get("message").and_then(Value::as_str).unwrap_or_default().to_string()).collect::>().join(", ") + } +} + +// --------------------------------------------------------------------------- +// Briques de schéma +// --------------------------------------------------------------------------- + +/// `z.object(...)` : la racine doit être un objet. `None` (issue posée) sinon. +pub fn object<'a>(v: Option<&'a Value>, path: &[Seg], issues: &mut Issues) -> Option<&'a Map> { + match v { + Some(Value::Object(m)) => Some(m), + autre => { + issues.invalid_type(path, "object", autre); + None + } + } +} + +/// `z.string()` obligatoire. +pub fn string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option { + match v { + Some(Value::String(s)) => Some(s.clone()), + autre => { + issues.invalid_type(path, "string", autre); + None + } + } +} + +/// `z.string().optional()` : `Ok(None)` si absent. +pub fn opt_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option> { + match v { + None => Some(None), + autre => string(autre, path, issues).map(Some), + } +} + +/// `z.string().nullable()`. +pub fn nullable_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option> { + match v { + Some(Value::Null) => Some(None), + autre => string(autre, path, issues).map(Some), + } +} + +/// Bornes de longueur d'une chaîne (`.min(n)`, `.max(n)`), en unités UTF-16 comme JS. +pub fn check_len( + s: &str, + path: &[Seg], + min: Option<(usize, Option<&str>)>, + max: Option<(usize, Option<&str>)>, + issues: &mut Issues, +) -> bool { + let len = s.encode_utf16().count(); + let mut ok = true; + if let Some((m, msg)) = min { + if len < m { + issues.too_small(path, "string", m as f64, msg); + ok = false; + } + } + if let Some((m, msg)) = max { + if len > m { + issues.too_big(path, "string", m as f64, msg); + ok = false; + } + } + ok +} + +/// `z.number()` (pas de coercition) : nombre fini. +pub fn number(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option { + match v { + Some(Value::Number(x)) => x.as_f64(), + autre => { + issues.invalid_type(path, "number", autre); + None + } + } +} + +/// `z.boolean()`. +pub fn boolean(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option { + match v { + Some(Value::Bool(b)) => Some(*b), + autre => { + issues.invalid_type(path, "boolean", autre); + None + } + } +} + +/// `z.enum([...])` sur une valeur quelconque. +pub fn enum_value(v: Option<&Value>, values: &[&str], path: &[Seg], message: Option<&str>, issues: &mut Issues) -> Option { + match v.and_then(Value::as_str) { + Some(s) if values.contains(&s) => Some(s.to_string()), + _ => { + issues.invalid_value(path, values, message); + None + } + } +} + +/// `z.coerce.number().int().min(min)[.max(max)]` sur un paramètre d'URL. +pub fn coerce_int(s: &str, path: &[Seg], min: f64, max: Option, issues: &mut Issues) -> Option { + let x = js::js_number_from_str(s); + if !x.is_finite() { + issues.invalid_number(path, x); + return None; + } + if x.fract() != 0.0 { + issues.not_int(path); + return None; + } + let mut ok = true; + if x > MAX_SAFE_INTEGER { + issues.unsafe_int(path, true); + ok = false; + } else if x < -MAX_SAFE_INTEGER { + issues.unsafe_int(path, false); + ok = false; + } + if x < min { + issues.too_small(path, "number", min, None); + ok = false; + } + if let Some(m) = max { + if x > m { + issues.too_big(path, "number", m, None); + ok = false; + } + } + // `-0` reste un entier valide pour zod ; il ne peut de toute façon pas + // franchir un minimum de 1. + ok.then_some(x as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forme_des_issues() { + let mut i = Issues::default(); + let p = vec![Seg::from("niveau")]; + assert!(coerce_int("abc", &p, 1.0, Some(3.0), &mut i).is_none()); + assert!(coerce_int("1e20", &p, 1.0, Some(3.0), &mut i).is_none()); + assert!(coerce_int("1.5", &p, 1.0, Some(3.0), &mut i).is_none()); + assert_eq!(coerce_int(" 0x2 ", &p, 1.0, Some(3.0), &mut i), Some(2)); + assert_eq!( + serde_json::to_string(&i.to_json()).unwrap(), + r#"[{"expected":"number","code":"invalid_type","received":"NaN","path":["niveau"],"message":"Invalid input: expected number, received NaN"},{"code":"too_big","maximum":9007199254740991,"note":"Integers must be within the safe integer range.","origin":"int","inclusive":true,"path":["niveau"],"message":"Too big: expected int to be <=9007199254740991"},{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"expected":"int","format":"safeint","code":"invalid_type","path":["niveau"],"message":"Invalid input: expected int, received number"}]"# + ); + } + + #[test] + fn message_indente_comme_json_stringify() { + let mut i = Issues::default(); + i.invalid_type(&[], "object", Some(&Value::Null)); + assert_eq!( + i.message(), + "[\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [],\n \"message\": \"Invalid input: expected object, received null\"\n }\n]" + ); + } +} From 8db3fe03351b767b2f5f044f3f796e402d2d9a4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:37:23 +0000 Subject: [PATCH 13/19] =?UTF-8?q?Synchro=20nocturne=20:=20extraction=20Qli?= =?UTF-8?q?k=20branch=C3=A9e,=20fra=C3=AEcheur=20du=20pr=C3=A9chauffage=20?= =?UTF-8?q?lue=20en=20ISO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/admin/grid_warmup.rs | 8 +++++++- server/src/admin/qlik_bridge.rs | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/server/src/admin/grid_warmup.rs b/server/src/admin/grid_warmup.rs index 71f4181..2b13f15 100644 --- a/server/src/admin/grid_warmup.rs +++ b/server/src/admin/grid_warmup.rs @@ -79,7 +79,13 @@ async fn run_warmup(stale_hours: f64) { let fresh_limit = chrono::Utc::now().naive_utc() - chrono::Duration::milliseconds((stale_hours * 3_600_000.0) as i64); let fresh: HashSet = existing .iter() - .filter(|s| s.computed_at_naive().map(|d| d > fresh_limit).unwrap_or(false)) + .filter(|s| { + s.computed_at + .as_deref() + .and_then(|c| chrono::DateTime::parse_from_rfc3339(c).ok()) + .map(|d| d.naive_utc() > fresh_limit) + .unwrap_or(false) + }) .map(|s| s.code_fournisseur.clone()) .collect(); maj(|j| j.total = fournisseurs.len()); diff --git a/server/src/admin/qlik_bridge.rs b/server/src/admin/qlik_bridge.rs index a6a8ef4..08d8e7b 100644 --- a/server/src/admin/qlik_bridge.rs +++ b/server/src/admin/qlik_bridge.rs @@ -25,3 +25,13 @@ pub fn reset_ff_api_base() { pub async fn diagnostiquer_api_ff(base: Option) -> Value { serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base).await).unwrap_or(Value::Null) } + +/// Extraction Qlik d'un fournisseur sur la fenêtre de la synchro (`QLIK_SYNC_MONTHS_BACK`), +/// puis enregistrement dans le cache réseau. Renvoie le nombre de codes enregistrés. +pub async fn extraire_et_enregistrer(codes: Vec, fournisseur: &str) -> anyhow::Result { + use crate::qlik::date_range::{build_grid_network_qlik_date_filter_now, env_months_back, QLIK_MONTHS_BACK_DEFAULT}; + let filtre = build_grid_network_qlik_date_filter_now(env_months_back("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT)); + let metrics = crate::qlik::fetch_network_metrics_playwright(Some(codes), None, Some(filtre), Some(fournisseur.to_string())).await?; + let liste: Vec<_> = metrics.into_values().collect(); + crate::qlik::upsert_network_metrics(&liste).await +} From 46ecd1dccce50d56c3977edf0854ebf40861f2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:48:42 +0000 Subject: [PATCH 14/19] =?UTF-8?q?Docker=20:=20niveau=20de=20journalisation?= =?UTF-8?q?=20par=20d=C3=A9faut=20du=20serveur=20(sans=20le=20bruit=20de?= =?UTF-8?q?=20sqlx)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- Dockerfile | 3 +-- README.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6aac4a6..2660563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,8 +48,7 @@ ENV PORT=5643 \ HOSTNAME=0.0.0.0 \ DATA_DIR=/app/data \ STATIC_DIR=/app/web \ - PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium \ - RUST_LOG=info + PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium USER collectflow EXPOSE 5643 diff --git a/README.md b/README.md index e12980f..8911a72 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Construire l'image localement plutôt que la télécharger : `docker compose bui Le volume `collectflow-data` (`/app/data`) conserve les réglages saisis dans **Paramètres** (`.db-config.json` : base, Qlik, API FF, synchro nocturne), les comptes de secours (`users.json`) et le secret de session (`.auth-secret`, si `AUTH_SECRET` n'est pas défini). Il est repris tel quel depuis l'ancienne version Node : comptes et mots de passe restent valables (seules les sessions ouvertes sont à refaire). -Variables d'environnement utiles : `DATABASE_URL`, `AUTH_SECRET`, `FF_API_BASE_URL`, `QLIK_HOST` / `QLIK_USER` / `QLIK_PWD` (et les réglages fins `QLIK_*`), `PG_STATEMENT_TIMEOUT_MS`, `FF_CACHE_TTL_S`, `RUST_LOG` (niveau de journalisation, `info` par défaut). +Variables d'environnement utiles : `DATABASE_URL`, `AUTH_SECRET`, `FF_API_BASE_URL`, `QLIK_HOST` / `QLIK_USER` / `QLIK_PWD` (et les réglages fins `QLIK_*`), `PG_STATEMENT_TIMEOUT_MS`, `FF_CACHE_TTL_S`, `RUST_LOG` (niveau de journalisation ; par défaut `info`, avec sqlx et Chromium limités aux avertissements). ## Développement local From 97c1dea849ef9757ca8dcb193320d86e0a898847 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:54:54 +0000 Subject: [PATCH 15/19] Serveur Rust : module grid, phase 2 (calcul de la grille, routes /api/grid et /api/v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - product_rows.rs : getProductRows complet (7 requêtes FF en parallèle, métriques réseau Qlik, report du dernier snapshot, filtre gamme Y, rattrapage d'un magasin par l'API FF), branché sur rows_cache (calculs en cours partagés, compteur d'enregistrements, compléments par magasin), instantané grid_rows resservi s'il date du mois (rafraîchi au-delà de 20 h), persistance non bloquante ; getStorePatch ; get_product_rows_for_sync pour la synchro nocturne et le préchauffage. - store_patch.rs : buildStorePatchEntries. - enrich.rs (api-enrich) et api_gammes.rs (affecterGammes, lireCorpsJson). - Routes /api/grid/rows (NDJSON), /api/grid/rows/store-patch, /api/v1/grid (diffusion complète sans limit), /products/search, /products/{codein}, /products/{codein}/gamme, /gammes, /fournisseurs, /network/{codeCentrale}. - store.rs : upsert_grid_rows_shared, écriture par lots depuis les lignes partagées du cache (sans copie intégrale). Écart volontaire : après un calcul à la demande, /api/v1 attend la fin de l'écriture de l'instantané avant de le relire (le TS ne l'attendait pas et pouvait répondre à tort « aucun article »). Test d'intégration sur la base ff_fixture (GRID_FF_TEST_DATABASE_URL) : calcul, cache, instantané, complément magasin via une fausse API FF, enrichissement et toutes les routes (session et clé d'API). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/grid/api_gammes.rs | 143 ++++++ server/src/grid/api_v1_grid.rs | 551 ++++++++++++++++++++ server/src/grid/enrich.rs | 148 ++++++ server/src/grid/grid_rows.rs | 135 +++++ server/src/grid/mod.rs | 32 +- server/src/grid/product_rows.rs | 879 ++++++++++++++++++++++++++++++++ server/src/grid/store.rs | 38 +- server/src/grid/store_patch.rs | 95 +++- server/src/grid/tests_db.rs | 2 + server/src/grid/tests_ff.rs | 302 +++++++++++ 10 files changed, 2306 insertions(+), 19 deletions(-) create mode 100644 server/src/grid/api_gammes.rs create mode 100644 server/src/grid/api_v1_grid.rs create mode 100644 server/src/grid/enrich.rs create mode 100644 server/src/grid/grid_rows.rs create mode 100644 server/src/grid/product_rows.rs create mode 100644 server/src/grid/tests_ff.rs diff --git a/server/src/grid/api_gammes.rs b/server/src/grid/api_gammes.rs new file mode 100644 index 0000000..da7bba7 --- /dev/null +++ b/server/src/grid/api_gammes.rs @@ -0,0 +1,143 @@ +//! CollectFlow — Affectation de gammes via l'API `/api/v1` +//! (port de `src/lib/api-gammes.ts`). +//! +//! L'API n'enregistre pas la gamme : elle la **propose**. La proposition est +//! rangée dans `gammes_a_valider` et apparaît dans la Grille comme une +//! modification non enregistrée — on y voit donc quels produits changent de gamme +//! avant de valider (« Enregistrer ») ou de rejeter (« Annuler les modifications »). +//! Tant qu'elle n'est pas validée, `codeGamme` reste inchangée et la proposition +//! est exposée dans `gammeAValider`. +//! +//! Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est +//! proposé et la réponse liste les articles en cause. + +use super::gammes_a_valider::{proposer_gammes, PropositionGamme}; +use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput}; +use super::store::{get_grid_freshness, get_grid_rows_gammes}; +use crate::api_v1_response::{fail, ApiErrorCode}; +use axum::response::Response; +use serde::Serialize; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResultatGamme { + pub codein: String, + /// Gamme proposée. + pub gamme: String, + /// Gamme enregistrée au moment de l'appel (modifications validées comprises). + pub gamme_precedente: Option, + /// Gamme présente en base FF, non modifiée par l'API. + pub code_gamme_serveur: Option, + /// `true` = la gamme change : proposition déposée, à valider dans la Grille. + /// `false` = l'article a déjà cette gamme (une proposition antérieure en + /// attente pour lui est retirée). + pub modifie: bool, + /// `a_valider` quand `modifie`, sinon `inchangee`. + pub statut: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AffectationGammes { + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub resultats: Vec, +} + +/// Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée. +pub fn lire_corps_json(body: &[u8]) -> Result { + serde_json::from_slice(body) + .map_err(|_| fail(ApiErrorCode::BadRequest, "Corps de requête JSON attendu (Content-Type: application/json).", None)) +} + +/// Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille). +/// +/// Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la +/// demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les +/// articles lui appartiennent. +/// +/// `auteur` : nom de la clé d'API ou de l'utilisateur — affiché dans la Grille. +pub async fn affecter_gammes( + code_fournisseur: &str, + changes: &[(String, String)], + compute: bool, + auteur: &str, +) -> Result { + // Un même article cité deux fois : la dernière valeur l'emporte (à la place + // de sa première occurrence, comme une Map JS). + let mut demandes: Vec<(String, String)> = Vec::new(); + for (codein, gamme) in changes { + match demandes.iter_mut().find(|(c, _)| c == codein) { + Some(d) => d.1 = gamme.clone(), + None => demandes.push((codein.clone(), gamme.clone())), + } + } + let codeins: Vec = demandes.iter().map(|(c, _)| c.clone()).collect(); + let interne = |e: crate::error::AppError| fail(ApiErrorCode::InternalError, e.message, None); + + if compute && get_grid_freshness(code_fournisseur).await.map_err(interne)?.is_none() { + tracing::info!("[api/v1/gammes] instantané absent pour {code_fournisseur} — calcul à la demande"); + let calcul = get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await; + attendre_persistance(code_fournisseur).await; + if let Err(e) = calcul { + tracing::error!("[api/v1/gammes] calcul KO pour {code_fournisseur}: {e:#}"); + return Err(fail( + ApiErrorCode::InternalError, + format!("Le calcul de la grille a échoué pour le fournisseur « {code_fournisseur} »."), + None, + )); + } + } + + let actuelles = get_grid_rows_gammes(code_fournisseur, &codeins).await.map_err(interne)?; + let inconnus: Vec<&String> = codeins.iter().filter(|c| !actuelles.contains_key(*c)).collect(); + if !inconnus.is_empty() { + return Err(fail( + ApiErrorCode::NotFound, + format!( + "{} article(s) introuvable(s) chez le fournisseur « {code_fournisseur} » : aucune gamme n'a été enregistrée.", + inconnus.len() + ), + Some(json!({ "fournisseur": code_fournisseur, "inconnus": inconnus })), + )); + } + + let mut resultats = Vec::with_capacity(demandes.len()); + let mut propositions = Vec::new(); + let mut annules = Vec::new(); + let mut nom_fournisseur: Option = None; + + for (codein, gamme) in demandes { + let actuelle = &actuelles[&codein]; + if nom_fournisseur.is_none() { + nom_fournisseur = actuelle.nom_fournisseur.clone(); + } + let modifie = actuelle.code_gamme.as_deref() != Some(gamme.as_str()); + if modifie { + propositions.push(PropositionGamme { + codein: codein.clone(), + gamme: gamme.clone(), + gamme_precedente: actuelle.code_gamme.clone(), + }); + } else { + annules.push(codein.clone()); + } + resultats.push(ResultatGamme { + codein, + gamme, + gamme_precedente: actuelle.code_gamme.clone(), + code_gamme_serveur: actuelle.code_gamme_init.clone(), + modifie, + statut: if modifie { "a_valider" } else { "inchangee" }, + }); + } + + if let Err(e) = proposer_gammes(code_fournisseur, &propositions, &annules, auteur).await { + tracing::error!("[api/v1/gammes] proposition KO pour {code_fournisseur}: {}", e.message); + return Err(fail(ApiErrorCode::InternalError, "L'enregistrement des gammes à valider a échoué.", None)); + } + + tracing::info!("[api/v1/gammes] {code_fournisseur} — {} gamme(s) à valider proposée(s) par {auteur}", propositions.len()); + Ok(AffectationGammes { code_fournisseur: code_fournisseur.to_string(), nom_fournisseur, resultats }) +} diff --git a/server/src/grid/api_v1_grid.rs b/server/src/grid/api_v1_grid.rs new file mode 100644 index 0000000..e5c4590 --- /dev/null +++ b/server/src/grid/api_v1_grid.rs @@ -0,0 +1,551 @@ +//! Routes `/api/v1` adossées au calcul de la grille (phase 2) : `/grid`, +//! `/products/search`, `/products/{codein}`, `/products/{codein}/gamme`, +//! `/gammes`, `/fournisseurs`, `/network/{codeCentrale}`. + +use super::api_gammes::{affecter_gammes, lire_corps_json}; +use super::api_schemas::{ + parse_fournisseurs_query, parse_gammes_batch_body, parse_grid_query, parse_product_detail, parse_product_gamme_body, + parse_product_search, pick_fields, query_map, to_sort_key, GridQueryParams, +}; +use super::enrich::enrich_rows; +use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput}; +use super::store::{ + get_grid_freshness, get_grid_row_by_codein, list_grid_suppliers, list_grid_suppliers_for_codein, query_grid_rows, GridFreshness, + GridQuery, SortOrder, +}; +use super::types::ProductRow; +use crate::api_v1_response::{build_pagination, fail, ok, ApiErrorCode}; +use crate::auth::ApiAuth; +use crate::error::AppError; +use axum::body::{Body, Bytes}; +use axum::extract::{Path, Query}; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use futures::channel::mpsc; +use futures::SinkExt; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; + +fn interne(e: AppError) -> Response { + fail(ApiErrorCode::InternalError, e.message, None) +} + +fn meta(entrees: Vec<(&str, Value)>) -> Option> { + Some(entrees.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) +} + +/// Lignes prêtes à sérialiser : enrichies (`enrich=1`) puis projetées (`fields`). +async fn lignes_json(rows: Vec, enrichir: bool, fields: Option<&str>) -> Vec { + let valeurs: Vec = if enrichir { + enrich_rows(rows).await.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect() + } else { + rows.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect() + }; + valeurs.into_iter().map(|v| pick_fields(v, fields)).collect() +} + +fn ordre(order: &str) -> Option { + Some(if order == "asc" { SortOrder::Asc } else { SortOrder::Desc }) +} + +/// `GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=` +/// +/// Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis +/// l'instantané persisté (`grid_rows`). +/// +/// Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande** +/// (`compute=1`, défaut) ; `compute=0` restaure le comportement strict : échec +/// immédiat en `202 not_ready`. +pub async fn get_grid(ApiAuth(auth): ApiAuth, Query(params): Query>) -> Response { + let q = match parse_grid_query(&query_map(params)) { + Ok(q) => q, + Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())), + }; + + // Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide). + let mut freshness = match get_grid_freshness(&q.fournisseur).await { + Ok(f) => f, + Err(e) => return interne(e), + }; + let mut computed_on_demand = false; + + if freshness.is_none() { + if q.compute == "0" { + return fail( + ApiErrorCode::NotReady, + format!( + "Aucun instantané pour le fournisseur « {} ». Relancez sans « compute=0 » pour le calculer à la demande.", + q.fournisseur + ), + Some(json!({ "fournisseur": q.fournisseur })), + ); + } + + tracing::info!("[api/v1/grid] instantané absent pour {} — calcul à la demande (via {})", q.fournisseur, auth.via); + // magasin TOTAL : c'est la seule variante persistée par getProductRows, + // et ProductRow embarque déjà les ventilations par magasin. + match get_product_rows(GetProductRowsInput::total(&q.fournisseur, false)).await { + // La persistance est lancée en arrière-plan par le calcul : on attend + // qu'elle soit écrite avant de relire l'instantané. + Ok(_) => attendre_persistance(&q.fournisseur).await, + Err(e) => { + tracing::error!("[api/v1/grid] calcul à la demande KO pour {}: {e:#}", q.fournisseur); + return fail( + ApiErrorCode::InternalError, + format!("Le calcul de la grille a échoué pour le fournisseur « {} ».", q.fournisseur), + Some(json!({ "fournisseur": q.fournisseur })), + ); + } + } + + freshness = match get_grid_freshness(&q.fournisseur).await { + Ok(f) => f, + Err(e) => return interne(e), + }; + if freshness.is_none() { + // Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou + // il n'a aucun article. À distinguer d'une panne. + return fail( + ApiErrorCode::NotFound, + format!("Aucun article pour le fournisseur « {} ». Vérifiez le code auprès de /api/v1/fournisseurs.", q.fournisseur), + Some(json!({ "fournisseur": q.fournisseur })), + ); + } + computed_on_demand = true; + } + let freshness = freshness.unwrap_or(GridFreshness { row_count: 0, computed_at: None }); + + let filtres = GridQuery { + code_fournisseur: Some(q.fournisseur.clone()), + gamme: q.gamme.clone(), + code1: q.code1.clone(), + code2: q.code2.clone(), + code3: q.code3.clone(), + nomenclature: q.nomenclature.clone(), + search: q.search.clone(), + sort: to_sort_key(q.sort.sort.as_deref()), + order: ordre(&q.sort.order), + page: None, + limit: None, + }; + + // Sans `limit`, la réponse peut porter tout un fournisseur — et certains en + // comptent plus de 130 000. On diffuse par lots, à mémoire constante. + let Some(limit) = q.limit else { + return stream_all_rows(filtres, q, freshness, computed_on_demand); + }; + + let result = match query_grid_rows(&GridQuery { page: Some(q.page), limit: Some(limit), ..filtres }).await { + Ok(r) => r, + Err(e) => return interne(e), + }; + let enrichi = q.sort.enrich == "1"; + let nb = result.rows.len(); + let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await; + let pagination = build_pagination(q.page, limit, result.total); + let has_more = pagination.has_more; + + let mut m = meta(vec![ + ("fournisseur", json!(q.fournisseur)), + ("computedAt", json!(result.computed_at)), + ("snapshotComputedAt", json!(freshness.computed_at)), + ("snapshotRowCount", json!(freshness.row_count)), + ("enrichi", json!(enrichi)), + // true = l'instantané n'existait pas et vient d'être calculé par cet appel. + ("computedOnDemand", json!(computed_on_demand)), + // true = cette réponse contient **toutes** les lignes du fournisseur. + ("complet", json!(!has_more)), + ]) + .unwrap_or_default(); + if has_more { + m.insert( + "avertissement".into(), + json!(format!( + "Réponse partielle : {nb} lignes sur {}, parce que « limit » a été précisé. Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page={}.", + result.total, + q.page + 1 + )), + ); + } + ok(rows, Some(pagination), Some(m)) +} + +/// Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. +const STREAM_BATCH: i64 = 1000; + +/// Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans +/// jamais la construire entièrement en mémoire. +/// +/// La forme reste celle des autres réponses (`{ data, pagination, meta }`) ; +/// `pagination` et `meta` sont écrits en dernier, une fois le total connu. Une +/// erreur survenant après les premiers octets ne peut plus changer le code HTTP : +/// on ferme alors le document avec `meta.erreur`. +fn stream_all_rows(filtres: GridQuery, q: GridQueryParams, freshness: GridFreshness, computed_on_demand: bool) -> Response { + let (mut tx, rx) = mpsc::channel::>(4); + tokio::spawn(async move { + let mut envoyees: i64 = 0; + let mut total: i64 = 0; + let mut computed_at: Option = None; + let mut erreur: Option = None; + let enrichi = q.sort.enrich == "1"; + + if tx.send(Ok(Bytes::from_static(b"{\"data\":["))).await.is_err() { + return; + } + let mut page = 1; + loop { + let lot = match query_grid_rows(&GridQuery { page: Some(page), limit: Some(STREAM_BATCH), ..filtres.clone() }).await { + Ok(l) => l, + Err(e) => { + erreur = Some(e.message); + break; + } + }; + if page == 1 { + total = lot.total; + computed_at = lot.computed_at.clone(); + } + let n = lot.rows.len() as i64; + if n == 0 { + break; + } + let mut morceau = Vec::new(); + for r in lignes_json(lot.rows, enrichi, q.sort.fields.as_deref()).await { + if envoyees > 0 { + morceau.push(b','); + } + morceau.extend(serde_json::to_vec(&r).unwrap_or_default()); + envoyees += 1; + } + if tx.send(Ok(Bytes::from(morceau))).await.is_err() { + return; + } + if n < STREAM_BATCH { + break; + } + page += 1; + } + if let Some(e) = &erreur { + tracing::error!("[api/v1/grid] diffusion interrompue pour {} après {envoyees} lignes: {e}", q.fournisseur); + } + + let complet = erreur.is_none() && envoyees == total; + let mut meta = json!({ + "fournisseur": q.fournisseur, + "computedAt": computed_at, + "snapshotComputedAt": freshness.computed_at, + "snapshotRowCount": freshness.row_count, + "enrichi": enrichi, + "computedOnDemand": computed_on_demand, + "diffuse": true, + "complet": complet, + }); + if let Some(e) = erreur { + meta["erreur"] = json!(format!("Diffusion interrompue après {envoyees} lignes sur {total} : {e}")); + } + let fin = json!({ + "pagination": { "page": 1, "limit": envoyees, "total": total, "totalPages": 1, "hasMore": !complet }, + "meta": meta, + }); + let texte = serde_json::to_string(&fin).unwrap_or_default(); + let _ = tx.send(Ok(Bytes::from(format!("],{}", &texte[1..])))).await; + }); + + ( + [ + (header::CONTENT_TYPE, "application/json; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + // Empêche un proxy de tamponner la réponse entière avant de la relayer. + (header::HeaderName::from_static("x-accel-buffering"), "no"), + ], + Body::from_stream(rx), + ) + .into_response() +} + +/// `GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=` +/// +/// **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale — +/// tous fournisseurs confondus. Lit uniquement l'instantané `grid_rows`. +pub async fn get_products_search(ApiAuth(_auth): ApiAuth, Query(params): Query>) -> Response { + let q = match parse_product_search(&query_map(params)) { + Ok(q) => q, + Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())), + }; + let result = match query_grid_rows(&GridQuery { + code_fournisseur: q.fournisseur.clone(), + gamme: q.gamme.clone(), + search: Some(q.q.clone()), + sort: to_sort_key(q.sort.sort.as_deref()), + order: ordre(&q.sort.order), + page: Some(q.page), + limit: Some(q.limit), + ..Default::default() + }) + .await + { + Ok(r) => r, + Err(e) => return interne(e), + }; + let enrichi = q.sort.enrich == "1"; + let total = result.total; + let computed_at = result.computed_at.clone(); + let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await; + ok( + rows, + Some(build_pagination(q.page, q.limit, total)), + meta(vec![ + ("query", json!(q.q)), + ("computedAt", json!(computed_at)), + ("scope", json!(q.fournisseur.as_ref().map(|f| format!("fournisseur:{f}")).unwrap_or_else(|| "tous fournisseurs".into()))), + ("enrichi", json!(enrichi)), + ]), + ) +} + +/// `GET /api/v1/products/{codein}?fournisseur=…` +/// +/// Fiche complète d'un produit, enrichie des métriques réseau Qlik lues **en cache**. +/// `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ; +/// sans lui, l'instantané le plus récent est renvoyé. +pub async fn get_product(ApiAuth(_auth): ApiAuth, Path(codein): Path, Query(params): Query>) -> Response { + if codein.is_empty() { + return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None); + } + let parsed = match parse_product_detail(&query_map(params)) { + Ok(p) => p, + Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())), + }; + + let mut found = match get_grid_row_by_codein(&codein, parsed.fournisseur.as_deref()).await { + Ok(f) => f, + Err(e) => return interne(e), + }; + + // Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a + // précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article. + if found.is_none() && parsed.compute == "1" { + if let Some(f) = parsed.fournisseur.as_deref() { + tracing::info!("[api/v1/products] {codein} absent — calcul du lot {f} à la demande"); + let calcul = async { + get_product_rows(GetProductRowsInput::total(f, false)).await?; + attendre_persistance(f).await; + get_grid_row_by_codein(&codein, Some(f)).await.map_err(|e| anyhow::anyhow!(e.message)) + }; + match calcul.await { + Ok(r) => found = r, + Err(e) => tracing::error!("[api/v1/products] calcul KO pour {f}: {e:#}"), + } + } + } + + let Some(found) = found else { + return fail( + ApiErrorCode::NotFound, + match &parsed.fournisseur { + Some(f) => format!("Produit « {codein} » introuvable chez le fournisseur « {f} »."), + None => format!( + "Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande." + ), + }, + None, + ); + }; + + // Métriques réseau + gamme serveur relues maintenant : le payload date du dernier + // calcul de la grille, alors que ces deux données évoluent indépendamment. + let Some(enriched) = enrich_rows(vec![found.row]).await.into_iter().next() else { + return fail(ApiErrorCode::InternalError, "Enrichissement impossible.", None); + }; + let network_fetched_at = enriched.network.as_ref().and_then(|n| n.fetched_at.clone()); + let has_network = enriched.network.is_some(); + ok( + enriched, + None, + meta(vec![ + ("computedAt", json!(found.computed_at)), + ("networkFetchedAt", json!(network_fetched_at)), + ("hasNetwork", json!(has_network)), + ]), + ) +} + +fn maintenant_iso() -> String { + crate::db::iso_utc(chrono::Utc::now()) +} + +/// `PUT /api/v1/products/{codein}/gamme` `{ "gamme": "A", "fournisseur"?: "…" }` +/// +/// Propose une gamme pour un article. La proposition reste **à valider** dans la +/// Grille. `fournisseur` n'est requis que si l'article est référencé chez plusieurs +/// fournisseurs, ou s'il n'apparaît pas encore dans l'instantané. +pub async fn put_product_gamme(ApiAuth(auth): ApiAuth, Path(codein): Path, body: Bytes) -> Response { + if codein.is_empty() { + return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None); + } + let corps = match lire_corps_json(&body) { + Ok(c) => c, + Err(r) => return r, + }; + let parsed = match parse_product_gamme_body(&corps) { + Ok(p) => p, + Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())), + }; + + let fournisseur = match parsed.fournisseur { + Some(f) => f, + None => { + let fournisseurs = match list_grid_suppliers_for_codein(&codein).await { + Ok(f) => f, + Err(e) => return interne(e), + }; + if fournisseurs.is_empty() { + return fail( + ApiErrorCode::NotFound, + format!( + "Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande." + ), + None, + ); + } + if fournisseurs.len() > 1 { + return fail( + ApiErrorCode::BadRequest, + format!("Le produit « {codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur »."), + Some(json!({ "fournisseurs": fournisseurs })), + ); + } + fournisseurs[0].code_fournisseur.clone() + } + }; + + let res = match affecter_gammes(&fournisseur, &[(codein, parsed.gamme)], parsed.compute, &auth.subject).await { + Ok(r) => r, + Err(r) => return r, + }; + let mut data = match res.resultats.first().map(serde_json::to_value) { + Some(Ok(Value::Object(m))) => m, + _ => Map::new(), + }; + data.insert("codeFournisseur".into(), json!(res.code_fournisseur)); + data.insert("nomFournisseur".into(), json!(res.nom_fournisseur)); + ok(Value::Object(data), None, meta(vec![("enregistreLe", json!(maintenant_iso()))])) +} + +/// `POST /api/v1/gammes` `{ "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }` +/// +/// Propose la gamme de plusieurs articles d'un fournisseur en un appel, à valider +/// dans la Grille. Tout ou rien : un article inconnu chez le fournisseur fait +/// échouer l'appel (404). +pub async fn post_gammes(ApiAuth(auth): ApiAuth, body: Bytes) -> Response { + let corps = match lire_corps_json(&body) { + Ok(c) => c, + Err(r) => return r, + }; + let parsed = match parse_gammes_batch_body(&corps) { + Ok(p) => p, + Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())), + }; + let changes: Vec<(String, String)> = parsed.changes.into_iter().map(|c| (c.codein, c.gamme)).collect(); + let res = match affecter_gammes(&parsed.fournisseur, &changes, parsed.compute, &auth.subject).await { + Ok(r) => r, + Err(r) => return r, + }; + let demandes = res.resultats.len(); + let modifies = res.resultats.iter().filter(|r| r.modifie).count(); + let a_valider = res.resultats.iter().filter(|r| r.statut == "a_valider").count(); + ok( + &res.resultats, + None, + meta(vec![ + ("codeFournisseur", json!(res.code_fournisseur)), + ("nomFournisseur", json!(res.nom_fournisseur)), + ("demandes", json!(demandes)), + ("modifies", json!(modifies)), + ("aValider", json!(a_valider)), + ("enregistreLe", json!(maintenant_iso())), + ]), + ) +} + +/// `GET /api/v1/fournisseurs?search=…&withData=1` +/// +/// Liste des fournisseurs : tout le référentiel (`fouident`), enrichi de la +/// fraîcheur de l'instantané de grille quand il existe. `withData=1` restreint aux +/// seuls fournisseurs déjà présents dans l'instantané. +pub async fn get_fournisseurs(ApiAuth(_auth): ApiAuth, Query(params): Query>) -> Response { + let parsed = match parse_fournisseurs_query(&query_map(params)) { + Ok(p) => p, + Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())), + }; + let snapshot = match list_grid_suppliers().await { + Ok(s) => s, + Err(e) => return interne(e), + }; + + if parsed.with_data.as_deref() == Some("1") { + let term = parsed.search.as_ref().map(|s| s.to_lowercase()); + let data: Vec = snapshot + .iter() + .filter(|s| match &term { + None => true, + Some(t) => { + s.code_fournisseur.to_lowercase().contains(t.as_str()) + || s.nom_fournisseur.as_deref().unwrap_or("").to_lowercase().contains(t.as_str()) + } + }) + .map(|s| { + json!({ "code": s.code_fournisseur, "nom": s.nom_fournisseur, "hasSnapshot": true, "rowCount": s.row_count, "computedAt": s.computed_at }) + }) + .collect(); + let total = data.len(); + return ok(data, None, meta(vec![("total", json!(total)), ("scope", json!("instantané uniquement"))])); + } + + let by_code: HashMap<&str, &super::store::GridSupplier> = snapshot.iter().map(|s| (s.code_fournisseur.as_str(), s)).collect(); + let fournisseurs = crate::ff::pg::pg_get_fournisseurs(parsed.search.as_deref()).await; + let data: Vec = fournisseurs + .iter() + .map(|f| { + let snap = by_code.get(f.code.as_str()); + json!({ + "code": f.code, + "nom": f.nom, + "hasSnapshot": snap.is_some(), + "rowCount": snap.map(|s| s.row_count).unwrap_or(0), + "computedAt": snap.and_then(|s| s.computed_at.clone()), + }) + }) + .collect(); + let total = data.len(); + ok(data, None, meta(vec![("total", json!(total)), ("withSnapshot", json!(by_code.len()))])) +} + +/// `GET /api/v1/network/{codeCentrale}` +/// +/// Métriques réseau Qlik d'un produit et sa courbe de quantités mensuelles. +/// Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik. +pub async fn get_network(ApiAuth(_auth): ApiAuth, Path(code_centrale): Path) -> Response { + if code_centrale.is_empty() { + return fail(ApiErrorCode::BadRequest, "Paramètre 'codeCentrale' requis.", None); + } + let metrics = match crate::qlik::network_cache::get_network_metrics_by_code_centrale(std::slice::from_ref(&code_centrale)).await { + Ok(m) => m, + Err(e) => return fail(ApiErrorCode::InternalError, format!("{e:#}"), None), + }; + let Some(found) = metrics.get(&code_centrale) else { + return fail( + ApiErrorCode::NotFound, + format!( + "Aucune donnée réseau en cache pour le code centrale « {code_centrale} ». Lancez une synchronisation Qlik ou une recherche réseau." + ), + None, + ); + }; + let mut data = Map::new(); + data.insert("codeCentrale".into(), json!(code_centrale)); + if let Ok(Value::Object(m)) = serde_json::to_value(found) { + data.extend(m); + } + ok(Value::Object(data), None, meta(vec![("fetchedAt", json!(found.fetched_at)), ("source", json!("cache"))])) +} diff --git a/server/src/grid/enrich.rs b/server/src/grid/enrich.rs new file mode 100644 index 0000000..335e4c7 --- /dev/null +++ b/server/src/grid/enrich.rs @@ -0,0 +1,148 @@ +//! CollectFlow — Enrichissement des lignes servies par `/api/v1` +//! (port de `src/lib/api-enrich.ts`). +//! +//! L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations +//! doivent pourtant refléter l'état **courant** au moment de l'appel : +//! +//! 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par +//! les synchronisations et les recherches réseau, indépendamment du calcul de la +//! grille. On les relit donc à la lecture, et on les expose telles quelles quand +//! elles existent (`network: null` sinon). +//! +//! 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session. +//! L'API expose la gamme **non modifiée**, celle réellement présente en base, +//! relue à chaque appel. +//! +//! Ce n'est pas un recalcul : des lectures indexées bornées au nombre de lignes. + +use super::gammes_a_valider::gammes_a_valider_par_article; +use super::months; +use super::network_trend::{compute_network_trend, trend_label, Direction}; +use super::types::ProductRow; +use crate::ff::pg::pg_get_gammes_by_codeins; +use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +/// Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductTrend { + pub direction: Direction, + /// Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. + #[serde(serialize_with = "super::js::opt_num::serialize")] + pub pct: Option, + /// Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… + pub label: &'static str, + /// `true` = tendance calculée sur la quantité **par magasin vendeur**. + pub sur_qte_par_magasin: bool, + /// Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. + pub nouveau: bool, + /// Nombre de mois de la série (12 quand elle est complète). + pub months_used: usize, +} + +/// Ligne enrichie : la ligne, puis la gamme serveur, la proposition en attente, +/// les métriques réseau et la tendance (ordre des clés du TS). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnrichedProductRow { + #[serde(flatten)] + pub row: ProductRow, + /// Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée. + pub code_gamme_serveur: Option, + /// Gamme proposée par l'API et pas encore validée dans la Grille. + pub gamme_a_valider: Option, + /// Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. + pub network: Option, + /// Tendance réseau sur les 12 derniers mois, `null` sans série mensuelle. + pub trend: Option, +} + +/// Enrichit un lot de lignes. Les lectures sont indépendantes et lancées en +/// parallèle ; un échec de l'une n'empêche pas les autres (l'API reste servie, avec +/// la valeur de l'instantané en repli). +pub async fn enrich_rows(rows: Vec) -> Vec { + if rows.is_empty() { + return vec![]; + } + let mut vus = BTreeSet::new(); + let codes_centraux: Vec = + rows.iter().filter_map(|r| r.code_centrale.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect(); + let mut vus = BTreeSet::new(); + let codeins: Vec = rows.iter().map(|r| r.codein.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect(); + + let (network, gammes, a_valider) = tokio::join!( + async { + if codes_centraux.is_empty() { + return HashMap::new(); + } + get_network_metrics_by_code_centrale(&codes_centraux).await.unwrap_or_else(|e| { + tracing::error!("[api-enrich] métriques réseau KO: {}", crate::error::short(&e, 160)); + HashMap::new() + }) + }, + async { + if codeins.is_empty() { + return HashMap::new(); + } + pg_get_gammes_by_codeins(&codeins).await.unwrap_or_else(|e| { + tracing::error!("[api-enrich] gammes serveur KO: {}", crate::error::short(&e, 160)); + HashMap::new() + }) + }, + async { + gammes_a_valider_par_article(&codeins).await.unwrap_or_else(|e| { + tracing::error!("[api-enrich] gammes à valider KO: {}", crate::error::short(&e.message, 160)); + HashMap::new() + }) + }, + ); + + let aujourd_hui = months::today(); + rows.into_iter() + .map(|mut row| { + let metrics = row.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| network.get(c)).cloned(); + // Gamme absente de la base = pas de gamme, et non « inconnue ». + let gamme_serveur = gammes.get(&row.codein).cloned(); + let gamme_a_valider = a_valider.get(&format!("{}|{}", row.code_fournisseur, row.codein)).cloned(); + + // `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille : + // on la garde alignée pour que les deux champs ne divergent jamais. + if !gammes.is_empty() { + row.code_gamme_init = gamme_serveur.clone(); + } + + // Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le + // cache quand il a quelque chose à dire. + if let Some(m) = &metrics { + row.ca_reseau = Some(m.ca_reseau); + row.qte_reseau = Some(m.qte_reseau); + row.nb_magasins_reseau = Some(m.nb_magasins_reseau); + row.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau); + row.marge_pct_reseau = Some(m.marge_pct_reseau); + row.qte_reseau_by_month = Some(m.qte_by_month.clone()); + row.network_fetched_at = m.fetched_at.clone(); + } + + // Tendance : mêmes entrées que la colonne « Tendance » de la Grille — la + // série des quantités **et** celle du nombre de magasins vendeurs. + let serie = row.qte_reseau_by_month.clone().flatten(); + let nb_mag_by_month: Option> = metrics + .as_ref() + .and_then(|m| m.metrics_by_month.as_ref()) + .map(|d| d.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| !n.is_nan()).unwrap_or(0.0))).collect()); + let t = compute_network_trend(serie.as_ref(), nb_mag_by_month.as_ref(), aujourd_hui); + let trend = t.has_data.then(|| ProductTrend { + direction: t.direction, + pct: t.pct, + label: trend_label(t.pct, t.nouveau), + sur_qte_par_magasin: t.sur_qte_par_magasin, + nouveau: t.nouveau, + months_used: t.values.len(), + }); + + EnrichedProductRow { row, code_gamme_serveur: gamme_serveur, gamme_a_valider, network: metrics, trend } + }) + .collect() +} diff --git a/server/src/grid/grid_rows.rs b/server/src/grid/grid_rows.rs new file mode 100644 index 0000000..1042d1a --- /dev/null +++ b/server/src/grid/grid_rows.rs @@ -0,0 +1,135 @@ +//! Routes internes de la Grille (ports de `src/app/api/grid/rows/route.ts` et +//! `src/app/api/grid/rows/store-patch/route.ts`). + +use super::js::JsNum; +use super::product_rows::{get_product_rows, get_store_patch, GetProductRowsInput}; +use super::types::ProductRow; +use crate::auth::SessionUser; +use axum::body::{Body, Bytes}; +use axum::extract::Query; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures::channel::mpsc; +use futures::SinkExt; +use serde::Serialize; +use serde_json::json; + +/// `searchParams.get(nom)` : première occurrence. +pub fn premier<'a>(params: &'a [(String, String)], nom: &str) -> Option<&'a str> { + params.iter().find(|(k, _)| k == nom).map(|(_, v)| v.as_str()) +} + +#[derive(Serialize)] +struct Lot<'a> { + #[serde(rename = "type")] + type_: &'static str, + rows: &'a [ProductRow], + loaded: JsNum, + total: usize, +} + +/// Indice de `Array.prototype.slice` (troncature, bornage à la longueur). +fn indice_slice(x: f64, len: usize) -> usize { + if x.is_nan() { + 0 + } else { + x.trunc().clamp(0.0, len as f64) as usize + } +} + +fn ndjson(v: &impl Serialize) -> Bytes { + let mut out = serde_json::to_vec(v).unwrap_or_default(); + out.push(b'\n'); + Bytes::from(out) +} + +/// `GET /api/grid/rows?fournisseur=…&magasin=…&chunkSize=…&refresh=1` +/// +/// Flux NDJSON : `{ type: "start" }`, puis des lots `{ type: "chunk", rows, loaded, +/// total }`, puis `{ type: "done" }` — ou `{ type: "error", error }`. +pub async fn get_grid_rows(SessionUser(_utilisateur): SessionUser, Query(params): Query>) -> Response { + let Some(code_fournisseur) = premier(¶ms, "fournisseur").filter(|f| !f.is_empty()).map(str::to_string) else { + return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Missing fournisseur" }))).into_response(); + }; + let magasin = premier(¶ms, "magasin").unwrap_or("TOTAL").to_string(); + let chunk_size = { + let brut = premier(¶ms, "chunkSize").map(super::js::js_number_from_str).unwrap_or(150.0); + // Math.max(50, Math.min(500, x)) : NaN se propage. + if brut.is_nan() { + f64::NAN + } else { + brut.clamp(50.0, 500.0) + } + }; + // `code1`/`code2`/`code3` ne sont pas lus : la Grille filtre en local + // (getProductRows ne s'en sert pas). + let force_refresh = premier(¶ms, "refresh") == Some("1"); + + // Client parti (changement de fournisseur, page quittée) : on arrête de + // sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et + // mis en cache, la prochaine ouverture en profitera. + let (mut tx, rx) = mpsc::channel::>(4); + tokio::spawn(async move { + if tx.send(Ok(ndjson(&json!({ "type": "start", "loaded": 0, "total": null })))).await.is_err() { + return; + } + let input = GetProductRowsInput { code_fournisseur, magasin: Some(magasin), force_refresh }; + match get_product_rows(input).await { + Ok(shared) => { + let total = shared.len(); + let mut start = 0.0f64; + while start < total as f64 { + let ligne = { + let rows = shared.rows.read(); + let len = rows.len(); + let (a, b) = (indice_slice(start, len), indice_slice(start + chunk_size, len)); + let lot = &rows[a..b.max(a)]; + ndjson(&Lot { type_: "chunk", rows: lot, loaded: JsNum((start + lot.len() as f64).min(total as f64)), total }) + }; + if tx.send(Ok(ligne)).await.is_err() { + return; + } + start += chunk_size; + tokio::task::yield_now().await; + } + let _ = tx.send(Ok(ndjson(&json!({ "type": "done", "loaded": total, "total": total })))).await; + } + Err(e) => { + let _ = tx.send(Ok(ndjson(&json!({ "type": "error", "error": e.to_string() })))).await; + } + } + }); + + ( + [ + (header::CONTENT_TYPE, "application/x-ndjson; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + (header::HeaderName::from_static("x-accel-buffering"), "no"), + ], + Body::from_stream(rx), + ) + .into_response() +} + +/// `GET /api/grid/rows/store-patch?fournisseur=…&magasin=…` +/// +/// Complément de l'API FF pour un magasin (cf. `store_patch`). La Grille charge +/// toujours les lignes « tous magasins » et applique ce complément à son arrivée : +/// le changement de magasin n'attend plus rien. +pub async fn get_grid_store_patch(SessionUser(_utilisateur): SessionUser, Query(params): Query>) -> Response { + let code_fournisseur = premier(¶ms, "fournisseur").unwrap_or(""); + let magasin = premier(¶ms, "magasin").unwrap_or(""); + // Un code inconnu ferait de chaque article un candidat : autant d'appels + // inutiles à l'API FF. « Tous magasins » n'a pas de complément. + if code_fournisseur.is_empty() || !crate::domain::MAGASINS.iter().any(|m| m.code == magasin) { + return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Paramètres fournisseur et magasin requis." }))).into_response(); + } + match get_store_patch(code_fournisseur, magasin).await { + Ok(patch) => ([(header::CACHE_CONTROL, "no-store")], Json(&*patch)).into_response(), + Err(e) => { + tracing::error!("[store-patch] {code_fournisseur}/{magasin} KO: {}", crate::error::short(&e, 200)); + (StatusCode::BAD_GATEWAY, Json(json!({ "error": "Complément indisponible." }))).into_response() + } + } +} diff --git a/server/src/grid/mod.rs b/server/src/grid/mod.rs index 75a9cef..9966e94 100644 --- a/server/src/grid/mod.rs +++ b/server/src/grid/mod.rs @@ -1,27 +1,30 @@ //! Module `grid` — grille d'assortiment. //! -//! Phase 1 (ce qui ne dépend ni de la base FF ni de Qlik) : //! - [`types`] : `ProductRow` et types associés (JSON identique au TS) ; -//! - [`store`] : instantané persisté `grid_rows` (lu par `/api/v1`) ; -//! - [`rows_cache`] : cache mémoire des lignes calculées (10 min) ; +//! - [`product_rows`] : calcul des lignes (`getProductRows`), branché sur +//! [`rows_cache`] (cache mémoire 10 min) et [`store`] (instantané `grid_rows`) ; //! - [`network_trend`], [`months`], [`store_patch`] : logique pure ; //! - [`gammes_a_valider`], [`enregistrer`], [`snapshots`] : gammes et snapshots ; -//! - [`exports`] : exports Excel ; [`api_schemas`], [`api_v1`], [`openapi`] : API publique. -//! -//! Phase 2 : calcul de la grille (`getProductRows`), enrichissement `/api/v1` -//! (`api-enrich`), routes `/api/v1/grid`, `/products/*`, `/gammes`, -//! `/fournisseurs`, `/network/*` et `/api/grid/rows*`. +//! - [`grid_rows`] : `/api/grid/rows` (NDJSON) et `/api/grid/rows/store-patch` ; +//! - [`exports`] : exports Excel ; +//! - API publique : [`api_schemas`], [`enrich`], [`api_gammes`], [`api_v1`], +//! [`api_v1_grid`], [`openapi`]. +pub mod api_gammes; pub mod api_schemas; pub mod api_v1; +pub mod api_v1_grid; pub mod enregistrer; +pub mod enrich; pub mod exports; pub mod gammes; pub mod gammes_a_valider; +pub mod grid_rows; pub mod js; pub mod months; pub mod network_trend; pub mod openapi; +pub mod product_rows; pub mod rows_cache; pub mod session; pub mod snapshots; @@ -32,9 +35,11 @@ pub mod zod; #[cfg(test)] mod tests_db; +#[cfg(test)] +mod tests_ff; use crate::rpc::Handler; -use axum::routing::{get, post}; +use axum::routing::{get, post, put}; use axum::Router; /// Routes HTTP du module. @@ -44,6 +49,15 @@ pub fn router() -> Router { .route("/api/export/modified-gammes", post(exports::export_modified_gammes)) .route("/api/v1/nomenclatures", get(api_v1::get_nomenclatures)) .route("/api/v1/openapi.json", get(openapi::get_openapi)) + .route("/api/grid/rows", get(grid_rows::get_grid_rows)) + .route("/api/grid/rows/store-patch", get(grid_rows::get_grid_store_patch)) + .route("/api/v1/grid", get(api_v1_grid::get_grid)) + .route("/api/v1/products/search", get(api_v1_grid::get_products_search)) + .route("/api/v1/products/{codein}", get(api_v1_grid::get_product)) + .route("/api/v1/products/{codein}/gamme", put(api_v1_grid::put_product_gamme)) + .route("/api/v1/gammes", post(api_v1_grid::post_gammes)) + .route("/api/v1/fournisseurs", get(api_v1_grid::get_fournisseurs)) + .route("/api/v1/network/{codeCentrale}", get(api_v1_grid::get_network)) } /// Actions (`POST /api/actions/{nom}`) du module. diff --git a/server/src/grid/product_rows.rs b/server/src/grid/product_rows.rs new file mode 100644 index 0000000..bb76d8c --- /dev/null +++ b/server/src/grid/product_rows.rs @@ -0,0 +1,879 @@ +//! Calcul des lignes de la Grille (port de `src/features/grid/api/get-product-rows.ts`). +//! +//! Sept requêtes SQL en parallèle sur la base miroir FF, jointure avec le cache +//! réseau Qlik, report des gammes du dernier snapshot. Le résultat est gardé +//! 10 minutes en mémoire ([`super::rows_cache`]) et persisté dans `grid_rows` +//! (magasin « TOTAL » seulement), d'où il est resservi tant qu'il date du mois +//! en cours (`serveFromSnapshot`). + +use super::months; +use super::network_trend::NB_MAGASINS_RESEAU; +use super::rows_cache::{self, GridRows, RowsFuture, SharedRows, StorePatchFuture}; +use super::store::{read_grid_snapshot, upsert_grid_rows_shared}; +use super::store_patch::{apply_store_patch_in_place, build_store_patch_entries, store_patch_candidates}; +use super::types::{ProductRow, StorePatch, StorePatchEntry}; +use crate::ff::api::{build_last12_months_range, get_mensuel_by_articles, FfArticle}; +use crate::ff::pg::{self, PgStockRow}; +use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached}; +use futures::future::{BoxFuture, FutureExt}; +use serde_json::{Map, Value}; +use sqlx::Row; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::Arc; +use std::time::Duration; + +/// Paramètres de `getProductRows`. (`filters` n'est pas repris : le TS ne s'en +/// servait pas, la Grille filtre en local.) +#[derive(Debug, Clone, Default)] +pub struct GetProductRowsInput { + pub code_fournisseur: String, + /// `None` = « TOTAL ». + pub magasin: Option, + pub force_refresh: bool, +} + +impl GetProductRowsInput { + pub fn total(code_fournisseur: &str, force_refresh: bool) -> Self { + Self { code_fournisseur: code_fournisseur.to_string(), magasin: Some("TOTAL".into()), force_refresh } + } + fn magasin(&self) -> &str { + self.magasin.as_deref().unwrap_or("TOTAL") + } +} + +/// Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan. +const SNAPSHOT_REFRESH_AFTER: Duration = Duration::from_secs(20 * 60 * 60); + +fn court(e: &dyn std::fmt::Display) -> String { + crate::error::short(e, 200) +} + +/// `Number(x) || 0` sur une valeur déjà numérique. +fn or0(v: f64) -> f64 { + if v.is_nan() { + 0.0 + } else { + v + } +} + +/// Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante) +/// sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas +/// touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT) +/// est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le +/// marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif. +async fn refresh_gamme_init(rows: &GridRows, code_fournisseur: &str) { + match pg::pg_get_gammes_by_fournisseur(code_fournisseur).await { + Ok(gamme_map) => appliquer_gammes_init(rows, &gamme_map), + Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)), + } +} + +fn appliquer_gammes_init(rows: &GridRows, gamme_map: &HashMap) { + if gamme_map.is_empty() { + return; + } + for row in rows.rows.write().iter_mut() { + if let Some(live) = gamme_map.get(&row.codein) { + row.code_gamme_init = Some(live.clone()); + } + } +} + +/// Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`). +/// +/// Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations +/// par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes. +/// Toute erreur est avalée : la persistance ne doit jamais casser l'affichage. +async fn persist_grid_snapshot(input: &GetProductRowsInput, rows: &GridRows) { + if input.magasin() != "TOTAL" { + return; + } + match upsert_grid_rows_shared(&input.code_fournisseur, rows).await { + Ok(n) => tracing::info!("[getProductRows] instantané persisté: {n} lignes pour {}", input.code_fournisseur), + Err(e) => tracing::error!("[getProductRows] persistance grid_rows KO: {}", court(&e.message)), + } +} + +/// Lignes de la Grille d'un fournisseur (cache 10 min, instantané, sinon calcul). +pub fn get_product_rows(input: GetProductRowsInput) -> BoxFuture<'static, anyhow::Result> { + async move { + let cache_key = rows_cache::grid_rows_cache_key(&input.code_fournisseur, input.magasin.as_deref()); + if !input.force_refresh { + if let Some(cached) = rows_cache::get_fresh(&cache_key) { + // La colonne INIT doit refléter l'état serveur à CHAQUE chargement, + // y compris sur un hit de cache (les données lourdes restent, elles, cachées). + refresh_gamme_init(&cached, &input.code_fournisseur).await; + return Ok(cached); + } + } + + // Un calcul déjà en cours est réutilisé même en rechargement forcé : il part + // de données fraîches, et en lancer un second en parallèle doublait la charge + // (double clic, onglet rouvert…) sans rien apporter. + if let Some(pending) = rows_cache::pending_get(&cache_key) { + return pending.await.map_err(|e| anyhow::anyhow!(e)); + } + + // Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi + // en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement + // forcé — c'est précisément ce que l'utilisateur demande d'éviter. + if !input.force_refresh { + if let Some(servi) = serve_from_snapshot(&input, &cache_key).await { + return Ok(servi); + } + } + + let version_au_depart = rows_cache::gamme_save_version(&input.code_fournisseur); + // Le calcul est lancé à part, comme une promesse JS : il continue même si + // l'appelant s'en va (client déconnecté), et il est partagé entre appelants. + let (tx, rx) = tokio::sync::oneshot::channel::>(); + let partage: RowsFuture = + async move { rx.await.unwrap_or_else(|_| Err("calcul de la grille interrompu".to_string())) }.boxed().shared(); + rows_cache::pending_insert(&cache_key, partage.clone()); + let entree = input.clone(); + let cle = cache_key.clone(); + tokio::spawn(async move { + let resultat = async { + let mut rows = build_product_rows(&entree).await?; + if rows_cache::gamme_save_version(&entree.code_fournisseur) != version_au_depart { + // Gammes enregistrées pendant le calcul : on reprend le dernier snapshot. + apply_snapshot_changes(&mut rows, load_latest_snapshot_changes(&entree.code_fournisseur).await.as_ref()); + } + let shared = GridRows::new(rows); + rows_cache::cache_grid_rows(&cle, shared.clone()); + rows_cache::pending_remove(&cle); + // Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on + // arrête simplement de jeter le résultat. Volontairement NON bloquant — + // l'affichage de la Grille ne doit pas attendre l'écriture. + let a_persister = shared.clone(); + let code = entree.code_fournisseur.clone(); + let ecriture: Persistance = + tokio::spawn(async move { persist_grid_snapshot(&entree, &a_persister).await }).map(|_| ()).boxed().shared(); + PERSISTANCES.lock().insert(code.clone(), ecriture.clone()); + tokio::spawn(async move { + ecriture.clone().await; + let mut p = PERSISTANCES.lock(); + if p.get(&code).map(|e| e.ptr_eq(&ecriture)).unwrap_or(false) { + p.remove(&code); + } + }); + anyhow::Ok(shared) + } + .await; + let resultat = resultat.map_err(|e| { + rows_cache::pending_remove(&cle); + format!("{e:#}") + }); + let _ = tx.send(resultat); + }); + partage.await.map_err(|e| anyhow::anyhow!(e)) + } + .boxed() +} + +/// Écriture de l'instantané en cours, par fournisseur. +type Persistance = futures::future::Shared>; +static PERSISTANCES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| parking_lot::Mutex::new(HashMap::new())); + +/// Attend la fin de l'écriture de l'instantané lancée par le dernier calcul du +/// fournisseur (s'il y en a une en cours). +/// +/// Écart volontaire avec le TS : `/api/v1` relisait `grid_rows` juste après un +/// calcul à la demande alors que la persistance, non attendue, pouvait ne pas +/// être finie — d'où un faux « aucun article ». Les routes attendent ici l'écriture. +pub async fn attendre_persistance(code_fournisseur: &str) { + let en_cours = PERSISTANCES.lock().get(code_fournisseur).cloned(); + if let Some(e) = en_cours { + e.await; + } +} + +/// `getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true })` : +/// recalcul complet (synchro nocturne, préchauffage). Renvoie le nombre de lignes. +pub async fn get_product_rows_for_sync(code_fournisseur: &str) -> anyhow::Result { + let rows = get_product_rows(GetProductRowsInput::total(code_fournisseur, true)).await?; + Ok(rows.len()) +} + +/// Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le +/// calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et +/// les dernières gammes enregistrées reportées dessus. Hors « tous magasins », +/// le rattrapage du magasin choisi est appliqué comme après un calcul. +/// +/// `None` = pas d'instantané utilisable (absent, ancien format, autre mois) : +/// l'appelant recalcule. +async fn serve_from_snapshot(input: &GetProductRowsInput, cache_key: &str) -> Option { + let code = input.code_fournisseur.as_str(); + let magasin = input.magasin(); + let snapshot = match read_grid_snapshot(code).await { + Ok(s) => s, + Err(e) => { + tracing::error!("[getProductRows] lecture grid_rows KO: {}", court(&e.message)); + return None; + } + }; + let snapshot = snapshot.filter(|s| !s.rows.is_empty())?; + let computed_at = snapshot.computed_at; + let mut rows = snapshot.rows; + + // Les colonnes réseau de l'instantané datent de son calcul : on les relit dans + // le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite + // depuis (au changement de mois notamment) restait invisible jusqu'au + // prochain recalcul, et la tendance réseau disparaissait. + let codes: Vec = rows.iter().map(|r| r.code_centrale.clone().unwrap_or_default()).collect(); + let (changes, network, gammes) = + tokio::join!(load_latest_snapshot_changes(code), fetch_network_metrics(&codes), pg::pg_get_gammes_by_fournisseur(code)); + match gammes { + Ok(g) if !g.is_empty() => { + for row in rows.iter_mut() { + if let Some(live) = g.get(&row.codein) { + row.code_gamme_init = Some(live.clone()); + } + } + } + Ok(_) => {} + Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)), + } + apply_snapshot_changes(&mut rows, changes.as_ref()); + enrich_with_network_metrics(&mut rows, network.as_ref()); + + if magasin != "TOTAL" { + let range = build_last12_months_range(); + reconcile_selected_store_from_mensuel_api(&mut rows, magasin, &range.date_debut, &range.date_fin, &last12_periods()).await; + } + + let shared = GridRows::new(rows); + rows_cache::cache_grid_rows(cache_key, shared.clone()); + + // Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans + // faire attendre l'utilisateur (la prochaine ouverture en profitera). + // Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané. + let age = chrono::Utc::now().naive_utc() - computed_at; + if age.to_std().map(|d| d > SNAPSHOT_REFRESH_AFTER).unwrap_or(false) + && !rows_cache::pending_contains(&rows_cache::grid_rows_cache_key(code, Some("TOTAL"))) + { + let code = code.to_string(); + tokio::spawn(async move { + if let Err(e) = get_product_rows(GetProductRowsInput::total(&code, true)).await { + tracing::error!("[getProductRows] rafraîchissement en arrière-plan KO: {}", court(&e)); + } + }); + } + Some(shared) +} + +/// Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul). +fn last12_periods() -> Vec { + months::get_last12_months(months::today()) +} + +#[derive(Default, Clone, Copy)] +struct PeriodData { + qty: f64, + ca: f64, + marge: f64, + stock: f64, + receptions: f64, +} + +/// `Math.max(...valeurs)` : `NaN` si l'une l'est. +fn js_max(valeurs: impl Iterator) -> f64 { + valeurs.fold(f64::NEG_INFINITY, |m, v| if m.is_nan() || v.is_nan() { f64::NAN } else { m.max(v) }) +} + +/// Le calcul complet (`buildProductRows`). +async fn build_product_rows(input: &GetProductRowsInput) -> anyhow::Result> { + let code_fournisseur = input.code_fournisseur.as_str(); + let magasin = input.magasin(); + tracing::info!("\n>>> [getProductRows] supplier: {code_fournisseur}, magasin: {magasin}"); + + let range = build_last12_months_range(); + let (date_debut, date_fin) = (range.date_debut.as_str(), range.date_fin.as_str()); + + // ─── Phase 1 : 7 requêtes SQL en parallèle ──────────────────────────── + // Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des + // articles (codes centraux) ou le seul code fournisseur. + let articles_et_reseau = async { + let articles = pg::pg_get_articles_by_fournisseur(code_fournisseur).await.unwrap_or_else(|e| { + tracing::error!("[getProductRows] pgGetArticlesByFournisseur ERROR: {e:#}"); + vec![] + }); + let codes: Vec = articles + .iter() + .map(|a| a.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()).unwrap_or_default()) + .collect(); + let network = fetch_network_metrics(&codes).await; + (articles, network) + }; + let ou_vide = |nom: &'static str| move |e: &anyhow::Error| tracing::error!("[getProductRows] {nom} ERROR: {e:#}"); + let ((articles, network), mensuel_rows, gamme_map, nom_map, stock_map, commandes_map, prix_vente_map, snapshot_changes) = tokio::join!( + articles_et_reseau, + async { + pg::pg_get_mensuel_by_fournisseur(code_fournisseur, date_debut, date_fin) + .await + .inspect_err(ou_vide("pgGetMensuelByFournisseur")) + .unwrap_or_default() + }, + async { + pg::pg_get_gammes_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetGammesByFournisseur")).unwrap_or_default() + }, + async { + pg::pg_get_nomenclature_by_fournisseur(code_fournisseur) + .await + .inspect_err(ou_vide("pgGetNomenclatureByFournisseur")) + .unwrap_or_default() + }, + async { + pg::pg_get_stock_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetStockByFournisseur")).unwrap_or_default() + }, + async { + pg::pg_get_commandes_by_fournisseur(code_fournisseur) + .await + .inspect_err(ou_vide("pgGetCommandesByFournisseur")) + .unwrap_or_default() + }, + async { + pg::pg_get_prix_vente_by_fournisseur(code_fournisseur) + .await + .inspect_err(ou_vide("pgGetPrixVenteByFournisseur")) + .unwrap_or_default() + }, + load_latest_snapshot_changes(code_fournisseur), + ); + + tracing::info!("[getProductRows] {} articles, {} mensuel rows, {} gammes", articles.len(), mensuel_rows.len(), gamme_map.len()); + + // ─── Phase 2 : Fenêtre temporelle 12 mois complets ─────────────────── + let sorted_periods = last12_periods(); + + // ─── Phase 3 : Seed productMap depuis articles ──────────────────────── + // Ordre d'insertion conservé (Map JS) : index par codein. + let mut products: Vec = Vec::with_capacity(articles.len()); + let mut index: HashMap = HashMap::with_capacity(articles.len()); + for art in &articles { + if art.codein.is_empty() || index.contains_key(&art.codein) { + continue; + } + index.insert(art.codein.clone(), products.len()); + let non_nul = |v: Option| v.filter(|x| *x != 0.0 && !x.is_nan()); + products.push(ProductRow { + codein: art.codein.clone(), + code_fournisseur: art.codefou.clone(), + nom_fournisseur: art.nomfou.clone().unwrap_or_default(), + fournisseur_principal_code: art.codefou_principal.clone(), + fournisseur_principal_nom: art.nomfou_principal.clone(), + libelle1: art.libelle1.clone().unwrap_or_default(), + gtin: art.gtin.clone().unwrap_or_default(), + reference: art.reference.clone().unwrap_or_default(), + code_centrale: art.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()), + code_gamme: None, + code_gamme_init: None, + code_gamme_draft: None, + total_quantite: 0.0, + total_ca: 0.0, + total_marge: 0.0, + taux_marge: 0.0, + working_stores: vec![], + noid: (art.no_id != 0).then_some(art.no_id as f64), + pcb: non_nul(art.pcb), + prix_vente: non_nul(art.pv_central), + ..Default::default() + }); + } + + // ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ───────────── + // SQL a déjà fait l'agrégation par (codein, site, mois). + // On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side. + let allowed: BTreeSet<&str> = sorted_periods.iter().map(String::as_str).collect(); + let mut mensuel_by_codein: Vec<(String, HashMap)> = Vec::new(); + let mut mensuel_index: HashMap = HashMap::new(); + let mut mensuel_by_site: HashMap>> = HashMap::new(); + let mut store_months: HashMap> = HashMap::new(); + + for row in &mensuel_rows { + let periode = row.mois.replacen('-', "", 1); // "2026-02" → "202602" + if !allowed.contains(periode.as_str()) { + continue; + } + let stock = or0(row.stock_fin_mois.unwrap_or(f64::NAN)); + // Accumulation TOTAL (toujours tous sites) + let i = *mensuel_index.entry(row.codein.clone()).or_insert_with(|| { + mensuel_by_codein.push((row.codein.clone(), HashMap::new())); + mensuel_by_codein.len() - 1 + }); + let p = mensuel_by_codein[i].1.entry(periode.clone()).or_default(); + p.stock += stock; + p.qty += or0(row.qte_vendue); + p.ca += or0(row.ca_ht); + p.marge += or0(row.marge); + p.receptions += or0(row.qte_recue); + + // Accumulation par site (pour switch client-side) + let sp = mensuel_by_site.entry(row.codein.clone()).or_default().entry(row.site.clone()).or_default().entry(periode).or_default(); + sp.qty += or0(row.qte_vendue); + sp.ca += or0(row.ca_ht); + sp.marge += or0(row.marge); + sp.stock = stock; // SQL donne déjà le dernier mouvement + sp.receptions += or0(row.qte_recue); + + // Suivi magasins actifs (pour workingStores) : 1 vente suffit + if row.qte_vendue > 0.0 { + store_months.entry(row.codein.clone()).or_default().insert(row.site.clone()); + } + } + + // ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ───────────── + for (codein, by_period) in &mensuel_by_codein { + let Some(&pi) = index.get(codein) else { continue }; + let product = &mut products[pi]; + + let mut last_stock = 0.0; + for periode in &sorted_periods { + match by_period.get(periode) { + Some(p) => { + product.sales12m.insert(periode.clone(), p.qty); + product.stock12m.insert(periode.clone(), p.stock); + product.receptions12m.insert(periode.clone(), p.receptions); + product.total_quantite += p.qty; + product.total_ca += p.ca; + product.total_marge += p.marge; + last_stock = p.stock; + } + None => { + product.sales12m.insert(periode.clone(), 0.0); + product.stock12m.insert(periode.clone(), last_stock); // carry-forward + product.receptions12m.insert(periode.clone(), 0.0); + } + } + } + + product.taux_marge = if product.total_ca > 0.0 { (product.total_marge / product.total_ca) * 100.0 } else { 0.0 }; + + // Breakdowns par site pour le switch client-side + if let Some(site_map) = mensuel_by_site.get(codein) { + let mut sales_by_store = BTreeMap::new(); + let mut stock_by_store = BTreeMap::new(); + let mut receptions_by_store = BTreeMap::new(); + let mut ca_by_store = BTreeMap::new(); + let mut quantite_by_store = BTreeMap::new(); + let mut marge_by_store = BTreeMap::new(); + for (site, site_by_period) in site_map { + let (mut sales, mut stock, mut receptions) = (BTreeMap::new(), BTreeMap::new(), BTreeMap::new()); + let (mut s_qty, mut s_ca, mut s_marge) = (0.0, 0.0, 0.0); + let mut last_site_stock = 0.0; + for periode in &sorted_periods { + let sp = site_by_period.get(periode); + sales.insert(periode.clone(), sp.map(|p| p.qty).unwrap_or(0.0)); + receptions.insert(periode.clone(), sp.map(|p| p.receptions).unwrap_or(0.0)); + match sp { + Some(p) => { + stock.insert(periode.clone(), p.stock); + last_site_stock = p.stock; + } + None => { + stock.insert(periode.clone(), last_site_stock); // carry-forward + } + } + s_qty += sp.map(|p| p.qty).unwrap_or(0.0); + s_ca += sp.map(|p| p.ca).unwrap_or(0.0); + s_marge += sp.map(|p| p.marge).unwrap_or(0.0); + } + sales_by_store.insert(site.clone(), sales); + stock_by_store.insert(site.clone(), stock); + receptions_by_store.insert(site.clone(), receptions); + quantite_by_store.insert(site.clone(), s_qty); + ca_by_store.insert(site.clone(), s_ca); + marge_by_store.insert(site.clone(), s_marge); + } + + // Le stock TOTAL se recalcule ici, à partir des séries par site. + // + // Il était sommé plus haut depuis les lignes mensuelles brutes, ce + // qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock + // est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu + // détient toujours sa marchandise. Les séries par site, elles, sont + // reportées d'un mois sur l'autre : leur somme est la bonne. + if !stock_by_store.is_empty() { + for periode in &sorted_periods { + let total: f64 = stock_by_store.values().map(|s: &BTreeMap| s.get(periode).copied().unwrap_or(0.0)).sum(); + product.stock12m.insert(periode.clone(), total); + } + } + product.sales12m_by_store = Some(sales_by_store); + product.stock12m_by_store = Some(stock_by_store); + product.receptions12m_by_store = Some(receptions_by_store); + product.ca_by_store = Some(ca_by_store); + product.quantite_by_store = Some(quantite_by_store); + product.marge_by_store = Some(marge_by_store); + } + + // workingStores : sites avec au moins 1 vente sur les 12 derniers mois + if let Some(sites) = store_months.get(codein) { + product.working_stores = sites.iter().cloned().collect(); + } + + // Commandes en cours + if let Some(&cmd) = commandes_map.get(codein) { + if cmd != 0.0 && !cmd.is_nan() { + product.commandes_en_cours = Some(cmd); + } + } + } + + // ─── Phase 6 : Gammes (saison active) ──────────────────────────────── + for (codein, gamme) in &gamme_map { + let Some(&pi) = index.get(codein) else { continue }; + products[pi].code_gamme_init = Some(gamme.clone()); + products[pi].code_gamme = Some(gamme.clone()); + } + + // ─── Phase 6b : Nomenclature (famille / sous-famille) ──────────────── + for (codein, nom) in &nom_map { + let Some(&pi) = index.get(codein) else { continue }; + let p = &mut products[pi]; + p.code3 = nom.code3.clone(); + p.libelle3 = nom.libelle3.clone(); + p.code2 = nom.code2.clone().unwrap_or_default(); + p.libelle_niveau2 = nom.libelle2.clone().unwrap_or_default(); + p.code1 = nom.code1.clone().unwrap_or_default(); + p.libelle_niveau1 = nom.libelle1nom.clone().unwrap_or_default(); + } + + // ─── Phase 7 : Stock temps réel (cube_stock) ───────────────────────── + for (codein, stocks) in &stock_map { + let Some(&pi) = index.get(codein) else { continue }; + let product = &mut products[pi]; + let somme = |f: fn(&PgStockRow) -> Option| stocks.iter().map(|r| or0(f(r).unwrap_or(f64::NAN))).sum::(); + product.stock_actuel = Some(somme(|r| r.stockdispo)); + product.stock_total = Some(somme(|r| r.qte)); + product.stock_valeur = Some(somme(|r| r.valstock)); + + if let Some(prmp) = stocks.iter().filter_map(|r| r.prmp).find(|p| *p > 0.0) { + product.pa = Some(prmp); + product.prix_achat = Some(prmp); + } + + let plus_recente = |dates: Vec<&String>| -> String { + dates.into_iter().fold(String::new(), |best, d| if !d.is_empty() && *d > best { d.clone() } else { best }) + }; + let dern_vente = plus_recente(stocks.iter().filter_map(|r| r.dernierevente.as_ref()).collect()); + if !dern_vente.is_empty() { + product.derniere_vente = Some(dern_vente); + } + let dern_liv = plus_recente(stocks.iter().filter_map(|r| r.dernierereception.as_ref()).collect()); + if !dern_liv.is_empty() { + product.derniere_livraison = Some(dern_liv); + } + + // Map store-specific delivery dates + let mut par_site = BTreeMap::new(); + for r in stocks { + if let Some(d) = r.dernierereception.as_ref().filter(|d| !d.is_empty() && !r.site.is_empty()) { + par_site.insert(r.site.clone(), d.clone()); + } + } + product.derniere_livraison_by_store = Some(par_site); + } + + // ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─ + // + // Le vrai prix vit dans `cube_pv`, par article ET par site. On garde le + // détail par magasin — deux magasins peuvent ne pas pratiquer le même prix, + // et l'afficher vaut mieux que le moyenner. + for product in products.iter_mut() { + match prix_vente_map.get(&product.codein).filter(|m| !m.is_empty()) { + Some(par_site) => product.prix_vente_by_store = Some(par_site.clone()), + None => { + // Filet de secours : le cube de stock porte le même PV, mais + // seulement pour les articles qui ont une ligne de stock. + let mut depuis_stock = BTreeMap::new(); + for s in stock_map.get(&product.codein).map(|v| v.as_slice()).unwrap_or_default() { + let pv = s.pv.unwrap_or(f64::NAN); + let site = s.site.trim(); + if !site.is_empty() && pv.is_finite() && pv > 0.0 { + depuis_stock.insert(site.to_string(), pv); + } + } + if !depuis_stock.is_empty() { + product.prix_vente_by_store = Some(depuis_stock); + } + } + } + // Le plus élevé quand les magasins divergent : une moyenne inventerait + // un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran. + if let Some(prix) = product.prix_vente_by_store.as_ref().filter(|m| !m.is_empty()) { + product.prix_vente = Some(js_max(prix.values().copied())); + } + } + + // ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ── + enrich_with_network_metrics(&mut products, network.as_ref()); + + // ─── Phase 9 : Restaurer gammes depuis dernier snapshot ────────────── + // La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle. + // La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur + // rechargé en Phase 6 — jamais écrasée ici. + apply_snapshot_changes(&mut products, snapshot_changes.as_ref()); + + // ─── Phase 10 : Filtrer gamme Y sans ventes ────────────────────────── + let tous = products.len(); + let mut rows: Vec = + products.into_iter().filter(|p| p.code_gamme.as_deref() != Some("Y") || p.total_quantite > 0.0).collect(); + reconcile_selected_store_from_mensuel_api(&mut rows, magasin, date_debut, date_fin, &sorted_periods).await; + let exclus_y = tous - rows.len(); + tracing::info!( + "[getProductRows] {} produits ({exclus_y} gamme Y sans ventes exclus), {} avec ventes", + rows.len(), + mensuel_by_codein.len() + ); + + Ok(rows) +} + +/// Articles complétés par l'API FF, au plus, lors d'un changement de magasin +/// (`GRID_STORE_RECONCILE_MAX`, 300 par défaut). +fn plafond_complement() -> f64 { + match std::env::var("GRID_STORE_RECONCILE_MAX") { + Ok(v) => super::js::js_number_from_str(&v), + Err(_) => 300.0, + } +} + +/// `candidates.slice(0, plafond)`. +fn js_slice_end(len: usize, fin: f64) -> usize { + if fin.is_nan() { + return 0; + } + let fin = fin.trunc(); + if fin < 0.0 { + (len as f64 + fin).max(0.0) as usize + } else { + fin.min(len as f64) as usize + } +} + +/// Article candidat au complément, copié hors verrou. +struct Candidat { + codein: String, + libelle1: String, + codefou: String, + noid: Option, +} + +fn candidats(rows: &[ProductRow], magasin: &str) -> Vec { + store_patch_candidates(rows, magasin) + .into_iter() + .map(|r| Candidat { codein: r.codein.clone(), libelle1: r.libelle1.clone(), codefou: r.code_fournisseur.clone(), noid: r.noid }) + .collect() +} + +/// Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). +async fn fetch_store_patch_entries( + candidates: Vec, + magasin: &str, + date_debut: &str, + date_fin: &str, + periods: &[String], +) -> Vec { + if candidates.is_empty() { + return vec![]; + } + + // Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur, + // les candidats se comptent par milliers et le changement de magasin se fige + // plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté + // plutôt que de tronquer en silence. + let nb = candidates.len(); + let retenus = &candidates[..js_slice_end(nb, plafond_complement())]; + if nb > retenus.len() { + tracing::warn!( + "[getProductRows] rattrapage magasin {magasin} borné à {} articles sur {nb} : les autres gardent la valeur SQL.", + retenus.len() + ); + } + + let articles: Vec = retenus + .iter() + .map(|c| FfArticle { + codein: c.codein.clone(), + libelle1: c.libelle1.clone(), + codefou: c.codefou.clone(), + noid: c.noid.filter(|n| n.is_finite()).map(|n| n as i64), + ..Default::default() + }) + .collect(); + let mensuel_map = get_mensuel_by_articles(&articles, date_debut, date_fin, 25).await; + let codes: Vec = retenus.iter().map(|c| c.codein.clone()).collect(); + build_store_patch_entries(&codes, magasin, periods, &mensuel_map) +} + +async fn reconcile_selected_store_from_mensuel_api( + rows: &mut [ProductRow], + magasin: &str, + date_debut: &str, + date_fin: &str, + sorted_periods: &[String], +) { + if magasin == "TOTAL" { + return; + } + let entries = fetch_store_patch_entries(candidats(rows, magasin), magasin, date_debut, date_fin, sorted_periods).await; + let fixed = apply_store_patch_in_place(rows, magasin, sorted_periods, &entries); + if fixed > 0 { + tracing::info!("[getProductRows] {fixed} produits corrigés via API mensuelle pour magasin {magasin}"); + } +} + +/// Complément de l'API FF pour un magasin, calculé sur les lignes « tous +/// magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans +/// recharger les lignes. Les demandes simultanées partagent le même calcul, et +/// un échec n'est jamais gardé. +pub async fn get_store_patch(code_fournisseur: &str, magasin: &str) -> anyhow::Result> { + let rows = match rows_cache::peek_fresh(&rows_cache::grid_rows_cache_key(code_fournisseur, Some("TOTAL"))) { + Some(r) => r, + None => get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await?, + }; + + let patch: StorePatchFuture = { + let mut table = rows.store_patches.lock(); + match table.get(magasin) { + Some(p) => p.clone(), + None => { + let periods = last12_periods(); + let range = build_last12_months_range(); + let candidates = candidats(&rows.rows.read(), magasin); + let magasin_s = magasin.to_string(); + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let entries = fetch_store_patch_entries(candidates, &magasin_s, &range.date_debut, &range.date_fin, &periods).await; + let _ = tx.send(Ok(Arc::new(StorePatch { magasin: magasin_s, periods, entries }))); + }); + let en_cours: StorePatchFuture = + async move { rx.await.unwrap_or_else(|_| Err("complément interrompu".to_string())) }.boxed().shared(); + table.insert(magasin.to_string(), en_cours.clone()); + en_cours + } + } + }; + match patch.clone().await { + Ok(p) => Ok(p), + Err(e) => { + let mut table = rows.store_patches.lock(); + if table.get(magasin).map(|p| p.ptr_eq(&patch)).unwrap_or(false) { + table.remove(magasin); + } + Err(anyhow::anyhow!(e)) + } + } +} + +/// Extrait la série « nombre de magasins vendeurs » du détail mensuel complet. +/// +/// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de `nbMag`, +/// c'est qu'aucun magasin n'a vendu, donc zéro. Renvoie `None` plutôt qu'un objet +/// vide pour que l'UI distingue « pas de donnée » de « zéro magasin ». +fn nb_mag_par_mois(m: &NetworkMetricCached) -> Option> { + let detail = m.metrics_by_month.as_ref()?; + let par_mois: BTreeMap = + detail.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| n.is_finite()).unwrap_or(0.0))).collect(); + (!par_mois.is_empty()).then_some(par_mois) +} + +/// Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ; +/// codeGammeInit reste figé (état serveur). +pub fn apply_snapshot_changes(rows: &mut [ProductRow], changes: Option<&Map>) { + let Some(changes) = changes else { return }; + for row in rows.iter_mut() { + let after = changes.get(&row.codein).and_then(|c| c.get("after")); + if super::js::js_truthy(after) { + row.code_gamme = Some(super::js::js_string(after)); + } + } +} + +/// Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `None`. +pub async fn load_latest_snapshot_changes(code_fournisseur: &str) -> Option> { + let res = async { + let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + let row = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1") + .bind(code_fournisseur) + .fetch_optional(&pool) + .await?; + anyhow::Ok(row.map(|r| r.try_get::("changes")).transpose()?) + } + .await; + match res { + Ok(Some(Value::Object(m))) => Some(m), + Ok(_) => None, + Err(e) => { + tracing::error!("[getProductRows] Snapshot restore error: {e:#}"); + None + } + } +} + +/// Lit les métriques réseau Qlik (cache qlik_network_metrics) des codes centraux +/// donnés. `None` en cas d'erreur : la grille s'affiche alors sans données réseau. +async fn fetch_network_metrics(codes_centrale: &[String]) -> Option> { + let mut vus = BTreeSet::new(); + let codes: Vec = codes_centrale.iter().filter(|c| !c.is_empty() && vus.insert(c.as_str())).cloned().collect(); + if codes.is_empty() { + return None; + } + match get_network_metrics_by_code_centrale(&codes).await { + Ok(m) => Some(m), + Err(e) => { + tracing::error!("[getProductRows] enrichWithNetworkMetrics error: {e:#}"); + None + } + } +} + +/// Enrichit les produits avec les métriques réseau Qlik, jointes par code centrale. +/// Dégradation propre si la sync Qlik n'a jamais tourné ou si le code centrale +/// n'est pas encore disponible. +pub fn enrich_with_network_metrics(products: &mut [ProductRow], metrics: Option<&HashMap>) { + let Some(metrics) = metrics.filter(|m| !m.is_empty()) else { return }; + for product in products.iter_mut() { + let Some(m) = product.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| metrics.get(c)) else { continue }; + product.ca_reseau = Some(m.ca_reseau); + product.qte_reseau = Some(m.qte_reseau); + product.nb_magasins_reseau = Some(m.nb_magasins_reseau); + product.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau); + product.marge_pct_reseau = Some(m.marge_pct_reseau); + product.taux_presence_reseau = Some(m.nb_magasins_reseau / NB_MAGASINS_RESEAU); + product.qte_reseau_by_month = Some(m.qte_by_month.clone()); + product.nb_mag_reseau_by_month = Some(nb_mag_par_mois(m)); + product.network_fetched_at = m.fetched_at.clone(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slice_et_max_comme_js() { + assert_eq!(js_slice_end(10, 300.0), 10); + assert_eq!(js_slice_end(10, 3.7), 3); + assert_eq!(js_slice_end(10, -2.0), 8); + assert_eq!(js_slice_end(10, f64::NAN), 0); + assert_eq!(js_max([1.0, 3.0, 2.0].into_iter()), 3.0); + assert!(js_max([1.0, f64::NAN].into_iter()).is_nan()); + } + + #[test] + fn report_des_gammes_du_snapshot() { + let mut rows = vec![ + ProductRow { codein: "1".into(), code_gamme: Some("B".into()), ..Default::default() }, + ProductRow { codein: "2".into(), code_gamme: Some("B".into()), ..Default::default() }, + ProductRow { codein: "3".into(), code_gamme: Some("B".into()), ..Default::default() }, + ]; + let changes = serde_json::json!({ "1": { "before": "B", "after": "Z" }, "2": { "before": "B", "after": "" } }); + apply_snapshot_changes(&mut rows, changes.as_object()); + let g: Vec<_> = rows.iter().map(|r| r.code_gamme.as_deref().unwrap()).collect(); + assert_eq!(g, vec!["Z", "B", "B"]); + } +} diff --git a/server/src/grid/store.rs b/server/src/grid/store.rs index 7c37fe6..909e7b7 100644 --- a/server/src/grid/store.rs +++ b/server/src/grid/store.rs @@ -159,16 +159,40 @@ pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> Ap if code_fournisseur.is_empty() || rows.is_empty() { return Ok(0); } + let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect(); + upsert_lots(code_fournisseur, values.len(), |plage| values[plage].iter().map(|r| (*r).clone()).collect()).await +} + +/// Même écriture depuis les lignes partagées du cache mémoire : chaque lot est +/// copié sous verrou, puis écrit sans le tenir (pas de copie intégrale d'un +/// fournisseur de 130 000 articles). +pub async fn upsert_grid_rows_shared(code_fournisseur: &str, rows: &super::rows_cache::GridRows) -> AppResult { + let indices: Vec = { + let lignes = rows.rows.read(); + if code_fournisseur.is_empty() || lignes.is_empty() { + return Ok(0); + } + lignes.iter().enumerate().filter(|(_, r)| !r.codein.is_empty()).map(|(i, _)| i).collect() + }; + upsert_lots(code_fournisseur, indices.len(), |plage| { + let lignes = rows.rows.read(); + indices[plage].iter().filter_map(|i| lignes.get(*i).cloned()).collect() + }) + .await +} + +/// Écrit `nb` lignes par lots de [`CHUNK_UPSERT`] (`lot(plage)` fournit les lignes +/// d'une plage), puis purge les lignes du fournisseur restées sur un calcul antérieur. +async fn upsert_lots(code_fournisseur: &str, nb: usize, lot: impl Fn(std::ops::Range) -> Vec) -> AppResult { + if nb == 0 { + return Ok(0); + } let pool = db::pool()?; let computed_at = maintenant_utc_ms(); - let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect(); - if values.is_empty() { - return Ok(0); - } - let mut written = 0usize; - for batch in values.chunks(CHUNK_UPSERT) { + for debut in (0..nb).step_by(CHUNK_UPSERT) { + let batch = lot(debut..(debut + CHUNK_UPSERT).min(nb)); let mut qb: QueryBuilder = QueryBuilder::new( "insert into grid_rows (code_fournisseur, codein, nom_fournisseur, libelle1, gtin, reference, \ code_centrale, code1, code2, code3, code_gamme, code_gamme_init, total_ca, total_quantite, \ @@ -177,7 +201,7 @@ pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> Ap ); let mut erreur_payload = None; qb.push_values(batch.iter(), |mut b, r| { - let mut payload = (*r).clone(); + let mut payload = r.clone(); payload.payload_version = Some(GRID_PAYLOAD_VERSION); let payload = serde_json::to_value(&payload).unwrap_or_else(|e| { erreur_payload = Some(e.to_string()); diff --git a/server/src/grid/store_patch.rs b/server/src/grid/store_patch.rs index bb94270..9cb3ecf 100644 --- a/server/src/grid/store_patch.rs +++ b/server/src/grid/store_patch.rs @@ -4,13 +4,77 @@ //! Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains //! articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire, //! pour ces articles seulement, les séries et totaux du magasin. -//! -//! `buildStorePatchEntries` (qui lit les entrées mensuelles de l'API FF) viendra -//! avec le calcul de la grille (phase 2). use super::types::{ProductRow, StorePatchEntry}; +use crate::ff::api::FfMensuelEntry; +use crate::ff::js::js_number_or0; use std::collections::HashMap; +/// Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes. +/// +/// `retenus` : codes article des candidats retenus (cf. [`store_patch_candidates`]). +pub fn build_store_patch_entries( + retenus: &[String], + magasin: &str, + periods: &[String], + mensuel: &HashMap>, +) -> Vec { + struct Valeurs { + qty: f64, + ca: f64, + marge: f64, + stock: f64, + receptions: f64, + } + let mut resultat = Vec::new(); + for codein in retenus { + let entries: Vec<&FfMensuelEntry> = + mensuel.get(codein).map(|v| v.iter().filter(|e| e.site == magasin).collect()).unwrap_or_default(); + if entries.is_empty() { + continue; + } + + let mut by_period: HashMap = HashMap::new(); + for entry in entries { + let period = entry.mois.replacen('-', "", 1); + if !periods.contains(&period) { + continue; + } + let ventes = entry.ventes.as_ref(); + // qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on + // les nie au lieu de Math.abs pour que les retours restent déduits. + let qty = -ventes.map(|v| js_number_or0(&v.qte_vendue)).unwrap_or(0.0); + let ca = -ventes.map(|v| js_number_or0(&v.ca_ht)).unwrap_or(0.0); + let marge = ventes.map(|v| js_number_or0(&v.marge)).unwrap_or(0.0); + let stock = js_number_or0(&entry.stock_fin_mois); + let receptions = entry.receptions.as_ref().map(|r| js_number_or0(&r.qte_recue)).unwrap_or(0.0); + by_period.insert(period, Valeurs { qty, ca, marge, stock, receptions }); + } + + let api_qty: f64 = by_period.values().map(|v| v.qty).sum(); + if api_qty == 0.0 { + continue; + } + + let mut e = StorePatchEntry { codein: codein.clone(), ..Default::default() }; + let mut last_stock = 0.0; + for period in periods { + let value = by_period.get(period); + e.sales.insert(period.clone(), value.map(|v| v.qty).unwrap_or(0.0)); + e.receptions.insert(period.clone(), value.map(|v| v.receptions).unwrap_or(0.0)); + if let Some(v) = value { + last_stock = v.stock; + } + e.stock.insert(period.clone(), last_stock); + e.quantite += value.map(|v| v.qty).unwrap_or(0.0); + e.ca += value.map(|v| v.ca).unwrap_or(0.0); + e.marge += value.map(|v| v.marge).unwrap_or(0.0); + } + resultat.push(e); + } + resultat +} + /// Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. pub fn store_patch_candidates<'a>(rows: &'a [ProductRow], magasin: &str) -> Vec<&'a ProductRow> { rows.iter() @@ -77,6 +141,31 @@ pub fn apply_store_patch_in_place(rows: &mut [ProductRow], magasin: &str, period mod tests { use super::*; + #[test] + fn complement_depuis_l_api() { + let periods = vec!["202601".to_string(), "202602".to_string(), "202603".to_string()]; + let entree = |mois: &str, site: &str, qte: &str| -> FfMensuelEntry { + serde_json::from_value(serde_json::json!({ + "mois": mois, "site": site, "stock_fin_mois": "5.000", + "ventes": { "qte_vendue": qte, "ca_ht": "-26.66", "marge": "12.76" }, "receptions": null + })) + .unwrap() + }; + let mut mensuel = HashMap::new(); + mensuel.insert( + "A1".to_string(), + vec![entree("2026-01", "292", "-2.000"), entree("2026-02", "579", "-9"), entree("2025-01", "292", "-4")], + ); + mensuel.insert("A2".to_string(), vec![entree("2026-01", "292", "0")]); + let e = build_store_patch_entries(&["A1".into(), "A2".into(), "A3".into()], "292", &periods, &mensuel); + assert_eq!(e.len(), 1, "A2 sans vente, A3 sans donnée"); + assert_eq!(e[0].quantite, 2.0); + assert!((e[0].ca - 26.66).abs() < 1e-9); + assert_eq!(e[0].sales["202602"], 0.0); + assert_eq!(e[0].stock["202603"], 5.0, "stock reporté"); + assert_eq!(serde_json::to_value(&e[0]).unwrap()["receptions"], serde_json::json!({ "202601": 0, "202602": 0, "202603": 0 })); + } + #[test] fn complement_reporte_sur_les_totaux() { let periods = vec!["202601".to_string(), "202602".to_string()]; diff --git a/server/src/grid/tests_db.rs b/server/src/grid/tests_db.rs index 61583b7..6d4696c 100644 --- a/server/src/grid/tests_db.rs +++ b/server/src/grid/tests_db.rs @@ -60,6 +60,8 @@ async fn grid_rows_snapshots_gammes_a_valider() { std::fs::create_dir_all(&dir).unwrap(); std::env::set_var("DATA_DIR", &dir); std::env::set_var("DATABASE_URL", &url); + // Une autre base a pu servir à un test précédent du même processus. + crate::db::refresh(); let pool = crate::db::pool().unwrap(); sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap(); nettoyer(&pool).await; diff --git a/server/src/grid/tests_ff.rs b/server/src/grid/tests_ff.rs new file mode 100644 index 0000000..14076d1 --- /dev/null +++ b/server/src/grid/tests_ff.rs @@ -0,0 +1,302 @@ +//! Test d'intégration du calcul de la grille (phase 2) sur une base de test +//! contenant les tables FF (`ff_fixture`), avec une fausse API FF Nancy. +//! +//! ```sh +//! GRID_FF_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/ff_fixture cargo test grid::tests_ff -- --nocapture +//! ``` +//! +//! Écrit dans `grid_rows`, `session_snapshots`, `gammes_a_valider`, `users` et +//! `api_keys` de la base de test (nettoyés à la fin). + +use super::enrich::enrich_rows; +use super::months; +use super::product_rows::*; +use super::rows_cache; +use super::store::*; +use crate::auth::Claims; +use axum::body::Body; +use axum::extract::Path; +use axum::http::Request; +use axum::routing::get; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +const CLE_API: &str = "cf_cle-de-test-grille-phase-2"; + +/// Fausse API FF : seul l'historique mensuel sert au complément par magasin. +async fn fausse_api(mois: String) -> String { + let app = Router::new().route( + "/api/articles/{id}/mensuel", + get(move |Path(id): Path| { + let mois = mois.clone(); + async move { + Json(json!({ "data": [ + { "mois": mois, "site": "579", "stock_fin_mois": "7.000", + "ventes": { "qte_vendue": "-3.000", "ca_ht": "-30.00", "marge": "9.5" }, + "receptions": { "qte_recue": "2" }, "id": id }, + { "mois": mois, "site": "292", "stock_fin_mois": "1", + "ventes": { "qte_vendue": "-100", "ca_ht": "-1", "marge": "0" } } + ] })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +async fn appel(req: Request) -> (u16, Vec) { + let res = super::router().oneshot(req).await.unwrap(); + let status = res.status().as_u16(); + (status, axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec()) +} + +fn get_cle(uri: &str) -> Request { + Request::builder().uri(uri).header("x-api-key", CLE_API).body(Body::empty()).unwrap() +} + +fn json_de(b: &[u8]) -> Value { + serde_json::from_slice(b).unwrap_or_else(|e| panic!("JSON illisible ({e}) : {}", String::from_utf8_lossy(b))) +} + +async fn nettoyer(pool: &sqlx::PgPool) { + for sql in [ + "delete from grid_rows where code_fournisseur in ('F001', 'F002', 'F404')", + "delete from session_snapshots where code_fournisseur in ('F001', 'F002')", + "delete from gammes_a_valider where code_fournisseur in ('F001', 'F002')", + "delete from api_keys where name = 'test-grille'", + "delete from users where username = 'test-grille'", + ] { + sqlx::query(sql).execute(pool).await.unwrap(); + } +} + +#[tokio::test] +async fn calcul_de_la_grille_et_routes() { + let Ok(url) = std::env::var("GRID_FF_TEST_DATABASE_URL") else { + eprintln!("GRID_FF_TEST_DATABASE_URL absent : test d'intégration du calcul ignoré."); + return; + }; + let _verrou = rows_cache::TEST_LOCK.lock(); + let dir = std::env::temp_dir().join(format!("grid-ff-tests-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("DATA_DIR", &dir); + std::env::set_var("DATABASE_URL", &url); + std::env::set_var("AUTH_SECRET", "secret-de-test"); + crate::db::refresh(); + let periods = months::get_last12_months(months::today()); + let dernier_mois = months::ff_month_to_qlik(periods.last().unwrap()); + std::env::set_var("FF_API_BASE_URL", fausse_api(dernier_mois).await); + crate::ff::api::reset_ff_api_base_cache(); + + let pool = crate::db::pool().unwrap(); + sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap(); + nettoyer(&pool).await; + rows_cache::invalidate_grid_rows_cache(None); + + // ── calcul complet ───────────────────────────────────────────────────── + let rows = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap(); + let lignes = rows.snapshot(); + println!("── lignes calculées\n{}", serde_json::to_string_pretty(&lignes).unwrap()); + assert!(!lignes.is_empty()); + // `articles.codein` est un char(10) dans la base de test : codes complétés d'espaces. + let tapis = lignes.iter().find(|r| r.codein.trim() == "100001").expect("article 100001"); + let code_tapis = tapis.codein.clone(); + let tapis_url = urlencoding::encode(&code_tapis).into_owned(); + assert_eq!(tapis.sales12m.len(), 12); + assert_eq!(tapis.code_centrale.as_deref(), Some("10000111111")); + assert_eq!(tapis.ca_reseau, Some(50000.0), "métriques réseau du cache Qlik"); + assert_eq!(tapis.taux_presence_reseau, Some(1.0)); + assert!(tapis.sales12m_by_store.as_ref().unwrap().contains_key("292")); + assert!(tapis.working_stores.contains(&"292".to_string())); + assert_eq!(tapis.stock_actuel, Some(4.0), "5 + -1 sur les deux sites"); + assert_eq!(tapis.prix_vente, Some(12.9), "le PV le plus élevé des deux magasins"); + assert!(tapis.total_quantite > 0.0 && tapis.total_ca > 0.0); + + // Persistance (lancée en arrière-plan) puis instantané. + attendre_persistance("F001").await; + let fraicheur = get_grid_freshness("F001").await.unwrap().expect("instantané écrit"); + assert_eq!(fraicheur.row_count as usize, lignes.len()); + + // Cache : même tableau partagé. + let encore = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap(); + assert!(std::sync::Arc::ptr_eq(&rows, &encore)); + + // Instantané resservi après invalidation du cache, gammes du snapshot reportées. + sqlx::query("insert into session_snapshots (code_fournisseur, magasin, changes, type) values ('F001', 'TOTAL', $1, 'snapshot')") + .bind(json!({ code_tapis.clone(): { "before": null, "after": "Z" } })) + .execute(&pool) + .await + .unwrap(); + rows_cache::invalidate_grid_rows_cache(Some("F001")); + let servies = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap(); + assert!(!std::sync::Arc::ptr_eq(&rows, &servies)); + assert_eq!(servies.len(), lignes.len()); + let s = servies.snapshot(); + assert_eq!(s.iter().find(|r| r.codein == code_tapis).unwrap().code_gamme.as_deref(), Some("Z")); + assert!(s.iter().all(|r| r.payload_version == Some(GRID_PAYLOAD_VERSION)), "lignes issues de grid_rows"); + + // Synchro : recalcul forcé, nombre de lignes. + assert_eq!(get_product_rows_for_sync("F001").await.unwrap(), lignes.len()); + + // ── complément par magasin (fausse API FF) ───────────────────────────── + let patch = get_store_patch("F001", "579").await.unwrap(); + println!("── complément 579\n{}", serde_json::to_string_pretty(&*patch).unwrap()); + assert_eq!(patch.magasin, "579"); + assert_eq!(patch.periods, periods); + assert!(!patch.entries.is_empty()); + assert!(patch.entries.iter().all(|e| e.quantite == 3.0 && e.ca == 30.0)); + let meme = get_store_patch("F001", "579").await.unwrap(); + assert!(std::sync::Arc::ptr_eq(&patch, &meme), "complément partagé"); + + // Vue d'un magasin : rattrapage appliqué aux lignes. + let vue_579 = + get_product_rows(GetProductRowsInput { code_fournisseur: "F001".into(), magasin: Some("579".into()), force_refresh: false }) + .await + .unwrap(); + assert!(vue_579.snapshot().iter().any(|r| r.quantite_by_store.as_ref().and_then(|q| q.get("579")).copied() == Some(3.0))); + + // ── enrichissement /api/v1 ───────────────────────────────────────────── + let enrichies = + enrich_rows(query_grid_rows(&GridQuery { code_fournisseur: Some("F001".into()), ..Default::default() }).await.unwrap().rows).await; + let e = enrichies.iter().find(|r| r.row.codein == code_tapis).unwrap(); + assert_eq!(e.network.as_ref().map(|n| n.ca_reseau), Some(50000.0)); + let v = serde_json::to_value(e).unwrap(); + assert!(v.get("codeGammeServeur").is_some() && v.get("gammeAValider").is_some() && v.get("trend").is_some()); + assert_eq!(v["codein"], json!(code_tapis)); + + // ── routes ───────────────────────────────────────────────────────────── + let (id_user,): (i32,) = + sqlx::query_as("insert into users (username, password_hash, role) values ('test-grille', 'x', 'admin') returning id") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("insert into api_keys (name, key_prefix, key_hash, role) values ('test-grille', 'cf_cle', $1, 'user')") + .bind(crate::auth::api_key::hash_api_key(CLE_API)) + .execute(&pool) + .await + .unwrap(); + let claims = + Claims { id: id_user.to_string(), name: "test-grille".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 }; + let cookie = format!("{}={}", crate::auth::session::COOKIE_NAME, crate::auth::session::encode(&claims)); + let get_session = |uri: &str| Request::builder().uri(uri).header("cookie", cookie.clone()).body(Body::empty()).unwrap(); + + // /api/grid/rows (NDJSON) + let (st, corps) = appel(get_session("/api/grid/rows?fournisseur=F001&magasin=TOTAL&chunkSize=1")).await; + assert_eq!(st, 200); + let messages: Vec = String::from_utf8(corps).unwrap().lines().map(|l| json_de(l.as_bytes())).collect(); + assert_eq!(messages[0], json!({ "type": "start", "loaded": 0, "total": null })); + let chunks: Vec<&Value> = messages.iter().filter(|m| m["type"] == "chunk").collect(); + // chunkSize=1 est relevé au minimum de 50 : un seul lot ici. + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0]["rows"].as_array().unwrap().len(), lignes.len()); + assert_eq!(chunks[0]["loaded"], lignes.len()); + assert_eq!(messages.last().unwrap(), &json!({ "type": "done", "loaded": lignes.len(), "total": lignes.len() })); + let (st, _) = appel(get_session("/api/grid/rows")).await; + assert_eq!(st, 400); + let (st, _) = appel(Request::builder().uri("/api/grid/rows?fournisseur=F001").body(Body::empty()).unwrap()).await; + assert_eq!(st, 401); + + // /api/grid/rows/store-patch + let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=579")).await; + assert_eq!(st, 200); + assert_eq!(json_de(&corps)["magasin"], "579"); + let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=TOTAL")).await; + assert_eq!((st, json_de(&corps)), (400, json!({ "error": "Paramètres fournisseur et magasin requis." }))); + + // /api/v1/grid : diffusion complète, puis page + let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001")).await; + assert_eq!(st, 200); + let tout = json_de(&corps); + assert_eq!(tout["data"].as_array().unwrap().len(), lignes.len()); + assert_eq!(tout["meta"]["diffuse"], true); + assert_eq!(tout["meta"]["complet"], true); + assert_eq!(tout["pagination"]["total"], lignes.len()); + let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001&limit=1&fields=totalCa&sort=codein&order=asc")).await; + assert_eq!(st, 200); + let page = json_de(&corps); + assert_eq!(page["data"][0].as_object().unwrap().keys().collect::>(), vec!["totalCa", "codein"]); + assert_eq!(page["meta"]["complet"], json!(lignes.len() <= 1)); + if lignes.len() > 1 { + assert!(page["meta"]["avertissement"].as_str().unwrap().contains("page=2")); + } + let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002&compute=0")).await; + assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (202, json!("not_ready"))); + let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002")).await; + let f002 = json_de(&corps); + assert_eq!(st, 200, "{f002}"); + assert_eq!(f002["meta"]["computedOnDemand"], true); + let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F404")).await; + assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (404, json!("not_found"))); + + // /api/v1/products/* + let (st, corps) = appel(get_cle("/api/v1/products/search?q=tapis&fields=libelle1")).await; + assert_eq!(st, 200); + let rech = json_de(&corps); + assert_eq!(rech["data"][0]["codein"], json!(code_tapis)); + assert_eq!(rech["meta"]["scope"], "tous fournisseurs"); + let (st, corps) = appel(get_cle(&format!("/api/v1/products/{tapis_url}?fournisseur=F001"))).await; + assert_eq!(st, 200); + let fiche = json_de(&corps); + assert_eq!(fiche["meta"]["hasNetwork"], true); + assert_eq!(fiche["data"]["network"]["caReseau"], 50000); + let (st, _) = appel(get_cle("/api/v1/products/999999")).await; + assert_eq!(st, 404); + + // Gammes : proposition à valider, puis lot avec un inconnu (tout ou rien). + let put = Request::builder() + .method("PUT") + .uri(format!("/api/v1/products/{tapis_url}/gamme")) + .header("x-api-key", CLE_API) + .header("content-type", "application/json") + .body(Body::from(r#"{"gamme":"A","fournisseur":"F001"}"#)) + .unwrap(); + let (st, corps) = appel(put).await; + let rep = json_de(&corps); + assert_eq!(st, 200, "{rep}"); + assert_eq!(rep["data"]["statut"], "a_valider"); + assert_eq!(rep["data"]["codeFournisseur"], "F001"); + let a_valider = super::gammes_a_valider::lister_gammes_a_valider("F001").await.unwrap(); + assert_eq!((a_valider[0].codein.as_str(), a_valider[0].auteur.as_deref()), (code_tapis.as_str(), Some("test-grille"))); + let post = |corps: &str| { + Request::builder().method("POST").uri("/api/v1/gammes").header("x-api-key", CLE_API).body(Body::from(corps.to_string())).unwrap() + }; + let (st, corps) = appel(post( + &json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "B" }, { "codein": "XXX", "gamme": "A" }] }) + .to_string(), + )) + .await; + let rep = json_de(&corps); + assert_eq!(st, 404); + assert_eq!(rep["error"]["details"]["inconnus"], json!(["XXX"])); + let (st, corps) = appel(post("pas du json")).await; + assert_eq!( + (st, json_de(&corps)["error"]["message"].clone()), + (400, json!("Corps de requête JSON attendu (Content-Type: application/json).")) + ); + let (st, corps) = appel(post(&json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "Z" }] }).to_string())).await; + let rep = json_de(&corps); + assert_eq!(st, 200, "{rep}"); + assert_eq!(rep["meta"]["demandes"], 1); + + // /api/v1/fournisseurs, /api/v1/network + let (st, corps) = appel(get_cle("/api/v1/fournisseurs")).await; + assert_eq!(st, 200); + let fous = json_de(&corps); + assert!(fous["data"].as_array().unwrap().iter().any(|f| f["code"] == "F001" && f["hasSnapshot"] == true)); + let (st, corps) = appel(get_cle("/api/v1/fournisseurs?withData=1&search=f00")).await; + assert_eq!(st, 200); + assert_eq!(json_de(&corps)["meta"]["scope"], "instantané uniquement"); + let (st, corps) = appel(get_cle("/api/v1/network/10000111111")).await; + assert_eq!(st, 200); + let net = json_de(&corps); + assert_eq!(net["data"].as_object().unwrap().keys().next().unwrap(), "codeCentrale"); + assert_eq!(net["meta"]["source"], "cache"); + let (st, _) = appel(get_cle("/api/v1/network/inconnu")).await; + assert_eq!(st, 404); + + nettoyer(&pool).await; + rows_cache::invalidate_grid_rows_cache(None); +} From f232673138c1b72c39617120b91a95eba9e76265 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:56:31 +0000 Subject: [PATCH 16/19] =?UTF-8?q?Portage=20du=20module=20Produits=20:=20re?= =?UTF-8?q?cherche=20Qlik=20d'abord,=20fiche=20produit,=20opportunit=C3=A9?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/produits/search (POST démarre un job, GET suit l'avancement) : Qlik d'abord, repli catalogue FF (source: "db"), cache mémoire 10 min des seuls résultats Qlik exploitables, une recherche Qlik à la fois. - /api/produits/opportunites : produits de la même famille, local vs réseau. - Page produit-fiche (?codein= ou ?cc=) : getProduitFiche / getProduitFicheByCodeCentrale, report du stock de fin de mois. - normalizeMargePct (compare-reseau) et types JSON identiques au client. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/produits/compare_reseau.rs | 42 ++ server/src/produits/fiche.rs | 329 +++++++++++ server/src/produits/mod.rs | 21 +- server/src/produits/routes.rs | 132 +++++ server/src/produits/search.rs | 772 ++++++++++++++++++++++++++ server/src/produits/types.rs | 169 ++++++ 6 files changed, 1463 insertions(+), 2 deletions(-) create mode 100644 server/src/produits/compare_reseau.rs create mode 100644 server/src/produits/fiche.rs create mode 100644 server/src/produits/routes.rs create mode 100644 server/src/produits/search.rs create mode 100644 server/src/produits/types.rs diff --git a/server/src/produits/compare_reseau.rs b/server/src/produits/compare_reseau.rs new file mode 100644 index 0000000..c9f9100 --- /dev/null +++ b/server/src/produits/compare_reseau.rs @@ -0,0 +1,42 @@ +//! CollectFlow — Comparatif local vs réseau pour un produit — partie serveur de +//! `src/features/produits/lib/compare-reseau.ts`. +//! +//! Le calcul du comparatif lui-même (`computeComparatif`, `indiceVerdict`) reste +//! côté interface : seul `normalizeMargePct` sert au serveur (recherche produit). + +/// Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines +/// configurations renvoient déjà des points de pourcentage. Même normalisation +/// que la colonne « Marge % / Réseau » de la Grille. +pub fn normalize_marge_pct(raw: Option) -> Option { + let v = raw?; + if !v.is_finite() || v == 0.0 { + return None; + } + Some(if v.abs() <= 1.0 { v * 100.0 } else { v }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ratio_brut_converti_en_points() { + assert_eq!(normalize_marge_pct(Some(0.32)), Some(32.0)); + assert_eq!(normalize_marge_pct(Some(-0.5)), Some(-50.0)); + assert_eq!(normalize_marge_pct(Some(1.0)), Some(100.0)); + } + + #[test] + fn points_de_pourcentage_conserves() { + assert_eq!(normalize_marge_pct(Some(32.5)), Some(32.5)); + assert_eq!(normalize_marge_pct(Some(-12.0)), Some(-12.0)); + } + + #[test] + fn absent_nul_ou_non_fini() { + assert_eq!(normalize_marge_pct(None), None); + assert_eq!(normalize_marge_pct(Some(0.0)), None); + assert_eq!(normalize_marge_pct(Some(f64::NAN)), None); + assert_eq!(normalize_marge_pct(Some(f64::INFINITY)), None); + } +} diff --git a/server/src/produits/fiche.rs b/server/src/produits/fiche.rs new file mode 100644 index 0000000..0742139 --- /dev/null +++ b/server/src/produits/fiche.rs @@ -0,0 +1,329 @@ +//! CollectFlow — Fiche produit — port de +//! `src/features/produits/api/get-produit-fiche.ts` +//! (`getProduitFiche`, `getProduitFicheByCodeCentrale`). + +use super::types::{ProduitFiche, ProduitMois, ProduitTotaux, SerieMensuelle}; +use crate::ff::api::build_last12_months_range; +use crate::ff::pg::{ + pg_get_codein_by_code_centrale, pg_get_commandes_by_codein, pg_get_fournisseurs_by_codein, pg_get_gamme_history_by_codein, + pg_get_mensuel_by_codein, pg_get_produit_detail, pg_get_stock_by_codein, PgMensuelRow, +}; +use crate::grid::js::js_trim; +use crate::grid::months::{get_last12_months, today}; +use crate::qlik::get_network_metrics_by_code_centrale; +use std::collections::{BTreeMap, HashMap, HashSet}; + +/// Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. +fn build_month_keys() -> Vec { + get_last12_months(today()) +} + +fn empty_mois(mois: &str) -> ProduitMois { + ProduitMois { mois: mois.to_string(), qte: 0.0, ca: 0.0, marge: 0.0, stock_fin_mois: 0.0, qte_recue: 0.0 } +} + +fn totaux_from<'a>(rows: impl Iterator) -> ProduitTotaux { + let (mut qte, mut ca, mut marge) = (0.0, 0.0, 0.0); + for r in rows { + qte += r.qte; + ca += r.ca; + marge += r.marge; + } + ProduitTotaux { qte, ca, marge, taux_marge: if ca > 0.0 { (marge / ca) * 100.0 } else { 0.0 } } +} + +/// Totaux d'une série sur les mois de la fenêtre, dans l'ordre de `months`. +fn totaux_serie(months: &[String], serie: &SerieMensuelle) -> ProduitTotaux { + totaux_from(months.iter().filter_map(|m| serie.get(m))) +} + +/// `Number(x) || 0`. +fn ou_zero(x: f64) -> f64 { + if x.is_nan() { + 0.0 + } else { + x + } +} + +/// Partie mensuelle de la fiche, calculée à partir des lignes `mvtart` agrégées. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Mensuel { + pub mensuel_total: SerieMensuelle, + pub mensuel_par_site: BTreeMap, + pub totaux: ProduitTotaux, + pub totaux_par_site: BTreeMap, + pub sites_actifs: Vec, +} + +/// Agrège les lignes mensuelles (total et par site) sur la fenêtre `months`, +/// avec report du stock de fin de mois sur les mois sans mouvement. +pub fn assembler_mensuel(months: &[String], mensuel_rows: &[PgMensuelRow]) -> Mensuel { + let allowed: HashSet<&str> = months.iter().map(|m| m.as_str()).collect(); + + let mut mensuel_total: SerieMensuelle = months.iter().map(|m| (m.clone(), empty_mois(m))).collect(); + let mut mensuel_par_site: BTreeMap = BTreeMap::new(); + + // Mois réellement présents dans les mouvements. Indispensable pour distinguer + // « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock + // effectivement nul » — c'est la convention de la Grille. + let mut mois_avec_donnees: HashSet = HashSet::new(); + let mut mois_avec_donnees_par_site: HashMap> = HashMap::new(); + + for row in mensuel_rows { + // SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM". + let mois = row.mois.replacen('-', "", 1); + if !allowed.contains(mois.as_str()) { + continue; + } + let site = js_trim(&row.site).to_string(); + + let qte = ou_zero(row.qte_vendue); + let ca = ou_zero(row.ca_ht); + let marge = ou_zero(row.marge); + let stock_fin_mois = ou_zero(row.stock_fin_mois.unwrap_or(0.0)); + let qte_recue = ou_zero(row.qte_recue); + + mois_avec_donnees.insert(mois.clone()); + if let Some(tot) = mensuel_total.get_mut(&mois) { + tot.qte += qte; + tot.ca += ca; + tot.marge += marge; + tot.stock_fin_mois += stock_fin_mois; + tot.qte_recue += qte_recue; + } + + let par_site = + mensuel_par_site.entry(site.clone()).or_insert_with(|| months.iter().map(|m| (m.clone(), empty_mois(m))).collect()); + mois_avec_donnees_par_site.entry(site).or_default().insert(mois.clone()); + if let Some(cell) = par_site.get_mut(&mois) { + cell.qte += qte; + cell.ca += ca; + cell.marge += marge; + // Le SQL donne déjà le stock du dernier mouvement du mois pour ce site. + cell.stock_fin_mois = stock_fin_mois; + cell.qte_recue += qte_recue; + } + } + + // Report du stock de fin de mois sur les mois SANS mouvement (carry-forward), + // même convention que la Grille : un mois sans mouvement conserve le stock du + // mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0. + let carry_forward = |serie: &mut SerieMensuelle, avec_donnees: Option<&HashSet>| { + let mut last = 0.0; + for m in months { + let Some(cell) = serie.get_mut(m) else { continue }; + if avec_donnees.is_some_and(|s| s.contains(m)) { + last = cell.stock_fin_mois; + } else { + cell.stock_fin_mois = last; + } + } + }; + carry_forward(&mut mensuel_total, Some(&mois_avec_donnees)); + for (site, by_site) in mensuel_par_site.iter_mut() { + carry_forward(by_site, mois_avec_donnees_par_site.get(site)); + } + + let totaux = totaux_serie(months, &mensuel_total); + let totaux_par_site: BTreeMap = + mensuel_par_site.iter().map(|(site, by_site)| (site.clone(), totaux_serie(months, by_site))).collect(); + + let mut sites_actifs: Vec = totaux_par_site.iter().filter(|(_, t)| t.qte > 0.0).map(|(s, _)| s.clone()).collect(); + sites_actifs.sort(); + + Mensuel { mensuel_total, mensuel_par_site, totaux, totaux_par_site, sites_actifs } +} + +/// Assemble la fiche d'un produit **à partir de son code centrale** : c'est la +/// clé du réseau Qlik, seule identité disponible pour un produit que le réseau +/// travaille mais que Nancy ne référence pas. +/// +/// Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie +/// locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes). +/// Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal +/// depuis la recherche Qlik. +/// +/// Renvoie `None` uniquement si ni Qlik ni le catalogue ne connaissent le code. +pub async fn get_produit_fiche_by_code_centrale(code_centrale: &str) -> anyhow::Result> { + let cleaned = js_trim(code_centrale); + if cleaned.is_empty() { + return Ok(None); + } + + if let Some(codein) = pg_get_codein_by_code_centrale(cleaned).await { + return get_produit_fiche(&codein).await; + } + + // Produit réseau pur : aucune donnée locale à assembler. + let mut map = get_network_metrics_by_code_centrale(&[cleaned.to_string()]).await?; + let Some(reseau) = map.remove(cleaned) else { + return Ok(None); + }; + + let periode = build_last12_months_range(); + let months = build_month_keys(); + let mensuel_total: SerieMensuelle = months.iter().map(|m| (m.clone(), empty_mois(m))).collect(); + let totaux = totaux_serie(&months, &mensuel_total); + + Ok(Some(ProduitFiche { + detail: None, + code_centrale: cleaned.to_string(), + libelle_reseau: reseau.libelle_reseau.clone().unwrap_or_default(), + fournisseur_reseau: reseau.fournisseur_reseau.clone().unwrap_or_default(), + fournisseurs: vec![], + stock: vec![], + commandes_en_cours: 0.0, + gammes: vec![], + months, + mensuel_total, + mensuel_par_site: BTreeMap::new(), + totaux, + totaux_par_site: BTreeMap::new(), + sites_actifs: vec![], + reseau: Some(reseau), + periode, + })) +} + +/// Assemble la fiche complète d'un produit : identité, fournisseurs, stock, +/// gammes, 12 mois glissants de ventes (total et par magasin) et métriques +/// réseau Qlik. +/// +/// Renvoie `None` si le codein est inconnu du catalogue FF. +pub async fn get_produit_fiche(codein: &str) -> anyhow::Result> { + let cleaned = js_trim(codein); + if cleaned.is_empty() { + return Ok(None); + } + + let periode = build_last12_months_range(); + + // Tout part en même temps : l'identité du produit était lue seule d'abord, + // puis les cinq autres lectures, puis les métriques réseau — trois attentes + // successives. Les métriques réseau n'attendent que l'identité (code centrale). + let detail_et_reseau = async { + let detail = pg_get_produit_detail(cleaned).await; + let code = detail.as_ref().map(|d| d.code_centrale.clone()).filter(|c| !c.is_empty()); + let reseau = match code { + Some(code) => get_network_metrics_by_code_centrale(std::slice::from_ref(&code)).await?.remove(&code), + None => None, + }; + anyhow::Ok((detail, reseau)) + }; + let (detail_et_reseau, fournisseurs, stock, commandes_en_cours, gammes, mensuel_rows) = tokio::join!( + detail_et_reseau, + pg_get_fournisseurs_by_codein(cleaned), + pg_get_stock_by_codein(cleaned), + pg_get_commandes_by_codein(cleaned), + pg_get_gamme_history_by_codein(cleaned), + pg_get_mensuel_by_codein(cleaned, &periode.date_debut, &periode.date_fin), + ); + let (detail, reseau) = detail_et_reseau?; + let Some(detail) = detail else { + return Ok(None); + }; + + let months = build_month_keys(); + let Mensuel { mensuel_total, mensuel_par_site, totaux, totaux_par_site, sites_actifs } = assembler_mensuel(&months, &mensuel_rows); + + Ok(Some(ProduitFiche { + code_centrale: detail.code_centrale.clone(), + detail: Some(detail), + libelle_reseau: reseau.as_ref().and_then(|r| r.libelle_reseau.clone()).unwrap_or_default(), + fournisseur_reseau: reseau.as_ref().and_then(|r| r.fournisseur_reseau.clone()).unwrap_or_default(), + fournisseurs, + stock, + commandes_en_cours, + gammes, + months, + mensuel_total, + mensuel_par_site, + totaux, + totaux_par_site, + sites_actifs, + reseau, + periode, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ligne(site: &str, mois: &str, qte: f64, ca: f64, marge: f64, stock: Option, recu: f64) -> PgMensuelRow { + PgMensuelRow { + codein: "123".into(), + site: site.into(), + mois: mois.into(), + qte_vendue: qte, + ca_ht: ca, + marge, + stock_fin_mois: stock, + qte_recue: recu, + } + } + + fn mois() -> Vec { + get_last12_months(chrono::NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()) + } + + #[test] + fn fenetre_12_mois_hors_mois_courant() { + let m = mois(); + assert_eq!(m.len(), 12); + assert_eq!(m.first().unwrap(), "202503"); + assert_eq!(m.last().unwrap(), "202602"); + } + + #[test] + fn agrege_total_et_sites_avec_report_de_stock() { + let m = mois(); + let rows = vec![ + ligne("292", "2025-04", 2.0, 20.0, 5.0, Some(10.0), 0.0), + ligne(" 579 ", "2025-04", 1.0, 10.0, 2.0, Some(4.0), 3.0), + // Mois avec mouvements et stock nul : reste à 0 (pas de report). + ligne("292", "2025-06", 0.0, 0.0, 0.0, Some(0.0), 0.0), + // Hors fenêtre : ignoré. + ligne("292", "2024-01", 99.0, 99.0, 99.0, Some(99.0), 99.0), + ]; + let r = assembler_mensuel(&m, &rows); + + assert_eq!(r.mensuel_total.len(), 12); + let avril = &r.mensuel_total["202504"]; + assert_eq!((avril.qte, avril.ca, avril.marge, avril.stock_fin_mois, avril.qte_recue), (3.0, 30.0, 7.0, 14.0, 3.0)); + // Mars : avant tout mouvement → 0 ; mai : report d'avril ; juin : 0 réel ; juillet : report de juin. + assert_eq!(r.mensuel_total["202503"].stock_fin_mois, 0.0); + assert_eq!(r.mensuel_total["202505"].stock_fin_mois, 14.0); + assert_eq!(r.mensuel_total["202506"].stock_fin_mois, 0.0); + assert_eq!(r.mensuel_total["202602"].stock_fin_mois, 0.0); + + // Le site est « trimé » ; chaque site a ses 12 mois. + assert_eq!(r.mensuel_par_site.keys().cloned().collect::>(), vec!["292", "579"]); + assert_eq!(r.mensuel_par_site["579"].len(), 12); + assert_eq!(r.mensuel_par_site["579"]["202602"].stock_fin_mois, 4.0); + assert_eq!(r.mensuel_par_site["292"]["202505"].stock_fin_mois, 10.0); + assert_eq!(r.mensuel_par_site["292"]["202507"].stock_fin_mois, 0.0); + + assert_eq!(r.totaux, ProduitTotaux { qte: 3.0, ca: 30.0, marge: 7.0, taux_marge: 7.0 / 30.0 * 100.0 }); + assert_eq!(r.totaux_par_site["292"].qte, 2.0); + assert_eq!(r.sites_actifs, vec!["292", "579"]); + } + + #[test] + fn site_sans_vente_non_actif_et_taux_nul_sans_ca() { + let m = mois(); + let rows = vec![ligne("579", "2025-05", 0.0, 0.0, 0.0, None, 6.0)]; + let r = assembler_mensuel(&m, &rows); + assert!(r.sites_actifs.is_empty()); + assert_eq!(r.totaux.taux_marge, 0.0); + assert_eq!(r.mensuel_total["202505"].qte_recue, 6.0); + assert_eq!(r.mensuel_total["202505"].stock_fin_mois, 0.0); + } + + #[test] + fn mois_vide_serialise_comme_le_ts() { + let v = serde_json::to_value(empty_mois("202501")).unwrap(); + assert_eq!(v, serde_json::json!({ "mois": "202501", "qte": 0, "ca": 0, "marge": 0, "stockFinMois": 0, "qteRecue": 0 })); + } +} diff --git a/server/src/produits/mod.rs b/server/src/produits/mod.rs index 05e86bf..2224a0e 100644 --- a/server/src/produits/mod.rs +++ b/server/src/produits/mod.rs @@ -1,11 +1,28 @@ -//! Module `produits` — à porter. +//! Module `produits` — recherche produit (Qlik d'abord, base FF Nancy en repli) +//! et fiche produit. +//! +//! - [`search`] : recherche asynchrone (jobs en mémoire, cache 10 min) ; +//! - [`fiche`] : assemblage de la fiche produit (local + réseau) ; +//! - [`compare_reseau`] : normalisation du taux de marge réseau ; +//! - [`types`] : formes JSON partagées avec l'interface ; +//! - [`routes`] : `/api/produits/search`, `/api/produits/opportunites`, page `produit-fiche`. +pub mod compare_reseau; +pub mod fiche; +pub mod routes; +pub mod search; +pub mod types; + +use crate::handler; use crate::rpc::Handler; +use axum::routing::get; use axum::Router; /// Routes HTTP du module. pub fn router() -> Router { Router::new() + .route("/api/produits/search", get(routes::search_get).post(routes::search_post)) + .route("/api/produits/opportunites", get(routes::opportunites)) } /// Actions (`POST /api/actions/{nom}`) du module. @@ -15,5 +32,5 @@ pub fn actions() -> Vec<(&'static str, Handler)> { /// Données de pages (`GET /api/pages/{nom}`) du module. pub fn pages() -> Vec<(&'static str, Handler)> { - vec![] + vec![("produit-fiche", handler!(routes::page_produit_fiche))] } diff --git a/server/src/produits/routes.rs b/server/src/produits/routes.rs new file mode 100644 index 0000000..30f2af4 --- /dev/null +++ b/server/src/produits/routes.rs @@ -0,0 +1,132 @@ +//! Routes HTTP et page du module `produits` : +//! - `/api/produits/search` (port de `src/app/api/produits/search/route.ts`) ; +//! - `/api/produits/opportunites` (port de `src/app/api/produits/opportunites/route.ts`) ; +//! - page `produit-fiche` (données de l'ancienne page serveur `/produits`). + +use super::fiche::{get_produit_fiche, get_produit_fiche_by_code_centrale}; +use super::search::{demarrer_recherche, etat_recherche, RechercheEtat}; +use crate::auth::SessionUser; +use crate::error::{AppError, AppResult}; +use crate::ff::api::build_last12_months_range; +use crate::ff::pg::pg_get_opportunites_famille; +use crate::grid::js::{js_number_from_str, js_trim}; +use crate::rpc::{json, Ctx}; +use axum::extract::Query; +use axum::Json; +use serde_json::{json, Value}; +use std::collections::HashMap; + +/// Nombre de produits de la famille renvoyés (30 dans le code d'origine). +const LIMITE_OPPORTUNITES: i64 = 30; + +const TERME_TROP_COURT: &str = "Saisissez au moins 3 caractères"; + +/// Terme de recherche (`q`), `None` s'il fait moins de 3 caractères. +fn lire_terme(q: &HashMap) -> Option { + let terme = js_trim(q.get("q").map(|s| s.as_str()).unwrap_or("")); + (terme.encode_utf16().count() >= 3).then(|| terme.to_string()) +} + +/// Recherche produit **Qlik d'abord** — API asynchrone. +/// +/// `POST /api/produits/search?q=[&force=1]` démarre la recherche et rend la +/// main tout de suite. `GET /api/produits/search?q=` renvoie l'avancement, +/// puis le résultat. +/// +/// Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une +/// extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue +/// aussi longtemps se fait couper par le reverse proxy, qui répond une page +/// d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is +/// not valid JSON ». Même schéma que `POST /api/qlik/sync`. +/// +/// Contrôle de session explicite : il répond ici en JSON, et relit le compte en +/// base (cf. `auth::authz`). +pub async fn search_post(_session: SessionUser, Query(q): Query>) -> AppResult> { + let Some(terme) = lire_terme(&q) else { + return Err(AppError::bad_request(TERME_TROP_COURT)); + }; + let force = q.get("force").map(|s| s.as_str()) == Some("1"); + let job = demarrer_recherche(&terme, force); + Ok(Json(RechercheEtat::Job(job).reponse())) +} + +/// `GET /api/produits/search?q=` — état de la recherche (polling client). +pub async fn search_get(_session: SessionUser, Query(q): Query>) -> AppResult> { + let Some(terme) = lire_terme(&q) else { + return Err(AppError::bad_request(TERME_TROP_COURT)); + }; + Ok(Json(etat_recherche(&terme).reponse())) +} + +/// GET /api/produits/opportunites?nomNoId=123 +/// +/// Produits de la même nomenclature, classés par écart entre performance réseau +/// et performance locale (quantités par magasin). Chargé à la demande depuis la +/// fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille, +/// on ne veut pas la payer au chargement de la page. +pub async fn opportunites(_session: SessionUser, Query(q): Query>) -> AppResult> { + let raw = q.get("nomNoId").map(|s| s.as_str()).unwrap_or(""); + let nom_no_id = js_number_from_str(raw); + if !nom_no_id_valide(raw, nom_no_id) { + return Err(AppError::bad_request("Param 'nomNoId' invalide")); + } + + // `pg_get_opportunites_famille` avale déjà ses erreurs (liste vide, journalisée). + let periode = build_last12_months_range(); + let rows = pg_get_opportunites_famille(nom_no_id as i64, &periode.date_debut, &periode.date_fin, LIMITE_OPPORTUNITES).await; + Ok(Json(json!({ "success": true, "rows": rows }))) +} + +/// `!raw || !Number.isInteger(nomNoId) || nomNoId <= 0` → invalide. +fn nom_no_id_valide(raw: &str, n: f64) -> bool { + !raw.is_empty() && n.is_finite() && n.fract() == 0.0 && n > 0.0 +} + +/// Page `produit-fiche` : `?codein=…` → `getProduitFiche(codein)`, +/// `?cc=…` → `getProduitFicheByCodeCentrale(cc)`, sinon `null`. +pub async fn page_produit_fiche(ctx: Ctx) -> AppResult { + ctx.session().await?; + let codein = js_trim(ctx.query.get("codein").map(|s| s.as_str()).unwrap_or("")); + let code_centrale = js_trim(ctx.query.get("cc").map(|s| s.as_str()).unwrap_or("")); + + let fiche = if !codein.is_empty() { + get_produit_fiche(codein).await? + } else if !code_centrale.is_empty() { + get_produit_fiche_by_code_centrale(code_centrale).await? + } else { + None + }; + json(fiche) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(q: &str) -> HashMap { + HashMap::from([("q".to_string(), q.to_string())]) + } + + #[test] + fn terme_minimum_3_caracteres() { + assert_eq!(lire_terme(¶ms(" ab ")), None); + assert_eq!(lire_terme(&HashMap::new()), None); + assert_eq!(lire_terme(¶ms(" poê ")), Some("poê".to_string())); + } + + #[test] + fn nom_no_id_comme_number_is_integer() { + let ok = |s: &str| nom_no_id_valide(s, js_number_from_str(s)); + assert!(ok("123")); + assert!(ok(" 12 ")); + assert!(ok("12.0")); + assert!(ok("0x10")); + assert!(!ok("")); + assert!(!ok(" ")); + assert!(!ok("0")); + assert!(!ok("-3")); + assert!(!ok("1.5")); + assert!(!ok("abc")); + assert!(!ok("Infinity")); + } +} diff --git a/server/src/produits/search.rs b/server/src/produits/search.rs new file mode 100644 index 0000000..98afdc6 --- /dev/null +++ b/server/src/produits/search.rs @@ -0,0 +1,772 @@ +//! CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite — +//! port de `src/features/produits/api/search-produits.ts`. +//! +//! Déroulé : +//! 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET +//! leurs mesures réseau sur 12 mois glissants (`search_qlik_articles`). +//! 2. Base FF Nancy : lesquels référençons-nous ? (`pg_get_produits_by_code_centrale`) +//! 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà +//! extrait (le cube de recherche est agrégé, il ne le fournit pas). +//! +//! Une seconde session Qlik pour les mesures faisait abandonner le moteur +//! (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app, +//! dont une avec une grosse sélection, et l'Engine coupe les requêtes. +//! +//! Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne +//! pas laisser l'utilisateur sans résultat — la réponse le signale explicitement +//! (`source: "db"`). + +use super::compare_reseau::normalize_marge_pct; +use super::types::{ProduitRechercheResultat, ProduitRechercheRow, SourceRecherche}; +use crate::ff::pg::{compare_fr, pg_get_produits_by_code_centrale, pg_search_produits, PgProduitSearchRow}; +use crate::grid::js::{js_trim, replace_whitespace_runs}; +use crate::grid::months::today; +use crate::grid::network_trend::{build_rolling12_qlik_months, NB_MAGASINS_RESEAU}; +use crate::qlik::network_cache::upsert_network_metrics; +use crate::qlik::search::{search_qlik_articles, QLIK_SEARCH_MAX_RESULTS}; +use crate::qlik::{get_network_metrics_by_code_centrale, NetworkMetric, NetworkMetricCached, QlikMonthMetrics}; +use chrono::NaiveDate; +use parking_lot::Mutex; +use serde::Serialize; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; + +/// Durée de validité d'un résultat de recherche en cache mémoire. +const CACHE_TTL: Duration = Duration::from_secs(10 * 60); + +/// Durée de conservation d'un job terminé (le client a le temps de le relire). +const JOB_TTL: Duration = Duration::from_secs(5 * 60); + +/// Nombre de lignes de la recherche catalogue local (50 dans le code d'origine). +const LIMITE_RECHERCHE_LOCALE: i64 = 50; + +/// Série « magasins vendeurs » extraite du détail mensuel du cache. +/// +/// Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est +/// qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la +/// série et priverait la tendance de son dénominateur. +fn nb_mag_par_mois(metrics_by_month: Option<&BTreeMap>) -> Option> { + let metrics_by_month = metrics_by_month?; + let par_mois: BTreeMap = metrics_by_month + .iter() + .map(|(mois, m)| (mois.clone(), m.nb_mag.filter(|nb| nb.is_finite()).unwrap_or(0.0))) + .collect(); + if par_mois.is_empty() { + None + } else { + Some(par_mois) + } +} + +/// Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. +fn cache_reseau_complet(cache: Option<&NetworkMetricCached>, periode: Option<&str>, now: NaiveDate) -> bool { + let Some(cache) = cache else { return false }; + let (Some(par_mois), Some(periode)) = (cache.qte_by_month.as_ref(), periode) else { + return false; + }; + if cache.periode.as_deref() != Some(periode) { + return false; + } + build_rolling12_qlik_months(now).iter().all(|mois| par_mois.get(mois).is_some_and(|v| v.is_finite())) +} + +/// Nombre maximum de recherches Qlik simultanées. +/// +/// Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on +/// plafonne plutôt que de lui envoyer N extractions en parallèle. +const MAX_RECHERCHES_SIMULTANEES: usize = 1; + +/// Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik +/// (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur +/// ou un rafraîchissement relancerait toute l'extraction. +static CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Résultat en cache et instant de sa mise en cache. +type EntreeCache = (Instant, Arc); + +/// Jobs de recherche, indexés par terme normalisé. +/// +/// Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours +/// Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps +/// se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le +/// client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail +/// tourne donc en tâche de fond et le client interroge l'état. +static JOBS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +type JobPartage = Arc>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StatutRecherche { + Running, + Success, + Error, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RechercheJob { + pub job_id: String, + pub terme: String, + pub status: StatutRecherche, + /// Étape en cours, affichée pendant l'attente. + pub etape: String, + pub started_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option>, + /// Instant de fin (purge), non sérialisé. + #[serde(skip)] + termine: Option, +} + +/// État renvoyé au client : le job, ou `idle` si aucune recherche connue. +#[derive(Debug, Clone)] +pub enum RechercheEtat { + Job(RechercheJob), + Idle { terme: String }, +} + +impl RechercheEtat { + /// Corps de réponse `{ success: true, ...etat }`. + pub fn reponse(&self) -> Value { + let mut m = Map::new(); + m.insert("success".into(), Value::Bool(true)); + match self { + RechercheEtat::Job(job) => { + if let Ok(Value::Object(champs)) = serde_json::to_value(job) { + m.extend(champs); + } + } + RechercheEtat::Idle { terme } => { + m.insert("status".into(), Value::from("idle")); + m.insert("terme".into(), Value::from(terme.clone())); + } + } + Value::Object(m) + } +} + +fn cache_key(term: &str) -> String { + replace_whitespace_runs(&js_trim(term).to_lowercase(), " ") +} + +fn maintenant_iso() -> String { + crate::db::iso_utc(chrono::Utc::now()) +} + +/// `n.toString(36)`. +fn base36(mut n: u128) -> String { + const CHIFFRES: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if n == 0 { + return "0".into(); + } + let mut out = Vec::new(); + while n > 0 { + out.push(CHIFFRES[(n % 36) as usize]); + n /= 36; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + +fn horodatage_base36() -> String { + base36(chrono::Utc::now().timestamp_millis().max(0) as u128) +} + +/// `Math.random().toString(36).slice(2, 8)` : 6 caractères base 36. +fn alea_base36() -> String { + (0..6).map(|_| base36(rand::random::() as u128 % 36)).collect() +} + +/// `s.slice(0, n)` en JS (unités UTF-16). +fn slice_js(s: &str, n: usize) -> String { + let mut unites = 0; + let mut out = String::new(); + for c in s.chars() { + unites += c.len_utf16(); + if unites > n { + break; + } + out.push(c); + } + out +} + +/// Purge les entrées expirées (le volume reste faible, un balayage suffit). +fn purge(jobs: &mut HashMap) { + CACHE.lock().retain(|_, (at, _)| at.elapsed() <= CACHE_TTL); + jobs.retain(|_, j| { + let j = j.lock(); + !(j.status != StatutRecherche::Running && j.termine.is_some_and(|t| t.elapsed() > JOB_TTL)) + }); +} + +fn jobs_en_cours(jobs: &HashMap) -> usize { + jobs.values().filter(|j| j.lock().status == StatutRecherche::Running).count() +} + +fn job_termine(terme: &str, result: Arc) -> RechercheJob { + let maintenant = maintenant_iso(); + RechercheJob { + job_id: format!("cache_{}", cache_key(terme)), + terme: terme.to_string(), + status: StatutRecherche::Success, + etape: "Terminé".into(), + started_at: maintenant.clone(), + finished_at: Some(maintenant), + error: None, + result: Some(result), + termine: Some(Instant::now()), + } +} + +/// Démarre (ou réutilise) une recherche et rend la main **immédiatement**. +/// +/// - résultat déjà en cache et pas de `force` → job `success` directement ; +/// - recherche déjà en cours sur le même terme → on renvoie ce job ; +/// - sinon on lance le travail en tâche de fond. +pub fn demarrer_recherche(term: &str, force: bool) -> RechercheJob { + let cleaned = js_trim(term).to_string(); + let key = cache_key(&cleaned); + // Verrou tenu de bout en bout : la vérification « déjà en cours ? » et + // l'enregistrement du nouveau job doivent être atomiques (le TS, mono-thread, + // l'était par construction). + let mut jobs = JOBS.lock(); + purge(&mut jobs); + + if !force { + let hit = CACHE.lock().get(&key).map(|(_, r)| r.clone()); + if let Some(result) = hit { + tracing::info!("[produits/search] \"{cleaned}\" — servi depuis le cache mémoire"); + return job_termine(&cleaned, result); + } + } + + if let Some(existant) = jobs.get(&key) { + let j = existant.lock(); + if j.status == StatutRecherche::Running { + return j.clone(); + } + if !force && j.status == StatutRecherche::Success && j.result.is_some() { + return j.clone(); + } + } + + if jobs_en_cours(&jobs) >= MAX_RECHERCHES_SIMULTANEES { + let maintenant = maintenant_iso(); + return RechercheJob { + job_id: format!("refus_{}", horodatage_base36()), + terme: cleaned, + status: StatutRecherche::Error, + etape: "Refusé".into(), + started_at: maintenant.clone(), + finished_at: Some(maintenant), + error: Some("Trop de recherches Qlik en cours. Réessayez dans quelques instants.".into()), + result: None, + termine: Some(Instant::now()), + }; + } + + let job = RechercheJob { + job_id: format!("rech_{}_{}", horodatage_base36(), alea_base36()), + terme: cleaned.clone(), + status: StatutRecherche::Running, + etape: "Recherche des articles dans Qlik…".into(), + started_at: maintenant_iso(), + finished_at: None, + error: None, + result: None, + termine: None, + }; + let partage: JobPartage = Arc::new(Mutex::new(job.clone())); + jobs.insert(key.clone(), partage.clone()); + drop(jobs); + tracing::info!("[produits/search] job={} démarré pour \"{cleaned}\"", job.job_id); + + lancer(partage, cleaned, key); + job +} + +/// Fire-and-forget : le client suivra l'avancement via [`etat_recherche`]. +/// Filet de sécurité : une panique de la recherche ne doit jamais laisser le +/// job « running » (il bloquerait toutes les recherches suivantes). +fn lancer(job: JobPartage, terme: String, key: String) { + let suivi = job.clone(); + let tache = tokio::spawn(async move { + let etape = suivi.clone(); + executer_recherche(&terme, move |e| etape.lock().etape = e.to_string()).await + }); + tokio::spawn(async move { + match tache.await { + Ok(result) => { + let result = Arc::new(result); + let (job_id, n, duree) = { + let mut j = job.lock(); + j.result = Some(result.clone()); + j.status = StatutRecherche::Success; + j.etape = "Terminé".into(); + j.finished_at = Some(maintenant_iso()); + j.termine = Some(Instant::now()); + (j.job_id.clone(), result.rows.len(), result.duree_ms) + }; + // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un + // repli catalogue (panne Qlik passagère) le figerait 10 minutes + // alors qu'un simple « Relancer » aurait suffi. + if result.source == SourceRecherche::Qlik && result.qlik_error.is_none() { + CACHE.lock().insert(key, (Instant::now(), result)); + } + tracing::info!("[produits/search] job={job_id} terminé — {n} produit(s) en {duree} ms"); + } + Err(e) => { + let message = e.to_string(); + let job_id = { + let mut j = job.lock(); + j.status = StatutRecherche::Error; + j.etape = "Échec".into(); + j.error = Some(message_qlik_lisible(&message)); + j.finished_at = Some(maintenant_iso()); + j.termine = Some(Instant::now()); + j.job_id.clone() + }; + tracing::error!("[produits/search] job={job_id} échec : {}", slice_js(&message, 300)); + } + } + }); +} + +/// État courant de la recherche pour ce terme (pour le polling client). +pub fn etat_recherche(term: &str) -> RechercheEtat { + let cleaned = js_trim(term).to_string(); + let key = cache_key(&cleaned); + let mut jobs = JOBS.lock(); + purge(&mut jobs); + if let Some(job) = jobs.get(&key) { + return RechercheEtat::Job(job.lock().clone()); + } + drop(jobs); + let hit = CACHE.lock().get(&key).map(|(_, r)| r.clone()); + if let Some(result) = hit { + return RechercheEtat::Job(job_termine(&cleaned, result)); + } + RechercheEtat::Idle { terme: cleaned } +} + +/// `a || b` sur des chaînes (une chaîne vide est « fausse »). +fn ou<'a>(a: &'a str, b: &'a str) -> &'a str { + if a.is_empty() { + b + } else { + a + } +} + +async fn executer_recherche(term: &str, on_etape: impl Fn(&str)) -> ProduitRechercheResultat { + let started = Instant::now(); + let duree_ms = || started.elapsed().as_millis() as u64; + + // La recherche catalogue local tourne en parallèle : elle sert à la fois de + // repli si Qlik échoue, et de complément (produits Nancy sans code centrale, + // donc invisibles côté réseau). + let terme_local = term.to_string(); + let local = tokio::spawn(async move { pg_search_produits(&terme_local, LIMITE_RECHERCHE_LOCALE).await }); + let locaux = move || async move { + local.await.unwrap_or_else(|e| { + tracing::error!("[produits/search] recherche locale échouée: {e}"); + Vec::::new() + }) + }; + + on_etape("Recherche des articles dans Qlik…"); + let qlik = match search_qlik_articles(term, Some(QLIK_SEARCH_MAX_RESULTS), None).await { + Ok(q) => q, + Err(e) => { + let message = format!("{e:#}"); + tracing::error!("[produits/search] Qlik indisponible, repli catalogue local: {message}"); + let locaux = locaux().await; + return ProduitRechercheResultat { + rows: locaux.iter().map(row_depuis_catalogue_seul).collect(), + source: SourceRecherche::Db, + qlik_error: Some(message_qlik_lisible(&message)), + tronque: locaux.len() as i64 >= LIMITE_RECHERCHE_LOCALE, + champ_utilise: None, + locaux_hors_reseau: vec![], + duree_ms: duree_ms(), + }; + } + }; + + let codes: Vec = qlik.matches.iter().map(|m| m.code_centrale.clone()).collect(); + if codes.is_empty() { + let locaux = locaux().await; + return ProduitRechercheResultat { + rows: vec![], + source: SourceRecherche::Qlik, + // `avertissement` n'est renseigné que si Qlik a répondu sans appliquer + // le filtre : l'utilisateur doit savoir que ce n'est pas « pas de + // produit » mais « recherche non appliquée ». + qlik_error: qlik.avertissement, + tronque: false, + champ_utilise: qlik.champ_utilise, + locaux_hors_reseau: locaux, + duree_ms: duree_ms(), + }; + } + + // Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik). + // Les extraire dans une seconde session concurrente faisait abandonner le + // moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la + // recherche alors que la sync de la Grille, elle seule sur le serveur, + // fonctionnait. + // + // Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit + // dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche + // produit propose son bouton « Actualiser depuis Qlik ». + on_etape("Rapprochement avec notre catalogue…"); + // Deux clés candidates : la dimension « Article Code » et le champ + // `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et + // une seule joint avec `articles.artcentrale`. + let candidats: Vec = codes + .iter() + .cloned() + .chain(qlik.matches.iter().map(|m| m.code_centrale_alt.clone()).filter(|c| !c.is_empty())) + .collect(); + let (cache_reseau, catalogue, locaux) = tokio::join!( + async { + get_network_metrics_by_code_centrale(&codes).await.unwrap_or_else(|e| { + tracing::error!("[produits/search] lecture du cache réseau échouée: {e:#}"); + HashMap::new() + }) + }, + pg_get_produits_by_code_centrale(&candidats), + locaux(), + ); + + let now = today(); + let periode = qlik.periode.as_deref(); + + // Le cube de recherche utilise encore les master measures annuelles pour + // classer rapidement les résultats. Il ne doit jamais écraser une extraction + // 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec + // leur mensuel. Sans cache complet, les valeurs de découverte restent + // temporaires jusqu'au bouton « Actualiser depuis Qlik ». + let a_persister: Vec = qlik + .matches + .iter() + .map(|m| { + let cache = cache_reseau.get(&m.code_centrale); + let fiable = cache.filter(|c| cache_reseau_complet(Some(c), periode, now)); + NetworkMetric { + code_centrale: m.code_centrale.clone(), + ca_reseau: fiable.map_or(m.ca_reseau, |c| c.ca_reseau), + qte_reseau: fiable.map_or(m.qte_reseau, |c| c.qte_reseau), + nb_magasins_reseau: fiable.map_or(m.nb_magasins_reseau, |c| c.nb_magasins_reseau), + ca_par_magasin_reseau: fiable.map_or(m.ca_par_magasin_reseau, |c| c.ca_par_magasin_reseau), + marge_pct_reseau: fiable.map_or(m.marge_pct_reseau, |c| c.marge_pct_reseau), + periode: match fiable { + Some(c) => c.periode.clone(), + None => qlik.periode.clone(), + }, + qte_by_month: fiable.and_then(|c| c.qte_by_month.clone()), + metrics_by_month: fiable.and_then(|c| c.metrics_by_month.clone()), + libelle_reseau: Some(m.libelle.clone()).filter(|s| !s.is_empty()), + fournisseur_reseau: Some(m.fournisseur.clone()).filter(|s| !s.is_empty()), + } + }) + .collect(); + match upsert_network_metrics(&a_persister).await { + Ok(n) => tracing::info!("[produits/search] {n} ligne(s) réseau mises en cache"), + Err(e) => tracing::error!("[produits/search] mise en cache des mesures échouée: {e:#}"), + } + + let mut rows: Vec = qlik + .matches + .iter() + .map(|m| { + let local = catalogue + .get(&m.code_centrale) + .or_else(|| if m.code_centrale_alt.is_empty() { None } else { catalogue.get(&m.code_centrale_alt) }); + let cache = cache_reseau.get(&m.code_centrale); + let fiable = cache.filter(|c| cache_reseau_complet(Some(c), periode, now)); + let qte_reseau = fiable.map_or(m.qte_reseau, |c| c.qte_reseau); + let ca_reseau = fiable.map_or(m.ca_reseau, |c| c.ca_reseau); + let nb_mag = fiable.map_or(m.nb_magasins_reseau, |c| c.nb_magasins_reseau); + let ca_par_magasin = fiable.map_or(m.ca_par_magasin_reseau, |c| c.ca_par_magasin_reseau); + let marge_pct = fiable.map_or(m.marge_pct_reseau, |c| c.marge_pct_reseau); + ProduitRechercheRow { + code_centrale: m.code_centrale.clone(), + libelle: ou(ou(local.map_or("", |l| l.libelle1.as_str()), &m.libelle), &m.code_centrale).to_string(), + libelle_reseau: m.libelle.clone(), + fournisseur: ou(local.map_or("", |l| l.fournisseur.as_str()), &m.fournisseur).to_string(), + codein: local.map(|l| l.codein.clone()), + nomenclature: local.map(|l| l.nomenclature.clone()).unwrap_or_default(), + stock_local: local.map(|l| if l.stock_total.is_nan() { 0.0 } else { l.stock_total }), + qte_reseau, + ca_reseau, + nb_magasins_reseau: nb_mag, + taux_presence: nb_mag / NB_MAGASINS_RESEAU, + qte_par_magasin_reseau: if nb_mag > 0.0 { qte_reseau / nb_mag } else { 0.0 }, + ca_par_magasin_reseau: ca_par_magasin, + prix_moyen_reseau: if qte_reseau > 0.0 { Some(ca_reseau / qte_reseau) } else { None }, + marge_pct_reseau: normalize_marge_pct(Some(marge_pct)), + // Le mensuel ne vient que du cache : le cube de recherche est agrégé. + qte_by_month: fiable.and_then(|c| c.qte_by_month.clone()), + nb_mag_by_month: fiable.and_then(|c| nb_mag_par_mois(c.metrics_by_month.as_ref())), + periode: qlik.periode.clone(), + } + }) + .collect(); + + // Les plus vendus du réseau d'abord : c'est l'information utile pour + // arbitrer, un produit que le réseau ne vend pas n'intéresse personne. + trier_lignes(&mut rows); + + // Les produits Nancy déjà couverts par un résultat réseau ne doivent pas + // réapparaître dans la liste secondaire. On compare sur les deux clés + // candidates, la jointure pouvant se faire par l'une ou par l'autre. + let mut codes_trouves: HashSet<&str> = codes.iter().map(|c| c.as_str()).collect(); + for m in &qlik.matches { + if !m.code_centrale_alt.is_empty() { + codes_trouves.insert(m.code_centrale_alt.as_str()); + } + } + let locaux_hors_reseau: Vec = + locaux.into_iter().filter(|l| l.code_centrale.is_empty() || !codes_trouves.contains(l.code_centrale.as_str())).collect(); + + // Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format + // de clé différent entre Qlik et `articles.artcentrale`. + if catalogue.is_empty() && !codes.is_empty() { + let echantillon: Vec<&String> = codes.iter().take(3).collect(); + let alt: Vec<&String> = qlik.matches.iter().take(3).map(|m| &m.code_centrale_alt).collect(); + tracing::warn!( + "[produits/search] aucun des {} codes Qlik ne joint avec articles.artcentrale — échantillon Article Code={} article_no_centrale={}", + codes.len(), + serde_json::to_string(&echantillon).unwrap_or_default(), + serde_json::to_string(&alt).unwrap_or_default(), + ); + } + + ProduitRechercheResultat { + rows, + source: SourceRecherche::Qlik, + qlik_error: qlik.avertissement, + tronque: qlik.tronque, + champ_utilise: qlik.champ_utilise, + locaux_hors_reseau, + duree_ms: duree_ms(), + } +} + +/// `rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"))`. +/// +/// Une quantité `NaN` (mesure Qlik absente) rendait le comparateur JS +/// incohérent ; ici elle est classée en dernier pour garder un ordre total +/// (le tri Rust l'exige). +fn trier_lignes(rows: &mut [ProduitRechercheRow]) { + let qte = |r: &ProduitRechercheRow| if r.qte_reseau.is_nan() { f64::NEG_INFINITY } else { r.qte_reseau + 0.0 }; + rows.sort_by(|a, b| qte(b).total_cmp(&qte(a)).then_with(|| compare_fr(&a.libelle, &b.libelle))); +} + +/// Ligne de repli : catalogue Nancy seul, sans données réseau fiables. +fn row_depuis_catalogue_seul(r: &PgProduitSearchRow) -> ProduitRechercheRow { + let qte_reseau = if r.qte_reseau.is_nan() { 0.0 } else { r.qte_reseau }; + let nb_mag = r.nb_magasins_reseau as f64; + ProduitRechercheRow { + code_centrale: r.code_centrale.clone(), + libelle: ou(&r.libelle1, &r.codein).to_string(), + libelle_reseau: String::new(), + fournisseur: r.fournisseur.clone(), + codein: Some(r.codein.clone()), + nomenclature: r.nomenclature.clone(), + stock_local: Some(if r.stock_total.is_nan() { 0.0 } else { r.stock_total }), + qte_reseau, + ca_reseau: 0.0, + nb_magasins_reseau: nb_mag, + taux_presence: nb_mag / NB_MAGASINS_RESEAU, + qte_par_magasin_reseau: if nb_mag > 0.0 { qte_reseau / nb_mag } else { 0.0 }, + ca_par_magasin_reseau: 0.0, + prix_moyen_reseau: None, + marge_pct_reseau: None, + qte_by_month: None, + nb_mag_by_month: None, + periode: None, + } +} + +/// Traduit les pannes Qlik récurrentes en message actionnable. Les autres +/// erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables. +pub fn message_qlik_lisible(message: &str) -> String { + static CODE_15: LazyLock = LazyLock::new(|| regex::Regex::new(r#""code"\s*:\s*15\b"#).expect("regex")); + let lower = message.to_lowercase(); + if lower.contains("out of memory") || lower.contains("not enough memory") || lower.contains("\"code\":6") || lower.contains("\"code\":3002") + { + return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.".into(); + } + if CODE_15.is_match(message) || lower.contains("request aborted") { + return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez.".into(); + } + if lower.contains("identifiants qlik manquants") || lower.contains("qlik_user") { + return "Identifiants Qlik non configurés (Paramètres → Qlik).".into(); + } + if lower.contains("timeout") || lower.contains("econnrefused") || lower.contains("enotfound") { + return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).".into(); + } + slice_js(message, 300) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cache_complet(now: NaiveDate, periode: &str) -> NetworkMetricCached { + NetworkMetricCached { + qte_by_month: Some(build_rolling12_qlik_months(now).into_iter().map(|m| (m, 1.0)).collect()), + periode: Some(periode.into()), + ..Default::default() + } + } + + #[test] + fn cle_de_cache_normalisee() { + assert_eq!(cache_key(" Tapis ANTI\tpoussière "), "tapis anti poussière"); + } + + #[test] + fn cache_reseau_complet_exige_periode_et_12_mois() { + let now = NaiveDate::from_ymd_opt(2026, 3, 10).unwrap(); + let c = cache_complet(now, "2025-03_2026-02"); + assert!(cache_reseau_complet(Some(&c), Some("2025-03_2026-02"), now)); + // Période différente, absente, ou cache absent. + assert!(!cache_reseau_complet(Some(&c), Some("2025-02_2026-01"), now)); + assert!(!cache_reseau_complet(Some(&c), None, now)); + assert!(!cache_reseau_complet(None, Some("2025-03_2026-02"), now)); + // Un mois manquant ou non fini. + let mut trou = c.clone(); + trou.qte_by_month.as_mut().unwrap().remove("2025-07"); + assert!(!cache_reseau_complet(Some(&trou), Some("2025-03_2026-02"), now)); + let mut nan = c.clone(); + nan.qte_by_month.as_mut().unwrap().insert("2025-07".into(), f64::NAN); + assert!(!cache_reseau_complet(Some(&nan), Some("2025-03_2026-02"), now)); + // Sans qteByMonth. + let mut vide = c; + vide.qte_by_month = None; + assert!(!cache_reseau_complet(Some(&vide), Some("2025-03_2026-02"), now)); + } + + #[test] + fn nb_mag_par_mois_zero_si_absent() { + let mut m = BTreeMap::new(); + m.insert("2025-03".to_string(), QlikMonthMetrics { qte: 4.0, nb_mag: Some(3.0), ..Default::default() }); + m.insert("2025-04".to_string(), QlikMonthMetrics { qte: 0.0, nb_mag: None, ..Default::default() }); + let r = nb_mag_par_mois(Some(&m)).unwrap(); + assert_eq!(r["2025-03"], 3.0); + assert_eq!(r["2025-04"], 0.0); + assert_eq!(nb_mag_par_mois(Some(&BTreeMap::new())), None); + assert_eq!(nb_mag_par_mois(None), None); + } + + #[test] + fn messages_qlik_lisibles() { + assert!(message_qlik_lisible("Engine: Out of memory").starts_with("Serveur Qlik saturé")); + assert!(message_qlik_lisible(r#"{"code":3002}"#).starts_with("Serveur Qlik saturé")); + assert!(message_qlik_lisible(r#"{"code": 15, "message":"x"}"#).starts_with("Qlik a interrompu")); + assert!(message_qlik_lisible("Request aborted").starts_with("Qlik a interrompu")); + assert!(message_qlik_lisible("Identifiants Qlik manquants").starts_with("Identifiants Qlik non configurés")); + assert!(message_qlik_lisible("connect ECONNREFUSED 10.0.0.1").starts_with("Serveur Qlik injoignable")); + // `"code":150` n'est pas le code 15. + assert_eq!(message_qlik_lisible(r#"{"code":150}"#), r#"{"code":150}"#); + let long = "é".repeat(400); + assert_eq!(message_qlik_lisible(&long).chars().count(), 300); + } + + #[test] + fn tri_par_quantite_reseau_puis_libelle() { + let ligne = |libelle: &str, qte: f64| ProduitRechercheRow { libelle: libelle.into(), qte_reseau: qte, ..Default::default() }; + let mut rows = vec![ligne("b", 5.0), ligne("Éa", 10.0), ligne("a", 10.0), ligne("z", f64::NAN), ligne("c", 0.0)]; + trier_lignes(&mut rows); + let ordre: Vec<&str> = rows.iter().map(|r| r.libelle.as_str()).collect(); + assert_eq!(ordre, vec!["a", "Éa", "b", "c", "z"]); + } + + #[test] + fn ligne_catalogue_seul() { + let r = PgProduitSearchRow { + codein: "123".into(), + code_centrale: "10000167303".into(), + libelle1: String::new(), + fournisseur: "ACME".into(), + nomenclature: "Cuisine".into(), + stock_total: 7.0, + qte_reseau: 54.0, + nb_magasins_reseau: 27, + ..Default::default() + }; + let v = serde_json::to_value(row_depuis_catalogue_seul(&r)).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "codeCentrale": "10000167303", + "libelle": "123", + "libelleReseau": "", + "fournisseur": "ACME", + "codein": "123", + "nomenclature": "Cuisine", + "stockLocal": 7, + "qteReseau": 54, + "caReseau": 0, + "nbMagasinsReseau": 27, + "tauxPresence": 0.1, + "qteParMagasinReseau": 2, + "caParMagasinReseau": 0, + "prixMoyenReseau": null, + "margePctReseau": null, + "qteByMonth": null, + "nbMagByMonth": null, + "periode": null, + }) + ); + } + + #[test] + fn etat_idle_et_job_serialises() { + let idle = RechercheEtat::Idle { terme: "poêle".into() }.reponse(); + assert_eq!(idle, serde_json::json!({ "success": true, "status": "idle", "terme": "poêle" })); + + let job = RechercheJob { + job_id: "rech_x".into(), + terme: "poêle".into(), + status: StatutRecherche::Running, + etape: "Recherche des articles dans Qlik…".into(), + started_at: "2026-01-01T00:00:00.000Z".into(), + finished_at: None, + error: None, + result: None, + termine: None, + }; + let v = RechercheEtat::Job(job).reponse(); + assert_eq!( + v, + serde_json::json!({ + "success": true, + "jobId": "rech_x", + "terme": "poêle", + "status": "running", + "etape": "Recherche des articles dans Qlik…", + "startedAt": "2026-01-01T00:00:00.000Z", + }) + ); + } + + #[test] + fn base36_comme_js() { + assert_eq!(base36(0), "0"); + assert_eq!(base36(35), "z"); + assert_eq!(base36(36), "10"); + assert_eq!(alea_base36().len(), 6); + } +} diff --git a/server/src/produits/types.rs b/server/src/produits/types.rs new file mode 100644 index 0000000..c499080 --- /dev/null +++ b/server/src/produits/types.rs @@ -0,0 +1,169 @@ +//! CollectFlow — Types de la fiche produit (page /produits) — port de +//! `src/features/produits/types.ts`. +//! +//! Les formes JSON doivent rester **identiques** à la copie client +//! (`src/features/produits/types.ts`) : noms de champs camelCase, `null` +//! explicites là où le TS déclare `T | null`. + +use crate::ff::api::DateRange; +use crate::ff::pg::{PgGammeHistoryRow, PgProduitDetailRow, PgProduitFournisseurRow, PgProduitSearchRow, PgProduitStockRow}; +use crate::qlik::jsnum; +use crate::qlik::NetworkMetricCached; +use serde::Serialize; +use std::collections::BTreeMap; + +/// Une ligne de résultat de recherche produit. +/// +/// La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau +/// (identifié par son code centrale), éventuellement rapproché du catalogue FF +/// Nancy. `codein === null` = le réseau travaille ce produit, nous ne le +/// référençons pas. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitRechercheRow { + /// Dimension Qlik « Article Code » = `articles.artcentrale`. + pub code_centrale: String, + /// Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. + pub libelle: String, + /// Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). + pub libelle_reseau: String, + /// Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. + pub fournisseur: String, + /// `null` si le produit n'est pas au catalogue Nancy. + pub codein: Option, + pub nomenclature: String, + /// Stock Nancy tous sites, `null` si produit non référencé. + #[serde(serialize_with = "jsnum::ser_opt")] + pub stock_local: Option, + + // ─── Réseau Qlik, sur 12 mois glissants ───────────────────────────────── + #[serde(serialize_with = "jsnum::ser")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser")] + pub nb_magasins_reseau: f64, + /// Part des magasins du réseau travaillant le produit (0..1). + #[serde(serialize_with = "jsnum::ser")] + pub taux_presence: f64, + /// Quantité vendue par magasin qui travaille le produit. + #[serde(serialize_with = "jsnum::ser")] + pub qte_par_magasin_reseau: f64, + #[serde(serialize_with = "jsnum::ser")] + pub ca_par_magasin_reseau: f64, + /// CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. + #[serde(serialize_with = "jsnum::ser_opt")] + pub prix_moyen_reseau: Option, + /// Taux de marge réseau en points de % (déjà normalisé), `null` si absent. + #[serde(serialize_with = "jsnum::ser_opt")] + pub marge_pct_reseau: Option, + /// Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. + #[serde(serialize_with = "jsnum::ser_map")] + pub qte_by_month: Option>, + /// Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la + /// qté/magasin : sans cette série, elle se rabat sur les quantités brutes. + #[serde(serialize_with = "jsnum::ser_map")] + pub nb_mag_by_month: Option>, + pub periode: Option, +} + +/// Source d'un résultat de recherche. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SourceRecherche { + /// Recherche nominale. + #[default] + Qlik, + /// Repli catalogue local (Qlik indisponible). + Db, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitRechercheResultat { + pub rows: Vec, + /// `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). + pub source: SourceRecherche, + /// Message d'erreur Qlik quand on a dû se rabattre sur la base locale. + pub qlik_error: Option, + /// true si Qlik a renvoyé plus d'articles que la limite affichée. + pub tronque: bool, + /// Champ Qlik réellement interrogé (diagnostic). + pub champ_utilise: Option, + /// Produits du catalogue Nancy qui matchent le terme mais qu'aucun article + /// Qlik ne couvre (pas de code centrale, ou non vendus par le réseau). + pub locaux_hors_reseau: Vec, + pub duree_ms: u64, +} + +/// Les métriques d'un mois, pour un périmètre donné (total ou un site). +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitMois { + /// Clé de mois FF, format "YYYYMM". + pub mois: String, + #[serde(serialize_with = "jsnum::ser")] + pub qte: f64, + /// Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. + #[serde(serialize_with = "jsnum::ser")] + pub ca: f64, + #[serde(serialize_with = "jsnum::ser")] + pub marge: f64, + #[serde(serialize_with = "jsnum::ser")] + pub stock_fin_mois: f64, + #[serde(serialize_with = "jsnum::ser")] + pub qte_recue: f64, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitTotaux { + #[serde(serialize_with = "jsnum::ser")] + pub qte: f64, + #[serde(serialize_with = "jsnum::ser")] + pub ca: f64, + #[serde(serialize_with = "jsnum::ser")] + pub marge: f64, + /// Taux de marge en % (marge / CA × 100). + #[serde(serialize_with = "jsnum::ser")] + pub taux_marge: f64, +} + +/// Série mensuelle d'un périmètre : mois "YYYYMM" → métriques. +/// +/// `BTreeMap` : les clés "YYYYMM" sont des indices de tableau pour JS, qui les +/// énumère par ordre croissant — même ordre que le tri lexicographique ici. +pub type SerieMensuelle = BTreeMap; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitFiche { + /// Identité catalogue FF Nancy — `null` pour un produit que le réseau + /// travaille mais que Nancy ne référence pas (fiche 100 % réseau). + pub detail: Option, + /// Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. + pub code_centrale: String, + /// Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). + pub libelle_reseau: String, + /// Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). + pub fournisseur_reseau: String, + pub fournisseurs: Vec, + pub stock: Vec, + #[serde(serialize_with = "jsnum::ser")] + pub commandes_en_cours: f64, + pub gammes: Vec, + /// Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. + pub months: Vec, + /// Cumul tous sites, par mois. + pub mensuel_total: SerieMensuelle, + /// site → mois → métriques. + pub mensuel_par_site: BTreeMap, + pub totaux: ProduitTotaux, + pub totaux_par_site: BTreeMap, + /// Sites ayant vendu au moins une unité sur les 12 mois. + pub sites_actifs: Vec, + /// Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. + pub reseau: Option, + /// Fenêtre analysée, pour l'affichage. + pub periode: DateRange, +} From 1dce73afaa5531eac7f5d8038889315a1bfc356b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:57:50 +0000 Subject: [PATCH 17/19] =?UTF-8?q?Synchro=20nocturne=20et=20pr=C3=A9chauffa?= =?UTF-8?q?ge=20de=20la=20grille=20branch=C3=A9s=20(/api/admin/sync,=20/ap?= =?UTF-8?q?i/admin/grid-warmup,=20minuteur)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/admin/mod.rs | 9 ++++++++- server/src/auth/mod.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/server/src/admin/mod.rs b/server/src/admin/mod.rs index 84e7e96..ceae0da 100644 --- a/server/src/admin/mod.rs +++ b/server/src/admin/mod.rs @@ -1,10 +1,13 @@ //! Module `admin` — comptes, clés d'API, Paramètres, synchronisation nocturne. pub mod api_keys; +pub mod grid_warmup; pub mod logs; mod qlik_bridge; pub mod settings; pub mod sync_fournisseurs; +pub mod sync_routes; +pub mod sync_scheduler; pub mod sync_settings; pub mod users; @@ -19,6 +22,8 @@ use serde_json::{Map, Value}; pub fn router() -> Router { Router::new() .route("/api/logs", get(logs::get_logs)) + .route("/api/admin/sync", get(sync_routes::get).patch(sync_routes::patch).post(sync_routes::post)) + .route("/api/admin/grid-warmup", get(grid_warmup::get).post(grid_warmup::post)) .route("/api/admin/sync/fournisseurs", get(sync_fournisseurs::get).patch(sync_fournisseurs::patch)) } @@ -62,4 +67,6 @@ pub fn pages() -> Vec<(&'static str, Handler)> { } /// Tâches de fond lancées au démarrage (minuteur de la synchro nocturne). -pub fn start_background_tasks() {} +pub fn start_background_tasks() { + sync_scheduler::start_sync_timer(); +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index a1da3d4..ff802da 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -6,7 +6,7 @@ pub mod fallback; pub mod password; pub mod session; -pub use api_key::{ApiAuth, AuthContext}; +pub use api_key::ApiAuth; pub use authz::{require_admin, require_session, AdminUser, SessionUser, UtilisateurCourant}; pub use session::{Claims, Token}; From d28b3f12c734639f0ff09112699b19183646ee74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:01:23 +0000 Subject: [PATCH 18/19] =?UTF-8?q?Serveur=20:=20avertissements=20de=20code?= =?UTF-8?q?=20mort=20neutralis=C3=A9s=20(utilitaires=20port=C3=A9s=20sans?= =?UTF-8?q?=20appelant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/main.rs b/server/src/main.rs index 7e7f1cc..092a982 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,4 +1,7 @@ +// Les modules portés gardent quelques utilitaires et types de l'ancienne +// version sans appelant (déjà inutilisés côté TypeScript) : on ne les signale pas. #![allow(dead_code)] + //! CollectFlow — serveur. //! //! Un binaire unique : API JSON (interface + `/api/v1` publique), accès à la From 31303a078a6a89152b9feef9617d631e2385b428 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:03:04 +0000 Subject: [PATCH 19/19] =?UTF-8?q?Documentation=20:=20structure=20de=20l'in?= =?UTF-8?q?terface=20et=20contrat=20du=20serveur=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- README.md | 2 +- server/PORTING.md | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8911a72..c8bd84a 100644 --- a/README.md +++ b/README.md @@ -57,5 +57,5 @@ Vérifications : `cargo test` (dans `server/`), `npm run lint`, `npm run build`. - `server/src/` — serveur Rust, un module par domaine : `auth` (sessions, clés d'API), `ff` (base miroir et API FF Nancy), `qlik` (données réseau), `grid` (révision d'assortiment, API `/api/v1`), `produits`, `admin` (comptes, paramètres, synchronisation nocturne). - `server/sql/init.sql` — schéma joué (idempotent) à chaque démarrage. -- `src/` — interface React : `features/*` par domaine, `components/` partagés, `pages/` et `router.tsx`. +- `src/` — interface React : `app/` (écrans, mêmes dossiers que les URL), `features/*` par domaine, `components/` partagés, `router.tsx` (table des routes), `lib/rpc.ts` (appels au serveur). - `server/PORTING.md` — contrat entre l'interface et le serveur (actions, données de pages, routes). diff --git a/server/PORTING.md b/server/PORTING.md index 0b40e75..12c1f3d 100644 --- a/server/PORTING.md +++ b/server/PORTING.md @@ -1,11 +1,12 @@ -# Portage Rust — conventions de travail +# Serveur Rust — contrat avec l'interface et conventions Ce document fixe le contrat entre le serveur Rust (`server/`) et l'interface -React (`src/`, compilée en SPA par Vite). Le code TypeScript d'origine (Next.js) -reste la **référence fonctionnelle** : chaque module Rust en est la traduction -fidèle (mêmes requêtes SQL, mêmes règles, mêmes messages, mêmes formes JSON). +React (`src/`, compilée en SPA par Vite). Le serveur est la traduction fidèle de +l'ancienne version Next.js (mêmes requêtes SQL, mêmes règles, mêmes messages, +mêmes formes JSON) ; son code TypeScript reste consultable dans l'historique +git (`git show d2c3dc7:src/...`, dernier état avant la réécriture). -## Socle déjà en place (ne pas réécrire) +## Socle commun | Besoin | Rust | |---|---|