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:
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user