Compare commits

..
Author SHA1 Message Date
Claude 9be48b111c Qlik : extraction coupée à 30 s par chromiumoxide (« Request timed out »)
Depuis le passage à Rust, toutes les extractions Qlik échouaient au bout
d'environ 32 s : chromiumoxide abandonne toute commande CDP après 30 s,
constante codée en dur dans CommandFuture, quel que soit request_timeout.
L'extraction tenait dans un seul Runtime.evaluate attendant la promesse.

Le script est désormais lancé sans être attendu, son résultat rangé dans
window, puis relevé toutes les 500 ms par des évaluations courtes. Les
erreurs in-page (pile comprise) et la perte du contexte remontent comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjmvaJ5VTadoufBQ8a57no
2026-10-14 14:08:52 +00:00
LogiFlow a26f9ebee3 Merge pull request #29 from R0m1k3/claude/project-thread-m5x8ia
Synchro Qlik : message d'échec visible et journal des extractions de nuit
2026-10-14 15:52:53 +02:00
Claude c98c71dc92 Synchro Qlik : message d'échec visible et journal des extractions de nuit
La page Synchronisation n'affichait qu'un badge « Échec » alors que l'erreur
est enregistrée par fournisseur : elle apparaît désormais dans la colonne
Remarque. Sur la Grille, l'erreur du bouton réseau reste affichée jusqu'à la
tentative suivante (elle s'effaçait après 4 s et était masquée hors très
grands écrans). L'extraction Qlik de nuit ouvre un journal téléchargeable
dans Paramètres, comme la synchro manuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjmvaJ5VTadoufBQ8a57no
2026-10-14 13:51:35 +00:00
LogiFlow a80a6e724c Merge pull request #28 from R0m1k3/claude/busy-brown-0qxuyz
Fournisseur : repli quand aucun fournisseur n'est marqué principal
2026-10-13 00:17:37 +02:00
Claude 26123783eb Fournisseur : repli sur un fournisseur référencé quand aucun n'est marqué principal
Le Hit Parade, la recherche produit et les opportunités n'acceptaient que
artfou1.preference = 1 : un article dont la préférence n'est pas renseignée
dans FF (ou posée sur une ancienne fiche du même codein) s'affichait en
« Sans fournisseur ». On prend désormais le principal s'il existe, sinon le
fournisseur référencé le plus récent ; le Hit Parade cherche sur toutes les
fiches du codein.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WT49YnjumAVBjCB4EUXNL1
2026-10-12 22:08:34 +00:00
LogiFlow 02e258bf12 Merge pull request #27 from R0m1k3/claude/vigilant-goldberg-0pqg5x
Réécriture du serveur en Rust + image Docker mise à jour au démarrage
2026-10-12 23:28:51 +02:00
7 changed files with 125 additions and 36 deletions

No files matched your search

