Files
IPSExtractor/services/services-count-facturas-medicas-serving/web/app.js
T

347 lines
10 KiB
JavaScript

const MAX_SYNC_FILES = 50;
const MAX_ASYNC_FILES = 500;
const JOB_CHUNK_SIZE = 500;
const LONG_POLL_MS = 30000;
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
function $(id) {
return document.getElementById(id);
}
function getBaseUrl() {
const input = $("baseUrl").value.trim();
return (input || window.location.origin).replace(/\/$/, "");
}
function getIncludeRejected() {
return $("includeRejected").checked;
}
function getSelectedFiles(inputId) {
const input = $(inputId);
return Array.from(input.files || []);
}
function validateFiles(files, maxFiles) {
if (files.length === 0) {
throw new Error("Selecciona al menos un archivo PDF.");
}
if (files.length > maxFiles) {
throw new Error(`Máximo ${maxFiles} archivos por petición.`);
}
for (const file of files) {
if (!file.name.toLowerCase().endsWith(".pdf")) {
throw new Error(`"${file.name}" no es un PDF.`);
}
}
}
async function parseError(res) {
try {
const data = await res.json();
if (data && data.error) return data.error;
} catch (_) {
/* ignore */
}
return `Error HTTP ${res.status}`;
}
function buildMultipart(files) {
const form = new FormData();
for (const file of files) {
form.append("files", file, file.name);
}
return form;
}
function setLoading(button, loading, label) {
button.disabled = loading;
button.textContent = loading ? "Procesando…" : label;
}
function showError(containerId, message) {
const el = $(containerId);
el.hidden = false;
el.textContent = message;
}
function hideError(containerId) {
const el = $(containerId);
el.hidden = true;
el.textContent = "";
}
function formatProbability(value) {
if (typeof value !== "number") return "—";
return (value * 100).toFixed(1) + "%";
}
function renderResults(container, results) {
container.innerHTML = "";
if (!results || results.length === 0) {
container.innerHTML = '<p class="empty">Sin resultados.</p>';
return;
}
for (const item of results) {
const card = document.createElement("article");
card.className = "result-card";
const header = document.createElement("div");
header.className = "result-header";
header.innerHTML = `
<h3>${escapeHtml(item.filename)}</h3>
<span class="badge badge-${item.status}">${escapeHtml(item.status)}</span>
`;
card.appendChild(header);
if (item.status === "error" && item.error) {
const err = document.createElement("p");
err.className = "result-error";
err.textContent = item.error;
card.appendChild(err);
} else {
const meta = document.createElement("p");
meta.className = "result-meta";
meta.textContent = `Servicios detectados: ${item.service_count ?? 0}`;
card.appendChild(meta);
if (item.services && item.services.length > 0) {
const table = document.createElement("table");
table.innerHTML = `
<thead>
<tr>
<th>Texto</th>
<th>Probabilidad</th>
<th>Es servicio</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector("tbody");
for (const row of item.services) {
const tr = document.createElement("tr");
const isService =
row.is_service === undefined || row.is_service === null
? "—"
: row.is_service
? "Sí"
: "No";
tr.innerHTML = `
<td>${escapeHtml(row.text)}</td>
<td>${formatProbability(row.probability)}</td>
<td>${isService}</td>
`;
tbody.appendChild(tr);
}
card.appendChild(table);
}
}
container.appendChild(card);
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text ?? "";
return div.innerHTML;
}
async function countSync(baseUrl, files, includeRejected) {
const url = new URL(`${baseUrl}/v1/count`);
if (includeRejected) url.searchParams.set("include_rejected", "true");
const res = await fetch(url, {
method: "POST",
body: buildMultipart(files),
});
if (!res.ok) throw new Error(await parseError(res));
const data = await res.json();
return data.results || [];
}
async function createEmptyJob(baseUrl, includeRejected) {
const res = await fetch(`${baseUrl}/v1/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ include_rejected: includeRejected }),
});
if (!res.ok) throw new Error(await parseError(res));
const data = await res.json();
return data.job_id;
}
async function uploadChunk(baseUrl, jobId, files, includeRejected) {
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/upload`);
if (includeRejected) url.searchParams.set("include_rejected", "true");
const res = await fetch(url, {
method: "POST",
body: buildMultipart(files),
});
if (!res.ok) throw new Error(await parseError(res));
return res.json();
}
async function getJobStatus(baseUrl, jobId, waitMs = 0) {
const url = new URL(`${baseUrl}/v1/jobs/${jobId}`);
if (waitMs > 0) url.searchParams.set("wait_ms", String(waitMs));
const res = await fetch(url);
if (!res.ok) throw new Error(await parseError(res));
return res.json();
}
async function fetchAllResults(baseUrl, jobId) {
const results = [];
let cursor = "";
let hasMore = true;
while (hasMore) {
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/results`);
url.searchParams.set("limit", "500");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(await parseError(res));
const page = await res.json();
results.push(...(page.results || []));
cursor = page.next_cursor || "";
hasMore = Boolean(page.has_more);
}
return results;
}
function chunkArray(items, size) {
const chunks = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
async function runAsyncJob(baseUrl, files, includeRejected, onProgress) {
const jobId = await createEmptyJob(baseUrl, includeRejected);
const chunks = chunkArray(files, JOB_CHUNK_SIZE);
for (let i = 0; i < chunks.length; i++) {
onProgress({
status: "uploading",
total: files.length,
processed: 0,
failed: 0,
uploadChunk: i + 1,
uploadChunks: chunks.length,
});
await uploadChunk(baseUrl, jobId, chunks[i], includeRejected);
}
while (true) {
const status = await getJobStatus(baseUrl, jobId, LONG_POLL_MS);
onProgress(status);
if (TERMINAL_STATUSES.has(status.status)) {
if (status.status === "cancelled") {
throw new Error("El job fue cancelado.");
}
break;
}
}
return fetchAllResults(baseUrl, jobId);
}
function updateProgress(status) {
const bar = $("asyncProgress");
const label = $("asyncProgressLabel");
const total = status.total || 0;
const done = (status.processed || 0) + (status.failed || 0);
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
bar.hidden = false;
label.hidden = false;
if (status.status === "uploading") {
const chunkPct =
status.uploadChunks > 0
? Math.round((status.uploadChunk / status.uploadChunks) * 100)
: 0;
bar.value = chunkPct;
label.textContent = `Subiendo lote ${status.uploadChunk}/${status.uploadChunks} (${total} archivos)…`;
return;
}
bar.value = pct;
label.textContent = `Estado: ${status.status} — ${done}/${total} procesados (${status.failed || 0} fallidos)`;
}
function initTabs() {
const tabs = document.querySelectorAll(".tab");
const panels = document.querySelectorAll(".panel");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
tabs.forEach((t) => t.classList.remove("active"));
panels.forEach((p) => p.classList.remove("active"));
tab.classList.add("active");
document.getElementById(tab.dataset.panel).classList.add("active");
});
});
}
function init() {
$("baseUrl").value = window.location.origin;
initTabs();
$("syncForm").addEventListener("submit", async (e) => {
e.preventDefault();
const btn = $("syncSubmit");
hideError("syncError");
$("syncResults").innerHTML = "";
try {
const files = getSelectedFiles("syncFiles");
validateFiles(files, MAX_SYNC_FILES);
setLoading(btn, true, "Contar servicios");
const results = await countSync(getBaseUrl(), files, getIncludeRejected());
renderResults($("syncResults"), results);
} catch (err) {
showError("syncError", err.message);
} finally {
setLoading(btn, false, "Contar servicios");
}
});
$("asyncForm").addEventListener("submit", async (e) => {
e.preventDefault();
const btn = $("asyncSubmit");
hideError("asyncError");
$("asyncResults").innerHTML = "";
$("asyncProgress").hidden = true;
$("asyncProgressLabel").hidden = true;
try {
const files = getSelectedFiles("asyncFiles");
validateFiles(files, MAX_ASYNC_FILES);
setLoading(btn, true, "Iniciar job");
const results = await runAsyncJob(
getBaseUrl(),
files,
getIncludeRejected(),
updateProgress,
);
renderResults($("asyncResults"), results);
} catch (err) {
showError("asyncError", err.message);
} finally {
setLoading(btn, false, "Iniciar job");
}
});
}
document.addEventListener("DOMContentLoaded", init);