From 447359166b5423720030ed46d14095032c774f05 Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 17 Jul 2026 09:32:18 -0500 Subject: [PATCH] feat: Bump version to 1.1.1 and implement new file handling features in the desktop application. Add MinIO download source picker, enhance extraction and counting wizards with support for downloading files, and improve styling for better user experience. Introduce unit tests for download manifest functionalities. --- services/desktop_app/electron/main.cjs | 25 + services/desktop_app/electron/preload.cjs | 3 + services/desktop_app/package.json | 2 +- .../renderer/components/CountingWizard.tsx | 253 +++--- .../renderer/components/ExtractionWizard.tsx | 299 +++++-- .../components/MinioDownloadSourcePicker.tsx | 216 +++++ .../src/renderer/lib/downloadManifest.test.ts | 27 + .../src/renderer/lib/downloadManifest.ts | 31 + .../renderer/lib/mergeExtractionRows.test.ts | 29 + .../src/renderer/lib/mergeExtractionRows.ts | 51 ++ .../src/renderer/lib/saveMinioPdf.ts | 82 ++ .../modules/counting/CountingWizardModule.tsx | 4 +- .../modules/extraction/ExtractionResults.tsx | 133 ++- .../extraction/JobExtractionWizard.tsx | 810 ++++++++++++++---- services/desktop_app/src/renderer/styles.css | 324 ++++++- .../desktop_app/src/renderer/test/setup.ts | 3 + services/desktop_app/src/shared/types.ts | 9 + 17 files changed, 1914 insertions(+), 387 deletions(-) create mode 100644 services/desktop_app/src/renderer/components/MinioDownloadSourcePicker.tsx create mode 100644 services/desktop_app/src/renderer/lib/downloadManifest.test.ts create mode 100644 services/desktop_app/src/renderer/lib/downloadManifest.ts create mode 100644 services/desktop_app/src/renderer/lib/mergeExtractionRows.test.ts create mode 100644 services/desktop_app/src/renderer/lib/mergeExtractionRows.ts create mode 100644 services/desktop_app/src/renderer/lib/saveMinioPdf.ts diff --git a/services/desktop_app/electron/main.cjs b/services/desktop_app/electron/main.cjs index 1be97c2..ce85062 100644 --- a/services/desktop_app/electron/main.cjs +++ b/services/desktop_app/electron/main.cjs @@ -103,11 +103,36 @@ ipcMain.handle("dialog:save-csv", async (_event, defaultPath) => { return result.filePath; }); +ipcMain.handle("dialog:save-file", async (_event, defaultPath, filters) => { + const result = await dialog.showSaveDialog({ + defaultPath: defaultPath || "documento.pdf", + filters: Array.isArray(filters) && filters.length > 0 + ? filters + : [{ name: "PDF", extensions: ["pdf"] }], + }); + if (result.canceled || !result.filePath) return null; + return result.filePath; +}); + ipcMain.handle("file:write-text", async (_event, filePath, content) => { await fs.writeFile(filePath, content, "utf8"); return { ok: true, filePath }; }); +ipcMain.handle("file:write-bytes", async (_event, filePath, data) => { + const buffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data instanceof ArrayBuffer ? data : new Uint8Array(data)); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, buffer); + return { ok: true, filePath }; +}); + +ipcMain.handle("path:join", async (_event, parts) => { + const list = Array.isArray(parts) ? parts.map(String) : []; + return path.join(...list); +}); + ipcMain.handle("fs:first-document", async (_event, rootPath, keywords) => { const matches = await findDocuments(rootPath, Array.isArray(keywords) ? keywords : []); return matches[0] || null; diff --git a/services/desktop_app/electron/preload.cjs b/services/desktop_app/electron/preload.cjs index 2a32da8..08ae111 100644 --- a/services/desktop_app/electron/preload.cjs +++ b/services/desktop_app/electron/preload.cjs @@ -4,7 +4,10 @@ contextBridge.exposeInMainWorld("desktopApi", { getRuntimeConfig: () => ipcRenderer.invoke("runtime:config"), selectDirectory: () => ipcRenderer.invoke("dialog:select-directory"), saveCsvDialog: (defaultPath) => ipcRenderer.invoke("dialog:save-csv", defaultPath), + saveFileDialog: (defaultPath, filters) => ipcRenderer.invoke("dialog:save-file", defaultPath, filters), writeTextFile: (filePath, content) => ipcRenderer.invoke("file:write-text", filePath, content), + writeBytesFile: (filePath, data) => ipcRenderer.invoke("file:write-bytes", filePath, data), + joinPath: (...parts) => ipcRenderer.invoke("path:join", parts), findFirstDocument: (rootPath, keywords) => ipcRenderer.invoke("fs:first-document", rootPath, keywords), indexFolder: (rootPath, structureMode, docTypes, keywords) => ipcRenderer.invoke("fs:index-folder", rootPath, structureMode, docTypes, keywords), diff --git a/services/desktop_app/package.json b/services/desktop_app/package.json index 6974a07..8dbd0b5 100644 --- a/services/desktop_app/package.json +++ b/services/desktop_app/package.json @@ -1,6 +1,6 @@ { "name": "ipsextractor-desktop", - "version": "1.1.0", + "version": "1.1.1", "private": true, "description": "Desktop extraction client for IPSExtractor microservices.", "main": "electron/main.cjs", diff --git a/services/desktop_app/src/renderer/components/CountingWizard.tsx b/services/desktop_app/src/renderer/components/CountingWizard.tsx index a2aa869..a5cf1f8 100644 --- a/services/desktop_app/src/renderer/components/CountingWizard.tsx +++ b/services/desktop_app/src/renderer/components/CountingWizard.tsx @@ -19,14 +19,20 @@ import type { CountingFormState, CreateCountJobPayload, DocumentType, + DownloadJobFile, Job, ManifestEntry, Task, } from "../../shared/types"; import { ApiClient } from "../lib/apiClient"; import { downloadCsv, toCsv, buildExportCsvFilename } from "../lib/csv"; +import { countFlatFolders, downloadFilesToManifest } from "../lib/downloadManifest"; import { CountResultsTable } from "./CountResultsTable"; import { CountingStepper, type CountingStep } from "./CountingStepper"; +import { + MinioDownloadSourcePicker, + type DocumentSourceMode, +} from "./MinioDownloadSourcePicker"; declare const desktopApi: import("../../shared/types").DesktopBridge; @@ -36,7 +42,12 @@ interface Props { containerRoot: string; /** Job al que pertenece este wizard (para vincular la tarea en la BD) */ job?: Job; - onStartExtraction: (countingTaskId: string, validCount: number, validCounts?: number[]) => void; + onStartExtraction: ( + countingTaskId: string, + validCount: number, + validCounts?: number[], + downloadJobId?: string | null, + ) => void; /** Callback cuando se guarda la tarea en BD (para el módulo de JobView) */ onTaskCreated?: (task: Task) => void; /** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */ @@ -59,32 +70,6 @@ const BATCH_SIZE = 10; /** Peticiones HTTP simultáneas al count-api. */ const BATCH_CONCURRENCY = 2; -function downloadFilesToManifest( - files: Array<{ key: string; nit?: string | null; radicado?: string | null }>, -): ManifestEntry[] { - return files - .filter((f) => f.key.toLowerCase().endsWith(".pdf")) - .map((f) => { - const filename = f.key.split("/").pop() || f.key; - const radicado = - (f.radicado || "").trim() || - filename.replace(/\.pdf$/i, ""); - return { - radicado, - filename, - localPath: f.key, - size: 0, - nit: f.nit ?? null, - objectKey: f.key, - }; - }) - .sort((a, b) => { - const nitCmp = String(a.nit ?? "").localeCompare(String(b.nit ?? ""), undefined, { numeric: true }); - if (nitCmp !== 0) return nitCmp; - return a.radicado.localeCompare(b.radicado, undefined, { numeric: true }); - }); -} - function formatEta(progress: CountProgress): string | null { if (!progress.startedAt || progress.processed === 0) return null; const elapsed = (Date.now() - progress.startedAt) / 1000; @@ -108,7 +93,9 @@ export function CountingWizard({ downloadJobId, downloadFiles, }: Props) { - const fromDownload = Boolean(downloadJobId); + const [source, setSource] = useState(downloadJobId ? "minio" : "local"); + const [selectedDownloadJobId, setSelectedDownloadJobId] = useState(downloadJobId); + const fromDownload = source === "minio"; const [step, setStep] = useState(1); const [maxReached, setMaxReached] = useState(1); const [form, setForm] = useState({ @@ -159,31 +146,56 @@ export function CountingWizard({ [docTypes], ); - const indexFromDownload = useCallback( - async (jobId: string, seedFiles?: Array<{ key: string; nit?: string | null; radicado?: string | null }>) => { + const handleMinioIndexed = useCallback( + async (jobId: string, files: DownloadJobFile[]) => { + downloadJobIdRef.current = jobId; + setSelectedDownloadJobId(jobId); setIndexing(true); setError(null); try { - let files = seedFiles ?? []; - if (files.length === 0) { - const resp = await client.listDownloadJobFiles(jobId); - files = resp.files; + let resolved = files; + if ( + resolved.length === 0 && + downloadJobId && + jobId === downloadJobId && + downloadFiles && + downloadFiles.length > 0 + ) { + resolved = downloadFiles; } - const entries = downloadFilesToManifest(files); + if (resolved.length === 0) { + const resp = await client.listDownloadJobFiles(jobId); + resolved = resp.files; + } + const entries = downloadFilesToManifest(resolved); setManifest(entries); if (entries.length === 0) { setError("No se encontraron PDF en MinIO para esta corrida de descarga."); } - return entries; } catch (e) { - setError(e instanceof Error ? e.message : String(e)); setManifest([]); - return []; + setError(e instanceof Error ? e.message : String(e)); } finally { setIndexing(false); } }, - [client], + [client, downloadJobId, downloadFiles], + ); + + const handleSourceChange = useCallback( + (next: DocumentSourceMode) => { + setSource(next); + setError(null); + if (next === "local") { + downloadJobIdRef.current = undefined; + setSelectedDownloadJobId(undefined); + setManifest([]); + if (form.localRoot) void indexLocalFolder(form.localRoot); + } else { + setManifest([]); + } + }, + [form.localRoot, indexLocalFolder], ); useEffect(() => { @@ -195,12 +207,13 @@ export function CountingWizard({ }, [localRoot, containerRoot]); useEffect(() => { - downloadJobIdRef.current = downloadJobId; if (downloadJobId) { - void indexFromDownload(downloadJobId, downloadFiles); + setSource("minio"); + setSelectedDownloadJobId(downloadJobId); + downloadJobIdRef.current = downloadJobId; return; } - if (!localRoot) return; + if (source !== "local" || !localRoot) return; let cancelled = false; indexLocalFolder(localRoot).catch(() => { if (!cancelled) setError("Error al indexar la carpeta."); @@ -208,9 +221,8 @@ export function CountingWizard({ return () => { cancelled = true; }; - // Solo reindexar al cambiar de corrida/carpeta; seed files es opcional. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localRoot, downloadJobId, indexLocalFolder, indexFromDownload]); + }, [localRoot, downloadJobId, indexLocalFolder]); function goToStep(target: CountingStep) { setStep(target); @@ -470,7 +482,7 @@ export function CountingWizard({ function handleStartExtraction() { if (!savedTask) return; const validCount = savedFiles.filter(isFileValid).length; - onStartExtraction(savedTask.id, validCount, form.validCounts); + onStartExtraction(savedTask.id, validCount, form.validCounts, downloadJobIdRef.current ?? null); } function handleExportCsv() { @@ -503,8 +515,8 @@ export function CountingWizard({ setAutoAdjustedFilenames(new Set()); setError(null); setProgress({ processed: 0, total: 0, failed: 0, currentBatch: "", startedAt: 0 }); - if (downloadJobIdRef.current) { - void indexFromDownload(downloadJobIdRef.current, downloadFiles); + if (source === "minio" && downloadJobIdRef.current) { + void handleMinioIndexed(downloadJobIdRef.current, []); } else if (form.localRoot) { void indexLocalFolder(form.localRoot); } @@ -529,77 +541,88 @@ export function CountingWizard({

Origen de las facturas

- {fromDownload - ? "Los PDF se leen directamente desde MinIO (todas las carpetas NIT de la corrida de descarga)." - : "Selecciona la carpeta local con los PDF de facturas médicas. El servicio contará cuántos servicios contiene cada documento usando OCR."} + Elige carpeta local o una corrida MinIO del bot de descarga. El servicio contará + cuántos servicios contiene cada documento usando OCR.

+ + {job?.id ? ( + + +
+
+ {form.localRoot ? ( + <> + + {form.localRoot} + + ) : ( + Ninguna carpeta seleccionada + )} +
+ +
+ + {manifest.length > 0 && !indexing && source === "local" && ( +
+ + + {manifest.length} documento(s) encontrados y listos para contar + +
+ )} + {form.localRoot && manifest.length === 0 && !indexing && !error && source === "local" && ( +

No se encontraron PDF en esta carpeta.

+ )} +
+ } + /> + ) : ( +
+ +
+
+ {form.localRoot ? ( + <> + + {form.localRoot} + + ) : ( + Ninguna carpeta seleccionada + )} +
+ +
+ {manifest.length > 0 && !indexing && ( +
+ + + {manifest.length} documento(s) encontrados y listos para contar + +
+ )} +
+ )} - {fromDownload ? ( -
- -
-
- - - downloads/{downloadJobId} - -
- -
- {manifest.length > 0 && !indexing && ( -
- - - {manifest.length} PDF(s) en{" "} - {new Set(manifest.map((m) => m.nit).filter(Boolean)).size || 1}{" "} - carpeta(s) planas — listos para contar - -
- )} -
- ) : ( -
- -
-
- {form.localRoot ? ( - <> - - {form.localRoot} - - ) : ( - Ninguna carpeta seleccionada - )} -
- -
- - {manifest.length > 0 && !indexing && ( -
- - - {manifest.length} documento(s) encontrados y listos para contar - -
- )} - {form.localRoot && manifest.length === 0 && !indexing && !error && ( -

No se encontraron PDF en esta carpeta.

- )} -
- )} -
Opciones avanzadas
diff --git a/services/desktop_app/src/renderer/components/ExtractionWizard.tsx b/services/desktop_app/src/renderer/components/ExtractionWizard.tsx index c2e2773..64e2e1f 100644 --- a/services/desktop_app/src/renderer/components/ExtractionWizard.tsx +++ b/services/desktop_app/src/renderer/components/ExtractionWizard.tsx @@ -20,10 +20,20 @@ import { Zap, } from "lucide-react"; import { useState } from "react"; -import type { DocumentType, ExtractionFormState, FieldSelection, ManifestEntry, SchemaResponse } from "../../shared/types"; +import type { + DocumentType, + DownloadJobFile, + ExtractionFormState, + FieldSelection, + ManifestEntry, + SchemaResponse, +} from "../../shared/types"; +import type { ApiClient } from "../lib/apiClient"; +import { countFlatFolders } from "../lib/downloadManifest"; import { SchemaFieldPicker } from "./SchemaFieldPicker"; import { SampleTreeView } from "./SampleTreeView"; import { ExtractionStepper, type ExtractionStep } from "./ExtractionStepper"; +import { MinioDownloadSourcePicker, type DocumentSourceMode } from "./MinioDownloadSourcePicker"; interface ExtractionWizardProps { step: ExtractionStep; @@ -64,6 +74,20 @@ interface ExtractionWizardProps { effectiveDocumentCount?: number; /** Etiqueta descriptiva para el origen de los documentos (ej. "desde conteo") */ documentSourceLabel?: string; + /** Permite elegir corrida MinIO en el paso 1 (extracción sin conteo). */ + allowMinioSource?: boolean; + docSource?: DocumentSourceMode; + onDocSourceChange?: (source: DocumentSourceMode) => void; + selectedDownloadJobId?: string; + onSelectedDownloadJobIdChange?: (id: string | undefined) => void; + onMinioIndexed?: (downloadJobId: string, files: DownloadJobFile[]) => void | Promise; + minioIndexing?: boolean; + client?: ApiClient; + parentJobId?: string; + /** Multi-NIT: páginas de selección (una por carpeta plana). */ + nitPages?: Array<{ nit: string; fieldCount: number; selectedCount: number; docCount: number }>; + selectNitIndex?: number; + onSelectNitIndexChange?: (index: number) => void; } const MODELS = [ @@ -83,6 +107,14 @@ interface EstimateResult { extractor_model?: string; normalizer_model?: string; pages_per_doc?: number; + estimate_mode?: string; + multi_nit_breakdown?: Array<{ + nit: string; + docs: number; + fields: number; + total_usd: number; + per_doc_usd: number; + }>; } function fmtUsd(val: number | undefined): string { @@ -130,12 +162,61 @@ export function ExtractionWizard({ onNext, effectiveDocumentCount, documentSourceLabel, + allowMinioSource = false, + docSource = "local", + onDocSourceChange, + selectedDownloadJobId, + onSelectedDownloadJobIdChange, + onMinioIndexed, + minioIndexing = false, + client, + parentJobId, + nitPages, + selectNitIndex = 0, + onSelectNitIndexChange, }: ExtractionWizardProps) { const [advancedOpen, setAdvancedOpen] = useState(false); const fieldCount = Object.keys(flattened.schema).length; // Número efectivo de documentos: usa el override si está disponible (ej. "desde conteo") const displayDocCount = effectiveDocumentCount ?? manifest.length; const ingestPercent = ingestProgress ? Math.round((ingestProgress.processed / ingestProgress.total) * 100) : 0; + const multiNitSelect = Boolean(nitPages && nitPages.length > 1); + const activeNitPage = multiNitSelect ? nitPages![selectNitIndex] : undefined; + const totalSelectedFields = multiNitSelect + ? nitPages!.reduce((acc, p) => acc + p.selectedCount, 0) + : selection.fields.length; + + const localFolderSection = ( +
+ +
+
+ {form.selectedLocalPath ? ( + <> + + {form.selectedLocalPath} + + ) : ( + Ninguna carpeta seleccionada + )} +
+ +
+ + {manifest.length > 0 && docSource === "local" && ( +
+ + {manifest.length} documento(s) encontrados y listos para procesar +
+ )} + + {form.mode === "general" && docSource === "local" && ( +

Se procesaran todos los PDF y TIFF encontrados en la carpeta.

+ )} +
+ ); return (
@@ -148,7 +229,7 @@ export function ExtractionWizard({

Origen de los documentos

- Elige como estan organizados tus archivos y selecciona la carpeta que contiene los documentos a procesar. + Elige como estan organizados tus archivos y selecciona carpeta local o una corrida MinIO.

@@ -156,7 +237,7 @@ export function ExtractionWizard({ type="button" className={form.mode === "radicados" ? "active" : ""} onClick={() => onModeChange("radicados")} - disabled={busy} + disabled={busy || docSource === "minio"} > Por radicados @@ -171,38 +252,33 @@ export function ExtractionWizard({ Carpeta general
-
- -
- -
-
- {form.selectedLocalPath ? ( - <> - - {form.selectedLocalPath} - - ) : ( - Ninguna carpeta seleccionada - )} -
- -
- - {manifest.length > 0 && ( -
- - {manifest.length} documento(s) encontrados y listos para procesar -
- )} - - {form.mode === "general" && ( -

Se procesaran todos los PDF y TIFF encontrados en la carpeta.

+ {docSource === "minio" && ( +

+ Origen MinIO usa carpetas planas por NIT (modo general). El descubrimiento toma una muestra + por carpeta y unifica el schema. +

)}
+ {allowMinioSource && client && parentJobId && onDocSourceChange && onMinioIndexed ? ( + + ) : ( + localFolderSection + )} + {form.mode === "radicados" && (
@@ -422,10 +498,43 @@ export function ExtractionWizard({ {step === 3 && (
+ {multiNitSelect && activeNitPage && ( +
+
+ + Selección {selectNitIndex + 1}/{nitPages!.length} + + + NIT {activeNitPage.nit} · {activeNitPage.docCount} PDF ·{" "} + {activeNitPage.selectedCount}/{activeNitPage.fieldCount} campos + +
+
+ {nitPages!.map((p, i) => ( +
+
+ )}

Campos a extraer - {selection.fields.length} / {fieldCount} + {multiNitSelect && activeNitPage ? ( + NIT {activeNitPage.nit} + ) : null} + + {selection.fields.length} / {fieldCount} +

@@ -468,7 +577,9 @@ export function ExtractionWizard({
¿Algo no salió como esperabas? - Reintenta el descubrimiento con otro archivo aleatorio + {multiNitSelect + ? "Reintenta el descubrimiento solo para este NIT con otro archivo." + : "Reintenta el descubrimiento con otro archivo aleatorio"} {discoverySampleLabels.length > 0 && ( <> (ahora: {discoverySampleLabels.join(", ")}) )} @@ -515,7 +626,9 @@ export function ExtractionWizard({

Listo para extraer

- Revisa el resumen y lanza la extraccion. Los archivos se suben uno a uno sin guardarse en el servidor. + {multiNitSelect + ? "Se extraerá cada NIT con sus campos; al final verás un solo CSV con columnas alineadas por nombre." + : "Revisa el resumen y lanza la extraccion. Los archivos se suben uno a uno sin guardarse en el servidor."}

@@ -523,9 +636,24 @@ export function ExtractionWizard({
- Carpeta - - {form.selectedLocalPath || "—"} + + {docSource === "minio" ? "Origen MinIO" : "Carpeta"} + + + {docSource === "minio" + ? selectedDownloadJobId + ? `downloads/${selectedDownloadJobId.slice(0, 8)}…` + : "—" + : form.selectedLocalPath || "—"}
@@ -543,26 +671,57 @@ export function ExtractionWizard({
- Campos seleccionados - {selection.fields.length} + + {multiNitSelect ? "Campos (suma NIT)" : "Campos seleccionados"} + + {totalSelectedFields}
Modo - {form.mode === "radicados" ? "Por radicados" : "General"} + + {multiNitSelect + ? `${nitPages!.length} NIT secuenciales` + : form.mode === "radicados" + ? "Por radicados" + : "General"} +
+ {multiNitSelect && ( +
+ {nitPages!.map((p) => ( +
+ NIT {p.nit} + + {p.docCount} PDF · {p.selectedCount} campos + +
+ ))} +
+ )} +
- -
@@ -581,14 +740,43 @@ export function ExtractionWizard({ const normPrompt = normUsage?.prompt_tokens ?? 0; const normCompl = normUsage?.completion_tokens ?? 0; const pages = est.pages_per_doc ?? 1; + const multiBreakdown = est.multi_nit_breakdown; + const isMultiEst = est.estimate_mode === "multi_nit" && Boolean(multiBreakdown?.length); return (
- Estimado de costo — muestra de 1 documento × {docs} + + {isMultiEst + ? `Estimado multi-NIT — ${multiBreakdown!.length} carpetas · ${docs} docs` + : `Estimado de costo — muestra de 1 documento × ${docs}`} +
+ {isMultiEst && ( +
+ {multiBreakdown!.map((p) => ( +
+ + NIT {p.nit} + + {" "} + · {p.docs} PDF · {p.fields} campo{p.fields === 1 ? "" : "s"} + + + + {fmtUsd(p.total_usd)} + + {" "} + ({fmtUsd(p.per_doc_usd)}/doc) + + +
+ ))} +
+ )} +
{/* Extractor */}
@@ -600,7 +788,9 @@ export function ExtractionWizard({ Entrada {fmtTokens(extPrompt)} tok - {pages} pág(s) imagen + prompt + + {isMultiEst ? "muestra ref." : `${pages} pág(s) imagen + prompt`} + {extCached > 0 && ( @@ -641,15 +831,22 @@ export function ExtractionWizard({
- Por documento + + {isMultiEst ? "Promedio por documento" : "Por documento"} + {fmtUsd(perDoc?.total_usd)}
- Total estimado ({docs} doc{docs !== 1 ? "s" : ""}) + + Total estimado ({docs} doc{docs !== 1 ? "s" : ""} + {isMultiEst ? ` · ${multiBreakdown!.length} NIT` : ""}) + {fmtUsd(total?.total_usd)}

- Estimado basado en 1 documento de muestra. El costo real puede variar según el numero de páginas y la complejidad del contenido. + {isMultiEst + ? "Suma de un estimado por NIT (muestra × docs de esa carpeta, con sus campos). El costo real puede variar." + : "Estimado basado en 1 documento de muestra. El costo real puede variar según el numero de páginas y la complejidad del contenido."}

@@ -704,7 +901,9 @@ export function ExtractionWizard({
{step < 4 ? ( ) : ( + +
+ + {source === "local" ? ( + localContent + ) : ( +
+ +

+ Se leen los PDF de todas las carpetas planas (NIT) de la corrida seleccionada. +

+
+ + +
+ + {indexedFileCount > 0 && !busy && ( +
+ + + {indexedFileCount} PDF(s) en{" "} + {indexedFolderCount || 1} carpeta(s) planas — listos para procesar + +
+ )} +
+ )} + + {error && source === "minio" && ( +
+ + {error} +
+ )} +
+ ); +} diff --git a/services/desktop_app/src/renderer/lib/downloadManifest.test.ts b/services/desktop_app/src/renderer/lib/downloadManifest.test.ts new file mode 100644 index 0000000..2c88691 --- /dev/null +++ b/services/desktop_app/src/renderer/lib/downloadManifest.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { countFlatFolders, downloadFilesToManifest } from "./downloadManifest"; + +describe("downloadFilesToManifest", () => { + it("maps PDF keys to manifest with nit and objectKey", () => { + const entries = downloadFilesToManifest([ + { key: "downloads/j1/9001/rad-a.pdf", nit: "9001", radicado: "rad-a" }, + { key: "downloads/j1/9002/rad-b.pdf", nit: "9002", radicado: null }, + { key: "downloads/j1/9001/ignore.txt", nit: "9001" }, + ]); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + nit: "9001", + filename: "rad-a.pdf", + radicado: "rad-a", + objectKey: "downloads/j1/9001/rad-a.pdf", + }); + expect(entries[1]?.nit).toBe("9002"); + expect(entries[1]?.radicado).toBe("rad-b"); + }); + + it("counts flat NIT folders", () => { + expect(countFlatFolders([])).toBe(0); + expect(countFlatFolders([{ nit: "1" }, { nit: "1" }, { nit: "2" }])).toBe(2); + expect(countFlatFolders([{ nit: null }, { nit: "" }])).toBe(1); + }); +}); diff --git a/services/desktop_app/src/renderer/lib/downloadManifest.ts b/services/desktop_app/src/renderer/lib/downloadManifest.ts new file mode 100644 index 0000000..2ce7ac1 --- /dev/null +++ b/services/desktop_app/src/renderer/lib/downloadManifest.ts @@ -0,0 +1,31 @@ +import type { DownloadJobFile, ManifestEntry } from "../../shared/types"; + +/** Convierte archivos listados de una corrida MinIO a ManifestEntry (carpetas planas NIT). */ +export function downloadFilesToManifest(files: DownloadJobFile[]): ManifestEntry[] { + return files + .filter((f) => f.key.toLowerCase().endsWith(".pdf")) + .map((f) => { + const filename = f.key.split("/").pop() || f.key; + const radicado = (f.radicado || "").trim() || filename.replace(/\.pdf$/i, ""); + return { + radicado, + filename, + localPath: f.key, + size: 0, + nit: f.nit ?? null, + objectKey: f.key, + }; + }) + .sort((a, b) => { + const nitCmp = String(a.nit ?? "").localeCompare(String(b.nit ?? ""), undefined, { + numeric: true, + }); + if (nitCmp !== 0) return nitCmp; + return a.radicado.localeCompare(b.radicado, undefined, { numeric: true }); + }); +} + +export function countFlatFolders(entries: { nit?: string | null }[]): number { + const nits = new Set(entries.map((e) => (e.nit || "").trim()).filter(Boolean)); + return nits.size || (entries.length > 0 ? 1 : 0); +} diff --git a/services/desktop_app/src/renderer/lib/mergeExtractionRows.test.ts b/services/desktop_app/src/renderer/lib/mergeExtractionRows.test.ts new file mode 100644 index 0000000..4ad5776 --- /dev/null +++ b/services/desktop_app/src/renderer/lib/mergeExtractionRows.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { mergeExtractionRows } from "./mergeExtractionRows"; + +describe("mergeExtractionRows", () => { + it("aligns columns by name and fills missing with empty string", () => { + const { columns, rows } = mergeExtractionRows([ + [ + { nit: "1", radicado: "a", Ciudad: "Bogota", NIT: "111" }, + { nit: "1", radicado: "b", Ciudad: "Cali", NIT: "111" }, + ], + [{ nit: "2", radicado: "c", Folio: "F1", NIT: "222" }], + ]); + + expect(columns[0]).toBe("nit"); + expect(columns[1]).toBe("radicado"); + expect(columns).toContain("Ciudad"); + expect(columns).toContain("Folio"); + expect(columns).toContain("NIT"); + + expect(rows).toHaveLength(3); + expect(rows[0]).toMatchObject({ nit: "1", Ciudad: "Bogota", Folio: "" }); + expect(rows[2]).toMatchObject({ nit: "2", Folio: "F1", Ciudad: "" }); + }); + + it("handles empty sets", () => { + expect(mergeExtractionRows([])).toEqual({ columns: [], rows: [] }); + expect(mergeExtractionRows([[]])).toEqual({ columns: [], rows: [] }); + }); +}); diff --git a/services/desktop_app/src/renderer/lib/mergeExtractionRows.ts b/services/desktop_app/src/renderer/lib/mergeExtractionRows.ts new file mode 100644 index 0000000..ca1d0ae --- /dev/null +++ b/services/desktop_app/src/renderer/lib/mergeExtractionRows.ts @@ -0,0 +1,51 @@ +export type ExtractionRow = Record; + +const SKIP_COLS = new Set(["_id", "batch_id"]); + +/** + * Fusiona filas de varias extracciones (p.ej. una por NIT) en un solo set + * de columnas alineadas por nombre. Celdas ausentes → "". + */ +export function mergeExtractionRows( + rowSets: ExtractionRow[][], + options?: { preferNitFirst?: boolean }, +): { columns: string[]; rows: ExtractionRow[] } { + const preferNit = options?.preferNitFirst !== false; + const seen = new Set(); + const encounter: string[] = []; + + for (const set of rowSets) { + for (const row of set) { + for (const key of Object.keys(row)) { + if (!key || SKIP_COLS.has(key) || seen.has(key)) continue; + seen.add(key); + encounter.push(key); + } + } + } + + let columns: string[]; + if (preferNit) { + const rest = encounter.filter((c) => c !== "nit" && c !== "radicado"); + columns = []; + if (seen.has("nit")) columns.push("nit"); + if (seen.has("radicado")) columns.push("radicado"); + columns.push(...rest); + } else { + columns = encounter; + } + + const rows: ExtractionRow[] = []; + for (const set of rowSets) { + for (const row of set) { + const normalized: ExtractionRow = {}; + for (const col of columns) { + const val = row[col]; + normalized[col] = val === undefined || val === null ? "" : val; + } + rows.push(normalized); + } + } + + return { columns, rows }; +} diff --git a/services/desktop_app/src/renderer/lib/saveMinioPdf.ts b/services/desktop_app/src/renderer/lib/saveMinioPdf.ts new file mode 100644 index 0000000..26177fe --- /dev/null +++ b/services/desktop_app/src/renderer/lib/saveMinioPdf.ts @@ -0,0 +1,82 @@ +/** Helpers para guardar PDFs desde MinIO en el desktop Electron (con fallback blob). */ + +declare const desktopApi: import("../../shared/types").DesktopBridge | undefined; + +function pdfBasename(key: string, radicado?: string | null): string { + const fromRad = (radicado || "").trim(); + if (fromRad) return fromRad.toLowerCase().endsWith(".pdf") ? fromRad : `${fromRad}.pdf`; + const base = key.split("/").pop() || "documento.pdf"; + return base.toLowerCase().endsWith(".pdf") ? base : `${base}.pdf`; +} + +function sanitizeFolderName(name: string): string { + return name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").trim() || "sin-nit"; +} + +/** Guarda un PDF con diálogo «Guardar como» (o descarga blob si no hay Electron). */ +export async function savePdfAs(filename: string, data: ArrayBuffer): Promise { + const safeName = pdfBasename(filename); + if (typeof desktopApi?.saveFileDialog === "function" && typeof desktopApi?.writeBytesFile === "function") { + const dest = await desktopApi.saveFileDialog(safeName, [{ name: "PDF", extensions: ["pdf"] }]); + if (!dest) return false; + await desktopApi.writeBytesFile(dest, data); + return true; + } + const blob = new Blob([data], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = safeName; + a.click(); + URL.revokeObjectURL(url); + return true; +} + +export type BulkPdfItem = { + key: string; + nit?: string | null; + radicado?: string | null; +}; + +/** + * Guarda varios PDFs en una carpeta elegida por el usuario, organizados por NIT. + * Retorna cuántos se escribieron; null si el usuario canceló el diálogo. + */ +export async function savePdfsToDirectory( + items: BulkPdfItem[], + fetchBytes: (key: string) => Promise, + onProgress?: (done: number, total: number, label: string) => void, +): Promise<{ saved: number; failed: number } | null> { + if (typeof desktopApi?.selectDirectory !== "function" || typeof desktopApi?.writeBytesFile !== "function") { + throw new Error("La descarga masiva requiere la app de escritorio."); + } + const dir = await desktopApi.selectDirectory(); + if (!dir) return null; + + let saved = 0; + let failed = 0; + const total = items.length; + + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const label = item.radicado || item.key.split("/").pop() || item.key; + onProgress?.(i, total, String(label)); + try { + const data = await fetchBytes(item.key); + const nitFolder = sanitizeFolderName((item.nit || "").trim() || "Sin NIT"); + const filename = pdfBasename(item.key, item.radicado); + const dest = + typeof desktopApi.joinPath === "function" + ? await desktopApi.joinPath(dir, nitFolder, filename) + : `${dir}\\${nitFolder}\\${filename}`; + await desktopApi.writeBytesFile(dest, data); + saved += 1; + } catch { + failed += 1; + } + } + onProgress?.(total, total, "Listo"); + return { saved, failed }; +} + +export { pdfBasename }; diff --git a/services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx b/services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx index abdfe7b..726d76b 100644 --- a/services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx +++ b/services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx @@ -36,13 +36,13 @@ export function CountingWizardModule({ downloadFiles={downloadFiles} onTaskCreated={onTaskCreated} onProgress={onProgress} - onStartExtraction={(countingTaskId, validCount, validCounts) => { + onStartExtraction={(countingTaskId, validCount, validCounts, downloadJobIdFromWizard) => { onActivateModule?.("extraction", { countingTaskId, validCount, validCounts, jobId: job.id, - downloadJobId, + downloadJobId: downloadJobIdFromWizard ?? downloadJobId, }); }} /> diff --git a/services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx b/services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx index de07aea..cdac9ac 100644 --- a/services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx +++ b/services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx @@ -3,43 +3,102 @@ import { useEffect, useState } from "react"; import type { ExtractionBatch } from "../../../shared/types"; import { ExtractionResultsTable } from "../../components/ExtractionResultsTable"; import { downloadCsv, toCsv, buildExportCsvFilename } from "../../lib/csv"; +import { mergeExtractionRows, type ExtractionRow } from "../../lib/mergeExtractionRows"; import type { ModuleResultsProps } from "../registry"; -type Row = Record; +type Props = ModuleResultsProps & { + /** Tasks hijas de extracción multi-NIT (incluida la principal). */ + childTaskIds?: string[]; +}; + +function isActiveStatus(status: string | undefined): boolean { + return status === "running" || status === "pending"; +} + +export function ExtractionResults({ + client, + task, + job, + onActivateModule, + childTaskIds, +}: Props) { + const taskIds = + childTaskIds && childTaskIds.length > 0 + ? childTaskIds + : (() => { + const fromResult = (task.result as Record | undefined)?.child_task_ids; + return Array.isArray(fromResult) && fromResult.every((x) => typeof x === "string") + ? (fromResult as string[]) + : [task.id]; + })(); + + const multi = taskIds.length > 1; -export function ExtractionResults({ client, task, job, onActivateModule }: ModuleResultsProps) { const [batch, setBatch] = useState(null); - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const [columns, setColumns] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [polling, setPolling] = useState | null>(null); + const [doneCount, setDoneCount] = useState(0); function stop() { - if (polling !== null) { clearInterval(polling); setPolling(null); } + if (polling !== null) { + clearInterval(polling); + setPolling(null); + } } async function load() { try { - const t = await client.task(task.id); + const tasks = await Promise.all(taskIds.map((id) => client.task(id))); + const anyRunning = tasks.some((t) => isActiveStatus(t.status)); + const allDone = tasks.every((t) => !isActiveStatus(t.status)); + setDoneCount(tasks.filter((t) => !isActiveStatus(t.status)).length); + + const primary = tasks.find((t) => t.id === task.id) ?? tasks[0]!; const pseudoBatch: ExtractionBatch = { - id: String((t.result as Record)?.extraction_batch_id ?? t.id), - status: t.status as ExtractionBatch["status"], + id: String((primary.result as Record)?.extraction_batch_id ?? primary.id), + status: anyRunning ? "running" : (primary.status as ExtractionBatch["status"]), root_path: "", request: {}, - created_at: t.created_at, - updated_at: t.updated_at, + created_at: primary.created_at, + updated_at: primary.updated_at, }; setBatch(pseudoBatch); - if (t.status === "running" || t.status === "pending") return; + + if (!allDone) return; stop(); - const response = await client.taskRows(t.id, 500, 0); - const data = response.rows ?? []; - setRows(data); - const cols = Array.from(new Set(data.flatMap((r: Record) => Object.keys(r)))).filter( - (c) => !["_id", "batch_id"].includes(c), - ) as string[]; - setColumns(cols); + + const rowSets: ExtractionRow[][] = []; + for (const t of tasks) { + const response = await client.taskRows(t.id, 2000, 0); + const data = (response.rows ?? []) as ExtractionRow[]; + // Añadir nit desde request de la task si falta en la fila + const req = (t.request || {}) as Record; + const nitHint = + typeof req.nit === "string" + ? req.nit + : typeof (t.result as Record | undefined)?.nit === "string" + ? String((t.result as Record).nit) + : ""; + rowSets.push( + data.map((r) => { + if (nitHint && (r.nit === undefined || r.nit === null || r.nit === "")) { + return { ...r, nit: nitHint }; + } + return r; + }), + ); + } + + const merged = multi ? mergeExtractionRows(rowSets) : mergeExtractionRows(rowSets); + setRows(merged.rows); + setColumns(merged.columns); + + if (onActivateModule) { + /* keep parent in sync with primary */ + } } catch (e) { setError(String(e)); stop(); @@ -53,7 +112,8 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul const id = setInterval(load, 5000); setPolling(id); return () => clearInterval(id); - }, [task.id]); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.id, taskIds.join("|")]); function exportCsv() { const csv = toCsv( @@ -65,9 +125,7 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul function startCleaning() { const batchId = String( - (task.result as Record)?.extraction_batch_id - ?? batch?.id - ?? "", + (task.result as Record)?.extraction_batch_id ?? batch?.id ?? "", ); onActivateModule?.("cleaning", { extractionBatchId: batchId || undefined, @@ -89,7 +147,13 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul return (
{error} -
@@ -102,12 +166,11 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
-

{task.title}

- +

{multi ? `${job.title || "Extracción"} · ${taskIds.length} NIT` : task.title}

+ {isRunning && } {batch?.status ?? "pendiente"} + {multi && isRunning ? ` · ${doneCount}/${taskIds.length}` : ""}
@@ -116,22 +179,34 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul Descargar CSV ({rows.length} filas) )} - {rows.length > 0 && !isRunning && onActivateModule && ( + {rows.length > 0 && !isRunning && onActivateModule && !multi && ( )} + {rows.length > 0 && !isRunning && onActivateModule && multi && ( + + )}
{isRunning && (
- Extracción en progreso… + + {multi + ? `Extracción multi-NIT en progreso (${doneCount}/${taskIds.length} listas)…` + : "Extracción en progreso…"} +
-
+
)} diff --git a/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx b/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx index 862cdaf..38a04a9 100644 --- a/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx +++ b/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx @@ -2,13 +2,17 @@ * Wrapper del ExtractionWizard original para el contexto de Job. * Contiene toda la lógica de estado que antes vivía en App.tsx, * preservando el diseño paso a paso completo. + * + * Multi-NIT (MinIO): discovery y selección por carpeta plana; extracción + * secuencial por NIT; un solo CSV fusionado al final. */ -import { ArrowRight, CheckCircle2, FileText } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { ArrowRight, CheckCircle2, Cloud, FileText } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import type { BackgroundProgress, CountJobFile, DocumentType, + DownloadJobFile, ExtractionFormState, FieldSelection, ManifestEntry, @@ -17,6 +21,7 @@ import type { } from "../../../shared/types"; import { ExtractionWizard } from "../../components/ExtractionWizard"; import type { ExtractionStep } from "../../components/ExtractionStepper"; +import type { DocumentSourceMode } from "../../components/MinioDownloadSourcePicker"; import { buildDesktopBatchPayload, buildEstimateUploadForm, @@ -26,6 +31,7 @@ import { mergeSchemaResponses, validDocTypes, } from "../../lib/payloads"; +import { countFlatFolders, downloadFilesToManifest } from "../../lib/downloadManifest"; import { defaultAlias } from "../../lib/schemaDisplay"; import { loadApiBaseUrl } from "../../lib/apiDefaults"; import { splitRadicados } from "../../lib/pathMapping"; @@ -35,16 +41,18 @@ import { ExtractionResults } from "./ExtractionResults"; declare const desktopApi: import("../../../shared/types").DesktopBridge; interface Props extends ModuleWizardProps { - /** ID de tarea de conteo previa (desde contexto de JobView) */ countingTaskId?: string; - /** Conteos exactos válidos para filtrar desde el conteo */ validCounts?: number[]; - /** Número de archivos válidos del conteo (para mostrar en banner) */ validCount?: number; - /** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */ onProgress?: (progress: BackgroundProgress) => void; } +export type NitSchemaPage = { + nit: string; + schema: SchemaResponse; + sampleLabel: string; +}; + function makeDefaultForm(job: { metadata?: Record }, fromCount = false): ExtractionFormState { const folder = String(job.metadata?.folder_path ?? ""); return { @@ -53,8 +61,6 @@ function makeDefaultForm(job: { metadata?: Record }, fromCount containerRoot: localStorage.getItem("containerRoot") || "/data", selectedLocalPath: folder, selectedContainerPath: folder, - // Cuando viene de conteo usamos "general" porque los archivos están en carpeta plana. - // En extracción directa, se puede cambiar en el wizard. mode: fromCount ? "general" : "radicados", docTypes: [{ name: "Factura", keyword: "Factura_O_Documento_Equivalente" }], keywordsText: "factura", @@ -68,6 +74,20 @@ function makeDefaultForm(job: { metadata?: Record }, fromCount }; } +function emptySelectionForSchema(schema: SchemaResponse): FieldSelection { + const flat = flattenSchema(schema); + return { + fields: [], + aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])), + }; +} + +function listManifestNits(manifest: ManifestEntry[]): string[] { + return [ + ...new Set(manifest.map((m) => (m.nit || "").trim()).filter(Boolean)), + ].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); +} + export function JobExtractionWizard({ client, job, @@ -82,12 +102,13 @@ export function JobExtractionWizard({ const countingTaskId = countingTaskIdProp ?? (context?.countingTaskId as string | undefined); const validCounts = validCountsProp ?? (context?.validCounts as number[] | undefined); const validCount = validCountProp ?? (context?.validCount as number | undefined); - const downloadJobId = - (context?.downloadJobId as string | undefined) ?? - undefined; + const downloadJobId = (context?.downloadJobId as string | undefined) ?? undefined; const [form, setForm] = useState(() => makeDefaultForm(job, !!countingTaskId)); const [schemaResponse, setSchemaResponse] = useState(null); + const [nitSchemas, setNitSchemas] = useState([]); + const [selectionsByNit, setSelectionsByNit] = useState>({}); + const [selectNitIndex, setSelectNitIndex] = useState(0); const [schemaSearch, setSchemaSearch] = useState(""); const [selection, setSelection] = useState({ fields: [], aliasMap: {} }); const [estimateJson, setEstimateJson] = useState(null); @@ -96,30 +117,63 @@ export function JobExtractionWizard({ const [statusText, setStatusText] = useState("Listo"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - // Si viene del conteo, empezamos en paso 2 (los archivos ya están definidos) - const [extractionStep, setExtractionStep] = useState(() => countingTaskId ? 2 : 1); - const [maxReachedStep, setMaxReachedStep] = useState(() => countingTaskId ? 2 : 1); + const [extractionStep, setExtractionStep] = useState(() => (countingTaskId ? 2 : 1)); + const [maxReachedStep, setMaxReachedStep] = useState(() => (countingTaskId ? 2 : 1)); const [taskCreated, setTaskCreated] = useState(null); - /** Rutas de muestras ya usadas en descubrimiento (para no repetir al reintentar). */ + const [childTaskIds, setChildTaskIds] = useState([]); const [usedDiscoveryPaths, setUsedDiscoveryPaths] = useState([]); const [discoverySampleLabels, setDiscoverySampleLabels] = useState([]); const [resolvedDownloadJobId, setResolvedDownloadJobId] = useState(downloadJobId); + const [docSource, setDocSource] = useState( + downloadJobId && !countingTaskId ? "minio" : "local", + ); + const [minioIndexing, setMinioIndexing] = useState(false); + + const multiNit = nitSchemas.length > 1; + const activeNit = multiNit ? nitSchemas[selectNitIndex]?.nit : undefined; + + const activeSchema = multiNit + ? nitSchemas[selectNitIndex]?.schema ?? null + : schemaResponse; const flattened = useMemo( - () => (schemaResponse ? flattenSchema(schemaResponse) : { schema: {}, sample: null }), - [schemaResponse], + () => (activeSchema ? flattenSchema(activeSchema) : { schema: {}, sample: null }), + [activeSchema], ); + const fieldCount = Object.keys(flattened.schema).length; + const effectiveSelection = multiNit && activeNit + ? selectionsByNit[activeNit] ?? { fields: [], aliasMap: {} } + : selection; + + const nitPages = useMemo(() => { + if (!multiNit) return undefined; + return nitSchemas.map((page) => { + const sel = selectionsByNit[page.nit] ?? { fields: [], aliasMap: {} }; + const flat = flattenSchema(page.schema); + return { + nit: page.nit, + fieldCount: Object.keys(flat.schema).length, + selectedCount: sel.fields.length, + docCount: manifest.filter((m) => (m.nit || "").trim() === page.nit).length, + }; + }); + }, [multiNit, nitSchemas, selectionsByNit, manifest]); + + const allNitsHaveSelection = + !multiNit || + nitSchemas.every((p) => (selectionsByNit[p.nit]?.fields.length ?? 0) > 0); + const canGoNext = - // Paso 1: necesita documentos en carpeta (o saltarse si viene de conteo) (extractionStep === 1 && (manifest.length > 0 || !!countingTaskId)) || - // Paso 2: necesita campos descubiertos - (extractionStep === 2 && fieldCount > 0) || - // Paso 3: necesita al menos un campo seleccionado - (extractionStep === 3 && selection.fields.length > 0); + (extractionStep === 2 && (multiNit ? nitSchemas.length > 0 : fieldCount > 0)) || + (extractionStep === 3 && + (multiNit + ? effectiveSelection.fields.length > 0 && + (selectNitIndex < nitSchemas.length - 1 || allNitsHaveSelection) + : selection.fields.length > 0)); - // Auto-indexar: carpeta local, o archivos del conteo desde MinIO useEffect(() => { if (countingTaskId) { void (async () => { @@ -157,22 +211,28 @@ export function JobExtractionWizard({ return; } } catch { - /* fallback a carpeta local abajo */ + /* fallback */ } })(); } const folder = form.localRoot || form.selectedLocalPath; - if (!folder || manifest.length > 0 || countingTaskId) return; + if (!folder || manifest.length > 0 || countingTaskId || docSource === "minio") return; indexFolderAt(folder, form.mode, form.docTypes) .then((entries) => { setManifest(entries); setForm((cur) => ({ ...cur, selectedLocalPath: folder, selectedContainerPath: folder })); }) .catch(() => undefined); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [countingTaskId]); + // Sync selection view when changing NIT page + useEffect(() => { + if (!multiNit || !activeNit) return; + setSchemaSearch(""); + }, [multiNit, activeNit, selectNitIndex]); + async function readEntryBytes(entry: ManifestEntry): Promise<{ filename: string; data: ArrayBuffer }> { if (entry.objectKey && resolvedDownloadJobId) { const data = await client.fetchDownloadObject(resolvedDownloadJobId, entry.objectKey); @@ -208,27 +268,95 @@ export function JobExtractionWizard({ function resetSchemaState() { setSchemaResponse(null); + setNitSchemas([]); + setSelectionsByNit({}); + setSelectNitIndex(0); setSelection({ fields: [], aliasMap: {} }); setEstimateJson(null); setUsedDiscoveryPaths([]); setDiscoverySampleLabels([]); + setChildTaskIds([]); setMaxReachedStep(1); if (extractionStep > 1) setExtractionStep(1); } + const handleMinioIndexed = useCallback( + async (jobId: string, files: DownloadJobFile[]) => { + setMinioIndexing(true); + setError(null); + try { + const entries = downloadFilesToManifest(files); + setResolvedDownloadJobId(jobId); + setManifest(entries); + updateForm({ mode: "general" }); + resetSchemaState(); + const folders = countFlatFolders(entries); + setStatusText( + entries.length === 0 + ? "Sin PDF en la corrida MinIO" + : `${entries.length} PDF(s) desde MinIO · ${folders} carpeta(s) planas`, + ); + if (entries.length === 0) { + setError("No se encontraron PDF en MinIO para esta corrida de descarga."); + } + } finally { + setMinioIndexing(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [extractionStep], + ); + + const handleDocSourceChange = useCallback( + (next: DocumentSourceMode) => { + setDocSource(next); + setError(null); + setManifest([]); + resetSchemaState(); + if (next === "minio") { + setResolvedDownloadJobId(undefined); + updateForm({ mode: "general" }); + setStatusText("Selecciona una corrida MinIO"); + } else { + setResolvedDownloadJobId(undefined); + setStatusText("Listo"); + const folder = form.localRoot || form.selectedLocalPath; + if (folder) { + void indexFolderAt(folder, form.mode === "radicados" ? form.mode : "general", form.docTypes) + .then((entries) => { + setManifest(entries); + setStatusText(`${entries.length} documento(s) listos`); + }) + .catch(() => undefined); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [form.localRoot, form.selectedLocalPath, form.mode, form.docTypes], + ); + async function selectRoot() { - if (!window.desktopApi) { setError("La selección de carpetas requiere Electron."); return; } + if (!window.desktopApi) { + setError("La selección de carpetas requiere Electron."); + return; + } setError(null); const selected = await window.desktopApi.selectDirectory(); if (!selected) return; try { const indexed = await indexFolderAt(selected, form.mode, form.docTypes); setManifest(indexed); + setResolvedDownloadJobId(undefined); + setDocSource("local"); updateForm({ localRoot: selected, selectedLocalPath: selected, selectedContainerPath: selected }); resetSchemaState(); setMaxReachedStep(1); setStatusText(`${indexed.length} documento(s) listos`); - try { await client.updateJob(job.id, { folder_path: selected }); } catch { /* ignore */ } + try { + await client.updateJob(job.id, { folder_path: selected }); + } catch { + /* ignore */ + } } catch (err) { setManifest([]); setError(err instanceof Error ? err.message : "No se pudo indexar la carpeta"); @@ -238,6 +366,7 @@ export function JobExtractionWizard({ async function handleModeChange(mode: ExtractionFormState["mode"]) { if (mode === form.mode) return; updateForm({ mode }); + if (docSource === "minio") return; if (!form.selectedLocalPath) return; try { const indexed = await indexFolderAt(form.selectedLocalPath, mode, form.docTypes); @@ -262,49 +391,109 @@ export function JobExtractionWizard({ const unused = candidates.filter((e) => !exclude.has(keyOf(e))); const pool = unused.length > 0 ? unused : candidates; if (!preferRandom && unused.length === candidates.length) { - return candidates[0]; + return candidates[0]!; } return pool[Math.floor(Math.random() * pool.length)]!; } - async function discoverSchema(options?: { randomOther?: boolean }) { + async function discoverOneNit( + nit: string, + exclude: Set, + randomOther: boolean, + ): Promise<{ schema: SchemaResponse; entry: ManifestEntry }> { + const pool = manifest.filter((m) => (m.nit || "").trim() === nit); + const entry = pickSampleEntry(pool, exclude, randomOther); + const fileData = await readEntryBytes(entry); + const blob = new Blob([fileData.data]); + const schema = await client.discoverSchemaUpload( + buildSchemaUploadForm(form, blob, fileData.filename), + ); + return { schema, entry }; + } + + async function discoverSchema(options?: { randomOther?: boolean; onlyActiveNit?: boolean }) { const randomOther = Boolean(options?.randomOther); + const onlyActiveNit = Boolean(options?.onlyActiveNit); await runTask( randomOther ? "Probando con otro archivo…" : "Descubriendo campos...", async () => { if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos."); const exclude = new Set(randomOther ? usedDiscoveryPaths : []); + const fromMinio = manifest.some((m) => m.objectKey); + const nits = listManifestNits(manifest); + + // Rediscover solo el NIT activo (paso 3 multi-NIT) + if (onlyActiveNit && multiNit && activeNit) { + const { schema, entry } = await discoverOneNit(activeNit, exclude, true); + const label = `${activeNit}/${entry.filename}`; + setNitSchemas((prev) => + prev.map((p) => + p.nit === activeNit ? { nit: activeNit, schema, sampleLabel: label } : p, + ), + ); + setSelectionsByNit((prev) => ({ + ...prev, + [activeNit]: emptySelectionForSchema(schema), + })); + setDiscoverySampleLabels([label]); + setUsedDiscoveryPaths((prev) => [...prev, entry.objectKey || entry.localPath]); + setStatusText( + `${Object.keys(flattenSchema(schema).schema).length} campo(s) · NIT ${activeNit} · muestra: ${label}`, + ); + return; + } + + // Multi-NIT: un schema por carpeta (sin merge para el picker) + if (fromMinio && form.mode === "general" && nits.length > 1) { + const pages: NitSchemaPage[] = []; + const selMap: Record = {}; + const labels: string[] = []; + const used: string[] = []; + + for (let i = 0; i < nits.length; i++) { + const nit = nits[i]!; + setStatusText(`Descubriendo carpeta ${i + 1}/${nits.length} (NIT ${nit})…`); + const { schema, entry } = await discoverOneNit(nit, exclude, randomOther); + const label = `${nit}/${entry.filename}`; + pages.push({ nit, schema, sampleLabel: label }); + selMap[nit] = emptySelectionForSchema(schema); + labels.push(label); + used.push(entry.objectKey || entry.localPath); + exclude.add(entry.objectKey || entry.localPath); + } + + setNitSchemas(pages); + setSelectionsByNit(selMap); + setSelectNitIndex(0); + setSchemaResponse(pages[0]?.schema ?? null); + setSelection(selMap[pages[0]!.nit]!); + setEstimateJson(null); + setDiscoverySampleLabels(labels); + setUsedDiscoveryPaths(randomOther ? [...usedDiscoveryPaths, ...used] : used); + setStatusText( + `${pages.length} schemas descubiertos (uno por NIT) · selección por carpeta`, + ); + setMaxReachedStep((cur) => (cur < 3 ? 3 : cur)); + if (!randomOther) setExtractionStep(3); + return; + } + + // Flujo simple (1 NIT / local / radicados) const responses: SchemaResponse[] = []; const picked: ManifestEntry[] = []; - const fromMinio = manifest.some((m) => m.objectKey); - const nits = [ - ...new Set(manifest.map((m) => (m.nit || "").trim()).filter(Boolean)), - ].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - - // Desde descarga multi-NIT: 1 muestra por carpeta plana, luego merge. - if (fromMinio && form.mode === "general" && nits.length > 0) { - for (const nit of nits) { - const pool = manifest.filter((m) => (m.nit || "").trim() === nit); - const entry = pickSampleEntry(pool, exclude, randomOther); - picked.push(entry); - const fileData = await readEntryBytes(entry); - const blob = new Blob([fileData.data]); - responses.push( - await client.discoverSchemaUpload( - buildSchemaUploadForm(form, blob, fileData.filename), - ), - ); - } + if (fromMinio && form.mode === "general" && nits.length === 1) { + const { schema, entry } = await discoverOneNit(nits[0]!, exclude, randomOther); + responses.push(schema); + picked.push(entry); } else if (form.mode === "general") { const entry = pickSampleEntry(manifest, exclude, randomOther); picked.push(entry); const fileData = await readEntryBytes(entry); - const blob = new Blob([fileData.data]); responses.push( await client.discoverSchemaUpload( - buildSchemaUploadForm(form, blob, fileData.filename), + buildSchemaUploadForm(form, new Blob([fileData.data]), fileData.filename), ), ); } else { @@ -316,16 +505,18 @@ export function JobExtractionWizard({ const entry = pickSampleEntry(typed, exclude, randomOther); picked.push(entry); const fileData = await readEntryBytes(entry); - const blob = new Blob([fileData.data]); responses.push( await client.discoverSchemaUpload( - buildSchemaUploadForm(form, blob, fileData.filename, docType.name), + buildSchemaUploadForm(form, new Blob([fileData.data]), fileData.filename, docType.name), ), ); } } const schema = mergeSchemaResponses(responses); + setNitSchemas([]); + setSelectionsByNit({}); + setSelectNitIndex(0); setSchemaResponse(schema); const flat = flattenSchema(schema); setSelection({ @@ -333,9 +524,7 @@ export function JobExtractionWizard({ aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])), }); setEstimateJson(null); - const labels = picked.map((e) => - e.nit ? `${e.nit}/${e.filename}` : e.filename, - ); + const labels = picked.map((e) => (e.nit ? `${e.nit}/${e.filename}` : e.filename)); setDiscoverySampleLabels(labels); setUsedDiscoveryPaths((prev) => { const next = new Set(randomOther ? prev : []); @@ -347,33 +536,126 @@ export function JobExtractionWizard({ (labels.length ? ` · muestra: ${labels.join(", ")}` : ""), ); setMaxReachedStep((cur) => (cur < 3 ? 3 : cur)); - if (!randomOther) { - setExtractionStep(3); - } + if (!randomOther) setExtractionStep(3); }, ); } async function rediscoverWithOtherSample() { - await discoverSchema({ randomOther: true }); + await discoverSchema({ randomOther: true, onlyActiveNit: multiNit && extractionStep === 3 }); } async function estimate() { await runTask("Estimando costo...", async () => { - if (!selection.fields.length) throw new Error("Selecciona al menos un campo."); if (manifest.length === 0) throw new Error("Selecciona una carpeta."); - const sampleEntry = manifest[0]; + + // Multi-NIT: un estimado por carpeta (sus campos × sus PDF) y suma. + if (multiNit) { + type Part = { + nit: string; + docs: number; + fields: number; + total_usd: number; + per_doc_usd: number; + }; + const parts: Part[] = []; + let totalDocs = 0; + let totalUsd = 0; + let totalInputUsd = 0; + let totalOutputUsd = 0; + let sampleResult: Record | null = null; + + for (let i = 0; i < nitSchemas.length; i++) { + const page = nitSchemas[i]!; + const sel = selectionsByNit[page.nit]; + if (!sel || sel.fields.length === 0) { + throw new Error(`Selecciona al menos un campo para NIT ${page.nit}.`); + } + const pool = manifest.filter((m) => (m.nit || "").trim() === page.nit); + if (pool.length === 0) continue; + + setStatusText(`Estimando NIT ${i + 1}/${nitSchemas.length} (${page.nit})…`); + const sampleEntry = pool[0]!; + const fileData = await readEntryBytes(sampleEntry); + const formData = buildEstimateUploadForm( + form, + new Blob([fileData.data]), + fileData.filename, + pool.length, + sel.fields, + form.model, + ); + const result = (await client.estimateUpload(formData)) as Record; + if (!sampleResult) sampleResult = result; + + const docs = Number(result.total_docs ?? pool.length) || pool.length; + const estTotal = (result.estimated_total_cost || {}) as { + total_usd?: number; + input_usd?: number; + output_usd?: number; + }; + const perDoc = (result.per_doc_cost || {}) as { total_usd?: number }; + const partTotal = Number(estTotal.total_usd ?? 0); + const partPerDoc = + Number(perDoc.total_usd ?? (docs > 0 ? partTotal / docs : 0)) || 0; + + parts.push({ + nit: page.nit, + docs, + fields: sel.fields.length, + total_usd: partTotal, + per_doc_usd: partPerDoc, + }); + totalDocs += docs; + totalUsd += partTotal; + totalInputUsd += Number(estTotal.input_usd ?? 0); + totalOutputUsd += Number(estTotal.output_usd ?? 0); + } + + if (parts.length === 0 || !sampleResult) { + throw new Error("No se pudo estimar ningún NIT."); + } + + setEstimateJson({ + ...sampleResult, + total_docs: totalDocs, + per_doc_cost: { + ...((sampleResult.per_doc_cost as object) || {}), + total_usd: totalDocs > 0 ? totalUsd / totalDocs : 0, + }, + estimated_total_cost: { + input_usd: totalInputUsd, + output_usd: totalOutputUsd, + total_usd: totalUsd, + }, + multi_nit_breakdown: parts, + estimate_mode: "multi_nit", + }); + setStatusText( + `Estimación multi-NIT: ${totalDocs} docs · $${totalUsd.toFixed(4)}`, + ); + return; + } + + if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo."); + const sampleEntry = manifest[0]!; const fileData = await readEntryBytes(sampleEntry); const blob = new Blob([fileData.data]); const uniqueRadicados = new Set(manifest.map((e) => e.radicado)).size; - const formData = buildEstimateUploadForm(form, blob, fileData.filename, uniqueRadicados, selection.fields, form.model); + const formData = buildEstimateUploadForm( + form, + blob, + fileData.filename, + uniqueRadicados, + selection.fields, + form.model, + ); const result = await client.estimateUpload(formData); setEstimateJson(result); setStatusText("Estimación completada"); }); } - /** Determina si un archivo del conteo es válido según los filtros activos */ function isCountFileValid(f: CountJobFile): boolean { if (f.status !== "ok" || f.is_valid === false) return false; if (f.is_valid === true) return true; @@ -381,130 +663,210 @@ export function JobExtractionWizard({ return validCounts.includes(f.service_count ?? 0); } + async function resolveBaseManifest(): Promise { + if (countingTaskId) { + const countFiles = await client.countJobFiles(countingTaskId); + const validKeys = new Set( + countFiles + .filter(isCountFileValid) + .map((f) => f.object_key || `${f.nit ?? ""}|${f.filename}`), + ); + const validFilenames = new Set(countFiles.filter(isCountFileValid).map((f) => f.filename)); + const workManifest = manifest.filter((e) => { + if (e.objectKey) return validKeys.has(e.objectKey); + if (e.nit) return validKeys.has(`${e.nit}|${e.filename}`); + return validFilenames.has(e.filename); + }); + if (workManifest.length === 0) { + throw new Error( + "No se encontraron archivos válidos del conteo." + + (manifest.some((m) => m.objectKey) + ? " Verifica el filtro de conteos válidos." + : " Asegúrate de que los PDFs válidos estén en la carpeta seleccionada."), + ); + } + return workManifest; + } + let workManifest = [...manifest]; + if (form.filterEnabled) { + const allowed = new Set(splitRadicados(form.radicadoFilterText)); + workManifest = workManifest.filter((e) => allowed.has(e.radicado)); + } + if (workManifest.length === 0) throw new Error("El filtro no dejó documentos."); + return workManifest; + } + + async function ingestManifestToTask( + task: Task, + workManifest: ManifestEntry[], + progressPrefix: string, + ) { + const total = workManifest.length; + setIngestProgress({ processed: 0, total }); + onProgress?.({ + module: "extraction", + total, + processed: 0, + failed: 0, + currentFile: "", + running: true, + label: `${progressPrefix}Preparando envío…`, + }); + + const BATCH_SIZE = 20; + const BATCH_CONCURRENCY = 3; + const batches: ManifestEntry[][] = []; + for (let i = 0; i < workManifest.length; i += BATCH_SIZE) { + batches.push(workManifest.slice(i, i + BATCH_SIZE)); + } + let queued = 0; + let nextBatchIdx = 0; + + async function uploadBatch(batch: ManifestEntry[], batchIdx: number) { + const files = []; + for (let fi = 0; fi < batch.length; fi++) { + onProgress?.({ + module: "extraction", + total, + processed: queued + fi, + failed: 0, + currentFile: batch[fi]!.filename, + running: true, + label: `${progressPrefix}Subiendo (${queued + fi}/${total})…`, + }); + files.push(await readEntryBytes(batch[fi]!)); + } + const blobs = files.map((f) => new Blob([f.data])); + await client.ingestTaskDocumentsBulk(task.id, buildIngestFormBulk(batch, blobs)); + queued += batch.length; + setIngestProgress({ processed: queued, total }); + setStatusText(`${progressPrefix}${queued}/${total} documentos…`); + onProgress?.({ + module: "extraction", + total, + processed: queued, + failed: 0, + currentFile: batch[batch.length - 1]?.filename ?? "", + running: true, + label: `${progressPrefix}Subiendo (${queued}/${total})…`, + }); + void batchIdx; + } + + async function uploadWorker() { + while (nextBatchIdx < batches.length) { + const batchIdx = nextBatchIdx++; + await uploadBatch(batches[batchIdx]!, batchIdx); + } + } + + await Promise.all( + Array.from({ length: Math.min(BATCH_CONCURRENCY, batches.length) }, () => uploadWorker()), + ); + } + async function runExtraction() { await runTask("Iniciando extracción...", async () => { - if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo."); - if (manifest.length === 0) throw new Error("Selecciona una carpeta."); + const baseManifest = await resolveBaseManifest(); - let workManifest: ManifestEntry[]; + // Multi-NIT: una task por NIT, secuencial + if (multiNit) { + for (const page of nitSchemas) { + const sel = selectionsByNit[page.nit]; + if (!sel || sel.fields.length === 0) { + throw new Error(`Selecciona al menos un campo para NIT ${page.nit}.`); + } + } - // Caso 1: desde conteo previo — filtrar manifest a los archivos válidos - if (countingTaskId) { - const countFiles = await client.countJobFiles(countingTaskId); - const validKeys = new Set( - countFiles - .filter(isCountFileValid) - .map((f) => f.object_key || `${f.nit ?? ""}|${f.filename}`), - ); - const validFilenames = new Set( - countFiles.filter(isCountFileValid).map((f) => f.filename), - ); - workManifest = manifest.filter((e) => { - if (e.objectKey) return validKeys.has(e.objectKey); - if (e.nit) return validKeys.has(`${e.nit}|${e.filename}`); - return validFilenames.has(e.filename); - }); - if (workManifest.length === 0) { - throw new Error( - "No se encontraron archivos válidos del conteo." + - (manifest.some((m) => m.objectKey) - ? " Verifica el filtro de conteos válidos." - : " Asegúrate de que los PDFs válidos estén en la carpeta seleccionada."), + const taskIds: string[] = []; + let firstTask: Task | null = null; + const grandTotal = baseManifest.length; + let grandProcessed = 0; + + for (let i = 0; i < nitSchemas.length; i++) { + const page = nitSchemas[i]!; + const sel = selectionsByNit[page.nit]!; + const workManifest = baseManifest.filter((m) => (m.nit || "").trim() === page.nit); + if (workManifest.length === 0) continue; + + const prefix = `NIT ${i + 1}/${nitSchemas.length} (${page.nit}) · `; + setStatusText(`${prefix}Creando tarea…`); + + const payload = buildDesktopBatchPayload(form, sel, workManifest, form.model); + const payloadWithJob = { + ...payload, + job_id: job.id, + nit: page.nit, + multi_nit_group: true, + }; + const task = await client.createExtractionTask( + payloadWithJob as Parameters[0], ); + taskIds.push(task.id); + if (!firstTask) firstTask = task; + onTaskCreated(task); + + await ingestManifestToTask(task, workManifest, prefix); + grandProcessed += workManifest.length; + onProgress?.({ + module: "extraction", + total: grandTotal, + processed: grandProcessed, + failed: 0, + currentFile: "", + running: i < nitSchemas.length - 1, + label: `${prefix}Enviado (${workManifest.length} PDF)`, + }); } - } else { - // ── Caso 2: extracción desktop normal ── - workManifest = [...manifest]; - if (form.filterEnabled) { - const allowed = new Set(splitRadicados(form.radicadoFilterText)); - workManifest = workManifest.filter((e) => allowed.has(e.radicado)); + + if (!firstTask || taskIds.length === 0) { + throw new Error("No se creó ninguna tarea de extracción."); } - if (workManifest.length === 0) throw new Error("El filtro no dejó documentos."); + + const primary: Task = { + ...firstTask, + title: firstTask.title || `Extracción multi-NIT (${taskIds.length})`, + result: { + ...(firstTask.result || {}), + child_task_ids: taskIds, + }, + }; + setChildTaskIds(taskIds); + setTaskCreated(primary); + setIngestProgress(null); + setStatusText( + `${grandProcessed} documentos en ${taskIds.length} NIT. Procesando en segundo plano…`, + ); + onProgress?.({ + module: "extraction", + total: grandTotal, + processed: grandProcessed, + failed: 0, + currentFile: "", + running: false, + label: "Subida completada — extrayendo datos…", + }); + onTaskCreated(primary); + return; } + // Flujo simple (una task) + if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo."); + const workManifest = baseManifest; const payload = buildDesktopBatchPayload(form, selection, workManifest, form.model); const payloadWithJob = { ...payload, job_id: job.id }; const task = await client.createExtractionTask( payloadWithJob as Parameters[0], ); + setChildTaskIds([task.id]); setTaskCreated(task); - - const total = workManifest.length; - setIngestProgress({ processed: 0, total }); - onProgress?.({ - module: "extraction", - total, - processed: 0, - failed: 0, - currentFile: "", - running: true, - label: "Preparando envío de documentos…", - }); - - const BATCH_SIZE = 20; - const BATCH_CONCURRENCY = 3; - const batches: ManifestEntry[][] = []; - for (let i = 0; i < workManifest.length; i += BATCH_SIZE) { - batches.push(workManifest.slice(i, i + BATCH_SIZE)); - } - let queued = 0; - let nextBatchIdx = 0; - - async function uploadBatch(batch: ManifestEntry[], batchIdx: number) { - const files = []; - for (let fi = 0; fi < batch.length; fi++) { - onProgress?.({ - module: "extraction", - total, - processed: queued + fi, - failed: 0, - currentFile: batch[fi].filename, - running: true, - label: `Subiendo archivos (${queued + fi}/${total})…`, - }); - files.push(await readEntryBytes(batch[fi])); - } - const blobs = files.map((f) => new Blob([f.data])); - onProgress?.({ - module: "extraction", - total, - processed: queued, - failed: 0, - currentFile: batch[0]?.filename ?? "", - running: true, - label: `Enviando lote ${batchIdx + 1}/${batches.length} al servidor…`, - }); - await client.ingestTaskDocumentsBulk(task.id, buildIngestFormBulk(batch, blobs)); - queued += batch.length; - setIngestProgress({ processed: queued, total }); - setStatusText(`Enviando ${queued}/${total} documentos…`); - onProgress?.({ - module: "extraction", - total, - processed: queued, - failed: 0, - currentFile: batch[batch.length - 1]?.filename ?? "", - running: true, - label: `Subiendo archivos (${queued}/${total})…`, - }); - } - - async function uploadWorker() { - while (nextBatchIdx < batches.length) { - const batchIdx = nextBatchIdx++; - await uploadBatch(batches[batchIdx], batchIdx); - } - } - - await Promise.all( - Array.from({ length: Math.min(BATCH_CONCURRENCY, batches.length) }, () => uploadWorker()), - ); + await ingestManifestToTask(task, workManifest, ""); setIngestProgress(null); - setStatusText(`${total} documentos enviados. Procesando en segundo plano…`); + setStatusText(`${workManifest.length} documentos enviados. Procesando en segundo plano…`); onProgress?.({ module: "extraction", - total, - processed: total, + total: workManifest.length, + processed: workManifest.length, failed: 0, currentFile: "", running: false, @@ -531,6 +893,25 @@ export function JobExtractionWizard({ function goToStep(step: ExtractionStep) { setExtractionStep(step); setMaxReachedStep((cur) => (step > cur ? step : cur)); + if (step === 3 && multiNit) setSelectNitIndex(0); + } + + function handleBack() { + if (extractionStep === 3 && multiNit && selectNitIndex > 0) { + setSelectNitIndex((i) => i - 1); + return; + } + goToStep((extractionStep > 1 ? extractionStep - 1 : 1) as ExtractionStep); + } + + function handleNext() { + if (!canGoNext) return; + if (extractionStep === 3 && multiNit && selectNitIndex < nitSchemas.length - 1) { + if (effectiveSelection.fields.length === 0) return; + setSelectNitIndex((i) => i + 1); + return; + } + if (extractionStep < 4) goToStep((extractionStep + 1) as ExtractionStep); } function setDocType(index: number, patch: Partial) { @@ -539,8 +920,19 @@ export function JobExtractionWizard({ }); } + function applySelectionUpdate(updater: (cur: FieldSelection) => FieldSelection) { + if (multiNit && activeNit) { + setSelectionsByNit((prev) => { + const cur = prev[activeNit] ?? { fields: [], aliasMap: {} }; + return { ...prev, [activeNit]: updater(cur) }; + }); + return; + } + setSelection(updater); + } + function toggleField(path: string, checked: boolean) { - setSelection((cur) => { + applySelectionUpdate((cur) => { const fields = checked ? Array.from(new Set([...cur.fields, path])) : cur.fields.filter((f) => f !== path); @@ -551,15 +943,26 @@ export function JobExtractionWizard({ } function selectAllFields() { - setSelection({ + applySelectionUpdate((cur) => ({ fields: Object.keys(flattened.schema), aliasMap: Object.fromEntries( - Object.keys(flattened.schema).map((p) => [p, selection.aliasMap[p] || defaultAlias(p)]), + Object.keys(flattened.schema).map((p) => [p, cur.aliasMap[p] || defaultAlias(p)]), ), - }); + })); } - // Tras crear la tarea: mostrar resultados (con polling) en esta misma pestaña + const setSelectionBridge: Dispatch> = (action) => { + applySelectionUpdate((cur) => (typeof action === "function" ? action(cur) : action)); + }; + + const canRediscover = multiNit && activeNit + ? manifest.filter((m) => (m.nit || "").trim() === activeNit).length > 1 + : form.mode === "general" + ? manifest.length > 1 + : validDocTypes(form.docTypes).some( + (dt) => manifest.filter((m) => m.docType === dt.name).length > 1, + ); + if (taskCreated) { return (
@@ -573,6 +976,7 @@ export function JobExtractionWizard({ client={client} job={job} task={taskCreated} + childTaskIds={childTaskIds.length > 0 ? childTaskIds : undefined} onTaskUpdated={(t) => { setTaskCreated(t); onTaskCreated(t); @@ -583,9 +987,10 @@ export function JobExtractionWizard({ ); } + const folderCount = countFlatFolders(manifest); + return (
- {/* ── Banner "desde conteo" — solo cuando viene de archivos válidos del conteo ── */} {countingTaskId && (
@@ -602,6 +1007,21 @@ export function JobExtractionWizard({
)} + {!countingTaskId && docSource === "minio" && manifest.length > 0 && ( +
+ + + Origen MinIO — {manifest.length} PDF en {folderCount} carpeta(s) planas. + Selección y extracción por NIT · al final un solo CSV. + + + + + {manifest.length} archivos + +
+ )} + discoverSchema()} rediscoverWithOtherSample={() => rediscoverWithOtherSample()} - discoverySampleLabels={discoverySampleLabels} - canRediscoverWithOtherSample={ - form.mode === "general" - ? manifest.length > 1 - : validDocTypes(form.docTypes).some( - (dt) => manifest.filter((m) => m.docType === dt.name).length > 1, - ) + discoverySampleLabels={ + multiNit && nitSchemas[selectNitIndex] + ? [nitSchemas[selectNitIndex]!.sampleLabel] + : discoverySampleLabels } + canRediscoverWithOtherSample={canRediscover} estimate={estimate} runExtraction={runExtraction} - selection={selection} - setSelection={setSelection} + selection={effectiveSelection} + setSelection={setSelectionBridge} schemaSearch={schemaSearch} setSchemaSearch={setSchemaSearch} flattened={flattened} - schemaResponse={schemaResponse} + schemaResponse={activeSchema} estimateJson={estimateJson} manifest={manifest} ingestProgress={ingestProgress} @@ -638,10 +1056,24 @@ export function JobExtractionWizard({ toggleField={toggleField} selectAllFields={selectAllFields} canGoNext={canGoNext} - onBack={() => goToStep((extractionStep > 1 ? extractionStep - 1 : 1) as ExtractionStep)} - onNext={() => { if (canGoNext && extractionStep < 4) goToStep((extractionStep + 1) as ExtractionStep); }} + onBack={handleBack} + onNext={handleNext} effectiveDocumentCount={countingTaskId ? validCount : undefined} - documentSourceLabel={countingTaskId ? "válidos del conteo" : undefined} + documentSourceLabel={ + countingTaskId ? "válidos del conteo" : docSource === "minio" ? "desde MinIO" : undefined + } + allowMinioSource={!countingTaskId} + docSource={docSource} + onDocSourceChange={handleDocSourceChange} + selectedDownloadJobId={resolvedDownloadJobId} + onSelectedDownloadJobIdChange={setResolvedDownloadJobId} + onMinioIndexed={handleMinioIndexed} + minioIndexing={minioIndexing} + client={client} + parentJobId={job.id} + nitPages={nitPages} + selectNitIndex={selectNitIndex} + onSelectNitIndexChange={setSelectNitIndex} />
); diff --git a/services/desktop_app/src/renderer/styles.css b/services/desktop_app/src/renderer/styles.css index 56c561d..0b1f51b 100644 --- a/services/desktop_app/src/renderer/styles.css +++ b/services/desktop_app/src/renderer/styles.css @@ -1984,6 +1984,15 @@ th { gap: 10px; } +.folder-picker .form-input { + flex: 1; + min-width: 0; +} + +.minio-source-picker { + margin-top: 4px; +} + .folder-picker-display { align-items: center; background: #f8fafc; @@ -4237,7 +4246,8 @@ th { flex: 1; flex-direction: column; min-height: 0; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } .results-section-header { @@ -4264,6 +4274,318 @@ th { gap: 6px; } +.results-section-header-left, +.results-section-header-right { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.results-empty { + align-items: center; + color: var(--muted, #64748b); + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + justify-content: center; + padding: 48px 24px; + text-align: center; +} + +/* ── Resultados bot descarga: multi-NIT + descargas ── */ +.dl-files-section { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 12px; + min-height: 0; +} + +.dl-files-header { + align-items: center; + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; +} + +.dl-files-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dl-prefix-code { + font-size: 11px; + word-break: break-all; +} + +.dl-bulk-progress { + background: var(--surface-2, #f1f5f9); + border: 1px solid var(--border, #e2e8f0); + border-radius: 10px; + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 8px; + margin-bottom: 12px; + padding: 10px 12px; +} + +.dl-bulk-progress-meta { + align-items: center; + color: var(--text, #334155); + display: flex; + font-size: 13px; + gap: 8px; +} + +.dl-nit-list { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 10px; + max-height: min(420px, 50vh); + overflow: auto; + padding-right: 2px; +} + +.dl-nit-card { + background: var(--surface, #fff); + border: 1px solid var(--border, #e2e8f0); + border-radius: 12px; + flex-shrink: 0; + overflow: hidden; +} + +.dl-nit-card--open { + border-color: color-mix(in srgb, var(--primary, #2563eb) 35%, var(--border, #e2e8f0)); + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); +} + +.dl-nit-card-header { + align-items: center; + background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); + display: flex; + flex-shrink: 0; + gap: 8px; + justify-content: space-between; + min-height: 44px; + padding: 4px 8px 4px 4px; +} + +.dl-nit-card-toggle { + align-items: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + display: flex; + flex: 1; + font: inherit; + gap: 8px; + min-height: 36px; + min-width: 0; + padding: 8px 10px; + text-align: left; +} + +.dl-nit-card-toggle:hover { + background: rgb(15 23 42 / 3%); + border-radius: 8px; +} + +.dl-nit-card-icon { + color: var(--primary, #2563eb); + flex-shrink: 0; +} + +.dl-nit-card-title { + font-size: 13px; + font-weight: 650; + letter-spacing: 0.01em; +} + +.dl-nit-card-count { + background: #e2e8f0; + border-radius: 999px; + color: #475569; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; +} + +.dl-nit-card-body { + border-top: 1px solid var(--border, #e2e8f0); + max-height: 240px; + overflow: auto; +} + +.dl-files-table { + border-collapse: collapse; + font-size: 12.5px; + width: 100%; +} + +.dl-files-table th, +.dl-files-table td { + border-bottom: 1px solid var(--border, #eef2f7); + padding: 8px 12px; + text-align: left; + vertical-align: middle; +} + +.dl-files-table th { + background: #f8fafc; + color: #64748b; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.03em; + position: sticky; + text-transform: uppercase; + top: 0; + z-index: 1; +} + +.dl-files-table tbody tr:hover td { + background: #f8fafc; +} + +.dl-col-radicado { + font-variant-numeric: tabular-nums; + white-space: nowrap; + width: 40%; +} + +.dl-col-file { + color: #475569; + max-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dl-files-table-actions { + text-align: right; + white-space: nowrap; + width: 1%; +} + +.dl-log-box { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + max-height: 200px; + overflow: auto; +} + +.nit-select-pager { + align-items: center; + background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); + border: 1px solid var(--border, #e2e8f0); + border-radius: 10px; + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; + margin-bottom: 12px; + padding: 10px 12px; +} + +.nit-select-pager-meta { + display: flex; + flex-direction: column; + font-size: 13px; + gap: 2px; +} + +.nit-select-pager-meta span { + color: #64748b; + font-size: 12px; +} + +.nit-select-dots { + align-items: center; + display: flex; + gap: 6px; +} + +.nit-select-dot { + background: #cbd5e1; + border: none; + border-radius: 999px; + cursor: pointer; + height: 10px; + padding: 0; + width: 10px; +} + +.nit-select-dot--done { + background: #93c5fd; +} + +.nit-select-dot--active { + background: var(--primary, #2e7db2); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #2e7db2) 25%, transparent); + height: 12px; + width: 12px; +} + +.nit-exec-summary { + background: #f8fafc; + border: 1px solid var(--border, #e2e8f0); + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 auto 16px; + max-width: 420px; + padding: 10px 14px; + width: 100%; +} + +.nit-exec-summary-row { + display: flex; + font-size: 13px; + justify-content: space-between; + gap: 12px; +} + +.nit-exec-summary-row span { + color: #64748b; +} + +.est-nit-breakdown { + border-bottom: 1px solid var(--border, #e2e8f0); + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 0 12px; + padding: 0 0 12px; +} + +.est-nit-breakdown-row { + display: flex; + font-size: 12.5px; + gap: 12px; + justify-content: space-between; +} + +.est-nit-breakdown-meta, +.est-nit-breakdown-per { + color: #64748b; + font-weight: 400; +} + +.est-nit-breakdown-cost { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + /* ── Extracción desde conteo: wrapper y banner informativo ── */ .job-extraction-wrapper { display: flex; diff --git a/services/desktop_app/src/renderer/test/setup.ts b/services/desktop_app/src/renderer/test/setup.ts index a7a1ff4..dfc1311 100644 --- a/services/desktop_app/src/renderer/test/setup.ts +++ b/services/desktop_app/src/renderer/test/setup.ts @@ -7,7 +7,10 @@ beforeEach(() => { getRuntimeConfig: vi.fn().mockResolvedValue({ documentRootLocal: "C:\\docs", documentRootContainer: "/data" }), selectDirectory: vi.fn(), saveCsvDialog: vi.fn(), + saveFileDialog: vi.fn(), writeTextFile: vi.fn(), + writeBytesFile: vi.fn(), + joinPath: vi.fn(async (...parts: string[]) => parts.join("\\")), findFirstDocument: vi.fn(), indexFolder: vi.fn().mockResolvedValue([ { radicado: "R1", filename: "factura.pdf", docType: "Factura", localPath: "C:\\docs\\R1\\factura.pdf", size: 10 }, diff --git a/services/desktop_app/src/shared/types.ts b/services/desktop_app/src/shared/types.ts index 5e8a2be..7478525 100644 --- a/services/desktop_app/src/shared/types.ts +++ b/services/desktop_app/src/shared/types.ts @@ -373,7 +373,16 @@ export interface DesktopBridge { getRuntimeConfig(): Promise; selectDirectory(): Promise; saveCsvDialog(defaultPath?: string): Promise; + saveFileDialog( + defaultPath?: string, + filters?: Array<{ name: string; extensions: string[] }>, + ): Promise; writeTextFile(filePath: string, content: string): Promise<{ ok: true; filePath: string }>; + writeBytesFile( + filePath: string, + data: ArrayBuffer | Uint8Array, + ): Promise<{ ok: true; filePath: string }>; + joinPath(...parts: string[]): Promise; findFirstDocument(rootPath: string, keywords: string[]): Promise; indexFolder( rootPath: string,