Subida a producción

This commit is contained in:
David Arranz 2025-11-30 22:31:09 +01:00
parent 51bd9fe7af
commit c349d12f9d
9 changed files with 419 additions and 189 deletions

View File

@ -1,9 +1,15 @@
from typing import Any, Mapping
import fdb import fdb
import mysql.connector import mysql.connector
from app.config import logger 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: try:
conn = fdb.connect( conn = fdb.connect(
host=config['FACTUGES_HOST'], host=config['FACTUGES_HOST'],
@ -11,34 +17,53 @@ def get_factuges_connection(config):
database=config['FACTUGES_DATABASE'], database=config['FACTUGES_DATABASE'],
user=config['FACTUGES_USER'], user=config['FACTUGES_USER'],
password=config['FACTUGES_PASSWORD'], password=config['FACTUGES_PASSWORD'],
charset='UTF8' charset='UTF8',
) )
logger.info( 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 return conn
except Exception as e: except Exception as e:
logger.error("Error al conectar a la base de datos FactuGES.") logger.error("Error al conectar a la base de datos FactuGES.")
logger.error( 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)) logger.error(str(e))
raise 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: try:
conn = mysql.connector.connect( conn = mysql.connector.connect(
host=config['FWEB_MYSQL_HOST'], host=config["FWEB_MYSQL_HOST"],
port=config['FWEB_MYSQL_PORT'], port=config["FWEB_MYSQL_PORT"],
database=config['FWEB_MYSQL_DATABASE'], database=config["FWEB_MYSQL_DATABASE"],
user=config['FWEB_MYSQL_USER'], user=config["FWEB_MYSQL_USER"],
password=config['FWEB_MYSQL_PASSWORD'] password=config["FWEB_MYSQL_PASSWORD"],
) )
logger.info( 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 return conn
except Exception as e: except Exception as e:
logger.error("Error al conectar a la base de datos MySQL.") logger.error("Error al conectar a la base de datos MySQL.")
logger.error( 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)) logger.error(str(e))
raise e raise e

View File

