# -*- coding: utf-8 -*-
"""
Genera archivo plano SIIGO desde COMPRAS (Orlando) + BASE PROVEEDORES.
Mapeo de impuestos: código SIIGO, cuenta desde BASE PROVEEDORES, base gravable cuando aplica.

- Gastos (tipo 10): IVA a cuentas 2408… según tarifa; si la base tiene cuenta clase 5, no se envía código de impuesto.
- Prefijo vacío: FC (factura/gastos) o NC (nota crédito); mismo criterio en _prefijo para cruce con IVA detalle.
- No cuota / fecha vencimiento: solo en cuentas 2205* y 2335*.
- Fedepapa (BASE PROVEEDORES): 1% sobre base, código 45; siempre cuenta 236902 — crédito en factura de compra, débito en nota crédito; descuenta CxP 2205*.
- Egresos (DFPGFC): script aparte `generar_egresos_siigo.py` → `plano_siigo_egresos.xlsx` (no altera este main).
"""
import pandas as pd
import os
from datetime import datetime

# ---------------------------------------------------------------------------
# PARÁMETROS DEL PROYECTO (rutas, hojas, salidas — ajustar por empresa)
# Usado también por generar_egresos_siigo.py (libro DFPGFC independiente).
# ---------------------------------------------------------------------------
RUTA_COMPRAS = "COMPRAS Y VENTAS ORLANDO CAMACHO FEBRERO.xlsx"
HOJA_COMPRAS = "COMPRAS"
RUTA_BASE_PROVEEDORES = "BASE PROVEEDORES.xlsx"
HOJA_PROVEEDORES = "PROVEEDORES"
RUTA_ESTRUCTURA = "ESTRUCTURA PLANO SIIGO.xlsx"
HOJA_ESTRUCTURA = "Datos"
RUTA_EJEMPLO = "EJEMPLO ARCHIVO DE COMPRAS.xlsx"
HOJA_EJEMPLO = "DFFC"
RUTA_SALIDA_EXCEL = "plano_siigo_compras.xlsx"
# Egresos (cruce CxP 2205/2335 vs tesorería) — salida en archivo aparte
RUTA_SALIDA_EGRESOS = "plano_siigo_egresos.xlsx"
HOJA_EGRESOS = "DFPGFC"
CUENTA_TESORERIA_EGRESOS = "11050501"
TIPO_COMPROBANTE_EGRESOS = 11  # SIIGO: Egresos
CONSECUTIVO_INICIAL_11 = 20595
PREFIJO_TEXTO_EGRESO_CANCELACION = "CANCELACION"

# Archivo opcional con detalle de IVA por tarifa (mantiene COMPRAS sin cambios)
# Columnas requeridas: NIT Emisor, Prefijo, Folio, Base exenta, Base 5%, IVA 5%, Base 19%, IVA 19%
# Si en COMPRAS el prefijo va vacío, use en Prefijo el mismo FC/NC que asigna el script para que el cruce coincida.
RUTA_IVA_DETALLE = "COMPRAS_IVA_DETALLE.xlsx"
HOJA_IVA_DETALLE = "IVA detalle"
HOJA_REVISAR_IVA = "Revisar IVA"
COL_IVA_DETALLE = {
    "nit": "NIT Emisor",
    "prefijo": "Prefijo",
    "folio": "Folio",
    "base_exenta": "Base exenta",
    "base_5": "Base 5%",
    "iva_5": "IVA 5%",
    "base_19": "Base 19%",
    "iva_19": "IVA 19%",
}

# Códigos de impuesto SIIGO (según configuración): 1-11 primera lista; 12-23 adicionales
COD_IVA_19 = 1
COD_IVA_5 = 2
COD_RETEFUENTE_11 = 3
COD_RETEFUENTE_10 = 4
COD_RETEFUENTE_6 = 5
COD_RETEFUENTE_4 = 6
COD_RETEFUENTE_2_5 = 7
COD_RETELCA_11_04 = 8
COD_RETELCA_13_8 = 9
COD_RETELCA_9_66 = 10
COD_RETELCA_8 = 11
# Adicionales (12-23)
COD_RETELCA_7 = 12
COD_RETELCA_6_9 = 13
COD_RETELCA_4_14 = 14
COD_RETELVA_15 = 15
COD_IMPO_CONSUMO_8 = 16
COD_IMPO_CONSUMO_VALOR = 17
COD_RETEFUENTE_3_5 = 18
COD_RETEFUENTE_7 = 19
COD_RETEFUENTE_2 = 20
COD_RETEFUENTE_1 = 21
COD_IVA_0 = 22
COD_RETELCA_2_2 = 23
# Retención Fedepapa (1% sobre base) — tipo impuesto en SIIGO
COD_FEDEPAPA = 45
# Retención Fedepapa: siempre cuenta 236902 (crédito en FE, débito en NC vía asignar_dc)
CUENTA_FEDEPAPA_NC = "236902"
# Columna en BASE PROVEEDORES: indica que el proveedor aplica Fedepapa (debe informarse para causar el 1%)
COL_FEDEPAPA_PROVEEDOR = "Fedepapa"
# Rete Renta: mapeo TARIFAFTE (%) -> código Retefuente (3-7, 18-21). Resto -> 3 (Retefuente 11%)
TARIFA_A_CODIGO_RETEFUENTE = {
    0.5: 7, 1: 21, 1.5: 7, 2: 20, 2.5: 7, 3.5: 18, 4: 6, 5: 5, 6: 5, 7: 19,
    10: 4, 11: 3,
}

# Mapeo: columna COMPRAS -> (código impuesto SIIGO, columna cuenta BASE PROVEEDORES, lleva base gravable, es_retencion=Crédito)
MAPEO_IMPUESTOS = [
    ("IVA", COD_IVA_19, "IVA", True, False),
    ("ICA", COD_RETELCA_11_04, "Rete ICA", False, False),
    ("IC", 16, "IC", True, False),
    ("INC", 16, "INC", True, False),
    ("Timbre", None, "Timbre", False, False),
    ("INC Bolsas", 16, "INC Bolsas", True, False),
    ("IN Carbono", 8, "IN Carbono", False, False),
    ("IN Combustibles", 8, "IN Combustibles", False, False),
    ("IC Datos", 9, "IC Datos", False, False),
    ("ICL", 17, "ICL", True, False),
    ("INPP", 11, "INPP", False, False),
    ("IBUA", None, "IBUA", False, False),
    ("ICUI", None, "ICUI", False, False),
    ("Rete IVA", COD_RETEFUENTE_11, "Rete IVA", False, True),
    ("Rete Renta", None, "Rete Renta", False, True),  # código se asigna por TARIFAFTE en el loop
    ("Rete ICA", COD_RETELCA_11_04, "Rete ICA", False, True),
]

# Cuentas por defecto si el proveedor no está en BASE PROVEEDORES
CUENTA_BASE_DEFECTO = "14350101"
CUENTA_CXPAGAR_DEFECTO = "22050501"
# IVA por tarifa: compras y devolución compras (Nota Crédito) - según tabla SIIGO
CUENTA_IVA_19_COMPRAS = "24081001"      # Iva descontable por compras 19%
CUENTA_IVA_5_COMPRAS = "24081003"       # Iva descontable por compras 5%
CUENTA_IVA_19_DEVOLUCION = "24081002"   # Iva Devolución en compras 19%
CUENTA_IVA_5_DEVOLUCION = "24081004"     # Iva Devolución en compras 5%
CUENTA_IVA_DEFECTO = CUENTA_IVA_19_COMPRAS  # retrocompatibilidad
# Impuestos que se llevan como mayor valor del costo (débito a esta cuenta)
CUENTA_MAYOR_VALOR_COSTO = "61359501"
IMPUESTOS_A_COSTO_61359501 = ["ICUI", "IBUA"]  # columnas en COMPRAS con mismo tratamiento

