Checkpoint: Les exportations PDF manuelles résolvent désormais une destination facture par facture via les règles actives d’Automatismes d’export, y compris pour les factures d’abonnement. Les paramètres globaux demeurent le repli ; les destinations Teams et SharePoint utilisent l’envoi Microsoft existant. Suite complète : 56 tests, TypeScript et build validés.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 3m6s

This commit is contained in:
Manus
2026-09-04 10:44:41 +00:00
parent 1b7a46e05f
commit f7cfa73206
3 changed files with 47 additions and 32 deletions

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { findMatchingExportRule } from "../shared/exportAutomation"; import { findMatchingExportRule } from "../shared/exportAutomation";
const rules = [ const rules = [
@@ -23,4 +25,15 @@ describe("règles de destination d’export", () => {
it("ne retourne aucune règle si la facture ne correspond à aucun critère", () => { it("ne retourne aucune règle si la facture ne correspond à aucun critère", () => {
expect(findMatchingExportRule(rules, { recipientName: "Service achats", ventilationComptable: "606000" })).toBeUndefined(); expect(findMatchingExportRule(rules, { recipientName: "Service achats", ventilationComptable: "606000" })).toBeUndefined();
}); });
it("applique aussi le résolveur de destination aux exports PDF de factures sélectionnées", () => {
const routerSource = readFileSync(join(process.cwd(), "server", "routers.ts"), "utf8");
const exportPdfSection = routerSource.slice(
routerSource.indexOf("exportToPdf: protectedProcedure"),
routerSource.indexOf("exportInvoices: protectedProcedure"),
);
expect(exportPdfSection).toContain("resolveBapExportDestination(ctx.user.id, invoice, settings)");
expect(exportPdfSection).toContain("isMicrosoftDestination");
});
}); });

View File

