commit 457ca296fa8a9764b833ff80583c36ab1dde1ade Author: George Date: Thu Jul 9 09:29:54 2026 -0500 proyecto en producción diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1559d8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +.venv/ +venv/ +env/ +logs/ +.env.dev +.DS +.env +.env.* +.DS_Store +.vscode/ + diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..0c7b6ee --- /dev/null +++ b/constants.py @@ -0,0 +1,30 @@ +ESTADOS_COMPROBANTE = { + "0": "NO EXISTE", + "1": "ACEPTADO", + "2": "ANULADO", + "3": "AUTORIZADO", + "4": "NO AUTORIZADO" +} + +ESTADOS_CONTRIBUYENTE = { + "00": "ACTIVO", + "01": "BAJA PROVISIONAL", + "02": "BAJA PROV. POR OFICIO", + "03": "SUSPENSION TEMPORAL", + "10": "BAJA DEFINITIVA", + "11": "BAJA OFICIO", + "22": "INHABILITADO-VENT.UNICA" +} + +CONDICIONES_DOMICILIADO = { + "00": "HABIDO", + "09": "PENDIENTE", + "11": "POR VERIFICAR", + "12": "NO HABIDO", + "20": "NO HALLADO" +} + +TABLAS = { + "18": "OPCH", + "19": "ORPC" +} diff --git a/empresas.json b/empresas.json new file mode 100644 index 0000000..bd0aa64 --- /dev/null +++ b/empresas.json @@ -0,0 +1,56 @@ +[ + { + "CompanyDB": "SBO_TAGUMEDICA_21", + "Client_ID": "f0e69d77-c1a4-43a9-8b12-d1d03009b374", + "Client_Secret": "LgfpmMh6SehsUOmcfsXIuw==", + "Ruc": "20503662869", + "Locked": "N", + "UserName": "manager", + "Password": "m1r1" + }, + { + "CompanyDB": "SBO_SAFENA", + "Client_ID": "f4ecb759-a991-4aee-abe5-eb474993dcbb", + "Client_Secret": "RNBaHKShBC3lFdquGwe/gg==", + "Ruc": "20603837721", + "Locked": "N", + "UserName": "manager", + "Password": "m1r1" + }, + { + "CompanyDB": "SBO_CIRUGIA", + "Client_ID": "21d41835-0289-4b3c-99c0-ff0591d2233d", + "Client_Secret": "hzKoLbgjRqLUmNhsLagb7w==", + "Ruc": "20521486857", + "Locked": "N", + "UserName": "manager", + "Password": "m1r1" + }, + { + "CompanyDB": "SBO_FRANQUICIA", + "Client_ID": "516d9f51-f7c3-4d29-ba04-4d97b406773f", + "Client_Secret": "LZ1JO7osqAccSm3/8sGMSQ==", + "Ruc": "20600278259", + "Locked": "N", + "UserName": "manager", + "Password": "m1r1" + }, + { + "CompanyDB": "SBO_CALICAY", + "Client_ID": "d7ceb759-b6dc-4d97-a0c7-42bfc5226c53", + "Client_Secret": "MIjVHUtvqEIeE06M35nOuQ==", + "Ruc": "20611487143", + "Locked": "N", + "UserName": "manager", + "Password": "m1r1" + }, + { + "CompanyDB": "SBO_ELAVENT", + "Client_ID": "9b67b816-d900-48e6-85c3-c9ba78b58040", + "Client_Secret": "xgnnvxeU1ZYTABPzhUAxxA==", + "Ruc": "20613152700", + "Locked": "N", + "UserName": "manager", + "Password": "m1r1" + } +] \ No newline at end of file diff --git a/hana_manager.py b/hana_manager.py new file mode 100644 index 0000000..0032eb0 --- /dev/null +++ b/hana_manager.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 + +import os +import sys +import re + +from hdbcli import dbapi +from dotenv import load_dotenv + +from constants import TABLAS, ESTADOS_COMPROBANTE, ESTADOS_CONTRIBUYENTE, CONDICIONES_DOMICILIADO + +# ============================================================================ +# CONFIGURACIÓN +# ============================================================================ + +# Cargar variables de entorno desde .env +load_dotenv(dotenv_path=".env.dev") + +# Configuración de la API +HANA_HOST = os.getenv("HANA_HOST", "") +HANA_PORT = int(os.getenv("HANA_PORT", 30015)) +HANA_USER = os.getenv("HANA_USER", "") +HANA_PASSWORD = os.getenv("HANA_PASSWORD", "") + +# ============================================================================ +def get_connection(): + """Crea y retorna una conexión a SAP HANA.""" + try: + conn = dbapi.connect( + address=HANA_HOST, + port=HANA_PORT, + user=HANA_USER, + password=HANA_PASSWORD + ) + return conn + except Exception as e: + print(f" ❌ Error conectando a HANA: {e}") + return None + +# ============================================================================ +def obtener_documentos_creados_actualizados_hace_x_minutos(schema, minutes=60): + """ + Se conecta a la base de datos SAP HANA, realiza una consulta a la tabla OPCH, + y formatea el resultado para que coincida con el esquema de documentos.json. + """ + + query = f""" + SELECT T0."ObjType", T0."DocEntry", T0."DocNum", T0."CardCode", T0."CardName", + TO_VARCHAR(T0."DocDate",'DD/MM/YYYY') "DocDate", + TO_VARCHAR(T0."TaxDate",'DD/MM/YYYY') "TaxDate", + TO_VARCHAR(T0."CreateDate",'DD/MM/YYYY') "CreateDate", + T3."LicTradNum" "LicTradNum", + T1."USER_CODE" "UserCode", + T0."Indicator", T0."FolioPref", T0."FolioNum", + CASE + WHEN IFNULL(T0."DocCur",'SOL') = 'SOL' + THEN T0."DocTotal" + IFNULL((SELECT SUM(T5."WTAmnt") FROM "{schema}".PCH5 T5 WHERE T5."AbsEntry" = T0."DocEntry" AND T5."Category" = 'I'),0.00) + ELSE T0."DocTotalFC" + IFNULL((SELECT SUM(T5."WTAmntFC") FROM "{schema}".PCH5 T5 WHERE T5."AbsEntry" = T0."DocEntry" AND T5."Category" = 'I'),0.00) + END "DocTotal" + FROM "{schema}".OPCH T0 + INNER JOIN "{schema}".OCRD T3 ON T3."CardCode" = T0."CardCode" + INNER JOIN "{schema}".OUSR T1 ON T1."USERID" = T0."UserSign2" + WHERE TO_TIMESTAMP(TO_VARCHAR(T0."UpdateDate", 'YYYY-MM-DD') + || ' ' || LPAD(TO_VARCHAR(T0."UpdateTS"), 6, '0'), + 'YYYY-MM-DD HH24MISS') + >= ADD_SECONDS(CURRENT_TIMESTAMP, {-1 * minutes * 60}) + + AND T0."CANCELED" = 'N' + AND IFNULL(T0."Indicator",'') IN ('01','02','03','08') + AND LENGTH(IFNULL(T0."FolioPref",'')) > 0 + AND IFNULL(T0."FolioNum",0) > 0 + AND IFNULL(T3."U_EXX_TIPODOCU",'') IN ('6','4') + AND (LENGTH(IFNULL(T0."U_PGX_STMENSAJE",'')) = 0 OR LOCATE('NO EXISTE', IFNULL(T0."U_PGX_STMENSAJE",'')) > 0) + ORDER BY T0."CreateDate" DESC; + """ + print(f"🔌 Conectando al servidor SAP HANA ({HANA_HOST}:{HANA_PORT}) para schema {schema}...") + connection = get_connection() + if not connection: + raise ConnectionError("No se pudo establecer la conexión a SAP HANA.") + + cursor = connection.cursor() + try: + cursor.execute(query) + + # Obtener nombres de columnas + columns = [column[0] for column in cursor.description] + + # Convertir resultados a lista de diccionarios + results = [] + for row in cursor.fetchall(): + results.append(dict(zip(columns, row))) + + cursor.close() + return results + + except Exception as e: + print(f" ❌ Error en consulta HANA: {e}") + return [] + finally: + if cursor: + cursor.close() + if connection: + connection.close() + print(f" 🔒 [{sys._getframe().f_code.co_name}] Conexión HANA cerrada.") + +# ============================================================================ +def obtener_documentos_fecha_emision_ultimos_8_dias(schema): + query = f""" + SELECT T0."ObjType", T0."DocEntry", T0."DocNum", T0."CardCode", T0."CardName", + TO_VARCHAR(T0."DocDate",'DD/MM/YYYY') "DocDate", + TO_VARCHAR(T0."TaxDate",'DD/MM/YYYY') "TaxDate", + TO_VARCHAR(T0."CreateDate",'DD/MM/YYYY') "CreateDate", + T3."LicTradNum" "LicTradNum", + T1."USER_CODE" "UserCode", + T0."Indicator", T0."FolioPref", T0."FolioNum", + CASE + WHEN IFNULL(T0."DocCur",'SOL') = 'SOL' + THEN T0."DocTotal" + IFNULL((SELECT SUM(T5."WTAmnt") FROM "{schema}".PCH5 T5 WHERE T5."AbsEntry" = T0."DocEntry" AND T5."Category" = 'I'),0.00) + ELSE T0."DocTotalFC" + IFNULL((SELECT SUM(T5."WTAmntFC") FROM "{schema}".PCH5 T5 WHERE T5."AbsEntry" = T0."DocEntry" AND T5."Category" = 'I'),0.00) + END "DocTotal" + FROM "{schema}".OPCH T0 + INNER JOIN "{schema}".OCRD T3 ON T3."CardCode" = T0."CardCode" + INNER JOIN "{schema}".OUSR T1 ON T1."USERID" = T0."UserSign2" + WHERE T0."TaxDate" >= ADD_DAYS(CURRENT_DATE, -8) + AND T0."CANCELED" = 'N' + AND IFNULL(T0."Indicator",'') IN ('01','02','03','08') + AND LENGTH(IFNULL(T0."FolioPref",'')) > 0 + AND IFNULL(T0."FolioNum",0) > 0 + AND IFNULL(T3."U_EXX_TIPODOCU",'') IN ('6','4') + ORDER BY T0."U_PGX_STMENSAJE", T0."CreateDate" DESC; + """ + print(f"🔌 Conectando al servidor SAP HANA ({HANA_HOST}:{HANA_PORT}) para schema {schema}...") + connection = get_connection() + if not connection: + raise ConnectionError("No se pudo establecer la conexión a SAP HANA.") + + cursor = connection.cursor() + try: + cursor.execute(query) + + columns = [column[0] for column in cursor.description] + + results = [] + for row in cursor.fetchall(): + results.append(dict(zip(columns, row))) + + cursor.close() + return results + + except Exception as e: + print(f" ❌ Error en consulta HANA: {e}") + return [] + finally: + if cursor: + cursor.close() + if connection: + connection.close() + print(f" 🔒 [{sys._getframe().f_code.co_name}] Conexión HANA cerrada.") + +# ============================================================================ +def obtener_documentos_creados_hace_x_dias(schema, dias=8): + query = f""" + SELECT T0."ObjType", T0."DocEntry", T0."DocNum", T0."CardCode", T0."CardName", + TO_VARCHAR(T0."DocDate",'DD/MM/YYYY') "DocDate", + TO_VARCHAR(T0."TaxDate",'DD/MM/YYYY') "TaxDate", + TO_VARCHAR(T0."CreateDate",'DD/MM/YYYY') "CreateDate", + T3."LicTradNum" "LicTradNum", + T1."USER_CODE" "UserCode", + T0."Indicator", T0."FolioPref", T0."FolioNum", + CASE + WHEN IFNULL(T0."DocCur",'SOL') = 'SOL' + THEN T0."DocTotal" + IFNULL((SELECT SUM(T5."WTAmnt") FROM "{schema}".PCH5 T5 WHERE T5."AbsEntry" = T0."DocEntry" AND T5."Category" = 'I'),0.00) + ELSE T0."DocTotalFC" + IFNULL((SELECT SUM(T5."WTAmntFC") FROM "{schema}".PCH5 T5 WHERE T5."AbsEntry" = T0."DocEntry" AND T5."Category" = 'I'),0.00) + END "DocTotal" + FROM "{schema}".OPCH T0 + INNER JOIN "{schema}".OCRD T3 ON T3."CardCode" = T0."CardCode" + INNER JOIN "{schema}".OUSR T1 ON T1."USERID" = T0."UserSign2" + WHERE T0."CreateDate" >= ADD_DAYS(CURRENT_DATE, {-1 * dias}) + AND T0."CANCELED" = 'N' + AND (LENGTH(IFNULL(T0."U_PGX_STMENSAJE",'')) = 0 OR LOCATE('NO EXISTE', IFNULL(T0."U_PGX_STMENSAJE",'')) > 0) + AND IFNULL(T0."Indicator",'') IN ('01','02','03','08') + AND LENGTH(IFNULL(T0."FolioPref",'')) > 0 + AND IFNULL(T0."FolioNum",0) > 0 + AND IFNULL(T3."U_EXX_TIPODOCU",'') IN ('6','4') + ORDER BY T0."CreateDate" DESC; + """ + print(f"🔌 Conectando al servidor SAP HANA ({HANA_HOST}:{HANA_PORT}) para schema {schema}...") + connection = get_connection() + if not connection: + raise ConnectionError("No se pudo establecer la conexión a SAP HANA.") + + cursor = connection.cursor() + try: + cursor.execute(query) + + columns = [column[0] for column in cursor.description] + + results = [] + for row in cursor.fetchall(): + results.append(dict(zip(columns, row))) + + cursor.close() + return results + + except Exception as e: + print(f" ❌ Error en consulta HANA: {e}") + return [] + finally: + if cursor: + cursor.close() + if connection: + connection.close() + print(f" 🔒 [{sys._getframe().f_code.co_name}] Conexión HANA cerrada.") + +# ============================================================================ +def actualizar_documento_sap(schema, registro,resultado): + + estado_sunat = "4" + estado_comprobante = "" + + if resultado["success"] == True: + error = "200" + observaciones = "" + d = resultado.get("data") or {} + + if not any(d.values()): + mensaje = "SIN_DATOS|||" + estado_comprobante = "0" + estado_sunat = "2" + else: + observaciones = d.get("observaciones", [""])[0] if d.get("observaciones") else "" + estado_comprobante = d.get("estadoCp", "") + estado_contribuyente = d.get("estadoRuc", "") + condicion_domicilio = d.get("condDomiRuc", "") + + mensaje = "" if estado_comprobante == "" else ESTADOS_COMPROBANTE[estado_comprobante] + mensaje += "|" if estado_contribuyente == "" else "|" + ESTADOS_CONTRIBUYENTE[estado_contribuyente] + mensaje += "|" if condicion_domicilio == "" else "|" + CONDICIONES_DOMICILIADO[condicion_domicilio] + mensaje += "|" + observaciones + + estado_sunat = "2" if estado_comprobante == "" else ("0" if estado_comprobante == "1" else "2") + + if resultado["success"] == False: + error = "400" + mensaje = resultado["message"] + + mensaje_limpio = re.sub(r'[^0-9a-zA-Z .|#-]',' ',mensaje) + + connection = get_connection() + if not connection: + raise ConnectionError("No se pudo establecer la conexión a SAP HANA.") + + cursor = connection.cursor() + + + # actualizando la tabla SAP + tabla = TABLAS[registro["objtype"]] + + query = f"""UPDATE "{registro["companydb"]}".{tabla} + SET "U_PGX_STMENSAJE" = '{mensaje_limpio}', + "U_PGX_SSTADO" = '{estado_sunat}', + "U_PGX_PETOTORFECHA" = CURRENT_DATE, + "U_PGX_PETOTORHORA" = TO_VARCHAR(CURRENT_TIME,'HH24:MM:SS') + WHERE "DocEntry" = {registro["fieldvalue"]};""" + + print(f" ┌─ 🗄️ UPDATE HANA ──────────────────────") + for line in query.splitlines(): + print(f" │ {line}") + print(f" └─────────────────────────────────────────") + + try: + cursor.execute(query) + connection.commit() + cursor.close() + return True + except Exception as e: + print(f" ❌ Error actualizando registro en HANA: {e}") + return False + finally: + if cursor: + cursor.close() + if connection: + connection.close() + print(f" 🔒 [{sys._getframe().f_code.co_name}] Conexión HANA cerrada.") + +# ============================================================================ diff --git a/logger_utilities.py b/logger_utilities.py new file mode 100644 index 0000000..65f40cf --- /dev/null +++ b/logger_utilities.py @@ -0,0 +1,93 @@ +import sys +import os +from datetime import datetime, timedelta + +_stdout_original = sys.stdout +_stderr_original = sys.stderr +_file_stream = None +_log_dir = "logs" + +# ============================================================================ +class MultiStream: + def __init__(self, *streams): + self.streams = list(streams) + + def write(self, data): + for stream in self.streams: + stream.write(data) + stream.flush() + + def flush(self): + for stream in self.streams: + stream.flush() + + def reemplazar_file(self, old_fh, new_fh): + for i, s in enumerate(self.streams): + if s is old_fh: + self.streams[i] = new_fh + return + +# ============================================================================ +def _ruta_log(hoy=None): + if hoy is None: + hoy = datetime.now().strftime("%Y-%m-%d") + if not os.path.exists(_log_dir): + os.makedirs(_log_dir) + return os.path.join(_log_dir, f"{hoy}.log") + +# ============================================================================ +def setup_logger(): + global _file_stream + log_path = _ruta_log() + _file_stream = open(log_path, "a", encoding="utf-8") + sys.stdout = MultiStream(_stdout_original, _file_stream) + sys.stderr = MultiStream(_stderr_original, _file_stream) + return log_path + +# ============================================================================ +def rotar_log(): + global _file_stream + hoy = datetime.now().strftime("%Y-%m-%d") + log_path = _ruta_log(hoy) + + if _file_stream and os.path.abspath(_file_stream.name) == os.path.abspath(log_path): + return + + nuevo_fh = open(log_path, "a", encoding="utf-8") + if isinstance(sys.stdout, MultiStream): + sys.stdout.reemplazar_file(_file_stream, nuevo_fh) + if isinstance(sys.stderr, MultiStream): + sys.stderr.reemplazar_file(_file_stream, nuevo_fh) + try: + _file_stream.close() + except Exception: + pass + _file_stream = nuevo_fh + print(f"📄 Log rotado a: {log_path}") + +# ============================================================================ +def limpiar_logs_antiguos(dias=7): + """ + Elimina archivos de log con más de 'n' días de antigüedad. + """ + log_dir = "logs" + if not os.path.exists(log_dir): + return + + limite = datetime.now() - timedelta(days=dias) + count = 0 + + for archivo in os.listdir(log_dir): + ruta_archivo = os.path.join(log_dir, archivo) + if os.path.isfile(ruta_archivo) and archivo.endswith(".log"): + fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo)) + if fecha_archivo < limite: + try: + os.remove(ruta_archivo) + count += 1 + except Exception as e: + print(f"⚠️ Error al eliminar log {archivo}: {e}") + + if count > 0: + print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).") +# ============================================================================ \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..2179b0e --- /dev/null +++ b/main.py @@ -0,0 +1,259 @@ +import json +import time +from datetime import datetime +from dotenv import load_dotenv + +import logger_utilities +import sunat_manager +import hana_manager +import sapb1_manager +from constants import ESTADOS_COMPROBANTE +from hana_manager import actualizar_documento_sap + +# ============================================================================ +def es_hora_pico(dt: datetime) -> bool: + minutos = dt.hour * 60 + dt.minute + return (360 <= minutos < 795) or (840 <= minutos < 1050) + +# ============================================================================ +def cargar_empresas(): + with open("empresas.json", "r", encoding="utf-8") as f: + return json.load(f) + +# ============================================================================ +def enviar_mensaje_sl(usercode, doc, resultado, schema): + estado_cp = resultado.get("data", {}).get("estadoCp", "") + estado_texto = ESTADOS_COMPROBANTE.get(estado_cp, "DESCONOCIDO") + + subject = f"❌ SUNAT - {doc['Indicator']}-{doc['FolioPref']}-{doc['FolioNum']}" + observaciones = resultado.get("data", {}).get("observaciones", [""])[0] or "" + + text = ( + f"El documento {doc['Indicator']}-{doc['FolioPref']}-{doc['FolioNum']} " + f"de {schema} entrego el siguiente estado por SUNAT.\n" + f"Estado: {estado_texto} (código: {estado_cp})\n" + f"RUC Proveedor: {doc.get('LicTradNum', 'N/A')}\n" + f"Razón Social: {doc.get('CardName', 'N/A')}\n" + f"Mensaje SUNAT: {resultado.get('message', 'N/A')}\n" + ) + + if observaciones: + text += f"Observaciones SUNAT: {observaciones}\n" + + payload = { + "MessageDataColumns": [ + { + "ColumnName": "Numero", + "Link": "tNO", + "MessageDataLines": [ + { + "Object":"", + "ObjectKey":"", + "Value":f"{doc['Indicator']}-{doc['FolioPref']}-{doc['FolioNum']} " + } + ] + }, + { + "ColumnName": "Documento", + "Link": "tYES", + "MessageDataLines": [ + { + "Object": str(doc.get("ObjType", "18")), + "ObjectKey": str(doc["DocEntry"]), + "Value": str(doc["DocNum"]) + } + ] + }, + { + "ColumnName": "Razón Social / Nombre", + "Link": "tNO", + "MessageDataLines": [ + { + "Object": "", + "ObjectKey": "", + "Value": f"{doc.get('CardName', 'N/A')}" + } + ] + } + ], + "RecipientCollection": [ + { + "SendInternal": "tYES", + "UserCode": usercode + } + ], + "Subject": subject, + "Text": text + } + + + + print(f" ┌─ 📩 Enviando Messages a {usercode} ───────────") + # for line in json.dumps(payload, indent=2).splitlines(): + # print(f" │ {line}") + sapb1_manager.send_message(payload) + print(f" │ ✅ Mensaje enviado a {usercode}") + print(f" └───────────────────────────────────────────────") + +# ============================================================================ +def procesar_empresa(schema, datos_empresa, modo): + print(f"\n{'='*60}") + print(f"🏢 Procesando empresa: {schema}") + print(f"{'='*60}") + + if modo == "rapido": + documentos = hana_manager.obtener_documentos_creados_actualizados_hace_x_minutos(schema, 60) + else: + documentos = hana_manager.obtener_documentos_fecha_emision_ultimos_8_dias(schema) + if not documentos: + print(f"⚠️ No se encontraron documentos para {schema}") + return 0, 0 + + ## documentos = hana_manager.obtener_documentos_creados_hace_x_dias(schema, 8) + + print(f"📄 Documentos encontrados: {len(documentos)}") + + print(f"🔑 Solicitando token SUNAT...") + token = sunat_manager.token(datos_empresa) + if not token: + print(f"❌ No se obtuvo token para {schema}") + return 0, len(documentos) + + print(f"✅ Token obtenido correctamente") + + sapb1_manager.login() + print(f"✅ Sesión SL establecida para {schema}") + + ruc_empresa = datos_empresa["Ruc"] + procesados = 0 + errores = 0 + + try: + for idx, doc in enumerate(documentos, start=1): + print(f"\n--- [{idx}/{len(documentos)}] {doc['FolioPref']}-{doc['FolioNum']} ---") + + try: + payload = { + "numRuc": doc["LicTradNum"], + "codComp": "R1" if doc["Indicator"] == "02" else doc["Indicator"], + "numeroSerie": doc["FolioPref"], + "numero": doc["FolioNum"], + "fechaEmision": doc["TaxDate"], + } + + if doc["FolioPref"] and doc["FolioPref"][0].isalpha(): + payload["monto"] = str(doc["DocTotal"]) + + print(f" ┌─ 📤 Enviando a SUNAT {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}─────────────────────") + for line in json.dumps(payload, indent=2).splitlines(): + print(f" │ {line}") + print(f" └────────────────────────────────────────────") + resultado = sunat_manager.validar_comprobante(token, ruc_empresa, payload) + print(f" ┌─ 📥 Respuesta de SUNAT {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}───────────────────") + for line in json.dumps(resultado, indent=2).splitlines(): + print(f" │ {line}") + print(f" └────────────────────────────────────────────") + + registro = { + "objtype": str(doc["ObjType"]), + "companydb": schema, + "fieldvalue": doc["DocEntry"], + } + + actualizar_documento_sap(schema, registro, resultado) + + if resultado.get("success"): + estado_cp = resultado.get("data", {}).get("estadoCp", "") + print(f"✅ OK - Estado CP: {estado_cp}") + + if estado_cp not in ["1","3"] and doc.get("UserCode"): + enviar_mensaje_sl(doc["UserCode"], doc, resultado, schema) + + else: + print(f"⚠️ Falló - {resultado.get('message', 'Error desconocido')}") + + procesados += 1 + + if idx < len(documentos): + time.sleep(2) + + except Exception as e: + print(f"❌ Error procesando documento: {e}") + errores += 1 + finally: + sapb1_manager.logout() + print(f"🔒 Sesión SL cerrada para {schema}") + + return procesados, errores + +# ============================================================================ +def main(): + log_path = logger_utilities.setup_logger() + logger_utilities.limpiar_logs_antiguos(dias=7) + + load_dotenv(dotenv_path=".env.dev") + + empresas = cargar_empresas() + print(f"📋 Empresas cargadas: {len(empresas)}") + + print("=" * 60) + print("🚀 Demonio iniciado") + print(f"📄 Log guardado en: {log_path}") + print(" Horario pico (cada 10 seg): 06:00 - 13:15 | 14:00 - 17:30") + print(" Horario valle (cada 30 min): resto del día") + print("=" * 60) + + while True: + inicio_ciclo = datetime.now() + + logger_utilities.rotar_log() + logger_utilities.limpiar_logs_antiguos(dias=7) + + modo = "rapido" if es_hora_pico(inicio_ciclo) else "lento" + + print(f"\n{'='*60}") + print(f"🔄 Ciclo [{modo.upper()}] {inicio_ciclo.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"{'='*60}") + + total_procesados = 0 + total_errores = 0 + + for empresa in empresas: + schema = empresa["CompanyDB"] + + if empresa.get("Locked") == "Y": + print(f"\n⏭️ Empresa {schema} está bloqueada (LOCKED=Y), saltando...") + continue + + sapb1_manager._sap_credentials = { + "company": empresa["CompanyDB"], + "username": empresa["UserName"], + "password": empresa["Password"], + } + + try: + p, e = procesar_empresa(schema, empresa, modo) + total_procesados += p + total_errores += e + except Exception as ex: + print(f"❌ Error procesando empresa {schema}: {ex}") + total_errores += 1 + + duracion_ciclo = datetime.now() - inicio_ciclo + print(f"\n📊 CICLO COMPLETADO en {str(duracion_ciclo).split('.')[0]}") + print(f" ✅ {total_procesados} procesados | ❌ {total_errores} errores") + + transcurrido = duracion_ciclo.total_seconds() + modo_ahora = "rapido" if es_hora_pico(datetime.now()) else "lento" + intervalo = 10 if modo_ahora == "rapido" else 1800 + dormir = max(0, intervalo - transcurrido) + + print(f"💤 Próximo ciclo [{modo_ahora}] en {dormir:.0f}s...") + time.sleep(dormir) + +# ============================================================================ +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\n🛑 Demonio detenido por el usuario.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8414a5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +certifi==2026.6.17 +charset-normalizer==3.4.9 +dotenv==0.9.9 +hdbcli==2.29.23 +idna==3.18 +python-dotenv==1.2.2 +requests==2.34.2 +urllib3==2.7.0 diff --git a/sapb1_manager.py b/sapb1_manager.py new file mode 100644 index 0000000..8561139 --- /dev/null +++ b/sapb1_manager.py @@ -0,0 +1,185 @@ + +from datetime import datetime +import requests +import urllib3 +import os +import json +from typing import Optional, Dict, Any +from dotenv import load_dotenv + + + +# ============================================================================ +# CONFIGURACIÓN +# ============================================================================ + +urllib3.disable_warnings() + +load_dotenv(dotenv_path=".env.dev") + +SAPB1_URL = os.getenv("SAP_B1_URL", "") +SAPB1_TIMEOUT = float(os.getenv("SAP_B1_TIMEOUT", 15.0)) +SAPB1_VERIFY_SSL = os.getenv("SAP_B1_VERIFY_SSL", "False").lower() == "true" + +# Almacenamiento global de sesión +_sap_session: dict = {"headers": None, "b1session": None, "routeid": None} +_sap_credentials: dict = {} + + +# ============================================================================ +# LOGIN +# ============================================================================ +def login() -> dict: + if not _sap_credentials: + raise RuntimeError("No hay credenciales. Asigna sapb1_manager._sap_credentials antes de llamar login().") + payload = { + "CompanyDB": _sap_credentials["company"], + "UserName": _sap_credentials["username"], + "Password": _sap_credentials["password"], + } + print("🔐 Iniciando sesión en SAP B1 Service Layer...") + resp = requests.post( + f"{SAPB1_URL}/Login", + json=payload, + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + if resp.status_code != 200: + raise ConnectionError( + f"Login fallido: HTTP {resp.status_code} - {resp.text}" + ) + + data = resp.json() + b1session = data["SessionId"] + routeid = resp.cookies.get("ROUTEID", "") + + headers = {"Cookie": f"B1SESSION={b1session}; ROUTEID={routeid}"} + _sap_session.update({"headers": headers, "b1session": b1session, "routeid": routeid}) + print(f"✅ Sesión SAP B1 establecida (B1SESSION: {b1session[:10]}...)") + return headers + + +# ============================================================================ +# LOGOUT +# ============================================================================ +def logout(): + if not _sap_session["b1session"]: + print("⚠️ No hay sesión activa para cerrar.") + return + print("🔓 Cerrando sesión SAP B1...") + try: + resp = requests.post( + f"{SAPB1_URL}/Logout", + headers=_sap_session["headers"], + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + if resp.status_code == 200: + print("✅ Sesión SAP B1 cerrada correctamente.") + else: + print(f"⚠️ Logout respondió HTTP {resp.status_code}") + except Exception as exc: + print(f"❌ Error al cerrar sesión: {exc}") + finally: + _sap_session.update({"headers": None, "b1session": None, "routeid": None}) + _sap_credentials.clear() + + +# ============================================================================ +# SESIÓN (interna - lazy login) +# ============================================================================ +def _ensure_session() -> dict: + if _sap_session["headers"]: + return _sap_session["headers"] + if _sap_credentials: + return login() + raise RuntimeError("No hay sesión ni credenciales para autenticarse en SAP B1.") + + +# ============================================================================ +# LOGGING DE RESPUESTA +# ============================================================================ +def _log_response(method: str, endpoint: str, response: requests.Response, payload: Any = None): + log_line = ( + f" │ [{method}] {endpoint} → {response.status_code}" + f" ({len(response.content)} bytes)" + ) + if response.status_code >= 400: + print(f" │ ⚠️ {log_line} - {response.text[:300]}") + else: + print(log_line) + + +# ============================================================================ +# PROXY CENTRALIZADO +# ============================================================================ +def forward_to_sapb1( + method: str, + endpoint_path: str, + json_data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, +) -> requests.Response: + if not method or not endpoint_path: + raise ValueError(" │ method y endpoint_path son requeridos") + + method = method.upper() + headers = _ensure_session() + + url = f"{SAPB1_URL}/{endpoint_path}" + req_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + **_sap_session["headers"], + } + + print(f" │ 🔄 [{method}] {endpoint_path} | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + try: + resp = requests.request( + method=method, + url=url, + json=json_data, + params=params, + headers=req_headers, + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + except requests.exceptions.Timeout: + raise ConnectionError(f" │ Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}") + except requests.exceptions.ConnectionError as exc: + raise ConnectionError(f" │ No se pudo conectar con SAP B1: {exc}") + except requests.RequestException as exc: + raise ConnectionError(f" │ Error en solicitud HTTP: {exc}") + + if resp.status_code == 401: + if not _sap_credentials: + raise ConnectionError("Sesión expirada (401) y no hay credenciales para re-autenticar.") + print(" │ ⚠️ Sesión expirada (401). Reautenticando e intentando una vez...") + login() + req_headers["Cookie"] = _sap_session["headers"]["Cookie"] + resp = requests.request( + method=method, + url=url, + json=json_data, + params=params, + headers=req_headers, + timeout=SAPB1_TIMEOUT, + verify=SAPB1_VERIFY_SSL, + ) + + _log_response(method, endpoint_path, resp, json_data) + return resp + + +# ============================================================================ +# SEND MESSAGE (POST /Messages) +# ============================================================================ +def send_message(payload: dict) -> dict: + resp = forward_to_sapb1("POST", "Messages", json_data=payload) + if resp.status_code not in (200, 201): + raise ConnectionError( + f" │ Error al enviar Mensaje: HTTP {resp.status_code} - {resp.text}" + ) + result = resp.json() + print(f" │ ✅ SAP B1 Service Layer - {resp.status_code}") + return result diff --git a/sunat_manager.py b/sunat_manager.py new file mode 100644 index 0000000..4c3838e --- /dev/null +++ b/sunat_manager.py @@ -0,0 +1,80 @@ +import requests +import json +import urllib3 +import time + +# Desactivar advertencias de SSL no verificado +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +# ============================================================================ +def token(datos): + + CLIENT_ID=datos["Client_ID"] + CLIENT_SECRET=datos["Client_Secret"] + + url = f"https://api-seguridad.sunat.gob.pe/v1/clientesextranet/{CLIENT_ID}/oauth2/token/" + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'application/json, text/plain, */*', + 'Origin': 'https://api-seguridad.sunat.gob.pe' + } + + payload = 'grant_type=client_credentials' + '&' + payload += 'scope=https://api.sunat.gob.pe/v1/contribuyente/contribuyentes' + '&' + payload += f'client_id={CLIENT_ID}' + '&' + payload += f'client_secret={CLIENT_SECRET}' + + response = requests.request("POST", url, headers=headers, data=payload, verify=False, timeout=30) + + print(f" 📤 Token request enviado") + print(f" 📥 Token response: {response.status_code}") + + if response.status_code == 200: + result = response.json() + + if "access_token" in result: + token = result["access_token"] + return token + else: + return "" + else: + return "" + +# ============================================================================ +def validar_comprobante(token,RUC,payload:dict): + url = f"https://api.sunat.gob.pe/v1/contribuyente/contribuyentes/{RUC}/validarcomprobante" + + # payload = { + # "numRuc": "20538298485", + # "codComp": "08", + # "numeroSerie": "E001", + # "numero": "3773", + # "fechaEmision": "15/11/2023", + # "monto": "1328.52" + # } + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {token}', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'application/json, text/plain, */*' + } + + max_retries = 3 + for attempt in range(max_retries): + try: + response = requests.request("POST", url, headers=headers, json=payload, verify=False, timeout=30) + print(f" 📥 Status code: {response.status_code}") + return response.json() + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + print(f" ⚠️ Error de conexión (intento {attempt + 1}/{max_retries}): {e}") + if attempt < max_retries - 1: + time_to_wait = 2 ** (attempt + 1) + print(f" 🔄 Reintentando en {time_to_wait} segundos...") + time.sleep(time_to_wait) + else: + raise e + +# ============================================================================ \ No newline at end of file