import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { LotesService } from 'src/lotes/lotes.service';
import { PagosProveedorService } from 'src/pagos-proveedor/pagos-proveedor.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { ImportacionesCosteoService } from 'src/importaciones/services/importaciones-costeo.service';
import { whereProveedorPorRubro } from 'src/common/utils/rubros.util';
import { rubrosFiltroUsuario } from 'src/utils/alcance-rubro';
import { alcanceSucursalUsuario, exigirAccesoElevado, filtroSucursalOperativo } from 'src/utils/alcance-sucursal';
import { CreateCompraDto, CompraCuotaDto, CompraItemDto } from './dto/create-compra.dto';
import { UpdateCompraDto } from './dto/update-compra.dto';
import * as Marangatu from 'src/common/marangatu/registro-comprobantes.util';
import { UpsertConfigComprasMonedaDto } from './dto/config-compras-moneda.dto';

type Tx = Prisma.TransactionClient;

/**
 * Qué comprobantes trae el Libro IVA Compras.
 *
 * - `preimpresos` (default): lo que hay que declarar a mano en Marangatu.
 * - `electronicos`: lo que la DNIT ya tiene por el documento del proveedor. Sirve
 *   para cruzar contra Marangatu lo cargado en el sistema, que es para lo que lo
 *   pidió contabilidad: sin esta opción el listado "no preimpresos" mezclaba los
 *   dos tipos y el cruce había que hacerlo a ojo.
 * - `todos`: el período completo, para auditar.
 */
export type TipoComprobanteLibro = 'preimpresos' | 'electronicos' | 'todos';

/**
 * Resuelve el filtro desde el query string. `solo_preimpresos` es el parámetro
 * viejo (booleano) y se mantiene por compatibilidad con enlaces guardados:
 * ahí `false` seguía significando "todos", no "solo electrónicos".
 */
export function tipoComprobanteLibro(
  tipo?: string,
  soloPreimpresos?: string,
): TipoComprobanteLibro {
  const t = (tipo || '').trim().toLowerCase();
  if (t === 'preimpresos' || t === 'electronicos' || t === 'todos') return t;
  return soloPreimpresos === 'false' ? 'todos' : 'preimpresos';
}

type ComprasModoRedondeo = 'HALF_UP' | 'HALF_EVEN' | 'DOWN' | 'UP';

type RedondeoConfig = {
  decimalesMonto: number;
  decimalesPorcentaje: number;
  modo: ComprasModoRedondeo;
};

const DEFAULT_REDONDEO_CONFIG: RedondeoConfig = {
  decimalesMonto: 2,
  decimalesPorcentaje: 4,
  modo: 'HALF_UP',
};

type NormalizedItem = {
  orden_compra_det_id: string | null;
  requisicion_det_id: string | null;
  producto_id: string | null;
  codigo_producto: string | null;
  descripcion: string;
  cantidad: number;
  precio_unitario: number;
  porcentaje_iva: number;
  afecta_stock: boolean;
  numero_lote: string | null;
  fecha_fabricacion: Date | null;
  fecha_vencimiento: Date | null;
  requiere_lote: boolean;
  // Descuento por ítem (ya calculado y aplicado a subtotal)
  descuento_tipo: 'porcentaje' | 'monto';
  descuento_pct: number;
  descuento_monto: number;
  subtotal_bruto: number; // cantidad * precio_unitario
  subtotal: number; // subtotal_bruto - descuento_monto (post ítem)
  monto_iva: number;
  costo_unitario_neto: number; // subtotal / cantidad (unit cost after item discount)
};

type DescuentoGlobal = {
  tipo: 'por_item' | 'global_pct' | 'global_monto';
  global_pct: number;
  global_monto: number; // monto efectivamente aplicado (sale del cálculo)
};

/**
 * Aviso (no bloqueante) de que una compra subió el costo de un producto que ya
 * tiene precio de venta cargado, dejando ese precio calculado sobre el costo
 * viejo. No se recalcula automáticamente: se informa para que alguien con
 * `INV_RGP_REGLA_EDITAR` decida. Solo se emite si la empresa tiene contratado el
 * addon de Reglas de Precio (`INV_REGLAS_PRECIO`); sin addon no hay motor de
 * precios que recalcular y el aviso sería ruido.
 */
export type AlertaCostoCompra = {
  producto_id: string;
  descripcion: string;
  costo_anterior: number;
  costo_nuevo: number;
  precio_venta_actual: number;
  vende_bajo_costo: boolean;
  origen_precio: string;
};

type Totals = {
  total_exento: number;
  total_gravado_5: number;
  total_gravado_10: number;
  total_iva_5: number;
  total_iva_10: number;
  total_iva: number;
  subtotal: number;
  total: number;
  total_descuento: number;
  descuento_global_monto_aplicado: number;
  subtotalesPorIva: Array<{ tipo_iva: number; base_gravada: number; monto_iva: number }>;
};

type AutoOrderPagoInput = {
  medio_pago_id: string;
  moneda_id?: string | null;
  monto: number;
  monto_moneda?: number | null;
  referencia?: string | null;
  cotizacion?: number | null;
};

export type CuotaPersist = {
  numero_cuota: number;
  fecha_vencimiento: Date;
  monto: number;
  observaciones?: string | null;
};

type NumeracionOrdenPago = {
  numeracionId: string;
  numeroOrden: string;
};

