Aller au contenu

🔄 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)

  1. ✅ Finaliser MVP dynors-media (backend + API REST)
  2. ✅ Déployer en staging
  3. ✅ Tester upload/download/search

Référence : PLAN_DEVELOPPEMENT_DYNORS_MEDIA.md

Phase 2 : Implémenter FISCAL PdfExportService (Semaine 3)

  1. ⏳ 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")

  2. ⏳ Implémenter PdfExportService.exportToPdf() :

  3. Récupérer logo depuis dynors-media
  4. Générer PDF via dynors-pdf
  5. Retourner bytes

  6. ⏳ Ajouter FiscalInvoiceService.generateAndStorePdf() :

  7. Appeler PdfExportService.exportToPdf()
  8. Stocker PDF dans dynors-media
  9. Retourner URL

  10. ⏳ Tests unitaires + intégration

Phase 3 : Créer InvoiceController (Semaine 3)

  1. ⏳ Créer InvoiceController avec endpoints :
  2. GET /api/fiscal/invoices/{invoiceNumber}/pdf
  3. POST /api/fiscal/invoices/{invoiceNumber}/pdf/regenerate

  4. ⏳ Tests E2E

Phase 4 : Refactoriser RAGNAR (Semaine 4)

  1. ⏳ Ajouter client FISCAL dans ragnar/build.gradle.kts
  2. ⏳ Refactoriser BillingService pour appeler FISCAL
  3. ⏳ Tests E2E complets

Phase 5 : Templates PDF par pays (Semaine 4)

  1. ⏳ Créer invoice-template-fr.html (France, TVA 20%)
  2. ⏳ Compléter invoice-template-sn.html (Sénégal, TVA 18%)
  3. ⏳ 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