diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a79fb96 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1.4 + +# Usa una imagen base de Python +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + APP_HOME="/app" + + +WORKDIR ${APP_HOME} + +# Instalar librerías cliente Firebird +RUN apt-get update +RUN apt-get install libfbclient2 -y + +# Copiamos solo lo necesario para instalar el paquete +COPY pyproject.toml setup.cfg README.md ./ +COPY app ./app + +RUN pip install --no-cache-dir . + +# Copiar enviroment (se sobreescribe en compose) +#COPY enviroment/ ./enviroment + +# Volumen para logs persistentes +#VOLUME ["/app/logs"] + +# Entrypoint genérico +#CMD ["python", "-m", "sync_factuges_main"] +CMD ["factuges-sync", "all"] diff --git a/app/cli.py b/app/cli.py index 2679221..d49c57d 100644 --- a/app/cli.py +++ b/app/cli.py @@ -3,42 +3,65 @@ # Permite: # factuges-sync factuges # factuges-sync verifactu +# factuges-sync all +# +# También por variable de entorno: # SYNC_MODE=factuges factuges-sync import argparse import os import subprocess import sys +from typing import Literal, Optional + +Mode = Literal["factuges", "verifactu", "all"] -def main(): +def run_module(module: str) -> None: + """Lanza un módulo Python como subproceso y falla en caso de error.""" + subprocess.run([sys.executable, "-m", module], check=True) + + +def resolve_mode(arg_mode: Optional[str]) -> Mode: + """Resuelve el modo desde CLI o variable de entorno.""" + mode = arg_mode or os.getenv("SYNC_MODE") + + valid_modes: tuple[Mode, ...] = ("factuges", "verifactu", "all") + if mode not in valid_modes: + print( + "Error: debes indicar modo: 'factuges', 'verifactu' " + "o 'all' para ejecutar ambos" + ) + sys.exit(1) + + return mode # type: ignore[return-value] + + +def main() -> None: """Selector de modos de sincronización.""" parser = argparse.ArgumentParser(description="Factuges Sync Dispatcher") parser.add_argument( "mode", nargs="?", - choices=["factuges", "verifactu"], + choices=["factuges", "verifactu", "all"], help="Modo de sincronización", ) args = parser.parse_args() - mode = args.mode or os.getenv("SYNC_MODE") + mode = resolve_mode(args.mode) - if mode not in ("factuges", "verifactu"): - print("Error: debes indicar modo: 'factuges' o 'verifactu'") - sys.exit(1) - - if os.getenv("ENV") == "developement": + if os.getenv("ENV") == "development": print("Running in development mode (no docker)") - module = ( - "app.sync_factuges_main" - if mode == "factuges" - else "app.sync_verifactu_main" - ) - - # Ejecuta el módulo Python correspondiente - subprocess.run([sys.executable, "-m", module], check=True) + if mode == "factuges": + run_module("app.sync_factuges_main") + elif mode == "verifactu": + run_module("app.sync_verifactu_main") + else: # mode == "all" + # Primero sincroniza FactuGES, luego Verifactu. + # Si la primera falla, el proceso termina por el check=True. + run_module("app.sync_factuges_main") + run_module("app.sync_verifactu_main") if __name__ == "__main__": diff --git a/app/config/settings.py b/app/config/settings.py index 75a705c..f05d67e 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -1,10 +1,11 @@ import os -from os.path import join, dirname -from typing import Any, Dict, Optional -from .setup_logger import logger +from os.path import dirname, join +from typing import Any, Dict from dotenv import load_dotenv +from .setup_logger import logger + def _required(name: str) -> str: """ @@ -43,7 +44,7 @@ def load_config() -> Dict[str, Any]: # Opcionales (con valor por defecto) "ENV": env, "LOCAL_TZ": os.getenv("LOCAL_TZ", "Europe/Madrid"), - "LAST_RUN_PATH": _required("LAST_RUN_PATH"), + "STATE_PATH": _required("STATE_PATH"), # FACTUGES (requeridas) "FACTUGES_HOST": _required("FACTUGES_HOST"), diff --git a/app/config/setup_logger.py b/app/config/setup_logger.py index 4e3af06..da90aa7 100644 --- a/app/config/setup_logger.py +++ b/app/config/setup_logger.py @@ -1,49 +1,68 @@ +# app/logger.py + +from __future__ import annotations + import logging -import sys import os +import sys from logging.handlers import RotatingFileHandler from pathlib import Path +from typing import Optional, Union def create_logger( name: str = "factuges-sync", *, level: int = logging.INFO, - log_path: str | None = None, + log_path: Optional[Union[str, Path]] = None, + max_bytes: int = 5_000_000, # rotación opcional + backup_count: int = 3, ) -> logging.Logger: """ - Crea un logger: - - SIEMPRE stdout (Docker-friendly) - - SOLO EN PRODUCCIÓN añade RotatingFileHandler si log_path no es None + Crea un logger consistente para FactuGES Sync. + + Reglas: + - SIEMPRE envia logs a stdout (Docker-friendly). + - SOLO en producción escribe también a fichero si `log_path` está definido. + - `log_path` puede ser `str` o `Path`. + - Evita duplicar handlers. """ logger = logging.getLogger(name) logger.setLevel(level) - # No duplicar handlers si ya existe el logger + # Si ya está configurado, no duplicamos handlers if logger.handlers: return logger - formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s" + ) - # ------------------------------------------------------------------ - # 1) Consola → SIEMPRE (para docker logs) - # ------------------------------------------------------------------ + # ------------------------------ + # 1) Handler de consola (siempre) + # ------------------------------ h_console = logging.StreamHandler(sys.stdout) h_console.setFormatter(formatter) logger.addHandler(h_console) - # ------------------------------------------------------------------ - # 2) Fichero → SOLO en prod + si se define log_path - # ------------------------------------------------------------------ - environment = os.getenv("ENV", "development") - if environment == "production" and log_path: - Path(log_path).parent.mkdir(parents=True, exist_ok=True) + # ------------------------------ + # 2) Handler de fichero (solo prod) + # ------------------------------ + environment = os.getenv("ENV", "development").lower() + + if log_path and environment in ("production", "prod"): + p = Path(log_path) + + # Aseguramos directorios + p.parent.mkdir(parents=True, exist_ok=True) + + # Puedes usar FileHandler simple, pero Rotating es más seguro. h_file = RotatingFileHandler( - log_path, - maxBytes=5 * 1024 * 1024, - backupCount=15, - encoding="utf8", + filename=str(p), + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8", ) h_file.setFormatter(formatter) logger.addHandler(h_file) diff --git a/app/sync_factuges_main.py b/app/sync_factuges_main.py index 914ecca..2b6428c 100644 --- a/app/sync_factuges_main.py +++ b/app/sync_factuges_main.py @@ -1,11 +1,12 @@ import sys - -from app.config import get_package_version from datetime import datetime +from pathlib import Path + from dateutil import tz -from app.config import create_logger, load_config, log_config -from app.db import get_mysql_connection, get_factuges_connection, sync_invoices_factuges -from app.utils import obtener_fecha_ultima_ejecucion, actualizar_fecha_ultima_ejecucion + +from app.config import create_logger, get_package_version, load_config, log_config +from app.db import get_factuges_connection, get_mysql_connection, sync_invoices_factuges +from app.utils import actualizar_fecha_ultima_ejecucion, obtener_fecha_ultima_ejecucion def main(): @@ -14,10 +15,14 @@ def main(): version = get_package_version() local_tz = tz.gettz(config['LOCAL_TZ']) + state_path = Path(config["STATE_PATH"]) + # Logging + log_dir = state_path / "logs" + log_dir.mkdir(parents=True, exist_ok=True) logger = create_logger( name="factuges-sync", - log_path="/app/logs/sync_factuges.log", # Solo lo genera en producción + log_path=log_dir / "sync_factuges.log", # Solo lo genera en producción ) logger.info("============================================================") @@ -28,11 +33,12 @@ def main(): log_config(config) + state_file = state_path / "factuges_last.ini" conn_factuges = None conn_mysql = None try: # Obtener la fecha de la última ejecución del programa - last_execution_date_utc = obtener_fecha_ultima_ejecucion(config['LAST_RUN_PATH']) + last_execution_date_utc = obtener_fecha_ultima_ejecucion(state_file) last_execution_date_local_tz = last_execution_date_utc.astimezone( tz=local_tz).strftime("%Y-%m-%d %H:%M:%S") @@ -47,7 +53,7 @@ def main(): # Sincronizamos logger.info( - f">>>>>>>>>>> INI Sync invoices FactuGES escritorio to FactuGES web") + ">>>>>>>>>>> INI Sync invoices FactuGES escritorio to FactuGES web") sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date_local_tz) # Confirmar los cambios @@ -55,9 +61,9 @@ def main(): conn_factuges.commit() conn_factuges.close() conn_mysql.close() - logger.info(f">>>>>>>>>>> FIN Sync invoices FactuGES escritorio to FactuGES web") + logger.info(">>>>>>>>>>> FIN Sync invoices FactuGES escritorio to FactuGES web") - actualizar_fecha_ultima_ejecucion(config['LAST_RUN_PATH']) + actualizar_fecha_ultima_ejecucion(state_file) # Enviar email # send_orders_mail(inserted_orders) diff --git a/app/sync_verifactu_main.py b/app/sync_verifactu_main.py index 621bbd5..8fca565 100644 --- a/app/sync_verifactu_main.py +++ b/app/sync_verifactu_main.py @@ -1,11 +1,12 @@ import sys - -from app.config import get_package_version from datetime import datetime +from pathlib import Path + from dateutil import tz -from app.config import create_logger, load_config, log_config -from app.db import get_mysql_connection, get_factuges_connection, sync_invoices_verifactu -from app.utils import obtener_fecha_ultima_ejecucion, actualizar_fecha_ultima_ejecucion + +from app.config import create_logger, get_package_version, load_config, log_config +from app.db import get_mysql_connection, sync_invoices_verifactu +from app.utils import actualizar_fecha_ultima_ejecucion, obtener_fecha_ultima_ejecucion def main(): @@ -15,10 +16,14 @@ def main(): version = get_package_version() local_tz = tz.gettz(config['LOCAL_TZ']) + state_path = Path(config["STATE_PATH"]) + # Logging + log_dir = state_path / "logs" + log_dir.mkdir(parents=True, exist_ok=True) logger = create_logger( name="factuges-sync", - log_path="/app/logs/sync_verifactu.log", # Solo lo genera en producción + log_path=log_dir / "sync_verifactu.log", # Solo lo genera en producción ) logger.info("============================================================") @@ -29,11 +34,12 @@ def main(): log_config(config) + state_file = Path(config["STATE_PATH"]) / "verifactu_last.ini" conn_factuges = None conn_mysql = None try: # Obtener la fecha de la última ejecución del programa - last_execution_date_utc = obtener_fecha_ultima_ejecucion() + last_execution_date_utc = obtener_fecha_ultima_ejecucion(state_file) last_execution_date_local_tz = last_execution_date_utc.astimezone( tz=local_tz).strftime("%Y-%m-%d %H:%M:%S") @@ -47,13 +53,13 @@ def main(): # Sync Verifactu logger.info( - f">>>>>>>>>> INI Sync facturas emitidas to Verifactu") + ">>>>>>>>>> INI Sync facturas emitidas to Verifactu") sync_invoices_verifactu(conn_mysql, last_execution_date_local_tz) conn_mysql.commit() conn_mysql.close() - logger.info(f">>>>>>>>>> FIN Sync facturas emitidas to Verifactu") + logger.info(">>>>>>>>>> FIN Sync facturas emitidas to Verifactu") - actualizar_fecha_ultima_ejecucion() + actualizar_fecha_ultima_ejecucion(state_file) # Enviar email # send_orders_mail(inserted_orders) diff --git a/app/utils/last_execution_helper.py b/app/utils/last_execution_helper.py index 215eab1..45a79c3 100644 --- a/app/utils/last_execution_helper.py +++ b/app/utils/last_execution_helper.py @@ -2,74 +2,68 @@ from __future__ import annotations from datetime import datetime, timezone from pathlib import Path -import os from typing import Optional - -DEFAULT_PATH = Path("last_execution.txt") -DEFAULT_FALLBACK = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) -FMT = "%Y-%m-%d %H:%M:%S" +# Fecha por defecto si nunca se ha ejecutado +DEFAULT_FALLBACK = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +FMT = "%Y-%m-%d %H:%M:%S" # Formato persistido def obtener_fecha_ultima_ejecucion( - path: str = DEFAULT_PATH, + path: str | Path, *, fallback: Optional[datetime] = None, ) -> datetime: """ - Lee la última fecha de ejecución desde `path` y la devuelve como aware (UTC). + Lee la última fecha de ejecución almacenada en `path`. + Retorna always-aware UTC. - - Si el fichero no existe o el contenido es inválido, devuelve `fallback`. - - Si `fallback` es None, usa DEFAULT_FALLBACK (2024-01-01T00:00:00Z). + - Si el archivo no existe → fallback + - Si está vacío o el formato es inválido → fallback + + fallback predeterminado: DEFAULT_FALLBACK (2025-01-01) """ - # Comentario: fallback explícito para evitar lógica duplicada en llamadas effective_fallback = fallback or DEFAULT_FALLBACK - - if path is None: - return effective_fallback - - # 2. Convertimos str -> Path - path = Path(path) + p = Path(path) try: - text = path.read_text(encoding="utf-8").strip() + text = p.read_text(encoding="utf-8").strip() if not text: - # Comentario: fichero vacío -> usamos fallback return effective_fallback dt_naive = datetime.strptime(text, FMT) return dt_naive.replace(tzinfo=timezone.utc) + except FileNotFoundError: return effective_fallback except ValueError: - # Comentario: formato inválido en el archivo -> fallback return effective_fallback def actualizar_fecha_ultima_ejecucion( - path: str = DEFAULT_PATH, + path: str | Path, *, momento: Optional[datetime] = None, ) -> None: """ - Escribe en `path` la fecha/hora (UTC) en formato YYYY-MM-DD HH:MM:SS. - Si `momento` es None, usa ahora en UTC. - Crea directorios intermedios si no existen. + Guarda `momento` (UTC) en `path`, creando directorios si hace falta. + + - Si `momento` es None → ahora en UTC + - Si `momento` viene naive → se asume UTC + - Si trae tz → se convierte a UTC """ + + p = Path(path) + if momento is None: momento = datetime.now(timezone.utc) else: - # Normalizamos a UTC si viene con tz; si es naive, asumimos UTC if momento.tzinfo is None: momento = momento.replace(tzinfo=timezone.utc) else: momento = momento.astimezone(timezone.utc) - # Asegurar carpeta si `path` incluye directorios - folder = os.path.dirname(os.path.abspath(path)) - if folder and not os.path.exists(folder): - os.makedirs(folder, exist_ok=True) + p.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf8") as f: - f.write(momento.strftime(FMT)) + p.write_text(momento.strftime(FMT), encoding="utf-8") diff --git a/enviroment/.env b/enviroment/.env index eb32565..c22722f 100644 --- a/enviroment/.env +++ b/enviroment/.env @@ -1,6 +1,6 @@ ENV = development LOCAL_TZ = Europe/Madrid -LAST_RUN_PATH = ./app.last_run.txt +STATE_PATH = ./ #LOG_PATH = ./app.log #DESARROLLO ACANA diff --git a/enviroment/.env.production.sync.factuges b/enviroment/.env.production.sync.factuges index 2c95d7d..99a9bb0 100644 --- a/enviroment/.env.production.sync.factuges +++ b/enviroment/.env.production.sync.factuges @@ -1,6 +1,6 @@ ENV = development LOCAL_TZ = Europe/Madrid -LAST_RUN_PATH = ./app.last_run.txt +STATE_PATH = ./ #LOG_PATH = ./app.log #DESARROLLO ACANA diff --git a/enviroment/.env_V b/enviroment/.env_V index 344631a..7f4aedc 100644 --- a/enviroment/.env_V +++ b/enviroment/.env_V @@ -1,6 +1,6 @@ ENV = development LOCAL_TZ = Europe/Madrid -LAST_RUN_PATH = ./app.last_run.txt +STATE_PATH = ./ #LOG_PATH = ./app.log #DESARROLLO diff --git a/enviroment/dev.env b/enviroment/dev.env index 3b06498..940cd58 100644 --- a/enviroment/dev.env +++ b/enviroment/dev.env @@ -1,6 +1,7 @@ ENV = development LOCAL_TZ = Europe/Madrid -LAST_RUN_PATH = ./app.last_run.txt +STATE_PATH = ./ + #LOG_PATH = ./app.log #DESARROLLO ACANA diff --git a/enviroment/stack.acana.env b/enviroment/stack.acana.env index 8a6b87d..04427c7 100644 --- a/enviroment/stack.acana.env +++ b/enviroment/stack.acana.env @@ -1,7 +1,7 @@ # SYNC ENV = development LOCAL_TZ = Europe/Madrid -LAST_RUN_PATH = /usr/share/factuges-app/last_run_factuges.ini +STATE_PATH = /app/state FACTUGES_HOST = acana.mywire.org FACTUGES_PORT = 63050 diff --git a/scripts/build-factuges-sync.sh b/scripts/build-factuges-sync.sh index fdc362c..0ba26e9 100755 --- a/scripts/build-factuges-sync.sh +++ b/scripts/build-factuges-sync.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -SCRIPT_VERSION="1.1.0" +SCRIPT_VERSION="2.0.0" # ================================================ -# FACTUGES SYNC - Docker Build Script +# FACTUGES SYNC - Docker Build Script (Simplificado) # ----------------------------------------------- # Uso: # ./build.sh [--load] @@ -12,7 +12,7 @@ SCRIPT_VERSION="1.1.0" # ---------- 1. Validación ---------- if [[ $# -eq 0 || "$1" == --* ]]; then - echo "❌ ERROR: Falta el parámetro " + echo "ERROR: Falta el parámetro " echo "Uso: ./build.sh [--load]" exit 1 fi @@ -31,64 +31,45 @@ mkdir -p "$OUT_DIR" # ---------- 3. Info ---------- DATE=$(date +'%Y%m%d-%H%M%S') ISO_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -USER_NAME=$(whoami) -IMAGE_VERSION=$(sed -n 's/^version[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$PROJECT_DIR/setup.cfg" | head -n1) +IMAGE_VERSION=$(sed -n 's/^version[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' \ + "$PROJECT_DIR/setup.cfg" | head -n1) IMAGE_VERSION="${IMAGE_VERSION:-0.0.0}" +IMAGE_NAME="factuges-sync" +TAG_VERSION="${IMAGE_NAME}:${COMPANY}-${IMAGE_VERSION}" +TAG_LATEST="${IMAGE_NAME}:${COMPANY}-latest" + echo "" echo "-------------------------------------------------------" echo " FACTUGES SYNC Build Script v${SCRIPT_VERSION}" -echo " Compañía: ${COMPANY}" -echo " Versión: ${IMAGE_VERSION}" -echo " Fecha: ${DATE}" +echo " Compañía: ${COMPANY}" +echo " Versión: ${IMAGE_VERSION}" +echo " Fecha: ${DATE}" echo "-------------------------------------------------------" echo "" -# ---------- 4. Función para generar 1 build ---------- -build_image() { - local MODE="$1" # factuges | verifactu +# ---------- 4. Build único ---------- +echo "📦 Construyendo imagen Docker..." - local IMAGE_NAME="factuges-sync-${MODE}" +docker build --no-cache \ + -t "${TAG_VERSION}" \ + -t "${TAG_LATEST}" \ + --build-arg COMPANY="${COMPANY}" \ + -f "${PROJECT_DIR}/Dockerfile" "${PROJECT_DIR}" - local TAG_VERSION="${IMAGE_NAME}:${COMPANY}-${IMAGE_VERSION}" - local TAG_LATEST="${IMAGE_NAME}:${COMPANY}-latest" +echo "✅ Imagen construida: ${TAG_VERSION}" - echo "📦 Construyendo imagen Docker (${MODE})..." +# ---------- 5. Save tar ---------- +TAR_V="${OUT_DIR}/${IMAGE_NAME}-${COMPANY}-v${IMAGE_VERSION}-${DATE}.tar" +TAR_LATEST="${OUT_DIR}/${IMAGE_NAME}-${COMPANY}-latest.tar" - docker build --no-cache \ - -t "${TAG_VERSION}" \ - -t "${TAG_LATEST}" \ - --build-arg COMPANY="${COMPANY}" \ - -f "${PROJECT_DIR}/Dockerfile.${MODE}" "${PROJECT_DIR}" +docker save -o "${TAR_V}" "${TAG_VERSION}" "${TAG_LATEST}" +cp -f "${TAR_V}" "${TAR_LATEST}" - echo "✅ Imagen construida: ${TAG_VERSION}" - - local TAR_V="${OUT_DIR}/${IMAGE_NAME}-${COMPANY}-v${IMAGE_VERSION}-${DATE}.tar" - local TAR_LATEST="${OUT_DIR}/${IMAGE_NAME}-${COMPANY}-latest.tar" - - docker save -o "${TAR_V}" "${TAG_VERSION}" "${TAG_LATEST}" - cp -f "${TAR_V}" "${TAR_LATEST}" - - echo "📦 Imagen guardada:" - echo " - ${TAR_V}" - echo " - ${TAR_LATEST}" - - # Exportamos variables a nivel global para el LOAD opcional - echo "${TAR_V}" - echo "${TAR_LATEST}" - - echo "${TAR_V}|${TAR_LATEST}" -} - -# ---------- 5. Ejecutar build para ambos modos ---------- -BUILD_OUT_FACTUGES=$(build_image "factuges") -FACTUGES_TAR_V=$(echo "$BUILD_OUT_FACTUGES" | cut -d '|' -f1) -FACTUGES_TAR_LATEST=$(echo "$BUILD_OUT_FACTUGES" | cut -d '|' -f2) - -BUILD_OUT_VERIFACTU=$(build_image "verifactu") -VERIFACTU_TAR_V=$(echo "$BUILD_OUT_VERIFACTU" | cut -d '|' -f1) -VERIFACTU_TAR_LATEST=$(echo "$BUILD_OUT_VERIFACTU" | cut -d '|' -f2) +echo "📦 Imagen guardada:" +echo " - ${TAR_V}" +echo " - ${TAR_LATEST}" # ---------- 6. Manifest ---------- MANIFEST_FILE="${OUT_DIR}/manifest-${IMAGE_VERSION}-${DATE}.json" @@ -98,13 +79,9 @@ cat > "${MANIFEST_FILE}" <