Checkpoint: Automatismes séparé en onglets Import et Export. Les règles d’export ciblent un destinataire ou une ventilation, avec destination locale, Teams ou SharePoint et ouverture navigateur optionnelle. La validation BAP unique ou en masse applique la première règle active par priorité puis conserve le paramétrage historique comme repli. Paramètres d’export déplacés vers Automatismes et page renommée Paramètres. Migration additive, tests, TypeScript, build et contrôles visuels validés.
This commit is contained in:
44
server/db.ts
44
server/db.ts
@@ -30,6 +30,9 @@ import {
|
||||
automationRules,
|
||||
InsertAutomationRule,
|
||||
AutomationRule,
|
||||
exportAutomationRules,
|
||||
InsertExportAutomationRule,
|
||||
ExportAutomationRule,
|
||||
llmFieldsConfig,
|
||||
InsertLlmFieldConfig,
|
||||
LlmFieldConfig,
|
||||
@@ -747,6 +750,47 @@ export async function deleteAutomationRule(id: number): Promise<void> {
|
||||
await db.delete(automationRules).where(eq(automationRules.id, id));
|
||||
}
|
||||
|
||||
// ============= EXPORT AUTOMATION RULES OPERATIONS =============
|
||||
|
||||
export async function getExportAutomationRulesByUser(userId: number): Promise<ExportAutomationRule[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(exportAutomationRules)
|
||||
.where(eq(exportAutomationRules.userId, userId))
|
||||
.orderBy(exportAutomationRules.priority, exportAutomationRules.id);
|
||||
}
|
||||
|
||||
export async function getExportAutomationRuleById(id: number): Promise<ExportAutomationRule | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const [rule] = await db.select().from(exportAutomationRules).where(eq(exportAutomationRules.id, id));
|
||||
return rule || null;
|
||||
}
|
||||
|
||||
export async function createExportAutomationRule(data: InsertExportAutomationRule): Promise<ExportAutomationRule> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(exportAutomationRules).values(data);
|
||||
const rule = await getExportAutomationRuleById(result[0].insertId);
|
||||
if (!rule) throw new Error("Export automation rule could not be created");
|
||||
return rule;
|
||||
}
|
||||
|
||||
export async function updateExportAutomationRule(id: number, data: Partial<InsertExportAutomationRule>): Promise<ExportAutomationRule> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(exportAutomationRules).set(data).where(eq(exportAutomationRules.id, id));
|
||||
const rule = await getExportAutomationRuleById(id);
|
||||
if (!rule) throw new Error("Export automation rule not found");
|
||||
return rule;
|
||||
}
|
||||
|
||||
export async function deleteExportAutomationRule(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.delete(exportAutomationRules).where(eq(exportAutomationRules.id, id));
|
||||
}
|
||||
|
||||
// ============= INITIALIZE DEFAULT VALUES =============
|
||||
|
||||
export async function initializeDefaultLists(userId: number): Promise<void> {
|
||||
|
||||
26
server/exportAutomation.test.ts
Normal file
26
server/exportAutomation.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findMatchingExportRule } from "../shared/exportAutomation";
|
||||
|
||||
const rules = [
|
||||
{ isActive: 1, priority: 2, conditionField: "recipientName" as const, conditionValue: "Direction générale" },
|
||||
{ isActive: 1, priority: 1, conditionField: "ventilationComptable" as const, conditionValue: "615200" },
|
||||
{ isActive: 0, priority: 0, conditionField: "recipientName" as const, conditionValue: "Direction générale" },
|
||||
];
|
||||
|
||||
describe("règles de destination d’export", () => {
|
||||
it("sélectionne une règle de ventilation active", () => {
|
||||
expect(findMatchingExportRule(rules, { recipientName: "Direction générale", ventilationComptable: "615200" })).toBe(rules[1]);
|
||||
});
|
||||
|
||||
it("compare les destinataires sans tenir compte de la casse et des espaces", () => {
|
||||
expect(findMatchingExportRule(rules, { recipientName: " direction GÉNÉRALE " })).toBe(rules[0]);
|
||||
});
|
||||
|
||||
it("ignore les règles inactives", () => {
|
||||
expect(findMatchingExportRule([rules[2]], { recipientName: "Direction générale" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ne retourne aucune règle si la facture ne correspond à aucun critère", () => {
|
||||
expect(findMatchingExportRule(rules, { recipientName: "Service achats", ventilationComptable: "606000" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
42
server/exportDestinationResolver.ts
Normal file
42
server/exportDestinationResolver.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ImportSettings, Invoice } from "../drizzle/schema";
|
||||
import { findMatchingExportRule } from "@shared/exportAutomation";
|
||||
import { getExportAutomationRulesByUser } from "./db";
|
||||
|
||||
export type ResolvedExportDestination = {
|
||||
exportMode: "browser" | "folder" | "both";
|
||||
destinationPath: string | null;
|
||||
destinationType: "local" | "teams" | "sharepoint";
|
||||
source: "rule" | "default";
|
||||
ruleName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Résout une destination pour une validation BAP. Une règle active ciblant le
|
||||
* destinataire ou la ventilation est prioritaire sur les anciens paramètres
|
||||
* globaux, qui restent le comportement de repli pour préserver l’existant.
|
||||
*/
|
||||
export async function resolveBapExportDestination(
|
||||
userId: number,
|
||||
invoice: Pick<Invoice, "recipientName" | "ventilationComptable">,
|
||||
settings: ImportSettings | null,
|
||||
): Promise<ResolvedExportDestination> {
|
||||
const rules = await getExportAutomationRulesByUser(userId);
|
||||
const matchingRule = findMatchingExportRule(rules, invoice);
|
||||
|
||||
if (matchingRule) {
|
||||
return {
|
||||
exportMode: matchingRule.openInBrowser === 1 ? "both" : "folder",
|
||||
destinationPath: matchingRule.destinationPath,
|
||||
destinationType: matchingRule.destinationType,
|
||||
source: "rule",
|
||||
ruleName: matchingRule.name,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exportMode: settings?.bapExportMode || "browser",
|
||||
destinationPath: settings?.exportFolder || null,
|
||||
destinationType: settings?.exportFolderType || "local",
|
||||
source: "default",
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,11 @@ import {
|
||||
createAutomationRule,
|
||||
updateAutomationRule,
|
||||
deleteAutomationRule,
|
||||
getExportAutomationRulesByUser,
|
||||
getExportAutomationRuleById,
|
||||
createExportAutomationRule,
|
||||
updateExportAutomationRule,
|
||||
deleteExportAutomationRule,
|
||||
getSignaturesByUser,
|
||||
getSignatureById,
|
||||
createSignature,
|
||||
@@ -97,6 +102,7 @@ import { calculateFileSha256 } from "./fileFingerprint";
|
||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||
import { drawBapCartouche } from "./bapCartouche";
|
||||
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
||||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -521,9 +527,10 @@ export const appRouter = router({
|
||||
const { localStoragePut, generateStorageKey } = await import('./localStorage');
|
||||
|
||||
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
||||
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
||||
const exportFolder = importSettings?.exportFolder || null;
|
||||
const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
|
||||
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
|
||||
const bapExportMode = resolvedDestination.exportMode;
|
||||
const exportFolder = resolvedDestination.destinationPath;
|
||||
const exportFolderType = resolvedDestination.destinationType;
|
||||
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
||||
|
||||
let pdfUrl: string | null = null;
|
||||
@@ -609,11 +616,11 @@ export const appRouter = router({
|
||||
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
|
||||
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
|
||||
|
||||
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
|
||||
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, source: ${resolvedDestination.source}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
|
||||
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
||||
if (exportFolderType === 'sharepoint') {
|
||||
// Mode SharePoint : upload via Microsoft Graph
|
||||
console.log('[BAP] Démarrage upload SharePoint pour:', bapFilename);
|
||||
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
|
||||
// Les fichiers Teams sont déposés via le site SharePoint de l’équipe.
|
||||
console.log('[BAP] Démarrage upload Microsoft 365 pour:', bapFilename);
|
||||
const { uploadToSharePoint } = await import('./sharepoint');
|
||||
const spResult = await uploadToSharePoint(
|
||||
{
|
||||
@@ -740,9 +747,6 @@ export const appRouter = router({
|
||||
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
||||
const { localStoragePut, generateStorageKey } = await import('./localStorage');
|
||||
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
||||
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
||||
const exportFolder = importSettings?.exportFolder || null;
|
||||
const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
|
||||
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
||||
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
|
||||
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
|
||||
@@ -756,6 +760,10 @@ export const appRouter = router({
|
||||
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
|
||||
let sharepointUploadPath: string | null = null;
|
||||
let sharepointUploadError: string | null = null;
|
||||
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
|
||||
const bapExportMode = resolvedDestination.exportMode;
|
||||
const exportFolder = resolvedDestination.destinationPath;
|
||||
const exportFolderType = resolvedDestination.destinationType;
|
||||
try {
|
||||
// ─ Lecture du PDF source ─
|
||||
let sourcePdfBytes: Buffer;
|
||||
@@ -826,7 +834,7 @@ export const appRouter = router({
|
||||
const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
|
||||
const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf';
|
||||
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
||||
if (exportFolderType === 'sharepoint') {
|
||||
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
|
||||
const { uploadToSharePoint } = await import('./sharepoint');
|
||||
const spResult = await uploadToSharePoint(
|
||||
{
|
||||
@@ -2276,6 +2284,55 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
// ============= EXPORT AUTOMATION RULES =============
|
||||
exportAutomationRules: router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getExportAutomationRulesByUser(ctx.user.id);
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.input(z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
conditionField: z.enum(["recipientName", "ventilationComptable"]),
|
||||
conditionValue: z.string().trim().min(1).max(255),
|
||||
destinationType: z.enum(["local", "teams", "sharepoint"]),
|
||||
destinationPath: z.string().trim().min(1),
|
||||
openInBrowser: z.number().int().min(0).max(1).default(0),
|
||||
isActive: z.number().int().min(0).max(1).default(1),
|
||||
priority: z.number().int().min(0).default(0),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => createExportAutomationRule({ userId: ctx.user.id, ...input })),
|
||||
update: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().trim().min(1).max(255).optional(),
|
||||
conditionField: z.enum(["recipientName", "ventilationComptable"]).optional(),
|
||||
conditionValue: z.string().trim().min(1).max(255).optional(),
|
||||
destinationType: z.enum(["local", "teams", "sharepoint"]).optional(),
|
||||
destinationPath: z.string().trim().min(1).optional(),
|
||||
openInBrowser: z.number().int().min(0).max(1).optional(),
|
||||
isActive: z.number().int().min(0).max(1).optional(),
|
||||
priority: z.number().int().min(0).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const existing = await getExportAutomationRuleById(input.id);
|
||||
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
const { id, ...data } = input;
|
||||
return updateExportAutomationRule(id, data);
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const existing = await getExportAutomationRuleById(input.id);
|
||||
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
await deleteExportAutomationRule(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ============= SIGNATURES ROUTES =============
|
||||
signatures: router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
|
||||
Reference in New Issue
Block a user