feat: Update docker-compose configuration to use environment variables for sensitive data, enhance testing documentation with new LaTeX files for capacity planning, and refine README for clarity on compilation and testing processes.

This commit is contained in:
erik
2026-07-17 08:32:00 -05:00
parent e47ef4f8c6
commit dc3fcc2280
89 changed files with 5331 additions and 1210 deletions
@@ -8,7 +8,7 @@ import {
Search,
XCircle,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { Fragment, useMemo, useRef, useState } from "react";
import type { CountJobFile } from "../../shared/types";
interface Props {
@@ -41,6 +41,7 @@ export function CountResultsTable({
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [toggling, setToggling] = useState<Set<string>>(new Set());
const [collapsedNits, setCollapsedNits] = useState<Set<string>>(new Set());
const tagInputRef = useRef<HTMLInputElement>(null);
const summary = useMemo(() => {
@@ -74,11 +75,47 @@ export function CountResultsTable({
else if (filter === "errors") list = list.filter((f) => f.status === "error");
const q = search.trim().toLowerCase();
if (q) list = list.filter((f) => f.filename.toLowerCase().includes(q) || (f.radicado ?? "").toLowerCase().includes(q));
if (q) {
list = list.filter(
(f) =>
f.filename.toLowerCase().includes(q) ||
(f.radicado ?? "").toLowerCase().includes(q) ||
(f.nit ?? "").toLowerCase().includes(q),
);
}
return list;
}, [files, filter, search, validCounts]);
const nitGroups = useMemo(() => {
const hasNit = files.some((f) => f.nit);
if (!hasNit) return null;
const map = new Map<string, CountJobFile[]>();
for (const f of filtered) {
const nit = (f.nit || "").trim() || "Sin NIT";
const list = map.get(nit) ?? [];
list.push(f);
map.set(nit, list);
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
.map(([nit, group]) => ({ nit, files: group }));
}, [files, filtered]);
const folderCount = useMemo(() => {
const nits = new Set(files.map((f) => (f.nit || "").trim()).filter(Boolean));
return nits.size;
}, [files]);
function toggleNitGroup(nit: string) {
setCollapsedNits((prev) => {
const next = new Set(prev);
if (next.has(nit)) next.delete(nit);
else next.add(nit);
return next;
});
}
function toggleExpand(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
@@ -142,6 +179,12 @@ export function CountResultsTable({
<span className="wiz-stat-value">{summary.total}</span>
<span className="wiz-stat-label">Total</span>
</div>
{folderCount > 0 && (
<div className="wiz-stat">
<span className="wiz-stat-value">{folderCount}</span>
<span className="wiz-stat-label">Carpetas</span>
</div>
)}
<div className="wiz-stat count-stat--valid">
<span className="wiz-stat-value">{summary.valid}</span>
<span className="wiz-stat-label">Válidos</span>
@@ -157,6 +200,12 @@ export function CountResultsTable({
</div>
)}
</div>
{folderCount > 0 && (
<p className="wiz-hint" style={{ marginTop: -8, marginBottom: 12 }}>
{folderCount} carpeta{folderCount === 1 ? "" : "s"} · {summary.total} PDF
{summary.total === 1 ? "" : "s"}
</p>
)}
{/* ── Filtro por conteo exacto ── */}
<div className="count-threshold">
@@ -241,7 +290,7 @@ export function CountResultsTable({
<div className="wiz-search-box">
<Search size={14} />
<input
placeholder="Buscar archivo…"
placeholder={folderCount > 0 ? "Buscar archivo o NIT…" : "Buscar archivo…"}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -275,109 +324,231 @@ export function CountResultsTable({
</td>
</tr>
)}
{filtered.map((f) => {
const st = rowStatus(f);
const isExpanded = expanded.has(f.id);
const hasServices = f.services && f.services.length > 0;
const isToggling = toggling.has(f.id);
const wasAutoAdjusted = autoAdjustedFilenames?.has(f.filename);
return (
<>
<tr key={f.id} className={`count-row count-row--${st}`}>
<td className="count-td">
<div className="count-file-cell">
{hasServices ? (
{nitGroups
? nitGroups.map(({ nit, files: groupFiles }) => {
const collapsed = collapsedNits.has(nit);
const servicesSum = groupFiles.reduce(
(acc, f) => acc + (f.status === "ok" ? f.service_count ?? 0 : 0),
0,
);
return (
<Fragment key={`nit-${nit}`}>
<tr className="count-row count-row--group">
<td colSpan={4} className="count-td">
<button
type="button"
className="count-expand-btn"
onClick={() => toggleExpand(f.id)}
title={isExpanded ? "Ocultar servicios" : "Ver servicios detectados"}
onClick={() => toggleNitGroup(nit)}
style={{ display: "inline-flex", alignItems: "center", gap: 8, width: "100%" }}
>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{collapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
<strong>NIT {nit}</strong>
<span className="count-radicado">
{groupFiles.length} PDF · {servicesSum} servicios
</span>
</button>
) : (
<span className="count-expand-placeholder" />
)}
<div className="count-file-info">
<div className="count-filename-row">
<span className="count-filename" title={f.filename}>{f.filename}</span>
{wasAutoAdjusted && (
<span
className="count-auto-badge"
title="Conteo ajustado de 0 a 1 automáticamente por regla de negocio"
</td>
</tr>
{!collapsed &&
groupFiles.map((f) => {
const st = rowStatus(f);
const isExpanded = expanded.has(f.id);
const hasServices = f.services && f.services.length > 0;
const isToggling = toggling.has(f.id);
const wasAutoAdjusted = autoAdjustedFilenames?.has(f.filename);
return (
<Fragment key={f.id}>
<tr className={`count-row count-row--${st}`}>
<td className="count-td">
<div className="count-file-cell">
{hasServices ? (
<button
type="button"
className="count-expand-btn"
onClick={() => toggleExpand(f.id)}
title={isExpanded ? "Ocultar servicios" : "Ver servicios detectados"}
>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
) : (
<span className="count-expand-placeholder" />
)}
<div className="count-file-info">
<div className="count-filename-row">
<span className="count-filename" title={f.filename}>{f.filename}</span>
{wasAutoAdjusted && (
<span
className="count-auto-badge"
title="Conteo ajustado de 0 a 1 automáticamente por regla de negocio"
>
auto→1
</span>
)}
</div>
{f.radicado && f.radicado !== f.filename && (
<span className="count-radicado">{f.radicado}</span>
)}
{f.status === "error" && f.error && (
<span className="count-error-msg">{f.error}</span>
)}
</div>
</div>
</td>
<td className="count-td count-td-center">
{f.status === "error" ? (
<span className="count-dash">—</span>
) : (
<span className="count-service-num">{f.service_count ?? "?"}</span>
)}
</td>
<td className="count-td count-td-center">
{st === "error" && <XCircle size={15} className="count-icon count-icon--error" />}
{st === "valid" && <CheckCircle2 size={15} className="count-icon count-icon--valid" />}
{st === "invalid" && <AlertCircle size={15} className="count-icon count-icon--warn" />}
</td>
<td className="count-td count-td-center">
{f.status !== "error" && (
<div className="count-validity-btns">
<button
type="button"
className={`count-validity-btn ${f.is_valid === true ? "count-validity-btn--accept" : ""}`}
onClick={() => handleToggle(f, f.is_valid === true ? null : true)}
disabled={loading || isToggling}
title="Marcar como válido manualmente"
>
✓
</button>
<button
type="button"
className={`count-validity-btn ${f.is_valid === false ? "count-validity-btn--reject" : ""}`}
onClick={() => handleToggle(f, f.is_valid === false ? null : false)}
disabled={loading || isToggling}
title="Rechazar manualmente"
>
✗
</button>
</div>
)}
</td>
</tr>
{isExpanded && hasServices && (
<tr className="count-row count-row-expanded">
<td colSpan={4} className="count-services-cell">
<div className="count-services-list">
{f.services.map((svc, i) => (
<div key={i} className="count-service-item">
<span className="count-service-text">{svc.text}</span>
<span className="count-service-prob">{(svc.probability * 100).toFixed(0)}%</span>
</div>
))}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</Fragment>
);
})
: filtered.map((f) => {
const st = rowStatus(f);
const isExpanded = expanded.has(f.id);
const hasServices = f.services && f.services.length > 0;
const isToggling = toggling.has(f.id);
const wasAutoAdjusted = autoAdjustedFilenames?.has(f.filename);
return (
<Fragment key={f.id}>
<tr className={`count-row count-row--${st}`}>
<td className="count-td">
<div className="count-file-cell">
{hasServices ? (
<button
type="button"
className="count-expand-btn"
onClick={() => toggleExpand(f.id)}
title={isExpanded ? "Ocultar servicios" : "Ver servicios detectados"}
>
auto→1
</span>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
) : (
<span className="count-expand-placeholder" />
)}
</div>
{f.radicado && f.radicado !== f.filename && (
<span className="count-radicado">{f.radicado}</span>
)}
{f.status === "error" && f.error && (
<span className="count-error-msg">{f.error}</span>
)}
</div>
</div>
</td>
<td className="count-td count-td-center">
{f.status === "error" ? (
<span className="count-dash">—</span>
) : (
<span className="count-service-num">{f.service_count ?? "?"}</span>
)}
</td>
<td className="count-td count-td-center">
{st === "error" && <XCircle size={15} className="count-icon count-icon--error" />}
{st === "valid" && <CheckCircle2 size={15} className="count-icon count-icon--valid" />}
{st === "invalid" && <AlertCircle size={15} className="count-icon count-icon--warn" />}
</td>
<td className="count-td count-td-center">
{f.status !== "error" && (
<div className="count-validity-btns">
<button
type="button"
className={`count-validity-btn ${f.is_valid === true ? "count-validity-btn--accept" : ""}`}
onClick={() => handleToggle(f, f.is_valid === true ? null : true)}
disabled={loading || isToggling}
title="Marcar como válido manualmente"
>
✓
</button>
<button
type="button"
className={`count-validity-btn ${f.is_valid === false ? "count-validity-btn--reject" : ""}`}
onClick={() => handleToggle(f, f.is_valid === false ? null : false)}
disabled={loading || isToggling}
title="Rechazar manualmente"
>
✗
</button>
</div>
)}
</td>
</tr>
{isExpanded && hasServices && (
<tr key={`${f.id}-services`} className="count-row count-row-expanded">
<td colSpan={4} className="count-services-cell">
<div className="count-services-list">
{f.services.map((svc, i) => (
<div key={i} className="count-service-item">
<span className="count-service-text">{svc.text}</span>
<span className="count-service-prob">{(svc.probability * 100).toFixed(0)}%</span>
<div className="count-file-info">
<div className="count-filename-row">
<span className="count-filename" title={f.filename}>{f.filename}</span>
{wasAutoAdjusted && (
<span
className="count-auto-badge"
title="Conteo ajustado de 0 a 1 automáticamente por regla de negocio"
>
auto→1
</span>
)}
</div>
{f.radicado && f.radicado !== f.filename && (
<span className="count-radicado">{f.radicado}</span>
)}
{f.status === "error" && f.error && (
<span className="count-error-msg">{f.error}</span>
)}
</div>
))}
</div>
</td>
</tr>
)}
</>
);
})}
</div>
</td>
<td className="count-td count-td-center">
{f.status === "error" ? (
<span className="count-dash">—</span>
) : (
<span className="count-service-num">{f.service_count ?? "?"}</span>
)}
</td>
<td className="count-td count-td-center">
{st === "error" && <XCircle size={15} className="count-icon count-icon--error" />}
{st === "valid" && <CheckCircle2 size={15} className="count-icon count-icon--valid" />}
{st === "invalid" && <AlertCircle size={15} className="count-icon count-icon--warn" />}
</td>
<td className="count-td count-td-center">
{f.status !== "error" && (
<div className="count-validity-btns">
<button
type="button"
className={`count-validity-btn ${f.is_valid === true ? "count-validity-btn--accept" : ""}`}
onClick={() => handleToggle(f, f.is_valid === true ? null : true)}
disabled={loading || isToggling}
title="Marcar como válido manualmente"
>
✓
</button>
<button
type="button"
className={`count-validity-btn ${f.is_valid === false ? "count-validity-btn--reject" : ""}`}
onClick={() => handleToggle(f, f.is_valid === false ? null : false)}
disabled={loading || isToggling}
title="Rechazar manualmente"
>
✗
</button>
</div>
)}
</td>
</tr>
{isExpanded && hasServices && (
<tr className="count-row count-row-expanded">
<td colSpan={4} className="count-services-cell">
<div className="count-services-list">
{f.services.map((svc, i) => (
<div key={i} className="count-service-item">
<span className="count-service-text">{svc.text}</span>
<span className="count-service-prob">{(svc.probability * 100).toFixed(0)}%</span>
</div>
))}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
@@ -41,6 +41,9 @@ interface Props {
onTaskCreated?: (task: Task) => void;
/** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */
onProgress?: (progress: BackgroundProgress) => void;
/** Origen MinIO: corrida de bot de descarga. */
downloadJobId?: string;
downloadFiles?: Array<{ key: string; nit?: string | null; radicado?: string | null }>;
}
interface CountProgress {
@@ -56,6 +59,32 @@ 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;
@@ -68,7 +97,18 @@ function formatEta(progress: CountProgress): string | null {
return `~${Math.ceil(secs / 60)} min · ${rateStr}`;
}
export function CountingWizard({ client, localRoot, containerRoot, job, onStartExtraction, onTaskCreated, onProgress }: Props) {
export function CountingWizard({
client,
localRoot,
containerRoot,
job,
onStartExtraction,
onTaskCreated,
onProgress,
downloadJobId,
downloadFiles,
}: Props) {
const fromDownload = Boolean(downloadJobId);
const [step, setStep] = useState<CountingStep>(1);
const [maxReached, setMaxReached] = useState<CountingStep>(1);
const [form, setForm] = useState<CountingFormState>({
@@ -95,6 +135,7 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const abortRef = useRef(false);
const downloadJobIdRef = useRef(downloadJobId);
const indexLocalFolder = useCallback(
async (dir: string) => {
@@ -118,6 +159,33 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
[docTypes],
);
const indexFromDownload = useCallback(
async (jobId: string, seedFiles?: Array<{ key: string; nit?: string | null; radicado?: string | null }>) => {
setIndexing(true);
setError(null);
try {
let files = seedFiles ?? [];
if (files.length === 0) {
const resp = await client.listDownloadJobFiles(jobId);
files = resp.files;
}
const entries = downloadFilesToManifest(files);
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 [];
} finally {
setIndexing(false);
}
},
[client],
);
useEffect(() => {
setForm((f) => ({
...f,
@@ -127,13 +195,22 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
}, [localRoot, containerRoot]);
useEffect(() => {
downloadJobIdRef.current = downloadJobId;
if (downloadJobId) {
void indexFromDownload(downloadJobId, downloadFiles);
return;
}
if (!localRoot) return;
let cancelled = false;
indexLocalFolder(localRoot).catch(() => {
if (!cancelled) setError("Error al indexar la carpeta.");
});
return () => { cancelled = true; };
}, [localRoot, indexLocalFolder]);
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]);
function goToStep(target: CountingStep) {
setStep(target);
@@ -154,6 +231,15 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
return norm.startsWith(root) ? container + norm.slice(root.length) : norm;
}
async function readEntryBytes(entry: ManifestEntry): Promise<ArrayBuffer> {
const jobId = downloadJobIdRef.current;
if (jobId && entry.objectKey) {
return client.fetchDownloadObject(jobId, entry.objectKey);
}
const fileData = await desktopApi.readFile(entry.localPath);
return fileData.data;
}
async function runCounting() {
if (!manifest.length) {
setError("No hay archivos en la carpeta seleccionada.");
@@ -215,7 +301,8 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
running: true,
label: `Preparando archivos (${Math.min(batchStart + fi + 1, manifest.length)}/${manifest.length})…`,
});
return desktopApi.readFile(entry.localPath);
const data = await readEntryBytes(entry);
return { filename: entry.filename, data };
}),
);
@@ -317,16 +404,27 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
});
try {
const nitCount = new Set(manifest.map((m) => m.nit).filter(Boolean)).size;
const titleBase = fromDownload
? `Conteo desde descarga${nitCount ? ` · ${nitCount} carpeta${nitCount === 1 ? "" : "s"}` : ""}`
: `Conteo — ${form.localRoot.split(/[\\/]/).pop()}`;
const payload: CreateCountJobPayload = {
title: `Conteo — ${form.localRoot.split(/[\\/]/).pop()} (${finalResults.length} archivos)`,
title: `${titleBase} (${finalResults.length} archivos)`,
job_id: job?.id,
results: finalResults.map((r) => {
const entry = manifest.find((m) => m.filename === r.filename);
download_job_id: downloadJobIdRef.current ?? null,
source: fromDownload ? "from-download" : "desktop",
results: finalResults.map((r, i) => {
const entry = manifest[i];
return {
radicado: entry?.radicado ?? null,
filename: r.filename,
file_path: entry ? localToContainer(entry.localPath) : null,
file_path:
fromDownload || !entry
? entry?.objectKey ?? null
: localToContainer(entry.localPath),
doc_type: entry?.docType ?? null,
nit: entry?.nit ?? null,
object_key: entry?.objectKey ?? null,
service_count: r.status === "ok" ? r.service_count : null,
services: r.services ?? [],
status: r.status === "ok" ? "ok" : "error",
@@ -377,16 +475,22 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
function handleExportCsv() {
if (!savedFiles.length) return;
const headers = ["filename", "radicado", "service_count", "auto_ajustado", "status", "is_valid", "error"];
const rows = savedFiles.map((f) => [
f.filename,
f.radicado ?? "",
f.service_count ?? "",
autoAdjustedFilenames.has(f.filename) ? "si" : "",
f.status,
f.is_valid === null ? "" : String(f.is_valid),
f.error ?? "",
]);
const hasNit = savedFiles.some((f) => f.nit);
const headers = hasNit
? ["nit", "filename", "radicado", "service_count", "auto_ajustado", "status", "is_valid", "error"]
: ["filename", "radicado", "service_count", "auto_ajustado", "status", "is_valid", "error"];
const rows = savedFiles.map((f) => {
const base = [
f.filename,
f.radicado ?? "",
f.service_count ?? "",
autoAdjustedFilenames.has(f.filename) ? "si" : "",
f.status,
f.is_valid === null ? "" : String(f.is_valid),
f.error ?? "",
];
return hasNit ? [f.nit ?? "", ...base] : base;
});
downloadCsv(buildExportCsvFilename("conteo", job?.title), toCsv(headers, rows));
}
@@ -399,8 +503,10 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
setAutoAdjustedFilenames(new Set());
setError(null);
setProgress({ processed: 0, total: 0, failed: 0, currentBatch: "", startedAt: 0 });
if (form.localRoot) {
indexLocalFolder(form.localRoot);
if (downloadJobIdRef.current) {
void indexFromDownload(downloadJobIdRef.current, downloadFiles);
} else if (form.localRoot) {
void indexLocalFolder(form.localRoot);
}
}
@@ -423,58 +529,94 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
<div className="wiz-section">
<h3 className="wiz-section-title">Origen de las facturas</h3>
<p className="wiz-desc">
Selecciona la carpeta local con los PDF de facturas médicas.
El servicio contará cuántos servicios contiene cada documento usando OCR.
{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."}
</p>
</div>
<div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.localRoot}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
{fromDownload ? (
<div className="wiz-section">
<label className="wiz-field-label">Corrida MinIO</label>
<div className="folder-picker">
<div className="folder-picker-display">
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">
downloads/{downloadJobId}
</span>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={() => downloadJobId && void indexFromDownload(downloadJobId)}
disabled={indexing}
>
{indexing ? <Loader2 size={14} className="spin" /> : <RotateCcw size={14} />}
{indexing ? "Indexando…" : "Recargar"}
</button>
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> PDF(s) en{" "}
<strong>{new Set(manifest.map((m) => m.nit).filter(Boolean)).size || 1}</strong>{" "}
carpeta(s) planas — listos para contar
</span>
</div>
)}
</div>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
</span>
) : (
<div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.localRoot}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
</div>
)}
{form.localRoot && manifest.length === 0 && !indexing && !error && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
</span>
</div>
)}
{form.localRoot && manifest.length === 0 && !indexing && !error && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div>
)}
<details className="wiz-advanced">
<summary className="wiz-advanced-trigger">Opciones avanzadas</summary>
<div className="wiz-advanced-body">
<div className="wiz-section">
<label className="wiz-field-label">Ruta en contenedor Docker</label>
<input
type="text"
value={form.containerRoot}
onChange={(e) => setForm((f) => ({ ...f, containerRoot: e.target.value }))}
placeholder="/data"
/>
<p className="wiz-hint">
Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD).
</p>
</div>
{!fromDownload && (
<div className="wiz-section">
<label className="wiz-field-label">Ruta en contenedor Docker</label>
<input
type="text"
value={form.containerRoot}
onChange={(e) => setForm((f) => ({ ...f, containerRoot: e.target.value }))}
placeholder="/data"
/>
<p className="wiz-hint">
Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD).
</p>
</div>
)}
<div className="wiz-checks-row">
<label className="wiz-check-label">
@@ -262,6 +262,21 @@ export class ApiClient {
return this.handle<DownloadJobFilesResponse>(response);
}
/** Descarga bytes de un PDF del resultado MinIO (proxy bot-descarga). */
async fetchDownloadObject(jobId: string, key: string): Promise<ArrayBuffer> {
const qs = new URLSearchParams({ key });
const response = await fetchWithTimeout(
this.url(`/bot-descarga/jobs/${encodeURIComponent(jobId)}/object?${qs.toString()}`),
{ headers: this.botHeaders() },
120_000,
);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(text || `Error ${response.status} al leer objeto MinIO`);
}
return response.arrayBuffer();
}
async getDownloadEvents(jobId: string, since = 0): Promise<{ events: DownloadJobEvent[] }> {
const response = await fetchWithTimeout(
this.url(`/bot-descarga/jobs/${encodeURIComponent(jobId)}/events?since=${since}`),
@@ -1,5 +1,5 @@
import { CountingWizard } from "../../components/CountingWizard";
import type { BackgroundProgress } from "../../../shared/types";
import type { BackgroundProgress, DownloadJobFile } from "../../../shared/types";
import type { ModuleWizardProps } from "../registry";
interface Props extends ModuleWizardProps {
@@ -9,21 +9,41 @@ interface Props extends ModuleWizardProps {
/**
* Wrapper del CountingWizard para el sistema de módulos.
* Pasa el job al wizard para que los resultados queden vinculados.
* Si el contexto trae downloadJobId (desde Resultados → Continuar a conteo),
* indexa PDFs desde MinIO en todas las carpetas NIT planas.
*/
export function CountingWizardModule({ client, job, onTaskCreated, onActivateModule, onProgress }: Props) {
export function CountingWizardModule({
client,
job,
context,
onTaskCreated,
onActivateModule,
onProgress,
}: Props) {
const folderPath = String(job.metadata?.folder_path ?? "");
const containerRoot = "/data";
const downloadJobId = context?.downloadJobId as string | undefined;
const downloadFiles = context?.files as DownloadJobFile[] | undefined;
return (
<CountingWizard
key={downloadJobId ? `dl-${downloadJobId}` : `local-${folderPath || "default"}`}
client={client}
job={job}
localRoot={folderPath}
localRoot={downloadJobId ? "" : folderPath}
containerRoot={containerRoot}
downloadJobId={downloadJobId}
downloadFiles={downloadFiles}
onTaskCreated={onTaskCreated}
onProgress={onProgress}
onStartExtraction={(countingTaskId, validCount, validCounts) => {
onActivateModule?.("extraction", { countingTaskId, validCount, validCounts, jobId: job.id });
onActivateModule?.("extraction", {
countingTaskId,
validCount,
validCounts,
jobId: job.id,
downloadJobId,
});
}}
/>
);
@@ -82,6 +82,9 @@ 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 [form, setForm] = useState<ExtractionFormState>(() => makeDefaultForm(job, !!countingTaskId));
const [schemaResponse, setSchemaResponse] = useState<SchemaResponse | null>(null);
@@ -100,6 +103,7 @@ export function JobExtractionWizard({
/** Rutas de muestras ya usadas en descubrimiento (para no repetir al reintentar). */
const [usedDiscoveryPaths, setUsedDiscoveryPaths] = useState<string[]>([]);
const [discoverySampleLabels, setDiscoverySampleLabels] = useState<string[]>([]);
const [resolvedDownloadJobId, setResolvedDownloadJobId] = useState<string | undefined>(downloadJobId);
const flattened = useMemo(
() => (schemaResponse ? flattenSchema(schemaResponse) : { schema: {}, sample: null }),
@@ -115,19 +119,69 @@ export function JobExtractionWizard({
// Paso 3: necesita al menos un campo seleccionado
(extractionStep === 3 && selection.fields.length > 0);
// Auto-indexar la carpeta del job al montar (usada como muestra para descubrir campos)
// Auto-indexar: carpeta local, o archivos del conteo desde MinIO
useEffect(() => {
if (countingTaskId) {
void (async () => {
try {
const [countFiles, countTask] = await Promise.all([
client.countJobFiles(countingTaskId),
client.countJob(countingTaskId).catch(() => null),
]);
const req = (countTask?.request ?? {}) as Record<string, unknown>;
const dlId =
downloadJobId ||
(typeof req.download_job_id === "string" ? req.download_job_id : undefined);
if (dlId) setResolvedDownloadJobId(dlId);
const fromMinio = countFiles.some((f) => f.object_key);
if (fromMinio) {
const entries: ManifestEntry[] = countFiles
.filter((f) => f.object_key)
.map((f) => ({
radicado: f.radicado || f.filename.replace(/\.pdf$/i, ""),
filename: f.filename,
localPath: f.object_key!,
size: 0,
nit: f.nit ?? null,
objectKey: f.object_key!,
}));
setManifest(entries);
setForm((cur) => ({ ...cur, mode: "general" }));
setStatusText(
`${entries.length} documento(s) desde MinIO` +
(new Set(entries.map((e) => e.nit).filter(Boolean)).size
? ` · ${new Set(entries.map((e) => e.nit).filter(Boolean)).size} carpetas`
: ""),
);
return;
}
} catch {
/* fallback a carpeta local abajo */
}
})();
}
const folder = form.localRoot || form.selectedLocalPath;
if (!folder || manifest.length > 0) return;
if (!folder || manifest.length > 0 || countingTaskId) return;
indexFolderAt(folder, form.mode, form.docTypes)
.then((entries) => {
setManifest(entries);
// Asegurar que selectedLocalPath esté sincronizado
setForm((cur) => ({ ...cur, selectedLocalPath: folder, selectedContainerPath: folder }));
})
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [countingTaskId]);
async function readEntryBytes(entry: ManifestEntry): Promise<{ filename: string; data: ArrayBuffer }> {
if (entry.objectKey && resolvedDownloadJobId) {
const data = await client.fetchDownloadObject(resolvedDownloadJobId, entry.objectKey);
return { filename: entry.filename, data };
}
if (!window.desktopApi) throw new Error("Requiere Electron.");
const fileData = await window.desktopApi.readFile(entry.localPath);
return { filename: fileData.filename, data: fileData.data };
}
function updateForm(patch: Partial<ExtractionFormState>) {
setForm((cur) => {
@@ -204,7 +258,8 @@ export function JobExtractionWizard({
if (candidates.length === 0) {
throw new Error("No hay documentos candidatos para la muestra.");
}
const unused = candidates.filter((e) => !exclude.has(e.localPath));
const keyOf = (e: ManifestEntry) => e.objectKey || e.localPath;
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];
@@ -217,17 +272,35 @@ export function JobExtractionWizard({
await runTask(
randomOther ? "Probando con otro archivo…" : "Descubriendo campos...",
async () => {
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos.");
const exclude = new Set(randomOther ? usedDiscoveryPaths : []);
const responses: SchemaResponse[] = [];
const picked: ManifestEntry[] = [];
if (form.mode === "general") {
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),
),
);
}
} else if (form.mode === "general") {
const entry = pickSampleEntry(manifest, exclude, randomOther);
picked.push(entry);
const fileData = await window.desktopApi.readFile(entry.localPath);
const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push(
await client.discoverSchemaUpload(
@@ -242,7 +315,7 @@ export function JobExtractionWizard({
}
const entry = pickSampleEntry(typed, exclude, randomOther);
picked.push(entry);
const fileData = await window.desktopApi.readFile(entry.localPath);
const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push(
await client.discoverSchemaUpload(
@@ -260,11 +333,13 @@ export function JobExtractionWizard({
aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])),
});
setEstimateJson(null);
const labels = picked.map((e) => 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 : []);
for (const e of picked) next.add(e.localPath);
for (const e of picked) next.add(e.objectKey || e.localPath);
return [...next];
});
setStatusText(
@@ -285,11 +360,10 @@ export function JobExtractionWizard({
async function estimate() {
await runTask("Estimando costo...", async () => {
if (!window.desktopApi) throw new Error("Requiere Electron.");
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];
const fileData = await window.desktopApi.readFile(sampleEntry.localPath);
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);
@@ -310,22 +384,32 @@ export function JobExtractionWizard({
async function runExtraction() {
await runTask("Iniciando extracción...", async () => {
if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo.");
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta.");
let workManifest: ManifestEntry[];
// ── Caso 1: desde conteo previo — filtrar manifest a los archivos válidos ──
// 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) => validFilenames.has(e.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 en la carpeta. " +
"Asegúrate de que los PDFs válidos están en la carpeta seleccionada.",
"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."),
);
}
} else {
@@ -378,7 +462,7 @@ export function JobExtractionWizard({
running: true,
label: `Subiendo archivos (${queued + fi}/${total})…`,
});
files.push(await window.desktopApi!.readFile(batch[fi].localPath));
files.push(await readEntryBytes(batch[fi]));
}
const blobs = files.map((f) => new Blob([f.data]));
onProgress?.({
@@ -11,7 +11,7 @@ import {
Sparkles,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { BackgroundProgress, CleaningJob, CountJobFile, Job, Task } from "../../shared/types";
import type { BackgroundProgress, CleaningJob, CountJobFile, DownloadJobFile, Job, Task } from "../../shared/types";
import { CountResultsTable } from "../components/CountResultsTable";
import { ExtractionResultsTable } from "../components/ExtractionResultsTable";
import { BackgroundProgressBanner } from "../components/BackgroundProgressBanner";
@@ -33,6 +33,11 @@ interface Props {
onStartExtraction: (ctx?: Record<string, unknown>) => void;
onStartCleaning: (ctx?: Record<string, unknown>) => void;
onStartDownload: () => void;
onContinueToCounting?: (ctx: {
downloadJobId: string;
downloadsPrefix?: string;
files?: DownloadJobFile[];
}) => void;
}
export function JobResultsView({
@@ -47,6 +52,7 @@ export function JobResultsView({
onStartExtraction,
onStartCleaning,
onStartDownload,
onContinueToCounting,
}: Props) {
const [activeTab, setActiveTab] = useState<ResultTab>("counting");
const [refreshing, setRefreshing] = useState(false);
@@ -177,6 +183,7 @@ export function JobResultsView({
job={job}
tasks={downloadTasks}
onStartDownload={onStartDownload}
onContinueToCounting={onContinueToCounting}
/>
)}
</div>
@@ -236,15 +243,21 @@ function CountingResultsSection({
function exportCsv(subset: "valid" | "invalid") {
const subset_files = subset === "valid" ? files.filter(isFileValid) : files.filter((f) => !isFileValid(f));
const headers = ["filename", "radicado", "service_count", "status", "is_valid", "error"];
const rows = subset_files.map((f) => [
f.filename,
f.radicado ?? "",
f.service_count ?? "",
f.status,
f.is_valid === null ? "" : String(f.is_valid),
f.error ?? "",
]);
const hasNit = subset_files.some((f) => f.nit);
const headers = hasNit
? ["nit", "filename", "radicado", "service_count", "status", "is_valid", "error"]
: ["filename", "radicado", "service_count", "status", "is_valid", "error"];
const rows = subset_files.map((f) => {
const base = [
f.filename,
f.radicado ?? "",
f.service_count ?? "",
f.status,
f.is_valid === null ? "" : String(f.is_valid),
f.error ?? "",
];
return hasNit ? [f.nit ?? "", ...base] : base;
});
downloadCsv(buildExportCsvFilename("conteo", job.title, subset), toCsv(headers, rows));
}
@@ -149,6 +149,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
<CountingWizardModule
client={client}
job={job}
context={sectionContext}
onTaskCreated={handleTaskCreated}
onProgress={(p) => setCountingProgress(p.running ? p : null)}
onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
@@ -198,6 +199,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
onStartExtraction={(ctx?: Record<string, unknown>) => onActivateSection("extraction", ctx)}
onStartCleaning={(ctx?: Record<string, unknown>) => onActivateSection("cleaning", ctx)}
onStartDownload={() => onActivateSection("downloads")}
onContinueToCounting={(ctx) => onActivateSection("counting", ctx)}
/>
</div>
</div>
+10
View File
@@ -6,6 +6,10 @@ export interface ManifestEntry {
docType?: string;
localPath: string;
size: number;
/** NIT / carpeta plana (resultados bot descarga MinIO). */
nit?: string | null;
/** Object key en MinIO cuando el origen es descarga remota. */
objectKey?: string | null;
}
export interface FileUploadData {
@@ -117,6 +121,8 @@ export interface CountJobFile {
is_valid: boolean | null;
error: string | null;
created_at: string;
nit?: string | null;
object_key?: string | null;
}
export interface CountJobFileCreateEntry {
@@ -128,12 +134,16 @@ export interface CountJobFileCreateEntry {
services?: Array<{ text: string; probability: number; is_service?: boolean | null }>;
status?: "ok" | "error";
error?: string | null;
nit?: string | null;
object_key?: string | null;
}
export interface CreateCountJobPayload {
title?: string;
job_id?: string;
results: CountJobFileCreateEntry[];
download_job_id?: string | null;
source?: string | null;
}
export interface ExtractionFromCountPayload {