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) | Description | Déclencheur |
|---|---|---|
balance_low | Solde d'une carte sous le seuil configuré | Job quotidien 06:00 |
stock_inconsistency | Écart entre stock théorique et mouvements cumulés | Job toutes les 6h |
expired_product | Produit périmé détecté dans le stock | Job chaque heure |
ledger_mismatch | Solde carte différent de la somme des transactions | Job quotidien 03:00 (log only) |
register_discrepancy | Écart de caisse constaté | Réservé usage futur |
transaction_without_stock | Transaction sans stock correspondant | Réservé usage futur |
negative_stock_blocked | Stock 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 |
critical | Situation grave, intervention immédiate |
Statuts
| Statut | Description |
|---|---|
open | Anomalie détectée, non traitée |
acknowledged | Prise en compte par un opérateur |
resolved | Résolue |
false_positive | Considé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_lowpour une même carte - Le seuil par défaut est configurable par organisation (
default_anomaly_threshold_cents) - L'événement
AnomalyDetectedest émis via la table outbox, et un consumer envoie une notification email au templateanomaly_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.
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 :
| Param | Type | Description |
|---|---|---|
kind | string | Filtrer par type (balance_low, stock_inconsistency, ...) |
severity | string | low, medium, high, critical |
status | string | open, acknowledged, resolved, false_positive |
from | datetime | Date de début (RFC 3339) |
to | datetime | Date de fin (RFC 3339) |
limit | integer | Éléments par page (défaut: 50, max: 200) |
offset | integer | Dé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"
}
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 :
| Champ | Type | Défaut | Description |
|---|---|---|---|
default_anomaly_threshold_cents | integer | 30000 | Seuil 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
}
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 :
- L'anomalie est insérée dans le schéma tenant avec son statut
open - Un événement
AnomalyDetectedest écrit dans la tableoutbox - Le worker
outbox_consumerlit l'événement et appellenotify_anomaly_detected() - Une notification est créée dans la table
{schema}.notificationsavec :- Canal :
email - Template :
anomaly_alert - Destinataire :
admin(les admins de l'organisation)
- Canal :
- Le
NotificationRouterachemine selon la configurationevent_routingde 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
| Permission | Description |
|---|---|
anomaly.manage | Gestion complète des anomalies (lecture, acknowledge, resolve) |
anomaly.threshold.configure | Configuration du seuil de détection |
organization.manage | Super-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.