Aller au contenu

Histoire ING-002 : Scheduler + MinIO inbox client + tenant iteration skeleton

Module : ingestion Tranche : Sous-tranche de niveau 1 ING-001-a (architecture §11) Side : [BACKEND] Version cible : V0.0.0 Priorité : 2 Dépend de : ING-001 (infra de test + migrations vides), TENANT-001 (TenantConfigCache bean + default-agency provisioning), AUDIT-001 (AuditEventBuilder + outbox) et AUDIT catalog extension OQ-X10 admettant ING_UPLOAD_RECEIVED Peut être développé en même temps que : ING-003 (le code pur de l'analyseur/valideur n'a pas de chevauchement avec cette tranche) Ordre de fusion : Avant ING-004 Complexité estimée : S Histoires d'utilisateurs du DRP : Dérivé de ING-US-01 (voie de ramassage en amont ; substitut cron dans v0.0.0) Cadre de travail : N/A - backend uniquement


Objectif

Câbler le cron nocturne @Scheduled qui itère les locataires actifs, liste /<tenantId>/inbox/ dans le seau MinIO de la plateforme, et émet l'audit ING_UPLOAD_RECEIVED lorsqu'un fichier est récupéré. Pas d'analyse, pas de validation, pas encore de validation - juste le squelette d'orchestration et le wrapper MinIO utilisé par toutes les tranches suivantes. Après cette histoire, l'opérateur qui dépose un fichier dans MinIO provoque une ligne d'audit et un mouvement processed/ au prochain tick.


Portée du backend

Entités

Aucune nouvelle. Cette tranche n'écrit pas encore de lignes ingestion_import - cela arrive avec ING-004. Elle n'émet que des événements d'audit avec la clé MinIO comme métadonnées équivalentes à subject_id.

Migrations

Aucune. (Le corps de V010 est encore vide ; rempli avec ING-004)

Couche de service

  • IngestionScheduler (@Component) : single @Scheduled(cron = "${INGESTION_CRON_EXPRESSION:0 0 2 * *}", zone = "${INGESTION_CRON_ZONE:Africa/Abidjan}") method tick(). Également exposée via le point d'endpoint POST /internal/ingestion/tick uniquement sur le profil local pour la commande de vérification de la fumée dans l'architecture §14.3.
  • IngestionService.tick() : itère TenantConfigCache.listActiveTenants(), ouvre TenantContext.scope(tenant.id, tenant.countryCode), appelle processInbox(tenant) par locataire séquentiellement (architecture §4.1).
  • IngestionInboxClient (wrapper MinIO) : list(tenantId) → Stream<InboxObject>, get(tenantId, key) → InputStream, moveToProcessed(...), moveToRejected(...), moveToDuplicate(...), putErrorsSidecar(...). Clés construites uniquement à partir du tenantId lié à l'itération (architecture §2.5, §7.6 menace A4).
  • IngestionAuditEvents.uploadReceived(...) : construit ING_UPLOAD_RECEIVED AuditEventBuilder payload avec { filename, file_size_bytes, file_hash_sha256, minio_inbox_key } - pas de contenu de ligne.
  • Dans cette tranche, le flux processInbox = liste → pour chaque objet : read size + stream-hash SHA-256 → emit ING_UPLOAD_RECEIVED → call moveToProcessed (placeholder behavior ; ING-004 remplace par full parse/commit/reject branching).

Les endpoints de l'API

Method Path Request Response Auth
POST /internal/ingestion/tick (local profile ONLY) 204 No Content None (gated by profile filter)

