feat: lancer manuellement les imports SFR

This commit is contained in:
Manus
2026-09-30 14:40:01 +00:00
committed by manus-admin
parent d2937fd26a
commit 0d65592d75
7 changed files with 280 additions and 6 deletions

129
server/manualWebImport.ts Normal file
View File

@@ -0,0 +1,129 @@
import { chromium } from "playwright-core";
import type { WebImportSource } from "../drizzle/schema";
export type ManualWebImportResult = {
imported: number;
duplicates: number;
total: number;
};
type InvoicePushResult = ManualWebImportResult;
type InvoicePusher = (fileName: string, pdfBuffer: Buffer) => Promise<InvoicePushResult>;
const SFR_INVOICE_LINK_SELECTOR =
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]';
/** Types de connecteurs dont l’automate est réellement livré dans l’image applicative. */
export function getManualWebImportAvailability(connectorType: string):
| { available: true }
| { available: false; reason: string } {
if (connectorType === "sfr") return { available: true };
return {
available: false,
reason: `L’import manuel n’est pas encore disponible pour le connecteur « ${connectorType} ».`,
};
}
export function summarizeManualWebImport(results: InvoicePushResult[]): ManualWebImportResult {
return results.reduce(
(summary, result) => ({
imported: summary.imported + result.imported,
duplicates: summary.duplicates + result.duplicates,
total: summary.total + result.total,
}),
{ imported: 0, duplicates: 0, total: 0 },
);
}
function getApplicationBaseUrl(): string {
return process.env.APP_BASE_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
}
async function pushInvoiceToApplication(source: WebImportSource, fileName: string, pdfBuffer: Buffer) {
const response = await fetch(`${getApplicationBaseUrl()}/api/web-import/push-invoice`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiToken: source.apiToken,
fileName,
fileBase64: pdfBuffer.toString("base64"),
}),
});
if (!response.ok) {
throw new Error(`Le dépôt sécurisé du PDF a échoué (HTTP ${response.status}).`);
}
const result = (await response.json()) as Partial<InvoicePushResult>;
return {
imported: Number(result.imported || 0),
duplicates: Number(result.duplicates || 0),
total: Number(result.total || 0),
};
}
/**
* Télécharge les nouvelles factures SFR puis les soumet au même endpoint
* de contrôle, dédoublonnage, stockage et analyse IA que l’import automatique.
* Les identifiants restent confinés au processus serveur et ne sont jamais loggés.
*/
export async function runManualWebImport(
source: WebImportSource,
pushInvoice: InvoicePusher = (fileName, pdfBuffer) => pushInvoiceToApplication(source, fileName, pdfBuffer),
): Promise<ManualWebImportResult> {
const availability = getManualWebImportAvailability(source.connectorType);
if (!availability.available) throw new Error(availability.reason);
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || "/usr/bin/chromium",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const context = await browser.newContext({ acceptDownloads: true });
const page = await context.newPage();
await page.goto(source.portalUrl, { waitUntil: "networkidle", timeout: 30_000 });
await page
.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3_000 })
.catch(() => undefined);
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', source.loginEmail);
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', source.loginPassword);
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
const invoicePage = new URL("factures", source.portalUrl).toString();
await page.goto(invoicePage, { waitUntil: "networkidle", timeout: 20_000 }).catch(() => undefined);
const invoiceLinks = await page.$$eval(SFR_INVOICE_LINK_SELECTOR, (links) =>
links.map((link) => ({
href: (link as HTMLAnchorElement).href,
label: link.textContent?.trim() || "facture.pdf",
})),
);
const uniqueLinks = Array.from(
new Map(invoiceLinks.filter((link) => link.href).map((link) => [link.href, link])).values(),
);
const results: InvoicePushResult[] = [];
for (const link of uniqueLinks) {
// BrowserContext.request partage la session authentifiée du navigateur. Cela évite
// de dépendre du comportement du visualiseur PDF ou de l’événement download.
const response = await context.request.get(link.href, { timeout: 20_000 });
if (!response.ok()) {
throw new Error(`Téléchargement de facture indisponible (HTTP ${response.status()}).`);
}
const fileName = link.label || `sfr-facture-${Date.now()}.pdf`;
const pdfBuffer = Buffer.from(await response.body());
results.push(await pushInvoice(fileName, pdfBuffer));
}
return summarizeManualWebImport(results);
} finally {
await browser.close();
}
}