import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AsientosService } from './asientos.service';
import { MapeoCuentasService } from './mapeo-cuentas.service';
import { CODIGO_MEDIO_POR_NOMBRE, MapeoMediosPagoService } from './mapeo-medios-pago.service';
import { desglosarComision } from './regimen-comision';
import { PeriodosService } from './periodos.service';
import { TipoCambioService } from './tipo-cambio.service';
import { facturaVentaContabilizable, movimientoSeContabilizaPorSuOrigen } from './reglas-contabilizacion';

@Injectable()
export class ContabilidadIntegracionService {
  private readonly logger = new Logger(ContabilidadIntegracionService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly asientos: AsientosService,
    private readonly mapeo: MapeoCuentasService,
    private readonly mediosPago: MapeoMediosPagoService,
    private readonly periodos: PeriodosService,
    private readonly tipoCambio: TipoCambioService,
  ) {}


  /**
   * Reparte un total entre las cuentas contables que la empresa configuró para
   * cada medio de pago (`cont_mapeo_medio_pago`).
   *
   * Lo que no está configurado cae en `cuentaPorDefecto`, que es el concepto que
   * el código usaba antes (CAJA_GENERAL y compañía). Así una empresa que no
   * configura nada sigue generando exactamente el mismo asiento.
   *
   * Devuelve un mapa cuenta_id → monto, ya agrupado: dos medios que apuntan a la
   * misma cuenta se suman en una sola línea, como lo escribiría el contador.
   */
  private repartirPorMedio(
    partes: Array<{ medioPagoId: string | null; monto: number }>,
    mapaMedios: Map<string, string>,
    cuentaPorDefecto: string,
  ): Map<string, number> {
    const porCuenta = new Map<string, number>();
    for (const parte of partes) {
      if (!(parte.monto > 0)) continue;
      const cuenta = (parte.medioPagoId ? mapaMedios.get(parte.medioPagoId) : null) ?? cuentaPorDefecto;
      porCuenta.set(cuenta, (porCuenta.get(cuenta) ?? 0) + parte.monto);
    }
    return porCuenta;
  }

  /**
   * Agrega el cheque al mapa de cuentas por medio de pago.
   *
   * El cheque no se configura como medio: se imputa con los conceptos
   * CHEQUES_EN_CARTERA / CHEQUES_EN_TRANSITO, que distinguen el cheque al día
   * del diferido. Ni la factura ni el detalle del recibo simple guardan el
   * vencimiento del cheque, así que en esos dos caminos entra como cheque al
   * día; el recibo con varios medios sí conoce el vencimiento y hace la
   * distinción por su cuenta.
   */
  private async agregarChequeAlMapa(empresaId: string, mapaMedios: Map<string, string>): Promise<void> {
    const [idCheque, cChequesCartera] = await Promise.all([
      this.mediosPago.idMedioPorCodigo(CODIGO_MEDIO_POR_NOMBRE.CHEQUE),
      this.mapeo.getCuentaPorConcepto(empresaId, 'CHEQUES_EN_CARTERA').catch(() => null),
    ]);
    if (idCheque && cChequesCartera) mapaMedios.set(idCheque, cChequesCartera);
  }

  /**
   * Corrige el redondeo del reparto por medio de pago.
   *
   * Los medios se guardan en la moneda del documento y acá se multiplican por la
   * cotización, así que la suma de las partes puede quedar a un guaraní del total
   * del asiento. La diferencia se le carga a la cuenta más grande: sin esto el
   * asiento no cuadra y `crearConfirmado` lo rechaza.
   */
  private ajustarAlTotal(porCuenta: Map<string, number>, total: number): Map<string, number> {
    const suma = [...porCuenta.values()].reduce((a, b) => a + b, 0);
    const diferencia = this.round(total - suma);
    if (porCuenta.size === 0 || diferencia === 0) return porCuenta;

    let cuentaMayor = '';
    let montoMayor = -Infinity;
    for (const [cuenta, monto] of porCuenta) {
      if (monto > montoMayor) {
        montoMayor = monto;
        cuentaMayor = cuenta;
      }
    }
    porCuenta.set(cuentaMayor, this.round(montoMayor + diferencia));
    return porCuenta;
  }

  /*
   * Nota sobre el autor del asiento: cuando el documento no guarda usuario, el
   * asiento va sin usuario (`null`). Antes se mandaba el id de la EMPRESA como
   * si fuera un usuario y la FK `cont_asientos_usuario_id_fkey` rechazaba el
   * insert: el documento quedaba sin contabilizar para siempre, sin que nadie
   * se enterara. `crearConfirmado` acepta `null` desde siempre.
   */

  // ── Guard: módulo CONTABILIDAD activo para la empresa ─────────────────────

  private async tieneModuloContabilidad(empresaId: string): Promise<boolean> {
    const registro = await this.prisma.suscripcion_modulos.findFirst({
      where: {
        activo: true,
        modulos: { codigo: 'CONTABILIDAD' },
        suscripciones: { empresa_id: empresaId },
      },
    });
    return !!registro;
  }

  /**
   * FASE 2 — Acreedores y proveedores del exterior.
   * Resuelve la cuenta contable pasivo que corresponde acreditar/debitar para
   * un proveedor específico, en orden de precedencia:
   *   1. `proveedores.cuenta_contable_id` (override manual del contador).
   *   2. Concepto en `cont_mapeo_cuentas` según `tipo_entidad`:
   *        ACREEDOR_VARIO     → ACREEDORES_VARIOS
   *        PROVEEDOR_EXTERIOR → PROVEEDORES_EXTERIOR
   *        PROVEEDOR_LOCAL    → PROVEEDORES
   *   3. Fallback duro a `PROVEEDORES` (comportamiento legacy — nunca falla).
   *
   * Ver docs/plan-acreedores-proveedores-exterior.md.
   */
  async resolverCuentaPasivo(proveedorId: string, empresaId: string): Promise<string> {
    const prov = await this.prisma.proveedores.findFirst({
      where: { id: proveedorId, empresa_id: empresaId },
      select: { tipo_entidad: true, cuenta_contable_id: true },
    });

    // 1. Override manual del tercero
    if (prov?.cuenta_contable_id) {
      const cuenta = await this.prisma.cont_plan_cuentas.findUnique({
        where: { id: prov.cuenta_contable_id },
        select: { id: true, acepta_movimientos: true, active: true },
      });
      if (cuenta && cuenta.active && cuenta.acepta_movimientos) {
        return cuenta.id;
      }
      this.logger.warn(
        `Override de cuenta contable inválido para proveedor ${proveedorId} — cae a mapeo por concepto`,
      );
    }

    // 2. Concepto según tipo_entidad
    const concepto =
      prov?.tipo_entidad === 'ACREEDOR_VARIO'
        ? 'ACREEDORES_VARIOS'
        : prov?.tipo_entidad === 'PROVEEDOR_EXTERIOR'
          ? 'PROVEEDORES_EXTERIOR'
          : 'PROVEEDORES';
    try {
      return await this.mapeo.getCuentaPorConcepto(empresaId, concepto);
    } catch (err) {
      // 3. Fallback duro a PROVEEDORES si el concepto específico no está mapeado.
      // Garantiza no-regresión — empresas sin los conceptos nuevos siguen operando.
      if (concepto !== 'PROVEEDORES') {
        this.logger.warn(
          `Concepto ${concepto} sin mapeo para empresa ${empresaId} — cae a PROVEEDORES (legacy)`,
        );
        return this.mapeo.getCuentaPorConcepto(empresaId, 'PROVEEDORES');
      }
      throw err;
    }
  }

  // ── Factura de Venta ───────────────────────────────────────────────────────

  async integrarFacturaVenta(facturaId: string) {
    const factura = await this.prisma.factura_cab.findUnique({
      where: { id: facturaId },
      include: {
        factura_subtotales: true,
        condicion_operacion: true,
        moneda: true,
        // Para saber con qué se cobró: la cuenta del debe sale del medio de pago
        // que la empresa haya configurado en el mapeo.
        factura_forma_pagos: { select: { medio_pago_id: true, dmontipag: true, dticamtipag: true } },
      },
    });
    if (!factura) return;
    // Solo una factura aprobada por SIFEN es una venta. Sin esto, "Regenerar"
    // desde Conciliación asentaba facturas anuladas o rechazadas.
    if (!facturaVentaContabilizable(factura.estado)) {
      this.logger.warn(`Factura ${facturaId} en estado ${factura.estado}: no se contabiliza`);
      return;
    }

    const empresaId = factura.empresa_id as string;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    // Idempotencia
    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'factura_cab', origen_id: facturaId },
    });
    if (docExistente) {
      this.logger.warn(`Factura ${facturaId} ya tiene documento contable`);
      return;
    }

    // Si la factura es contado pero marcada cobro_diferido, contablemente se trata como crédito
    // (el dinero entra después vía recibo de cobranza; el asiento de caja lo genera el recibo)
    const esContado = factura.condicion_operacion?.codigo === 1 && !factura.cobro_diferido;
    const monedaCodigo = factura.moneda?.codigo ?? 'PYG';
    const esPYG = monedaCodigo === 'PYG';
    // dticam es la cotización guardada en la factura al momento de emisión (campo SIFEN)
    const tipoCambio = esPYG ? 1 : Number(factura.dticam ?? 1);

    const fecha = factura.dfeemide ?? new Date();
    const sub = factura.factura_subtotales?.[0];

    // Totales desde factura_subtotales (SIFEN) — en moneda de la factura
    // Multiplicamos por tipoCambio para obtener guaraníes (si ya es PYG, tc=1)
    const total = Number(sub?.dtotgralope ?? 0) * tipoCambio;
    const iva10 = Number(sub?.diva10 ?? 0) * tipoCambio;
    const iva5 = Number(sub?.diva5 ?? 0) * tipoCambio;
    // dsub10/dsub5 en SIFEN incluyen IVA → la base neta es dsub - iva
    const base10 = (Number(sub?.dsub10 ?? 0) - Number(sub?.diva10 ?? 0)) * tipoCambio;
    const base5 = (Number(sub?.dsub5 ?? 0) - Number(sub?.diva5 ?? 0)) * tipoCambio;
    const exento = (Number(sub?.dsubexe ?? 0) + Number(sub?.dsubexo ?? 0)) * tipoCambio;

    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para factura ${facturaId}: ${e.message}`);
      return;
    }

    const [cContraparte, cVentas10, cVentas5, cVentasEx, cIva10, cIva5] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, esContado ? 'CAJA_GENERAL' : 'CLIENTES'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'VENTAS_10'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'VENTAS_5'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'VENTAS_EXENTAS'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_DEBITO_10'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_DEBITO_5'),
    ]);

    const lineas: any[] = [];

    if (esContado) {
      // El contador elige a qué cuenta entra cada medio (efectivo, transferencia,
      // cheque…). Lo que no configuró cae en el concepto de siempre.
      const mapaMedios = await this.mediosPago.resolverPorMedio(empresaId, 'COBRO');
      await this.agregarChequeAlMapa(empresaId, mapaMedios);
      const partes = (factura.factura_forma_pagos ?? []).map((fp) => ({
        medioPagoId: fp.medio_pago_id,
        // El medio guarda su propia cotización (`dticamtipag`); si no la trae,
        // vale la de la factura.
        monto: Number(fp.dmontipag ?? 0) * (esPYG ? 1 : Number(fp.dticamtipag ?? 0) || tipoCambio),
      }));

      let porCuenta = this.repartirPorMedio(partes, mapaMedios, cContraparte);
      // Sin formas de pago cargadas no hay nada que repartir: una sola línea,
      // igual que antes.
      porCuenta = porCuenta.size === 0
        ? new Map([[cContraparte, total]])
        : this.ajustarAlTotal(porCuenta, total);

      for (const [cuentaId, monto] of porCuenta) {
        lineas.push({ cuenta_id: cuentaId, debe_moneda: monto, haber_moneda: 0, descripcion: 'Cobro contado' });
      }
    } else {
      lineas.push({ cuenta_id: cContraparte, debe_moneda: total, haber_moneda: 0, descripcion: 'Clientes' });
    }
    if (base10 > 0) lineas.push({ cuenta_id: cVentas10, debe_moneda: 0, haber_moneda: base10, descripcion: 'Ventas 10%' });
    if (base5 > 0) lineas.push({ cuenta_id: cVentas5, debe_moneda: 0, haber_moneda: base5, descripcion: 'Ventas 5%' });
    if (exento > 0) lineas.push({ cuenta_id: cVentasEx, debe_moneda: 0, haber_moneda: exento, descripcion: 'Ventas exentas' });
    if (iva10 > 0) lineas.push({ cuenta_id: cIva10, debe_moneda: 0, haber_moneda: iva10, descripcion: 'IVA Débito 10%' });
    if (iva5 > 0) lineas.push({ cuenta_id: cIva5, debe_moneda: 0, haber_moneda: iva5, descripcion: 'IVA Débito 5%' });

    const nroComprobante = `${factura.dest}-${factura.dpunexp}-${factura.dnumdoc}`;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: esContado ? 'FACTURA_VENTA_CONTADO' : 'FACTURA_VENTA_CREDITO',
          origen_tipo: 'factura_cab',
          origen_id: facturaId,
          numero_documento: nroComprobante,
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Factura venta ${esContado ? '(contado)' : '(crédito)'} N° ${nroComprobante}${!esPYG ? ` [${monedaCodigo} x ${tipoCambio}]` : ''}`,
        moneda_origen: 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(`Factura venta ${facturaId} (${nroComprobante}) contabilizada`);
  }

  // ── Nota de Crédito ───────────────────────────────────────────────────────

  async integrarNotaCredito(notaCreditoId: string) {
    const nc = await this.prisma.nota_credito_cab.findUnique({
      where: { id: notaCreditoId },
      include: { nota_credito_subtotal: true, moneda: true },
    });
    if (!nc) return;
    if (!facturaVentaContabilizable(nc.estado)) {
      this.logger.warn(`Nota de crédito ${notaCreditoId} en estado ${nc.estado}: no se contabiliza`);
      return;
    }

    const empresaId = nc.empresa_id as string;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'nota_credito_cab', origen_id: notaCreditoId },
    });
    if (docExistente) {
      this.logger.warn(`NC ${notaCreditoId} ya tiene documento contable`);
      return;
    }

    const monedaCodigo = nc.moneda?.codigo ?? 'PYG';
    const esPYG = monedaCodigo === 'PYG';
    const tipoCambio = esPYG ? 1 : Number((nc as any).dticam ?? 1);

    const fecha = nc.dfeemide ?? new Date();
    const sub = nc.nota_credito_subtotal?.[0];

    const total = Number(sub?.dtotgralope ?? 0) * tipoCambio;
    const iva10 = Number(sub?.diva10 ?? 0) * tipoCambio;
    const iva5 = Number(sub?.diva5 ?? 0) * tipoCambio;
    const base10 = (Number(sub?.dsub10 ?? 0) - Number(sub?.diva10 ?? 0)) * tipoCambio;
    const base5 = (Number(sub?.dsub5 ?? 0) - Number(sub?.diva5 ?? 0)) * tipoCambio;
    const exento = (Number(sub?.dsubexe ?? 0) + Number(sub?.dsubexo ?? 0)) * tipoCambio;

    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para NC ${notaCreditoId}: ${e.message}`);
      return;
    }

    const [cClientes, cVentas10, cVentas5, cVentasEx, cIva10, cIva5] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, 'CLIENTES'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'VENTAS_10'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'VENTAS_5'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'VENTAS_EXENTAS'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_DEBITO_10'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_DEBITO_5'),
    ]);

    // NC invierte el asiento de venta: Ventas/IVA al DEBE, Clientes al HABER
    const lineas: any[] = [
      { cuenta_id: cClientes, debe_moneda: 0, haber_moneda: total, descripcion: 'Clientes' },
    ];
    if (base10 > 0) lineas.push({ cuenta_id: cVentas10, debe_moneda: base10, haber_moneda: 0, descripcion: 'Devolución ventas 10%' });
    if (base5 > 0) lineas.push({ cuenta_id: cVentas5, debe_moneda: base5, haber_moneda: 0, descripcion: 'Devolución ventas 5%' });
    if (exento > 0) lineas.push({ cuenta_id: cVentasEx, debe_moneda: exento, haber_moneda: 0, descripcion: 'Devolución ventas exentas' });
    if (iva10 > 0) lineas.push({ cuenta_id: cIva10, debe_moneda: iva10, haber_moneda: 0, descripcion: 'Reversión IVA Débito 10%' });
    if (iva5 > 0) lineas.push({ cuenta_id: cIva5, debe_moneda: iva5, haber_moneda: 0, descripcion: 'Reversión IVA Débito 5%' });

    const nroComprobante = `${nc.dest}-${nc.dpunexp}-${nc.dnumdoc}`;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'NOTA_CREDITO',
          origen_tipo: 'nota_credito_cab',
          origen_id: notaCreditoId,
          numero_documento: nroComprobante,
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Nota de Crédito N° ${nroComprobante}${!esPYG ? ` [${monedaCodigo} x ${tipoCambio}]` : ''}`,
        moneda_origen: 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(`Nota de Crédito ${notaCreditoId} (${nroComprobante}) contabilizada`);
  }

  // ── Factura de Compra ──────────────────────────────────────────────────────

  async integrarFacturaCompra(compraId: string) {
    const compra = await this.prisma.compra_cab.findUnique({
      where: { id: compraId },
      include: { condicion_operacion: true, moneda: true },
    });
    if (!compra) return;

    const empresaId = compra.empresa_id;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    // Con "incluir contado en orden de pago" activo, una compra de contado difiere
    // el pago a la orden de pago: contablemente se comporta como crédito (acredita
    // Proveedores, no Caja). Espejo de `cobro_diferido` en ventas. El pago real lo
    // asienta la orden de pago (Dr Proveedores / Cr Caja) al ejecutarse.
    const configCompras = await this.prisma.config_compras.findUnique({
      where: { empresa_id: empresaId },
      select: { incluir_contado_en_orden_pago: true },
    });
    const difierePagoContado = Boolean(configCompras?.incluir_contado_en_orden_pago);
    const esContado = compra.condicion_operacion?.codigo === 1 && !difierePagoContado;
    const tipoDocumento = esContado ? 'FACTURA_COMPRA_CONTADO' : 'FACTURA_COMPRA_CREDITO';
    const numeroDoc = compra.numero_factura ?? `COMPRA-${compraId.substring(0, 8).toUpperCase()}`;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'compra_cab', origen_id: compraId },
      include: { asiento: { select: { id: true, estado: true } } },
    });
    if (docExistente?.asiento && docExistente.estado === 'CONFIRMADO') return;
    if (docExistente?.asiento && docExistente.estado !== 'CONFIRMADO') {
      await this.prisma.cont_documentos.update({
        where: { id: docExistente.id },
        data: { origen_tipo: null, origen_id: null, updated_at: new Date() },
      });
    }

    const upsertDocumentoBorrador = async (motivo: string) => {
      await this.prisma.cont_documentos.upsert({
        where: {
          empresa_id_origen_tipo_origen_id: {
            empresa_id: empresaId,
            origen_tipo: 'compra_cab',
            origen_id: compraId,
          },
        },
        create: {
          empresa_id: empresaId,
          tipo: tipoDocumento,
          origen_tipo: 'compra_cab',
          origen_id: compraId,
          numero_documento: numeroDoc,
          estado: 'BORRADOR',
        },
        update: {
          tipo: tipoDocumento,
          numero_documento: numeroDoc,
          estado: 'BORRADOR',
          updated_at: new Date(),
        },
      });
      this.logger.warn(`Integración contable pendiente para compra ${compraId}: ${motivo}`);
    };

    const monedaCodigo = compra.moneda?.codigo ?? 'PYG';
    const esPYG = monedaCodigo === 'PYG';
    const tipoCambio = esPYG ? 1 : Number(compra.cotizacion ?? 1);

    const fecha = compra.fecha_emision ?? new Date();

    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      const reason = e instanceof Error ? e.message : String(e);
      await upsertDocumentoBorrador(`No hay período abierto (${reason})`);
      return;
    }

    const total = Number(compra.total ?? 0) * tipoCambio;
    const iva10 = Number(compra.total_iva_10 ?? 0) * tipoCambio;
    const iva5 = Number(compra.total_iva_5 ?? 0) * tipoCambio;
    const subtotalSinIva = total - iva10 - iva5;

    let cContraparte: string;
    let cInventario: string;
    let cIvaCredito10: string;
    let cIvaCredito5: string;
    try {
      // FASE 2: Si es crédito, resolver cuenta pasivo según tipo de proveedor.
      const contrapartePromise = esContado
        ? this.mapeo.getCuentaPorConcepto(empresaId, 'CAJA_GENERAL')
        : this.resolverCuentaPasivo(compra.proveedor_id, empresaId);
      [cContraparte, cInventario, cIvaCredito10, cIvaCredito5] = await Promise.all([
        contrapartePromise,
        this.mapeo.getCuentaPorConcepto(empresaId, 'INVENTARIO'),
        this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_10'),
        this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_5'),
      ]);
    } catch (e) {
      const message = e instanceof Error ? e.message : 'Error de mapeo contable';
      if (message.includes('No hay cuenta mapeada para el concepto')) {
        await upsertDocumentoBorrador(message);
        return;
      }
      throw e;
    }

    const lineas: any[] = [
      { cuenta_id: cInventario, debe_moneda: subtotalSinIva, haber_moneda: 0, descripcion: 'Inventario/Gasto' },
    ];
    if (iva10 > 0) lineas.push({ cuenta_id: cIvaCredito10, debe_moneda: iva10, haber_moneda: 0, descripcion: 'IVA Crédito 10%' });
    if (iva5 > 0) lineas.push({ cuenta_id: cIvaCredito5, debe_moneda: iva5, haber_moneda: 0, descripcion: 'IVA Crédito 5%' });
    lineas.push({ cuenta_id: cContraparte, debe_moneda: 0, haber_moneda: total, descripcion: esContado ? 'Caja (contado)' : 'Proveedor' });

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.upsert({
        where: {
          empresa_id_origen_tipo_origen_id: {
            empresa_id: empresaId,
            origen_tipo: 'compra_cab',
            origen_id: compraId,
          },
        },
        create: {
          empresa_id: empresaId,
          tipo: tipoDocumento,
          origen_tipo: 'compra_cab',
          origen_id: compraId,
          numero_documento: numeroDoc,
          estado: 'CONFIRMADO',
        },
        update: {
          tipo: tipoDocumento,
          numero_documento: numeroDoc,
          estado: 'CONFIRMADO',
          updated_at: new Date(),
        },
      });

      await this.asientos.crearConfirmado(empresaId, (compra.usuario_id as string) ?? null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Factura compra ${esContado ? '(contado)' : '(crédito)'} N° ${numeroDoc}${!esPYG ? ` [${monedaCodigo} x ${tipoCambio}]` : ''}`,
        moneda_origen: 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(`Factura compra ${compraId} contabilizada`);
  }

  // ── Nota de Crédito de Compra ────────────────────────────────────────────
  // Asiento inverso a la factura de compra: DEBE Proveedores (reduce el pasivo)
  // / HABER Inventario-Gasto (neto) + HABER IVA Crédito (reversa el crédito fiscal).
  async integrarNotaCreditoCompra(ncId: string) {
    const nc = await this.prisma.nota_credito_compra_cab.findUnique({
      where: { id: ncId },
      include: { moneda: true },
    });
    if (!nc) return;
    const empresaId = nc.empresa_id;
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'nota_credito_compra_cab', origen_id: ncId },
      include: { asiento: { select: { id: true, estado: true } } },
    });
    if (docExistente?.asiento && docExistente.estado === 'CONFIRMADO') return;
    if (docExistente?.asiento && docExistente.estado !== 'CONFIRMADO') {
      await this.prisma.cont_documentos.update({
        where: { id: docExistente.id },
        data: { origen_tipo: null, origen_id: null, updated_at: new Date() },
      });
    }

    const monedaCodigo = nc.moneda?.codigo ?? 'PYG';
    const esPYG = monedaCodigo === 'PYG';
    const tipoCambio = esPYG ? 1 : Number(nc.cotizacion ?? 1);
    const fecha = nc.fecha_emision ?? new Date();
    const numeroDoc = [nc.establecimiento, nc.punto_expedicion, nc.numero].filter(Boolean).join('-') || `NCC-${ncId.substring(0, 8).toUpperCase()}`;

    const total = Number(nc.total ?? 0) * tipoCambio;
    const iva10 = Number(nc.total_iva_10 ?? 0) * tipoCambio;
    const iva5 = Number(nc.total_iva_5 ?? 0) * tipoCambio;
    const neto = total - iva10 - iva5;

    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para NC compra ${ncId}: ${e instanceof Error ? e.message : e}`);
      return;
    }

    let cProveedor: string;
    let cInventario: string;
    let cIvaCredito10: string;
    let cIvaCredito5: string;
    try {
      [cProveedor, cInventario, cIvaCredito10, cIvaCredito5] = await Promise.all([
        this.resolverCuentaPasivo(nc.proveedor_id, empresaId),
        this.mapeo.getCuentaPorConcepto(empresaId, 'INVENTARIO'),
        this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_10'),
        this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_5'),
      ]);
    } catch (e) {
      this.logger.warn(`NC compra ${ncId} sin mapeo contable: ${e instanceof Error ? e.message : e}`);
      return;
    }

    // Inverso a compra: Proveedores al DEBE; Inventario/Gasto e IVA Crédito al HABER.
    const lineas: any[] = [
      { cuenta_id: cProveedor, debe_moneda: total, haber_moneda: 0, descripcion: 'Proveedor (NC compra)' },
    ];
    if (neto > 0) lineas.push({ cuenta_id: cInventario, debe_moneda: 0, haber_moneda: neto, descripcion: 'Reversión Inventario/Gasto' });
    if (iva10 > 0) lineas.push({ cuenta_id: cIvaCredito10, debe_moneda: 0, haber_moneda: iva10, descripcion: 'Reversión IVA Crédito 10%' });
    if (iva5 > 0) lineas.push({ cuenta_id: cIvaCredito5, debe_moneda: 0, haber_moneda: iva5, descripcion: 'Reversión IVA Crédito 5%' });

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'NOTA_CREDITO_COMPRA',
          origen_tipo: 'nota_credito_compra_cab',
          origen_id: ncId,
          numero_documento: numeroDoc,
          estado: 'CONFIRMADO',
        },
      });
      await this.asientos.crearConfirmado(empresaId, nc.usuario_id ?? null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Nota de Crédito de compra N° ${numeroDoc}${!esPYG ? ` [${monedaCodigo} x ${tipoCambio}]` : ''}`,
        moneda_origen: 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(`NC compra ${ncId} (${numeroDoc}) contabilizada`);
  }

  // ── Despacho de importación ──────────────────────────────────────────────

  async integrarCierreDespacho(despachoId: string) {
    const despacho = await this.prisma.imp_despachos.findUnique({
      where: { id: despachoId },
      include: { embarque: { include: { proveedores: true, moneda: true } } },
    });
    if (!despacho) return;
    if (despacho.estado !== 'CONFIRMADO') return;

    const empresaId = despacho.empresa_id;
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'imp_despacho', origen_id: despachoId },
    });
    if (docExistente) {
      this.logger.warn(`Despacho ${despachoId} ya tiene documento contable`);
      return;
    }

    const fecha = despacho.fecha_despacho ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e: any) {
      this.logger.error(`No hay período abierto para despacho ${despachoId}: ${e?.message ?? e}`);
      return;
    }

    // Componentes activos del embarque (incluyen los tributos insertados al confirmar el despacho).
    // Se excluyen los que nacieron de un comprobante propio —un gasto de compras o
    // la factura de mercadería del exterior—: ese documento ya generó su asiento
    // por `integrarGasto` / `integrarCompra`, así que sumarlo acá contabilizaría el
    // mismo costo dos veces.
    const componentes = await this.prisma.imp_componentes_costo.findMany({
      where: {
        embarque_id: despacho.embarque_id,
        empresa_id: empresaId,
        activo: true,
        gasto_id: null,
        compra_id: null,
      },
      include: { concepto: { select: { afecta_costo: true } } },
    });

    const totalGs = componentes.reduce((acc, c) => acc + Number(c.importe_gs ?? 0), 0);
    const ivaImportacion = Number(despacho.iva_importacion_gs ?? 0);
    // El anticipo de IRE no es costo de la mercadería: es un crédito de impuesto
    // a la renta. Sale del debe del inventario y va a su propia cuenta, si está
    // mapeada. Sin mapeo se queda en el costo y se avisa, que es preferible a
    // dejar el asiento descuadrado.
    const noCostoGs = componentes
      .filter((c) => c.concepto?.afecta_costo === false)
      .reduce((acc, c) => acc + Number(c.importe_gs ?? 0), 0);
    const inventarioGs = Math.max(0, totalGs - ivaImportacion - noCostoGs);

    if (totalGs <= 0) {
      this.logger.warn(`Despacho ${despachoId} sin importes para contabilizar`);
      return;
    }

    // FASE 2: Proveedor de importación normalmente es PROVEEDOR_EXTERIOR → resolver.
    const [cInventario, cIvaCredito10, cProveedor] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, 'INVENTARIO'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_10'),
      this.resolverCuentaPasivo(despacho.embarque.proveedor_id, empresaId),
    ]);

    // Modelo de cuenta puente (fase 2, docs/plan-importaciones-fase2-contable.md).
    // Es OPCIONAL y se activa solo mapeando IMPORTACIONES_EN_CURSO: las empresas
    // que no lo usen siguen con el asiento directo a inventario de siempre.
    //
    // Con la puente activa el costo no entra al inventario acá: se acumula, y
    // recién al cerrar la importación se descarga contra mercaderías.
    const cPuente = await this.mapeo
      .getCuentaPorConcepto(empresaId, 'IMPORTACIONES_EN_CURSO')
      .catch(() => null);
    const cuentaCosto = cPuente ?? cInventario;

    // La contrapartida depende de si hay anticipo de despacho: si lo hay, los
    // tributos se aplican contra ese anticipo en vez de generar deuda. El sobrante
    // queda como saldo en la cuenta de anticipos, que es lo que pidió el cliente.
    let cContrapartida = cProveedor;
    let glosaContrapartida = `Proveedores y fisco (despacho ${despacho.numero_despacho})`;
    if (cPuente) {
      const anticipo = await this.prisma.tes_movimientos.findFirst({
        where: {
          embarque_id: despacho.embarque_id,
          tipo_pago_importacion: 'DESPACHO',
          estado: { not: 'ANULADO' },
        },
        select: { id: true },
      });
      if (anticipo) {
        const cAnticipo = await this.mapeo
          .getCuentaPorConcepto(empresaId, 'ANTICIPO_DESPACHO')
          .catch(() => null);
        if (cAnticipo) {
          cContrapartida = cAnticipo;
          glosaContrapartida = `Aplicación anticipo de despacho ${despacho.numero_despacho}`;
        } else {
          this.logger.warn(
            `Despacho ${despachoId}: hay anticipo de despacho pero la empresa no mapeó ANTICIPO_DESPACHO — se acredita al proveedor`,
          );
        }
      }
    }

    const lineas: any[] = [
      {
        cuenta_id: cuentaCosto,
        debe_moneda: inventarioGs,
        haber_moneda: 0,
        descripcion: cPuente ? 'Importación en curso' : 'Inventario importación',
      },
    ];
    if (ivaImportacion > 0) {
      lineas.push({
        cuenta_id: cIvaCredito10,
        debe_moneda: ivaImportacion,
        haber_moneda: 0,
        descripcion: 'IVA Crédito importación',
      });
    }
    if (noCostoGs > 0) {
      const cRenta = await this.mapeo
        .getCuentaPorConcepto(empresaId, 'RETENCION_RENTA_FAVOR')
        .catch(() => null);
      lineas.push({
        cuenta_id: cRenta ?? cuentaCosto,
        debe_moneda: noCostoGs,
        haber_moneda: 0,
        descripcion: 'Anticipo IRE importación',
      });
      if (!cRenta) {
        this.logger.warn(
          `Despacho ${despachoId}: RETENCION_RENTA_FAVOR sin mapear — el anticipo de IRE queda en la cuenta de costo`,
        );
      }
    }
    lineas.push({
      cuenta_id: cContrapartida,
      debe_moneda: 0,
      haber_moneda: totalGs,
      descripcion: glosaContrapartida,
    });

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'DESPACHO_IMPORTACION',
          origen_tipo: 'imp_despacho',
          origen_id: despachoId,
          numero_documento: despacho.numero_despacho ?? despachoId.substring(0, 8),
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(
        empresaId,
        (despacho.usuario_id as string) ?? null,
        doc.id,
        {
          periodo_id: periodoId,
          fecha: fecha.toISOString().substring(0, 10),
          glosa: `Cierre despacho importación ${despacho.numero_despacho} (embarque ${despacho.embarque?.numero ?? ''})`,
          moneda_origen: 'PYG',
          lineas,
        },
        tx,
      );
    });

    this.logger.log(`Despacho ${despachoId} contabilizado`);
  }

  /**
   * Asiento de cierre de la importación: descarga la cuenta puente contra
   * mercaderías (fase 2, docs/plan-importaciones-fase2-contable.md).
   *
   *     Debe   INVENTARIO               (costo sin IVA)
   *        Haber   IMPORTACIONES_EN_CURSO
   *
   * Es el último paso del circuito: hasta acá la importación vivió acumulada en
   * la cuenta puente. Sólo corre si la empresa mapeó `IMPORTACIONES_EN_CURSO`;
   * sin eso el inventario ya se cargó al confirmar el despacho y no hay nada que
   * descargar.
   *
   * El monto sale del costeo del embarque (`costo_total_gs`), que es el mismo
   * número con el que la mercadería entró al stock. Si se tomara la suma de los
   * componentes se podría separar del stock cuando hubo recostificación.
   */
  async integrarCierreImportacion(embarqueId: string) {
    const embarque = await this.prisma.imp_embarques.findUnique({
      where: { id: embarqueId },
      select: {
        id: true,
        empresa_id: true,
        numero: true,
        estado: true,
        costo_total_gs: true,
        usuario_id: true,
        despacho: { select: { fecha_despacho: true } },
      },
    });
    if (!embarque) return;
    if (embarque.estado !== 'CERRADO') return;

    const empresaId = embarque.empresa_id;
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const cPuente = await this.mapeo
      .getCuentaPorConcepto(empresaId, 'IMPORTACIONES_EN_CURSO')
      .catch(() => null);
    if (!cPuente) return; // La empresa no usa el modelo de cuenta puente.

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'imp_cierre', origen_id: embarqueId },
    });
    if (docExistente) {
      this.logger.warn(`Embarque ${embarqueId} ya tiene asiento de cierre`);
      return;
    }

    const costo = Number(embarque.costo_total_gs ?? 0);
    if (costo <= 0) {
      this.logger.warn(`Embarque ${embarqueId} sin costo para descargar de importaciones en curso`);
      return;
    }

    const fecha = new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e: any) {
      this.logger.error(`No hay período abierto para el cierre del embarque ${embarqueId}: ${e?.message ?? e}`);
      return;
    }

    const cInventario = await this.mapeo.getCuentaPorConcepto(empresaId, 'INVENTARIO');

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'CIERRE_IMPORTACION',
          origen_tipo: 'imp_cierre',
          origen_id: embarqueId,
          numero_documento: embarque.numero ?? embarqueId.substring(0, 8),
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(
        empresaId,
        (embarque.usuario_id as string) ?? empresaId,
        doc.id,
        {
          periodo_id: periodoId,
          fecha: fecha.toISOString().substring(0, 10),
          glosa: `Cierre importación ${embarque.numero ?? ''}`.trim(),
          moneda_origen: 'PYG',
          lineas: [
            { cuenta_id: cInventario, debe_moneda: costo, haber_moneda: 0, descripcion: 'Mercaderías' },
            { cuenta_id: cPuente, debe_moneda: 0, haber_moneda: costo, descripcion: 'Importaciones en curso' },
          ],
        },
        tx,
      );
    });

    this.logger.log(`Embarque ${embarqueId} cerrado y contabilizado`);
  }

  // ── Gasto ──────────────────────────────────────────────────────────────────
  // Diferencia clave vs compra: los ítems NO van a INVENTARIO.
  // Cada línea de gasto va a su cuenta contable específica (por tipo_gasto
  // o por fallback 'GASTOS_GENERALES'). IVA sólo se debita a cuentas
  // 'IVA_CREDITO_*' si gasto.deducible = true; en caso contrario engrosa la
  // cuenta de gasto. Contraparte depende de forma_pago.

  async integrarGasto(gastoId: string) {
    const gasto = await this.prisma.gasto_cab.findUnique({
      where: { id: gastoId },
      include: {
        moneda: true,
        gasto_det: { include: { tipo_gasto: true } },
      },
    });
    if (!gasto) return;

    const empresaId = gasto.empresa_id;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;
    if (gasto.anulado) return;

    const monedaCodigo = gasto.moneda?.codigo ?? 'PYG';
    const esPYG = monedaCodigo === 'PYG';
    const tipoCambio = esPYG ? 1 : Number(gasto.cotizacion ?? 1);
    const fecha = gasto.fecha_emision ?? new Date();
    const deducible = gasto.deducible !== false;
    const forma = String(gasto.forma_pago ?? '').toUpperCase();
    // Con "incluir contado en orden de pago" activo, un gasto de contado/transferencia
    // con proveedor difiere el pago a la orden de pago: contablemente acredita
    // Proveedores (pasivo) en vez de Caja/Banco. El pago real lo asienta la OP.
    const configComprasGasto = await this.prisma.config_compras.findUnique({
      where: { empresa_id: empresaId },
      select: { incluir_contado_en_orden_pago: true },
    });
    const difierePagoGasto =
      Boolean(configComprasGasto?.incluir_contado_en_orden_pago) &&
      !!gasto.proveedor_id &&
      forma !== 'ANTICIPO' &&
      forma !== 'CREDITO';
    const tipoDocumento = forma === 'CREDITO' || difierePagoGasto ? 'GASTO_CREDITO' : 'GASTO_CONTADO';
    const numeroDoc =
      gasto.numero_factura ??
      `GASTO-${gastoId.substring(0, 8).toUpperCase()}`;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'gasto_cab', origen_id: gastoId },
      include: { asiento: { select: { id: true, estado: true } } },
    });
    if (docExistente?.asiento && docExistente.estado === 'CONFIRMADO') return;
    if (docExistente?.asiento && docExistente.estado !== 'CONFIRMADO') {
      // Libera el origen para permitir nueva integración luego de una reversión.
      await this.prisma.cont_documentos.update({
        where: { id: docExistente.id },
        data: {
          origen_tipo: null,
          origen_id: null,
          updated_at: new Date(),
        },
      });
    }

    const upsertDocumentoBorrador = async (motivo: string) => {
      await this.prisma.cont_documentos.upsert({
        where: {
          empresa_id_origen_tipo_origen_id: {
            empresa_id: empresaId,
            origen_tipo: 'gasto_cab',
            origen_id: gastoId,
          },
        },
        create: {
          empresa_id: empresaId,
          tipo: tipoDocumento,
          origen_tipo: 'gasto_cab',
          origen_id: gastoId,
          numero_documento: numeroDoc,
          estado: 'BORRADOR',
        },
        update: {
          tipo: tipoDocumento,
          numero_documento: numeroDoc,
          estado: 'BORRADOR',
          updated_at: new Date(),
        },
      });
      this.logger.warn(`Integración contable pendiente para gasto ${gastoId}: ${motivo}`);
    };

    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      const reason = e instanceof Error ? e.message : String(e);
      await upsertDocumentoBorrador(`No hay período abierto (${reason})`);
      return;
    }

    // Totales en moneda origen → PYG
    const total = this.round(Number(gasto.total ?? 0) * tipoCambio, 2);
    const iva10 = this.round(Number(gasto.total_iva_10 ?? 0) * tipoCambio, 2);
    const iva5 = this.round(Number(gasto.total_iva_5 ?? 0) * tipoCambio, 2);

    // Contraparte según forma_pago.
    // FASE 2: el crédito con proveedor asignado usa `resolverCuentaPasivo` para
    // acreditar la cuenta correcta según tipo_entidad (acreedor vario, exterior, local).
    let conceptoContraparte = 'CAJA_GENERAL';
    let descContraparte = 'Caja (contado)';
    let esCreditoConProveedor = false;
    if (forma === 'CREDITO' || (forma === '' && gasto.proveedor_id) || difierePagoGasto) {
      conceptoContraparte = 'PROVEEDORES';
      descContraparte = 'Proveedor / Acreedor (crédito)';
      esCreditoConProveedor = !!gasto.proveedor_id;
    } else if (forma === 'TRANSFERENCIA') {
      conceptoContraparte = 'BANCOS_DEFAULT';
      descContraparte = 'Banco (transferencia)';
    } else if (forma === 'ANTICIPO') {
      conceptoContraparte = 'ANTICIPO_PERSONAL';
      descContraparte = 'Anticipo personal';
    }

    // Cuenta fallback para gastos (cuando tipo_gasto.cuenta_contable_id es null)
    let cuentaFallbackGasto: string;
    let cContraparte: string;
    try {
      const contraparteResolver = esCreditoConProveedor
        ? this.resolverCuentaPasivo(gasto.proveedor_id as string, empresaId)
        : this.mapeo.getCuentaPorConcepto(empresaId, conceptoContraparte);
      [cuentaFallbackGasto, cContraparte] = await Promise.all([
        this.mapeo.getCuentaPorConcepto(empresaId, 'GASTOS_GENERALES'),
        contraparteResolver,
      ]);
    } catch (e) {
      const message = e instanceof Error ? e.message : 'Error de mapeo contable';
      if (message.includes('No hay cuenta mapeada para el concepto')) {
        await upsertDocumentoBorrador(message);
        return;
      }
      throw e;
    }

    // Líneas DEBE: agrupar por cuenta contable del ítem (tipo_gasto.cuenta_contable_id o fallback)
    // Si es deducible: gasto neto (sin IVA) + IVA Crédito separado.
    // Si NO es deducible: todo (IVA incluido) a cuenta de gasto.
    const debePorCuenta = new Map<string, { monto: number; descripcion: string }>();
    for (const det of gasto.gasto_det) {
      const cuentaGastoId = det.tipo_gasto?.cuenta_contable_id ?? cuentaFallbackGasto;
      const subtotal = Number(det.subtotal ?? 0);
      const montoIva = Number(det.monto_iva ?? 0);
      const netoSinIva = Math.max(subtotal - montoIva, 0);
      const monto = this.round((deducible ? netoSinIva : subtotal) * tipoCambio, 2);
      if (monto <= 0) continue;
      const prev = debePorCuenta.get(cuentaGastoId);
      const desc = det.tipo_gasto?.descripcion ?? det.descripcion ?? 'Gasto';
      if (prev) {
        prev.monto = this.round(prev.monto + monto, 2);
      } else {
        debePorCuenta.set(cuentaGastoId, { monto, descripcion: desc });
      }
    }

    const lineas: any[] = [];
    for (const [cuentaId, info] of debePorCuenta.entries()) {
      lineas.push({
        cuenta_id: cuentaId,
        debe_moneda: info.monto,
        haber_moneda: 0,
        descripcion: info.descripcion,
      });
    }

    // IVA crédito sólo si es deducible y hay IVA
    if (deducible && iva10 > 0) {
      try {
        const cIva10 = await this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_10');
        lineas.push({ cuenta_id: cIva10, debe_moneda: iva10, haber_moneda: 0, descripcion: 'IVA Crédito 10%' });
      } catch (e) {
        const message = e instanceof Error ? e.message : 'Error de mapeo contable';
        if (message.includes('No hay cuenta mapeada para el concepto')) {
          await upsertDocumentoBorrador(message);
          return;
        }
        throw e;
      }
    }
    if (deducible && iva5 > 0) {
      try {
        const cIva5 = await this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_5');
        lineas.push({ cuenta_id: cIva5, debe_moneda: iva5, haber_moneda: 0, descripcion: 'IVA Crédito 5%' });
      } catch (e) {
        const message = e instanceof Error ? e.message : 'Error de mapeo contable';
        if (message.includes('No hay cuenta mapeada para el concepto')) {
          await upsertDocumentoBorrador(message);
          return;
        }
        throw e;
      }
    }

    const totalDebe = this.round(
      lineas.reduce((acc, l) => acc + Number(l.debe_moneda ?? 0), 0),
      2,
    );
    const diferencia = this.round(totalDebe - total, 2);
    if (Math.abs(diferencia) > 1) {
      await upsertDocumentoBorrador(
        `Asiento desbalanceado (DEBE=${totalDebe.toFixed(2)} / HABER=${total.toFixed(2)})`,
      );
      return;
    }

    lineas.push({
      cuenta_id: cContraparte,
      debe_moneda: 0,
      haber_moneda: this.round(total + diferencia, 2),
      descripcion: descContraparte,
    });

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.upsert({
        where: {
          empresa_id_origen_tipo_origen_id: {
            empresa_id: empresaId,
            origen_tipo: 'gasto_cab',
            origen_id: gastoId,
          },
        },
        create: {
          empresa_id: empresaId,
          tipo: tipoDocumento,
          origen_tipo: 'gasto_cab',
          origen_id: gastoId,
          numero_documento: numeroDoc,
          estado: 'CONFIRMADO',
        },
        update: {
          tipo: tipoDocumento,
          numero_documento: numeroDoc,
          estado: 'CONFIRMADO',
          updated_at: new Date(),
        },
      });

      await this.asientos.crearConfirmado(
        empresaId,
        gasto.usuario_id ?? null,
        doc.id,
        {
          periodo_id: periodoId,
          fecha: fecha.toISOString().substring(0, 10),
          glosa: `Gasto ${forma ? `(${forma.toLowerCase()})` : ''} N° ${numeroDoc}${deducible ? '' : ' [no deducible]'}${!esPYG ? ` [${monedaCodigo} x ${tipoCambio}]` : ''}`.trim(),
          moneda_origen: 'PYG',
          lineas,
        },
        tx,
      );
    });

    this.logger.log(`Gasto ${gastoId} (${numeroDoc}) contabilizado`);
  }

  // ── Cobro ──────────────────────────────────────────────────────────────────

  async integrarCobro(cobroId: string) {
    const cobro = await this.prisma.recibos_cobro.findUnique({ where: { id: cobroId } });
    if (!cobro) return;

    const empresaId = cobro.empresa_id;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'recibos_cobro', origen_id: cobroId },
    });
    if (docExistente) return;

    const fecha = cobro.fecha_emision ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para cobro ${cobroId}: ${e.message}`);
      return;
    }

    const monto = Number(cobro.monto_total ?? 0);
    const [cCaja, cClientes, mapaMedios] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, 'CAJA_GENERAL'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'CLIENTES'),
      this.mediosPago.resolverPorMedio(empresaId, 'COBRO'),
    ]);

    await this.agregarChequeAlMapa(empresaId, mapaMedios);

    // Con qué pagó el cliente: el detalle del recibo guarda un medio por cuota.
    // La cuenta del debe sale de lo que la empresa configuró para cada medio.
    const detalles = await this.prisma.recibo_cobro_detalle.findMany({
      where: { recibo_cobro_id: cobroId },
      select: { medio_pago_id: true, monto_pagado: true },
    });
    const sumaDetalle = detalles.reduce((acc, d) => acc + Number(d.monto_pagado ?? 0), 0);
    // Se reparte en proporción, no por el monto del detalle: con mora o descuento
    // la suma del detalle no coincide con el total del recibo, y el asiento tiene
    // que cuadrar contra `monto_total`.
    const partes = sumaDetalle > 0
      ? detalles.map((d) => ({
          medioPagoId: d.medio_pago_id,
          monto: this.round(monto * (Number(d.monto_pagado ?? 0) / sumaDetalle)),
        }))
      : [];

    let cobradoPorCuenta = this.repartirPorMedio(partes, mapaMedios, cCaja);
    cobradoPorCuenta = cobradoPorCuenta.size === 0
      ? new Map([[cCaja, monto]])
      : this.ajustarAlTotal(cobradoPorCuenta, monto);

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'COBRO',
          origen_tipo: 'recibos_cobro',
          origen_id: cobroId,
          numero_documento: cobro.numero_recibo,
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, (cobro.usuario_id as string) ?? null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Cobro recibo N° ${cobro.numero_recibo}`,
        moneda_origen: 'PYG',
        lineas: [
          ...[...cobradoPorCuenta].map(([cuenta_id, importe]) => ({
            cuenta_id,
            debe_moneda: importe,
            haber_moneda: 0,
            descripcion: 'Cobranza',
          })),
          { cuenta_id: cClientes, debe_moneda: 0, haber_moneda: monto, descripcion: 'Clientes' },
        ],
      }, tx);
    });

    this.logger.log(`Cobro ${cobroId} contabilizado`);
  }

  // ── Pago a Proveedor ───────────────────────────────────────────────────────

  async integrarPago(pagoId: string) {
    const pago = await this.prisma.pagos_proveedor.findUnique({
      where: { id: pagoId },
      include: {
        cuentas_pagar: { select: { cuenta_pasivo_id: true } },
        moneda: { select: { codigo: true } },
      },
    });
    if (!pago) return;

    const empresaId = pago.empresa_id;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'pagos_proveedor', origen_id: pagoId },
    });
    if (docExistente) return;

    const fecha = pago.fecha_pago ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para pago ${pagoId}: ${e.message}`);
      return;
    }

    // FASE 3 — asiento siempre en PYG. Si el pago está en moneda extranjera,
    // aplicamos la cotización del pago (mismo criterio que integrarFacturaCompra).
    const monedaPago = (pago as any).moneda?.codigo ?? 'PYG';
    const tipoCambio = monedaPago === 'PYG' ? 1 : Number(pago.cotizacion ?? 1) || 1;
    const monto = Number(pago.monto ?? 0) * tipoCambio;
    // FASE 2: la OP debe debitar la MISMA cuenta que acreditó el documento origen.
    // Preferimos la cuenta congelada en CxP; si no existe (CxP legacy), resolvemos
    // según tipo actual del proveedor con fallback a PROVEEDORES.
    const cuentaPasivoCongelada = pago.cuentas_pagar?.cuenta_pasivo_id ?? null;
    const [cProveedores, cCaja, mapaMedios] = await Promise.all([
      cuentaPasivoCongelada
        ? Promise.resolve(cuentaPasivoCongelada)
        : this.resolverCuentaPasivo(pago.proveedor_id, empresaId),
      this.mapeo.getCuentaPorConcepto(empresaId, 'CAJA_GENERAL'),
      this.mediosPago.resolverPorMedio(empresaId, 'PAGO'),
    ]);

    // De dónde sale la plata: lo define el medio de pago del recibo, con la
    // cuenta que la empresa configuró. Sin configurar, sigue saliendo del
    // concepto CAJA_GENERAL como hasta ahora.
    const cuentaSalida = (pago.medio_pago_id ? mapaMedios.get(pago.medio_pago_id) : null) ?? cCaja;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'PAGO',
          origen_tipo: 'pagos_proveedor',
          origen_id: pagoId,
          numero_documento: pago.numero_recibo ?? `PAGO-${pagoId.substring(0, 8)}`,
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, (pago.usuario_id as string) ?? null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Pago proveedor N° ${pago.numero_recibo ?? ''}`,
        moneda_origen: 'PYG',
        lineas: [
          { cuenta_id: cProveedores, debe_moneda: monto, haber_moneda: 0, descripcion: 'Proveedor' },
          { cuenta_id: cuentaSalida, debe_moneda: 0, haber_moneda: monto, descripcion: 'Salida de fondos' },
        ],
      }, tx);
    });

    this.logger.log(`Pago proveedor ${pagoId} contabilizado`);
  }

  // ── Orden de Pago a Proveedor ─────────────────────────────────────────────

  async integrarOrdenPago(ordenId: string) {
    const orden = await this.prisma.orden_pago_proveedor_cab.findUnique({
      where: { id: ordenId },
      include: {
        moneda: { select: { codigo: true } },
        orden_pago_proveedor_det: {
          include: { cuentas_pagar: { select: { id: true, cuenta_pasivo_id: true } } },
        },
      },
    });
    if (!orden || orden.anulado) return;
    if (String(orden.estado || '').toUpperCase() !== 'PAGADO') return;

    const empresaId = orden.empresa_id;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const numeroDoc = orden.numero_orden_pago ?? `OP-${ordenId.substring(0, 8).toUpperCase()}`;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'orden_pago_proveedor_cab', origen_id: ordenId },
      include: { asiento: { select: { id: true, estado: true } } },
    });
    if (docExistente?.asiento && docExistente.estado === 'CONFIRMADO') return;
    if (docExistente?.asiento && docExistente.estado !== 'CONFIRMADO') {
      await this.prisma.cont_documentos.update({
        where: { id: docExistente.id },
        data: { origen_tipo: null, origen_id: null, updated_at: new Date() },
      });
    }

    const upsertDocumentoBorrador = async (motivo: string) => {
      await this.prisma.cont_documentos.upsert({
        where: {
          empresa_id_origen_tipo_origen_id: {
            empresa_id: empresaId,
            origen_tipo: 'orden_pago_proveedor_cab',
            origen_id: ordenId,
          },
        },
        create: {
          empresa_id: empresaId,
          tipo: 'PAGO',
          origen_tipo: 'orden_pago_proveedor_cab',
          origen_id: ordenId,
          numero_documento: numeroDoc,
          estado: 'BORRADOR',
        },
        update: {
          tipo: 'PAGO',
          numero_documento: numeroDoc,
          estado: 'BORRADOR',
          updated_at: new Date(),
        },
      });
      this.logger.warn(`Integración contable pendiente para orden pago ${ordenId}: ${motivo}`);
    };

    const fecha = orden.fecha_pago ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      const reason = e instanceof Error ? e.message : String(e);
      await upsertDocumentoBorrador(`No hay período abierto (${reason})`);
      return;
    }

    // FASE 3 — Conversión a PYG cuando la OP está en moneda extranjera.
    // El asiento se registra siempre en la moneda funcional (PYG) usando la
    // cotización de la OP. Alineado con `integrarFacturaCompra`.
    const monedaOP = (orden as any).moneda?.codigo ?? 'PYG';
    const esPYG = monedaOP === 'PYG';
    const tipoCambio = esPYG ? 1 : Number(orden.cotizacion ?? 1) || 1;

    const monto = Number(orden.monto_total ?? 0) * tipoCambio;
    if (monto === 0) return;

    // FASE 2: la OP debita las cuentas pasivo CONGELADAS en cada CxP asociada.
    // Agrupamos por cuenta_pasivo_id y sumamos montos. Si una CxP no tiene
    // cuenta_pasivo_id (legacy), la resolvemos ahora vía `resolverCuentaPasivo`
    // usando el tipo actual del proveedor (fallback a PROVEEDORES).
    let cCajaFallback: string;
    try {
      cCajaFallback = await this.mapeo.getCuentaPorConcepto(empresaId, 'CAJA_GENERAL');
    } catch (e) {
      const message = e instanceof Error ? e.message : 'Error de mapeo contable';
      if (message.includes('No hay cuenta mapeada para el concepto')) {
        await upsertDocumentoBorrador(message);
        return;
      }
      throw e;
    }

    const debePorCuentaPasivo = new Map<string, number>();
    try {
      for (const det of orden.orden_pago_proveedor_det ?? []) {
        const cuentaPasivo =
          det.cuentas_pagar?.cuenta_pasivo_id ??
          (await this.resolverCuentaPasivo(orden.proveedor_id, empresaId));
        const importe = Number(det.monto ?? 0) * tipoCambio;
        debePorCuentaPasivo.set(
          cuentaPasivo,
          (debePorCuentaPasivo.get(cuentaPasivo) ?? 0) + importe,
        );
      }
    } catch (e) {
      const message = e instanceof Error ? e.message : 'Error de mapeo contable';
      if (message.includes('No hay cuenta mapeada para el concepto')) {
        await upsertDocumentoBorrador(message);
        return;
      }
      throw e;
    }

    // Si por alguna razón no hay detalles (OP legacy), caemos al total contra PROVEEDORES.
    if (debePorCuentaPasivo.size === 0) {
      const cProvFallback = await this.resolverCuentaPasivo(orden.proveedor_id, empresaId);
      debePorCuentaPasivo.set(cProvFallback, monto);
    }
    const debeLineas: Array<{ cuenta_id: string; debe_moneda: number; haber_moneda: number; descripcion: string }> = [];
    for (const [cuentaId, importe] of debePorCuentaPasivo) {
      debeLineas.push({
        cuenta_id: cuentaId,
        debe_moneda: importe,
        haber_moneda: 0,
        descripcion: 'Cancelación deuda proveedor',
      });
    }

    // HABER por medio de pago: usar la cuenta contable del banco/caja de cada movimiento de
    // tesorería de la orden (creados por syncTesoreriaOrdenPago). Si no hay movimientos
    // (tesorería no usada) o no cuadran con el total, se acredita CAJA_GENERAL por el total.
    const movimientos = await this.prisma.tes_movimientos.findMany({
      where: { empresa_id: empresaId, origen_tipo: 'ORDEN_PAGO_PROVEEDOR', origen_id: ordenId, estado: { not: 'ANULADO' } },
      select: { monto: true, cuenta: { select: { cuenta_contable_id: true } } },
    });
    // Los movimientos de tesorería vienen en la moneda del OP; convertimos a PYG
    // para que crucen con el DEBE (que ya se convirtió arriba).
    const sumaMov = movimientos.reduce((s, m) => s + Number(m.monto), 0) * tipoCambio;
    const haberLineas: Array<{ cuenta_id: string; debe_moneda: number; haber_moneda: number; descripcion: string }> = [];
    if (movimientos.length > 0 && Math.abs(sumaMov - monto) < 1) {
      const porCuenta = new Map<string, number>();
      for (const m of movimientos) {
        const cc = m.cuenta?.cuenta_contable_id ?? cCajaFallback;
        porCuenta.set(cc, (porCuenta.get(cc) ?? 0) + Number(m.monto) * tipoCambio);
      }
      for (const [cuentaId, importe] of porCuenta) {
        haberLineas.push({ cuenta_id: cuentaId, debe_moneda: 0, haber_moneda: importe, descripcion: 'Pago (banco/caja)' });
      }
    } else {
      haberLineas.push({ cuenta_id: cCajaFallback, debe_moneda: 0, haber_moneda: monto, descripcion: 'Caja' });
    }

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.upsert({
        where: {
          empresa_id_origen_tipo_origen_id: {
            empresa_id: empresaId,
            origen_tipo: 'orden_pago_proveedor_cab',
            origen_id: ordenId,
          },
        },
        create: {
          empresa_id: empresaId,
          tipo: 'PAGO',
          origen_tipo: 'orden_pago_proveedor_cab',
          origen_id: ordenId,
          numero_documento: numeroDoc,
          estado: 'CONFIRMADO',
        },
        update: {
          tipo: 'PAGO',
          numero_documento: numeroDoc,
          estado: 'CONFIRMADO',
          updated_at: new Date(),
        },
      });

      await this.asientos.crearConfirmado(empresaId, orden.usuario_id ?? null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Pago proveedor N° ${numeroDoc}`,
        moneda_origen: 'PYG',
        lineas: [...debeLineas, ...haberLineas],
      }, tx);
    });

    this.logger.log(`Orden pago proveedor ${ordenId} (${numeroDoc}) contabilizada`);
  }

  // ── Movimiento de Tesorería ───────────────────────────────────────────────

  async integrarMovimientoTesoreria(movimientoId: string) {
    const movimiento = await this.prisma.tes_movimientos.findUnique({
      where: { id: movimientoId },
      include: {
        cuenta: true,
        detalles: { include: { categoria: true } },
      },
    });
    if (!movimiento) return;
    // Generado por un gasto, recibo, OP, rendición o transferencia: el asiento
    // de ese documento ya lleva la línea del banco. Asentarlo acá lo duplica.
    if (movimientoSeContabilizaPorSuOrigen(movimiento.origen_tipo)) {
      this.logger.warn(
        `Movimiento tesorería ${movimientoId} viene de ${movimiento.origen_tipo}: se contabiliza con su documento origen`,
      );
      return;
    }

    const empresaId = movimiento.empresa_id;

    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'tes_movimientos', origen_id: movimientoId },
    });
    if (docExistente) {
      this.logger.warn(`Movimiento tesorería ${movimientoId} ya tiene documento contable`);
      return;
    }

    const cuentaContableId = movimiento.cuenta?.cuenta_contable_id;
    if (!cuentaContableId) {
      this.logger.warn(`Cuenta de tesorería ${movimiento.cuenta_id} sin cuenta_contable_id — integración omitida`);
      return;
    }

    // Pago imputado a una importación: la contrapartida no sale de la categoría del
    // detalle sino del concepto que corresponde al tipo de pago. El del proveedor
    // del exterior entra directo a la cuenta puente; el del despachante pasa por
    // anticipos y se aplica después contra la puente (ver
    // docs/plan-importaciones-fase2-contable.md).
    //
    // Si la empresa no tiene mapeado el concepto se cae al camino normal: el
    // modelo de cuenta puente es opcional y no puede romperle el asiento a quien
    // no lo usa.
    let cuentaImportacionId: string | null = null;
    if (movimiento.embarque_id && movimiento.tipo_pago_importacion) {
      const concepto =
        movimiento.tipo_pago_importacion === 'DESPACHO'
          ? 'ANTICIPO_DESPACHO'
          : 'IMPORTACIONES_EN_CURSO';
      cuentaImportacionId = await this.mapeo
        .getCuentaPorConcepto(empresaId, concepto)
        .catch(() => null);
      if (!cuentaImportacionId) {
        this.logger.warn(
          `Movimiento ${movimientoId} imputado a importación pero la empresa no tiene mapeado ${concepto}: se contabiliza por categoría`,
        );
      }
    }

    // Resolver cuenta contable efectiva por detalle:
    // 1. La cuenta de la importación, si el movimiento está imputado a un embarque
    // 2. det.cuenta_contable_id (override en el detalle)
    // 3. tes_categoria_cuenta_contable (mapeo por empresa — para categorías del sistema)
    // 4. tes_categorias.cuenta_contable_id (para categorías propias de la empresa)
    const detalles = movimiento.detalles ?? [];

    const categoriaIds = [...new Set(detalles.map((d) => d.categoria_id).filter(Boolean))];
    const mapeos = await this.prisma.tes_categoria_cuenta_contable.findMany({
      where: { empresa_id: empresaId, categoria_id: { in: categoriaIds } },
    });
    const mapeoIndex = Object.fromEntries(mapeos.map((m) => [m.categoria_id, m.cuenta_contable_id]));

    for (const det of detalles) {
      const ccat =
        cuentaImportacionId ??
        det.cuenta_contable_id ??
        mapeoIndex[det.categoria_id] ??
        det.categoria?.cuenta_contable_id;
      if (!ccat) {
        this.logger.warn(`Categoría "${det.categoria?.nombre}" sin cuenta contable mapeada — integración de movimiento ${movimientoId} omitida`);
        return;
      }
    }

    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, movimiento.fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para movimiento tesorería ${movimientoId}: ${e.message}`);
      return;
    }

    // Construir líneas por cada detalle (categoría)
    // INGRESO: DEBE = cuenta tesorería, HABER = cuenta categoría
    // EGRESO:  DEBE = cuenta categoría, HABER = cuenta tesorería
    const esIngreso = movimiento.tipo === 'INGRESO';
    const lineas: any[] = [];

    for (const det of detalles) {
      const ccat =
        cuentaImportacionId ??
        det.cuenta_contable_id ??
        mapeoIndex[det.categoria_id] ??
        det.categoria?.cuenta_contable_id;
      const monto = Number(det.monto);
      lineas.push(
        esIngreso
          ? { cuenta_id: cuentaContableId, debe_moneda: monto, haber_moneda: 0, descripcion: det.categoria?.nombre ?? 'Ingreso' }
          : { cuenta_id: ccat, debe_moneda: monto, haber_moneda: 0, descripcion: det.categoria?.nombre ?? 'Egreso' },
      );
      lineas.push(
        esIngreso
          ? { cuenta_id: ccat, debe_moneda: 0, haber_moneda: monto, descripcion: det.descripcion ?? det.categoria?.nombre }
          : { cuenta_id: cuentaContableId, debe_moneda: 0, haber_moneda: monto, descripcion: det.descripcion ?? movimiento.descripcion },
      );
    }

    if (lineas.length === 0) return;

    const tipo = esIngreso ? 'MOVIMIENTO_TES_INGRESO' : 'MOVIMIENTO_TES_EGRESO';
    const glosa = `${esIngreso ? 'Ingreso' : 'Egreso'} tesorería — ${movimiento.descripcion ?? ''}`.trim();

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo,
          origen_tipo: 'tes_movimientos',
          origen_id: movimientoId,
          numero_documento: movimientoId.substring(0, 8).toUpperCase(),
          estado: 'CONFIRMADO',
        },
      });

      // Sin usuario creador (extractos importados, movimientos de sistema) va
      // null: el id de la empresa no es un usuario y rompía la FK del asiento.
      await this.asientos.crearConfirmado(empresaId, movimiento.created_by ?? null, doc.id, {
        periodo_id: periodoId,
        fecha: movimiento.fecha.toISOString().substring(0, 10),
        glosa,
        moneda_origen: movimiento.cuenta?.moneda ?? 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(`Movimiento tesorería ${movimientoId} contabilizado`);
  }

  // ── Transferencia entre cuentas propias ──────────────────────────────────

  async integrarTransferenciaTes(movOrigenId: string, movDestinoId: string, transferenciaId: string, empresaId: string) {
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'tes_transferencias', origen_id: transferenciaId },
    });
    if (docExistente) return;

    const [movOrigen, movDestino] = await Promise.all([
      this.prisma.tes_movimientos.findUnique({ where: { id: movOrigenId }, include: { cuenta: true } }),
      this.prisma.tes_movimientos.findUnique({ where: { id: movDestinoId }, include: { cuenta: true } }),
    ]);
    if (!movOrigen || !movDestino) return;

    const cuentaOrigenContableId = movOrigen.cuenta?.cuenta_contable_id;
    const cuentaDestinoContableId = movDestino.cuenta?.cuenta_contable_id;
    if (!cuentaOrigenContableId || !cuentaDestinoContableId) {
      this.logger.warn(`Transferencia ${transferenciaId}: cuenta(s) sin cuenta_contable_id — integración omitida`);
      return;
    }

    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, movOrigen.fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para transferencia ${transferenciaId}: ${e.message}`);
      return;
    }

    const montoOrigen = Number(movOrigen.monto);
    const montoDestino = Number(movDestino.monto);
    const monedaOrigen = movOrigen.cuenta?.moneda ?? 'PYG';
    const monedaDestino = movDestino.cuenta?.moneda ?? 'PYG';
    const multiMoneda = monedaOrigen !== monedaDestino;

    // montoPYG = el monto que esté expresado en PYG (moneda funcional)
    // montoExtranjero = el monto en la moneda no-PYG
    // monedaExtranjera = la moneda no-PYG
    const montoPYG = monedaOrigen === 'PYG' ? montoOrigen : montoDestino;
    const montoExtranjero = monedaOrigen === 'PYG' ? montoDestino : montoOrigen;
    const monedaExtranjera = monedaOrigen === 'PYG' ? monedaDestino : monedaOrigen;

    const fmtRef = (monto: number, moneda: string) =>
      moneda === 'PYG'
        ? `Gs. ${Math.round(monto).toLocaleString('es-PY')}`
        : `${moneda} ${monto.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`;

    const descOrigen = multiMoneda
      ? `Salida: ${movOrigen.cuenta?.nombre} (${fmtRef(montoOrigen, monedaOrigen)})`
      : `Salida: ${movOrigen.cuenta?.nombre}`;
    const descDestino = multiMoneda
      ? `Entrada: ${movDestino.cuenta?.nombre} (${fmtRef(montoDestino, monedaDestino)})`
      : `Entrada: ${movDestino.cuenta?.nombre}`;

    // Buscar TC sistema en cont_tipo_cambio para calcular diferencia de cambio
    let tcSistema: number | null = null;
    if (multiMoneda) {
      const tcReg = await this.prisma.cont_tipo_cambio.findFirst({
        where: { empresa_id: empresaId, moneda: monedaExtranjera, fecha: { lte: movOrigen.fecha } },
        orderBy: { fecha: 'desc' },
      });
      if (tcReg) tcSistema = Number(tcReg.tasa);
    }

    // Construir asiento — 3 patas si hay TC sistema y difiere del TC banco; 2 patas si no
    const lineas: any[] = [];

    if (multiMoneda && tcSistema !== null) {
      // tc_banco = PYG reales / unidades extranjeras (cotización efectiva del banco)
      const tcBanco = montoPYG / montoExtranjero;
      // Valor de las unidades extranjeras al TC sistema
      const montoExtAvalTC = Math.round(montoExtranjero * tcSistema);
      const diferencia = montoExtAvalTC - montoPYG; // >0 ganancia, <0 pérdida

      if (Math.abs(diferencia) < 1) {
        // TC banco = TC sistema — asiento de 2 patas normal
        lineas.push({ cuenta_id: cuentaOrigenContableId, debe_moneda: 0, haber_moneda: montoPYG, descripcion: descOrigen });
        lineas.push({ cuenta_id: cuentaDestinoContableId, debe_moneda: montoPYG, haber_moneda: 0, descripcion: descDestino });
      } else {
        // Asiento de 3 patas con diferencia de cambio
        const cuentaDifCambio = await this.prisma.cont_plan_cuentas.findFirst({
          where: { empresa_id: empresaId, codigo: diferencia > 0 ? '4.2.1.02' : '6.3.1.03' },
          select: { id: true },
        });

        if (!cuentaDifCambio) {
          this.logger.warn(`Cuenta diferencia de cambio no encontrada — asiento de 2 patas`);
          lineas.push({ cuenta_id: cuentaOrigenContableId, debe_moneda: 0, haber_moneda: montoPYG, descripcion: descOrigen });
          lineas.push({ cuenta_id: cuentaDestinoContableId, debe_moneda: montoPYG, haber_moneda: 0, descripcion: descDestino });
        } else {
          const tcBancofmt = tcBanco.toLocaleString('es-PY', { maximumFractionDigits: 2 });
          const tcSistemafmt = tcSistema.toLocaleString('es-PY', { maximumFractionDigits: 2 });
          if (diferencia > 0) {
            // GANANCIA: vendiste/compraste extranjera más barata que el TC sistema
            lineas.push({ cuenta_id: cuentaOrigenContableId, debe_moneda: 0, haber_moneda: montoPYG, descripcion: `${descOrigen} — TC banco Gs. ${tcBancofmt}` });
            lineas.push({ cuenta_id: cuentaDestinoContableId, debe_moneda: montoExtAvalTC, haber_moneda: 0, descripcion: `${descDestino} — TC sistema Gs. ${tcSistemafmt}` });
            lineas.push({ cuenta_id: cuentaDifCambio.id, debe_moneda: 0, haber_moneda: diferencia, descripcion: `Diferencia de cambio ganada (${monedaExtranjera})` });
          } else {
            // PÉRDIDA: vendiste/compraste extranjera más cara que el TC sistema
            const perdida = Math.abs(diferencia);
            lineas.push({ cuenta_id: cuentaOrigenContableId, debe_moneda: 0, haber_moneda: montoPYG, descripcion: `${descOrigen} — TC banco Gs. ${tcBancofmt}` });
            lineas.push({ cuenta_id: cuentaDestinoContableId, debe_moneda: montoExtAvalTC, haber_moneda: 0, descripcion: `${descDestino} — TC sistema Gs. ${tcSistemafmt}` });
            lineas.push({ cuenta_id: cuentaDifCambio.id, debe_moneda: perdida, haber_moneda: 0, descripcion: `Diferencia de cambio perdida (${monedaExtranjera})` });
          }
        }
      }
    } else {
      lineas.push({ cuenta_id: cuentaOrigenContableId, debe_moneda: 0, haber_moneda: montoPYG, descripcion: descOrigen });
      lineas.push({ cuenta_id: cuentaDestinoContableId, debe_moneda: montoPYG, haber_moneda: 0, descripcion: descDestino });
    }

    const glosa = `Transferencia ${movOrigen.cuenta?.nombre ?? ''} → ${movDestino.cuenta?.nombre ?? ''}${movOrigen.descripcion ? ` — ${movOrigen.descripcion}` : ''}`;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'TRANSFERENCIA_TES',
          origen_tipo: 'tes_transferencias',
          origen_id: transferenciaId,
          numero_documento: transferenciaId.substring(0, 8).toUpperCase(),
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, movOrigen.created_by ?? null, doc.id, {
        periodo_id: periodoId,
        fecha: movOrigen.fecha.toISOString().substring(0, 10),
        glosa,
        moneda_origen: 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(`Transferencia ${transferenciaId} contabilizada`);
  }

  // ── Reversión por anulación ────────────────────────────────────────────────


  // ── Pago Bancard (post-webhook) ───────────────────────────────────────────

  async integrarPagoBancard(pagoBancardId: string) {
    const pago = await this.prisma.pago_bancard.findUnique({
      where: { id: pagoBancardId },
      include: { empresa: { include: { bancard_config: true } } },
    });
    if (!pago || pago.estado !== 'aprobado') return;

    const empresaId = pago.empresa_id;
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const bcConfig = pago.empresa?.bancard_config;
    if (!bcConfig?.cuenta_contable_por_liquidar) return; // sin cuentas configuradas

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'pago_bancard', origen_id: pagoBancardId },
    });
    if (docExistente) return;

    const fecha = pago.fecha_confirmacion ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para pago Bancard ${pagoBancardId}: ${(e as Error).message}`);
      return;
    }

    const monto = Number(pago.monto ?? 0);
    const cClientes = await this.mapeo.getCuentaPorConcepto(empresaId, 'CLIENTES').catch(() => null);
    const cBancardPorLiquidar = bcConfig.cuenta_contable_por_liquidar;

    if (!cClientes) {
      this.logger.warn(`Sin cuenta CLIENTES mapeada para empresa ${empresaId}, omitiendo asiento Bancard`);
      return;
    }

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'COBRO',
          origen_tipo: 'pago_bancard',
          origen_id: pagoBancardId,
          numero_documento: `BC-${pago.shop_process_id}`,
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, empresaId, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa: `Pago Bancard SPID ${pago.shop_process_id} — ${pago.origen_modulo}`,
        moneda_origen: 'PYG',
        lineas: [
          { cuenta_id: cBancardPorLiquidar, debe_moneda: monto, haber_moneda: 0, descripcion: 'Bancard por liquidar' },
          { cuenta_id: cClientes, debe_moneda: 0, haber_moneda: monto, descripcion: 'Cliente' },
        ],
      }, tx);
    });

    this.logger.log(`Pago Bancard ${pagoBancardId} contabilizado`);
  }
  async revertirDocumento(origenTipo: string, origenId: string, usuarioId: string | null) {
    const doc = await this.prisma.cont_documentos.findFirst({
      where: { origen_tipo: origenTipo, origen_id: origenId, estado: 'CONFIRMADO' },
      include: { asiento: true },
    });
    if (!doc || !doc.asiento) {
      this.logger.warn(`No hay asiento confirmado para ${origenTipo}:${origenId}`);
      return;
    }

    await this.asientos.revertir(doc.asiento.id, doc.empresa_id, usuarioId);
    this.logger.log(`Reversión contable ejecutada para ${origenTipo}:${origenId}`);
  }

  // ── RRHH: Liquidación cerrada ──────────────────────────────────────────────
  //
  // Asiento generado por una liquidación mensual/quincenal CERRADA:
  //   DEBE  (gasto, distribuido por centro de costo)
  //     SUELDOS_JORNALES         = Σ ingresos (salario + comisiones + bonos + planillas INGRESO)
  //     APORTE_PATRONAL_IPS      = Σ ips_patronal      (sólo si > 0)
  //     APORTE_ADMIN_IPS         = Σ ips_admin         (sólo si > 0)
  //   HABER (pasivos, global sin CC)
  //     SUELDOS_A_PAGAR          = Σ neto_a_pagar
  //     IPS_A_DEPOSITAR          = Σ (obrero + patronal + admin)        (sólo si > 0)
  //     DESCUENTOS_VARIOS_RRHH   = Σ egresos − ips_obrero               (anticipos, cuotas, novedades, planillas EGRESO)
  //
  // Idempotente: respeta el índice único cont_documentos(origen_tipo, origen_id).
  // Conceptos requeridos en cont_mapeo_cuentas: SUELDOS_JORNALES, SUELDOS_A_PAGAR.
  // Conceptos opcionales (si la cuenta no existe se omite la línea correspondiente):
  // APORTE_PATRONAL_IPS, APORTE_ADMIN_IPS, IPS_A_DEPOSITAR, DESCUENTOS_VARIOS_RRHH.
  async integrarLiquidacionRrhh(liquidacionId: string): Promise<void> {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findUnique({
      where: { id: liquidacionId },
      include: {
        resumenes: { include: { empleado: { select: { id: true, centro_costo_id: true } } } },
      },
    });
    if (!liq) return;
    if (liq.estado !== 'CERRADA') {
      this.logger.warn(`Liquidación ${liquidacionId} no está CERRADA — no se integra`);
      return;
    }
    const empresaId = liq.empresa_id;
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    // Idempotencia
    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'rrhh_liquidacion', origen_id: liquidacionId },
    });
    if (docExistente) {
      this.logger.warn(`Liquidación ${liquidacionId} ya tiene asiento contable`);
      return;
    }

    // El asiento pertenece al período LIQUIDADO, no al día en que se cerró la
    // liquidación: una nómina de julio devenga el 31/07 aunque se cierre en agosto.
    // Usar fecha_cierre mandaba el asiento al mes siguiente y desaparecía de los
    // reportes del período liquidado.
    const ultimoDiaPeriodo = new Date(Date.UTC(liq.periodo_anio, liq.periodo_mes, 0));

    let fecha = ultimoDiaPeriodo;
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch {
      // El período liquidado ya está cerrado: se cae a la fecha de cierre para no
      // dejar la liquidación sin asiento, pero queda registrado que no devengó
      // donde correspondía.
      const fechaAlterna = liq.fecha_cierre ?? new Date();
      try {
        periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fechaAlterna);
      } catch (e) {
        this.logger.error(
          `No hay período contable abierto para liquidación ${liquidacionId}: ${(e as Error).message}`,
        );
        return;
      }
      this.logger.warn(
        `Liquidación ${liquidacionId} (${liq.periodo_mes}/${liq.periodo_anio}): el período liquidado ` +
          `está cerrado, el asiento se imputa a ${fechaAlterna.toISOString().substring(0, 10)}.`,
      );
      fecha = fechaAlterna;
    }

    // Resolver cuentas requeridas + opcionales sin reventar
    const tryMapeo = async (concepto: string): Promise<string | null> => {
      try {
        return await this.mapeo.getCuentaPorConcepto(empresaId, concepto);
      } catch {
        return null;
      }
    };
    const cSueldos = await tryMapeo('SUELDOS_JORNALES');
    const cSueldosPagar = await tryMapeo('SUELDOS_A_PAGAR');
    if (!cSueldos || !cSueldosPagar) {
      this.logger.warn(
        `Liquidación ${liquidacionId} no se integra: faltan mapeos contables SUELDOS_JORNALES y/o SUELDOS_A_PAGAR.`,
      );
      return;
    }
    const cAportePatronal = await tryMapeo('APORTE_PATRONAL_IPS');
    const cAporteAdmin = await tryMapeo('APORTE_ADMIN_IPS');
    const cIpsDepositar = await tryMapeo('IPS_A_DEPOSITAR');
    const cDescuentosRrhh = await tryMapeo('DESCUENTOS_VARIOS_RRHH');

    // Centros de costo válidos (cont_centros_costo). El centro de costo del empleado ya está
    // unificado con Contabilidad, pero validamos por las dudas: si un id no existe, se postea
    // la línea sin centro de costo en vez de reventar el asiento por violación de FK.
    const ccValidos = new Set(
      (
        await this.prisma.cont_centros_costo.findMany({
          where: { empresa_id: empresaId },
          select: { id: true },
        })
      ).map((c) => c.id),
    );

    // Agrupar gastos por centro de costo del empleado
    type Bucket = { sueldos: number; patronal: number; admin: number };
    const porCC = new Map<string | null, Bucket>();
    let totalNeto = 0;
    let totalObrero = 0;
    let totalPatronal = 0;
    let totalAdmin = 0;
    let totalIngresos = 0;
    let totalEgresos = 0;
    for (const r of liq.resumenes) {
      const rawCcId = r.empleado?.centro_costo_id ?? null;
      const ccId = rawCcId && ccValidos.has(rawCcId) ? rawCcId : null;
      const ingresos = Number(r.total_ingresos ?? 0);
      const obrero = Number(r.ips_obrero ?? 0);
      const patronal = Number(r.ips_patronal ?? 0);
      const admin = Number(r.ips_admin ?? 0);
      const neto = Number(r.neto_a_pagar ?? 0);
      const egresos = Number(r.total_egresos ?? 0);
      const bucket = porCC.get(ccId) ?? { sueldos: 0, patronal: 0, admin: 0 };
      bucket.sueldos += ingresos;
      bucket.patronal += patronal;
      bucket.admin += admin;
      porCC.set(ccId, bucket);
      totalNeto += neto;
      totalObrero += obrero;
      totalPatronal += patronal;
      totalAdmin += admin;
      totalIngresos += ingresos;
      totalEgresos += egresos;
    }

    const descuentosVarios = this.round(totalEgresos - totalObrero); // anticipos, cuotas, novedades, planillas
    const lineas: Array<{
      cuenta_id: string;
      debe_moneda: number;
      haber_moneda: number;
      descripcion: string;
      centro_costo_id?: string | null;
    }> = [];

    // DEBE: sueldos + aportes patronales, distribuidos por centro de costo
    for (const [ccId, b] of porCC.entries()) {
      if (this.round(b.sueldos) > 0) {
        lineas.push({
          cuenta_id: cSueldos,
          debe_moneda: this.round(b.sueldos),
          haber_moneda: 0,
          descripcion: 'Sueldos y jornales',
          centro_costo_id: ccId,
        });
      }
      if (cAportePatronal && this.round(b.patronal) > 0) {
        lineas.push({
          cuenta_id: cAportePatronal,
          debe_moneda: this.round(b.patronal),
          haber_moneda: 0,
          descripcion: 'Aporte patronal IPS (16.5%)',
          centro_costo_id: ccId,
        });
      }
      if (cAporteAdmin && this.round(b.admin) > 0) {
        lineas.push({
          cuenta_id: cAporteAdmin,
          debe_moneda: this.round(b.admin),
          haber_moneda: 0,
          descripcion: 'Aporte administrativo IPS (1%)',
          centro_costo_id: ccId,
        });
      }
    }

    // HABER: pasivos
    if (this.round(totalNeto) > 0) {
      lineas.push({
        cuenta_id: cSueldosPagar,
        debe_moneda: 0,
        haber_moneda: this.round(totalNeto),
        descripcion: 'Sueldos a pagar',
      });
    }
    const totalIps = this.round(totalObrero + totalPatronal + totalAdmin);
    if (cIpsDepositar && totalIps > 0) {
      lineas.push({
        cuenta_id: cIpsDepositar,
        debe_moneda: 0,
        haber_moneda: totalIps,
        descripcion: 'IPS a depositar (obrero + patronal + admin)',
      });
    }
    if (cDescuentosRrhh && descuentosVarios > 0) {
      lineas.push({
        cuenta_id: cDescuentosRrhh,
        debe_moneda: 0,
        haber_moneda: descuentosVarios,
        descripcion: 'Descuentos a empleados (anticipos, cuotas, ausencias)',
      });
    }

    // Validación rápida (la validación estricta la hace AsientosService)
    const debe = lineas.reduce((s, l) => s + l.debe_moneda, 0);
    const haber = lineas.reduce((s, l) => s + l.haber_moneda, 0);
    if (this.round(debe) !== this.round(haber)) {
      this.logger.warn(
        `Liquidación ${liquidacionId} desbalanceada (debe=${debe}, haber=${haber}). ` +
          `Probablemente faltan mapeos opcionales. Revisá Contabilidad → Mapeo de cuentas.`,
      );
      // Aún así, persistir desbalance bloquearía. Mejor abortar limpio.
      return;
    }
    if (lineas.length === 0) return;

    const tipoAsiento =
      liq.tipo === 'QUINCENAL'
        ? `LIQUIDACION_QUINCENAL_${liq.quincena ?? ''}`
        : 'LIQUIDACION_MENSUAL';
    const periodoLabel = `${String(liq.periodo_mes).padStart(2, '0')}/${liq.periodo_anio}`;
    const numeroDocumento =
      liq.tipo === 'QUINCENAL'
        ? `RRHH-${periodoLabel}-Q${liq.quincena ?? ''}`
        : `RRHH-${periodoLabel}`;
    const glosa =
      liq.tipo === 'QUINCENAL'
        ? `Liquidación quincenal ${liq.quincena}ª ${periodoLabel}`
        : `Liquidación mensual ${periodoLabel}`;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: tipoAsiento,
          origen_tipo: 'rrhh_liquidacion',
          origen_id: liquidacionId,
          numero_documento: numeroDocumento,
          estado: 'CONFIRMADO',
        },
      });
      await this.asientos.crearConfirmado(empresaId, null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa,
        moneda_origen: 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(
      `Integración contable creada para liquidación ${liquidacionId} (${tipoAsiento}, debe=${debe})`,
    );
  }

  // ── Recibo Multi-Factura ───────────────────────────────────────────────────
  //
  // Asiento generado al confirmar un recibo multi-factura:
  //   DEBE
  //     Caja/Banco/etc. (según medio de pago)      → monto efectivo/transferencia/tarjeta
  //     Retenciones IVA a Favor  (1.1.3.03)        → total retenciones IVA
  //     Retenciones Renta a Favor (1.1.3.04)       → total retenciones Renta
  //     NC Aplicada Puente (1.1.2.05)              → total NC modo FLEXIBLE
  //     Anticipos de Clientes (2.1.1.03) [si consume saldo a favor]
  //   HABER
  //     Clientes (1.1.2.01)                        → total facturas + intereses
  //     Intereses Moratorios Cobrados (4.2.1.05)   → total intereses
  //     Anticipos de Clientes (2.1.1.03) [si genera saldo a favor]
  //
  // Idempotente: índice único cont_documentos(origen_tipo='recibos_multi', origen_id).
  async integrarReciboMulti(reciboId: string, empresaId: string): Promise<void> {
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const docExistente = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'recibos_multi', origen_id: reciboId },
    });
    if (docExistente) return;

    const reciboRow = await this.prisma.recibos_cobro.findUnique({
      where: { id: reciboId },
      include: {
        facturas_multi:            true,
        nc_aplicadas_multi:        true,
        retenciones_multi:         true,
        medios_pago_multi:         true,
        facturas_compra_aplicadas: true,
        gastos_asociados: {
          include: {
            gasto:       { select: { proveedor_id: true } },
            cuenta_pagar: { select: { cuenta_pasivo_id: true } },
          },
        },
        moneda:                    { select: { codigo: true } },
      },
    });
    if (!reciboRow || reciboRow.estado === 'anulado' || reciboRow.modo !== 'MULTI') return;
    // Adaptar nombres legacy esperados más abajo
    const recibo = {
      ...reciboRow,
      facturas:     reciboRow.facturas_multi,
      nc_aplicadas: reciboRow.nc_aplicadas_multi,
      retenciones:  reciboRow.retenciones_multi,
      medios_pago:  reciboRow.medios_pago_multi,
      numero:       reciboRow.numero_recibo,
      fecha:        reciboRow.fecha_emision,
      moneda:       reciboRow.moneda?.codigo ?? 'PYG',
      created_by:   reciboRow.usuario_id ?? '',
    };

    const fecha = recibo.fecha ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para recibo multi ${reciboId}: ${e.message}`);
      return;
    }

    // Obtener cuentas del mapeo
    const [cClientes, cIntereses, cNcPuente, cRetIVA, cRetRenta, cAnticipos, cCaja, cChequesCartera, cChequesDiferidos, cProveedores] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, 'CLIENTES'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'INTERESES_MORATORIOS_COBRADOS').catch(() => null),
      this.mapeo.getCuentaPorConcepto(empresaId, 'NC_APLICADA_PUENTE').catch(() => null),
      this.mapeo.getCuentaPorConcepto(empresaId, 'RETENCION_IVA_FAVOR').catch(() => null),
      this.mapeo.getCuentaPorConcepto(empresaId, 'RETENCION_RENTA_FAVOR').catch(() => null),
      this.mapeo.getCuentaPorConcepto(empresaId, 'ANTICIPOS_CLIENTES').catch(() => null),
      this.mapeo.getCuentaPorConcepto(empresaId, 'CAJA_GENERAL'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'CHEQUES_EN_CARTERA').catch(() => null),
      this.mapeo.getCuentaPorConcepto(empresaId, 'CHEQUES_EN_TRANSITO').catch(() => null),
      this.mapeo.getCuentaPorConcepto(empresaId, 'PROVEEDORES').catch(() => null),
    ]);

    // Cuenta por medio de pago, si la empresa la configuró. Un medio configurado
    // manda sobre todo lo demás: es una decisión explícita del contador. Lo que
    // deja sin configurar sigue resolviéndose como siempre (cuenta de tesorería
    // del medio, o cheques en cartera / en tránsito según el vencimiento).
    const cuentaPorCodigoMedio = await this.mediosPago.resolverPorCodigoMedio(empresaId, 'COBRO');
    const cuentaDelMedio = (nombre: string): string | null => {
      const codigo = CODIGO_MEDIO_POR_NOMBRE[nombre];
      return codigo != null ? cuentaPorCodigoMedio.get(codigo) ?? null : null;
    };

    // Cargar cheques vinculados al recibo (para diferenciar al día vs diferido)
    const movimientosRecibo = await this.prisma.tes_movimientos.findMany({
      where: { empresa_id: empresaId, origen_tipo: 'recibos_multi', origen_id: reciboId },
      select: { id: true, cheque: { select: { fecha_vencimiento: true, monto: true } } },
    });
    const chequeByMov = new Map<string, { fechaVenc: Date | null; monto: number }>();
    for (const m of movimientosRecibo) {
      if (m.cheque) chequeByMov.set(m.id, { fechaVenc: m.cheque.fecha_vencimiento, monto: Number(m.cheque.monto) });
    }
    const hoy = new Date(fecha);
    hoy.setHours(0, 0, 0, 0);

    // Resolver cuenta_tesoreria_id → cuenta_contable_id (tes_cuentas.cuenta_contable_id)
    const tesCuentaIds = Array.from(
      new Set(
        recibo.medios_pago
          .filter((mp) => mp.medio !== 'SALDO_FAVOR' && mp.medio !== 'CHEQUE' && mp.cuenta_tesoreria_id)
          .map((mp) => mp.cuenta_tesoreria_id as string),
      ),
    );
    const tesCuentas = tesCuentaIds.length
      ? await this.prisma.tes_cuentas.findMany({
          where: { id: { in: tesCuentaIds } },
          select: { id: true, cuenta_contable_id: true },
        })
      : [];
    const tesToContable = new Map(tesCuentas.map((t) => [t.id, t.cuenta_contable_id]));

    // Agrupar medios de pago por cuenta tesorería (separando cheques)
    const mediosCuentaMap = new Map<string, number>();
    let totalChequesCartera = 0;
    let totalChequesDiferidos = 0;
    for (const mp of recibo.medios_pago) {
      if (mp.medio === 'SALDO_FAVOR') continue; // Se maneja como anticipo

      // El cheque va por sus propios conceptos, que distinguen al día de diferido.
      const cuentaMedio = mp.medio === 'CHEQUE' ? null : cuentaDelMedio(mp.medio);
      if (cuentaMedio) {
        mediosCuentaMap.set(cuentaMedio, (mediosCuentaMap.get(cuentaMedio) ?? 0) + Number(mp.monto));
        continue;
      }

      // Cheques van a cuenta contable específica según fecha_vencimiento
      if (mp.medio === 'CHEQUE') {
        // Buscar el cheque por movimiento del mismo recibo y mismo monto
        let esDiferido = false;
        for (const [, ch] of chequeByMov) {
          if (Math.abs(ch.monto - Number(mp.monto)) < 0.01 && ch.fechaVenc && ch.fechaVenc > hoy) {
            esDiferido = true;
            break;
          }
        }
        if (esDiferido) {
          totalChequesDiferidos += Number(mp.monto);
        } else {
          totalChequesCartera += Number(mp.monto);
        }
        continue;
      }

      const cuentaContableTes = mp.cuenta_tesoreria_id ? tesToContable.get(mp.cuenta_tesoreria_id) : null;
      const cuentaId = cuentaContableTes ?? cCaja;
      mediosCuentaMap.set(cuentaId, (mediosCuentaMap.get(cuentaId) ?? 0) + Number(mp.monto));
    }

    const totalSaldoFavor = recibo.medios_pago
      .filter((m) => m.medio === 'SALDO_FAVOR')
      .reduce((s, m) => s + Number(m.monto), 0);

    const totalRetIVA   = recibo.retenciones.filter((r) => r.tipo === 'IVA').reduce((s, r) => s + Number(r.monto), 0);
    const totalRetRenta = recibo.retenciones.filter((r) => r.tipo === 'RENTA').reduce((s, r) => s + Number(r.monto), 0);
    const totalNcFlex   = recibo.nc_aplicadas.filter((n) => n.modo === 'FLEXIBLE').reduce((s, n) => s + Number(n.monto), 0);
    const totalNcEstricto = recibo.nc_aplicadas.filter((n) => n.modo === 'ESTRICTO').reduce((s, n) => s + Number(n.monto), 0);
    const totalIntereses = Number(recibo.total_intereses ?? 0);
    // NC ESTRICTO ya descargó CxC al emitirse → no debe contar como crédito a clientes acá.
    const totalClientes  = Number(recibo.total_facturas) + totalIntereses - totalNcEstricto;

    const lineas: Array<{ cuenta_id: string; debe_moneda: number; haber_moneda: number; descripcion: string }> = [];

    // DEBE: Medios de pago efectivos (excluye cheques)
    for (const [cuentaId, monto] of mediosCuentaMap) {
      lineas.push({ cuenta_id: cuentaId, debe_moneda: monto, haber_moneda: 0, descripcion: 'Cobro recibo' });
    }

    // DEBE: Cheques al día → CHEQUES_EN_CARTERA
    if (totalChequesCartera > 0) {
      const cuenta = cChequesCartera ?? cCaja;
      lineas.push({ cuenta_id: cuenta, debe_moneda: totalChequesCartera, haber_moneda: 0, descripcion: 'Cheques recibidos (al día)' });
    }

    // DEBE: Cheques diferidos → CHEQUES_EN_TRANSITO (diferidos)
    if (totalChequesDiferidos > 0) {
      const cuenta = cChequesDiferidos ?? cChequesCartera ?? cCaja;
      lineas.push({ cuenta_id: cuenta, debe_moneda: totalChequesDiferidos, haber_moneda: 0, descripcion: 'Cheques diferidos a cobrar' });
    }

    // DEBE: Retenciones recibidas
    if (totalRetIVA > 0 && cRetIVA) {
      lineas.push({ cuenta_id: cRetIVA, debe_moneda: totalRetIVA, haber_moneda: 0, descripcion: 'Retención IVA recibida' });
    }
    if (totalRetRenta > 0 && cRetRenta) {
      lineas.push({ cuenta_id: cRetRenta, debe_moneda: totalRetRenta, haber_moneda: 0, descripcion: 'Retención Renta recibida' });
    }

    // DEBE: NC modo FLEXIBLE aplicada (cuenta puente)
    if (totalNcFlex > 0 && cNcPuente) {
      lineas.push({ cuenta_id: cNcPuente, debe_moneda: totalNcFlex, haber_moneda: 0, descripcion: 'NC a aplicar (modo flexible)' });
    }

    // DEBE: Consumo de anticipos de clientes (saldo a favor consumido)
    if (totalSaldoFavor > 0 && cAnticipos) {
      lineas.push({ cuenta_id: cAnticipos, debe_moneda: totalSaldoFavor, haber_moneda: 0, descripcion: 'Anticipo cliente consumido' });
    }

    // DEBE: Compensación con facturas de compra (acuerdo comercial)
    // Cancela cuenta por pagar al proveedor (mismo titular que el cliente).
    const totalFacturasCompra = Number(reciboRow.total_facturas_compra ?? 0);
    if (totalFacturasCompra > 0 && cProveedores) {
      lineas.push({ cuenta_id: cProveedores, debe_moneda: totalFacturasCompra, haber_moneda: 0, descripcion: 'Compensación factura acuerdo comercial' });
    }

    // DEBE: Gastos descontados del cobro — cancela CxP del gasto (ya devengada).
    // Agrupa por cuenta pasivo para soportar proveedores con cuentas distintas.
    const gastosCuentaMap = new Map<string, number>();
    for (const g of (reciboRow.gastos_asociados ?? [])) {
      const monto = Number(g.monto_aplicado);
      if (monto <= 0) continue;
      // Prioridad: cuenta congelada en CxP → resolverCuentaPasivo → fallback cProveedores
      let cuentaId: string | null = (g as any).cuenta_pagar?.cuenta_pasivo_id ?? null;
      if (!cuentaId && (g as any).gasto?.proveedor_id) {
        cuentaId = await this.resolverCuentaPasivo((g as any).gasto.proveedor_id, empresaId).catch(() => null);
      }
      if (!cuentaId) cuentaId = cProveedores;
      if (!cuentaId) continue;
      gastosCuentaMap.set(cuentaId, (gastosCuentaMap.get(cuentaId) ?? 0) + monto);
    }
    for (const [cuentaId, monto] of gastosCuentaMap) {
      lineas.push({ cuenta_id: cuentaId, debe_moneda: monto, haber_moneda: 0, descripcion: 'Gasto descontado del cobro' });
    }

    // HABER: Clientes
    if (totalClientes > 0) {
      lineas.push({ cuenta_id: cClientes, debe_moneda: 0, haber_moneda: totalClientes, descripcion: 'Cancelación CxC' });
    }

    // HABER: Intereses
    if (totalIntereses > 0 && cIntereses) {
      lineas.push({ cuenta_id: cIntereses, debe_moneda: 0, haber_moneda: totalIntereses, descripcion: 'Intereses moratorios' });
    }

    // HABER: Generación de nuevo anticipo (diferencia positiva → saldo a favor)
    const diferencia = Number(recibo.diferencia ?? 0);
    if (recibo.diferencia_tipo === 'SALDO_FAVOR' && diferencia > 0 && cAnticipos) {
      lineas.push({ cuenta_id: cAnticipos, debe_moneda: 0, haber_moneda: diferencia, descripcion: 'Saldo a favor generado' });
    }

    if (lineas.length === 0) {
      this.logger.warn(`Recibo multi ${reciboId}: sin líneas contables generadas`);
      return;
    }

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id:       empresaId,
          tipo:             'RECIBO_MULTI',
          origen_tipo:      'recibos_multi',
          origen_id:        reciboId,
          numero_documento: recibo.numero,
          estado:           'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, recibo.created_by, doc.id, {
        periodo_id:    periodoId,
        fecha:         fecha.toISOString().substring(0, 10),
        glosa:         `Recibo multi N° ${recibo.numero}`,
        moneda_origen: recibo.moneda ?? 'PYG',
        lineas,
      }, tx);
    });

    this.logger.log(`Recibo multi ${reciboId} contabilizado`);
  }

  // ── Liquidación de Comisiones (Vendedores/Cobradores) ─────────────────────
  //
  // Devengamiento (al crear la liquidación, estado=pendiente):
  //   DEBE   Gasto Comisiones Ventas  (6.2.1.02)   → total comisiones
  //   HABER  Comisiones a Pagar       (2.1.1.05)   → total comisiones
  //
  // Pago (al marcar liquidación como pagada):
  //   DEBE   Comisiones a Pagar       (2.1.1.05)   → total
  //   HABER  Caja General             (1.1.1.01)   → total
  //
  // Anulación: revertir ambos asientos (si existen).
  //
  // Idempotente: índice único cont_documentos(origen_tipo, origen_id).
  async integrarDevengamientoComisiones(liquidacionId: string, empresaId: string): Promise<void> {
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const exists = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'liquidacion_comisiones_deveng', origen_id: liquidacionId },
    });
    if (exists) return;

    const liq = await this.prisma.liquidaciones_comisiones.findUnique({ where: { id: liquidacionId } });
    if (!liq) return;

    const total = Number(liq.total_comisiones ?? 0);
    if (total <= 0) return;

    const vendedor = await this.prisma.vendedores_cobradores.findUnique({
      where: { id: liq.vendedor_cobrador_id },
      select: { nombre: true, apellido: true, regimen_comision: true },
    });

    // Un empleado cobra remuneración (sin IVA); un tercero factura su servicio y
    // ahí sí hay IVA crédito. Lo declara el vendedor, no lo supone el sistema.
    const desglose = desglosarComision(total, vendedor?.regimen_comision, (v) => this.round(v));

    const fecha = liq.created_at ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para liquidación ${liquidacionId}: ${(e as Error).message}`);
      return;
    }

    const [cGasto, cPagar, cIvaCredito] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, 'GASTO_COMISIONES_VENTAS'),
      this.mapeo.getCuentaPorConcepto(empresaId, 'COMISIONES_A_PAGAR'),
      desglose.conIva
        ? this.mapeo.getCuentaPorConcepto(empresaId, 'IVA_CREDITO_10').catch(() => null)
        : Promise.resolve(null),
    ]);

    if (desglose.conIva && !cIvaCredito) {
      // Sin cuenta de IVA crédito el asiento no cerraría: se asienta el total como
      // gasto y se avisa, en vez de dejar la liquidación sin contabilizar.
      this.logger.warn(
        `Liquidación ${liquidacionId}: el vendedor factura con IVA pero IVA_CREDITO_10 no está mapeado. ` +
          'Se asienta el total como gasto. Configurá el mapeo en Contabilidad → Configuración.',
      );
    }
    const separaIva = desglose.conIva && !!cIvaCredito;
    const gasto = separaIva ? desglose.base : desglose.aPagar;

    const nombre = `${vendedor?.nombre ?? ''} ${vendedor?.apellido ?? ''}`.trim();
    const glosa = `Devengamiento comisiones — ${nombre || 'Vendedor/Cobrador'}`;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'LIQUIDACION_COMISIONES',
          origen_tipo: 'liquidacion_comisiones_deveng',
          origen_id: liquidacionId,
          numero_documento: liquidacionId.slice(0, 8),
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa,
        moneda_origen: 'PYG',
        lineas: [
          { cuenta_id: cGasto, debe_moneda: gasto, haber_moneda: 0, descripcion: 'Gasto comisiones' },
          ...(separaIva
            ? [{ cuenta_id: cIvaCredito as string, debe_moneda: desglose.iva, haber_moneda: 0, descripcion: 'IVA crédito comisiones' }]
            : []),
          { cuenta_id: cPagar, debe_moneda: 0, haber_moneda: desglose.aPagar, descripcion: 'Comisiones a pagar' },
        ],
      }, tx);
    });

    this.logger.log(`Liquidación comisiones ${liquidacionId} devengada contablemente`);
  }

  async integrarPagoComisiones(liquidacionId: string, empresaId: string, fechaPago?: Date): Promise<void> {
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const exists = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'liquidacion_comisiones_pago', origen_id: liquidacionId },
    });
    if (exists) return;

    const liq = await this.prisma.liquidaciones_comisiones.findUnique({ where: { id: liquidacionId } });
    if (!liq) return;

    const total = Number(liq.total_comisiones ?? 0);
    if (total <= 0) return;

    const vendedor = await this.prisma.vendedores_cobradores.findUnique({
      where: { id: liq.vendedor_cobrador_id },
      select: { nombre: true, apellido: true, regimen_comision: true },
    });

    // Se cancela lo mismo que se devengó: si el comisionista factura con IVA
    // aparte, se le paga el neto más el IVA, no sólo el porcentaje calculado.
    const montoPagado = desglosarComision(total, vendedor?.regimen_comision, (v) => this.round(v)).aPagar;

    const fecha = fechaPago ?? liq.fecha_pago ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para pago liquidación ${liquidacionId}: ${(e as Error).message}`);
      return;
    }

    // La liquidación no guarda medio de pago, así que de dónde sale la plata se
    // define con un concepto propio: quien paga las comisiones por banco lo
    // apunta al banco. Sin mapear, sale de caja como hasta ahora.
    const [cPagar, cFondos] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, 'COMISIONES_A_PAGAR'),
      this.mapeo
        .getCuentaPorConcepto(empresaId, 'PAGO_COMISIONES_FONDOS')
        .catch(() => this.mapeo.getCuentaPorConcepto(empresaId, 'CAJA_GENERAL')),
    ]);

    const nombre = `${vendedor?.nombre ?? ''} ${vendedor?.apellido ?? ''}`.trim();
    const glosa = `Pago comisiones — ${nombre || 'Vendedor/Cobrador'}`;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'PAGO_COMISIONES',
          origen_tipo: 'liquidacion_comisiones_pago',
          origen_id: liquidacionId,
          numero_documento: liquidacionId.slice(0, 8),
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa,
        moneda_origen: 'PYG',
        lineas: [
          { cuenta_id: cPagar, debe_moneda: montoPagado, haber_moneda: 0, descripcion: 'Cancelación comisiones a pagar' },
          { cuenta_id: cFondos, debe_moneda: 0, haber_moneda: montoPagado, descripcion: 'Salida de fondos' },
        ],
      }, tx);
    });

    this.logger.log(`Pago liquidación ${liquidacionId} contabilizado`);
  }

  // Supervisor: comisión liquidada como un solo asiento (devengo + pago combinado)
  async integrarComisionSupervisor(comisionId: string, empresaId: string): Promise<void> {
    if (!(await this.tieneModuloContabilidad(empresaId))) return;

    const exists = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'comision_supervisor', origen_id: comisionId },
    });
    if (exists) return;

    const com = await this.prisma.comisiones_supervisores.findUnique({
      where: { id: comisionId },
      include: { supervisor: { select: { nombre: true, apellido: true } } },
    });
    if (!com) return;

    const total = Number(com.total_comision ?? 0);
    if (total <= 0) return;

    const fecha = com.updated_at ?? com.created_at ?? new Date();
    let periodoId: string;
    try {
      periodoId = await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch (e) {
      this.logger.error(`No hay período abierto para comisión supervisor ${comisionId}: ${(e as Error).message}`);
      return;
    }

    const [cGasto, cFondos] = await Promise.all([
      this.mapeo.getCuentaPorConcepto(empresaId, 'GASTO_COMISIONES_VENTAS'),
      this.mapeo
        .getCuentaPorConcepto(empresaId, 'PAGO_COMISIONES_FONDOS')
        .catch(() => this.mapeo.getCuentaPorConcepto(empresaId, 'CAJA_GENERAL')),
    ]);

    const nombre = `${com.supervisor?.nombre ?? ''} ${com.supervisor?.apellido ?? ''}`.trim();
    const glosa = `Liquidación comisión supervisor — ${nombre || 'Supervisor'}`;

    await this.prisma.$transaction(async (tx) => {
      const doc = await tx.cont_documentos.create({
        data: {
          empresa_id: empresaId,
          tipo: 'COMISION_SUPERVISOR',
          origen_tipo: 'comision_supervisor',
          origen_id: comisionId,
          numero_documento: comisionId.slice(0, 8),
          estado: 'CONFIRMADO',
        },
      });

      await this.asientos.crearConfirmado(empresaId, null, doc.id, {
        periodo_id: periodoId,
        fecha: fecha.toISOString().substring(0, 10),
        glosa,
        moneda_origen: 'PYG',
        lineas: [
          { cuenta_id: cGasto, debe_moneda: total, haber_moneda: 0, descripcion: 'Gasto comisión supervisor' },
          { cuenta_id: cFondos, debe_moneda: 0, haber_moneda: total, descripcion: 'Salida de fondos' },
        ],
      }, tx);
    });

    this.logger.log(`Comisión supervisor ${comisionId} contabilizada`);
  }

  // ── Para que el frontend pueda mostrar el asiento generado ────────────────
  async findAsientoPorLiquidacion(empresaId: string, liquidacionId: string) {
    const doc = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'rrhh_liquidacion', origen_id: liquidacionId },
      include: { asiento: { select: { id: true, numero: true, fecha: true, estado: true } } },
    });
    return doc?.asiento ?? null;
  }

  private round(value: number, decimals = 2): number {
    const factor = 10 ** decimals;
    return Math.round((value + Number.EPSILON) * factor) / factor;
  }
}
