feat: Add poblacion_apta service to docker-compose, enhance API gateway for new service integration, and update README with service details. Introduce new database tables for poblacion_apta_jobs and poblacion_apta_rows, and implement functionality for handling cop/mod calculations in the cleaning service.

This commit is contained in:
erik
2026-07-28 04:29:14 -05:00
parent 447359166b
commit e73e425e4b
47 changed files with 6978 additions and 84 deletions
@@ -27,6 +27,7 @@ import { ExtractionResultsTable } from "../../components/ExtractionResultsTable"
import type { ApiClient } from "../../lib/apiClient";
import { loadCopModConfig } from "../../lib/cleaningSettings";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../../lib/csv";
import { taskTimingLabel, timingDuration } from "../../lib/time";
type SourceMode = "extraction" | "csv";
@@ -43,20 +44,6 @@ function batchIdFromTask(task: Task): string {
return String((task.result as Record<string, unknown>)?.extraction_batch_id ?? "");
}
function formatTaskDate(iso: string): string {
try {
return new Date(iso).toLocaleString("es-CO", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
export function CleaningWizard({
client,
job,
@@ -78,6 +65,8 @@ export function CleaningWizard({
const [analysis, setAnalysis] = useState<CleaningAnalysisResponse | null>(null);
const [rulesPerColumn, setRulesPerColumn] = useState<Record<string, string[]>>({});
const [copModFechaColumn, setCopModFechaColumn] = useState<Record<string, string>>({});
const [copModBrutoColumn, setCopModBrutoColumn] = useState<Record<string, string>>({});
const [copModNetoColumn, setCopModNetoColumn] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -179,6 +168,26 @@ export function CleaningWizard({
return preferred?.name ?? "";
}
function guessBrutoColumn(columns: CleaningColumnAnalysis[], preferCol?: string): string {
if (preferCol && /bruto|total[_\s]?cargos|valor[_\s]?bruto|valor[_\s]?factura/i.test(preferCol)) {
return preferCol;
}
const preferred =
columns.find((c) => /valor[_\s]?bruto|^bruto$|total[_\s]?cargos/i.test(c.name)) ??
columns.find((c) => /bruto/i.test(c.name)) ??
(preferCol ? columns.find((c) => c.name === preferCol) : undefined);
return preferred?.name ?? preferCol ?? "";
}
function guessNetoColumn(columns: CleaningColumnAnalysis[], brutoCol: string): string {
const preferred =
columns.find(
(c) => c.name !== brutoCol && /valor[_\s]?neto|^neto$|valor[_\s]?pagado/i.test(c.name),
) ??
columns.find((c) => c.name !== brutoCol && /neto/i.test(c.name));
return preferred?.name ?? "";
}
function toggleRule(column: string, ruleId: string) {
setRulesPerColumn((prev) => {
const current = prev[column] ?? [];
@@ -202,6 +211,27 @@ export function CleaningWizard({
return guess ? { ...prev, [column]: guess } : prev;
});
}
if (ruleId === "cop_mod_bruto_neto") {
const current = rulesPerColumn[column] ?? [];
const turningOn = !current.includes(ruleId);
if (!turningOn) {
setCopModBrutoColumn((prev) => {
const next = { ...prev };
delete next[column];
return next;
});
setCopModNetoColumn((prev) => {
const next = { ...prev };
delete next[column];
return next;
});
} else if (analysis) {
const bruto = guessBrutoColumn(analysis.columns, column);
const neto = guessNetoColumn(analysis.columns, bruto || column);
setCopModBrutoColumn((prev) => (prev[column] ? prev : { ...prev, [column]: bruto || column }));
setCopModNetoColumn((prev) => (prev[column] ? prev : { ...prev, [column]: neto }));
}
}
setSampleMode("after");
}
@@ -213,20 +243,38 @@ export function CleaningWizard({
}
setRulesPerColumn(suggested);
setCopModFechaColumn({});
setCopModBrutoColumn({});
setCopModNetoColumn({});
}
function cleaningExtraPayload(): {
cop_mod_fecha_column?: Record<string, string>;
cop_mod_bruto_column?: Record<string, string>;
cop_mod_neto_column?: Record<string, string>;
cop_mod_config: ReturnType<typeof loadCopModConfig>;
} {
const fechaEntries = Object.entries(copModFechaColumn).filter(
([col, fecha]) =>
Boolean(fecha) && (rulesPerColumn[col] ?? []).includes("cop_mod_split"),
);
const brutoEntries = Object.entries(copModBrutoColumn).filter(
([col, bruto]) =>
Boolean(bruto) && (rulesPerColumn[col] ?? []).includes("cop_mod_bruto_neto"),
);
const netoEntries = Object.entries(copModNetoColumn).filter(
([col, neto]) =>
Boolean(neto) && (rulesPerColumn[col] ?? []).includes("cop_mod_bruto_neto"),
);
return {
...(fechaEntries.length > 0
? { cop_mod_fecha_column: Object.fromEntries(fechaEntries) }
: {}),
...(brutoEntries.length > 0
? { cop_mod_bruto_column: Object.fromEntries(brutoEntries) }
: {}),
...(netoEntries.length > 0
? { cop_mod_neto_column: Object.fromEntries(netoEntries) }
: {}),
cop_mod_config: loadCopModConfig(),
};
}
@@ -241,17 +289,24 @@ export function CleaningWizard({
return [];
}
/** Columnas nuevas respecto al análisis (p. ej. *_doc_tipo / *_doc_numeros). */
/** Columnas nuevas respecto al análisis (p. ej. *_doc_tipo / *_doc_numeros / cop/mod). */
function derivedColumnNames(sourceCol: string): string[] {
if (!preview) return [];
const original = new Set(analysis?.columns.map((c) => c.name) ?? []);
const selected = rulesPerColumn[sourceCol] ?? [];
return preview.columns
.map((c) => c.name)
.filter(
(name) =>
!original.has(name) &&
name.toLowerCase().startsWith(`${sourceCol.toLowerCase()}_`),
);
.filter((name) => {
if (original.has(name)) return false;
if (name.toLowerCase().startsWith(`${sourceCol.toLowerCase()}_`)) return true;
if (
selected.includes("cop_mod_bruto_neto") &&
(name === "cop/mod" || name.startsWith("cop/mod_"))
) {
return true;
}
return false;
});
}
/** Orden: columna activa → columnas derivadas → resto (sin cortar). */
@@ -309,7 +364,7 @@ export function CleaningWizard({
return () => {
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
};
}, [step, analysis, rulesPerColumn, copModFechaColumn, batchId, client]);
}, [step, analysis, rulesPerColumn, copModFechaColumn, copModBrutoColumn, copModNetoColumn, batchId, client]);
async function loadAnalysis(mode: SourceMode, batch?: string, file?: File | null) {
setLoading(true);
@@ -393,6 +448,12 @@ export function CleaningWizard({
if (extra.cop_mod_fecha_column) {
form.append("cop_mod_fecha_column", JSON.stringify(extra.cop_mod_fecha_column));
}
if (extra.cop_mod_bruto_column) {
form.append("cop_mod_bruto_column", JSON.stringify(extra.cop_mod_bruto_column));
}
if (extra.cop_mod_neto_column) {
form.append("cop_mod_neto_column", JSON.stringify(extra.cop_mod_neto_column));
}
form.append("cop_mod_config", JSON.stringify(extra.cop_mod_config));
cleaningJob = await client.runCleaningFromCsv(form);
}
@@ -528,7 +589,7 @@ export function CleaningWizard({
{task.title ?? `Extracción ${extractionTasks.length - index}`}
</div>
<div className="cleaning-source-item-meta">
{formatTaskDate(task.created_at)}
{taskTimingLabel(task)}
{index === 0 ? " · más reciente" : ""}
</div>
</div>
@@ -791,6 +852,63 @@ export function CleaningWizard({
</p>
</div>
)}
{selected.includes("cop_mod_bruto_neto") && (
<div className="cleaning-copmod-fecha">
<label className="wiz-field-label" htmlFor={`copmod-bruto-${col.name}`}>
Valor bruto
</label>
<select
id={`copmod-bruto-${col.name}`}
className="form-input"
value={copModBrutoColumn[col.name] ?? col.name}
onChange={(e) =>
setCopModBrutoColumn((prev) => ({
...prev,
[col.name]: e.target.value,
}))
}
>
{analysis.columns.map((c) => (
<option key={c.name} value={c.name}>
{c.name}
</option>
))}
</select>
<label
className="wiz-field-label"
htmlFor={`copmod-neto-${col.name}`}
style={{ marginTop: 10 }}
>
Valor neto
</label>
<select
id={`copmod-neto-${col.name}`}
className="form-input"
value={copModNetoColumn[col.name] ?? ""}
onChange={(e) =>
setCopModNetoColumn((prev) => ({
...prev,
[col.name]: e.target.value,
}))
}
>
<option value="">Selecciona columna neto…</option>
{analysis.columns
.filter(
(c) =>
c.name !== (copModBrutoColumn[col.name] ?? col.name),
)
.map((c) => (
<option key={c.name} value={c.name}>
{c.name}
</option>
))}
</select>
<p className="form-hint">
Se crea la columna <strong>cop/mod</strong> = bruto − neto.
</p>
</div>
)}
</div>
</>
);
@@ -911,6 +1029,7 @@ export function CleaningWizard({
Limpieza completada
{resultJob.log?.length ? ` · ${resultJob.log.length} operaciones` : null}
{resultRows.length > 0 ? ` · ${resultRows.length} filas` : null}
{` · duración ${timingDuration(resultJob)}`}
</span>
</div>