diff --git a/app/db/sql_sentences.py b/app/db/sql_sentences.py index deccd78..d15e930 100644 --- a/app/db/sql_sentences.py +++ b/app/db/sql_sentences.py @@ -1,5 +1,5 @@ # ========================= -# SQL (constantes) +# MYSQL (constantes) # ========================= SELECT_CUSTOMER_BY_FACTUGES = ( "SELECT customers.id FROM customers WHERE customers.factuges_id=%s" @@ -30,6 +30,27 @@ INSERT_PAYMENT_METHOD = ( ) INSERT_INVOICE = ( + "INSERT INTO customer_invoices (id, company_id, invoice_number, status, series, reference, invoice_date, operation_date, description, " + "subtotal_amount_value, discount_amount_value, discount_percentage_value, taxable_amount_value, taxes_amount_value, total_amount_value, " + "customer_id, customer_tin, customer_name, customer_street, customer_city, customer_province, customer_postal_code, customer_country, " + "payment_method_id, payment_method_description, factuges_id, " + "subtotal_amount_scale, discount_amount_scale, discount_percentage_scale, taxable_amount_scale, taxes_amount_scale, total_amount_scale, " + "language_code, currency_code, created_at, updated_at) " + "SELECT " + "%s AS id, " + "%s AS company_id, " + "COALESCE(MAX(invoice_number + 0),0)+1 AS invoice_number, " + "%s AS status, %s AS series, %s AS reference, %s AS invoice_date, %s AS operation_date, %s AS description, " + "%s AS subtotal_amount_value, %s AS discount_amount_value, %s AS discount_percentage_value, %s AS taxable_amount_value, %s AS taxes_amount_value, %s AS total_amount_value, " + "%s AS customer_id, %s AS customer_tin, %s AS customer_name, %s AS customer_street, %s AS customer_city, %s AS customer_province, %s AS customer_postal_code, %s AS customer_country, " + "%s AS payment_method_id, %s AS payment_method_description, %s AS factuges_id, " + "2,2,2,2,2,2, 'es','EUR', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP " + "FROM customer_invoices " + "WHERE company_id = %s " + "AND deleted_at is null" +) + +INSERT_INVOICE_BAK = ( "INSERT INTO customer_invoices (id, company_id, invoice_number, status, series, reference, invoice_date, operation_date, description, " "subtotal_amount_value, discount_amount_value, discount_percentage_value, taxable_amount_value, taxes_amount_value, total_amount_value, " "customer_id, customer_tin, customer_name, customer_street, customer_city, customer_province, customer_postal_code, customer_country, " @@ -39,6 +60,7 @@ INSERT_INVOICE = ( "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,2,2,2,2,2,2,'es','EUR',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)" ) + INSERT_INVOICE_ITEM = ( "INSERT INTO customer_invoice_items " "(item_id, invoice_id, position, description, quantity_value, unit_amount_value, " @@ -59,6 +81,37 @@ INSERT_INVOICE_ITEM_TAX = ( "VALUES (%s,%s,%s,%s,%s,4,2,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)" ) +SELECT_INVOICES_DELETED = ( + "SELECT ci.id " + "FROM customer_invoices as ci " + "WHERE " + "(ci.deleted_at is not null) " +) + + +# ========================= +# FIREBIRD (constantes) +# ========================= +SELECT_FACTUGES_FACTURAS_CLIENTE = ( + f"SELECT fac.VERIFACTU, fac.ID_VERIFACTU, fac.ID || '' AS ID, fac.ID_EMPRESA || '' AS ID_EMPRESA, fac.REFERENCIA, fac.FECHA_FACTURA, fac.ID_CLIENTE || '' as ID_CLIENTE, fac.NIF_CIF, fac.NOMBRE, " + f"fac.CALLE, fac.POBLACION, fac.PROVINCIA, fac.CODIGO_POSTAL, fac.FECHA_ALTA, " + f"fac.IMPORTE_NETO, fac.DESCUENTO, fac.IMPORTE_DESCUENTO, fac.BASE_IMPONIBLE, fac.IVA, fac.IMPORTE_IVA, fac.IMPORTE_TOTAL, " + f"fac.ID_FORMA_PAGO, fp.DESCRIPCION as DES_FORMA_PAGO, fac.ID_TIPO_IVA, ti.REFERENCIA as DES_TIPO_IVA, fac.RECARGO_EQUIVALENCIA, fac.RE, fac.IMPORTE_RE, " + f"fac.ID_CLIENTE, fac.NIF_CIF, fac.NOMBRE, fac.CALLE, fac.POBLACION, fac.PROVINCIA, fac.CODIGO_POSTAL, " + f"cc.TELEFONO_1, cc.TELEFONO_2, cc.MOVIL_1, cc.MOVIL_2, cc.EMAIL_1, cc.EMAIL_2, cc.PAGINA_WEB, " + f"facdet.ID || '' as ID_DET, facdet.ID_FACTURA, facdet.POSICION, facdet.TIPO_DETALLE, facdet.ID_ARTICULO, facdet.CONCEPTO, facdet.CANTIDAD, " + f"facdet.IMPORTE_UNIDAD, facdet.DESCUENTO as DESCUENTO_DET, facdet.IMPORTE_TOTAL as IMPORTE_TOTAL_DET, facdet.VISIBLE, facdet.FECHA_ALTA as FECHA_ALTA_DET, facdet.FECHA_MODIFICACION as FECHA_MODIFICACION_DET " + f"FROM FACTURAS_CLIENTE AS fac " + f"LEFT JOIN CONTACTOS AS cc ON fac.ID_CLIENTE = cc.ID " + f"LEFT JOIN FORMAS_PAGO AS fp ON fac.ID_FORMA_PAGO = fp.ID " + f"LEFT JOIN TIPOS_IVA AS ti ON fac.ID_TIPO_IVA = ti.ID " + f"LEFT JOIN FACTURAS_CLIENTE_DETALLES as facdet ON fac.ID = facdet.ID_FACTURA " + f"WHERE " + f"(fac.VERIFACTU > 0) " + f"AND (fac.ID_VERIFACTU is null)" + f"ORDER BY (fac.ID)" +) + UPDATE_FACTUGES_LINK = ( "UPDATE FACTURAS_CLIENTE " "SET ID_VERIFACTU=? " @@ -70,5 +123,4 @@ LIMPIAR_FACTUGES_LINK = ( "SET ID_VERIFACTU = NULL, " "VERIFACTU = 0 " "WHERE (ID_VERIFACTU = ?)" - ) - +) diff --git a/app/db/sync_invoices.py b/app/db/sync_invoices.py index ecb4dbe..a7cffe6 100644 --- a/app/db/sync_invoices.py +++ b/app/db/sync_invoices.py @@ -1,64 +1,33 @@ import logging import textwrap -from . import sql_sentences as SQL +from typing import Dict, Any, Optional, Tuple from uuid6 import uuid7 from config import load_config from decimal import Decimal, ROUND_HALF_UP -from utils import limpiar_cadena, normalizar_telefono_con_plus, corregir_y_validar_email, normalizar_url_para_insert +from . import sql_sentences as SQL +from utils import limpiar_cadena, normalizar_telefono_con_plus, corregir_y_validar_email, normalizar_url_para_insert, map_tax_code, cents, money_round, tax_fraction_from_code def sync_invoices(conn_factuges, conn_mysql, last_execution_date): config = load_config() - # Recorrer todas las facturas eliminadas para liberarlas en factuges - # VERIFACTU = 0 and ID_VERIFACTU = NULL - consulta_sql_customer_invoices_deleted = ( - f"SELECT ci.id " - f"FROM customer_invoices as ci " - f"WHERE " - f"(ci.deleted_at is not null) " - ) - - consulta_sql_FACTURAS_CLIENTE = ( - f"SELECT fac.VERIFACTU, fac.ID_VERIFACTU, fac.ID || '' AS ID, fac.ID_EMPRESA || '' AS ID_EMPRESA, fac.REFERENCIA, fac.FECHA_FACTURA, fac.ID_CLIENTE || '' as ID_CLIENTE, fac.NIF_CIF, fac.NOMBRE, " - f"fac.CALLE, fac.POBLACION, fac.PROVINCIA, fac.CODIGO_POSTAL, fac.FECHA_ALTA, " - f"fac.IMPORTE_NETO, fac.DESCUENTO, fac.IMPORTE_DESCUENTO, fac.BASE_IMPONIBLE, fac.IVA, fac.IMPORTE_IVA, fac.IMPORTE_TOTAL, " - f"fac.ID_FORMA_PAGO, fp.DESCRIPCION as DES_FORMA_PAGO, fac.ID_TIPO_IVA, ti.REFERENCIA as DES_TIPO_IVA, fac.RECARGO_EQUIVALENCIA, fac.RE, fac.IMPORTE_RE, " - f"fac.ID_CLIENTE, fac.NIF_CIF, fac.NOMBRE, fac.CALLE, fac.POBLACION, fac.PROVINCIA, fac.CODIGO_POSTAL, " - f"cc.TELEFONO_1, cc.TELEFONO_2, cc.MOVIL_1, cc.MOVIL_2, cc.EMAIL_1, cc.EMAIL_2, cc.PAGINA_WEB, " - f"facdet.ID || '' as ID_DET, facdet.ID_FACTURA, facdet.POSICION, facdet.TIPO_DETALLE, facdet.ID_ARTICULO, facdet.CONCEPTO, facdet.CANTIDAD, " - f"facdet.IMPORTE_UNIDAD, facdet.DESCUENTO as DESCUENTO_DET, facdet.IMPORTE_TOTAL as IMPORTE_TOTAL_DET, facdet.VISIBLE, facdet.FECHA_ALTA as FECHA_ALTA_DET, facdet.FECHA_MODIFICACION as FECHA_MODIFICACION_DET " - f"FROM FACTURAS_CLIENTE AS fac " - f"LEFT JOIN CONTACTOS AS cc ON fac.ID_CLIENTE = cc.ID " - f"LEFT JOIN FORMAS_PAGO AS fp ON fac.ID_FORMA_PAGO = fp.ID " - f"LEFT JOIN TIPOS_IVA AS ti ON fac.ID_TIPO_IVA = ti.ID " - f"LEFT JOIN FACTURAS_CLIENTE_DETALLES as facdet ON fac.ID = facdet.ID_FACTURA " - f"WHERE " - f"(fac.VERIFACTU > 0) " - f"AND (fac.ID_VERIFACTU is null)" - f"ORDER BY (fac.ID)" - ) - # LIMPIAMOS LAS FACTURAS DE FACTUGES QUE HAYAN SIDO ELIMINADAS DEL PROGRAMA NUEVO DE FACTURACION, PARA QUE PUEDAN SER MODIFICADAS # Crear un cursor para ejecutar consultas SQL cursor_mysql = None try: cursor_mysql = conn_mysql.cursor() - # Ejecutar la consulta de FACTURAS_CLIENTE - cursor_mysql.execute(consulta_sql_customer_invoices_deleted) + cursor_mysql.execute(SQL.SELECT_INVOICES_DELETED) filas = cursor_mysql.fetchall() cursor_mysql.close() # Crear un conjunto con los IDs [0] de los customer_inovices que debo liberar en FactuGES, porque han sido eliminadas en programa de facturación nuevo ids_verifactu_deleted = {str(fila[0]) for fila in filas} - logging.info(f"Customer invoices rows to be deleted: { - len(ids_verifactu_deleted)}") - + # logging.info(f"Customer invoices rows to be deleted: {len(ids_verifactu_deleted)}") # Verificar si hay filas en el resultado if ids_verifactu_deleted: eliminar_datos(conn_factuges, ids_verifactu_deleted, config) else: - logging.info(f"There are no rows to deleted") + logging.info(f"There are customer invoices deleted") except Exception as e: if cursor_mysql is not None: @@ -74,7 +43,7 @@ def sync_invoices(conn_factuges, conn_mysql, last_execution_date): try: cursor_FactuGES = conn_factuges.cursor() # Ejecutar la consulta de FACTURAS_CLIENTE - cursor_FactuGES.execute(consulta_sql_FACTURAS_CLIENTE) + cursor_FactuGES.execute(SQL.SELECT_FACTUGES_FACTURAS_CLIENTE) filas = cursor_FactuGES.fetchall() except Exception as e: if cursor_FactuGES is not None: @@ -93,10 +62,6 @@ def sync_invoices(conn_factuges, conn_mysql, last_execution_date): for fila in filas: tupla = dict(zip(columnas, fila)) tuplas_seleccionadas.append(tupla) - - logging.info( - f"FACTURAS_CLIENTE_DETALLE rows to be processed: {len(tuplas_seleccionadas)}") - # Verificar si hay filas en el resultado if tuplas_seleccionadas: insertar_datos(conn_mysql, tuplas_seleccionadas, conn_factuges, config) @@ -106,8 +71,8 @@ def sync_invoices(conn_factuges, conn_mysql, last_execution_date): def eliminar_datos(conn_factuges, ids_verifactu_deleted, config): - # Eliminamos todos los IDs de verifacti que han sido eliminados así liberaremos la factura borrador y podermos modificarla de nuevo, para volverla a subir una vez hechos los cambios. - + # Eliminamos todos los IDs asociados en FactuGES que han sido eliminados así liberaremos la factura borrador y podermos modificarla de nuevo, para volverla a subir una vez hechos los cambios. + # VERIFACTU = 0 and ID_VERIFACTU = NULL cursor_FactuGES = None try: cursor_FactuGES = conn_factuges.cursor() @@ -130,79 +95,36 @@ def eliminar_datos(conn_factuges, ids_verifactu_deleted, config): def insertar_datos(conn_mysql, filas, conn_factuges, config): # Insertaremos cada factura existente en las filas a la nueva estructura de tablas del programa nuevo de facturacion. + # logging.info(f"FACTURAS_CLIENTE_DETALLE rows to be processed: {len(filas)}") # Compañia RODAX cte_company_id = '5e4dc5b3-96b9-4968-9490-14bd032fec5f' - cursorMySQL = None cursor_FactuGES = None factuges_id_anterior = None id_customer_invoice = None num_fac_procesed = 0 + try: cursorMySQL = conn_mysql.cursor() cursor_FactuGES = conn_factuges.cursor() - contador_serie = 0 + # Insertar datos en la tabla 'customer_invoices' for factura_detalle in filas: - factuges_id = int(factura_detalle['ID_FACTURA']) - invoice_status = str('draft') - invoice_series = str('F25/') - reference = str(factura_detalle['REFERENCIA']) - invoice_date = str(factura_detalle['FECHA_FACTURA']) - operation_date = str(factura_detalle['FECHA_FACTURA']) - # siempre tendrán 2 decimales - subtotal_amount_value = (factura_detalle['IMPORTE_NETO'] or 0)*100 - discount_amount_value = ( - factura_detalle['IMPORTE_DESCUENTO'] or 0)*100 - discount_percentage_value = ( - factura_detalle['DESCUENTO'] or 0)*100 if (factura_detalle['DESCUENTO']) is not None else 0 - taxable_amount_value = (factura_detalle['BASE_IMPONIBLE'] or 0)*100 # Preparamos el tipo de IVA, en FactuGES es único - tax_code = str(factura_detalle['DES_TIPO_IVA']) - if (tax_code == 'IVA21') or (tax_code == 'IVA 21'): - tax_code = 'iva_21' - elif tax_code == 'IVA18' or (tax_code == 'IVA 18'): - tax_code = 'iva_18' - elif tax_code == 'IVA16' or (tax_code == 'IVA 16'): - tax_code = 'iva_16' - elif tax_code == 'IVA10' or (tax_code == 'IVA 10'): - tax_code = 'iva_10' - elif tax_code == 'IVA4' or (tax_code == 'IVA 4'): - tax_code = 'iva_4' - elif tax_code == 'EXENTO': - tax_code = 'iva_exenta' - else: - tax_code = '' + # Map tax code (cabecera) + tax_code = map_tax_code(str(factura_detalle.get("DES_TIPO_IVA"))) + # La cuota de impuestos es el IVA + RE tax_amount_value = ( (factura_detalle['IMPORTE_IVA'] or 0) + (factura_detalle['IMPORTE_RE'] or 0))*100 - total_amount_value = (factura_detalle['IMPORTE_TOTAL'] or 0)*100 - - payment_method_id = str(uuid7()) factuges_payment_method_id = str(factura_detalle['ID_FORMA_PAGO']) payment_method_description = str(factura_detalle['DES_FORMA_PAGO']) - customer_id = str(uuid7()) factuges_customer_id = str(factura_detalle['ID_CLIENTE']) customer_tin = limpiar_cadena(str(factura_detalle['NIF_CIF'])) - customer_name = str(factura_detalle['NOMBRE']) - customer_street = str(factura_detalle['CALLE']) - customer_city = str(factura_detalle['POBLACION']) - customer_province = str(factura_detalle['PROVINCIA']) - customer_postal_code = str(factura_detalle['CODIGO_POSTAL']) - customer_phone_primary = factura_detalle['TELEFONO_1'] - customer_phone_secondary = factura_detalle['TELEFONO_2'] - customer_mobile_primary = factura_detalle['MOVIL_1'] - customer_mobile_secondary = factura_detalle['MOVIL_2'] - customer_email_primary = factura_detalle['EMAIL_1'] - customer_email_secondary = factura_detalle['EMAIL_2'] - customer_webside = str(factura_detalle['PAGINA_WEB']) - customer_country = 'es' - description = textwrap.shorten( - f"{reference or ''} - {customer_name or ''}", width=50, placeholder="…") item_position = int(factura_detalle['POSICION']) item_description = str(factura_detalle['CONCEPTO']) @@ -213,10 +135,6 @@ def insertar_datos(conn_mysql, filas, conn_factuges, config): Descuento = factura_detalle['DESCUENTO_DET'] item_discount_percentage_value = None if Descuento is None else None if Descuento == 0 else ( factura_detalle['DESCUENTO_DET'])*100 - item_discount_amount = None - # Se calcula en el objeto de negocio de nuevo, comprobar si coincide - # item_discount_amount = ( - # (factura_detalle['IMPORTE_UNIDAD'] or 0)*((factura_detalle['DESCUENTO'] or 0)/100))*100 item_total_amount = (factura_detalle['IMPORTE_TOTAL_DET'] or 0)*100 # None if factura_detalle['IMPORTE_TOTAL_DET'] is None else ( @@ -227,123 +145,256 @@ def insertar_datos(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['OBSERVACIONES']) + factuges_id = int(factura_detalle['ID_FACTURA']) + if factuges_id_anterior is None or factuges_id_anterior != factuges_id: # Comprobamos si existe el cliente del primer item de la factura - cursorMySQL.execute(SQL.SELECT_CUSTOMER_BY_FACTUGES, - (factuges_customer_id, )) - row = cursorMySQL.fetchone() - is_new = (row is None) or (row[0] is None) + # cursorMySQL.execute(SQL.SELECT_CUSTOMER_BY_FACTUGES, + # (factuges_customer_id, )) + # row = cursorMySQL.fetchone() + # is_new = (row is None) or (row[0] is None) - #Validamos los campos que pueden dar conflicto - customer_phone_primary_tratado = normalizar_telefono_con_plus(customer_phone_primary) - customer_phone_secondary_tratado = normalizar_telefono_con_plus(customer_phone_secondary) - customer_mobile_primary_tratado = normalizar_telefono_con_plus(customer_mobile_primary) - customer_mobile_secondary_tratado = normalizar_telefono_con_plus(customer_mobile_secondary) - customer_webside_tratado = normalizar_url_para_insert(customer_webside) - customer_email_primary_ok, customer_email_primary_tratado = corregir_y_validar_email(customer_email_primary) - customer_email_secondary_ok, customer_email_secondary_tratado = corregir_y_validar_email(customer_email_secondary) - + # ---- cliente + customer_fields = normalize_customer_fields(factura_detalle) + customer_id = get_or_create_customer( + cursorMySQL, + cte_company_id, + str(factura_detalle["ID_CLIENTE"]), + customer_fields, + ) - logging.info(f"La ficha de cliente {customer_tin} se modifican los siguientes campos:") - if (customer_phone_primary_tratado != customer_phone_primary): - logging.info(f"phone_primary ({customer_phone_primary}) se cambia por ({customer_phone_primary_tratado})") - if (customer_phone_secondary_tratado != customer_phone_secondary): - logging.info(f"phone_secondary ({customer_phone_secondary}) se cambia por ({customer_phone_secondary_tratado})") - if (customer_mobile_primary_tratado != customer_mobile_primary): - logging.info(f"mobile_primary ({customer_mobile_primary}) se cambia por ({customer_mobile_primary_tratado})") - if (customer_mobile_secondary_tratado != customer_mobile_secondary): - logging.info(f"mobile_secondary ({customer_mobile_secondary}) se cambia por ({customer_mobile_secondary_tratado})") - if (customer_webside_tratado != customer_webside): - logging.info(f"customer_webside ({customer_webside}) se cambia por ({customer_webside_tratado})") + # ---- forma de pago + pm_id = get_or_create_payment_method( + cursorMySQL, + str(factura_detalle["ID_FORMA_PAGO"]), + str(factura_detalle["DES_FORMA_PAGO"]), + ) - if customer_email_primary_ok: - if (customer_email_primary_tratado != customer_email_primary): - logging.info(f"email_primary ({customer_email_primary}) se cambia por ({customer_email_primary_tratado})") - else: - logging.info(f"Omitimos email_primary invalido({customer_email_primary})") - if customer_email_secondary_ok: - if (customer_email_secondary_tratado != customer_email_secondary): - logging.info(f"email_secondary ({customer_email_secondary}) se cambia por ({customer_email_secondary_tratado})") - else: - logging.info(f"Omitimos email_secondary invalido({customer_email_secondary})") + # ---- cabecera factura + invoice_id, tax_code = insert_invoice_header( + cursorMySQL, cte_company_id, factura_detalle, customer_id, pm_id, str( + factura_detalle["DES_FORMA_PAGO"]) + ) - - if is_new: - logging.info( - f"Inserting customer {factuges_customer_id} {customer_tin} {customer_name}") - cursorMySQL.execute(SQL.INSERT_CUSTOMER, (customer_id, customer_name, customer_tin, customer_street, customer_city, customer_province, - customer_postal_code, customer_country, customer_phone_primary_tratado, customer_phone_secondary_tratado, customer_mobile_primary_tratado, - customer_mobile_secondary_tratado, customer_email_primary_tratado, customer_email_secondary_tratado, customer_webside_tratado, factuges_customer_id, cte_company_id)) - else: - # Si ya exite ponemos el id del customer correspondiente - customer_id = str(row[0]) - logging.info( - f"Updating customer {factuges_customer_id} {customer_id}") - cursorMySQL.execute(SQL.UPDATE_CUSTOMER, (customer_name, customer_tin, customer_street, customer_city, customer_province, customer_postal_code, customer_country, - customer_phone_primary_tratado, customer_phone_secondary_tratado, customer_mobile_primary_tratado, customer_mobile_secondary_tratado, - customer_email_primary_tratado, customer_email_secondary_tratado, customer_webside_tratado, customer_id)) - - - # Comprobamos si existe la forma de pago del primer item de la factura - cursorMySQL.execute(SQL.SELECT_PAYMENT_METHOD_BY_FACTUGES, - (factuges_payment_method_id, )) - row = cursorMySQL.fetchone() - is_new = (row is None) or (row[0] is None) - - if is_new: - logging.info( - f"Inserting cuspayment method {factuges_payment_method_id} {payment_method_id} {payment_method_description}") - cursorMySQL.execute(SQL.INSERT_PAYMENT_METHOD, ( - payment_method_id, payment_method_description, factuges_payment_method_id)) - else: - # Si ya exite ponemos el id del customer correspondiente - payment_method_id = str(row[0]) - logging.info( - f"Updating customer {factuges_payment_method_id} {payment_method_id}") - # cursorMySQL.execute(update_customer_query, .....) - - # Insertamos cabecera de la factura - # Generar un ID único para la tabla customer_invoices - id_customer_invoice = str(uuid7()) - contador_serie = contador_serie + 1 - logging.info( - f"Inserting customer_invoice {id_customer_invoice} {reference} {invoice_date}") - cursorMySQL.execute(SQL.INSERT_INVOICE, (id_customer_invoice, cte_company_id, contador_serie, invoice_status, invoice_series, reference, invoice_date, operation_date, description, - subtotal_amount_value, discount_amount_value, discount_percentage_value, taxable_amount_value, tax_amount_value, total_amount_value, - customer_id, customer_tin, customer_name, customer_street, customer_city, customer_province, customer_postal_code, customer_country, - payment_method_id, payment_method_description)) - - - # Insertamos el IVA y RE si viene - if (factura_detalle['IVA'] >= 0): - taxable_amount_value = ( - factura_detalle['BASE_IMPONIBLE'])*100 - tax_amount_value = (factura_detalle['IMPORTE_IVA'])*100 - cursorMySQL.execute(SQL.INSERT_INVOICE_TAX, (str(uuid7()), - id_customer_invoice, tax_code, taxable_amount_value, tax_amount_value)) - - if (factura_detalle['RECARGO_EQUIVALENCIA'] > 0): - tax_code = 're_5_2' - taxable_amount_value = ( - factura_detalle['BASE_IMPONIBLE'])*100 - tax_amount_value = (factura_detalle['IMPORTE_RE'])*100 - cursorMySQL.execute(SQL.INSERT_INVOICE_TAX, (str(uuid7()), - id_customer_invoice, tax_code, taxable_amount_value, tax_amount_value)) + # ---- impuestos cabecera + insert_header_taxes_if_any( + cursorMySQL, invoice_id, factura_detalle, tax_code) # Guardamos en Factuges el id de la customer_invoice logging.info( - f"Updating FACTURAS_CLIENTE {id_customer_invoice} {factuges_id}") + f"Updating FACTURAS_CLIENTE {invoice_id} {factuges_id}") cursor_FactuGES.execute( - SQL.UPDATE_FACTUGES_LINK, (id_customer_invoice, factuges_id)) + SQL.UPDATE_FACTUGES_LINK, (invoice_id, factuges_id)) num_fac_procesed += 1 # Insertamos detalles y taxes correspondientes siempre - # Generar un ID único para la tabla customer_invoice_items - item_id = str(uuid7()) - logging.info( - f"Inserting customer_invoice_items {id_customer_invoice} {item_position} {item_quantity_value}") - cursorMySQL.execute(SQL.INSERT_INVOICE_ITEM, (item_id, id_customer_invoice, item_position, item_description, - item_quantity_value, item_unit_amount_value, item_discount_percentage_value, item_discount_amount, item_total_amount)) + # Siempre insertamos la línea + insert_item_and_taxes(cursorMySQL, invoice_id, + factura_detalle, tax_code) + + # Asignamos el id factura anterior para no volver a inserta cabecera + factuges_id_anterior = factuges_id + + logging.info( + f"FACTURAS_CLIENTE rows to be processed: {str(num_fac_procesed)}") + + except Exception as e: + # Escribir el error en el archivo de errores + logging.error(str(e)) + raise e # Re-lanzar la excepción para detener el procesamiento + + finally: + # Cerrar la conexión + if cursorMySQL is not None: + cursorMySQL.close() + if cursor_FactuGES is not None: + cursor_FactuGES.close() + + +def normalize_customer_fields(fd: Dict[str, Any]) -> Dict[str, Any]: + """ + Normaliza campos de cliente del registro de origen 'fd' (factura_detalle). + """ + # >>> aquí usa tus helpers reales: + def clean_phone(v): return normalizar_telefono_con_plus(v) + def clean_web(v): return normalizar_url_para_insert(v) + def clean_tin(v): return limpiar_cadena(v) + + email1_ok, email1 = corregir_y_validar_email(fd.get("EMAIL_1")) + email2_ok, email2 = corregir_y_validar_email(fd.get("EMAIL_2")) + + return { + "tin": clean_tin(str(fd.get("NIF_CIF"))), + "name": str(fd.get("NOMBRE")), + "street": str(fd.get("CALLE")), + "city": str(fd.get("POBLACION")), + "province": str(fd.get("PROVINCIA")), + "postal_code": str(fd.get("CODIGO_POSTAL")), + "country": "es", + "phone_primary": clean_phone(fd.get("TELEFONO_1")), + "phone_secondary": clean_phone(fd.get("TELEFONO_2")), + "mobile_primary": clean_phone(fd.get("MOVIL_1")), + "mobile_secondary": clean_phone(fd.get("MOVIL_2")), + "email_primary": email1 if email1_ok else None, + "email_secondary": email2 if email2_ok else None, + "website": clean_web(str(fd.get("PAGINA_WEB"))), + } + + +def get_or_create_customer(cur, company_id: str, factuges_customer_id: str, fields: Dict[str, Any]) -> str: + cur.execute(SQL.SELECT_CUSTOMER_BY_FACTUGES, (factuges_customer_id,)) + row = cur.fetchone() + if not row or not row[0]: + customer_id = str(uuid7()) + logging.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["phone_primary"], fields["phone_secondary"], + fields["mobile_primary"], fields["mobile_secondary"], fields["email_primary"], fields["email_secondary"], + fields["website"], factuges_customer_id, company_id, + ), + ) + return customer_id + customer_id = str(row[0]) + logging.info("Updating customer %s %s", factuges_customer_id, customer_id) + cur.execute( + SQL.UPDATE_CUSTOMER, + ( + fields["name"], fields["tin"], fields["street"], fields["city"], fields["province"], fields["postal_code"], + fields["country"], 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: + cur.execute(SQL.SELECT_PAYMENT_METHOD_BY_FACTUGES, (factuges_payment_id,)) + row = cur.fetchone() + if not row or not row[0]: + pm_id = str(uuid7()) + logging.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]) + logging.info("Payment method exists %s -> %s", factuges_payment_id, pm_id) + return pm_id + + +def insert_invoice_header(cur, company_id: str, fd: Dict[str, Any], customer_id: str, + payment_method_id: str, payment_method_description: str) -> Tuple[str, str]: + """ + Inserta cabecera y devuelve (invoice_id, tax_code_normalized) + """ + invoice_id = str(uuid7()) + + reference = str(fd['REFERENCIA']) + invoice_date = str(fd['FECHA_FACTURA']) + operation_date = str(fd['FECHA_FACTURA']) + description = textwrap.shorten( + f"{reference or ''} - {str(fd.get('NOMBRE')) or ''}", width=50, placeholder="…") + + # siempre tendrán 2 decimales + subtotal_amount_value = cents(fd.get('IMPORTE_NETO')) + discount_amount_value = cents(fd.get('IMPORTE_DESCUENTO')) + discount_percentage_val = int((Decimal(str(fd.get('DESCUENTO') or 0)) * 100).to_integral_value()) \ + if fd.get('DESCUENTO') is not None else 0 + taxable_amount_value = cents(fd.get('BASE_IMPONIBLE')) + taxes_amount_value = cents( + (fd.get('IMPORTE_IVA') or 0) + (fd.get('IMPORTE_RE') or 0)) + total_amount_value = cents(fd.get('IMPORTE_TOTAL')) + + # Mapea tax_code cabecera + tax_code = map_tax_code(str(fd.get("DES_TIPO_IVA"))) + + logging.info("Inserting invoice %s %s %s", + invoice_id, reference, invoice_date) + cur.execute( + SQL.INSERT_INVOICE, + ( + invoice_id, company_id, 'draft', 'F25/', reference, invoice_date, operation_date, description, + subtotal_amount_value, discount_amount_value, discount_percentage_val, taxable_amount_value, + taxes_amount_value, total_amount_value, + customer_id, fd.get('NIF_CIF'), fd.get( + 'NOMBRE'), fd.get('CALLE'), fd.get('POBLACION'), + fd.get('PROVINCIA'), fd.get('CODIGO_POSTAL'), 'es', + payment_method_id, payment_method_description, fd.get( + 'ID_FACTURA'), company_id + ), + ) + return invoice_id, tax_code + + +def insert_header_taxes_if_any(cur, invoice_id: str, fd: Dict[str, Any], tax_code: str) -> None: + base = cents(fd.get('BASE_IMPONIBLE')) + iva = cents(fd.get('IMPORTE_IVA')) + re = cents(fd.get('IMPORTE_RE')) + + # IVA (>= 0 acepta 0% también, si no quieres registrar 0, cambia condición a > 0) + if (fd.get('IVA') or 0) >= 0: + cur.execute(SQL.INSERT_INVOICE_TAX, + (str(uuid7()), invoice_id, tax_code, base, iva)) + + # Recargo equivalencia + if (fd.get('RECARGO_EQUIVALENCIA') or 0) > 0: + cur.execute(SQL.INSERT_INVOICE_TAX, + (str(uuid7()), invoice_id, 'rec_5_2', base, re)) + + +def insert_item_and_taxes(cur, invoice_id: str, fd: Dict[str, Any], tax_code: str) -> None: + """ + Inserta línea y sus impuestos derivados. + """ + item_id = str(uuid7()) + position = int(fd['POSICION']) + description = str(fd['CONCEPTO']) + quantity_value = None if fd['CANTIDAD'] is None else int( + Decimal(str(fd['CANTIDAD'] or 0)) * 100) + unit_value = None if fd['IMPORTE_UNIDAD'] is None else int( + Decimal(str(fd['IMPORTE_UNIDAD'] or 0)) * 10000) + disc_pct = fd.get('DESCUENTO_DET') + discount_percentage_value = None if disc_pct in ( + None, 0) else int(Decimal(str(disc_pct)) * 100) + total_value = cents(fd.get('IMPORTE_TOTAL_DET')) + + logging.info("Inserting item %s pos=%s qty=%s", + item_id, position, quantity_value) + cur.execute( + SQL.INSERT_INVOICE_ITEM, + (item_id, invoice_id, position, description, quantity_value, + unit_value, discount_percentage_value, None, total_value) + ) + # Se calcula en el objeto de negocio de nuevo, comprobar si coincide + # item_discount_amount = ( + # (factura_detalle['IMPORTE_UNIDAD'] or 0)*((factura_detalle['DESCUENTO'] or 0)/100))*100 + + # Calcular cuota de IVA de la línea + frac = tax_fraction_from_code(tax_code) # 0.21, 0.10, 0… + # si tu total_det ya incluye descuento + line_base = Decimal(str(fd.get('IMPORTE_TOTAL_DET') or 0)) + tax_amount = int((money_round(line_base * frac, 2) + * 100).to_integral_value()) + logging.info("Inserting item tax %s code=%s base=%s tax=%s", + item_id, tax_code, total_value, tax_amount) + + cur.execute( + SQL.INSERT_INVOICE_ITEM_TAX, + (str(uuid7()), item_id, tax_code, total_value, tax_amount) + ) + + +""" +logging.info( + f"Inserting customer_invoice_item_taxes {item_id} {item_position} {tax_code} {item_total_amount} {tax_amount_value}") + cursorMySQL.execute(SQL.INSERT_INVOICE_ITEM_TAX, (str(uuid7()), item_id, tax_code, + item_total_amount, tax_amount_value)) + if tax_code == 'iva_21': tax_amount_value = ( @@ -364,25 +415,4 @@ def insertar_datos(conn_mysql, filas, conn_factuges, config): tax_amount_value = ( factura_detalle['IMPORTE_TOTAL_DET'] or 0)*100 - logging.info( - f"Inserting customer_invoice_item_taxes {item_id} {item_position} {tax_code} {item_total_amount} {tax_amount_value}") - cursorMySQL.execute(SQL.INSERT_INVOICE_ITEM_TAX, (str(uuid7()), item_id, tax_code, - item_total_amount, tax_amount_value)) - - # Asignamos el id factura anterior para no volver a inserta cabecera - factuges_id_anterior = factuges_id - - logging.info( - f"FACTURAS_CLIENTE rows to be processed: {str(num_fac_procesed)}") - - except Exception as e: - # Escribir el error en el archivo de errores - logging.error(str(e)) - raise e # Re-lanzar la excepción para detener el procesamiento - - finally: - # Cerrar la conexión - if cursorMySQL is not None: - cursorMySQL.close() - if cursor_FactuGES is not None: - cursor_FactuGES.close() +""" diff --git a/app/main.py b/app/main.py index f04fcf1..9a7508a 100644 --- a/app/main.py +++ b/app/main.py @@ -42,7 +42,8 @@ def main(): conn_mysql = get_mysql_connection(config) # Sync invoices - logging.info(f">>>>>>>>>>> Sync invoices FactuGES escritorio to FactuGES web") + logging.info( + f">>>>>>>>>>> Sync invoices FactuGES escritorio to FactuGES web") sync_invoices(conn_factuges, conn_mysql, last_execution_date_local_tz) # Confirmar los cambios @@ -57,8 +58,9 @@ def main(): conn_mysql = get_mysql_connection(config) # Sync Verifactu - logging.info(f">>>>>>>>>> Sync facturas emitidas en FactuGES web to Verifactu") - sync_invoices_verifactu(conn_mysql, last_execution_date_local_tz) + logging.info( + f">>>>>>>>>> Sync facturas emitidas en FactuGES web to Verifactu") + # sync_invoices_verifactu(conn_mysql, last_execution_date_local_tz) conn_mysql.commit() conn_mysql.close() logging.info(f"FIN Sync Verifactu >>>>>>>>>>") diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 374fbd6..4b050cf 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -4,8 +4,8 @@ from .password import hashPassword from .send_orders_mail import send_orders_mail from .text_converter import text_converter, limpiar_cadena from .send_rest_api import validar_nif, estado_factura, crear_factura -from .tax_catalog_helper import TaxCatalog, get_default_tax_catalog -from .importes_helper import unscale_to_str, unscale_to_decimal +from .tax_catalog_helper import TaxCatalog, get_default_tax_catalog, map_tax_code, calc_item_tax_amount, tax_fraction_from_code +from .importes_helper import unscale_to_str, unscale_to_decimal, cents, money_round from .telefonos_helper import normalizar_telefono_con_plus from .mails_helper import corregir_y_validar_email -from .websites_helper import normalizar_url_para_insert \ No newline at end of file +from .websites_helper import normalizar_url_para_insert diff --git a/app/utils/importes_helper.py b/app/utils/importes_helper.py index 08a13a4..e678a88 100644 --- a/app/utils/importes_helper.py +++ b/app/utils/importes_helper.py @@ -2,6 +2,18 @@ from decimal import Decimal, ROUND_HALF_UP from typing import Any, Optional +def cents(value: Optional[Decimal | float | int]) -> int: + """Convierte a centésimas (valor * 100). Soporta None.""" + v = Decimal(str(value or 0)) + return int((v * 100).to_integral_value(rounding=ROUND_HALF_UP)) + + +def money_round(value: Decimal, ndigits: int = 2) -> Decimal: + """Redondeo bancario a n decimales (por defecto 2).""" + q = Decimal((0, (1,), -ndigits)) # 10^-ndigits + return value.quantize(q, rounding=ROUND_HALF_UP) + + def unscale_to_decimal(value: Any, scale: Any) -> Decimal: """ Convierte un valor escalado (p. ej. 24200 con scale=2) a su valor en unidades (242). diff --git a/app/utils/tax_catalog_helper.py b/app/utils/tax_catalog_helper.py index f097ca8..21ee5cc 100644 --- a/app/utils/tax_catalog_helper.py +++ b/app/utils/tax_catalog_helper.py @@ -5,6 +5,60 @@ from functools import lru_cache from pathlib import Path +def map_tax_code(raw: str) -> str: + t = (raw or "").strip().lower().replace(" ", "") + mapping = { + "iva21": "iva_21", + "iva18": "iva_18", + "iva16": "iva_16", + "iva10": "iva_10", + "iva4": "iva_4", + "exento": "iva_exenta", + } + return mapping.get(t, "") + + +def calc_item_tax_amount(tax_code: str, importe_total_det: Optional[Decimal | float | int]) -> int: + """ + Devuelve el impuesto de línea escalado a céntimos (x100) como entero. + """ + base = Decimal(str(importe_total_det or 0)) + rates = { + "iva_21": Decimal("0.21"), + "iva_18": Decimal("0.18"), + "iva_16": Decimal("0.16"), + "iva_10": Decimal("0.10"), + "iva_4": Decimal("0.04"), + "iva_exenta": Decimal("0.00"), + } + rate = rates.get(tax_code) + if rate is None: + # fallback: copiar total como “impuesto” (mantiene tu comportamiento previo) + return int(base * 100) + return int((base * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100) + + +def tax_fraction_from_code(tax_code: str) -> Decimal: + """ + Devuelve la fracción (0.21, 0.18, ...) según el tax_code normalizado. + Exenta -> 0. + """ + # Si ya tienes TaxCatalog, puedes delegarlo allí; lo dejo inline para simplicidad. + mapping = { + 'iva_21': Decimal('0.21'), + 'iva_18': Decimal('0.18'), + 'iva_16': Decimal('0.16'), + 'iva_10': Decimal('0.10'), + 'iva_7_5': Decimal('0.075'), + 'iva_5': Decimal('0.05'), + 'iva_4': Decimal('0.04'), + 'iva_2': Decimal('0.02'), + 'iva_0': Decimal('0.00'), + 'iva_exenta': Decimal('0.00'), + } + return mapping.get(tax_code or '', Decimal('0.00')) + + def reduce_scale_pair(value: int, scale: int, *, min_scale: int = 0) -> Tuple[int, int]: """ Reduce la escala todo lo posible eliminando ceros finales de 'value',