From c349d12f9d8cc1dbe6ca77c83caca86ae8190efc Mon Sep 17 00:00:00 2001 From: david Date: Sun, 30 Nov 2025 22:31:09 +0100 Subject: [PATCH] =?UTF-8?q?Subida=20a=20producci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/db_connection.py | 49 ++- app/db/sync_invoices_factuges.py | 330 +++++++++++++----- app/db/sync_invoices_verifactu.py | 162 ++++++--- app/utils/send_orders_mail.py | 2 +- app/utils/send_rest_api.py | 18 +- app/utils/tax_catalog_helper.py | 5 +- enviroment/{acana.env => stack.acana.env} | 19 +- .../docker-compose.acana.yml | 8 +- setup.cfg | 15 +- 9 files changed, 419 insertions(+), 189 deletions(-) rename enviroment/{acana.env => stack.acana.env} (71%) rename docker-compose.yml => scripts/docker-compose.acana.yml (84%) diff --git a/app/db/db_connection.py b/app/db/db_connection.py index d1640fa..f4fc1c6 100644 --- a/app/db/db_connection.py +++ b/app/db/db_connection.py @@ -1,9 +1,15 @@ +from typing import Any, Mapping + import fdb import mysql.connector + from app.config import logger -def get_factuges_connection(config): +def get_factuges_connection(config) -> fdb.Connection: + """ + Crea y devuelve una conexión a FactuGES (Firebird). + """ try: conn = fdb.connect( host=config['FACTUGES_HOST'], @@ -11,34 +17,53 @@ def get_factuges_connection(config): database=config['FACTUGES_DATABASE'], user=config['FACTUGES_USER'], password=config['FACTUGES_PASSWORD'], - charset='UTF8' + charset='UTF8', ) logger.info( - f"Conexión a la base de datos FactuGES establecida: {config['FACTUGES_HOST']} with database:{config['FACTUGES_DATABASE']} - using user:{config['FACTUGES_USER']}") + "Conexión a la base de datos FactuGES establecida: %s with database:%s - using user:%s", + config['FACTUGES_HOST'], + config['FACTUGES_DATABASE'], + config['FACTUGES_USER'], + ) return conn except Exception as e: logger.error("Error al conectar a la base de datos FactuGES.") logger.error( - f"(ERROR) Failed to establish connection to: {config['FACTUGES_HOST']} with database:{config['FACTUGES_DATABASE']} - using user:{config['FACTUGES_USER']}") + "(ERROR) Failed to establish connection to: %s with database:%s - using user:%s", + config['FACTUGES_HOST'], + config['FACTUGES_DATABASE'], + config['FACTUGES_USER'], + ) logger.error(str(e)) raise e -def get_mysql_connection(config): +def get_mysql_connection(config: Mapping[str, Any]): + """ + Crea y devuelve una conexión a MySQL. + """ try: conn = mysql.connector.connect( - host=config['FWEB_MYSQL_HOST'], - port=config['FWEB_MYSQL_PORT'], - database=config['FWEB_MYSQL_DATABASE'], - user=config['FWEB_MYSQL_USER'], - password=config['FWEB_MYSQL_PASSWORD'] + host=config["FWEB_MYSQL_HOST"], + port=config["FWEB_MYSQL_PORT"], + database=config["FWEB_MYSQL_DATABASE"], + user=config["FWEB_MYSQL_USER"], + password=config["FWEB_MYSQL_PASSWORD"], ) logger.info( - f"Conexión a la base de datos MySQL establecida a: {config['FWEB_MYSQL_HOST']} with database:{config['FWEB_MYSQL_DATABASE']} - using user:{config['FWEB_MYSQL_USER']}") + "Conexión a la base de datos MySQL establecida a: %s with database:%s - using user:%s", + config['FWEB_MYSQL_HOST'], + config['FWEB_MYSQL_DATABASE'], + config['FWEB_MYSQL_USER'], + ) return conn except Exception as e: logger.error("Error al conectar a la base de datos MySQL.") logger.error( - f"(ERROR) Failed to establish connection to: {config['FWEB_MYSQL_HOST']} with database:{config['FWEB_MYSQL_DATABASE']} - using user:{config['FWEB_MYSQL_USER']}") + "(ERROR) Failed to establish connection to: %s with database:%s - using user:%s", + config['FWEB_MYSQL_HOST'], + config['FWEB_MYSQL_DATABASE'], + config['FWEB_MYSQL_USER'], + ) logger.error(str(e)) raise e diff --git a/app/db/sync_invoices_factuges.py b/app/db/sync_invoices_factuges.py index 9f0af0e..b23bb74 100644 --- a/app/db/sync_invoices_factuges.py +++ b/app/db/sync_invoices_factuges.py @@ -1,11 +1,13 @@ -from app.config import logger -from typing import Dict, Any +from typing import Any, Dict + from uuid6 import uuid7 -from app.config import load_config -from . import sql_sentences as SQL -from . import normalizations as NORMALIZA + +from app.config import load_config, logger from app.utils import validar_nif +from . import normalizations as NORMALIZA +from . import sql_sentences as SQL + def sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date): config = load_config() @@ -26,14 +28,14 @@ def sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date): if ids_verifactu_deleted: sync_delete_invoices(conn_factuges, ids_verifactu_deleted, config) else: - logger.info( - f"There are NOT customer invoices deleted since the last run") + logger.info("There are NOT customer invoices deleted since the last run") except Exception as e: if cursor_mysql is not None: cursor_mysql.close() - logger.error(f"(ERROR) Failed to fetch from database:{ - config['FWEB_MYSQL_DATABASE']} - using user:{config['FWEB_MYSQL_USER']}") + logger.error( + f"(ERROR) Failed to fetch from database:{config['FWEB_MYSQL_DATABASE']} - using user:{config['FWEB_MYSQL_USER']}" + ) logger.error(e) raise e @@ -48,8 +50,9 @@ def sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date): except Exception as e: if cursor_FactuGES is not None: cursor_FactuGES.close() - logger.error(f"(ERROR) Failed to fetch from database:{ - config['FACTUGES_DATABASE']} - using user:{config['FACTUGES_USER']}") + logger.error( + f"(ERROR) Failed to fetch from database:{config['FACTUGES_DATABASE']} - using user:{config['FACTUGES_USER']}" + ) logger.error(e) raise e @@ -65,10 +68,10 @@ def sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date): # Verificar si hay filas en el resultado if tuplas_seleccionadas: sync_invoices_from_FACTUGES( - conn_mysql, tuplas_seleccionadas, conn_factuges, config) + conn_mysql, tuplas_seleccionadas, conn_factuges, config + ) else: - logger.info( - "There are NOT new FACTURAS rows since the last run.") + logger.info("There are NOT new FACTURAS rows since the last run.") def sync_delete_invoices(conn_factuges, ids_verifactu_deleted, config): @@ -79,8 +82,10 @@ def sync_delete_invoices(conn_factuges, ids_verifactu_deleted, config): cursor_FactuGES = conn_factuges.cursor() if ids_verifactu_deleted: logger.info(f"Liberate factuGES: {ids_verifactu_deleted}") - cursor_FactuGES.executemany(SQL.LIMPIAR_FACTUGES_LINK, [( - id_verifactu,) for id_verifactu in ids_verifactu_deleted]) + cursor_FactuGES.executemany( + SQL.LIMPIAR_FACTUGES_LINK, + [(id_verifactu,) for id_verifactu in ids_verifactu_deleted], + ) else: logger.info("No articles to delete.") @@ -103,7 +108,7 @@ def sync_invoices_from_FACTUGES(conn_mysql, filas, conn_factuges, config): factuges_id_anterior = None num_fac_procesed = 0 customer_valid = True - invoice_id = None + invoice_id: str | None = None try: cursorMySQL = conn_mysql.cursor() @@ -111,27 +116,28 @@ def sync_invoices_from_FACTUGES(conn_mysql, filas, conn_factuges, config): # Insertar datos en la tabla 'customer_invoices' for factura_detalle in filas: - # Preparamos los campos para evitar errores - customer_fields = NORMALIZA.normalize_customer_fields( - factura_detalle) + customer_fields = NORMALIZA.normalize_customer_fields(factura_detalle) header_invoice_fields = NORMALIZA.normalize_header_invoice_fields( - factura_detalle) + factura_detalle + ) details_invoice_fields = NORMALIZA.normalize_details_invoice_fields( - factura_detalle) + factura_detalle + ) - factuges_id = int(factura_detalle['ID_FACTURA']) + factuges_id = int(factura_detalle["ID_FACTURA"]) if factuges_id_anterior is None or factuges_id_anterior != factuges_id: - # Validamos que el cif de la factura exista en la AEAT si no es así no se hace la sincro de la factura - sync_result = int(config['CTE_SYNC_RESULT_OK']) + sync_result = int(config["CTE_SYNC_RESULT_OK"]) sync_notes = None - customer_valid = validar_nif(customer_fields["tin"], customer_fields["name"], config) + customer_valid = validar_nif( + customer_fields["tin"], customer_fields["name"], config + ) if customer_valid: # Comprobamos si existe el cliente del primer item de la factura customer_id = get_or_create_customer( cursorMySQL, - config['CTE_COMPANY_ID'], + config["CTE_COMPANY_ID"], str(factura_detalle["ID_CLIENTE"]), customer_fields, ) @@ -146,40 +152,59 @@ def sync_invoices_from_FACTUGES(conn_mysql, filas, conn_factuges, config): # xxxxxxx = str(factura_detalle['ID_FORMA_PAGO']) según este id se debe de guardar en la factura los vencimiento asociados a la forma de pago # ---- cabecera factura - invoice_id = insert_invoice_header(cursorMySQL, customer_fields, header_invoice_fields, customer_id, pm_id, str( - factura_detalle["DES_FORMA_PAGO"]), config + invoice_id = insert_invoice_header( + cursorMySQL, + customer_fields, + header_invoice_fields, + customer_id, + pm_id, + str(factura_detalle["DES_FORMA_PAGO"]), + config, ) # ---- impuestos cabecera insert_header_taxes_if_any( - cursorMySQL, invoice_id, factura_detalle['IVA'], factura_detalle['RECARGO_EQUIVALENCIA'], header_invoice_fields) + cursorMySQL, + invoice_id, + factura_detalle["IVA"], + factura_detalle["RECARGO_EQUIVALENCIA"], + header_invoice_fields, + ) # ---- registro verifactu insert_verifactu_record( - cursorMySQL, header_invoice_fields, invoice_id, config) + cursorMySQL, header_invoice_fields, invoice_id, config + ) else: - sync_result = int(config['CTE_SYNC_RESULT_FAIL']) - sync_notes = (f">>> Factura {header_invoice_fields['reference']} no cumple requisitos para ser mandada a Verifactu: " - f">>>>>> El NIF/NOMBRE ({customer_fields['tin']}/{customer_fields['name']}) no está registrado en la AEAT. " - f"El NIF/CIF debe estar registrado en la AEAT y el nombre debe ser suficientemente parecido al nombre registrado en la AEAT") + sync_result = int(config["CTE_SYNC_RESULT_FAIL"]) + sync_notes = ( + f">>> Factura {header_invoice_fields['reference']} no cumple requisitos para ser mandada a Verifactu: " + f">>>>>> El NIF/NOMBRE ({customer_fields['tin']}/{customer_fields['name']}) no está registrado en la AEAT. " + f"El NIF/CIF debe estar registrado en la AEAT y el nombre debe ser suficientemente parecido al nombre registrado en la AEAT" + ) logger.info(sync_notes) # Guardamos en Factuges el id de la customer_invoice logger.info( - f"Updating FACTURAS_CLIENTE {sync_result} {invoice_id} {factuges_id} {sync_notes}") - cursor_FactuGES.execute(SQL.UPDATE_FACTUGES_LINK, (sync_result, invoice_id, sync_notes, factuges_id)) + f"Updating FACTURAS_CLIENTE {sync_result} {invoice_id} {factuges_id} {sync_notes}" + ) + cursor_FactuGES.execute( + SQL.UPDATE_FACTUGES_LINK, + (sync_result, invoice_id, sync_notes, factuges_id), + ) num_fac_procesed += 1 # Insertamos detalles y taxes correspondientes siempre que hayamos insertado cabecera if customer_valid: + if invoice_id is None: + raise RuntimeError("BUG: invoice_id no debería ser None si customer_valid es True") insert_item_and_taxes(cursorMySQL, invoice_id, details_invoice_fields) # Asignamos el id factura anterior para no volver a inserta cabecera factuges_id_anterior = factuges_id - logger.info( - f"FACTURAS_CLIENTE rows to be processed: {str(num_fac_procesed)}") + logger.info(f"FACTURAS_CLIENTE rows to be processed: {str(num_fac_procesed)}") except Exception as e: # Escribir el error en el archivo de errores @@ -194,7 +219,9 @@ def sync_invoices_from_FACTUGES(conn_mysql, filas, conn_factuges, config): cursor_FactuGES.close() -def get_or_create_customer(cur, company_id: str, factuges_customer_id: str, fields: Dict[str, Any]) -> str: +def get_or_create_customer( + cur, company_id: str, factuges_customer_id: str, fields: Dict[str, Any] +) -> str: """ Comprobamos si existe el cliente del primer item de la factura y si no lo creamos """ @@ -202,15 +229,34 @@ def get_or_create_customer(cur, company_id: str, factuges_customer_id: str, fiel row = cur.fetchone() if not row or not row[0]: customer_id = str(uuid7()) - logger.info("Inserting customer %s %s %s", - factuges_customer_id, fields["tin"], fields["name"]) + logger.info( + "Inserting customer %s %s %s", + factuges_customer_id, + fields["tin"], + fields["name"], + ) cur.execute( SQL.INSERT_CUSTOMER, ( - customer_id, fields["name"], fields["tin"], fields["street"], fields["city"], fields["province"], - fields["postal_code"], fields["country"], fields["language_code"], fields["phone_primary"], fields["phone_secondary"], - fields["mobile_primary"], fields["mobile_secondary"], fields["email_primary"], fields["email_secondary"], - fields["website"], factuges_customer_id, company_id, fields["is_company"] + customer_id, + fields["name"], + fields["tin"], + fields["street"], + fields["city"], + fields["province"], + fields["postal_code"], + fields["country"], + fields["language_code"], + fields["phone_primary"], + fields["phone_secondary"], + fields["mobile_primary"], + fields["mobile_secondary"], + fields["email_primary"], + fields["email_secondary"], + fields["website"], + factuges_customer_id, + company_id, + fields["is_company"], ), ) return customer_id @@ -219,16 +265,31 @@ def get_or_create_customer(cur, company_id: str, factuges_customer_id: str, fiel cur.execute( SQL.UPDATE_CUSTOMER, ( - fields["name"], fields["tin"], fields["street"], fields["city"], fields["province"], fields["postal_code"], - fields["country"], fields["language_code"], fields["is_company"], fields["phone_primary"], fields["phone_secondary"], - fields["mobile_primary"], fields["mobile_secondary"], fields["email_primary"], fields["email_secondary"], - fields["website"], customer_id, + fields["name"], + fields["tin"], + fields["street"], + fields["city"], + fields["province"], + fields["postal_code"], + fields["country"], + fields["language_code"], + fields["is_company"], + fields["phone_primary"], + fields["phone_secondary"], + fields["mobile_primary"], + fields["mobile_secondary"], + fields["email_primary"], + fields["email_secondary"], + fields["website"], + customer_id, ), ) return customer_id -def get_or_create_payment_method(cur, factuges_payment_id: str, description: str) -> str: +def get_or_create_payment_method( + cur, factuges_payment_id: str, description: str +) -> str: """ En el caso de que la forma de pago no exista la creamos """ @@ -236,73 +297,142 @@ def get_or_create_payment_method(cur, factuges_payment_id: str, description: str row = cur.fetchone() if not row or not row[0]: pm_id = str(uuid7()) - logger.info("Inserting payment method %s %s %s", - factuges_payment_id, pm_id, description) - cur.execute(SQL.INSERT_PAYMENT_METHOD, - (pm_id, description, factuges_payment_id)) + logger.info( + "Inserting payment method %s %s %s", factuges_payment_id, pm_id, description + ) + cur.execute( + SQL.INSERT_PAYMENT_METHOD, (pm_id, description, factuges_payment_id) + ) return pm_id pm_id = str(row[0]) logger.info("Payment method exists %s -> %s", factuges_payment_id, pm_id) return pm_id -def insert_invoice_header(cur: str, cf: Dict[str, Any], hif: Dict[str, Any], customer_id: str, - payment_method_id: str, payment_method_description: str, config) -> str: +def insert_invoice_header( + cur: str, + cf: Dict[str, Any], + hif: Dict[str, Any], + customer_id: str, + payment_method_id: str, + payment_method_description: str, + config, +) -> str: """ Inserta cabecera y devuelve invoice_id """ invoice_id = str(uuid7()) - logger.info("Inserting invoice %s %s %s %s %s", - invoice_id, hif.get('reference'), hif.get('invoice_date'), hif.get('operation_date'), config['CTE_STATUS_INVOICE']) - cur.execute( + logger.info( + "Inserting invoice %s %s %s %s %s", + invoice_id, + hif.get("reference"), + hif.get("invoice_date"), + hif.get("operation_date"), + config["CTE_STATUS_INVOICE"], + ) + cur.execute( # type: ignore SQL.INSERT_INVOICE, ( - invoice_id, hif.get('company_id'), hif.get('status'), hif.get('is_proforma'), hif.get('series'), hif.get('reference'), hif.get( - 'invoice_date'), hif.get('operation_date'), hif.get('description'), - hif.get('subtotal_amount_value'), hif.get('discount_amount_value'), hif.get( - 'discount_percentage_val'), hif.get('taxable_amount_value'), - hif.get('taxes_amount_value'), hif.get('total_amount_value'), - customer_id, cf.get("tin"), cf.get( - 'name'), cf.get('street'), cf.get('city'), - cf.get('province'), cf.get('postal_code'), 'es', - payment_method_id, payment_method_description, hif.get( - 'factuges_id'), hif.get('company_id'), hif.get('is_proforma') + invoice_id, + hif.get("company_id"), + hif.get("status"), + hif.get("is_proforma"), + hif.get("series"), + hif.get("reference"), + hif.get("invoice_date"), + hif.get("operation_date"), + hif.get("description"), + hif.get("subtotal_amount_value"), + hif.get("discount_amount_value"), + hif.get("discount_percentage_val"), + hif.get("taxable_amount_value"), + hif.get("taxes_amount_value"), + hif.get("total_amount_value"), + customer_id, + cf.get("tin"), + cf.get("name"), + cf.get("street"), + cf.get("city"), + cf.get("province"), + cf.get("postal_code"), + "es", + payment_method_id, + payment_method_description, + hif.get("factuges_id"), + hif.get("company_id"), + hif.get("is_proforma"), ), ) return invoice_id -def insert_verifactu_record(cur: str, hif: Dict[str, Any], invoice_id: str, config) -> str: +def insert_verifactu_record( + cur: str, hif: Dict[str, Any], invoice_id: str, config +) -> str: """ Inserta registro verifactu vacio y devuelve id """ id = str(uuid7()) - logger.info("Inserting verifactu record %s %s %s", - id, hif.get('reference'), hif.get('invoice_date')) - cur.execute( + logger.info( + "Inserting verifactu record %s %s %s", + id, + hif.get("reference"), + hif.get("invoice_date"), + ) + cur.execute( # type: ignore SQL.INSERT_VERIFACTU_RECORD, - ( - id, invoice_id, config['CTE_STATUS_VERIFACTU'] - ), + (id, invoice_id, config["CTE_STATUS_VERIFACTU"]), ) return id -def insert_header_taxes_if_any(cur, invoice_id: str, IVA: str, RECARGO: str, hif: Dict[str, Any]) -> None: - """ - Inserta impuestos de cabecera - """ - # IVA (>= 0 acepta 0% también, si no quieres registrar 0, cambia condición a > 0) - if (IVA or 0) >= 0: - cur.execute(SQL.INSERT_INVOICE_TAX, - (str(uuid7()), invoice_id, hif.get('tax_code'), hif.get('base'), hif.get('iva'))) +def insert_header_taxes_if_any( + cur, + invoice_id: str, + IVA: str, + RECARGO: str, + hif: Dict[str, Any], +) -> None: + """Inserta impuestos de cabecera""" - # Recargo equivalencia - if (RECARGO or 0) > 0: - cur.execute(SQL.INSERT_INVOICE_TAX, - (str(uuid7()), invoice_id, 'rec_5_2', hif.get('base'), hif.get('re'))) + # Conversión segura a número + try: + iva_val = float(IVA) + except (TypeError, ValueError): + iva_val = 0.0 + + try: + rec_val = float(RECARGO) + except (TypeError, ValueError): + rec_val = 0.0 + + # IVA (>= 0 acepta 0%) + if iva_val >= 0: + cur.execute( + SQL.INSERT_INVOICE_TAX, + ( + str(uuid7()), + invoice_id, + hif.get("tax_code"), + hif.get("base"), + hif.get("iva"), + ), + ) + + # Recargo equivalencia (> 0) + if rec_val > 0: + cur.execute( + SQL.INSERT_INVOICE_TAX, + ( + str(uuid7()), + invoice_id, + "rec_5_2", + hif.get("base"), + hif.get("re"), + ), + ) def insert_item_and_taxes(cur, invoice_id: str, fields: Dict[str, Any]) -> None: @@ -314,11 +444,23 @@ def insert_item_and_taxes(cur, invoice_id: str, fields: Dict[str, Any]) -> None: # logger.info("Inserting item %s pos=%s qty=%s", item_id, fields.get('position'), fields.get('quantity_value')) cur.execute( SQL.INSERT_INVOICE_ITEM, - (item_id, invoice_id, fields.get('position'), fields.get('description'), fields.get('quantity_value'), - fields.get('unit_value'), fields.get('discount_percentage_value'), None, fields.get('total_value'), - fields.get('iva_code'), fields.get('iva_percentage_value'), fields.get('tax_amount'), - fields.get('rec_code'), fields.get('rec_percentage_value'), fields.get('rec_amount'), - ) + ( + item_id, + invoice_id, + fields.get("position"), + fields.get("description"), + fields.get("quantity_value"), + fields.get("unit_value"), + fields.get("discount_percentage_value"), + None, + fields.get("total_value"), + fields.get("iva_code"), + fields.get("iva_percentage_value"), + fields.get("tax_amount"), + fields.get("rec_code"), + fields.get("rec_percentage_value"), + fields.get("rec_amount"), + ), ) # logger.info("Inserting item tax %s code=%s base=%s tax=%s", diff --git a/app/db/sync_invoices_verifactu.py b/app/db/sync_invoices_verifactu.py index 1db328d..2a392b3 100644 --- a/app/db/sync_invoices_verifactu.py +++ b/app/db/sync_invoices_verifactu.py @@ -1,8 +1,8 @@ -from app.config import logger -from typing import Dict, Any, Tuple, Optional, List, Iterable -from app.config import load_config -from decimal import Decimal -from app.utils import validar_nif, estado_factura, crear_factura, TaxCatalog, unscale_to_str +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from app.config import load_config, logger +from app.utils import TaxCatalog, crear_factura, estado_factura, unscale_to_str + from . import sql_sentences as SQL @@ -32,9 +32,9 @@ def sync_invoices_verifactu(conn_mysql, last_execution_date): # Verificar si hay filas en el resultado if tuplas_seleccionadas: enviar_datos(tuplas_seleccionadas, cursor_mysql, config) - logger.info(f"Ok send Verifactu") + logger.info("Ok send Verifactu") else: - logger.info(f"There are no rows to send") + logger.info("There are no rows to send") except Exception as error: if cursor_mysql is not None: @@ -45,40 +45,57 @@ def sync_invoices_verifactu(conn_mysql, last_execution_date): raise error -def enviar_datos(invoices_to_verifactu, cursor_mysql, config): +def enviar_datos( + invoices_to_verifactu, + cursor_mysql, + config: Dict[str, Any] +) -> None: + + factura: Optional[Dict[str, Any]] = None + invoice_id: Optional[str] = None # Recorrer todas las facturas para crear json de envio try: - invoice_id = None - factura = None for fila in invoices_to_verifactu: + fila_id = str(fila["id"]) + # Si los ids de factura anterior y actual no coinciden, empezamos factura nueva, miramos si ya existe una factura si es así la mandamos AEAT # y creamos la cabecera de la factura siguiente, si no existe factura solo la creamos - if invoice_id != str(fila['id']): + if invoice_id != fila_id: - procesar_factura_verifactu(factura, cursor_mysql, config) + # Cerrar la factura anterior (si existe) + if factura is not None: + procesar_factura_verifactu(factura, cursor_mysql, config) # preparamos nueva factura - ok, respuesta = preparar_factura(fila) + ok, factura_nueva, error = preparar_factura(fila) if not ok: logger.info( f"ERROR >>>>>> Factura {fila['reference']} no cumple requisitos para ser mandada a Verifactu:") - logger.info( - f">>>>>> Faltan campos requeridos: {respuesta}") - factura = None + logger.info(f"ERROR >>> Factura {fila['reference']} no válida: {error}") continue - factura = respuesta + factura = factura_nueva + invoice_id = fila_id + + # Añadir línea a factura actual ok, linea = preparar_linea(fila) if not ok: + ref = factura["reference"] if factura else "(desconocida)" logger.info( - f"ERROR >>>>>> Factura {factura.get('reference')} no cumple requisitos para ser mandada a Verifactu:") + f"ERROR >>>>>> Factura {ref} no cumple requisitos para ser mandada a Verifactu:") logger.info(f">>>>>> Faltan campos requeridos: {linea}") factura = None - else: + continue + + # Línea válida + # Garantizamos que factura no es None + if factura is not None: factura["lineas"].append(linea) - procesar_factura_verifactu(factura, cursor_mysql, config) + # Procesar la última factura + if factura is not None: + procesar_factura_verifactu(factura, cursor_mysql, config) except Exception as e: # Escribir el error en el archivo de errores @@ -92,58 +109,95 @@ def procesar_factura_verifactu( config: Dict[str, Any] ) -> bool: - if factura != None: - # Creamos registro de factura en verifactu - if factura.get('uuid') == '': - # logger.info(f"Send to create Verifactu: {factura}") - respuesta = crear_factura(factura, config) - if respuesta.get("status") == 200 and respuesta.get("ok"): - data = respuesta.get("data") - qr_verifactu = f"data:image/png;base64,{data.get('qr', '')}" - cursor_mysql.execute(SQL.update_verifactu_records_with_invoiceId, (data.get("estado"), data.get( - "uuid"), data.get("url"), qr_verifactu, factura.get("id"))) - logger.info( - f">>> Factura {factura.get("reference")} registrada en Verifactu") - return True - else: - logger.info( - f">>> Factura {factura.get("reference")} enviada a Verifactu con error {respuesta}") - return False - # Actualizamos registro de factura en verifactu + if factura is None: + return False + + # Creamos registro de factura en verifactu + if factura.get('uuid') == '': + # logger.info(f"Send to create Verifactu: {factura}") + respuesta = crear_factura(factura, config) + + if respuesta.get("status") == 200 and respuesta.get("ok"): + data = respuesta.get("data") or {} + qr_verifactu = f"data:image/png;base64,{data.get('qr', '')}" + + cursor_mysql.execute( + SQL.update_verifactu_records_with_invoiceId, + ( + data.get("estado"), + data.get("uuid"), + data.get("url"), + qr_verifactu, + factura.get("id"), + ) + ) + logger.info( + f">>> Factura {factura.get('reference')} registrada en Verifactu") + return True else: - # logger.info(f"Send to update Verifactu: {factura}") - respuesta = estado_factura(factura.get('uuid'), config) - if respuesta.get("status") == 200 and respuesta.get("ok"): - data = respuesta.get("data") - cursor_mysql.execute(SQL.update_verifactu_records_with_uuid, (data.get( - 'estado'), data.get('operacion'), factura.get('uuid'))) - logger.info( - f">>> Factura {factura.get("reference")} actualizado registro de Verifactu") - return True - else: - return False + logger.info( + f">>> Factura {factura.get('reference')} enviada a Verifactu con error {respuesta}") + return False + + # Actualizamos registro de factura en verifactu + + # logger.info(f"Send to update Verifactu: {factura}") + uuid_val = factura.get("uuid") + if not uuid_val: # None, '', o falsy + logger.error(f"Factura {factura.get('reference')} sin UUID válido") + return False + + respuesta = estado_factura(uuid_val, config) + + if respuesta.get("status") == 200 and respuesta.get("ok"): + data = respuesta.get("data") or {} + cursor_mysql.execute( + SQL.update_verifactu_records_with_uuid, + (data.get('estado'), + data.get('operacion'), + uuid_val) + ) + logger.info( + f">>> Factura {factura.get('reference')} actualizado registro de Verifactu") + return True + else: + return False -def preparar_factura(fila: Dict[str, Any]) -> Tuple[bool, Dict[str, Any] | list]: +def preparar_factura( + fila: Dict[str, Any] +) -> Tuple[bool, Dict[str, Any] | None, str | None]: """ Prepara el JSON de factura para Verifactu a partir de 'fila'. + + Retorna: + (ok, factura, error) """ - campos_requeridos = ("series", "invoice_number", - "invoice_date", "description", "total_amount_value") + + campos_requeridos = ( + "series", "invoice_number", "invoice_date", + "description", "total_amount_value" + ) + ok, missing = validar_requeridos(fila, campos_requeridos) + if not ok: - return False, missing + # Devolvemos string, NO lista + return False, None, ", ".join(missing) factura = { "nif": fila['customer_tin'], "nombre": fila['customer_name'], "serie": fila['series'], "numero": fila['invoice_number'], + # convertimos la fecha al formato requerido en el api "fecha_expedicion": fila['invoice_date'].strftime("%d-%m-%Y"), + # F1: Factura (Art. 6, 7.2 Y 7.3 del RD 1619/2012) "tipo_factura": "F1", "descripcion": fila['description'], + # desescalamos el importe para dar su importe real "importe_total": unscale_to_str(str(fila['total_amount_value']), str(fila['total_amount_scale'])), "lineas": [], @@ -154,7 +208,7 @@ def preparar_factura(fila: Dict[str, Any]) -> Tuple[bool, Dict[str, Any] | list] "uuid": fila['uuid'], } - return True, factura + return True, factura, None def preparar_linea(fila: Dict[str, Any]) -> Tuple[bool, Dict[str, Any] | list]: diff --git a/app/utils/send_orders_mail.py b/app/utils/send_orders_mail.py index 127fedd..e6d8a1f 100644 --- a/app/utils/send_orders_mail.py +++ b/app/utils/send_orders_mail.py @@ -17,7 +17,7 @@ def send_orders_mail(inserted_orders): for order in inserted_orders: send_smtp_email = brevo_python.SendSmtpEmail( to=[{'email': config['MAIL_TO']}], - subject=f"Nuevo pedido del distribuidor {order["dealer_name"]}", + subject=f"Nuevo pedido del distribuidor {order['dealer_name']}", template_id=int(config["BREVO_EMAIL_TEMPLATE"]), params={ "customer_reference": order["customer_reference"], diff --git a/app/utils/send_rest_api.py b/app/utils/send_rest_api.py index 1e16cee..762510d 100644 --- a/app/utils/send_rest_api.py +++ b/app/utils/send_rest_api.py @@ -1,11 +1,13 @@ +from typing import Any, Dict, Optional, Tuple + import requests + from app.config import logger -from typing import Optional, Dict, Any, Tuple def estado_factura(uuid_str: str, config, - ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + ) -> Dict[str, Any]: """ Llama al endpoint de Verifacti para cosultar el estado de registro. @@ -52,15 +54,15 @@ def estado_factura(uuid_str: str, except requests.RequestException as e: logger.error("Error de conexión con la API Verifacti: %s", e) - return False, None, str(e) + return {"ok": False, "status": 500, "error": str(e)} except ValueError as e: logger.error("Respuesta no es JSON válido: %s", e) - return False, None, "Respuesta no es JSON válido" + return {"ok": False, "status": 500, "error": "Respuesta no es JSON válido"} def crear_factura(payload, config, - ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + ) -> Dict[str, Any]: """ Llama al endpoint de Verifacti para crear una factura. @@ -107,10 +109,10 @@ def crear_factura(payload, except requests.RequestException as e: logger.error("Error de conexión con la API Verifacti: %s", e) - return False, None, str(e) + return {"ok": False, "status": 500, "error": str(e)} except ValueError as e: logger.error("Respuesta no es JSON válido: %s", e) - return False, None, "Respuesta no es JSON válido" + return {"ok": False, "status": 500, "error": "Respuesta no es JSON válido"} def validar_nif( @@ -140,7 +142,7 @@ def validar_nif( resp = requests.post( url, json=payload, headers=headers, timeout=timeout) if resp.status_code != 200: - logger.info(f"ERRRRRROOOOOOORRRRR LLAMADA REST API") + logger.info("ERRRRRROOOOOOORRRRR LLAMADA REST API") # return False, None, f"HTTP {resp.status_code}: {resp.text}" data = resp.json() diff --git a/app/utils/tax_catalog_helper.py b/app/utils/tax_catalog_helper.py index 514e553..79dbaae 100644 --- a/app/utils/tax_catalog_helper.py +++ b/app/utils/tax_catalog_helper.py @@ -1,9 +1,8 @@ -from app.config import logger import json -from decimal import Decimal -from typing import Any, Dict, Iterable, Optional, Tuple +from decimal import ROUND_HALF_UP, Decimal from functools import lru_cache from pathlib import Path +from typing import Any, Dict, Iterable, Optional, Tuple def map_tax_code(raw: str) -> str: diff --git a/enviroment/acana.env b/enviroment/stack.acana.env similarity index 71% rename from enviroment/acana.env rename to enviroment/stack.acana.env index b646a11..8a6b87d 100644 --- a/enviroment/acana.env +++ b/enviroment/stack.acana.env @@ -1,21 +1,21 @@ +# SYNC ENV = development LOCAL_TZ = Europe/Madrid -LAST_RUN_PATH = /usr/share/factuges-app/last_run.txt +LAST_RUN_PATH = /usr/share/factuges-app/last_run_factuges.ini FACTUGES_HOST = acana.mywire.org FACTUGES_PORT = 63050 FACTUGES_DATABASE = D:\Rodax\BD\FACTUGES.FDB FACTUGES_USER = sysdba FACTUGES_PASSWORD = masterkey + +FWEB_MYSQL_HOST = db # ${DB_NAME} +FWEB_MYSQL_PORT = 3306 # ${DB_PORT} +FWEB_MYSQL_DATABASE = factuges_acana # ${DB_NAME} +FWEB_MYSQL_USER = acana # ${DB_USER} +FWEB_MYSQL_PASSWORD = r@U8%GJ+2e/AWR # ${DB_PASS} + CTE_COMPANY_ID = '019a9667-6a65-767a-a737-48234ee50a3a' -VERIFACTU_API_KEY = vf_test_ei8WYAvEq5dhSdEyQVjgCS8NZaNpEK2BljSHSUXf+Y0= - -FWEB_MYSQL_HOST = db -FWEB_MYSQL_PORT = 3306 -FWEB_MYSQL_DATABASE = factuges_acana -FWEB_MYSQL_USER = acana -FWEB_MYSQL_PASSWORD = r@U8%GJ+2e/AWR - CTE_SERIE = 'F25/' CTE_STATUS_INVOICE = 'issued' CTE_IS_PROFORMA = 0 @@ -26,5 +26,6 @@ CTE_IS_COMPANY = 1 CTE_SYNC_RESULT_OK = 1 CTE_SYNC_RESULT_FAIL = 2 +VERIFACTU_API_KEY = vf_test_ei8WYAvEq5dhSdEyQVjgCS8NZaNpEK2BljSHSUXf+Y0= VERIFACTU_BASE_URL = https://api.verifacti.com/ VERIFACTU_NIFS_API_KEY = vfn_osYpNdqSzAdTAHpazXG2anz4F3o0gfbSb5FFrCBZcno= diff --git a/docker-compose.yml b/scripts/docker-compose.acana.yml similarity index 84% rename from docker-compose.yml rename to scripts/docker-compose.acana.yml index 7f80931..b69eb93 100644 --- a/docker-compose.yml +++ b/scripts/docker-compose.acana.yml @@ -4,7 +4,7 @@ services: container_name: "factuges-sync-factuges-acana" restart: "no" environment: - ENV: "prod" + ENV: "production" LOCAL_TZ: "Europe/Madrid" LAST_RUN_PATH: "${LAST_RUN_PATH}" @@ -29,7 +29,11 @@ services: CTE_COUNTRY_CODE: "${CTE_COUNTRY_CODE}" CTE_IS_COMPANY: "${CTE_IS_COMPANY}" CTE_SYNC_RESULT_OK: "${CTE_SYNC_RESULT_OK}" - CTE_SYNC_RESULT_FAIL: "${CTE_SYNC_RESULT_FAIL}" + CTE_SYNC_RESULT_FAIL: "${CTE_SYNC_RESULT_FAIL}" + + VERIFACTU_API_KEY: "${VERIFACTU_API_KEY}" + VERIFACTU_BASE_URL: "${VERIFACTU_BASE_URL}" + VERIFACTU_NIFS_API_KEY: "${VERIFACTU_NIFS_API_KEY}" depends_on: db: condition: service_healthy diff --git a/setup.cfg b/setup.cfg index 6977bdd..2e1a717 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = factuges-sync -version = 0.0.15 +version = 0.0.21 description = ETL job to sync data from legacy DB to MariaDB author = Rodax Software author_email = info@rodax-software.com @@ -39,10 +39,12 @@ install_requires = PyNaCl==1.5.0 python-dateutil==2.9.0.post0 python-dotenv==1.0.0 + requests==2.32.5 six==1.16.0 sshtunnel==0.4.0 + striprtf==0.0.29 urllib3==2.2.3 - uuid6 + uuid6==2025.0.1 [options.extras_require] dev = @@ -51,6 +53,7 @@ dev = packaging==24.1 pathspec==0.12.1 platformdirs==4.3.6 + ruff==0.14.7 [options.entry_points] console_scripts = @@ -62,9 +65,9 @@ where = . [tool:pytest] testpaths = tests -[flake8] -max-line-length = 88 -exclude = .git,__pycache__,build,dist - [isort] profile = black + +[tool.ruff] +line-length = 88 +target-version = "py312" \ No newline at end of file