# IVA: tres tarifas 19%, 5% y 0% (base exenta). Tolerancia para considerar congruente la tasa efectiva.
TARIFA_IVA_19 = 0.19
TARIFA_IVA_5 = 0.05
TOLERANCIA_TARIFA_IVA = 0.005  # si |tasa_efectiva - 0.19| o | - 0.05| > esto, advertencia
# Si detalle IVA trae IVA 19% igual al monto de INC en COMPRAS, no duplicar línea (causar solo INC en MAPEO)
TOLERANCIA_DUPLICADO_INC_VS_IVA19_DETALLE = 0.02

# Validación base × tarifa = monto: tarifa (decimal) por código de impuesto para comprobar congruencia
CODIGO_IMPUESTO_TARIFA = {
    1: 0.19, 2: 0.05,  # IVA 19%, IVA 5%
    3: 0.11, 4: 0.10, 5: 0.06, 6: 0.04, 7: 0.025,  # Retefuente
    18: 0.035, 19: 0.07, 20: 0.02, 21: 0.01,       # Retefuente adicionales
    8: 0.1104, 9: 0.138, 10: 0.0966, 11: 0.08,     # RetelCA (opcional)
    COD_FEDEPAPA: 0.01,  # Fedepapa 1%
}
TOLERANCIA_BASE_TARIFA = 0.02  # diferencia permitida entre (base × tarifa) y monto registrado

# Retención en la fuente (Rete Renta) 2026 - Decreto Único Tributario 1625 de 2016 y normativa vigente
# (base mínima en pesos, tarifa %) por TARIFAFTE en BASE PROVEEDORES. Valores 2026.
# Compras generales declarantes 10 UVT=$524.000 2.50%; no declarantes 10 UVT=$524.000 3.50%
# Agrícolas/pecuarios sin procesamiento >70 UVT=>$3.666.000 1.5%; Café pergamino/cereza 70 UVT=$3.666.000 0.5%
# Combustibles 0 UVT 0.1%; Tarjetas débito/crédito 0 UVT 1.5%; Oro 0 UVT 2.5%
# Servicios generales 2 UVT=>$105.000 4% o 6%; Software 0 UVT 3.5%; Honorarios 0 UVT 10%-11%
UVT_2026 = 52_400  # aproximado para 2026 (ajustar si publican valor oficial)
RETENCION_RENTA_2026 = {
    0.1: (0, 0.1),              # Combustibles derivados del petróleo - Art. 1.2.4.10.5
    0.5: (3_666_000, 0.5),      # Café pergamino o cereza - Art. 1.2.4.6.8
    1.5: (0, 1.5),              # Tarjetas débito/crédito (base 0); agrícolas sin proc. usa base 3666000 - Art. 1.3.2.1.8 / 1.2.4.6.7
    2.5: (524_000, 2.5),        # Compras generales declarantes; Oro 0 (no distinguido aquí) - Art. 1.2.4.9.1 / 1.2.4.6.9
    3.5: (524_000, 3.5),        # Compras no declarantes; Software 0 (se ajusta por DESCRIPCION) - Art. 1.2.4.9.1 / 1.2.4.3.1
    4: (105_000, 4),            # Servicios generales declarantes - Art. 1.2.4.4.14
    6: (105_000, 6),            # Servicios generales no declarantes - Art. 1.2.4.4.14
    10: (0, 10),                # Honorarios personas naturales - Art. 1.2.4.3.1
    11: (0, 11),                # Honorarios personas jurídicas - Art. 1.2.4.3.1
}
BASE_AGRICOLA_SIN_PROCESAMIENTO_PESOS = 3_666_000  # >70 UVT - Art. 1.2.4.6.7
BASE_SERVICIOS_PESOS = 105_000   # 2 UVT - Art. 1.2.4.4.14
BASE_COMPRAS_GENERAL_PESOS = 524_000  # 10 UVT
PALABRAS_AGRICOLA_SIN_PROCESAMIENTO = (
    "zanahoria", "platano", "banano", "limon", "arando", "guatila", "tomate", "cebolla", "mazorca",
    "arveja", "lechuga", "espinaca", "cubios", "aji", "habas", "manzana", "uva", "pera", "acelga",
    "repolla", "cafe", "pergamino", "cereza", "agricola", "pecuario",
)
PALABRAS_SOFTWARE = ("software", "licenciamiento", "derecho de uso", "licencia ")

# Si False: Rete Renta solo se registra cuando viene en el archivo COMPRAS (comportamiento del archivo ejemplo).
# Si True: además se calcula por regla 2026 cuando base >= base mínima y el proveedor tiene TARIFAFTE.
APLICAR_RETE_RENTA_POR_REGLA = False

# Tipo de comprobante SIIGO: 9 = Factura electrónica, 12 = Nota crédito electrónica, 10 = Gastos
TIPO_FACTURA_ELECTRONICA = 9
TIPO_NOTA_CREDITO = 12
TIPO_GASTOS = 10
CONSECUTIVO_INICIAL_9 = 22452   # primer consecutivo tipo 9 (Factura electrónica)
CONSECUTIVO_INICIAL_10 = 21174  # primer consecutivo tipo 10 (Gastos)
CONSECUTIVO_INICIAL_12 = 21150  # primer consecutivo tipo 12 (Nota crédito)


def normalizar_nit(s):
    """
    Clave única para cruzar COMPRAS con BASE PROVEEDORES.
    Excel suele leer NIT como float (860049599.0) → antes quedaba '860049599.0' y el merge fallaba,
    aplicando CUENTA_BASE_DEFECTO (1435…) en lugar de la cuenta «Base» del maestro (p. ej. 51…).
    """
    if pd.isna(s):
        return ""
    if isinstance(s, (int, float)):
        if isinstance(s, float) and pd.isna(s):
            return ""
        f = float(s)
        if f.is_integer():
            return str(int(f))
    t = str(s).strip().replace("-", "").replace(" ", "")
    if t.endswith(".0"):
        t = t[:-2]
    # Puntos como separadores de miles en el texto del NIT
    t = t.replace(".", "")
    return "".join(c for c in t if c.isdigit())


def valor_num(v):
    if v is None or (isinstance(v, float) and pd.isna(v)): return 0
    try: return float(v)
    except: return 0


def cuenta_del_proveedor(prov_row, col_name, default=""):
    if col_name not in prov_row.index: return default
    v = prov_row[col_name]
    if pd.isna(v): return default
    s = str(v).strip()
    if s.endswith(".0"): s = s[:-2]
    return s if s else default


def cuenta_iva_por_tarifa(prov_row, es_19, es_nota_credito):
    """Cuenta de IVA según tarifa (19% o 5%) y si es compra o devolución (Nota Crédito).
    Para 5% no se usa la cuenta genérica 'IVA' como respaldo (suele ser 19%), así se evita
    que ambas tarifas queden en 24081001."""
    if es_nota_credito:
        col = "IVA 19% devolución" if es_19 else "IVA 5% devolución"
        defaul = CUENTA_IVA_19_DEVOLUCION if es_19 else CUENTA_IVA_5_DEVOLUCION
    else:
        col = "IVA 19%" if es_19 else "IVA 5%"
        defaul = CUENTA_IVA_19_COMPRAS if es_19 else CUENTA_IVA_5_COMPRAS
    cuenta = cuenta_del_proveedor(prov_row, col, "")
    if not cuenta and es_19:
        cuenta = cuenta_del_proveedor(prov_row, "IVA", "")  # solo 19% puede usar cuenta genérica IVA
    return cuenta or defaul


