se completan los horarios, se fijan las rutas al código y se muestra la versión
- HORARIOS cubre las 24 horas: se agregan 21:30-24:00 (cada 60 min) y 05:00-06:30 (cada 30 min); antes el demonio dormía hasta las 06:30 en esos tramos. Se corrigen los comentarios, que no coincidían con los valores. tests/test_horarios.py garantiza que no haya huecos ni solapes. - La carpeta de resultados se resuelve junto al código, no relativa al cwd (un servicio de Windows puede arrancar desde cualquier carpeta). - copiar_a_red no crea carpetas locales cuando RUTA_RED_FACTURACION no es una ruta absoluta en el sistema operativo (una UNC fuera de Windows); antes el draft podía referenciar archivos que no estaban en el recurso compartido. - El log muestra la versión del proyecto (pyproject.toml) al arrancar. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c57988f54
commit
fcce30eda6
7 files changed
+128
-10
No files matched your search
+6
-4
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
main.py
|
main.py
|
||||||
├── Constantes: HORARIOS (5 bloques horarios)
|
├── Constantes: HORARIOS (7 bloques que cubren las 24 horas)
|
||||||
├── obtener_intervalo(dt) → (intervalo, modo) | None
|
├── obtener_intervalo(dt) → (intervalo, modo) | None
|
||||||
├── segundos_hasta_proximo_inicio(dt) → int
|
├── segundos_hasta_proximo_inicio(dt) → int
|
||||||
├── non_negative_int(value) → int (validador argparse)
|
├── non_negative_int(value) → int (validador argparse)
|
||||||
@@ -69,7 +69,7 @@ main()
|
|||||||
│ │
|
│ │
|
||||||
│ └── ¿No está en horario?
|
│ └── ¿No está en horario?
|
||||||
│ │
|
│ │
|
||||||
│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente)
|
│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente; rama inalcanzable mientras los bloques cubran el día)
|
||||||
│ └── Log "Fuera del horario, próxima ejecución en..."
|
│ └── Log "Fuera del horario, próxima ejecución en..."
|
||||||
│ │
|
│ │
|
||||||
│ └── 3.3. time.sleep(dormir) → vuelve a 3.1
|
│ └── 3.3. time.sleep(dormir) → vuelve a 3.1
|
||||||
@@ -81,13 +81,15 @@ main()
|
|||||||
|
|
||||||
| Desde | Hasta | Intervalo | Modo |
|
| Desde | Hasta | Intervalo | Modo |
|
||||||
|---------|---------|-----------|----------|
|
|---------|---------|-----------|----------|
|
||||||
|
| 00:00 | 05:00 | 60s | rápido |
|
||||||
|
| 05:00 | 06:30 | 1800s | full |
|
||||||
| 06:30 | 13:15 | 300s | rápido |
|
| 06:30 | 13:15 | 300s | rápido |
|
||||||
| 13:15 | 14:00 | 1800s | full |
|
| 13:15 | 14:00 | 1800s | full |
|
||||||
| 14:00 | 18:30 | 300s | rápido |
|
| 14:00 | 18:30 | 300s | rápido |
|
||||||
| 18:30 | 21:30 | 3600s | full |
|
| 18:30 | 21:30 | 3600s | full |
|
||||||
| 00:00 | 05:00 | 60s | rápido |
|
| 21:30 | 24:00 | 3600s | full |
|
||||||
|
|
||||||
Nota: entre las 21:30 y las 00:00, y entre las 05:00 y las 06:30, ningún bloque cubre esos minutos. En esos huecos `obtener_intervalo()` devuelve `None` y `segundos_hasta_proximo_inicio()` calcula el tiempo de espera hasta las 06:30 del día siguiente (no hasta el bloque de las 00:00), por lo que el demonio se queda dormido hasta las 06:30 aunque exista un bloque configurado a las 00:00.
|
Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba `tests/test_horarios.py`). El modo (`rápido`/`full`) solo se registra en el log: `run_pipeline` se ejecuta igual en ambos. Antes de la versión 1.2.0 los tramos 21:30–00:00 y 05:00–06:30 no tenían bloque y el demonio dormía hasta las 06:30.
|
||||||
|
|
||||||
## Entry point (línea 126-134)
|
## Entry point (línea 126-134)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ def copiar_a_red(data: dict) -> None:
|
|||||||
|
|
||||||
if not RUTA_RED_FACTURACION:
|
if not RUTA_RED_FACTURACION:
|
||||||
return
|
return
|
||||||
|
if not os.path.isabs(RUTA_RED_FACTURACION):
|
||||||
|
# Fuera de Windows una ruta UNC (\\\\servidor\\recurso) no es absoluta: makedirs la crearía como una carpeta
|
||||||
|
# local con ese nombre, y el draft apuntaría a archivos que no están en el recurso compartido.
|
||||||
|
log("⚠️ RUTA_RED_FACTURACION no es una ruta absoluta en este sistema operativo: no se copia a la red", 3,
|
||||||
|
severity=logging.WARNING)
|
||||||
|
return
|
||||||
|
|
||||||
ruc_emisor = data.get("ruc_emisor", "")
|
ruc_emisor = data.get("ruc_emisor", "")
|
||||||
tipo = data.get("tipo", "")
|
tipo = data.get("tipo", "")
|
||||||
|
|||||||
@@ -8,17 +8,22 @@ from dotenv import load_dotenv
|
|||||||
import logger_manager
|
import logger_manager
|
||||||
from config_validator import verificar_configuracion
|
from config_validator import verificar_configuracion
|
||||||
from pipeline import run_pipeline
|
from pipeline import run_pipeline
|
||||||
|
from pipeline_utils import version_del_proyecto
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
# (hora inicio, min inicio, hora fin, min fin, intervalo en segundos, modo). El fin no se incluye.
|
||||||
|
# Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba tests/test_horarios.py).
|
||||||
HORARIOS = [
|
HORARIOS = [
|
||||||
|
(0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 60 segundos
|
||||||
|
(5, 0, 6, 30, 1800, "full"), # 05:00 - 06:30 -> cada 30 minutos
|
||||||
(6, 30, 13, 15, 300, "rapido"), # 06:30 - 13:15 -> cada 5 minutos
|
(6, 30, 13, 15, 300, "rapido"), # 06:30 - 13:15 -> cada 5 minutos
|
||||||
(13, 15, 14, 0, 1800, "full"), # 13:15 - 14:00 -> cada 30 minutos
|
(13, 15, 14, 0, 1800, "full"), # 13:15 - 14:00 -> cada 30 minutos
|
||||||
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 17:30 -> cada 5 minutos
|
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 18:30 -> cada 5 minutos
|
||||||
(18, 30, 21, 30, 3600, "full"), # 17:30 - 21:30 -> cada 60 minutos
|
(18, 30, 21, 30, 3600, "full"), # 18:30 - 21:30 -> cada 60 minutos
|
||||||
(0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 5 minutos
|
(21, 30, 24, 0, 3600, "full"), # 21:30 - 24:00 -> cada 60 minutos
|
||||||
]
|
]
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
|
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
|
||||||
@@ -130,7 +135,7 @@ if __name__ == "__main__":
|
|||||||
logger_manager.setup_logging()
|
logger_manager.setup_logging()
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S}")
|
logger.info(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S} (versión {version_del_proyecto()})")
|
||||||
logger.info(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
|
logger.info(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import sunat_schema
|
import sunat_schema
|
||||||
import pdf_utilities
|
import pdf_utilities
|
||||||
from pipeline_utils import log
|
from pipeline_utils import log, ruta_carpeta
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -34,6 +34,7 @@ def guardar_resultado(documentos: list[dict], datos_pdf: list[dict], output_fold
|
|||||||
"""
|
"""
|
||||||
if not documentos:
|
if not documentos:
|
||||||
return []
|
return []
|
||||||
|
output_folder = ruta_carpeta(output_folder) # relativa -> junto al código; absoluta -> tal cual
|
||||||
if not os.path.exists(output_folder):
|
if not os.path.exists(output_folder):
|
||||||
os.makedirs(output_folder, exist_ok=True)
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
@@ -154,6 +155,7 @@ def guardar_draft_json(draft: dict, serie: str, numero: str, output_folder: str)
|
|||||||
guardar_draft_json(draft, "F002", "486", output_folder)
|
guardar_draft_json(draft, "F002", "486", output_folder)
|
||||||
# → Crea draft_sapb1_F002-486_2026-07-08_12-00-00.json
|
# → Crea draft_sapb1_F002-486_2026-07-08_12-00-00.json
|
||||||
"""
|
"""
|
||||||
|
output_folder = ruta_carpeta(output_folder)
|
||||||
if not os.path.exists(output_folder):
|
if not os.path.exists(output_folder):
|
||||||
os.makedirs(output_folder, exist_ok=True)
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import tomllib
|
||||||
|
|
||||||
logger = logging.getLogger("pipeline")
|
logger = logging.getLogger("pipeline")
|
||||||
|
|
||||||
@@ -25,3 +26,13 @@ def log(msg: str, level: int = 1, last: bool = False, severity: int = logging.IN
|
|||||||
branch = " └── " if last else " ├── "
|
branch = " └── " if last else " ├── "
|
||||||
pfx = {1: branch, 2: f" │ {branch}", 3: f" │ │ {branch}", 4: f" │ │ │ {branch}"}.get(level, branch)
|
pfx = {1: branch, 2: f" │ {branch}", 3: f" │ │ {branch}", 4: f" │ │ │ {branch}"}.get(level, branch)
|
||||||
logger.log(severity, f"{pfx}{msg}")
|
logger.log(severity, f"{pfx}{msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def version_del_proyecto() -> str:
|
||||||
|
"""Versión de ``pyproject.toml`` (por ejemplo ``1.2.0``), o ``desconocida`` si no se puede leer."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "pyproject.toml"), "rb") as f:
|
||||||
|
return tomllib.load(f)["project"]["version"]
|
||||||
|
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
||||||
|
return "desconocida"
|
||||||
|
|
||||||
@@ -92,6 +92,21 @@ def test_copiar_a_red_deshabilitada_si_no_hay_ruta(tmp_path, monkeypatch):
|
|||||||
assert "ruta_red_xml" not in data and "ruta_red_pdf" not in data
|
assert "ruta_red_xml" not in data and "ruta_red_pdf" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_copiar_a_red_con_una_ruta_que_no_es_absoluta_no_crea_carpetas_ni_apunta_el_draft_a_la_nada(tmp_path, monkeypatch, caplog):
|
||||||
|
"""Fuera de Windows una ruta UNC no es absoluta: antes se creaba como una carpeta local con ese nombre."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.setenv("RUTA_RED_FACTURACION", "red_relativa")
|
||||||
|
monkeypatch.setenv("RUC_EMPRESA", "20999")
|
||||||
|
data = _comprobante(tmp_path)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="pipeline"):
|
||||||
|
documentos.copiar_a_red(data)
|
||||||
|
|
||||||
|
assert not (tmp_path / "red_relativa").exists() # no se creó nada
|
||||||
|
assert "ruta_red_xml" not in data and "ruta_red_pdf" not in data # el draft no referencia archivos inexistentes
|
||||||
|
assert any("no es una ruta absoluta" in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
def test_copiar_a_red_fecha_invalida_no_falla(tmp_path, monkeypatch):
|
def test_copiar_a_red_fecha_invalida_no_falla(tmp_path, monkeypatch):
|
||||||
red = tmp_path / "red"
|
red = tmp_path / "red"
|
||||||
monkeypatch.setenv("RUTA_RED_FACTURACION", str(red))
|
monkeypatch.setenv("RUTA_RED_FACTURACION", str(red))
|
||||||
@@ -121,3 +136,45 @@ def test_filtrar_sin_pdf_ni_xml_borra_todo(tmp_path):
|
|||||||
|
|
||||||
assert adjuntos.filtrar_y_limpiar(email, str(tmp_path)) is False
|
assert adjuntos.filtrar_y_limpiar(email, str(tmp_path)) is False
|
||||||
assert os.listdir(tmp_path) == []
|
assert os.listdir(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Rutas: no dependen del directorio de trabajo
|
||||||
|
# ============================================================================
|
||||||
|
def test_la_base_de_datos_va_junto_al_codigo_aunque_se_lance_desde_otra_carpeta(tmp_path, monkeypatch):
|
||||||
|
import importlib.util
|
||||||
|
import database_utilities
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path) # un servicio puede arrancar desde cualquier carpeta
|
||||||
|
spec = importlib.util.spec_from_file_location("database_utilities_nueva", database_utilities.__file__)
|
||||||
|
nueva = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(nueva) # DB_NAME se calcula al importar
|
||||||
|
|
||||||
|
carpeta_del_codigo = os.path.dirname(os.path.abspath(database_utilities.__file__))
|
||||||
|
assert nueva.DB_NAME == os.path.join(carpeta_del_codigo, "emails.db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ruta_carpeta_no_depende_del_directorio_actual(tmp_path, monkeypatch):
|
||||||
|
import pipeline_utils
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
esperado = os.path.join(os.path.dirname(os.path.abspath(pipeline_utils.__file__)), "resultado")
|
||||||
|
assert pipeline_utils.ruta_carpeta("resultado") == esperado
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_resultados_relativos_se_guardan_con_ruta_carpeta_no_en_el_cwd(tmp_path, monkeypatch):
|
||||||
|
import persistencia
|
||||||
|
proyecto, otro = tmp_path / "proyecto", tmp_path / "otro"
|
||||||
|
proyecto.mkdir(), otro.mkdir()
|
||||||
|
monkeypatch.chdir(otro)
|
||||||
|
monkeypatch.setattr(persistencia, "ruta_carpeta", lambda r: os.path.join(proyecto, r))
|
||||||
|
|
||||||
|
persistencia.guardar_draft_json({"a": 1}, "F002", "1", "resultado")
|
||||||
|
|
||||||
|
assert any(n.startswith("draft_sapb1_F002-1") for n in os.listdir(proyecto / "resultado"))
|
||||||
|
assert os.listdir(otro) == [] # nada en el directorio desde el que se lanzó
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_carpeta_de_resultados_absoluta_se_respeta(tmp_path):
|
||||||
|
import persistencia
|
||||||
|
persistencia.guardar_draft_json({"a": 1}, "F002", "2", str(tmp_path / "salida"))
|
||||||
|
assert any(n.startswith("draft_sapb1_F002-2") for n in os.listdir(tmp_path / "salida"))
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import main
|
||||||
|
import pipeline_utils
|
||||||
|
|
||||||
|
|
||||||
|
def _intervalo(hora, minuto):
|
||||||
|
return main.obtener_intervalo(datetime(2026, 10, 22, hora, minuto))
|
||||||
|
|
||||||
|
|
||||||
|
def test_cada_minuto_del_dia_pertenece_a_un_bloque_y_solo_uno():
|
||||||
|
"""Sin huecos (el demonio dormiría hasta las 06:30) ni solapes."""
|
||||||
|
for minuto_del_dia in range(24 * 60):
|
||||||
|
bloques = [b for b in main.HORARIOS if b[0] * 60 + b[1] <= minuto_del_dia < b[2] * 60 + b[3]]
|
||||||
|
assert len(bloques) == 1, f"{minuto_del_dia // 60:02d}:{minuto_del_dia % 60:02d} -> {len(bloques)} bloques"
|
||||||
|
assert _intervalo(minuto_del_dia // 60, minuto_del_dia % 60) == (bloques[0][4], bloques[0][5])
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_bloques_estan_ordenados_y_se_encadenan():
|
||||||
|
fines = [b[2] * 60 + b[3] for b in main.HORARIOS]
|
||||||
|
inicios = [b[0] * 60 + b[1] for b in main.HORARIOS]
|
||||||
|
assert inicios[0] == 0 and fines[-1] == 24 * 60
|
||||||
|
assert inicios[1:] == fines[:-1] # cada bloque empieza donde termina el anterior
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_huecos_de_antes_ahora_tienen_horario():
|
||||||
|
assert _intervalo(21, 29) == (3600, "full") and _intervalo(21, 30) == (3600, "full")
|
||||||
|
assert _intervalo(23, 59) == (3600, "full")
|
||||||
|
assert _intervalo(5, 0) == (1800, "full") and _intervalo(6, 29) == (1800, "full")
|
||||||
|
assert _intervalo(6, 30) == (300, "rapido")
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_version_del_proyecto_sale_de_pyproject():
|
||||||
|
version = pipeline_utils.version_del_proyecto()
|
||||||
|
assert version != "desconocida" and version.count(".") == 2
|
||||||
Reference in new issue
Block a user