Checkpoint: Ajout de l'association Service→Signature dans l'onglet Signatures des Paramètres et apposition automatique de la signature sur les PDFs exportés lors de l'export BAP. Inclut : table serviceSignatures, routes tRPC, interface de configuration, et modification de exportToPdf pour apposer la signature en bas à droite du PDF avec le nom du signataire.
This commit is contained in:
42
server/db.ts
42
server/db.ts
@@ -35,7 +35,10 @@ import {
|
||||
LlmFieldConfig,
|
||||
signatures,
|
||||
InsertSignature,
|
||||
Signature
|
||||
Signature,
|
||||
serviceSignatures,
|
||||
InsertServiceSignature,
|
||||
ServiceSignature
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
@@ -701,3 +704,40 @@ export async function deleteSignature(id: number): Promise<void> {
|
||||
if (!db) return;
|
||||
await db.delete(signatures).where(eq(signatures.id, id));
|
||||
}
|
||||
|
||||
// ============= SERVICE SIGNATURES HELPERS =============
|
||||
|
||||
export async function getServiceSignaturesByUser(userId: number): Promise<ServiceSignature[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId));
|
||||
}
|
||||
|
||||
export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const results = await db.select().from(serviceSignatures)
|
||||
.where(eq(serviceSignatures.userId, userId))
|
||||
.limit(1);
|
||||
// Filter in JS to avoid SQL case sensitivity issues
|
||||
return results.find(r => r.serviceName.toLowerCase() === serviceName.toLowerCase());
|
||||
}
|
||||
|
||||
export async function upsertServiceSignature(data: InsertServiceSignature): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.insert(serviceSignatures).values(data)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { signatureId: data.signatureId, updatedAt: new Date() }
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteServiceSignature(userId: number, serviceName: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
const all = await db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId));
|
||||
const match = all.find(r => r.serviceName.toLowerCase() === serviceName.toLowerCase());
|
||||
if (match) {
|
||||
await db.delete(serviceSignatures).where(eq(serviceSignatures.id, match.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ import {
|
||||
getSignatureById,
|
||||
createSignature,
|
||||
deleteSignature,
|
||||
getServiceSignaturesByUser,
|
||||
upsertServiceSignature,
|
||||
deleteServiceSignature,
|
||||
} from "./db";
|
||||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||
@@ -568,11 +571,15 @@ export const appRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
|
||||
// Get export folder from settings
|
||||
const settings = await getImportSettingsByUser(ctx.user.id);
|
||||
const exportFolder = settings?.exportFolder;
|
||||
|
||||
// Load service→signature associations
|
||||
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
|
||||
|
||||
if (!exportFolder) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -622,9 +629,76 @@ export const appRouter = router({
|
||||
const filename = path.basename(invoice.fileKey);
|
||||
const destPath = path.join(exportFolder, filename);
|
||||
|
||||
// Copy file
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
// Find signature associated with invoice service
|
||||
const serviceName = invoice.serviceConcerne || '';
|
||||
const assoc = serviceAssociations.find(
|
||||
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
|
||||
);
|
||||
|
||||
if (assoc) {
|
||||
// Load signature image and embed it in the PDF
|
||||
try {
|
||||
const sig = await getSignatureById(assoc.signatureId);
|
||||
if (sig) {
|
||||
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
|
||||
const pdfBytes = await fs.readFile(sourcePath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const { width, height } = lastPage.getSize();
|
||||
|
||||
// Read signature image
|
||||
const sigImageBytes = await fs.readFile(sigImagePath);
|
||||
const sigImageBase64 = sigImageBytes.toString('base64');
|
||||
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
|
||||
let embeddedSig;
|
||||
if (mimeType === 'image/png') {
|
||||
embeddedSig = await pdfDoc.embedPng(sigImageBytes);
|
||||
} else {
|
||||
embeddedSig = await pdfDoc.embedJpg(sigImageBytes);
|
||||
}
|
||||
|
||||
// Draw signature in bottom-right corner
|
||||
const sigWidth = 120;
|
||||
const sigHeight = 50;
|
||||
const margin = 30;
|
||||
lastPage.drawImage(embeddedSig, {
|
||||
x: width - sigWidth - margin,
|
||||
y: margin,
|
||||
width: sigWidth,
|
||||
height: sigHeight,
|
||||
});
|
||||
|
||||
// Add signer name below signature
|
||||
const { StandardFonts } = await import('pdf-lib');
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
lastPage.drawText(`${sig.firstName} ${sig.lastName}`, {
|
||||
x: width - sigWidth - margin,
|
||||
y: margin - 14,
|
||||
size: 9,
|
||||
font,
|
||||
});
|
||||
|
||||
const signedPdfBytes = await pdfDoc.save();
|
||||
await fs.writeFile(destPath, signedPdfBytes);
|
||||
copiedFiles.push(destPath);
|
||||
console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`);
|
||||
} else {
|
||||
// Signature not found, copy without signature
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
}
|
||||
} catch (sigError: any) {
|
||||
console.warn(`[Export] Impossible d'apposer la signature: ${sigError.message}. Copie sans signature.`);
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
}
|
||||
} else {
|
||||
// No signature association, copy as-is
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
}
|
||||
|
||||
// Update export status
|
||||
await updateInvoice(invoice.id, {
|
||||
@@ -1222,7 +1296,7 @@ export const appRouter = router({
|
||||
});
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const sig = await getSignatureById(input.id);
|
||||
@@ -1233,6 +1307,33 @@ export const appRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// ============= SERVICE SIGNATURES ROUTES =============
|
||||
serviceSignatures: router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getServiceSignaturesByUser(ctx.user.id);
|
||||
}),
|
||||
|
||||
upsert: protectedProcedure
|
||||
.input(z.object({
|
||||
serviceName: z.string().min(1).max(100),
|
||||
signatureId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await upsertServiceSignature({
|
||||
userId: ctx.user.id,
|
||||
serviceName: input.serviceName,
|
||||
signatureId: input.signatureId,
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ serviceName: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await deleteServiceSignature(ctx.user.id, input.serviceName);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
Reference in New Issue
Block a user