+9
View File
@@ -260,6 +260,10 @@ async fn traiter_qlik(code: &str, nom: &Option<String>) {
e.fournisseur_courant = Some(libelle(code, nom));
e.etape = Some("qlik");
}
// Journal téléchargeable depuis Paramètres, comme la synchro manuelle : la
// sortie du conteneur ne garde pas les milliers de lignes d'une extraction.
let journal = format!("sync-nuit_qlik_{}_{}", code, Local::now().format("%Y%m%d-%H%M%S"));
crate::logcapture::start_capture(&journal, Some(&format!("synchro de nuit Qlik fournisseur={}", libelle(code, nom))));
let res: anyhow::Result<Option<usize>> = async {
let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?;
let mut vus = HashSet::new();
@@ -276,6 +280,11 @@ async fn traiter_qlik(code: &str, nom: &Option<String>) {
Ok(Some(n))
}
.await;
let pied = match &res {
Ok(_) => "statut final=success".to_string(),
Err(e) => format!("statut final=error — {e:#}"),
};
crate::logcapture::stop_capture(&journal, Some(&pied));
let ms = t0.elapsed().as_millis() as i32;
let Ok(pool) = crate::db::pool() else { return };
match res {
+27 -13
View File
@@ -1226,6 +1226,13 @@ pub struct HitParadeRow {
/// mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que
/// par article vendu (et non par ligne de mouvement).
///
/// Fournisseur affiché : le principal (`artfou1.preference = 1`) s'il existe,
/// sinon n'importe quel fournisseur référencé — cherché sur toutes les fiches
/// portant le même codein (ré-créations d'article), la plus récente d'abord.
/// Exiger `preference = 1` laissait en « Sans fournisseur » les articles dont
/// la préférence n'est pas renseignée dans FF ou n'est posée que sur une
/// ancienne fiche du même codein.
///
/// 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.
@@ -1247,6 +1254,19 @@ pub async fn pg_get_hit_parade(date_debut: &str, date_fin: &str) -> anyhow::Resu
AND a.codein IS NOT NULL
GROUP BY TRIM(a.codein::text), m.site
),
fou AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
TRIM(a.codein::text) AS codein,
af1.code,
af1.reference
FROM articles a
JOIN artfou1 af1 ON af1.art_no_id = a.no_id
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
ORDER BY TRIM(a.codein::text),
(af1.preference = 1) DESC NULLS LAST,
a.no_id DESC,
af1.no_id DESC
),
attrs AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
TRIM(a.codein::text) AS codein,
@@ -1256,13 +1276,7 @@ pub async fn pg_get_hit_parade(date_debut: &str, date_fin: &str) -> anyhow::Resu
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 fou af ON af.codein = TRIM(a.codein::text)
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)
@@ -2253,8 +2267,8 @@ pub async fn pg_search_produits(term: &str, limit: i64) -> Vec<PgProduitSearchRo
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
WHERE af1.art_no_id = m.no_id
ORDER BY (af1.preference = 1) DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
@@ -2339,8 +2353,8 @@ pub async fn pg_get_produits_by_code_centrale(codes_centraux: &[String]) -> Hash
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
WHERE af1.art_no_id = m.no_id
ORDER BY (af1.preference = 1) DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
@@ -2907,8 +2921,8 @@ pub async fn pg_get_opportunites_famille(nom_no_id: i64, date_debut: &str, date_
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
WHERE af1.art_no_id = r.no_id
ORDER BY (af1.preference = 1) DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
+46 -12
View File
@@ -67,9 +67,9 @@ async fn lancer() -> anyhow::Result<Navigateur> {
.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).
// Délai des commandes internes du gestionnaire CDP. Il ne couvre PAS
// `page.execute`, borné à 30 s par chromiumoxide : les évaluations
// longues passent par un relevé périodique (cf. `PageQlik::evaluer`).
.request_timeout(Duration::from_secs(24 * 3600));
if let Some(chemin) = chemin_chromium() {
builder = builder.chrome_executable(chemin);
@@ -312,14 +312,52 @@ impl PageQlik {
/// `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).
///
/// ⚠️ chromiumoxide abandonne toute commande CDP au bout de 30 s
/// (« Request timed out. »), quel que soit `request_timeout` : la constante
/// est codée en dur dans `CommandFuture`. Une extraction Qlik dure plusieurs
/// minutes : attendre la promesse dans un seul `Runtime.evaluate` la faisait
/// échouer à coup sûr. Le script est donc lancé sans être attendu, son
/// résultat rangé dans `window`, et relevé par des évaluations courtes.
pub async fn evaluer(&self, source: &str, args: &Value) -> anyhow::Result<Value> {
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)?
static EVALUATIONS: AtomicUsize = AtomicUsize::new(0);
let cle = serde_json::to_string(&format!("__cf_eval_{}", EVALUATIONS.fetch_add(1, Ordering::SeqCst)))?;
let lancement = format!(
"(() => {{ const __k = {cle}; window[__k] = {{ fini: false }}; \
(async () => {{ const __f = (\n{source}\n); const __r = await __f({args}); return JSON.stringify(__r === undefined ? null : __r); }})()\
.then((json) => {{ window[__k] = {{ fini: true, json }}; }}, \
(e) => {{ window[__k] = {{ fini: true, erreur: (e && e.stack) ? String(e.stack) : String(e) }}; }}); \
return true; }})()",
args = serde_json::to_string(args)?
);
self.evaluer_court(lancement).await?;
let releve = format!(
"(() => {{ const __k = {cle}; const e = window[__k]; \
if (e === undefined) return JSON.stringify({{ perdu: true }}); \
if (!e.fini) return null; delete window[__k]; return JSON.stringify(e); }})()"
);
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
let Some(Value::String(etat)) = self.evaluer_court(releve.clone()).await? else { continue };
let etat: Value = serde_json::from_str(&etat)?;
if etat.get("perdu").is_some() {
return Err(anyhow!("Execution context was destroyed (page rechargée pendant l'évaluation)"));
}
if let Some(erreur) = etat.get("erreur").and_then(|e| e.as_str()) {
return Err(anyhow!("{erreur}"));
}
return match etat.get("json").and_then(|j| j.as_str()) {
Some(json) => Ok(serde_json::from_str(json)?),
None => Ok(Value::Null),
};
}
}
/// Un `Runtime.evaluate` qui rend la main aussitôt (valeur JSON brute).
async fn evaluer_court(&self, expression: String) -> anyhow::Result<Option<Value>> {
let params = EvaluateParams::builder()
.expression(expression)
.await_promise(true)
.return_by_value(true)
.build()
.map_err(|e| anyhow!("[qlik] paramètres d'évaluation : {e}"))?;
@@ -328,11 +366,7 @@ impl PageQlik {
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),
}
Ok(res.result.result.value.clone())
}
/// Ferme la page puis le contexte (`ctx.close()`).
+14
View File
@@ -95,6 +95,20 @@ async fn plomberie_cdp() {
let err = page.evaluer("() => { throw new Error('boum'); }", &json!(null)).await.unwrap_err().to_string();
assert!(err.contains("boum"), "{err}");
// Une extraction dure plusieurs minutes : l'évaluation doit dépasser les
// 30 s après lesquelles chromiumoxide abandonne une commande CDP.
let r = page
.evaluer("async ({ ms }) => { await new Promise((r) => setTimeout(r, ms)); return { ok: true, ms }; }", &json!({ "ms": 35_000 }))
.await
.expect("évaluation de 35 s");
assert_eq!(r, json!({ "ok": true, "ms": 35000 }));
let err = page
.evaluer("async () => { await new Promise((r) => setTimeout(r, 50)); throw new Error('boum asynchrone'); }", &json!(null))
.await
.unwrap_err()
.to_string();
assert!(err.contains("boum asynchrone"), "{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!({
@@ -50,9 +50,11 @@ interface FournisseurRow {
dernierSqlStatut: string | null;
dernierSqlLignes: number | null;
dernierSqlMs: number | null;
dernierSqlErreur: string | null;
dernierQlikAt: string | null;
dernierQlikStatut: string | null;
dernierQlikCodes: number | null;
dernierQlikErreur: string | null;
}
interface Resume {
@@ -357,9 +359,23 @@ export function SynchronisationClient() {
{
id: "remarque",
header: "Remarque",
cell: (r) => (r.desactiveMotif
? <span className="text-[13px] text-[var(--text-secondary)]">{r.desactiveMotif}</span>
: null),
// Le badge « Échec » seul ne dit pas quoi corriger : on affiche le
// message enregistré par la dernière tentative.
cell: (r) => {
const erreurs = [
r.dernierSqlStatut === "echec" && r.dernierSqlErreur ? `${VENTES} : ${r.dernierSqlErreur}` : null,
r.dernierQlikStatut === "echec" && r.dernierQlikErreur ? `${RESEAU} : ${r.dernierQlikErreur}` : null,
].filter((e): e is string => e !== null);
if (!r.desactiveMotif && erreurs.length === 0) return null;
return (
<div className="max-w-[480px] space-y-1 text-[13px]">
{r.desactiveMotif && <div className="text-[var(--text-secondary)]">{r.desactiveMotif}</div>}
{erreurs.map((e) => (
<div key={e} className="break-words text-[var(--accent-error)]">{e}</div>
))}
</div>
);
},
},
];
@@ -36,7 +36,7 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
return (
<div className="flex items-center gap-2">
<span
className="text-xs leading-tight text-right whitespace-nowrap hidden xl:block"
className={`text-xs leading-tight text-right ${status === "error" ? "max-w-[420px] line-clamp-2" : "whitespace-nowrap hidden xl:block"}`}
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message || undefined}
>
+9 -7
View File
@@ -110,9 +110,10 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
if (data.status === "success" || data.status === "error") {
trackingRef.current = false;
if (data.status === "success") onSuccessRef.current?.();
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
// Le succès s'efface après un court délai ; une erreur reste
// affichée jusqu'à la prochaine tentative, sinon elle passe inaperçue.
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
setStatus((cur) => (cur === "success" ? "idle" : cur));
}, 4000);
return false;
}
@@ -159,10 +160,10 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
applyJobToUi(data);
startPolling();
} else if (data.status === "success" || data.status === "error") {
// Job terminé avant le montage : on l'affiche brièvement.
// Job terminé avant le montage : succès affiché brièvement, erreur conservée.
applyJobToUi(data);
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
setStatus((cur) => (cur === "success" ? "idle" : cur));
}, 4000);
}
} catch {
@@ -208,12 +209,13 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
}
// Statut terminal direct (cas rare : 0 code → success immédiat).
applyJobToUi(data);
if (data.status === "success") onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
if (data.status === "success") {
onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
}
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
}, [applyJobToUi, query, startPolling, status, target.mode]);