import { sincronizarCuentaCobrar, type TxSincronizacion } from 'src/common/utils/sincronizar-cuenta.util';
import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  NotFoundException,
  Logger,
} from '@nestjs/common';
import { CobMoraEstado } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { ClientesService } from 'src/clientes/clientes.service';
import { PersonasService } from 'src/personas/personas.service';
import { envs } from 'src/config/envs';
import { MiddlewareSifenService } from 'src/middleware-sifen/middleware-sifen.service';
import { FifoService } from 'src/lotes/fifo.service';
import { LotesService } from 'src/lotes/lotes.service';
import { PlanLimitsService } from 'src/plan-limits/plan-limits.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { filtroNumeroDocumento } from 'src/utils/numero-documento';
import { alcanceSucursalUsuario, exigirAccesoElevado, filtroFiscalPorSucursal } from 'src/utils/alcance-sucursal';
import { rubrosFiltroUsuario } from 'src/utils/alcance-rubro';
import { whereProductoPorRubro } from 'src/common/utils/rubros.util';
import { QueuesService } from 'src/queues/queues.service';
import { EcommerceNotificationsService } from 'src/ecommerce/notifications/ecommerce-notifications.service';
import { ReversionCajaService } from 'src/tesoreria/reversion-caja.service';
import { MonedasService } from 'src/monedas/monedas.service';
import { formatDateResponse, nowAsuncionNaive, parseYmdValida, toPrismaDate } from 'src/utils/utilidades';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { AsignacionFacturasService } from 'src/vendedores-cobradores/asignacion-facturas.service';
import { ListaPreciosService } from 'src/lista-precios/lista-precios.service';
import { EstadoAutorizacionCaja, TipoAutorizacionCaja } from 'src/autorizaciones-caja/dto/crear-autorizacion.dto';
import { CreateFacturaAutoDto } from './dto/create-factura-auto.dto';
import { CreateFacturaDto } from './dto/create-factura.dto';

const MORA_ESTADOS_ACTIVOS: CobMoraEstado[] = [
  CobMoraEstado.GESTION_INTERNA,
  CobMoraEstado.INFORMCONF,
  CobMoraEstado.DEMANDA,
];

export { EstadoFactura, EstadoSifen } from './facturas.enums';
import { EstadoFactura } from './facturas.enums';
import * as Marangatu from 'src/common/marangatu/registro-comprobantes.util';

interface PagosData {
  factura_cab_id: string;
  empresa_id: string;
  sesion_caja_id: string | null;
  medio_pago_id: string;
  tarjeta_id?: string | null;
  forma_procesamiento_pago_id?: string | null;
  dmontipag: number;
  cmonetipag: string;
  dticamtipag: number | null;
  monto_moneda_original?: number | null;
  moneda_pago_id?: string | null;
  drsprotar?: string | null;
  drucprotar?: string | null;
  ddvprotar?: string | null;
  dcodauope?: string | null;
  dnomtit?: string | null;
  dnumtarj?: string | null;
  ddesdentarj?: string | null;
  dnumcheq?: string | null;
  dbcoemi?: string | null;
  // Campo interno (no se persiste): monto en moneda de la factura para contabilidad de caja
  montoBase?: number;
}

@Injectable()
export class FacturasService {
  private readonly logger = new Logger(FacturasService.name);
  constructor(
    private prisma: PrismaService,
    private clientesService: ClientesService,
    private readonly personasService: PersonasService,
    private readonly queuesService: QueuesService,
    private readonly auditService: AuditService,
    private readonly planLimits: PlanLimitsService,
    private readonly lotesService: LotesService,
    private readonly fifoService: FifoService,
    private readonly middlewareSifenService: MiddlewareSifenService,
    private readonly contabilidadIntegracion: ContabilidadIntegracionService,
    private readonly asignacionFacturas: AsignacionFacturasService,
    private readonly ecommerceNotifications: EcommerceNotificationsService,
    private readonly listaPreciosService: ListaPreciosService,
    private readonly reversionCaja: ReversionCajaService,
    private readonly monedasService: MonedasService,
  ) {}