@ -1,11 +1,13 @@
from app.config import logger from typing import Any, Dict
from typing import Dict, Any
from uuid6 import uuid7 from uuid6 import uuid7
from app.config import load_config
from . import sql_sentences as SQL from app.config import load_config, logger
from . import normalizations as NORMALIZA
from app.utils import validar_nif 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): def sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date):
config = load_config() config = load_config()
@ -26,14 +28,14 @@ def sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date):
if ids_verifactu_deleted: if ids_verifactu_deleted:
sync_delete_invoices(conn_factuges, ids_verifactu_deleted, config) sync_delete_invoices(conn_factuges, ids_verifactu_deleted, config)
else: else:
logger.info( logger.info("There are NOT customer invoices deleted since the last run")
f"There are NOT customer invoices deleted since the last run")
except Exception as e: except Exception as e:
if cursor_mysql is not None: if cursor_mysql is not None:
cursor_mysql.close() cursor_mysql.close()
logger.error(f"(ERROR) Failed to fetch from database:{ logger.error(
config['FWEB_MYSQL_DATABASE']} - using user:{config['FWEB_MYSQL_USER']}") f"(ERROR) Failed to fetch from database:{config['FWEB_MYSQL_DATABASE']} - using user:{config['FWEB_MYSQL_USER']}"
)
logger.error(e) logger.error(e)
raise e raise e
@ -48,8 +50,9 @@ def sync_invoices_factuges(conn_factuges, conn_mysql, last_execution_date):
except Exception as e: except Exception as e:
if cursor_FactuGES is not None: if cursor_FactuGES is not None:
cursor_FactuGES.close() cursor_FactuGES.close()
logger.error(f"(ERROR) Failed to fetch from database:{ logger.error(
config['FACTUGES_DATABASE']} - using user:{config['FACTUGES_USER']}") f"(ERROR) Failed to fetch from database:{config['FACTUGES_DATABASE']} - using user:{config['FACTUGES_USER']}"
)
logger.error(e) logger.error(e)
raise 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 # Verificar si hay filas en el resultado
if tuplas_seleccionadas: if tuplas_seleccionadas:
sync_invoices_from_FACTUGES( sync_invoices_from_FACTUGES(
conn_mysql, tuplas_seleccionadas, conn_factuges, config) conn_mysql, tuplas_seleccionadas, conn_factuges, config
)
else: else:
logger.info( logger.info("There are NOT new FACTURAS rows since the last run.")
"There are NOT new FACTURAS rows since the last run.")
def sync_delete_invoices(conn_factuges, ids_verifactu_deleted, config): 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() cursor_FactuGES = conn_factuges.cursor()
if ids_verifactu_deleted: if ids_verifactu_deleted:
logger.info(f"Liberate factuGES: {ids_verifactu_deleted}") logger.info(f"Liberate factuGES: {ids_verifactu_deleted}")
cursor_FactuGES.executemany(SQL.LIMPIAR_FACTUGES_LINK, [( cursor_FactuGES.executemany(
id_verifactu,) for id_verifactu in ids_verifactu_deleted]) SQL.LIMPIAR_FACTUGES_LINK,
[(id_verifactu,) for id_verifactu in ids_verifactu_deleted],
)
else: else:
logger.info("No articles to delete.") 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 factuges_id_anterior = None
num_fac_procesed = 0 num_fac_procesed = 0
customer_valid = True customer_valid = True
invoice_id = None invoice_id: str | None = None
try: try:
cursorMySQL = conn_mysql.cursor() 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' # Insertar datos en la tabla 'customer_invoices'
for factura_detalle in filas: for factura_detalle in filas:
# Preparamos los campos para evitar errores # Preparamos los campos para evitar errores
customer_fields = NORMALIZA.normalize_customer_fields( customer_fields = NORMALIZA.normalize_customer_fields(factura_detalle)
factura_detalle)
header_invoice_fields = NORMALIZA.normalize_header_invoice_fields( header_invoice_fields = NORMALIZA.normalize_header_invoice_fields(
factura_detalle) factura_detalle
)
details_invoice_fields = NORMALIZA.normalize_details_invoice_fields( 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: 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 # 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 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: if customer_valid:
# Comprobamos si existe el cliente del primer item de la factura # Comprobamos si existe el cliente del primer item de la factura
customer_id = get_or_create_customer( customer_id = get_or_create_customer(
cursorMySQL, cursorMySQL,
config['CTE_COMPANY_ID'], config["CTE_COMPANY_ID"],
str(factura_detalle["ID_CLIENTE"]), str(factura_detalle["ID_CLIENTE"]),
customer_fields, 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 # 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 # ---- cabecera factura
invoice_id = insert_invoice_header(cursorMySQL, customer_fields, header_invoice_fields, customer_id, pm_id, str( invoice_id = insert_invoice_header(
factura_detalle["DES_FORMA_PAGO"]), config cursorMySQL,
customer_fields,
header_invoice_fields,
customer_id,
pm_id,
str(factura_detalle["DES_FORMA_PAGO"]),
config,
) )
# ---- impuestos cabecera # ---- impuestos cabecera
insert_header_taxes_if_any( 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 # ---- registro verifactu
insert_verifactu_record( insert_verifactu_record(
cursorMySQL, header_invoice_fields, invoice_id, config) cursorMySQL, header_invoice_fields, invoice_id, config
)
else: else:
sync_result = int(config['CTE_SYNC_RESULT_FAIL']) sync_result = int(config["CTE_SYNC_RESULT_FAIL"])
sync_notes = (f">>> Factura {header_invoice_fields['reference']} no cumple requisitos para ser mandada a Verifactu: " sync_notes = (
f">>>>>> El NIF/NOMBRE ({customer_fields['tin']}/{customer_fields['name']}) no está registrado en la AEAT. " f">>> Factura {header_invoice_fields['reference']} no cumple requisitos para ser mandada a Verifactu: "
f"El NIF/CIF debe estar registrado en la AEAT y el nombre debe ser suficientemente parecido al nombre registrado en la AEAT") 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) logger.info(sync_notes)
# Guardamos en Factuges el id de la customer_invoice # Guardamos en Factuges el id de la customer_invoice
logger.info( logger.info(
f"Updating FACTURAS_CLIENTE {sync_result} {invoice_id} {factuges_id} {sync_notes}") 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)) )
cursor_FactuGES.execute(
SQL.UPDATE_FACTUGES_LINK,
(sync_result, invoice_id, sync_notes, factuges_id),
)
num_fac_procesed += 1 num_fac_procesed += 1
# Insertamos detalles y taxes correspondientes siempre que hayamos insertado cabecera # Insertamos detalles y taxes correspondientes siempre que hayamos insertado cabecera
if customer_valid: 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) insert_item_and_taxes(cursorMySQL, invoice_id, details_invoice_fields)
# Asignamos el id factura anterior para no volver a inserta cabecera # Asignamos el id factura anterior para no volver a inserta cabecera
factuges_id_anterior = factuges_id factuges_id_anterior = factuges_id
logger.info( logger.info(f"FACTURAS_CLIENTE rows to be processed: {str(num_fac_procesed)}")
f"FACTURAS_CLIENTE rows to be processed: {str(num_fac_procesed)}")
except Exception as e: except Exception as e:
# Escribir el error en el archivo de errores # 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() 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 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() row = cur.fetchone()
if not row or not row[0]: if not row or not row[0]:
customer_id = str(uuid7()) customer_id = str(uuid7())
logger.info("Inserting customer %s %s %s", logger.info(
factuges_customer_id, fields["tin"], fields["name"]) "Inserting customer %s %s %s",
factuges_customer_id,
fields["tin"],
fields["name"],
)
cur.execute( cur.execute(
SQL.INSERT_CUSTOMER, SQL.INSERT_CUSTOMER,
( (
customer_id, fields["name"], fields["tin"], fields["street"], fields["city"], fields["province"], customer_id,
fields["postal_code"], fields["country"], fields["language_code"], fields["phone_primary"], fields["phone_secondary"], fields["name"],
fields["mobile_primary"], fields["mobile_secondary"], fields["email_primary"], fields["email_secondary"], fields["tin"],
fields["website"], factuges_customer_id, company_id, fields["is_company"] 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 return customer_id
@ -219,16 +265,31 @@ def get_or_create_customer(cur, company_id: str, factuges_customer_id: str, fiel
cur.execute( cur.execute(
SQL.UPDATE_CUSTOMER, SQL.UPDATE_CUSTOMER,
( (
fields["name"], fields["tin"], fields["street"], fields["city"], fields["province"], fields["postal_code"], fields["name"],
fields["country"], fields["language_code"], fields["is_company"], fields["phone_primary"], fields["phone_secondary"], fields["tin"],
fields["mobile_primary"], fields["mobile_secondary"], fields["email_primary"], fields["email_secondary"], fields["street"],
fields["website"], customer_id, 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 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 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() row = cur.fetchone()
if not row or not row[0]: if not row or not row[0]:
pm_id = str(uuid7()) pm_id = str(uuid7())
logger.info("Inserting payment method %s %s %s", logger.info(
factuges_payment_id, pm_id, description) "Inserting payment method %s %s %s", factuges_payment_id, pm_id, description
cur.execute(SQL.INSERT_PAYMENT_METHOD, )
(pm_id, description, factuges_payment_id)) cur.execute(
SQL.INSERT_PAYMENT_METHOD, (pm_id, description, factuges_payment_id)
)
return pm_id return pm_id
pm_id = str(row[0]) pm_id = str(row[0])
logger.info("Payment method exists %s -> %s", factuges_payment_id, pm_id) logger.info("Payment method exists %s -> %s", factuges_payment_id, pm_id)
return pm_id return pm_id
def insert_invoice_header(cur: str, cf: Dict[str, Any], hif: Dict[str, Any], customer_id: str, def insert_invoice_header(
payment_method_id: str, payment_method_description: str, config) -> str: 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 Inserta cabecera y devuelve invoice_id
""" """
invoice_id = str(uuid7()) invoice_id = str(uuid7())
logger.info("Inserting invoice %s %s %s %s %s", logger.info(
invoice_id, hif.get('reference'), hif.get('invoice_date'), hif.get('operation_date'), config['CTE_STATUS_INVOICE']) "Inserting invoice %s %s %s %s %s",
cur.execute( invoice_id,
hif.get("reference"),
hif.get("invoice_date"),
hif.get("operation_date"),
config["CTE_STATUS_INVOICE"],
)
cur.execute( # type: ignore
SQL.INSERT_INVOICE, 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_id,
'invoice_date'), hif.get('operation_date'), hif.get('description'), hif.get("company_id"),
hif.get('subtotal_amount_value'), hif.get('discount_amount_value'), hif.get( hif.get("status"),
'discount_percentage_val'), hif.get('taxable_amount_value'), hif.get("is_proforma"),
hif.get('taxes_amount_value'), hif.get('total_amount_value'), hif.get("series"),
customer_id, cf.get("tin"), cf.get( hif.get("reference"),
'name'), cf.get('street'), cf.get('city'), hif.get("invoice_date"),
cf.get('province'), cf.get('postal_code'), 'es', hif.get("operation_date"),
payment_method_id, payment_method_description, hif.get( hif.get("description"),
'factuges_id'), hif.get('company_id'), hif.get('is_proforma') 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 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 Inserta registro verifactu vacio y devuelve id
""" """
id = str(uuid7()) id = str(uuid7())
logger.info("Inserting verifactu record %s %s %s", logger.info(
id, hif.get('reference'), hif.get('invoice_date')) "Inserting verifactu record %s %s %s",
cur.execute( id,
hif.get("reference"),
hif.get("invoice_date"),
)
cur.execute( # type: ignore
SQL.INSERT_VERIFACTU_RECORD, SQL.INSERT_VERIFACTU_RECORD,
( (id, invoice_id, config["CTE_STATUS_VERIFACTU"]),
id, invoice_id, config['CTE_STATUS_VERIFACTU']
),
) )
return id return id
def insert_header_taxes_if_any(cur, invoice_id: str, IVA: str, RECARGO: str, hif: Dict[str, Any]) -> None: def insert_header_taxes_if_any(
""" cur,
Inserta impuestos de cabecera invoice_id: str,
""" IVA: str,
# IVA (>= 0 acepta 0% también, si no quieres registrar 0, cambia condición a > 0) RECARGO: str,
if (IVA or 0) >= 0: hif: Dict[str, Any],
cur.execute(SQL.INSERT_INVOICE_TAX, ) -> None:
(str(uuid7()), invoice_id, hif.get('tax_code'), hif.get('base'), hif.get('iva'))) """Inserta impuestos de cabecera"""
# Recargo equivalencia # Conversión segura a número
if (RECARGO or 0) > 0: try:
cur.execute(SQL.INSERT_INVOICE_TAX, iva_val = float(IVA)
(str(uuid7()), invoice_id, 'rec_5_2', hif.get('base'), hif.get('re'))) 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: 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')) # logger.info("Inserting item %s pos=%s qty=%s", item_id, fields.get('position'), fields.get('quantity_value'))
cur.execute( cur.execute(
SQL.INSERT_INVOICE_ITEM, 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'), item_id,
fields.get('iva_code'), fields.get('iva_percentage_value'), fields.get('tax_amount'), invoice_id,
fields.get('rec_code'), fields.get('rec_percentage_value'), fields.get('rec_amount'), 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", # logger.info("Inserting item tax %s code=%s base=%s tax=%s",

View File

@ -1,8 +1,8 @@
from app.config import logger from typing import Any, Dict, Iterable, List, Optional, Tuple
from typing import Dict, Any, Tuple, Optional, List, Iterable
from app.config import load_config from app.config import load_config, logger
from decimal import Decimal from app.utils import TaxCatalog, crear_factura, estado_factura, unscale_to_str
from app.utils import validar_nif, estado_factura, crear_factura, TaxCatalog, unscale_to_str
from . import sql_sentences as SQL 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 # Verificar si hay filas en el resultado
if tuplas_seleccionadas: if tuplas_seleccionadas:
enviar_datos(tuplas_seleccionadas, cursor_mysql, config) enviar_datos(tuplas_seleccionadas, cursor_mysql, config)
logger.info(f"Ok send Verifactu") logger.info("Ok send Verifactu")
else: else:
logger.info(f"There are no rows to send") logger.info("There are no rows to send")
except Exception as error: except Exception as error:
if cursor_mysql is not None: if cursor_mysql is not None:
@ -45,40 +45,57 @@ def sync_invoices_verifactu(conn_mysql, last_execution_date):
raise error 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 # Recorrer todas las facturas para crear json de envio
try: try:
invoice_id = None
factura = None
for fila in invoices_to_verifactu: 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 # 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 # 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 # preparamos nueva factura
ok, respuesta = preparar_factura(fila) ok, factura_nueva, error = preparar_factura(fila)
if not ok: if not ok:
logger.info( logger.info(
f"ERROR >>>>>> Factura {fila['reference']} no cumple requisitos para ser mandada a Verifactu:") f"ERROR >>>>>> Factura {fila['reference']} no cumple requisitos para ser mandada a Verifactu:")
logger.info( logger.info(f"ERROR >>> Factura {fila['reference']} no válida: {error}")
f">>>>>> Faltan campos requeridos: {respuesta}")
factura = None
continue continue
factura = respuesta
factura = factura_nueva
invoice_id = fila_id
# Añadir línea a factura actual
ok, linea = preparar_linea(fila) ok, linea = preparar_linea(fila)
if not ok: if not ok:
ref = factura["reference"] if factura else "(desconocida)"
logger.info( 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}") logger.info(f">>>>>> Faltan campos requeridos: {linea}")
factura = None factura = None
else: continue
# Línea válida
# Garantizamos que factura no es None
if factura is not None:
factura["lineas"].append(linea) 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: except Exception as e:
# Escribir el error en el archivo de errores # Escribir el error en el archivo de errores
@ -92,58 +109,95 @@ def procesar_factura_verifactu(
config: Dict[str, Any] config: Dict[str, Any]
) -> bool: ) -> bool:
if factura != None: if factura is None:
# Creamos registro de factura en verifactu return False
if factura.get('uuid') == '':
# logger.info(f"Send to create Verifactu: {factura}") # Creamos registro de factura en verifactu
respuesta = crear_factura(factura, config) if factura.get('uuid') == '':
if respuesta.get("status") == 200 and respuesta.get("ok"): # logger.info(f"Send to create Verifactu: {factura}")
data = respuesta.get("data") respuesta = crear_factura(factura, config)
qr_verifactu = f"data:image/png;base64,{data.get('qr', '')}"
cursor_mysql.execute(SQL.update_verifactu_records_with_invoiceId, (data.get("estado"), data.get( if respuesta.get("status") == 200 and respuesta.get("ok"):
"uuid"), data.get("url"), qr_verifactu, factura.get("id"))) data = respuesta.get("data") or {}
logger.info( qr_verifactu = f"data:image/png;base64,{data.get('qr', '')}"
f">>> Factura {factura.get("reference")} registrada en Verifactu")
return True cursor_mysql.execute(
else: SQL.update_verifactu_records_with_invoiceId,
logger.info( (
f">>> Factura {factura.get("reference")} enviada a Verifactu con error {respuesta}") data.get("estado"),
return False data.get("uuid"),
# Actualizamos registro de factura en verifactu data.get("url"),
qr_verifactu,
factura.get("id"),
)
)
logger.info(
f">>> Factura {factura.get('reference')} registrada en Verifactu")
return True
else: else:
# logger.info(f"Send to update Verifactu: {factura}") logger.info(
respuesta = estado_factura(factura.get('uuid'), config) f">>> Factura {factura.get('reference')} enviada a Verifactu con error {respuesta}")
if respuesta.get("status") == 200 and respuesta.get("ok"): return False
data = respuesta.get("data")
cursor_mysql.execute(SQL.update_verifactu_records_with_uuid, (data.get( # Actualizamos registro de factura en verifactu
'estado'), data.get('operacion'), factura.get('uuid')))
logger.info( # logger.info(f"Send to update Verifactu: {factura}")
f">>> Factura {factura.get("reference")} actualizado registro de Verifactu") uuid_val = factura.get("uuid")
return True if not uuid_val: # None, '', o falsy
else: logger.error(f"Factura {factura.get('reference')} sin UUID válido")
return False 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'. 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) ok, missing = validar_requeridos(fila, campos_requeridos)
if not ok: if not ok:
return False, missing # Devolvemos string, NO lista
return False, None, ", ".join(missing)
factura = { factura = {
"nif": fila['customer_tin'], "nif": fila['customer_tin'],
"nombre": fila['customer_name'], "nombre": fila['customer_name'],
"serie": fila['series'], "serie": fila['series'],
"numero": fila['invoice_number'], "numero": fila['invoice_number'],
# convertimos la fecha al formato requerido en el api # convertimos la fecha al formato requerido en el api
"fecha_expedicion": fila['invoice_date'].strftime("%d-%m-%Y"), "fecha_expedicion": fila['invoice_date'].strftime("%d-%m-%Y"),
# F1: Factura (Art. 6, 7.2 Y 7.3 del RD 1619/2012) # F1: Factura (Art. 6, 7.2 Y 7.3 del RD 1619/2012)
"tipo_factura": "F1", "tipo_factura": "F1",
"descripcion": fila['description'], "descripcion": fila['description'],
# desescalamos el importe para dar su importe real # desescalamos el importe para dar su importe real
"importe_total": unscale_to_str(str(fila['total_amount_value']), str(fila['total_amount_scale'])), "importe_total": unscale_to_str(str(fila['total_amount_value']), str(fila['total_amount_scale'])),
"lineas": [], "lineas": [],
@ -154,7 +208,7 @@ def preparar_factura(fila: Dict[str, Any]) -> Tuple[bool, Dict[str, Any] | list]
"uuid": fila['uuid'], "uuid": fila['uuid'],
} }
return True, factura return True, factura, None
def preparar_linea(fila: Dict[str, Any]) -> Tuple[bool, Dict[str, Any] | list]: def preparar_linea(fila: Dict[str, Any]) -> Tuple[bool, Dict[str, Any] | list]:

View File

@ -17,7 +17,7 @@ def send_orders_mail(inserted_orders):
for order in inserted_orders: for order in inserted_orders:
send_smtp_email = brevo_python.SendSmtpEmail( send_smtp_email = brevo_python.SendSmtpEmail(
to=[{'email': config['MAIL_TO']}], 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"]), template_id=int(config["BREVO_EMAIL_TEMPLATE"]),
params={ params={
"customer_reference": order["customer_reference"], "customer_reference": order["customer_reference"],

View File

@ -1,11 +1,13 @@
from typing import Any, Dict, Optional, Tuple
import requests import requests
from app.config import logger from app.config import logger
from typing import Optional, Dict, Any, Tuple
def estado_factura(uuid_str: str, def estado_factura(uuid_str: str,
config, config,
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: ) -> Dict[str, Any]:
""" """
Llama al endpoint de Verifacti para cosultar el estado de registro. 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: except requests.RequestException as e:
logger.error("Error de conexión con la API Verifacti: %s", 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: except ValueError as e:
logger.error("Respuesta no es JSON válido: %s", 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, def crear_factura(payload,
config, config,
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: ) -> Dict[str, Any]:
""" """
Llama al endpoint de Verifacti para crear una factura. Llama al endpoint de Verifacti para crear una factura.
@ -107,10 +109,10 @@ def crear_factura(payload,
except requests.RequestException as e: except requests.RequestException as e:
logger.error("Error de conexión con la API Verifacti: %s", 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: except ValueError as e:
logger.error("Respuesta no es JSON válido: %s", 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( def validar_nif(
@ -140,7 +142,7 @@ def validar_nif(
resp = requests.post( resp = requests.post(
url, json=payload, headers=headers, timeout=timeout) url, json=payload, headers=headers, timeout=timeout)
if resp.status_code != 200: 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}" # return False, None, f"HTTP {resp.status_code}: {resp.text}"
data = resp.json() data = resp.json()

View File

@ -1,9 +1,8 @@
from app.config import logger
import json import json
from decimal import Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Iterable, Optional, Tuple
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, Optional, Tuple
def map_tax_code(raw: str) -> str: def map_tax_code(raw: str) -> str:

View File

@ -1,21 +1,21 @@
# SYNC
ENV = development ENV = development
LOCAL_TZ = Europe/Madrid 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_HOST = acana.mywire.org
FACTUGES_PORT = 63050 FACTUGES_PORT = 63050
FACTUGES_DATABASE = D:\Rodax\BD\FACTUGES.FDB FACTUGES_DATABASE = D:\Rodax\BD\FACTUGES.FDB
FACTUGES_USER = sysdba FACTUGES_USER = sysdba
FACTUGES_PASSWORD = masterkey 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' 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_SERIE = 'F25/'
CTE_STATUS_INVOICE = 'issued' CTE_STATUS_INVOICE = 'issued'
CTE_IS_PROFORMA = 0 CTE_IS_PROFORMA = 0
@ -26,5 +26,6 @@ CTE_IS_COMPANY = 1
CTE_SYNC_RESULT_OK = 1 CTE_SYNC_RESULT_OK = 1
CTE_SYNC_RESULT_FAIL = 2 CTE_SYNC_RESULT_FAIL = 2
VERIFACTU_API_KEY = vf_test_ei8WYAvEq5dhSdEyQVjgCS8NZaNpEK2BljSHSUXf+Y0=
VERIFACTU_BASE_URL = https://api.verifacti.com/ VERIFACTU_BASE_URL = https://api.verifacti.com/
VERIFACTU_NIFS_API_KEY = vfn_osYpNdqSzAdTAHpazXG2anz4F3o0gfbSb5FFrCBZcno= VERIFACTU_NIFS_API_KEY = vfn_osYpNdqSzAdTAHpazXG2anz4F3o0gfbSb5FFrCBZcno=

View File

@ -4,7 +4,7 @@ services:
container_name: "factuges-sync-factuges-acana" container_name: "factuges-sync-factuges-acana"
restart: "no" restart: "no"
environment: environment:
ENV: "prod" ENV: "production"
LOCAL_TZ: "Europe/Madrid" LOCAL_TZ: "Europe/Madrid"
LAST_RUN_PATH: "${LAST_RUN_PATH}" LAST_RUN_PATH: "${LAST_RUN_PATH}"
@ -29,7 +29,11 @@ services:
CTE_COUNTRY_CODE: "${CTE_COUNTRY_CODE}" CTE_COUNTRY_CODE: "${CTE_COUNTRY_CODE}"
CTE_IS_COMPANY: "${CTE_IS_COMPANY}" CTE_IS_COMPANY: "${CTE_IS_COMPANY}"
CTE_SYNC_RESULT_OK: "${CTE_SYNC_RESULT_OK}" 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@ -1,6 +1,6 @@
[metadata] [metadata]
name = factuges-sync name = factuges-sync
version = 0.0.15 version = 0.0.21
description = ETL job to sync data from legacy DB to MariaDB description = ETL job to sync data from legacy DB to MariaDB
author = Rodax Software author = Rodax Software
author_email = info@rodax-software.com author_email = info@rodax-software.com
@ -39,10 +39,12 @@ install_requires =
PyNaCl==1.5.0 PyNaCl==1.5.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-dotenv==1.0.0 python-dotenv==1.0.0
requests==2.32.5
six==1.16.0 six==1.16.0
sshtunnel==0.4.0 sshtunnel==0.4.0
striprtf==0.0.29
urllib3==2.2.3 urllib3==2.2.3
uuid6 uuid6==2025.0.1
[options.extras_require] [options.extras_require]
dev = dev =
@ -51,6 +53,7 @@ dev =
packaging==24.1 packaging==24.1
pathspec==0.12.1 pathspec==0.12.1
platformdirs==4.3.6 platformdirs==4.3.6
ruff==0.14.7
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
@ -62,9 +65,9 @@ where = .
[tool:pytest] [tool:pytest]
testpaths = tests testpaths = tests
[flake8]
max-line-length = 88
exclude = .git,__pycache__,build,dist
[isort] [isort]
profile = black profile = black
[tool.ruff]
line-length = 88
target-version = "py312"