def cuenta_iva_efectiva(prov_row, es_19, es_nota_credito, es_gastos):
    """En gastos (tipo 10), el IVA debe ir a 2408… según tarifa de la factura, no a cuentas clase 5."""
    c = cuenta_iva_por_tarifa(prov_row, es_19, es_nota_credito)
    if es_gastos and c and str(c).strip().startswith("5"):
        if es_nota_credito:
            return CUENTA_IVA_19_DEVOLUCION if es_19 else CUENTA_IVA_5_DEVOLUCION
        return CUENTA_IVA_19_COMPRAS if es_19 else CUENTA_IVA_5_COMPRAS
    return c


def prefijo_documento(row, tipo_comp):
    """Prefijo del documento; si falta en COMPRAS: NC (nota crédito) o FC (factura / gastos)."""
    p = row.get("Prefijo")
    if p is None or (isinstance(p, float) and pd.isna(p)):
        raw = ""
    else:
        raw = str(p).strip()
        if raw.endswith(".0"):
            raw = raw[:-2]
    if not raw:
        return "NC" if tipo_comp == TIPO_NOTA_CREDITO else "FC"
    return raw


def _valor_fecha_emision_desde_fila(row):
    """Primera celda no vacía de «Fecha … emisión» en la fila (Series/DataFrame row)."""
    for k in row.index:
        if "Fecha" in str(k) and "Emis" in str(k):
            v = row.get(k)
            if pd.notna(v):
                return v
    for alt in ("Fecha Emisión", "Fecha Emisi\u00f3n"):
        v = row.get(alt)
        if pd.notna(v):
            return v
    return None


def _parse_fecha_emision_celda(val):
    """
    Convierte el valor de celda a Timestamp de forma uniforme.
    - Texto tipo DD/MM/AAAA (Colombia): día primero (evita invertir con formato US MM/DD).
    - AAAA-MM-DD: sin ambigüedad.
    - datetime / Timestamp ya tipados por Excel: se respetan.
    - Número serial de Excel (~30k–60k): se interpreta como días desde 1899-12-30.
    """
    if val is None:
        return pd.NaT
    try:
        if pd.isna(val):
            return pd.NaT
    except (ValueError, TypeError):
        pass
    if isinstance(val, pd.Timestamp):
        return val
    if isinstance(val, datetime):
        return pd.Timestamp(val)
    if isinstance(val, (int, float)) and not isinstance(val, bool):
        v = float(val)
        if 25000 <= v <= 65000:
            ts = pd.to_datetime(v, unit="D", origin="1899-12-30", errors="coerce")
            if pd.notna(ts):
                return ts
    s = str(val).strip()
    if not s:
        return pd.NaT
    if len(s) >= 10 and s[4] == "-" and s[7] == "-":
        ts = pd.to_datetime(s[:10], errors="coerce")
        if pd.notna(ts):
            return ts
    return pd.to_datetime(val, dayfirst=True, errors="coerce")


def fecha_emision_str(row):
    """Fecha de emisión del documento como dd/mm/yyyy (uniforme)."""
    v = _valor_fecha_emision_desde_fila(row)
    ts = _parse_fecha_emision_celda(v)
    if pd.isna(ts):
        if v is not None and not (isinstance(v, float) and pd.isna(v)):
            try:
                return str(v).strip()[:10]
            except Exception:
                pass
        return ""
    return ts.strftime("%d/%m/%Y")


def fijar_vencimiento_y_cuota_si_aplica(lin, col_codigo_cuenta, col_no_cuota, col_fecha_venc, fecha_str):
    """Solo cuentas 2205* o 2335* llevan No cuota y fecha de vencimiento."""
    c = str(lin.get(col_codigo_cuenta) or "").strip()
    if c.startswith("2205") or c.startswith("2335"):
        lin[col_no_cuota] = 1
        lin[col_fecha_venc] = fecha_str if fecha_str else None
    else:
        lin[col_no_cuota] = None
        lin[col_fecha_venc] = None


def limpiar_codigo_impuesto_si_cuenta_5(lin, col_codigo_cuenta, col_codigo_impuesto):
    """Impuestos llevados a cuenta clase 5: no se envía código de impuesto a SIIGO."""
    c = str(lin.get(col_codigo_cuenta) or "").strip()
    if c.startswith("5"):
        lin[col_codigo_impuesto] = None


def tipo_comprobante_desde_row(row, prov_row=None):
    """Factura electrónica → 9, Nota de crédito electrónica → 12. Si Categoría (BASE PROVEEDORES) no es Compras ni Ventas → 10 (Gastos)."""
    td = row.get("Tipo de documento")
    if pd.isna(td): base = TIPO_FACTURA_ELECTRONICA
    else:
        s = str(td).strip().lower()
        if "nota" in s and "cr" in s:
            base = TIPO_NOTA_CREDITO
        else:
            base = TIPO_FACTURA_ELECTRONICA
    if prov_row is not None:
        cat = prov_row.get("Categoría") or prov_row.get("CATEGORIA") or prov_row.get("Categoria") or ""
        if pd.notna(cat):
            c = str(cat).strip().lower()
            if c and c not in ("compras", "ventas"):
                return TIPO_GASTOS
    return base


def codigo_retefuente_renta(prov_row):
    """Código Retefuente (3-7) según TARIFAFTE del proveedor. Por defecto 3."""
    t = _normalizar_tarifa(prov_row.get("TARIFAFTE"))
    if t is not None:
        return TARIFA_A_CODIGO_RETEFUENTE.get(t, COD_RETEFUENTE_11)
    return COD_RETEFUENTE_11


def fecha_para_orden(row):
    """Fecha de emisión como datetime para ordenar (mismo criterio que fecha_emision_str)."""
    v = _valor_fecha_emision_desde_fila(row)
    ts = _parse_fecha_emision_celda(v)
    if pd.isna(ts):
        return pd.Timestamp.min
    return ts


def iva_base_gravable_y_tarifa(monto_iva, base_doc, tolerancia=TOLERANCIA_TARIFA_IVA):
    """
    Dado monto de IVA y base del documento, determina si la tasa efectiva es 19% o 5%.
    Devuelve (base_gravable, es_congruente).
    Si es congruente con 19%: base_gravable = monto_iva/0.19.
    Si es congruente con 5%: base_gravable = monto_iva/0.05.
    Si no es congruente: base_gravable = base_doc (para no descuadrar) y es_congruente=False.
    """
    if base_doc is None or base_doc <= 0:
        return (round(monto_iva / TARIFA_IVA_19, 2), True)
    tasa_efectiva = monto_iva / base_doc
    if abs(tasa_efectiva - TARIFA_IVA_19) <= tolerancia:
        return (round(monto_iva / TARIFA_IVA_19, 2), True)
    if abs(tasa_efectiva - TARIFA_IVA_5) <= tolerancia:
        return (round(monto_iva / TARIFA_IVA_5, 2), True)
    # No congruente: aun así base_gravable debe cumplir base × tarifa = monto (para que cuadre en 24)
    # Se usa 19% por defecto para el cálculo de la base mostrada
    return (round(monto_iva / TARIFA_IVA_19, 2), False)


