feat: Introduce bot-digitador service for automated data entry in Integración Total. Add CLI and GUI interfaces for CSV processing, implement auto-start functionality on Windows, and enhance README with setup instructions and usage details. Include necessary configurations in docker-compose for service integration.

This commit is contained in:
erik
2026-08-09 21:26:56 -05:00
parent a3b4c7fc47
commit e9b766281a
74 changed files with 11177 additions and 115 deletions
@@ -0,0 +1,99 @@
"""Parseo y partición de Excel/CSV de radicados para descarga distribuida."""
from __future__ import annotations
import csv
import io
import re
from typing import Any
from openpyxl import Workbook, load_workbook
_HEADER_NAMES = ("radicado", "proceso", "numero", "número")
def _clean_nit(valor: Any) -> str:
valor = str(valor).strip()
valor = re.sub(r'[\\/:*?"<>|]', "_", valor)
return valor if valor else "SIN_NIT"
def parse_radicado_rows(data: bytes, filename: str = "input.xlsx") -> list[tuple[str, str]]:
"""Devuelve lista de (radicado, nit)."""
name = (filename or "input.xlsx").lower()
if name.endswith(".csv"):
return _parse_csv(data)
return _parse_xlsx(data)
def _parse_csv(data: bytes) -> list[tuple[str, str]]:
text = None
for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
try:
text = data.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
text = data.decode("utf-8", errors="replace")
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters="|,;\t")
delim = dialect.delimiter
except csv.Error:
delim = "|" if sample.count("|") >= sample.count(",") else ","
reader = csv.reader(io.StringIO(text), delimiter=delim)
out: list[tuple[str, str]] = []
for fila in reader:
if not fila:
continue
radicado = fila[0] if len(fila) > 0 else None
nit = fila[1] if len(fila) > 1 else None
if not radicado or str(radicado).strip() in ("", "None"):
continue
radicado_s = str(radicado).strip()
if radicado_s.lower() in _HEADER_NAMES:
continue
out.append((radicado_s, _clean_nit(nit)))
return out
def _parse_xlsx(data: bytes) -> list[tuple[str, str]]:
wb = load_workbook(io.BytesIO(data), data_only=True)
ws = wb.active
out: list[tuple[str, str]] = []
for fila in ws.iter_rows(values_only=True):
radicado = fila[0] if len(fila) > 0 else None
nit = fila[1] if len(fila) > 1 else None
if not radicado or str(radicado).strip() in ("", "None"):
continue
radicado_s = str(radicado).strip()
if radicado_s.lower() in _HEADER_NAMES:
continue
out.append((radicado_s, _clean_nit(nit)))
return out
def split_rows(rows: list[tuple[str, str]], n: int) -> list[list[tuple[str, str]]]:
"""Parte filas en n trozos lo más equilibrados posible (chunks contiguos)."""
if n <= 1 or not rows:
return [rows]
n = min(n, len(rows))
size, rem = divmod(len(rows), n)
chunks: list[list[tuple[str, str]]] = []
idx = 0
for i in range(n):
take = size + (1 if i < rem else 0)
chunks.append(rows[idx : idx + take])
idx += take
return [c for c in chunks if c]
def rows_to_xlsx_bytes(rows: list[tuple[str, str]]) -> bytes:
wb = Workbook()
ws = wb.active
ws.append(["radicado", "nit"])
for radicado, nit in rows:
ws.append([radicado, nit])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
+16 -1
View File
@@ -40,7 +40,7 @@ class Worker:
@dataclass
class Job:
id: str
worker_id: str
worker_id: Optional[str]
status: str = JobStatus.PENDING.value
title: str = ""
excel_key: str = ""
@@ -54,10 +54,25 @@ class Job:
finished_at: Optional[str] = None
job_id: Optional[str] = None
task_id: Optional[str] = None
parent_download_job_id: Optional[str] = None
shard_index: Optional[int] = None
shard_count: Optional[int] = None
def to_dict(self) -> dict:
return asdict(self)
@property
def is_distributed_parent(self) -> bool:
return (
self.parent_download_job_id is None
and self.shard_count is not None
and int(self.shard_count) > 1
)
@property
def is_shard(self) -> bool:
return bool(self.parent_download_job_id)
@dataclass
class JobEventRecord: