Aller au contenu principal

Anomalies

Le système de détection d'anomalies analyse automatiquement les données de chaque organisation selon plusieurs cycles programmés et signale les écarts par rapport aux comportements attendus.


Types d'anomalies​

Type (kind)DescriptionDéclencheur
balance_lowSolde d'une carte sous le seuil configuréJob quotidien 06:00
stock_inconsistencyÉcart entre stock théorique et mouvements cumulésJob toutes les 6h
expired_productProduit périmé détecté dans le stockJob chaque heure
ledger_mismatchSolde carte différent de la somme des transactionsJob quotidien 03:00 (log only)
register_discrepancyÉcart de caisse constatéRéservé usage futur
transaction_without_stockTransaction sans stock correspondantRéservé usage futur
negative_stock_blockedStock négatif bloquéRéservé usage futur

Sévérités​

SéveritéSignification
lowÉcart mineur, informationnel
mediumÉcart significatif, nécessite attention
highÉcart critique, action requise
criticalSituation grave, intervention immédiate

Statuts​

StatutDescription
openAnomalie détectée, non traitée
acknowledgedPrise en compte par un opérateur
resolvedRésolue
false_positiveConsidérée comme non pertinente

Jobs automatiques​

Seuls 3 types d'anomalies sont créés automatiquement :

balance_low — Quotidien 06:00​

flowchart LR
A[Job 06:00] --> B{Pour chaque<br/>org active}
B --> C[Lire seuil org]
C --> D[Chercher cartes<br/>solde < seuil]
D --> E[Déjà notifié<br/>dans les 8 jours?]
E -->|Non| F[Créer anomalie]
E -->|Oui| G[Ignorer]
F --> H{Solde}
H -->|< 0| I[Sévérité high]
H -->|< seuil/2| J[Sévérité medium]
H -->|sinon| K[Sévérité low]
I --> L[Émettre event<br/>AnomalyDetected]
J --> L
K --> L
L --> M[Outbox → notif email]
  • Verrou advisory pg_try_advisory_lock(1, 200) empêche les exécutions concurrentes
  • Règle anti-spam : 8 jours minimum entre deux notifications balance_low pour une même carte
  • Le seuil par défaut est configurable par organisation (default_anomaly_threshold_cents)
  • L'événement AnomalyDetected est émis via la table outbox, et un consumer envoie une notification email au template anomaly_alert

stock_inconsistency — Toutes les 6h​

Job → Pour chaque org → Comparer products.stock_quantity avec SUM(delta) stock_movements
→ Si écart → Créer anomalie kind=stock_inconsistency, severity=high

expired_product — Chaque heure​

Job → Chercher lots périmés → Créer mouvement de rebut → Ajuster stock
→ Créer anomalie kind=expired_product, severity=medium

ledger_mismatch — Quotidien 03:00​

Ce job est un contrôle detective read-only. Il compare le solde de chaque carte avec la somme des transactions dans une transaction REPEATABLE READ. En cas d'écart, il logge une erreur mais n'insère pas d'enregistrement d'anomalie. Ce comportement est réservé à une future implémentation.

astuce

Les types register_discrepancy, transaction_without_stock et negative_stock_blocked sont définis dans le domaine mais ne sont pas encore automatiquement créés. Ils sont prévus pour des versions futures.


API​

Tous les endpoints d'anomalies requièrent la permission anomaly.manage (ou anomaly.threshold.configure, ou organization.manage via wildcard).

GET /admin/org/anomalies​

Liste paginée des anomalies de l'organisation.

Query parameters :

ParamTypeDescription
kindstringFiltrer par type (balance_low, stock_inconsistency, ...)
severitystringlow, medium, high, critical
statusstringopen, acknowledged, resolved, false_positive
fromdatetimeDate de début (RFC 3339)
todatetimeDate de fin (RFC 3339)
limitintegerÉléments par page (défaut: 50, max: 200)
offsetintegerDécalage (défaut: 0)

Réponse :

