import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
  ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { PrismaService } from 'src/prisma/prisma.service';
import {
  CreateGastoDto,
  GastoItemDto,
} from './dto/create-gasto.dto';
import { CreateTipoGastoDto } from './dto/create-tipo-gasto.dto';
import { UpdateGastoDto } from './dto/update-gasto.dto';
import { UpdateTipoGastoDto } from './dto/update-tipo-gasto.dto';
import { GastosTesoreriaService } from './services/gastos-tesoreria.service';
import { ImportacionesCosteoService } from '../importaciones/services/importaciones-costeo.service';
import { datosCxpGasto, motivoBloqueoEdicionFinanciera } from './cxp-gasto';

type Tx = Prisma.TransactionClient;

type NormalizedItem = {
  requisicion_det_id: string | null;
  producto_id: string | null;
  tipo_gasto_id: string | null;
  descripcion: string;
  cantidad: number;
  precio_unitario: number;
  porcentaje_iva: number;
  subtotal: number; // cantidad * precio_unitario (IVA incluido, convención Paraguay)
  monto_iva: number; // IVA extraído del subtotal
  total: number; // subtotal (IVA incluido)
};

type Totals = {
  total_exento: number;
  total_gravado_5: number;
  total_gravado_10: number;
  total_iva_5: number;
  total_iva_10: number;
  iva_total: number;
  subtotal: number;
  total: number;
};

