🔄 Mécanisme Complet : Facturation → PDF → Media
Objectif : Documenter le flux complet de bout en bout pour la génération de factures, leur export en PDF et leur stockage dans dynors-media.
📋 Vue d'Ensemble
┌──────────────────────────────────────────────────────────────────────────┐
│ FLUX COMPLET DE FACTURATION │
└──────────────────────────────────────────────────────────────────────────┘
1. RAGNAR (Module Facturation)
↓ appelle
2. FISCAL (Application Facturation)
↓ génère
3. dynors-pdf (Export PDF)
↓ utilise (pour logos)
4. dynors-media (Stockage)
↓ stocke
5. PDF final disponible
🎯 Acteurs et Responsabilités
| Acteur | Rôle | Responsabilité |
|---|---|---|
| RAGNAR | Orchestrateur ESN | Agrège time tracking, prépare données facture, déclenche facturation |
| FISCAL | Moteur facturation | Crée factures, calcule taxes, numérotation séquentielle, compliance, export |
| dynors-pdf | Générateur PDF | Génère PDF depuis templates (par pays), intègre logos/images |
| dynors-media | Stockage centralisé | Fournit logos pour PDF, stocke PDF générés, gère tous les fichiers |
🔄 FLUX 1 : RAGNAR → FISCAL (Création Facture)
Étape 1 : RAGNAR agrège le time tracking
Fichier : dynors-internal/applications/ragnar/backend/src/main/java/com/dynors/internal/ragnar/module/facturation/service/BillingService.java
@Service
public class BillingService {
@Autowired
private FiscalInvoiceService fiscalInvoiceService; // Client FISCAL
@Autowired
private ProjectService projectService;
@Autowired
private TimesheetService timesheetService;
/**
* Génère une facture depuis les feuilles de temps validées.
* RAGNAR orchestre, FISCAL exécute la logique technique.
*/
public Invoice generateInvoiceFromTimesheet(String projectCode,
LocalDate startDate,
LocalDate endDate) {
// 1. Récupérer données projet
Project project = projectService.getProjectByCode(projectCode);
// 2. Agréger heures validées par employé
Map<String, BigDecimal> hoursByEmployee =
timesheetService.getHoursByProjectAndEmployee(projectCode, startDate, endDate);
// 3. Créer structure Invoice (modèle dynors-invoicing)
Invoice invoice = buildInvoiceFromProject(project, hoursByEmployee, startDate, endDate);
// 4. Déléguer à FISCAL pour création + finalisation
Invoice created = fiscalInvoiceService.createInvoice(invoice);
Invoice finalized = fiscalInvoiceService.finalizeInvoice(created.getInvoiceNumber());
// 5. Suivre dans RAGNAR
trackInvoiceInRagnar(projectCode, finalized);
return finalized;
}
private Invoice buildInvoiceFromProject(Project project,
Map<String, BigDecimal> hoursByEmployee,
LocalDate startDate,
LocalDate endDate) {
Invoice invoice = new Invoice();
// Client (depuis projet)
InvoiceParty client = new InvoiceParty();
client.setName(project.getClientName());
client.setCode(project.getClientCode());
client.setAddress(project.getClientAddress());
client.setCountry(project.getClientCountry()); // SN, FR, etc.
invoice.setClient(client);
// Fournisseur (DYNORS)
InvoiceParty supplier = createDynorsParty();
invoice.setSupplier(supplier);
// Lignes depuis time tracking
List<InvoiceLine> lines = new ArrayList<>();
for (Map.Entry<String, BigDecimal> entry : hoursByEmployee.entrySet()) {
String employeeUid = entry.getKey();
BigDecimal hours = entry.getValue();
BigDecimal tjm = getTJMForEmployee(employeeUid, project);
InvoiceLine line = new InvoiceLine();
line.setDescription("Prestation " + getEmployeeName(employeeUid) + " - " + startDate + " à " + endDate);
line.setQuantity(hours);
line.setUnitPrice(tjm);
line.setTotalPrice(tjm.multiply(hours));
lines.add(line);
}
invoice.setLines(lines);
// Dates
invoice.setInvoiceDate(LocalDate.now());
invoice.setDueDate(LocalDate.now().plusDays(30));
return invoice;
}
}
🔄 FLUX 2 : FISCAL → Création + Finalisation
Étape 2 : FISCAL crée la facture
Fichier : dynors-internal/applications/fiscal/backend/src/main/java/com/dynors/internal/fiscal/invoicing/core/FiscalInvoiceService.java
@Service
public class FiscalInvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceNumberingService numberingService;
private final TaxCalculatorService taxCalculatorService;
private final ComplianceValidationService complianceService;
/**
* Crée une facture.
* Génère automatiquement le numéro séquentiel.
*/
public Invoice createInvoice(Invoice invoice) {
// 1. Générer numéro séquentiel (INV-2025-001, INV-2025-002, etc.)
String countryCode = detectCountryCode(invoice);
String invoiceNumber = numberingService.generateNextInvoiceNumber(countryCode);
invoice.setInvoiceNumber(invoiceNumber);
// 2. Initialiser statut
invoice.setStatus(InvoiceStatus.DRAFT);
// 3. Sauvegarder
return invoiceRepository.save(invoice);
}
/**
* Finalise une facture (calcul taxes, validation compliance).
*/
public Invoice finalizeInvoice(String invoiceNumber) {
Invoice invoice = invoiceRepository.findByInvoiceNumber(invoiceNumber)
.orElseThrow(() -> new IllegalArgumentException("Invoice not found"));
// 1. Calculer taxes selon pays (SN: 18%, FR: 20%)
String countryCode = detectCountryCode(invoice);
BigDecimal taxAmount = taxCalculatorService.calculateTax(invoice.getSubtotal(), countryCode);
invoice.setTotalTax(taxAmount);
invoice.setTotalAmount(invoice.getSubtotal().add(taxAmount));
// 2. Valider compliance
ComplianceResult compliance = complianceService.validate(invoice);
if (!compliance.isValid()) {
throw new ComplianceException("Invoice non conforme", compliance.getErrors());
}
// 3. Mettre à jour statut
invoice.setStatus(InvoiceStatus.FINALIZED);
// 4. Sauvegarder
return invoiceRepository.save(invoice);
}
}
🔄 FLUX 3 : FISCAL → dynors-pdf (Export PDF)
Étape 3A : Export PDF avec logo depuis dynors-media
Fichier : dynors-internal/applications/fiscal/backend/src/main/java/com/dynors/internal/fiscal/invoicing/export/PdfExportService.java (À implémenter)
@Service
public class PdfExportService {
@Autowired
private PdfService pdfService; // dynors-pdf
@Autowired
private MediaService mediaService; // dynors-media
/**
* Exporte une facture en PDF avec logo client.
*
* Flux :
* 1. Récupérer logo client depuis dynors-media
* 2. Générer PDF avec template pays (via dynors-pdf)
* 3. Retourner bytes PDF
*/
public byte[] exportToPdf(Invoice invoice) {
String tenantCode = invoice.getTenantCode();
String country = invoice.getCountry();
// 1. Récupérer logo client depuis dynors-media
MediaFile logo = mediaService.getByCode(
tenantCode,
"logo-client-" + invoice.getClient().getCode()
);
// Convertir en base64 pour intégration dans template HTML
String logoBase64 = null;
if (logo != null) {
try (InputStream logoStream = mediaService.download(tenantCode, logo.getId())) {
byte[] logoBytes = logoStream.readAllBytes();
logoBase64 = "data:image/png;base64," + Base64.getEncoder().encodeToString(logoBytes);
} catch (IOException e) {
// Logo non disponible, continuer sans logo
log.warn("Logo non disponible pour client {}", invoice.getClient().getCode());
}
}
// 2. Préparer données template
Map<String, Object> templateData = new HashMap<>();
templateData.put("invoice", invoice);
templateData.put("client", invoice.getClient());
templateData.put("supplier", invoice.getSupplier());
templateData.put("lines", invoice.getLines());
templateData.put("totals", calculateTotals(invoice));
templateData.put("logoBase64", logoBase64); // Logo en base64
templateData.put("country", country);
// 3. Générer PDF avec template spécifique au pays
// Templates : invoice-template-sn.html, invoice-template-fr.html
String templateName = "invoice-template-" + country.toLowerCase();
byte[] pdfBytes = pdfService.generateFromTemplate(templateName, templateData, country);
return pdfBytes;
}
private Map<String, Object> calculateTotals(Invoice invoice) {
Map<String, Object> totals = new HashMap<>();
totals.put("subtotal", invoice.getSubtotal());
totals.put("tax", invoice.getTotalTax());
totals.put("total", invoice.getTotalAmount());
return totals;
}
}
Étape 3B : dynors-pdf génère le PDF
Fichier : dynors-extensions/packages/extensions/pdf/src/main/java/com/dynors/extensions/pdf/service/PdfServiceImpl.java
@Service
public class PdfServiceImpl implements PdfService {
private final PdfRenderer pdfRenderer;
private final TemplateEngine templateEngine; // Thymeleaf
@Override
public byte[] generateFromTemplate(String templateId, Map<String, Object> data, String country) {
// 1. Résoudre template (invoice-template-sn, invoice-template-fr, etc.)
String templateName = resolveTemplateName(templateId, country);
// 2. Créer contexte Thymeleaf avec données
Context context = new Context(Locale.FRANCE);
if (data != null) {
data.forEach(context::setVariable);
}
// 3. Générer HTML depuis template
String html = templateEngine.process(templateName, context);
// 4. Convertir HTML → PDF (OpenHTMLToPDF)
byte[] pdfBytes = pdfRenderer.htmlToPdf(html);
return pdfBytes;
}
}
Template Thymeleaf : dynors-extensions/packages/extensions/pdf/src/main/resources/templates/pdf/invoice-template-sn.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>Facture</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.header { display: flex; justify-content: space-between; }
.logo { max-width: 150px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
.totals { text-align: right; margin-top: 20px; }
</style>
</head>
<body>
<!-- Logo client (base64 depuis dynors-media) -->
<div class="header">
<div>
<img th:if="${logoBase64 != null}" th:src="${logoBase64}" alt="Logo" class="logo" />
</div>
<div class="invoice-info">
<h1>FACTURE</h1>
<p>N° : <span th:text="${invoice.invoiceNumber}"></span></p>
<p>Date : <span th:text="${#temporals.format(invoice.invoiceDate, 'dd/MM/yyyy')}"></span></p>
</div>
</div>
<!-- Client -->
<div class="client-info">
<h3>Client</h3>
<p th:text="${client.name}"></p>
<p th:text="${client.address}"></p>
<p th:text="${client.city + ', ' + client.country}"></p>
</div>
<!-- Lignes facture -->
<table>
<thead>
<tr>
<th>Description</th>
<th>Quantité</th>
<th>Prix unitaire</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<tr th:each="line : ${lines}">
<td th:text="${line.description}"></td>
<td th:text="${line.quantity}"></td>
<td th:text="${#numbers.formatDecimal(line.unitPrice, 2, 2)} + ' XOF'"></td>
<td th:text="${#numbers.formatDecimal(line.totalPrice, 2, 2)} + ' XOF'"></td>
</tr>
</tbody>
</table>
<!-- Totaux -->
<div class="totals">
<p>Sous-total : <span th:text="${#numbers.formatDecimal(totals.subtotal, 2, 2)} + ' XOF'"></span></p>
<p>TVA (18%) : <span th:text="${#numbers.formatDecimal(totals.tax, 2, 2)} + ' XOF'"></span></p>
<p><strong>Total TTC : <span th:text="${#numbers.formatDecimal(totals.total, 2, 2)} + ' XOF'"></span></strong></p>
</div>
</body>
</html>
🔄 FLUX 4 : FISCAL → dynors-media (Stockage PDF)
Étape 4 : Stocker PDF généré dans dynors-media
Fichier : dynors-internal/applications/fiscal/backend/src/main/java/com/dynors/internal/fiscal/invoicing/core/FiscalInvoiceService.java (Extension)
@Service
public class FiscalInvoiceService {
@Autowired
private PdfExportService pdfExportService;
@Autowired
private MediaService mediaService; // dynors-media
/**
* Génère le PDF d'une facture et le stocke dans dynors-media.
*
* @param invoiceNumber Numéro de la facture
* @return URL du PDF stocké
*/
public String generateAndStorePdf(String invoiceNumber) {
// 1. Récupérer facture
Invoice invoice = getInvoice(invoiceNumber);
// 2. Générer PDF (avec logo depuis dynors-media)
byte[] pdfBytes = pdfExportService.exportToPdf(invoice);
// 3. Stocker PDF dans dynors-media
String tenantCode = invoice.getTenantCode();
MediaFile pdfFile = mediaService.create(
tenantCode,
new ByteArrayInputStream(pdfBytes),
"facture-" + invoiceNumber + ".pdf",
"application/pdf",
Map.of(
"code", "facture-" + invoiceNumber,
"description", "Facture " + invoiceNumber,
"tags", List.of("facture", "pdf", invoiceNumber),
"entityType", "invoice",
"entityId", invoice.getId(),
"source_application", "fiscal",
"source_version", "1.0.0"
)
);
// 4. Retourner URL
return mediaService.getPublicUrl(tenantCode, pdfFile.getId());
}
/**
* Récupère l'URL du PDF d'une facture stocké dans dynors-media.
*/
public String getInvoicePdfUrl(String invoiceNumber) {
Invoice invoice = getInvoice(invoiceNumber);
String tenantCode = invoice.getTenantCode();
// Rechercher PDF dans dynors-media
MediaFile pdfFile = mediaService.getByCode(tenantCode, "facture-" + invoiceNumber);
return mediaService.getPublicUrl(tenantCode, pdfFile.getId());
}
}
🔄 FLUX 5 : API REST pour téléchargement PDF
Étape 5 : Endpoint pour télécharger le PDF
Fichier : dynors-internal/applications/fiscal/backend/src/main/java/com/dynors/internal/fiscal/invoicing/controller/InvoiceController.java (À créer)
@RestController
@RequestMapping("/api/fiscal/invoices")
public class InvoiceController {
@Autowired
private FiscalInvoiceService fiscalInvoiceService;
/**
* Télécharger le PDF d'une facture.
*
* GET /api/fiscal/invoices/INV-2025-001/pdf
*/
@GetMapping("/{invoiceNumber}/pdf")
public ResponseEntity<Resource> downloadInvoicePdf(@PathVariable String invoiceNumber) {
// Générer et stocker PDF si pas déjà fait
String pdfUrl = fiscalInvoiceService.generateAndStorePdf(invoiceNumber);
// Rediriger vers dynors-media pour téléchargement
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(pdfUrl))
.build();
}
/**
* Régénérer le PDF d'une facture (force refresh).
*
* POST /api/fiscal/invoices/INV-2025-001/pdf/regenerate
*/
@PostMapping("/{invoiceNumber}/pdf/regenerate")
public ResponseEntity<String> regenerateInvoicePdf(@PathVariable String invoiceNumber) {
String pdfUrl = fiscalInvoiceService.generateAndStorePdf(invoiceNumber);
return ResponseEntity.ok(pdfUrl);
}
}
📊 Diagramme de Séquence Complet
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌────────────┐ ┌─────────────┐
│ RAGNAR │ │ FISCAL │ │ dynors- │ │ dynors- │ │ dynors- │
│ │ │ │ │ pdf │ │ media │ │ media │
└────┬────┘ └────┬────┘ └────┬────┘ └─────┬──────┘ └──────┬──────┘
│ │ │ │ │
│ 1. generateInvoice│ │ │ │
│──────────────────>│ │ │ │
│ │ │ │ │
│ │ 2. createInvoice()│ │ │
│ │────────┐ │ │ │
│ │ │ Numérotation séquentielle │ │
│ │<───────┘ │ │ │
│ │ │ │ │
│ │ 3. finalizeInvoice() │ │
│ │────────┐ │ │ │
│ │ │ Taxes + Compliance │ │
│ │<───────┘ │ │ │
│ │ │ │ │
│ │ 4. exportToPdf() │ │ │
│ │──────────────────>│ │ │
│ │ │ │ │
│ │ │ 5. getByCode("logo-client-X") │
│ │ │───────────────────────────────────────────>│
│ │ │ │ │
│ │ │ 6. logo bytes │ │
│ │ │<───────────────────────────────────────────│
│ │ │ │ │
│ │ │ 7. generateFromTemplate() │
│ │ │────────┐ │ │
│ │ │ │ HTML → PDF│ │
│ │ │<───────┘ │ │
│ │ │ │ │
│ │ 8. PDF bytes │ │ │
│ │<──────────────────│ │ │
│ │ │ │ │
│ │ 9. create("facture-INV-2025-001.pdf") │ │
│ │────────────────────────────────────────────────────────────────>│
│ │ │ │ │
│ │ 10. MediaFile + URL │ │
│ │<────────────────────────────────────────────────────────────────│
│ │ │ │ │
│ 11. Invoice + PDF URL │ │ │
│<──────────────────│ │ │ │
🎯 État Actuel vs État Cible
| Composant | État Actuel | État Cible | Action Requise |
|---|---|---|---|
| RAGNAR BillingService | ✅ Existe, logique simplifiée | ⏳ Doit appeler FISCAL | Refactoriser pour déléguer à FISCAL |
| FISCAL FiscalInvoiceService | ✅ Existe (taxes, numbering, compliance) | ⏳ Doit intégrer PDF + media | Ajouter generateAndStorePdf() |
| FISCAL PdfExportService | ❌ Stub (TODO) | ⏳ Doit utiliser dynors-pdf + media | Implémenter avec PdfService + MediaService |
| dynors-pdf | ✅ Existe (SDK mode) | ✅ Prêt | Utilisable |
| dynors-media | ⏳ En cours (Phase 1) | ⏳ Déployer + exposer API | Finaliser MVP + déployer |
| Templates PDF | ✅ Exemple existe (invoice-template-sn.html) | ⏳ Compléter templates (FR, etc.) | Créer templates par pays |
| Controller FISCAL | ❌ Manquant | ⏳ Exposer API REST | Créer InvoiceController |
🚀 Plan d'Action pour Compléter le Mécanisme
Phase 1 : Finaliser dynors-media (Semaines 1-2)
- ✅ Finaliser MVP dynors-media (backend + API REST)
- ✅ Déployer en staging
- ✅ Tester upload/download/search
Référence : PLAN_DEVELOPPEMENT_DYNORS_MEDIA.md
Phase 2 : Implémenter FISCAL PdfExportService (Semaine 3)
-
⏳ Ajouter dépendances dans
fiscal/build.gradle.kts:kotlin implementation("com.dynors:dynors-pdf:1.0.0-SNAPSHOT") implementation("com.dynors:dynors-media-client:1.0.0-SNAPSHOT") -
⏳ Implémenter
PdfExportService.exportToPdf(): - Récupérer logo depuis dynors-media
- Générer PDF via dynors-pdf
-
Retourner bytes
-
⏳ Ajouter
FiscalInvoiceService.generateAndStorePdf(): - Appeler
PdfExportService.exportToPdf() - Stocker PDF dans dynors-media
-
Retourner URL
-
⏳ Tests unitaires + intégration
Phase 3 : Créer InvoiceController (Semaine 3)
- ⏳ Créer
InvoiceControlleravec endpoints : GET /api/fiscal/invoices/{invoiceNumber}/pdf-
POST /api/fiscal/invoices/{invoiceNumber}/pdf/regenerate -
⏳ Tests E2E
Phase 4 : Refactoriser RAGNAR (Semaine 4)
- ⏳ Ajouter client FISCAL dans
ragnar/build.gradle.kts - ⏳ Refactoriser
BillingServicepour appeler FISCAL - ⏳ Tests E2E complets
Phase 5 : Templates PDF par pays (Semaine 4)
- ⏳ Créer
invoice-template-fr.html(France, TVA 20%) - ⏳ Compléter
invoice-template-sn.html(Sénégal, TVA 18%) - ⏳ Tests visuels
📚 Références
- FISCAL Architecture :
dynors-internal/applications/fiscal/backend/ARCHITECTURE.md - dynors-pdf Guide :
core/GUIDE_INTEGRATION_DYNORS_PDF.md - dynors-media Plan :
PLAN_DEVELOPPEMENT_DYNORS_MEDIA.md - RAGNAR Facturation :
core/INTEGRATION_FACTURATION_SPECIFICATIONS.md - Besoins archi :
docs/BESOINS_ARCHI_APPS_FISCAL_PDF_MEDIA.md
Date création : 2026-01-30
Status : ✅ Documentation complète du mécanisme