{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"kind": "balance_low",
"severity": "medium",
"status": "open",
"message": "La carte #123456 (Jean Dupont) a un solde de 500 FCFA sous le seuil de 3000 FCFA",
"details": {
"balance_cents": 500,
"threshold_cents": 3000
},
"card_id": "550e8400-e29b-41d4-a716-446655440010",
"holder_name": "Jean Dupont",
"detected_at": "2025-06-01T06:00:00Z",
"acknowledged_at": null,
"resolved_at": null,
"resolved_by": null,
"created_at": "2025-06-01T06:00:00Z"
}
],
"total": 5
}

POST /admin/org/anomalies/{id}/acknowledge​

Accuser réception d'une anomalie. Passe le statut de open à acknowledged et renseigne acknowledged_at.

Réponse :

{
"status": "acknowledged"
}

POST /admin/org/anomalies/{id}/resolve​

Marquer une anomalie comme résolue. Passe le statut à resolved et renseigne resolved_at.

Réponse :

{
"status": "resolved"
}
remarque

Le champ resolved_by (UUID de l'utilisateur) n'est pas encore alimenté par l'API actuelle. Le statut false_positive est défini dans le domaine mais n'a pas de route dédiée pour l'instant.


Seuil de détection​

Chaque organisation a un seuil configurable pour la détection des soldes faibles :

ChampTypeDéfautDescription
default_anomaly_threshold_centsinteger30000Seuil en centimes (30000 = 300 FCFA)

La valeur par défaut est définie à la création de l'organisation et peut être modifiée via PUT /admin/system/organizations/{id}.

{
"default_anomaly_threshold_cents": 50000
}
astuce

Un seuil à 30000 centimes (300 FCFA) signifie que toute carte passant sous 300 FCFA déclenchera une anomalie balance_low le lendemain à 06:00 (le job est quotidien).


Cycle de vie d'une anomalie​

stateDiagram-v2
[*] --> open: Job automatique
open --> acknowledged: POST /anomalies/{id}/acknowledge
acknowledged --> resolved: POST /anomalies/{id}/resolve
acknowledged --> false_positive: (réservé)
open --> resolved: POST /anomalies/{id}/resolve
resolved --> [*]

Notifications​

Quand une anomalie balance_low est créée, le système émet un événement AnomalyDetected qui déclenche une notification :

  1. L'anomalie est insérée dans le schéma tenant avec son statut open
  2. Un événement AnomalyDetected est écrit dans la table outbox
  3. Le worker outbox_consumer lit l'événement et appelle notify_anomaly_detected()
  4. Une notification est créée dans la table {schema}.notifications avec :
    • Canal : email
    • Template : anomaly_alert
    • Destinataire : admin (les admins de l'organisation)
  5. Le NotificationRouter achemine selon la configuration event_routing de l'org

Règle anti-spam (8 jours)​

Pour éviter de noyer les administrateurs de notifications, le système n'envoie pas plus d'une notification balance_low par carte tous les 8 jours :

NOT EXISTS (
SELECT 1 FROM public.anomalies
WHERE card_id = c.id
AND kind = 'balance_low'
AND detected_at > now() - interval '8 days'
)

Configuration du routage​

La configuration par défaut à la création d'une organisation :

{
"notification_config": {
"channels": {
"email": { "enabled": true },
"sms": { "enabled": false },
"whatsapp": { "enabled": false }
},
"event_routing": {
"anomaly_detected": ["email"]
},
"email_default_from": "noreply@caffette.app"
}
}

Permissions​

PermissionDescription
anomaly.manageGestion complète des anomalies (lecture, acknowledge, resolve)
anomaly.threshold.configureConfiguration du seuil de détection
organization.manageSuper-permission qui couvre les deux ci-dessus

Bonnes pratiques​

  • Seuil : Ajustez le seuil en fonction du panier moyen de votre organisation. Un seuil trop bas génère peu d'alertes mais expose à des impayés ; un seuil trop haut submerge les administrateurs.
  • Période de détection : Le job tourne à 06:00 chaque jour. Les anomalies sont détectées avec un décalage maximum de 24h par rapport au passage sous le seuil.
  • Stock : Le job de réconciliation de stock (reconcile_stock) tourne toutes les 6h. En cas d'écart important, vérifiez les mouvements de stock manuels ou les imports.
  • Notifications : Seul le canal email est actif par défaut. Si votre organisation utilise un autre canal, configurez-le dans notification_config.