def _normalizar_tarifa(tarifafte):
    if pd.isna(tarifafte) or tarifafte is None:
        return None
    if isinstance(tarifafte, str) and "-" in tarifafte.strip():
        return None
    try:
        return float(tarifafte)
    except (TypeError, ValueError):
        try:
            return float(str(tarifafte).replace(",", ".").strip())
        except (TypeError, ValueError):
            return None


def retencion_renta_regla(prov_row, base_doc):
    """
    Devuelve (base_minima, tarifa_pct) según TARIFAFTE en BASE PROVEEDORES y cuadro 2026.
    Si aplica y base_doc >= base_minima, retención = base_doc * tarifa_pct / 100.
    """
    if prov_row is None or not hasattr(prov_row, "index") or "TARIFAFTE" not in prov_row.index:
        return None, None
    t = _normalizar_tarifa(prov_row.get("TARIFAFTE"))
    if t is None or t not in RETENCION_RENTA_2026:
        return None, None
    base_min_default, tarifa_pct = RETENCION_RENTA_2026[t]
    desc = str(prov_row.get("DESCRIPCION", "") or "").strip().lower()
    nota = str(prov_row.get("NOTA", "") or "").strip().lower()
    texto = f"{desc} {nota}"
    # 1.5%: agrícolas/pecuarios sin procesamiento >70 UVT = 3.666.000; tarjetas 0
    if t == 1.5:
        base_min = BASE_AGRICOLA_SIN_PROCESAMIENTO_PESOS if any(k in texto for k in PALABRAS_AGRICOLA_SIN_PROCESAMIENTO) else base_min_default
        return base_min, 1.5
    # 3.5%: licenciamiento/derecho de software base 0; compras no declarantes 524.000
    if t == 3.5:
        base_min = 0 if any(k in texto for k in PALABRAS_SOFTWARE) else base_min_default
        return base_min, 3.5
    return base_min_default, tarifa_pct


def validar_nits_compras_en_base_proveedores(compras, bp_uniq):
    """
    Comprueba que cada NIT presente en COMPRAS exista en BASE PROVEEDORES (por nit_norm).
    Imprime relación de NIT faltantes con conteo de filas y nombre de ejemplo.
    """
    nits_compras = set(compras["nit_norm"].dropna().astype(str).str.strip())
    nits_compras.discard("")
    nits_base = set(bp_uniq["nit_norm"].dropna().astype(str).str.strip())
    nits_base.discard("")
    faltantes = sorted(nits_compras - nits_base)
    print()
    print("--- Validación: COMPRAS vs BASE PROVEEDORES ---")
    if not faltantes:
        print("OK: todos los NIT de COMPRAS tienen fila en BASE PROVEEDORES.")
        return
    print(
        "ADVERTENCIA: {} NIT de COMPRAS no están en BASE PROVEEDORES (el plano usará cuentas por defecto donde aplique):".format(
            len(faltantes)
        )
    )
    rows = []
    for nit in faltantes:
        mask = compras["nit_norm"].astype(str) == nit
        n = int(mask.sum())
        sub = compras.loc[mask]
        nom = ""
        if "Nombre Emisor" in sub.columns and len(sub):
            v = sub["Nombre Emisor"].iloc[0]
            if pd.notna(v):
                nom = str(v).strip()[:60]
        rows.append({"NIT": nit, "Filas_COMPRAS": n, "Nombre_Emisor_ejemplo": nom})
    df_f = pd.DataFrame(rows)
    print(df_f.to_string(index=False))
    print("---")


def cargar_merged_y_plano():
    """
    COMPRAS + BASE PROVEEDORES + detalle IVA opcional; mismo orden que main().
    Devuelve merged, bp_uniq, columnas_plano (27), COL — usado también por generar_egresos_siigo.py.
    """
    os.chdir(os.path.dirname(os.path.abspath(__file__)))

    ej = pd.read_excel(RUTA_EJEMPLO, sheet_name=HOJA_EJEMPLO, header=0, nrows=0)
    columnas_plano = [ej.columns[i] for i in range(27)]
    COL = {
        "tipo_comprobante": columnas_plano[0],
        "consecutivo": columnas_plano[1],
        "fecha_elab": columnas_plano[2],
        "sigla_moneda": columnas_plano[3],
        "tasa_cambio": columnas_plano[4],
        "codigo_cuenta": columnas_plano[5],
        "id_tercero": columnas_plano[6],
        "sucursal": columnas_plano[7],
        "cod_producto": columnas_plano[8],
        "cod_bodega": columnas_plano[9],
        "accion": columnas_plano[10],
        "cantidad": columnas_plano[11],
        "prefijo": columnas_plano[12],
        "consecutivo_doc": columnas_plano[13],
        "no_cuota": columnas_plano[14],
        "fecha_venc": columnas_plano[15],
        "codigo_impuesto": columnas_plano[16],
        "cod_grupo_activo": columnas_plano[17],
        "cod_activo_fijo": columnas_plano[18],
        "descripcion": columnas_plano[19],
        "centro_costos": columnas_plano[20],
        "debito": columnas_plano[21],
        "credito": columnas_plano[22],
        "observaciones": columnas_plano[23],
        "base_gravable": columnas_plano[24],
        "base_exenta": columnas_plano[25],
        "mes_cierre": columnas_plano[26],
    }

    compras = pd.read_excel(RUTA_COMPRAS, sheet_name=HOJA_COMPRAS, header=0)
    compras["nit_norm"] = compras["NIT Emisor"].apply(normalizar_nit)

    bp = pd.read_excel(RUTA_BASE_PROVEEDORES, sheet_name=HOJA_PROVEEDORES, header=0)
    bp["nit_norm"] = bp["NIT"].apply(normalizar_nit)
    bp_uniq = bp.drop_duplicates("nit_norm", keep="first")

    merged = compras.merge(bp_uniq, on="nit_norm", how="left", suffixes=("", "_prov"))

    def _prov_row_por_nit(r):
        pr = bp_uniq[bp_uniq["nit_norm"] == r["nit_norm"]]
        return pr.iloc[0] if len(pr) else pd.Series({})

    merged["_fecha_ord"] = merged.apply(fecha_para_orden, axis=1)
    merged["_tipo_comp_row"] = merged.apply(lambda r: tipo_comprobante_desde_row(r, _prov_row_por_nit(r)), axis=1)
    merged["_prefijo"] = merged.apply(lambda r: prefijo_documento(r, r["_tipo_comp_row"]), axis=1)
    merged["_folio"] = merged["Folio"].fillna(0).astype(str).str.replace(".0", "", regex=False).str.strip()
    merged = merged.sort_values(["_fecha_ord", "_prefijo", "_folio"]).reset_index(drop=True)

    if os.path.isfile(RUTA_IVA_DETALLE):
        try:
            c = COL_IVA_DETALLE
            cols_det = [c["nit"], c["prefijo"], c["folio"], c["base_exenta"], c["base_5"], c["iva_5"], c["base_19"], c["iva_19"]]
            try:
                detalle = pd.read_excel(RUTA_IVA_DETALLE, sheet_name=HOJA_IVA_DETALLE, header=0)
                detalle = detalle[[x for x in cols_det if x in detalle.columns]].copy()
            except Exception:
                detalle = pd.DataFrame(columns=cols_det)
            try:
                revisar = pd.read_excel(RUTA_IVA_DETALLE, sheet_name=HOJA_REVISAR_IVA, header=0)
                if c["iva_19"] in revisar.columns:
                    vb = pd.to_numeric(revisar.get(c["base_exenta"], pd.Series(dtype=float)), errors="coerce").fillna(0)
                    v5 = pd.to_numeric(revisar.get(c["iva_5"], pd.Series(dtype=float)), errors="coerce").fillna(0)
                    v19 = pd.to_numeric(revisar.get(c["iva_19"], pd.Series(dtype=float)), errors="coerce").fillna(0)
                    tiene_valor = (vb + v5 + v19) > 0
                    rev_ok = revisar.loc[tiene_valor, [x for x in cols_det if x in revisar.columns]].copy()
                    if len(rev_ok) > 0:
                        detalle = pd.concat([detalle, rev_ok], ignore_index=True)
                        detalle = detalle.drop_duplicates(subset=[c["nit"], c["prefijo"], c["folio"]], keep="first")
            except Exception:
                pass
            detalle["nit_norm"] = detalle[c["nit"]].apply(normalizar_nit)
            detalle["_prefijo"] = detalle[c["prefijo"]].fillna("").astype(str).str.strip().str.replace(".0", "", regex=False)
            detalle["_folio"] = detalle[c["folio"]].fillna(0).astype(str).str.replace(".0", "", regex=False).str.strip()
            detalle["_tiene_detalle_iva"] = 1
            cols_detalle = [c["base_exenta"], c["base_5"], c["iva_5"], c["base_19"], c["iva_19"]]
            detalle_merge = detalle[["nit_norm", "_prefijo", "_folio", "_tiene_detalle_iva"] + cols_detalle].copy()
            merged = merged.merge(detalle_merge, on=["nit_norm", "_prefijo", "_folio"], how="left", suffixes=("", "_det"))
            print("Detalle IVA cargado:", RUTA_IVA_DETALLE, "| documentos con detalle:", merged["_tiene_detalle_iva"].notna().sum())
        except Exception as e:
            print("No se pudo cargar detalle IVA:", RUTA_IVA_DETALLE, "|", e)
            merged["_tiene_detalle_iva"] = pd.NA
    else:
        merged["_tiene_detalle_iva"] = pd.NA

    validar_nits_compras_en_base_proveedores(compras, bp_uniq)

    return merged, bp_uniq, columnas_plano, COL