  async createFacturaAutomatico(createFacturaAutoDto: CreateFacturaAutoDto, empresa_id: string, user_id?: string) {
    // No loguear el DTO completo (datos del cliente/ítems). Solo un identificador.
    this.logger.debug?.(`createFacturaAutomatico | empresa: ${empresa_id}`, 'FacturasService');
    try {
      await this.planLimits.checkLimit(empresa_id, 'documentos');
      await this.planLimits.checkLimit(empresa_id, 'monto_facturacion');

      const cabecera = createFacturaAutoDto.cabecera;
      const items = createFacturaAutoDto.items;
      const est = '001';
      const exp = '001';
      const { siguiente: numeracion_siguente } = await this.getSiguienteNumeracion(est, exp, empresa_id);

      const producto = await this.prisma.productos.findFirst({
        where: {
          id: items[0].producto_id,
        },
      });

      const cliente = await this.prisma.clientes.findFirst({
        where: {
          id: cabecera.cliente_id,
        },
        include: {
          personas: true,
          tipo_operacion: { select: { codigo: true } },
        },
      });
      const persona = cliente?.personas;
      const indicador_presencia = await this.prisma.indicador_presencia.findFirst({
        where: {
          codigo: 1,
        },
      });
      const moneda = await this.prisma.moneda.findFirst({
        where: {
          codigo: 'PYG',
        },
      });
      const condicion_operacion = await this.prisma.condicion_operacion.findFirst({
        where: {
          codigo: 1,
        },
      });

      const tipo_impuesto = await this.prisma.tipo_impuesto.findFirst({
        where: {
          codigo: 1,
        },
      });
      const tipo_transaccion = await this.prisma.tipo_transaccion.findFirst({
        where: {
          codigo: 3,
        },
      });
      const medio_pago = await this.prisma.medio_pago.findFirst({
        where: {
          codigo: 1,
        },
      });
      if (!cliente) throw new NotFoundException('Cliente no encontrado');

      // Validación de datos de cliente (mismas reglas que el formulario manual)
      if (cliente?.persona_id) {
        const errores = await this.personasService.validatePersonaById(cliente.persona_id, empresa_id, {
          tipoOperacionCodigo: cliente.tipo_operacion?.codigo ?? null,
          clienteDireccionId: (cabecera as any)?.cliente_direccion_id ?? null,
        });
        if (errores.length > 0) {
          throw new BadRequestException({
            message: 'El cliente tiene datos incompletos o inválidos. Corregilos antes de emitir la factura:',
            errores,
            cliente_id: cliente.id,
          });
        }
      }

      if (!producto) throw new NotFoundException('Producto no encontrado');
      if (!indicador_presencia) throw new NotFoundException('Indicador de presencia no encontrado');
      if (!moneda) throw new NotFoundException('Moneda no encontrada');
      if (!condicion_operacion) throw new NotFoundException('Condicion de operacion no encontrada');
      if (!tipo_impuesto) throw new NotFoundException('Tipo de impuesto no encontrado');
      if (!tipo_transaccion) throw new NotFoundException('Tipo de transaccion no encontrado');
      if (!medio_pago) throw new NotFoundException('Medio de pago no encontrado');
      const fechaactual = nowAsuncionNaive();
      const facturaCabecera = await this.prisma.factura_cab.create({
        data: {
          dest: est,
          dpunexp: exp,
          dnumdoc: numeracion_siguente,
          dserienum: cabecera?.dserienum ?? null,
          empresa_id: empresa_id,
          cliente_id: cliente.id,
          moneda_id: moneda.id,
          tipo_impuesto_id: tipo_impuesto.id,
          tipo_transaccion_id: tipo_transaccion.id,
          condicion_operacion_id: condicion_operacion.id,
          icondcred: null,
          dplazocre: null,
          dcuotas: null,
          dmonent: null,
          indicador_presencia_id: indicador_presencia.id,
          sucripcion_id: null,
          dinfoemi: null,
          dinfofisc: null,
          dfeemide: fechaactual,
          dcondticam: null,
          dticam: null,
          icondant: null,
          dfecemnr: null,
          demailadmin: null,
          dciclo: null,
          dfecinic: null,
          dfecfinc: null,
          dvencpag: null,
          dcontrato: null,
          dsalant: null,
          dinfadic: null,
          dordcompra: null,
          dordvta: null,
          dasiento: null,
          dmodcont: null,
          dentcont: null,
          danocont: null,
          dseccont: null,
          dfecodcont: null,
        },
      });

      const item = items[0];
      const cantidadAuto = Number(item.cantidad || 0);
      const depositoLotesAutoBase = producto.maneja_inventario
        ? await this.prisma.depositos.findFirst({
            where: {
              empresa_id,
              active: true,
              es_principal: true,
            },
            select: { id: true, sucursal_id: true },
          })
        : null;
      const sucursalLotesAutoId = depositoLotesAutoBase?.sucursal_id || null;
      const lotesEnabledAuto = sucursalLotesAutoId
        ? await this.lotesService.isEnabledForEmpresa(empresa_id, sucursalLotesAutoId)
        : false;
      const depositoLotesAuto = lotesEnabledAuto ? depositoLotesAutoBase : null;
      let costoUnitarioAuto = Number(producto.precio_costo || 0);
      let costoTotalAuto = costoUnitarioAuto * cantidadAuto;
      let lotesConsumidosAuto: Array<{
        lote_id: string;
        cantidad: number;
        costo_unitario: number;
        costo_total: number;
      }> = [];

      // Mismo criterio que el camino principal: FIFO solo para productos que
      // manejan lote, y el remanente sin lote se costea con `precio_costo`.
      if (
        lotesEnabledAuto &&
        depositoLotesAuto?.id &&
        producto.maneja_inventario &&
        producto.maneja_lote &&
        cantidadAuto > 0
      ) {
        const fifoResult = await this.prisma.$transaction((tx) =>
          this.fifoService.deductFifo(tx, empresa_id, item.producto_id, depositoLotesAuto.id, cantidadAuto),
        );
        const sinLoteAuto = Number(fifoResult.cantidad_sin_lote || 0);
        costoTotalAuto =
          Number(fifoResult.costo_total || 0) + sinLoteAuto * Number(producto.precio_costo || 0);
        costoUnitarioAuto = cantidadAuto > 0 ? Number((costoTotalAuto / cantidadAuto).toFixed(4)) : 0;
        lotesConsumidosAuto = fifoResult.lotes_consumidos;
      }

      // Compatibilidad temporal: Prisma Client local puede no incluir aun los nuevos campos de costo.
      const facturaDetAutoData = {
        factura_cab_id: facturaCabecera.id,
        producto_id: item.producto_id,
        deposito_id: depositoLotesAuto?.id || null,
        ddesproser: item.descripcion,
        cunimed: 'UNI',
        dcantproser: item.cantidad,
        cpaisorig: null,
        duniproser: item.precio_unitario,
        costo_unitario_venta: costoUnitarioAuto,
        costo_total_venta: costoTotalAuto,
        dticamit: null,
        dtotbruopeitem: 0,
        ddescitem: 0,
        dporcdesit: 0,
        ddescgloitem: 0,
        dtotopeitem: 0,
        dtotopegs: 0,
        iafeciva: 1,
        dtasiva: 0,
        dpropiva: 0,
        dbasgraviva: 0,
        dliqivaitem: 0,
        dbasexe: 0,
        dinfitem: null,
        dcdcanticipo: null,
        dantpreuniit: null,
      } as any;

      const facturaDetAuto = await this.prisma.factura_det.create({
        data: facturaDetAutoData,
      });

      if (lotesConsumidosAuto.length > 0) {
        await this.prisma.factura_det_lote.createMany({
          data: lotesConsumidosAuto.map((row) => ({
            factura_det_id: facturaDetAuto.id,
            lote_id: row.lote_id,
            cantidad: row.cantidad,
            costo_unitario: row.costo_unitario,
            costo_total: row.costo_total,
          })),
        });
      }

      await this.prisma.factura_forma_pagos.create({
        data: {
          factura_cab_id: facturaCabecera.id,
          medio_pago_id: medio_pago.id,
          dmontipag: 10000,
          cmonetipag: 'PYG',
          dticamtipag: null,
          drsprotar: null,
          drucprotar: null,
          ddvprotar: null,
          dcodauope: null,
          dnomtit: null,
          dnumtarj: null,
          ddesdentarj: null,
          dnumcheq: null,
          dbcoemi: null,
        },
      });
      await this.prisma.factura_subtotales.create({
        data: {
          factura_cab_id: facturaCabecera.id,
          dsubexe: 0,
          dsubexo: 0,
          dsub5: 0,
          dsub10: 0,
          dtotope: 0,
          dtotdesc: 0,
          dtotdescglotem: 0,
          dtotantitem: 0,
          dtotant: 0,
          dporcdesctotal: 0,
          ddesctotal: 0,
          danticipo: 0,
          dredon: 0,
          dcomi: 0,
          dtotgralope: 0,
          diva5: 0,
          diva10: 0,
          dliqtotiva5: 0,
          dliqtotiva10: 0,
          divacomi: 0,
          dtotiva: 0,
          dbasegrav5: 0,
          dbasegrav10: 0,
          dtbasgraiva: 0,
          dtotalgs: 0,
        },
      });

      const numeroFacturaAuto = `${facturaCabecera.dest}-${facturaCabecera.dpunexp}-${facturaCabecera.dnumdoc}`;
      this.logger.log(`Factura automática creada: ${numeroFacturaAuto} | Empresa: ${empresa_id}`, 'FacturasService');
      await this.auditService.log({
        empresa_id,
        user_id,
        action: 'CREATE',
        entity_type: 'factura',
        entity_id: facturaCabecera.id,
        descripcion: `Factura automática creada: ${numeroFacturaAuto} | Empresa: ${empresa_id}`,
        new_value: { numero: numeroFacturaAuto, cliente_id: facturaCabecera.cliente_id },
      });
      return {
        message: 'Factura automatica creada exitosamente',
        numero: numeroFacturaAuto,
      };
    } catch (error) {
      this.logger.error(
        `Error en createFacturaAutomatico: ${error instanceof Error ? error.message : String(error)}`,
        'FacturasService',
      );
      if (error instanceof ConflictException) throw error;
      if (error instanceof ForbiddenException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new Error(`Error al crear factura automatica: ${message}`);
    }
  }

  /**
   * Recompara, del lado del servidor, el descuento efectivamente aplicado en cada
   * ítem contra su tope (calculado sobre precio_costo/precio_minimo reales, no lo
   * que haya calculado el frontend). Si algún ítem lo supera, exige una
   * autorizaciones_caja vigente (tipo=descuento, estado=aprobada, no vencida).
   * Ver docs/plan-motor-precios-rentabilidad.md §10.6.
   */
  private async validarTopeDescuentoFactura(params: {
    items: CreateFacturaDto['items'];
    empresaId: string;
    clienteId: string;
    autorizacionId?: string;
  }): Promise<{ requiereConsumoAutorizacion: boolean; autorizacionId?: string }> {
    const { items, empresaId, clienteId, autorizacionId } = params;
    const productoIds = items.map((item) => item.producto_id);
    const topes = await this.listaPreciosService.obtenerPreciosProductosBatch({
      productoIds,
      empresaId,
      clienteId,
    });

    const excedidos: Array<{ producto_id: string; descuentoAplicadoPct: number; topeDescuentoPct: number }> = [];

    for (const item of items) {
      const totalBruto = Number((item as any).total_bruto || 0);
      const totalOperacion = Number((item as any).total_operacion || 0);
      if (totalBruto <= 0) continue;
      const descuentoAplicadoPct = Math.max(0, (1 - totalOperacion / totalBruto) * 100);
      const topeDescuentoPct = Number((topes as any)[item.producto_id]?.topeDescuentoPct ?? 0);
      // Tolerancia chica para ruido de redondeo (float / redondeo de moneda).
      if (descuentoAplicadoPct > topeDescuentoPct + 0.05) {
        excedidos.push({ producto_id: item.producto_id, descuentoAplicadoPct, topeDescuentoPct });
      }
    }

    if (excedidos.length === 0) return { requiereConsumoAutorizacion: false };

    if (!autorizacionId) {
      throw new ForbiddenException({
        message:
          'El descuento aplicado supera el tope permitido para uno o más productos. Se requiere autorización de supervisor.',
        items: excedidos,
      });
    }

    const autorizacion = await this.prisma.autorizaciones_caja.findUnique({ where: { id: autorizacionId } });
    const vigente =
      !!autorizacion &&
      autorizacion.empresa_id === empresaId &&
      autorizacion.tipo === TipoAutorizacionCaja.DESCUENTO &&
      autorizacion.estado === EstadoAutorizacionCaja.APROBADA &&
      !!autorizacion.expires_at &&
      autorizacion.expires_at.getTime() > Date.now();

    if (!vigente) {
      throw new ForbiddenException({
        message: 'La autorización de descuento no es válida o expiró. Solicitá una nueva autorización de supervisor.',
        items: excedidos,
      });
    }

    return { requiereConsumoAutorizacion: true, autorizacionId };
  }

  async create(createFacturaDto: CreateFacturaDto, empresa_id: string, user_id?: string) {
    try {
      await this.planLimits.checkLimit(empresa_id, 'documentos');
      await this.planLimits.checkLimit(empresa_id, 'monto_facturacion');

      const cabecera = createFacturaDto.cabecera;
      const items = createFacturaDto.items;
      const pagos = createFacturaDto.pagos;
      const cuotas = createFacturaDto.cuotas;
      const subtotal = createFacturaDto.subtotal;

      // Validar que se envíe numeracion_id
      if (!cabecera.numeracion_id) {
        throw new BadRequestException('numeracion_id es obligatorio');
      }

      const cliente = await this.clientesService.findOne(cabecera.cliente_id);
      const moneda = await this.prisma.moneda.findUnique({
        where: { id: cabecera.moneda_id },
      });
      const condicion_operacion = await this.prisma.condicion_operacion.findUnique({
        where: { id: cabecera.condicion_operacion_id },
      });

      if (items.length === 0) throw new BadRequestException('No se puede crear una factura sin items');
      if (!cliente) throw new NotFoundException(`El cliente ${cabecera.cliente_id} no existe`);

      // Validar datos del cliente con las mismas reglas que usa el formulario del ERP.
      // Necesario para clientes importados desde Excel/integraciones que pueden quedar
      // con datos incompletos y luego ser rechazados por SIFEN.
      const personaId = (cliente as any)?.data?.personas?.id;
      if (personaId) {
        const tipoOperacionCodigo = (cliente as any)?.data?.tipo_operacion?.codigo ?? null;
        const errores = await this.personasService.validatePersonaById(personaId, empresa_id, {
          tipoOperacionCodigo,
          clienteDireccionId: (cabecera as any)?.cliente_direccion_id ?? null,
        });
        if (errores.length > 0) {
          throw new BadRequestException({
            message: 'El cliente tiene datos incompletos o inválidos. Corregilos antes de emitir la factura:',
            errores,
            cliente_id: cabecera.cliente_id,
          });
        }
      }

      if (!moneda) throw new NotFoundException(`La moneda ${cabecera.moneda_id} no existe`);
      if (!condicion_operacion)
        throw new NotFoundException(`La condicion de operacion ${cabecera.condicion_operacion_id} no existe`);

      // Arrastre de la cotización presupuestada: si la factura proviene de una Orden de Venta
      // en moneda extranjera, el tipo de cambio (dticam) se fuerza al congelado en la OV —que a
      // su vez se arrastró del presupuesto— para que la facturación respete la cotización pactada.
      if (moneda.codigo !== 'PYG' && cabecera.pedido_id) {
        const ov = await this.prisma.pedidos.findFirst({
          where: { id: cabecera.pedido_id, empresa_id },
          select: { cotizacion_usd_gs: true },
        });
        const cotOV = ov?.cotizacion_usd_gs ? Number(ov.cotizacion_usd_gs) : 0;
        if (cotOV > 0) {
          cabecera.dticam = cotOV;
          if (!cabecera.dcondticam) cabecera.dcondticam = 'G';
        }
      }

      if (moneda.codigo !== 'PYG') {
        if (!cabecera.dticam || cabecera.dticam === null || cabecera.dticam === 0)
          throw new BadRequestException('La factura debe tener tipo de cambio y un valor mayor a 0');
        if (!cabecera.dcondticam || cabecera.dcondticam === null)
          throw new BadRequestException('La factura debe tener condicion de tipo de cambio si es Global o por item');
      }

      // Contado
      if (condicion_operacion.codigo === 1) {
        // SIFEN exige gPaConEIni con al menos un medio de pago para contado,
        // por eso se sigue requiriendo pagos[] aunque sea cobro diferido.
        const pagos = createFacturaDto.pagos;
        if ((pagos?.length ?? 0) === 0) throw new ConflictException('La factura debe tener al menos un pago');
        // Cobro diferido (en ruta): el pago se declara fiscalmente pero no entra a caja;
        // queda saldo pendiente que el cobrador recauda en campo y rinde.
        if (cabecera.cobro_diferido && !cabecera.cobrador_id) {
          throw new BadRequestException('Para cobro en ruta se requiere asignar un cobrador a la factura');
        }
      }
      // Credito
      if (condicion_operacion.codigo === 2) {
        // vemos si es plazos o cuotas
        const tipo_credito = cabecera.icondcred;
        if (tipo_credito === '1') {
          // plazos
        }
        if (tipo_credito === '2') {
          //cuotas
        }

        // Fase 2: Verificar crédito disponible del cliente
        const montoCredito = subtotal.total_general_operacion - (cabecera.dmonent || 0);
        if (montoCredito > 0) {
          const creditCheck = await this.clientesService.verificarCredito(cabecera.cliente_id, montoCredito);
          if (!creditCheck.permitido) {
            throw new ForbiddenException(creditCheck.mensaje || 'Crédito insuficiente para este cliente');
          }
        }

        // verificamos si tiene entrega inicial debe tener pagos
        const entrega_inicial = cabecera.dmonent;
        if (entrega_inicial && entrega_inicial > 0) {
          const pagos = createFacturaDto.pagos;
          if ((pagos?.length ?? 0) === 0)
            throw new ConflictException('Si la factura es credito y tiene entrega inicial debe tener pagos');
        }
      }

      // verificamos si el cliente es B2G
      if (cliente.data.tipo_operacion.descripcion === 'B2G') {
        const { dmodcont, dentcont, danocont, dseccont, dfecodcont } = cabecera;
        if (dmodcont === null || danocont === null || dseccont === null || dfecodcont === null || dentcont === null)
          throw new BadRequestException(
            'Los campos dmodcont, dentcont, danocont, dseccont, dfecodcont son obligatorios',
          );
      }
      if (cliente.data.tipo_operacion.descripcion === 'B2F') {
        const { ddirrec } = cabecera;
        if (ddirrec == null)
          throw new BadRequestException(
            'La direccion es obligatorio cuando el tipo de operacion es Negocio a Extranjero (B2F)',
          );
      }

      // Validar que todos los productos existan y obtener info de inventario
      const productosIds = items.map((item) => item.producto_id);
      const productosExistentes = await this.prisma.productos.findMany({
        where: { id: { in: productosIds } },
        // `maneja_lote` es necesario para decidir si el ítem entra a FIFO (ver más
        // abajo): sin él en el select, el chequeo daría siempre falso.
        select: { id: true, maneja_inventario: true, maneja_lote: true, descripcion: true, precio_costo: true },
      });
      const productosMap = new Map(productosExistentes.map((p) => [p.id, p]));
      const productosNoEncontrados = productosIds.filter((id) => !productosMap.has(id));
      if (productosNoEncontrados.length > 0) {
        throw new NotFoundException(`Los siguientes productos no existen: ${productosNoEncontrados.join(', ')}`);
      }

      // Descuentos por encima del tope requieren autorización de supervisor. Ver
      // docs/plan-motor-precios-rentabilidad.md §10.
      const validacionDescuento = await this.validarTopeDescuentoFactura({
        items,
        empresaId: empresa_id,
        clienteId: cabecera.cliente_id,
        autorizacionId: (cabecera as any).autorizacion_descuento_id,
      });

      // Transacción: si algo falla, se revierte todo (incluyendo la numeración)
      const facturaId = await this.prisma.$transaction(async (tx) => {
        // Variables para numeración
        let dest = cabecera.dest;
        let dpunexp = cabecera.dpunexp;
        let dnumdoc = cabecera.dnumdoc;
        let puntoExpedicion: {
          id: string;
          punto_expedicion?: string | null;
          // Caja a la que pertenece el punto: se usa para verificar que el
          // cobro no entre en una caja distinta a la del punto.
          caja_id?: string | null;
          empresas_sucursales?: {
            id: string;
            punto_establecimiento?: string | null;
            config?: unknown;
          } | null;
        } | null = null;

        // Si se envía numeracion_id, reservar número atómicamente dentro de la transacción
        if (cabecera.numeracion_id) {
          // Bloqueo pesimista: SELECT FOR UPDATE
          const numeraciones = await tx.$queryRaw<
            Array<{
              id: string;
              numero_actual: number;
              numero_final: number | null;
              active: boolean;
              punto_expedicion_id: string;
            }>
          >`SELECT id, numero_actual, numero_final, active, punto_expedicion_id 
            FROM numeraciones_documento 
            WHERE id = ${cabecera.numeracion_id}::uuid 
            FOR UPDATE`;

          if (numeraciones.length === 0) {
            throw new NotFoundException('Numeración no encontrada');
          }

          const numeracion = numeraciones[0];

          if (!numeracion.active) {
            throw new ConflictException('La numeración está inactiva');
          }

          if (numeracion.numero_final && numeracion.numero_actual >= numeracion.numero_final) {
            throw new ConflictException('Se alcanzó el número final de la numeración');
          }

          // Obtener datos del punto de expedición con config de sucursal
          puntoExpedicion = await tx.empresas_puntos_expedicion.findUnique({
            where: { id: numeracion.punto_expedicion_id },
            include: {
              empresas_sucursales: {
                select: {
                  id: true,
                  punto_establecimiento: true,
                  config: true,
                },
              },
            },
          });

          // Asignar valores
          dnumdoc = String(numeracion.numero_actual).padStart(7, '0');
          dest = puntoExpedicion?.empresas_sucursales?.punto_establecimiento || '001';
          dpunexp = puntoExpedicion?.punto_expedicion || '001';

          // Incrementar número para la siguiente factura
          await tx.numeraciones_documento.update({
            where: { id: cabecera.numeracion_id },
            data: { numero_actual: numeracion.numero_actual + 1 },
          });
        }

        // Validar que tengamos los datos de numeración
        if (!dest || !dpunexp || !dnumdoc) {
          throw new BadRequestException('Debe proporcionar numeracion_id o dest, dpunexp y dnumdoc');
        }

        // El punto de expedición y la caja donde entra la plata tienen que ser
        // de la misma caja. Son dos datos independientes — `dpunexp` sale de la
        // numeración elegida y `sesion_caja_id` viaja en cada forma de pago —,
        // así que sin esta validación una venta podía numerarse con el punto de
        // una caja y registrar el movimiento en la sesión de otra.
        //
        // Solo aplica si el punto tiene caja asignada: hay puntos sin caja
        // (configuración vieja) que deben seguir funcionando.
        if (puntoExpedicion?.caja_id) {
          const sesionesDePago = [
            ...new Set(
              (createFacturaDto.pagos ?? [])
                .map((pago) => pago.sesion_caja_id)
                .filter((id): id is string => !!id),
            ),
          ];
          if (sesionesDePago.length > 0) {
            const sesiones = await tx.sesiones_caja.findMany({
              where: { id: { in: sesionesDePago } },
              select: { id: true, caja_id: true, cajas: { select: { descripcion: true } } },
            });
            const ajena = sesiones.find((ses) => ses.caja_id !== puntoExpedicion!.caja_id);
            if (ajena) {
              const cajaDelPunto = await tx.cajas.findUnique({
                where: { id: puntoExpedicion.caja_id },
                select: { descripcion: true },
              });
              throw new BadRequestException(
                `El punto de expedición ${dpunexp} pertenece a "${cajaDelPunto?.descripcion ?? 'otra caja'}" ` +
                  `y el cobro entra en "${ajena.cajas?.descripcion ?? 'otra caja'}". ` +
                  `Elegí un punto de expedición de tu caja.`,
              );
            }
          }
        }
        // Obtener datos de la empresa para generar CDC
        const empresa = await tx.empresas.findUnique({
          where: { id: empresa_id },
          select: {
            ruc: true,
            dv: true,
            tipo_contribuyente: true,
          },
        });
        if (!empresa) throw new NotFoundException('Empresa no encontrada');
        // Obtener código del tipo de contribuyente
        let iTipCont = 1; // Por defecto: Persona Física
        if (empresa.tipo_contribuyente) {
          const tipContrib = await tx.tipo_contribuyente.findUnique({
            where: { id: empresa.tipo_contribuyente },
            select: { codigo: true },
          });
          if (tipContrib?.codigo) iTipCont = tipContrib.codigo;
        }

        // Obtener código del tipo de transacción (= tipo documento electrónico iTiDE)
        const tipoTransaccion = await tx.tipo_transaccion.findUnique({
          where: { id: cabecera.tipo_transaccion_id },
          select: { codigo: true },
        });
        if (!tipoTransaccion) throw new NotFoundException('Tipo de transacción no encontrado');

        // Fecha de emisión como Date
        const fechaEmision = toPrismaDate(cabecera.dfeemide) ?? nowAsuncionNaive();

        // Generar CDC de 44 dígitos.
        // El tipo de documento del CDC (iTiDE) para una FACTURA electrónica es SIEMPRE 1.
        // (Antes se usaba tipoTransaccion.codigo — que es el iTipTra: 1=Venta mercadería,
        //  2=Servicios, 8=Donación, etc. — por lo que un tipo de transacción distinto de 1
        //  generaba un CDC con tipo inválido, ej. '08', y SIFEN lo rechazaba.)
        const cdc = this.generarCdc({
          iTiDE: 1,
          dRucEm: empresa.ruc,
          dDVEmi: empresa.dv,
          dEst: dest,
          dPunExp: dpunexp,
          dNumDoc: dnumdoc,
          iTipCont,
          dFeEmiDE: fechaEmision instanceof Date ? fechaEmision : new Date(fechaEmision),
          iTipEmi: 1, // 1 = Normal
        });

        // Generar enlace QR público
        const enlace_qr = this.generarEnlaceQr(cdc);

        // Presupuesto de origen: si la factura viene de una Orden de Venta que
        // nació de un presupuesto, se hereda para dejarlo escrito en la factura.
        // Con el enlace sólo del lado del presupuesto había que buscar al revés.
        const presupuestoOrigenId = cabecera.pedido_id
          ? (
              await tx.pedidos.findFirst({
                where: { id: cabecera.pedido_id, empresa_id },
                select: { presupuesto_id: true },
              })
            )?.presupuesto_id ?? null
          : null;

        // Registramos la cabecera
        const facturaCabecera = await tx.factura_cab.create({
          data: {
            dest,
            dpunexp,
            dnumdoc,
            dserienum: cabecera?.dserienum ?? null,
            empresa_id: empresa_id,
            cliente_id: cabecera.cliente_id,
            moneda_id: cabecera.moneda_id,
            tipo_impuesto_id: cabecera.tipo_impuesto_id,
            tipo_transaccion_id: cabecera.tipo_transaccion_id,
            condicion_operacion_id: cabecera.condicion_operacion_id,
            icondcred: Number(cabecera.icondcred) || null,
            dplazocre: cabecera?.dplazocre ?? null,
            dcuotas: cabecera?.dcuotas ?? null,
            plan_cuota_id: cabecera?.plan_cuota_id ?? null,
            dmonent: cabecera?.dmonent ?? null,
            indicador_presencia_id: cabecera.indicador_presencia_id,
            sucripcion_id: cabecera.sucripcion_id || null,
            dinfoemi: cabecera?.dinfoemi ?? null,
            dinfofisc: cabecera?.dinfofisc ?? null,
            dfeemide: fechaEmision,
            dcondticam: cabecera?.dcondticam ?? null,
            dticam: cabecera?.dticam ?? null,
            icondant: cabecera?.icondant ?? null,
            dfecemnr: cabecera?.dfecemnr ?? null,
            demailadmin: cabecera?.demailadmin ?? null,
            dciclo: cabecera?.dciclo ?? null,
            dfecinic: cabecera?.dfecinic ?? null,
            dfecfinc: cabecera?.dfecfinc ?? null,
            dvencpag: cabecera?.dvencpag ?? null,
            dcontrato: cabecera?.dcontrato ?? null,
            dsalant: cabecera?.dsalant ?? null,
            dinfadic: cabecera?.dinfadic ?? null,
            dordcompra: cabecera?.dordcompra ?? null,
            dordvta: cabecera?.dordvta ?? null,
            dasiento: cabecera?.dasiento ?? null,
            ddirrec: cabecera?.ddirrec ?? null,
            cliente_direccion_id: cabecera?.cliente_direccion_id ?? null,
            dmodcont: cabecera?.dmodcont ?? null,
            dentcont: Number(cabecera.dentcont) || null,
            danocont: Number(cabecera.danocont) || null,
            dseccont: cabecera?.dseccont ?? null,
            dfecodcont: cabecera?.dfecodcont ?? null,
            estado: EstadoFactura.PENDIENTE,
            cdc,
            enlace_qr,
            pedido_id: cabecera.pedido_id || null,
            // Si la factura sale de una OV que nació de un presupuesto, el
            // origen se hereda: así el dato queda en la factura y no depende de
            // ir saltando factura → pedido → presupuesto para averiguarlo.
            presupuesto_id: presupuestoOrigenId,
            vendedor_id: cabecera.vendedor_id || null,
            cobrador_id: cabecera.cobrador_id || null,
            solicitud_credito_id: cabecera.solicitud_credito_id || null,
            cobro_diferido: cabecera.cobro_diferido === true,
            saldo_pendiente:
              cabecera.cobro_diferido === true && condicion_operacion.codigo === 1
                ? subtotal.total_general_operacion
                : undefined,
          },
        });

        // Descuento por encima del tope: la autorización de supervisor ya fue
        // validada fuera de la transacción (validarTopeDescuentoFactura); acá se
        // "consume" de forma atómica para que no pueda reutilizarse en otra factura.
        if (validacionDescuento.requiereConsumoAutorizacion) {
          const consumo = await tx.autorizaciones_caja.updateMany({
            where: { id: validacionDescuento.autorizacionId, estado: EstadoAutorizacionCaja.APROBADA },
            data: { estado: EstadoAutorizacionCaja.UTILIZADA },
          });
          if (consumo.count === 0) {
            throw new ForbiddenException('La autorización de descuento ya fue utilizada por otra operación.');
          }
        }

        // === DESCUENTO DE INVENTARIO ===
        // Avisos no bloqueantes sobre el inventario de esta factura (ej. no se pudo
        // resolver depósito, así que no se descontó stock). Viajan en la respuesta.
        const advertenciasInventario: string[] = [];
        // Resolver depósito ANTES de crear factura_det para poder guardarlo por ítem
        const sucursalId = puntoExpedicion?.empresas_sucursales?.id;
        let depositoParaInventario: { id: string; descripcion: string; sucursal_id: string | null } | null = null;

        // Si se envió deposito_id en la cabecera, usarlo
        if (cabecera.deposito_id) {
          depositoParaInventario = await tx.depositos.findFirst({
            where: {
              id: cabecera.deposito_id,
              empresa_id: empresa_id,
              active: true,
            },
          });
        }

        // Si no se envió o no se encontró, buscar el depósito principal de la sucursal
        if (!depositoParaInventario && sucursalId) {
          depositoParaInventario = await tx.depositos.findFirst({
            where: {
              sucursal_id: sucursalId,
              empresa_id: empresa_id,
              es_principal: true,
              active: true,
            },
          });
        }

        const sucursalLotesId = sucursalId;
        const lotesEnabled = sucursalLotesId
          ? await this.lotesService.isEnabledForEmpresa(empresa_id, sucursalLotesId, tx)
          : false;

        // Remisiones distintas que esta factura consolida (para vincular cabeceras).
        const remisionesFacturadas = new Set<string>();
        // Un ítem que viene de una remisión NO descuenta stock (ya lo hizo la
        // remisión). El chequeo es POR ÍTEM: permite mezclar en una misma factura
        // ítems de remisión (no descuentan) con productos normales (sí descuentan).
        const esItemDeRemision = (it: any) => (it?.aplicaciones_remision?.length ?? 0) > 0;

        // Sin depósito resuelto NO se descuenta stock, no se generan movimientos y
        // no se consume FIFO: la factura queda válida pero el inventario intacto.
        // Pasa cuando el punto de expedición apunta a una sucursal sin ningún
        // depósito principal activo. Antes ocurría en silencio — ahora se registra
        // en el log y se devuelve como advertencia al frontend.
        const itemsQueAfectanStock = items.filter(
          (it: any) => productosMap.get(it.producto_id)?.maneja_inventario && !esItemDeRemision(it),
        );
        if (!depositoParaInventario?.id && itemsQueAfectanStock.length > 0) {
          const motivo = sucursalId
            ? 'la sucursal del punto de expedición no tiene un depósito principal activo'
            : 'el punto de expedición no está vinculado a ninguna sucursal';
          advertenciasInventario.push(
            `No se descontó stock: ${motivo}. ${itemsQueAfectanStock.length} ítem(s) con control de inventario quedaron sin movimiento. Asigná un depósito principal a esa sucursal, o enviá el depósito al facturar.`,
          );
          this.logger.error(
            `Factura sin depósito resuelto — inventario NO afectado. empresa=${empresa_id} sucursal=${sucursalId ?? 'NULL'} items=${itemsQueAfectanStock.length}`,
            'FacturasService',
          );
        }

        for (const item of items) {
          const producto = productosMap.get(item.producto_id);
          const cantidadItem = Number(item.cantidad || 0);
          let costoUnitario = Number(producto?.precio_costo || 0);
          let costoTotal = costoUnitario * cantidadItem;
          let lotesConsumidos: Array<{
            lote_id: string;
            cantidad: number;
            costo_unitario: number;
            costo_total: number;
          }> = [];

          // Deducción FIFO — se salta para ítems de remisión (ya consumieron
          // stock/lotes al remisionar; evita doble descuento).
          // Solo entran a FIFO los productos que efectivamente manejan lote: uno
          // con `maneja_inventario` pero sin `maneja_lote` nunca recibe lotes en la
          // compra, así que pedirle lotes acá lo dejaría sin costo (o bloquearía la
          // venta). Ese caso sigue costeando por `precio_costo`, como antes.
          if (
            !esItemDeRemision(item) &&
            lotesEnabled &&
            depositoParaInventario?.id &&
            producto?.maneja_inventario &&
            producto?.maneja_lote &&
            cantidadItem > 0
          ) {
            const fifoResult = await this.fifoService.deductFifo(
              tx,
              empresa_id,
              item.producto_id,
              depositoParaInventario.id,
              cantidadItem,
            );
            // Lo cubierto por lotes va a costo real; el remanente sin lote (stock
            // previo a activar lotes, o desfases) se costea con `precio_costo`.
            const sinLote = Number(fifoResult.cantidad_sin_lote || 0);
            costoTotal = Number(fifoResult.costo_total || 0) + sinLote * Number(producto?.precio_costo || 0);
            costoUnitario = cantidadItem > 0 ? Number((costoTotal / cantidadItem).toFixed(4)) : 0;
            lotesConsumidos = fifoResult.lotes_consumidos;
          }

          const facturaDet = await tx.factura_det.create({
            data: {
              factura_cab_id: facturaCabecera.id,
              producto_id: item.producto_id,
              deposito_id: depositoParaInventario?.id ?? null,
              ddesproser: item.descripcion,
              cunimed: item.unidad_medida,
              dcantproser: item.cantidad,
              cpaisorig: item?.pais_origen ?? null,
              duniproser: item.precio_unitario,
              costo_unitario_venta: costoUnitario,
              costo_total_venta: costoTotal,
              dticamit: item?.tipo_cambio_item ?? null,
              dtotbruopeitem: item.total_bruto,
              ddescitem: item.descuento,
              dporcdesit: item.porcentaje_descuento,
              ddescgloitem: item.descuento_global,
              dtotopeitem: item.total_operacion,
              dtotopegs: null,
              iafeciva: item.afectacion_iva,
              dtasiva: item.porcentaje_iva,
              dpropiva: item.proporcion_iva,
              dbasgraviva: item.base_gravada,
              dliqivaitem: item.liquidacion_iva,
              dbasexe: item.base_gravada_exenta,
              dinfitem: item.info_adicional_item,
              dcdcanticipo: item?.cdc_anticipo_item,
              dantpreuniit: item?.anticipo_precio_unitario,
            } as any,
          });

          if (lotesConsumidos.length > 0) {
            await tx.factura_det_lote.createMany({
              data: lotesConsumidos.map((row) => ({
                factura_det_id: facturaDet.id,
                lote_id: row.lote_id,
                cantidad: row.cantidad,
                costo_unitario: row.costo_unitario,
                costo_total: row.costo_total,
              })),
            });
          }

          // Vínculo ítem factura → ítem remisión (flujo Remisión → Factura).
          // Valida saldo, crea la aplicación e incrementa cantidad_facturada.
          const aplicaciones = (item as any).aplicaciones_remision as
            | Array<{ nota_remision_det_id: string; cantidad: number }>
            | undefined;
          if (aplicaciones?.length) {
            for (const ap of aplicaciones) {
              const cant = Number(ap.cantidad || 0);
              if (cant <= 0) continue;
              const remDet = await tx.nota_remision_det.findUnique({
                where: { id: ap.nota_remision_det_id },
                select: { id: true, dcantproser: true, cantidad_facturada: true, nota_remision_cab_id: true },
              });
              if (!remDet) throw new NotFoundException(`Ítem de remisión ${ap.nota_remision_det_id} no encontrado`);
              const disponible = Number(remDet.dcantproser) - Number(remDet.cantidad_facturada || 0);
              if (cant > disponible + 0.0001) {
                throw new BadRequestException(
                  `Cantidad a facturar (${cant}) supera lo disponible (${disponible}) en el ítem de remisión`,
                );
              }
              await tx.nota_remision_det_aplicacion.create({
                data: {
                  nota_remision_det_id: remDet.id,
                  factura_det_id: facturaDet.id,
                  cantidad_aplicada: cant,
                },
              });
              await tx.$executeRaw`
                UPDATE nota_remision_det
                SET cantidad_facturada = cantidad_facturada + ${cant}
                WHERE id = ${remDet.id}::uuid`;
              if (remDet.nota_remision_cab_id) remisionesFacturadas.add(remDet.nota_remision_cab_id);
            }
          }
        }

        if (depositoParaInventario) {
          // === VALIDACIÓN DE POLÍTICA DE STOCK ===
          // Obtener política de stock de la sucursal
          let politicaStock: string = 'advertencia';
          if (sucursalId) {
            const sucConfig = await tx.empresas_sucursales.findUnique({
              where: { id: sucursalId },
              select: { config: true },
            });
            const cfg = sucConfig?.config as Record<string, any> | null;
            if (cfg?.politica_stock) politicaStock = cfg.politica_stock;
          }

          // Precargar el stock de todos los productos con inventario en UNA sola query
          // (antes: un findUnique por ítem en el loop de validación Y en el de descuento
          // → N+1 mientras se sostiene el lock de numeración). El map se actualiza tras
          // cada upsert para preservar read-your-writes ante productos repetidos.
          const productosInvIds = [
            ...new Set(
              items
                .filter((it) => !esItemDeRemision(it) && productosMap.get(it.producto_id)?.maneja_inventario)
                .map((it) => it.producto_id),
            ),
          ];
          const stockMap = new Map<string, any>();
          if (productosInvIds.length > 0) {
            const stockRows = await tx.stock_deposito.findMany({
              where: { deposito_id: depositoParaInventario.id, producto_id: { in: productosInvIds } },
            });
            for (const s of stockRows) stockMap.set(s.producto_id, s);
          }

          // Política "estricto": bloquea sin excepción (igual que nota-remision.service.ts).
          // No hay override por caja — el punto de "estricto" es que la sucursal lo garantice
          // siempre, sin depender de que cada caja esté configurada correctamente.
          if (politicaStock === 'estricto') {
            for (const item of items) {
              if (esItemDeRemision(item)) continue; // la remisión ya validó/descontó
              const prod = productosMap.get(item.producto_id);
              if (!prod?.maneja_inventario) continue;

              const stockActual = stockMap.get(item.producto_id) ?? null;
              const disponible = stockActual
                ? Number(stockActual.cantidad_disponible) - Number(stockActual.cantidad_reservada)
                : 0;

              if (disponible < item.cantidad) {
                throw new BadRequestException(
                  `Stock insuficiente para "${item.descripcion}". Disponible: ${disponible}, requerido: ${item.cantidad}`,
                );
              }
            }
          }

          // Buscar o crear tipo de movimiento "Venta"
          let tipoMovVenta = await tx.tipo_movimiento_inventario.findFirst({
            where: {
              OR: [
                { codigo: 'VENTA' },
                { descripcion: { contains: 'Venta', mode: 'insensitive' } },
                { descripcion: { contains: 'Salida', mode: 'insensitive' } },
              ],
            },
          });
          if (!tipoMovVenta) {
            tipoMovVenta = await tx.tipo_movimiento_inventario.upsert({
              where: { codigo: 'VENTA' },
              update: {},
              create: { codigo: 'VENTA', descripcion: 'Salida de mercadería', afecta_stock: -1 },
            });
          }

          // Procesar cada item que maneje inventario
          for (const item of items) {
            if (esItemDeRemision(item)) continue; // ítem de remisión: no descuenta
            const producto = productosMap.get(item.producto_id);

            // Solo descontar si el producto maneja inventario
            if (producto?.maneja_inventario) {
              // Stock actual desde el map precargado (read-your-writes vía el propio map).
              const stockActual = stockMap.get(item.producto_id) ?? null;

              const cantidadActual = stockActual ? Number(stockActual.cantidad_disponible) : 0;
              const nuevaCantidad = cantidadActual - item.cantidad;

              // Actualizar o crear registro de stock (permitir negativo para no bloquear ventas)
              await tx.stock_deposito.upsert({
                where: {
                  deposito_id_producto_id: {
                    deposito_id: depositoParaInventario.id,
                    producto_id: item.producto_id,
                  },
                },
                create: {
                  deposito_id: depositoParaInventario.id,
                  producto_id: item.producto_id,
                  cantidad_disponible: -item.cantidad,
                },
                update: {
                  cantidad_disponible: nuevaCantidad,
                  updated_at: new Date(),
                },
              });
              // Reflejar el nuevo stock en el map para líneas siguientes del mismo producto.
              stockMap.set(item.producto_id, { ...(stockActual ?? {}), cantidad_disponible: nuevaCantidad });

              const numeroFactura = `${facturaCabecera.dest}-${facturaCabecera.dpunexp}-${facturaCabecera.dnumdoc}`;
              await tx.movimientos_inventario.create({
                data: {
                  deposito_origen_id: depositoParaInventario.id,
                  producto_id: item.producto_id,
                  tipo_movimiento_id: tipoMovVenta.id,
                  cantidad: item.cantidad,
                  precio_unitario: item.precio_unitario,
                  documento_origen: 'factura_venta',
                  documento_id: facturaCabecera.id,
                  observaciones: `Venta Fact. ${numeroFactura}`,
                },
              });
            }
          }
        }
        // === FIN DESCUENTO DE INVENTARIO ===

        // Vincular a nivel cabecera cada remisión consolidada por esta factura.
        for (const remisionId of remisionesFacturadas) {
          await tx.nota_remision_factura.create({
            data: { nota_remision_cab_id: remisionId, factura_cab_id: facturaCabecera.id },
          });
        }

        const pagosData: Array<PagosData> = [];

        if (pagos) {
          for (const pago of pagos) {
            // 1) Validaciones base
            const medio_pago = await tx.medio_pago.findUnique({
              where: { id: pago.medio_pago_id },
            });
            if (!medio_pago) throw new NotFoundException(`El medio de pago ${pago.medio_pago_id} no existe`);

            // 2) Validaciones por tipo
            if (medio_pago.codigo === 2) {
              // Cheque
              if (!pago.banco_emisor?.trim()) throw new BadRequestException('El banco emisor es obligatorio');
              if (!pago.numero_cheque?.trim()) throw new BadRequestException('El número de cheque es obligatorio');
            }

            if (medio_pago.codigo === 3) {
              // Tarjeta
              if (!pago.tarjeta_id) throw new BadRequestException('La tarjeta es obligatoria');
              const tarjeta = await tx.tarjeta.findUnique({
                where: { id: pago.tarjeta_id },
              });
              if (!tarjeta) throw new NotFoundException(`La tarjeta ${pago.tarjeta_id} no existe`);

              if (!pago.forma_procesamiento_pago_id)
                throw new BadRequestException('La forma de procesamiento de pago es obligatoria');
              const forma_proc = await tx.forma_procesamiento_pago.findUnique({
                where: { id: pago.forma_procesamiento_pago_id },
              });
              if (!forma_proc)
                throw new NotFoundException(`La forma de procesamiento ${pago.forma_procesamiento_pago_id} no existe`);

              if (tarjeta.codigo === '9') {
                if (!pago.tarjeta_descripcion_otro?.trim())
                  throw new BadRequestException('La descripción de la tarjeta es obligatoria para código 9 (Otro)');
              }
            }

            // 3) Multi-moneda: resolver moneda de pago y conversión
            // dmontipag = monto en moneda de pago (SIFEN dMonTiPag)
            // montoBase  = monto convertido a moneda de la factura (para contabilidad de caja)
            let montoPagoCurrency = pago.monto_pago || 0;
            let montoBase = pago.monto_pago || 0;
            let monedaPagoCodigo = moneda?.codigo || '';
            let tipoCambioPago: number | null = null;
            let montoOriginal: number | null = null;
            let monedaPagoId: string | null = null;

            if (pago.moneda_pago_id) {
              const monedaPago = await tx.moneda.findUnique({ where: { id: pago.moneda_pago_id } });
              if (!monedaPago) throw new NotFoundException(`La moneda de pago ${pago.moneda_pago_id} no existe`);

              monedaPagoId = monedaPago.id;
              monedaPagoCodigo = monedaPago.codigo || moneda?.codigo || '';

              // Si la moneda de pago difiere de la moneda de la factura, buscar cotización y convertir
              if (monedaPago.id !== moneda?.id) {
                const cotizacion = await tx.cotizacion_moneda.findFirst({
                  where: { empresa_id, moneda_id: monedaPago.id },
                  orderBy: { fecha: 'desc' },
                });
                if (!cotizacion) {
                  throw new BadRequestException(
                    `No se encontró cotización para la moneda ${monedaPago.codigo}. Registre una cotización antes de usar esta moneda.`,
                  );
                }
                tipoCambioPago = Number(cotizacion.cotizacion_venta) || 0;
                // monto_pago ya viene en moneda de pago desde el frontend
                montoOriginal = pago.monto_moneda_original ?? pago.monto_pago ?? 0;
                montoPagoCurrency = montoOriginal;

                // Convertir a moneda de la factura para contabilidad
                if (moneda?.codigo === 'PYG') {
                  montoBase = montoOriginal * tipoCambioPago;
                } else {
                  montoBase = pago.monto_pago || 0;
                }
              }
            } else if (pago.monto_moneda_original) {
              montoOriginal = pago.monto_moneda_original;
            }

            // 4) Armar el registro listo para createMany
            pagosData.push({
              factura_cab_id: facturaCabecera.id,
              empresa_id,
              sesion_caja_id: pago.sesion_caja_id || null,
              medio_pago_id: pago.medio_pago_id,
              tarjeta_id: pago?.tarjeta_id || null,
              forma_procesamiento_pago_id: pago?.forma_procesamiento_pago_id || null,
              dmontipag: montoPagoCurrency,
              cmonetipag: monedaPagoCodigo,
              dticamtipag: tipoCambioPago,
              monto_moneda_original: montoOriginal,
              moneda_pago_id: monedaPagoId,
              montoBase,
              drsprotar: pago?.razon_social_procesadora || null,
              drucprotar: pago?.ruc_procesadora || null,
              ddvprotar: pago?.dv_procesadora || null,
              dcodauope: pago?.nro_autorizacion_procesadora || null,
              dnomtit: pago?.nombre_titular_tarjeta || null,
              dnumtarj: pago?.numero_tarjeta || null,
              ddesdentarj: pago?.tarjeta_descripcion_otro || null,
              dnumcheq: pago?.numero_cheque || null,
              dbcoemi: pago?.banco_emisor || null,
            });
          }

          // 4) Inserción en lote
          if (pagosData.length > 0) {
            // Quitar montoBase antes de persistir (campo interno, no existe en la tabla)
            const pagosParaDB = pagosData.map(({ montoBase: _mb, ...rest }) => rest);
            await tx.factura_forma_pagos.createMany({ data: pagosParaDB });

            // 5) Actualizar montos de sesión de caja si existe
            // Cobro diferido: el pago se declara para SIFEN pero NO entra a caja
            // (el cobrador lo recauda en campo y lo registra como recibo_cobro).
            for (const pagoData of cabecera.cobro_diferido ? [] : pagosData) {
              // montoBase = monto en moneda de la factura (para contabilidad de caja)
              const montoParaCaja = pagoData.montoBase ?? pagoData.dmontipag;

              if (pagoData.sesion_caja_id) {
                const medioPago = await tx.medio_pago.findUnique({
                  where: { id: pagoData.medio_pago_id },
                });

                if (medioPago) {
                  const sesion = await tx.sesiones_caja.findUnique({
                    where: { id: pagoData.sesion_caja_id },
                  });

                  if (sesion) {
                    const codigoMedio = medioPago.codigo;

                    // Buscar tipo de movimiento "Entrada" para registrar movimientos
                    const tipoMovEntrada = await tx.tipo_movimiento_cajas.findFirst({
                      where: {
                        descripcion: {
                          contains: 'Entrada',
                          mode: 'insensitive',
                        },
                      },
                    });

                    // Actualizar campo específico según código de medio de pago.
                    // Incremento atómico de Prisma (`SET col = col + $1` a nivel
                    // SQL) en vez de leer-y-escribir: si dos pagos de la misma
                    // sesión se procesan casi simultáneamente (dos cajeros, o dos
                    // ventas rápidas), la lectura previa podía quedar desactualizada
                    // y la segunda escritura pisaba la suma de la primera, perdiendo
                    // ese monto del contador de la sesión (visible después como una
                    // diferencia inexplicable al hacer el arqueo de cierre de caja).
                    if (codigoMedio === 1) {
                      // Efectivo
                      await tx.sesiones_caja.update({
                        where: { id: pagoData.sesion_caja_id },
                        data: {
                          monto_ventas_efectivo: { increment: montoParaCaja },
                          updated_at: new Date(),
                        },
                      });
                    } else if (codigoMedio === 3 || codigoMedio === 4 || codigoMedio === 8) {
                      // Tarjeta (crédito / débito / empresarial)
                      await tx.sesiones_caja.update({
                        where: { id: pagoData.sesion_caja_id },
                        data: {
                          monto_ventas_tarjeta: { increment: montoParaCaja },
                          updated_at: new Date(),
                        },
                      });
                    } else if (codigoMedio === 5) {
                      // Transferencia
                      await tx.sesiones_caja.update({
                        where: { id: pagoData.sesion_caja_id },
                        data: {
                          monto_ventas_transferencia: { increment: montoParaCaja },
                          updated_at: new Date(),
                        },
                      });
                    } else {
                      // Para otros medios de pago (QR, cheque, etc.), sumar a un campo genérico
                      await tx.sesiones_caja.update({
                        where: { id: pagoData.sesion_caja_id },
                        data: {
                          monto_entradas_manual: { increment: montoParaCaja },
                          updated_at: new Date(),
                        },
                      });
                    }

                    // Fase 2: Actualizar resumen_monedas si el pago es en moneda distinta
                    if (pagoData.moneda_pago_id && pagoData.monto_moneda_original) {
                      const resumenActual = (sesion.resumen_monedas as Record<string, any>) || {};
                      const codigoMoneda = pagoData.cmonetipag || 'PYG';
                      const entradaMoneda = resumenActual[codigoMoneda] || {
                        moneda_id: pagoData.moneda_pago_id,
                        codigo: codigoMoneda,
                        total_original: 0,
                        total_convertido: 0,
                        cantidad_operaciones: 0,
                      };
                      entradaMoneda.total_original += pagoData.monto_moneda_original;
                      entradaMoneda.total_convertido += montoParaCaja;
                      entradaMoneda.cantidad_operaciones += 1;
                      resumenActual[codigoMoneda] = entradaMoneda;

                      await tx.sesiones_caja.update({
                        where: { id: pagoData.sesion_caja_id },
                        data: { resumen_monedas: resumenActual, updated_at: new Date() },
                      });
                    }

                    // Crear movimiento de caja para trazabilidad de TODOS los medios de pago
                    const numeroFactura = `${facturaCabecera.dest}-${facturaCabecera.dpunexp}-${facturaCabecera.dnumdoc}`;
                    await tx.movimiento_cajas.create({
                      data: {
                        sesion_id: pagoData.sesion_caja_id,
                        empresa_id: pagoData.empresa_id,
                        tipo_movimiento_caja_id: tipoMovEntrada?.id || null,
                        monto: montoParaCaja,
                        descripcion: `Venta Fact. ${numeroFactura} - ${medioPago.descripcion}`,
                        referencia: facturaCabecera.id,
                        fecha_mov: new Date().toISOString(),
                      },
                    });
                  }
                }
              }
            }
          }
        }

        // Una factura a credito SIEMPRE tiene que generar deuda. Si vino sin cuotas
        // -- el caso de credito simple a plazo, donde el emisor solo indica dplazocre --
        // se arma una unica cuota por el total: sin esto no se crea la cuenta a cobrar,
        // la factura queda con saldo vacio, el listado la muestra como pagada y el
        // wizard de cobros dice que el cliente no tiene nada pendiente. La venta a
        // credito existe, pero es incobrable desde el sistema.
        let cuotasEfectivas = cuotas;
        if (condicion_operacion?.codigo === 2 && (!cuotas || cuotas.length === 0)) {
          const totalCredito = subtotal.total_general_operacion - (cabecera.dmonent || 0);
          if (totalCredito > 0) {
            const plazoDias = Number(String(cabecera?.dplazocre ?? '').match(/\d+/)?.[0]) || 30;
            const vencimiento = new Date(toPrismaDate(cabecera.dfeemide) ?? nowAsuncionNaive());
            vencimiento.setDate(vencimiento.getDate() + plazoDias);
            cuotasEfectivas = [
              {
                nro_cuota: 1,
                monto_cuota: totalCredito,
                moneda_iso: moneda?.codigo || 'PYG',
                vencimiento: vencimiento.toISOString(),
              } as any,
            ];
            this.logger.warn(
              `Factura a credito sin cuotas: se genera 1 cuota por ${totalCredito} a ${plazoDias} dias`,
              'FacturasService',
            );
          }
        }

        if (cuotasEfectivas && cuotasEfectivas.length > 0) {
          const cuotas = cuotasEfectivas;
          // Calcular monto total y última fecha de vencimiento para la cuenta a cobrar
          const montoTotalCuotas = cuotas.reduce((sum, c) => sum + c.monto_cuota, 0);
          const ultimaFechaVencimiento = cuotas.reduce((max, c) => {
            const fecha = new Date(c.vencimiento);
            return fecha > max ? fecha : max;
          }, new Date(cuotas[0].vencimiento));

          // Si la factura proviene de una solicitud de crédito, copiar la política
          // acordada a la cuenta a cobrar (fuente de verdad para vencimientos futuros
          // y agenda del cobrador). Ver docs/plan-solicitud_credito.md FASE 3.5.
          let politicaCobro: {
            intervalo_dias?: number | null;
            dia_fijo_pago_1?: number | null;
            dia_fijo_pago_2?: number | null;
            dia_cobro_semana?: number | null;
            dias_gracia_override?: number | null;
            solicitud_credito_id?: string | null;
          } = {};
          if (cabecera.solicitud_credito_id) {
            const solicitud = await tx.solicitud_credito.findUnique({
              where: { id: cabecera.solicitud_credito_id },
              select: {
                id: true,
                intervalo_dias: true,
                dia_fijo_pago_1: true,
                dia_fijo_pago_2: true,
                dia_cobro_semana: true,
                dias_gracia_override: true,
              },
            });
            if (solicitud) {
              politicaCobro = {
                intervalo_dias: solicitud.intervalo_dias,
                dia_fijo_pago_1: solicitud.dia_fijo_pago_1,
                dia_fijo_pago_2: solicitud.dia_fijo_pago_2,
                dia_cobro_semana: solicitud.dia_cobro_semana,
                dias_gracia_override: solicitud.dias_gracia_override,
                solicitud_credito_id: solicitud.id,
              };
            }
          }

          // Crear cuenta a cobrar
          const cuentaCobrar = await tx.cuentas_cobrar.create({
            data: {
              empresa_id,
              cliente_id: cabecera.cliente_id,
              factura_venta_id: facturaCabecera.id,
              fecha_emision: toPrismaDate(cabecera.dfeemide),
              fecha_vencimiento: ultimaFechaVencimiento,
              moneda_iso: cuotas[0].moneda_iso,
              monto_total: montoTotalCuotas,
              saldo_pendiente: montoTotalCuotas,
              estado: 'pendiente',
              ...politicaCobro,
            },
          });

          // Crear cuotas asociadas a la cuenta a cobrar
          await tx.factura_cuotas.createMany({
            data: cuotas.map((cuota) => ({
              factura_cab_id: facturaCabecera.id,
              cuenta_id: cuentaCobrar.id,
              cmonecuo: cuota.moneda_iso,
              dmoncuota: cuota.monto_cuota,
              dvenccuo: toPrismaDate(cuota.vencimiento),
              nro_cuota: cuota.nro_cuota,
              saldo_pendiente: cuota.monto_cuota,
              estado: EstadoFactura.PENDIENTE,
            })),
          });

          // Fase 2: Actualizar saldo pendiente del cliente
          await this.clientesService.actualizarSaldoPendiente(
            cabecera.cliente_id,
            montoTotalCuotas,
            'sumar',
            empresa_id,
            user_id,
          );
        }

        await tx.factura_subtotales.create({
          data: {
            factura_cab_id: facturaCabecera.id,
            dsubexe: subtotal.subtotal_exento,
            dsubexo: subtotal.subtotal_exonerado,
            dsub5: subtotal.subtotal_5,
            dsub10: subtotal.subtotal_10,
            dtotope: subtotal.total_general_operacion,
            dtotdesc: subtotal.total_descuento_particular_item,
            dtotdescglotem: subtotal.total_descuento_global_item,
            dtotantitem: subtotal.total_anticipo_item,
            dtotant: subtotal.total_anticipo_global_item,
            dporcdesctotal: subtotal.porcentaje_descuento_total,
            ddesctotal: subtotal.total_descuento,
            danticipo: subtotal.total_anticipo_operacion,
            dredon: subtotal.total_redondeo,
            dcomi: subtotal.total_comision,
            dtotgralope: subtotal.total_general_operacion,
            diva5: subtotal.liquidacion_iva_5,
            diva10: subtotal.liquidacion_iva_10,
            dliqtotiva5: subtotal.liquidacion_redondeo_5,
            dliqtotiva10: subtotal.liquidacion_redondeo_10,
            divacomi: subtotal.liquidacion_iva_comision,
            dtotiva: subtotal.total_iva,
            dbasegrav5: subtotal.total_base_gravada_5,
            dbasegrav10: subtotal.total_base_gravada_10,
            dtbasgraiva: subtotal.total_base_gravada,
            dtotalgs: subtotal.total_gs,
          },
        });

        // Cobro diferido: registrar cuenta a cobrar + cuota única (vence hoy, es contado)
        // + asignación al cobrador para que aparezca en cartera y se pueda cobrar/rendir.
        if (cabecera.cobro_diferido && condicion_operacion.codigo === 1) {
          const fechaEmisionDate = toPrismaDate(cabecera.dfeemide);
          const monedaIso = moneda?.codigo || 'PYG';
          const totalDiferido = subtotal.total_general_operacion;

          const cuentaCobrarDiferida = await tx.cuentas_cobrar.create({
            data: {
              empresa_id,
              cliente_id: cabecera.cliente_id,
              factura_venta_id: facturaCabecera.id,
              fecha_emision: fechaEmisionDate,
              fecha_vencimiento: fechaEmisionDate,
              moneda_iso: monedaIso,
              monto_total: totalDiferido,
              saldo_pendiente: totalDiferido,
              estado: 'pendiente',
            },
          });

          // Una cuota única que vence el mismo día de emisión (es contado).
          await tx.factura_cuotas.create({
            data: {
              factura_cab_id: facturaCabecera.id,
              cuenta_id: cuentaCobrarDiferida.id,
              cmonecuo: monedaIso,
              dmoncuota: totalDiferido,
              dvenccuo: fechaEmisionDate,
              nro_cuota: 1,
              saldo_pendiente: totalDiferido,
              estado: 'pendiente',
            },
          });
        }

        // Asignar cobrador si se envió cobrador_id (aplica a contado, contado diferido y crédito)
        if (cabecera.cobrador_id) {
          const cobrador = await tx.vendedores_cobradores.findFirst({
            where: {
              id: cabecera.cobrador_id,
              empresa_id,
              tipo: { in: ['cobrador', 'ambos'] },
              active: true,
            },
          });
          // La creación consolidada (un solo registro de asignación por factura)
          // sucede debajo — acá solo validamos que el cobrador exista.
          if (!cobrador) {
            // cobrador_id inválido → limpiar antes de consolidar
            cabecera.cobrador_id = null;
          }
        }

        // Validar vendedor por separado
        if (cabecera.vendedor_id) {
          const vendedor = await tx.vendedores_cobradores.findFirst({
            where: {
              id: cabecera.vendedor_id,
              empresa_id,
              tipo: { in: ['vendedor', 'ambos'] },
              active: true,
            },
          });
          if (!vendedor) {
            cabecera.vendedor_id = null;
          }
        }

        // Consolidar en un solo registro de asignación por factura (evita las
        // filas duplicadas que aparecían cuando se enviaban ambos IDs).
        if (cabecera.cobrador_id || cabecera.vendedor_id) {
          const existente = await tx.asignacion_facturas.findFirst({
            where: { empresa_id, factura_id: facturaCabecera.id },
          });
          if (existente) {
            await tx.asignacion_facturas.update({
              where: { id: existente.id },
              data: {
                cobrador_id: cabecera.cobrador_id || existente.cobrador_id,
                vendedor_id: cabecera.vendedor_id || existente.vendedor_id,
                updated_at: new Date(),
              },
            });
          } else {
            await tx.asignacion_facturas.create({
              data: {
                empresa_id,
                factura_id: facturaCabecera.id,
                cobrador_id: cabecera.cobrador_id || null,
                vendedor_id: cabecera.vendedor_id || null,
                estado: 'pendiente',
              },
            });
          }
        }

        // Auto-generar comisión de VENTA si la empresa tiene el módulo activo y el
        // vendedor tiene comisión configurada. Antes esta lógica solo se disparaba
        // vía asignarVendedor manual desde Asignación de Cobranza. Ahora se ejecuta
        // al momento de emitir la factura, alineado con la comisión de cobranza que
        // recibos.service.ts genera al aplicar el pago.
        if (cabecera.vendedor_id) {
          // Idem tieneModuloComisiones: submódulo específico o módulo padre.
          const [subActivo, modActivo] = await Promise.all([
            tx.suscripcion_submodulos.findFirst({
              where: {
                active: true,
                submodulos: { codigo: 'COB_COMISIONES' },
                suscripciones: { empresa_id },
              },
              select: { id: true },
            }),
            tx.suscripcion_modulos.findFirst({
              where: {
                activo: true,
                modulos: { codigo: 'COMISIONES' },
                suscripciones: { empresa_id },
              },
              select: { id: true },
            }),
          ]);
          const tieneComisiones = subActivo || modActivo;
          if (tieneComisiones) {
            const vendedor = await tx.vendedores_cobradores.findFirst({
              where: { id: cabecera.vendedor_id, empresa_id },
              select: { comision_venta: true, comision_porcentaje: true, comision_fija: true, active: true },
            });
            if (vendedor?.active) {
              const pctVenta = Number(vendedor.comision_venta ?? 0);
              const pctGenerico = Number(vendedor.comision_porcentaje ?? 0);
              const porcentaje = pctVenta > 0 ? pctVenta : pctGenerico;
              const montoFijo = Number(vendedor.comision_fija ?? 0);
              const montoBase = Number(subtotal.total_general_operacion ?? 0);
              if ((porcentaje > 0 || montoFijo > 0) && montoBase > 0) {
                const montoComision = (montoBase * porcentaje) / 100 + montoFijo;
                if (montoComision > 0) {
                  const yaExiste = await tx.comisiones.findFirst({
                    where: {
                      factura_id: facturaCabecera.id,
                      vendedor_cobrador_id: cabecera.vendedor_id,
                      tipo: 'venta',
                    },
                    select: { id: true },
                  });
                  if (!yaExiste) {
                    await tx.comisiones.create({
                      data: {
                        empresa_id,
                        vendedor_cobrador_id: cabecera.vendedor_id,
                        factura_id: facturaCabecera.id,
                        tipo: 'venta',
                        monto_base: montoBase,
                        porcentaje_aplicado: porcentaje > 0 ? porcentaje : null,
                        monto_fijo_aplicado: montoFijo > 0 ? montoFijo : null,
                        monto_comision: montoComision,
                        estado: 'pendiente',
                      },
                    });
                  }
                }
              }
            }
          }
        }

        // Vincular pedido mayorista si se envió pedido_id
        if (cabecera.pedido_id) {
          const pedido = await tx.pedidos.findFirst({
            where: { id: cabecera.pedido_id, empresa_id, active: true },
          });
          if (pedido && pedido.estado === 'confirmado') {
            await tx.pedidos.update({
              where: { id: cabecera.pedido_id },
              data: { factura_id: facturaCabecera.id, estado: 'facturado', updated_at: new Date() },
            });
            // Notificar al cliente ecommerce si el pedido proviene de una sesión de checkout (fire-and-forget)
            this.ecommerceNotifications
              .onOrderInvoiced(empresa_id, cabecera.pedido_id)
              .catch((err) =>
                this.logger.error(
                  `Fallo notificación ecommerce onOrderInvoiced (pedido ${cabecera.pedido_id}): ${err instanceof Error ? err.message : String(err)}`,
                  'FacturasService',
                ),
              );
          }
        }

        return {
          facturaId: facturaCabecera.id,
          sucursalConfig: puntoExpedicion?.empresas_sucursales?.config as Record<string, unknown> | null,
          dest,
          dpunexp,
          dnumdoc,
          remisionesFacturadas: Array.from(remisionesFacturadas),
          advertenciasInventario,
        };
      });

      // ── Side-effects POST-COMMIT ────────────────────────────────────────
      // La factura YA está persistida (con CDC + numeración). Un fallo aquí NO
      // debe propagarse como error de creación: si respondiéramos 500, el retry
      // del cliente generaría una SEGUNDA factura legal con nuevo CDC/numeración.
      // Por eso todo esto va en su propio try/catch que loguea y continúa.
      // Default envio_lote_middleware = true (no enviar automáticamente).
      let envioLoteMiddleware = true;
      try {
        // Empresas sin facturación electrónica habilitada: aprobamos localmente
        // y saltamos cualquier envío al middleware (sea automático o en lote).
        const usaSifen = await this.middlewareSifenService.empresaUsaSifen(empresa_id);
        envioLoteMiddleware = Boolean(facturaId.sucursalConfig?.envio_lote_middleware ?? true);
        if (!usaSifen) {
          await this.middlewareSifenService.aprobarFacturaSinSifen(facturaId.facturaId);
        } else if (!envioLoteMiddleware) {
          // Solo enviar automáticamente si envio_lote_middleware es false
          await this.queuesService.enqueueSifenFactura(facturaId.facturaId, empresa_id);
        }

        this.logger.log(`Factura creada: ${facturaId.facturaId} | Empresa: ${empresa_id}`, 'FacturasService');
        await this.auditService.log({
          empresa_id,
          user_id,
          action: 'CREATE',
          entity_type: 'factura',
          entity_id: facturaId.facturaId,
          descripcion:
            `Factura creada | ${facturaId.dest}-${facturaId.dpunexp}-${facturaId.dnumdoc} |Empresa: ${empresa_id}` +
            (facturaId.remisionesFacturadas.length
              ? ` | Facturada desde ${facturaId.remisionesFacturadas.length} remisión(es) (sin descuento de stock)`
              : ''),
          new_value: {
            empresa_id,
            cliente_id: cabecera.cliente_id,
            total: subtotal.total_general_operacion,
            nro_comprobante: `${facturaId.dest}-${facturaId.dpunexp}-${facturaId.dnumdoc}`,
            ...(facturaId.remisionesFacturadas.length ? { remisiones_facturadas: facturaId.remisionesFacturadas } : {}),
          },
        });
      } catch (postErr) {
        // La factura quedó creada; solo falló un side-effect (envío SIFEN / auditoría).
        // Se puede reenviar manualmente desde el listado. NO relanzar (evita factura duplicada).
        const m = postErr instanceof Error ? postErr.message : String(postErr);
        this.logger.error(
          `Factura ${facturaId.facturaId} creada OK, pero falló un side-effect post-commit (SIFEN/auditoría): ${m}`,
          'FacturasService',
        );
      }
      // Contabilización se dispara cuando SIFEN aprueba (ver middleware-sifen.service.ts)

      // Hook comisión de venta — fire and forget
      if (cabecera.vendedor_id) {
        this.asignacionFacturas
          .generarComisionVentaPorFactura(facturaId.facturaId, cabecera.vendedor_id, empresa_id)
          .catch((err) => {
            this.logger.error?.(
              `Error generando comisión de venta para factura ${facturaId.facturaId}: ${err.message}`,
            );
          });
      }

      return {
        message: 'Factura creada exitosamente',
        data: {
          id: facturaId.facturaId,
          envio_automatico: !envioLoteMiddleware,
          // Avisos no bloqueantes (ej. no se descontó stock por falta de depósito).
          advertencias: facturaId.advertenciasInventario ?? [],
        },
      };

      // validamos si es contado o credito
    } catch (error: unknown) {
      const logMsg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
      this.logger.error(`Error al crear factura: ${logMsg}`, 'FacturasService');
      if (error instanceof NotFoundException) throw error;
      if (error instanceof ConflictException) throw error;
      if (error instanceof BadRequestException) throw error;
      if (error instanceof ForbiddenException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new Error(`Error al crear factura: ${message}`);
    }
  }

  async getSiguienteNumeracion(
    est: string,
    exp: string,
    empresa_id: string,
  ): Promise<{ siguiente: string; ultimo: string | null }> {
    const last = await this.prisma.factura_cab.findFirst({
      where: { dest: est, dpunexp: exp, empresa_id },
      orderBy: { dnumdoc: 'desc' },
      select: { dnumdoc: true },
    });
    if (!last || !last.dnumdoc) {
      return { siguiente: '0000001', ultimo: null };
    }
    const current = parseInt(last.dnumdoc, 10) || 0;
    const next = current + 1;
    return {
      siguiente: next.toString().padStart(7, '0'),
      ultimo: last.dnumdoc,
    };
  }

  /**
   * `where` compartido por el reporte de ventas por producto y su detalle por
   * factura: ventas válidas + alcance por sucursal + filtros de la pantalla
   * (fechas, moneda, cliente) para la cabecera, y categoría / rubro / producto
   * para el producto de cada línea.
   */
  private async whereReporteVentasPorProducto(
    empresa_id: string,
    params: {
      fecha_desde?: string;
      fecha_hasta?: string;
      categoria_id?: string;
      moneda_id?: string;
      rubro_id?: string;
      producto_id?: string;
      cliente_id?: string;
    },
    usuario_id?: string,
  ): Promise<{ whereCab: any; whereProducto: Record<string, unknown> }> {
    // Ventas VÁLIDAS (mismo criterio fiscal que el Libro IVA Ventas / Resumen):
    // no anuladas + aprobadas SIFEN (o sin SIFEN) + sin cancelación aprobada.
    // (Antes filtraba estado != 'ANULADA', string que NO matchea el valor real 'Anulado',
    //  por lo que contaba anuladas; además no excluía rechazadas ni pendientes.)
    const where: any = {
      empresa_id,
      estado: { notIn: ['Anulado', 'Anulada', 'ANULADO', 'ANULADA', 'anulado', 'anulada'] },
      AND: [
        { OR: [{ estado_sifen: 'Aprobado' }, { estado_sifen: null }] },
        {
          OR: [
            { evento_aplicado: null },
            { evento_aplicado: { notIn: ['ECAN', 'EINO', 'EINU'] } },
            { estado_evento: { not: 'Aprobado' } },
          ],
        },
      ],
    };

    if (params.fecha_desde || params.fecha_hasta) {
      const gte = parseYmdValida(params.fecha_desde, false);
      const lte = parseYmdValida(params.fecha_hasta, true);
      if (gte || lte) {
        where.dfeemide = {};
        if (gte) where.dfeemide.gte = gte;
        if (lte) where.dfeemide.lte = lte;
      }
    }
    if (params.moneda_id) where.moneda_id = params.moneda_id;
    if (params.cliente_id) where.cliente_id = params.cliente_id;

    // Alcance por sucursal, igual que Rentabilidad: un producto del taller
    // vendido en la regalería es una venta de la regalería, y un usuario del
    // taller no la tiene que ver aunque el producto sea de su rubro.
    const filtroFiscal = await filtroFiscalPorSucursal(
      this.prisma,
      await alcanceSucursalUsuario(this.prisma, usuario_id, empresa_id),
    );
    if (filtroFiscal) where.dest = filtroFiscal.dest;

    // Rubro: el pedido por la pantalla, validado contra los que el usuario
    // tiene permitidos. Un usuario del taller sin rubro pedido igual queda
    // recortado a Taller. Misma regla que el catálogo (productos.rubro_id o el
    // de su categoría; sin rubro = compartido y entra en todos).
    const rubros = await rubrosFiltroUsuario(this.prisma, usuario_id, empresa_id, params.rubro_id);
    const whereProducto = {
      ...(params.producto_id ? { id: params.producto_id } : {}),
      ...(params.categoria_id ? { categoria_id: params.categoria_id } : {}),
      ...(rubros !== null ? whereProductoPorRubro(rubros) : {}),
    };
    return { whereCab: where, whereProducto };
  }

  async getReporteProductos(
    empresa_id: string,
    params: {
      fecha_desde?: string;
      fecha_hasta?: string;
      categoria_id?: string;
      moneda_id?: string;
      rubro_id?: string;
      producto_id?: string;
      cliente_id?: string;
      limit?: number;
    },
    usuario_id?: string,
  ) {
    const { whereCab: where, whereProducto } = await this.whereReporteVentasPorProducto(empresa_id, params, usuario_id);

    // Traer todos los detalles de facturas del período
    const detalles = await this.prisma.factura_det.findMany({
      where: {
        factura_cab: where,
        ...(Object.keys(whereProducto).length > 0 && { productos: whereProducto }),
      },
      include: {
        productos: {
          select: {
            id: true,
            descripcion: true,
            cod_producto: true,
            precio: true,
            precio_costo: true,
            tipo: true,
            categoria: { select: { id: true, descripcion: true } },
          },
        },
        factura_cab: {
          select: { moneda: { select: { codigo: true } } },
        },
      },
    });

    // Agrupar por producto
    const productosMap = new Map<
      string,
      {
        producto_id: string;
        descripcion: string;
        cod_producto: string;
        categoria: string;
        categoria_id: string | null;
        tipo: string;
        moneda: string;
        cantidad_vendida: number;
        ingresos_brutos: number;
        descuentos: number;
        ingresos_netos: number;
        iva_generado: number;
        costo_total: number;
        precio_promedio: number;
        facturas_count: number;
        precios: number[];
      }
    >();

    const facturasIds = new Set<string>();

    for (const det of detalles) {
      const pid = det.producto_id || 'sin-producto';
      const prod = det.productos;
      const moneda = det.factura_cab?.moneda?.codigo || 'PYG';

      if (!productosMap.has(pid)) {
        productosMap.set(pid, {
          producto_id: pid,
          descripcion: prod?.descripcion || det.ddesproser || 'Sin nombre',
          cod_producto: prod?.cod_producto || '',
          categoria: prod?.categoria?.descripcion || 'Sin categoría',
          categoria_id: prod?.categoria?.id || null,
          tipo: prod?.tipo || 'producto',
          moneda,
          cantidad_vendida: 0,
          ingresos_brutos: 0,
          descuentos: 0,
          ingresos_netos: 0,
          iva_generado: 0,
          costo_total: 0,
          precio_promedio: 0,
          facturas_count: 0,
          precios: [],
        });
      }

      const item = productosMap.get(pid);
      const cantidad = Number(det.dcantproser || 0);
      const bruto = Number(det.dtotbruopeitem || 0);
      const neto = Number(det.dtotopeitem || 0);
      const descuento = Number(det.ddescitem || 0) + Number(det.ddescgloitem || 0);
      const iva = Number(det.dliqivaitem || 0);
      const costo = Number(prod?.precio_costo || 0) * cantidad;

      item.cantidad_vendida += cantidad;
      item.ingresos_brutos += bruto;
      item.descuentos += descuento;
      item.ingresos_netos += neto;
      item.iva_generado += iva;
      item.costo_total += costo;
      item.precios.push(Number(det.duniproser || 0));

      if (det.factura_cab_id && !facturasIds.has(`${pid}-${det.factura_cab_id}`)) {
        facturasIds.add(`${pid}-${det.factura_cab_id}`);
        item.facturas_count++;
      }
    }

    // Calcular promedios y ordenar
    const productos = Array.from(productosMap.values())
      .map((p) => ({
        ...p,
        precio_promedio: p.precios.length > 0 ? p.precios.reduce((a, b) => a + b, 0) / p.precios.length : 0,
        margen: p.costo_total > 0 ? ((p.ingresos_netos - p.costo_total) / p.ingresos_netos) * 100 : null,
        precios: undefined, // no enviar array de precios
      }))
      .sort((a, b) => b.ingresos_netos - a.ingresos_netos)
      .slice(0, params.limit || 50);

    // KPIs globales
    const totalIngresos = productos.reduce((acc, p) => acc + p.ingresos_netos, 0);
    const totalCantidad = productos.reduce((acc, p) => acc + p.cantidad_vendida, 0);
    const totalIVA = productos.reduce((acc, p) => acc + p.iva_generado, 0);
    const totalDescuentos = productos.reduce((acc, p) => acc + p.descuentos, 0);
    const productosUnicos = productos.length;

    // Agrupar por categoría
    const categoriasMap = new Map<string, { nombre: string; ingresos: number; cantidad: number; productos: number }>();
    for (const p of productos) {
      const cat = p.categoria || 'Sin categoría';
      if (!categoriasMap.has(cat)) {
        categoriasMap.set(cat, { nombre: cat, ingresos: 0, cantidad: 0, productos: 0 });
      }
      const c = categoriasMap.get(cat);
      c.ingresos += p.ingresos_netos;
      c.cantidad += p.cantidad_vendida;
      c.productos++;
    }
    const categorias = Array.from(categoriasMap.values()).sort((a, b) => b.ingresos - a.ingresos);

    return {
      kpis: {
        total_ingresos: totalIngresos,
        total_cantidad: totalCantidad,
        total_iva: totalIVA,
        total_descuentos: totalDescuentos,
        productos_unicos: productosUnicos,
      },
      productos,
      categorias,
    };
  }

  /**
   * Detalle del reporte de ventas por producto: una fila por línea de factura,
   * para responder "cuánto de X le vendimos a Y y en qué facturas". Mismo
   * criterio de ventas válidas y alcance que el agregado. Paginado porque un
   * producto de alta rotación puede tener miles de líneas en el período.
   */
  async getReporteProductosDetalle(
    empresa_id: string,
    params: {
      fecha_desde?: string;
      fecha_hasta?: string;
      categoria_id?: string;
      moneda_id?: string;
      rubro_id?: string;
      producto_id?: string;
      cliente_id?: string;
      page?: number;
      limit?: number;
    },
    usuario_id?: string,
  ) {
    const { whereCab, whereProducto } = await this.whereReporteVentasPorProducto(empresa_id, params, usuario_id);
    const where = {
      factura_cab: whereCab,
      ...(Object.keys(whereProducto).length > 0 && { productos: whereProducto }),
    };
    const take = Math.max(1, Math.min(500, Number(params.limit) || 100));
    const page = Math.max(1, Number(params.page) || 1);

    const [total, lineas, agregado] = await Promise.all([
      this.prisma.factura_det.count({ where }),
      this.prisma.factura_det.findMany({
        where,
        orderBy: [{ factura_cab: { dfeemide: 'desc' } }, { factura_cab: { dnumdoc: 'desc' } }],
        skip: (page - 1) * take,
        take,
        select: {
          id: true,
          producto_id: true,
          ddesproser: true,
          cunimed: true,
          dcantproser: true,
          duniproser: true,
          dtotopeitem: true,
          productos: { select: { descripcion: true, cod_producto: true } },
          factura_cab: {
            select: {
              id: true,
              dfeemide: true,
              dest: true,
              dpunexp: true,
              dnumdoc: true,
              estado: true,
              estado_sifen: true,
              cliente_id: true,
              moneda: { select: { codigo: true } },
              clientes: { select: { personas: { select: { razon_social: true, ruc: true, dv: true, nro_documento: true } } } },
            },
          },
        },
      }),
      this.prisma.factura_det.aggregate({ where, _sum: { dcantproser: true, dtotopeitem: true } }),
    ]);

    return {
      total,
      page,
      limit: take,
      lastPage: Math.max(1, Math.ceil(total / take)),
      totales: {
        cantidad: Number(agregado._sum.dcantproser || 0),
        importe: Number(agregado._sum.dtotopeitem || 0),
      },
      items: lineas.map((l) => {
        const cab = l.factura_cab;
        const persona = cab?.clientes?.personas;
        return {
          id: l.id,
          factura_id: cab?.id,
          fecha: cab?.dfeemide,
          numero: `${cab?.dest}-${cab?.dpunexp}-${cab?.dnumdoc}`,
          estado: cab?.estado,
          estado_sifen: cab?.estado_sifen,
          moneda: cab?.moneda?.codigo || 'PYG',
          cliente_id: cab?.cliente_id,
          cliente: persona?.razon_social || '',
          // RUC con DV; si el cliente no tiene RUC (consumidor final), su documento.
          cliente_ruc: persona?.ruc
            ? `${persona.ruc}${persona.dv != null ? `-${persona.dv}` : ''}`
            : (persona?.nro_documento && persona.nro_documento !== '0' ? persona.nro_documento : ''),
          producto_id: l.producto_id,
          producto: l.productos?.descripcion || l.ddesproser,
          cod_producto: l.productos?.cod_producto || '',
          unidad: l.cunimed,
          cantidad: Number(l.dcantproser || 0),
          precio_unitario: Number(l.duniproser || 0),
          total: Number(l.dtotopeitem || 0),
        };
      }),
    };
  }

  /**
   * Clientes con movimiento real (ventas y/o pagos) en un rango de fechas.
   * "recibos_cobro" es la tabla unificada de cobros de cliente: cubre tanto
   * Recibo Multi (modo='MULTI') como los cobros rápidos de cajero — ambos
   * cuentan como pago acá, no se filtra por modo.
   */
  async getReporteClientes(
    empresa_id: string,
    params: { fecha_desde?: string; fecha_hasta?: string },
    usuario_id?: string,
  ) {
    // OJO: el valor real persistido es 'Anulado' (no 'ANULADA'); el filtro anterior
    // no excluía nada. notIn cubre las variantes por seguridad.
    const whereFactura: any = {
      empresa_id,
      estado: { notIn: ['Anulado', 'Anulada', 'ANULADO', 'ANULADA', 'anulado', 'anulada'] },
    };
    const whereRecibo: any = {
      empresa_id,
      estado: { notIn: ['anulado', 'Anulado', 'ANULADO'] },
    };

    // Notas de crédito emitidas en el período que siguen vigentes: fuera las
    // anuladas/rechazadas y las canceladas por evento SIFEN aprobado.
    const whereNc: any = {
      empresa_id,
      estado: { notIn: ['Anulado', 'Anulada', 'ANULADO', 'ANULADA', 'anulado', 'anulada', 'Rechazado', 'rechazado'] },
      OR: [
        { evento_aplicado: null },
        { evento_aplicado: { notIn: ['ECAN', 'EINO', 'EINU'] } },
        { estado_evento: { not: 'Aprobado' } },
      ],
    };

    if (params.fecha_desde || params.fecha_hasta) {
      whereFactura.dfeemide = {};
      whereRecibo.fecha_emision = {};
      whereNc.dfeemide = {};
      const desde = parseYmdValida(params.fecha_desde, false);
      const hasta = parseYmdValida(params.fecha_hasta, true);
      if (desde) {
        whereFactura.dfeemide.gte = desde;
        whereRecibo.fecha_emision.gte = desde;
        whereNc.dfeemide.gte = desde;
      }
      if (hasta) {
        whereFactura.dfeemide.lte = hasta;
        whereRecibo.fecha_emision.lte = hasta;
        whereNc.dfeemide.lte = hasta;
      }
    }

    // Alcance por sucursal (y con él, por rubro): un usuario del taller sólo
    // ve lo facturado y cobrado en sus sucursales. Las facturas no tienen
    // sucursal_id, se atan por punto de establecimiento (`dest`).
    const alcance = await alcanceSucursalUsuario(this.prisma, usuario_id, empresa_id);
    const filtroFiscal = await filtroFiscalPorSucursal(this.prisma, alcance);
    if (filtroFiscal) {
      whereFactura.dest = filtroFiscal.dest;
      whereNc.dest = filtroFiscal.dest;
    }
    if (alcance) whereRecibo.sucursal_id = { in: alcance.sucursalIds };

    const [ventasFacturas, cobrosPorCliente, notasCredito] = await Promise.all([
      // factura_cab.total_factura está vacío en la práctica; el total real del
      // comprobante vive en factura_subtotales.dtotgralope. Traemos las facturas
      // con su subtotal y agregamos por cliente+moneda en memoria.
      this.prisma.factura_cab.findMany({
        where: whereFactura,
        select: {
          cliente_id: true,
          moneda_id: true,
          dfeemide: true,
          dticam: true,
          moneda: { select: { codigo: true } },
          factura_subtotales: { select: { dtotgralope: true } },
        },
      }),
      // Recibo por recibo (no groupBy) porque cada uno trae su propia cotización.
      this.prisma.recibos_cobro.findMany({
        where: whereRecibo,
        select: {
          cliente_id: true,
          moneda_id: true,
          monto_total: true,
          cotizacion: true,
          moneda: { select: { codigo: true } },
        },
      }),
      this.prisma.nota_credito_cab.findMany({
        where: whereNc,
        select: {
          cliente_id: true,
          moneda_id: true,
          dticam: true,
          moneda: { select: { codigo: true } },
          nota_credito_subtotal: { select: { dtotgralope: true } },
        },
      }),
    ]);

    // Equivalente en guaraníes para comparar y totalizar entre monedas. Se usa
    // la cotización guardada en cada documento (dticam de la factura, cotizacion
    // del recibo): es la del día de la operación. Si un documento en moneda
    // extranjera no la tiene, se usa la vigente y la fila queda marcada como
    // estimada. Base PYG porque dticam (SIFEN) siempre es contra el guaraní.
    const MONEDA_BASE = 'PYG';
    const vigentes = new Map<string, number>();
    const tasaVigente = async (codigo: string) => {
      if (!vigentes.has(codigo)) {
        const v = await this.monedasService.getCotizacionVigente(empresa_id, codigo).catch(() => null);
        vigentes.set(codigo, Number(v?.tasa) > 0 ? Number(v?.tasa) : 0);
      }
      return vigentes.get(codigo) as number;
    };
    const estimadas = new Set<string>();
    const aBase = async (monto: number, codigo: string, tasaDoc: unknown, claveFila: string) => {
      if (!monto || codigo === MONEDA_BASE) return monto;
      const tasa = Number(tasaDoc);
      if (tasa > 0) return monto * tasa;
      estimadas.add(claveFila);
      return monto * (await tasaVigente(codigo));
    };

    type Fila = {
      cliente_id: string;
      moneda_id: string | null;
      total_facturado: number;
      total_cobrado: number;
      facturado_base: number;
      cobrado_base: number;
      total_nc: number;
      nc_base: number;
      cant_nc: number;
      cant_facturas: number;
      ultima_compra: Date | null;
    };
    const filasMap = new Map<string, Fila>();
    const claveDe = (clienteId: string, monedaId: string | null) => `${clienteId}::${monedaId ?? 'sin-moneda'}`;

    for (const f of ventasFacturas) {
      if (!f.cliente_id) continue;
      const clave = claveDe(f.cliente_id, f.moneda_id);
      const monto = Number(f.factura_subtotales?.[0]?.dtotgralope || 0);
      const montoBase = await aBase(monto, f.moneda?.codigo || MONEDA_BASE, f.dticam, clave);
      const existente = filasMap.get(clave);
      if (existente) {
        existente.total_facturado += monto;
        existente.facturado_base += montoBase;
        existente.cant_facturas += 1;
        if (f.dfeemide && (!existente.ultima_compra || f.dfeemide > existente.ultima_compra)) {
          existente.ultima_compra = f.dfeemide;
        }
      } else {
        filasMap.set(clave, {
          cliente_id: f.cliente_id,
          moneda_id: f.moneda_id,
          total_facturado: monto,
          total_cobrado: 0,
          facturado_base: montoBase,
          cobrado_base: 0,
          total_nc: 0,
          nc_base: 0,
          cant_nc: 0,
          cant_facturas: 1,
          ultima_compra: f.dfeemide ?? null,
        });
      }
    }
    for (const c of cobrosPorCliente) {
      const clave = claveDe(c.cliente_id, c.moneda_id);
      const monto = Number(c.monto_total || 0);
      const montoBase = await aBase(monto, c.moneda?.codigo || MONEDA_BASE, c.cotizacion, clave);
      const existente = filasMap.get(clave);
      if (existente) {
        existente.total_cobrado += monto;
        existente.cobrado_base += montoBase;
      } else {
        filasMap.set(clave, {
          cliente_id: c.cliente_id,
          moneda_id: c.moneda_id,
          total_facturado: 0,
          total_cobrado: monto,
          facturado_base: 0,
          cobrado_base: montoBase,
          total_nc: 0,
          nc_base: 0,
          cant_nc: 0,
          cant_facturas: 0,
          ultima_compra: null,
        });
      }
    }
    for (const nc of notasCredito) {
      if (!nc.cliente_id) continue;
      const clave = claveDe(nc.cliente_id, nc.moneda_id);
      const monto = Number(nc.nota_credito_subtotal?.[0]?.dtotgralope || 0);
      const montoBase = await aBase(monto, nc.moneda?.codigo || MONEDA_BASE, nc.dticam, clave);
      const existente = filasMap.get(clave);
      if (existente) {
        existente.total_nc += monto;
        existente.nc_base += montoBase;
        existente.cant_nc += 1;
      } else {
        filasMap.set(clave, {
          cliente_id: nc.cliente_id,
          moneda_id: nc.moneda_id,
          total_facturado: 0,
          total_cobrado: 0,
          facturado_base: 0,
          cobrado_base: 0,
          total_nc: monto,
          nc_base: montoBase,
          cant_nc: 1,
          cant_facturas: 0,
          ultima_compra: null,
        });
      }
    }

    const clienteIds = Array.from(new Set(Array.from(filasMap.values()).map((f) => f.cliente_id)));
    const monedaIds = Array.from(
      new Set(Array.from(filasMap.values()).map((f) => f.moneda_id).filter((id): id is string => Boolean(id))),
    );

    // Facturas no anuladas del cliente, sin filtro de fecha pero con el mismo
    // alcance por sucursal: base de la deuda viva y de la primera compra.
    const whereFacturaCliente: any = {
      empresa_id,
      cliente_id: { in: clienteIds },
      estado: whereFactura.estado,
      ...(whereFactura.dest && { dest: whereFactura.dest }),
    };
    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);

    const [clientes, monedas, primerasCompras, cuotasConSaldo] = await Promise.all([
      this.prisma.clientes.findMany({
        where: { id: { in: clienteIds } },
        select: {
          id: true,
          nombre_fantasia: true,
          cod_cliente: true,
          personas: {
            select: { razon_social: true, ruc: true, dv: true, nro_documento: true, telefono: true, celular: true },
          },
        },
      }),
      monedaIds.length
        ? this.prisma.moneda.findMany({ where: { id: { in: monedaIds } }, select: { id: true, codigo: true } })
        : Promise.resolve([]),
      // Primera factura histórica: si cae dentro del período, es un cliente nuevo.
      this.prisma.factura_cab.groupBy({
        by: ['cliente_id'],
        where: whereFacturaCliente,
        _min: { dfeemide: true },
      }),
      // Deuda viva HOY (no la del período): cuotas con saldo, vencidas o no.
      // Sin filtrar por `estado` de la cuota: conviven 'pendiente', 'Pendiente'
      // y 'parcial', y el saldo es lo que manda.
      this.prisma.factura_cuotas.findMany({
        where: { saldo_pendiente: { gt: 0 }, factura_cab: whereFacturaCliente },
        select: {
          cmonecuo: true,
          dvenccuo: true,
          saldo_pendiente: true,
          factura_cab: { select: { cliente_id: true, moneda_id: true, dticam: true } },
        },
      }),
    ]);

    const primeraCompraMap = new Map(primerasCompras.map((p) => [p.cliente_id, p._min.dfeemide]));
    type Deuda = { saldo: number; vencido: number; saldo_base: number; vencido_base: number; dias_atraso: number };
    const deudaMap = new Map<string, Deuda>();
    for (const c of cuotasConSaldo) {
      const clienteId = c.factura_cab?.cliente_id;
      if (!clienteId) continue;
      const codigo = c.cmonecuo || MONEDA_BASE;
      const clave = `${clienteId}::${codigo}`;
      const d = deudaMap.get(clave) || { saldo: 0, vencido: 0, saldo_base: 0, vencido_base: 0, dias_atraso: 0 };
      const saldo = Number(c.saldo_pendiente || 0);
      // La deuda se convierte con la cotización de la factura que la originó.
      const saldoBase = await aBase(saldo, codigo, c.factura_cab?.dticam, claveDe(clienteId, c.factura_cab?.moneda_id ?? null));
      d.saldo += saldo;
      d.saldo_base += saldoBase;
      if (c.dvenccuo && c.dvenccuo < hoy) {
        d.vencido += saldo;
        d.vencido_base += saldoBase;
        const dias = Math.floor((hoy.getTime() - c.dvenccuo.getTime()) / 86_400_000);
        d.dias_atraso = Math.max(d.dias_atraso, dias);
      }
      deudaMap.set(clave, d);
    }
    const desdePeriodo = parseYmdValida(params.fecha_desde, false);
    const clientesMap = new Map(clientes.map((c) => [c.id, c]));
    const monedasMap = new Map(monedas.map((m) => [m.id, m.codigo]));

    const data = Array.from(filasMap.values())
      .map((f) => {
        const cliente = clientesMap.get(f.cliente_id);
        const persona = cliente?.personas;
        const moneda = (f.moneda_id && monedasMap.get(f.moneda_id)) || MONEDA_BASE;
        const deuda = deudaMap.get(`${f.cliente_id}::${moneda}`);
        const primeraCompra = primeraCompraMap.get(f.cliente_id) ?? null;
        return {
          cliente_id: f.cliente_id,
          razon_social: persona?.razon_social || cliente?.nombre_fantasia || 'Sin nombre',
          nombre_fantasia: cliente?.nombre_fantasia || null,
          ruc: persona?.ruc
            ? `${persona.ruc}-${persona.dv || 0}`
            : persona?.nro_documento && persona.nro_documento !== '0'
              ? persona.nro_documento
              : '',
          cod_cliente: cliente?.cod_cliente || null,
          telefono: persona?.celular || persona?.telefono || null,
          moneda,
          total_facturado: f.total_facturado,
          total_cobrado: f.total_cobrado,
          total_movimiento: f.facturado_base + f.cobrado_base,
          // Notas de crédito del período y facturado neto (facturado − NC).
          cant_nc: f.cant_nc,
          total_nc: f.total_nc,
          facturado_neto: f.total_facturado - f.total_nc,
          cant_facturas: f.cant_facturas,
          ticket_promedio: f.cant_facturas > 0 ? f.total_facturado / f.cant_facturas : 0,
          ultima_compra: f.ultima_compra,
          primera_compra: primeraCompra,
          // Nuevo = su primera factura (histórica) cae dentro del período.
          es_nuevo: !!(primeraCompra && desdePeriodo && primeraCompra >= desdePeriodo),
          saldo_pendiente: deuda?.saldo ?? 0,
          saldo_vencido: deuda?.vencido ?? 0,
          dias_atraso: deuda?.dias_atraso ?? 0,
          // Equivalentes en la moneda base (MONEDA_BASE) para ordenar y totalizar.
          facturado_base: f.facturado_base,
          cobrado_base: f.cobrado_base,
          nc_base: f.nc_base,
          facturado_neto_base: f.facturado_base - f.nc_base,
          saldo_pendiente_base: deuda?.saldo_base ?? 0,
          saldo_vencido_base: deuda?.vencido_base ?? 0,
          cotizacion_estimada: estimadas.has(claveDe(f.cliente_id, f.moneda_id)),
        };
      })
      // Un documento que matchea el where pero con monto en null/0 no es
      // movimiento real: se excluye acá.
      .filter((f) => f.total_facturado > 0 || f.total_cobrado > 0 || f.total_nc > 0)
      .sort((a, b) => b.total_movimiento - a.total_movimiento);

    return {
      data,
      moneda_base: MONEDA_BASE,
      resumen: {
        cantidad_clientes: new Set(data.map((d) => d.cliente_id)).size,
        // En moneda base: sumar montos crudos mezclaba guaraníes con dólares.
        total_facturado: data.reduce((acc, d) => acc + d.facturado_base, 0),
        total_cobrado: data.reduce((acc, d) => acc + d.cobrado_base, 0),
        total_nc: data.reduce((acc, d) => acc + d.nc_base, 0),
      },
    };
  }

  async getLibroIvaVentas(empresa_id: string, mes: number, anio: number, moneda_id?: string, usuarioId?: string) {
    await exigirAccesoElevado(this.prisma, usuarioId, empresa_id, 'El Libro IVA de ventas');
    const fechaDesde = new Date(anio, mes - 1, 1, 0, 0, 0, 0);
    const fechaHasta = new Date(anio, mes, 0, 23, 59, 59, 999); // último día del mes

    const whereFactura: any = {
      empresa_id,
      dfeemide: { gte: fechaDesde, lte: fechaHasta },
      estado_sifen: EstadoFactura.APROBADO,
      OR: [
        { evento_aplicado: null },
        { evento_aplicado: { notIn: ['ECAN', 'EINO', 'EINU'] } },
        { estado_evento: { not: 'Aprobado' } },
      ],
    };
    if (moneda_id) whereFactura.moneda_id = moneda_id;

    const facturas = await this.prisma.factura_cab.findMany({
      where: whereFactura,
      include: {
        clientes: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
        moneda: { select: { codigo: true } },
        factura_subtotales: true,
        condicion_operacion: { select: { descripcion: true } },
      },
      orderBy: { dfeemide: 'asc' },
    });

    // Timbrado de respaldo: los comprobantes no electrónicos (y los migrados) no
    // guardan el timbrado en `dinfofisc`, y la columna del libro salía vacía. Se usa
    // el timbrado vigente de la empresa, igual que la exportación a Marangatu.
    const timbradoEmpresaVentas = await this.prisma.empresas_timbrado.findFirst({
      where: { empresa_id },
      orderBy: { fecha_inicio: 'desc' },
      select: { num_timbrado: true },
    });
    const timbradoLibro = timbradoEmpresaVentas?.num_timbrado ?? '';

    // También incluir notas de crédito del período (restan del débito fiscal)
    const whereNc: any = {
      empresa_id,
      dfeemide: { gte: fechaDesde, lte: fechaHasta },
      estado_sifen: EstadoFactura.APROBADO,
      OR: [
        { evento_aplicado: null },
        { evento_aplicado: { notIn: ['ECAN', 'EINO', 'EINU'] } },
        { estado_evento: { not: 'Aprobado' } },
      ],
    };
    if (moneda_id) whereNc.moneda_id = moneda_id;

    const notasCredito = await this.prisma.nota_credito_cab.findMany({
      where: whereNc,
      include: {
        clientes: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
        moneda: { select: { codigo: true } },
        nota_credito_subtotal: true,
      },
      orderBy: { dfeemide: 'asc' },
    });

    // Calcular totales
    let totalGravada10 = 0;
    let totalGravada5 = 0;
    let totalExentas = 0;
    let totalIva10 = 0;
    let totalIva5 = 0;
    let totalOperaciones = 0;

    const detalle = facturas.map((f) => {
      const sub = f.factura_subtotales?.[0];
      const gravada10 = Number(sub?.dbasegrav10 ?? sub?.dsub10 ?? 0);
      const gravada5 = Number(sub?.dbasegrav5 ?? sub?.dsub5 ?? 0);
      const exentas = Number(sub?.dsubexe ?? 0);
      const iva10 = Number(sub?.diva10 ?? sub?.dliqtotiva10 ?? 0);
      const iva5 = Number(sub?.diva5 ?? sub?.dliqtotiva5 ?? 0);
      const totalOpe = Number(sub?.dtotope ?? 0);

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

      return {
        tipo: 'FACTURA',
        fecha: formatDateResponse(f.dfeemide) || f.created_at?.toISOString() || null,
        numero: `${f.dest}-${f.dpunexp}-${f.dnumdoc}`,
        timbrado: f.dinfofisc || timbradoLibro,
        cdc: f.cdc || '',
        cliente: f.clientes?.personas?.razon_social || 'Sin nombre',
        ruc: f.clientes?.personas?.ruc ? `${f.clientes.personas.ruc}-${f.clientes.personas.dv ?? ''}` : '',
        condicion: f.condicion_operacion?.descripcion || 'Contado',
        moneda: f.moneda?.codigo || 'PYG',
        gravada_10: gravada10,
        gravada_5: gravada5,
        exentas,
        iva_10: iva10,
        iva_5: iva5,
        total: totalOpe,
      };
    });

    // Notas de crédito (restan)
    let totalNcGravada10 = 0;
    let totalNcGravada5 = 0;
    let totalNcExentas = 0;
    let totalNcIva10 = 0;
    let totalNcIva5 = 0;
    let totalNcOperaciones = 0;

    const detalleNc = notasCredito.map((nc) => {
      const sub = nc.nota_credito_subtotal?.[0];
      const gravada10 = Number(sub?.dbasegrav10 ?? sub?.dsub10 ?? 0);
      const gravada5 = Number(sub?.dbasegrav5 ?? sub?.dsub5 ?? 0);
      const exentas = Number(sub?.dsubexe ?? 0);
      const iva10 = Number(sub?.diva10 ?? sub?.dliqtotiva10 ?? 0);
      const iva5 = Number(sub?.diva5 ?? sub?.dliqtotiva5 ?? 0);
      const totalOpe = Number(sub?.dtotope ?? 0);

      totalNcGravada10 += gravada10;
      totalNcGravada5 += gravada5;
      totalNcExentas += exentas;
      totalNcIva10 += iva10;
      totalNcIva5 += iva5;
      totalNcOperaciones += totalOpe;

      return {
        tipo: 'NOTA_CREDITO',
        fecha: formatDateResponse(nc.dfeemide) || nc.created_at?.toISOString() || null,
        numero: `${nc.dest ?? ''}-${nc.dpunexp ?? ''}-${nc.dnumdoc ?? ''}`,
        timbrado: nc.dinfofisc || timbradoLibro,
        cdc: nc.cdc || '',
        cliente: nc.clientes?.personas?.razon_social || 'Sin nombre',
        ruc: nc.clientes?.personas?.ruc ? `${nc.clientes.personas.ruc}-${nc.clientes.personas.dv ?? ''}` : '',
        condicion: 'N/C',
        moneda: nc.moneda?.codigo || 'PYG',
        gravada_10: -gravada10,
        gravada_5: -gravada5,
        exentas: -exentas,
        iva_10: -iva10,
        iva_5: -iva5,
        total: -totalOpe,
      };
    });

    return {
      periodo: { mes, anio, desde: fechaDesde, hasta: fechaHasta },
      resumen: {
        facturas: {
          cantidad: facturas.length,
          gravada_10: totalGravada10,
          gravada_5: totalGravada5,
          exentas: totalExentas,
          iva_debito_10: totalIva10,
          iva_debito_5: totalIva5,
          total: totalOperaciones,
        },
        notas_credito: {
          cantidad: notasCredito.length,
          gravada_10: totalNcGravada10,
          gravada_5: totalNcGravada5,
          exentas: totalNcExentas,
          iva_10: totalNcIva10,
          iva_5: totalNcIva5,
          total: totalNcOperaciones,
        },
        neto: {
          gravada_10: totalGravada10 - totalNcGravada10,
          gravada_5: totalGravada5 - totalNcGravada5,
          exentas: totalExentas - totalNcExentas,
          iva_debito_10: totalIva10 - totalNcIva10,
          iva_debito_5: totalIva5 - totalNcIva5,
          iva_debito_total: totalIva10 - totalNcIva10 + (totalIva5 - totalNcIva5),
          total: totalOperaciones - totalNcOperaciones,
        },
      },
      detalle: [...detalle, ...detalleNc],
    };
  }

  /**
   * Libro IVA Ventas en el formato importable de Marangatu (Registro de
   * Comprobantes, registro tipo 1). Incluye todos los comprobantes aprobados del
   * período (electrónicos y no electrónicos). Los flags IMPUTA IVA/IRE/IRP-RSP se
   * toman de las obligaciones de la empresa.
   * @param secuencia identificador del archivo (<=5 alfanum.), p.ej. 'V0001'.
   */
  async getLibroIvaVentasMarangatu(
    empresa_id: string,
    mes: number,
    anio: number,
    moneda_id?: string,
    secuencia = 'V0001',
    usuarioId?: string,
  ) {
    await exigirAccesoElevado(this.prisma, usuarioId, empresa_id, 'El Libro IVA de ventas para Marangatu');
    // Límites del período en UTC: las fechas de emisión se guardan como la
    // fecha-calendario a medianoche UTC. Usar límites en hora local (servidor
    // fuera de UTC) haría que un comprobante del día 1 o del último día caiga en
    // el mes vecino. 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);

    // Timbrado de respaldo: cuando la factura no guardó el timbrado en dinfofisc
    // (comprobantes no electrónicos migrados), se usa el timbrado vigente de la
    // empresa. Marangatu rechaza el campo timbrado vacío para facturas.
    const timbradoEmpresa = await this.prisma.empresas_timbrado.findFirst({
      where: { empresa_id },
      orderBy: { fecha_inicio: 'desc' },
      select: { num_timbrado: true },
    });
    const timbradoFallback = timbradoEmpresa?.num_timbrado ?? '';

    // Se incluyen TODOS los comprobantes aprobados del período (electrónicos y no
    // electrónicos): el contribuyente vuelve a subirlos en Marangatu de todas formas.
    const baseWhere: any = {
      empresa_id,
      dfeemide: { gte: fechaDesde, lte: fechaHasta },
      estado_sifen: EstadoFactura.APROBADO,
    };
    if (moneda_id) baseWhere.moneda_id = moneda_id;

    const personaSel = {
      personas: { select: { razon_social: true, ruc: true, dv: true, nro_documento: true } },
    };

    const facturas = await this.prisma.factura_cab.findMany({
      where: baseWhere,
      include: {
        clientes: { include: personaSel },
        moneda: { select: { codigo: true } },
        factura_subtotales: true,
        condicion_operacion: { select: { descripcion: true } },
      },
      orderBy: { dfeemide: 'asc' },
    });

    const notasCredito = await this.prisma.nota_credito_cab.findMany({
      where: { ...baseWhere },
      include: {
        clientes: { include: personaSel },
        moneda: { select: { codigo: true } },
        nota_credito_subtotal: true,
        factura_cab: { select: { dest: true, dpunexp: true, dnumdoc: true, dinfofisc: true } },
      },
      orderBy: { dfeemide: '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.round(opts.gross10);
      const g5 = Math.round(opts.gross5);
      const ex = Math.round(opts.exento);
      const total = Math.max(0, g10) + Math.max(0, g5) + Math.max(0, ex);
      return Marangatu.filaCsv([
        Marangatu.TIPO_REGISTRO.VENTAS, // 1 tipo de registro
        tipoId, // 2 tipo identificación comprador
        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 venta (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
        opts.asocNumero ?? '', // 18 número comprobante de venta asociado
        opts.asocTimbrado ?? '', // 19 timbrado comprobante asociado
      ]);
    };

    for (const f of facturas) {
      const sub = f.factura_subtotales?.[0];
      const gross10 = Number(sub?.dsub10 ?? Number(sub?.dbasegrav10 ?? 0) + Number(sub?.diva10 ?? 0));
      const gross5 = Number(sub?.dsub5 ?? Number(sub?.dbasegrav5 ?? 0) + Number(sub?.diva5 ?? 0));
      const exento = Number(sub?.dsubexe ?? 0);
      lineas.push(
        armarFila({
          persona: f.clientes?.personas ?? null,
          tipoComprobante: Marangatu.TIPO_COMPROBANTE.FACTURA,
          fecha: f.dfeemide,
          timbrado: f.dinfofisc || timbradoFallback,
          est: f.dest,
          punto: f.dpunexp,
          numero: f.dnumdoc,
          gross10,
          gross5,
          exento,
          condicionDesc: f.condicion_operacion?.descripcion,
          monedaCod: f.moneda?.codigo,
        }),
      );
    }

    for (const nc of notasCredito) {
      const sub = nc.nota_credito_subtotal?.[0];
      const gross10 = Number(sub?.dsub10 ?? Number(sub?.dbasegrav10 ?? 0) + Number(sub?.diva10 ?? 0));
      const gross5 = Number(sub?.dsub5 ?? Number(sub?.dbasegrav5 ?? 0) + Number(sub?.diva5 ?? 0));
      const exento = Number(sub?.dsubexe ?? 0);
      const asoc = nc.factura_cab;
      lineas.push(
        armarFila({
          persona: nc.clientes?.personas ?? null,
          tipoComprobante: Marangatu.TIPO_COMPROBANTE.NOTA_CREDITO,
          fecha: nc.dfeemide,
          timbrado: nc.dinfofisc || timbradoFallback,
          est: nc.dest,
          punto: nc.dpunexp,
          numero: nc.dnumdoc,
          gross10,
          gross5,
          exento,
          monedaCod: nc.moneda?.codigo,
          asocNumero: asoc
            ? Marangatu.numeroComprobante(asoc.dest, asoc.dpunexp, asoc.dnumdoc)
            : '',
          asocTimbrado: asoc ? Marangatu.timbrado(asoc.dinfofisc) : '',
        }),
      );
    }

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

  async getLiquidacionIva(empresa_id: string, mes: number, anio: number, moneda_id?: string, usuarioId?: string) {
    await exigirAccesoElevado(this.prisma, usuarioId, empresa_id, 'La liquidación de IVA');
    // Reutiliza la lógica del Libro IVA Ventas
    // El usuario viaja también acá: el guard del libro corre de nuevo y sin
    // esto la liquidación se rechazaba a sí misma aunque el actor fuera elevado.
    const ventas = await this.getLibroIvaVentas(empresa_id, mes, anio, moneda_id, usuarioId);
    const netoVentas = ventas.resumen.neto;

    // Consulta directa a compras (evita dependencia circular con ComprasModule)
    // Límites en UTC, igual que el Libro IVA Compras: compra_cab.fecha_emision y
    // nota_credito_compra_cab.fecha_emision son @db.Date (fecha-calendario a
    // medianoche UTC). Con límites locales el crédito fiscal del mes se llevaba
    // los comprobantes del día 1 del mes siguiente.
    // Ojo: las ventas NO usan estos límites — getLibroIvaVentas filtra por
    // dfeemide, que guarda la hora real de emisión, y ahí el corte local es el
    // correcto (una factura de las 21:00 del 30 es del mes que cierra).
    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 whereCompras: any = {
      empresa_id,
      fecha_emision: { gte: fechaDesde, lte: fechaHasta },
      anulado: false,
    };
    if (moneda_id) whereCompras.moneda_id = moneda_id;

    const compras = await this.prisma.compra_cab.findMany({
      where: whereCompras,
      select: {
        total_gravado_10: true,
        total_gravado_5: true,
        total_exento: true,
        total_iva_10: true,
        total_iva_5: true,
        total: true,
      },
    });

    let compraGravada10 = 0,
      compraGravada5 = 0,
      compraExentas = 0;
    let compraIva10 = 0,
      compraIva5 = 0,
      compraTotal = 0;

    for (const c of compras) {
      compraGravada10 += Number(c.total_gravado_10 ?? 0);
      compraGravada5 += Number(c.total_gravado_5 ?? 0);
      compraExentas += Number(c.total_exento ?? 0);
      compraIva10 += Number(c.total_iva_10 ?? 0);
      compraIva5 += Number(c.total_iva_5 ?? 0);
      compraTotal += Number(c.total ?? 0);
    }

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

    let ncCompraIva10 = 0,
      ncCompraIva5 = 0,
      ncCompraTotal = 0,
      ncCompraCantidad = 0;
    try {
      const ncCompras = await this.prisma.nota_credito_compra_cab.findMany({
        where: whereNCCompras,
        select: { total_iva_10: true, total_iva_5: true, total: true },
      });
      ncCompraCantidad = ncCompras.length;
      for (const n of ncCompras) {
        ncCompraIva10 += Number(n.total_iva_10 ?? 0);
        ncCompraIva5 += Number(n.total_iva_5 ?? 0);
        ncCompraTotal += Number(n.total ?? 0);
      }
    } catch {
      // Tabla ausente (migración desfasada): se omite sin romper la liquidación.
    }

    // Crédito de compras neto de NC.
    compraIva10 -= ncCompraIva10;
    compraIva5 -= ncCompraIva5;
    compraTotal -= ncCompraTotal;

    const retencionesIva = await this.prisma.recibo_cobro_retenciones.findMany({
      where: {
        tipo: 'IVA',
        fecha_comprobante: { gte: fechaDesde, lte: fechaHasta },
        recibo: { empresa_id, estado: 'CONFIRMADO', modo: 'MULTI' },
      },
      select: { monto: true, declarada_set: true },
    });
    const retencionesIvaTotal = retencionesIva.reduce((s, r) => s + Number(r.monto ?? 0), 0);
    const retencionesIvaDeclaradas = retencionesIva
      .filter((r) => r.declarada_set)
      .reduce((s, r) => s + Number(r.monto ?? 0), 0);
    const retencionesIvaPendientes = retencionesIvaTotal - retencionesIvaDeclaradas;

    const ivaDebito10 = netoVentas.iva_debito_10;
    const ivaDebito5 = netoVentas.iva_debito_5;
    const ivaDebitoTotal = netoVentas.iva_debito_total;
    const ivaCredito10 = compraIva10;
    const ivaCredito5 = compraIva5;
    const ivaCreditoTotal = compraIva10 + compraIva5;

    const saldo10 = ivaDebito10 - ivaCredito10;
    const saldo5 = ivaDebito5 - ivaCredito5;
    const saldoAntesRetenciones = ivaDebitoTotal - ivaCreditoTotal;
    const saldoTotal = saldoAntesRetenciones - retencionesIvaTotal;

    return {
      periodo: { mes, anio, desde: fechaDesde, hasta: fechaHasta },
      debito_fiscal: {
        cantidad_facturas: ventas.resumen.facturas.cantidad,
        cantidad_nc: ventas.resumen.notas_credito.cantidad,
        gravada_10: netoVentas.gravada_10,
        gravada_5: netoVentas.gravada_5,
        exentas: netoVentas.exentas,
        iva_10: ivaDebito10,
        iva_5: ivaDebito5,
        total_iva: ivaDebitoTotal,
        total_operaciones: netoVentas.total,
      },
      credito_fiscal: {
        cantidad_compras: compras.length,
        cantidad_nc: ncCompraCantidad,
        gravada_10: compraGravada10,
        gravada_5: compraGravada5,
        exentas: compraExentas,
        iva_10: ivaCredito10,
        iva_5: ivaCredito5,
        total_iva: ivaCreditoTotal,
        total_operaciones: compraTotal,
      },
      retenciones_recibidas: {
        cantidad: retencionesIva.length,
        monto_total: retencionesIvaTotal,
        monto_declaradas: retencionesIvaDeclaradas,
        monto_pendientes: retencionesIvaPendientes,
      },
      liquidacion: {
        saldo_iva_10: saldo10,
        saldo_iva_5: saldo5,
        saldo_antes_retenciones: saldoAntesRetenciones,
        retenciones_iva: retencionesIvaTotal,
        saldo_total: saldoTotal,
        resultado: saldoTotal > 0 ? 'A_PAGAR' : saldoTotal < 0 ? 'A_FAVOR' : 'CERO',
        es_a_pagar: saldoTotal > 0,
        es_a_favor: saldoTotal < 0,
      },
    };
  }

  private buildFacturasWhere(
    empresa_id: string,
    filters?: {
      fechaDesde?: string;
      fechaHasta?: string;
      estado?: string;
      clienteId?: string;
      search?: string;
      sucursalId?: string;
      estadoSifen?: string;
      sesionCajaId?: string;
    },
    /**
     * Restricción por sucursal del usuario, ya resuelta a `{ dest: { in: [...] } }`.
     * Va como AND aparte para no mezclarse con el `OR` de la búsqueda por texto
     * que se arma más abajo: sumarlo ahí lo convertiría en "de mi sucursal O que
     * coincida con el texto", o sea sin restricción.
     */
    filtroSucursal?: { dest: { in: string[] } } | null,
  ): any {
    const where: any = { empresa_id };

    if (filters?.fechaDesde || filters?.fechaHasta) {
      const gte = parseYmdValida(filters.fechaDesde, false);
      const lte = parseYmdValida(filters.fechaHasta, true);
      if (gte || lte) {
        where.dfeemide = {};
        if (gte) where.dfeemide.gte = gte;
        if (lte) where.dfeemide.lte = lte;
      }
    }

    if (filters?.estado) {
      switch (filters.estado.toLowerCase()) {
        case 'pendiente':
          where.estado = EstadoFactura.PENDIENTE;
          break;
        case 'pagada':
          where.estado = EstadoFactura.PAGADA;
          break;
        case 'aprobado':
          where.estado = EstadoFactura.APROBADO;
          break;
        case 'rechazado':
          where.estado = EstadoFactura.RECHAZADO;
          break;
        case 'enviado':
          where.estado = EstadoFactura.ENVIADO;
          break;
        case 'anulada':
        case 'anulado':
          where.estado = EstadoFactura.ANULADA;
          break;
      }
    }

    if (filters?.clienteId) where.cliente_id = filters.clienteId;
    if (filters?.estadoSifen) where.estado_sifen = filters.estadoSifen;
    if (filters?.sesionCajaId) {
      where.factura_forma_pagos = { some: { sesion_caja_id: filters.sesionCajaId } };
    }

    if (filters?.search) {
      // Si hay búsqueda activa, ignorar filtro de fechas para buscar en todo el historial
      delete where.dfeemide;

      const term = filters.search.trim();
      const numero = filtroNumeroDocumento(term);
      where.OR = [
        ...(numero ? [numero] : []),
        { dnumdoc: { contains: term, mode: 'insensitive' } },
        { dest: { contains: term, mode: 'insensitive' } },
        { dpunexp: { contains: term, mode: 'insensitive' } },
        {
          clientes: {
            personas: {
              OR: [
                { ruc: { contains: term, mode: 'insensitive' } },
                { nro_documento: { contains: term, mode: 'insensitive' } },
                { razon_social: { contains: term, mode: 'insensitive' } },
              ],
            },
          },
        },
      ];
    }

    if (filtroSucursal) {
      where.AND = [...(where.AND ?? []), filtroSucursal];
    }

    return where;
  }

  async getResumen(
    empresa_id: string,
    filters?: {
      fechaDesde?: string;
      fechaHasta?: string;
      estado?: string;
      clienteId?: string;
      search?: string;
      sucursalId?: string;
      estadoSifen?: string;
      sesionCajaId?: string;
      /** Quién consulta. Define qué sucursales puede ver. */
      usuarioId?: string;
    },
  ) {
    const alcance = await alcanceSucursalUsuario(this.prisma, filters?.usuarioId, empresa_id);
    const filtroSucursal = await filtroFiscalPorSucursal(this.prisma, alcance, filters?.sucursalId);
    const where = this.buildFacturasWhere(empresa_id, filters, filtroSucursal);
    // Las condiciones propias de cada KPI se suman con AND sobre el `where` de la
    // lista, nunca pisando sus campos: antes `{ ...where, estado: ... }`
    // reemplazaba el filtro de estado elegido, y con "Aprobado" los totales
    // seguían contando las pendientes (la lista mostraba 2 facturas y el KPI 6).
    const con = (base: any, extra: any) => ({ ...base, AND: [...(base.AND ?? []), extra] });
    const whereNoAnuladas = con(where, { estado: { not: 'Anulado' } });
    // Ventas VÁLIDAS (mismo criterio fiscal que el Libro IVA Ventas): no anuladas +
    // aprobadas en SIFEN (o sin SIFEN, para empresas que no facturan electrónico) + sin
    // cancelación aprobada. Así el "Total Ventas" no suma rechazadas ni pendientes/error
    // y coincide con el Libro. Se usa AND para no pisar el OR de búsqueda de buildFacturasWhere.
    const whereVentas: any = {
      ...whereNoAnuladas,
      AND: [
        // El alcance por sucursal vive en `where.AND`. Este objeto se arma con
        // spread y define su propio `AND`, así que hay que arrastrar el de
        // arriba: sin esto los KPI de "Facturas" y "Ventas" salían sin filtrar
        // mientras la lista sí filtraba, y los números no coincidían.
        ...((whereNoAnuladas as any).AND ?? []),
        { OR: [{ estado_sifen: EstadoFactura.APROBADO }, { estado_sifen: null }] },
        {
          OR: [
            { evento_aplicado: null },
            { evento_aplicado: { notIn: ['ECAN', 'EINO', 'EINU'] } },
            { estado_evento: { not: 'Aprobado' } },
          ],
        },
      ],
    };

    const [facturasValidas, totalAnuladas, pendientesSifen, aprobadosSifen, rechazadosSifen, sumasPorMoneda] =
      await Promise.all([
        this.prisma.factura_cab.count({ where: whereVentas }),
        this.prisma.factura_cab.count({ where: con(where, { estado: 'Anulado' }) }),
        this.prisma.factura_cab.count({ where: con(whereNoAnuladas, { estado_sifen: null }) }),
        this.prisma.factura_cab.count({ where: con(whereNoAnuladas, { estado_sifen: EstadoFactura.APROBADO }) }),
        this.prisma.factura_cab.count({ where: con(whereNoAnuladas, { estado_sifen: 'Rechazado' }) }),
        // Suma de ventas por moneda + IVA sobre las VÁLIDAS (=Libro). Filtro-wide, sin paginar.
        this.prisma.factura_cab.findMany({
          where: whereVentas,
          select: {
            moneda: { select: { codigo: true } },
            condicion_operacion: { select: { codigo: true } },
            factura_subtotales: {
              select: {
                dtotope: true,
                diva10: true,
                diva5: true,
                dliqtotiva10: true,
                dliqtotiva5: true,
              },
            },
          },
        }),
      ]);

    const ventasPorMoneda = sumasPorMoneda.reduce<Record<string, number>>((acc, f) => {
      const codigo = f.moneda?.codigo || 'PYG';
      const total = Number(f.factura_subtotales?.[0]?.dtotope || 0);
      acc[codigo] = (acc[codigo] || 0) + total;
      return acc;
    }, {});

    // Desglose Contado / Crédito con el mismo criterio que la columna "Condición"
    // de la lista: codigo 1 = contado, cualquier otro (o sin condición) = crédito.
    const ventasPorCondicion = {
      contado: { cantidad: 0, porMoneda: {} as Record<string, number> },
      credito: { cantidad: 0, porMoneda: {} as Record<string, number> },
    };
    for (const f of sumasPorMoneda) {
      const grupo = f.condicion_operacion?.codigo === 1 ? ventasPorCondicion.contado : ventasPorCondicion.credito;
      const codigo = f.moneda?.codigo || 'PYG';
      grupo.cantidad += 1;
      grupo.porMoneda[codigo] = (grupo.porMoneda[codigo] || 0) + Number(f.factura_subtotales?.[0]?.dtotope || 0);
    }

    let totalIva10 = 0;
    let totalIva5 = 0;
    for (const f of sumasPorMoneda) {
      const sub = f.factura_subtotales?.[0];
      totalIva10 += Number(sub?.diva10 ?? sub?.dliqtotiva10 ?? 0);
      totalIva5 += Number(sub?.diva5 ?? sub?.dliqtotiva5 ?? 0);
    }

    return {
      facturas: facturasValidas,
      anuladas: totalAnuladas,
      pendientesSifen,
      aprobadosSifen,
      rechazadosSifen,
      ventasPorMoneda,
      ventasPorCondicion,
      totalIva10,
      totalIva5,
    };
  }

  /**
   * Aplica el criterio fiscal de "ventas válidas" (mismo que getResumen / Libro IVA Ventas)
   * a un where base: no anuladas + aprobadas en SIFEN (o sin SIFEN) + sin cancelación aprobada.
   */
  private buildWhereVentasValidas(baseWhere: any): any {
    return {
      ...baseWhere,
      estado: { not: 'Anulado' },
      AND: [
        // El alcance por sucursal/rubro vive en `baseWhere.AND`: sin arrastrarlo
        // el dashboard mostraba la empresa entera aunque el listado sí filtrara.
        ...(baseWhere.AND ?? []),
        { OR: [{ estado_sifen: EstadoFactura.APROBADO }, { estado_sifen: null }] },
        {
          OR: [
            { evento_aplicado: null },
            { evento_aplicado: { notIn: ['ECAN', 'EINO', 'EINU'] } },
            { estado_evento: { not: 'Aprobado' } },
          ],
        },
      ],
    };
  }

  /**
   * Agrega una lista de facturas (cabecera + detalle) a las métricas del dashboard de ventas.
   * Porta 1:1 la lógica que antes vivía en el frontend (processFacturas / processContadoCredito /
   * buildDesglosePorMoneda), pero acá corre filter-wide (sin el cap de 100 filas de /facturas),
   * así el dashboard cuadra con el "Total Ventas" de Facturación.
   * @param convertToGs true = modo "todas": convierte cada monto a PYG (dtotalgs o dtotope*dticam).
   */
  private aggregateDashboardVentas(facturas: any[], convertToGs: boolean) {
    const getMonto = (f: any) => {
      const dtotope = Number(f.factura_subtotales?.[0]?.dtotope || 0);
      if (!convertToGs) return dtotope;
      const codigo = (f.moneda?.codigo || '').toUpperCase();
      if (codigo === 'PYG' || codigo === 'GS') return dtotope;
      const dtotalgs = Number(f.factura_subtotales?.[0]?.dtotalgs || 0);
      if (dtotalgs > 0) return dtotalgs;
      const tc = Number(f.dticam || 0);
      return tc > 0 ? Math.round(dtotope * tc) : dtotope;
    };
    const getIva = (f: any) => {
      const dtotiva = Number(f.factura_subtotales?.[0]?.dtotiva || 0);
      if (!convertToGs) return dtotiva;
      const codigo = (f.moneda?.codigo || '').toUpperCase();
      if (codigo === 'PYG' || codigo === 'GS') return dtotiva;
      const tc = Number(f.dticam || 0);
      return tc > 0 ? Math.round(dtotiva * tc) : dtotiva;
    };
    const esCredito = (f: any) => {
      const codigo = f.condicion_operacion?.codigo;
      if (codigo !== undefined && codigo !== null) return Number(codigo) === 2;
      const desc = (f.condicion_operacion?.descripcion || '').toLowerCase();
      return desc.includes('créd') || desc.includes('cred');
    };

    let ventas = 0;
    let cantidadProductos = 0;
    let totalIva = 0;
    let ventasContado = 0;
    let facturasContado = 0;
    let ventasCredito = 0;
    let facturasCredito = 0;
    const clienteIds = new Set<string>();
    const productosMap: Record<string, { nombre: string; cantidad: number; monto: number }> = {};
    const ventasPorHoraMap: Record<string, number> = {};
    const categoriasMap: Record<string, number> = {};
    const monedaMap: Record<string, { moneda: any; ventas: number; facturas: number }> = {};

    for (const f of facturas) {
      const monto = getMonto(f);
      ventas += monto;
      totalIva += getIva(f);
      if (f.cliente_id) clienteIds.add(f.cliente_id);

      if (esCredito(f)) {
        ventasCredito += monto;
        facturasCredito++;
      } else {
        ventasContado += monto;
        facturasContado++;
      }

      const hora = `${String(new Date(f.dfeemide).getHours()).padStart(2, '0')}:00`;
      ventasPorHoraMap[hora] = (ventasPorHoraMap[hora] || 0) + monto;

      // Desglose por moneda (montos en moneda original, sin convertir — igual que Facturación)
      const monedaId = f.moneda_id || 'sin-moneda';
      if (!monedaMap[monedaId]) {
        monedaMap[monedaId] = {
          moneda: {
            id: monedaId,
            codigo: f.moneda?.codigo || '???',
            simbolo: f.moneda?.simbolo || '$',
            descripcion: f.moneda?.descripcion || 'Moneda',
          },
          ventas: 0,
          facturas: 0,
        };
      }
      monedaMap[monedaId].ventas += Number(f.factura_subtotales?.[0]?.dtotope || 0);
      monedaMap[monedaId].facturas++;

      for (const det of f.factura_det || []) {
        const cant = Number(det.dcantproser || 0);
        let montoItem = Number(det.dtotopeitem || 0);
        if (convertToGs) {
          const codigo = (f.moneda?.codigo || '').toUpperCase();
          if (codigo !== 'PYG' && codigo !== 'GS') {
            const dtotopegs = Number(det.dtotopegs || 0);
            if (dtotopegs > 0) montoItem = dtotopegs;
            else {
              const tc = Number(f.dticam || det.dticamit || 0);
              if (tc > 0) montoItem = Math.round(montoItem * tc);
            }
          }
        }
        cantidadProductos += cant;
        const nombre = det.productos?.descripcion || det.ddesproser || 'Sin nombre';
        const key = det.producto_id || nombre;
        if (!productosMap[key]) productosMap[key] = { nombre, cantidad: 0, monto: 0 };
        productosMap[key].cantidad += cant;
        productosMap[key].monto += montoItem;
        const categoria = det.productos?.categoria?.descripcion || 'Sin categoría';
        categoriasMap[categoria] = (categoriasMap[categoria] || 0) + montoItem;
      }
    }

    const cantidadFacturas = facturas.length;
    const ticketPromedio = cantidadFacturas > 0 ? Math.round(ventas / cantidadFacturas) : 0;
    const totalContadoCredito = ventasContado + ventasCredito;

    const topProductos = Object.values(productosMap)
      .sort((a, b) => b.cantidad - a.cantidad)
      .slice(0, 5);
    const ventasPorHora = Object.entries(ventasPorHoraMap)
      .map(([hora, v]) => ({ hora, ventas: v }))
      .sort((a, b) => a.hora.localeCompare(b.hora));
    const totalCategorias = Object.values(categoriasMap).reduce((s, v) => s + v, 0);
    const ventasPorCategoria = Object.entries(categoriasMap)
      .map(([categoria, monto]) => ({
        categoria,
        monto,
        porcentaje: totalCategorias > 0 ? Math.round((monto / totalCategorias) * 100) : 0,
      }))
      .sort((a, b) => b.monto - a.monto)
      .slice(0, 5);

    return {
      ventas,
      cantidadProductos: Math.round(cantidadProductos),
      ganancias: totalIva,
      facturas: cantidadFacturas,
      ticketPromedio,
      clientesAtendidos: clienteIds.size,
      topProductos,
      ventasPorHora,
      ventasPorCategoria,
      contado: {
        ventas: ventasContado,
        facturas: facturasContado,
        porcentaje: totalContadoCredito > 0 ? Math.round((ventasContado / totalContadoCredito) * 100) : 0,
      },
      credito: {
        ventas: ventasCredito,
        facturas: facturasCredito,
        porcentaje: totalContadoCredito > 0 ? Math.round((ventasCredito / totalContadoCredito) * 100) : 0,
      },
      desglosePorMoneda: Object.values(monedaMap).sort((a, b) => b.ventas - a.ventas),
    };
  }

  /**
   * Métricas del dashboard de ventas, agregadas en el backend (filter-wide, criterio fiscal).
   * Reemplaza el cálculo cliente que sumaba solo las primeras 100 facturas y no cuadraba
   * con Facturación. Devuelve período actual (completo) + anterior (para % de variación).
   */
  async getDashboardVentas(
    empresa_id: string,
    filters: {
      fechaDesde?: string;
      fechaHasta?: string;
      fechaDesdeAnterior?: string;
      fechaHastaAnterior?: string;
      monedaId?: string;
      /** Quién consulta. Define qué sucursales puede ver. */
      usuarioId?: string;
    },
  ) {
    const convertToGs = !filters.monedaId;

    // El dashboard tiene que respetar el mismo alcance que el listado: si no,
    // un usuario de una sucursal vería los totales de toda la empresa.
    const alcance = await alcanceSucursalUsuario(this.prisma, filters.usuarioId, empresa_id);
    const filtroSucursal = await filtroFiscalPorSucursal(this.prisma, alcance);

    const baseActual: any = this.buildFacturasWhere(empresa_id, {
      fechaDesde: filters.fechaDesde,
      fechaHasta: filters.fechaHasta,
    }, filtroSucursal);
    const baseAnterior: any = this.buildFacturasWhere(empresa_id, {
      fechaDesde: filters.fechaDesdeAnterior,
      fechaHasta: filters.fechaHastaAnterior,
    }, filtroSucursal);
    if (filters.monedaId) {
      baseActual.moneda_id = filters.monedaId;
      baseAnterior.moneda_id = filters.monedaId;
    }
    const whereActual = this.buildWhereVentasValidas(baseActual);
    const whereAnterior = this.buildWhereVentasValidas(baseAnterior);

    const cabSelect = {
      cliente_id: true,
      dfeemide: true,
      dticam: true,
      moneda_id: true,
      moneda: { select: { id: true, codigo: true, simbolo: true, descripcion: true } },
      condicion_operacion: { select: { codigo: true, descripcion: true } },
      factura_subtotales: { select: { dtotope: true, dtotiva: true, dtotalgs: true } },
    };
    const detSelect = {
      dcantproser: true,
      dtotopeitem: true,
      dtotopegs: true,
      dticamit: true,
      producto_id: true,
      ddesproser: true,
      productos: { select: { descripcion: true, categoria: { select: { descripcion: true } } } },
    };

    const [facturasActual, facturasAnterior, productosAnteriorAgg] = await Promise.all([
      // Actual: cabecera + detalle (necesario para top productos, categorías, productos vendidos).
      this.prisma.factura_cab.findMany({
        where: whereActual,
        select: { ...cabSelect, factura_det: { select: detSelect } },
      }),
      // Anterior: solo cabecera (para ventas/IVA/facturas/clientes de la comparación).
      this.prisma.factura_cab.findMany({ where: whereAnterior, select: cabSelect }),
      // Productos vendidos del período anterior: agregado directo, sin traer las filas.
      this.prisma.factura_det.aggregate({
        _sum: { dcantproser: true },
        where: { factura_cab: whereAnterior },
      }),
    ]);

    const actual = this.aggregateDashboardVentas(facturasActual, convertToGs);
    const anterior = this.aggregateDashboardVentas(facturasAnterior, convertToGs);

    const monedasMap: Record<string, any> = {};
    for (const f of [...facturasActual, ...facturasAnterior]) {
      const m = f.moneda;
      if (m?.id && !monedasMap[m.id]) {
        monedasMap[m.id] = {
          id: m.id,
          codigo: m.codigo || '???',
          descripcion: m.descripcion || m.codigo || 'Moneda',
          simbolo: m.simbolo || '$',
        };
      }
    }

    return {
      actual,
      anterior: {
        ventas: anterior.ventas,
        ganancias: anterior.ganancias,
        facturas: anterior.facturas,
        cantidadProductos: Math.round(Number(productosAnteriorAgg._sum.dcantproser || 0)),
        clientesAtendidos: anterior.clientesAtendidos,
      },
      monedas: Object.values(monedasMap),
      convertido_a_pyg: convertToGs,
    };
  }

  async findAll(
    page = 1,
    limit = 10,
    empresa_id: string,
    filters?: {
      fechaDesde?: string;
      fechaHasta?: string;
      estado?: string;
      clienteId?: string;
      search?: string;
      sucursalId?: string;
      estadoSifen?: string;
      sesionCajaId?: string;
      /** Quién consulta. Define qué sucursales puede ver. */
      usuarioId?: string;
    },
  ) {
    const take = Math.max(1, Math.min(100, Number(limit) || 10));
    const currentPage = Math.max(1, Number(page) || 1);
    const skip = (currentPage - 1) * take;
    const alcance = await alcanceSucursalUsuario(this.prisma, filters?.usuarioId, empresa_id);
    const filtroSucursal = await filtroFiscalPorSucursal(this.prisma, alcance, filters?.sucursalId);
    const where = this.buildFacturasWhere(empresa_id, filters, filtroSucursal);
    const [items, total] = await this.prisma.$transaction([
      this.prisma.factura_cab.findMany({
        where,
        include: {
          cuentas_cobrar: true,
          clientes: {
            include: {
              personas: true,
            },
          },
          condicion_operacion: true,
          empresas: true,
          indicador_presencia: true,
          moneda: true,
          suscripciones: true,
          tipo_impuesto: true,
          tipo_operacion: true,
          tipo_transaccion: true,
          factura_cuotas: {
            include: {
              recibo_cobro_detalle: {
                select: {
                  id: true,
                  monto_pagado: true,
                  recibos_cobro: {
                    select: {
                      id: true,
                      numero_recibo: true,
                      fecha_emision: true,
                      fecha_registro: true,
                      estado: true,
                    },
                  },
                },
              },
            },
          },
          factura_det: {
            include: {
              productos: {
                include: {
                  unidades_de_medida: true,
                  categoria: true,
                },
              },
              factura_det_lote: {
                include: {
                  lotes_producto: {
                    select: {
                      id: true,
                      numero_lote: true,
                      fecha_vencimiento: true,
                    },
                  },
                },
              },
            },
          },
          factura_forma_pagos: {
            include: {
              medio_pago: true,
            },
          },
          factura_subtotales: true,
          pedido_origen: {
            select: {
              id: true,
              numero_pedido: true,
              estado: true,
              fecha_pedido: true,
              fecha_confirmacion: true,
              vendedor: { select: { id: true, nombre: true, apellido: true } },
              creador: { select: { id: true, username: true } },
            },
          },
        },
        orderBy: { created_at: 'desc' },
        take,
        skip,
      }),
      this.prisma.factura_cab.count({ where }),
    ]);
    const lastPage = Math.max(1, Math.ceil(total / take));

    // Estado de gestión de mora por cliente (page actual)
    const clienteIds = Array.from(new Set(items.map((it) => it.cliente_id).filter((id): id is string => !!id)));
    const gestionesActivas = clienteIds.length
      ? await this.prisma.cob_gestion_mora.findMany({
          where: {
            cliente_id: { in: clienteIds },
            estado_actual: { in: MORA_ESTADOS_ACTIVOS },
          },
          select: { cliente_id: true, estado_actual: true, fecha_ingreso: true },
          orderBy: { fecha_ingreso: 'desc' },
        })
      : [];
    const moraByCliente = new Map<string, CobMoraEstado>();
    for (const g of gestionesActivas) {
      if (!moraByCliente.has(g.cliente_id)) moraByCliente.set(g.cliente_id, g.estado_actual);
    }

    // Formatear fechas SIFEN para respuesta
    const formattedItems = items.map((item) => {
      const estado = item.cliente_id ? (moraByCliente.get(item.cliente_id) ?? null) : null;
      // Disponibilidad para remisión: ítems con cantidad aún no remisionada.
      // Se calcula sobre factura_det (ya incluido), sin queries extra.
      const dets = item.factura_det || [];
      const itemsTotal = dets.length;
      const itemsDisponibles = dets.filter(
        (d) => Number(d.dcantproser || 0) - Number((d as any).cantidad_aplicada_remision || 0) > 0,
      ).length;
      return {
        ...item,
        dfeemide: formatDateResponse(item.dfeemide),
        created_at: formatDateResponse(item.created_at),
        fecha_envio_sifen: formatDateResponse(item.fecha_envio_sifen),
        fecha_firma_sifen: formatDateResponse(item.fecha_firma_sifen),
        fecha_registro_sifen: formatDateResponse(item.fecha_registro_sifen),
        cliente_en_gestion_mora: { activa: !!estado, estado },
        remision: {
          items_total: itemsTotal,
          items_disponibles: itemsDisponibles,
          completa: itemsTotal > 0 && itemsDisponibles === 0,
        },
        pedido_origen: item.pedido_origen
          ? {
              ...item.pedido_origen,
              fecha_pedido: formatDateResponse(item.pedido_origen.fecha_pedido),
              fecha_confirmacion: formatDateResponse(item.pedido_origen.fecha_confirmacion),
            }
          : null,
      };
    });

    return {
      items: formattedItems,
      total,
      page: currentPage,
      limit: take,
      lastPage,
    };
  }

  async findOne(id: string, empresa_id: string) {
    const factura = await this.prisma.factura_cab.findFirst({
      where: { id, empresa_id },
      select: {
        cuentas_cobrar: true,
        clientes: {
          include: {
            personas: true,
          },
        },
        condicion_operacion: true,
        empresas: true,
        indicador_presencia: true,
        moneda: true,
        suscripciones: true,
        tipo_impuesto: true,
        tipo_operacion: true,
        tipo_transaccion: true,
        factura_cuotas: true,
        factura_det: {
          include: {
            factura_det_lote: {
              include: {
                lotes_producto: {
                  select: {
                    id: true,
                    numero_lote: true,
                    fecha_vencimiento: true,
                    fecha_fabricacion: true,
                  },
                },
              },
            },
          },
        },
        factura_forma_pagos: true,
        factura_subtotales: true,
        // Equipo asignado — se resuelve con precedencia factura → dirección → cliente en el helper
        vendedor: { select: { id: true, nombre: true, apellido: true } },
        cobrador: { select: { id: true, nombre: true, apellido: true } },
        // Presupuesto de origen: el detalle mostraba la factura sin decir de
        // dónde salió aunque el dato ya estuviera guardado.
        presupuesto_origen: {
          select: {
            id: true,
            numero: true,
            version: true,
            titulo: true,
            estado: true,
            fecha_emision: true,
          },
        },
        cliente_direccion: {
          select: {
            id: true,
            label: true,
            vendedor: { select: { id: true, nombre: true, apellido: true } },
            cobrador: { select: { id: true, nombre: true, apellido: true } },
            supervisor: { select: { id: true, nombre: true, apellido: true } },
          },
        },
      },
    });
    if (!factura) throw new NotFoundException(`La factura ${id} no existe`);

    // Resolver equipo asignado con precedencia factura → cliente_direccion → cliente
    const clienteCobradorId = (factura.clientes as any)?.cobrador_id ?? null;
    let clienteCobrador: { id: string; nombre: string | null; apellido: string | null } | null = null;
    if (!factura.cobrador && !factura.cliente_direccion?.cobrador && clienteCobradorId) {
      clienteCobrador = await this.prisma.vendedores_cobradores.findUnique({
        where: { id: clienteCobradorId },
        select: { id: true, nombre: true, apellido: true },
      });
    }
    const fullName = (p?: { nombre: string | null; apellido: string | null } | null) =>
      p ? `${p.nombre || ''} ${p.apellido || ''}`.trim() || null : null;
    const equipo_asignado = {
      vendedor: factura.vendedor
        ? { origen: 'FACTURA', id: factura.vendedor.id, nombre: fullName(factura.vendedor) }
        : factura.cliente_direccion?.vendedor
          ? {
              origen: 'DIRECCION',
              id: factura.cliente_direccion.vendedor.id,
              nombre: fullName(factura.cliente_direccion.vendedor),
            }
          : null,
      cobrador: factura.cobrador
        ? { origen: 'FACTURA', id: factura.cobrador.id, nombre: fullName(factura.cobrador) }
        : factura.cliente_direccion?.cobrador
          ? {
              origen: 'DIRECCION',
              id: factura.cliente_direccion.cobrador.id,
              nombre: fullName(factura.cliente_direccion.cobrador),
            }
          : clienteCobrador
            ? { origen: 'CLIENTE', id: clienteCobrador.id, nombre: fullName(clienteCobrador) }
            : null,
      supervisor: factura.cliente_direccion?.supervisor
        ? {
            origen: 'DIRECCION',
            id: factura.cliente_direccion.supervisor.id,
            nombre: fullName(factura.cliente_direccion.supervisor),
          }
        : null,
    };

    // Si esta factura fue emitida como comprobante de intereses moratorios,
    // buscamos el recibo de origen para mostrarlo en el drawer (trazabilidad).
    const interesOrigen = await this.prisma.cob_interes_cobrado.findFirst({
      where: { factura_cab_id: id },
      select: {
        id: true,
        recibo_cobro_id: true,
        factura_cuota_id: true,
        dias_mora: true,
        tasa_aplicada: true,
        monto_interes_cobrado: true,
      },
    });
    let origen_intereses = null;
    if (interesOrigen) {
      const recibo = await this.prisma.recibos_cobro.findUnique({
        where: { id: interesOrigen.recibo_cobro_id },
        select: {
          id: true,
          numero_recibo: true,
          fecha_emision: true,
          mora_total: true,
          cliente_id: true,
          clientes: {
            select: {
              personas: { select: { razon_social: true, ruc: true } },
            },
          },
        },
      });
      origen_intereses = {
        tipo_origen: 'INTERES_MORATORIO',
        interes: interesOrigen,
        recibo,
      };
    }

    return {
      message: 'Factura encontrada exitosamente',
      data: { ...factura, origen_intereses, equipo_asignado },
    };
  }

  /**
   * Devuelve los documentos vinculados a una factura para trazabilidad:
   * recibos que la imputaron (MULTI + LEGACY) y notas de crédito emitidas contra ella.
   */
  async getDocumentosVinculados(facturaId: string, empresaId: string) {
    const factura = await this.prisma.factura_cab.findFirst({
      where: { id: facturaId, empresa_id: empresaId },
      select: {
        id: true,
        moneda: { select: { codigo: true } },
        pedido_id: true,
        solicitud_credito_id: true,
      },
    });
    if (!factura) throw new NotFoundException(`La factura ${facturaId} no existe`);

    // Solicitud de crédito de origen (si la factura provino de una)
    const solicitud = factura.solicitud_credito_id
      ? await this.prisma.solicitud_credito.findUnique({
          where: { id: factura.solicitud_credito_id },
          select: {
            id: true,
            numero_solicitud: true,
            fecha_solicitud: true,
            fecha_aprobacion: true,
            estado: true,
            monto_total: true,
            cant_cuotas_total: true,
            moneda: true,
          },
        })
      : null;

    // Pedido / Orden de venta de origen (si la factura provino de uno)
    const pedido = factura.pedido_id
      ? await this.prisma.pedidos.findUnique({
          where: { id: factura.pedido_id },
          select: {
            id: true,
            numero_pedido: true,
            fecha_pedido: true,
            estado: true,
            total: true,
            moneda: { select: { codigo: true } },
          },
        })
      : null;

    // Recibos MULTI: imputaciones directas por factura
    const imputacionesMulti = await this.prisma.recibo_cobro_facturas.findMany({
      where: { factura_cab_id: facturaId },
      select: {
        id: true,
        monto_pagado: true,
        monto_nc: true,
        monto_retencion: true,
        monto_interes: true,
        recibo: {
          select: {
            id: true,
            numero_recibo: true,
            fecha_emision: true,
            estado: true,
            monto_total: true,
            moneda: { select: { codigo: true } },
          },
        },
      },
      orderBy: { created_at: 'desc' },
    });

    // Recibos LEGACY: vía cuotas de la factura
    const legacyDetalles = await this.prisma.recibo_cobro_detalle.findMany({
      where: { factura_cuotas: { factura_cab_id: facturaId } },
      select: {
        id: true,
        monto_pagado: true,
        recibo_cobro_id: true,
        recibos_cobro: {
          select: {
            id: true,
            numero_recibo: true,
            fecha_emision: true,
            estado: true,
            monto_total: true,
            moneda: { select: { codigo: true } },
          },
        },
      },
    });

    // Agrupar por recibo_id — un recibo LEGACY suele tener varios detalles (uno por cuota)
    const recibosMap = new Map<string, any>();
    for (const imp of imputacionesMulti) {
      if (!imp.recibo) continue;
      const key = imp.recibo.id;
      const prev = recibosMap.get(key);
      // Lo aplicado a la factura es el bruto `monto_pagado` (ya salda la factura).
      // NC/retención son fondeo del efectivo, no reducen la factura: sumarlos hacía
      // que "Aplicado" superara el total de la factura (ver recibos.service crear()).
      const monto = Number(imp.monto_pagado);
      if (prev) {
        prev.monto_aplicado += monto;
      } else {
        recibosMap.set(key, {
          id: imp.recibo.id,
          numero_recibo: imp.recibo.numero_recibo,
          fecha_emision: imp.recibo.fecha_emision,
          estado: imp.recibo.estado,
          monto_total: Number(imp.recibo.monto_total),
          moneda: imp.recibo.moneda?.codigo,
          origen: 'MULTI',
          monto_aplicado: monto,
        });
      }
    }
    for (const det of legacyDetalles) {
      const r = det.recibos_cobro;
      if (!r) continue;
      const prev = recibosMap.get(r.id);
      const monto = Number(det.monto_pagado);
      if (prev) {
        prev.monto_aplicado += monto;
      } else {
        recibosMap.set(r.id, {
          id: r.id,
          numero_recibo: r.numero_recibo,
          fecha_emision: r.fecha_emision,
          estado: r.estado,
          monto_total: Number(r.monto_total),
          moneda: r.moneda?.codigo,
          origen: 'LEGACY',
          monto_aplicado: monto,
        });
      }
    }
    const recibos = Array.from(recibosMap.values()).sort(
      (a, b) => new Date(b.fecha_emision).getTime() - new Date(a.fecha_emision).getTime(),
    );

    // Notas de crédito emitidas contra la factura
    const notasCredito = await this.prisma.nota_credito_cab.findMany({
      where: { factura_cab_id: facturaId, empresa_id: empresaId },
      select: {
        id: true,
        dest: true,
        dpunexp: true,
        dnumdoc: true,
        dfeemide: true,
        estado: true,
        estado_sifen: true,
        cdc: true,
        moneda: { select: { codigo: true } },
        motivo_emision_nota_credito_y_debito: { select: { descripcion: true } },
        nota_credito_subtotal: { select: { dtotgralope: true, dtotope: true } },
      },
      orderBy: { dfeemide: 'desc' },
    });

    return {
      recibos,
      notas_credito: notasCredito.map((nc) => ({
        id: nc.id,
        numero: `${(nc.dest || '001').padStart(3, '0')}-${(nc.dpunexp || '001').padStart(3, '0')}-${(nc.dnumdoc || '0000000').padStart(7, '0')}`,
        fecha_emision: nc.dfeemide,
        estado: nc.estado,
        estado_sifen: nc.estado_sifen,
        cdc: nc.cdc,
        moneda: nc.moneda?.codigo,
        motivo: nc.motivo_emision_nota_credito_y_debito?.descripcion ?? null,
        total: Number(nc.nota_credito_subtotal?.[0]?.dtotgralope ?? nc.nota_credito_subtotal?.[0]?.dtotope ?? 0),
      })),
      solicitud_credito: solicitud
        ? {
            id: solicitud.id,
            numero: solicitud.numero_solicitud,
            fecha_solicitud: solicitud.fecha_solicitud,
            fecha_aprobacion: solicitud.fecha_aprobacion,
            estado: solicitud.estado,
            cant_cuotas: solicitud.cant_cuotas_total,
            moneda: solicitud.moneda,
            total: Number(solicitud.monto_total ?? 0),
          }
        : null,
      pedido: pedido
        ? {
            id: pedido.id,
            numero: pedido.numero_pedido,
            fecha_pedido: pedido.fecha_pedido,
            estado: pedido.estado,
            moneda: pedido.moneda?.codigo,
            total: Number(pedido.total ?? 0),
          }
        : null,
    };
  }

  async findPendientesEnvio(page = 1, limit = 50, empresa_id: string) {
    const take = Math.max(1, Math.min(100, Number(limit) || 50));
    const currentPage = Math.max(1, Number(page) || 1);
    const skip = (currentPage - 1) * take;

    const where = {
      empresa_id,
      estado: EstadoFactura.PENDIENTE,
    };

    const [items, total] = await this.prisma.$transaction([
      this.prisma.factura_cab.findMany({
        where,
        skip,
        take,
        orderBy: { dfeemide: 'desc' },
        select: {
          id: true,
          dest: true,
          dpunexp: true,
          dnumdoc: true,
          dfeemide: true,
          estado: true,
          clientes: {
            select: {
              personas: {
                select: {
                  razon_social: true,
                  ruc: true,
                },
              },
            },
          },
          factura_subtotales: {
            select: {
              dtotgralope: true,
            },
          },
          moneda: {
            select: {
              codigo: true,
            },
          },
        },
      }),
      this.prisma.factura_cab.count({ where }),
    ]);

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

  /**
   * Encola facturas para envío/reenvío a SIFEN.
   *
   * ⚠️ **Uso EXCLUSIVO por acción explícita del usuario** (endpoint HTTP con
   * permiso `VEN_FAC_SIFEN_ENVIAR`). No invocar desde jobs / cron / triggers
   * automáticos: los documentos rechazados por SIFEN **requieren revisión
   * humana** (típicamente hay que corregir datos del cliente, montos, timbrado,
   * etc.) antes de un reintento. Un reenvío automático perpetuaría el error.
   *
   * Documentos aptos:
   *   - Estado local `Pendiente` (nunca llegó a SIFEN).
   *   - Estado local `Rechazado` (SIFEN rechazó, el usuario ya corrigió los datos).
   */
  async enviarLoteMiddleware(facturaIds: string[], empresa_id: string, user_id?: string) {
    if (!facturaIds || facturaIds.length === 0) {
      throw new BadRequestException('Debe proporcionar al menos una factura');
    }

    // Reenviables: pendientes locales (nunca llegaron a SIFEN) o rechazadas
    // por SIFEN (para reintentar con datos corregidos). Cuando SIFEN devuelve
    // rechazo, `estado` local también se actualiza a "Rechazado" (ver
    // middleware-sifen.service.ts), así que basta filtrar por `estado`.
    // Anuladas y aprobadas no aplican — SIFEN no acepta reenvío de documentos ya firmados.
    const facturas = await this.prisma.factura_cab.findMany({
      where: {
        id: { in: facturaIds },
        empresa_id,
        estado: { in: [EstadoFactura.PENDIENTE, EstadoFactura.RECHAZADO] },
      },
      select: { id: true, estado: true, estado_sifen: true },
    });

    if (facturas.length === 0) {
      throw new NotFoundException('No se encontraron facturas reenviables (pendientes o rechazadas por SIFEN).');
    }

    const facturasEncontradas = facturas.map((f) => f.id);
    const facturasNoEncontradas = facturaIds.filter((id) => !facturasEncontradas.includes(id));

    // Encolar cada factura para envío
    const resultados: { facturaId: string; status: string }[] = [];

    for (const facturaId of facturasEncontradas) {
      try {
        await this.queuesService.enqueueSifenFactura(facturaId, empresa_id);
        resultados.push({ facturaId, status: 'encolado' });
      } catch (error) {
        const msg = error instanceof Error ? error.message : 'Error desconocido';
        resultados.push({ facturaId, status: `error: ${msg}` });
      }
    }

    this.logger.log(
      `Lote facturas enviado a SIFEN: ${facturasEncontradas.length} documentos | Empresa: ${empresa_id}`,
      'FacturasService',
    );
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'UPDATE',
      entity_type: 'factura',
      entity_id: empresa_id,
      descripcion: `Lote de ${facturasEncontradas.length} factura(s) encolada(s) para envío SIFEN`,
      new_value: { enviadas: facturasEncontradas.length, no_encontradas: facturasNoEncontradas, resultados },
    });

    return {
      enviadas: facturasEncontradas.length,
      no_encontradas: facturasNoEncontradas,
      resultados,
    };
  }

  /**
   * Calcular Dígito Verificador usando Módulo 11 (algoritmo SIFEN Paraguay)
   * Multiplicadores cíclicos: 2,3,4,5,6,7,2,3,4,5,6,7...
  //  */
  // private calcularDVModulo11(cadena: string): number {
  //   const multiplicadores = [2, 3, 4, 5, 6, 7];
  //   let suma = 0;
  //   const chars = cadena.split('').reverse();
  //   for (let i = 0; i < chars.length; i++) {
  //     suma +=
  //       parseInt(chars[i], 10) * multiplicadores[i % multiplicadores.length];
  //   }
  //   const resto = suma % 11;
  //   if (resto <= 1) return 0;
  //   return 11 - resto;
  // }

  private calcularDVModulo11(p_numero: string, p_basemax = 11): number {
    let v_total = 0;
    let k = 2;

    // Eliminar todos los caracteres que no son dígitos y convertirlos a dígitos
    let v_numero_al = '';
    for (let i = 0; i < p_numero.length; i++) {
      const c = p_numero.charAt(i);
      if (/\d/.test(c)) {
        v_numero_al += c;
      } else {
        v_numero_al += c.charCodeAt(0);
      }
    }

    // Calcular el total ponderado
    for (let i = v_numero_al.length - 1; i >= 0; i--) {
      k = k > p_basemax ? 2 : k;
      const v_numero_aux = parseInt(v_numero_al.charAt(i), 10);
      v_total += v_numero_aux * k++;
    }

    // Calcular el dígito verificador
    const v_resto = v_total % 11;
    return v_resto > 1 ? 11 - v_resto : 0;
  }

  /**
   * Generar CDC (Código de Control Digital) de 44 dígitos
   * Conformación: iTiDE(2) + dRucEm(8) + dDVEmi(1) + dEst(3) + dPunExp(3) + dNumDoc(7) + iTipCont(1) + dFeEmiDE(8) + iTipEmi(1) + dCodSeg(9) + dDVId(1)
   */
  private generarCdc(params: {
    iTiDE: number;
    dRucEm: string;
    dDVEmi: string;
    dEst: string;
    dPunExp: string;
    dNumDoc: string;
    iTipCont: number;
    dFeEmiDE: Date;
    iTipEmi?: number;
  }): string {
    const tipoDoc = String(params.iTiDE).padStart(2, '0');
    const ruc = params.dRucEm.padStart(8, '0');
    const dv = String(params.dDVEmi);
    const est = params.dEst.padStart(3, '0');
    const punExp = params.dPunExp.padStart(3, '0');
    const numDoc = params.dNumDoc.padStart(7, '0');
    const tipCont = String(params.iTipCont);

    const fecha = params.dFeEmiDE;
    // Usar componentes UTC: dfeemide se persiste como wall-clock UTC (toPrismaDate
    // agrega 'Z') y dFeEmiDE del payload usa toISOString() (UTC). El CDC debe
    // coincidir; con getDate() local se corría un día cerca de medianoche.
    const anio = fecha.getUTCFullYear();
    const mes = String(fecha.getUTCMonth() + 1).padStart(2, '0');
    const dia = String(fecha.getUTCDate()).padStart(2, '0');
    const fechaStr = `${anio}${mes}${dia}`;

    const tipEmi = String(params.iTipEmi ?? 1);

    // Código de seguridad: número aleatorio de 9 dígitos
    const codSeg = String(Math.floor(Math.random() * 999999999)).padStart(9, '0');

    // Concatenar primeros 43 dígitos
    const cdcSinDV = `${tipoDoc}${ruc}${dv}${est}${punExp}${numDoc}${tipCont}${fechaStr}${tipEmi}${codSeg}`;

    // Calcular dígito verificador Módulo 11
    const dvCdc = this.calcularDVModulo11(cdcSinDV);

    return `${cdcSinDV}${dvCdc}`;
  }

  /**
   * Generar enlace QR público para consulta de documento
   */
  private generarEnlaceQr(cdc: string): string {
    const baseUrl = envs.urlPanelFrontend;
    return `${baseUrl}/consulta/documento/${cdc}`;
  }

  /**
   * Obtener id y empresa_id de una factura por CDC (para generación de PDF público)
   */
  async findIdByCdc(cdc: string): Promise<{ id: string; empresa_id: string }> {
    const factura = await this.prisma.factura_cab.findFirst({
      where: { cdc },
      select: { id: true, empresa_id: true },
    });
    if (!factura) throw new NotFoundException('Documento no encontrado para el CDC dado');
    return { id: factura.id, empresa_id: factura.empresa_id };
  }

  /**
   * Consultar documento por CDC (endpoint público, sin autenticación)
   */
  async findByCdc(cdc: string) {
    const factura = await this.prisma.factura_cab.findFirst({
      where: { cdc },
      select: {
        id: true,
        dest: true,
        dpunexp: true,
        dnumdoc: true,
        dfeemide: true,
        cdc: true,
        estado: true,
        estado_sifen: true,
        mensaje_sifen: true,
        fecha_envio_sifen: true,
        fecha_firma_sifen: true,
        clientes: {
          select: {
            personas: {
              select: {
                razon_social: true,
                ruc: true,
                dv: true,
              },
            },
          },
        },
        empresas: {
          select: {
            razon_social: true,
            ruc: true,
            dv: true,
          },
        },
        moneda: {
          select: { codigo: true, descripcion: true },
        },
        factura_subtotales: {
          select: {
            dtotgralope: true,
            dtotiva: true,
            dsubexe: true,
            dsub5: true,
            dsub10: true,
          },
        },
        factura_det: {
          select: {
            ddesproser: true,
            dcantproser: true,
            duniproser: true,
            dtotopeitem: true,
            dtasiva: true,
          },
        },
        condicion_operacion: {
          select: { descripcion: true },
        },
      },
    });

    if (!factura) throw new NotFoundException('Documento no encontrado');

    return {
      message: 'Documento encontrado',
      data: {
        ...factura,
        dfeemide: formatDateResponse(factura.dfeemide),
        fecha_envio_sifen: formatDateResponse(factura.fecha_envio_sifen),
        fecha_firma_sifen: formatDateResponse(factura.fecha_firma_sifen),
      },
    };
  }

  // ==================== ANULACIÓN DE FACTURA (Retail) ====================

  async anularFactura(
    facturaId: string,
    empresa_id: string,
    opts: { motivo: string; token_autorizacion?: string; user_id?: string },
  ) {
    const { motivo, user_id } = opts;
    if (!motivo?.trim()) throw new BadRequestException('Debe proporcionar un motivo de anulación');

    const factura = await this.prisma.factura_cab.findFirst({
      where: { id: facturaId, empresa_id },
      include: {
        factura_forma_pagos: { include: { medio_pago: true } },
        factura_det: { include: { productos: { select: { id: true, maneja_inventario: true } } } },
        factura_subtotales: true,
      },
    });
    if (!factura) throw new NotFoundException('Factura no encontrada');
    if (factura.estado === EstadoFactura.ANULADA) throw new ConflictException('La factura ya está anulada');
    // Anular dispara ECAN/EINU: con otro evento esperando respuesta de SIFEN se
    // duplicaría. `enviarEvento` también lo bloquea; acá es para un mensaje claro
    // en vez de "SIFEN rechazó...".
    if (factura.estado_evento === 'Pendiente') {
      throw new ConflictException(
        'La factura tiene un evento SIFEN pendiente de respuesta. Esperá a que se procese antes de anularla.',
      );
    }
    const depositoInventarioId = factura.factura_det.find((det) => Boolean(det.deposito_id))?.deposito_id || null;
    const sucursalLotes = depositoInventarioId
      ? await this.prisma.depositos.findFirst({
          where: { id: depositoInventarioId, empresa_id, active: true },
          select: { sucursal_id: true },
        })
      : null;
    const lotesEnabled = sucursalLotes?.sucursal_id
      ? await this.lotesService.isEnabledForEmpresa(empresa_id, sucursalLotes.sucursal_id)
      : false;

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

    // Empresas sin facturación electrónica: nunca enviamos el evento al middleware.
    // Marcamos el documento como Cancelado/Anulado localmente y aplicamos todos
    // los reversos (stock, contabilidad, caja, CxC, comisiones) en el bloque
    // de "reversos locales" más abajo.
    const usaSifen = await this.middlewareSifenService.empresaUsaSifen(empresa_id);

    // ===== PASO 1: Enviar evento SIFEN PRIMERO =====
    // No tocar el estado local ni hacer reversos hasta confirmar respuesta SIFEN.
    // Si SIFEN rechaza, abortamos sin cambios.
    const tipoEventoSifen: 'cancelacion' | 'inutilizacion' | null = !usaSifen
      ? null
      : factura.estado_sifen === (EstadoFactura.APROBADO as string)
        ? 'cancelacion'
        : factura.estado_sifen === 'Rechazado'
          ? 'inutilizacion'
          : null;

    let sifenResult: { success: boolean; message: string } | null = null;
    if (tipoEventoSifen) {
      try {
        sifenResult = await this.middlewareSifenService.enviarEvento(
          empresa_id,
          'factura',
          tipoEventoSifen,
          facturaId,
          motivo,
        );
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Error SIFEN';
        this.logger.error(`Error evento SIFEN anulación ${facturaId}: ${msg}`, 'FacturasService');
        throw new BadRequestException(`Error enviando evento a SIFEN: ${msg}`);
      }
      if (!sifenResult?.success) {
        throw new BadRequestException(
          `SIFEN rechazó la ${tipoEventoSifen} de la factura ${nroFact}: ${sifenResult?.message || 'sin detalle'}`,
        );
      }
    }

    // ===== PASO 2: Aplicar reversos locales =====
    // Si hubo flujo SIFEN aprobado, el middleware (esperarYConsultarEstado) ya:
    //   - marcó estado = 'Cancelado' | 'Inutilizado'
    //   - revirtió stock + movimientos de inventario
    //   - revirtió contabilidad (para ECAN)
    //   - revirtió orden de venta vinculada
    // Acá solo aplicamos los reversos locales que el middleware no hace:
    // pagos en caja, cuentas a cobrar, cuotas, comisiones.
    // Si NO hubo SIFEN (factura nunca enviada): marcamos estado=ANULADA y
    // hacemos también stock + contabilidad (no hay middleware que lo haga).
    const aplicarStockYContaLocal = !tipoEventoSifen;

    await this.prisma.$transaction(async (tx) => {
      if (!tipoEventoSifen) {
        // Para empresas sin SIFEN que ya tenían el documento aprobado manualmente
        // el evento semánticamente es ECAN (cancelación). Para facturas que nunca
        // se enviaron a SIFEN, sigue siendo EINU (inutilización local).
        const eventoLocal = !usaSifen && factura.estado_sifen === 'Aprobado' ? 'ECAN' : 'EINU';
        const estadoEvento = !usaSifen ? 'Aprobado' : 'Pendiente';
        const mensaje = !usaSifen
          ? `Cancelación local (empresa sin SIFEN) - ${motivo}`
          : `Pendiente inutilización - ${motivo}`;
        await tx.factura_cab.update({
          where: { id: facturaId },
          data: {
            estado: EstadoFactura.ANULADA,
            // Para empresas sin SIFEN, marcamos el evento ECAN como aprobado pero
            // el estado interno queda Anulada (consistente con el path histórico).
            estado_sifen: !usaSifen ? 'Cancelado' : factura.estado_sifen,
            updated_at: new Date(),
            evento_aplicado: eventoLocal,
            estado_evento: estadoEvento,
            mensaje_evento: mensaje,
            fecha_evento: new Date(),
          },
        });
      }

      // Reversión de vínculos con remisiones (facturas creadas desde remisión).
      // Estas facturas NUNCA descontaron stock (lo hizo la remisión), así que
      // tampoco se repone en la anulación (más abajo se condiciona el bloque).
      const aplicacionesRem = await tx.nota_remision_det_aplicacion.findMany({
        where: { factura_det: { factura_cab_id: facturaId } },
        select: { id: true, nota_remision_det_id: true, cantidad_aplicada: true },
      });
      const facturaDesdeRemisiones = aplicacionesRem.length > 0;
      if (facturaDesdeRemisiones) {
        for (const ap of aplicacionesRem) {
          await tx.$executeRaw`
            UPDATE nota_remision_det
            SET cantidad_facturada = GREATEST(0, cantidad_facturada - ${Number(ap.cantidad_aplicada)})
            WHERE id = ${ap.nota_remision_det_id}::uuid`;
        }
        await tx.nota_remision_det_aplicacion.deleteMany({
          where: { id: { in: aplicacionesRem.map((a) => a.id) } },
        });
        await tx.nota_remision_factura.deleteMany({ where: { factura_cab_id: facturaId } });
      }

      // La reversión de caja se hace fuera de esta transacción, con
      // `ReversionCajaService` (idempotente y compartido con el camino de
      // eventos SIFEN, que antes no revertía caja).

      // Revertir inventario — solo si no hubo flujo SIFEN (el middleware lo hace)
      // y si la factura descontó stock (las creadas desde remisión no lo hicieron).
      if (aplicarStockYContaLocal && !facturaDesdeRemisiones)
        for (const det of factura.factura_det) {
          if (!det.productos?.maneja_inventario || !det.deposito_id) continue;
          const stock = await tx.stock_deposito.findUnique({
            where: { deposito_id_producto_id: { deposito_id: det.deposito_id, producto_id: det.producto_id } },
          });
          const cant = Number(det.dcantproser) || 0;
          await tx.stock_deposito.upsert({
            where: { deposito_id_producto_id: { deposito_id: det.deposito_id, producto_id: det.producto_id } },
            create: { deposito_id: det.deposito_id, producto_id: det.producto_id, cantidad_disponible: cant },
            update: { cantidad_disponible: (Number(stock?.cantidad_disponible) || 0) + cant, updated_at: new Date() },
          });

          if (lotesEnabled) {
            await this.lotesService.restoreFacturaDetLotes(tx, {
              facturaDetId: det.id,
              depositoId: det.deposito_id,
              cantidad: cant,
            });
          }

          const tipoMov = await tx.tipo_movimiento_inventario.findFirst({
            where: { OR: [{ codigo: 'DEVOLUCION' }, { descripcion: { contains: 'Entrada', mode: 'insensitive' } }] },
          });
          if (tipoMov) {
            await tx.movimientos_inventario.create({
              data: {
                deposito_origen: { connect: { id: det.deposito_id } },
                deposito_destino: { connect: { id: det.deposito_id } },
                producto: { connect: { id: det.producto_id } },
                tipo_movimiento: { connect: { id: tipoMov.id } },
                cantidad: cant,
                documento_origen: 'anulacion_factura',
                documento_id: facturaId,
                observaciones: `Anulación Fact. ${nroFact}`,
              },
            });
          }
        }

      // Cuotas, cuentas a cobrar, saldo, caja, stock, contabilidad y comisiones
      // los aplica `revertirFacturaAnulada` después de la transacción: es el
      // punto único que comparten los tres caminos de anulación.
    });

    // Reversión completa por el punto único compartido con los caminos de
    // evento SIFEN. Idempotente: si `esperarYConsultarEstado` ya la corrió
    // recién (flujo con SIFEN), volver a llamarla no duplica nada.
    // `aplicarStockYContaLocal` es true sólo cuando la factura nunca fue a
    // SIFEN; ahí el asiento se revierte con el usuario humano, no con null.
    await this.middlewareSifenService.revertirFacturaAnulada(facturaId, empresa_id, {
      motivo,
      usuarioId: user_id ?? null,
      revertirContabilidad: aplicarStockYContaLocal && !!user_id,
    });

    // Auditoría
    this.logger.log(`Factura anulada: ${nroFact} | ${motivo} | Empresa: ${empresa_id}`, 'FacturasService');
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'UPDATE',
      entity_type: 'factura',
      entity_id: facturaId,
      descripcion: `Factura anulada: ${nroFact} | Motivo: ${motivo}`,
      new_value: { estado: EstadoFactura.ANULADA, motivo, numero: nroFact },
    });

    // Mensaje de SIFEN para el response (ya se envió/validó al inicio)
    if (!tipoEventoSifen) {
      sifenResult = { success: true, message: 'Factura anulada. Se inutilizará al procesar en SIFEN.' };
    }

    // Reversión contable — solo si no hubo flujo SIFEN aprobado
    // (en ECAN aprobado, esperarYConsultarEstado ya disparó la reversión contable)

    return {
      message: `Factura ${nroFact} anulada exitosamente`,
      data: { facturaId, numero: nroFact, sifen: sifenResult },
    };
  }

  async getUltimoPrecioCliente(empresaId: string, clienteId: string, productoId: string) {
    const det = await this.prisma.factura_det.findFirst({
      where: {
        producto_id: productoId,
        factura_cab: {
          empresa_id: empresaId,
          cliente_id: clienteId,
          NOT: { estado: { equals: 'Anulado' } },
        },
      },
      orderBy: { factura_cab: { dfeemide: 'desc' } },
      select: {
        duniproser: true,
        dcantproser: true,
        factura_cab: {
          select: {
            dfeemide: true,
            dnumdoc: true,
            dest: true,
            dpunexp: true,
          },
        },
      },
    });

    if (!det) return null;

    return {
      precio_unitario: Number(det.duniproser),
      cantidad: Number(det.dcantproser),
      fecha: formatDateResponse(det.factura_cab.dfeemide),
      numero_factura: `${det.factura_cab.dest}-${det.factura_cab.dpunexp}-${det.factura_cab.dnumdoc}`,
    };
  }
}