@@ -1334,22 +1334,13 @@ export const appRouter = router({
const path = await import('path'); const path = await import('path');
const { PDFDocument } = await import('pdf-lib'); const { PDFDocument } = await import('pdf-lib');
// Get export folder from settings // Les paramètres globaux restent le repli lorsqu’aucune règle de
// destination ne cible une facture exportée.
const settings = await getImportSettingsByUser(ctx.user.id); const settings = await getImportSettingsByUser(ctx.user.id);
const exportFolder = settings?.exportFolder;
const exportFolderType = (settings as any)?.exportFolderType || 'local';
const isSharePoint = exportFolderType === 'sharepoint';
// Load service→signature associations // Load service→signature associations
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
if (!exportFolder) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le dossier d'export n'est pas configuré. Veuillez le définir dans les paramètres de réception."
});
}
// Validate that all invoices have quality score of 100 // Validate that all invoices have quality score of 100
const invoices = await Promise.all( const invoices = await Promise.all(
input.invoiceIds.map(id => getInvoiceById(id)) input.invoiceIds.map(id => getInvoiceById(id))
@@ -1366,29 +1357,32 @@ export const appRouter = router({
}); });
} }
// Create export folder if not SharePoint // La destination est résolue facture par facture : une sélection peut
if (!isSharePoint) { // contenir plusieurs abonnements avec des règles de priorité distinctes.
try {
await fs.mkdir(exportFolder, { recursive: true });
} catch (error) {
console.error('Error creating export folder:', error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Impossible de créer le dossier d'export: ${exportFolder}`
});
}
}
console.log(`[exportToPdf] Mode: ${exportFolderType}, dossier: ${exportFolder}, ${invoices.length} facture(s)`);
// Copy/upload PDFs to export folder
const copiedFiles: string[] = []; const copiedFiles: string[] = [];
const errors: string[] = []; const errors: string[] = [];
const usedDestinations = new Set<string>();
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), "storage"); const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), "storage");
for (const invoice of invoices) { for (const invoice of invoices) {
if (invoice && invoice.fileKey) { if (invoice && invoice.fileKey) {
try { try {
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, settings);
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
const isMicrosoftDestination = exportFolderType === "sharepoint" || exportFolderType === "teams";
if (!exportFolder) {
throw new Error("Aucune destination d'export n'est configurée pour cette facture");
}
if (!isMicrosoftDestination) {
await fs.mkdir(exportFolder, { recursive: true });
}
usedDestinations.add(exportFolder);
console.log(`[exportToPdf] Facture ${invoice.id}: ${resolvedDestination.source}/${exportFolderType} vers ${exportFolder}`);
// Build source path from fileKey // Build source path from fileKey
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey); const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
@@ -1474,7 +1468,7 @@ export const appRouter = router({
const signedPdfBytes = await pdfDoc.save(); const signedPdfBytes = await pdfDoc.save();
console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`); console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`);
if (isSharePoint) { if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint'); const { uploadToSharePoint } = await import('./sharepoint');
console.log(`[exportToPdf] Upload SharePoint: ${filename}`); console.log(`[exportToPdf] Upload SharePoint: ${filename}`);
const spResult = await uploadToSharePoint( const spResult = await uploadToSharePoint(
@@ -1497,7 +1491,7 @@ export const appRouter = router({
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`); const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
pdfBuf = Buffer.from(await rb.arrayBuffer()); pdfBuf = Buffer.from(await rb.arrayBuffer());
} }
if (isSharePoint) { if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint'); const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint( const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder }, { tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
@@ -1519,7 +1513,7 @@ export const appRouter = router({
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`); const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
pdfBuf = Buffer.from(await rb.arrayBuffer()); pdfBuf = Buffer.from(await rb.arrayBuffer());
} }
if (isSharePoint) { if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint'); const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint( const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder }, { tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
@@ -1541,7 +1535,7 @@ export const appRouter = router({
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`); const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
pdfBuf = Buffer.from(await rb.arrayBuffer()); pdfBuf = Buffer.from(await rb.arrayBuffer());
} }
if (isSharePoint) { if (isMicrosoftDestination) {
const { uploadToSharePoint } = await import('./sharepoint'); const { uploadToSharePoint } = await import('./sharepoint');
console.log(`[exportToPdf] Upload SharePoint sans signature: ${filename}`); console.log(`[exportToPdf] Upload SharePoint sans signature: ${filename}`);
const spResult = await uploadToSharePoint( const spResult = await uploadToSharePoint(
@@ -1585,7 +1579,7 @@ export const appRouter = router({
return { return {
success: true, success: true,
exportFolder, exportFolder: Array.from(usedDestinations).join(", "),
copiedCount: copiedFiles.length, copiedCount: copiedFiles.length,
invoices: invoices.filter(Boolean).map(inv => ({ invoices: invoices.filter(Boolean).map(inv => ({
id: inv!.id, id: inv!.id,

View File

@@ -910,3 +910,11 @@
- [x] Adapter le garde-fou afin qu’il soit obligatoire uniquement avec `REQUIRE_STORAGE_VOLUME=true` - [x] Adapter le garde-fou afin qu’il soit obligatoire uniquement avec `REQUIRE_STORAGE_VOLUME=true`
- [x] Conserver le contrôle strict du volume sur recette et production Santinova - [x] Conserver le contrôle strict du volume sur recette et production Santinova
- [x] Valider le démarrage du déploiement cloud sans volume local - [x] Valider le démarrage du déploiement cloud sans volume local
## Portée des règles d’automatismes d’export
- [x] Vérifier l’application des règles lors des validations BAP : les règles actives sont bien résolues lors de la validation unitaire ou en masse
- [x] Vérifier l’application des règles lors des exports de factures d’abonnement : elles ne sont pas résolues par l’export générique actuel
- [x] Aligner l’export des factures d’abonnement sur les règles de destination après confirmation utilisateur
- [x] Résoudre une destination par facture d’abonnement sélectionnée selon les règles actives
- [x] Préserver les paramètres globaux comme repli lorsqu’aucune règle ne correspond
- [x] Ajouter les tests de priorité et de regroupement des exports d’abonnements