def main():
    merged, bp_uniq, columnas_plano, COL = cargar_merged_y_plano()

    def fila_vacia():
        return {c: None for c in columnas_plano}

    def rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=None, tipo_comp=TIPO_FACTURA_ELECTRONICA, incluir_prefijo_folio=False):
        prefijo_texto = prefijo_documento(row, tipo_comp)
        folio = row.get("Folio")
        if pd.notna(folio) and isinstance(folio, float): folio = int(folio)
        folio = str(folio).strip() if folio is not None else ""
        # Prefijo y número (consecutivo doc) solo en la línea de CxPagar (incluir_prefijo_folio)
        prefijo_out = prefijo_texto if incluir_prefijo_folio else None
        folio_out = folio if incluir_prefijo_folio else None
        fecha_str = fecha_emision_str(row)
        nit = str(row.get("NIT Emisor", "")).strip()
        obs = f"{descripcion_proveedor or ''} {prefijo_texto} {folio}".strip()[:200]
        if not obs:
            obs = f"{prefijo_texto}{folio} {row.get('Nombre Emisor', '')}"[:200].strip()
        return {
            COL["tipo_comprobante"]: tipo_comp,
            COL["consecutivo"]: consecutivo_comprobante,
            COL["fecha_elab"]: fecha_str,
            COL["sigla_moneda"]: None,
            COL["tasa_cambio"]: None,
            COL["id_tercero"]: nit,
            COL["sucursal"]: None,
            COL["cod_producto"]: None,
            COL["cod_bodega"]: None,
            COL["accion"]: None,
            COL["cantidad"]: None,
            COL["prefijo"]: prefijo_out,
            COL["consecutivo_doc"]: folio_out,
            COL["no_cuota"]: None,
            COL["fecha_venc"]: None,
            COL["codigo_impuesto"]: None,
            COL["cod_grupo_activo"]: None,
            COL["cod_activo_fijo"]: None,
            COL["descripcion"]: obs,
            COL["centro_costos"]: None,
            COL["debito"]: None,
            COL["credito"]: None,
            COL["observaciones"]: obs,
            COL["base_gravable"]: None,
            COL["base_exenta"]: None,
            COL["mes_cierre"]: None,
        }

    lineas = []
    consecutivo_9 = CONSECUTIVO_INICIAL_9
    consecutivo_10 = CONSECUTIVO_INICIAL_10
    consecutivo_12 = CONSECUTIVO_INICIAL_12
    consecutivos_asignados = []  # para validación vs COMPRAS (mismo orden que merged)
    documentos_iva_revisar = []  # documentos donde IVA/base no es congruente con 19% ni 5%

    for idx, row in merged.iterrows():
        nit_norm = row["nit_norm"]
        prov = bp_uniq[bp_uniq["nit_norm"] == nit_norm]
        if len(prov) == 0:
            prov_row = pd.Series({})
        else:
            prov_row = prov.iloc[0]
        tipo_comp = tipo_comprobante_desde_row(row, prov_row)
        es_nota_credito = tipo_comp == TIPO_NOTA_CREDITO
        es_gastos = tipo_comp == TIPO_GASTOS
        fecha_doc_str = fecha_emision_str(row)

        if tipo_comp == TIPO_NOTA_CREDITO:
            consecutivo_comprobante = consecutivo_12
            consecutivo_12 += 1
        elif tipo_comp == TIPO_GASTOS:
            consecutivo_comprobante = consecutivo_10
            consecutivo_10 += 1
        else:
            consecutivo_comprobante = consecutivo_9
            consecutivo_9 += 1
        consecutivos_asignados.append(consecutivo_comprobante)

        descripcion_prov = ""
        if "DESCRIPCION" in prov_row.index and pd.notna(prov_row.get("DESCRIPCION")):
            descripcion_prov = str(prov_row["DESCRIPCION"]).strip()

        base_doc = valor_num(row.get("BASE", 0))
        total_doc = valor_num(row.get("Total", 0))

        base_cuenta = cuenta_del_proveedor(prov_row, "Base", CUENTA_BASE_DEFECTO)
        cxpagar_cuenta = cuenta_del_proveedor(prov_row, "CxPagar", CUENTA_CXPAGAR_DEFECTO)

        fedepapa_cuenta = cuenta_del_proveedor(prov_row, COL_FEDEPAPA_PROVEEDOR, "")
        if not fedepapa_cuenta:
            for cand in ("FEDEPAPA", "Fedepapá"):
                fedepapa_cuenta = cuenta_del_proveedor(prov_row, cand, "")
                if fedepapa_cuenta:
                    break
        cxp_es_2205 = str(cxpagar_cuenta).strip().startswith("2205")
        aplica_fedepapa = bool(fedepapa_cuenta) and base_doc > 0 and cxp_es_2205
        fedepapa_monto = round(base_doc * 0.01, 2) if aplica_fedepapa else 0

        # Retención: usar solo los montos que vienen en COMPRAS (como en el archivo ejemplo)
        # En el ejemplo solo se registra Rete Renta cuando ya viene informada en el archivo, no se calcula por regla.
        rete_iva_monto = valor_num(row.get("Rete IVA", 0))
        rete_ica_monto = valor_num(row.get("Rete ICA", 0))
        rete_renta_monto = valor_num(row.get("Rete Renta", 0))
        if APLICAR_RETE_RENTA_POR_REGLA and rete_renta_monto <= 0:
            base_min, tarifa_pct = retencion_renta_regla(prov_row, base_doc)
            if base_min is not None and tarifa_pct is not None and base_doc >= base_min:
                rete_renta_monto = round(base_doc * tarifa_pct / 100, 2)
                if not cuenta_del_proveedor(prov_row, "Rete Renta", ""):
                    rete_renta_monto = 0
        total_retenciones = rete_iva_monto + rete_renta_monto + rete_ica_monto + fedepapa_monto

        # Precalcular si hay IVA y saldo_iva para base exenta y tarifa IVA (19% / 5%)
        sum_impuestos_debito_pre = 0.0
        for compras_col, cod_imp, col_cuenta_prov, usa_base, es_retencion in MAPEO_IMPUESTOS:
            if cod_imp is None or es_retencion:
                continue
            monto = rete_renta_monto if compras_col == "Rete Renta" else valor_num(row.get(compras_col, 0))
            if monto <= 0:
                continue
            if cuenta_del_proveedor(prov_row, col_cuenta_prov, ""):
                sum_impuestos_debito_pre += monto
        sum_costo_pre = sum(valor_num(row.get(c, 0)) for c in IMPUESTOS_A_COSTO_61359501)
        saldo_iva_pre = round(total_doc - base_doc - sum_costo_pre - sum_impuestos_debito_pre, 2)
        tiene_iva = (valor_num(row.get("IVA", 0)) > 0) or (saldo_iva_pre > 0.02)

        # Solo cuando la cuenta empieza por 14: RegistroManual, +, cantidad 0
        def si_cuenta_14(lin, cuenta):
            if cuenta and str(cuenta).strip().startswith("14"):
                lin[COL["cod_producto"]] = "RegistroManual"
                lin[COL["cod_bodega"]] = 1
                # Nota crédito: movimiento en inventario negativo (-); factura: positivo (+)
                lin[COL["accion"]] = "-" if es_nota_credito else "+"
                lin[COL["cantidad"]] = 0

        # Nota crédito (12): invertir débito y crédito respecto a factura (9)
        def _v(x):
            if x is None or x == 0: return None
            return round(float(x), 2)
        def asignar_dc(lin, debito_val, credito_val):
            if es_nota_credito:
                d, c = _v(credito_val), _v(debito_val)
            else:
                d, c = _v(debito_val), _v(credito_val)
            lin[COL["debito"]] = d
            lin[COL["credito"]] = c

        def cerrar_linea(lin):
            limpiar_codigo_impuesto_si_cuenta_5(lin, COL["codigo_cuenta"], COL["codigo_impuesto"])
            fijar_vencimiento_y_cuota_si_aplica(lin, COL["codigo_cuenta"], COL["no_cuota"], COL["fecha_venc"], fecha_doc_str)

        tiene_detalle_iva = pd.notna(row.get("_tiene_detalle_iva"))
        civa = COL_IVA_DETALLE

        # 1) Línea cuenta Base: FE = débito Base; NC = crédito Base
        comun = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp)
        comun[COL["codigo_cuenta"]] = base_cuenta
        si_cuenta_14(comun, base_cuenta)
        asignar_dc(comun, base_doc, None)
        # Base exenta: si hay detalle IVA usar "Base exenta"; si no hay IVA usar base_doc
        if tiene_detalle_iva and civa["base_exenta"] in row.index:
            comun[COL["base_exenta"]] = round(valor_num(row.get(civa["base_exenta"], 0)), 2)
        elif not tiene_iva:
            comun[COL["base_exenta"]] = round(base_doc, 2)
        cerrar_linea(comun)
        lineas.append(comun.copy())

        # 1b) Impuestos a mayor valor del costo: cuenta 61359501, débito (FE) o crédito (NC)
        for col_impuesto in IMPUESTOS_A_COSTO_61359501:
            monto_costo = valor_num(row.get(col_impuesto, 0))
            if monto_costo <= 0:
                continue
            lin_costo = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp)
            lin_costo[COL["codigo_cuenta"]] = CUENTA_MAYOR_VALOR_COSTO
            asignar_dc(lin_costo, monto_costo, None)
            cerrar_linea(lin_costo)
            lineas.append(lin_costo.copy())

        # 2) Líneas por cada impuesto con monto > 0 (Rete Renta usa monto del archivo o el calculado por regla)
        base_gravable_doc = base_doc  # una sola BASE en COMPRAS
        sum_impuestos_debito = 0.0  # para detectar saldo a IVA
        for compras_col, cod_imp, col_cuenta_prov, usa_base, es_retencion in MAPEO_IMPUESTOS:
            if compras_col == "Rete Renta":
                cod_imp = codigo_retefuente_renta(prov_row)
            if cod_imp is None: continue
            # Si hay detalle IVA, el IVA se registra en el bloque 2c (por tarifa 5% y 19%)
            if compras_col == "IVA" and tiene_detalle_iva:
                continue
            if compras_col == "Rete Renta":
                monto = rete_renta_monto
            else:
                monto = valor_num(row.get(compras_col, 0))
            if monto <= 0: continue
            if compras_col == "IVA":
                base_grav_iva, congruente = iva_base_gravable_y_tarifa(monto, base_gravable_doc)
                es_19_iva = (base_grav_iva is None or base_grav_iva == 0 or
                             (base_grav_iva > 0 and abs(monto / base_grav_iva - TARIFA_IVA_19) <= TOLERANCIA_TARIFA_IVA))
                cod_imp = COD_IVA_19 if es_19_iva else COD_IVA_5
                cuenta_imp = cuenta_iva_efectiva(prov_row, es_19_iva, es_nota_credito, es_gastos)
            else:
                cuenta_imp = cuenta_del_proveedor(prov_row, col_cuenta_prov, "")
            if not cuenta_imp: continue
            lin = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp)
            lin[COL["codigo_cuenta"]] = cuenta_imp
            si_cuenta_14(lin, cuenta_imp)
            lin[COL["codigo_impuesto"]] = cod_imp
            if es_retencion:
                asignar_dc(lin, None, monto)
            else:
                asignar_dc(lin, monto, None)
                sum_impuestos_debito += monto
            if usa_base:
                if compras_col == "IVA":
                    base_grav_iva, congruente = iva_base_gravable_y_tarifa(monto, base_gravable_doc)
                    # Siempre base_gravable × tarifa = monto (para que concuerde en cuenta 24)
                    tarifa_iva = TARIFA_IVA_19 if es_19_iva else TARIFA_IVA_5
                    lin[COL["base_gravable"]] = round(monto / tarifa_iva, 2)
                    if not congruente:
                        documentos_iva_revisar.append({
                            "nit": row.get("NIT Emisor"),
                            "consecutivo": consecutivo_comprobante,
                            "prefijo": str(row.get("Prefijo", "")),
                            "folio": str(row.get("Folio", "")),
                            "cufe": row.get("CUFE/CUDE"),
                            "tasa_efectiva_pct": round(100 * monto / base_gravable_doc, 2) if base_gravable_doc else None,
                            "monto_iva": monto,
                            "base": base_gravable_doc,
                            "origen": "columna_IVA",
                        })
                else:
                    lin[COL["base_gravable"]] = round(base_gravable_doc, 2)
            else:
                lin[COL["base_gravable"]] = None
            cerrar_linea(lin)
            lineas.append(lin.copy())

        # 2c) Detalle IVA por tarifa (archivo COMPRAS_IVA_DETALLE): líneas IVA 5% e IVA 19%
        if tiene_detalle_iva and civa["iva_19"] in row.index:
            iva_5 = valor_num(row.get(civa["iva_5"], 0))
            base_5 = valor_num(row.get(civa["base_5"], 0))
            iva_19 = valor_num(row.get(civa["iva_19"], 0))
            base_19 = valor_num(row.get(civa["base_19"], 0))
            # Evitar dos líneas por el mismo monto: INC (COMPRAS, MAPEO) e IVA 19% (detalle) — p. ej. CL 190267
            inc_compras = valor_num(row.get("INC", 0))
            if (
                iva_19 > 0
                and inc_compras > 0
                and abs(iva_19 - inc_compras) <= TOLERANCIA_DUPLICADO_INC_VS_IVA19_DETALLE
            ):
                iva_19 = 0.0
            cuenta_iva_5 = cuenta_iva_efectiva(prov_row, False, es_nota_credito, es_gastos)
            cuenta_iva_19 = cuenta_iva_efectiva(prov_row, True, es_nota_credito, es_gastos)
            if iva_5 > 0 and cuenta_iva_5:
                lin5 = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp)
                lin5[COL["codigo_cuenta"]] = cuenta_iva_5
                lin5[COL["codigo_impuesto"]] = COD_IVA_5
                # Base tal que base × 5% = iva_5 (para que concuerde en cuenta 24)
                lin5[COL["base_gravable"]] = round(iva_5 / TARIFA_IVA_5, 2)
                asignar_dc(lin5, iva_5, None)
                cerrar_linea(lin5)
                lineas.append(lin5.copy())
                sum_impuestos_debito += iva_5
            if iva_19 > 0 and cuenta_iva_19:
                lin19 = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp)
                lin19[COL["codigo_cuenta"]] = cuenta_iva_19
                lin19[COL["codigo_impuesto"]] = COD_IVA_19
                # Base tal que base × 19% = iva_19 (para que concuerde en cuenta 24)
                lin19[COL["base_gravable"]] = round(iva_19 / TARIFA_IVA_19, 2)
                asignar_dc(lin19, iva_19, None)
                cerrar_linea(lin19)
                lineas.append(lin19.copy())
                sum_impuestos_debito += iva_19

        # 2b) Saldo a IVA: cuando Total - BASE no está desglosado en columnas de impuestos (ej. POS)
        sum_costo_61359501 = sum(valor_num(row.get(c, 0)) for c in IMPUESTOS_A_COSTO_61359501)
        debitos_antes_cxpagar = base_doc + sum_costo_61359501 + sum_impuestos_debito
        saldo_iva = round(total_doc - debitos_antes_cxpagar, 2)
        if saldo_iva > 0.02:
            # Saldo a IVA sin detalle → se asume 19%; cuenta según compra o devolución (NC)
            cuenta_iva = cuenta_iva_efectiva(prov_row, True, es_nota_credito, es_gastos)
            if cuenta_iva:
                base_grav_iva, congruente = iva_base_gravable_y_tarifa(saldo_iva, base_doc)
                lin_iva = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp)
                lin_iva[COL["codigo_cuenta"]] = cuenta_iva
                lin_iva[COL["codigo_impuesto"]] = COD_IVA_19  # IVA (saldo sin detalle → 19%)
                # Base gravable tal que base × 19% = saldo_iva (para que concuerde en 24)
                lin_iva[COL["base_gravable"]] = round(saldo_iva / TARIFA_IVA_19, 2)
                asignar_dc(lin_iva, saldo_iva, None)
                cerrar_linea(lin_iva)
                lineas.append(lin_iva.copy())
                if not congruente:
                    documentos_iva_revisar.append({
                        "nit": row.get("NIT Emisor"),
                        "consecutivo": consecutivo_comprobante,
                        "prefijo": str(row.get("Prefijo", "")),
                        "folio": str(row.get("Folio", "")),
                        "cufe": row.get("CUFE/CUDE"),
                        "tasa_efectiva_pct": round(100 * saldo_iva / base_doc, 2) if base_doc else None,
                        "monto_iva": saldo_iva,
                        "base": base_doc,
                        "origen": "saldo_iva",
                    })

        # 2d) Fedepapa 1% sobre base (columna Fedepapa en proveedor; CxP 2205*). Cuenta 236902: crédito en FE, débito en NC.
        if fedepapa_monto > 0:
            lin_fp = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp)
            lin_fp[COL["codigo_cuenta"]] = CUENTA_FEDEPAPA_NC
            si_cuenta_14(lin_fp, CUENTA_FEDEPAPA_NC)
            lin_fp[COL["codigo_impuesto"]] = COD_FEDEPAPA
            lin_fp[COL["base_gravable"]] = round(base_doc, 2)
            # Mismo (None, monto): sin NC → crédito; con NC asignar_dc invierte → débito
            asignar_dc(lin_fp, None, fedepapa_monto)
            cerrar_linea(lin_fp)
            lineas.append(lin_fp.copy())

        # 3) Línea CxPagar: FE = crédito (total - retenciones); NC = débito (prefijo y número solo aquí)
        lin_cxp = rellenar_comun(row, consecutivo_comprobante, descripcion_proveedor=descripcion_prov, tipo_comp=tipo_comp, incluir_prefijo_folio=True)
        lin_cxp[COL["codigo_cuenta"]] = cxpagar_cuenta
        si_cuenta_14(lin_cxp, cxpagar_cuenta)
        asignar_dc(lin_cxp, None, total_doc - total_retenciones)
        cerrar_linea(lin_cxp)
        lineas.append(lin_cxp.copy())

    # Armar DataFrame en orden de columnas del plano
    df_out = pd.DataFrame(lineas)
    df_out = df_out[[c for c in columnas_plano if c in df_out.columns]]

    # Validación: débitos y créditos deben sumar igual
    col_deb = columnas_plano[21]
    col_cred = columnas_plano[22]
    col_conc = columnas_plano[1]
    total_deb = pd.to_numeric(df_out[col_deb], errors="coerce").fillna(0).sum()
    total_cred = pd.to_numeric(df_out[col_cred], errors="coerce").fillna(0).sum()
    print("Total débitos:  ", total_deb)
    print("Total créditos: ", total_cred)
    if abs(total_deb - total_cred) > 0.02:
        print("ADVERTENCIA: Los totales no cuadran. Documentos que pueden generar el descuadre:")
        df_out["_deb"] = pd.to_numeric(df_out[col_deb], errors="coerce").fillna(0)
        df_out["_cred"] = pd.to_numeric(df_out[col_cred], errors="coerce").fillna(0)
        resumen = df_out.groupby(col_conc).agg(debito=("_deb", "sum"), credito=("_cred", "sum"))
        resumen["diferencia"] = (resumen["debito"] - resumen["credito"]).round(2)
        descuadrados = resumen[resumen["diferencia"].abs() > 0.02]
        if len(descuadrados) > 0:
            print(descuadrados.head(50).to_string())
            print("... (total documentos descuadrados:", len(descuadrados), ")")
    else:
        print("Validación OK: débitos = créditos.")

    # Validación: totales del plano vs totales del archivo original COMPRAS (por documento)
    total_compras_archivo = pd.to_numeric(merged["Total"], errors="coerce").fillna(0).sum()
    compras_por_doc = pd.DataFrame({
        "consecutivo": consecutivos_asignados,
        "Total_compras": pd.to_numeric(merged["Total"], errors="coerce").fillna(0),
    })
    df_out["_deb"] = pd.to_numeric(df_out[col_deb], errors="coerce").fillna(0)
    plano_por_doc = df_out.groupby(col_conc)["_deb"].sum().reset_index()
    plano_por_doc = plano_por_doc.rename(columns={col_conc: "consecutivo", "_deb": "Total_plano"})
    compra_vs_plano = compras_por_doc.merge(plano_por_doc, on="consecutivo", how="outer")
    compra_vs_plano["diferencia"] = (compra_vs_plano["Total_compras"] - compra_vs_plano["Total_plano"]).round(2)
    docs_totales_distintos = compra_vs_plano[compra_vs_plano["diferencia"].abs() > 0.02]
    total_plano_global = compra_vs_plano["Total_plano"].fillna(0).sum()
    print()
    print("Totales COMPRAS (archivo original):", round(total_compras_archivo, 2))
    print("Totales plano (suma débitos por doc):", round(total_plano_global, 2))
    if len(docs_totales_distintos) > 0:
        print("ADVERTENCIA: Documentos donde el total del plano no concuerda con el total de COMPRAS:")
        print(docs_totales_distintos.head(30).to_string(index=False))
        if len(docs_totales_distintos) > 30:
            print("... (total documentos con diferencia:", len(docs_totales_distintos), ")")
    elif abs(total_compras_archivo - total_plano_global) > 0.02:
        print("ADVERTENCIA: Suma global de totales no coincide (revisar documentos).")
    else:
        print("Validación OK: totales del plano concuerdan con COMPRAS.")

    # Validación: base gravable × tarifa debe coincidir con el monto (débito o crédito) del impuesto
    col_cod_imp = columnas_plano[16]
    col_base_grav = columnas_plano[24]
    inconsistencias_impuesto = []
    for idx, r in df_out.iterrows():
        cod = r.get(col_cod_imp)
        if pd.isna(cod): continue
        try: cod = int(cod)
        except (TypeError, ValueError): continue
        if cod not in CODIGO_IMPUESTO_TARIFA: continue
        base = pd.to_numeric(r.get(col_base_grav), errors="coerce")
        deb = pd.to_numeric(r.get(col_deb), errors="coerce")
        cred = pd.to_numeric(r.get(col_cred), errors="coerce")
        monto = (deb if pd.notna(deb) and deb != 0 else None) or (cred if pd.notna(cred) and cred != 0 else None)
        if pd.isna(base) or base <= 0 or monto is None or monto <= 0: continue
        tarifa = CODIGO_IMPUESTO_TARIFA[cod]
        esperado = round(base * tarifa, 2)
        if abs(esperado - monto) > TOLERANCIA_BASE_TARIFA:
            inconsistencias_impuesto.append({
                "consecutivo": r.get(col_conc),
                "cuenta": r.get(columnas_plano[5]),
                "cod_impuesto": cod,
                "tarifa_pct": round(100 * tarifa, 2),
                "base_gravable": base,
                "monto_registrado": monto,
                "esperado_base_x_tarifa": esperado,
                "diferencia": round(monto - esperado, 2),
            })
    if inconsistencias_impuesto:
        print()
        print("ADVERTENCIA: Líneas donde (base gravable × tarifa) no concuerda con el monto registrado:")
        for inc in inconsistencias_impuesto[:30]:
            print("  Consec. {} | Cuenta {} | Cód.imp {} ({}%) | Base {} | Monto {} | Esperado {} | Diff {}".format(
                inc["consecutivo"], inc["cuenta"], inc["cod_impuesto"], inc["tarifa_pct"],
                inc["base_gravable"], inc["monto_registrado"], inc["esperado_base_x_tarifa"], inc["diferencia"],
            ))
        if len(inconsistencias_impuesto) > 30:
            print("  ... (total líneas con inconsistencia:", len(inconsistencias_impuesto), ")")
    else:
        print("Validación OK: base × tarifa concuerda con montos de impuestos.")

    # Advertencia IVA: documentos donde la tasa efectiva no es 19% ni 5% -> escribir en COMPRAS_IVA_DETALLE hoja "Revisar IVA"
    civa = COL_IVA_DETALLE
    if documentos_iva_revisar:
        print()
        print("ADVERTENCIA IVA: Documentos con IVA cuya tasa efectiva no es congruente con 19% ni 5% (revisar):")
        for d in documentos_iva_revisar:
            print("  Consecutivo {} | Prefijo {} | Folio {} | Tasa efectiva {}% | IVA {} | Base {} | {}".format(
                d["consecutivo"], d["prefijo"], d["folio"], d.get("tasa_efectiva_pct"), d["monto_iva"], d["base"], d.get("origen", ""),
            ))
        print("Total documentos a revisar:", len(documentos_iva_revisar))
        # Escribir resumen en el mismo archivo de detalle, hoja "Revisar IVA", para que pueda ajustar ahí
        df_revisar = pd.DataFrame(documentos_iva_revisar)
        df_revisar = df_revisar.rename(columns={"nit": civa["nit"], "prefijo": civa["prefijo"], "folio": civa["folio"]})
        df_revisar[civa["base_exenta"]] = pd.NA
        df_revisar[civa["base_5"]] = pd.NA
        df_revisar[civa["iva_5"]] = pd.NA
        df_revisar[civa["base_19"]] = pd.NA
        df_revisar[civa["iva_19"]] = pd.NA
        df_revisar = df_revisar[[civa["nit"], civa["prefijo"], civa["folio"], "cufe", civa["base_exenta"], civa["base_5"], civa["iva_5"], civa["base_19"], civa["iva_19"],
                                "consecutivo", "tasa_efectiva_pct", "monto_iva", "base", "origen"]]
        df_revisar = df_revisar.rename(columns={"cufe": "CUFE", "consecutivo": "Consecutivo", "tasa_efectiva_pct": "Tasa efectiva %", "monto_iva": "Monto IVA", "base": "Base actual", "origen": "Origen"})
        try:
            if os.path.isfile(RUTA_IVA_DETALLE):
                with pd.ExcelFile(RUTA_IVA_DETALLE) as xl:
                    sheets = {s: pd.read_excel(xl, sheet_name=s) for s in xl.sheet_names if s != HOJA_REVISAR_IVA}
            else:
                sheets = {}
            sheets[HOJA_REVISAR_IVA] = df_revisar
            with pd.ExcelWriter(RUTA_IVA_DETALLE, engine="openpyxl") as w:
                for name, dff in sheets.items():
                    dff.to_excel(w, sheet_name=name, index=False)
            print("Resumen escrito en", RUTA_IVA_DETALLE, "hoja", HOJA_REVISAR_IVA, "| Complete Base exenta, Base 5%%, IVA 5%%, Base 19%%, IVA 19%% y pase a hoja IVA detalle o deje en Revisar IVA.")
        except Exception as e:
            print("No se pudo escribir Revisar IVA en", RUTA_IVA_DETALLE, "|", e)

    # Guardar solo Excel, con formato del ejemplo (hoja DFFC)
    df_out.to_excel(RUTA_SALIDA_EXCEL, sheet_name=HOJA_EJEMPLO, index=False)
    print("Documentos procesados:", len(merged))
    print("Líneas generadas:", len(lineas))
    print("Guardado:", RUTA_SALIDA_EXCEL)
    return df_out


if __name__ == "__main__":
    main()
