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,