Checkpoint: Ajout du déclenchement manuel pour les connecteurs web SFR : confirmation explicite, automate Chromium côté serveur, récupération des PDF par session authentifiée et injection dans le flux existant de stockage/dédoublonnage/analyse. Le bouton est désactivé pour les types sans moteur réellement livré. Le résultat affiche factures importées et doublons, sans créer de planification. Chromium et playwright-core sont intégrés à l’image. Validation locale : 77 tests, TypeScript et build.
This commit is contained in:
129
server/manualWebImport.ts
Normal file
129
server/manualWebImport.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user