Files
LogiFlow/server-rs/src/services/invoice_verification.rs
T
LogiFlowandClaude Opus 5.5 600627c632 feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)
- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API,
  l'interface, les migrations de démarrage et les tâches de fond ; même
  comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés
  USERS-02, RAPPRO-21, R4, R5).
- Node retiré : server/, dépendances serveur, image Docker sans Node
  (51 Mo), docker-compose à un seul service applicatif.
- R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin).
- R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris.
- R6 : mainteneur dans .github/CODEOWNERS.
- Menu latéral : Accueil puis Calendrier en tête.
- Plan et état : docs/PLAN-MIGRATION-RUST.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
2026-10-15 20:31:14 +02:00

1060 lines
39 KiB
Rust

// Vérification des factures dans NocoDB, avec son cache (server/invoiceVerification.ts).
//
// - verify_invoice : recherche par référence de facture (colonne du magasin, à
// défaut « RefFacture »), puis, si le magasin a une colonne de BL, par numéro de BL ;
// - verify_invoice_by_bl : recherche par numéro de BL, le fournisseur trouvé devant
// contenir le nom attendu (ou l'inverse) ;
// - cache dans invoice_verification_cache, mêmes clés et mêmes durées que Node :
// facture trouvée 6 h, non trouvée ou en erreur 12 h, livraison validée 50 ans,
// recherche par BL 24 h ; une entrée expirée est supprimée à sa lecture ;
// - update_cache_as_reconciled / update_existing_reconciled_caches : marquage
// « rapproché » d'une entrée, ou de toutes celles des livraisons validées.
//
// Comportement de Node en production (NODE_ENV=production), le seul porté : la
// simulation du mode développement (références contenant « test » ou « bl ») ne
// l'est pas. Toute erreur devient un résultat { exists: false, errorMessage },
// jamais une erreur HTTP.
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
use serde::ser::{Serialize, SerializeMap, Serializer};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::postgres::PgArguments;
use crate::AppState;
use crate::db::Table;
use crate::js;
use crate::schema::{GroupsRow, InvoiceVerificationCacheRow};
use crate::services::nocodb::{self, Search, truthy};
/// Table NocoDB lue quand le magasin n'en précise pas (repli codé en dur dans Node)
pub const FALLBACK_TABLE_ID: &str = "mrr733dfb8wtt9b";
pub const MATCH_INVOICE_REFERENCE: &str = "invoice_reference";
pub const MATCH_BL_NUMBER: &str = "bl_number";
pub const MATCH_NONE: &str = "none";
// ---------- Résultat ----------
/// Champ d'un objet JavaScript : absent (undefined, omis du JSON), null, ou une valeur
#[derive(Debug, Clone, PartialEq, Default)]
pub enum JsField<T> {
#[default]
Absent,
Null,
Set(T),
}
impl<T> JsField<T> {
pub fn get(&self) -> Option<&T> {
match self {
JsField::Set(value) => Some(value),
_ => None,
}
}
}
/// Résultat de verifyInvoice / verifyInvoiceByBL, sérialisé comme Node le renvoie.
/// Les champs lus dans NocoDB (référence, fournisseur, échéance) gardent leur
/// valeur JSON d'origine ; les montants sont des nombres JavaScript (NaN possible).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Verification {
pub exists: bool,
/// invoice_reference, bl_number ou none (texte lu en cache sinon)
pub match_type: String,
pub error_message: JsField<String>,
pub invoice_reference: JsField<Value>,
pub invoice_amount: JsField<f64>,
pub invoice_amount_ttc: JsField<f64>,
pub supplier_name: JsField<Value>,
pub due_date: JsField<Value>,
/// Présent (true) quand le résultat vient du cache
pub from_cache: Option<bool>,
/// Présent pour un résultat du cache par référence : entrée « rapprochée »
pub permanent: Option<bool>,
}
impl Verification {
/// { exists: false, matchType: 'none', errorMessage }
pub fn failure(message: impl Into<String>) -> Self {
Self {
exists: false,
match_type: MATCH_NONE.to_string(),
error_message: JsField::Set(message.into()),
..Default::default()
}
}
/// normalizeDateString(result.dueDate) : échéance AAAA-MM-JJ, si c'est une chaîne reconnue
pub fn normalized_due_date(&self) -> Option<String> {
self.due_date
.get()
.filter(|value| truthy(value))
.and_then(crate::services::dates::normalize_date_value)
}
}
impl Serialize for Verification {
/// Ordre des clés de Node : celui du cache (erreur, référence, fournisseur,
/// montants, échéance, fromCache, permanent) ou celui d'une recherche (référence,
/// montants, fournisseur, échéance)
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
fn put<M: SerializeMap, T, F: Fn(&T) -> Value>(
map: &mut M,
key: &str,
field: &JsField<T>,
to_json: F,
) -> Result<(), M::Error> {
match field {
JsField::Absent => Ok(()),
JsField::Null => map.serialize_entry(key, &Value::Null),
JsField::Set(value) => map.serialize_entry(key, &to_json(value)),
}
}
let number = |n: &f64| {
if n.is_finite() {
js::number_value(*n)
} else {
Value::Null
}
};
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("exists", &self.exists)?;
map.serialize_entry("matchType", &self.match_type)?;
put(&mut map, "errorMessage", &self.error_message, |m| {
Value::String(m.clone())
})?;
put(
&mut map,
"invoiceReference",
&self.invoice_reference,
Value::clone,
)?;
if self.from_cache.is_some() {
put(&mut map, "supplierName", &self.supplier_name, Value::clone)?;
put(&mut map, "invoiceAmount", &self.invoice_amount, number)?;
put(&mut map, "invoiceAmountTTC", &self.invoice_amount_ttc, number)?;
put(&mut map, "dueDate", &self.due_date, Value::clone)?;
} else {
put(&mut map, "invoiceAmount", &self.invoice_amount, number)?;
put(&mut map, "invoiceAmountTTC", &self.invoice_amount_ttc, number)?;
put(&mut map, "supplierName", &self.supplier_name, Value::clone)?;
put(&mut map, "dueDate", &self.due_date, Value::clone)?;
}
if let Some(from_cache) = self.from_cache {
map.serialize_entry("fromCache", &from_cache)?;
}
if let Some(permanent) = self.permanent {
map.serialize_entry("permanent", &permanent)?;
}
map.end()
}
}
// ---------- Clés et durées du cache ----------
/// generateCacheKey : `${groupId}_${référence.trim().toLowerCase()}`
pub fn cache_key(invoice_reference: &str, group_key: &str) -> String {
format!("{group_key}_{}", js::trim(invoice_reference).to_lowercase())
}
/// Clé du cache par BL : `bl_${groupId}_${bl.trim().toLowerCase()}_${fournisseur.toLowerCase()}`
pub fn bl_cache_key(bl_number: &str, group_key: &str, supplier_name: &str) -> String {
format!(
"bl_{group_key}_{}_{}",
js::trim(bl_number).to_lowercase(),
supplier_name.to_lowercase()
)
}
/// Durée de vie d'une entrée du cache
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CacheLifetime {
/// Facture trouvée, pas encore validée : 6 h, pour permettre les corrections
Found,
/// Facture non trouvée ou erreur : 12 h, pour éviter de rappeler NocoDB
NotFound,
/// Livraison validée : 50 ans (« permanent »)
Reconciled,
/// Recherche par numéro de BL, quel qu'en soit le résultat : 24 h
BlNumber,
}
/// Date d'expiration comme Node la calcule (setHours / setFullYear, en UTC) :
/// un 29 février plus 50 ans tombe le 1er mars d'une année non bissextile
pub fn expires_at(now: DateTime<Utc>, lifetime: CacheLifetime) -> DateTime<Utc> {
match lifetime {
CacheLifetime::Found => now + Duration::hours(6),
CacheLifetime::NotFound => now + Duration::hours(12),
CacheLifetime::BlNumber => now + Duration::hours(24),
CacheLifetime::Reconciled => {
let date = now.date_naive();
NaiveDate::from_ymd_opt(date.year() + 50, date.month(), 1)
.and_then(|first| first.checked_add_signed(Duration::days(i64::from(date.day()) - 1)))
.map(|day| day.and_time(now.time()).and_utc())
.unwrap_or(now)
}
}
}
/// storage.getInvoiceVerificationCache : l'entrée si elle n'a pas expiré ; une
/// entrée expirée est supprimée au passage
pub async fn read_cache(db: &PgPool, key: &str) -> Result<Option<InvoiceVerificationCacheRow>, sqlx::Error> {
let cached = sqlx::query_as::<_, InvoiceVerificationCacheRow>(&InvoiceVerificationCacheRow::select(
"WHERE cache_key = $1",
))
.bind(key)
.fetch_optional(db)
.await?;
match cached {
Some(cached) if Utc::now().naive_utc() < cached.expires_at => Ok(Some(cached)),
Some(_) => {
sqlx::query("DELETE FROM invoice_verification_cache WHERE cache_key = $1")
.bind(key)
.execute(db)
.await?;
Ok(None)
}
None => Ok(None),
}
}
/// checkCache : résultat en cache pour une référence de facture
async fn check_cache(db: &PgPool, key: &str) -> Option<Verification> {
let cached = match read_cache(db, key).await {
Ok(cached) => cached?,
Err(error) => {
tracing::error!(%error, "lecture du cache des factures");
return None;
}
};
if Utc::now().naive_utc() >= cached.expires_at {
return None;
}
let amount = |amount: Option<rust_decimal::Decimal>| match amount {
Some(amount) => JsField::Set(js::parse_float(&amount.to_string())),
None => JsField::Absent,
};
let optional_text = |text: Option<String>| match text {
Some(text) => JsField::Set(Value::String(text)),
None => JsField::Null,
};
Some(Verification {
exists: cached.exists,
match_type: cached.match_type,
error_message: match cached.error_message {
Some(message) => JsField::Set(message),
None => JsField::Null,
},
invoice_reference: JsField::Set(Value::String(cached.invoice_reference)),
invoice_amount: amount(cached.invoice_amount),
invoice_amount_ttc: amount(cached.invoice_amount_ttc),
supplier_name: optional_text(cached.supplier_name),
due_date: optional_text(cached.due_date.map(|date| crate::json::format_ts(&date))),
from_cache: Some(true),
// Facture validée (coche verte) : jamais revérifiée
permanent: Some(cached.is_reconciled == Some(true) && cached.exists),
})
}
/// Entrée à enregistrer ; un champ None n'est ni inséré ni mis à jour (undefined
/// pour Drizzle), Some(None) l'écrit à NULL
struct CacheEntry {
key: String,
group_param: Option<String>,
invoice_reference: Option<String>,
supplier_name: Option<String>,
invoice_amount: Option<Option<String>>,
invoice_amount_ttc: Option<Option<String>>,
due_date: Option<Option<String>>,
exists: bool,
match_type: String,
error_message: Option<String>,
is_reconciled: bool,
expires_at: DateTime<Utc>,
}
/// storage.saveInvoiceVerificationCache : insertion, ou mise à jour de l'entrée de
/// même clé. Comme l'upsert de Node, la mise à jour ne touche ni invoice_amount_ttc
/// ni expires_at. Une erreur est journalisée, jamais remontée.
async fn save_cache(db: &PgPool, entry: CacheEntry) {
let now = crate::modules::now_iso();
let mut columns: Vec<(&str, Option<String>, &str, bool)> = vec![
("cache_key", Some(entry.key), "varchar", false),
("group_id", entry.group_param, "int4", false),
("invoice_reference", entry.invoice_reference, "varchar", true),
("supplier_name", entry.supplier_name, "varchar", true),
];
if let Some(amount) = entry.invoice_amount {
columns.push(("invoice_amount", amount, "numeric", true));
}
if let Some(amount) = entry.invoice_amount_ttc {
columns.push(("invoice_amount_ttc", amount, "numeric", false));
}
if let Some(due_date) = entry.due_date {
columns.push(("due_date", due_date, "timestamp", true));
}
columns.extend([
("exists", Some(entry.exists.to_string()), "bool", true),
("match_type", Some(entry.match_type), "varchar", true),
("error_message", entry.error_message, "text", true),
("cache_hit", Some("false".to_string()), "bool", true),
("api_call_time", None, "int4", true),
(
"is_reconciled",
Some(entry.is_reconciled.to_string()),
"bool",
true,
),
("expires_at", Some(js::iso(&entry.expires_at)), "timestamp", false),
]);
let mut arguments = PgArguments::default();
let mut names = Vec::new();
let mut placeholders = Vec::new();
let mut updates = Vec::new();
for (index, (column, value, sql_type, updated)) in columns.into_iter().enumerate() {
if let Err(error) = sqlx::Arguments::add(&mut arguments, value) {
tracing::error!(%error, "cache des factures : paramètre");
return;
}
names.push(format!("\"{column}\""));
placeholders.push(format!("${}::{sql_type}", index + 1));
if updated {
updates.push(format!("\"{column}\" = EXCLUDED.\"{column}\""));
}
}
let _ = sqlx::Arguments::add(&mut arguments, now);
let sql = format!(
"INSERT INTO invoice_verification_cache ({}) VALUES ({}) ON CONFLICT (cache_key) DO UPDATE SET {}, \"updated_at\" = ${}::timestamp",
names.join(", "),
placeholders.join(", "),
updates.join(", "),
names.len() + 1
);
if let Err(error) = sqlx::query_with(&sql, arguments).execute(db).await {
tracing::error!(%error, "cache des factures : enregistrement");
}
}
// ---------- Vérification ----------
/// Identifiant de magasin tel que reçu : texte de la clé de cache (String(groupId))
/// et paramètre SQL (texte converti par PostgreSQL, comme node-pg)
#[derive(Debug, Clone)]
struct GroupRef {
key: String,
param: Option<String>,
}
impl GroupRef {
fn id(group_id: i32) -> Self {
Self {
key: group_id.to_string(),
param: Some(group_id.to_string()),
}
}
fn raw(value: &Value) -> Self {
Self {
key: js::to_js_string(value),
param: crate::db::pg_text(value),
}
}
}
/// Texte d'un champ du magasin, ou sa valeur par défaut s'il est vide
fn column_or(value: &Option<String>, default: &str) -> String {
value
.clone()
.filter(|name| !name.is_empty())
.unwrap_or_else(|| default.to_string())
}
/// Le magasin a-t-il une configuration de rapprochement (NocoDB ou webhook) ?
fn has_reconciliation_config(group: &GroupsRow) -> bool {
group.nocodb_config_id.is_some_and(|id| id != 0)
|| group
.nocodb_table_name
.as_deref()
.is_some_and(|name| !name.is_empty())
|| group.webhook_url.as_deref().is_some_and(|url| !url.is_empty())
}
/// Ligne NocoDB → résultat trouvé : montants parseFloat(cellule || '0'),
/// échéance `cellule || null`, colonnes du magasin ou celles par défaut de Node
fn found_result(
data: &Value,
group: &GroupsRow,
match_type: &str,
reference: JsField<Value>,
supplier_name: Value,
) -> Verification {
let cell = |column: &Option<String>, default: &str| {
data.get(column_or(column, default))
.filter(|value| truthy(value))
.cloned()
};
let amount = |column: &Option<String>, default: &str| {
cell(column, default).map_or(0.0, |value| js::parse_float(&js::to_js_string(&value)))
};
Verification {
exists: true,
match_type: match_type.to_string(),
invoice_reference: reference,
invoice_amount: JsField::Set(amount(&group.nocodb_amount_column_name, "amount")),
invoice_amount_ttc: JsField::Set(amount(
&group.nocodb_invoice_amount_ttc_column_name,
"Montant TTC",
)),
supplier_name: JsField::Set(supplier_name),
due_date: JsField::Set(cell(&group.nocodb_due_date_column_name, "Echeance").unwrap_or(Value::Null)),
..Default::default()
}
}
/// montant.toString() d'un nombre JavaScript, NaN et Infinity compris (texte
/// écrit en base pour une colonne numeric)
pub fn js_number_text(number: f64) -> String {
if number.is_nan() {
"NaN".to_string()
} else if number.is_infinite() {
if number > 0.0 { "Infinity" } else { "-Infinity" }.to_string()
} else {
js::number_to_string(number)
}
}
/// Montant écrit en cache : `montant || null` (0 et NaN donnent null)
fn cached_amount(field: &JsField<f64>) -> Option<String> {
field
.get()
.filter(|amount| **amount != 0.0 && !amount.is_nan())
.map(|amount| js_number_text(*amount))
}
/// Paramètre texte d'une valeur JSON « vraie » (`valeur || null`)
fn truthy_param(value: Option<&Value>) -> Option<String> {
value.filter(|value| truthy(value)).and_then(crate::db::pg_text)
}
/// saveToCache : durée selon le résultat ; une échéance que new Date() ne lit pas
/// fait échouer l'enregistrement (RangeError de toISOString côté Node)
async fn save_to_cache(
db: &PgPool,
invoice_reference: &str,
group: &GroupRef,
result: &Verification,
is_reconciled: bool,
) {
let due_date = match result.due_date.get().filter(|value| truthy(value)) {
None => None,
Some(value) => match crate::services::dates::js_date(value) {
Some(date) => Some(js::iso(&date)),
None => {
tracing::error!("cache des factures : échéance illisible");
return;
}
},
};
let lifetime = if is_reconciled {
CacheLifetime::Reconciled
} else if result.exists {
CacheLifetime::Found
} else {
CacheLifetime::NotFound
};
let supplier_name = result.supplier_name.get();
save_cache(
db,
CacheEntry {
key: cache_key(invoice_reference, &group.key),
group_param: group.param.clone(),
invoice_reference: Some(invoice_reference.to_string()),
supplier_name: truthy_param(supplier_name),
invoice_amount: Some(cached_amount(&result.invoice_amount)),
invoice_amount_ttc: Some(cached_amount(&result.invoice_amount_ttc)),
due_date: Some(due_date),
exists: result.exists,
match_type: result.match_type.clone(),
error_message: result.error_message.get().filter(|m| !m.is_empty()).cloned(),
is_reconciled,
expires_at: expires_at(Utc::now(), lifetime),
},
)
.await;
}
/// Magasin, configuration NocoDB active et table : ou le résultat d'échec de Node
async fn reconciliation_setup(
state: &AppState,
group: &GroupRef,
) -> Result<(GroupsRow, crate::schema::NocodbConfigRow, String), Verification> {
let group = match nocodb::group_by_text_id(&state.db, group.param.as_deref()).await {
Ok(Some(group)) => group,
Ok(None) => return Err(Verification::failure("Groupe non trouvé")),
Err(message) => return Err(Verification::failure(message)),
};
if !has_reconciliation_config(&group) {
return Err(Verification::failure(
"Configuration NocoDB manquante pour ce magasin",
));
}
let Some(config) = nocodb::active_config(state).await else {
return Err(Verification::failure("Configuration NocoDB non trouvée"));
};
let table_id = column_or(&group.nocodb_table_id, FALLBACK_TABLE_ID);
Ok((group, config, table_id))
}
async fn run_verify_invoice(
state: &AppState,
invoice_reference: &str,
group_ref: &GroupRef,
force_refresh: bool,
is_reconciled: bool,
) -> Verification {
if js::trim(invoice_reference).is_empty() {
return Verification::failure("Référence de facture vide");
}
if !force_refresh {
if let Some(cached) = check_cache(&state.db, &cache_key(invoice_reference, &group_ref.key)).await {
return cached;
}
}
let (group, config, table_id) = match reconciliation_setup(state, group_ref).await {
Ok(setup) => setup,
Err(failure) => return failure,
};
// Par référence de facture, puis par numéro de BL si le magasin a la colonne
let mut searches = vec![(
column_or(&group.invoice_column_name, "RefFacture"),
MATCH_INVOICE_REFERENCE,
)];
if let Some(bl_column) = group
.nocodb_bl_column_name
.clone()
.filter(|name| !name.is_empty())
{
searches.push((bl_column, MATCH_BL_NUMBER));
}
for (column, match_type) in searches {
let result = match nocodb::search(&config, &table_id, &column, invoice_reference).await {
Search::Error(message) => Verification::failure(message),
Search::Found(Value::Null) => Verification::failure(
"Erreur NocoDB: Cannot read properties of null (reading 'invoice_reference')",
),
Search::Found(data) => {
let reference = data
.get("invoice_reference")
.filter(|value| truthy(value))
.cloned()
.unwrap_or_else(|| {
Value::String(if match_type == MATCH_BL_NUMBER {
format!("BL_{invoice_reference}")
} else {
invoice_reference.to_string()
})
});
let supplier = data
.get(column_or(&group.nocodb_supplier_column_name, "supplier"))
.filter(|value| truthy(value))
.cloned()
.unwrap_or_else(|| Value::String("Inconnu".to_string()));
found_result(&data, &group, match_type, JsField::Set(reference), supplier)
}
Search::NotFound => continue,
};
save_to_cache(&state.db, invoice_reference, group_ref, &result, is_reconciled).await;
return result;
}
let result = Verification::failure("Aucune correspondance trouvée dans NocoDB");
save_to_cache(&state.db, invoice_reference, group_ref, &result, is_reconciled).await;
result
}
/// verifyInvoice : la référence existe-t-elle dans la table NocoDB du magasin ?
/// Cache lu d'abord, sauf `force_refresh` ; `is_reconciled` (livraison validée)
/// rend l'entrée enregistrée permanente.
pub async fn verify_invoice(
state: &AppState,
invoice_reference: &str,
group_id: i32,
force_refresh: bool,
is_reconciled: bool,
) -> Verification {
run_verify_invoice(
state,
invoice_reference,
&GroupRef::id(group_id),
force_refresh,
is_reconciled,
)
.await
}
/// verifyInvoice appelé avec les valeurs brutes d'un corps de requête
/// (POST /api/verify-invoice) : référence qui n'est pas une chaîne (TypeError de
/// Node) et identifiant de magasin quelconque (texte de la clé de cache, erreur de
/// PostgreSQL s'il n'est pas un entier)
pub async fn verify_invoice_raw(
state: &AppState,
invoice_reference: &Value,
group_id: &Value,
force_refresh: bool,
is_reconciled: bool,
) -> Verification {
let Some(reference) = invoice_reference.as_str() else {
return Verification::failure(nocodb::not_a_string_error(
"invoiceReference",
"trim",
Some(invoice_reference),
));
};
run_verify_invoice(
state,
reference,
&GroupRef::raw(group_id),
force_refresh,
is_reconciled,
)
.await
}
/// Règle de rapprochement du fournisseur d'un BL : l'un des noms contient l'autre,
/// sans tenir compte de la casse (un nom vide correspond donc toujours)
pub fn bl_supplier_matches(found: &str, expected: &str) -> bool {
let (found, expected) = (found.to_lowercase(), expected.to_lowercase());
found.contains(&expected) || expected.contains(&found)
}
/// Enregistrement du cache par BL (24 h) : montants et échéance jamais écrits
async fn save_bl_cache(
db: &PgPool,
key: &str,
group: &GroupRef,
invoice_reference: Option<&Value>,
supplier_name: &Value,
result: &Verification,
is_reconciled: bool,
) {
save_cache(
db,
CacheEntry {
key: key.to_string(),
group_param: group.param.clone(),
invoice_reference: invoice_reference.and_then(crate::db::pg_text),
supplier_name: crate::db::pg_text(supplier_name),
invoice_amount: None,
invoice_amount_ttc: None,
due_date: None,
exists: result.exists,
match_type: result.match_type.clone(),
error_message: result.error_message.get().cloned(),
is_reconciled,
expires_at: expires_at(Utc::now(), CacheLifetime::BlNumber),
},
)
.await;
}
/// verifyInvoiceByBL : un BL de ce numéro existe-t-il pour ce fournisseur ?
pub async fn verify_invoice_by_bl(
state: &AppState,
bl_number: &str,
supplier_name: &str,
group_id: i32,
force_refresh: bool,
is_reconciled: bool,
) -> Verification {
if js::trim(bl_number).is_empty() {
return Verification::failure("Numéro BL vide");
}
let group_ref = GroupRef::id(group_id);
let key = bl_cache_key(bl_number, &group_ref.key, supplier_name);
if !force_refresh {
match read_cache(&state.db, &key).await {
Ok(Some(cached)) if Utc::now().naive_utc() < cached.expires_at => {
// `valeur || undefined` : un texte vide ou null est omis
let text = |text: Option<String>| match text.filter(|text| !text.is_empty()) {
Some(text) => JsField::Set(Value::String(text)),
None => JsField::Absent,
};
return Verification {
exists: cached.exists,
match_type: cached.match_type,
error_message: match cached.error_message.filter(|message| !message.is_empty()) {
Some(message) => JsField::Set(message),
None => JsField::Absent,
},
invoice_reference: text(Some(cached.invoice_reference)),
supplier_name: text(cached.supplier_name),
from_cache: Some(true),
..Default::default()
};
}
Ok(_) => {}
Err(error) => tracing::error!(%error, "lecture du cache BL"),
}
}
let (group, config, table_id) = match reconciliation_setup(state, &group_ref).await {
Ok(setup) => setup,
Err(failure) => return failure,
};
let bl_value = Value::String(bl_number.to_string());
let supplier_value = Value::String(supplier_name.to_string());
let supplier_column = column_or(&group.nocodb_supplier_column_name, "supplier");
let column = column_or(&group.nocodb_bl_column_name, "Numero_BL");
match nocodb::search(&config, &table_id, &column, bl_number).await {
Search::Error(message) => {
let result = Verification::failure(message);
save_bl_cache(
&state.db,
&key,
&group_ref,
Some(&bl_value),
&supplier_value,
&result,
is_reconciled,
)
.await;
result
}
Search::Found(data) => {
// Erreurs JavaScript de Node sur une ligne inattendue (null, fournisseur non textuel)
let found_supplier = match &data {
Value::Null => Err(format!(
"Cannot read properties of null (reading '{supplier_column}')"
)),
data => match data.get(&supplier_column).filter(|value| truthy(value)) {
None => Ok(String::new()),
Some(Value::String(text)) => Ok(text.clone()),
Some(_) => Err("foundSupplier.toLowerCase is not a function".to_string()),
},
};
let found_supplier = match found_supplier {
Ok(found) => found,
Err(message) => {
let result = Verification::failure(format!("Erreur NocoDB BL: {message}"));
save_bl_cache(
&state.db,
&key,
&group_ref,
Some(&bl_value),
&supplier_value,
&result,
is_reconciled,
)
.await;
return result;
}
};
if !bl_supplier_matches(&found_supplier, supplier_name) {
tracing::info!(
expected = supplier_name,
found = found_supplier,
"fournisseur du BL différent"
);
return Verification::failure(format!(
"BL trouvé mais fournisseur différent: {found_supplier} vs {supplier_name}"
));
}
let reference = data
.get(column_or(&group.invoice_column_name, "RefFacture"))
.filter(|value| truthy(value))
.cloned()
.unwrap_or_else(|| Value::String(format!("BL_{bl_number}")));
let supplier = Value::String(if found_supplier.is_empty() {
supplier_name.to_string()
} else {
found_supplier
});
let result = found_result(
&data,
&group,
MATCH_BL_NUMBER,
JsField::Set(reference.clone()),
supplier.clone(),
);
save_bl_cache(
&state.db,
&key,
&group_ref,
Some(&reference),
&supplier,
&result,
is_reconciled,
)
.await;
result
}
Search::NotFound => {
let result = Verification::failure("Aucun BL correspondant trouvé dans NocoDB");
save_bl_cache(
&state.db,
&key,
&group_ref,
Some(&bl_value),
&supplier_value,
&result,
is_reconciled,
)
.await;
result
}
}
}
// ---------- Marquage « rapproché » ----------
async fn mark_reconciled(db: &PgPool, key: &str) {
let cached = match read_cache(db, key).await {
Ok(Some(cached)) => cached,
Ok(None) => {
tracing::info!(key, "cache des factures : aucune entrée à marquer");
return;
}
Err(error) => {
tracing::error!(%error, "cache des factures : marquage");
return;
}
};
if cached.is_reconciled == Some(true) {
return;
}
// L'upsert de Node réécrit l'entrée à l'identique sauf is_reconciled et
// updated_at : expires_at n'est pas prolongé (la colonne n'est pas dans son SET)
let result = sqlx::query(
"UPDATE invoice_verification_cache SET is_reconciled = true, updated_at = $2::timestamp WHERE cache_key = $1",
)
.bind(key)
.bind(crate::modules::now_iso())
.execute(db)
.await;
if let Err(error) = result {
tracing::error!(%error, "cache des factures : marquage");
}
}
/// updateCacheAsReconciled : marque « rapprochée » l'entrée d'une référence (ou
/// d'un numéro de BL) de ce magasin, si elle existe
pub async fn update_cache_as_reconciled(db: &PgPool, invoice_reference: &str, group_id: i32) {
mark_reconciled(db, &cache_key(invoice_reference, &group_id.to_string())).await;
}
/// updateCacheAsReconciled avec les valeurs brutes d'un corps de requête
/// (POST /api/cache/mark-reconciled) : rien n'est fait si la référence n'est pas une
/// chaîne (TypeError interceptée par Node)
pub async fn update_cache_as_reconciled_raw(db: &PgPool, invoice_reference: &Value, group_id: &Value) {
match invoice_reference.as_str() {
Some(reference) => mark_reconciled(db, &cache_key(reference, &js::to_js_string(group_id))).await,
None => tracing::error!("cache des factures : référence qui n'est pas une chaîne"),
}
}
/// Blancs ASCII retirés par String.prototype.trim(), pour reproduire les clés en SQL
const ASCII_WHITESPACE: &str = " \t\n\u{0B}\u{0C}\r";
/// updateExistingReconciledCaches : marque « rapprochées » les entrées non expirées
/// des références et numéros de BL de toutes les livraisons validées, en une
/// requête ; rend le nombre d'entrées modifiées (les erreurs sont journalisées)
pub async fn update_existing_reconciled_caches(db: &PgPool) -> u64 {
let result = sqlx::query(
"UPDATE invoice_verification_cache
SET is_reconciled = true, updated_at = $1::timestamp
WHERE is_reconciled IS NOT TRUE
AND expires_at > $1::timestamp
AND cache_key IN (
SELECT group_id::text || '_' || lower(btrim(invoice_reference, $2))
FROM deliveries
WHERE reconciled = true AND btrim(invoice_reference, $2) <> ''
UNION
SELECT group_id::text || '_' || lower(btrim(bl_number, $2))
FROM deliveries
WHERE reconciled = true AND btrim(bl_number, $2) <> ''
)",
)
.bind(crate::modules::now_iso())
.bind(ASCII_WHITESPACE)
.execute(db)
.await;
match result {
Ok(done) => done.rows_affected(),
Err(error) => {
tracing::error!(%error, "cache des factures : mise à jour en lot");
0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn utc(text: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(text).unwrap().with_timezone(&Utc)
}
fn group() -> GroupsRow {
let mut group = empty_group();
group.nocodb_amount_column_name = Some("MontantHT".into());
group.nocodb_invoice_amount_ttc_column_name = Some("MontantTTC".into());
group.nocodb_supplier_column_name = Some("Fournisseur".into());
group.nocodb_due_date_column_name = Some("Echeance".into());
group
}
fn empty_group() -> GroupsRow {
GroupsRow {
id: 1,
name: "Magasin".into(),
color: None,
nocodb_config_id: None,
nocodb_table_name: None,
nocodb_table_id: None,
invoice_column_name: None,
nocodb_bl_column_name: None,
nocodb_amount_column_name: None,
nocodb_invoice_amount_ttc_column_name: None,
nocodb_supplier_column_name: None,
nocodb_due_date_column_name: None,
webhook_url: None,
address: None,
phone: None,
logo: None,
smtp_enabled: None,
smtp_host: None,
smtp_port: None,
smtp_secure: None,
smtp_user: None,
smtp_password: None,
smtp_sender_email: None,
smtp_sender_name: None,
created_at: None,
updated_at: None,
}
}
#[test]
fn cles_du_cache_comme_node() {
// generateCacheKey et la clé BL de verifyInvoiceByBL
assert_eq!(cache_key(" FAC-2001 ", "1"), "1_fac-2001");
assert_eq!(cache_key("Fac\u{a0}É", "01"), "01_fac\u{a0}é");
assert_eq!(
bl_cache_key(" BL-1002 ", "1", "Bonduelle SA"),
"bl_1_bl-1002_bonduelle sa"
);
}
#[test]
fn durees_du_cache_comme_node() {
// new Date() puis setHours / setFullYear, dans Node 22 (TZ=UTC)
let now = utc("2026-10-15T20:00:00.000Z");
assert_eq!(
js::iso(&expires_at(now, CacheLifetime::Found)),
"2026-10-16T02:00:00.000Z"
);
assert_eq!(
js::iso(&expires_at(now, CacheLifetime::NotFound)),
"2026-10-16T08:00:00.000Z"
);
assert_eq!(
js::iso(&expires_at(now, CacheLifetime::BlNumber)),
"2026-10-16T20:00:00.000Z"
);
assert_eq!(
js::iso(&expires_at(now, CacheLifetime::Reconciled)),
"2076-10-15T20:00:00.000Z"
);
let leap = utc("2028-02-29T10:00:00.000Z");
assert_eq!(
js::iso(&expires_at(leap, CacheLifetime::Reconciled)),
"2078-03-01T10:00:00.000Z"
);
}
#[test]
fn resultat_trouve_depuis_une_ligne_nocodb() {
let row = json!({ "Id": 1, "RefFacture": "FAC-2001", "MontantHT": 1250.5, "MontantTTC": "1 500,60",
"Fournisseur": "Lactalis", "Echeance": "2026-10-25" });
let result = found_result(
&row,
&group(),
MATCH_INVOICE_REFERENCE,
JsField::Set(json!("FAC-2001")),
json!("Lactalis"),
);
assert_eq!(
serde_json::to_value(&result).unwrap(),
json!({ "exists": true, "matchType": "invoice_reference", "invoiceReference": "FAC-2001",
"invoiceAmount": 1250.5, "invoiceAmountTTC": 1, "supplierName": "Lactalis",
"dueDate": "2026-10-25" })
);
// Cellules absentes : montants à 0, échéance null ; colonnes par défaut de Node
let empty = found_result(
&json!({}),
&empty_group(),
MATCH_BL_NUMBER,
JsField::Set(json!("BL_X")),
json!("Inconnu"),
);
assert_eq!(empty.invoice_amount, JsField::Set(0.0));
assert_eq!(empty.due_date, JsField::Set(Value::Null));
let nan = found_result(
&json!({ "amount": "abc" }),
&empty_group(),
MATCH_BL_NUMBER,
JsField::Absent,
json!("x"),
);
assert!(nan.invoice_amount.get().unwrap().is_nan());
assert_eq!(serde_json::to_value(&nan).unwrap()["invoiceAmount"], Value::Null);
assert_eq!(cached_amount(&nan.invoice_amount), None);
assert_eq!(cached_amount(&JsField::Set(975.0)).as_deref(), Some("975"));
}
#[test]
fn echec_et_resultat_du_cache() {
assert_eq!(
serde_json::to_string(&Verification::failure("Référence de facture vide")).unwrap(),
r#"{"exists":false,"matchType":"none","errorMessage":"Référence de facture vide"}"#
);
let cached = Verification {
exists: true,
match_type: MATCH_INVOICE_REFERENCE.into(),
error_message: JsField::Null,
invoice_reference: JsField::Set(json!("FAC-2004")),
invoice_amount: JsField::Set(975.0),
invoice_amount_ttc: JsField::Absent,
supplier_name: JsField::Set(json!("Brico Pro")),
due_date: JsField::Set(json!("2026-11-09T00:00:00.000Z")),
from_cache: Some(true),
permanent: Some(false),
};
assert_eq!(
serde_json::to_string(&cached).unwrap(),
r#"{"exists":true,"matchType":"invoice_reference","errorMessage":null,"invoiceReference":"FAC-2004","supplierName":"Brico Pro","invoiceAmount":975,"dueDate":"2026-11-09T00:00:00.000Z","fromCache":true,"permanent":false}"#
);
assert_eq!(cached.normalized_due_date().as_deref(), Some("2026-11-09"));
}
#[test]
fn rapprochement_du_fournisseur_d_un_bl() {
assert!(bl_supplier_matches("LACTALIS France", "lactalis"));
assert!(bl_supplier_matches("Lactalis", "Lactalis France"));
assert!(bl_supplier_matches("", "Bonduelle"));
assert!(!bl_supplier_matches("Brico Pro", "Bonduelle"));
}
#[test]
fn configuration_de_rapprochement() {
let mut group = empty_group();
assert!(!has_reconciliation_config(&group));
group.webhook_url = Some(String::new());
assert!(!has_reconciliation_config(&group));
group.nocodb_table_name = Some("factures".into());
assert!(has_reconciliation_config(&group));
assert_eq!(column_or(&Some(String::new()), "RefFacture"), "RefFacture");
}
}