Règles de validation

  • La vérification des octets magiques (CSV vs XLSX) est reportée aux analyseurs ING-003.
  • La taille des fichiers est limitée (INGESTION_MAX_FILE_BYTES=5_242_880) à l'étape du hash-streaming : si les octets cumulés dépassent la limite, l'itération est interrompue pour ce fichier, ING_UPLOAD_RECEIVED est émise avec file_size_bytes fixé à la limite + un drapeau truncated:true, le fichier est déplacé vers /<tid>/rejected/ avec la raison FICHIER_TROP_VOLUMINEUX dans le sidecar. (Note : dans ING-004 cela devient une ligne PARSE_FAILED correcte ; dans ING-002 il n'y a pas encore de ligne DB)

Considérations multi-tenant

Cron ne transporte pas de JWT. Par itération, TenantContext.scope(tenant.id, tenant.countryCode) est la seule source de vérité pour le locataire. actor_type=SYSTEM, actor_id="ingestion-cron" sur toutes les émissions d'audit (architecture §4.1, §7.4). La dérivation de la clé MinIO DOIT utiliser uniquement la variable d'itération - jamais une valeur provenant du contenu du fichier (architecture §7.6 A4). Les deux profils de base de données fonctionnent parce que les seules écritures dans la base de données sont des lignes d'audit-outbox dans la base de données de la plate-forme (architecture §4.2, D12).

Audit et journalisation

  • ING_UPLOAD_RECEIVED émis une fois par objet ramassé. actor_type=SYSTEM, actor_id=ingestion-cron, subject_type=INGESTION_IMPORT, subject_id=<UUIDv7 minted for the import> (le même UUID est réutilisé comme l'éventuel ingestion_import.id une fois que ING-004 arrive ; persistez dans un thread-local pour l'itération). Détails : { nom_de_fichier, taille_du_fichier_bytes, file_hash_sha256, minio_inbox_key }. Règles PII : le nom de fichier est contrôlé par l'opérateur ; ne PAS hacher ; le contenu du fichier n'est jamais répercuté.
  • Journal INFO structuré par limite d'itération (tenantId, objectsFound, durationMs).

Portée du frontend

N/A - backend uniquement.

États de l'interface utilisateur (TOUS OBLIGATOIRES)

State Behavior French Copy
Loading N/A — backend only
Empty N/A — backend only
Error N/A — backend only
Offline / low network N/A — backend only
Success N/A — backend only

Comportement réactif

N/A.

Interactions

N/A.


Critères d'acceptation

AC-001: Cron iterates active tenants and emits ING_UPLOAD_RECEIVED per picked file
Given two active tenants A and B in `papillon_platform.tenant`
  And a 1-KB text file dropped in `<tenantA>/inbox/foo.csv`
When `IngestionService.tick()` runs
Then exactly one `ING_UPLOAD_RECEIVED` audit row exists for tenant A with subject_type=INGESTION_IMPORT
  And zero audit rows for tenant B
  And the audit details contain `filename=foo.csv`, the streamed SHA-256, and the inbox key prefixed by tenant A's UUID
  And the file is moved to `<tenantA>/processed/<yyyy-MM-dd>__foo.csv`.
AC-002: Local profile tick endpoint exists; non-local profiles do not expose it
Given the application running with `spring.profiles.active=local`
When `POST /internal/ingestion/tick` is called
Then it returns 204 and IngestionService.tick() is invoked
  And the same request on `spring.profiles.active=staging` returns 404 (endpoint not registered).
AC-003: File over 5 MB is rejected at the hash-streaming step without ever being fully buffered
Given a 6-MB binary file in `<tenantA>/inbox/big.csv`
When IngestionService.tick() runs
Then the file is moved to `<tenantA>/rejected/<ts>__big.csv` with sidecar JSON `{"reason":"FICHIER_TROP_VOLUMINEUX"}`
  And heap usage during the run stays under 256 MB (asserted by the test profile JFR snapshot).

Règles de conformité

  • Architecture §7.4 : actor_type=SYSTEM, actor_id="ingestion-cron" est obligatoire sur chaque émission d'audit de cette tranche.
  • Règle critique #1 (isolation des locataires, docs/standards/critical-rules.md) : La construction de la clé MinIO doit dériver tenantId de la variable d'itération uniquement.
  • Architecture §7.6 A4 : la revue de code doit rejeter tout préfixe dérivé du contenu d'un fichier.

Normes et conventions

  • docs/standards/critical-rules.md (règles #1 isolation du locataire, #3 catalogue d'audit).
  • docs/standards/multi-tenant-model.md § contexte cron.
  • docs/standards/java-spring-guidelines.md § @Scheduled + @TransactionalEventListener.
  • docs/standards/api-guidelines.md § endpoints internes (liés au profil).

Exigences de test

Tests unitaires

  • IngestionInboxClientTest : la construction des clés utilise uniquement tenantId lié par itération ; refuse les clés ne commençant pas par <tenantId>/.
  • IngestionAuditEventsTest : la forme du payload ING_UPLOAD_RECEIVED correspond à la spécification.

Tests d'intégration

  • IngestionSchedulerIT (TestContainers) : fixation de deux locataires, drop in A only → AC-001 assertions ; verify audit_log row count by tenant.
  • IngestionLocalTickEndpointIT : visibilité des endpoints liée au profil selon AC-002.
  • IngestionLargeFileIT : AC-003 avec un fichier aléatoire généré de 6 Mo.

Ce que l'AQ validera

  • Exécuter la commande de vérification de la fumée de l'architecture §14.3 étapes 1-4 (drop, tick, verify audit row, verify processed/ key). L'étape 5 (DB assertions on ingestion_import) est N/A jusqu'à ING-004.

Hors du champ d'application

  • Analyse CSV/XLSX et validation des lignes (ING-003).
  • écriture des tables ingestion_import / ingestion_import_row, déduplication SHA-256 contre les importations existantes, transitions de statut PARSE_FAILED / DUPLICATE / COMMITTED (ING-004).
  • Récupération au démarrage pour les lignes PICKED_UP bloquées (ING-005).
  • Option de fonctionnalité feature.artci.phone-auth.granted sur le profil prod (ING-005).
  • Interface utilisateur pour l'upload ou l'historique (V0.0.3+).
  • résolution du code_agence (V0.0.2+).

Définition de Fait

  • [Tous les endpoints du backend sont implémentés et renvoient des réponses correctes
  • [Toutes les pages du frontend s'affichent correctement à 360 / 768 / 1024 px (N/A - backend uniquement)
  • [Les 5 états de l'interface utilisateur sont implémentés (N/A - backend uniquement)
  • [La micro-copie française correspond exactement aux spécifications du design (N/A - backend uniquement)
  • [Tous les critères d'acceptation sont passés manuellement
  • [Tests unitaires et d'intégration écrits et réussis
  • [ ] Isolation des locataires vérifiée (tenant_id sur chaque clé MinIO + ligne d'audit ; les deux profils de BD V1 testés - SHARED actif, BYO smoke)
  • [ ] [Histoires de clients] payload + Slow-3G + resumability (N/A - backend uniquement)
  • [Les entrées du journal d'audit sont présentes pour chaque événement requis (ING_UPLOAD_RECEIVED par ramassage ; vérifié dans le service informatique)
  • [Aucune défaillance silencieuse sur les erreurs de réseau (les erreurs transitoires MinIO sont enregistrées au niveau de WARN et l'itération continue ; le fichier reste dans la boîte de réception pour le prochain tick - architecture §5.1 étape 1)