type ModoFlujoCompra = 'directo' | 'requisicion_opcional' | 'requisicion_obligatoria';

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly lotesService: LotesService,
    private readonly contabilidadIntegracion: ContabilidadIntegracionService,
    private readonly pagosProveedor: PagosProveedorService,
    private readonly costeo: ImportacionesCosteoService,
  ) {}

  async create(dto: CreateCompraDto, empresaId: string, userId?: string) {
    await this.validateCreateOrUpdatePayload(dto, true, empresaId);
    await this.assertComprobanteNoDuplicado(empresaId, dto.cabecera);

    let createMeta: {
      compraId: string;
      ordenCompraId: string | null;
      numeroFactura: string | null;
      ordenPagoAutomaticaId: string | null;
      alertasCosto: AlertaCostoCompra[];
    };
    try {
      createMeta = await this.prisma.$transaction(async (tx) => {
      const cabecera = dto.cabecera;
      const refs = await this.validateCabeceraRefs(
        tx,
        empresaId,
        cabecera.proveedor_id,
        cabecera.deposito_id,
        cabecera.moneda_id,
      );
      const sucursalOperacionId = cabecera.sucursal_id || refs.depositoSucursalId;
      if (!sucursalOperacionId) {
        throw new BadRequestException(
          'La compra requiere sucursal para generar numeración de orden de pago (sucursal_id o sucursal del depósito)',
        );
      }

      const [lotesEnabled, configSucursal, configCompras] = await Promise.all([
        this.lotesService.isEnabledForEmpresa(empresaId, sucursalOperacionId, tx),
        this.lotesService.getEmpresaConfig(empresaId, sucursalOperacionId, tx),
        this.getOrCreateConfigCompras(tx, empresaId),
      ]);
      const generarCxpAutomatico = Boolean(configCompras.generar_cxp_automatico ?? true);
      const creditoRequiereCuotas = Boolean(configSucursal.compras_credito_requiere_cuotas);
      // Flag: si está activo, una compra de CONTADO no se auto-paga; su cuenta por
      // pagar queda pendiente para pagarse vía orden de pago (evita doble registro).
      const incluirContadoEnOP = Boolean(configCompras.incluir_contado_en_orden_pago);

      const condicion = await this.resolveCondicionOperacion(tx, cabecera.condicion_operacion_id);
      const isCredito = condicion?.codigo === 2;
      const plazoCreditoDias = isCredito ? this.resolvePlazoCreditoDias(cabecera.plazo_credito) : 0;

      const redondeoConfig = await this.resolveRedondeoConfig(tx, empresaId, cabecera.moneda_id);
      const normalizedItems = this.normalizeItems(dto.items, redondeoConfig);
      await this.validateItems(tx, empresaId, normalizedItems, lotesEnabled);
      const flowResolution = await this.resolveFlujoCompra({
        tx,
        empresaId,
        configCompras,
        requisicionId: cabecera.requisicion_id || null,
        ordenCompraId: cabecera.orden_compra_id || null,
        items: normalizedItems,
      });
      await this.validateMatchingOrdenCompra(
        tx,
        empresaId,
        null,
        cabecera.proveedor_id,
        cabecera.orden_compra_id || null,
        normalizedItems,
        {
          ordenCompraHabilitada: Boolean(configCompras.habilitar_orden_compra ?? true),
          matching3ViasHabilitado: Boolean(configCompras.habilitar_matching_3_vias ?? true),
          recepcionRequeridaParaFacturar:
            Boolean(configCompras.habilitar_recepcion_compra ?? true) &&
            !Boolean(configCompras.afectar_stock_en_compra_directa ?? true),
        },
      );
      await this.ensureLotesForItems(tx, empresaId, normalizedItems, lotesEnabled);
      const descuentoGlobal = this.resolveDescuentoGlobal(
        normalizedItems,
        {
          descuento_tipo: cabecera.descuento_tipo,
          descuento_global_pct: cabecera.descuento_global_pct,
          descuento_global_monto: cabecera.descuento_global_monto,
        },
        redondeoConfig,
      );
      this.aplicarDescuentoGlobalEnItems(normalizedItems, descuentoGlobal, redondeoConfig);
      const totals = this.calculateTotals(normalizedItems, descuentoGlobal, redondeoConfig);

      const pagosNorm = this.normalizePagos(dto.pagos || [], redondeoConfig);
      const pagosTotal = pagosNorm.reduce((acc, p) => acc + p.monto, 0);
      this.validateReglasPagoCompra({ isCredito, pagosTotal, totalCompra: totals.total, pagosCount: pagosNorm.length, incluirContadoEnOP });

      const fechaEmision = cabecera.fecha_emision ? new Date(cabecera.fecha_emision) : new Date();
      const cuotasPersist = this.buildCuotasPersist({
        isCredito,
        creditoRequiereCuotas,
        cuotas: dto.cuotas || [],
        totalCompra: totals.total,
        fechaEmision,
        plazoCreditoDias,
        redondeoConfig,
      });
      const estadoInicial = !isCredito && pagosNorm.length > 0 ? 'pagada' : 'pendiente';
      const sucursalOrdenId = sucursalOperacionId;

      const compraCreateData = {
        empresa_id: empresaId,
        sucursal_id: cabecera.sucursal_id || null,
        proveedor_id: cabecera.proveedor_id,
        deposito_id: cabecera.deposito_id,
        orden_compra_id: cabecera.orden_compra_id || null,
        requisicion_id: cabecera.requisicion_id || null,
        usuario_id: userId || null,
        moneda_id: cabecera.moneda_id || null,
        cotizacion: this.round(cabecera.cotizacion ?? 1, 6),
        condicion_operacion_id: cabecera.condicion_operacion_id || null,
        timbrado_proveedor: cabecera.timbrado_proveedor || null,
        establecimiento: cabecera.establecimiento || null,
        punto_expedicion: cabecera.punto_expedicion || null,
        numero_factura: cabecera.numero_factura || null,
        fecha_emision: fechaEmision,
        fecha_vencimiento_timbrado: cabecera.fecha_vencimiento_timbrado
          ? new Date(cabecera.fecha_vencimiento_timbrado)
          : null,
        total_exento: totals.total_exento,
        total_gravado_5: totals.total_gravado_5,
        total_gravado_10: totals.total_gravado_10,
        total_iva_5: totals.total_iva_5,
        total_iva_10: totals.total_iva_10,
        total_iva: totals.total_iva,
        subtotal: totals.subtotal,
        total: totals.total,
        descuento_tipo: descuentoGlobal.tipo,
        descuento_global_pct: descuentoGlobal.global_pct,
        descuento_global_monto: descuentoGlobal.global_monto,
        total_descuento: totals.total_descuento,
        plazo_credito: isCredito ? plazoCreditoDias : 0,
        observaciones: cabecera.observaciones || null,
        // Por defecto preimpreso: es lo que se carga a mano por esta pantalla. El import
        // de Marangatu, que trae documentos electrónicos, lo setea en false.
        es_preimpreso: cabecera.es_preimpreso ?? true,
        // Factura de mercadería del exterior: ver crearComponenteImportacion.
        embarque_id: cabecera.embarque_id ?? null,
        estado: estadoInicial,
        anulado: false,
        activo: true,
      };

      const compra = await tx.compra_cab.create({
        data: compraCreateData,
      });
      const numeroComprobanteCompra = this.buildNumeroComprobanteCompra({
        establecimiento: cabecera.establecimiento,
        puntoExpedicion: cabecera.punto_expedicion,
        numeroFactura: cabecera.numero_factura,
      });

      const detallesPersistidos = await this.persistDetalles(tx, compra.id, normalizedItems);
      await this.persistSubtotales(tx, compra.id, totals.subtotalesPorIva);

      if (cabecera.embarque_id) {
        await this.crearComponenteImportacion(tx, {
          compraId: compra.id,
          empresaId,
          embarqueId: cabecera.embarque_id,
          conceptoCostoId: cabecera.concepto_costo_id,
          convertirComponenteId: cabecera.convertir_componente_id,
          proveedorId: cabecera.proveedor_id ?? null,
          monedaId: cabecera.moneda_id ?? null,
          numeroFactura: numeroComprobanteCompra,
          // Una factura del exterior no lleva IVA, así que el neto es el total.
          // Se usa el subtotal igual que en gastos: si alguien la cargó con IVA
          // por error, el costo sigue siendo el neto y no se infla el embarque.
          importe: Number(totals.subtotal ?? 0),
          cotizacionFactura: Number(cabecera.cotizacion ?? 1),
          fechaDevengo: cabecera.fecha_emision ? new Date(cabecera.fecha_emision) : new Date(),
        });
      }
      if (!isCredito) await this.persistPagos(tx, compra.id, dto.pagos || []);
      if (generarCxpAutomatico) {
        await this.persistCuentasPagar(tx, {
          empresaId,
          compraId: compra.id,
          proveedorId: cabecera.proveedor_id,
          monedaId: cabecera.moneda_id || null,
          fechaEmision,
          cuotas: cuotasPersist,
        });
      }

      const tipoMovCompraId = await this.resolveTipoMovimientoEntradaId(tx);
      let alertasCosto: AlertaCostoCompra[] = [];
      // La mercadería importada entra al stock al LIBERAR el embarque, con el
      // costo ya prorrateado (FOB + flete + seguro + tributos). Si además entrara
      // acá, se duplicarían las unidades y quedarían valorizadas solo al FOB.
      if (flowResolution.afectarStockEnCompra && !cabecera.embarque_id) {
        alertasCosto = await this.applyInventarioYCostos(tx, {
          empresaId,
          compraId: compra.id,
          depositoId: cabecera.deposito_id,
          items: normalizedItems,
          detalles: detallesPersistidos,
          lotesEnabled,
          tipoMovimientoId: tipoMovCompraId,
          usuarioId: userId,
          documentoOrigen: 'factura_compra',
          numeroComprobante: numeroComprobanteCompra,
        });
      }

      let ordenPagoAutomaticaId: string | null = null;
      if (generarCxpAutomatico) {
        const cuentasCompra = await tx.cuentas_pagar.findMany({
          where: { compra_id: compra.id, empresa_id: empresaId, activo: true },
          orderBy: [{ numero_cuota: 'asc' }, { created_at: 'asc' }],
          select: {
            id: true,
            moneda_id: true,
            saldo_pendiente: true,
          },
        });

        const autoOrderDetalles: AutoOrderPagoInput[] =
          !isCredito && pagosNorm.length > 0 && cuentasCompra.length > 0
            ? pagosNorm.map((pago) => ({
                ...pago,
                monto_moneda: pago.monto_moneda ?? pago.monto,
              }))
            : [];

        // Contado con flag ON: no auto-pagar; la CxP queda pendiente para pagarse
        // vía orden de pago manual. Crédito y contado sin flag: comportamiento normal.
        if (!(incluirContadoEnOP && !isCredito)) {
          const ordenAuto = await this.createOrdenPagoAutomatica(tx, {
          empresaId,
          compraId: compra.id,
          sucursalId: sucursalOrdenId,
          proveedorId: cabecera.proveedor_id,
          monedaId: cabecera.moneda_id || null,
          sesionCajaId: null,
          fechaPago: fechaEmision,
          referencia: cabecera.numero_factura || null,
          observaciones: cabecera.observaciones || null,
          userId,
          cuentasCompra,
          detallesPago: autoOrderDetalles,
        });
          ordenPagoAutomaticaId = ordenAuto?.id ?? null;
        }
      }
      await this.aplicarEjecucionRequisicion(tx, {
        empresaId,
        requisicionId: cabecera.requisicion_id || null,
        items: normalizedItems,
        fechaOperacion: fechaEmision,
      });
      await this.recalculateOrdenCompraEstado(tx, cabecera.orden_compra_id || null);

      return {
        compraId: compra.id,
        ordenCompraId: cabecera.orden_compra_id || null,
        numeroFactura: numeroComprobanteCompra || null,
        ordenPagoAutomaticaId,
        alertasCosto,
      };
      });
    } catch (error) {
      this.rethrowIfCompraDuplicada(error, dto.cabecera);
      throw error;
    }

    // Hook contable — fire and forget
    this.contabilidadIntegracion.integrarFacturaCompra(createMeta.compraId).catch((err) => {
      this.logger.error(`Error contabilizando compra ${createMeta.compraId}: ${err.message}`);
    });

    // Hook Tesorería — la orden automática (contado sin diferir a OP) ya quedó
    // creada; syncTesoreriaOrdenPago no hace nada si no llegó a PAGADO/APLICADO.
    if (createMeta.ordenPagoAutomaticaId) {
      this.pagosProveedor
        .syncTesoreriaOrdenPago(createMeta.ordenPagoAutomaticaId, empresaId, userId)
        .catch((err) => {
          this.logger.error(`Error registrando Tesorería para compra ${createMeta.compraId}: ${err.message}`);
        });
    }

    if (createMeta.ordenCompraId) {
      await this.logOrdenCompraHistorial({
        empresaId,
        userId: userId || null,
        ordenCompraId: createMeta.ordenCompraId,
        action: 'FACTURA_CREATE',
        descripcion: createMeta.numeroFactura
          ? `Factura de compra ${createMeta.numeroFactura} registrada desde OC`
          : 'Factura de compra registrada desde OC',
        newValue: { compra_id: createMeta.compraId, numero_factura: createMeta.numeroFactura },
      });
    }

    const compraCreada = await this.findOne(createMeta.compraId, empresaId);
    // Avisos de costo al alza (solo con addon de Reglas de Precio): el frontend los
    // muestra tras registrar la compra, con acceso a recalcular si el usuario tiene
    // `INV_RGP_REGLA_EDITAR`. No se recalcula nada acá a propósito.
    return { ...compraCreada, alertas_costo: createMeta.alertasCosto ?? [] };
  }

  async findAll(
    empresaId: string,
    query?: {
      page?: string;
      limit?: string;
      fecha_desde?: string;
      fecha_hasta?: string;
      proveedor_id?: string;
      sucursal_id?: string;
      deposito_id?: string;
      producto_id?: string;
      search?: string;
      estado?: string;
      /** Quién consulta. Define qué sucursales puede ver. */
      usuarioId?: string;
    },
  ) {
    const take = Math.max(1, Math.min(100, Number(query?.limit) || 20));
    const currentPage = Math.max(1, Number(query?.page) || 1);
    const skip = (currentPage - 1) * take;

    const where: Prisma.compra_cabWhereInput = {
      empresa_id: empresaId,
      activo: true,
      // importado y rechazado son estados exclusivos del flujo Marangatu — no aparecen en Facturas
      estado: { notIn: ['importado', 'rechazado'] },
    };

    if (query?.proveedor_id) where.proveedor_id = query.proveedor_id;

    // El alcance del usuario manda sobre la sucursal pedida por filtro: si pide
    // una que no tiene asignada, el listado sale vacío en vez de mostrarla.
    const alcance = await alcanceSucursalUsuario(this.prisma, query?.usuarioId, empresaId);
    const filtroSucursal = filtroSucursalOperativo(alcance, query?.sucursal_id);
    if (filtroSucursal !== undefined) where.sucursal_id = filtroSucursal;
    if (query?.deposito_id) where.deposito_id = query.deposito_id;
    if (query?.estado) where.estado = query.estado;
    if (query?.producto_id) {
      where.compra_det = {
        some: {
          producto_id: query.producto_id,
        },
      };
    }

    if (query?.fecha_desde || query?.fecha_hasta) {
      where.fecha_emision = {};
      if (query?.fecha_desde) {
        const desde = new Date(query.fecha_desde);
        if (/^\d{4}-\d{2}-\d{2}$/.test(query.fecha_desde)) {
          desde.setHours(0, 0, 0, 0);
        }
        where.fecha_emision.gte = desde;
      }
      if (query?.fecha_hasta) {
        const hasta = new Date(query.fecha_hasta);
        if (/^\d{4}-\d{2}-\d{2}$/.test(query.fecha_hasta)) {
          hasta.setHours(23, 59, 59, 999);
        }
        where.fecha_emision.lte = hasta;
      }
    }
    if (query?.search?.trim()) {
      const busqueda = query.search.trim();
      where.OR = [
        { numero_factura: { contains: busqueda, mode: 'insensitive' } },
        { timbrado_proveedor: { contains: busqueda, mode: 'insensitive' } },
        { proveedores: { personas: { razon_social: { contains: busqueda, mode: 'insensitive' } } } },
        { proveedores: { personas: { ruc: { contains: busqueda, mode: 'insensitive' } } } },
        { compra_det: { some: { descripcion: { contains: busqueda, mode: 'insensitive' } } } },
      ];
    }

    const includeBase: Prisma.compra_cabInclude = {
      proveedores: {
        include: {
          personas: {
            select: { razon_social: true, ruc: true, dv: true },
          },
        },
      },
      empresas_sucursales: {
        select: {
          id: true,
          descripcion: true,
          punto_establecimiento: true,
        },
      },
      condicion_operacion: {
        select: {
          id: true,
          codigo: true,
          descripcion: true,
        },
      },
      moneda: { select: { id: true, codigo: true, descripcion: true, simbolo: true } },
      compra_det: {
        select: {
          id: true,
          orden_compra_det_id: true,
          producto_id: true,
          descripcion: true,
          cantidad: true,
          cantidad_acreditada_nc: true,
        },
      },
    };

    const includeWithOrden: Prisma.compra_cabInclude = {
      ...includeBase,
      orden_pago_proveedor_cab: {
        where: { origen: 'compra_auto' },
        orderBy: { created_at: 'desc' },
        take: 1,
        select: {
          id: true,
          numero_orden_pago: true,
          estado: true,
          anulado: true,
          origen: true,
        },
      },
    };

    let items: any[] = [];
    let total = 0;
    try {
      [items, total] = await this.prisma.$transaction([
        this.prisma.compra_cab.findMany({
          where,
          include: includeWithOrden,
          orderBy: [{ fecha_emision: 'desc' }, { created_at: 'desc' }],
          skip,
          take,
        }),
        this.prisma.compra_cab.count({ where }),
      ]);
    } catch (error) {
      if (!this.isMissingTableError(error)) throw error;

      [items, total] = await this.prisma.$transaction([
        this.prisma.compra_cab.findMany({
          where,
          include: includeBase,
          orderBy: [{ fecha_emision: 'desc' }, { created_at: 'desc' }],
          skip,
          take,
        }),
        this.prisma.compra_cab.count({ where }),
      ]);

      items = items.map((item) => ({ ...item, orden_pago_proveedor_cab: [] }));
    }

    // Agregados sobre TODO el filtro (no la página) para los KPIs de la pantalla:
    // conteo por estado y suma de monto por moneda. Usa el mismo `where`.
    const [estadoGroups, montoGroups] = await Promise.all([
      this.prisma.compra_cab.groupBy({ by: ['estado'], where, _count: { _all: true } }),
      this.prisma.compra_cab.groupBy({ by: ['moneda_id'], where, _sum: { total: true } }),
    ]);

    const stats = { total, pendiente: 0, parcial: 0, pagada: 0, anulada: 0 };
    for (const g of estadoGroups) {
      const e = String(g.estado ?? '').toLowerCase();
      if (e === 'pendiente' || e === 'parcial' || e === 'pagada' || e === 'anulada') {
        stats[e] = g._count._all;
      }
    }

    const monedaIds = montoGroups
      .map((g) => g.moneda_id)
      .filter((id): id is string => Boolean(id));
    const monedas = monedaIds.length
      ? await this.prisma.moneda.findMany({
          where: { id: { in: monedaIds } },
          select: { id: true, codigo: true },
        })
      : [];
    const codigoPorMonedaId = new Map(monedas.map((m) => [m.id, m.codigo]));
    const montosPorMoneda: Record<string, number> = {};
    for (const g of montoGroups) {
      const codigo = (g.moneda_id && codigoPorMonedaId.get(g.moneda_id)) || 'PYG';
      montosPorMoneda[codigo] = (montosPorMoneda[codigo] || 0) + this.toNumber(g._sum.total);
    }

    return {
      items,
      total,
      page: currentPage,
      limit: take,
      lastPage: Math.max(1, Math.ceil(total / take)),
      stats,
      montosPorMoneda,
    };
  }

  async getDashboard(
    empresaId: string,
    query?: {
      fecha_desde?: string;
      fecha_hasta?: string;
      fecha_desde_anterior?: string;
      fecha_hasta_anterior?: string;
      moneda_id?: string;
      proveedor_id?: string;
      sucursal_id?: string;
      deposito_id?: string;
      producto_id?: string;
      usuarioId?: string;
    },
  ) {
    // Mismo alcance que el listado (findAll): la sucursal pedida sólo vale si
    // el usuario la tiene asignada; sin pedir ninguna, las suyas.
    const alcance = await alcanceSucursalUsuario(this.prisma, query?.usuarioId, empresaId);
    const filtroSucursal = filtroSucursalOperativo(alcance, query?.sucursal_id);

    const whereActual = this.buildDashboardWhere(
      empresaId,
      query?.fecha_desde,
      query?.fecha_hasta,
      query?.moneda_id,
      query?.proveedor_id,
      undefined,
      query?.deposito_id,
      query?.producto_id,
    );
    const whereAnterior = this.buildDashboardWhere(
      empresaId,
      query?.fecha_desde_anterior,
      query?.fecha_hasta_anterior,
      query?.moneda_id,
      query?.proveedor_id,
      undefined,
      query?.deposito_id,
      query?.producto_id,
    );
    if (filtroSucursal !== undefined) {
      whereActual.sucursal_id = filtroSucursal;
      whereAnterior.sucursal_id = filtroSucursal;
    }

    const [actualRows, anteriorRows, cantidadActual, cantidadAnterior] = await this.prisma.$transaction([
      this.prisma.compra_cab.findMany({
        where: whereActual,
        select: {
          total: true,
          cotizacion: true,
          moneda: {
            select: {
              codigo: true,
            },
          },
        },
      }),
      this.prisma.compra_cab.findMany({
        where: whereAnterior,
        select: {
          total: true,
          cotizacion: true,
          moneda: {
            select: {
              codigo: true,
            },
          },
        },
      }),
      this.prisma.compra_cab.count({ where: whereActual }),
      this.prisma.compra_cab.count({ where: whereAnterior }),
    ]);

    const convertToGs = !query?.moneda_id;
    const totalActual = this.sumComprasRows(actualRows, convertToGs);
    const totalAnterior = this.sumComprasRows(anteriorRows, convertToGs);

    const variacion = totalAnterior > 0 ? ((totalActual - totalAnterior) / totalAnterior) * 100 : 0;

    // Notas de crédito de compra del período (restan del total efectivo comprado).
    // Se acotan por fecha + proveedor + moneda; los filtros de sucursal/depósito/producto
    // son propios de la factura y no se trasladan a la NC.
    let totalNcCompras = 0;
    let cantidadNc = 0;
    try {
      const ncRows = await this.prisma.nota_credito_compra_cab.findMany({
        where: {
          empresa_id: empresaId,
          estado: { not: 'anulada' },
          ...(query?.fecha_desde || query?.fecha_hasta
            ? {
                fecha_emision: {
                  ...(query?.fecha_desde ? { gte: new Date(query.fecha_desde) } : {}),
                  ...(query?.fecha_hasta ? { lte: new Date(`${query.fecha_hasta}T23:59:59.999`) } : {}),
                },
              }
            : {}),
          ...(query?.proveedor_id ? { proveedor_id: query.proveedor_id } : {}),
          ...(query?.moneda_id ? { moneda_id: query.moneda_id } : {}),
        },
        select: { total: true, cotizacion: true, moneda: { select: { codigo: true } } },
      });
      cantidadNc = ncRows.length;
      totalNcCompras = this.sumComprasRows(ncRows as any, convertToGs);
    } catch (error) {
      // Tabla ausente (migración desfasada): se omite sin romper el dashboard.
      if (!this.isMissingTableError(error)) {
        this.logger.warn(
          `Dashboard compras: no se pudo calcular NC: ${error instanceof Error ? error.message : 'error'}`,
        );
      }
    }

    const totalNeto = totalActual - totalNcCompras;

    return {
      total_compras: this.round(totalActual, 2),
      total_compras_anterior: this.round(totalAnterior, 2),
      total_nc_compras: this.round(totalNcCompras, 2),
      cantidad_nc_compras: cantidadNc,
      total_neto: this.round(totalNeto, 2),
      cantidad_compras: cantidadActual,
      cantidad_compras_anterior: cantidadAnterior,
      variacion_pct: this.round(variacion, 2),
      moneda_id: query?.moneda_id || null,
      convertido_a_pyg: convertToGs,
    };
  }

  async findOne(id: string, empresaId: string, tx?: Tx) {
    const db = tx ?? this.prisma;
    const compraIncludeBase: Prisma.compra_cabInclude = {
      proveedores: {
        include: {
          personas: {
            select: {
              razon_social: true,
              ruc: true,
              dv: true,
              telefono: true,
              email: true,
            },
          },
        },
      },
      moneda: true,
      condicion_operacion: true,
      deposito: true,
      compra_det: {
        include: {
          productos: { select: { id: true, descripcion: true, cod_producto: true, maneja_lote: true } },
        },
        orderBy: { created_at: 'asc' },
      },
      compra_subtotales: true,
      compra_forma_pagos: {
        include: { medio_pago: true, moneda: true },
      },
      cuentas_pagar: {
        where: { activo: true },
        orderBy: [{ numero_cuota: 'asc' }, { fecha_vencimiento: 'asc' }],
      },
      orden_compra_cab: {
        select: {
          id: true,
          numero_orden: true,
          estado: true,
          fecha_emision: true,
          fecha_entrega: true,
          total: true,
          activo: true,
          recepcion_compra_cab: {
            orderBy: [{ fecha_recepcion: 'desc' }, { created_at: 'desc' }],
            select: {
              id: true,
              fecha_recepcion: true,
              estado: true,
              anulado: true,
              activo: true,
              deposito_id: true,
              observaciones: true,
            },
          },
        },
      },
    };

    const compraInclude: Prisma.compra_cabInclude = {
      ...compraIncludeBase,
      compra_det: {
        include: {
          productos: { select: { id: true, descripcion: true, cod_producto: true, maneja_lote: true } },
          lotes_producto: {
            select: {
              id: true,
              numero_lote: true,
              fecha_fabricacion: true,
              fecha_vencimiento: true,
              cantidad_inicial: true,
              cantidad_disponible: true,
            },
          },
        },
        orderBy: { created_at: 'asc' },
      },
      orden_pago_proveedor_cab: {
        orderBy: { created_at: 'desc' },
        select: {
          id: true,
          numero_orden_pago: true,
          estado: true,
          anulado: true,
          activo: true,
          fecha_pago: true,
          monto_total: true,
          created_at: true,
          origen: true,
        },
      },
      nota_credito_compra_cab: {
        orderBy: { fecha_emision: 'desc' },
        select: {
          id: true,
          establecimiento: true,
          punto_expedicion: true,
          numero: true,
          fecha_emision: true,
          total: true,
          estado: true,
          afecta_stock: true,
        },
      },
    };

    const where: Prisma.compra_cabWhereInput = {
      id,
      empresa_id: empresaId,
      activo: true,
    };

    let compra: any;
    try {
      compra = await db.compra_cab.findFirst({
        where,
        include: compraInclude,
      });
    } catch (error) {
      if (!this.isMissingTableError(error)) throw error;

      compra = await db.compra_cab.findFirst({
        where,
        include: compraIncludeBase,
      });

      if (compra) {
        compra.orden_pago_proveedor_cab = [];
        compra.nota_credito_compra_cab = [];
        compra.compra_det = (compra.compra_det || []).map((det: any) => ({
          ...det,
          lotes_producto: [],
        }));
      }
    }

    if (!compra) throw new NotFoundException('Compra no encontrada');

    // Una OP automática se vincula directamente por compra_id. Las OP manuales
    // se relacionan a través de sus detalles y las cuentas por pagar de la compra.
    // Unificamos ambas rutas para que el detalle muestre el circuito financiero completo.
    try {
      compra.orden_pago_proveedor_cab = await db.orden_pago_proveedor_cab.findMany({
        where: {
          empresa_id: empresaId,
          OR: [
            { compra_id: id },
            {
              orden_pago_proveedor_det: {
                some: { cuentas_pagar: { compra_id: id } },
              },
            },
          ],
        },
        orderBy: { created_at: 'desc' },
        select: {
          id: true,
          numero_orden_pago: true,
          estado: true,
          anulado: true,
          activo: true,
          fecha_pago: true,
          monto_total: true,
          created_at: true,
          origen: true,
        },
      });
    } catch (error) {
      if (!this.isMissingTableError(error)) throw error;
      compra.orden_pago_proveedor_cab = compra.orden_pago_proveedor_cab || [];
    }

    return compra;
  }

  async update(id: string, dto: UpdateCompraDto, empresaId: string, userId?: string) {
    await this.validateCreateOrUpdatePayload(dto, false, empresaId);
    if (dto.cabecera) await this.assertComprobanteNoDuplicado(empresaId, dto.cabecera, { excluirCompraId: id });

    let updateMeta: { compraId: string; ordenPagoAutomaticaId: string | null };
    try {
      updateMeta = await this.prisma.$transaction(async (tx) => {
      const existing = await tx.compra_cab.findFirst({
        where: { id, empresa_id: empresaId, activo: true },
        include: { compra_det: true },
      });

      if (!existing) throw new NotFoundException('Compra no encontrada');
      if (existing.anulado) throw new BadRequestException('No se puede editar una compra anulada');
      await this.autoCancelBorradorOrdenesForCompra(
        tx,
        empresaId,
        existing.id,
        'Anulado automáticamente por edición de la compra vinculada',
      );
      await this.assertNoActiveOrdenesForCompra(tx, empresaId, existing.id, 'editar');
      await this.assertNoCxpPagadaForCompra(tx, existing.id, 'editar');
      await this.cleanupInactiveOrdenesForCompra(tx, empresaId, existing.id);

      const cabecera = dto.cabecera;
      const refs = await this.validateCabeceraRefs(
        tx,
        empresaId,
        cabecera.proveedor_id,
        cabecera.deposito_id,
        cabecera.moneda_id,
      );
      const sucursalOperacionId = cabecera.sucursal_id || refs.depositoSucursalId;
      if (!sucursalOperacionId) {
        throw new BadRequestException(
          'La compra requiere sucursal para generar numeración de orden de pago (sucursal_id o sucursal del depósito)',
        );
      }

      const [lotesEnabled, configSucursal, configCompras] = await Promise.all([
        this.lotesService.isEnabledForEmpresa(empresaId, sucursalOperacionId, tx),
        this.lotesService.getEmpresaConfig(empresaId, sucursalOperacionId, tx),
        this.getOrCreateConfigCompras(tx, empresaId),
      ]);
      const generarCxpAutomatico = Boolean(configCompras.generar_cxp_automatico ?? true);
      const creditoRequiereCuotas = Boolean(configSucursal.compras_credito_requiere_cuotas);
      // Flag: si está activo, una compra de CONTADO no se auto-paga; su cuenta por
      // pagar queda pendiente para pagarse vía orden de pago (evita doble registro).
      const incluirContadoEnOP = Boolean(configCompras.incluir_contado_en_orden_pago);

      const condicion = await this.resolveCondicionOperacion(tx, cabecera.condicion_operacion_id);
      const isCredito = condicion?.codigo === 2;
      const plazoCreditoDias = isCredito ? this.resolvePlazoCreditoDias(cabecera.plazo_credito) : 0;
      const requisicionId = cabecera.requisicion_id ?? existing.requisicion_id ?? null;

      const redondeoConfig = await this.resolveRedondeoConfig(tx, empresaId, cabecera.moneda_id);
      const normalizedItems = this.normalizeItems(dto.items || [], redondeoConfig);
      await this.validateItems(tx, empresaId, normalizedItems, lotesEnabled);
      const flowResolution = await this.resolveFlujoCompra({
        tx,
        empresaId,
        configCompras,
        requisicionId,
        ordenCompraId: cabecera.orden_compra_id || null,
        items: normalizedItems,
      });
      await this.validateMatchingOrdenCompra(
        tx,
        empresaId,
        existing.id,
        cabecera.proveedor_id,
        cabecera.orden_compra_id || null,
        normalizedItems,
        {
          ordenCompraHabilitada: Boolean(configCompras.habilitar_orden_compra ?? true),
          matching3ViasHabilitado: Boolean(configCompras.habilitar_matching_3_vias ?? true),
          recepcionRequeridaParaFacturar:
            Boolean(configCompras.habilitar_recepcion_compra ?? true) &&
            !Boolean(configCompras.afectar_stock_en_compra_directa ?? true),
        },
      );
      await this.ensureLotesForItems(tx, empresaId, normalizedItems, lotesEnabled);
      const descuentoGlobal = this.resolveDescuentoGlobal(
        normalizedItems,
        {
          descuento_tipo: cabecera.descuento_tipo,
          descuento_global_pct: cabecera.descuento_global_pct,
          descuento_global_monto: cabecera.descuento_global_monto,
        },
        redondeoConfig,
      );
      this.aplicarDescuentoGlobalEnItems(normalizedItems, descuentoGlobal, redondeoConfig);
      const totals = this.calculateTotals(normalizedItems, descuentoGlobal, redondeoConfig);
      const pagosNorm = this.normalizePagos(dto.pagos || [], redondeoConfig);
      const pagosTotal = pagosNorm.reduce((acc, p) => acc + p.monto, 0);
      this.validateReglasPagoCompra({ isCredito, pagosTotal, totalCompra: totals.total, pagosCount: pagosNorm.length, incluirContadoEnOP });
      const fechaEmision = cabecera.fecha_emision ? new Date(cabecera.fecha_emision) : new Date();
      const cuotasPersist = this.buildCuotasPersist({
        isCredito,
        creditoRequiereCuotas,
        cuotas: dto.cuotas || [],
        totalCompra: totals.total,
        fechaEmision,
        plazoCreditoDias,
        redondeoConfig,
      });
      const estadoInicial = !isCredito && pagosNorm.length > 0 ? 'pagada' : 'pendiente';
      const sucursalOrdenId = sucursalOperacionId;

      const oldItems = existing.compra_det.map((d) => ({
        id: d.id,
        producto_id: d.producto_id,
        orden_compra_det_id: d.orden_compra_det_id,
        requisicion_det_id: d.requisicion_det_id,
        cantidad: Number(d.cantidad || 0),
        precio_unitario:
          Number(d.cantidad || 0) > 0
            ? this.round(Number(d.subtotal || 0) / Number(d.cantidad || 0), 4)
            : 0,
        subtotal: Number(d.subtotal || 0),
        afecta_stock: d.afecta_stock ?? true,
      }));
      const oldProductIds = oldItems.map((i) => i.producto_id).filter((v): v is string => Boolean(v));

      const existingDepositoId = existing.deposito_id || null;
      const numeroComprobanteExistente = this.buildNumeroComprobanteCompra({
        establecimiento: existing.establecimiento,
        puntoExpedicion: existing.punto_expedicion,
        numeroFactura: existing.numero_factura,
      });

      const inventarioAplicadoEnCompra = await this.hasInventarioAplicadoEnCompra(tx, existing.id);
      if (inventarioAplicadoEnCompra) {
        const tipoMovSalidaId = await this.resolveTipoMovimientoSalidaId(tx);
        await this.revertInventario(tx, {
          compraId: existing.id,
          depositoId: existingDepositoId || cabecera.deposito_id,
          items: oldItems,
          lotesEnabled,
          tipoMovimientoId: tipoMovSalidaId,
          usuarioId: userId,
          numeroComprobante: numeroComprobanteExistente,
        });
      }

      await this.revertirEjecucionRequisicion(tx, {
        empresaId,
        requisicionId: existing.requisicion_id || null,
        items: oldItems.map((item) => ({
          requisicion_det_id: item.requisicion_det_id || null,
          cantidad: this.round(this.toNumber(item.cantidad), 4),
          subtotal: this.round(this.toNumber(item.subtotal), 2),
        })),
        fechaOperacion: existing.fecha_emision || new Date(),
      });

      await tx.compra_forma_pagos.deleteMany({ where: { compra_id: existing.id } });
      await tx.compra_subtotales.deleteMany({ where: { compra_id: existing.id } });
      await tx.cuentas_pagar.deleteMany({ where: { compra_id: existing.id } });
      await tx.compra_det.deleteMany({ where: { compra_id: existing.id } });

      const compraUpdateData = {
        sucursal_id: cabecera.sucursal_id || null,
        proveedor_id: cabecera.proveedor_id,
        deposito_id: cabecera.deposito_id,
        orden_compra_id: cabecera.orden_compra_id || null,
        requisicion_id: requisicionId,
        usuario_id: userId || existing.usuario_id,
        moneda_id: cabecera.moneda_id || null,
        cotizacion: this.round(cabecera.cotizacion ?? 1, 6),
        condicion_operacion_id: cabecera.condicion_operacion_id || null,
        timbrado_proveedor: cabecera.timbrado_proveedor || null,
        establecimiento: cabecera.establecimiento || null,
        punto_expedicion: cabecera.punto_expedicion || null,
        numero_factura: cabecera.numero_factura || null,
        fecha_emision: fechaEmision,
        fecha_vencimiento_timbrado: cabecera.fecha_vencimiento_timbrado
          ? new Date(cabecera.fecha_vencimiento_timbrado)
          : null,
        total_exento: totals.total_exento,
        total_gravado_5: totals.total_gravado_5,
        total_gravado_10: totals.total_gravado_10,
        total_iva_5: totals.total_iva_5,
        total_iva_10: totals.total_iva_10,
        total_iva: totals.total_iva,
        subtotal: totals.subtotal,
        total: totals.total,
        descuento_tipo: descuentoGlobal.tipo,
        descuento_global_pct: descuentoGlobal.global_pct,
        descuento_global_monto: descuentoGlobal.global_monto,
        total_descuento: totals.total_descuento,
        plazo_credito: isCredito ? plazoCreditoDias : 0,
        observaciones: cabecera.observaciones || null,
        estado: estadoInicial,
        updated_at: new Date(),
      };

      await tx.compra_cab.update({
        where: { id: existing.id },
        data: compraUpdateData,
      });
      const numeroComprobanteActualizado = this.buildNumeroComprobanteCompra({
        establecimiento: cabecera.establecimiento,
        puntoExpedicion: cabecera.punto_expedicion,
        numeroFactura: cabecera.numero_factura,
      });

      const detallesPersistidos = await this.persistDetalles(tx, existing.id, normalizedItems);
      await this.persistSubtotales(tx, existing.id, totals.subtotalesPorIva);
      if (!isCredito) await this.persistPagos(tx, existing.id, dto.pagos || []);
      if (generarCxpAutomatico) {
        await this.persistCuentasPagar(tx, {
          empresaId,
          compraId: existing.id,
          proveedorId: cabecera.proveedor_id,
          monedaId: cabecera.moneda_id || null,
          fechaEmision,
          cuotas: cuotasPersist,
        });
      }

      // Igual que en create(): la importación mueve stock al liberar el embarque.
      if (flowResolution.afectarStockEnCompra && !cabecera.embarque_id) {
        const tipoMovCompraId = await this.resolveTipoMovimientoEntradaId(tx);
        await this.applyInventarioYCostos(tx, {
          empresaId,
          compraId: existing.id,
          depositoId: cabecera.deposito_id,
          items: normalizedItems,
          detalles: detallesPersistidos,
          lotesEnabled,
          tipoMovimientoId: tipoMovCompraId,
          usuarioId: userId,
          documentoOrigen: 'factura_compra',
          numeroComprobante: numeroComprobanteActualizado,
        });
      }

      let ordenPagoAutomaticaId: string | null = null;
      if (generarCxpAutomatico) {
        const cuentasCompra = await tx.cuentas_pagar.findMany({
          where: { compra_id: existing.id, empresa_id: empresaId, activo: true },
          orderBy: [{ numero_cuota: 'asc' }, { created_at: 'asc' }],
          select: {
            id: true,
            moneda_id: true,
            saldo_pendiente: true,
          },
        });

        const autoOrderDetalles: AutoOrderPagoInput[] =
          !isCredito && pagosNorm.length > 0 && cuentasCompra.length > 0
            ? pagosNorm.map((pago) => ({
                ...pago,
                monto_moneda: pago.monto_moneda ?? pago.monto,
              }))
            : [];

        // Contado con flag ON: no auto-pagar; la CxP queda pendiente para pagarse
        // vía orden de pago manual. Crédito y contado sin flag: comportamiento normal.
        if (!(incluirContadoEnOP && !isCredito)) {
          const ordenAuto = await this.createOrdenPagoAutomatica(tx, {
          empresaId,
          compraId: existing.id,
          sucursalId: sucursalOrdenId,
          proveedorId: cabecera.proveedor_id,
          monedaId: cabecera.moneda_id || null,
          sesionCajaId: null,
          fechaPago: fechaEmision,
          referencia: cabecera.numero_factura || null,
          observaciones: cabecera.observaciones || null,
          userId,
          cuentasCompra,
          detallesPago: autoOrderDetalles,
        });
          ordenPagoAutomaticaId = ordenAuto?.id ?? null;
        }
      }
      await this.aplicarEjecucionRequisicion(tx, {
        empresaId,
        requisicionId,
        items: normalizedItems,
        fechaOperacion: fechaEmision,
      });
      await this.recalculateOrdenCompraEstado(tx, existing.orden_compra_id || null);
      await this.recalculateOrdenCompraEstado(tx, cabecera.orden_compra_id || null);

      const newProductIds = normalizedItems
        .map((item) => item.producto_id)
        .filter((value): value is string => Boolean(value));
      const toRecalc = Array.from(new Set([...oldProductIds, ...newProductIds]));
      await this.recalculateProductCosts(tx, empresaId, toRecalc);

      return { compraId: existing.id, ordenPagoAutomaticaId };
      });
    } catch (error) {
      this.rethrowIfCompraDuplicada(error, dto.cabecera ?? {});
      throw error;
    }

    if (updateMeta.ordenPagoAutomaticaId) {
      this.pagosProveedor
        .syncTesoreriaOrdenPago(updateMeta.ordenPagoAutomaticaId, empresaId, userId)
        .catch((err) => {
          this.logger.error(`Error registrando Tesorería para compra ${updateMeta.compraId}: ${err.message}`);
        });
    }

    return this.findOne(updateMeta.compraId, empresaId);
  }

  async anular(id: string, empresaId: string, userId?: string) {
    const result = await this.prisma.$transaction(async (tx) => {
      const compra = await tx.compra_cab.findFirst({
        where: { id, empresa_id: empresaId, activo: true },
        include: { compra_det: true },
      });

      if (!compra) throw new NotFoundException('Compra no encontrada');
      if (compra.anulado) throw new BadRequestException('La compra ya está anulada');
      // Las órdenes en BORRADOR no representan un pago real: se anulan automáticamente.
      // Las órdenes PAGADAS sí bloquean (el dinero ya salió y hay que revertirlo manualmente).
      await this.autoCancelBorradorOrdenesForCompra(tx, empresaId, compra.id);
      await this.assertNoActiveOrdenesForCompra(tx, empresaId, compra.id, 'anular');
      await this.assertNoCxpPagadaForCompra(tx, compra.id, 'anular');
      const compraDepositoId = compra.deposito_id || null;
      if (!compraDepositoId) {
        throw new BadRequestException('La compra no tiene depósito asociado para reversión de stock');
      }
      const sucursalOperacionId =
        compra.sucursal_id ||
        (
          await tx.depositos.findFirst({
            where: { id: compraDepositoId, empresa_id: empresaId, active: true },
            select: { sucursal_id: true },
          })
        )?.sucursal_id ||
        null;
      const lotesEnabled = sucursalOperacionId
        ? await this.lotesService.isEnabledForEmpresa(empresaId, sucursalOperacionId, tx)
        : false;

      const tipoMovSalidaId = await this.resolveTipoMovimientoSalidaId(tx);
      const oldItems = compra.compra_det.map((d) => ({
        id: d.id,
        producto_id: d.producto_id,
        requisicion_det_id: d.requisicion_det_id,
        cantidad: Number(d.cantidad || 0),
        precio_unitario:
          Number(d.cantidad || 0) > 0
            ? this.round(Number(d.subtotal || 0) / Number(d.cantidad || 0), 4)
            : 0,
        subtotal: Number(d.subtotal || 0),
        afecta_stock: d.afecta_stock ?? true,
      }));

      const inventarioAplicadoEnCompra = await this.hasInventarioAplicadoEnCompra(tx, compra.id);
      if (inventarioAplicadoEnCompra) {
        await this.revertInventario(tx, {
          compraId: compra.id,
          depositoId: compraDepositoId,
          items: oldItems,
          lotesEnabled,
          tipoMovimientoId: tipoMovSalidaId,
          usuarioId: userId,
          numeroComprobante: this.buildNumeroComprobanteCompra({
            establecimiento: compra.establecimiento,
            puntoExpedicion: compra.punto_expedicion,
            numeroFactura: compra.numero_factura,
          }),
        });
      }

      await this.revertirEjecucionRequisicion(tx, {
        empresaId,
        requisicionId: compra.requisicion_id || null,
        items: oldItems.map((item) => ({
          requisicion_det_id: item.requisicion_det_id || null,
          cantidad: this.round(this.toNumber(item.cantidad), 4),
          subtotal: this.round(this.toNumber(item.subtotal), 2),
        })),
        fechaOperacion: compra.fecha_emision || new Date(),
      });

      await tx.compra_cab.update({
        where: { id: compra.id },
        data: {
          anulado: true,
          estado: 'anulada',
          fecha_anulacion: new Date(),
          activo: false,
          updated_at: new Date(),
        },
      });

      await tx.cuentas_pagar.updateMany({
        where: { compra_id: compra.id, activo: true },
        data: {
          estado: 'anulada',
          activo: false,
          updated_at: new Date(),
        },
      });

      const productIds = oldItems.map((i) => i.producto_id).filter((v): v is string => Boolean(v));
      await this.recalculateProductCosts(tx, empresaId, Array.from(new Set(productIds)));
      await this.recalculateOrdenCompraEstado(tx, compra.orden_compra_id || null);

      return {
        message: 'Compra anulada exitosamente',
        id: compra.id,
        ordenCompraId: compra.orden_compra_id || null,
        numeroFactura: this.buildNumeroComprobanteCompra({
          establecimiento: compra.establecimiento,
          puntoExpedicion: compra.punto_expedicion,
          numeroFactura: compra.numero_factura,
        }),
      };
    });

    // Reversión contable — fire and forget
    if (userId) {
      this.contabilidadIntegracion.revertirDocumento('compra_cab', result.id, userId).catch((err) => {
        this.logger.error(`Error revirtiendo contabilidad compra ${result.id}: ${err.message}`);
      });
    }

    if (result.ordenCompraId) {
      await this.logOrdenCompraHistorial({
        empresaId,
        userId: userId || null,
        ordenCompraId: result.ordenCompraId,
        action: 'FACTURA_CANCEL',
        descripcion: result.numeroFactura
          ? `Factura de compra ${result.numeroFactura} anulada`
          : 'Factura de compra anulada',
        newValue: { compra_id: result.id, numero_factura: result.numeroFactura || null },
      });
    }

    return result;
  }

  async reintentarContabilidad(id: string, empresaId: string) {
    const compra = await this.prisma.compra_cab.findFirst({
      where: { id, empresa_id: empresaId },
      select: { id: true, anulado: true },
    });
    if (!compra) throw new NotFoundException('Compra no encontrada');
    if (compra.anulado) {
      throw new BadRequestException('No se puede contabilizar una compra anulada');
    }

    await this.contabilidadIntegracion.integrarFacturaCompra(id);
    return this.findOne(id, empresaId);
  }

  async searchProveedores(empresaId: string, q?: string, limit = 20, rubroId?: string | null, usuarioId?: string) {
    const take = Math.max(1, Math.min(100, Number(limit) || 20));

    // Excluir ACREEDOR_VARIO: no opera vía Compras/OC/Recepción (sólo Gastos).
    // Ver docs/plan-acreedores-proveedores-exterior.md.
    const where: Prisma.proveedoresWhereInput = {
      empresa_id: empresaId,
      activo: true,
      NOT: { tipo_entidad: 'ACREEDOR_VARIO' },
      // El filtro de rubro (validado contra los permitidos del usuario) va bajo
      // AND: `where.OR` se usa más abajo para la búsqueda por texto.
      // Ver docs/plan-rubros-multi-sucursal.md.
      ...whereProveedorPorRubro(await rubrosFiltroUsuario(this.prisma, usuarioId, empresaId, rubroId)),
    };

    if (q && q.trim().length >= 2) {
      const search = q.trim();
      where.OR = [
        { personas: { razon_social: { contains: search, mode: 'insensitive' } } },
        { personas: { ruc: { contains: search, mode: 'insensitive' } } },
      ];
    }

    const items = await this.prisma.proveedores.findMany({
      where,
      select: {
        id: true,
        activo: true,
        tipo_entidad: true,
        es_extranjero: true,
        cuenta_contable_id: true,
        pais: true,
        swift: true,
        banco_corresponsal: true,
        timbrado: true,
        fecha_vencimiento_timbrado: true,
        created_at: true,
        updated_at: true,
        personas: {
          select: {
            razon_social: true,
            ruc: true,
            dv: true,
            telefono: true,
            email: true,
          },
        },
      },
      orderBy: { created_at: 'desc' },
      take,
    });

    return {
      items,
      total: items.length,
    };
  }

  async getConfig(empresaId: string, sucursalId: string) {
    const lotesConfig = await this.lotesService.getEmpresaConfig(empresaId, sucursalId);
    return {
      maneja_lotes: Boolean(lotesConfig?.maneja_lotes),
      metodo_costeo: lotesConfig?.metodo_costeo || 'FIFO',
      compras_credito_requiere_cuotas: Boolean(lotesConfig?.compras_credito_requiere_cuotas),
      plazo_credito_default_dias: 30,
    };
  }

  async getUltimoCostoProveedor(empresaId: string, proveedorId: string, productoId: string) {
    const det = await this.prisma.compra_det.findFirst({
      where: {
        producto_id: productoId,
        compra_cab: {
          empresa_id: empresaId,
          proveedor_id: proveedorId,
          activo: true,
          anulado: false,
        },
      },
      orderBy: [{ compra_cab: { fecha_emision: 'desc' } }, { created_at: 'desc' }],
      select: {
        precio_unitario: true,
        descuento_pct: true,
        cantidad: true,
        compra_cab: {
          select: {
            fecha_emision: true,
            establecimiento: true,
            punto_expedicion: true,
            numero_factura: true,
          },
        },
      },
    });

    if (!det) return null;

    const nro = this.buildNumeroComprobanteCompra({
      establecimiento: det.compra_cab.establecimiento,
      puntoExpedicion: det.compra_cab.punto_expedicion,
      numeroFactura: det.compra_cab.numero_factura,
    });

    return {
      costo_unitario: Number(det.precio_unitario || 0),
      descuento_porcentaje: Number(det.descuento_pct || 0),
      cantidad: Number(det.cantidad || 0),
      fecha: det.compra_cab.fecha_emision,
      numero_factura: nro,
    };
  }

  private async validateCreateOrUpdatePayload(
    dto: Partial<CreateCompraDto>,
    isCreate: boolean,
    empresaId?: string,
  ) {
    if (!dto.cabecera) {
      throw new BadRequestException('cabecera es obligatoria');
    }

    if (!dto.cabecera.deposito_id) {
      throw new BadRequestException('deposito_id es obligatorio por compra');
    }

    if (isCreate && (!dto.items || dto.items.length === 0)) {
      throw new BadRequestException('Debe enviar al menos un ítem de compra');
    }

    if (!isCreate && (!dto.items || dto.items.length === 0)) {
      throw new BadRequestException('PATCH completo requiere items[]');
    }

    // FASE 3 — Proveedores del exterior: timbrado, establecimiento y punto de expedición
    // son campos del régimen fiscal paraguayo (SET) y NO aplican a facturas emitidas por
    // proveedores no residentes. Se aceptan tal cual (texto libre o vacíos) para exterior.
    let esExterior = false;
    if (empresaId && dto.cabecera.proveedor_id) {
      const prov = await this.prisma.proveedores.findFirst({
        where: { id: dto.cabecera.proveedor_id, empresa_id: empresaId },
        select: { tipo_entidad: true, es_extranjero: true },
      });
      esExterior =
        (prov as any)?.tipo_entidad === 'PROVEEDOR_EXTERIOR' || prov?.es_extranjero === true;
    }

    const timbradoProveedor = String(dto.cabecera.timbrado_proveedor || '').trim();
    const establecimiento = String(dto.cabecera.establecimiento || '').trim();
    const puntoExpedicion = String(dto.cabecera.punto_expedicion || '').trim();

    if (esExterior) {
      // Para exterior no exigimos formato paraguayo; sólo largo razonable si vino algo.
      if (timbradoProveedor && timbradoProveedor.length > 30) {
        throw new BadRequestException('timbrado_proveedor supera 30 caracteres');
      }
      if (establecimiento && establecimiento.length > 20) {
        throw new BadRequestException('establecimiento supera 20 caracteres');
      }
      if (puntoExpedicion && puntoExpedicion.length > 20) {
        throw new BadRequestException('punto_expedicion supera 20 caracteres');
      }
      return;
    }

    if (!timbradoProveedor) {
      throw new BadRequestException('timbrado_proveedor es obligatorio');
    }
    if (!/^\d{8}$/.test(timbradoProveedor)) {
      throw new BadRequestException('timbrado_proveedor debe tener 8 dígitos');
    }

    if (!establecimiento) {
      throw new BadRequestException('establecimiento es obligatorio');
    }
    if (!/^\d{3}$/.test(establecimiento)) {
      throw new BadRequestException('establecimiento debe tener 3 dígitos');
    }

    if (!puntoExpedicion) {
      throw new BadRequestException('punto_expedicion es obligatorio');
    }
    if (!/^\d{3}$/.test(puntoExpedicion)) {
      throw new BadRequestException('punto_expedicion debe tener 3 dígitos');
    }
  }

  /**
   * Impide registrar un comprobante ya cargado (misma factura dos veces).
   * Coincidencia por timbrado + establecimiento + punto + número, contra facturas
   * de compra y gastos. La unicidad compra-vs-compra también la garantiza un índice,
   * pero acá se pre-valida para dar un mensaje claro y cubrir el cruce con gastos.
   */
  private async assertComprobanteNoDuplicado(
    empresaId: string,
    cabecera: { timbrado_proveedor?: string | null; establecimiento?: string | null; punto_expedicion?: string | null; numero_factura?: string | null },
    opts: { excluirCompraId?: string } = {},
  ) {
    const numero_factura = String(cabecera.numero_factura ?? '').trim();
    if (!numero_factura) return;
    const est = String(cabecera.establecimiento ?? '').trim() || undefined;
    const punto = String(cabecera.punto_expedicion ?? '').trim() || undefined;
    const timbrado = String(cabecera.timbrado_proveedor ?? '').trim() || undefined;

    const [compraDup, gastoDup] = await Promise.all([
      this.prisma.compra_cab.findFirst({
        where: {
          empresa_id: empresaId,
          anulado: false,
          numero_factura,
          ...(est ? { establecimiento: est } : {}),
          ...(punto ? { punto_expedicion: punto } : {}),
          ...(timbrado ? { timbrado_proveedor: timbrado } : {}),
          ...(opts.excluirCompraId ? { id: { not: opts.excluirCompraId } } : {}),
        },
        select: { id: true },
      }),
      this.prisma.gasto_cab.findFirst({
        where: {
          empresa_id: empresaId,
          anulado: false,
          numero_factura,
          ...(est ? { establecimiento: est } : {}),
          ...(punto ? { punto_expedicion: punto } : {}),
          ...(timbrado ? { timbrado } : {}),
        },
        select: { id: true },
      }),
    ]);

    if (compraDup || gastoDup) {
      const numeroFmt = [est, punto, numero_factura].filter(Boolean).join('-');
      const origen = compraDup ? 'otra factura de compra' : 'un gasto';
      throw new BadRequestException(
        `Ya existe ${origen} con el comprobante ${numeroFmt}${timbrado ? ` (timbrado ${timbrado})` : ''}. No se puede registrar duplicado.`,
      );
    }
  }

  private async validateCabeceraRefs(
    tx: Tx,
    empresaId: string,
    proveedorId: string,
    depositoId: string,
    monedaId?: string,
  ): Promise<{ depositoSucursalId: string | null }> {
    const proveedor = await tx.proveedores.findFirst({
      where: { id: proveedorId, empresa_id: empresaId, activo: true },
      select: { id: true, tipo_entidad: true },
    });
    if (!proveedor) throw new NotFoundException('Proveedor no encontrado para la empresa');
    // FASE 2: acreedor vario no opera vía Compras (que involucra inventario/OC/recepción).
    // Solo puede registrarse vía Gastos.
    if ((proveedor as any).tipo_entidad === 'ACREEDOR_VARIO') {
      throw new BadRequestException(
        'Este tercero está clasificado como "Acreedor vario" y no puede usarse en compras. Registrá la operación como Gasto.',
      );
    }

    const deposito = await tx.depositos.findFirst({
      where: { id: depositoId, empresa_id: empresaId, active: true },
      select: { id: true, sucursal_id: true },
    });
    if (!deposito) throw new NotFoundException('Depósito no encontrado para la empresa');

    if (monedaId) {
      const moneda = await tx.moneda.findUnique({ where: { id: monedaId }, select: { id: true } });
      if (!moneda) throw new NotFoundException('Moneda no encontrada');
    }

    return {
      depositoSucursalId: deposito.sucursal_id || null,
    };
  }

  private async resolveCondicionOperacion(tx: Tx, condicionId?: string) {
    if (!condicionId) return null;
    const condicion = await tx.condicion_operacion.findUnique({ where: { id: condicionId } });
    if (!condicion) throw new NotFoundException('Condición de operación no encontrada');
    return condicion;
  }

  private normalizeItems(
    items: CompraItemDto[],
    cfg: RedondeoConfig = DEFAULT_REDONDEO_CONFIG,
  ): NormalizedItem[] {
    const { decimalesMonto: dM, decimalesPorcentaje: dP, modo } = cfg;
    return items.map((item) => {
      const cantidad = Number(item.cantidad || 0);
      const precio = Number(item.precio_unitario || 0);
      const porcentajeIva = Number(item.porcentaje_iva ?? 10);
      const subtotalBruto = this.roundMode(cantidad * precio, dM, modo);

      const tipoDescuentoItem: 'porcentaje' | 'monto' =
        item.descuento_tipo === 'monto' ? 'monto' : 'porcentaje';
      const pctInput = Number(item.descuento_pct ?? item.descuento_porcentaje ?? 0);
      const montoInput = Number(item.descuento_monto ?? 0);

      let descuentoPct = 0;
      let descuentoMonto = 0;
      if (tipoDescuentoItem === 'monto') {
        // Fuente de verdad = monto exacto de la factura del proveedor.
        descuentoMonto = this.roundMode(montoInput, dM, modo);
        descuentoPct =
          subtotalBruto > 0 ? this.roundMode((descuentoMonto / subtotalBruto) * 100, dP, modo) : 0;
      } else {
        // Fuente de verdad = porcentaje, con la precisión configurada por moneda.
        descuentoPct = this.roundMode(pctInput, dP, modo);
        descuentoMonto = this.roundMode((subtotalBruto * descuentoPct) / 100, dM, modo);
      }

      const subtotal = this.roundMode(subtotalBruto - descuentoMonto, dM, modo);
      const montoIva = this.computeIva(subtotal, porcentajeIva, cfg);
      const costoUnitarioNeto = cantidad > 0 ? this.round(subtotal / cantidad, 4) : 0;
      const numeroLoteRaw = (item.numero_lote || item.lote || '').trim();

      return {
        orden_compra_det_id: item.orden_compra_det_id || null,
        requisicion_det_id: item.requisicion_det_id || null,
        producto_id: item.producto_id || null,
        codigo_producto: item.codigo_producto || null,
        descripcion: item.descripcion,
        cantidad,
        precio_unitario: precio,
        porcentaje_iva: porcentajeIva,
        afecta_stock: item.afecta_stock ?? true,
        numero_lote: numeroLoteRaw || null,
        fecha_fabricacion: item.fecha_fabricacion ? new Date(item.fecha_fabricacion) : null,
        fecha_vencimiento: item.fecha_vencimiento ? new Date(item.fecha_vencimiento) : null,
        requiere_lote: false,
        descuento_tipo: tipoDescuentoItem,
        descuento_pct: descuentoPct,
        descuento_monto: descuentoMonto,
        subtotal_bruto: subtotalBruto,
        subtotal,
        monto_iva: montoIva,
        costo_unitario_neto: costoUnitarioNeto,
      };
    });
  }

  private async validateItems(tx: Tx, empresaId: string, items: NormalizedItem[], lotesEnabled: boolean) {
    const productosCache = new Map<
      string,
      {
        empresa_id: string;
        active: boolean | null;
        deleted: boolean | null;
        maneja_inventario: boolean | null;
        maneja_lote: boolean | null;
      }
    >();

    for (const item of items) {
      if (!item.descripcion?.trim()) {
        throw new BadRequestException('Cada ítem debe tener descripción');
      }
      if (item.cantidad <= 0) {
        throw new BadRequestException(`Cantidad inválida para ítem ${item.descripcion}`);
      }
      if (item.precio_unitario < 0) {
        throw new BadRequestException(`Precio inválido para ítem ${item.descripcion}`);
      }
      if (
        item.descuento_tipo === 'porcentaje' &&
        (item.descuento_pct < 0 || item.descuento_pct > 100)
      ) {
        throw new BadRequestException(
          `Descuento inválido para ítem ${item.descripcion}: debe estar entre 0% y 100%`,
        );
      }
      const subtotalBruto = item.subtotal_bruto;
      if (item.descuento_monto < 0 || item.descuento_monto > subtotalBruto) {
        throw new BadRequestException(
          `Descuento inválido para ítem ${item.descripcion}: el monto no puede superar ${subtotalBruto}`,
        );
      }

      if (item.numero_lote && item.numero_lote.length > 50) {
        throw new BadRequestException(`Ítem ${item.descripcion}: numero_lote supera el máximo de 50 caracteres`);
      }

      if (item.fecha_fabricacion && Number.isNaN(item.fecha_fabricacion.getTime())) {
        throw new BadRequestException(`Ítem ${item.descripcion}: fecha_fabricacion inválida`);
      }

      if (item.fecha_vencimiento && Number.isNaN(item.fecha_vencimiento.getTime())) {
        throw new BadRequestException(`Ítem ${item.descripcion}: fecha_vencimiento inválida`);
      }

      if (item.producto_id) {
        let producto = productosCache.get(item.producto_id);
        if (!producto) {
          producto = await tx.productos.findUnique({
            where: { id: item.producto_id },
            select: {
              empresa_id: true,
              active: true,
              deleted: true,
              maneja_inventario: true,
              maneja_lote: true,
            },
          });
          if (producto) productosCache.set(item.producto_id, producto);
        }

        if (!producto) {
          throw new NotFoundException(`Producto ${item.producto_id} no existe`);
        }

        if (producto.empresa_id !== empresaId) {
          throw new BadRequestException(`Producto ${item.producto_id} no pertenece a la empresa del usuario`);
        }

        if (producto.deleted || !producto.active) {
          throw new BadRequestException(`Producto ${item.producto_id} está inactivo`);
        }

        item.requiere_lote = Boolean(
          lotesEnabled && item.afecta_stock && producto.maneja_inventario && producto.maneja_lote,
        );
      } else {
        item.requiere_lote = false;
      }

      if (!item.requiere_lote) {
        item.numero_lote = null;
        item.fecha_fabricacion = null;
        item.fecha_vencimiento = null;
      }
    }
  }

  private async ensureLotesForItems(tx: Tx, empresaId: string, items: NormalizedItem[], lotesEnabled: boolean) {
    if (!lotesEnabled) return;

    const loteItems = items.filter((item) => item.requiere_lote);
    if (loteItems.length === 0) return;

    const manualNumbers = loteItems
      .map((item) => String(item.numero_lote || '').trim())
      .filter((numero) => numero.length > 0);

    const duplicadosEnPayload = manualNumbers.find((numero, idx) => manualNumbers.indexOf(numero) !== idx);
    if (duplicadosEnPayload) {
      throw new BadRequestException(`Número de lote duplicado en la compra: ${duplicadosEnPayload}`);
    }

    if (manualNumbers.length > 0) {
      const existentes = await tx.lotes_producto.findMany({
        where: {
          empresa_id: empresaId,
          numero_lote: { in: manualNumbers },
        },
        select: { numero_lote: true },
        take: manualNumbers.length,
      });

      if (existentes.length > 0) {
        throw new BadRequestException(`El número de lote ${existentes[0].numero_lote} ya existe en la empresa`);
      }
    }

    const usedNumbers = new Set(manualNumbers);
    const sequenceCache = new Map<string, number>();
    const today = this.toDateOnly(new Date());

    for (const item of loteItems) {
      if (!item.fecha_fabricacion) {
        item.fecha_fabricacion = new Date(today);
      }
      // `fecha_vencimiento` queda NULL si no se informa: el lote se usa sobre todo
      // como capa de costo (comprar el mismo producto a distinto precio), y el
      // vencimiento es metadato opcional. Antes se forzaba a hoy, con lo cual todo
      // lote sin vencimiento nacía VENCIDO y ensuciaba las alertas y los reportes
      // de vencimiento de productos que ni siquiera vencen.
      if (!item.numero_lote) {
        item.numero_lote = await this.generateNumeroLoteDiario(
          tx,
          empresaId,
          today,
          sequenceCache,
          usedNumbers,
        );
      }
      if (item.numero_lote) usedNumbers.add(item.numero_lote);
    }
  }

  private async validateMatchingOrdenCompra(
    tx: Tx,
    empresaId: string,
    compraId: string | null,
    proveedorId: string,
    ordenCompraId: string | null,
    items: NormalizedItem[],
    options?: {
      ordenCompraHabilitada?: boolean;
      matching3ViasHabilitado?: boolean;
      recepcionRequeridaParaFacturar?: boolean;
    },
  ) {
    const ordenCompraHabilitada = options?.ordenCompraHabilitada ?? true;
    const matching3ViasHabilitado = options?.matching3ViasHabilitado ?? true;
    const recepcionRequeridaParaFacturar = options?.recepcionRequeridaParaFacturar ?? false;
    const requiereDetalleOrdenPorFlujo = matching3ViasHabilitado || recepcionRequeridaParaFacturar;
    const hasOrdenDetalleRefs = items.some((item) => Boolean(item.orden_compra_det_id));
    if (!ordenCompraHabilitada && (ordenCompraId || hasOrdenDetalleRefs)) {
      throw new BadRequestException(
        'La empresa tiene deshabilitada la Orden de Compra en Configuración de Compras',
      );
    }
    if (!ordenCompraId && hasOrdenDetalleRefs) {
      throw new BadRequestException(
        'Si enviás orden_compra_det_id en items, también debés informar cabecera.orden_compra_id',
      );
    }
    if (!ordenCompraId) return;

    const orden = await tx.orden_compra_cab.findFirst({
      where: {
        id: ordenCompraId,
        empresa_id: empresaId,
        activo: true,
      },
      include: {
        orden_compra_det: {
          select: {
            id: true,
            producto_id: true,
            descripcion: true,
            cantidad: true,
            cantidad_recibida: true,
            precio_unitario: true,
          },
        },
      },
    });

    if (!orden) {
      throw new NotFoundException('Orden de compra no encontrada para la empresa');
    }
    if (orden.proveedor_id !== proveedorId) {
      throw new BadRequestException('El proveedor de la factura no coincide con el proveedor de la orden');
    }

    const estadoOrden = String(orden.estado || '').toLowerCase();
    if (estadoOrden === 'pendiente_aprobacion') {
      throw new BadRequestException('La orden de compra aún no está aprobada');
    }
    if (estadoOrden === 'cancelada') {
      throw new BadRequestException('No se puede facturar una orden de compra cancelada');
    }

    const detalleIds = new Set(orden.orden_compra_det.map((det) => det.id));
    if (requiereDetalleOrdenPorFlujo && items.some((item) => !item.orden_compra_det_id)) {
      throw new BadRequestException(
        recepcionRequeridaParaFacturar
          ? 'Cuando la compra se vincula a una OC con flujo de recepción, cada ítem debe incluir orden_compra_det_id para impactar correctamente en facturas'
          : 'Cuando cabecera.orden_compra_id está informado, cada ítem debe incluir orden_compra_det_id',
      );
    }

    const payloadQtyByDetalle = new Map<string, number>();
    const orderDetById = new Map(orden.orden_compra_det.map((det) => [det.id, det]));

    for (const item of items) {
      if (!item.orden_compra_det_id) {
        continue;
      }
      const ordenDetId = item.orden_compra_det_id;
      if (!detalleIds.has(ordenDetId)) {
        throw new BadRequestException(`orden_compra_det_id inválido: ${ordenDetId}`);
      }

      const ordenDet = orderDetById.get(ordenDetId);
      if (!ordenDet) {
        throw new BadRequestException(`Detalle de orden no encontrado: ${ordenDetId}`);
      }

      if (ordenDet.producto_id && item.producto_id && ordenDet.producto_id !== item.producto_id) {
        throw new BadRequestException(
          `Ítem ${item.descripcion}: producto no coincide con el detalle de orden ${ordenDetId}`,
        );
      }

      if (matching3ViasHabilitado) {
        const precioOrden = this.round(this.toNumber(ordenDet.precio_unitario), 4);
        const precioCompra = this.round(this.toNumber(item.precio_unitario), 4);
        if (!this.equalsAmount(precioCompra, precioOrden, 0.0001)) {
          throw new BadRequestException(
            `Ítem ${item.descripcion}: precio unitario debe coincidir con la orden de compra (${precioOrden})`,
          );
        }
      }

      const prevQty = payloadQtyByDetalle.get(ordenDetId) || 0;
      payloadQtyByDetalle.set(ordenDetId, this.round(prevQty + this.toNumber(item.cantidad), 4));
    }

    if (!matching3ViasHabilitado && !recepcionRequeridaParaFacturar) {
      return;
    }

    if (payloadQtyByDetalle.size === 0) {
      return;
    }

    const detalleIdsList = Array.from(payloadQtyByDetalle.keys());
    const compraDetFacturados = await tx.compra_det.findMany({
      where: {
        orden_compra_det_id: { in: detalleIdsList },
        compra_cab: {
          empresa_id: empresaId,
          activo: true,
          anulado: false,
          ...(compraId ? { NOT: { id: compraId } } : {}),
        },
      },
      select: {
        orden_compra_det_id: true,
        cantidad: true,
      },
    });

    const facturadoByDetalle = new Map<string, number>();
    for (const row of compraDetFacturados) {
      const key = String(row.orden_compra_det_id || '');
      if (!key) continue;
      const prev = facturadoByDetalle.get(key) || 0;
      facturadoByDetalle.set(key, this.round(prev + this.toNumber(row.cantidad), 4));
    }

    for (const [ordenDetId, payloadQty] of payloadQtyByDetalle.entries()) {
      const ordenDet = orderDetById.get(ordenDetId);
      if (!ordenDet) continue;
      const recibido = this.round(this.toNumber(ordenDet.cantidad_recibida), 4);
      const facturadoPrevio = this.round(this.toNumber(facturadoByDetalle.get(ordenDetId)), 4);
      const facturadoNuevo = this.round(facturadoPrevio + payloadQty, 4);
      if (facturadoNuevo - recibido > 0.0001) {
        throw new BadRequestException(
          `Detalle ${ordenDetId}: cantidad facturada (${facturadoNuevo}) supera recibido (${recibido})`,
        );
      }
    }
  }

  private async recalculateOrdenCompraEstado(tx: Tx, ordenCompraId: string | null) {
    if (!ordenCompraId) return;

    const orden = await tx.orden_compra_cab.findUnique({
      where: { id: ordenCompraId },
      select: {
        id: true,
        empresa_id: true,
        estado: true,
        orden_compra_det: {
          select: {
            id: true,
            cantidad: true,
            cantidad_recibida: true,
          },
        },
      },
    });

    if (!orden) return;
    const estadoActual = String(orden.estado || '').toLowerCase();
    if (estadoActual === 'cancelada') return;

    const detalleIds = orden.orden_compra_det.map((det) => det.id);
    if (detalleIds.length === 0) return;

    const facturados = await tx.compra_det.findMany({
      where: {
        orden_compra_det_id: { in: detalleIds },
        compra_cab: {
          empresa_id: orden.empresa_id,
          activo: true,
          anulado: false,
        },
      },
      select: {
        orden_compra_det_id: true,
        cantidad: true,
      },
    });

    const facturadoByDetalle = new Map<string, number>();
    for (const row of facturados) {
      const key = String(row.orden_compra_det_id || '');
      if (!key) continue;
      const prev = facturadoByDetalle.get(key) || 0;
      facturadoByDetalle.set(key, this.round(prev + this.toNumber(row.cantidad), 4));
    }

    let anyProgress = false;
    let allCompleted = orden.orden_compra_det.length > 0;
    for (const det of orden.orden_compra_det) {
      const ordered = this.round(this.toNumber(det.cantidad), 4);
      const received = this.round(this.toNumber(det.cantidad_recibida), 4);
      const billed = this.round(this.toNumber(facturadoByDetalle.get(det.id)), 4);
      if (received > 0 || billed > 0) anyProgress = true;
      if (received + 0.0001 < ordered || billed + 0.0001 < ordered) {
        allCompleted = false;
      }
    }

    let newState = estadoActual === 'pendiente_aprobacion' ? 'pendiente_aprobacion' : 'pendiente';
    if (allCompleted) newState = 'completada';
    else if (anyProgress) newState = 'parcial';

    if (newState !== estadoActual) {
      await tx.orden_compra_cab.update({
        where: { id: ordenCompraId },
        data: {
          estado: newState,
          updated_at: new Date(),
        },
      });
    }
  }

  private async generateNumeroLoteDiario(
    tx: Tx,
    empresaId: string,
    baseDate: Date,
    sequenceCache: Map<string, number>,
    usedNumbers: Set<string>,
  ) {
    const prefix = this.buildLotePrefix(baseDate);

    if (!sequenceCache.has(prefix)) {
      const existentes = await tx.lotes_producto.findMany({
        where: {
          empresa_id: empresaId,
          numero_lote: {
            startsWith: `${prefix}-`,
          },
        },
        select: { numero_lote: true },
      });

      let maxSequence = 0;
      for (const row of existentes) {
        const seq = this.extractLoteSequence(row.numero_lote, prefix);
        if (seq > maxSequence) maxSequence = seq;
      }
      sequenceCache.set(prefix, maxSequence);
    }

    let nextSequence = (sequenceCache.get(prefix) || 0) + 1;

    while (nextSequence <= 999999) {
      const candidate = `${prefix}-${String(nextSequence).padStart(3, '0')}`;
      if (!usedNumbers.has(candidate)) {
        const existing = await tx.lotes_producto.findFirst({
          where: {
            empresa_id: empresaId,
            numero_lote: candidate,
          },
          select: { id: true },
        });

        if (!existing) {
          sequenceCache.set(prefix, nextSequence);
          return candidate;
        }
      }
      nextSequence += 1;
    }

    throw new BadRequestException(`No se pudo generar un número de lote disponible para ${prefix}`);
  }

  private buildLotePrefix(date: Date) {
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    return `L${year}${month}${day}`;
  }

  private extractLoteSequence(numeroLote: string, prefix: string) {
    if (!numeroLote?.startsWith(`${prefix}-`)) return 0;
    const rawSeq = numeroLote.slice(prefix.length + 1);
    const parsed = Number(rawSeq);
    return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0;
  }

  /**
   * Distribuye el descuento global sobre los ítems, prorrateado por subtotal
   * (post descuento por ítem). Retorna un mapa índice → monto de descuento aplicado.
   * El último ítem absorbe el redondeo para que la suma cuadre exactamente con el monto solicitado.
   */
  private distribuirDescuentoGlobal(
    items: NormalizedItem[],
    descuentoTotal: number,
    cfg: RedondeoConfig = DEFAULT_REDONDEO_CONFIG,
  ): number[] {
    const r = (value: number) =>
      this.roundMode(value, cfg.decimalesMonto, cfg.modo);
    const n = items.length;
    if (n === 0 || descuentoTotal <= 0) return new Array(n).fill(0);

    const totalBase = items.reduce((acc, i) => acc + i.subtotal, 0);
    if (totalBase <= 0) return new Array(n).fill(0);

    const desc = Math.min(descuentoTotal, totalBase);
    const out: number[] = new Array(n).fill(0);
    let acumulado = 0;
    for (let i = 0; i < n; i++) {
      if (i === n - 1) {
        out[i] = r(desc - acumulado);
      } else {
        const cuota = r((items[i].subtotal / totalBase) * desc);
        out[i] = cuota;
        acumulado += cuota;
      }
      // Salvaguarda: el descuento por ítem no puede superar su subtotal.
      if (out[i] > items[i].subtotal) out[i] = r(items[i].subtotal);
    }
    return out;
  }

  private resolveDescuentoGlobal(
    items: NormalizedItem[],
    input: {
      descuento_tipo?: 'por_item' | 'global_pct' | 'global_monto';
      descuento_global_pct?: number;
      descuento_global_monto?: number;
    },
    cfg: RedondeoConfig = DEFAULT_REDONDEO_CONFIG,
  ): DescuentoGlobal {
    const roundAmount = (value: number) =>
      this.roundMode(value, cfg.decimalesMonto, cfg.modo);
    const roundPercentage = (value: number) =>
      this.roundMode(value, cfg.decimalesPorcentaje, cfg.modo);
    const tipo = input.descuento_tipo ?? 'por_item';
    const subtotalPostItem = items.reduce((acc, i) => acc + i.subtotal, 0);

    if (tipo === 'global_pct') {
      const pct = Math.min(
        Math.max(roundPercentage(Number(input.descuento_global_pct ?? 0)), 0),
        100,
      );
      const monto = roundAmount((subtotalPostItem * pct) / 100);
      return { tipo, global_pct: pct, global_monto: monto };
    }
    if (tipo === 'global_monto') {
      const raw = Math.max(Number(input.descuento_global_monto ?? 0), 0);
      const monto = Math.min(roundAmount(raw), roundAmount(subtotalPostItem));
      const pct = subtotalPostItem > 0
        ? roundPercentage((monto / subtotalPostItem) * 100)
        : 0;
      return { tipo, global_pct: pct, global_monto: monto };
    }
    return { tipo: 'por_item', global_pct: 0, global_monto: 0 };
  }

  /**
   * Aplica el descuento global sobre los ítems, mutando `subtotal` y `monto_iva`.
   * Debe llamarse una sola vez antes de persistir/calcular totales.
   */
  private aplicarDescuentoGlobalEnItems(
    items: NormalizedItem[],
    descuentoGlobal: DescuentoGlobal,
    cfg: RedondeoConfig = DEFAULT_REDONDEO_CONFIG,
  ): void {
    if (descuentoGlobal.global_monto <= 0) return;
    const cuotas = this.distribuirDescuentoGlobal(
      items,
      descuentoGlobal.global_monto,
      cfg,
    );
    for (let i = 0; i < items.length; i++) {
      const item = items[i];
      const cuota = cuotas[i] ?? 0;
      if (cuota <= 0) continue;
      item.subtotal = this.roundMode(
        item.subtotal - cuota,
        cfg.decimalesMonto,
        cfg.modo,
      );
      item.monto_iva = this.computeIva(item.subtotal, item.porcentaje_iva, cfg);
    }
  }

  private calculateTotals(
    items: NormalizedItem[],
    descuentoGlobal?: DescuentoGlobal,
    cfg: RedondeoConfig = DEFAULT_REDONDEO_CONFIG,
  ): Totals {
    const dM = cfg.decimalesMonto;
    const modo = cfg.modo;
    const r = (v: number) => this.roundMode(v, dM, modo);
    let totalExento = 0;
    let totalGravado5 = 0;
    let totalGravado10 = 0;
    let totalIva5 = 0;
    let totalIva10 = 0;
    let subtotal = 0;
    let totalDescuentoItems = 0;
    const descuentoGlobalMonto = descuentoGlobal?.global_monto ?? 0;

    for (const item of items) {
      totalDescuentoItems += item.descuento_monto;
      subtotal += item.subtotal;

      if (item.porcentaje_iva === 0) {
        totalExento += item.subtotal;
      } else if (item.porcentaje_iva === 5) {
        totalGravado5 += item.subtotal;
        totalIva5 += item.monto_iva;
      } else {
        totalGravado10 += item.subtotal;
        totalIva10 += item.monto_iva;
      }
    }

    const totalIva = totalIva5 + totalIva10;
    const total = subtotal;
    const totalDescuento = r(totalDescuentoItems + descuentoGlobalMonto);

    return {
      total_exento: r(totalExento),
      total_gravado_5: r(totalGravado5),
      total_gravado_10: r(totalGravado10),
      total_iva_5: r(totalIva5),
      total_iva_10: r(totalIva10),
      total_iva: r(totalIva),
      subtotal: r(subtotal),
      total: r(total),
      total_descuento: totalDescuento,
      descuento_global_monto_aplicado: r(descuentoGlobalMonto),
      subtotalesPorIva: [
        { tipo_iva: 0, base_gravada: r(totalExento), monto_iva: 0 },
        { tipo_iva: 5, base_gravada: r(totalGravado5), monto_iva: r(totalIva5) },
        {
          tipo_iva: 10,
          base_gravada: r(totalGravado10),
          monto_iva: r(totalIva10),
        },
      ],
    };
  }

  private normalizePagos(
    pagos: CreateCompraDto['pagos'],
    cfg: RedondeoConfig = DEFAULT_REDONDEO_CONFIG,
  ): AutoOrderPagoInput[] {
    if (!pagos || pagos.length === 0) return [];

    return pagos
      .map((pago) => ({
        medio_pago_id: pago.medio_pago_id,
        moneda_id: pago.moneda_id || null,
        monto: this.roundMode(Number(pago.monto || 0), cfg.decimalesMonto, cfg.modo),
        monto_moneda: pago.monto_moneda == null
          ? null
          : this.roundMode(Number(pago.monto_moneda || 0), cfg.decimalesMonto, cfg.modo),
        referencia: pago.referencia || null,
      }))
      .filter((pago) => pago.monto > 0);
  }

  private validateReglasPagoCompra(params: {
    isCredito: boolean;
    pagosTotal: number;
    totalCompra: number;
    pagosCount: number;
    // Flag: contado se paga vía orden de pago → se comporta como crédito (sin pagos al alta).
    incluirContadoEnOP?: boolean;
  }) {
    // Con el flag activo, el contado difiere el pago a la orden de pago: mismas
    // reglas que crédito (no admite pagos[] en el alta; la CxP nace pendiente).
    const difierePago = params.isCredito || Boolean(params.incluirContadoEnOP);

    if (difierePago && params.pagosCount > 0) {
      throw new BadRequestException(
        params.isCredito
          ? 'Compras a crédito no permiten pagos[] en el alta/edición'
          : 'Con "incluir contado en orden de pago" activo, el pago se realiza vía orden de pago (no en el alta de la compra)',
      );
    }

    if (!difierePago && params.pagosCount === 0) {
      throw new BadRequestException('En compras contado debe informar al menos un pago');
    }

    if (!difierePago && params.pagosCount > 0) {
      if (!this.equalsAmount(params.pagosTotal, params.totalCompra, 0.01)) {
        throw new BadRequestException('En compras contado, pagos[] debe coincidir exactamente con el total de la compra');
      }
    }
  }

  buildCuotasPersist(params: {
    isCredito: boolean;
    creditoRequiereCuotas: boolean;
    cuotas: CompraCuotaDto[];
    totalCompra: number;
    fechaEmision: Date;
    plazoCreditoDias: number;
    redondeoConfig?: RedondeoConfig;
  }): CuotaPersist[] {
    const cfg = params.redondeoConfig ?? DEFAULT_REDONDEO_CONFIG;
    const r = (value: number) =>
      this.roundMode(value, cfg.decimalesMonto, cfg.modo);
    if (!params.isCredito) {
      return [
        {
          numero_cuota: 1,
          fecha_vencimiento: params.fechaEmision,
          monto: r(params.totalCompra),
          observaciones: 'Contado',
        },
      ];
    }

    const cuotasManual = (params.cuotas || []).filter(Boolean);
    if (params.creditoRequiereCuotas && cuotasManual.length === 0) {
      throw new BadRequestException(
        'Esta empresa requiere cuotas manuales para compras a crédito. Debe informar cuotas[]',
      );
    }

    if (cuotasManual.length === 0) {
      const fechaVencimiento = new Date(params.fechaEmision);
      fechaVencimiento.setDate(fechaVencimiento.getDate() + params.plazoCreditoDias);

      return [
        {
          numero_cuota: 1,
          fecha_vencimiento: fechaVencimiento,
          monto: r(params.totalCompra),
          observaciones: 'Crédito a plazo',
        },
      ];
    }

    const cuotasPersist = cuotasManual.map((cuota, idx) => {
      const fechaVencimiento = new Date(cuota.fecha_vencimiento);
      const monto = r(Number(cuota.monto || 0));
      const numeroCuota = Number(cuota.numero_cuota || idx + 1);

      if (Number.isNaN(fechaVencimiento.getTime())) {
        throw new BadRequestException(`Cuota ${idx + 1}: fecha_vencimiento inválida`);
      }

      if (monto <= 0) {
        throw new BadRequestException(`Cuota ${idx + 1}: monto debe ser mayor a 0`);
      }

      return {
        numero_cuota: Number.isFinite(numeroCuota) && numeroCuota > 0 ? numeroCuota : idx + 1,
        fecha_vencimiento: fechaVencimiento,
        monto,
        observaciones: cuota.observaciones || null,
      };
    });

    const totalCuotas = r(
      cuotasPersist.reduce((acc, cuota) => acc + this.toNumber(cuota.monto), 0),
    );
    const epsilon = 1 / 10 ** cfg.decimalesMonto;
    if (!this.equalsAmount(totalCuotas, params.totalCompra, epsilon)) {
      throw new BadRequestException('En compras crédito con cuotas, la suma de cuotas debe coincidir con el total');
    }

    return cuotasPersist;
  }

  resolvePlazoCreditoDias(value?: number | null): number {
    const parsed = Number(value ?? 0);
    if (!Number.isFinite(parsed)) return 30;

    const days = Math.trunc(parsed);
    return days > 0 ? days : 30;
  }

  private async persistDetalles(tx: Tx, compraId: string, items: NormalizedItem[]) {
    if (!items.length) return [];

    const created: Array<{ id: string; producto_id: string | null }> = [];
    for (const item of items) {
      const detalle = await tx.compra_det.create({
        data: {
          compra_id: compraId,
          orden_compra_det_id: item.orden_compra_det_id,
          requisicion_det_id: item.requisicion_det_id,
          producto_id: item.producto_id,
          codigo_producto: item.codigo_producto,
          descripcion: item.descripcion,
          cantidad: item.cantidad,
          precio_unitario: item.precio_unitario,
          porcentaje_iva: item.porcentaje_iva,
          monto_iva: item.monto_iva,
          subtotal: item.subtotal,
          descuento_tipo: item.descuento_tipo,
          descuento_pct: item.descuento_pct,
          descuento_monto: item.descuento_monto,
          afecta_stock: item.afecta_stock,
          lote: item.numero_lote,
          fecha_vencimiento: item.fecha_vencimiento,
        },
        select: {
          id: true,
          orden_compra_det_id: true,
          requisicion_det_id: true,
          producto_id: true,
        },
      });
      created.push(detalle);
    }

    return created;
  }

  private async persistSubtotales(
    tx: Tx,
    compraId: string,
    subtotalesPorIva: Array<{ tipo_iva: number; base_gravada: number; monto_iva: number }>,
  ) {
    await tx.compra_subtotales.createMany({
      data: subtotalesPorIva.map((s) => ({
        compra_id: compraId,
        tipo_iva: s.tipo_iva,
        base_gravada: s.base_gravada,
        monto_iva: s.monto_iva,
      })),
    });
  }

  private async persistPagos(tx: Tx, compraId: string, pagos: CreateCompraDto['pagos']) {
    if (!pagos || pagos.length === 0) return;

    await tx.compra_forma_pagos.createMany({
      data: pagos.map((p) => ({
        compra_id: compraId,
        medio_pago_id: p.medio_pago_id,
        moneda_id: p.moneda_id || null,
        monto: p.monto,
        monto_moneda: p.monto_moneda ?? p.monto,
        referencia: p.referencia || null,
      })),
    });
  }

  async persistCuentasPagar(
    tx: Tx,
    params: {
      empresaId: string;
      compraId: string | null;
      proveedorId: string;
      monedaId: string | null;
      fechaEmision: Date;
      cuotas: CuotaPersist[];
      embarqueId?: string | null;
      origenTipo?: string | null;
      origenId?: string | null;
    },
  ) {
    const origenTipo = params.origenTipo ?? (params.compraId ? 'compra' : null);
    const origenId = params.origenId ?? params.compraId ?? null;
    if (!origenTipo || !origenId) {
      throw new BadRequestException('No se pudo determinar el origen de las cuentas por pagar');
    }

    // FASE 2: congelar la cuenta pasivo al crear la CxP. La OP debita la MISMA
    // cuenta, incluso si el proveedor se reclasifica después. Si el módulo
    // contabilidad no está habilitado o resolver falla, dejamos NULL (comportamiento legacy).
    let cuentaPasivoId: string | null = null;
    try {
      cuentaPasivoId = await this.contabilidadIntegracion.resolverCuentaPasivo(
        params.proveedorId,
        params.empresaId,
      );
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'unknown';
      this.logger.warn(
        `No se pudo resolver cuenta_pasivo_id para proveedor ${params.proveedorId} (${msg}) — CxP queda sin congelar (legacy)`,
      );
    }

    const totalCuotas = params.cuotas.length;
    const created: Array<{ id: string }> = [];
    for (let idx = 0; idx < params.cuotas.length; idx += 1) {
      const cuota = params.cuotas[idx];
      const cuenta = await tx.cuentas_pagar.create({
        data: {
          empresa_id: params.empresaId,
          compra_id: params.compraId,
          proveedor_id: params.proveedorId,
          moneda_id: params.monedaId,
          embarque_id: params.embarqueId || null,
          origen_tipo: origenTipo,
          origen_id: origenId,
          numero_cuota: cuota.numero_cuota || idx + 1,
          total_cuotas: totalCuotas,
          fecha_emision: params.fechaEmision,
          fecha_vencimiento: cuota.fecha_vencimiento,
          monto_original: cuota.monto,
          monto_pagado: 0,
          saldo_pendiente: cuota.monto,
          estado: 'pendiente',
          observaciones: cuota.observaciones || null,
          activo: true,
          cuenta_pasivo_id: cuentaPasivoId,
        },
        select: { id: true },
      });
      created.push(cuenta);
    }

    return created;
  }

  private async resolveTipoMovimientoEntradaId(tx: Tx) {
    const tipo = await tx.tipo_movimiento_inventario.findFirst({
      where: {
        OR: [
          { codigo: 'COMPRA' },
          { codigo: 'ENTRADA' },
          { descripcion: { contains: 'Compra', mode: 'insensitive' } },
          { descripcion: { contains: 'Entrada', mode: 'insensitive' } },
        ],
      },
      select: { id: true },
    });

    if (!tipo) throw new NotFoundException('No se encontró tipo de movimiento de inventario de entrada/compra');
    return tipo.id;
  }

  private async resolveTipoMovimientoSalidaId(tx: Tx) {
    const tipo = await tx.tipo_movimiento_inventario.findFirst({
      where: {
        OR: [
          { codigo: 'SALIDA' },
          { descripcion: { contains: 'Salida', mode: 'insensitive' } },
          { descripcion: { contains: 'Ajuste', mode: 'insensitive' } },
        ],
      },
      select: { id: true },
    });

    if (!tipo) throw new NotFoundException('No se encontró tipo de movimiento de inventario de salida');
    return tipo.id;
  }

  /**
   * ¿La empresa tiene contratado el addon de Reglas de Precio? Mismo criterio que
   * `PermissionGuard` (submódulo `es_addon` + fila activa en
   * `suscripcion_submodulos`, con holdings/resellers exceptuados).
   */
  private async tieneAddonReglasPrecio(tx: Tx, empresaId: string): Promise<boolean> {
    const empresa = await tx.empresas.findUnique({
      where: { id: empresaId },
      select: { type: true },
    });
    if (empresa?.type === 'holding' || empresa?.type === 'reseller') return true;

    const submodulo = await tx.submodulos.findFirst({
      where: { codigo: 'INV_REGLAS_PRECIO', active: true },
      select: { id: true, es_addon: true },
    });
    if (!submodulo) return false;
    // Si dejara de ser addon, la funcionalidad viene incluida: se avisa igual.
    if (!submodulo.es_addon) return true;

    const addonActivo = await tx.suscripcion_submodulos.findFirst({
      where: {
        active: true,
        submodulo_id: submodulo.id,
        suscripciones: {
          empresa_id: empresaId,
          estado: { in: ['Activo', 'EnGracia'] },
        },
      },
      select: { id: true },
    });
    return Boolean(addonActivo);
  }

  private async applyInventarioYCostos(
    tx: Tx,
    params: {
      empresaId: string;
      compraId: string;
      depositoId: string;
      items: NormalizedItem[];
      detalles: Array<{ id: string; producto_id: string | null }>;
      lotesEnabled: boolean;
      tipoMovimientoId: string;
      usuarioId?: string;
      documentoOrigen: string;
      numeroComprobante?: string | null;
    },
  ): Promise<AlertaCostoCompra[]> {
    // Productos cuyo costo SUBIÓ con esta compra y que tienen precio en alguna
    // lista: su precio de venta quedó calculado sobre el costo viejo, así que la
    // marcación real es menor a la configurada (o directamente negativa). No se
    // recalcula solo a propósito — se avisa, y quien tenga INV_RGP_REGLA_EDITAR
    // decide si recalcula. Ver docs/plan-motor-precios-rentabilidad.md.
    const alertas: AlertaCostoCompra[] = [];
    // Sin el addon de Reglas de Precio no hay motor que recalcular: se saltea el
    // trabajo extra y no se emite ningún aviso.
    const avisarCosto = await this.tieneAddonReglasPrecio(tx, params.empresaId);

    for (let index = 0; index < params.items.length; index++) {
      const item = params.items[index];
      const detalle = params.detalles[index];
      if (!item.producto_id || !item.afecta_stock) continue;

      await tx.stock_deposito.upsert({
        where: {
          deposito_id_producto_id: {
            deposito_id: params.depositoId,
            producto_id: item.producto_id,
          },
        },
        create: {
          deposito_id: params.depositoId,
          producto_id: item.producto_id,
          cantidad_disponible: item.cantidad,
          cantidad_reservada: 0,
          stock_minimo: 0,
        },
        update: {
          cantidad_disponible: { increment: item.cantidad },
          updated_at: new Date(),
        },
      });

      await tx.movimientos_inventario.create({
        data: {
          deposito_origen_id: params.depositoId,
          producto_id: item.producto_id,
          tipo_movimiento_id: params.tipoMovimientoId,
          cantidad: item.cantidad,
          costo_unitario: item.costo_unitario_neto,
          documento_origen: params.documentoOrigen,
          documento_id: params.compraId,
          observaciones: `Compra ${params.numeroComprobante || 'S/N'}`,
          usuario_id: params.usuarioId || null,
        },
      });

      const productoPrevio = avisarCosto
        ? await tx.productos.findUnique({
            where: { id: item.producto_id },
            select: { descripcion: true, precio_costo: true },
          })
        : null;
      const costoAnterior = Number(productoPrevio?.precio_costo || 0);
      const costoNuevo = Number(item.costo_unitario_neto || 0);

      await tx.productos.update({
        where: { id: item.producto_id },
        data: {
          precio_costo: item.costo_unitario_neto,
          updated_at: new Date(),
        },
      });

      if (avisarCosto && costoNuevo > costoAnterior) {
        // Solo interesa avisar si el producto tiene un precio de venta cargado en
        // alguna lista: si no está en ninguna, no hay precio que quede desalineado.
        const precioEnLista = await tx.lista_precios_productos.findFirst({
          where: { producto_id: item.producto_id },
          select: { precio_base: true, origen_precio: true },
          orderBy: { lista_precios: { prioridad: 'asc' } },
        });

        if (precioEnLista) {
          const precioVenta = Number(precioEnLista.precio_base || 0);
          alertas.push({
            producto_id: item.producto_id,
            descripcion: productoPrevio?.descripcion || '',
            costo_anterior: costoAnterior,
            costo_nuevo: costoNuevo,
            precio_venta_actual: precioVenta,
            // El caso grave: el precio de venta quedó por debajo del costo nuevo.
            vende_bajo_costo: precioVenta > 0 && precioVenta < costoNuevo,
            origen_precio: precioEnLista.origen_precio || 'manual',
          });
        }
      }

      if (params.lotesEnabled && item.requiere_lote && detalle?.id && item.numero_lote) {
        await this.lotesService.createLoteIngreso(tx, {
          empresaId: params.empresaId,
          productoId: item.producto_id,
          compraId: params.compraId,
          compraDetId: detalle.id,
          depositoId: params.depositoId,
          numeroLote: item.numero_lote,
          fechaFabricacion: item.fecha_fabricacion,
          fechaVencimiento: item.fecha_vencimiento,
          cantidad: item.cantidad,
          costoUnitario: item.costo_unitario_neto,
          usuarioId: params.usuarioId,
        });
      }
    }

    return alertas;
  }

  private async revertInventario(
    tx: Tx,
    params: {
      compraId: string;
      depositoId: string;
      items: Array<{
        id: string;
        producto_id: string | null;
        cantidad: number;
        precio_unitario: number;
        afecta_stock: boolean;
      }>;
      lotesEnabled: boolean;
      tipoMovimientoId: string;
      usuarioId?: string;
      numeroComprobante?: string | null;
    },
  ) {
    for (const item of params.items) {
      if (!item.producto_id || !item.afecta_stock) continue;

      if (params.lotesEnabled) {
        await this.lotesService.revertCompraDetalleLote(tx, {
          compraDetId: item.id,
          depositoId: params.depositoId,
        });
      }

      await tx.stock_deposito.upsert({
        where: {
          deposito_id_producto_id: {
            deposito_id: params.depositoId,
            producto_id: item.producto_id,
          },
        },
        create: {
          deposito_id: params.depositoId,
          producto_id: item.producto_id,
          cantidad_disponible: -Math.abs(item.cantidad),
          cantidad_reservada: 0,
          stock_minimo: 0,
        },
        update: {
          cantidad_disponible: { decrement: Math.abs(item.cantidad) },
          updated_at: new Date(),
        },
      });

      await tx.movimientos_inventario.create({
        data: {
          deposito_origen_id: params.depositoId,
          producto_id: item.producto_id,
          tipo_movimiento_id: params.tipoMovimientoId,
          cantidad: Math.abs(item.cantidad),
          costo_unitario: item.precio_unitario,
          documento_origen: 'factura_compra_reversa',
          documento_id: params.compraId,
          observaciones: `Reversión compra ${params.numeroComprobante || 'S/N'}`,
          usuario_id: params.usuarioId || null,
        },
      });
    }
  }

  private buildNumeroComprobanteCompra(params: {
    establecimiento?: string | null;
    puntoExpedicion?: string | null;
    numeroFactura?: string | null;
  }): string | null {
    const establecimiento = String(params.establecimiento || '').trim();
    const puntoExpedicion = String(params.puntoExpedicion || '').trim();
    const numeroFactura = String(params.numeroFactura || '').trim();

    const parts = [establecimiento, puntoExpedicion, numeroFactura].filter((part) => part.length > 0);
    if (parts.length === 0) return null;
    if (parts.length === 3) return `${establecimiento}-${puntoExpedicion}-${numeroFactura}`;
    return parts.join('-');
  }

  private async autoCancelBorradorOrdenesForCompra(
    tx: Tx,
    empresaId: string,
    compraId: string,
    motivo = 'Anulado automáticamente por anulación de la compra vinculada',
  ) {
    const borradores = await tx.orden_pago_proveedor_cab.findMany({
      where: {
        empresa_id: empresaId,
        activo: true,
        anulado: false,
        estado: 'BORRADOR',
        OR: [
          { compra_id: compraId },
          {
            orden_pago_proveedor_det: {
              some: { cuentas_pagar: { compra_id: compraId } },
            },
          },
        ],
      },
      select: { id: true },
    });

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

    const ids = borradores.map((o) => o.id);
    await tx.orden_pago_proveedor_cab.updateMany({
      where: { id: { in: ids } },
      data: {
        anulado: true,
        activo: false,
        estado: 'ANULADA',
        motivo_anulacion: motivo,
        fecha_anulacion: new Date(),
      },
    });
  }

  private async assertNoActiveOrdenesForCompra(
    tx: Tx,
    empresaId: string,
    compraId: string,
    accion: 'editar' | 'anular',
  ) {
    const activeOrders = await tx.orden_pago_proveedor_cab.findMany({
      where: {
        empresa_id: empresaId,
        activo: true,
        anulado: false,
        OR: [
          { compra_id: compraId },
          {
            orden_pago_proveedor_det: {
              some: {
                cuentas_pagar: {
                  compra_id: compraId,
                },
              },
            },
          },
        ],
      },
      select: { numero_orden_pago: true, estado: true },
      take: 5,
    });

    if (activeOrders.length > 0) {
      const detalle = activeOrders
        .map((o) => `${o.numero_orden_pago ?? '—'}${o.estado ? ` (${o.estado})` : ''}`)
        .join(', ');
      throw new BadRequestException(
        `No se puede ${accion} la compra porque tiene órdenes de pago relacionadas: ${detalle}. Anulá primero esa orden de pago.`,
      );
    }
  }

  private async cleanupEmptyAutoDraftOrderForCompra(tx: Tx, empresaId: string, compraId: string) {
    await tx.orden_pago_proveedor_cab.deleteMany({
      where: {
        empresa_id: empresaId,
        compra_id: compraId,
        origen: 'compra_auto',
        estado: { in: ['BORRADOR', 'borrador'] },
        activo: true,
        anulado: false,
        orden_pago_proveedor_det: { none: {} },
      },
    });
  }

  private async assertNoCxpPagadaForCompra(tx: Tx, compraId: string, accion: 'editar' | 'anular') {
    const pagada = await tx.cuentas_pagar.findFirst({
      where: {
        compra_id: compraId,
        estado: { in: ['pagada', 'parcial'] },
        activo: true,
      },
      select: { id: true, estado: true },
    });
    if (pagada) {
      throw new BadRequestException(
        `No se puede ${accion} la compra porque tiene pagos aplicados (estado: ${pagada.estado}). Primero anulá el pago o la orden de pago.`,
      );
    }
  }

  private async cleanupInactiveOrdenesForCompra(tx: Tx, empresaId: string, compraId: string) {
    const ownInactiveOrders = await tx.orden_pago_proveedor_cab.findMany({
      where: {
        empresa_id: empresaId,
        compra_id: compraId,
        OR: [{ anulado: true }, { activo: false }],
      },
      select: { id: true },
    });

    if (ownInactiveOrders.length > 0) {
      const orderIds = ownInactiveOrders.map((o) => o.id);
      await tx.orden_pago_proveedor_det.deleteMany({
        where: { orden_pago_id: { in: orderIds } },
      });
      await tx.orden_pago_proveedor_cab.deleteMany({
        where: { id: { in: orderIds } },
      });
    }

    const mixedInactiveDetails = await tx.orden_pago_proveedor_det.findMany({
      where: {
        cuentas_pagar: { compra_id: compraId },
        orden_pago_proveedor_cab: {
          empresa_id: empresaId,
          OR: [{ anulado: true }, { activo: false }],
        },
      },
      select: {
        id: true,
        orden_pago_id: true,
      },
    });

    if (mixedInactiveDetails.length > 0) {
      const detailIds = mixedInactiveDetails.map((d) => d.id);
      const mixedOrderIds = Array.from(new Set(mixedInactiveDetails.map((d) => d.orden_pago_id)));
      await tx.orden_pago_proveedor_det.deleteMany({
        where: { id: { in: detailIds } },
      });
      await tx.orden_pago_proveedor_cab.deleteMany({
        where: {
          id: { in: mixedOrderIds },
          OR: [{ anulado: true }, { activo: false }],
          orden_pago_proveedor_det: { none: {} },
        },
      });
    }
  }

  private async getOrCreateConfigCompras(tx: Tx, empresaId: string) {
    const existing = await tx.config_compras.findUnique({
      where: { empresa_id: empresaId },
    });
    if (existing) return existing;

    return tx.config_compras.create({
      data: {
        empresa_id: empresaId,
        habilitar_requisicion_compra: false,
        modo_flujo_compra: 'directo',
        afectar_stock_en_compra_directa: true,
        habilitar_orden_compra: true,
        habilitar_recepcion_compra: true,
        habilitar_matching_3_vias: true,
        habilitar_orden_pago: true,
        requiere_aprobacion: false,
        permite_pago_sin_aprobacion: true,
        generar_cxp_automatico: true,
        permitir_edicion_cxp: true,
        permitir_pago_parcial: false,
        usar_workflow: false,
        workflow_obligatorio: false,
        aplicar_pago_al_guardar: true,
        incluir_contado_en_orden_pago: false,
      },
    });
  }

  private normalizeModoFlujoCompra(value: string | null | undefined): ModoFlujoCompra {
    if (value === 'requisicion_opcional' || value === 'requisicion_obligatoria') {
      return value;
    }
    return 'directo';
  }

  private async resolveFlujoCompra(params: {
    tx: Tx;
    empresaId: string;
    configCompras: {
      modo_flujo_compra: string | null;
      afectar_stock_en_compra_directa: boolean | null;
      habilitar_orden_compra?: boolean | null;
      habilitar_recepcion_compra?: boolean | null;
    };
    requisicionId: string | null;
    ordenCompraId: string | null;
    items: NormalizedItem[];
  }): Promise<{ afectarStockEnCompra: boolean }> {
    const { tx, empresaId, configCompras, requisicionId, ordenCompraId, items } = params;
    const modoFlujo = this.normalizeModoFlujoCompra(configCompras.modo_flujo_compra);

    await this.enrichRequisicionDetFromOrdenDetalles(tx, ordenCompraId, items);

    if (modoFlujo === 'requisicion_obligatoria' && !requisicionId) {
      throw new BadRequestException(
        'La configuración de compras exige requisición obligatoria. Debés informar cabecera.requisicion_id',
      );
    }
    if (modoFlujo === 'requisicion_obligatoria' && !ordenCompraId) {
      throw new BadRequestException(
        'La configuración de compras exige requisición obligatoria. Debés informar cabecera.orden_compra_id y facturar desde una OC originada en requisición aprobada',
      );
    }

    if (requisicionId) {
      await this.assertRequisicionCompraValida(tx, empresaId, requisicionId, items);
    } else if (items.some((item) => Boolean(item.requisicion_det_id))) {
      throw new BadRequestException(
        'Si enviás requisicion_det_id en ítems, también debés informar cabecera.requisicion_id',
      );
    }

    const afectaStockDirecto = Boolean(configCompras.afectar_stock_en_compra_directa ?? true);
    const hasStockItems = items.some((item) => Boolean(item.producto_id) && Boolean(item.afecta_stock));
    const permiteRecepcionLibre =
      Boolean(configCompras.habilitar_recepcion_compra ?? true) &&
      !Boolean(configCompras.habilitar_orden_compra ?? true);
    if (!afectaStockDirecto && hasStockItems && !ordenCompraId && !permiteRecepcionLibre) {
      throw new BadRequestException(
        Boolean(configCompras.habilitar_recepcion_compra ?? true) &&
          Boolean(configCompras.habilitar_orden_compra ?? true)
          ? 'Esta empresa ingresa el stock mediante Recepción contra Orden de Compra. Creá o seleccioná una OC, registrá la recepción y luego facturala'
          : 'La factura directa no puede ingresar stock con la configuración actual. Activá Recepciones o habilitá el impacto de stock en compra directa',
      );
    }

    return { afectarStockEnCompra: afectaStockDirecto };
  }

  private async enrichRequisicionDetFromOrdenDetalles(
    tx: Tx,
    ordenCompraId: string | null,
    items: NormalizedItem[],
  ) {
    if (!ordenCompraId || !Array.isArray(items) || !items.length) return;
    if (typeof (tx as any)?.orden_compra_det?.findMany !== 'function') return;

    const detalleIds = Array.from(
      new Set(
        items
          .filter((item) => !item.requisicion_det_id && item.orden_compra_det_id)
          .map((item) => String(item.orden_compra_det_id || '').trim())
          .filter(Boolean),
      ),
    );
    if (!detalleIds.length) return;

    const detalles = await tx.orden_compra_det.findMany({
      where: {
        id: { in: detalleIds },
        orden_compra_id: ordenCompraId,
      },
      select: {
        id: true,
        requisicion_det_id: true,
      },
    });

    const byId = new Map(detalles.map((det) => [det.id, det.requisicion_det_id || null]));
    for (const item of items) {
      if (item.requisicion_det_id || !item.orden_compra_det_id) continue;
      const resolvedReqDetId = byId.get(item.orden_compra_det_id);
      if (resolvedReqDetId) {
        item.requisicion_det_id = resolvedReqDetId;
      }
    }
  }

  private async assertRequisicionCompraValida(
    tx: Tx,
    empresaId: string,
    requisicionId: string,
    items: NormalizedItem[],
  ) {
    const requisicion = await tx.requisicion_cab.findFirst({
      where: { id: requisicionId, empresa_id: empresaId, activo: true },
      include: {
        requisicion_det: {
          select: {
            id: true,
            estado: true,
            cantidad_solicitada: true,
            cantidad_ejecutada: true,
            monto_ejecutado: true,
          },
        },
      },
    });

    if (!requisicion) {
      throw new BadRequestException('requisicion_id inválido para la empresa');
    }
    if (String(requisicion.origen_tipo) !== 'compra') {
      throw new BadRequestException('La requisición indicada no corresponde al origen compra');
    }
    if (!['aprobada', 'ejecutada_parcial'].includes(String(requisicion.estado))) {
      throw new BadRequestException('La requisición debe estar aprobada para ejecutar compras');
    }

    const detallesMap = new Map(requisicion.requisicion_det.map((det) => [det.id, det]));
    for (const item of items) {
      if (!item.requisicion_det_id) {
        throw new BadRequestException(
          'Cuando la compra está vinculada a una requisición, cada ítem debe incluir requisicion_det_id',
        );
      }
      if (!detallesMap.has(item.requisicion_det_id)) {
        throw new BadRequestException(
          `requisicion_det_id inválido o no pertenece a la requisición: ${item.requisicion_det_id}`,
        );
      }
    }
  }

  private async aplicarEjecucionRequisicion(
    tx: Tx,
    params: {
      empresaId: string;
      requisicionId: string | null;
      items: Array<{ requisicion_det_id: string | null; cantidad: number; subtotal: number }>;
      fechaOperacion: Date;
    },
  ) {
    const { empresaId, requisicionId, items, fechaOperacion } = params;
    if (!requisicionId) return;

    const requisicion = await tx.requisicion_cab.findFirst({
      where: { id: requisicionId, empresa_id: empresaId, activo: true },
      include: { requisicion_det: true },
    });
    if (!requisicion) {
      throw new BadRequestException('No se encontró la requisición vinculada a la compra');
    }

    const resumen = new Map<string, { cantidad: number; monto: number }>();
    for (const item of items) {
      if (!item.requisicion_det_id) continue;
      const prev = resumen.get(item.requisicion_det_id) || { cantidad: 0, monto: 0 };
      resumen.set(item.requisicion_det_id, {
        cantidad: this.round(prev.cantidad + this.toNumber(item.cantidad), 4),
        monto: this.round(prev.monto + this.toNumber(item.subtotal), 2),
      });
    }
    if (!resumen.size) return;

    const detallesMap = new Map(requisicion.requisicion_det.map((det) => [det.id, det]));
    const presupuestoAjuste = new Map<string, number>();

    for (const [detId, payload] of resumen.entries()) {
      const det = detallesMap.get(detId);
      if (!det) throw new BadRequestException(`Detalle de requisición inválido: ${detId}`);

      const solicitada = this.toNumber(det.cantidad_solicitada);
      const ejecutadaActual = this.toNumber(det.cantidad_ejecutada);
      const ejecutadaNueva = this.round(ejecutadaActual + payload.cantidad, 4);

      if (ejecutadaNueva - solicitada > 0.0001) {
        throw new BadRequestException(
          `Detalle de requisición ${detId}: cantidad ejecutada supera lo solicitado`,
        );
      }

      const montoEjecutadoNuevo = this.round(this.toNumber(det.monto_ejecutado) + payload.monto, 2);
      const estadoDet =
        ejecutadaNueva + 0.0001 >= solicitada
          ? 'ejecutada_total'
          : ejecutadaNueva > 0
            ? 'ejecutada_parcial'
            : 'pendiente';

      await tx.requisicion_det.update({
        where: { id: detId },
        data: {
          cantidad_ejecutada: ejecutadaNueva,
          monto_ejecutado: montoEjecutadoNuevo,
          estado: estadoDet,
          updated_at: new Date(),
        },
      });

      if (det.centro_costo_id) {
        const centro = String(det.centro_costo_id);
        const prevCentro = presupuestoAjuste.get(centro) || 0;
        presupuestoAjuste.set(centro, this.round(prevCentro + payload.monto, 2));
      }
    }

    await this.ajustarPresupuestoEjecutadoRequisicion(
      tx,
      requisicion.empresa_id,
      String(requisicion.origen_tipo),
      fechaOperacion,
      presupuestoAjuste,
      'increment',
    );
    await this.recalcularEstadoRequisicion(tx, requisicion.id);
  }

  private async revertirEjecucionRequisicion(
    tx: Tx,
    params: {
      empresaId: string;
      requisicionId: string | null;
      items: Array<{ requisicion_det_id: string | null; cantidad: number; subtotal: number }>;
      fechaOperacion: Date;
    },
  ) {
    const { empresaId, requisicionId, items, fechaOperacion } = params;
    if (!requisicionId) return;

    const requisicion = await tx.requisicion_cab.findFirst({
      where: { id: requisicionId, empresa_id: empresaId, activo: true },
      include: { requisicion_det: true },
    });
    if (!requisicion) return;

    const resumen = new Map<string, { cantidad: number; monto: number }>();
    for (const item of items) {
      if (!item.requisicion_det_id) continue;
      const prev = resumen.get(item.requisicion_det_id) || { cantidad: 0, monto: 0 };
      resumen.set(item.requisicion_det_id, {
        cantidad: this.round(prev.cantidad + this.toNumber(item.cantidad), 4),
        monto: this.round(prev.monto + this.toNumber(item.subtotal), 2),
      });
    }
    if (!resumen.size) return;

    const detallesMap = new Map(requisicion.requisicion_det.map((det) => [det.id, det]));
    const presupuestoAjuste = new Map<string, number>();

    for (const [detId, payload] of resumen.entries()) {
      const det = detallesMap.get(detId);
      if (!det) continue;

      const solicitada = this.toNumber(det.cantidad_solicitada);
      const ejecutadaNueva = Math.max(0, this.round(this.toNumber(det.cantidad_ejecutada) - payload.cantidad, 4));
      const montoEjecutadoNuevo = Math.max(0, this.round(this.toNumber(det.monto_ejecutado) - payload.monto, 2));
      const estadoDet =
        ejecutadaNueva + 0.0001 >= solicitada
          ? 'ejecutada_total'
          : ejecutadaNueva > 0
            ? 'ejecutada_parcial'
            : 'pendiente';

      await tx.requisicion_det.update({
        where: { id: detId },
        data: {
          cantidad_ejecutada: ejecutadaNueva,
          monto_ejecutado: montoEjecutadoNuevo,
          estado: estadoDet,
          updated_at: new Date(),
        },
      });

      if (det.centro_costo_id) {
        const centro = String(det.centro_costo_id);
        const prevCentro = presupuestoAjuste.get(centro) || 0;
        presupuestoAjuste.set(centro, this.round(prevCentro + payload.monto, 2));
      }
    }

    await this.ajustarPresupuestoEjecutadoRequisicion(
      tx,
      requisicion.empresa_id,
      String(requisicion.origen_tipo),
      fechaOperacion,
      presupuestoAjuste,
      'decrement',
    );
    await this.recalcularEstadoRequisicion(tx, requisicion.id);
  }

  private async recalcularEstadoRequisicion(tx: Tx, requisicionId: string) {
    const req = await tx.requisicion_cab.findUnique({
      where: { id: requisicionId },
      include: { requisicion_det: true },
    });
    if (!req) return;
    if (['cancelada', 'rechazada'].includes(String(req.estado))) return;

    let totalEjecutado = 0;
    let allDone = req.requisicion_det.length > 0;
    let anyProgress = false;
    for (const det of req.requisicion_det) {
      totalEjecutado = this.round(totalEjecutado + this.toNumber(det.monto_ejecutado), 2);
      const qtySolicitada = this.toNumber(det.cantidad_solicitada);
      const qtyEjecutada = this.toNumber(det.cantidad_ejecutada);
      if (qtyEjecutada > 0) anyProgress = true;
      if (qtyEjecutada + 0.0001 < qtySolicitada) {
        allDone = false;
      }
    }

    const nextEstado = allDone ? 'ejecutada_total' : anyProgress ? 'ejecutada_parcial' : 'aprobada';
    await tx.requisicion_cab.update({
      where: { id: requisicionId },
      data: {
        total_ejecutado: totalEjecutado,
        estado: nextEstado,
        updated_at: new Date(),
      },
    });
  }

  private async ajustarPresupuestoEjecutadoRequisicion(
    tx: Tx,
    empresaId: string,
    origenTipo: string,
    fechaOperacion: Date,
    groupedAmount: Map<string, number>,
    op: 'increment' | 'decrement',
  ) {
    if (!groupedAmount.size) return;
    const period = await tx.cont_periodos.findFirst({
      where: {
        empresa_id: empresaId,
        fecha_inicio: { lte: fechaOperacion },
        fecha_fin: { gte: fechaOperacion },
      },
      select: { id: true },
      orderBy: [{ anio: 'desc' }, { mes: 'desc' }],
    });
    if (!period) return;

    for (const [centroCostoId, amount] of groupedAmount.entries()) {
      if (amount <= 0) continue;
      const presupuesto = await tx.requisicion_presupuesto.findFirst({
        where: {
          empresa_id: empresaId,
          periodo_id: period.id,
          centro_costo_id: centroCostoId,
          origen_tipo: origenTipo,
          activo: true,
        },
        select: { id: true, monto_ejecutado: true },
      });
      if (!presupuesto) continue;

      if (op === 'increment') {
        await tx.requisicion_presupuesto.update({
          where: { id: presupuesto.id },
          data: {
            monto_ejecutado: { increment: amount },
            updated_at: new Date(),
          },
        });
      } else {
        const next = Math.max(0, this.round(this.toNumber(presupuesto.monto_ejecutado) - amount, 2));
        await tx.requisicion_presupuesto.update({
          where: { id: presupuesto.id },
          data: {
            monto_ejecutado: next,
            updated_at: new Date(),
          },
        });
      }
    }
  }

  private async hasInventarioAplicadoEnCompra(tx: Tx, compraId: string) {
    const count = await tx.movimientos_inventario.count({
      where: {
        documento_id: compraId,
        documento_origen: 'factura_compra',
      },
    });
    return count > 0;
  }

  private async createOrdenPagoAutomatica(
    tx: Tx,
    params: {
      empresaId: string;
      compraId: string;
      sucursalId: string;
      proveedorId: string;
      monedaId: string | null;
      sesionCajaId: string | null;
      fechaPago: Date;
      referencia: string | null;
      observaciones: string | null;
      userId?: string;
      cuentasCompra: Array<{ id: string; moneda_id: string | null; saldo_pendiente: Prisma.Decimal | null }>;
      detallesPago: AutoOrderPagoInput[];
    },
  ) {
    if (!params.cuentasCompra.length) {
      throw new BadRequestException('No se pudieron generar cuentas por pagar para crear la orden automática');
    }

    const totalCuentas = this.round(
      params.cuentasCompra.reduce((acc, c) => acc + this.toNumber(c.saldo_pendiente), 0),
      2,
    );

    const totalPagos = this.round(params.detallesPago.reduce((acc, p) => acc + this.toNumber(p.monto), 0), 2);
    const aplicaPagos = params.detallesPago.length > 0;

    if (aplicaPagos) {
      if (params.cuentasCompra.length !== 1) {
        throw new BadRequestException('La compra contado debe generar una sola cuenta por pagar para aplicar pagos iniciales');
      }

      const saldoCuenta = this.toNumber(params.cuentasCompra[0].saldo_pendiente);
      if (!this.equalsAmount(totalPagos, saldoCuenta)) {
        throw new BadRequestException('El total de pagos iniciales no coincide con el saldo pendiente de la compra');
      }
    }

    const { numeroOrden } = await this.getNextNumeroOrdenPago(tx, params.empresaId, params.sucursalId);
    const orden = await tx.orden_pago_proveedor_cab.create({
      data: {
        empresa_id: params.empresaId,
        sucursal_id: params.sucursalId,
        proveedor_id: params.proveedorId,
        compra_id: params.compraId,
        usuario_id: params.userId || null,
        sesion_caja_id: params.sesionCajaId || null,
        moneda_id: params.monedaId || null,
        numero_orden_pago: numeroOrden,
        fecha_pago: params.fechaPago,
        monto_total: totalCuentas,
        cotizacion: 1,
        referencia: params.referencia || null,
        observaciones: params.observaciones || null,
        origen: 'compra_auto',
        estado: aplicaPagos ? 'PAGADO' : 'BORRADOR',
        anulado: false,
        activo: true,
      },
    });

    if (aplicaPagos) {
      const targetCuenta = params.cuentasCompra[0];

      for (const pago of params.detallesPago) {
        await tx.orden_pago_proveedor_det.create({
          data: {
            orden_pago_id: orden.id,
            cuenta_pagar_id: targetCuenta.id,
            medio_pago_id: pago.medio_pago_id,
            monto: this.round(pago.monto, 2),
            monto_moneda: this.round(pago.monto_moneda ?? pago.monto, 2),
            cotizacion: this.round(pago.cotizacion ?? 1, 6),
            referencia: pago.referencia || params.referencia || null,
            observaciones: params.observaciones || null,
          },
        });

        await tx.pagos_proveedor.create({
          data: {
            empresa_id: params.empresaId,
            cuenta_pagar_id: targetCuenta.id,
            proveedor_id: params.proveedorId,
            medio_pago_id: pago.medio_pago_id,
            moneda_id: pago.moneda_id || params.monedaId || targetCuenta.moneda_id || null,
            usuario_id: params.userId || null,
            sesion_caja_id: params.sesionCajaId || null,
            numero_recibo: numeroOrden,
            fecha_pago: params.fechaPago,
            monto: this.round(pago.monto, 2),
            monto_moneda: this.round(pago.monto_moneda ?? pago.monto, 2),
            cotizacion: this.round(pago.cotizacion ?? 1, 6),
            referencia: pago.referencia || params.referencia || null,
            observaciones: params.observaciones || null,
            estado: 'aplicado',
            anulado: false,
            activo: true,
          },
        });
      }

      await tx.cuentas_pagar.update({
        where: { id: targetCuenta.id },
        data: {
          monto_pagado: { increment: totalPagos },
          saldo_pendiente: 0,
          estado: 'pagada',
          updated_at: new Date(),
        },
      });
    }

    await this.recalculateCompraEstado(tx, params.compraId);

    return orden;
  }

  private async getNextNumeroOrdenPago(tx: Tx, empresaId: string, sucursalId: string): Promise<NumeracionOrdenPago> {
    const rows = await tx.$queryRaw<
      Array<{
        id: string;
        numero_actual: number;
        numero_final: number | null;
        punto_expedicion: string | null;
        punto_establecimiento: string | null;
      }>
    >`
      SELECT
        nd.id,
        nd.numero_actual,
        nd.numero_final,
        epe.punto_expedicion,
        es.punto_establecimiento
      FROM numeraciones_documento nd
      INNER JOIN tipo_documento td ON td.id = nd.tipo_documento_id
      INNER JOIN empresas_puntos_expedicion epe ON epe.id = nd.punto_expedicion_id
      INNER JOIN empresas_sucursales es ON es.id = epe.sucursal_id
      WHERE td.codigo = 400
        AND nd.empresa_id = ${empresaId}::uuid
        AND epe.sucursal_id = ${sucursalId}::uuid
        AND nd.active = true
      ORDER BY nd.created_at ASC
      LIMIT 1
      FOR UPDATE
    `;

    if (!rows.length) {
      throw new NotFoundException(
        'No se encontró una numeración activa para Orden de pago proveedor (código 400) en la sucursal seleccionada',
      );
    }

    const numeracion = rows[0];
    const numeroActual = Number(numeracion.numero_actual || 0);
    const numeroFinal = numeracion.numero_final === null ? null : Number(numeracion.numero_final);

    if (numeroFinal !== null && numeroActual > numeroFinal) {
      throw new BadRequestException('Se alcanzó el número final de la numeración de orden de pago');
    }

    const dest = (numeracion.punto_establecimiento || '001').padStart(3, '0').slice(0, 3);
    const pexp = (numeracion.punto_expedicion || '001').padStart(3, '0').slice(0, 3);
    const sec = String(numeroActual).padStart(7, '0');
    const numeroOrden = `${dest}-${pexp}-${sec}`;

    await tx.numeraciones_documento.update({
      where: { id: numeracion.id },
      data: { numero_actual: numeroActual + 1 },
    });

    return {
      numeracionId: numeracion.id,
      numeroOrden,
    };
  }

  private async recalculateCompraEstado(tx: Tx, compraId: string) {
    const cuentas = await tx.cuentas_pagar.findMany({
      where: {
        compra_id: compraId,
        activo: true,
        estado: { not: 'anulada' },
      },
      select: {
        monto_original: true,
        saldo_pendiente: true,
      },
    });

    if (!cuentas.length) return;

    const totalOriginal = cuentas.reduce((acc, c) => acc + this.toNumber(c.monto_original), 0);
    const totalSaldo = cuentas.reduce((acc, c) => acc + this.toNumber(c.saldo_pendiente), 0);

    let estado = 'pendiente';
    if (totalSaldo <= 0.0001) estado = 'pagada';
    else if (totalSaldo < totalOriginal) estado = 'parcial';

    await tx.compra_cab.update({
      where: { id: compraId },
      data: {
        estado,
        updated_at: new Date(),
      },
    });
  }

  private async recalculateProductCosts(tx: Tx, empresaId: string, productIds: string[]) {
    for (const productId of productIds) {
      const rows = await tx.$queryRaw<Array<{ costo_unitario_neto: Prisma.Decimal | null }>>`
        SELECT CASE
          WHEN COALESCE(cd.cantidad, 0) > 0 THEN ROUND(COALESCE(cd.subtotal, 0) / cd.cantidad, 4)
          ELSE 0
        END AS costo_unitario_neto
        FROM compra_det cd
        JOIN compra_cab cc ON cc.id = cd.compra_id
        WHERE cc.empresa_id = ${empresaId}::uuid
          AND cd.producto_id = ${productId}::uuid
          AND COALESCE(cd.afecta_stock, true) = true
          AND COALESCE(cc.anulado, false) = false
          AND COALESCE(cc.activo, true) = true
        ORDER BY COALESCE(cc.fecha_emision, cc.created_at) DESC, cd.created_at DESC
        LIMIT 1
      `;

      const latest = rows.length > 0 ? rows[0] : null;
      await tx.productos.update({
        where: { id: productId },
        data: {
          precio_costo: latest?.costo_unitario_neto ?? null,
          updated_at: new Date(),
        },
      });
    }
  }

  private computeIva(
    subtotal: number,
    porcentajeIva: number,
    cfg: RedondeoConfig = DEFAULT_REDONDEO_CONFIG,
  ): number {
    if (porcentajeIva <= 0) return 0;

    const divisor = 1 + porcentajeIva / 100;
    const base = subtotal / divisor;
    const iva = subtotal - base;
    return this.roundMode(iva, cfg.decimalesMonto, cfg.modo);
  }

  private buildDashboardWhere(
    empresaId: string,
    fechaDesde?: string,
    fechaHasta?: string,
    monedaId?: string,
    proveedorId?: string,
    sucursalId?: string,
    depositoId?: string,
    productoId?: string,
  ): Prisma.compra_cabWhereInput {
    const where: Prisma.compra_cabWhereInput = {
      empresa_id: empresaId,
      activo: true,
      anulado: false,
      estado: {
        notIn: ['ANULADA', 'Anulada', 'anulada', 'importado', 'rechazado'],
      },
    };

    if (monedaId) {
      where.moneda_id = monedaId;
    }
    if (proveedorId) {
      where.proveedor_id = proveedorId;
    }
    if (sucursalId) {
      where.sucursal_id = sucursalId;
    }
    if (depositoId) {
      where.deposito_id = depositoId;
    }
    if (productoId) {
      where.compra_det = {
        some: {
          producto_id: productoId,
        },
      };
    }

    if (fechaDesde || fechaHasta) {
      where.fecha_emision = {};
      if (fechaDesde) where.fecha_emision.gte = this.parseDateBoundary(fechaDesde, false);
      if (fechaHasta) where.fecha_emision.lte = this.parseDateBoundary(fechaHasta, true);
    }

    return where;
  }

  private sumComprasRows(
    rows: Array<{
      total: Prisma.Decimal | null;
      cotizacion: Prisma.Decimal | null;
      moneda: { codigo: string | null } | null;
    }>,
    convertToGs: boolean,
  ): number {
    let total = 0;

    for (const row of rows) {
      const monto = this.toNumber(row.total);
      if (!convertToGs) {
        total += monto;
        continue;
      }

      const codigo = String(row.moneda?.codigo || '').toUpperCase();
      if (codigo === 'PYG' || codigo === 'GS' || !codigo) {
        total += monto;
      } else {
        const cotizacion = this.toNumber(row.cotizacion);
        total += monto * (cotizacion > 0 ? cotizacion : 1);
      }
    }

    return total;
  }

  private parseDateBoundary(value: string, endOfDay: boolean): Date {
    const parts = value.split('-').map(Number);
    if (parts.length === 3 && parts.every((n) => Number.isFinite(n))) {
      const [year, month, day] = parts;
      return endOfDay
        ? new Date(year, month - 1, day, 23, 59, 59, 999)
        : new Date(year, month - 1, day, 0, 0, 0, 0);
    }

    const parsed = new Date(value);
    if (Number.isNaN(parsed.getTime())) {
      return endOfDay ? new Date(9999, 0, 1, 23, 59, 59, 999) : new Date(1970, 0, 1);
    }

    if (endOfDay) {
      parsed.setHours(23, 59, 59, 999);
    } else {
      parsed.setHours(0, 0, 0, 0);
    }

    return parsed;
  }

  private toNumber(value: Prisma.Decimal | number | string | null | undefined): number {
    const n = Number(value || 0);
    return Number.isFinite(n) ? n : 0;
  }

  private toDateOnly(date: Date): Date {
    const normalized = new Date(date);
    normalized.setHours(0, 0, 0, 0);
    return normalized;
  }

  private equalsAmount(a: number, b: number, epsilon = 0.01): boolean {
    return Math.abs(this.round(a, 2) - this.round(b, 2)) <= epsilon;
  }

  /**
   * Cotización con la que se valoriza la mercadería importada.
   *
   * Es criterio contable y cada empresa usa el suyo, así que se configura en
   * `imp_config_empresa.criterio_cotizacion`. Si la cotización elegida todavía no
   * existe (p.ej. se carga la factura antes del despacho) se cae a la de la
   * factura, que es la única que seguro está: mejor costear con esa que con 1.
   */
  private async resolverCotizacionImportacion(
    tx: Tx,
    empresaId: string,
    embarqueId: string,
    cotizacionFactura: number,
  ): Promise<number> {
    const fallback = Math.max(Number(cotizacionFactura) || 1, 0.000001);

    const config = await tx.imp_config_empresa.findUnique({
      where: { empresa_id: empresaId },
      select: { criterio_cotizacion: true },
    });
    const criterio = config?.criterio_cotizacion ?? 'FACTURA';
    if (criterio === 'FACTURA') return fallback;

    if (criterio === 'DESPACHO') {
      const despacho = await tx.imp_despachos.findFirst({
        where: { embarque_id: embarqueId, empresa_id: empresaId },
        select: { cotizacion_usada: true },
      });
      const valor = Number(despacho?.cotizacion_usada ?? 0);
      return valor > 0 ? valor : fallback;
    }

    const embarque = await tx.imp_embarques.findFirst({
      where: { id: embarqueId, empresa_id: empresaId },
      select: { cotizacion_cierre: true },
    });
    const valor = Number(embarque?.cotizacion_cierre ?? 0);
    return valor > 0 ? valor : fallback;
  }

  /**
   * Convierte la factura de mercadería del exterior en el componente de costo del
   * embarque. Mismo patrón que `gastos.service.crearComponenteImportacion`, pero
   * el concepto por defecto es FOB: acá lo que llega es la mercadería, no un gasto.
   */
  private async crearComponenteImportacion(
    tx: Tx,
    params: {
      compraId: string;
      empresaId: string;
      embarqueId: string;
      conceptoCostoId?: string;
      convertirComponenteId?: string;
      proveedorId: string | null;
      monedaId: string | null;
      numeroFactura?: string | null;
      importe: number;
      cotizacionFactura: number;
      fechaDevengo: Date;
    },
  ) {
    const embarque = await tx.imp_embarques.findFirst({
      where: { id: params.embarqueId, empresa_id: params.empresaId },
      select: { id: true, estado: true },
    });
    if (!embarque) throw new BadRequestException('El embarque indicado no existe');
    if (['CERRADO', 'SELLADO'].includes(String(embarque.estado))) {
      throw new BadRequestException(
        `No se puede imputar una factura a un embarque ${embarque.estado}.`,
      );
    }

    const importe = Math.max(Number(params.importe) || 0, 0);
    const cotizacion = await this.resolverCotizacionImportacion(
      tx,
      params.empresaId,
      params.embarqueId,
      params.cotizacionFactura,
    );
    const datos = {
      compra_id: params.compraId,
      proveedor_id: params.proveedorId,
      moneda_id: params.monedaId,
      numero_factura: params.numeroFactura?.slice(0, 100) ?? null,
      importe,
      cotizacion,
      importe_gs: Math.round(importe * cotizacion),
      fecha_devengo: params.fechaDevengo,
      activo: true,
      updated_at: new Date(),
    };

    // Regularización: el FOB ya estaba tipeado a mano y ahora se le carga su
    // factura. Se convierte ese componente en vez de crear otro, porque
    // duplicarlo inflaría el costeo del embarque.
    if (params.convertirComponenteId) {
      const existente = await tx.imp_componentes_costo.findFirst({
        where: {
          id: params.convertirComponenteId,
          empresa_id: params.empresaId,
          embarque_id: params.embarqueId,
        },
        select: { id: true, compra_id: true, gasto_id: true },
      });
      if (!existente) {
        throw new BadRequestException('El costo a convertir no existe o no pertenece a este embarque.');
      }
      if (existente.compra_id || existente.gasto_id) {
        throw new BadRequestException('Ese costo ya tiene un comprobante asociado.');
      }
      await tx.imp_componentes_costo.update({ where: { id: existente.id }, data: datos });
      await this.costeo.recalcular(tx, params.empresaId, params.embarqueId);
      return;
    }

    let conceptoId = params.conceptoCostoId;
    if (!conceptoId) {
      const concepto = await tx.imp_conceptos_costo.findFirst({
        where: {
          OR: [{ empresa_id: params.empresaId }, { empresa_id: null, es_sistema: true }],
          codigo: 'FOB',
        },
        select: { id: true },
      });
      if (!concepto) {
        throw new BadRequestException(
          'No existe el concepto de costo FOB. Elegí un concepto al vincular la factura al embarque.',
        );
      }
      conceptoId = concepto.id;
    }

    await tx.imp_componentes_costo.create({
      data: {
        ...datos,
        empresa_id: params.empresaId,
        embarque_id: params.embarqueId,
        concepto_id: conceptoId,
        // Compartido y prorrateado por FOB, NO directo.
        //
        // Un componente directo sólo llega al ítem si tiene `item_id` (ver el
        // filtro `es_directo && item_id === item.id` en el costeo), y una factura
        // del exterior cubre varios ítems a la vez: no hay un único ítem al que
        // atarla. Marcándola directa sin `item_id` el costo se perdía en silencio
        // y el embarque quedaba valorizado sin la mercadería.
        //
        // Por FOB es además el reparto correcto: la factura se distribuye entre
        // los ítems en proporción a su valor en origen, que es lo que representan
        // sus propias líneas. Si los ítems no tienen FOB cargado el costeo corta
        // con un mensaje explícito, que es mejor que repartir con un criterio
        // inventado.
        es_directo: false,
        criterio_prorrateo: 'FOB',
        estado: 'CONFIRMADO',
      },
    });
    await this.costeo.recalcular(tx, params.empresaId, params.embarqueId);
  }

  /** Filtro sobre la marca del comprobante (compra_cab / gasto_cab). */
  private filtroPreimpreso(tipo: TipoComprobanteLibro): { es_preimpreso?: boolean } {
    if (tipo === 'preimpresos') return { es_preimpreso: true };
    if (tipo === 'electronicos') return { es_preimpreso: false };
    return {};
  }

  /**
   * Las NC de compra no tienen marca propia: se hereda del documento que anulan,
   * así que se filtran por la relación. Una NC suelta (sin compra ni gasto) se
   * toma como preimpresa, el mismo default que usa el resto del módulo.
   *
   * Sin esto, la NC de una factura electrónica caía en el listado de preimpresos
   * y restaba crédito de un cruce al que no pertenece.
   */
  private filtroPreimpresoNC(tipo: TipoComprobanteLibro): Prisma.nota_credito_compra_cabWhereInput {
    if (tipo === 'todos') return {};
    if (tipo === 'electronicos') {
      return {
        OR: [{ compra_cab: { es_preimpreso: false } }, { gasto_cab: { es_preimpreso: false } }],
      };
    }
    return {
      OR: [
        { compra_cab: { es_preimpreso: true } },
        { gasto_cab: { es_preimpreso: true } },
        { compra_id: null, gasto_id: null },
      ],
    };
  }

  async getLibroIvaCompras(
    empresa_id: string,
    mes: number,
    anio: number,
    moneda_id?: string,
    tipoComprobante: TipoComprobanteLibro = 'preimpresos',
    usuarioId?: string,
  ) {
    await exigirAccesoElevado(this.prisma, usuarioId, empresa_id, 'El Libro IVA de compras');
    // Límites del período en UTC. fecha_emision es @db.Date: Prisma la lee y la
    // compara como fecha-calendario a medianoche UTC. Con límites en hora local
    // (el server corre en -03) el `lte` del último día quedaba en 23:59:59.999
    // local = 02:59 UTC del día siguiente, así que el reporte de junio arrastraba
    // todo lo emitido el 1 de julio. Lo reportó contabilidad de DOBA y se
    // reprodujo: junio traía 12 documentos, 10 de ellos del 01/07.
    // El export a Marangatu ya usaba UTC; el reporte en pantalla había quedado atrás.
    const fechaDesde = new Date(Date.UTC(anio, mes - 1, 1, 0, 0, 0, 0));
    const fechaHasta = new Date(Date.UTC(anio, mes, 0, 23, 59, 59, 999));

    const where: any = {
      empresa_id,
      fecha_emision: { gte: fechaDesde, lte: fechaHasta },
      anulado: false,
      // Un comprobante sin importe no tiene nada que declarar. La sincronización con
      // Marangatu deja una compra por cada documento electrónico del proveedor con la
      // cabecera y el CDC pero sin montos (estado "importado"); si el usuario después
      // carga ese mismo comprobante por Gastos, el libro mostraba las dos: la real con
      // sus importes y la cáscara del import en cero. Esas líneas en cero son las que
      // ensuciaban el reporte, no comprobantes a los que les falte el monto.
      total: { gt: 0 },
      // La factura del proveedor del exterior no es comprobante paraguayo: el IVA
      // de esa importación se paga en aduana y se declara con el despacho, que sí
      // entra al libro más abajo. Incluirla acá duplicaría la base gravada.
      embarque_id: null,
    };
    if (moneda_id) where.moneda_id = moneda_id;
    // Por defecto solo lo que hay que declarar: los electrónicos ya llegaron al SET por
    // su propio documento y volver a subirlos al Libro IVA los duplicaría.
    Object.assign(where, this.filtroPreimpreso(tipoComprobante));

    const compras = await this.prisma.compra_cab.findMany({
      where,
      include: {
        proveedores: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
        moneda: { select: { codigo: true } },
        condicion_operacion: { select: { descripcion: true } },
      },
      orderBy: { fecha_emision: 'asc' },
    });

    let totalGravada10 = 0;
    let totalGravada5 = 0;
    let totalExentas = 0;
    let totalIva10 = 0;
    let totalIva5 = 0;
    let totalOperaciones = 0;

    const detalle = compras.map((c) => {
      const gravada10 = Number(c.total_gravado_10 ?? 0);
      const gravada5 = Number(c.total_gravado_5 ?? 0);
      const exentas = Number(c.total_exento ?? 0);
      const iva10 = Number(c.total_iva_10 ?? 0);
      const iva5 = Number(c.total_iva_5 ?? 0);
      const total = Number(c.total ?? 0);

      totalGravada10 += gravada10;
      totalGravada5 += gravada5;
      totalExentas += exentas;
      totalIva10 += iva10;
      totalIva5 += iva5;
      totalOperaciones += total;

      const fecha = c.fecha_emision ? c.fecha_emision.toISOString().split('T')[0] : null;
      const numero = [c.establecimiento, c.punto_expedicion, c.numero_factura].filter(Boolean).join('-');

      return {
        tipo: 'FACTURA_COMPRA',
        fecha,
        numero,
        timbrado: c.timbrado_proveedor || '',
        proveedor: c.proveedores?.personas?.razon_social || 'Sin nombre',
        ruc: c.proveedores?.personas?.ruc
          ? `${c.proveedores.personas.ruc}-${c.proveedores.personas.dv ?? ''}`
          : '',
        condicion: c.condicion_operacion?.descripcion || 'Contado',
        moneda: c.moneda?.codigo || 'PYG',
        gravada_10: gravada10,
        gravada_5: gravada5,
        exentas,
        iva_10: iva10,
        iva_5: iva5,
        total,
        es_preimpreso: c.es_preimpreso !== false,
      };
    });

    // --- Gastos y rendiciones de viáticos ---------------------------------
    // Todo comprobante de compra con timbrado va al Libro IVA, sin importar por
    // qué pantalla se cargó. Los gastos (incluidos los de una rendición de
    // viáticos, que son gasto_cab con rendicion_viatico_id) generan crédito
    // fiscal igual que una factura de proveedor.
    const gastoWhere: any = {
      empresa_id,
      fecha_emision: { gte: fechaDesde, lte: fechaHasta },
      anulado: false,
      activo: true,
      // Sin número de factura no hay comprobante que declarar.
      numero_factura: { not: null },
    };
    if (moneda_id) gastoWhere.moneda_id = moneda_id;
    Object.assign(gastoWhere, this.filtroPreimpreso(tipoComprobante));

    let gastos: any[] = [];
    try {
      gastos = await this.prisma.gasto_cab.findMany({
        where: gastoWhere,
        include: {
          proveedores: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
          moneda: { select: { codigo: true } },
        },
        orderBy: { fecha_emision: 'asc' },
      });
    } catch (error) {
      this.logger.error(
        `Libro IVA Compras: no se pudieron cargar los gastos: ${error instanceof Error ? error.message : 'error'}`,
      );
      gastos = [];
    }

    let gastoGravada10 = 0;
    let gastoGravada5 = 0;
    let gastoExentas = 0;
    let gastoIva10 = 0;
    let gastoIva5 = 0;
    let gastoTotal = 0;
    // IVA de gastos marcados NO deducibles: se listan en el libro (el comprobante
    // existe y se declara) pero no suman al crédito fiscal. Mismo criterio que usa
    // GastosService para su KPI `iva_credito`.
    let gastoIvaNoDeducible = 0;
    let gastosNoDeducibles = 0;

    const detalleGastos = gastos.map((g) => {
      const gravada10 = Number(g.total_gravado_10 ?? 0);
      const gravada5 = Number(g.total_gravado_5 ?? 0);
      const exentas = Number(g.total_exento ?? 0);
      const iva10 = Number(g.total_iva_10 ?? 0);
      const iva5 = Number(g.total_iva_5 ?? 0);
      const total = Number(g.total ?? 0);
      const esDeducible = g.deducible !== false;

      gastoGravada10 += gravada10;
      gastoGravada5 += gravada5;
      gastoExentas += exentas;
      gastoTotal += total;
      if (esDeducible) {
        gastoIva10 += iva10;
        gastoIva5 += iva5;
      } else {
        gastoIvaNoDeducible += iva10 + iva5;
        gastosNoDeducibles += 1;
      }

      const fecha = g.fecha_emision ? g.fecha_emision.toISOString().split('T')[0] : null;
      const numero = [g.establecimiento, g.punto_expedicion, g.numero_factura].filter(Boolean).join('-');
      const esViatico = !!g.rendicion_viatico_id;

      // El Libro IVA solo admite Contado o Crédito. `forma_pago` guarda el MEDIO de
      // pago (ANTICIPO en las rendiciones de viáticos, TRANSFERENCIA, etc.), que no
      // es la condición fiscal: pagar con transferencia o con un anticipo sigue
      // siendo una compra al contado. Solo el gasto diferido va como crédito.
      const condicionFiscal = String(g.forma_pago ?? '')
        .toUpperCase()
        .includes('CREDITO')
        ? 'Crédito'
        : 'Contado';

      // El RUC puede venir del proveedor vinculado o cargado a mano en el gasto
      // (acreedores ocasionales, exterior, rendiciones sin proveedor en maestro).
      const rucProveedor = g.proveedores?.personas?.ruc
        ? `${g.proveedores.personas.ruc}-${g.proveedores.personas.dv ?? ''}`
        : (g.ruc ?? '');

      return {
        tipo: esViatico ? 'VIATICO' : 'GASTO',
        fecha,
        numero,
        timbrado: g.timbrado || '',
        proveedor: g.proveedores?.personas?.razon_social || g.descripcion || 'Sin nombre',
        ruc: rucProveedor,
        condicion: condicionFiscal,
        moneda: g.moneda?.codigo || 'PYG',
        gravada_10: gravada10,
        gravada_5: gravada5,
        exentas,
        iva_10: iva10,
        iva_5: iva5,
        total,
        deducible: esDeducible,
        es_preimpreso: g.es_preimpreso !== false,
      };
    });

    // --- Notas de crédito de compra: restan del crédito fiscal del período ---
    const ncWhere: any = {
      empresa_id,
      fecha_emision: { gte: fechaDesde, lte: fechaHasta },
      estado: { not: 'anulada' },
    };
    if (moneda_id) ncWhere.moneda_id = moneda_id;
    Object.assign(ncWhere, this.filtroPreimpresoNC(tipoComprobante));

    let notasCredito: any[] = [];
    try {
      notasCredito = await this.prisma.nota_credito_compra_cab.findMany({
        where: ncWhere,
        include: {
          proveedores: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
          moneda: { select: { codigo: true } },
          detalles: {
            select: { subtotal: true, monto_iva: true, porcentaje_iva: true },
          },
        },
        orderBy: { fecha_emision: 'asc' },
      });
    } catch (error) {
      // Fail-soft: las facturas son el contenido principal del Libro IVA Compras; si la
      // subconsulta de NC falla (tabla ausente por migración desfasada u otro motivo),
      // se omiten las NC y el reporte igual muestra las facturas, dejando traza en el log.
      this.logger.error(
        `Libro IVA Compras: no se pudieron cargar las NC de compra: ${error instanceof Error ? error.message : 'error'}`,
      );
      notasCredito = [];
    }

    let ncGravada10 = 0;
    let ncGravada5 = 0;
    let ncExentas = 0;
    let ncIva10 = 0;
    let ncIva5 = 0;
    let ncTotal = 0;

    const detalleNC = notasCredito.map((nc) => {
      // Desglose de gravadas por tasa a partir del detalle (IVA incluido).
      let gravada10 = 0;
      let gravada5 = 0;
      let exentas = 0;
      let iva10 = 0;
      let iva5 = 0;
      for (const d of nc.detalles || []) {
        const sub = Number(d.subtotal ?? 0);
        const ivaLinea = Number(d.monto_iva ?? 0);
        const tasa = Number(d.porcentaje_iva ?? 0);
        const gravadaLinea = sub - ivaLinea;
        if (tasa >= 9.5) {
          gravada10 += gravadaLinea;
          iva10 += ivaLinea;
        } else if (tasa >= 4.5) {
          gravada5 += gravadaLinea;
          iva5 += ivaLinea;
        } else {
          exentas += sub;
        }
      }
      const total = Number(nc.total ?? 0);

      // Acumuladores de NC (se restan del crédito fiscal neto más abajo).
      ncGravada10 += gravada10;
      ncGravada5 += gravada5;
      ncExentas += exentas;
      ncIva10 += iva10;
      ncIva5 += iva5;
      ncTotal += total;

      const fecha = nc.fecha_emision ? nc.fecha_emision.toISOString().split('T')[0] : null;
      const numero = [nc.establecimiento, nc.punto_expedicion, nc.numero].filter(Boolean).join('-');

      return {
        tipo: 'NOTA_CREDITO_COMPRA',
        fecha,
        numero,
        timbrado: nc.timbrado || '',
        proveedor: nc.proveedores?.personas?.razon_social || 'Sin nombre',
        ruc: nc.proveedores?.personas?.ruc
          ? `${nc.proveedores.personas.ruc}-${nc.proveedores.personas.dv ?? ''}`
          : '',
        condicion: 'Nota de crédito',
        moneda: nc.moneda?.codigo || 'PYG',
        gravada_10: -gravada10,
        gravada_5: -gravada5,
        exentas: -exentas,
        iva_10: -iva10,
        iva_5: -iva5,
        total: -total,
      };
    });

    // --- Despachos de importación -----------------------------------------
    // El IVA importación se paga en la aduana y es crédito fiscal, pero no llega
    // por SIFEN: el contribuyente lo declara con el despacho. Sin esto el crédito
    // quedaba fuera del reporte (DOBA tenía Gs. 39.192.006 sin reflejar).
    //
    // Va con los preimpresos porque comparte su criterio: es lo que hay que
    // declarar a mano. En el listado de electrónicos no tiene sentido.
    let despachos: any[] = [];
    if (tipoComprobante !== 'electronicos') {
      try {
        despachos = await this.prisma.imp_despachos.findMany({
          where: {
            empresa_id,
            fecha_despacho: { gte: fechaDesde, lte: fechaHasta },
            estado: 'CONFIRMADO',
            iva_importacion_gs: { gt: 0 },
          },
          include: {
            despachante: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
            embarque: {
              select: {
                numero: true,
                proveedores: { include: { personas: { select: { razon_social: true } } } },
              },
            },
          },
          orderBy: { fecha_despacho: 'asc' },
        });
      } catch (error) {
        // Fail-soft, igual que las NC: si el módulo de importaciones no está
        // migrado en esta base, el libro sigue mostrando el resto.
        this.logger.error(
          `Libro IVA Compras: no se pudieron cargar los despachos: ${error instanceof Error ? error.message : 'error'}`,
        );
        despachos = [];
      }
    }

    let despGravada10 = 0;
    let despIva10 = 0;
    let despTotal = 0;

    const detalleDespachos = despachos.map((d) => {
      const iva10 = Number(d.iva_importacion_gs ?? 0);
      // La base imponible no se guarda: se deriva de la tasa del 10%, que es la
      // relación que la propia liquidación aduanera usó para calcular ese IVA.
      const gravada10 = this.round(iva10 * 10, 2);
      const total = gravada10 + iva10;

      despGravada10 += gravada10;
      despIva10 += iva10;
      despTotal += total;

      const persona = d.despachante?.personas;
      return {
        tipo: 'DESPACHO_IMPORTACION',
        fecha: d.fecha_despacho ? d.fecha_despacho.toISOString().split('T')[0] : null,
        numero: d.numero_despacho,
        timbrado: '',
        proveedor:
          persona?.razon_social ||
          d.embarque?.proveedores?.personas?.razon_social ||
          'Despacho de importación',
        ruc: persona?.ruc ? `${persona.ruc}-${persona.dv ?? ''}` : '',
        condicion: `Despacho${d.embarque?.numero ? ` — embarque ${d.embarque.numero}` : ''}`,
        moneda: 'PYG',
        gravada_10: gravada10,
        gravada_5: 0,
        exentas: 0,
        iva_10: iva10,
        iva_5: 0,
        total,
        es_preimpreso: true,
      };
    });

    // Detalle unificado ordenado por fecha (facturas + gastos/viáticos + despachos + NC).
    const detalleCompleto = [...detalle, ...detalleGastos, ...detalleDespachos, ...detalleNC].sort(
      (a, b) => (a.fecha || '').localeCompare(b.fecha || ''),
    );

    return {
      periodo: { mes, anio, desde: fechaDesde, hasta: fechaHasta },
      resumen: {
        compras: {
          cantidad: compras.length,
          gravada_10: totalGravada10,
          gravada_5: totalGravada5,
          exentas: totalExentas,
          iva_10: totalIva10,
          iva_5: totalIva5,
          total: totalOperaciones,
        },
        gastos: {
          cantidad: gastos.length,
          gravada_10: gastoGravada10,
          gravada_5: gastoGravada5,
          exentas: gastoExentas,
          // Solo el IVA deducible: es lo que efectivamente suma al crédito fiscal.
          iva_10: gastoIva10,
          iva_5: gastoIva5,
          total: gastoTotal,
          cantidad_no_deducibles: gastosNoDeducibles,
          iva_no_deducible: gastoIvaNoDeducible,
        },
        despachos_importacion: {
          cantidad: despachos.length,
          gravada_10: despGravada10,
          gravada_5: 0,
          exentas: 0,
          iva_10: despIva10,
          iva_5: 0,
          total: despTotal,
        },
        notas_credito: {
          cantidad: notasCredito.length,
          gravada_10: ncGravada10,
          gravada_5: ncGravada5,
          exentas: ncExentas,
          iva_10: ncIva10,
          iva_5: ncIva5,
          total: ncTotal,
        },
        credito_fiscal: {
          // Neto = crédito de facturas + gastos/viáticos + IVA importación de los
          // despachos, menos IVA de notas de crédito.
          iva_10: totalIva10 + gastoIva10 + despIva10 - ncIva10,
          iva_5: totalIva5 + gastoIva5 - ncIva5,
          total:
            totalIva10 + totalIva5 + gastoIva10 + gastoIva5 + despIva10 - (ncIva10 + ncIva5),
        },
      },
      detalle: detalleCompleto,
    };
  }

  private async logOrdenCompraHistorial(input: {
    empresaId: string;
    userId?: string | null;
    ordenCompraId: string;
    action: string;
    descripcion: string;
    oldValue?: Record<string, unknown>;
    newValue?: Record<string, unknown>;
  }) {
    try {
      await this.prisma.audit_logs.create({
        data: {
          empresa_id: input.empresaId,
          user_id: input.userId || null,
          action: input.action,
          entity_type: 'orden_compra',
          entity_id: input.ordenCompraId,
          descripcion: input.descripcion,
          old_value: input.oldValue ? JSON.parse(JSON.stringify(input.oldValue)) : undefined,
          new_value: input.newValue ? JSON.parse(JSON.stringify(input.newValue)) : undefined,
        },
      });
    } catch (err) {
      this.logger.error(
        `No se pudo registrar historial de OC (${input.ordenCompraId}): ${err instanceof Error ? err.message : 'error'}`,
      );
    }
  }

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

  /**
   * Redondea `value` a `decimals` decimales según el modo configurado por la empresa
   * para la moneda de la compra. HALF_UP es el comportamiento histórico (default).
   */
  private roundMode(
    value: number,
    decimals: number,
    modo: ComprasModoRedondeo = 'HALF_UP',
  ): number {
    if (!Number.isFinite(value)) return 0;
    const factor = Math.pow(10, Math.max(0, decimals));
    const scaled = value * factor;
    const EPS = 1e-9;
    let r: number;
    switch (modo) {
      case 'DOWN': // truncar hacia cero
        r = scaled >= 0 ? Math.floor(scaled + EPS) : Math.ceil(scaled - EPS);
        break;
      case 'UP': // alejar de cero
        r = scaled >= 0 ? Math.ceil(scaled - EPS) : Math.floor(scaled + EPS);
        break;
      case 'HALF_EVEN': {
        const floor = Math.floor(scaled);
        const diff = scaled - floor;
        if (diff < 0.5 - EPS) r = floor;
        else if (diff > 0.5 + EPS) r = floor + 1;
        else r = floor % 2 === 0 ? floor : floor + 1;
        break;
      }
      case 'HALF_UP':
      default:
        r = Math.round(scaled + (scaled >= 0 ? EPS : -EPS));
        break;
    }
    return r / factor;
  }

  /**
   * Resuelve la config de decimales/redondeo de compras para una moneda, con cadena de
   * fallback independiente de Ventas: config_compras_moneda →
   * moneda.decimales_estandar → default (2 montos / 4 porcentaje / HALF_UP).
   */
  private async resolveRedondeoConfig(
    tx: Prisma.TransactionClient,
    empresaId: string,
    monedaId?: string | null,
  ): Promise<RedondeoConfig> {
    const DEFAULT: RedondeoConfig = {
      decimalesMonto: 2,
      decimalesPorcentaje: 4,
      modo: 'HALF_UP',
    };
    if (!monedaId) return DEFAULT;

    const cfg = await tx.config_compras_moneda.findUnique({
      where: { empresa_id_moneda_id: { empresa_id: empresaId, moneda_id: monedaId } },
    });

    // Default de compras independiente de ventas: usa el estándar intrínseco de la moneda
    // (PYG = 2) en vez de empresa_monedas.decimales_visualizar (que en ventas es 0 para PYG).
    const decimalesFallback = async (): Promise<number> => {
      const mon = await tx.moneda.findUnique({
        where: { id: monedaId },
        select: { decimales_estandar: true },
      });
      return mon?.decimales_estandar ?? 2;
    };

    if (cfg) {
      return {
        decimalesMonto: cfg.decimales_monto ?? (await decimalesFallback()),
        decimalesPorcentaje: cfg.decimales_porcentaje,
        modo: cfg.modo_redondeo as ComprasModoRedondeo,
      };
    }
    return {
      decimalesMonto: await decimalesFallback(),
      decimalesPorcentaje: 4,
      modo: 'HALF_UP',
    };
  }

  /**
   * Libro IVA Compras en el formato importable de Marangatu (Registro de
   * Comprobantes, registro tipo 2). Incluye facturas de compra, gastos y notas
   * de crédito de compra. Los flags IMPUTA IVA/IRE/IRP-RSP se toman de las
   * obligaciones de la empresa; NO IMPUTA se deja en 'N'.
   * @param secuencia identificador del archivo (<=5 alfanum.), p.ej. 'C0001'.
   */
  async getLibroIvaComprasMarangatu(
    empresa_id: string,
    mes: number,
    anio: number,
    moneda_id?: string,
    secuencia = 'C0001',
    tipoComprobante: TipoComprobanteLibro = 'preimpresos',
    usuarioId?: string,
  ) {
    await exigirAccesoElevado(this.prisma, usuarioId, empresa_id, 'El Libro IVA de compras para Marangatu');
    // Límites del período en UTC: las fechas se guardan como fecha-calendario a
    // medianoche UTC. Con límites en hora local (servidor fuera de UTC) un
    // comprobante del día 1 o del último día caería en el mes vecino (bug de
    // "fechas mezcladas"). Consistente con fmtFecha (que también lee en UTC).
    const fechaDesde = new Date(Date.UTC(anio, mes - 1, 1, 0, 0, 0, 0));
    const fechaHasta = new Date(Date.UTC(anio, mes, 0, 23, 59, 59, 999));

    const empresa = await this.prisma.empresas.findUnique({
      where: { id: empresa_id },
      select: { ruc: true, imputa_iva: true, imputa_ire: true, imputa_irp_rsp: true },
    });
    if (!empresa) throw new NotFoundException('Empresa no encontrada');
    const imputaIva = Marangatu.boolSN(empresa.imputa_iva);
    const imputaIre = Marangatu.boolSN(empresa.imputa_ire);
    const imputaIrp = Marangatu.boolSN(empresa.imputa_irp_rsp);
    const NO_IMPUTA = 'N';

    const personaSel = {
      personas: { select: { razon_social: true, ruc: true, dv: true, nro_documento: true } },
    };
    const baseWhere: any = { empresa_id, fecha_emision: { gte: fechaDesde, lte: fechaHasta } };
    const monedaW = moneda_id ? { moneda_id } : {};
    // Mismo criterio que getLibroIvaCompras: los electrónicos ya están en el SET.
    const preimpresoW = this.filtroPreimpreso(tipoComprobante);
    const preimpresoNCW = this.filtroPreimpresoNC(tipoComprobante);

    const [compras, gastos, notasCredito] = await Promise.all([
      this.prisma.compra_cab.findMany({
        where: { ...baseWhere, anulado: false, ...monedaW, ...preimpresoW },
        include: {
          proveedores: { include: personaSel },
          moneda: { select: { codigo: true } },
          condicion_operacion: { select: { descripcion: true } },
          compra_det: { select: { subtotal: true, porcentaje_iva: true } },
        },
        orderBy: { fecha_emision: 'asc' },
      }),
      this.prisma.gasto_cab.findMany({
        where: {
          ...baseWhere,
          anulado: false,
          activo: true,
          numero_factura: { not: null },
          ...monedaW,
          ...preimpresoW,
        },
        include: { proveedores: { include: personaSel }, moneda: { select: { codigo: true } } },
        orderBy: { fecha_emision: 'asc' },
      }),
      this.prisma.nota_credito_compra_cab.findMany({
        where: {
          empresa_id,
          fecha_emision: { gte: fechaDesde, lte: fechaHasta },
          estado: { not: 'anulada' },
          ...monedaW,
          ...preimpresoNCW,
        },
        include: {
          proveedores: { include: personaSel },
          moneda: { select: { codigo: true } },
          detalles: { select: { subtotal: true, monto_iva: true, porcentaje_iva: true } },
          compra_cab: { select: { establecimiento: true, punto_expedicion: true, numero_factura: true, timbrado_proveedor: true } },
        },
        orderBy: { fecha_emision: 'asc' },
      }),
    ]);

    const lineas: string[] = [];

    const armarFila = (opts: {
      persona: Marangatu.PersonaMarangatu | null;
      tipoComprobante: number;
      fecha: Date | string | null;
      timbrado?: string | null;
      est?: string | null;
      punto?: string | null;
      numero?: string | null;
      gross10: number;
      gross5: number;
      exento: number;
      condicionDesc?: string | null;
      monedaCod?: string | null;
      asocNumero?: string;
      asocTimbrado?: string;
    }) => {
      const tipoId = Marangatu.tipoIdentificacion(opts.persona);
      const g10 = Math.max(0, Math.round(opts.gross10));
      const g5 = Math.max(0, Math.round(opts.gross5));
      const ex = Math.max(0, Math.round(opts.exento));
      const total = g10 + g5 + ex;
      return Marangatu.filaCsv([
        Marangatu.TIPO_REGISTRO.COMPRAS, // 1 tipo de registro
        tipoId, // 2 tipo identificación proveedor
        Marangatu.numeroIdentificacion(opts.persona), // 3 número identificación
        Marangatu.nombreRazonSocial(opts.persona?.razon_social), // 4 nombre/razón social
        opts.tipoComprobante, // 5 tipo de comprobante
        Marangatu.fmtFecha(opts.fecha), // 6 fecha emisión
        Marangatu.timbrado(opts.timbrado), // 7 timbrado
        Marangatu.numeroComprobante(opts.est, opts.punto, opts.numero), // 8 número comprobante
        Marangatu.montoEntero(g10), // 9 gravado 10% (IVA incluido)
        Marangatu.montoEntero(g5), // 10 gravado 5% (IVA incluido)
        Marangatu.montoEntero(ex), // 11 no gravado / exento
        Marangatu.montoEntero(total), // 12 total del comprobante
        opts.tipoComprobante === Marangatu.TIPO_COMPROBANTE.FACTURA
          ? Marangatu.condicionCodigo(opts.condicionDesc)
          : '', // 13 condición de compra (requerido para factura)
        Marangatu.monedaExtranjera(opts.monedaCod), // 14 operación en moneda extranjera
        imputaIva, // 15 imputa IVA
        imputaIre, // 16 imputa IRE
        imputaIrp, // 17 imputa IRP-RSP
        NO_IMPUTA, // 18 no imputa
        opts.asocNumero ?? '', // 19 número comprobante de compra asociado
        opts.asocTimbrado ?? '', // 20 timbrado comprobante asociado
      ]);
    };

    // Gravado por tasa (IVA incluido). Los totales de cabecera son la fuente
    // primaria; si están en 0 (p. ej. compras migradas), se agregan del detalle.
    const montosCompra = (c: any) => {
      let g10 = Number(c.total_gravado_10 ?? 0);
      let g5 = Number(c.total_gravado_5 ?? 0);
      let ex = Number(c.total_exento ?? 0);
      if (g10 + g5 + ex <= 0 && Array.isArray(c.compra_det) && c.compra_det.length) {
        g10 = 0;
        g5 = 0;
        ex = 0;
        for (const d of c.compra_det) {
          const sub = Number(d.subtotal ?? 0);
          const tasa = Number(d.porcentaje_iva ?? 0);
          if (tasa === 10) g10 += sub;
          else if (tasa === 5) g5 += sub;
          else ex += sub;
        }
      }
      return { gross10: g10, gross5: g5, exento: ex };
    };

    // Deduplicación por comprobante (timbrado + establecimiento + punto + número):
    // un mismo documento puede haberse cargado como factura de compra Y como gasto.
    // La factura de compra es la fuente canónica; el duplicado en gastos se omite.
    const vistos = new Set<string>();
    let duplicadosOmitidos = 0;
    const clave = (timbrado?: string | null, est?: string | null, punto?: string | null, numero?: string | null) => {
      const num = Marangatu.numeroComprobante(est, punto, numero);
      if (!num) return null; // sin número no se puede deduplicar de forma confiable
      return `${Marangatu.timbrado(timbrado)}|${num}`;
    };

    // Facturas de compra (gravado ya viene IVA incluido)
    for (const c of compras) {
      vistos.add(clave(c.timbrado_proveedor, c.establecimiento, c.punto_expedicion, c.numero_factura) ?? `compra:${c.id}`);
      lineas.push(
        armarFila({
          persona: c.proveedores?.personas ?? null,
          tipoComprobante: Marangatu.TIPO_COMPROBANTE.FACTURA,
          fecha: c.fecha_emision,
          timbrado: c.timbrado_proveedor,
          est: c.establecimiento,
          punto: c.punto_expedicion,
          numero: c.numero_factura,
          ...montosCompra(c),
          condicionDesc: c.condicion_operacion?.descripcion,
          monedaCod: c.moneda?.codigo,
        }),
      );
    }

    // Gastos (incluye rendiciones de viáticos). Proveedor del maestro o RUC manual.
    for (const g of gastos) {
      const k = clave(g.timbrado, g.establecimiento, g.punto_expedicion, g.numero_factura);
      if (k && vistos.has(k)) {
        duplicadosOmitidos += 1;
        continue; // ya está como factura de compra
      }
      if (k) vistos.add(k);
      const personaMaestro = g.proveedores?.personas;
      const persona: Marangatu.PersonaMarangatu | null = personaMaestro
        ? personaMaestro
        : g.ruc
        ? { ruc: String(g.ruc).split('-')[0].replace(/\D/g, ''), razon_social: g.descripcion, nro_documento: null }
        : null;
      lineas.push(
        armarFila({
          persona,
          tipoComprobante: Marangatu.TIPO_COMPROBANTE.FACTURA,
          fecha: g.fecha_emision,
          timbrado: g.timbrado,
          est: g.establecimiento,
          punto: g.punto_expedicion,
          numero: g.numero_factura,
          gross10: Number(g.total_gravado_10 ?? 0),
          gross5: Number(g.total_gravado_5 ?? 0),
          exento: Number(g.total_exento ?? 0),
          condicionDesc: g.forma_pago,
          monedaCod: g.moneda?.codigo,
        }),
      );
    }

    // Notas de crédito de compra (110). Gravado IVA incluido = subtotal de cada línea.
    for (const nc of notasCredito) {
      const kNc = clave(nc.timbrado, nc.establecimiento, nc.punto_expedicion, nc.numero);
      if (kNc && vistos.has(kNc)) {
        duplicadosOmitidos += 1;
        continue;
      }
      if (kNc) vistos.add(kNc);
      let gross10 = 0;
      let gross5 = 0;
      let exento = 0;
      for (const d of nc.detalles || []) {
        const sub = Number(d.subtotal ?? 0);
        const tasa = Number(d.porcentaje_iva ?? 0);
        if (tasa === 10) gross10 += sub;
        else if (tasa === 5) gross5 += sub;
        else exento += sub;
      }
      const asoc = nc.compra_cab;
      lineas.push(
        armarFila({
          persona: nc.proveedores?.personas ?? null,
          tipoComprobante: Marangatu.TIPO_COMPROBANTE.NOTA_CREDITO,
          fecha: nc.fecha_emision,
          timbrado: nc.timbrado,
          est: nc.establecimiento,
          punto: nc.punto_expedicion,
          numero: nc.numero,
          gross10,
          gross5,
          exento,
          monedaCod: nc.moneda?.codigo,
          asocNumero: asoc
            ? Marangatu.numeroComprobante(asoc.establecimiento, asoc.punto_expedicion, asoc.numero_factura)
            : '',
          asocTimbrado: asoc ? Marangatu.timbrado(asoc.timbrado_proveedor) : '',
        }),
      );
    }

    const filename = Marangatu.nombreArchivo(empresa.ruc, mes, anio, secuencia);
    return {
      filename,
      csv: lineas.join('\r\n'),
      filas: lineas.length,
      facturas: compras.length,
      gastos: gastos.length,
      notas_credito: notasCredito.length,
      duplicados_omitidos: duplicadosOmitidos,
    };
  }

  /**
   * Verifica si ya existe un comprobante con el mismo timbrado + establecimiento +
   * punto de expedición + número, buscando tanto en facturas de compra como en
   * gastos. No bloquea nada: solo informa al front para avisar al usuario, que
   * decide si igual lo carga (por las dudas). Se ignora el `excluir_id` para no
   * marcar como duplicado al propio registro que se está editando.
   */
  async verificarComprobanteDuplicado(
    empresaId: string,
    params: { timbrado?: string; establecimiento?: string; punto_expedicion?: string; numero_factura?: string; excluir_id?: string },
  ) {
    const numero_factura = (params.numero_factura ?? '').trim();
    if (!numero_factura) return { existe: false, coincidencias: [] as any[] };

    const est = (params.establecimiento ?? '').trim() || undefined;
    const punto = (params.punto_expedicion ?? '').trim() || undefined;
    const timbrado = (params.timbrado ?? '').trim() || undefined;

    const [compras, gastos] = await Promise.all([
      this.prisma.compra_cab.findMany({
        where: {
          empresa_id: empresaId,
          anulado: false,
          numero_factura,
          ...(est ? { establecimiento: est } : {}),
          ...(punto ? { punto_expedicion: punto } : {}),
          ...(timbrado ? { timbrado_proveedor: timbrado } : {}),
          ...(params.excluir_id ? { id: { not: params.excluir_id } } : {}),
        },
        select: {
          id: true,
          establecimiento: true,
          punto_expedicion: true,
          numero_factura: true,
          timbrado_proveedor: true,
          fecha_emision: true,
          proveedores: { select: { personas: { select: { razon_social: true } } } },
        },
        take: 5,
      }),
      this.prisma.gasto_cab.findMany({
        where: {
          empresa_id: empresaId,
          anulado: false,
          numero_factura,
          ...(est ? { establecimiento: est } : {}),
          ...(punto ? { punto_expedicion: punto } : {}),
          ...(timbrado ? { timbrado } : {}),
          ...(params.excluir_id ? { id: { not: params.excluir_id } } : {}),
        },
        select: {
          id: true,
          establecimiento: true,
          punto_expedicion: true,
          numero_factura: true,
          timbrado: true,
          fecha_emision: true,
          descripcion: true,
          proveedores: { select: { personas: { select: { razon_social: true } } } },
        },
        take: 5,
      }),
    ]);

    const coincidencias = [
      ...compras.map((c) => ({
        origen: 'COMPRA' as const,
        id: c.id,
        numero: [c.establecimiento, c.punto_expedicion, c.numero_factura].filter(Boolean).join('-'),
        timbrado: c.timbrado_proveedor,
        fecha: c.fecha_emision,
        nombre: c.proveedores?.personas?.razon_social ?? null,
      })),
      ...gastos.map((g) => ({
        origen: 'GASTO' as const,
        id: g.id,
        numero: [g.establecimiento, g.punto_expedicion, g.numero_factura].filter(Boolean).join('-'),
        timbrado: g.timbrado,
        fecha: g.fecha_emision,
        nombre: g.proveedores?.personas?.razon_social ?? g.descripcion ?? null,
      })),
    ];

    return { existe: coincidencias.length > 0, coincidencias };
  }

  /**
   * Lista las monedas de la empresa con su config de decimales/redondeo en compras.
   * Incluye las monedas configuradas en empresa_monedas para poder mostrar el fallback.
   */
  async listConfigMoneda(empresaId: string) {
    const [overrides, empresaMonedas] = await Promise.all([
      this.prisma.config_compras_moneda.findMany({
        where: { empresa_id: empresaId },
        include: { moneda: { select: { id: true, codigo: true, descripcion: true, simbolo: true, decimales_estandar: true } } },
      }),
      this.prisma.empresa_monedas.findMany({
        where: { empresa_id: empresaId, active: true },
        include: { moneda: { select: { id: true, codigo: true, descripcion: true, simbolo: true, decimales_estandar: true } } },
      }),
    ]);

    const overrideByMoneda = new Map(overrides.map((o) => [o.moneda_id, o]));

    return empresaMonedas.map((em) => {
      const ov = overrideByMoneda.get(em.moneda_id);
      // Default de compras = estándar de la moneda (PYG = 2), independiente de ventas.
      const fallbackDecimales = em.moneda?.decimales_estandar ?? 2;
      return {
        moneda_id: em.moneda_id,
        moneda: em.moneda,
        es_principal: em.es_principal ?? false,
        decimales_visualizar_ventas: em.decimales_visualizar ?? 0,
        configurado: Boolean(ov),
        decimales_monto: ov?.decimales_monto ?? null,
        decimales_monto_efectivo: ov?.decimales_monto ?? fallbackDecimales,
        decimales_porcentaje: ov?.decimales_porcentaje ?? 4,
        modo_redondeo: ov?.modo_redondeo ?? 'HALF_UP',
      };
    });
  }

  async upsertConfigMoneda(empresaId: string, dto: UpsertConfigComprasMonedaDto) {
    const moneda = await this.prisma.moneda.findUnique({ where: { id: dto.moneda_id }, select: { id: true } });
    if (!moneda) throw new BadRequestException('Moneda inválida');

    return this.prisma.config_compras_moneda.upsert({
      where: { empresa_id_moneda_id: { empresa_id: empresaId, moneda_id: dto.moneda_id } },
      create: {
        empresa_id: empresaId,
        moneda_id: dto.moneda_id,
        decimales_monto: dto.decimales_monto ?? null,
        decimales_porcentaje: dto.decimales_porcentaje ?? 4,
        modo_redondeo: dto.modo_redondeo ?? 'HALF_UP',
      },
      update: {
        decimales_monto: dto.decimales_monto ?? null,
        decimales_porcentaje: dto.decimales_porcentaje ?? 4,
        modo_redondeo: dto.modo_redondeo ?? 'HALF_UP',
        updated_at: new Date(),
      },
    });
  }

  async deleteConfigMoneda(empresaId: string, monedaId: string) {
    await this.prisma.config_compras_moneda.deleteMany({
      where: { empresa_id: empresaId, moneda_id: monedaId },
    });
    return { ok: true };
  }

  private isMissingTableError(error: unknown): boolean {
    if (!error || typeof error !== 'object') return false;

    const maybeCode = 'code' in error ? String((error as any).code || '') : '';
    const maybeMessage = 'message' in error ? String((error as any).message || '') : '';

    return (
      maybeCode === 'P2021' ||
      maybeCode === 'P2022' ||
      maybeMessage.includes('does not exist in the current database') ||
      maybeMessage.includes('no existe la relación')
    );
  }

  /**
   * Convierte el P2002 de Prisma sobre el índice único parcial del documento
   * fiscal (`uk_compra_documento_activo`) en un 400 legible. El índice solo
   * aplica cuando `anulado = false`, así que si salta es porque ya existe otra
   * compra ACTIVA con el mismo (timbrado, establecimiento, punto_expedicion,
   * numero_factura) para la empresa.
   */
  private rethrowIfCompraDuplicada(
    error: unknown,
    cab: Partial<CreateCompraDto['cabecera']>,
  ): void {
    if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return;
    if (error.code !== 'P2002') return;
    const target = (error.meta as any)?.target;
    const targetStr = Array.isArray(target) ? target.join(',') : String(target || '');
    const isDocumentoFiscal =
      targetStr.includes('uk_compra_documento') ||
      (Array.isArray(target) &&
        target.includes('timbrado_proveedor') &&
        target.includes('numero_factura'));
    if (!isDocumentoFiscal) return;

    const timbrado = cab.timbrado_proveedor ?? '—';
    const numeroCompleto =
      [cab.establecimiento, cab.punto_expedicion, cab.numero_factura]
        .filter(Boolean)
        .join('-') || cab.numero_factura || '—';
    throw new BadRequestException(
      `Ya existe una compra activa con timbrado ${timbrado} y factura ${numeroCompleto}. Si la anterior fue anulada revisá que la anulación haya quedado registrada; caso contrario, verificá la numeración cargada.`,
    );
  }
}
