Compare commits
3 Commits
a13fe40eb4
...
f7cfa73206
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7cfa73206 | ||
|
|
1b7a46e05f | ||
|
|
2c5b285e54 |
@@ -42,6 +42,8 @@ services:
|
|||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
# Chemin unique utilisé par le serveur et vérifié au démarrage.
|
# Chemin unique utilisé par le serveur et vérifié au démarrage.
|
||||||
STORAGE_BASE_PATH: /app/storage
|
STORAGE_BASE_PATH: /app/storage
|
||||||
|
# Le déploiement Santinova requiert le volume historique des PDF.
|
||||||
|
REQUIRE_STORAGE_VOLUME: "true"
|
||||||
# Manus OAuth (optionnel en recette)
|
# Manus OAuth (optionnel en recette)
|
||||||
VITE_APP_ID: ${VITE_APP_ID:-demat-facturation-recette}
|
VITE_APP_ID: ${VITE_APP_ID:-demat-facturation-recette}
|
||||||
OAUTH_SERVER_URL: ${OAUTH_SERVER_URL:-}
|
OAUTH_SERVER_URL: ${OAUTH_SERVER_URL:-}
|
||||||
|
|||||||
@@ -5,20 +5,27 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
storage_path="${STORAGE_BASE_PATH:-/app/storage}"
|
storage_path="${STORAGE_BASE_PATH:-/app/storage}"
|
||||||
|
require_storage_volume="${REQUIRE_STORAGE_VOLUME:-false}"
|
||||||
|
|
||||||
if [ ! -d "$storage_path" ]; then
|
if [ "$require_storage_volume" = "true" ]; then
|
||||||
|
if [ ! -d "$storage_path" ]; then
|
||||||
echo "[StorageGuard] Répertoire de stockage absent : $storage_path" >&2
|
echo "[StorageGuard] Répertoire de stockage absent : $storage_path" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! awk -v expected="$storage_path" '$2 == expected { mounted = 1 } END { exit mounted ? 0 : 1 }' /proc/mounts; then
|
if ! awk -v expected="$storage_path" '$2 == expected { mounted = 1 } END { exit mounted ? 0 : 1 }' /proc/mounts; then
|
||||||
echo "[StorageGuard] Volume persistant non monté sur $storage_path ; démarrage annulé." >&2
|
echo "[StorageGuard] Volume persistant non monté sur $storage_path ; démarrage annulé." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -w "$storage_path" ]; then
|
if [ ! -w "$storage_path" ]; then
|
||||||
echo "[StorageGuard] Volume persistant non inscriptible : $storage_path" >&2
|
echo "[StorageGuard] Volume persistant non inscriptible : $storage_path" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# L’hébergement cloud n’expose pas de volume Docker local. Il utilise le
|
||||||
|
# stockage applicatif disponible à l’exécution et doit donc pouvoir démarrer.
|
||||||
|
mkdir -p "$storage_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec node dist/index.js
|
exec node dist/index.js
|
||||||
|
|||||||
@@ -26,4 +26,13 @@ describe("routage et persistance de déploiement", () => {
|
|||||||
expect(guardScript).toContain("Volume persistant non monté");
|
expect(guardScript).toContain("Volume persistant non monté");
|
||||||
expect(guardScript).toContain("/proc/mounts");
|
expect(guardScript).toContain("/proc/mounts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("réserve le contrôle bloquant du volume aux environnements Santinova", () => {
|
||||||
|
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
|
||||||
|
const guardScript = readFileSync(join(process.cwd(), "scripts", "ensure-storage-volume.sh"), "utf8");
|
||||||
|
|
||||||
|
expect(composeFile).toContain('REQUIRE_STORAGE_VOLUME: "true"');
|
||||||
|
expect(guardScript).toContain('REQUIRE_STORAGE_VOLUME:-false');
|
||||||
|
expect(guardScript).toContain('mkdir -p "$storage_path"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
15
todo.md
15
todo.md
@@ -904,4 +904,17 @@
|
|||||||
## Prévention du démontage des PDF après reconstruction
|
## Prévention du démontage des PDF après reconstruction
|
||||||
- [x] Auditer les montages de stockage et la chaîne de reconstruction en recette et production
|
- [x] Auditer les montages de stockage et la chaîne de reconstruction en recette et production
|
||||||
- [x] Ajouter un contrôle bloquant avant reconstruction et une vérification après démarrage du volume `/app/storage`
|
- [x] Ajouter un contrôle bloquant avant reconstruction et une vérification après démarrage du volume `/app/storage`
|
||||||
- [ ] Vérifier les garde-fous sur les deux environnements sans modifier les données
|
- [x] Vérifier les garde-fous sur les deux environnements sans modifier les données
|
||||||
|
|
||||||
|
## Correctif du démarrage cloud après garde-fou PDF
|
||||||
|
- [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] 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
|
||||||
|
|||||||
Reference in New Issue
Block a user