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:
@@ -37,6 +37,15 @@ python run_gui.py
|
||||
|
||||
Configura URL API (ej. `https://pys1.builker.xyz/bot-descarga` o `http://127.0.0.1:8189/bot-descarga`), Worker ID y token. Pulsa **Conectar agent**. Queda idle hasta que el desktop asigne un job.
|
||||
|
||||
### Autoarranque en Windows
|
||||
|
||||
En la misma GUI:
|
||||
|
||||
1. Marca **Iniciar con Windows** (crea un `.bat` en la carpeta Startup del usuario).
|
||||
2. Deja **Conectar al abrir** activo para que tras el login el agent quede online solo.
|
||||
|
||||
Tras reiniciar el PC e iniciar sesión, se abre la GUI y se registra el worker automáticamente.
|
||||
|
||||
## Arranque solo agent (CLI)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import customtkinter as ctk
|
||||
@@ -11,6 +12,11 @@ from bot_descarga.ui.settings_store import (
|
||||
normalize_api_url,
|
||||
save_gui_settings,
|
||||
)
|
||||
from bot_descarga.ui.windows_autostart import (
|
||||
disable_autostart,
|
||||
enable_autostart,
|
||||
is_autostart_enabled,
|
||||
)
|
||||
from bot_descarga.worker.agent import WorkerAgent
|
||||
|
||||
ctk.set_appearance_mode("dark")
|
||||
@@ -24,7 +30,7 @@ class BotDashboard(ctk.CTk):
|
||||
super().__init__()
|
||||
|
||||
self.title("Bot de Descargas PDF — Agent")
|
||||
self.geometry("920x680")
|
||||
self.geometry("920x720")
|
||||
self.resizable(False, False)
|
||||
|
||||
self._agent: WorkerAgent | None = None
|
||||
@@ -36,6 +42,10 @@ class BotDashboard(ctk.CTk):
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
# Tras login de Windows: si está marcado, conectar solo
|
||||
if bool(load_gui_settings().get("auto_connect")):
|
||||
self.after(800, self._maybe_auto_connect)
|
||||
|
||||
def _construir_ui(self):
|
||||
root = ctk.CTkFrame(self, corner_radius=18)
|
||||
root.pack(fill="both", expand=True, padx=18, pady=18)
|
||||
@@ -81,7 +91,7 @@ class BotDashboard(ctk.CTk):
|
||||
self.entry_worker.pack(side="left", fill="x", expand=True)
|
||||
|
||||
row_token = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_token.pack(fill="x", padx=18, pady=(0, 14))
|
||||
row_token.pack(fill="x", padx=18, pady=(0, 8))
|
||||
ctk.CTkLabel(row_token, text="Token", width=90, anchor="w").pack(side="left")
|
||||
self.entry_token = ctk.CTkEntry(
|
||||
row_token,
|
||||
@@ -97,6 +107,23 @@ class BotDashboard(ctk.CTk):
|
||||
command=self._toggle_token,
|
||||
).pack(side="left")
|
||||
|
||||
row_auto = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_auto.pack(fill="x", padx=18, pady=(0, 14))
|
||||
self.chk_autostart = ctk.CTkCheckBox(
|
||||
row_auto,
|
||||
text="Iniciar con Windows",
|
||||
command=self._on_autostart_toggle,
|
||||
)
|
||||
self.chk_autostart.pack(side="left", padx=(0, 18))
|
||||
self.chk_auto_connect = ctk.CTkCheckBox(
|
||||
row_auto,
|
||||
text="Conectar al abrir",
|
||||
command=self._on_auto_connect_toggle,
|
||||
)
|
||||
self.chk_auto_connect.pack(side="left")
|
||||
if sys.platform != "win32":
|
||||
self.chk_autostart.configure(state="disabled")
|
||||
|
||||
status = ctk.CTkFrame(root, corner_radius=14)
|
||||
status.pack(fill="x", padx=24, pady=(0, 14))
|
||||
|
||||
@@ -158,19 +185,77 @@ class BotDashboard(ctk.CTk):
|
||||
)
|
||||
self.text_logs.pack(fill="both", expand=True, padx=18, pady=(0, 16))
|
||||
|
||||
def _persist_flags(self):
|
||||
save_gui_settings(
|
||||
normalize_api_url(self.entry_api.get()),
|
||||
self.entry_worker.get(),
|
||||
self.entry_token.get(),
|
||||
autostart_windows=bool(self.chk_autostart.get()),
|
||||
auto_connect=bool(self.chk_auto_connect.get()),
|
||||
)
|
||||
|
||||
def _cargar_settings(self):
|
||||
s = load_gui_settings()
|
||||
self.entry_api.insert(0, s.get("api_url") or DEFAULT_API_URL)
|
||||
self.entry_worker.insert(0, s.get("worker_id") or socket.gethostname())
|
||||
self.entry_token.insert(0, s.get("token") or "")
|
||||
|
||||
want_autostart = bool(s.get("autostart_windows"))
|
||||
if want_autostart and sys.platform == "win32":
|
||||
try:
|
||||
if not is_autostart_enabled():
|
||||
enable_autostart()
|
||||
self.chk_autostart.select()
|
||||
except Exception as e:
|
||||
self._log(f"No se pudo restaurar autoarranque: {e}")
|
||||
elif is_autostart_enabled():
|
||||
# Script presente aunque el flag esté off → marcar checkbox
|
||||
self.chk_autostart.select()
|
||||
|
||||
if bool(s.get("auto_connect")):
|
||||
self.chk_auto_connect.select()
|
||||
|
||||
def _guardar_settings(self):
|
||||
api = normalize_api_url(self.entry_api.get())
|
||||
self.entry_api.delete(0, "end")
|
||||
self.entry_api.insert(0, api)
|
||||
save_gui_settings(api, self.entry_worker.get(), self.entry_token.get())
|
||||
self._persist_flags()
|
||||
self._log("Configuración guardada en data/gui_settings.json")
|
||||
|
||||
def _on_autostart_toggle(self):
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
enabled = bool(self.chk_autostart.get())
|
||||
try:
|
||||
if enabled:
|
||||
path = enable_autostart()
|
||||
self._log(f"Autoarranque activado: {path}")
|
||||
# Tiene sentido conectar al abrir si arranca con Windows
|
||||
if not self.chk_auto_connect.get():
|
||||
self.chk_auto_connect.select()
|
||||
else:
|
||||
disable_autostart()
|
||||
self._log("Autoarranque desactivado.")
|
||||
self._persist_flags()
|
||||
except Exception as e:
|
||||
self._log(f"Error configurando autoarranque: {e}")
|
||||
if enabled:
|
||||
self.chk_autostart.deselect()
|
||||
else:
|
||||
self.chk_autostart.select()
|
||||
|
||||
def _on_auto_connect_toggle(self):
|
||||
self._persist_flags()
|
||||
|
||||
def _maybe_auto_connect(self):
|
||||
if self._connected:
|
||||
return
|
||||
if not self.entry_worker.get().strip() or not self.entry_token.get().strip():
|
||||
self._log("Auto-conectar omitido: falta Worker ID o token.")
|
||||
return
|
||||
self._log("Conectando automáticamente…")
|
||||
self._conectar()
|
||||
|
||||
def _toggle_token(self):
|
||||
self._token_visible = not self._token_visible
|
||||
self.entry_token.configure(show="" if self._token_visible else "•")
|
||||
@@ -195,7 +280,7 @@ class BotDashboard(ctk.CTk):
|
||||
|
||||
self.entry_api.delete(0, "end")
|
||||
self.entry_api.insert(0, api)
|
||||
save_gui_settings(api, worker_id, token)
|
||||
self._persist_flags()
|
||||
|
||||
self._agent = WorkerAgent(
|
||||
api_url=api,
|
||||
@@ -251,6 +336,9 @@ class BotDashboard(ctk.CTk):
|
||||
self.entry_api.configure(state=state)
|
||||
self.entry_worker.configure(state=state)
|
||||
self.entry_token.configure(state=state)
|
||||
if sys.platform == "win32":
|
||||
self.chk_autostart.configure(state="normal")
|
||||
self.chk_auto_connect.configure(state="normal")
|
||||
|
||||
def _limpiar_logs(self):
|
||||
self.text_logs.delete("1.0", "end")
|
||||
|
||||
@@ -21,6 +21,8 @@ def load_gui_settings() -> Dict[str, Any]:
|
||||
"api_url": DEFAULT_API_URL,
|
||||
"worker_id": "",
|
||||
"token": DEFAULT_TOKEN,
|
||||
"autostart_windows": False,
|
||||
"auto_connect": False,
|
||||
}
|
||||
if not _SETTINGS_PATH.exists():
|
||||
return defaults
|
||||
@@ -33,13 +35,26 @@ def load_gui_settings() -> Dict[str, Any]:
|
||||
return defaults
|
||||
|
||||
|
||||
def save_gui_settings(api_url: str, worker_id: str, token: str) -> None:
|
||||
def save_gui_settings(
|
||||
api_url: str,
|
||||
worker_id: str,
|
||||
token: str,
|
||||
*,
|
||||
autostart_windows: bool | None = None,
|
||||
auto_connect: bool | None = None,
|
||||
) -> None:
|
||||
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = load_gui_settings()
|
||||
payload = {
|
||||
**current,
|
||||
"api_url": api_url.strip(),
|
||||
"worker_id": worker_id.strip(),
|
||||
"token": token.strip(),
|
||||
}
|
||||
if autostart_windows is not None:
|
||||
payload["autostart_windows"] = bool(autostart_windows)
|
||||
if auto_connect is not None:
|
||||
payload["auto_connect"] = bool(auto_connect)
|
||||
_SETTINGS_PATH.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Autoarranque con Windows (carpeta Startup del usuario)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TASK_BASENAME = "IPSExtractor-BotDescarga"
|
||||
|
||||
|
||||
def bot_root() -> Path:
|
||||
"""Raíz del paquete bots/bot_descarga (donde está run_gui.py)."""
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def startup_dir() -> Path:
|
||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
||||
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
|
||||
|
||||
|
||||
def startup_script_path() -> Path:
|
||||
return startup_dir() / f"{TASK_BASENAME}.bat"
|
||||
|
||||
|
||||
def resolve_python(root: Path | None = None) -> Path:
|
||||
root = root or bot_root()
|
||||
for candidate in (
|
||||
root / ".venv" / "Scripts" / "pythonw.exe",
|
||||
root / ".venv" / "Scripts" / "python.exe",
|
||||
root / ".venv" / "bin" / "python",
|
||||
):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return Path(sys.executable)
|
||||
|
||||
|
||||
def is_autostart_enabled() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
return startup_script_path().exists()
|
||||
|
||||
|
||||
def enable_autostart(root: Path | None = None) -> Path:
|
||||
"""Crea un .bat en Startup que abre la GUI del bot tras el login."""
|
||||
if sys.platform != "win32":
|
||||
raise RuntimeError("El autoarranque solo está disponible en Windows.")
|
||||
|
||||
root = (root or bot_root()).resolve()
|
||||
gui = root / "run_gui.py"
|
||||
if not gui.exists():
|
||||
raise FileNotFoundError(f"No se encontró run_gui.py en {root}")
|
||||
|
||||
py = resolve_python(root)
|
||||
dest = startup_script_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# start "" deja que el .bat termine; la GUI queda como proceso independiente.
|
||||
content = (
|
||||
"@echo off\r\n"
|
||||
f'cd /d "{root}"\r\n'
|
||||
f'start "" "{py}" "{gui}"\r\n'
|
||||
)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
return dest
|
||||
|
||||
|
||||
def disable_autostart() -> bool:
|
||||
"""Elimina el .bat de Startup. True si había algo que borrar."""
|
||||
path = startup_script_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
@@ -131,13 +131,14 @@ class WorkerAgent:
|
||||
|
||||
def _run_job(self, job: dict) -> None:
|
||||
job_id = job["id"]
|
||||
downloads_prefix = (job.get("downloads_prefix") or "").strip() or f"downloads/{job_id}/"
|
||||
self._uploaded_keys = []
|
||||
work_dir = self.staging_root / job_id
|
||||
out_dir = work_dir / "out"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
excel_path = work_dir / (Path(job.get("excel_key") or "input.xlsx").name)
|
||||
|
||||
self._log(f"[worker {self.worker_id}] job {job_id} iniciado")
|
||||
self._log(f"[worker {self.worker_id}] job {job_id} iniciado (prefix={downloads_prefix})")
|
||||
|
||||
try:
|
||||
self.api.download_excel(job_id, excel_path)
|
||||
@@ -168,7 +169,14 @@ class WorkerAgent:
|
||||
self._log(f"[worker] no se pudo enviar evento: {e}")
|
||||
|
||||
def on_pdf_saved(nit: str, radicado: str, local_path: str) -> None:
|
||||
key = self.minio.upload_pdf(job_id, nit, radicado, local_path)
|
||||
key = self.minio.upload_pdf(
|
||||
job_id,
|
||||
nit,
|
||||
radicado,
|
||||
local_path,
|
||||
downloads_prefix=downloads_prefix,
|
||||
skip_if_exists=True,
|
||||
)
|
||||
self._uploaded_keys.append(key)
|
||||
|
||||
service = DownloadService(
|
||||
@@ -181,7 +189,11 @@ class WorkerAgent:
|
||||
summary = service.run()
|
||||
|
||||
try:
|
||||
self.minio.write_manifest(job_id, self._uploaded_keys)
|
||||
self.minio.write_manifest(
|
||||
job_id,
|
||||
self._uploaded_keys,
|
||||
downloads_prefix=downloads_prefix,
|
||||
)
|
||||
except Exception as e:
|
||||
self._log(f"[worker] manifest no escrito: {e}")
|
||||
|
||||
@@ -194,7 +206,7 @@ class WorkerAgent:
|
||||
"exitosos": summary.exitosos,
|
||||
"fallidos": fallidos_count,
|
||||
"cancelled": summary.cancelled,
|
||||
"downloads_prefix": job.get("downloads_prefix"),
|
||||
"downloads_prefix": downloads_prefix,
|
||||
"uploaded_count": len(self._uploaded_keys),
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,22 @@ class MinioSettings:
|
||||
)
|
||||
|
||||
|
||||
def pdf_key_from_prefix(downloads_prefix: str, nit: str, radicado: str) -> str:
|
||||
prefix = (downloads_prefix or "").strip().rstrip("/")
|
||||
if not prefix:
|
||||
raise ValueError("downloads_prefix vacío")
|
||||
return f"{prefix}/{nit}/{radicado}.pdf"
|
||||
|
||||
|
||||
def pdf_key(job_id: str, nit: str, radicado: str) -> str:
|
||||
return f"downloads/{job_id}/{nit}/{radicado}.pdf"
|
||||
|
||||
|
||||
def manifest_key_from_prefix(downloads_prefix: str) -> str:
|
||||
prefix = (downloads_prefix or "").strip().rstrip("/")
|
||||
return f"{prefix}/_manifest.json"
|
||||
|
||||
|
||||
def manifest_key(job_id: str) -> str:
|
||||
return f"downloads/{job_id}/_manifest.json"
|
||||
|
||||
@@ -89,8 +101,30 @@ class WorkerMinioUploader:
|
||||
self._client = _s3_client(self.settings)
|
||||
return self._client
|
||||
|
||||
def upload_pdf(self, job_id: str, nit: str, radicado: str, local_path: str | Path) -> str:
|
||||
key = pdf_key(job_id, nit, radicado)
|
||||
def object_exists(self, key: str) -> bool:
|
||||
try:
|
||||
self.client.head_object(Bucket=self.settings.bucket, Key=key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def upload_pdf(
|
||||
self,
|
||||
job_id: str,
|
||||
nit: str,
|
||||
radicado: str,
|
||||
local_path: str | Path,
|
||||
*,
|
||||
downloads_prefix: str | None = None,
|
||||
skip_if_exists: bool = True,
|
||||
) -> str:
|
||||
"""Sube PDF bajo downloads_prefix (padre) o downloads/{job_id}/."""
|
||||
if downloads_prefix:
|
||||
key = pdf_key_from_prefix(downloads_prefix, nit, radicado)
|
||||
else:
|
||||
key = pdf_key(job_id, nit, radicado)
|
||||
if skip_if_exists and self.object_exists(key):
|
||||
return key
|
||||
path = Path(local_path)
|
||||
# put_object (no multipart) es más fiable en buckets anónimos.
|
||||
self.client.put_object(
|
||||
@@ -101,9 +135,23 @@ class WorkerMinioUploader:
|
||||
)
|
||||
return key
|
||||
|
||||
def write_manifest(self, job_id: str, keys: List[str]) -> str:
|
||||
key = manifest_key(job_id)
|
||||
payload = {"job_id": job_id, "files": keys, "count": len(keys)}
|
||||
def write_manifest(
|
||||
self,
|
||||
job_id: str,
|
||||
keys: List[str],
|
||||
*,
|
||||
downloads_prefix: str | None = None,
|
||||
) -> str:
|
||||
if downloads_prefix:
|
||||
key = manifest_key_from_prefix(downloads_prefix)
|
||||
else:
|
||||
key = manifest_key(job_id)
|
||||
payload = {
|
||||
"job_id": job_id,
|
||||
"downloads_prefix": downloads_prefix or f"downloads/{job_id}/",
|
||||
"files": keys,
|
||||
"count": len(keys),
|
||||
}
|
||||
self.client.put_object(
|
||||
Bucket=self.settings.bucket,
|
||||
Key=key,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
|
||||
from bot_descarga.ui.windows_autostart import TASK_BASENAME, bot_root, resolve_python
|
||||
|
||||
|
||||
def test_bot_root_has_run_gui():
|
||||
root = bot_root()
|
||||
assert (root / "run_gui.py").exists()
|
||||
|
||||
|
||||
def test_resolve_python_returns_path():
|
||||
py = resolve_python()
|
||||
assert isinstance(py, Path)
|
||||
assert str(py)
|
||||
|
||||
|
||||
def test_task_basename():
|
||||
assert "BotDescarga" in TASK_BASENAME
|
||||
@@ -0,0 +1,7 @@
|
||||
BOT_API_URL=http://localhost:8189/bot-digitador
|
||||
BOT_API_TOKEN=dev-token-change-me
|
||||
BOT_WORKER_ID=
|
||||
DIGITADOR_APP_PATH=C:\Users\Pysctas185\Documents\Integracion Total.lnk
|
||||
MINIO_ENDPOINT=https://s3.builker.xyz
|
||||
MINIO_BUCKET=pys-cuentas-medicas
|
||||
MINIO_ANONYMOUS=1
|
||||
@@ -0,0 +1,92 @@
|
||||
# Bot Digitador (agent Windows)
|
||||
|
||||
Agent que **digita** radicados en **Integración Total** (pywinauto + OpenCV).
|
||||
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`).
|
||||
|
||||
## Requisitos en la PC
|
||||
|
||||
- Windows con **Integración Total** instalado e iniciado sesión
|
||||
- Python 3.11+
|
||||
- Plantillas OpenCV en `template/` (ver [template/README.md](template/README.md))
|
||||
- Acceso de red al gateway (`BOT_API_URL`) y a MinIO
|
||||
- Token `BOT_API_TOKEN`
|
||||
|
||||
## Instalación
|
||||
|
||||
```bash
|
||||
cd bots/bot_digitador
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
copy .env.example .env
|
||||
# Editar .env (especialmente DIGITADOR_APP_PATH)
|
||||
```
|
||||
|
||||
Copia los PNG de plantillas a `template/`.
|
||||
|
||||
## Arranque con GUI
|
||||
|
||||
```bash
|
||||
python run_gui.py
|
||||
```
|
||||
|
||||
Configura URL API (ej. `http://localhost:8189/bot-digitador`), Worker ID, token y
|
||||
ruta de Integración Total (`DIGITADOR_APP_PATH`). Pulsa **Conectar agent**.
|
||||
Queda idle hasta que el desktop asigne un job CSV.
|
||||
|
||||
### Autoarranque en Windows
|
||||
|
||||
1. Marca **Iniciar con Windows**.
|
||||
2. Deja **Conectar al abrir** activo.
|
||||
|
||||
## Arranque solo agent (CLI)
|
||||
|
||||
```bash
|
||||
python -m bot_digitador.worker \
|
||||
--api-url http://localhost:8189/bot-digitador \
|
||||
--worker-id PC-DIGITADOR-01 \
|
||||
--token %BOT_API_TOKEN%
|
||||
```
|
||||
|
||||
## Modo local (sin API)
|
||||
|
||||
Requiere Integración Total abierto y visible:
|
||||
|
||||
```bash
|
||||
python main.py --csv datos.csv
|
||||
python main.py --csv datos.csv --delimiter "|" --date-format dd/mm/yyyy --app-path "C:\ruta\Integracion Total.lnk"
|
||||
```
|
||||
|
||||
Reportes en la carpeta de salida: `RESULTADOS.csv`, `OMITIDOS.csv`, `ejecucion_bot.log`.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
Desktop IPSExtractor → Gateway :8189 → API bot-digitador
|
||||
↑
|
||||
PC bot (esta carpeta) ────────┘ HTTP outbound
|
||||
└─ PutObject reportes → MinIO …/digitados/{job_id}/
|
||||
└─ pywinauto → Integración Total
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
Ver [`.env.example`](.env.example):
|
||||
|
||||
| Variable | Uso |
|
||||
|----------|-----|
|
||||
| `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 |
|
||||
| `MINIO_*` | Bucket de reportes; por defecto anónimo |
|
||||
|
||||
## CSV de entrada
|
||||
|
||||
Columnas requeridas: `radicado`, `documento`, `ingreso`, `total_cargos`,
|
||||
`autorizacion`, `tipo_documento`, `tipo_servicio`, `copago`, `cuota_moderadora`.
|
||||
|
||||
Acepta delimitador `,` o `|` (auto-detección).
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Bot Digitador — automatización de digitación en Integración Total."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from bot_digitador.core.digitador_service import DigitadorRunSummary, DigitadorService
|
||||
|
||||
__all__ = ["DigitadorService", "DigitadorRunSummary"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Coordenadas, estrategia de documentos y constantes de Integración Total."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
DOC_COORDS = {
|
||||
"CC": (796, 343),
|
||||
"TI": (796, 549),
|
||||
"CE": (796, 329),
|
||||
"AS": (796, 296),
|
||||
"CD": (796, 314),
|
||||
"CN": (796, 371),
|
||||
"ME": (796, 378),
|
||||
"NI": (796, 412),
|
||||
"NU": (796, 453),
|
||||
"PS": (796, 450),
|
||||
"PE": (796, 467),
|
||||
"PT": (796, 483),
|
||||
"PJ": (796, 503),
|
||||
"RC": (796, 520),
|
||||
"SC": (796, 536),
|
||||
}
|
||||
|
||||
DOC_STRATEGY = [
|
||||
"CC", "TI", "CE", "RC", "PS", "PT", "PE",
|
||||
"AS", "ME", "CN", "SC", "CD", "NI", "NU", "PJ",
|
||||
]
|
||||
|
||||
SERVICES_COORDS = {
|
||||
"ambulancias y elem.": (1033, 367),
|
||||
"ambulatorio": (1033, 387),
|
||||
"c. externa": (1033, 405),
|
||||
"capacitacion": (1033, 417),
|
||||
"cirugia ambulatoria": (1033, 432),
|
||||
"fomento y prevencion": (1033, 449),
|
||||
"honorarios": (1033, 467),
|
||||
}
|
||||
|
||||
COORD_CLOSE_ERROR = (1223, 704)
|
||||
|
||||
DEFAULT_APP_PATH = os.getenv(
|
||||
"DIGITADOR_APP_PATH",
|
||||
r"C:\Users\Pysctas185\Documents\Integracion Total.lnk",
|
||||
)
|
||||
PROCESS_NAME = "SaludTotal.Health.WinClient.TotalIntegration.exe"
|
||||
REINICIO_CADA = 6
|
||||
TIMEOUT_REGISTRO = 120
|
||||
|
||||
TEMPLATE_ERROR = "error.png"
|
||||
TEMPLATE_SUCCESS = "success.png"
|
||||
TEMPLATE_BIEN = "bien.png"
|
||||
TEMPLATE_DIFERENCIAS = "flecha_diferencias_abierto.png"
|
||||
TEMPLATE_DIFERENCIAS_CERRADO = "flecha_diferencias_cerrado.png"
|
||||
TEMPLATE_FECHA_VACIA_SLASH = "fecha_vacia_slash.png"
|
||||
TEMPLATE_FECHA_VACIA_BLANCO = "fecha_vacia_blanco.png"
|
||||
TEMPLATE_POPUP_NOMBRES = "popup_nombres.png"
|
||||
TEMPLATE_RADICADO_EXISTENTE = "radicado_existente.png"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from bot_digitador.models.events import DigitadorEvent, create_event
|
||||
|
||||
__all__ = ["DigitadorEvent", "create_event"]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Eventos simples para callbacks del DigitadorService."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigitadorEvent:
|
||||
event_type: str
|
||||
message: str
|
||||
radicado: Optional[str] = None
|
||||
index: Optional[int] = None
|
||||
total: Optional[int] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def display_message(self) -> str:
|
||||
prefix = ""
|
||||
if self.index is not None and self.total is not None:
|
||||
prefix = f"[{self.index}/{self.total}] "
|
||||
return f"{prefix}{self.message}"
|
||||
|
||||
|
||||
def create_event(
|
||||
event_type: str,
|
||||
message: str,
|
||||
*,
|
||||
radicado: Optional[str] = None,
|
||||
index: Optional[int] = None,
|
||||
total: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> DigitadorEvent:
|
||||
return DigitadorEvent(
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
radicado=radicado,
|
||||
index=index,
|
||||
total=total,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""UI del agent digitador."""
|
||||
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH
|
||||
from bot_digitador.ui.settings_store import (
|
||||
DEFAULT_API_URL,
|
||||
load_gui_settings,
|
||||
normalize_api_url,
|
||||
save_gui_settings,
|
||||
)
|
||||
from bot_digitador.ui.windows_autostart import (
|
||||
disable_autostart,
|
||||
enable_autostart,
|
||||
is_autostart_enabled,
|
||||
)
|
||||
from bot_digitador.worker.agent import WorkerAgent
|
||||
|
||||
ctk.set_appearance_mode("dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
|
||||
class BotDashboard(ctk.CTk):
|
||||
"""GUI del agent digitador: configura API remota y conecta como worker idle."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.title("Bot Digitador — Integración Total")
|
||||
self.geometry("960x780")
|
||||
self.resizable(False, False)
|
||||
|
||||
self._agent: WorkerAgent | None = None
|
||||
self._agent_thread: threading.Thread | None = None
|
||||
self._connected = False
|
||||
|
||||
self._construir_ui()
|
||||
self._cargar_settings()
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
if bool(load_gui_settings().get("auto_connect")):
|
||||
self.after(800, self._maybe_auto_connect)
|
||||
|
||||
def _construir_ui(self):
|
||||
root = ctk.CTkFrame(self, corner_radius=18)
|
||||
root.pack(fill="both", expand=True, padx=18, pady=18)
|
||||
|
||||
ctk.CTkLabel(
|
||||
root,
|
||||
text="Bot Digitador",
|
||||
font=ctk.CTkFont(size=26, weight="bold"),
|
||||
).pack(pady=(18, 4))
|
||||
|
||||
ctk.CTkLabel(
|
||||
root,
|
||||
text="Agent remoto — digita en Integración Total (pywinauto + OpenCV)",
|
||||
text_color="gray",
|
||||
font=ctk.CTkFont(size=13),
|
||||
).pack(pady=(0, 18))
|
||||
|
||||
config = ctk.CTkFrame(root, corner_radius=14)
|
||||
config.pack(fill="x", padx=24, pady=(0, 14))
|
||||
|
||||
ctk.CTkLabel(
|
||||
config,
|
||||
text="API remota (IPSExtractor)",
|
||||
font=ctk.CTkFont(size=16, weight="bold"),
|
||||
).pack(anchor="w", padx=18, pady=(14, 8))
|
||||
|
||||
row_api = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_api.pack(fill="x", padx=18, pady=(0, 8))
|
||||
ctk.CTkLabel(row_api, text="URL API", width=100, anchor="w").pack(side="left")
|
||||
self.entry_api = ctk.CTkEntry(
|
||||
row_api,
|
||||
placeholder_text=DEFAULT_API_URL,
|
||||
)
|
||||
self.entry_api.pack(side="left", fill="x", expand=True)
|
||||
|
||||
row_id = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_id.pack(fill="x", padx=18, pady=(0, 8))
|
||||
ctk.CTkLabel(row_id, text="Worker ID", width=100, anchor="w").pack(side="left")
|
||||
self.entry_worker = ctk.CTkEntry(
|
||||
row_id,
|
||||
placeholder_text=socket.gethostname() or "PC-01",
|
||||
)
|
||||
self.entry_worker.pack(side="left", fill="x", expand=True)
|
||||
|
||||
row_token = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_token.pack(fill="x", padx=18, pady=(0, 8))
|
||||
ctk.CTkLabel(row_token, text="Token", width=100, anchor="w").pack(side="left")
|
||||
self.entry_token = ctk.CTkEntry(
|
||||
row_token,
|
||||
placeholder_text="BOT_API_TOKEN",
|
||||
show="•",
|
||||
)
|
||||
self.entry_token.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||
self._token_visible = False
|
||||
ctk.CTkButton(
|
||||
row_token,
|
||||
text="👁",
|
||||
width=40,
|
||||
command=self._toggle_token,
|
||||
).pack(side="left")
|
||||
|
||||
row_app = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_app.pack(fill="x", padx=18, pady=(0, 8))
|
||||
ctk.CTkLabel(row_app, text="APP_PATH", width=100, anchor="w").pack(side="left")
|
||||
self.entry_app = ctk.CTkEntry(
|
||||
row_app,
|
||||
placeholder_text=DEFAULT_APP_PATH,
|
||||
)
|
||||
self.entry_app.pack(side="left", fill="x", expand=True)
|
||||
|
||||
row_auto = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_auto.pack(fill="x", padx=18, pady=(0, 14))
|
||||
self.chk_autostart = ctk.CTkCheckBox(
|
||||
row_auto,
|
||||
text="Iniciar con Windows",
|
||||
command=self._on_autostart_toggle,
|
||||
)
|
||||
self.chk_autostart.pack(side="left", padx=(0, 18))
|
||||
self.chk_auto_connect = ctk.CTkCheckBox(
|
||||
row_auto,
|
||||
text="Conectar al abrir",
|
||||
command=self._on_auto_connect_toggle,
|
||||
)
|
||||
self.chk_auto_connect.pack(side="left")
|
||||
if sys.platform != "win32":
|
||||
self.chk_autostart.configure(state="disabled")
|
||||
|
||||
status = ctk.CTkFrame(root, corner_radius=14)
|
||||
status.pack(fill="x", padx=24, pady=(0, 14))
|
||||
|
||||
self.lbl_estado = ctk.CTkLabel(
|
||||
status,
|
||||
text="Estado: desconectado",
|
||||
font=ctk.CTkFont(size=15, weight="bold"),
|
||||
)
|
||||
self.lbl_estado.pack(anchor="w", padx=18, pady=(14, 2))
|
||||
|
||||
self.lbl_hint = ctk.CTkLabel(
|
||||
status,
|
||||
text="Configura la API y pulsa Conectar. Este PC no expone puertos.",
|
||||
text_color="gray",
|
||||
font=ctk.CTkFont(size=13),
|
||||
)
|
||||
self.lbl_hint.pack(anchor="w", padx=18, pady=(0, 14))
|
||||
|
||||
botones = ctk.CTkFrame(root, fg_color="transparent")
|
||||
botones.pack(fill="x", padx=24, pady=(0, 14))
|
||||
|
||||
self.btn_connect = ctk.CTkButton(
|
||||
botones,
|
||||
text="▶ Conectar agent",
|
||||
height=42,
|
||||
width=180,
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
command=self._toggle_connect,
|
||||
)
|
||||
self.btn_connect.pack(side="left", padx=(0, 10))
|
||||
|
||||
ctk.CTkButton(
|
||||
botones,
|
||||
text="Guardar config",
|
||||
height=42,
|
||||
width=140,
|
||||
command=self._guardar_settings,
|
||||
).pack(side="left", padx=(0, 10))
|
||||
|
||||
ctk.CTkButton(
|
||||
botones,
|
||||
text="Limpiar logs",
|
||||
height=42,
|
||||
width=130,
|
||||
command=self._limpiar_logs,
|
||||
).pack(side="left")
|
||||
|
||||
logs_frame = ctk.CTkFrame(root, corner_radius=14)
|
||||
logs_frame.pack(fill="both", expand=True, padx=24, pady=(0, 18))
|
||||
|
||||
ctk.CTkLabel(
|
||||
logs_frame,
|
||||
text="Detalle del agent",
|
||||
font=ctk.CTkFont(size=15, weight="bold"),
|
||||
).pack(anchor="w", padx=18, pady=(12, 6))
|
||||
|
||||
self.text_logs = ctk.CTkTextbox(
|
||||
logs_frame, font=ctk.CTkFont(family="Courier", size=12)
|
||||
)
|
||||
self.text_logs.pack(fill="both", expand=True, padx=18, pady=(0, 16))
|
||||
|
||||
def _persist_flags(self):
|
||||
save_gui_settings(
|
||||
normalize_api_url(self.entry_api.get()),
|
||||
self.entry_worker.get(),
|
||||
self.entry_token.get(),
|
||||
app_path=self.entry_app.get(),
|
||||
autostart_windows=bool(self.chk_autostart.get()),
|
||||
auto_connect=bool(self.chk_auto_connect.get()),
|
||||
)
|
||||
|
||||
def _cargar_settings(self):
|
||||
s = load_gui_settings()
|
||||
self.entry_api.insert(0, s.get("api_url") or DEFAULT_API_URL)
|
||||
self.entry_worker.insert(0, s.get("worker_id") or socket.gethostname())
|
||||
self.entry_token.insert(0, s.get("token") or "")
|
||||
self.entry_app.insert(0, s.get("app_path") or DEFAULT_APP_PATH)
|
||||
|
||||
want_autostart = bool(s.get("autostart_windows"))
|
||||
if want_autostart and sys.platform == "win32":
|
||||
try:
|
||||
if not is_autostart_enabled():
|
||||
enable_autostart()
|
||||
self.chk_autostart.select()
|
||||
except Exception as e:
|
||||
self._log(f"No se pudo restaurar autoarranque: {e}")
|
||||
elif is_autostart_enabled():
|
||||
self.chk_autostart.select()
|
||||
|
||||
if bool(s.get("auto_connect")):
|
||||
self.chk_auto_connect.select()
|
||||
|
||||
def _guardar_settings(self):
|
||||
api = normalize_api_url(self.entry_api.get())
|
||||
self.entry_api.delete(0, "end")
|
||||
self.entry_api.insert(0, api)
|
||||
self._persist_flags()
|
||||
self._log("Configuración guardada en data/gui_settings.json")
|
||||
|
||||
def _on_autostart_toggle(self):
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
enabled = bool(self.chk_autostart.get())
|
||||
try:
|
||||
if enabled:
|
||||
path = enable_autostart()
|
||||
self._log(f"Autoarranque activado: {path}")
|
||||
if not self.chk_auto_connect.get():
|
||||
self.chk_auto_connect.select()
|
||||
else:
|
||||
disable_autostart()
|
||||
self._log("Autoarranque desactivado.")
|
||||
self._persist_flags()
|
||||
except Exception as e:
|
||||
self._log(f"Error configurando autoarranque: {e}")
|
||||
if enabled:
|
||||
self.chk_autostart.deselect()
|
||||
else:
|
||||
self.chk_autostart.select()
|
||||
|
||||
def _on_auto_connect_toggle(self):
|
||||
self._persist_flags()
|
||||
|
||||
def _maybe_auto_connect(self):
|
||||
if self._connected:
|
||||
return
|
||||
if not self.entry_worker.get().strip() or not self.entry_token.get().strip():
|
||||
self._log("Auto-conectar omitido: falta Worker ID o token.")
|
||||
return
|
||||
self._log("Conectando automáticamente…")
|
||||
self._conectar()
|
||||
|
||||
def _toggle_token(self):
|
||||
self._token_visible = not self._token_visible
|
||||
self.entry_token.configure(show="" if self._token_visible else "•")
|
||||
|
||||
def _toggle_connect(self):
|
||||
if self._connected:
|
||||
self._desconectar()
|
||||
else:
|
||||
self._conectar()
|
||||
|
||||
def _conectar(self):
|
||||
api = normalize_api_url(self.entry_api.get())
|
||||
worker_id = self.entry_worker.get().strip()
|
||||
token = self.entry_token.get().strip()
|
||||
app_path = self.entry_app.get().strip() or DEFAULT_APP_PATH
|
||||
|
||||
if not worker_id:
|
||||
self._log("Debes indicar un Worker ID.")
|
||||
return
|
||||
if not token:
|
||||
self._log("Debes indicar el token (BOT_API_TOKEN).")
|
||||
return
|
||||
|
||||
self.entry_api.delete(0, "end")
|
||||
self.entry_api.insert(0, api)
|
||||
self._persist_flags()
|
||||
|
||||
self._agent = WorkerAgent(
|
||||
api_url=api,
|
||||
worker_id=worker_id,
|
||||
token=token,
|
||||
app_path=app_path,
|
||||
log_func=self._log,
|
||||
)
|
||||
self._connected = True
|
||||
self.btn_connect.configure(text="⏹ Desconectar", fg_color="#c0392b", hover_color="#a93226")
|
||||
self.lbl_estado.configure(text=f"Estado: conectado como {worker_id}")
|
||||
self.lbl_hint.configure(text=f"API: {api} — esperando jobs del desktop…")
|
||||
self._set_config_enabled(False)
|
||||
|
||||
self._agent_thread = threading.Thread(
|
||||
target=self._run_agent,
|
||||
daemon=True,
|
||||
)
|
||||
self._agent_thread.start()
|
||||
self._log(f"Conectando a {api}…")
|
||||
|
||||
def _run_agent(self):
|
||||
try:
|
||||
assert self._agent is not None
|
||||
self._agent.run_forever()
|
||||
except Exception as e:
|
||||
self._log(f"Error del agent: {e}")
|
||||
finally:
|
||||
self.after(0, self._on_agent_stopped)
|
||||
|
||||
def _desconectar(self):
|
||||
if self._agent:
|
||||
self._agent.stop()
|
||||
self._log("Solicitando desconexión…")
|
||||
self.btn_connect.configure(state="disabled", text="Desconectando…")
|
||||
|
||||
def _on_agent_stopped(self):
|
||||
self._connected = False
|
||||
self._agent = None
|
||||
self.btn_connect.configure(
|
||||
state="normal",
|
||||
text="▶ Conectar agent",
|
||||
fg_color=["#3B8ED0", "#1F6AA5"],
|
||||
hover_color=["#36719F", "#144870"],
|
||||
)
|
||||
self.lbl_estado.configure(text="Estado: desconectado")
|
||||
self.lbl_hint.configure(
|
||||
text="Configura la API y pulsa Conectar. Este PC no expone puertos."
|
||||
)
|
||||
self._set_config_enabled(True)
|
||||
|
||||
def _set_config_enabled(self, enabled: bool):
|
||||
state = "normal" if enabled else "disabled"
|
||||
self.entry_api.configure(state=state)
|
||||
self.entry_worker.configure(state=state)
|
||||
self.entry_token.configure(state=state)
|
||||
self.entry_app.configure(state=state)
|
||||
if sys.platform == "win32":
|
||||
self.chk_autostart.configure(state="normal")
|
||||
self.chk_auto_connect.configure(state="normal")
|
||||
|
||||
def _limpiar_logs(self):
|
||||
self.text_logs.delete("1.0", "end")
|
||||
|
||||
def _log(self, mensaje: str):
|
||||
def _escribir():
|
||||
self.text_logs.insert("end", mensaje + "\n")
|
||||
self.text_logs.see("end")
|
||||
|
||||
self.after(0, _escribir)
|
||||
|
||||
def _on_close(self):
|
||||
if self._agent:
|
||||
self._agent.stop()
|
||||
self.destroy()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Persistencia simple de configuración de la GUI del worker digitador."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH
|
||||
|
||||
DEFAULT_API_URL = os.getenv(
|
||||
"BOT_API_URL",
|
||||
"http://localhost:8189/bot-digitador",
|
||||
)
|
||||
DEFAULT_TOKEN = os.getenv("BOT_API_TOKEN", "dev-token-change-me")
|
||||
|
||||
_SETTINGS_PATH = Path("data") / "gui_settings.json"
|
||||
|
||||
|
||||
def load_gui_settings() -> Dict[str, Any]:
|
||||
defaults = {
|
||||
"api_url": DEFAULT_API_URL,
|
||||
"worker_id": "",
|
||||
"token": DEFAULT_TOKEN,
|
||||
"app_path": os.getenv("DIGITADOR_APP_PATH", DEFAULT_APP_PATH),
|
||||
"autostart_windows": False,
|
||||
"auto_connect": False,
|
||||
}
|
||||
if not _SETTINGS_PATH.exists():
|
||||
return defaults
|
||||
try:
|
||||
data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return defaults
|
||||
return {**defaults, **data}
|
||||
except Exception:
|
||||
return defaults
|
||||
|
||||
|
||||
def save_gui_settings(
|
||||
api_url: str,
|
||||
worker_id: str,
|
||||
token: str,
|
||||
*,
|
||||
app_path: str | None = None,
|
||||
autostart_windows: bool | None = None,
|
||||
auto_connect: bool | None = None,
|
||||
) -> None:
|
||||
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = load_gui_settings()
|
||||
payload = {
|
||||
**current,
|
||||
"api_url": api_url.strip(),
|
||||
"worker_id": worker_id.strip(),
|
||||
"token": token.strip(),
|
||||
}
|
||||
if app_path is not None:
|
||||
payload["app_path"] = app_path.strip()
|
||||
if autostart_windows is not None:
|
||||
payload["autostart_windows"] = bool(autostart_windows)
|
||||
if auto_connect is not None:
|
||||
payload["auto_connect"] = bool(auto_connect)
|
||||
_SETTINGS_PATH.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def normalize_api_url(url: str) -> str:
|
||||
"""Normaliza la URL base del API bot-digitador."""
|
||||
u = (url or "").strip().rstrip("/")
|
||||
if not u:
|
||||
return DEFAULT_API_URL.rstrip("/")
|
||||
if u.endswith("/bot-digitador"):
|
||||
return u
|
||||
if "builker.xyz" in u or u.endswith(":8189"):
|
||||
return f"{u}/bot-digitador"
|
||||
return u
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Autoarranque con Windows (carpeta Startup del usuario)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TASK_BASENAME = "IPSExtractor-BotDigitador"
|
||||
|
||||
|
||||
def bot_root() -> Path:
|
||||
"""Raíz del paquete bots/bot_digitador (donde está run_gui.py)."""
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def startup_dir() -> Path:
|
||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
||||
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
|
||||
|
||||
|
||||
def startup_script_path() -> Path:
|
||||
return startup_dir() / f"{TASK_BASENAME}.bat"
|
||||
|
||||
|
||||
def resolve_python(root: Path | None = None) -> Path:
|
||||
root = root or bot_root()
|
||||
for candidate in (
|
||||
root / ".venv" / "Scripts" / "pythonw.exe",
|
||||
root / ".venv" / "Scripts" / "python.exe",
|
||||
root / ".venv" / "bin" / "python",
|
||||
):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return Path(sys.executable)
|
||||
|
||||
|
||||
def is_autostart_enabled() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
return startup_script_path().exists()
|
||||
|
||||
|
||||
def enable_autostart(root: Path | None = None) -> Path:
|
||||
"""Crea un .bat en Startup que abre la GUI del bot tras el login."""
|
||||
if sys.platform != "win32":
|
||||
raise RuntimeError("El autoarranque solo está disponible en Windows.")
|
||||
|
||||
root = (root or bot_root()).resolve()
|
||||
gui = root / "run_gui.py"
|
||||
if not gui.exists():
|
||||
raise FileNotFoundError(f"No se encontró run_gui.py en {root}")
|
||||
|
||||
py = resolve_python(root)
|
||||
dest = startup_script_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
content = (
|
||||
"@echo off\r\n"
|
||||
f'cd /d "{root}"\r\n'
|
||||
f'start "" "{py}" "{gui}"\r\n'
|
||||
)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
return dest
|
||||
|
||||
|
||||
def disable_autostart() -> bool:
|
||||
"""Elimina el .bat de Startup. True si había algo que borrar."""
|
||||
path = startup_script_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1 @@
|
||||
"""Worker remoto del bot digitador."""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Permite: python -m bot_digitador.worker"""
|
||||
|
||||
from bot_digitador.worker.run import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH
|
||||
from bot_digitador.core.digitador_service import DigitadorService
|
||||
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."""
|
||||
|
||||
def __init__(self, base_url: str, token: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self._client = httpx.Client(
|
||||
timeout=httpx.Timeout(120.0, connect=10.0),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def register(self, worker_id: str, hostname: str = "", max_concurrent: int = 1) -> dict:
|
||||
r = self._client.post(
|
||||
self._url("/workers/register"),
|
||||
json={
|
||||
"worker_id": worker_id,
|
||||
"hostname": hostname or socket.gethostname(),
|
||||
"max_concurrent": max_concurrent,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def heartbeat(self, worker_id: str) -> dict:
|
||||
r = self._client.post(
|
||||
self._url("/workers/heartbeat"),
|
||||
json={"worker_id": worker_id},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def claim_pending(self, worker_id: str) -> Optional[dict]:
|
||||
r = self._client.get(
|
||||
self._url("/workers/me/pending"),
|
||||
params={"worker_id": worker_id},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data.get("job")
|
||||
|
||||
def download_file(self, job_id: str, dest: Path) -> Path:
|
||||
r = self._client.get(self._url(f"/jobs/{job_id}/file"))
|
||||
r.raise_for_status()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(r.content)
|
||||
return dest
|
||||
|
||||
def post_event(self, job_id: str, event: Dict[str, Any]) -> None:
|
||||
r = self._client.post(self._url(f"/jobs/{job_id}/events"), json=event)
|
||||
r.raise_for_status()
|
||||
|
||||
def cancel_flag(self, job_id: str) -> bool:
|
||||
r = self._client.get(self._url(f"/jobs/{job_id}/cancel-flag"))
|
||||
r.raise_for_status()
|
||||
return bool(r.json().get("cancel_requested"))
|
||||
|
||||
def patch_job(self, job_id: str, **body) -> dict:
|
||||
r = self._client.patch(self._url(f"/jobs/{job_id}"), json=body)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
class WorkerAgent:
|
||||
"""Agent idle: heartbeat + claim jobs de digitación asignados."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
worker_id: str,
|
||||
token: str,
|
||||
poll_seconds: float = 8.0,
|
||||
staging_root: Optional[Path] = None,
|
||||
app_path: Optional[str] = None,
|
||||
date_format: str = "dd/mm/yyyy",
|
||||
delimiter: str = "|",
|
||||
log_func=None,
|
||||
):
|
||||
self.api = RemoteApiClient(api_url, token)
|
||||
self.worker_id = worker_id
|
||||
self.poll_seconds = poll_seconds
|
||||
self.staging_root = staging_root or Path(tempfile.gettempdir()) / "bot_digitador"
|
||||
self.app_path = app_path or os.getenv("DIGITADOR_APP_PATH") or DEFAULT_APP_PATH
|
||||
self.date_format = date_format
|
||||
self.delimiter = delimiter
|
||||
self.minio = WorkerMinioUploader(MinioSettings.from_env())
|
||||
self._running = True
|
||||
self._uploaded_keys: List[str] = []
|
||||
self._log = log_func or print
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def run_forever(self) -> None:
|
||||
self.api.register(self.worker_id)
|
||||
self._log(f"[worker {self.worker_id}] registrado — idle (esperando jobs de digitación)")
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
self.api.heartbeat(self.worker_id)
|
||||
job = self.api.claim_pending(self.worker_id)
|
||||
if job:
|
||||
self._run_job(job)
|
||||
else:
|
||||
time.sleep(self.poll_seconds)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._log(f"[worker {self.worker_id}] error en loop: {e}")
|
||||
time.sleep(self.poll_seconds)
|
||||
finally:
|
||||
self.api.close()
|
||||
self._log(f"[worker {self.worker_id}] detenido")
|
||||
|
||||
def _run_job(self, job: dict) -> None:
|
||||
job_id = job["id"]
|
||||
reports_prefix = (job.get("reports_prefix") or "").strip() or f"digitados/{job_id}/"
|
||||
self._uploaded_keys = []
|
||||
work_dir = self.staging_root / job_id
|
||||
out_dir = work_dir / "out"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
csv_name = Path(job.get("csv_key") or job.get("file_key") or "input.csv").name
|
||||
if not csv_name.lower().endswith(".csv"):
|
||||
csv_name = "input.csv"
|
||||
csv_path = work_dir / csv_name
|
||||
|
||||
self._log(f"[worker {self.worker_id}] job {job_id} iniciado (prefix={reports_prefix})")
|
||||
|
||||
try:
|
||||
self.api.download_file(job_id, csv_path)
|
||||
|
||||
def should_cancel() -> bool:
|
||||
try:
|
||||
return self.api.cancel_flag(job_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def on_event(event) -> None:
|
||||
self._log(event.display_message())
|
||||
payload = {
|
||||
"event_type": event.event_type,
|
||||
"message": event.message,
|
||||
"radicado": event.radicado,
|
||||
"index": event.index,
|
||||
"total": event.total,
|
||||
"metadata": event.metadata,
|
||||
"timestamp": event.timestamp,
|
||||
}
|
||||
try:
|
||||
self.api.post_event(job_id, payload)
|
||||
except Exception as e:
|
||||
self._log(f"[worker] no se pudo enviar evento: {e}")
|
||||
|
||||
date_format = job.get("date_format") or self.date_format
|
||||
delimiter = job.get("delimiter") or self.delimiter
|
||||
|
||||
service = DigitadorService(
|
||||
csv_path=str(csv_path),
|
||||
output_dir=str(out_dir),
|
||||
date_format=date_format,
|
||||
delimiter=delimiter,
|
||||
app_path=self.app_path,
|
||||
event_callback=on_event,
|
||||
should_cancel=should_cancel,
|
||||
)
|
||||
summary = service.run()
|
||||
|
||||
# En shards, nombres únicos bajo el prefijo compartido del padre
|
||||
# para no pisar RESULTADOS.csv / OMITIDOS.csv entre bots.
|
||||
shard_index = job.get("shard_index")
|
||||
shard_suffix = ""
|
||||
if shard_index is not None:
|
||||
try:
|
||||
shard_suffix = f"_shard_{int(shard_index) + 1}"
|
||||
except (TypeError, ValueError):
|
||||
shard_suffix = f"_shard_{shard_index}"
|
||||
|
||||
def remote_name(local_name: str) -> str:
|
||||
if not shard_suffix:
|
||||
return local_name
|
||||
stem = Path(local_name).stem
|
||||
suffix = Path(local_name).suffix
|
||||
return f"{stem}{shard_suffix}{suffix}"
|
||||
|
||||
for name in REPORT_FILES:
|
||||
local = out_dir / name
|
||||
if not local.exists():
|
||||
continue
|
||||
try:
|
||||
key = self.minio.upload_report(
|
||||
job_id,
|
||||
local,
|
||||
reports_prefix=reports_prefix,
|
||||
object_name=remote_name(name),
|
||||
)
|
||||
self._uploaded_keys.append(key)
|
||||
self._log(f"[worker] subido {key}")
|
||||
except Exception as e:
|
||||
self._log(f"[worker] error subiendo {name}: {e}")
|
||||
|
||||
try:
|
||||
self.minio.write_manifest(
|
||||
job_id,
|
||||
self._uploaded_keys,
|
||||
reports_prefix=reports_prefix,
|
||||
manifest_name=(
|
||||
f"_manifest{shard_suffix}.json" if shard_suffix else "_manifest.json"
|
||||
),
|
||||
extra={
|
||||
"exitosos": summary.exitosos,
|
||||
"fallidos": summary.fallidos,
|
||||
"existentes": summary.existentes,
|
||||
"omitidos": summary.omitidos,
|
||||
"timeouts": summary.timeouts,
|
||||
"shard_index": shard_index,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
self._log(f"[worker] manifest no escrito: {e}")
|
||||
|
||||
result = {
|
||||
"total": summary.total,
|
||||
"exitosos": summary.exitosos,
|
||||
"fallidos": summary.fallidos,
|
||||
"existentes": summary.existentes,
|
||||
"omitidos": summary.omitidos,
|
||||
"timeouts": summary.timeouts,
|
||||
"cancelled": summary.cancelled,
|
||||
"reports_prefix": reports_prefix,
|
||||
"uploaded_count": len(self._uploaded_keys),
|
||||
}
|
||||
|
||||
if summary.cancelled:
|
||||
status = JOB_CANCELLED
|
||||
error_msg = None
|
||||
elif summary.fatal_error:
|
||||
status = JOB_FAILED
|
||||
error_msg = summary.fatal_error
|
||||
elif summary.fallidos and summary.exitosos == 0 and summary.existentes == 0:
|
||||
status = JOB_FAILED
|
||||
error_msg = "Todos los radicados fallaron"
|
||||
else:
|
||||
status = JOB_COMPLETED
|
||||
error_msg = None
|
||||
|
||||
if error_msg:
|
||||
try:
|
||||
self.api.post_event(
|
||||
job_id,
|
||||
{
|
||||
"event_type": "warning",
|
||||
"message": error_msg,
|
||||
"metadata": {"fatal": bool(summary.fatal_error)},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.api.patch_job(
|
||||
job_id,
|
||||
status=status,
|
||||
result=result,
|
||||
error=error_msg,
|
||||
)
|
||||
self._log(f"[worker {self.worker_id}] job {job_id} → {status}")
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"[worker {self.worker_id}] job {job_id} error: {e}")
|
||||
try:
|
||||
self.api.post_event(
|
||||
job_id,
|
||||
{
|
||||
"event_type": "warning",
|
||||
"message": f"Error fatal: {e}",
|
||||
"metadata": {"fatal": True},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.api.patch_job(
|
||||
job_id,
|
||||
status=JOB_FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Cliente MinIO para subir reportes del digitador (solo salida)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MinioSettings:
|
||||
endpoint: str = "https://s3.builker.xyz"
|
||||
bucket: str = "pys-cuentas-medicas"
|
||||
access_key: str = ""
|
||||
secret_key: str = ""
|
||||
region: str = "us-east-1"
|
||||
anonymous: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "MinioSettings":
|
||||
access_key = (os.getenv("MINIO_ACCESS_KEY") or "").strip()
|
||||
secret_key = (os.getenv("MINIO_SECRET_KEY") or "").strip()
|
||||
anon_flag = (os.getenv("MINIO_ANONYMOUS") or "").strip().lower()
|
||||
if anon_flag in ("0", "false", "no"):
|
||||
anonymous = False
|
||||
elif anon_flag in ("1", "true", "yes"):
|
||||
anonymous = True
|
||||
else:
|
||||
anonymous = not (access_key and secret_key)
|
||||
return cls(
|
||||
endpoint=os.getenv("MINIO_ENDPOINT", "https://s3.builker.xyz").rstrip("/"),
|
||||
bucket=os.getenv("MINIO_BUCKET", "pys-cuentas-medicas"),
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
region=os.getenv("MINIO_REGION", "us-east-1"),
|
||||
anonymous=anonymous,
|
||||
)
|
||||
|
||||
|
||||
def report_key_from_prefix(reports_prefix: str, filename: str) -> str:
|
||||
prefix = (reports_prefix or "").strip().rstrip("/")
|
||||
if not prefix:
|
||||
raise ValueError("reports_prefix vacío")
|
||||
name = Path(filename).name
|
||||
return f"{prefix}/{name}"
|
||||
|
||||
|
||||
def report_key(job_id: str, filename: str) -> str:
|
||||
return f"digitados/{job_id}/{Path(filename).name}"
|
||||
|
||||
|
||||
def manifest_key_from_prefix(reports_prefix: str) -> str:
|
||||
prefix = (reports_prefix or "").strip().rstrip("/")
|
||||
return f"{prefix}/_manifest.json"
|
||||
|
||||
|
||||
def manifest_key(job_id: str) -> str:
|
||||
return f"digitados/{job_id}/_manifest.json"
|
||||
|
||||
|
||||
def _s3_client(settings: MinioSettings):
|
||||
import boto3
|
||||
from botocore import UNSIGNED
|
||||
from botocore.client import Config
|
||||
|
||||
s3_cfg = {
|
||||
"addressing_style": "path",
|
||||
"request_checksum_calculation": "when_required",
|
||||
"response_checksum_validation": "when_required",
|
||||
}
|
||||
if settings.anonymous:
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.endpoint,
|
||||
region_name=settings.region,
|
||||
config=Config(signature_version=UNSIGNED, s3=s3_cfg),
|
||||
)
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.endpoint,
|
||||
aws_access_key_id=settings.access_key,
|
||||
aws_secret_access_key=settings.secret_key,
|
||||
region_name=settings.region,
|
||||
config=Config(signature_version="s3v4", s3=s3_cfg),
|
||||
)
|
||||
|
||||
|
||||
class WorkerMinioUploader:
|
||||
"""PutObject de reportes CSV/log y manifest bajo digitados/."""
|
||||
|
||||
def __init__(self, settings: Optional[MinioSettings] = None):
|
||||
self.settings = settings or MinioSettings.from_env()
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is None:
|
||||
self._client = _s3_client(self.settings)
|
||||
return self._client
|
||||
|
||||
def object_exists(self, key: str) -> bool:
|
||||
try:
|
||||
self.client.head_object(Bucket=self.settings.bucket, Key=key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def upload_report(
|
||||
self,
|
||||
job_id: str,
|
||||
local_path: str | Path,
|
||||
*,
|
||||
reports_prefix: str | None = None,
|
||||
object_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> str:
|
||||
path = Path(local_path)
|
||||
filename = object_name or path.name
|
||||
if reports_prefix:
|
||||
key = report_key_from_prefix(reports_prefix, filename)
|
||||
else:
|
||||
key = report_key(job_id, filename)
|
||||
|
||||
ctype = content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
self.client.put_object(
|
||||
Bucket=self.settings.bucket,
|
||||
Key=key,
|
||||
Body=path.read_bytes(),
|
||||
ContentType=ctype,
|
||||
)
|
||||
return key
|
||||
|
||||
def write_manifest(
|
||||
self,
|
||||
job_id: str,
|
||||
keys: List[str],
|
||||
*,
|
||||
reports_prefix: str | None = None,
|
||||
manifest_name: str = "_manifest.json",
|
||||
extra: Optional[dict] = None,
|
||||
) -> str:
|
||||
if reports_prefix:
|
||||
key = report_key_from_prefix(reports_prefix, manifest_name)
|
||||
else:
|
||||
key = report_key(job_id, manifest_name)
|
||||
payload = {
|
||||
"job_id": job_id,
|
||||
"reports_prefix": reports_prefix or f"digitados/{job_id}/",
|
||||
"files": keys,
|
||||
"count": len(keys),
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
self.client.put_object(
|
||||
Bucket=self.settings.bucket,
|
||||
Key=key,
|
||||
Body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
ContentType="application/json",
|
||||
)
|
||||
return key
|
||||
@@ -0,0 +1,64 @@
|
||||
"""CLI del agent worker digitador: idle hasta que el usuario asigne un job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH
|
||||
from bot_digitador.worker.agent import WorkerAgent
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Agent BotDigitador: registra el PC y espera jobs de digitación."
|
||||
)
|
||||
p.add_argument(
|
||||
"--api-url",
|
||||
default=os.getenv("BOT_API_URL", "http://localhost:8189/bot-digitador"),
|
||||
help="URL del API vía gateway (default: …/bot-digitador)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--worker-id",
|
||||
required=True,
|
||||
help="Identificador único de este PC (ej. PC-DIGITADOR-01)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--token",
|
||||
default=os.getenv("BOT_API_TOKEN", "dev-token-change-me"),
|
||||
help="Bearer token (BOT_API_TOKEN)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--app-path",
|
||||
default=os.getenv("DIGITADOR_APP_PATH", DEFAULT_APP_PATH),
|
||||
help="Ruta al acceso directo / ejecutable de Integración Total",
|
||||
)
|
||||
p.add_argument(
|
||||
"--poll",
|
||||
type=float,
|
||||
default=8.0,
|
||||
help="Segundos entre heartbeat/claim",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
agent = WorkerAgent(
|
||||
api_url=args.api_url,
|
||||
worker_id=args.worker_id,
|
||||
token=args.token,
|
||||
poll_seconds=args.poll,
|
||||
app_path=args.app_path,
|
||||
)
|
||||
try:
|
||||
agent.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nDetenido por usuario")
|
||||
agent.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,106 @@
|
||||
"""CLI local del Bot Digitador (sin API remota)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from bot_digitador.core.coords import DEFAULT_APP_PATH
|
||||
from bot_digitador.core.digitador_service import DigitadorService
|
||||
from bot_digitador.models.events import DigitadorEvent
|
||||
|
||||
|
||||
def _print_event(event: DigitadorEvent) -> None:
|
||||
print(event.display_message(), flush=True)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Digita radicados en Integración Total desde un CSV local."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv",
|
||||
required=True,
|
||||
help="Ruta al CSV de entrada (coma o pipe)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=None,
|
||||
help="Carpeta de reportes (default: <csv_dir>/reportes_bot/<timestamp>)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delimiter",
|
||||
choices=[",", "|"],
|
||||
default="|",
|
||||
help="Delimitador CSV (se auto-detecta si no coincide con columnas)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date-format",
|
||||
choices=["dd/mm/yyyy", "mm/dd/yyyy"],
|
||||
default="dd/mm/yyyy",
|
||||
help="Formato preferido de fechas en el CSV",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--app-path",
|
||||
default=os.getenv("DIGITADOR_APP_PATH", DEFAULT_APP_PATH),
|
||||
help="Ruta al acceso directo / ejecutable de Integración Total",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reinicio-cada",
|
||||
type=int,
|
||||
default=6,
|
||||
help="Reinicio completo de la app cada N registros",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
csv_path = Path(args.csv).resolve()
|
||||
if not csv_path.exists():
|
||||
print(f"CSV no encontrado: {csv_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.output_dir:
|
||||
output_dir = Path(args.output_dir).resolve()
|
||||
else:
|
||||
from datetime import datetime
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_dir = csv_path.parent / "reportes_bot" / stamp
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
service = DigitadorService(
|
||||
csv_path=str(csv_path),
|
||||
output_dir=str(output_dir),
|
||||
date_format=args.date_format,
|
||||
delimiter=args.delimiter,
|
||||
app_path=args.app_path,
|
||||
reinicio_cada=args.reinicio_cada,
|
||||
event_callback=_print_event,
|
||||
)
|
||||
summary = service.run()
|
||||
|
||||
print(
|
||||
f"Resumen: OK={summary.exitosos}/{summary.total}, "
|
||||
f"fallidos={summary.fallidos}, existentes={summary.existentes}, "
|
||||
f"omitidos={summary.omitidos}, timeouts={summary.timeouts}, "
|
||||
f"cancelled={summary.cancelled}",
|
||||
flush=True,
|
||||
)
|
||||
print(f"Reportes en: {summary.output_dir}", flush=True)
|
||||
|
||||
if summary.fatal_error:
|
||||
print(f"Error fatal: {summary.fatal_error}", file=sys.stderr)
|
||||
return 1
|
||||
if summary.cancelled:
|
||||
return 130
|
||||
return 0 if summary.fallidos == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
httpx>=0.27.0
|
||||
boto3>=1.34.0
|
||||
pywinauto>=0.6.8
|
||||
opencv-python-headless>=4.8.0
|
||||
numpy>=1.24.0
|
||||
customtkinter>=5.2.0
|
||||
python-dotenv>=1.0.0
|
||||
Pillow>=10.0.0
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Punto de entrada de la interfaz gráfica del bot digitador."""
|
||||
|
||||
from bot_digitador.ui.dashboard import BotDashboard
|
||||
|
||||
|
||||
def main():
|
||||
app = BotDashboard()
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
# Plantillas OpenCV
|
||||
|
||||
Copia aquí los PNG necesarios desde la carpeta `template/` del script original
|
||||
(`bot_saludtotal.py` / proyecto de digitación):
|
||||
|
||||
- `error.png`
|
||||
- `success.png`
|
||||
- `bien.png`
|
||||
- `flecha_diferencias_abierto.png`
|
||||
- `flecha_diferencias_cerrado.png`
|
||||
- `fecha_vacia_slash.png`
|
||||
- `fecha_vacia_blanco.png`
|
||||
- `popup_nombres.png`
|
||||
- `radicado_existente.png`
|
||||
|
||||
Sin estos archivos el bot no puede validar pantallas de Integración Total.
|
||||
Reference in New Issue
Block a user