diff --git a/app/db/normalizations.py b/app/db/normalizations.py index ee7dd55..ffe4a5e 100644 --- a/app/db/normalizations.py +++ b/app/db/normalizations.py @@ -1,21 +1,24 @@ import textwrap from datetime import date -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal from typing import Any, Dict, Optional from striprtf.striprtf import rtf_to_text -from app.config import load_config +from app.config import load_config, logger from app.utils import ( + apply_discount_cents4, cents, cents4, corregir_y_validar_email, limpiar_cadena, - map_tax_code, + map_iva_code, + map_rec_by_iva_code, money_round, normalizar_telefono_con_plus, normalizar_url_para_insert, tax_fraction_from_code, + unscale_to_decimal, ) @@ -70,6 +73,25 @@ def normalize_header_invoice_fields(fd: Dict[str, Any]) -> Dict[str, Any]: """ config = load_config() + iva_code = map_iva_code(str(fd.get("DES_TIPO_IVA"))) + # Cuota de IVA de la factura + iva_frac: Optional[Decimal] = tax_fraction_from_code(iva_code) # p.ej. Decimal('0.21') o Decimal('0') + iva_percentage_value = None if ( + iva_frac is None or iva_frac == 0) else cents4(iva_frac) + iva_amount = cents(fd.get('IMPORTE_IVA')) + + # Cuota de RE de la factura + rec_code = None + rec_frac = None + rec_percentage_value = None + rec_amount = None + importe_re = Decimal(str(fd.get("IMPORTE_RE") or 0)) + if importe_re > 0: + rec_code = map_rec_by_iva_code(iva_code) + rec_frac: Optional[Decimal] = tax_fraction_from_code(rec_code) # p.ej. Decimal('0.05') o Decimal('0.01') + rec_percentage_value = None if (rec_frac is None or rec_frac == 0) else cents4(rec_frac) + rec_amount = cents(fd.get('IMPORTE_RE')) + return { "company_id": config['CTE_COMPANY_ID'], "is_proforma": config["CTE_IS_PROFORMA"], @@ -90,14 +112,19 @@ def normalize_header_invoice_fields(fd: Dict[str, Any]) -> Dict[str, Any]: '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')), - 'tax_code': map_tax_code(str(fd.get("DES_TIPO_IVA"))), + 'iva_code': iva_code, + 'iva_percentage_value': iva_percentage_value, + 'rec_code': rec_code, + 'rec_percentage_value': rec_percentage_value, + 'base': cents(fd.get('BASE_IMPONIBLE')), + 'iva': iva_amount, + 're': rec_amount, 'taxes_amount_value': cents((fd.get('IMPORTE_IVA') or 0) + (fd.get('IMPORTE_RE') or 0)), 'total_amount_value': cents(fd.get('IMPORTE_TOTAL')), - 'base': cents(fd.get('BASE_IMPONIBLE')), - 'iva': cents(fd.get('IMPORTE_IVA')), - 're': cents(fd.get('IMPORTE_RE')) - + 'retention_code': None, + 'retention_percentage_value': None, + 'retention_amount_value': None, } @@ -110,23 +137,17 @@ def normalize_details_invoice_fields(fd: Dict[str, Any]) -> Dict[str, Any]: - discount_percentage_value: escala 2 (10 -> 1000) - total_value: escala 4 (cents4) - iva_amount: escala 2 (cents), calculado con redondeo a 2 decimales - - + - """ config = load_config() - iva_code = map_tax_code(str(fd.get("DES_TIPO_IVA"))) - # Calcular cuota de IVA de la línea - iva_frac: Optional[Decimal] = tax_fraction_from_code(iva_code) # p.ej. Decimal('0.21') o Decimal('0') - iva_percentage_value = None if ( - iva_frac is None or iva_frac == 0) else cents4(iva_frac) - - rec_code = None - rec_percentage_value = None - rec_amount = None - # --- Campos base del origen --- cantidad = fd.get("CANTIDAD") importe_unidad = fd.get("IMPORTE_UNIDAD") + c = Decimal(str(cantidad or 0)) + u = Decimal(str(importe_unidad or 0)) + subtotal_amount_value = int((c * u * 10000).to_integral_value(rounding=ROUND_HALF_UP)) + # total de línea (ya con descuento) total_det = fd.get("IMPORTE_TOTAL_DET") concepto_rtf = fd.get("CONCEPTO") @@ -137,41 +158,78 @@ def normalize_details_invoice_fields(fd: Dict[str, Any]) -> Dict[str, Any]: unit_value = None if importe_unidad is None else cents4( importe_unidad) # escala 4 - # --- descuento % (escala 2) --- + # --- descuento de linea % (escala 2) --- disc_raw = fd.get("DESCUENTO_DET") disc_pct: Optional[Decimal] = None if disc_raw is None else Decimal( str(disc_raw)) discount_percentage_value = None if ( disc_pct is None or disc_pct == 0) else cents(disc_pct) + discount_amount_value, subtotal_amount_with_dto = apply_discount_cents4(subtotal_amount_value, disc_pct) + + # --- descuento global por linea % (escala 2) --- + disc_global_raw = fd.get("DESCUENTO") + disc_global_pct: Optional[Decimal] = None if disc_global_raw is None else Decimal(str(disc_global_raw)) + global_percentage_value = None if (disc_global_pct is None or disc_global_pct == 0) else cents(disc_global_pct) + global_discount_amount_value, taxable_amount_value = apply_discount_cents4( + subtotal_amount_with_dto, disc_global_pct) + + total_discount_amount_value = discount_amount_value + global_discount_amount_value # --- total de línea (escala 4) --- total_value = cents4(total_det) + # DEBE SER LO MISMO LO CALCULADO QUE LO QUE NOS VIENE POR BD DE FACTUGES + logger.info("total con dto linea calculado: %s - total que llega: %s", subtotal_amount_with_dto, total_value) + # la base imponible sobre la que calcular impuestos es el neto menos el dto de linea y dto global + base = unscale_to_decimal(taxable_amount_value, 4) - # --- cuota (escala 2) --- - # calculamos sobre el total en unidades monetarias con redondeo bancario a 2 decimales - if iva_frac is None: - iva_amount = 0 - else: - base = Decimal(str(total_det or 0)) - iva_amount = cents(money_round(base * iva_frac, 2)) + logger.info("base imponible calculada: %s - subtotal: %s - descuentodto: %s - descuentoglobal: %s", + base, subtotal_amount_value, discount_amount_value, global_discount_amount_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 + iva_code = map_iva_code(str(fd.get("DES_TIPO_IVA"))) + iva_frac: Optional[Decimal] = tax_fraction_from_code(iva_code) # p.ej. Decimal('0.21') o Decimal('0') + iva_percentage_value = None if ( + iva_frac is None or iva_frac == 0) else cents4(iva_frac) + iva_amount_c4 = 0 + if iva_frac: + iva_amount_c4 = cents4(money_round(base * iva_frac, 2)) # escala 4 + + # Cuota cuota RE de la línea + rec_code = None + rec_frac: Optional[Decimal] = None + rec_percentage_value = None + rec_amount_c4 = 0 + importe_re = Decimal(str(fd.get("IMPORTE_RE") or 0)) + if importe_re > 0: + rec_code = map_rec_by_iva_code(iva_code) + rec_frac = tax_fraction_from_code(rec_code) # p.ej. Decimal('0.05') o Decimal('0.01') + if rec_frac and rec_frac != 0: + rec_percentage_value = cents4(rec_frac) # escala 4 del % (0.052 -> 520) + rec_amount_c4 = cents4(base * rec_frac) # escala 4 + + taxes_amount_value = iva_amount_c4 + rec_amount_c4 + + total_value = taxable_amount_value + taxes_amount_value return { 'position': int(fd.get("POSICION") or 0), 'description': rtf_a_texto_plano(str(concepto_rtf or "")), 'quantity_value': quantity_value, 'unit_value': unit_value, + 'subtotal_amount_value': subtotal_amount_value, 'disc_pct': disc_pct, 'discount_percentage_value': discount_percentage_value, + 'discount_amount_value': discount_amount_value, + 'global_percentage_value': global_percentage_value, + 'global_discount_amount_value': global_discount_amount_value, + 'total_discount_amount_value': total_discount_amount_value, + 'taxable_amount_value': taxable_amount_value, 'total_value': total_value, 'iva_code': iva_code, 'iva_percentage_value': iva_percentage_value, - 'iva_amount': iva_amount, + 'iva_amount_value': iva_amount_c4, 'rec_code': rec_code, 'rec_percentage_value': rec_percentage_value, - 'rec_amount': rec_amount, - + 'rec_amount_value': rec_amount_c4, + 'taxes_amount_value': taxes_amount_value, } diff --git a/app/db/sql_sentences.py b/app/db/sql_sentences.py index daa35dd..a7c32fd 100644 --- a/app/db/sql_sentences.py +++ b/app/db/sql_sentences.py @@ -69,23 +69,27 @@ INSERT_INVOICE_BAK = ( INSERT_INVOICE_ITEM = ( "INSERT INTO customer_invoice_items " - "(item_id, invoice_id, position, description, quantity_value, unit_amount_value, " - "discount_percentage_value, discount_amount_value, total_amount_value, " + "(item_id, invoice_id, position, description, quantity_value, unit_amount_value, subtotal_amount_value, " + "discount_percentage_value, discount_amount_value, global_percentage_value, global_discount_amount_value, total_discount_amount_value, " + " taxable_amount_value, total_amount_value, " "iva_code, iva_percentage_value, iva_amount_value, " - "rec_code, rec_percentage_value, rec_amount_value, " + "rec_code, rec_percentage_value, rec_amount_value, taxes_amount_value, " - "quantity_scale, unit_amount_scale, discount_amount_scale, total_amount_scale, discount_percentage_scale," - "iva_percentage_scale, iva_amount_scale, rec_percentage_scale, rec_amount_scale, retention_percentage_scale, retention_amount_scale," + "quantity_scale, unit_amount_scale, subtotal_amount_scale, " + "discount_percentage_scale, discount_amount_scale, global_percentage_scale, global_discount_amount_scale, total_discount_amount_scale, taxable_amount_scale, total_amount_scale, " + "iva_percentage_scale, iva_amount_scale, rec_percentage_scale, rec_amount_scale, taxes_amount_scale, retention_percentage_scale, retention_amount_scale, " "created_at, updated_at) " - "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,2,4,2,4,2,2,4,2,4,2,4,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)" + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,2,4,4,2,4,2,4,4,4,4,2,4,2,4,4,2,4,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)" ) INSERT_INVOICE_TAX = ( - "INSERT INTO customer_invoice_taxes (tax_id, invoice_id, tax_code, taxable_amount_value, taxes_amount_value, " - "taxable_amount_scale, taxes_amount_scale, created_at, updated_at) " - "VALUES (%s,%s,%s,%s,%s,2,2,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)" + "INSERT INTO customer_invoice_taxes (tax_id, invoice_id, taxable_amount_value, iva_code, iva_percentage_value, iva_amount_value, " + "rec_code, rec_percentage_value, rec_amount_value, retention_code, retention_percentage_value, retention_amount_value, taxes_amount_value, " + "taxable_amount_scale, iva_percentage_scale, iva_amount_scale, rec_percentage_scale, rec_amount_scale, retention_percentage_scale, retention_amount_scale, taxes_amount_scale, " + "created_at, updated_at) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,2,2,2,2,2,2,2,2,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)" ) # INSERT_INVOICE_ITEM_TAX = ( diff --git a/app/db/sync_invoices_factuges.py b/app/db/sync_invoices_factuges.py index a6cb4a2..8587554 100644 --- a/app/db/sync_invoices_factuges.py +++ b/app/db/sync_invoices_factuges.py @@ -166,8 +166,6 @@ def sync_invoices_from_FACTUGES(conn_mysql, filas, conn_factuges, config): insert_header_taxes_if_any( cursorMySQL, invoice_id, - factura_detalle["IVA"], - factura_detalle["RECARGO_EQUIVALENCIA"], header_invoice_fields, ) @@ -392,48 +390,27 @@ def insert_verifactu_record( def insert_header_taxes_if_any( cur, invoice_id: str, - IVA: str, - RECARGO: str, hif: Dict[str, Any], ) -> None: """Inserta impuestos de cabecera""" - # 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"), - ), - ) + cur.execute(SQL.INSERT_INVOICE_TAX, + ( + str(uuid7()), + invoice_id, + hif.get("base"), + hif.get("iva_code"), + hif.get("iva_percentage_value"), + hif.get("iva"), + hif.get("rec_code"), + hif.get("rec_percentage_value"), + hif.get("re"), + hif.get("retention_code"), + hif.get("retention_percentage_value"), + hif.get("retention_amount_value"), + hif.get("taxes_amount_value"), + ), + ) def insert_item_and_taxes(cur, invoice_id: str, fields: Dict[str, Any]) -> None: @@ -452,15 +429,21 @@ def insert_item_and_taxes(cur, invoice_id: str, fields: Dict[str, Any]) -> None: fields.get("description"), fields.get("quantity_value"), fields.get("unit_value"), + fields.get("subtotal_amount_value"), fields.get("discount_percentage_value"), - None, + fields.get("discount_amount_value"), + fields.get("global_percentage_value"), + fields.get("global_discount_amount_value"), + fields.get("total_discount_amount_value"), + fields.get("taxable_amount_value"), fields.get("total_value"), fields.get("iva_code"), fields.get("iva_percentage_value"), - fields.get("tax_amount"), + fields.get("iva_amount_value"), fields.get("rec_code"), fields.get("rec_percentage_value"), - fields.get("rec_amount"), + fields.get("rec_amount_value"), + fields.get("taxes_amount_value"), ), ) diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 2fb3a4e..bea47b6 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,10 +1,27 @@ -from .last_execution_helper import actualizar_fecha_ultima_ejecucion, obtener_fecha_ultima_ejecucion +from .importes_helper import ( + apply_discount_cents4, + cents, + cents4, + money_round, + unscale_to_decimal, + unscale_to_str, +) +from .last_execution_helper import ( + actualizar_fecha_ultima_ejecucion, + obtener_fecha_ultima_ejecucion, +) +from .mails_helper import corregir_y_validar_email 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, map_tax_code, calc_item_tax_amount, tax_fraction_from_code -from .importes_helper import unscale_to_str, unscale_to_decimal, cents, cents4, money_round +from .send_rest_api import crear_factura, estado_factura, validar_nif +from .tax_catalog_helper import ( + TaxCatalog, + calc_item_tax_amount, + get_default_tax_catalog, + map_iva_code, + map_rec_by_iva_code, + tax_fraction_from_code, +) from .telefonos_helper import normalizar_telefono_con_plus -from .mails_helper import corregir_y_validar_email +from .text_converter import limpiar_cadena, text_converter from .websites_helper import normalizar_url_para_insert diff --git a/app/utils/importes_helper.py b/app/utils/importes_helper.py index 0183097..95335e7 100644 --- a/app/utils/importes_helper.py +++ b/app/utils/importes_helper.py @@ -1,5 +1,5 @@ -from decimal import Decimal, ROUND_HALF_UP -from typing import Any, Optional +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, Optional, Tuple def cents4(value: Optional[Decimal | float | int]) -> int: @@ -53,3 +53,29 @@ def unscale_to_str( if strip_trailing_zeros and "." in s: s = s.rstrip("0").rstrip(".") return s + + +def calc_discount_cents4(subtotal_cents4: int, disc_pct: Optional[Decimal | float | int]) -> int: + """ + Calcula el importe de descuento en escala 4 (×10000) como ENTERO. + - subtotal_cents4: subtotal ya en escala 4 + - disc_pct: porcentaje de descuento (p.ej. 10 -> 10%) + + Devuelve un entero NEGATIVO (como en Delphi): + ImporteDto := (-1) * ((Subtotal * Descuento) / 100); + """ + pct = Decimal(str(disc_pct or 0)) + # descuento = round(subtotal * pct / 100) en la MISMA escala (×10000) + disc = (Decimal(subtotal_cents4) * pct / Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP) + return disc + + +def apply_discount_cents4(subtotal_cents4: int, disc_pct: Optional[Decimal | float | int]) -> Tuple[int, int]: + """ + Devuelve (discount_cents4, total_cents4) ambos en escala 4. + - discount_cents4 NEGATIVO + - total_cents4 = subtotal_cents4 + discount_cents4 + """ + discount = calc_discount_cents4(subtotal_cents4, disc_pct) + total = subtotal_cents4 - discount + return discount, total diff --git a/app/utils/tax_catalog_helper.py b/app/utils/tax_catalog_helper.py index 79dbaae..2ea84d4 100644 --- a/app/utils/tax_catalog_helper.py +++ b/app/utils/tax_catalog_helper.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, Optional, Tuple -def map_tax_code(raw: str) -> str: +def map_iva_code(raw: str) -> str: t = (raw or "").strip().lower().replace(" ", "") mapping = { "iva21": "iva_21", @@ -18,6 +18,18 @@ def map_tax_code(raw: str) -> str: return mapping.get(t, "") +def map_rec_by_iva_code(raw: str) -> str: + t = (raw or "").strip().lower().replace(" ", "") + mapping = { + "iva_21": "rec_5_2", + "iva_10": "rec_1_75", + "iva_5": "rec_0_62", + "iva_4": "rec_0_5", + "iva_7_5": "rec_1", + } + 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. @@ -30,6 +42,11 @@ def calc_item_tax_amount(tax_code: str, importe_total_det: Optional[Decimal | fl "iva_10": Decimal("0.10"), "iva_4": Decimal("0.04"), "iva_exenta": Decimal("0.00"), + "rec_5_2": Decimal('0.052'), + "rec_1_75": Decimal('0.0175'), + "rec_0_62": Decimal('0.0062'), + "rec_0_5": Decimal('0.005'), + "rec_1": Decimal('0.01') } rate = rates.get(tax_code) if rate is None: @@ -55,8 +72,13 @@ def tax_fraction_from_code(tax_code: str) -> Decimal: 'iva_2': Decimal('0.02'), 'iva_0': Decimal('0.00'), 'iva_exenta': Decimal('0.00'), + 'rec_5_2': Decimal('0.052'), + 'rec_1_75': Decimal('0.0175'), + 'rec_0_62': Decimal('0.0062'), + 'rec_0_5': Decimal('0.005'), + 'rec_1': Decimal('0.01') } - return mapping.get(tax_code or '', Decimal('0.13')) + return mapping.get(tax_code or '', Decimal('0.00')) def reduce_scale_pair(value: int, scale: int, *, min_scale: int = 0) -> Tuple[int, int]: