import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AsientosService } from './asientos.service';
import { MapeoCuentasService, ResolvedorMapeo } from './mapeo-cuentas.service';
import { dimensionMoneda, dimensionesCliente, dimensionesProveedor, dimensionesVenta } from './contexto-documento';
import {
  CODIGO_MEDIO_POR_NOMBRE,
  DONDE_SE_CORRIGE_MEDIO,
  MapeoMediosPagoService,
  MedioInutilizableError,
  cuentaDelMedio,
  detalleMediosInutilizables,
} 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';
import { fmtFecha } from '../../common/marangatu/registro-comprobantes.util';
import { AvisosMapeoService } from './avisos-mapeo.service';
import { FaltaMapeoError, datosDeFalta } from './falta-mapeo.error';
import { saldoPuenteDeImportacion } from './saldo-puente-importacion';
import { CODIGOS_MEDIO_SIN_CAJA, medioNoMueveCaja } from '../../common/utils/medio-pago-sin-caja.util';
import { DimensionesDocumento } from './reglas-mapeo';
import { abrirConcepto, dimensionesItem, ItemNeto, LineaAbierta, ProductoItem, SELECT_PRODUCTO_ITEM } from './apertura-items';
import { OrigenFicha } from './origenes-ficha';

/** Ítem de venta (factura o NC) con su neto por tasa, en la moneda del documento. */
type ItemVenta = { dims: DimensionesDocumento; netos: Record<10 | 5 | 0, number> };

/**
 * Para cargar de antemano un concepto que solo hace falta si el documento lo usa:
 * la falta de mapeo queda en null (después se exige con `requerir`/`necesito`);
 * cualquier otro error (base caída, etc.) se propaga y no se confunde con "falta mapear".
 */
const soloFaltaMapeo = (e: unknown): null => {
  if (e instanceof FaltaMapeoError) return null;
  throw e;
};

/**
 * Para conceptos que son un interruptor (la cuenta puente de importaciones): no
 * tener el mapeo significa "no se usa" (null). Tenerlo apuntando a una cuenta
 * desactivada o de agrupación NO es lo mismo: se relanza para que el documento
 * quede pendiente con aviso. Cualquier otro error también se relanza.
 */
const soloSinMapeo = (e: unknown): null => {
  if (e instanceof FaltaMapeoError && e.motivo === 'SIN_MAPEO') return null;
  throw e;
};

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

  /** Solo la simulación (vista previa de reglas) lo setea, en su copia sombra. */
  opcionesMapeo?: { incluirBorradores?: boolean };

  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,
    private readonly avisosMapeo: AvisosMapeoService,
  ) {}

  /**
   * Regla de la casa (spec 2026-10-13): si falta un mapeo, el documento queda
   * sin asiento, con constancia y aviso, y la operación del usuario NO falla.
   * Cualquier otro error se propaga como siempre.
   */
  private async conAvisoDeMapeo(origenTipo: string, origenId: string, fn: () => Promise<void>): Promise<void> {
    try {
      await fn();
    } catch (e) {
      if (!(e instanceof FaltaMapeoError)) throw e;
      this.logger.warn(`${origenTipo} ${origenId} sin asiento: ${e.message}`);
      await this.avisosMapeo
        .registrarFaltaMapeo({ empresaId: e.empresaId, origenTipo, origenId, ...datosDeFalta(e) })
        .catch((err) => this.logger.error(`No se pudo registrar el aviso de mapeo de ${origenTipo} ${origenId}: ${err?.message ?? err}`));
    }
  }

  /**
   * Resolvedor de cuentas del documento (spec 2026-10-13 "reglas de cuenta por
   * dimensión"): lee las reglas una vez, aplica las dimensiones de cabecera y
   * recuerda cómo salió cada cuenta para dejar la traza en las líneas.
   */
  private resolvedor(empresaId: string, fecha: Date, dimensiones: DimensionesDocumento): Promise<ResolvedorMapeo> {
    return this.mapeo.crearResolvedor(empresaId, {
      fecha,
      dimensiones,
      incluirBorradores: this.opcionesMapeo?.incluirBorradores,
    });
  }

  /** Cuenta obligatoria para una línea que el documento sí tiene. */
  private requerir(cuentaId: string | null | undefined, empresaId: string, concepto: string): string {
    if (!cuentaId) throw new FaltaMapeoError(empresaId, [concepto]);
    return cuentaId;
  }


  /**
   * 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 va a `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. Un medio
   * configurado a una cuenta que no sirve NO va al concepto: si la parte tiene
   * monto, lanza FaltaMapeoError (ver `MapeoMediosPagoService.resolverPorMedio`).
   *
   * 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.
   *
   * Con `res`, la cuenta que sale del mapeo de un medio queda marcada MEDIO_PAGO
   * en la traza (salvo que ya se haya resuelto por concepto, p. ej. el cheque).
   */
  private repartirPorMedio(
    partes: Array<{ medioPagoId: string | null; monto: number }>,
    mapaMedios: Map<string, string>,
    cuentaPorDefecto: string,
    res?: ResolvedorMapeo,
  ): Map<string, number> {
    const porCuenta = new Map<string, number>();
    for (const parte of partes) {
      if (!(parte.monto > 0)) continue;
      const delMedio = cuentaDelMedio(mapaMedios, parte.medioPagoId, parte.monto);
      if (delMedio && res) res.marcar(delMedio, 'MEDIO_PAGO');
      const cuenta = delMedio ?? 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.
   *
   * Solo si el documento trae cheque: entonces CHEQUES_EN_CARTERA es obligatorio
   * (sin respaldo a Caja, spec 2026-10-13). Devuelve la cuenta del cheque (para
   * la traza de su línea), o null si el documento no trae cheque.
   */
  private async agregarChequeAlMapa(
    res: ResolvedorMapeo,
    mapaMedios: Map<string, string>,
    partes: Array<{ medioPagoId: unknown; monto: number }>,
  ): Promise<string | null> {
    const idCheque = await this.mediosPago.idMedioPorCodigo(CODIGO_MEDIO_POR_NOMBRE.CHEQUE);
    if (!idCheque) return null;
    const hayCheque = partes.some((p) => String(p.medioPagoId) === String(idCheque) && p.monto > 0);
    if (!hayCheque) return null;
    // Sin respaldo: el cheque no entra a la caja si no se mapeó su cuenta.
    const cuenta = await res.cuenta('CHEQUES_EN_CARTERA');
    mapaMedios.set(idCheque, cuenta);
    return cuenta;
  }

  /**
   * 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
   *   Sin respaldo: si el concepto del tipo no está mapeado lanza FaltaMapeoError
   *   (antes caía a PROVEEDORES; spec 2026-10-13).
   *
   * Ver docs/plan-acreedores-proveedores-exterior.md.
   *
   * Con `res` (los integradores) el concepto se resuelve con las reglas del
   * documento y la cuenta propia del proveedor queda con traza FICHA. Sin `res`
   * (compras y gastos, para guardar la cuenta del pasivo) es el mapeo por defecto.
   * Si se pasa `origen`, deja ahí el concepto con que salió la cuenta (nada si es
   * la cuenta propia), para la traza de la línea.
   */
  async resolverCuentaPasivo(
    proveedorId: string,
    empresaId: string,
    res?: ResolvedorMapeo,
    origen?: { concepto?: 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 res ? res.marcar(cuenta.id, 'FICHA', 'FICHA_PROVEEDOR') : 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';
    if (origen) origen.concepto = concepto;
    return res ? res.cuenta(concepto) : this.mapeo.getCuentaPorConcepto(empresaId, concepto);
  }

  /**
   * Cuenta de pasivo a congelar en la CxP de una compra o un gasto (FASE 2): la
   * que va a acreditar el asiento del documento, con las mismas reglas por
   * dimensión (sucursal, moneda y tipo de proveedor del documento, a su fecha
   * contable). Así la orden de pago debita la misma cuenta que acreditó el
   * documento aunque haya reglas activas. `moneda_id` se usa si no viene `moneda`.
   */
  async cuentaPasivoParaCongelar(
    empresaId: string,
    documento: {
      proveedor_id: string;
      sucursal_id?: string | null;
      moneda?: { codigo: string } | null;
      moneda_id?: string | null;
      fecha: Date;
    },
  ): Promise<string> {
    const moneda =
      documento.moneda ??
      (documento.moneda_id
        ? await this.prisma.moneda.findUnique({ where: { id: documento.moneda_id }, select: { codigo: true } })
        : null);
    const res = await this.resolvedor(
      empresaId,
      documento.fecha,
      await dimensionesProveedor(this.prisma, empresaId, { ...documento, moneda }),
    );
    return this.resolverCuentaPasivo(documento.proveedor_id, empresaId, res);
  }

  // ── Apertura por ítem (spec 2026-10-13 "reglas de cuenta por dimensión", §5.2) ──

  /**
   * Líneas de un concepto de ventas/costo. Sin reglas de ítem para el concepto,
   * la línea única de hoy (sin leer los ítems). Con reglas, una línea por cuenta
   * resultante que suman exactamente `total`. `total` va en la unidad en que se
   * arman las líneas (guaraníes, ya multiplicado por el tipo de cambio); los netos
   * de los ítems vienen en la moneda del documento y se convierten con el mismo
   * tipo de cambio antes de abrir. Redondeo: 0 decimales en guaraníes, 2 en otra moneda.
   */
  private async lineasDeConcepto(
    res: ResolvedorMapeo,
    concepto: string,
    cuenta: string,
    total: number,
    items: () => Promise<ItemNeto[]>,
    tipoCambio: number,
    esPYG: boolean,
  ): Promise<LineaAbierta[]> {
    const unica = [{ cuenta_id: cuenta, monto: total }];
    if (!res.tieneReglasDeItem(concepto) || !(total > 0)) return unica;
    const deTasa = await items();
    if (!deTasa.length) return unica;
    return abrirConcepto(res, concepto, deTasa.map((i) => ({ neto: i.neto * tipoCambio, dims: i.dims })), total, esPYG ? 0 : 2);
  }

  /** Lee los ítems una sola vez (y prepara sus categorías), solo si algún concepto los pide. */
  private cargarUnaVez<T extends { dims: DimensionesDocumento }>(res: ResolvedorMapeo, leer: () => Promise<T[]>): () => Promise<T[]> {
    let cargados: Promise<T[]> | null = null;
    return () =>
      (cargados ??= leer().then(async (items) => {
        await res.prepararCategorias(items.map((i) => i.dims.CATEGORIA_PRODUCTO).filter((c): c is string => !!c));
        return items;
      }));
  }

  /**
   * Ítems de una factura o NC de venta con su neto por tasa, con la misma base que
   * la cabecera (factura_subtotales: dsub - iva): la base gravada (`dbasgraviva`)
   * en su tasa (`dtasiva`); exento/exonerado, el total del ítem; gravado parcial,
   * su parte exenta (`dbasexe`).
   */
  private async itemsVenta(origen: 'factura_det' | 'nota_credito_det', documentoId: string): Promise<ItemVenta[]> {
    const select = { dtasiva: true, iafeciva: true, dbasgraviva: true, dbasexe: true, dtotopeitem: true, productos: SELECT_PRODUCTO_ITEM } as const;
    const filas =
      origen === 'factura_det'
        ? await this.prisma.factura_det.findMany({ where: { factura_cab_id: documentoId }, select })
        : await this.prisma.nota_credito_det.findMany({ where: { nota_credito_cab_id: documentoId }, select });
    return filas.map((f) => {
      const tasa = Number(f.dtasiva ?? 0);
      const afectacion = Number(f.iafeciva ?? 0);
      const gravada = Number(f.dbasgraviva ?? 0);
      const exenta = afectacion === 2 || afectacion === 3 ? Number(f.dtotopeitem ?? 0) : Number(f.dbasexe ?? 0);
      return {
        dims: dimensionesItem(f.productos as ProductoItem | null),
        netos: { 10: tasa === 10 ? gravada : 0, 5: tasa === 5 ? gravada : 0, 0: exenta },
      };
    });
  }

  /** Ítems de una compra o NC de compra: neto `subtotal - monto_iva` (la compra asienta el costo en una sola línea, todas las tasas juntas). */
  private async itemsCompra(origen: 'compra_det' | 'nota_credito_compra_det', documentoId: string): Promise<ItemNeto[]> {
    const select = { subtotal: true, monto_iva: true, productos: SELECT_PRODUCTO_ITEM } as const;
    const filas =
      origen === 'compra_det'
        ? await this.prisma.compra_det.findMany({ where: { compra_id: documentoId }, select })
        : await this.prisma.nota_credito_compra_det.findMany({ where: { nota_credito_compra_id: documentoId }, select });
    return filas.map((f) => ({
      neto: Number(f.subtotal ?? 0) - Number(f.monto_iva ?? 0),
      dims: dimensionesItem(f.productos as ProductoItem | null),
    }));
  }

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

  /** Usa el concepto específico si la empresa lo mapeó; si no, el general. Devuelve la cuenta y el concepto usado. */
  private async cuentaConRespaldo(
    empresaId: string,
    res: ResolvedorMapeo,
    especifico: string,
    general: string,
  ): Promise<{ cuenta: string; concepto: string }> {
    const existe = await this.prisma.cont_mapeo_cuentas.findUnique({
      where: { empresa_id_concepto: { empresa_id: empresaId, concepto: especifico } },
      select: { id: true },
    });
    const concepto = existe ? especifico : general;
    return { cuenta: await res.cuenta(concepto), concepto };
  }

  async integrarFacturaVenta(facturaId: string) {
    return this.conAvisoDeMapeo('factura_cab', facturaId, async () => {
      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, sesion_caja_id: 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;
      }

      // Cuota de una venta inmobiliaria: ventas a sus propios conceptos (con respaldo
      // en los de siempre) y al centro de costo del proyecto.
      const cobroInm = await this.prisma.inm_cobros.findFirst({
        where: { factura_cab_id: facturaId },
        select: { inm_operaciones: { select: { inm_proyectos: { select: { centro_costo_id: true } } } } },
      });
      const ccVentas = cobroInm?.inm_operaciones?.inm_proyectos?.centro_costo_id ?? null;
      const res = await this.resolvedor(empresaId, fecha, await dimensionesVenta(this.prisma, empresaId, factura));
      const conceptoVentas = async (concepto: string) =>
        cobroInm ? this.cuentaConRespaldo(empresaId, res, `INM_${concepto}`, concepto) : { cuenta: await res.cuenta(concepto), concepto };
      const conceptoContraparte = esContado ? 'CAJA_GENERAL' : 'CLIENTES';

      const [cContraparte, cVentas10, ventas5, ventasEx, cIva10, cIva5] = await Promise.all([
        res.cuenta(conceptoContraparte),
        res.cuenta('VENTAS_10'),
        conceptoVentas('VENTAS_5'),
        conceptoVentas('VENTAS_EXENTAS'),
        res.cuenta('IVA_DEBITO_10'),
        res.cuenta('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');
        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),
        }));
        const cCheque = await this.agregarChequeAlMapa(res, mapaMedios, partes);

        let porCuenta = this.repartirPorMedio(partes, mapaMedios, cContraparte, res);
        // 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) {
          // La cuenta de un medio configurado no sale de un concepto: va con su marca MEDIO_PAGO.
          const concepto = cuentaId === cContraparte ? conceptoContraparte : cuentaId === cCheque ? 'CHEQUES_EN_CARTERA' : undefined;
          lineas.push({ cuenta_id: cuentaId, debe_moneda: monto, haber_moneda: 0, descripcion: 'Cobro contado', concepto });
        }
      } else {
        lineas.push({ cuenta_id: cContraparte, debe_moneda: total, haber_moneda: 0, descripcion: 'Clientes', concepto: conceptoContraparte });
      }
      // Con reglas de ítem en un concepto de ventas, su línea se abre por cuenta
      // (una venta inmobiliaria las busca en su concepto efectivo INM_*).
      const items = this.cargarUnaVez(res, () => this.itemsVenta('factura_det', facturaId));
      const ventas = (concepto: string, cuenta: string, total: number, tasa: 10 | 5 | 0) =>
        this.lineasDeConcepto(res, concepto, cuenta, total, async () =>
          (await items()).map((i) => ({ neto: i.netos[tasa], dims: i.dims })).filter((i) => i.neto !== 0), tipoCambio, esPYG);
      const cc = ccVentas ? { centro_costo_id: ccVentas } : {};
      if (base10 > 0) {
        for (const l of await ventas('VENTAS_10', cVentas10, base10, 10)) {
          lineas.push({ cuenta_id: l.cuenta_id, debe_moneda: 0, haber_moneda: l.monto, descripcion: 'Ventas 10%', concepto: 'VENTAS_10', dimsItem: l.dimsItem });
        }
      }
      if (base5 > 0) {
        for (const l of await ventas(ventas5.concepto, ventas5.cuenta, base5, 5)) {
          lineas.push({ cuenta_id: l.cuenta_id, debe_moneda: 0, haber_moneda: l.monto, descripcion: 'Ventas 5%', ...cc, concepto: ventas5.concepto, dimsItem: l.dimsItem });
        }
      }
      if (exento > 0) {
        for (const l of await ventas(ventasEx.concepto, ventasEx.cuenta, exento, 0)) {
          lineas.push({ cuenta_id: l.cuenta_id, debe_moneda: 0, haber_moneda: l.monto, descripcion: 'Ventas exentas', ...cc, concepto: ventasEx.concepto, dimsItem: l.dimsItem });
        }
      }
      if (iva10 > 0) lineas.push({ cuenta_id: cIva10, debe_moneda: 0, haber_moneda: iva10, descripcion: 'IVA Débito 10%', concepto: 'IVA_DEBITO_10' });
      if (iva5 > 0) lineas.push({ cuenta_id: cIva5, debe_moneda: 0, haber_moneda: iva5, descripcion: 'IVA Débito 5%', concepto: 'IVA_DEBITO_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: res.anotar(lineas),
        }, tx);
      });

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

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

  async integrarNotaCredito(notaCreditoId: string) {
    return this.conAvisoDeMapeo('nota_credito_cab', notaCreditoId, async () => {
      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;
      }

      // La NC no tiene formas de pago: no publica CANAL (no se deriva de la factura).
      const res = await this.resolvedor(empresaId, fecha, await dimensionesVenta(this.prisma, empresaId, nc));
      const [cClientes, cVentas10, cVentas5, cVentasEx, cIva10, cIva5] = await Promise.all([
        res.cuenta('CLIENTES'),
        res.cuenta('VENTAS_10'),
        res.cuenta('VENTAS_5'),
        res.cuenta('VENTAS_EXENTAS'),
        res.cuenta('IVA_DEBITO_10'),
        res.cuenta('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', concepto: 'CLIENTES' },
      ];
      // Con reglas de ítem, cada concepto de ventas se abre por cuenta con los ítems de la propia NC.
      const items = this.cargarUnaVez(res, () => this.itemsVenta('nota_credito_det', notaCreditoId));
      const devolucion: Array<[string, string, number, 10 | 5 | 0, string]> = [
        ['VENTAS_10', cVentas10, base10, 10, 'Devolución ventas 10%'],
        ['VENTAS_5', cVentas5, base5, 5, 'Devolución ventas 5%'],
        ['VENTAS_EXENTAS', cVentasEx, exento, 0, 'Devolución ventas exentas'],
      ];
      for (const [concepto, cuenta, monto, tasa, descripcion] of devolucion) {
        if (!(monto > 0)) continue;
        const abiertas = await this.lineasDeConcepto(res, concepto, cuenta, monto, async () =>
          (await items()).map((i) => ({ neto: i.netos[tasa], dims: i.dims })).filter((i) => i.neto !== 0), tipoCambio, esPYG);
        for (const l of abiertas) lineas.push({ cuenta_id: l.cuenta_id, debe_moneda: l.monto, haber_moneda: 0, descripcion, concepto, dimsItem: l.dimsItem });
      }
      if (iva10 > 0) lineas.push({ cuenta_id: cIva10, debe_moneda: iva10, haber_moneda: 0, descripcion: 'Reversión IVA Débito 10%', concepto: 'IVA_DEBITO_10' });
      if (iva5 > 0) lineas.push({ cuenta_id: cIva5, debe_moneda: iva5, haber_moneda: 0, descripcion: 'Reversión IVA Débito 5%', concepto: 'IVA_DEBITO_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: res.anotar(lineas),
        }, tx);
      });

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

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

  async integrarFacturaCompra(compraId: string) {
    return this.conAvisoDeMapeo('compra_cab', compraId, async () => {
      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.fecha_emision ? `COMPRA ${fmtFecha(compra.fecha_emision)}` : 'COMPRA S/N');

      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;

      const res = await this.resolvedor(empresaId, fecha, await dimensionesProveedor(this.prisma, empresaId, compra));
      let cContraparte: string;
      let costo: { cuenta: string; concepto: string };
      let cIvaCredito10: string;
      let cIvaCredito5: string;
      // Concepto del haber (nada si es la cuenta propia del proveedor: va con FICHA).
      const origenContraparte: { concepto?: string } = esContado ? { concepto: 'CAJA_GENERAL' } : {};
      try {
        // FASE 2: Si es crédito, resolver cuenta pasivo según tipo de proveedor
        // (con el mismo contexto con que se congeló en la CxP: cuentaPasivoParaCongelar).
        const contrapartePromise = esContado
          ? res.cuenta('CAJA_GENERAL')
          : this.resolverCuentaPasivo(compra.proveedor_id, empresaId, res, origenContraparte);
        [cContraparte, costo, cIvaCredito10, cIvaCredito5] = await Promise.all([
          contrapartePromise,
          this.cuentaCostoCompra(res, compraId),
          res.cuenta('IVA_CREDITO_10'),
          res.cuenta('IVA_CREDITO_5'),
        ]);
      } catch (e) {
        if (e instanceof FaltaMapeoError) {
          await upsertDocumentoBorrador(e.message);
          throw e;
        }
        throw e;
      }

      // Con reglas de ítem en el concepto de costo, la línea se abre por cuenta.
      const costoAbierto = await this.lineasDeConcepto(res, costo.concepto, costo.cuenta, subtotalSinIva,
        this.cargarUnaVez(res, () => this.itemsCompra('compra_det', compraId)), tipoCambio, esPYG);
      const lineas: any[] = costoAbierto.map((l) => (
        { cuenta_id: l.cuenta_id, debe_moneda: l.monto, haber_moneda: 0, descripcion: 'Inventario/Gasto', concepto: costo.concepto, dimsItem: l.dimsItem }
      ));
      if (iva10 > 0) lineas.push({ cuenta_id: cIvaCredito10, debe_moneda: iva10, haber_moneda: 0, descripcion: 'IVA Crédito 10%', concepto: 'IVA_CREDITO_10' });
      if (iva5 > 0) lineas.push({ cuenta_id: cIvaCredito5, debe_moneda: iva5, haber_moneda: 0, descripcion: 'IVA Crédito 5%', concepto: 'IVA_CREDITO_5' });
      if (esContado) {
        // La compra contado sale por los medios con que se pagó: cada uno a la
        // cuenta que la empresa configuró para ese medio (al pagar), y lo que no
        // está configurado a Caja general. Antes iba todo a Caja aunque se haya
        // pagado por transferencia.
        const [formasPago, mapaMedios] = await Promise.all([
          this.prisma.compra_forma_pagos.findMany({ where: { compra_id: compraId }, select: { medio_pago_id: true, monto: true } }),
          this.mediosPago.resolverPorMedio(empresaId, 'PAGO'),
        ]);
        let porCuenta = this.repartirPorMedio(
          formasPago.map((fp) => ({ medioPagoId: fp.medio_pago_id, monto: this.round(Number(fp.monto ?? 0) * tipoCambio, 2) })),
          mapaMedios,
          cContraparte,
          res,
        );
        porCuenta = porCuenta.size === 0 ? new Map([[cContraparte, total]]) : this.ajustarAlTotal(porCuenta, total);
        for (const [cuentaId, monto] of porCuenta) {
          const concepto = cuentaId === cContraparte ? origenContraparte.concepto : undefined;
          lineas.push({ cuenta_id: cuentaId, debe_moneda: 0, haber_moneda: monto, descripcion: 'Pago contado', concepto });
        }
      } else {
        lineas.push({ cuenta_id: cContraparte, debe_moneda: 0, haber_moneda: total, descripcion: 'Proveedor', concepto: origenContraparte.concepto });
      }

      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: res.anotar(lineas),
        }, tx);
      });

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

  /** Una compra cargada a una importación va a donde el contador mapeó IMPORTACION_COMPRA. Devuelve la cuenta y el concepto. */
  private async cuentaCostoCompra(res: ResolvedorMapeo, compraId: string): Promise<{ cuenta: string; concepto: string }> {
    const deImportacion = await this.prisma.imp_componentes_costo.findFirst({
      where: { compra_id: compraId, activo: true },
      select: { id: true },
    });
    const concepto = deImportacion ? 'IMPORTACION_COMPRA' : 'INVENTARIO';
    return { cuenta: await res.cuenta(concepto), concepto };
  }

  // ── 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) {
    return this.conAvisoDeMapeo('nota_credito_compra_cab', ncId, async () => {
      const nc = await this.prisma.nota_credito_compra_cab.findUnique({
        where: { id: ncId },
        // La NC no tiene sucursal propia: SUCURSAL sale de su compra.
        include: { moneda: true, compra_cab: { select: { sucursal_id: 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('-') ||
        `NC COMPRA ${fmtFecha(fecha)}`;

      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;
      }

      // La NC de compra no tiene sucursal propia: publica la de su compra (sin
      // compra, no publica SUCURSAL).
      const res = await this.resolvedor(
        empresaId,
        fecha,
        await dimensionesProveedor(this.prisma, empresaId, { ...nc, sucursal_id: nc.compra_cab?.sucursal_id ?? null }),
      );
      let cProveedor: string;
      let inventario: { cuenta: string; concepto: string };
      let cIvaCredito10: string;
      let cIvaCredito5: string;
      const origenProveedor: { concepto?: string } = {};
      try {
        // Simétrica a la compra: si la compra de la NC está cargada a una importación,
        // se revierte contra la misma cuenta (IMPORTACION_COMPRA), no contra INVENTARIO.
        [cProveedor, inventario, cIvaCredito10, cIvaCredito5] = await Promise.all([
          this.resolverCuentaPasivo(nc.proveedor_id, empresaId, res, origenProveedor),
          nc.compra_id
            ? this.cuentaCostoCompra(res, nc.compra_id)
            : res.cuenta('INVENTARIO').then((cuenta) => ({ cuenta, concepto: 'INVENTARIO' })),
          res.cuenta('IVA_CREDITO_10'),
          res.cuenta('IVA_CREDITO_5'),
        ]);
      } catch (e) {
        // Falta de mapeo: la registra el envoltorio (conAvisoDeMapeo).
        if (e instanceof FaltaMapeoError) throw 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)', concepto: origenProveedor.concepto },
      ];
      if (neto > 0) {
        const abiertas = await this.lineasDeConcepto(res, inventario.concepto, inventario.cuenta, neto,
          this.cargarUnaVez(res, () => this.itemsCompra('nota_credito_compra_det', ncId)), tipoCambio, esPYG);
        for (const l of abiertas) {
          lineas.push({ cuenta_id: l.cuenta_id, debe_moneda: 0, haber_moneda: l.monto, descripcion: 'Reversión Inventario/Gasto', concepto: inventario.concepto, dimsItem: l.dimsItem });
        }
      }
      if (iva10 > 0) lineas.push({ cuenta_id: cIvaCredito10, debe_moneda: 0, haber_moneda: iva10, descripcion: 'Reversión IVA Crédito 10%', concepto: 'IVA_CREDITO_10' });
      if (iva5 > 0) lineas.push({ cuenta_id: cIvaCredito5, debe_moneda: 0, haber_moneda: iva5, descripcion: 'Reversión IVA Crédito 5%', concepto: 'IVA_CREDITO_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: res.anotar(lineas),
        }, tx);
      });

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

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

  async integrarCierreDespacho(despachoId: string) {
    return this.conAvisoDeMapeo('imp_despacho', despachoId, async () => {
      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 costo y va a IMPORTACION_ANTICIPO_IRE, que es
      // obligatorio si hay IRE (sin mapeo el despacho queda pendiente con aviso).
      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;
      }

      // Moneda y proveedor son los del embarque (el despacho no tiene sucursal).
      const res = await this.resolvedor(
        empresaId,
        fecha,
        await dimensionesProveedor(this.prisma, empresaId, {
          proveedor_id: despacho.embarque.proveedor_id,
          moneda: despacho.embarque.moneda,
        }),
      );

      // FASE 2: Proveedor de importación normalmente es PROVEEDOR_EXTERIOR → resolver.
      const origenProveedor: { concepto?: string } = {};
      const [cIvaCredito10, cProveedor] = await Promise.all([
        res.cuenta('IVA_CREDITO_10'),
        this.resolverCuentaPasivo(despacho.embarque.proveedor_id, empresaId, res, origenProveedor),
      ]);

      // El costo va a donde el contador mapeó IMPORTACION_TRIBUTOS (la cuenta puente
      // o mercaderías, según trabaje la empresa; spec 2026-10-13).
      //
      // El modelo de cuenta puente (fase 2, docs/plan-importaciones-fase2-contable.md)
      // sigue activándose mapeando IMPORTACIONES_EN_CURSO: acá solo decide si se
      // aplica el anticipo de despacho.
      const cuentaCosto = await res.cuenta('IMPORTACION_TRIBUTOS');
      const cPuente = await res.cuenta('IMPORTACIONES_EN_CURSO').catch(soloSinMapeo);

      // 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 conceptoContrapartida = origenProveedor.concepto;
      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) {
          // Con anticipo la cuenta es obligatoria: antes, sin mapear, se acreditaba
          // al proveedor (spec 2026-10-13, decisión registrada).
          cContrapartida = await res.cuenta('ANTICIPO_DESPACHO');
          conceptoContrapartida = 'ANTICIPO_DESPACHO';
          glosaContrapartida = `Aplicación anticipo de despacho ${despacho.numero_despacho}`;
        }
      }

      const lineas: any[] = [
        {
          cuenta_id: cuentaCosto,
          debe_moneda: inventarioGs,
          haber_moneda: 0,
          descripcion: 'Costo de importación',
          concepto: 'IMPORTACION_TRIBUTOS',
        },
      ];
      if (ivaImportacion > 0) {
        lineas.push({
          cuenta_id: cIvaCredito10,
          debe_moneda: ivaImportacion,
          haber_moneda: 0,
          descripcion: 'IVA Crédito importación',
          concepto: 'IVA_CREDITO_10',
        });
      }
      if (noCostoGs > 0) {
        lineas.push({
          cuenta_id: await res.cuenta('IMPORTACION_ANTICIPO_IRE'),
          debe_moneda: noCostoGs,
          haber_moneda: 0,
          descripcion: 'Anticipo IRE importación',
          concepto: 'IMPORTACION_ANTICIPO_IRE',
        });
      }
      lineas.push({
        cuenta_id: cContrapartida,
        debe_moneda: 0,
        haber_moneda: totalGs,
        descripcion: glosaContrapartida,
        concepto: conceptoContrapartida,
      });

      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 || despacho.embarque.numero,
            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: res.anotar(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 es el saldo que la importación dejó en la cuenta puente: lo que
   * debitaron (menos lo que acreditaron) los asientos vigentes de su despacho,
   * sus compras, sus gastos y sus pagos (ver `saldoPuenteDeImportacion` en saldo-puente-importacion.ts). No es
   * el costeo (`costo_total_gs`): si el contador mandó una compra directo a
   * Mercaderías, trasladar el costeo completo dejaría la puente en negativo. La
   * diferencia contra el costeo la informa Salud contable.
   */
  async integrarCierreImportacion(embarqueId: string) {
    return this.conAvisoDeMapeo('imp_cierre', embarqueId, async () => {
      const embarque = await this.prisma.imp_embarques.findUnique({
        where: { id: embarqueId },
        select: {
          id: true,
          empresa_id: true,
          numero: true,
          estado: true,
          usuario_id: true,
          moneda: { select: { codigo: 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;

      // El asiento de cierre va con la fecha de hoy; la vigencia de las reglas también.
      const fecha = new Date();
      const res = await this.resolvedor(empresaId, fecha, dimensionMoneda(embarque.moneda?.codigo));

      // Sin fila de mapeo la empresa no usa la puente; mapeada a una cuenta que no
      // sirve, el cierre queda pendiente con aviso (soloSinMapeo relanza).
      const cPuente = await res.cuenta('IMPORTACIONES_EN_CURSO').catch(soloSinMapeo);
      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;
      }

      // Se traslada lo que efectivamente entró a la puente por esta importación,
      // no el costeo: si el contador mandó una compra directo a Mercaderías, no se
      // vuelve a sumar. La diferencia contra el costeo la informa Salud contable.
      const costo = await saldoPuenteDeImportacion(this.prisma, empresaId, embarqueId, cPuente);
      if (costo <= 0) {
        this.logger.warn(`Embarque ${embarqueId}: la cuenta puente no tiene saldo de esta importación`);
        return;
      }

      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 res.cuenta('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,
            estado: 'CONFIRMADO',
          },
        });

        await this.asientos.crearConfirmado(
          empresaId,
          embarque.usuario_id ?? null,
          doc.id,
          {
            periodo_id: periodoId,
            fecha: fecha.toISOString().substring(0, 10),
            glosa: `Cierre importación ${embarque.numero ?? ''}`.trim(),
            moneda_origen: 'PYG',
            lineas: res.anotar([
              { cuenta_id: cInventario, debe_moneda: costo, haber_moneda: 0, descripcion: 'Mercaderías', concepto: 'INVENTARIO' },
              { cuenta_id: cPuente, debe_moneda: 0, haber_moneda: costo, descripcion: 'Importaciones en curso', concepto: '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'; si está cargado a una importación, a
  // IMPORTACION_GASTO salvo que el contador elija conservar la del tipo). IVA
  // sólo se debita a 'IVA_CREDITO_*' si gasto.deducible = true; si no, va a
  // IVA_NO_DEDUCIBLE o engrosa la cuenta de gasto (SUMAR_A_PRINCIPAL).
  // Contraparte depende de forma_pago.

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

      const empresaId = gasto.empresa_id;

      if (!(await this.tieneModuloContabilidad(empresaId))) return;
      if (gasto.anulado) return;
      // Histórico: ya está contabilizado en el sistema anterior de la empresa.
      if (gasto.es_historico) 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 ${fmtFecha(fecha)}`;

      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';
      }

      // Gasto pagado en el momento (contado o transferencia): la plata sale de
      // donde la mandó tesorería. Primero la cuenta elegida en el gasto, después
      // la de la regla "Pago de gasto" (es la que usa tesorería para el egreso
      // cuando no se eligió ninguna), después la configurada para el medio (al
      // pagar) y recién al final el concepto. Antes iba siempre a CAJA_GENERAL o
      // BANCOS_DEFAULT: una transferencia desde la cuenta en USD acreditaba el
      // banco en guaraníes.
      const res = await this.resolvedor(empresaId, fecha, await dimensionesProveedor(this.prisma, empresaId, gasto));
      let cuentaPagoPropia: string | null = null;
      const pagoContado = conceptoContraparte === 'CAJA_GENERAL' || conceptoContraparte === 'BANCOS_DEFAULT';
      if (pagoContado && (await medioNoMueveCaja(this.prisma, empresaId, gasto.medio_pago_id, gasto.medio_pago))) {
        // Pagado con un medio que no mueve dinero: un anticipo ya entregado, o el que la empresa
        // marcó así (DOBA: "Otro" contra Otros acreedores). Se acredita la cuenta "Al pagar" del
        // medio, nunca la de tesorería ni la de la regla.
        const medio = gasto.medio_pago?.descripcion ?? 'Pago por anticipo';
        const esAnticipo = CODIGOS_MEDIO_SIN_CAJA.has(Number(gasto.medio_pago?.codigo));
        let delMedio: string | null;
        try {
          delMedio = cuentaDelMedio(await this.mediosPago.resolverPorMedio(empresaId, 'PAGO'), gasto.medio_pago_id, total);
        } catch (e) {
          if (e instanceof FaltaMapeoError) await upsertDocumentoBorrador(e.message);
          throw e;
        }
        if (!delMedio) {
          const falta = new FaltaMapeoError(
            empresaId,
            ['MEDIO_PAGO_PAGO'],
            `el medio ${medio} no mueve caja: necesita su cuenta "Al pagar" en ${DONDE_SE_CORRIGE_MEDIO}`,
            'SIN_MAPEO',
            undefined,
            undefined,
            { MEDIO_PAGO_PAGO: `la cuenta "Al pagar" del medio ${medio}` },
          );
          await upsertDocumentoBorrador(falta.message);
          throw falta;
        }
        cuentaPagoPropia = res.marcar(delMedio, 'MEDIO_PAGO');
        descContraparte = esAnticipo ? `Se aplica el anticipo (${medio})` : `Sin movimiento de caja (${medio})`;
      } else if (conceptoContraparte === 'CAJA_GENERAL' || conceptoContraparte === 'BANCOS_DEFAULT') {
        let tesCuentaId: string | null = gasto.cuenta_tesoreria_id ?? null;
        if (!tesCuentaId) {
          const reglas = await this.prisma.tes_reglas.findMany({
            where: { empresa_id: empresaId, tipo_operacion: 'PAGO_GASTO', activo: true, cuenta_id: { not: null } },
            select: { medio_pago_id: true, cuenta_id: true },
          });
          tesCuentaId =
            reglas.find((r) => gasto.medio_pago_id && r.medio_pago_id === gasto.medio_pago_id)?.cuenta_id ??
            reglas.find((r) => r.medio_pago_id === null)?.cuenta_id ??
            null;
        }
        if (tesCuentaId) {
          const tes = await this.prisma.tes_cuentas.findUnique({ where: { id: tesCuentaId }, select: { cuenta_contable_id: true } });
          cuentaPagoPropia = tes?.cuenta_contable_id ? res.marcar(tes.cuenta_contable_id, 'FICHA', 'FICHA_CUENTA_TESORERIA') : null;
        }
        if (!cuentaPagoPropia && gasto.medio_pago_id) {
          const mapaMedios = await this.mediosPago.resolverPorMedio(empresaId, 'PAGO');
          let delMedio: string | null;
          try {
            delMedio = cuentaDelMedio(mapaMedios, gasto.medio_pago_id, total);
          } catch (e) {
            // Medio configurado a una cuenta que no sirve: queda pendiente, como un concepto sin mapear.
            if (e instanceof FaltaMapeoError) await upsertDocumentoBorrador(e.message);
            throw e;
          }
          cuentaPagoPropia = delMedio ? res.marcar(delMedio, 'MEDIO_PAGO') : null;
        }
        if (cuentaPagoPropia) descContraparte = 'Pago (banco/caja)';
      }

      // Cuenta fallback para gastos (cuando tipo_gasto.cuenta_contable_id es null)
      let cuentaFallbackGasto: string;
      let cContraparte: string;
      // Concepto del haber; nada si la cuenta salió de una ficha o del medio de pago.
      const origenContraparte: { concepto?: string } = esCreditoConProveedor || cuentaPagoPropia ? {} : { concepto: conceptoContraparte };
      try {
        const contraparteResolver = esCreditoConProveedor
          ? this.resolverCuentaPasivo(gasto.proveedor_id as string, empresaId, res, origenContraparte)
          : cuentaPagoPropia
            ? Promise.resolve(cuentaPagoPropia)
            : res.cuenta(conceptoContraparte);
        // GASTOS_GENERALES es sólo para los ítems sin tipo de gasto con cuenta propia.
        // Pedirla siempre frenaba todos los gastos de una empresa que no la tenía mapeada,
        // aunque cada ítem trajera su cuenta.
        const necesitaCuentaGeneral = gasto.gasto_det.some((det) => !det.tipo_gasto?.cuenta_contable_id);
        [cuentaFallbackGasto, cContraparte] = await Promise.all([
          necesitaCuentaGeneral ? res.cuenta('GASTOS_GENERALES') : Promise.resolve(''),
          contraparteResolver,
        ]);
      } catch (e) {
        if (e instanceof FaltaMapeoError) {
          await upsertDocumentoBorrador(e.message);
          throw e;
        }
        throw e;
      }

      // Cargado a una importación: el contador decide si va a su cuenta propia o
      // conserva la del tipo de gasto (SUMAR_A_PRINCIPAL).
      const deImportacion = await this.prisma.imp_componentes_costo.findFirst({
        where: { gasto_id: gastoId, activo: true },
        select: { id: true },
      });
      const destinoImportacion = deImportacion ? await res.resolver('IMPORTACION_GASTO') : null;
      const cuentaImportacion = destinoImportacion?.modo === 'CUENTA' ? destinoImportacion.cuentaId : null;

      // IVA de un gasto no deducible: a su cuenta o sumado al gasto.
      // Sólo hace falta si hay IVA que imputar: un gasto sin factura y sin IVA (exento,
      // viático) no tiene por qué frenarse si el concepto no está mapeado.
      const hayIvaNoDeducible = iva10 + iva5 > 0 || gasto.gasto_det.some((det) => Number(det.monto_iva ?? 0) > 0);
      const destinoIvaNoDeducible = deducible || !hayIvaNoDeducible ? null : await res.resolver('IVA_NO_DEDUCIBLE');
      const ivaAparte = destinoIvaNoDeducible?.modo === 'CUENTA' ? destinoIvaNoDeducible.cuentaId : null;

      // Líneas DEBE: agrupar por cuenta contable del ítem (importación, tipo_gasto.cuenta_contable_id o fallback)
      // Si es deducible o el IVA no deducible va aparte: gasto neto (sin IVA) + IVA separado.
      // Si NO es deducible y se suma al gasto: todo (IVA incluido) a cuenta de gasto.
      // `concepto`: el de la cuenta (importación o fallback); sin concepto, la cuenta
      // es la del tipo de gasto y va con FICHA. Si una cuenta junta ítems de los dos
      // caminos, la línea lleva el del primer ítem.
      const debePorCuenta = new Map<string, { monto: number; descripcion: string; concepto?: string }>();
      for (const det of gasto.gasto_det) {
        const cuentaTipo = cuentaImportacion ? null : det.tipo_gasto?.cuenta_contable_id;
        const cuentaGastoId = cuentaImportacion ?? (cuentaTipo ? res.marcar(cuentaTipo, 'FICHA', 'FICHA_TIPO_GASTO') : cuentaFallbackGasto);
        const concepto = cuentaImportacion ? 'IMPORTACION_GASTO' : cuentaTipo ? undefined : 'GASTOS_GENERALES';
        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 || ivaAparte ? 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, concepto });
        }
      }

      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,
          concepto: info.concepto,
        });
      }

      // IVA crédito sólo si es deducible y hay IVA
      if (deducible && iva10 > 0) {
        try {
          const cIva10 = await res.cuenta('IVA_CREDITO_10');
          lineas.push({ cuenta_id: cIva10, debe_moneda: iva10, haber_moneda: 0, descripcion: 'IVA Crédito 10%', concepto: 'IVA_CREDITO_10' });
        } catch (e) {
          if (e instanceof FaltaMapeoError) {
            await upsertDocumentoBorrador(e.message);
            throw e;
          }
          throw e;
        }
      }
      if (deducible && iva5 > 0) {
        try {
          const cIva5 = await res.cuenta('IVA_CREDITO_5');
          lineas.push({ cuenta_id: cIva5, debe_moneda: iva5, haber_moneda: 0, descripcion: 'IVA Crédito 5%', concepto: 'IVA_CREDITO_5' });
        } catch (e) {
          if (e instanceof FaltaMapeoError) {
            await upsertDocumentoBorrador(e.message);
            throw e;
          }
          throw e;
        }
      }

      if (ivaAparte && iva10 + iva5 > 0) {
        lineas.push({ cuenta_id: ivaAparte, debe_moneda: this.round(iva10 + iva5, 2), haber_moneda: 0, descripcion: 'IVA no deducible', concepto: 'IVA_NO_DEDUCIBLE' });
      }

      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,
        concepto: origenContraparte.concepto,
      });

      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: res.anotar(lineas),
          },
          tx,
        );
      });

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

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

  async integrarCobro(cobroId: string) {
    return this.conAvisoDeMapeo('recibos_cobro', cobroId, async () => {
      const cobro = await this.prisma.recibos_cobro.findUnique({
        where: { id: cobroId },
        include: { moneda: { select: { codigo: true } } },
      });
      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 res = await this.resolvedor(empresaId, fecha, await dimensionesCliente(this.prisma, empresaId, cobro));
      const [cCaja, cClientes, mapaMedios] = await Promise.all([
        res.cuenta('CAJA_GENERAL'),
        res.cuenta('CLIENTES'),
        this.mediosPago.resolverPorMedio(empresaId, 'COBRO'),
      ]);

      // 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)),
          }))
        : [];
      const cCheque = await this.agregarChequeAlMapa(res, mapaMedios, partes);

      let cobradoPorCuenta = this.repartirPorMedio(partes, mapaMedios, cCaja, res);
      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: res.anotar([
            ...[...cobradoPorCuenta].map(([cuenta_id, importe]) => ({
              cuenta_id,
              debe_moneda: importe,
              haber_moneda: 0,
              descripcion: 'Cobranza',
              // La cuenta de un medio configurado no sale de un concepto: va con su marca MEDIO_PAGO.
              concepto: cuenta_id === cCaja ? 'CAJA_GENERAL' : cuenta_id === cCheque ? 'CHEQUES_EN_CARTERA' : undefined,
            })),
            { cuenta_id: cClientes, debe_moneda: 0, haber_moneda: monto, descripcion: 'Clientes', concepto: 'CLIENTES' },
          ]),
        }, tx);
      });

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

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

  async integrarPago(pagoId: string) {
    return this.conAvisoDeMapeo('pagos_proveedor', pagoId, async () => {
      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;
      // El pago no tiene sucursal: publica moneda y tipo de proveedor.
      const res = await this.resolvedor(empresaId, fecha, await dimensionesProveedor(this.prisma, empresaId, pago));
      // La cuenta congelada en la CxP es un dato guardado, no sale del mapeo: FICHA.
      const pasivo: { concepto?: string } = {};
      const [cProveedores, cCaja, mapaMedios] = await Promise.all([
        cuentaPasivoCongelada
          ? Promise.resolve(res.marcar(cuentaPasivoCongelada, 'FICHA', 'FICHA_CXP_CONGELADA'))
          : this.resolverCuentaPasivo(pago.proveedor_id, empresaId, res, pasivo),
        res.cuenta('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. Configurado a una cuenta que no
      // sirve: sin asiento y con aviso (no cae a Caja).
      const delMedio = cuentaDelMedio(mapaMedios, pago.medio_pago_id, monto);
      if (delMedio) res.marcar(delMedio, 'MEDIO_PAGO');
      const cuentaSalida = delMedio ?? 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.referencia || `PAGO ${fmtFecha(fecha)}`).slice(0, 50),
            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: res.anotar([
            { cuenta_id: cProveedores, debe_moneda: monto, haber_moneda: 0, descripcion: 'Proveedor', concepto: pasivo.concepto },
            {
              cuenta_id: cuentaSalida,
              debe_moneda: 0,
              haber_moneda: monto,
              descripcion: 'Salida de fondos',
              concepto: delMedio ? undefined : 'CAJA_GENERAL',
            },
          ]),
        }, tx);
      });

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

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

  async integrarOrdenPago(ordenId: string) {
    return this.conAvisoDeMapeo('orden_pago_proveedor_cab', ordenId, async () => {
      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, compra_id: true, origen_tipo: true, origen_id: true } },
              medio_pago: { select: { codigo: 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 ${fmtFecha(orden.fecha_pago ?? new Date())}`;

      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).
      const res = await this.resolvedor(empresaId, fecha, await dimensionesProveedor(this.prisma, empresaId, orden));
      let cCajaFallback: string;
      try {
        cCajaFallback = await res.cuenta('CAJA_GENERAL');
      } catch (e) {
        if (e instanceof FaltaMapeoError) {
          await upsertDocumentoBorrador(e.message);
          throw e;
        }
        throw e;
      }

      // Cada detalle cancela su CxP y sale por su propio medio. Se arma línea por
      // línea en guaraníes:
      //   DEBE  la cuenta pasivo CONGELADA en la CxP (FASE 2), a la cotización con
      //         que se registró la deuda (la de la compra o el gasto).
      //   HABER de dónde salió la plata, a la cotización de la OP:
      //         - cheque emitido → CHEQUES_A_PAGAR (obligatorio). Un cheque no mueve
      //           el banco hasta que se debita (tesorería no le crea movimiento), así
      //           que acreditar caja o banco al emitirlo era registrar una salida que
      //           todavía no pasó.
      //         - la cuenta de tesorería elegida en el detalle;
      //         - la de la regla de tesorería "Pago a proveedor" del medio;
      //         - la cuenta configurada para el medio (al pagar);
      //         - Caja general.
      //   La diferencia entre las dos cotizaciones es diferencia de cambio.
      const detalles = orden.orden_pago_proveedor_det ?? [];

      const [cChequesAPagar, mapaMediosPago, reglas] = await Promise.all([
        res.cuenta('CHEQUES_A_PAGAR').catch(soloFaltaMapeo),
        this.mediosPago.resolverPorMedio(empresaId, 'PAGO'),
        this.prisma.tes_reglas.findMany({
          where: { empresa_id: empresaId, tipo_operacion: 'PAGO_PROVEEDOR', activo: true, cuenta_id: { not: null } },
          select: { medio_pago_id: true, cuenta_id: true },
        }),
      ]);
      const reglaPorMedio = new Map(reglas.map((r) => [r.medio_pago_id ?? '', r.cuenta_id as string]));
      const tesIds = [
        ...new Set([
          ...detalles.map((d) => d.cuenta_bancaria_id).filter((id): id is string => !!id),
          ...reglas.map((r) => r.cuenta_id as string),
        ]),
      ];
      const tesCuentas = tesIds.length
        ? await this.prisma.tes_cuentas.findMany({ where: { id: { in: tesIds } }, select: { id: true, cuenta_contable_id: true } })
        : [];
      const tesToContable = new Map(tesCuentas.map((t) => [t.id, t.cuenta_contable_id]));

      // Cotización con que se registró cada deuda (solo importa en moneda extranjera).
      const tcDeuda = new Map<string, number>();
      if (!esPYG) {
        const compraIds = detalles.map((d) => d.cuentas_pagar?.compra_id).filter((id): id is string => !!id);
        const gastoIds = detalles
          .filter((d) => d.cuentas_pagar?.origen_tipo === 'gasto' && d.cuentas_pagar?.origen_id)
          .map((d) => d.cuentas_pagar?.origen_id as string);
        const [compras, gastos] = await Promise.all([
          compraIds.length
            ? this.prisma.compra_cab.findMany({ where: { id: { in: compraIds } }, select: { id: true, cotizacion: true } })
            : Promise.resolve([]),
          gastoIds.length
            ? this.prisma.gasto_cab.findMany({ where: { id: { in: gastoIds } }, select: { id: true, cotizacion: true } })
            : Promise.resolve([]),
        ]);
        for (const c of [...compras, ...gastos]) tcDeuda.set(c.id, Number(c.cotizacion ?? 0) || tipoCambio);
      }
      const tcDeLaDeuda = (cxp: { compra_id: string | null; origen_id: string | null } | null | undefined) =>
        (cxp?.compra_id ? tcDeuda.get(cxp.compra_id) : cxp?.origen_id ? tcDeuda.get(cxp.origen_id) : undefined) ?? tipoCambio;

      // Cada línea agrupa por cuenta; su traza es la de lo primero que se sumó a
      // esa cuenta (concepto, o la marca FICHA/MEDIO_PAGO si no salió de uno).
      const debePorCuentaPasivo = new Map<string, { monto: number; concepto?: string }>();
      const debitarPasivo = (cuentaId: string, monto: number, concepto?: string) => {
        const actual = debePorCuentaPasivo.get(cuentaId);
        debePorCuentaPasivo.set(cuentaId, { monto: (actual?.monto ?? 0) + monto, concepto: actual ? actual.concepto : concepto });
      };
      const haberPorCuenta = new Map<string, { monto: number; descripcion: string; concepto?: string }>();
      const acreditar = (cuentaId: string, monto: number, descripcion: string, concepto?: string) => {
        const actual = haberPorCuenta.get(cuentaId);
        haberPorCuenta.set(cuentaId, {
          monto: (actual?.monto ?? 0) + monto,
          descripcion: actual?.descripcion ?? descripcion,
          concepto: actual ? actual.concepto : concepto,
        });
      };
      try {
        for (const det of detalles) {
          // La cuenta congelada en la CxP es un dato guardado, no sale del mapeo: FICHA.
          const pasivo: { concepto?: string } = {};
          const congelada = det.cuentas_pagar?.cuenta_pasivo_id;
          if (congelada) res.marcar(congelada, 'FICHA', 'FICHA_CXP_CONGELADA');
          const cuentaPasivo =
            congelada ??
            (await this.resolverCuentaPasivo(orden.proveedor_id, empresaId, res, pasivo));
          const montoDet = Number(det.monto ?? 0);
          debitarPasivo(cuentaPasivo, this.round(montoDet * (esPYG ? 1 : tcDeLaDeuda(det.cuentas_pagar)), 2), pasivo.concepto);

          const importe = this.round(montoDet * tipoCambio, 2);
          const cuentaElegida = det.cuenta_bancaria_id ? tesToContable.get(det.cuenta_bancaria_id) ?? null : null;
          if (det.medio_pago?.codigo === CODIGO_MEDIO_POR_NOMBRE.CHEQUE) {
            // Sin respaldo (spec 2026-10-13): antes, sin CHEQUES_A_PAGAR, caía a la
            // cuenta de tesorería del detalle o a Caja.
            acreditar(this.requerir(cChequesAPagar, empresaId, 'CHEQUES_A_PAGAR'), importe, 'Cheque emitido a pagar', 'CHEQUES_A_PAGAR');
            continue;
          }
          const reglaTes = reglaPorMedio.get(det.medio_pago_id ?? '') ?? reglaPorMedio.get('');
          // Cuenta de tesorería (elegida o de la regla de tesorería) → FICHA;
          // la configurada para el medio → MEDIO_PAGO; si no, CAJA_GENERAL.
          const cuentaTes = cuentaElegida ?? (reglaTes ? tesToContable.get(reglaTes) ?? null : null);
          // Un medio configurado a una cuenta que no sirve no cae a Caja: sin asiento y con aviso.
          const delMedio = cuentaTes ? null : cuentaDelMedio(mapaMediosPago, det.medio_pago_id, importe);
          if (cuentaTes) res.marcar(cuentaTes, 'FICHA', 'FICHA_CUENTA_TESORERIA');
          else if (delMedio) res.marcar(delMedio, 'MEDIO_PAGO');
          const cuentaHaber = cuentaTes ?? delMedio ?? cCajaFallback;
          acreditar(cuentaHaber, importe, 'Pago (banco/caja)', cuentaTes || delMedio ? undefined : 'CAJA_GENERAL');
        }
      } catch (e) {
        if (e instanceof FaltaMapeoError) {
          await upsertDocumentoBorrador(e.message);
          throw e;
        }
        throw e;
      }

      // Si por alguna razón no hay detalles (OP legacy), caemos al total contra PROVEEDORES / CAJA.
      if (debePorCuentaPasivo.size === 0) {
        const pasivo: { concepto?: string } = {};
        const cProvFallback = await this.resolverCuentaPasivo(orden.proveedor_id, empresaId, res, pasivo);
        debitarPasivo(cProvFallback, monto, pasivo.concepto);
        acreditar(cCajaFallback, monto, 'Caja', 'CAJA_GENERAL');
      }
      type LineaOP = { cuenta_id: string; debe_moneda: number; haber_moneda: number; descripcion: string; concepto?: string };
      const debeLineas: LineaOP[] = [];
      for (const [cuentaId, { monto: importe, concepto }] of debePorCuentaPasivo) {
        debeLineas.push({
          cuenta_id: cuentaId,
          debe_moneda: importe,
          haber_moneda: 0,
          descripcion: 'Cancelación deuda proveedor',
          concepto,
        });
      }
      const haberLineas: LineaOP[] = [];
      for (const [cuentaId, { monto: importe, descripcion, concepto }] of haberPorCuenta) {
        haberLineas.push({ cuenta_id: cuentaId, debe_moneda: 0, haber_moneda: importe, descripcion, concepto });
      }
      if (!esPYG) {
        const lineaDifCambio = await this.lineaDiferenciaCambio(res, [...debeLineas, ...haberLineas], monedaOP);
        if (lineaDifCambio) haberLineas.push(lineaDifCambio);
      }

      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: res.anotar([...debeLineas, ...haberLineas]),
        }, tx);
      });

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

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

  async integrarMovimientoTesoreria(movimientoId: string) {
    return this.conAvisoDeMapeo('tes_movimientos', movimientoId, async () => {
      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 va a IMPORTACION_PAGO_PROVEEDOR (la puente, si así lo mapeó el
      // contador); el del despachante pasa por anticipos y se aplica después contra
      // la puente (ver
      // docs/plan-importaciones-fase2-contable.md).
      //
      // Sin respaldo (spec 2026-10-13): si el concepto no está mapeado, el
      // movimiento queda pendiente con aviso; antes se caía a la categoría.
      // MONEDA: la de la cuenta de tesorería.
      const res = await this.resolvedor(empresaId, movimiento.fecha, dimensionMoneda(movimiento.cuenta?.moneda));
      // La cuenta de tesorería no sale de un concepto: su línea va como FICHA.
      res.marcar(cuentaContableId, 'FICHA', 'FICHA_CUENTA_TESORERIA');
      let cuentaImportacionId: string | null = null;
      let conceptoImportacion: string | undefined;
      if (movimiento.embarque_id && movimiento.tipo_pago_importacion) {
        conceptoImportacion =
          movimiento.tipo_pago_importacion === 'DESPACHO'
            ? 'ANTICIPO_DESPACHO'
            : 'IMPORTACION_PAGO_PROVEEDOR';
        cuentaImportacionId = await res.cuenta(conceptoImportacion);
      }

      // 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. det.concepto_contable (lo guarda RRHH): obligatorio, sin respaldo — si no
      //    está mapeado el movimiento queda pendiente con aviso, nunca va a la categoría
      // 4. tes_categoria_cuenta_contable (mapeo por empresa — para categorías del sistema)
      // 5. tes_categorias.cuenta_contable_id (para categorías propias de la empresa)
      const detalles = movimiento.detalles ?? [];

      // Conceptos de una vez, juntando los faltantes para un solo aviso.
      const conceptos = cuentaImportacionId
        ? []
        : [
            ...new Set(
              detalles
                .filter((d) => !d.cuenta_contable_id)
                .map((d) => d.concepto_contable)
                .filter((c): c is string => !!c),
            ),
          ];
      const cuentaPorConcepto = new Map<string, string>();
      const faltas: FaltaMapeoError[] = [];
      for (const c of conceptos) {
        try {
          cuentaPorConcepto.set(c, await res.cuenta(c));
        } catch (e) {
          if (e instanceof FaltaMapeoError) faltas.push(e);
          else throw e;
        }
      }
      // Uno solo: se relanza tal cual, con su motivo y su regla (el aviso la nombra).
      if (faltas.length === 1) throw faltas[0];
      if (faltas.length) throw new FaltaMapeoError(empresaId, faltas.flatMap((f) => f.conceptos));

      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 ??
          (det.concepto_contable ? cuentaPorConcepto.get(det.concepto_contable) : undefined) ??
          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) {
        // Traza de la contrapartida: el concepto de la importación o del detalle; la
        // categoría de tesorería y la cuenta ya escrita en el detalle son FICHA.
        const delConcepto = !cuentaImportacionId && !det.cuenta_contable_id && det.concepto_contable
          ? cuentaPorConcepto.get(det.concepto_contable)
          : undefined;
        const deCategoria = mapeoIndex[det.categoria_id] ?? det.categoria?.cuenta_contable_id;
        const ccat = cuentaImportacionId ?? det.cuenta_contable_id ?? delConcepto ?? deCategoria;
        const concepto = cuentaImportacionId ? conceptoImportacion : delConcepto ? det.concepto_contable : undefined;
        if (ccat && !cuentaImportacionId && !delConcepto) {
          // De qué ficha sale: el detalle, la cuenta que la empresa le dio a la
          // categoría (o la de su categoría propia) o la global de una categoría
          // del sistema que la empresa no mapeó.
          const origenFicha: OrigenFicha = det.cuenta_contable_id
            ? 'FICHA_DETALLE_MOVIMIENTO'
            : mapeoIndex[det.categoria_id] || det.categoria?.empresa_id === empresaId
              ? 'FICHA_CATEGORIA_TESORERIA'
              : 'FICHA_CATEGORIA_TESORERIA_GLOBAL';
          res.marcar(ccat, 'FICHA', origenFicha);
        }
        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', concepto },
        );
        lineas.push(
          esIngreso
            ? { cuenta_id: ccat, debe_moneda: 0, haber_moneda: monto, descripcion: det.descripcion ?? det.categoria?.nombre, concepto }
            : { 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: (movimiento.referencia || `MOV ${fmtFecha(movimiento.fecha)}`).slice(0, 50),
            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: res.anotar(lineas),
        }, tx);
      });

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

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

  async integrarTransferenciaTes(movOrigenId: string, movDestinoId: string, transferenciaId: string, empresaId: string) {
    return this.conAvisoDeMapeo('tes_transferencias', transferenciaId, async () => {
      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);
      }

      // MONEDA: la moneda extranjera de la transferencia (si las dos cuentas son de
      // la misma moneda, esa). Las cuentas de tesorería van como FICHA.
      const res = await this.resolvedor(empresaId, movOrigen.fecha, dimensionMoneda(monedaExtranjera));
      res.marcar(cuentaOrigenContableId, 'FICHA', 'FICHA_CUENTA_TESORERIA');
      res.marcar(cuentaDestinoContableId, 'FICHA', 'FICHA_CUENTA_TESORERIA');

      // 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
          // Del mapeo, no por código fijo: los códigos del plan modelo (4.2.1.02 /
          // 6.3.1.03) no existen en un plan propio, o quedaron desactivados tras
          // migrarlo, y el asiento se grababa igual contra una cuenta muerta.
          // Sin respaldo (spec 2026-10-13): antes, sin el concepto mapeado, se
          // omitía la diferencia y se armaba un asiento de 2 patas.
          const conceptoDif = diferencia > 0 ? 'DIFERENCIA_CAMBIO_GANADA' : 'DIFERENCIA_CAMBIO_PERDIDA';
          const cuentaDifCambio = await res.cuenta(conceptoDif);
          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, debe_moneda: 0, haber_moneda: diferencia, descripcion: `Diferencia de cambio ganada (${monedaExtranjera})`, concepto: conceptoDif });
          } 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, debe_moneda: perdida, haber_moneda: 0, descripcion: `Diferencia de cambio perdida (${monedaExtranjera})`, concepto: conceptoDif });
          }
        }
      } 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: (movOrigen.referencia || `TRANSF ${fmtFecha(movOrigen.fecha)}`).slice(0, 50),
            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: res.anotar(lineas),
        }, tx);
      });

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

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


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

  async integrarPagoBancard(pagoBancardId: string) {
    return this.conAvisoDeMapeo('pago_bancard', pagoBancardId, async () => {
      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);
      // Sin CLIENTES el asiento queda pendiente con aviso (antes se omitía en silencio).
      const res = await this.resolvedor(empresaId, fecha, dimensionMoneda(pago.moneda));
      const cClientes = await res.cuenta('CLIENTES');
      // La cuenta por liquidar sale de la configuración de Bancard (el medio de cobro), no de un concepto.
      const cBancardPorLiquidar = res.marcar(bcConfig.cuenta_contable_por_liquidar, 'MEDIO_PAGO');

      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: res.anotar([
            { cuenta_id: cBancardPorLiquidar, debe_moneda: monto, haber_moneda: 0, descripcion: 'Bancard por liquidar' },
            { cuenta_id: cClientes, debe_moneda: 0, haber_moneda: monto, descripcion: 'Cliente', concepto: 'CLIENTES' },
          ]),
        }, 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 que se exigen solo si su línea tiene monto (sin mapeo → FaltaMapeoError,
  // spec 2026-10-13): APORTE_PATRONAL_IPS, APORTE_ADMIN_IPS, IPS_A_DEPOSITAR,
  // DESCUENTOS_VARIOS_RRHH.
  async integrarLiquidacionRrhh(liquidacionId: string): Promise<void> {
    return this.conAvisoDeMapeo('rrhh_liquidacion', liquidacionId, async () => {
      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: se cargan todas y los faltantes se juntan para avisar de
      // una vez (spec 2026-10-13). Solo faltan si la línea hace falta.
      // La liquidación no tiene moneda propia: se liquida en guaraníes.
      const res = await this.resolvedor(empresaId, fecha, dimensionMoneda('PYG'));
      const tryMapeo = async (concepto: string): Promise<string | null> => {
        try {
          return await res.cuenta(concepto);
        } catch (e) {
          if (e instanceof FaltaMapeoError) return null;
          throw e;
        }
      };
      const cSueldos = await tryMapeo('SUELDOS_JORNALES');
      const cSueldosPagar = await tryMapeo('SUELDOS_A_PAGAR');
      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 totalIps = this.round(totalObrero + totalPatronal + totalAdmin);

      // Sin respaldo (spec 2026-10-13): antes una línea opcional sin mapeo se
      // omitía y el asiento descuadrado se descartaba con un log.
      const faltan: string[] = [];
      if (!cSueldos) faltan.push('SUELDOS_JORNALES');
      if (!cSueldosPagar) faltan.push('SUELDOS_A_PAGAR');
      if (this.round(totalPatronal) > 0 && !cAportePatronal) faltan.push('APORTE_PATRONAL_IPS');
      if (this.round(totalAdmin) > 0 && !cAporteAdmin) faltan.push('APORTE_ADMIN_IPS');
      if (totalIps > 0 && !cIpsDepositar) faltan.push('IPS_A_DEPOSITAR');
      if (descuentosVarios > 0 && !cDescuentosRrhh) faltan.push('DESCUENTOS_VARIOS_RRHH');
      if (faltan.length) throw new FaltaMapeoError(empresaId, faltan);

      const lineas: Array<{
        cuenta_id: string;
        debe_moneda: number;
        haber_moneda: number;
        descripcion: string;
        centro_costo_id?: string | null;
        concepto: string;
      }> = [];

      // 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,
            concepto: 'SUELDOS_JORNALES',
          });
        }
        if (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,
            concepto: 'APORTE_PATRONAL_IPS',
          });
        }
        if (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,
            concepto: 'APORTE_ADMIN_IPS',
          });
        }
      }

      // HABER: pasivos
      if (this.round(totalNeto) > 0) {
        lineas.push({
          cuenta_id: cSueldosPagar!,
          debe_moneda: 0,
          haber_moneda: this.round(totalNeto),
          descripcion: 'Sueldos a pagar',
          concepto: 'SUELDOS_A_PAGAR',
        });
      }
      if (totalIps > 0) {
        lineas.push({
          cuenta_id: cIpsDepositar!,
          debe_moneda: 0,
          haber_moneda: totalIps,
          descripcion: 'IPS a depositar (obrero + patronal + admin)',
          concepto: 'IPS_A_DEPOSITAR',
        });
      }
      if (descuentosVarios > 0) {
        lineas.push({
          cuenta_id: cDescuentosRrhh!,
          debe_moneda: 0,
          haber_moneda: descuentosVarios,
          descripcion: 'Descuentos a empleados (anticipos, cuotas, ausencias)',
          concepto: 'DESCUENTOS_VARIOS_RRHH',
        });
      }

      // 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}). ` +
            'Revisá los totales del resumen de la liquidación.',
        );
        // 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: res.anotar(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> {
    return this.conAvisoDeMapeo('recibos_multi', reciboId, async () => {
      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, sucursal_id: true, fecha_emision: true, moneda: { select: { codigo: 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',
        // Sin usuario el asiento va con autor null: '' no es un UUID y Prisma lo rechaza.
        created_by:   reciboRow.usuario_id ?? null,
      };

      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 (con las reglas del recibo: sucursal, moneda y tipo de cliente)
      const res = await this.resolvedor(empresaId, fecha, await dimensionesCliente(this.prisma, empresaId, reciboRow));
      const [cClientes, cIntereses, cRetIVA, cRetRenta, cAnticipos, cCaja, cChequesCartera, cChequesDiferidos, cProveedores, cConsumoPuente, cSueldosFiniquito] = await Promise.all([
        res.cuenta('CLIENTES'),
        res.cuenta('INTERESES_MORATORIOS_COBRADOS').catch(soloFaltaMapeo),
        res.cuenta('RETENCION_IVA_FAVOR').catch(soloFaltaMapeo),
        res.cuenta('RETENCION_RENTA_FAVOR').catch(soloFaltaMapeo),
        res.cuenta('ANTICIPOS_CLIENTES').catch(soloFaltaMapeo),
        res.cuenta('CAJA_GENERAL'),
        res.cuenta('CHEQUES_EN_CARTERA').catch(soloFaltaMapeo),
        res.cuenta('CHEQUES_EN_TRANSITO').catch(soloFaltaMapeo),
        res.cuenta('PROVEEDORES').catch(soloFaltaMapeo),
        res.cuenta('CONSUMO_FUNCIONARIO_PUENTE').catch(soloFaltaMapeo),
        res.cuenta('SUELDOS_JORNALES').catch(soloFaltaMapeo),
      ]);

      // De dónde salen las cuentas de los medios, en este orden:
      //   1. La cuenta de tesorería elegida en el recibo. Es lo más específico que
      //      se sabe: dice a qué banco entró la plata y en qué moneda. Si la
      //      transferencia ya está acreditada en el banco, imputarla a una cuenta
      //      puente la dejaba creciendo sin cerrarse nunca (no hay depósito que la
      //      cancele), y una transferencia en USD caía en el banco en guaraníes.
      //   2. La cuenta que la empresa configuró para el medio (al cobrar).
      //   3. Caja general.
      // El cheque va aparte, por sus propios conceptos.
      const cuentaPorCodigoMedio = await this.mediosPago.resolverPorCodigoMedio(empresaId, 'COBRO');
      // Configurado a una cuenta que no sirve: sin asiento y con aviso (no cae a Caja).
      const cuentaConfiguradaDelMedio = (nombre: string, monto: number): string | null =>
        cuentaDelMedio(cuentaPorCodigoMedio, CODIGO_MEDIO_POR_NOMBRE[nombre], 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]));

      // Moneda extranjera: el asiento se arma directamente en guaraníes. Lo cobrado
      // vale a la cotización del recibo (o la propia del medio), pero la deuda del
      // cliente se cancela a la cotización con la que se facturó: la diferencia es
      // la diferencia de cambio. Antes se mandaba en USD y se convertía todo a la
      // cotización cargada en Contabilidad, así que la factura cobrada entera
      // dejaba saldo en Deudores y el recibo ni se asentaba si no había cotización.
      const esPYG = (recibo.moneda ?? 'PYG') === 'PYG';
      let tcRecibo = 1;
      if (!esPYG) {
        tcRecibo = Number(reciboRow.cotizacion ?? 0);
        if (!(tcRecibo > 0)) {
          tcRecibo = await this.tipoCambio.getTasa(empresaId, recibo.moneda, fecha);
        }
      }
      const aPYG = (monto: number, tc = tcRecibo) => this.round(monto * (esPYG ? 1 : tc), 2);

      // Agrupar medios de pago por cuenta (separando cheques)
      // La traza de cada línea por cuenta: el primer concepto que se sumó a esa cuenta;
      // sin concepto, la marca FICHA (cuenta de tesorería) o MEDIO_PAGO.
      const mediosCuentaMap = new Map<string, number>();
      const conceptoDeMedio = new Map<string, string>();
      const sumar = (cuentaId: string, monto: number, concepto?: string) => {
        mediosCuentaMap.set(cuentaId, (mediosCuentaMap.get(cuentaId) ?? 0) + monto);
        if (concepto && !conceptoDeMedio.has(cuentaId)) conceptoDeMedio.set(cuentaId, concepto);
      };
      let totalChequesCartera = 0;
      let totalChequesDiferidos = 0;
      // Conceptos que el recibo necesita y no están mapeados: se juntan todos y se
      // avisa de una vez (spec 2026-10-13). Antes la línea se omitía o caía a Caja.
      const faltan: string[] = [];
      // Texto de cada medio usado con cuenta que no sirve (va al detalle de MEDIO_PAGO_COBRO).
      const mediosRotos: string[] = [];
      const necesito = (cuenta: string | null, monto: number, concepto: string) => {
        if (monto > 0 && !cuenta) faltan.push(concepto);
      };
      for (const mp of recibo.medios_pago) {
        if (mp.medio === 'SALDO_FAVOR') continue; // Se maneja como anticipo
        const montoMedio = aPYG(Number(mp.monto), Number(mp.tipo_cambio ?? 0) || tcRecibo);

        // Consumo de funcionario cobrado por planilla: no entra plata, se
        // compensa contra el sueldo. Debita la cuenta puente, que el asiento de
        // la liquidación ya acreditó — las dos puntas se cancelan y lo que queda
        // es la baja de Deudores por Ventas.
        //
        // Sin el concepto mapeado no se arma el asiento: mejor que falte y quede
        // registrado el motivo, a imputarlo a caja y descuadrar el banco.
        if (mp.medio === 'DESCUENTO_PLANILLA') {
          if (cConsumoPuente) sumar(cConsumoPuente, montoMedio, 'CONSUMO_FUNCIONARIO_PUENTE');
          else necesito(null, montoMedio, 'CONSUMO_FUNCIONARIO_PUENTE');
          continue;
        }

        // Consumo de funcionario descontado del finiquito. El pago del finiquito
        // sale de tesorería por lo efectivamente pagado y netea los descuentos
        // contra los gastos, así que la puente no recibe nada. Este recibo
        // devuelve al gasto lo neteado (SUELDOS_JORNALES, la misma cuenta del
        // salario pendiente del finiquito) contra Deudores por Ventas.
        if (mp.medio === 'DESCUENTO_FINIQUITO') {
          if (cSueldosFiniquito) sumar(cSueldosFiniquito, montoMedio, 'SUELDOS_JORNALES');
          else necesito(null, montoMedio, 'SUELDOS_JORNALES');
          continue;
        }

        // Cheque: al día o diferido según el vencimiento guardado en el propio
        // medio. Antes se buscaba por el movimiento de tesorería del cheque, pero
        // un cheque recibido ya no genera movimiento (entra a la cartera y mueve
        // el banco recién al depositarse), así que todos caían como "al día".
        if (mp.medio === 'CHEQUE') {
          const venc = mp.cheque_fecha_vencimiento ? new Date(mp.cheque_fecha_vencimiento) : null;
          if (venc && venc > hoy) totalChequesDiferidos += montoMedio;
          else totalChequesCartera += montoMedio;
          continue;
        }

        const cuentaTes = mp.cuenta_tesoreria_id ? tesToContable.get(mp.cuenta_tesoreria_id) ?? null : null;
        let delMedio: string | null = null;
        try {
          delMedio = cuentaTes ? null : cuentaConfiguradaDelMedio(mp.medio, montoMedio);
        } catch (e) {
          // Medio configurado a una cuenta que no sirve: se junta con las demás faltas.
          if (!(e instanceof MedioInutilizableError)) throw e;
          faltan.push(...e.conceptos);
          mediosRotos.push(e.texto);
          continue;
        }
        if (cuentaTes) res.marcar(cuentaTes, 'FICHA', 'FICHA_CUENTA_TESORERIA');
        else if (delMedio) res.marcar(delMedio, 'MEDIO_PAGO');
        sumar(cuentaTes ?? delMedio ?? cCaja, montoMedio, cuentaTes || delMedio ? undefined : 'CAJA_GENERAL');
      }

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

      const totalRetIVA   = aPYG(recibo.retenciones.filter((r) => r.tipo === 'IVA').reduce((s, r) => s + Number(r.monto), 0));
      const totalRetRenta = aPYG(recibo.retenciones.filter((r) => r.tipo === 'RENTA').reduce((s, r) => s + Number(r.monto), 0));
      const totalNc       = recibo.nc_aplicadas.reduce((s, n) => s + Number(n.monto), 0);
      const totalIntereses = aPYG(Number(recibo.total_intereses ?? 0));

      // Lo que se cancela de la deuda del cliente. La NC aplicada no cuenta, en
      // ningún modo: su propio asiento ya acreditó Clientes al emitirse. Antes la
      // NC FLEXIBLE se volvía a acreditar acá contra una "cuenta puente" que nada
      // cancelaba, y Deudores quedaba bajado dos veces. Los intereses tampoco: no
      // son deuda registrada en Clientes, van directo a su cuenta de ingreso.
      //
      // Los recibos que arma RRHH por consumo de funcionarios no llenaban
      // `total_facturas`: el asiento salía con haber 0 y no se grababa nunca. Si
      // falta, vale lo cobrado (en esos recibos no hay NC ni retenciones).
      const totalFacturasDoc = Number(recibo.total_facturas ?? 0) || Number(reciboRow.monto_total ?? 0);
      let totalClientes: number;
      if (esPYG) {
        totalClientes = this.round(totalFacturasDoc - totalNc, 2);
      } else {
        // Cada factura se cancela a la cotización con que se emitió.
        const facturaIds = recibo.facturas.map((f) => f.factura_cab_id).filter((id): id is string => !!id);
        const facturasTc = facturaIds.length
          ? await this.prisma.factura_cab.findMany({ where: { id: { in: facturaIds } }, select: { id: true, dticam: true } })
          : [];
        const tcFactura = new Map(facturasTc.map((f) => [f.id, Number(f.dticam ?? 0) || tcRecibo]));
        const imputadoDetalle = recibo.facturas.reduce((s, f) => s + Number(f.monto_pagado ?? 0), 0);
        totalClientes = this.round(
          recibo.facturas.reduce(
            (s, f) => s + (Number(f.monto_pagado ?? 0) - Number(f.monto_nc ?? 0)) * (tcFactura.get(f.factura_cab_id as string) ?? tcRecibo),
            0,
          ) +
            // Lo que la cabecera tenga de más que el detalle (no debería pasar) va a la del recibo.
            Math.max(0, totalFacturasDoc - imputadoDetalle) * tcRecibo,
          2,
        );
      }

      const totalFacturasCompra = aPYG(Number(reciboRow.total_facturas_compra ?? 0));
      const diferencia = aPYG(Number(recibo.diferencia ?? 0));
      const saldoFavorGenerado = recibo.diferencia_tipo === 'SALDO_FAVOR' && diferencia > 0 ? diferencia : 0;

      // Gastos descontados del cobro — cancelan la CxP del gasto (ya devengada).
      // Agrupa por cuenta pasivo para soportar proveedores con cuentas distintas.
      // Prioridad: cuenta congelada en CxP → la del proveedor del gasto → PROVEEDORES
      // (gasto sin proveedor). Sin respaldo de la del proveedor a PROVEEDORES.
      // La cuenta congelada en la CxP es un dato guardado, no sale del mapeo: FICHA.
      // Cada entrada recuerda con qué resolvedor salió su concepto (el del gasto), para la traza.
      const gastosCuentaMap = new Map<string, { monto: number; concepto?: string; resolvedor: ResolvedorMapeo }>();
      for (const g of (reciboRow.gastos_asociados ?? [])) {
        const monto = aPYG(Number(g.monto_aplicado));
        if (monto <= 0) continue;
        let cuentaId: string | null = (g as any).cuenta_pagar?.cuenta_pasivo_id ?? null;
        if (cuentaId) res.marcar(cuentaId, 'FICHA', 'FICHA_CXP_CONGELADA');
        const pasivo: { concepto?: string } = {};
        let resPasivo = res;
        const gasto = (g as any).gasto;
        if (!cuentaId && gasto?.proveedor_id) {
          try {
            // El pasivo es el que acreditó el gasto: se resuelve con el contexto del
            // gasto (sucursal, moneda, tipo de proveedor y su fecha), no del recibo.
            const resGasto = await this.resolvedor(
              empresaId,
              gasto.fecha_emision ?? fecha,
              await dimensionesProveedor(this.prisma, empresaId, gasto),
            );
            cuentaId = await this.resolverCuentaPasivo(gasto.proveedor_id, empresaId, resGasto, pasivo);
            resPasivo = resGasto;
          } catch (e) {
            if (!(e instanceof FaltaMapeoError)) throw e;
            faltan.push(...e.conceptos);
            continue;
          }
        }
        if (!cuentaId && cProveedores) {
          cuentaId = cProveedores;
          pasivo.concepto = 'PROVEEDORES';
        }
        if (!cuentaId) {
          faltan.push('PROVEEDORES');
          continue;
        }
        const actual = gastosCuentaMap.get(cuentaId);
        gastosCuentaMap.set(
          cuentaId,
          actual
            ? { ...actual, monto: actual.monto + monto }
            : { monto, concepto: pasivo.concepto, resolvedor: resPasivo },
        );
      }

      necesito(cChequesCartera, totalChequesCartera, 'CHEQUES_EN_CARTERA');
      necesito(cChequesDiferidos, totalChequesDiferidos, 'CHEQUES_EN_TRANSITO');
      necesito(cRetIVA, totalRetIVA, 'RETENCION_IVA_FAVOR');
      necesito(cRetRenta, totalRetRenta, 'RETENCION_RENTA_FAVOR');
      necesito(cAnticipos, totalSaldoFavor + saldoFavorGenerado, 'ANTICIPOS_CLIENTES');
      necesito(cProveedores, totalFacturasCompra, 'PROVEEDORES');
      necesito(cIntereses, totalIntereses, 'INTERESES_MORATORIOS_COBRADOS');
      if (faltan.length) {
        const conceptos = [...new Set(faltan)];
        if (!mediosRotos.length) throw new FaltaMapeoError(empresaId, conceptos);
        const detalle = detalleMediosInutilizables(mediosRotos);
        // Solo medios rotos: hay mapeo, hay que reasignarlo. Mezclado con conceptos
        // sin mapear, el motivo general es SIN_MAPEO y el detalle va por concepto.
        const soloMedios = conceptos.every((c) => c === 'MEDIO_PAGO_COBRO');
        throw new FaltaMapeoError(empresaId, conceptos, detalle, soloMedios ? 'CUENTA_INUTILIZABLE' : 'SIN_MAPEO', undefined, undefined, {
          MEDIO_PAGO_COBRO: detalle,
        });
      }

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

      // 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', concepto: conceptoDeMedio.get(cuentaId) });
      }

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

      // DEBE: Cheques diferidos → CHEQUES_EN_TRANSITO (diferidos)
      if (totalChequesDiferidos > 0) {
        lineas.push({ cuenta_id: cChequesDiferidos!, debe_moneda: totalChequesDiferidos, haber_moneda: 0, descripcion: 'Cheques diferidos a cobrar', concepto: 'CHEQUES_EN_TRANSITO' });
      }

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

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

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

      // DEBE: Gastos descontados del cobro (cuentas resueltas arriba).
      for (const [cuentaId, { monto, concepto, resolvedor }] of gastosCuentaMap) {
        lineas.push({ cuenta_id: cuentaId, debe_moneda: monto, haber_moneda: 0, descripcion: 'Gasto descontado del cobro', concepto, resolvedor });
      }

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

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

      // HABER: Generación de nuevo anticipo (diferencia positiva → saldo a favor)
      if (saldoFavorGenerado > 0) {
        lineas.push({ cuenta_id: cAnticipos!, debe_moneda: 0, haber_moneda: saldoFavorGenerado, descripcion: 'Saldo a favor generado', concepto: 'ANTICIPOS_CLIENTES' });
      }

      // Diferencia de cambio: lo que separa lo cobrado (a la cotización del recibo)
      // de la deuda cancelada (a la de cada factura).
      if (!esPYG) {
        const lineaDifCambio = await this.lineaDiferenciaCambio(res, lineas, recibo.moneda);
        if (lineaDifCambio) lineas.push(lineaDifCambio);
      }

      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}`,
          // Las líneas ya están en guaraníes (ver la conversión de arriba).
          moneda_origen: 'PYG',
          // Cada línea con la traza de su resolvedor (el del gasto en los gastos descontados).
          lineas: lineas.map(({ resolvedor, ...l }) => (resolvedor ?? res).anotar([l])[0]),
        }, 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  PAGO_COMISIONES_FONDOS                → 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> {
    return this.conAvisoDeMapeo('liquidacion_comisiones_deveng', liquidacionId, async () => {
      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;
      }

      // Las comisiones se liquidan en guaraníes.
      const res = await this.resolvedor(empresaId, fecha, dimensionMoneda('PYG'));
      const [cGasto, cPagar, cIvaCredito] = await Promise.all([
        res.cuenta('GASTO_COMISIONES_VENTAS'),
        res.cuenta('COMISIONES_A_PAGAR'),
        // Con IVA, la cuenta de IVA crédito es obligatoria (spec 2026-10-13): antes,
        // sin mapear, se asentaba el total como gasto.
        desglose.conIva
          ? res.cuenta('IVA_CREDITO_10')
          : Promise.resolve(null),
      ]);

      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: `COM ${fmtFecha(liq.periodo_desde)}-${fmtFecha(liq.periodo_hasta)} ${nombre}`.trim().slice(0, 50),
            estado: 'CONFIRMADO',
          },
        });

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

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

  async integrarPagoComisiones(liquidacionId: string, empresaId: string, fechaPago?: Date): Promise<void> {
    return this.conAvisoDeMapeo('liquidacion_comisiones_pago', liquidacionId, async () => {
      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. Obligatorio (spec 2026-10-13): antes, sin mapear, salía de caja.
      const res = await this.resolvedor(empresaId, fecha, dimensionMoneda('PYG'));
      const [cPagar, cFondos] = await Promise.all([
        res.cuenta('COMISIONES_A_PAGAR'),
        res.cuenta('PAGO_COMISIONES_FONDOS'),
      ]);

      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: `COM ${fmtFecha(liq.periodo_desde)}-${fmtFecha(liq.periodo_hasta)} ${nombre}`.trim().slice(0, 50),
            estado: 'CONFIRMADO',
          },
        });

        await this.asientos.crearConfirmado(empresaId, null, doc.id, {
          periodo_id: periodoId,
          fecha: fecha.toISOString().substring(0, 10),
          glosa,
          moneda_origen: 'PYG',
          lineas: res.anotar([
            { cuenta_id: cPagar, debe_moneda: montoPagado, haber_moneda: 0, descripcion: 'Cancelación comisiones a pagar', concepto: 'COMISIONES_A_PAGAR' },
            { cuenta_id: cFondos, debe_moneda: 0, haber_moneda: montoPagado, descripcion: 'Salida de fondos', concepto: 'PAGO_COMISIONES_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> {
    return this.conAvisoDeMapeo('comision_supervisor', comisionId, async () => {
      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;
      }

      // Sin respaldo a CAJA_GENERAL (spec 2026-10-13).
      const res = await this.resolvedor(empresaId, fecha, dimensionMoneda('PYG'));
      const [cGasto, cFondos] = await Promise.all([
        res.cuenta('GASTO_COMISIONES_VENTAS'),
        res.cuenta('PAGO_COMISIONES_FONDOS'),
      ]);

      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: `COM SUP ${fmtFecha(com.periodo_desde)}-${fmtFecha(com.periodo_hasta)} ${nombre}`.trim().slice(0, 50),
            estado: 'CONFIRMADO',
          },
        });

        await this.asientos.crearConfirmado(empresaId, null, doc.id, {
          periodo_id: periodoId,
          fecha: fecha.toISOString().substring(0, 10),
          glosa,
          moneda_origen: 'PYG',
          lineas: res.anotar([
            { cuenta_id: cGasto, debe_moneda: total, haber_moneda: 0, descripcion: 'Gasto comisión supervisor', concepto: 'GASTO_COMISIONES_VENTAS' },
            { cuenta_id: cFondos, debe_moneda: 0, haber_moneda: total, descripcion: 'Salida de fondos', concepto: 'PAGO_COMISIONES_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;
  }

  /**
   * Línea que cierra un asiento en moneda extranjera por diferencia de cambio.
   *
   * Lo que entra o sale de caja/banco se valúa a la cotización del día del
   * documento; la deuda que se cancela, a la que tenía cuando se registró. La
   * diferencia es resultado: si el debe supera al haber, se ganó (haber
   * DIFERENCIA_CAMBIO_GANADA); si no, se perdió (debe DIFERENCIA_CAMBIO_PERDIDA).
   * Devuelve null si el asiento ya cuadra.
   */
  private async lineaDiferenciaCambio(
    res: ResolvedorMapeo,
    lineas: Array<{ debe_moneda: number; haber_moneda: number }>,
    moneda: string,
  ): Promise<{ cuenta_id: string; debe_moneda: number; haber_moneda: number; descripcion: string; concepto: string } | null> {
    const debe = lineas.reduce((s, l) => s + Number(l.debe_moneda), 0);
    const haber = lineas.reduce((s, l) => s + Number(l.haber_moneda), 0);
    const diferencia = this.round(debe - haber, 2);
    if (Math.abs(diferencia) < 0.01) return null;
    if (diferencia > 0) {
      const concepto = 'DIFERENCIA_CAMBIO_GANADA';
      const cuenta = await res.cuenta(concepto);
      return { cuenta_id: cuenta, debe_moneda: 0, haber_moneda: diferencia, descripcion: `Diferencia de cambio ganada (${moneda})`, concepto };
    }
    const concepto = 'DIFERENCIA_CAMBIO_PERDIDA';
    const cuenta = await res.cuenta(concepto);
    return { cuenta_id: cuenta, debe_moneda: -diferencia, haber_moneda: 0, descripcion: `Diferencia de cambio perdida (${moneda})`, concepto };
  }

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