type NormalizedDocumentoFiscal = {
  timbrado: string | null;
  establecimiento: string | null;
  punto_expedicion: string | null;
  numero_factura: string | null;
  deducible: boolean;
};

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

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly contabilidadIntegracion: ContabilidadIntegracionService,
    private readonly gastosTesoreria: GastosTesoreriaService,
    private readonly costeo: ImportacionesCosteoService,
  ) {}

  async create(dto: CreateGastoDto, empresaId: string, userId?: string) {
    this.validatePayload(dto);

    let gastoId: string;
    try {
      gastoId = await this.prisma.$transaction(async (tx) => {
        const cab = dto.cabecera;
        const esAnticipoViatico =
          String(cab.forma_pago ?? '').toUpperCase() === 'ANTICIPO' &&
          !!cab.rendicion_viatico_id;
        if (!cab.proveedor_id && !esAnticipoViatico) {
          throw new BadRequestException(
            'Proveedor requerido para registrar cuentas por pagar del gasto',
          );
        }
        const configCompras = await this.getOrCreateConfigCompras(tx, empresaId);
        const generarCxpAutomatico = Boolean(configCompras.generar_cxp_automatico ?? true);
        const modoFlujo = this.normalizeModoFlujoCompra(configCompras.modo_flujo_compra);
        if (modoFlujo === 'requisicion_obligatoria' && !cab.requisicion_id) {
          throw new BadRequestException(
            'La configuración exige requisición obligatoria. Debés informar cabecera.requisicion_id',
          );
        }
        const formaPago = String(cab.forma_pago ?? 'CONTADO').toUpperCase();
        const esCredito = formaPago === 'CREDITO';
        // Flag: si está activo, un gasto de CONTADO no se auto-paga; queda pendiente
        // (como crédito) para pagarse vía orden de pago. No aplica a anticipos de viático.
        const incluirContadoEnOP = Boolean(configCompras.incluir_contado_en_orden_pago);
        const dejarPendiente = esCredito || (incluirContadoEnOP && !esAnticipoViatico);
        // El gasto paga de una vez (no crédito, no diferido a OP, no anticipo de
        // viático — ese ya salió del anticipo del empleado): necesitamos saber con
        // qué medio se pagó para poder generar el egreso real en Tesorería.
        if (!dejarPendiente && !esAnticipoViatico && !cab.medio_pago_id) {
          throw new BadRequestException(
            'Medio de pago requerido: el gasto paga de contado y no está configurado para diferirse a Orden de Pago',
          );
        }
        const esExteriorGasto = await this.isProveedorExterior(tx, empresaId, cab.proveedor_id ?? null);
        const documentoFiscal = this.normalizeDocumentoFiscal(
          {
            timbrado: cab.timbrado,
            establecimiento: cab.establecimiento,
            punto_expedicion: cab.punto_expedicion,
            numero_factura: cab.numero_factura,
          },
          { esExterior: esExteriorGasto },
        );

        await this.assertComprobanteNoDuplicado(tx, empresaId, documentoFiscal);

        await this.validateRefs(tx, empresaId, cab);

        const items = await this.normalizeItems(tx, dto.items);
        await this.assertRequisicionGastoValida(tx, empresaId, cab.requisicion_id || null, items);
        const totals = this.calculateTotals(items);

        const gasto = await tx.gasto_cab.create({
          data: {
            empresa_id: empresaId,
            sucursal_id: cab.sucursal_id ?? null,
            proveedor_id: cab.proveedor_id ?? null,
            usuario_id: userId ?? null,
            moneda_id: cab.moneda_id ?? null,
            requisicion_id: cab.requisicion_id ?? null,
            ruc: cab.ruc ?? null,
            timbrado: documentoFiscal.timbrado,
            establecimiento: documentoFiscal.establecimiento,
            punto_expedicion: documentoFiscal.punto_expedicion,
            numero_factura: documentoFiscal.numero_factura,
            fecha_emision: cab.fecha_emision ? new Date(cab.fecha_emision) : new Date(),
            fecha_vencimiento_timbrado: cab.fecha_vencimiento_timbrado
              ? new Date(cab.fecha_vencimiento_timbrado)
              : null,
            subtotal: totals.subtotal,
            total_exento: totals.total_exento,
            total_gravado_5: totals.total_gravado_5,
            total_gravado_10: totals.total_gravado_10,
            total_iva_5: totals.total_iva_5,
            total_iva_10: totals.total_iva_10,
            iva_total: totals.iva_total,
            total: totals.total,
            cotizacion: cab.cotizacion ?? 1,
            forma_pago: cab.forma_pago ?? 'CONTADO',
            deducible: documentoFiscal.deducible,
            // Por defecto preimpreso: es lo que se carga a mano por esta pantalla.
            es_preimpreso: cab.es_preimpreso ?? true,
            descripcion: cab.descripcion ?? null,
            estado: dejarPendiente ? 'registrado' : 'pagada',
            rendicion_viatico_id: cab.rendicion_viatico_id ?? null,
            embarque_id: cab.embarque_id ?? null,
            medio_pago_id: cab.medio_pago_id ?? null,
            cuenta_tesoreria_id: cab.cuenta_tesoreria_id ?? null,
          },
        });

        // Gasto imputado a una importación: además del documento fiscal (que es
        // lo que lo lleva al Libro IVA), genera el componente que alimenta el
        // prorrateo del embarque. El componente queda marcado con gasto_id para
        // que el asiento de cierre del despacho no lo cuente dos veces.
        if (cab.embarque_id) {
          await this.crearComponenteImportacion(tx, {
            gastoId: gasto.id,
            empresaId,
            embarqueId: cab.embarque_id,
            conceptoCostoId: cab.concepto_costo_id,
            convertirComponenteId: cab.convertir_componente_id,
            proveedorId: cab.proveedor_id ?? null,
            monedaId: cab.moneda_id ?? null,
            numeroFactura: documentoFiscal.numero_factura,
            descripcion: cab.descripcion ?? null,
            // El costo que se prorratea es el neto: el IVA es crédito fiscal,
            // no forma parte del costo de la mercadería.
            importe: Number(totals.subtotal ?? 0),
            cotizacion: Number(cab.cotizacion ?? 1),
            fechaDevengo: cab.fecha_emision ? new Date(cab.fecha_emision) : new Date(),
            usuarioId: userId,
          });
        }

        await tx.gasto_det.createMany({
          data: items.map((it) => ({
            gasto_id: gasto.id,
            producto_id: it.producto_id,
            tipo_gasto_id: it.tipo_gasto_id,
            requisicion_det_id: it.requisicion_det_id,
            descripcion: it.descripcion,
            cantidad: it.cantidad,
            precio_unitario: it.precio_unitario,
            porcentaje_iva: it.porcentaje_iva,
            subtotal: it.subtotal,
            monto_iva: it.monto_iva,
            total: it.total,
          })),
        });

        const fechaEmision = cab.fecha_emision ? new Date(cab.fecha_emision) : new Date();
        // Anticipo de viáticos (con o sin factura): la deuda al proveedor no existe,
        // el gasto se paga desde el anticipo del empleado. Se salta la CxP.
        if (generarCxpAutomatico && !esAnticipoViatico && cab.proveedor_id) {
          await this.createCuentaPorPagarDesdeGasto(tx, {
            empresaId,
            gastoId: gasto.id,
            proveedorId: cab.proveedor_id,
            monedaId: cab.moneda_id ?? null,
            fechaEmision,
            montoTotal: totals.total,
            descripcion: cab.descripcion ?? null,
            esCredito: dejarPendiente,
          });
        }

        await this.aplicarEjecucionRequisicion(tx, {
          empresaId,
          requisicionId: cab.requisicion_id || null,
          items,
          fechaOperacion: fechaEmision,
        });

        return gasto.id;
      });
    } catch (error) {
      this.rethrowIfMissingGastosTables(error, 'crear gasto');
      this.rethrowIfGastoDuplicado(error, dto.cabecera);
      throw error;
    }

    // Integración contable (fire-and-forget, no bloquea la respuesta)
    this.contabilidadIntegracion.integrarGasto(gastoId).catch((err) => {
      this.logContabilidadIntegracionIssue('contabilizando', gastoId, err);
    });

    // Integración con Tesorería (fire-and-forget, no bloquea la respuesta)
    this.gastosTesoreria.integrarGasto(gastoId).catch((err) => {
      this.logger.error(`Error registrando gasto ${gastoId} en Tesorería: ${err.message}`);
    });

    return this.findOne(gastoId, empresaId);
  }

  async findAll(
    empresaId: string,
    filters: {
      page?: string;
      limit?: string;
      fecha_desde?: string;
      fecha_hasta?: string;
      proveedor_id?: string;
      tipo_gasto_id?: string;
      deducible?: string;
      estado?: string;
      search?: string;
    },
  ) {
    const page = Math.max(Number(filters.page) || 1, 1);
    const limit = Math.min(Math.max(Number(filters.limit) || 20, 1), 100);
    const skip = (page - 1) * limit;

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

    if (filters.fecha_desde || filters.fecha_hasta) {
      where.fecha_emision = {};
      if (filters.fecha_desde) {
        const desde = this.parseDateFilter(filters.fecha_desde, 'start');
        if (desde) where.fecha_emision.gte = desde;
      }
      if (filters.fecha_hasta) {
        const hasta = this.parseDateFilter(filters.fecha_hasta, 'end');
        if (hasta) where.fecha_emision.lte = hasta;
      }
    }
    if (filters.proveedor_id) where.proveedor_id = filters.proveedor_id;
    if (filters.estado) where.estado = filters.estado;
    if (typeof filters.deducible === 'string') {
      where.deducible = filters.deducible === 'true';
    }
    if (filters.tipo_gasto_id) {
      where.gasto_det = { some: { tipo_gasto_id: filters.tipo_gasto_id } };
    }
    if (filters.search?.trim()) {
      const search = filters.search.trim();
      where.OR = [
        { numero_factura: { contains: search, mode: 'insensitive' } },
        { ruc: { contains: search, mode: 'insensitive' } },
        { descripcion: { contains: search, mode: 'insensitive' } },
        { gasto_det: { some: { descripcion: { contains: search, mode: 'insensitive' } } } },
      ];
    }

    const includeListado: Prisma.gasto_cabInclude = {
      proveedores: {
        select: {
          id: true,
          personas: {
            select: {
              razon_social: true,
              ruc: true,
              dv: true,
            },
          },
        },
      },
      moneda: {
        select: {
          id: true,
          codigo: true,
          descripcion: true,
          simbolo: true,
        },
      },
      gasto_det: {
        select: {
          id: true,
          producto_id: true,
          tipo_gasto_id: true,
          descripcion: true,
          cantidad: true,
          precio_unitario: true,
          porcentaje_iva: true,
          subtotal: true,
          monto_iva: true,
          total: true,
          tipo_gasto: {
            select: {
              id: true,
              descripcion: true,
              porcentaje_iva: true,
              deducible: true,
            },
          },
          productos: {
            select: {
              id: true,
              descripcion: true,
              cod_producto: true,
              porcentaje_iva: true,
            },
          },
        },
      },
    };

    let data: any[] = [];
    let total = 0;
    try {
      [data, total] = await this.prisma.$transaction([
        this.prisma.gasto_cab.findMany({
          where,
          skip,
          take: limit,
          orderBy: [{ fecha_emision: 'desc' }, { created_at: 'desc' }],
          include: includeListado,
        }),
        this.prisma.gasto_cab.count({ where }),
      ]);
    } catch (error) {
      if (this.isMissingTableError(error)) {
        this.logger.warn(
          `Tablas del módulo gastos no inicializadas. Retornando listado vacío para empresa ${empresaId}.`,
        );
        return {
          page,
          limit,
          total: 0,
          data: [],
          stats: { total: 0, monto: 0, iva_credito: 0, deducibles: 0, no_deducibles: 0, anulados: 0 },
        };
      }
      throw error;
    }

    // KPIs sobre TODO el filtro (no la página). Las condiciones extra van con AND
    // para respetar cualquier filtro ya presente en `where` (ej. deducible/estado).
    const [montoAgg, ivaCredAgg, deducibles, noDeducibles, anulados] = await Promise.all([
      this.prisma.gasto_cab.aggregate({ where, _sum: { total: true } }),
      this.prisma.gasto_cab.aggregate({ where: { AND: [where, { deducible: true }] }, _sum: { iva_total: true } }),
      this.prisma.gasto_cab.count({ where: { AND: [where, { deducible: true }] } }),
      this.prisma.gasto_cab.count({ where: { AND: [where, { deducible: false }] } }),
      this.prisma.gasto_cab.count({ where: { AND: [where, { anulado: true }] } }),
    ]);
    const stats = {
      total,
      monto: this.toNumber(montoAgg._sum.total),
      iva_credito: this.toNumber(ivaCredAgg._sum.iva_total),
      deducibles,
      no_deducibles: noDeducibles,
      anulados,
    };

    // Estado del documento contable vinculado (para habilitar "Reintentar
    // contabilidad" en la UI cuando no esté CONFIRMADO, ej. tras revertir un
    // asiento por un mapeo de cuentas mal configurado).
    const docsPorGasto = await this.prisma.cont_documentos.findMany({
      where: {
        empresa_id: empresaId,
        origen_tipo: 'gasto_cab',
        origen_id: { in: data.map((g) => g.id) },
      },
      select: { origen_id: true, estado: true },
    });
    const estadoContablePorGasto = new Map(docsPorGasto.map((d) => [d.origen_id, d.estado]));

    // Estado del movimiento/cheque de Tesorería vinculado (para habilitar
    // "Reintentar tesorería" cuando falte, ej. tras configurar la regla PAGO_GASTO).
    const gastoIds = data.map((g) => g.id);
    const [movimientosPorGasto, chequesPorGasto] = await Promise.all([
      this.prisma.tes_movimientos
        .findMany({
          where: { empresa_id: empresaId, origen_tipo: 'gasto_cab', origen_id: { in: gastoIds } },
          select: { origen_id: true, estado: true },
        })
        .catch(() => []),
      this.prisma.tes_cheques
        .findMany({
          where: { empresa_id: empresaId, origen_tipo: 'gasto_cab', origen_id: { in: gastoIds } },
          select: { origen_id: true, estado: true },
        })
        .catch(() => []),
    ]);
    const estadoTesoreriaPorGasto = new Map<string, string>();
    for (const m of movimientosPorGasto) estadoTesoreriaPorGasto.set(m.origen_id as string, m.estado);
    for (const c of chequesPorGasto) {
      if (!estadoTesoreriaPorGasto.has(c.origen_id as string)) {
        estadoTesoreriaPorGasto.set(c.origen_id as string, c.estado);
      }
    }

    const dataConContabilidad = data.map((g) => ({
      ...g,
      contabilidad_estado: estadoContablePorGasto.get(g.id) ?? null,
      tesoreria_estado:
        estadoTesoreriaPorGasto.get(g.id) ??
        (this.necesitaTesoreria(g) ? 'PENDIENTE' : null),
    }));

    return { page, limit, total, data: dataConContabilidad, stats };
  }

  // El gasto necesita un movimiento/cheque de Tesorería cuando pagó de una vez
  // (no crédito, no diferido a OP) y no es un anticipo de viático (ese ya salió
  // del anticipo del empleado). Si `estado` es 'pagada' y no hay ningún registro
  // en tes_movimientos/tes_cheques, el intento falló silenciosamente (falta la
  // regla PAGO_GASTO) — se marca 'PENDIENTE' para habilitar "Reintentar tesorería".
  private necesitaTesoreria(gasto: { estado?: string | null; forma_pago?: string | null }): boolean {
    return gasto.estado === 'pagada' && String(gasto.forma_pago ?? 'CONTADO').toUpperCase() !== 'ANTICIPO';
  }

  async findOne(id: string, empresaId: string) {
    let gasto: any;
    try {
      gasto = await this.prisma.gasto_cab.findFirst({
        where: { id, empresa_id: empresaId },
        include: {
          proveedores: { include: { personas: true } },
          empresas_sucursales: true,
          moneda: true,
          medio_pago: { select: { id: true, descripcion: true } },
          cuenta_tesoreria: { select: { id: true, nombre: true } },
          usuario: { select: { id: true, nombres: true, apellidos: true } },
          gasto_det: {
            include: {
              tipo_gasto: true,
              productos: { select: { id: true, descripcion: true, cod_producto: true } },
            },
          },
          recibos_asociados: {
            include: {
              recibo: { select: { numero_recibo: true, estado: true, fecha_emision: true } },
            },
          },
        },
      });
    } catch (error) {
      this.rethrowIfMissingGastosTables(error, 'consultar gasto');
      throw error;
    }
    if (!gasto) throw new NotFoundException(`Gasto ${id} no encontrado`);

    const doc = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: 'gasto_cab', origen_id: id },
      select: { estado: true },
    });

    const [movimiento, cheque] = await Promise.all([
      this.prisma.tes_movimientos
        .findFirst({ where: { empresa_id: empresaId, origen_tipo: 'gasto_cab', origen_id: id }, select: { estado: true } })
        .catch(() => null),
      this.prisma.tes_cheques
        .findFirst({ where: { empresa_id: empresaId, origen_tipo: 'gasto_cab', origen_id: id }, select: { estado: true } })
        .catch(() => null),
    ]);

    return {
      ...gasto,
      contabilidad_estado: doc?.estado ?? null,
      tesoreria_estado:
        movimiento?.estado ?? cheque?.estado ?? (this.necesitaTesoreria(gasto) ? 'PENDIENTE' : null),
    };
  }

  async update(id: string, dto: UpdateGastoDto, empresaId: string, userId?: string) {
    const existing = await this.prisma.gasto_cab.findFirst({
      where: { id, empresa_id: empresaId },
      include: {
        gasto_det: {
          select: {
            requisicion_det_id: true,
            cantidad: true,
            subtotal: true,
          },
        },
      },
    });
    if (!existing) throw new NotFoundException(`Gasto ${id} no encontrado`);
    if (existing.anulado) throw new BadRequestException('No se puede editar un gasto anulado');

    // Si el egreso de tesorería ya está conciliado contra un extracto, editar el gasto
    // obligaría a rehacerlo y la línea del extracto quedaría apuntando a un movimiento
    // anulado. Se corta antes de escribir nada: es preferible pedir que deshagan la
    // conciliación a romperla por detrás.
    if (await this.gastosTesoreria.tieneMovimientoConciliado(id, empresaId)) {
      throw new BadRequestException(
        'Este gasto tiene un movimiento de Tesorería ya conciliado con el extracto bancario. ' +
          'Deshacé la conciliación de ese movimiento antes de editar el gasto.',
      );
    }

    const cabUpdate = dto.cabecera ?? {};
    const hasOwn = (field: string) => Object.prototype.hasOwnProperty.call(cabUpdate, field);
    const isFinancialEdit =
      Boolean(dto.items && dto.items.length > 0) ||
      hasOwn('proveedor_id') ||
      hasOwn('forma_pago') ||
      hasOwn('requisicion_id');

    if (isFinancialEdit) {
      await this.assertNoCxpOrPagosForFinancialEdit(id, empresaId);
    }

    // En el MVP: reemplazamos cabecera + ítems (si viene items)
    // Nota: la edición invalida el asiento contable existente — por simplicidad,
    // revertimos y re-integramos.
    try {
      await this.prisma.$transaction(async (tx) => {
        const configCompras = await this.getOrCreateConfigCompras(tx, empresaId);
        const modoFlujo = this.normalizeModoFlujoCompra(configCompras.modo_flujo_compra);
        const cab = dto.cabecera ?? {};
        const requisicionId = cab.requisicion_id !== undefined ? cab.requisicion_id || null : existing.requisicion_id;
        if (modoFlujo === 'requisicion_obligatoria' && !requisicionId) {
          throw new BadRequestException(
            'La configuración exige requisición obligatoria. Debés informar cabecera.requisicion_id',
          );
        }
        if (cab.requisicion_id !== undefined && (!dto.items || dto.items.length === 0)) {
          throw new BadRequestException('Para cambiar requisicion_id también debés enviar items');
        }
        const patchCab: Prisma.gasto_cabUpdateInput = {
          updated_at: new Date(),
        };
        const documentoTouched = ['timbrado', 'establecimiento', 'punto_expedicion', 'numero_factura'].some(
          (key) => Object.prototype.hasOwnProperty.call(cab, key),
        );
        let documentoFiscal: NormalizedDocumentoFiscal | null = null;
        if (documentoTouched) {
          const proveedorIdParaDoc =
            cab.proveedor_id !== undefined ? cab.proveedor_id : existing.proveedor_id;
          const esExteriorGastoUpdate = await this.isProveedorExterior(tx, empresaId, proveedorIdParaDoc);
          documentoFiscal = this.normalizeDocumentoFiscal(
            {
              timbrado: cab.timbrado !== undefined ? cab.timbrado : existing.timbrado,
              establecimiento: cab.establecimiento !== undefined ? cab.establecimiento : existing.establecimiento,
              punto_expedicion: cab.punto_expedicion !== undefined ? cab.punto_expedicion : existing.punto_expedicion,
              numero_factura: cab.numero_factura !== undefined ? cab.numero_factura : existing.numero_factura,
            },
            { esExterior: esExteriorGastoUpdate },
          );
          await this.assertComprobanteNoDuplicado(tx, empresaId, documentoFiscal, { excluirGastoId: id });
        }

        if (cab.proveedor_id !== undefined || cab.ruc !== undefined) {
          const proveedorIdToValidate =
            cab.proveedor_id !== undefined ? cab.proveedor_id : existing.proveedor_id;
          const rucToValidate = cab.ruc !== undefined ? cab.ruc : existing.ruc;
          await this.validateProveedorRefAndRuc(tx, empresaId, proveedorIdToValidate, rucToValidate);
        }

        if (cab.sucursal_id !== undefined) patchCab.empresas_sucursales = cab.sucursal_id ? { connect: { id: cab.sucursal_id } } : { disconnect: true };
        if (cab.proveedor_id !== undefined) patchCab.proveedores = cab.proveedor_id ? { connect: { id: cab.proveedor_id } } : { disconnect: true };
        if (cab.moneda_id !== undefined) patchCab.moneda = cab.moneda_id ? { connect: { id: cab.moneda_id } } : { disconnect: true };
        if (cab.requisicion_id !== undefined) {
          patchCab.requisicion_gasto_cab = cab.requisicion_id ? { connect: { id: cab.requisicion_id } } : { disconnect: true };
        }
        if (cab.ruc !== undefined) patchCab.ruc = cab.ruc;
        if (documentoTouched && documentoFiscal) {
          patchCab.timbrado = documentoFiscal.timbrado;
          patchCab.establecimiento = documentoFiscal.establecimiento;
          patchCab.punto_expedicion = documentoFiscal.punto_expedicion;
          patchCab.numero_factura = documentoFiscal.numero_factura;
          patchCab.deducible = documentoFiscal.deducible;
        }
        if (cab.fecha_emision !== undefined) patchCab.fecha_emision = cab.fecha_emision ? new Date(cab.fecha_emision) : null;
        if (cab.fecha_vencimiento_timbrado !== undefined) patchCab.fecha_vencimiento_timbrado = cab.fecha_vencimiento_timbrado ? new Date(cab.fecha_vencimiento_timbrado) : null;
        if (cab.forma_pago !== undefined) patchCab.forma_pago = cab.forma_pago;
        if (cab.medio_pago_id !== undefined) {
          patchCab.medio_pago = cab.medio_pago_id ? { connect: { id: cab.medio_pago_id } } : { disconnect: true };
        }
        if (cab.cuenta_tesoreria_id !== undefined) {
          patchCab.cuenta_tesoreria = cab.cuenta_tesoreria_id
            ? { connect: { id: cab.cuenta_tesoreria_id } }
            : { disconnect: true };
        }
        if (cab.cotizacion !== undefined) patchCab.cotizacion = cab.cotizacion;
        if (cab.descripcion !== undefined) patchCab.descripcion = cab.descripcion;

        if (dto.items && dto.items.length > 0) {
          await this.revertirEjecucionRequisicion(tx, {
            empresaId,
            requisicionId: existing.requisicion_id || null,
            items: existing.gasto_det.map((it) => ({
              requisicion_det_id: it.requisicion_det_id,
              cantidad: this.round(this.toNumber(it.cantidad), 4),
              subtotal: this.round(this.toNumber(it.subtotal), 2),
            })),
            fechaOperacion: existing.fecha_emision || new Date(),
          });

          const items = await this.normalizeItems(tx, dto.items);
          await this.assertRequisicionGastoValida(tx, empresaId, requisicionId, items);
          const totals = this.calculateTotals(items);

          patchCab.subtotal = totals.subtotal;
          patchCab.total_exento = totals.total_exento;
          patchCab.total_gravado_5 = totals.total_gravado_5;
          patchCab.total_gravado_10 = totals.total_gravado_10;
          patchCab.total_iva_5 = totals.total_iva_5;
          patchCab.total_iva_10 = totals.total_iva_10;
          patchCab.iva_total = totals.iva_total;
          patchCab.total = totals.total;

          if (!documentoTouched) {
            const hayFactura = !!(existing.numero_factura && String(existing.numero_factura).trim());
            patchCab.deducible = hayFactura;
          }

          await tx.gasto_det.deleteMany({ where: { gasto_id: id } });
          await tx.gasto_det.createMany({
            data: items.map((it) => ({
              gasto_id: id,
              producto_id: it.producto_id,
              tipo_gasto_id: it.tipo_gasto_id,
              requisicion_det_id: it.requisicion_det_id,
              descripcion: it.descripcion,
              cantidad: it.cantidad,
              precio_unitario: it.precio_unitario,
              porcentaje_iva: it.porcentaje_iva,
              subtotal: it.subtotal,
              monto_iva: it.monto_iva,
              total: it.total,
            })),
          });

          await this.aplicarEjecucionRequisicion(tx, {
            empresaId,
            requisicionId,
            items,
            fechaOperacion: cab.fecha_emision ? new Date(cab.fecha_emision) : existing.fecha_emision || new Date(),
          });
        }

        if (isFinancialEdit) {
          // Mismas reglas que al registrar: crédito o contado diferido a OP queda
          // pendiente; contado se da por pagado. Sin pagos reales (ya verificado),
          // la CxP y el estado del gasto se rehacen desde cero.
          const formaPago = String(cab.forma_pago ?? existing.forma_pago ?? 'CONTADO').toUpperCase();
          const esAnticipoViatico = formaPago === 'ANTICIPO' && !!existing.rendicion_viatico_id;
          const pendiente =
            formaPago === 'CREDITO' ||
            (Boolean(configCompras.incluir_contado_en_orden_pago) && !esAnticipoViatico);
          const medioPagoId = cab.medio_pago_id !== undefined ? cab.medio_pago_id : existing.medio_pago_id;
          if (!pendiente && !esAnticipoViatico && !medioPagoId) {
            throw new BadRequestException(
              'Medio de pago requerido: el gasto paga de contado y no está configurado para diferirse a Orden de Pago',
            );
          }
          patchCab.estado = pendiente ? 'registrado' : 'pagada';

          const proveedorId = cab.proveedor_id !== undefined ? cab.proveedor_id || null : existing.proveedor_id;
          await this.rearmarCuentaPorPagarDeGasto(tx, {
            empresaId,
            gastoId: id,
            proveedorId: esAnticipoViatico ? null : proveedorId,
            monedaId: cab.moneda_id !== undefined ? cab.moneda_id || null : existing.moneda_id,
            fechaEmision: cab.fecha_emision
              ? new Date(cab.fecha_emision)
              : existing.fecha_emision || new Date(),
            montoTotal: patchCab.total !== undefined ? Number(patchCab.total) : this.toNumber(existing.total),
            descripcion: cab.descripcion !== undefined ? cab.descripcion : existing.descripcion,
            pendiente,
            generarCxpAutomatico: Boolean(configCompras.generar_cxp_automatico ?? true),
          });
        }

        await tx.gasto_cab.update({ where: { id }, data: patchCab });
      });
    } catch (error) {
      this.rethrowIfMissingGastosTables(error, 'actualizar gasto');
      throw error;
    }

    // Revertir asiento anterior y volver a integrar
    this.contabilidadIntegracion
      .revertirDocumento('gasto_cab', id, userId ?? empresaId)
      .then(() => this.contabilidadIntegracion.integrarGasto(id))
      .catch((err) => this.logContabilidadIntegracionIssue('re-contabilizando', id, err));

    // Y lo mismo con Tesorería. Antes sólo se rehacía el asiento: el egreso quedaba con
    // el importe y la cuenta anteriores, así que contabilidad y tesorería se separaban
    // sin que nadie se enterara hasta la conciliación.
    this.gastosTesoreria
      .resincronizarGasto(id, empresaId)
      .catch((err: Error) =>
        this.logger.error(`Error re-sincronizando Tesorería del gasto ${id}: ${err.message}`, 'GastosService'),
      );

    return this.findOne(id, empresaId);
  }

  async anular(id: string, empresaId: string, userId: string, motivo?: string) {
    const gasto = await this.prisma.gasto_cab.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!gasto) throw new NotFoundException(`Gasto ${id} no encontrado`);
    if (gasto.anulado) throw new BadRequestException('Gasto ya anulado');

    try {
      await this.prisma.$transaction(async (tx) => {
        // Bloquear anulación si el gasto está asociado a un recibo multi activo.
        // Ver docs/plan-recibos-multi-gastos.md — Decisión #8.
        const recibosActivos = await tx.rec_multi_gastos.findMany({
          where: { gasto_id: id },
          include: { recibo: { select: { numero_recibo: true, estado: true } } },
        });
        const enRecibo = recibosActivos.find((r: any) => r.recibo?.estado !== 'anulado');
        if (enRecibo) {
          throw new BadRequestException(
            `El gasto está asociado al recibo multi N° ${(enRecibo as any).recibo?.numero_recibo ?? enRecibo.recibo_id}. Anule primero el recibo.`
          );
        }

        const detalles = await tx.gasto_det.findMany({
          where: { gasto_id: id },
          select: {
            requisicion_det_id: true,
            cantidad: true,
            subtotal: true,
          },
        });

        const cuentas = await tx.cuentas_pagar.findMany({
          where: {
            empresa_id: empresaId,
            origen_tipo: 'gasto',
            origen_id: id,
            activo: true,
          },
          select: { id: true },
        });

        if (cuentas.length > 0) {
          const cuentaIds = cuentas.map((c) => c.id);
          const pagosAplicados = await tx.pagos_proveedor.count({
            where: {
              empresa_id: empresaId,
              cuenta_pagar_id: { in: cuentaIds },
              anulado: false,
            },
          });

          if (pagosAplicados > 0) {
            throw new BadRequestException(
              'No se puede anular el gasto porque tiene pagos aplicados en cuentas por pagar',
            );
          }

          await tx.cuentas_pagar.updateMany({
            where: { id: { in: cuentaIds } },
            data: {
              estado: 'anulada',
              activo: false,
              saldo_pendiente: 0,
              updated_at: new Date(),
            },
          });
        }

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

        await tx.gasto_cab.update({
          where: { id },
          data: {
            anulado: true,
            estado: 'anulado',
            fecha_anulacion: new Date(),
            motivo_anulacion: motivo ?? null,
          },
        });

        // Si el gasto estaba imputado a una importación, su componente de costo
        // se desactiva y el prorrateo se recalcula: si no, el embarque quedaría
        // cargando un costo de un comprobante anulado.
        if (gasto.embarque_id) {
          const { count } = await tx.imp_componentes_costo.updateMany({
            where: { gasto_id: gasto.id, activo: true },
            data: { activo: false, updated_at: new Date() },
          });
          if (count > 0) {
            await this.costeo.recalcular(tx, empresaId, gasto.embarque_id);
          }
        }
      });
    } catch (error) {
      this.rethrowIfMissingGastosTables(error, 'anular gasto');
      throw error;
    }

    // Revertir asiento contable
    this.contabilidadIntegracion
      .revertirDocumento('gasto_cab', id, userId)
      .catch((err) => this.logger.error(`Error revirtiendo gasto ${id}: ${err.message}`));

    // Revertir movimiento de Tesorería (si el gasto había generado uno)
    this.gastosTesoreria
      .revertirGasto(id, empresaId, motivo)
      .catch((err) => this.logger.error(`Error revirtiendo Tesorería del gasto ${id}: ${err.message}`));

    return this.findOne(id, empresaId);
  }

  async reintentarContabilidad(id: string, empresaId: string) {
    const gasto = await this.prisma.gasto_cab.findFirst({
      where: { id, empresa_id: empresaId },
      select: { id: true, anulado: true },
    });
    if (!gasto) throw new NotFoundException(`Gasto ${id} no encontrado`);
    if (gasto.anulado) {
      throw new BadRequestException('No se puede contabilizar un gasto anulado');
    }

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

  async reintentarTesoreria(id: string, empresaId: string) {
    const gasto = await this.prisma.gasto_cab.findFirst({
      where: { id, empresa_id: empresaId },
      select: { id: true, anulado: true },
    });
    if (!gasto) throw new NotFoundException(`Gasto ${id} no encontrado`);
    if (gasto.anulado) {
      throw new BadRequestException('No se puede registrar en Tesorería un gasto anulado');
    }

    await this.gastosTesoreria.integrarGasto(id);
    return this.findOne(id, empresaId);
  }

  // ── Tipos de gasto ─────────────────────────────────────────────────────────

  async listarTiposGasto(empresaId: string) {
    try {
      return await this.prisma.tipo_gasto.findMany({
        where: {
          activo: true,
          OR: [{ empresa_id: null }, { empresa_id: empresaId }],
        },
        orderBy: { descripcion: 'asc' },
        include: {
          cuenta_contable: { select: { id: true, codigo: true, descripcion: true } },
        },
      });
    } catch (error) {
      if (this.isMissingTableError(error)) {
        this.logger.warn(
          `Tabla tipo_gasto no inicializada. Retornando catálogo vacío para empresa ${empresaId}.`,
        );
        return [];
      }
      throw error;
    }
  }

  async crearTipoGasto(dto: CreateTipoGastoDto, empresaId: string) {
    try {
      const descripcion = String(dto?.descripcion || '').trim();
      if (!descripcion) {
        throw new BadRequestException('La descripción del tipo de gasto es obligatoria');
      }

      const aplicaIva = dto.aplica_iva ?? true;
      const porcentajeIva = aplicaIva ? Number(dto.porcentaje_iva ?? 10) : 0;
      if (![0, 5, 10].includes(porcentajeIva)) {
        throw new BadRequestException('El porcentaje de IVA debe ser 0, 5 o 10');
      }

      if (dto.cuenta_contable_id) {
        await this.assertCuentaContableEmpresa(dto.cuenta_contable_id, empresaId);
      }

      const created = await this.prisma.tipo_gasto.create({
        data: {
          empresa_id: empresaId,
          descripcion,
          aplica_iva: Boolean(aplicaIva),
          porcentaje_iva: porcentajeIva,
          deducible: dto.deducible ?? true,
          cuenta_contable_id: dto.cuenta_contable_id ?? null,
          activo: true,
        },
        include: {
          cuenta_contable: { select: { id: true, codigo: true, descripcion: true } },
        },
      });

      return created;
    } catch (error) {
      this.rethrowIfMissingGastosTables(error, 'crear tipo de gasto');
      throw error;
    }
  }

  async actualizarTipoGasto(id: string, dto: UpdateTipoGastoDto, empresaId: string) {
    try {
      const current = await this.prisma.tipo_gasto.findUnique({ where: { id } });
      if (!current || current.activo === false) {
        throw new NotFoundException(`Tipo de gasto ${id} no encontrado`);
      }
      if (!current.empresa_id || current.empresa_id !== empresaId) {
        throw new BadRequestException(
          'Solo se pueden editar tipos de gasto propios de la empresa',
        );
      }

      const nextAplicaIva =
        dto.aplica_iva !== undefined ? Boolean(dto.aplica_iva) : Boolean(current.aplica_iva ?? true);
      const nextPorcentajeIva =
        dto.porcentaje_iva !== undefined
          ? Number(dto.porcentaje_iva)
          : Number(current.porcentaje_iva ?? 10);
      const resolvedPorcentajeIva = nextAplicaIva ? nextPorcentajeIva : 0;
      if (![0, 5, 10].includes(resolvedPorcentajeIva)) {
        throw new BadRequestException('El porcentaje de IVA debe ser 0, 5 o 10');
      }

      if (dto.cuenta_contable_id) {
        await this.assertCuentaContableEmpresa(dto.cuenta_contable_id, empresaId);
      }

      const data: Prisma.tipo_gastoUncheckedUpdateInput = {
        updated_at: new Date(),
      };

      if (dto.descripcion !== undefined) {
        const descripcion = String(dto.descripcion || '').trim();
        if (!descripcion) {
          throw new BadRequestException('La descripción del tipo de gasto es obligatoria');
        }
        data.descripcion = descripcion;
      }
      if (dto.aplica_iva !== undefined) data.aplica_iva = nextAplicaIva;
      if (dto.porcentaje_iva !== undefined || dto.aplica_iva !== undefined) {
        data.porcentaje_iva = resolvedPorcentajeIva;
      }
      if (dto.deducible !== undefined) data.deducible = Boolean(dto.deducible);
      if (dto.cuenta_contable_id !== undefined) {
        data.cuenta_contable_id = dto.cuenta_contable_id || null;
      }

      const updated = await this.prisma.tipo_gasto.update({
        where: { id },
        data,
        include: {
          cuenta_contable: { select: { id: true, codigo: true, descripcion: true } },
        },
      });

      return updated;
    } catch (error) {
      this.rethrowIfMissingGastosTables(error, 'actualizar tipo de gasto');
      throw error;
    }
  }

  async eliminarTipoGasto(id: string, empresaId: string) {
    try {
      const current = await this.prisma.tipo_gasto.findUnique({ where: { id } });
      if (!current || current.activo === false) {
        throw new NotFoundException(`Tipo de gasto ${id} no encontrado`);
      }
      if (!current.empresa_id || current.empresa_id !== empresaId) {
        throw new BadRequestException(
          'Solo se pueden desactivar tipos de gasto propios de la empresa',
        );
      }

      const updated = await this.prisma.tipo_gasto.update({
        where: { id },
        data: {
          activo: false,
          updated_at: new Date(),
        },
      });

      return {
        message: `Tipo de gasto ${updated.descripcion} desactivado`,
        id: updated.id,
      };
    } catch (error) {
      this.rethrowIfMissingGastosTables(error, 'desactivar tipo de gasto');
      throw error;
    }
  }

  // ── Helpers ────────────────────────────────────────────────────────────────

  private validatePayload(dto: CreateGastoDto) {
    if (!dto?.cabecera) throw new BadRequestException('Cabecera requerida');
    if (!Array.isArray(dto.items) || dto.items.length === 0) {
      throw new BadRequestException('Al menos un ítem es requerido');
    }
    for (const it of dto.items) {
      const hasProducto = !!it.producto_id;
      const hasDescripcion = !!(it.descripcion && it.descripcion.trim());
      if (!hasProducto && !hasDescripcion) {
        throw new BadRequestException(
          'Cada ítem debe tener producto_id o descripción',
        );
      }
    }
  }

  private async assertCuentaContableEmpresa(cuentaId: string, empresaId: string) {
    const cuenta = await this.prisma.cont_plan_cuentas.findFirst({
      where: {
        id: cuentaId,
        empresa_id: empresaId,
      },
      select: { id: true },
    });
    if (!cuenta) {
      throw new BadRequestException(
        'La cuenta contable seleccionada no existe o no pertenece a la empresa',
      );
    }
  }

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

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

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

  private async assertRequisicionGastoValida(
    tx: Tx,
    empresaId: string,
    requisicionId: string | null,
    items: NormalizedItem[],
  ) {
    if (!requisicionId) {
      if (items.some((item) => Boolean(item.requisicion_det_id))) {
        throw new BadRequestException(
          'Si enviás requisicion_det_id en ítems, también debés informar cabecera.requisicion_id',
        );
      }
      return;
    }

    const requisicion = await tx.requisicion_cab.findFirst({
      where: { id: requisicionId, empresa_id: empresaId, activo: true },
      include: {
        requisicion_det: {
          select: {
            id: true,
            estado: true,
            cantidad_solicitada: true,
            cantidad_ejecutada: true,
            monto_ejecutado: true,
          },
        },
      },
    });
    if (!requisicion) {
      throw new BadRequestException('requisicion_id inválido para la empresa');
    }
    if (String(requisicion.origen_tipo) !== 'gasto') {
      throw new BadRequestException('La requisición indicada no corresponde al origen gasto');
    }
    if (!['aprobada', 'ejecutada_parcial'].includes(String(requisicion.estado))) {
      throw new BadRequestException('La requisición debe estar aprobada para ejecutar gastos');
    }

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

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

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

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

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

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

      const solicitada = this.toNumber(det.cantidad_solicitada);
      const ejecutadaActual = this.toNumber(det.cantidad_ejecutada);
      const ejecutadaNueva = this.round(ejecutadaActual + payload.cantidad, 4);
      if (ejecutadaNueva - solicitada > 0.0001) {
        throw new BadRequestException(
          `Detalle de requisición ${detId}: cantidad ejecutada supera lo solicitado`,
        );
      }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  /**
   * Crea el componente de costo del embarque a partir de un gasto vinculado.
   *
   * El gasto es el documento fiscal (RUC, timbrado, IVA) y por eso llega al
   * Libro IVA de compras por su propio camino. El componente es sólo la
   * imputación del costo al embarque, que dispara el recálculo del prorrateo.
   *
   * `es_directo` queda en false y el prorrateo por FOB, que es el criterio por
   * defecto de los costos compartidos: un flete o un despachante no se imputan
   * a un ítem puntual.
   */
  private async crearComponenteImportacion(
    tx: Prisma.TransactionClient,
    params: {
      gastoId: string;
      empresaId: string;
      embarqueId: string;
      conceptoCostoId?: string;
      convertirComponenteId?: string;
      proveedorId: string | null;
      monedaId: string | null;
      numeroFactura?: string | null;
      descripcion?: string | null;
      importe: number;
      cotizacion: number;
      fechaDevengo: Date;
      usuarioId?: string;
    },
  ) {
    const embarque = await tx.imp_embarques.findFirst({
      where: { id: params.embarqueId, empresa_id: params.empresaId },
      select: { id: true, estado: true },
    });
    if (!embarque) {
      throw new BadRequestException('El embarque indicado no existe');
    }
    if (['CERRADO', 'SELLADO'].includes(String(embarque.estado))) {
      throw new BadRequestException(
        `No se puede imputar un gasto a un embarque ${embarque.estado}.`,
      );
    }

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

      const importeConv = Math.max(Number(params.importe) || 0, 0);
      const cotizacionConv = Math.max(Number(params.cotizacion) || 1, 0.000001);
      await tx.imp_componentes_costo.update({
        where: { id: existente.id },
        data: {
          gasto_id: params.gastoId,
          proveedor_id: params.proveedorId,
          moneda_id: params.monedaId,
          numero_factura: params.numeroFactura?.slice(0, 100) ?? null,
          // El importe pasa a ser el neto del comprobante: es el dato fiscal,
          // más confiable que el que se había tipeado a mano.
          importe: importeConv,
          cotizacion: cotizacionConv,
          importe_gs: Math.round(importeConv * cotizacionConv),
          fecha_devengo: params.fechaDevengo,
          activo: true,
          updated_at: new Date(),
        },
      });
      await this.costeo.recalcular(tx, params.empresaId, params.embarqueId);
      return;
    }

    // Sin concepto explícito se cae a "Otros gastos operativos", que es el
    // genérico del catálogo del sistema para un costo que llega por factura de
    // proveedor. Lo ideal es que el usuario elija el concepto al vincular.
    let conceptoId = params.conceptoCostoId;
    if (!conceptoId) {
      const concepto = await tx.imp_conceptos_costo.findFirst({
        where: {
          OR: [{ empresa_id: params.empresaId }, { empresa_id: null, es_sistema: true }],
          codigo: 'OTROS_GASTOS',
        },
        select: { id: true },
      });
      if (!concepto) {
        throw new BadRequestException(
          'No hay un concepto de costo de importación configurado. Elegí uno al vincular el gasto.',
        );
      }
      conceptoId = concepto.id;
    }

    const importe = Math.max(Number(params.importe) || 0, 0);
    const cotizacion = Math.max(Number(params.cotizacion) || 1, 0.000001);

    await tx.imp_componentes_costo.create({
      data: {
        empresa_id: params.empresaId,
        embarque_id: params.embarqueId,
        concepto_id: conceptoId,
        moneda_id: params.monedaId,
        proveedor_id: params.proveedorId,
        gasto_id: params.gastoId,
        descripcion: params.descripcion?.slice(0, 200) ?? null,
        numero_factura: params.numeroFactura?.slice(0, 100) ?? null,
        importe,
        cotizacion,
        importe_gs: Math.round(importe * cotizacion),
        es_directo: false,
        criterio_prorrateo: 'FOB',
        fecha_devengo: params.fechaDevengo,
        estado: 'CONFIRMADO',
        es_posterior_cierre: String(embarque.estado) === 'DESPACHADO',
        usuario_id: params.usuarioId ?? null,
      },
    });

    await this.costeo.recalcular(tx, params.empresaId, params.embarqueId);
  }

  private async createCuentaPorPagarDesdeGasto(
    tx: Tx,
    params: {
      empresaId: string;
      gastoId: string;
      proveedorId: string;
      monedaId: string | null;
      fechaEmision: Date;
      montoTotal: number;
      descripcion?: string | null;
      esCredito: boolean;
    },
  ) {
    const montos = datosCxpGasto({ total: Number(params.montoTotal || 0), pendiente: params.esCredito });

    // FASE 2: congelar la cuenta pasivo al crear la CxP (solo crédito;
    // en contado no hay saldo pendiente ni OP posterior que la lea).
    let cuentaPasivoId: string | null = null;
    if (params.esCredito) {
      try {
        cuentaPasivoId = await this.contabilidadIntegracion.resolverCuentaPasivo(
          params.proveedorId,
          params.empresaId,
        );
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'unknown';
        this.logger.warn(
          `No se pudo resolver cuenta_pasivo_id para gasto ${params.gastoId} (${msg}) — CxP queda sin congelar`,
        );
      }
    }

    await tx.cuentas_pagar.create({
      data: {
        empresa_id: params.empresaId,
        compra_id: null,
        origen_tipo: 'gasto',
        origen_id: params.gastoId,
        proveedor_id: params.proveedorId,
        moneda_id: params.monedaId,
        numero_cuota: 1,
        total_cuotas: 1,
        fecha_emision: params.fechaEmision,
        fecha_vencimiento: params.fechaEmision,
        ...montos,
        observaciones: params.descripcion ?? null,
        activo: true,
        cuenta_pasivo_id: cuentaPasivoId,
      },
    });
  }

  /**
   * Bloquea la edición financiera solo si la CxP del gasto ya quedó atada a un
   * pago real. La CxP en sí la crea el gasto al registrarse y se rearma al editar.
   */
  private async assertNoCxpOrPagosForFinancialEdit(gastoId: string, empresaId: string) {
    const cuentas = await this.prisma.cuentas_pagar.findMany({
      where: { empresa_id: empresaId, origen_tipo: 'gasto', origen_id: gastoId, activo: true },
      select: { id: true },
    });
    if (cuentas.length === 0) return;

    const cuentaIds = cuentas.map((c) => c.id);
    const [pagos, opDets, recibos] = await Promise.all([
      this.prisma.pagos_proveedor.count({
        where: { empresa_id: empresaId, cuenta_pagar_id: { in: cuentaIds }, anulado: false },
      }),
      this.prisma.orden_pago_proveedor_det.findMany({
        where: {
          cuenta_pagar_id: { in: cuentaIds },
          orden_pago_proveedor_cab: { anulado: { not: true }, estado: { not: 'ANULADA' } },
        },
        select: { orden_pago_proveedor_cab: { select: { numero_orden_pago: true } } },
      }),
      this.prisma.rec_multi_gastos.findMany({
        where: { cuenta_pagar_id: { in: cuentaIds }, recibo: { estado: { not: 'anulado' } } },
        select: { recibo: { select: { numero_recibo: true } } },
      }),
    ]);

    const motivo = motivoBloqueoEdicionFinanciera({
      pagos,
      ordenesPago: [...new Set(opDets.map((d) => d.orden_pago_proveedor_cab.numero_orden_pago))],
      recibos: [...new Set(recibos.map((r) => r.recibo.numero_recibo))],
    });
    if (motivo) throw new BadRequestException(motivo);
  }

  /**
   * Deja la CxP del gasto igual al gasto editado (total, proveedor, forma de
   * pago). Se actualiza en el lugar para no romper referencias (costeo de
   * importación); si el gasto no tenía, se crea como al registrarlo.
   */
  private async rearmarCuentaPorPagarDeGasto(
    tx: Tx,
    params: {
      empresaId: string;
      gastoId: string;
      proveedorId: string | null;
      monedaId: string | null;
      fechaEmision: Date;
      montoTotal: number;
      descripcion?: string | null;
      pendiente: boolean;
      generarCxpAutomatico: boolean;
    },
  ) {
    const cuentas = await tx.cuentas_pagar.findMany({
      where: { empresa_id: params.empresaId, origen_tipo: 'gasto', origen_id: params.gastoId, activo: true },
      select: { id: true },
      orderBy: { created_at: 'asc' },
    });

    if (!params.proveedorId) {
      if (cuentas.length > 0) {
        await tx.cuentas_pagar.updateMany({
          where: { id: { in: cuentas.map((c) => c.id) } },
          data: { estado: 'anulada', activo: false, saldo_pendiente: 0, updated_at: new Date() },
        });
      }
      return;
    }

    if (cuentas.length === 0) {
      if (!params.generarCxpAutomatico) return;
      await this.createCuentaPorPagarDesdeGasto(tx, {
        empresaId: params.empresaId,
        gastoId: params.gastoId,
        proveedorId: params.proveedorId,
        monedaId: params.monedaId,
        fechaEmision: params.fechaEmision,
        montoTotal: params.montoTotal,
        descripcion: params.descripcion ?? null,
        esCredito: params.pendiente,
      });
      return;
    }

    let cuentaPasivoId: string | null = null;
    if (params.pendiente) {
      try {
        cuentaPasivoId = await this.contabilidadIntegracion.resolverCuentaPasivo(params.proveedorId, params.empresaId);
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'unknown';
        this.logger.warn(`No se pudo resolver cuenta_pasivo_id para gasto ${params.gastoId} (${msg}) — CxP queda sin congelar`);
      }
    }

    const [principal, ...sobrantes] = cuentas;
    await tx.cuentas_pagar.update({
      where: { id: principal.id },
      data: {
        ...datosCxpGasto({ total: params.montoTotal, pendiente: params.pendiente }),
        proveedor_id: params.proveedorId,
        moneda_id: params.monedaId,
        numero_cuota: 1,
        total_cuotas: 1,
        fecha_emision: params.fechaEmision,
        fecha_vencimiento: params.fechaEmision,
        observaciones: params.descripcion ?? null,
        cuenta_pasivo_id: cuentaPasivoId,
        updated_at: new Date(),
      },
    });
    if (sobrantes.length > 0) {
      await tx.cuentas_pagar.updateMany({
        where: { id: { in: sobrantes.map((c) => c.id) } },
        data: { estado: 'anulada', activo: false, saldo_pendiente: 0, updated_at: new Date() },
      });
    }
  }

  private async validateRefs(
    tx: Tx,
    empresaId: string,
    cab: CreateGastoDto['cabecera'],
  ) {
    await this.validateProveedorRefAndRuc(tx, empresaId, cab.proveedor_id, cab.ruc);

    if (cab.sucursal_id) {
      const suc = await tx.empresas_sucursales.findFirst({
        where: { id: cab.sucursal_id, empresa_id: empresaId },
        select: { id: true },
      });
      if (!suc) throw new NotFoundException(`Sucursal ${cab.sucursal_id} no existe`);
    }
    if (cab.moneda_id) {
      const mon = await tx.moneda.findUnique({
        where: { id: cab.moneda_id },
        select: { id: true },
      });
      if (!mon) throw new NotFoundException(`Moneda ${cab.moneda_id} no existe`);
    }
  }

  private async validateProveedorRefAndRuc(
    tx: Tx,
    empresaId: string,
    proveedorId?: string | null,
    rucCabecera?: string | null,
  ) {
    if (!proveedorId) return;

    const prov = await tx.proveedores.findFirst({
      where: { id: proveedorId, empresa_id: empresaId },
      select: {
        id: true,
        personas: {
          select: {
            ruc: true,
            dv: true,
          },
        },
      },
    });
    if (!prov) throw new NotFoundException(`Proveedor ${proveedorId} no existe`);

    const rucIngresado = String(rucCabecera ?? '').trim();
    if (!rucIngresado) return;

    const proveedorRuc = this.composeProveedorRuc(prov.personas?.ruc, prov.personas?.dv);
    if (!proveedorRuc) return;

    if (this.normalizeRuc(rucIngresado) !== this.normalizeRuc(proveedorRuc)) {
      throw new BadRequestException(
        'El RUC de la cabecera no coincide con el RUC del proveedor seleccionado',
      );
    }
  }

  private async normalizeItems(
    tx: Tx,
    items: GastoItemDto[],
  ): Promise<NormalizedItem[]> {
    const productosCache = new Map<
      string,
      { descripcion: string; porcentaje_iva: number | null }
    >();

    const normalized: NormalizedItem[] = [];
    for (const item of items) {
      let descripcion = (item.descripcion ?? '').trim();
      let porcentaje_iva = item.porcentaje_iva;

      if (item.producto_id) {
        let prod = productosCache.get(item.producto_id);
        if (!prod) {
          const found = await tx.productos.findUnique({
            where: { id: item.producto_id },
            select: { descripcion: true, porcentaje_iva: true },
          });
          if (!found) throw new NotFoundException(`Producto ${item.producto_id} no existe`);
          prod = {
            descripcion: found.descripcion,
            porcentaje_iva: found.porcentaje_iva ?? null,
          };
          productosCache.set(item.producto_id, prod);
        }
        if (!descripcion) descripcion = prod.descripcion;
        if (porcentaje_iva === undefined && prod.porcentaje_iva !== null) {
          porcentaje_iva = prod.porcentaje_iva;
        }
      }

      if (!descripcion) {
        throw new BadRequestException('Cada ítem requiere descripción');
      }

      const cantidad = Number(item.cantidad || 0);
      const precio = Number(item.precio_unitario || 0);
      const pctIva = Number(porcentaje_iva ?? 10);

      if (![0, 5, 10].includes(pctIva)) {
        throw new BadRequestException(
          `Porcentaje de IVA inválido (${pctIva}). Debe ser 0, 5 o 10.`,
        );
      }

      const subtotal = this.round(cantidad * precio, 2);
      const monto_iva = this.computeIva(subtotal, pctIva);

      normalized.push({
        requisicion_det_id: item.requisicion_det_id ?? null,
        producto_id: item.producto_id ?? null,
        tipo_gasto_id: item.tipo_gasto_id ?? null,
        descripcion,
        cantidad,
        precio_unitario: precio,
        porcentaje_iva: pctIva,
        subtotal,
        monto_iva,
        total: subtotal,
      });
    }

    return normalized;
  }

  private calculateTotals(items: NormalizedItem[]): Totals {
    let total_exento = 0;
    let total_gravado_5 = 0;
    let total_gravado_10 = 0;
    let total_iva_5 = 0;
    let total_iva_10 = 0;
    let subtotal = 0;

    for (const it of items) {
      subtotal += it.subtotal;
      if (it.porcentaje_iva === 0) {
        total_exento += it.subtotal;
      } else if (it.porcentaje_iva === 5) {
        total_gravado_5 += it.subtotal;
        total_iva_5 += it.monto_iva;
      } else {
        total_gravado_10 += it.subtotal;
        total_iva_10 += it.monto_iva;
      }
    }

    const iva_total = total_iva_5 + total_iva_10;

    return {
      total_exento: this.round(total_exento, 2),
      total_gravado_5: this.round(total_gravado_5, 2),
      total_gravado_10: this.round(total_gravado_10, 2),
      total_iva_5: this.round(total_iva_5, 2),
      total_iva_10: this.round(total_iva_10, 2),
      iva_total: this.round(iva_total, 2),
      subtotal: this.round(subtotal, 2),
      total: this.round(subtotal, 2),
    };
  }

  private computeIva(subtotal: number, porcentajeIva: number): number {
    if (porcentajeIva <= 0) return 0;
    const divisor = 1 + porcentajeIva / 100;
    const base = subtotal / divisor;
    return this.round(subtotal - base, 2);
  }

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

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

  private parseDateFilter(rawDate: string, boundary: 'start' | 'end'): Date | null {
    const parsed = new Date(rawDate);
    if (Number.isNaN(parsed.getTime())) return null;

    if (/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) {
      if (boundary === 'start') parsed.setHours(0, 0, 0, 0);
      else parsed.setHours(23, 59, 59, 999);
    }

    return parsed;
  }

  private normalizeRuc(value?: string | null) {
    return String(value ?? '')
      .toUpperCase()
      .replace(/[^0-9A-Z]/g, '');
  }

  private composeProveedorRuc(ruc?: string | null, dv?: string | null) {
    const cleanRuc = String(ruc ?? '').trim();
    const cleanDv = String(dv ?? '').trim();
    if (!cleanRuc) return '';
    if (!cleanDv || cleanRuc.includes('-')) return cleanRuc;
    return `${cleanRuc}-${cleanDv}`;
  }

  private async isProveedorExterior(
    tx: Tx,
    empresaId: string,
    proveedorId?: string | null,
  ): Promise<boolean> {
    if (!proveedorId) return false;
    const prov = await tx.proveedores.findFirst({
      where: { id: proveedorId, empresa_id: empresaId },
      select: { tipo_entidad: true, es_extranjero: true },
    });
    return (prov as any)?.tipo_entidad === 'PROVEEDOR_EXTERIOR' || prov?.es_extranjero === true;
  }

  /**
   * Impide guardar un comprobante ya registrado (misma factura cargada dos veces).
   * Coincidencia por timbrado + establecimiento + punto + número, tanto en gastos
   * como en facturas de compra. No aplica sin número de factura (gasto no deducible).
   */
  private async assertComprobanteNoDuplicado(
    tx: Prisma.TransactionClient,
    empresaId: string,
    doc: NormalizedDocumentoFiscal,
    opts: { excluirGastoId?: string } = {},
  ) {
    const numero_factura = (doc.numero_factura ?? '').trim();
    if (!numero_factura) return;
    const est = (doc.establecimiento ?? '').trim() || undefined;
    const punto = (doc.punto_expedicion ?? '').trim() || undefined;
    const timbrado = (doc.timbrado ?? '').trim() || undefined;

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

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

  private normalizeDocumentoFiscal(
    input: {
      timbrado?: string | null;
      establecimiento?: string | null;
      punto_expedicion?: string | null;
      numero_factura?: string | null;
    },
    opts: { esExterior?: boolean } = {},
  ): NormalizedDocumentoFiscal {
    const normalize = (value?: string | null) => String(value ?? '').trim();

    const raw = {
      timbrado: normalize(input.timbrado),
      establecimiento: normalize(input.establecimiento),
      punto_expedicion: normalize(input.punto_expedicion),
      numero_factura: normalize(input.numero_factura),
    };

    const hasAny = Object.values(raw).some((value) => value.length > 0);
    if (!hasAny) {
      return {
        timbrado: null,
        establecimiento: null,
        punto_expedicion: null,
        numero_factura: null,
        deducible: false,
      };
    }

    // FASE 3 — Proveedor del exterior: se acepta la referencia fiscal tal cual (invoice
    // number, sin timbrado paraguayo). No es deducible para IVA local (se maneja vía
    // aranceles de importación).
    if (opts.esExterior) {
      return {
        timbrado: raw.timbrado || null,
        establecimiento: raw.establecimiento || null,
        punto_expedicion: raw.punto_expedicion || null,
        numero_factura: raw.numero_factura || null,
        deducible: false,
      };
    }

    const defs = [
      { key: 'timbrado', label: 'timbrado', length: 8 },
      { key: 'establecimiento', label: 'establecimiento', length: 3 },
      { key: 'punto_expedicion', label: 'punto_expedicion', length: 3 },
      { key: 'numero_factura', label: 'numero_factura', length: 7 },
    ] as const;

    const normalized: Record<(typeof defs)[number]['key'], string> = {
      timbrado: '',
      establecimiento: '',
      punto_expedicion: '',
      numero_factura: '',
    };

    for (const def of defs) {
      const value = raw[def.key];
      if (!value) {
        throw new BadRequestException(
          `Para registrar gasto deducible debe informar ${def.label} completo`,
        );
      }

      if (!/^\d+$/.test(value)) {
        throw new BadRequestException(`${def.label} debe contener solo dígitos`);
      }

      if (value.length > def.length) {
        throw new BadRequestException(`${def.label} no puede superar ${def.length} dígitos`);
      }

      normalized[def.key] = value.padStart(def.length, '0');
    }

    return {
      timbrado: normalized.timbrado,
      establecimiento: normalized.establecimiento,
      punto_expedicion: normalized.punto_expedicion,
      numero_factura: normalized.numero_factura,
      deducible: true,
    };
  }

  private isMissingTableError(error: unknown) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      return error.code === 'P2021';
    }
    const message = String((error as any)?.message || '');
    return message.includes('does not exist in the current database');
  }

  private rethrowIfMissingGastosTables(error: unknown, operation: string): void {
    if (!this.isMissingTableError(error)) return;

    this.logger.warn(`Módulo gastos no inicializado al intentar ${operation}.`);
    throw new ServiceUnavailableException(
      'El módulo de gastos no está inicializado en esta base de datos. Ejecutá las migraciones pendientes y reiniciá el backend.',
    );
  }

  /**
   * Convierte el P2002 de Prisma sobre la unique compuesta del documento
   * fiscal en un 400 con mensaje entendible por el operador. La constraint es
   * `(empresa_id, timbrado, establecimiento, punto_expedicion, numero_factura)`
   * y evita cargar dos veces la misma factura del proveedor.
   */
  private rethrowIfGastoDuplicado(
    error: unknown,
    cab: CreateGastoDto['cabecera'],
  ): void {
    if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return;
    if (error.code !== 'P2002') return;
    const target = (error.meta as any)?.target;
    const isDocumentoFiscal =
      Array.isArray(target) &&
      target.includes('timbrado') &&
      target.includes('numero_factura');
    if (!isDocumentoFiscal) return;

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

  private isCuentaMapeoPendienteError(error: unknown): boolean {
    const message = String((error as any)?.message || '');
    return message.includes('No hay cuenta mapeada para el concepto');
  }

  private logContabilidadIntegracionIssue(
    action: string,
    gastoId: string,
    error: unknown,
  ): void {
    const message = String((error as any)?.message || 'Error desconocido');
    if (this.isCuentaMapeoPendienteError(error)) {
      this.logger.warn(
        `Integración contable pendiente al ${action} gasto ${gastoId}: ${message}`,
      );
      return;
    }
    this.logger.error(`Error ${action} gasto ${gastoId}: ${message}`);
  }
}
