Refactor digitador bot and add runtime monitoring

This commit is contained in:
erik
2026-08-30 14:02:58 -05:00
parent 3b01456875
commit d9e2012e22
46 changed files with 1720 additions and 248 deletions
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -1,7 +1,8 @@
BOT_API_URL=http://localhost:8189/bot-digitador
BOT_API_URL=https://pys1.builker.xyz/bot-digitador
BOT_API_TOKEN=dev-token-change-me
BOT_WORKER_ID=
DIGITADOR_APP_PATH=C:\Users\Pysctas185\Documents\Integracion Total.lnk
# Opcional. Si se omite, usa Documents del usuario que ejecuta el agent.
DIGITADOR_APP_PATH=%USERPROFILE%\Documents\IT SaludTotal.lnk
MINIO_ENDPOINT=https://s3.builker.xyz
MINIO_BUCKET=pys-cuentas-medicas
MINIO_ANONYMOUS=1
+38 -1
View File
@@ -6,6 +6,10 @@ Vive en este monorepo bajo `bots/` (no es un microservicio de `services/`).
- **No descarga PDFs.** Solo digitación UI + reportes CSV/log.
- **No expone puertos.** Solo HTTP saliente al gateway IPSExtractor y PutObject a MinIO.
- La API de control se espera en `…/bot-digitador` (mismo patrón que `bot_descarga`).
- La selección del tipo documental replica `bot_saludtotal_6.py`: abre el combo,
vuelve a `HOME` y usa secuencias de teclado estables en lugar de coordenadas.
- El PC trabajador conserva el visor flotante pequeño del bot original, siempre
visible, con progreso, resultados, radicado, velocidad y duración en vivo.
## Requisitos en la PC
@@ -38,6 +42,9 @@ Configura URL API (ej. `http://localhost:8189/bot-digitador`), Worker ID, token
ruta de Integración Total (`DIGITADOR_APP_PATH`). Pulsa **Conectar agent**.
Queda idle hasta que el desktop asigne un job CSV.
Al aceptar un job se validan primero las nueve plantillas. Si falta alguna, la
corrida termina con un error explícito y no manipula Integración Total.
### Autoarranque en Windows
1. Marca **Iniciar con Windows**.
@@ -73,6 +80,15 @@ PC bot (esta carpeta) ────────┘ HTTP outbound
└─ pywinauto → Integración Total
```
El código está separado por responsabilidad:
- `core/config.py`: secuencias, coordenadas y recursos requeridos.
- `core/csv_rules.py`: CSV, fechas, valores monetarios y tipos documentales.
- `core/application.py`: interacción de teclado/ratón con Windows.
- `core/vision.py`: validación del inventario OpenCV.
- `core/reports.py`: `RESULTADOS`, `OMITIDOS` y log.
- `core/digitador_service.py`: orquestación, cancelación y eventos remotos.
## Variables
Ver [`.env.example`](.env.example):
@@ -81,7 +97,7 @@ Ver [`.env.example`](.env.example):
|----------|-----|
| `BOT_API_URL` | Base del API vía gateway (`…/bot-digitador`) |
| `BOT_API_TOKEN` | Bearer compartido con el servicio |
| `DIGITADOR_APP_PATH` | Acceso directo / ejecutable de Integración Total |
| `DIGITADOR_APP_PATH` | Acceso directo / ejecutable de Integración Total. Por defecto se resuelve como `%USERPROFILE%\Documents\IT SaludTotal.lnk` para cada usuario; puede sobrescribirse por worker. |
| `MINIO_*` | Bucket de reportes; por defecto anónimo |
## CSV de entrada
@@ -90,3 +106,24 @@ Columnas requeridas: `radicado`, `documento`, `ingreso`, `total_cargos`,
`autorizacion`, `tipo_documento`, `tipo_servicio`, `copago`, `cuota_moderadora`.
Acepta delimitador `,` o `|` (auto-detección).
## Entorno visual soportado
La automatización se calibra para el entorno operativo actual: Windows,
resolución 1920×1080, escala de pantalla al 100 % e Integración Total visible y
maximizado. No deben usarse sesiones bloqueadas o minimizadas mientras corre un job.
## Construcción y publicación
Con las nueve plantillas presentes:
```bash
python scripts/package_bot.py
python scripts/publish_bot.py --build-only
python scripts/publish_bot.py
```
El empaquetador excluye `.env`, cachés, datos, temporales y reportes. La
publicación usa `MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` y deja el artefacto en:
`apps-versions/BotDigitadorPYSCuentasMedicas/latest/bot_digitador.zip`
@@ -0,0 +1,56 @@
"""Interacción de bajo nivel con la aplicación Windows Integración Total."""
from __future__ import annotations
import ctypes
import time
from typing import Callable
from pywinauto import mouse
from bot_digitador.core.config import DOC_KEY_SEQUENCES
def click_abs(window, x: int, y: int) -> None:
try:
window.wrapper_object().set_focus()
except Exception:
pass
ctypes.windll.user32.SetCursorPos(x, y)
time.sleep(0.05)
ctypes.windll.user32.SetCursorPos(x + 1, y)
time.sleep(0.05)
ctypes.windll.user32.SetCursorPos(x, y)
time.sleep(0.2)
mouse.click(button="left", coords=(x, y))
def select_document_type(
window,
document_type: str,
*,
log: Callable[[str], None],
sleep: Callable[[float], None] = time.sleep,
click: Callable[[object, int, int], None] = click_abs,
) -> bool:
"""Replica la selección estable de bot_saludtotal_6.py mediante teclado."""
sequence = DOC_KEY_SEQUENCES.get(document_type)
if not sequence:
log(f"Advertencia: No hay secuencia configurada para {document_type}.")
return False
try:
log(f"Seleccionando tipo {document_type} con teclas: {sequence.upper()}")
click(window, 729, 279)
sleep(0.4)
window.wrapper_object().type_keys("{HOME}", set_foreground=True)
sleep(0.2)
for key in sequence:
window.wrapper_object().type_keys(key, set_foreground=True)
sleep(0.15)
sleep(0.3)
window.wrapper_object().type_keys("{ENTER}", set_foreground=True)
sleep(0.4)
return True
except Exception as exc:
log(f"Error seleccionando el tipo {document_type} por teclado: {exc}")
return False
@@ -0,0 +1,38 @@
"""Configuración funcional de Integración Total y recursos visuales requeridos."""
from __future__ import annotations
from bot_digitador.core.coords import * # noqa: F403 - compatibilidad pública
# Secuencias del bot_saludtotal_6.py. Las opciones que empiezan por C se
# recorren pulsando C varias veces después de llevar el combo a HOME.
DOC_KEY_SEQUENCES = {
"CD": "c",
"CE": "cc",
"CC": "ccc",
"CN": "cccc",
"TI": "ti",
"RC": "rc",
"AS": "as",
"ME": "me",
"NI": "ni",
"NU": "nu",
"PS": "ps",
"PE": "pe",
"PT": "pt",
"PJ": "pj",
"SC": "sc",
}
REQUIRED_TEMPLATE_NAMES = (
TEMPLATE_ERROR, # noqa: F405
TEMPLATE_SUCCESS, # noqa: F405
TEMPLATE_BIEN, # noqa: F405
TEMPLATE_DIFERENCIAS, # noqa: F405
TEMPLATE_DIFERENCIAS_CERRADO, # noqa: F405
TEMPLATE_FECHA_VACIA_SLASH, # noqa: F405
TEMPLATE_FECHA_VACIA_BLANCO, # noqa: F405
TEMPLATE_POPUP_NOMBRES, # noqa: F405
TEMPLATE_RADICADO_EXISTENTE, # noqa: F405
)
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
from pathlib import Path
DOC_COORDS = {
"CC": (796, 343),
@@ -39,10 +40,13 @@ SERVICES_COORDS = {
COORD_CLOSE_ERROR = (1223, 704)
DEFAULT_APP_PATH = os.getenv(
"DIGITADOR_APP_PATH",
r"C:\Users\Pysctas185\Documents\Integracion Total.lnk",
)
DEFAULT_APP_PATH = str(Path.home() / "Documents" / "IT SaludTotal.lnk")
def resolve_app_path(value: str | None = None) -> str:
"""Resuelve el acceso directo para el usuario que ejecuta este worker."""
configured = value or os.getenv("DIGITADOR_APP_PATH") or DEFAULT_APP_PATH
return os.path.abspath(os.path.expanduser(os.path.expandvars(configured)))
PROCESS_NAME = "SaludTotal.Health.WinClient.TotalIntegration.exe"
REINICIO_CADA = 6
TIMEOUT_REGISTRO = 120
@@ -0,0 +1,119 @@
"""Lectura de CSV y reglas puras de transformación del Bot Digitador."""
from __future__ import annotations
import csv
from datetime import datetime
from typing import Dict, List, Optional, Tuple
REQUIRED_COLUMNS = {
"radicado",
"documento",
"ingreso",
"total_cargos",
"autorizacion",
"tipo_documento",
"tipo_servicio",
"copago",
"cuota_moderadora",
}
def detect_csv_delimiter(csv_path: str, preferred: Optional[str] = None) -> str:
candidates = [preferred] if preferred in (",", "|") else []
candidates.extend(d for d in (",", "|") if d not in candidates)
for delimiter in candidates:
try:
with open(csv_path, newline="", encoding="utf-8-sig") as source:
headers = [h.strip() for h in (csv.DictReader(source, delimiter=delimiter).fieldnames or [])]
if REQUIRED_COLUMNS.issubset(headers):
return delimiter
except Exception:
continue
return preferred if preferred in (",", "|") else "|"
def load_csv_rows(csv_path: str, delimiter: str) -> List[Dict[str, str]]:
with open(csv_path, newline="", encoding="utf-8-sig") as source:
reader = csv.DictReader(source, delimiter=delimiter)
headers = [h.strip() for h in (reader.fieldnames or [])]
reader.fieldnames = headers
if not REQUIRED_COLUMNS.issubset(headers):
missing = REQUIRED_COLUMNS - set(headers)
raise ValueError(
"El CSV no tiene las columnas requeridas.\n"
f"Faltan: {', '.join(sorted(missing))}\n"
"Verifique si el delimitador es correcto."
)
return [
{k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()}
for row in reader
]
def convert_date(value: object, date_format: str) -> Tuple[str, bool]:
if not value:
return "", True
formats = ["%d/%m/%Y", "%m/%d/%Y", "%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%m-%d-%Y"]
preferred = "%d/%m/%Y" if date_format == "dd/mm/yyyy" else "%m/%d/%Y"
formats.remove(preferred)
formats.insert(0, preferred)
cleaned = str(value).strip()
for candidate in formats:
try:
return datetime.strptime(cleaned, candidate).strftime("%m%d%Y"), True
except ValueError:
continue
return str(value), False
def parse_money(value: object) -> float:
if value is None:
return 0.0
text = str(value).strip().replace("$", "").replace(" ", "")
if not text:
return 0.0
if "." in text and "," not in text:
parts = text.split(".")
if len(parts[-1]) == 3:
text = text.replace(".", "")
try:
return float(text.replace(",", "."))
except (TypeError, ValueError):
return 0.0
def format_money_for_input(value: object) -> str:
number = parse_money(value)
return str(int(number)) if number == int(number) else str(number).replace(".", ",")
def valid_document_types(value: object) -> List[str]:
document = str(value).strip()
if not document:
return []
length = len(document)
numeric = document.isdigit()
result: List[str] = []
rules = (
(length in (10, 12), "AS"),
(3 <= length <= 11, "CD"),
(3 <= length <= 7 and numeric, "CE"),
(3 <= length <= 10 and length != 9 and numeric, "CC"),
(9 <= length <= 14 and numeric, "CN"),
(length in (10, 12), "ME"),
(length == 9 and numeric, "NI"),
(length == 9 and numeric, "NU"),
(3 <= length <= 16, "PS"),
(length == 15 and numeric, "PE"),
(7 <= length <= 8 and numeric, "PT"),
(length == 9 and numeric, "PJ"),
(length == 10 and numeric, "RC"),
(1 <= length <= 8 and numeric, "SC"),
(length == 10 and numeric, "TI"),
)
for matches, name in rules:
if matches:
result.append(name)
return result
@@ -2,7 +2,6 @@
from __future__ import annotations
import csv
import ctypes
import os
import time
@@ -11,13 +10,11 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from pywinauto import mouse
from pywinauto.application import Application
from bot_digitador.core.coords import (
from bot_digitador.core.config import (
COORD_CLOSE_ERROR,
DEFAULT_APP_PATH,
DOC_COORDS,
DOC_KEY_SEQUENCES,
DOC_STRATEGY,
PROCESS_NAME,
REINICIO_CADA,
@@ -32,7 +29,19 @@ from bot_digitador.core.coords import (
TEMPLATE_RADICADO_EXISTENTE,
TEMPLATE_SUCCESS,
TIMEOUT_REGISTRO,
resolve_app_path,
)
from bot_digitador.core.application import click_abs, select_document_type
from bot_digitador.core.csv_rules import (
convert_date as convert_date_value,
detect_csv_delimiter,
format_money_for_input,
load_csv_rows,
parse_money,
valid_document_types,
)
from bot_digitador.core.reports import write_reports
from bot_digitador.core.vision import validate_templates
from bot_digitador.models.events import DigitadorEvent, create_event
try:
@@ -51,18 +60,6 @@ except Exception:
except Exception:
pass
REQUIRED_COLUMNS = {
"radicado",
"documento",
"ingreso",
"total_cargos",
"autorizacion",
"tipo_documento",
"tipo_servicio",
"copago",
"cuota_moderadora",
}
EventCallback = Callable[[DigitadorEvent], None]
CancelCallback = Callable[[], bool]
@@ -78,53 +75,16 @@ class DigitadorRunSummary:
cancelled: bool = False
fatal_error: Optional[str] = None
output_dir: str = ""
reinicios: int = 0
elapsed_seconds: int = 0
speed_per_minute: float = 0.0
resultados: List[Dict[str, Any]] = field(default_factory=list)
def click_abs(window, x: int, y: int) -> None:
try:
window.wrapper_object().set_focus()
except Exception:
pass
ctypes.windll.user32.SetCursorPos(x, y)
time.sleep(0.05)
ctypes.windll.user32.SetCursorPos(x + 1, y)
time.sleep(0.05)
ctypes.windll.user32.SetCursorPos(x, y)
time.sleep(0.2)
mouse.click(button="left", coords=(x, y))
def _default_template_dir() -> str:
return str(Path(__file__).resolve().parents[2] / "template")
def detect_csv_delimiter(csv_path: str, preferred: Optional[str] = None) -> str:
"""Detecta delimitador (, o |) mirando cabeceras requeridas."""
candidates: List[str] = []
if preferred in (",", "|"):
candidates.append(preferred)
for d in (",", "|"):
if d not in candidates:
candidates.append(d)
for delim in candidates:
try:
with open(csv_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=delim)
headers = [h.strip() for h in (reader.fieldnames or [])]
if REQUIRED_COLUMNS.issubset(set(headers)):
return delim
except Exception:
continue
if preferred in (",", "|"):
return preferred
return "|"
class DigitadorService:
"""
Servicio headless de digitación en Integración Total.
@@ -152,11 +112,7 @@ class DigitadorService:
self.date_format = date_format
self.delimiter = delimiter
self.auto_detect_delimiter = auto_detect_delimiter
self.app_path = (
app_path
or os.getenv("DIGITADOR_APP_PATH")
or DEFAULT_APP_PATH
)
self.app_path = resolve_app_path(app_path)
self.reinicio_cada = reinicio_cada
self.timeout_registro = timeout_registro
self.event_callback = event_callback
@@ -176,6 +132,8 @@ class DigitadorService:
self.timeouts = 0
self.reinicios = 0
self.registro_actual = "-"
self.estado_actual = "Listo"
self.ultimo_evento = "-"
self.inicio_proceso: Optional[float] = None
self.inicio_registro: Optional[float] = None
@@ -239,13 +197,29 @@ class DigitadorService:
total: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
elapsed_seconds = int(time.time() - self.inicio_proceso) if self.inicio_proceso else 0
processed = self.procesados_ok + self.errores + self.existentes
minutes = max(elapsed_seconds / 60, 0.01) if elapsed_seconds else 0
overlay = {
"estado": self.estado_actual,
"ultimo": self.ultimo_evento,
"processed": processed,
"exitosos": self.procesados_ok,
"errores": self.errores,
"omitidos": self.omitidos,
"existentes": self.existentes,
"reinicios": self.reinicios,
"radicado_actual": self.registro_actual,
"elapsed_seconds": elapsed_seconds,
"speed_per_minute": round(processed / minutes, 2) if minutes else 0,
}
event = create_event(
event_type,
message,
radicado=radicado,
index=index,
total=total,
metadata=metadata,
metadata={**overlay, **(metadata or {})},
)
if self.event_callback:
try:
@@ -253,6 +227,12 @@ class DigitadorService:
except Exception:
pass
def _set_status(self, status: str, last: str, *, emit: bool = True) -> None:
self.estado_actual = status
self.ultimo_evento = last
if emit:
self._emit("status", last, radicado=str(self.registro_actual or ""))
def log(self, message: str) -> None:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
full_msg = f"[{timestamp}] {message}"
@@ -342,38 +322,13 @@ class DigitadorService:
def save_reports(self) -> None:
try:
os.makedirs(self.output_dir, exist_ok=True)
log_filename = self.ruta_reporte("ejecucion_bot.log")
with open(log_filename, "a", encoding="utf-8") as f:
for entry in self.log_entries:
f.write(entry + "\n")
write_reports(
self.output_dir,
log_entries=self.log_entries,
omitted_records=self.omitted_records,
results=self.resultados,
)
self.log_entries = []
if self.omitted_records:
omitted_filename = self.ruta_reporte("OMITIDOS.csv")
fieldnames: List[str] = []
for registro in self.omitted_records:
for key in registro.keys():
if key not in fieldnames:
fieldnames.append(key)
with open(omitted_filename, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(self.omitted_records)
if self.resultados:
resultados_filename = self.ruta_reporte("RESULTADOS.csv")
with open(resultados_filename, "w", newline="", encoding="utf-8-sig") as f:
fieldnames = [
"radicado", "estado", "tipo_error", "motivo", "detalle",
"fecha_hora", "duracion_segundos", "accion_bot",
]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(self.resultados)
except Exception as e:
print(f"Error al guardar reportes: {e}")
@@ -381,108 +336,25 @@ class DigitadorService:
# Fechas / valores / docs
# ------------------------------------------------------------------
def convert_date(self, date_str) -> str:
if not date_str:
return ""
input_formats = [
"%d/%m/%Y",
"%m/%d/%Y",
"%Y-%m-%d",
"%Y/%m/%d",
"%d-%m-%Y",
"%m-%d-%Y",
]
user_fmt = "%d/%m/%Y" if self.date_format == "dd/mm/yyyy" else "%m/%d/%Y"
if user_fmt in input_formats:
input_formats.remove(user_fmt)
input_formats.insert(0, user_fmt)
date_clean = str(date_str).strip()
for fmt in input_formats:
try:
dt_obj = datetime.strptime(date_clean, fmt)
return dt_obj.strftime("%m%d%Y")
except ValueError:
continue
self.log(f"ADVERTENCIA: No se pudo convertir la fecha '{date_str}'. Se usará tal cual.")
return date_str
converted, valid = convert_date_value(date_str, self.date_format)
if not valid:
self.log(f"ADVERTENCIA: No se pudo convertir la fecha '{date_str}'. Se usará tal cual.")
return converted
def limpiar_valor_monetario(self, valor) -> float:
if valor is None:
return 0.0
texto = str(valor).strip()
if texto == "":
return 0.0
texto = texto.replace("$", "").replace(" ", "")
if "." in texto and "," not in texto:
partes = texto.split(".")
if len(partes[-1]) == 3:
texto = texto.replace(".", "")
texto = texto.replace(",", ".")
try:
return float(texto)
except Exception:
return 0.0
return parse_money(valor)
def formatear_valor_para_digitar(self, valor) -> str:
numero = self.limpiar_valor_monetario(valor)
if numero == int(numero):
return str(int(numero))
return str(numero).replace(".", ",")
return format_money_for_input(valor)
def valor_mayor_a_cero(self, valor) -> bool:
return self.limpiar_valor_monetario(valor) > 0
def get_valid_doc_types(self, doc_value) -> List[str]:
doc_value = str(doc_value).strip()
if not doc_value:
return []
return valid_document_types(doc_value)
length = len(doc_value)
is_numeric = doc_value.isdigit()
valid_types: List[str] = []
if length in [10, 12]:
valid_types.append("AS")
if 3 <= length <= 11:
valid_types.append("CD")
if 3 <= length <= 7 and is_numeric:
valid_types.append("CE")
if 3 <= length <= 10 and length != 9 and is_numeric:
valid_types.append("CC")
if 9 <= length <= 14 and is_numeric:
valid_types.append("CN")
if length in [10, 12]:
valid_types.append("ME")
if length == 9 and is_numeric:
valid_types.append("NI")
if length == 9 and is_numeric:
valid_types.append("NU")
if 3 <= length <= 16:
valid_types.append("PS")
if length == 15 and is_numeric:
valid_types.append("PE")
if 7 <= length <= 8 and is_numeric:
valid_types.append("PT")
if length == 9 and is_numeric:
valid_types.append("PJ")
if length == 10 and is_numeric:
valid_types.append("RC")
if 1 <= length <= 8 and is_numeric:
valid_types.append("SC")
if length == 10 and is_numeric:
valid_types.append("TI")
return valid_types
def seleccionar_tipo_documento(self, window, tipo_doc: str) -> bool:
return select_document_type(window, tipo_doc, log=self.log)
# ------------------------------------------------------------------
# OpenCV
@@ -731,6 +603,7 @@ class DigitadorService:
)
self.existentes += 1
self._set_status("Existente", "Radicado existente", emit=False)
self.log(f"RADICADO EXISTENTE: {val_radicado}. Se registra y vuelve al menú principal.")
self._emit("radicado_skipped", f"Radicado existente: {val_radicado}", radicado=val_radicado)
self.save_reports()
@@ -743,6 +616,7 @@ class DigitadorService:
self.first_time = True
self.reinicios += 1
self._set_status("Reinicio", "Menú principal", emit=False)
def restart_bot(
self,
@@ -773,6 +647,7 @@ class DigitadorService:
self.first_time = True
self.reinicios += 1
self._set_status("Reinicio", "Flujo restablecido", emit=False)
self.log("Bot reiniciado exitosamente.")
def navegar_menu_inicial(self, window) -> None:
@@ -799,9 +674,35 @@ class DigitadorService:
self.first_time = True
self.log("Menú listo para radicar.")
def inicio_completo_app(self) -> Tuple[Any, Any]:
"""Replica el inicio limpio del script: cerrar, abrir, conectar y navegar."""
try:
self._set_status("Conectando", "Inicio limpio de Integración Total", emit=False)
self.log("Cerrando instancias previas de Salud Total...")
os.system(f'taskkill /f /im "{PROCESS_NAME}"')
time.sleep(3)
if not os.path.exists(self.app_path):
raise FileNotFoundError(f"No existe DIGITADOR_APP_PATH: {self.app_path}")
self.log("Abriendo aplicación Salud Total...")
os.startfile(self.app_path)
self.log("Esperando carga inicial de la aplicación...")
time.sleep(12)
app = Application(backend="uia").connect(title_re=".*Integracion Total Fase 2.*")
window = app.window(title_re=".*Integracion Total Fase 2.*")
window.wait("visible enabled ready", timeout=30)
window.wrapper_object().set_focus()
self.navegar_menu_inicial(window)
self.first_time = True
self.log("Aplicación Salud Total lista para procesar.")
return app, window
except Exception as exc:
self.log(f"ERROR iniciando aplicación Salud Total: {exc}")
return None, None
def reinicio_completo_app(self) -> Tuple[Any, Any]:
try:
self.reinicios += 1
self._set_status("Reinicio app", "Reinicio completo", emit=False)
self.log("Reinicio completo preventivo de la aplicación...")
self._emit("browser_reconnected", "Reinicio completo de Integración Total")
@@ -839,25 +740,7 @@ class DigitadorService:
self.delimiter = delim
self.log(f"Delimitador CSV detectado/usado: '{delim}'")
data: List[Dict[str, str]] = []
with open(self.csv_path, newline="", encoding="utf-8-sig") as csvfile:
reader = csv.DictReader(csvfile, delimiter=delim)
headers = [h.strip() for h in reader.fieldnames] if reader.fieldnames else []
reader.fieldnames = headers
if not REQUIRED_COLUMNS.issubset(headers):
missing = REQUIRED_COLUMNS - set(headers)
raise ValueError(
"El CSV no tiene las columnas requeridas.\n"
f"Faltan: {', '.join(sorted(missing))}\n"
"Verifique si el delimitador es correcto."
)
for row in reader:
cleaned = {k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()}
data.append(cleaned)
return data
return load_csv_rows(self.csv_path, delim)
# ------------------------------------------------------------------
# Loop principal
@@ -868,11 +751,14 @@ class DigitadorService:
window = None
try:
validate_templates(self.template_dir)
self.log("Conectando a la aplicación...")
self._set_status("Conectando", "Validando Integración Total")
self._emit("run_started", f"Iniciando digitación. CSV: {self.csv_path}")
app = Application(backend="uia").connect(title_re=".*Integracion Total Fase 2.*")
window = app.window(title_re=".*Integracion Total Fase 2.*")
app, window = self.inicio_completo_app()
if window is None:
raise RuntimeError("No fue posible iniciar o conectar con Salud Total")
self.omitted_records = []
self.log_entries = []
@@ -888,6 +774,8 @@ class DigitadorService:
self.first_time = True
self._cancelled = False
self._fatal_error = None
self.estado_actual = "Procesando"
self.ultimo_evento = "Ejecución iniciada"
self.log(f"Iniciando nuevo proceso. Reportes en: {self.output_dir}")
self.log(f"APP_PATH={self.app_path}")
@@ -912,6 +800,7 @@ class DigitadorService:
self.inicio_registro = time.time()
self.registro_actual = row.get("radicado", "N/A")
self._set_status("Procesando", f"Registro {i + 1}/{len(data)}", emit=False)
self._emit(
"radicado_processing",
f"Procesando registro {i + 1}/{len(data)}: {self.registro_actual}",
@@ -1018,7 +907,7 @@ class DigitadorService:
tipos_a_probar: List[str] = []
if val_tipo_doc and val_tipo_doc in DOC_COORDS:
if val_tipo_doc and val_tipo_doc in DOC_KEY_SEQUENCES:
tipos_a_probar.append(val_tipo_doc)
for t in candidatos_ordenados:
if t != val_tipo_doc:
@@ -1045,16 +934,9 @@ class DigitadorService:
self.log(f"Probando tipo de documento: {tipo_doc}")
time.sleep(1)
click_abs(window, 729, 279)
coords_doc = DOC_COORDS.get(tipo_doc, (0, 0))
if coords_doc == (0, 0):
self.log(f"Advertencia: Coordenadas para {tipo_doc} no configuradas. Saltando.")
if not self.seleccionar_tipo_documento(window, tipo_doc):
continue
time.sleep(0.5)
click_abs(window, coords_doc[0], coords_doc[1])
time.sleep(0.2)
click_abs(window, 1015, 280)
@@ -1244,6 +1126,7 @@ class DigitadorService:
click_abs(window, 941, 305)
self.procesados_ok += 1
self._set_status("OK", "Registro OK", emit=False)
self._emit(
"radicado_saved",
f"Registro OK: {val_radicado}",
@@ -1270,6 +1153,7 @@ class DigitadorService:
self.log("TIMEOUT: No apareció éxito ni error después de registrar.")
self.errores += 1
self.timeouts += 1
self._set_status("Timeout", "Sin respuesta", emit=False)
self.guardar_resultado(
val_radicado,
"TIMEOUT",
@@ -1300,6 +1184,7 @@ class DigitadorService:
break
if not summary.cancelled:
self._set_status("Finalizado", "Proceso finalizado", emit=False)
self.log("--- PROCESO FINALIZADO ---")
self._emit("run_finished", "Proceso finalizado")
@@ -1307,6 +1192,7 @@ class DigitadorService:
except Exception as e:
self._fatal_error = str(e)
self._set_status("Error", str(e), emit=False)
self.log(f"ERROR: {str(e)}")
self._emit("warning", f"Error fatal: {e}", metadata={"fatal": True})
self.save_reports()
@@ -1319,6 +1205,11 @@ class DigitadorService:
summary.fallidos = self.omitidos + self.timeouts
summary.cancelled = summary.cancelled or self._cancelled
summary.fatal_error = self._fatal_error
summary.reinicios = self.reinicios
summary.elapsed_seconds = int(time.time() - self.inicio_proceso) if self.inicio_proceso else 0
elapsed_minutes = max(summary.elapsed_seconds / 60, 0.01) if summary.elapsed_seconds else 0
processed_total = summary.exitosos + summary.fallidos + summary.existentes
summary.speed_per_minute = round(processed_total / elapsed_minutes, 2) if elapsed_minutes else 0.0
summary.resultados = list(self.resultados)
summary.output_dir = self.output_dir
return summary
@@ -0,0 +1,41 @@
"""Escritura determinista de los reportes producidos por una ejecución."""
from __future__ import annotations
import csv
from pathlib import Path
from typing import Any, Dict, Iterable, List
REPORT_FILES = ("RESULTADOS.csv", "OMITIDOS.csv", "ejecucion_bot.log")
RESULT_FIELDS = (
"radicado", "estado", "tipo_error", "motivo", "detalle",
"fecha_hora", "duracion_segundos", "accion_bot",
)
def write_reports(
output_dir: str,
*,
log_entries: Iterable[str],
omitted_records: List[Dict[str, Any]],
results: List[Dict[str, Any]],
) -> None:
root = Path(output_dir)
root.mkdir(parents=True, exist_ok=True)
entries = list(log_entries)
if entries:
with (root / "ejecucion_bot.log").open("a", encoding="utf-8") as target:
target.writelines(f"{entry}\n" for entry in entries)
if omitted_records:
fields: List[str] = []
for record in omitted_records:
fields.extend(key for key in record if key not in fields)
with (root / "OMITIDOS.csv").open("w", newline="", encoding="utf-8-sig") as target:
writer = csv.DictWriter(target, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
writer.writerows(omitted_records)
if results:
with (root / "RESULTADOS.csv").open("w", newline="", encoding="utf-8-sig") as target:
writer = csv.DictWriter(target, fieldnames=RESULT_FIELDS)
writer.writeheader()
writer.writerows(results)
@@ -0,0 +1,23 @@
"""Inventario y validación de plantillas OpenCV del Bot Digitador."""
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES
def missing_templates(template_dir: str, required: Iterable[str] = REQUIRED_TEMPLATE_NAMES) -> List[str]:
root = Path(template_dir)
return [name for name in required if not (root / name).is_file()]
def validate_templates(template_dir: str) -> None:
missing = missing_templates(template_dir)
if missing:
formatted = ", ".join(missing)
raise RuntimeError(
f"Faltan {len(missing)} plantillas OpenCV en {template_dir}: {formatted}. "
"Copie la carpeta template completa antes de conectar el agent."
)
@@ -7,6 +7,8 @@ import threading
import customtkinter as ctk
from bot_digitador.core.coords import DEFAULT_APP_PATH
from bot_digitador.models.events import DigitadorEvent
from bot_digitador.ui.progress_overlay import DigitadorProgressOverlay
from bot_digitador.ui.settings_store import (
DEFAULT_API_URL,
load_gui_settings,
@@ -40,6 +42,7 @@ class BotDashboard(ctk.CTk):
self._construir_ui()
self._cargar_settings()
self._overlay = DigitadorProgressOverlay(self)
self.protocol("WM_DELETE_WINDOW", self._on_close)
@@ -299,6 +302,7 @@ class BotDashboard(ctk.CTk):
token=token,
app_path=app_path,
log_func=self._log,
event_func=self._on_agent_event,
)
self._connected = True
self.btn_connect.configure(text="⏹ Desconectar", fg_color="#c0392b", hover_color="#a93226")
@@ -363,6 +367,9 @@ class BotDashboard(ctk.CTk):
self.after(0, _escribir)
def _on_agent_event(self, event: DigitadorEvent) -> None:
self.after(0, lambda current=event: self._overlay.apply_event(current))
def _on_close(self):
if self._agent:
self._agent.stop()
@@ -0,0 +1,120 @@
"""Visor local compacto del progreso del digitador."""
from __future__ import annotations
import time
import tkinter as tk
from typing import Any
from bot_digitador.models.events import DigitadorEvent
def format_duration(seconds: int | float) -> str:
value = max(0, int(seconds))
hours, remainder = divmod(value, 3600)
minutes, secs = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
class DigitadorProgressOverlay:
"""Panel flotante 1:1 con el visor del bot monolítico."""
def __init__(self, parent: tk.Misc):
self.window = tk.Toplevel(parent)
self.window.title("BOT ST")
self.window.geometry("210x235+15+555")
self.window.overrideredirect(True)
self.window.attributes("-topmost", True)
self.window.attributes("-alpha", 0.88)
self.window.configure(bg="#101010")
self._vars: dict[str, tk.StringVar] = {}
self._metadata: dict[str, Any] = {}
self._started_at: float | None = None
self._running = False
self._add_label("titulo", "BOT SALUD TOTAL", ("Consolas", 9, "bold"), "#00ff88")
self._add_label("estado", "ESTADO: Listo", ("Consolas", 8, "bold"), "#ffffff")
self._add_label("progreso", "0/0 0%", ("Consolas", 8), "#ffffff")
self._add_label("ok", "OK: 0", ("Consolas", 8), "#00ff88")
self._add_label("err", "ERR: 0 | EX: 0", ("Consolas", 8), "#ff6666")
self._add_label("rei", "REI: 0", ("Consolas", 8), "#66ccff")
self._add_label("rad", "RAD: -", ("Consolas", 8), "#ffffff")
self._add_label("vel", "VEL: 0 rad/min", ("Consolas", 8), "#ffff99")
self._add_label("tiempo", "TIME: 00:00:00", ("Consolas", 8), "#ffffff")
self._add_label("ultimo", "ULT: -", ("Consolas", 8), "#cccccc")
self.window.after(1000, self._tick)
def _add_label(self, key: str, text: str, font: tuple, color: str) -> None:
variable = tk.StringVar(master=self.window, value=text)
tk.Label(
self.window,
textvariable=variable,
bg="#101010",
fg=color,
font=font,
anchor="w",
justify="left",
).pack(fill="x", padx=8, pady=1)
self._vars[key] = variable
def apply_event(self, event: DigitadorEvent) -> None:
metadata = dict(event.metadata or {})
if event.event_type == "run_started":
self._metadata = {}
self._started_at = time.monotonic()
self._running = True
self._metadata.update(metadata)
if event.total is not None:
self._metadata["total"] = event.total
if event.radicado:
self._metadata["radicado_actual"] = event.radicado
self._metadata["ultimo"] = metadata.get("ultimo") or event.message
elapsed = float(metadata.get("elapsed_seconds") or 0)
if self._running and elapsed and self._started_at is not None:
self._started_at = time.monotonic() - elapsed
if event.event_type == "run_finished":
self._running = False
self._refresh()
def set_connection_state(self, state: str, detail: str = "-") -> None:
self._metadata.update({"estado": state, "ultimo": detail})
self._refresh()
def _elapsed(self) -> int:
if self._running and self._started_at is not None:
return int(time.monotonic() - self._started_at)
return int(self._metadata.get("elapsed_seconds") or 0)
def _refresh(self) -> None:
metadata = self._metadata
processed = int(metadata.get("processed") or 0)
total = int(metadata.get("total") or 0)
percentage = int(processed / total * 100) if total else 0
elapsed = self._elapsed()
speed = float(metadata.get("speed_per_minute") or 0)
if self._running and elapsed:
speed = processed / max(elapsed / 60, 0.01)
self._vars["estado"].set(f"ESTADO: {str(metadata.get('estado') or 'Listo')[:18]}")
self._vars["progreso"].set(f"{processed}/{total} {percentage}%")
self._vars["ok"].set(f"OK: {int(metadata.get('exitosos') or 0)}")
self._vars["err"].set(
f"ERR: {int(metadata.get('errores') or 0)} | EX: {int(metadata.get('existentes') or 0)}"
)
self._vars["rei"].set(f"REI: {int(metadata.get('reinicios') or 0)}")
self._vars["rad"].set(f"RAD: {str(metadata.get('radicado_actual') or '-')[:18]}")
self._vars["vel"].set(f"VEL: {speed:.1f} rad/min")
self._vars["tiempo"].set(f"TIME: {format_duration(elapsed)}")
self._vars["ultimo"].set(f"ULT: {str(metadata.get('ultimo') or '-')[:18]}")
def _tick(self) -> None:
try:
if self.window.winfo_exists():
self._refresh()
self.window.after(1000, self._tick)
except tk.TclError:
return
@@ -11,15 +11,14 @@ import httpx
from bot_digitador.core.coords import DEFAULT_APP_PATH
from bot_digitador.core.digitador_service import DigitadorService
from bot_digitador.core.reports import REPORT_FILES
from bot_digitador.core.vision import validate_templates
from bot_digitador.worker.minio_upload import MinioSettings, WorkerMinioUploader
JOB_COMPLETED = "completed"
JOB_FAILED = "failed"
JOB_CANCELLED = "cancelled"
REPORT_FILES = ("RESULTADOS.csv", "OMITIDOS.csv", "ejecucion_bot.log")
class RemoteApiClient:
"""Cliente HTTP hacia la API bot-digitador (vía gateway). Sin puertos locales."""
@@ -102,6 +101,8 @@ class WorkerAgent:
date_format: str = "dd/mm/yyyy",
delimiter: str = "|",
log_func=None,
template_dir: Optional[Path] = None,
event_func=None,
):
self.api = RemoteApiClient(api_url, token)
self.worker_id = worker_id
@@ -111,14 +112,17 @@ class WorkerAgent:
self.date_format = date_format
self.delimiter = delimiter
self.minio = WorkerMinioUploader(MinioSettings.from_env())
self.template_dir = template_dir or Path(__file__).resolve().parents[2] / "template"
self._running = True
self._uploaded_keys: List[str] = []
self._log = log_func or print
self._event_func = event_func
def stop(self) -> None:
self._running = False
def run_forever(self) -> None:
validate_templates(str(self.template_dir))
self.api.register(self.worker_id)
self._log(f"[worker {self.worker_id}] registrado — idle (esperando jobs de digitación)")
try:
@@ -165,6 +169,11 @@ class WorkerAgent:
def on_event(event) -> None:
self._log(event.display_message())
if self._event_func:
try:
self._event_func(event)
except Exception as e:
self._log(f"[worker] no se pudo actualizar el visor local: {e}")
payload = {
"event_type": event.event_type,
"message": event.message,
@@ -190,6 +199,7 @@ class WorkerAgent:
app_path=self.app_path,
event_callback=on_event,
should_cancel=should_cancel,
template_dir=str(self.template_dir),
)
summary = service.run()
@@ -248,6 +258,7 @@ class WorkerAgent:
result = {
"total": summary.total,
"processed": summary.exitosos + summary.fallidos + summary.existentes,
"exitosos": summary.exitosos,
"fallidos": summary.fallidos,
"existentes": summary.existentes,
@@ -256,6 +267,9 @@ class WorkerAgent:
"cancelled": summary.cancelled,
"reports_prefix": reports_prefix,
"uploaded_count": len(self._uploaded_keys),
"reinicios": summary.reinicios,
"elapsed_seconds": summary.elapsed_seconds,
"speed_per_minute": summary.speed_per_minute,
}
if summary.cancelled:
+83
View File
@@ -0,0 +1,83 @@
"""Construye un ZIP reproducible y limpio del Bot Digitador."""
from __future__ import annotations
import argparse
import hashlib
import sys
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES # noqa: E402
DEFAULT_OUTPUT = ROOT.parent / "bot_digitador.zip"
TOP_LEVEL_FILES = (".env.example", "README.md", "requirements.txt", "run_gui.py", "main.py")
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache", ".venv", "data", "reportes_bot", "out"}
EXCLUDED_SUFFIXES = {".pyc", ".pyo", ".log"}
def package_files(root: Path) -> list[Path]:
files: list[Path] = []
for name in TOP_LEVEL_FILES:
path = root / name
if path.is_file():
files.append(path)
for folder in (root / "bot_digitador", root / "template"):
if not folder.exists():
continue
for path in folder.rglob("*"):
relative = path.relative_to(root)
if not path.is_file() or any(part in EXCLUDED_PARTS for part in relative.parts):
continue
if path.suffix.lower() in EXCLUDED_SUFFIXES or path.name == ".env":
continue
files.append(path)
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
def validate_assets(root: Path) -> None:
missing = [name for name in REQUIRED_TEMPLATE_NAMES if not (root / "template" / name).is_file()]
if missing:
raise RuntimeError(
"No se puede crear un paquete ejecutable. Faltan plantillas: " + ", ".join(missing)
)
def build_package(root: Path = ROOT, output: Path = DEFAULT_OUTPUT, *, require_templates: bool = True) -> Path:
if require_templates:
validate_assets(root)
output.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(output, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive:
for source in package_files(root):
relative = source.relative_to(root).as_posix()
info = ZipInfo(f"bot_digitador/{relative}", date_time=(2020, 1, 1, 0, 0, 0))
info.compress_type = ZIP_DEFLATED
info.external_attr = 0o644 << 16
archive.writestr(info, source.read_bytes())
return output
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--allow-missing-templates", action="store_true")
args = parser.parse_args()
target = build_package(output=args.output.resolve(), require_templates=not args.allow_missing_templates)
print(f"Paquete: {target}")
print(f"SHA256: {sha256(target)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+57
View File
@@ -0,0 +1,57 @@
"""Construye y publica el Bot Digitador en el prefijo latest de MinIO."""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
import boto3
from botocore.config import Config
from package_bot import DEFAULT_OUTPUT, build_package, sha256
DEFAULT_ENDPOINT = "https://s3.builker.xyz"
DEFAULT_BUCKET = "apps-versions"
DEFAULT_KEY = "BotDigitadorPYSCuentasMedicas/latest/bot_digitador.zip"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--build-only", action="store_true")
args = parser.parse_args()
artifact = build_package(output=args.output.resolve(), require_templates=True)
print(f"Paquete validado: {artifact} ({sha256(artifact)})")
if args.build_only:
return 0
access = os.getenv("MINIO_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
secret = os.getenv("MINIO_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
if not access or not secret:
print("Faltan MINIO_ACCESS_KEY/MINIO_SECRET_KEY para publicar.", file=sys.stderr)
return 2
endpoint = os.getenv("MINIO_ENDPOINT", DEFAULT_ENDPOINT).rstrip("/")
bucket = os.getenv("MINIO_APPS_BUCKET", DEFAULT_BUCKET)
key = os.getenv("DIGITADOR_PACKAGE_KEY", DEFAULT_KEY).strip("/")
client = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access,
aws_secret_access_key=secret,
region_name=os.getenv("MINIO_REGION", "us-east-1"),
config=Config(s3={"addressing_style": "path"}),
)
client.upload_file(
str(artifact),
bucket,
key,
ExtraArgs={"ContentType": "application/zip"},
)
print(f"Publicado: {endpoint}/{bucket}/{key}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -14,3 +14,4 @@ Copia aquí los PNG necesarios desde la carpeta `template/` del script original
- `radicado_existente.png`
Sin estos archivos el bot no puede validar pantallas de Integración Total.
El agent y el empaquetador validan el inventario completo antes de ejecutar o publicar.
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@@ -0,0 +1,37 @@
from __future__ import annotations
from types import SimpleNamespace
from bot_digitador.core.application import select_document_type
class FakeWrapper:
def __init__(self):
self.keys: list[str] = []
def type_keys(self, value: str, **_kwargs) -> None:
self.keys.append(value)
def test_select_document_type_uses_home_and_exact_c_sequence():
wrapper = FakeWrapper()
window = SimpleNamespace(wrapper_object=lambda: wrapper)
clicks: list[tuple[int, int]] = []
logs: list[str] = []
ok = select_document_type(
window,
"CC",
log=logs.append,
sleep=lambda _seconds: None,
click=lambda _window, x, y: clicks.append((x, y)),
)
assert ok is True
assert clicks == [(729, 279)]
assert wrapper.keys == ["{HOME}", "c", "c", "c", "{ENTER}"]
def test_select_document_type_rejects_unknown_type():
assert select_document_type(SimpleNamespace(), "XX", log=lambda _message: None) is False
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from pathlib import Path
from bot_digitador.core.coords import DEFAULT_APP_PATH, resolve_app_path
def test_default_app_path_uses_current_user_home():
assert Path(DEFAULT_APP_PATH) == Path.home() / "Documents" / "IT SaludTotal.lnk"
def test_app_path_expands_worker_user_profile(monkeypatch):
monkeypatch.setenv("USERPROFILE", r"C:\Users\worker-02")
resolved = resolve_app_path(r"%USERPROFILE%\Documents\IT SaludTotal.lnk")
assert resolved == r"C:\Users\worker-02\Documents\IT SaludTotal.lnk"
@@ -0,0 +1,33 @@
from __future__ import annotations
from bot_digitador.core.csv_rules import (
convert_date,
detect_csv_delimiter,
format_money_for_input,
parse_money,
valid_document_types,
)
HEADERS = "radicado|documento|ingreso|total_cargos|autorizacion|tipo_documento|tipo_servicio|copago|cuota_moderadora\n"
def test_detects_pipe_csv(tmp_path):
source = tmp_path / "input.csv"
source.write_text(HEADERS + "R1|123|2026-08-30|100|A|CC|ambulatorio|0|0\n", encoding="utf-8")
assert detect_csv_delimiter(str(source), preferred=",") == "|"
def test_dates_and_money_match_original_rules():
assert convert_date("30/08/2026", "dd/mm/yyyy") == ("08302026", True)
assert convert_date("2026-08-30", "dd/mm/yyyy") == ("08302026", True)
assert parse_money("$ 12.500") == 12500.0
assert parse_money("12,50") == 12.5
assert format_money_for_input("12,50") == "12,5"
def test_document_candidates_preserve_strategy_rules():
candidates = valid_document_types("1234567890")
assert {"CC", "TI", "RC", "CN", "AS", "ME", "PS"}.issubset(candidates)
assert valid_document_types("") == []
@@ -0,0 +1,35 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from zipfile import ZipFile
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "package_bot.py"
SPEC = importlib.util.spec_from_file_location("digitador_package_bot", SCRIPT)
assert SPEC and SPEC.loader
package_bot = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(package_bot)
def test_package_is_clean_and_contains_required_templates(tmp_path):
root = tmp_path / "source"
(root / "bot_digitador" / "core").mkdir(parents=True)
(root / "template").mkdir()
for name in ("README.md", "requirements.txt", "run_gui.py", "main.py", ".env.example"):
(root / name).write_text(name, encoding="utf-8")
(root / "bot_digitador" / "core" / "service.py").write_text("pass", encoding="utf-8")
(root / "bot_digitador" / "core" / "cached.pyc").write_bytes(b"cache")
(root / "data").mkdir()
(root / "data" / "gui_settings.json").write_text("secret", encoding="utf-8")
for name in REQUIRED_TEMPLATE_NAMES:
(root / "template" / name).write_bytes(b"png")
target = package_bot.build_package(root=root, output=tmp_path / "bot.zip")
with ZipFile(target) as archive:
names = archive.namelist()
assert "bot_digitador/template/radicado_existente.png" in names
assert not any("__pycache__" in name or name.endswith(".pyc") or "/data/" in name for name in names)
@@ -0,0 +1,7 @@
from bot_digitador.ui.progress_overlay import format_duration
def test_format_duration_for_local_viewer():
assert format_duration(0) == "00:00:00"
assert format_duration(65) == "00:01:05"
assert format_duration(3661) == "01:01:01"
@@ -0,0 +1,33 @@
from __future__ import annotations
import csv
import pytest
from bot_digitador.core.config import REQUIRED_TEMPLATE_NAMES
from bot_digitador.core.reports import write_reports
from bot_digitador.core.vision import missing_templates, validate_templates
def test_template_inventory_reports_every_missing_asset(tmp_path):
assert missing_templates(str(tmp_path)) == list(REQUIRED_TEMPLATE_NAMES)
with pytest.raises(RuntimeError, match="Faltan 9 plantillas"):
validate_templates(str(tmp_path))
for name in REQUIRED_TEMPLATE_NAMES:
(tmp_path / name).write_bytes(b"png")
validate_templates(str(tmp_path))
def test_report_writer_creates_all_outputs(tmp_path):
omitted = [{"radicado": "R2", "estado": "OMITIDO", "tipo_error": "ERROR"}]
results = [{
"radicado": "R1", "estado": "OK", "tipo_error": "", "motivo": "Cuenta registrada",
"detalle": "BIEN", "fecha_hora": "2026-08-30 12:00:00", "duracion_segundos": 10,
"accion_bot": "Continuó",
}]
write_reports(str(tmp_path), log_entries=["inicio"], omitted_records=omitted, results=results)
assert (tmp_path / "ejecucion_bot.log").read_text(encoding="utf-8") == "inicio\n"
with (tmp_path / "RESULTADOS.csv").open(encoding="utf-8-sig") as source:
assert list(csv.DictReader(source))[0]["radicado"] == "R1"
assert (tmp_path / "OMITIDOS.csv").is_file()
@@ -0,0 +1,29 @@
from __future__ import annotations
from bot_digitador.core.digitador_service import DigitadorService
def test_events_include_remote_overlay_metrics(tmp_path):
events = []
service = DigitadorService(
csv_path=str(tmp_path / "input.csv"),
output_dir=str(tmp_path / "out"),
event_callback=events.append,
)
service.inicio_proceso = None
service.total_registros = 10
service.procesados_ok = 2
service.errores = 1
service.existentes = 1
service.reinicios = 3
service.registro_actual = "RAD-4"
service.estado_actual = "Procesando"
service.ultimo_evento = "Registro 4/10"
service._emit("radicado_processing", "Procesando RAD-4")
metadata = events[0].metadata
assert metadata["estado"] == "Procesando"
assert metadata["processed"] == 4
assert metadata["reinicios"] == 3
assert metadata["radicado_actual"] == "RAD-4"