diff --git a/api-gateway/scripts/importar-cobranza-maestro.js b/api-gateway/scripts/importar-cobranza-maestro.js index beecf99..6571666 100644 --- a/api-gateway/scripts/importar-cobranza-maestro.js +++ b/api-gateway/scripts/importar-cobranza-maestro.js @@ -17,8 +17,10 @@ * - Pagos programados: numPagos mensualidades desde MES INICIO * (montoPagado=0; el módulo deriva VENCIDO por fecha). * - Celda de grid con valor > 0 = abono real del mes → AplicacionPago - * (fechaPago = mes de la columna) sobre la mensualidad de ese mes; - * si no hay mensualidad programada en ese mes → Pago standalone pagado. + * (fechaPago = mes de la columna) sobre la mensualidad de ese mes; si no + * hay mensualidad programada en ese mes, o el abono excede lo que falta de + * la mensualidad, el excedente se registra como Pago standalone ya cubierto + * (así ningún abono supera el monto de su pago). */ import { PrismaClient } from '@prisma/client' @@ -57,6 +59,7 @@ function norm(h) { } const toStr = (v) => (v == null ? null : String(v).trim() || null) +const round2 = (x) => Math.round(x * 100) / 100 const toFloat = (v) => { if (v == null || v === '') return null if (typeof v === 'number') return v @@ -230,23 +233,27 @@ async function escribir(planesLeidos) { } plan.engancheTotal = pagosProg.reduce((a, p) => a + p.monto, 0) - // 2) abonos del grid sobre la mensualidad del mismo mes; sobrantes → standalone + // 2) abonos del grid sobre la mensualidad del mismo mes. Un abono nunca + // puede exceder el monto de su pago: lo que sobra (y los meses sin + // mensualidad programada) se registra como pago nuevo ya cubierto, así + // Σ montoPagado == dinero recibido y ningún pago queda sobrepagado. const aplicaciones = [] // { idxPago, fechaPago, monto } const standalone = [] - for (const ab of abonosGrid) { + for (const ab of [...abonosGrid].sort((a, b) => a.fecha - b.fecha)) { // idx del arreglo == numPago (el inicial ocupa numPago 0 en el índice 0) const idx = pagosProg.findIndex((p) => p.numPago >= 1 && claveMes(p.fechaVencimiento) === claveMes(ab.fecha)) + let restante = ab.monto if (idx !== -1) { - aplicaciones.push({ idx, fechaPago: ab.fecha, monto: ab.monto }) - } else { - standalone.push({ fecha: ab.fecha, monto: ab.monto }) + const p = pagosProg[idx] + const falta = Math.max(0, round2(p.monto - p.montoPagado)) + const aplica = Math.min(restante, falta) + if (aplica > 0) { + aplicaciones.push({ idx, fechaPago: ab.fecha, monto: round2(aplica) }) + p.montoPagado = round2(p.montoPagado + aplica) + restante = round2(restante - aplica) + } } - } - - // aplicar abonos al montoPagado (sin pasar del monto programado) - for (const ap of aplicaciones) { - const p = pagosProg[ap.idx] - p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto) + if (restante > 0) standalone.push({ fecha: ab.fecha, monto: round2(restante) }) } // Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal diff --git a/api-gateway/src/config/campos-departamento.test.ts b/api-gateway/src/config/campos-departamento.test.ts new file mode 100644 index 0000000..e611760 --- /dev/null +++ b/api-gateway/src/config/campos-departamento.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { DEPARTAMENTOS, DEPARTAMENTOS_POR_ROL, departamentosDeRol, rolPuedeDepartamento } from './campos-departamento.js' + +/** + * Matriz de permisos de Captura (fuente de verdad del backend; el front la + * espeja en apps/landing/src/services/captura.ts para ocultar pestañas). + */ +describe('departamentosDeRol', () => { + it('los roles operativos ven exactamente su departamento', () => { + expect(departamentosDeRol('vendedor')).toEqual(['VENTAS']) + expect(departamentosDeRol('escrituracion')).toEqual(['ESCRITURACION']) + expect(departamentosDeRol('cobranza')).toEqual(['FINANZAS']) + expect(departamentosDeRol('postventa')).toEqual(['POSTVENTA']) + expect(departamentosDeRol('obra')).toEqual(['OBRA']) + }) + + it('dirección y administración ven todos', () => { + for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador', 'director']) { + expect(departamentosDeRol(rol)).toEqual([...DEPARTAMENTOS]) + } + }) + + it('tolera mayúsculas y espacios; sin token no restringe', () => { + expect(departamentosDeRol(' Vendedor ')).toEqual(['VENTAS']) + expect(departamentosDeRol(undefined)).toEqual([...DEPARTAMENTOS]) + expect(departamentosDeRol('')).toEqual([...DEPARTAMENTOS]) + }) + + it('un rol desconocido cae al fallback seguro (vendedor)', () => { + expect(departamentosDeRol('otro')).toEqual(['VENTAS']) + }) +}) + +describe('rolPuedeDepartamento', () => { + it('cada clave de la matriz ve sus departamentos y ninguno más', () => { + for (const [rol, departamentos] of Object.entries(DEPARTAMENTOS_POR_ROL)) { + for (const departamento of DEPARTAMENTOS) { + expect(rolPuedeDepartamento(rol, departamento)).toBe( + (departamentos as readonly string[]).includes(departamento), + ) + } + } + }) + + it('un departamento inexistente nunca se permite', () => { + expect(rolPuedeDepartamento('admin', 'INVENTADO')).toBe(false) + }) +}) diff --git a/api-gateway/src/config/campos-departamento.ts b/api-gateway/src/config/campos-departamento.ts index 19ac3c6..15351a4 100644 --- a/api-gateway/src/config/campos-departamento.ts +++ b/api-gateway/src/config/campos-departamento.ts @@ -1,4 +1,5 @@ export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const +export type Departamento = (typeof DEPARTAMENTOS)[number] export const CAMPOS_POR_DEPARTAMENTO: Record = { OBRA: [ @@ -59,3 +60,44 @@ export const CAMPOS_POR_DEPARTAMENTO: Record = { + admin: DEPARTAMENTOS, + manager: DEPARTAMENTOS, + direccion_general: DEPARTAMENTOS, + director_ventas: DEPARTAMENTOS, + administracion_venta: DEPARTAMENTOS, + director: DEPARTAMENTOS, + coordinador: DEPARTAMENTOS, + vendedor: ['VENTAS'], + escrituracion: ['ESCRITURACION'], + cobranza: ['FINANZAS'], + postventa: ['POSTVENTA'], + obra: ['OBRA'], +} + +/** + * Departamentos del rol. Sin token (AUTH_ENABLED=false, modo local) no hay + * restricción; un rol desconocido cae a vendedor, igual que modulosDeRol + * (fallback seguro: es el conjunto más chico). + */ +export function departamentosDeRol(rol: string | undefined): readonly Departamento[] { + const clave = (rol ?? '').trim().toLowerCase() + if (clave === '') return DEPARTAMENTOS + return DEPARTAMENTOS_POR_ROL[clave] ?? DEPARTAMENTOS_POR_ROL['vendedor']! +} + +/** ¿El rol puede ver/capturar ese departamento? */ +export function rolPuedeDepartamento(rol: string | undefined, departamento: string): boolean { + return (departamentosDeRol(rol) as readonly string[]).includes(departamento) +} diff --git a/api-gateway/src/controllers/captura-departamento.controller.test.ts b/api-gateway/src/controllers/captura-departamento.controller.test.ts index 86f3771..ac0f610 100644 --- a/api-gateway/src/controllers/captura-departamento.controller.test.ts +++ b/api-gateway/src/controllers/captura-departamento.controller.test.ts @@ -194,6 +194,111 @@ describe('bulkEndpoint (POST /api/captura/bulk)', () => { }) }) +describe('permisos por rol (DEPARTAMENTOS_POR_ROL)', () => { + const bulk = (departamento: string, rol: string, campo: string) => + reqFake({ + body: { desarrollo: 'SENDA', departamento, registros: [{ unidad: 'U-101', campo, valor: null }] }, + token: { sub: 'u1', rol }, + }) + + it('vendedor: 403 al guardar fuera de VENTAS (POST /captura)', async () => { + await expect( + guardarEndpoint( + reqFake({ + body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'OBRA', campo: 'habitabilidad', valor: '2026-09-01' }, + token: { sub: 'u1', rol: 'vendedor' }, + }), + resFake().res, + ), + ).rejects.toMatchObject({ status: 403 }) + expect(capturaService.guardarCampo).not.toHaveBeenCalled() + }) + + it('vendedor: 403 en FINANZAS y 204 en VENTAS (POST /captura/bulk)', async () => { + vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined) + + await expect(bulkEndpoint(bulk('FINANZAS', 'vendedor', 'bancario'), resFake().res)).rejects.toMatchObject({ status: 403 }) + expect(capturaService.guardarBulk).not.toHaveBeenCalled() + + const { res, status } = resFake() + await bulkEndpoint(bulk('VENTAS', 'vendedor', 'estatus'), res) + expect(status).toHaveBeenCalledWith(204) + expect(capturaService.guardarBulk).toHaveBeenCalledWith('SENDA', 'VENTAS', [{ unidad: 'U-101', campo: 'estatus', valor: null }], 'u1', 'vendedor') + }) + + it('cada rol operativo conserva su departamento y sólo ése', async () => { + vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined) + const casos: [string, string, string][] = [ + ['vendedor', 'VENTAS', 'estatus'], + ['escrituracion', 'ESCRITURACION', 'notaria'], + ['cobranza', 'FINANZAS', 'bancario'], + ['postventa', 'POSTVENTA', 'entrega_sac'], + ['obra', 'OBRA', 'comentarios_obra'], + ] + + for (const [rol, departamento, campo] of casos) { + const { res, status } = resFake() + await bulkEndpoint(bulk(departamento, rol, campo), res) + expect(status).toHaveBeenCalledWith(204) + } + + // El mismo rol no puede escribir en el departamento de otro + const { res } = resFake() + await expect(bulkEndpoint(bulk('VENTAS', 'obra', 'estatus'), res)).rejects.toMatchObject({ status: 403 }) + }) + + it('admin, manager, dirección y administración de venta pueden con todos los departamentos', async () => { + vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined) + + for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador']) { + await bulkEndpoint(bulk('OBRA', rol, 'comentarios_obra'), resFake().res) + } + expect(capturaService.guardarBulk).toHaveBeenCalledTimes(6) + }) + + it('sin token (AUTH_ENABLED=false, modo local) no se restringe', async () => { + vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined) + const { res, status } = resFake() + + await bulkEndpoint( + reqFake({ body: { desarrollo: 'SENDA', departamento: 'OBRA', registros: [{ unidad: 'U-101', campo: 'comentarios_obra', valor: 'ok' }] } }), + res, + ) + expect(status).toHaveBeenCalledWith(204) + }) + + it('GET: 403 si el rol pide un departamento que no le toca', async () => { + await expect( + listarEndpoint(reqFake({ query: { desarrollo: 'SENDA', departamento: 'OBRA' }, token: { sub: 'u1', rol: 'vendedor' } }), resFake().res), + ).rejects.toMatchObject({ status: 403 }) + expect(capturaService.obtenerCapturas).not.toHaveBeenCalled() + }) + + it('GET: filtra los campos de los demás departamentos', async () => { + vi.mocked(capturaService.obtenerCapturas).mockResolvedValue({ + 'U-101': { estatus: 'VENDIDO', comentarios_obra: 'en proceso' }, + 'U-102': { comentarios_obra: 'sin ventas' }, + }) + const { res, json } = resFake() + + await listarEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res) + + expect(json).toHaveBeenCalledWith({ 'U-101': { estatus: 'VENDIDO' } }) + }) + + it('GET /resumen: sólo devuelve los departamentos del rol', async () => { + vi.mocked(capturaService.resumenAvance).mockResolvedValue([ + { departamento: 'OBRA', camposTotales: 14, camposLlenos: 2, porcentaje: 14 }, + { departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 }, + ]) + const { res, json } = resFake() + + await resumenEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res) + + expect(json).toHaveBeenCalledWith([{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 }]) + }) +}) + // ── importarEndpoint ──────────────────────────────────────────────── describe('importarEndpoint (POST /api/captura/importar)', () => { diff --git a/api-gateway/src/controllers/captura-departamento.controller.ts b/api-gateway/src/controllers/captura-departamento.controller.ts index ddcd191..69dc48d 100644 --- a/api-gateway/src/controllers/captura-departamento.controller.ts +++ b/api-gateway/src/controllers/captura-departamento.controller.ts @@ -1,10 +1,52 @@ import type { NextFunction, Request, Response } from 'express' import { z } from 'zod' import * as capturaService from '../services/captura-departamento.service.js' -import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js' +import { + CAMPOS_POR_DEPARTAMENTO, + DEPARTAMENTOS, + departamentosDeRol, + rolPuedeDepartamento, +} from '../config/campos-departamento.js' import { AppError, BadRequestError } from '../utils/errors.js' import { esAdminOManager } from '../utils/roles.js' +// ── Permisos por rol ───────────────────────────────────────────────── + +/** + * El rol debe tener permitido el departamento (matriz DEPARTAMENTOS_POR_ROL): + * un vendedor sólo captura VENTAS aunque el front la oculte; sin esta guarda + * un token de vendedor podía escribir en OBRA o FINANZAS. + */ +function requireDepartamentoPermitido(rol: string | undefined, departamento: string): void { + if (!rolPuedeDepartamento(rol, departamento)) { + throw new AppError(403, `Tu rol no tiene acceso al departamento ${departamento}`) + } +} + +/** Campos visibles para el rol (unión de los campos de sus departamentos) */ +function camposPermitidos(rol: string | undefined): Set { + const campos = departamentosDeRol(rol).flatMap((d) => (CAMPOS_POR_DEPARTAMENTO[d] ?? []).map((c) => c.campo)) + return new Set(campos) +} + +/** + * La lectura también respeta la matriz: con un token de rol limitado sólo se + * devuelven los campos de sus departamentos (el front ya filtra las pestañas, + * esto evita que los datos lleguen al cliente). + */ +function filtrarPorRol( + datos: Record>, + rol: string | undefined, +): Record> { + const permitidos = camposPermitidos(rol) + const salida: Record> = {} + for (const [unidad, campos] of Object.entries(datos)) { + const visibles = Object.fromEntries(Object.entries(campos).filter(([campo]) => permitidos.has(campo))) + if (Object.keys(visibles).length > 0) salida[unidad] = visibles + } + return salida +} + // ── Validación de campos por departamento ──────────────────────────── /** @@ -44,8 +86,15 @@ const listarSchema = z.object({ export async function listarEndpoint(req: Request, res: Response): Promise { const query = listarSchema.parse(req.query) + const rol = req.usuarioToken?.rol + if (query.departamento !== undefined) { + if (!DEPARTAMENTOS.includes(query.departamento as (typeof DEPARTAMENTOS)[number])) { + throw new BadRequestError(`Departamento inválido: ${query.departamento}`) + } + requireDepartamentoPermitido(rol, query.departamento) + } const capturas = await capturaService.obtenerCapturas(query.desarrollo, query.departamento) - res.json(capturas) + res.json(filtrarPorRol(capturas, rol)) } // ── GET /api/captura/unidades?desarrollo=X ─────────────────────────── @@ -69,7 +118,8 @@ const resumenSchema = z.object({ export async function resumenEndpoint(req: Request, res: Response): Promise { const query = resumenSchema.parse(req.query) const resumen = await capturaService.resumenAvance(query.desarrollo) - res.json(resumen) + const rol = req.usuarioToken?.rol + res.json(resumen.filter((r) => rolPuedeDepartamento(rol, r.departamento))) } // ── POST /api/captura — guardar campo individual ───────────────────── @@ -89,6 +139,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise { if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) { throw new BadRequestError(`Departamento inválido: ${body.departamento}`) } + requireDepartamentoPermitido(req.usuarioToken?.rol, body.departamento) validarCamposDelDepartamento( body.departamento, body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })), diff --git a/api-gateway/src/controllers/cliente-maestro.controller.test.ts b/api-gateway/src/controllers/cliente-maestro.controller.test.ts index df19422..465a6cb 100644 --- a/api-gateway/src/controllers/cliente-maestro.controller.test.ts +++ b/api-gateway/src/controllers/cliente-maestro.controller.test.ts @@ -6,6 +6,7 @@ const servicioMock = vi.hoisted(() => ({ listar: vi.fn(), obtener: vi.fn(), contar: vi.fn(), + catalogos: vi.fn(), exportar: vi.fn(), importarDesdeExcel: vi.fn(), })) @@ -14,6 +15,7 @@ vi.mock('../services/cliente-maestro.service.js', () => servicioMock) import { listarEndpoint, + catalogosEndpoint, exportarEndpoint, obtenerEndpoint, importarEndpoint, @@ -77,6 +79,23 @@ describe('listarEndpoint', () => { }) }) +// ── GET /api/clientes-maestro/catalogos ────────────────────────────── + +describe('catalogosEndpoint', () => { + it('200 con desarrollos y estatus reales', async () => { + servicioMock.catalogos.mockResolvedValue({ + desarrollos: ['MUTUO - EDIFICACION', 'SOLE'], + estatuses: ['ENTREGA'], + }) + const { res, json } = resFake() + await catalogosEndpoint(reqFake(), res) + expect(json).toHaveBeenCalledWith({ + desarrollos: ['MUTUO - EDIFICACION', 'SOLE'], + estatuses: ['ENTREGA'], + }) + }) +}) + // ── GET /api/clientes-maestro/exportar ─────────────────────────────── describe('exportarEndpoint', () => { diff --git a/api-gateway/src/controllers/cliente-maestro.controller.ts b/api-gateway/src/controllers/cliente-maestro.controller.ts index 6f3f020..f937a62 100644 --- a/api-gateway/src/controllers/cliente-maestro.controller.ts +++ b/api-gateway/src/controllers/cliente-maestro.controller.ts @@ -22,6 +22,12 @@ export async function listarEndpoint(req: Request, res: Response): Promise res.json(resultado) } +// ── GET /api/clientes-maestro/catalogos ────────────────────────────── + +export async function catalogosEndpoint(_req: Request, res: Response): Promise { + res.json(await clienteMaestroService.catalogos()) +} + // ── GET /api/clientes-maestro/exportar ─────────────────────────────── export async function exportarEndpoint(req: Request, res: Response): Promise { diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts index 3b2a9a6..8a5a78f 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -7,6 +7,9 @@ const prismaMock = vi.hoisted(() => ({ unidad: { findUnique: vi.fn(), }, + capturaDepartamento: { + findMany: vi.fn(), + }, leadCaptura: { findFirst: vi.fn(), findUnique: vi.fn(), @@ -77,6 +80,7 @@ function resFake() { beforeEach(() => { vi.clearAllMocks() prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad) + prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) prismaMock.leadCaptura.findFirst.mockResolvedValue(null) prismaMock.leadCaptura.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) @@ -184,6 +188,49 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => { expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled() }) + + it('toma la fecha de entrega de la captura: mensualidad y total ya no salen nulos', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' }, + ]) + const { res, json } = resFake() + + await crearCotizacion(reqFake(bodyBase), res) + + const respuesta = json.mock.calls[0]?.[0] as { + numMensualidades: number + mensualidad: number + propuesta: { numMensualidades: number; mensualidad: number; mensualidadesTotal: number } + } + expect(respuesta.numMensualidades).toBeGreaterThan(0) + expect(respuesta.numMensualidades).toBe(respuesta.propuesta.numMensualidades) + expect(respuesta.propuesta.mensualidad).toBeGreaterThan(0) + expect(respuesta.propuesta.mensualidadesTotal).toBeGreaterThan(0) + }) + + it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => { + const { res, json } = resFake() + + await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res) + + // DIAGONAL bancario sin acabados: remanente 75,000 en 12 pagos + expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }), + }) + const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; propuesta: { numMensualidades: number } } + expect(respuesta.numMensualidades).toBe(12) + expect(respuesta.propuesta.numMensualidades).toBe(12) + }) + + it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => { + prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' }) + const { res } = resFake() + + await expect(crearCotizacion(reqFake({ ...bodyBase, unidadCodigo: 'ATELIER-10' }), res)).rejects.toMatchObject({ + status: 409, + }) + expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() + }) }) describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => { diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index e4d765b..9508c38 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -9,7 +9,7 @@ import { import { prisma } from '../services/prisma.js' import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import { actividadLocalService } from '../services/actividad-local.service.js' -import { NotFoundError } from '../utils/errors.js' +import { NotFoundError, AppError } from '../utils/errors.js' import * as auditoria from '../services/auditoria.service.js' import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js' @@ -24,13 +24,28 @@ const crearCotizacionSchema = z.object({ tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']), descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%) // Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a - // esquemas con crédito (bancario/Infonavit) + // esquemas con crédito (bancario/Infonavit). El Excel permite 2.5%–5%. enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(), + // Mensualidades elegidas por el asesor; sin él se resuelve con la fecha de + // entrega de la unidad (o el plazo por defecto del desarrollo) + numMensualidades: z.number().int().min(1).max(120).optional(), // Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi // día manda el id; sin él se busca por teléfono) oportunidadId: z.string().trim().min(1).optional(), }) +/** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */ +function validarUnidadDisponible(unidad: { codigo: string; status: string }): void { + const status = unidad.status.trim().toUpperCase() + if (status !== 'DISPONIBLE') { + throw new AppError( + 409, + `La unidad ${unidad.codigo} no está disponible (estatus: ${unidad.status.trim() || 'desconocido'}). ` + + 'Elige otra unidad del inventario disponible.', + ) + } +} + /** * Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo * criterio que el funnel: primero el mapping histórico (lead → su @@ -57,9 +72,20 @@ export async function crearCotizacion(req: Request, res: Response): Promise plazo desconocido (sin fecha de entrega) + // 0 => no se pudo resolver el plazo (no debería ocurrir: los desarrollos + // tienen plazo por defecto). Si no, el N elegido por el vendedor. + numMensualidades: propuesta.numMensualidades ?? 0, mensualidad: propuesta.mensualidad, pagoFinal: propuesta.pagoFinal, }, @@ -165,6 +193,7 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro tipoPropuesta: cotizacion.tipoPropuesta, precioFinal: cotizacion.precioFinal, enganche: cotizacion.enganche, + numMensualidades: cotizacion.numMensualidades, mensualidad: cotizacion.mensualidad, pagoFinal: cotizacion.pagoFinal, vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(), diff --git a/api-gateway/src/controllers/oportunidades.controller.test.ts b/api-gateway/src/controllers/oportunidades.controller.test.ts index 6fd9f9c..c84037a 100644 --- a/api-gateway/src/controllers/oportunidades.controller.test.ts +++ b/api-gateway/src/controllers/oportunidades.controller.test.ts @@ -4,6 +4,7 @@ import { cambiarEtapaOportunidadEndpoint, ETAPAS_OPORTUNIDAD_PERMITIDAS, perderOportunidadEndpoint, + STATUS_NO_BLOQUEABLES, } from './oportunidades.controller.js' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- @@ -17,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({ unidad: { updateMany: vi.fn(), }, + cotizacion: { + findFirst: vi.fn(), + }, automatizacionState: { findUnique: vi.fn(), upsert: vi.fn(), @@ -53,6 +57,7 @@ beforeEach(() => { prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.cotizacion.findFirst.mockResolvedValue(null) prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) @@ -149,7 +154,7 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' }) }) - it("'Aparto': crea el apartado local (crmLocal.apartar) y bloquea la unidad del inventario", async () => { + it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', usuarioId: 'u-1', @@ -164,22 +169,57 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res) - // Oportunidad en Aparto + apartado local en Pago Apartado + cola + // Oportunidad en Aparto + apartado local en Pago Apartado con la unidad + cola expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_op' }, data: { etapa: 'Aparto', pendienteSync: true }, }) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }), + data: expect.objectContaining({ + board: 'APARTADO', + etapa: 'Pago Apartado', + nombreLead: 'Luis Demo', + datosJson: JSON.stringify({ unidadCodigo: 'U-101' }), + }), }) - // La unidad asignada queda APARTADA en el espejo local + // La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ - where: { codigo: 'U-101' }, - data: { status: 'APARTADA' }, + where: { codigo: 'U-101', status: { notIn: [...STATUS_NO_BLOQUEABLES] } }, + data: { status: 'APARTADO' }, }) expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' }) }) + it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'loc_op', + usuarioId: 'u-1', + nombreLead: 'Luis Demo', + telefono: null, + email: null, + desarrollo: 'SENDA', + modelo: null, + datosJson: null, + }) + prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' }) + const { res } = resFake() + + await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res) + + expect(prismaMock.cotizacion.findFirst).toHaveBeenCalledWith({ + where: { mondayItemId: 'loc_op' }, + orderBy: { createdAt: 'desc' }, + select: { unidadCodigo: true }, + }) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ datosJson: JSON.stringify({ unidadCodigo: 'SENDA-411-1-7' }) }), + }) + expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ + where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } }, + data: { status: 'APARTADO' }, + }) + }) + it("'Aparto' sin unidad asignada no toca el inventario", async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts index d191cb4..dffa702 100644 --- a/api-gateway/src/controllers/oportunidades.controller.ts +++ b/api-gateway/src/controllers/oportunidades.controller.ts @@ -52,9 +52,40 @@ const cambiarEtapaSchema = z.object({ fecha: z.string().optional(), }) +/** + * Estatus de inventario que un apartado nunca debe pisar: la unidad ya se + * vendió, se escrituró o no está a la venta (BLOQUEADO). + */ +export const STATUS_NO_BLOQUEABLES = ['VENDIDO', 'ESCRITURADO', 'BLOQUEADO'] + +/** + * Unidad asignada a la oportunidad, leída de su `datosJson` (la deja el alta de + * clientes / la afluencia) o, si no está ahí, de la cotización más reciente + * ligada a ella (`Cotizacion.mondayItemId`): el vendedor cotiza y después + * aparta, así que la unidad cotizada ES la que se está apartando. + */ +async function unidadDeOportunidad(itemId: string, datosJson: string | null): Promise { + if (datosJson) { + try { + const datos = JSON.parse(datosJson) as Record + if (typeof datos['unidadCodigo'] === 'string' && datos['unidadCodigo'].trim() !== '') { + return datos['unidadCodigo'].trim() + } + } catch { /* datosJson inválido: se ignora */ } + } + const cotizacion = await prisma.cotizacion.findFirst({ + where: { mondayItemId: itemId }, + orderBy: { createdAt: 'desc' }, + select: { unidadCodigo: true }, + }) + const codigo = cotizacion?.unidadCodigo.trim() + return codigo !== undefined && codigo !== '' ? codigo : null +} + /** * Cambio de etapa local: guarda la etapa en la fila local y encola - * CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar). + * CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar), + * dejando la unidad apartada en su `datosJson`. * Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad). */ async function cambiarEtapaLocal( @@ -64,17 +95,12 @@ async function cambiarEtapaLocal( usuarioId: string | undefined, ): Promise { if (etapa === 'Aparto') { - await crmLocal.apartar(itemId, usuarioId) - // Leer la oportunidad local para obtener el unidadCodigo y bloquear la unidad const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) - let unidadCodigo = '' - if (captura?.datosJson) { - try { - const datos = JSON.parse(captura.datosJson) as Record - if (typeof datos['unidadCodigo'] === 'string') unidadCodigo = datos['unidadCodigo'] - } catch { /* datosJson inválido: se ignora */ } - } - return unidadCodigo.trim() !== '' ? unidadCodigo.trim() : null + const unidadCodigo = await unidadDeOportunidad(itemId, captura?.datosJson ?? null) + await crmLocal.apartar(itemId, usuarioId, { + ...(unidadCodigo !== null ? { unidadCodigo } : {}), + }) + return unidadCodigo } await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', { fecha, @@ -89,10 +115,15 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons const body = cambiarEtapaSchema.parse(req.body) const itemId = String(req.params['itemId']) const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub) - // Bloquear la unidad del inventario local si la oportunidad tiene una asignada + // Bloquear la unidad del inventario local si la oportunidad tiene una asignada. + // Nunca se degrada una unidad ya VENDIDA/ESCRITURADA/BLOQUEADA: el apartado + // solo bloquea inventario vendible. if (body.etapa === 'Aparto' && unidadCodigo !== null) { try { - await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } }) + await prisma.unidad.updateMany({ + where: { codigo: unidadCodigo, status: { notIn: STATUS_NO_BLOQUEABLES } }, + data: { status: 'APARTADO' }, + }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err) diff --git a/api-gateway/src/controllers/planes.controller.test.ts b/api-gateway/src/controllers/planes.controller.test.ts index f20e0de..7fc1ada 100644 --- a/api-gateway/src/controllers/planes.controller.test.ts +++ b/api-gateway/src/controllers/planes.controller.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' -import { resolverFiltroMios } from './planes.controller.js' +import { describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { ROLES_EDITAN_PLAN, requireEditaPlan, resolverFiltroMios } from './planes.controller.js' describe('resolverFiltroMios (?mios=1)', () => { it('sin ?mios=1 no filtra nada', () => { @@ -20,3 +21,35 @@ describe('resolverFiltroMios (?mios=1)', () => { expect(resolverFiltroMios('1', undefined)).toBeUndefined() }) }) + +const reqCon = (rol?: string): Request => + ({ + body: {}, + params: { id: 'plan-1' }, + ...(rol ? { usuarioToken: { sub: 'u-1', username: 'x', rol } } : {}), + }) as unknown as Request + +describe('requireEditaPlan (PATCH /planes/:id)', () => { + it('admin, manager, dirección y sus roles legacy pasan', () => { + for (const rol of ROLES_EDITAN_PLAN) { + const next = vi.fn() + requireEditaPlan(reqCon(rol), {} as Response, next) + expect(next).toHaveBeenCalled() + } + expect(ROLES_EDITAN_PLAN).toEqual( + expect.arrayContaining(['admin', 'manager', 'direccion_general', 'director_ventas']), + ) + }) + + it('los roles de cobranza que no son dirección quedan fuera', () => { + for (const rol of ['vendedor', 'administracion_venta', 'escrituracion']) { + expect(() => requireEditaPlan(reqCon(rol), {} as Response, vi.fn())).toThrow( + 'Solo admin, manager o dirección pueden modificar el plan', + ) + } + }) + + it('sin token también rechaza (403)', () => { + expect(() => requireEditaPlan(reqCon(), {} as Response, vi.fn())).toThrow() + }) +}) diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts index 4dab372..508477f 100644 --- a/api-gateway/src/controllers/planes.controller.ts +++ b/api-gateway/src/controllers/planes.controller.ts @@ -1,4 +1,4 @@ -import type { Request, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { z } from 'zod' import { prisma } from '../services/prisma.js' import { @@ -18,7 +18,7 @@ import { validarAbono, type PlanPago as PlanPagoDominio, } from '../services/cobranza.service.js' -import { BadRequestError, NotFoundError } from '../utils/errors.js' +import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js' import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client' // ---------- Mapeo Prisma → dominio (funciones puras del servicio) ---------- @@ -319,3 +319,48 @@ export async function cancelarPlan(req: Request, res: Response): Promise { const actualizado = await cargarPlan(plan.id) res.json(conCalculados(aDominio(actualizado))) } + +// ── Edición de parámetros del plan (tasa de mora) ──────────────────── + +/** Roles que pueden editar el plan: admin, manager y dirección (y sus legacy) */ +export const ROLES_EDITAN_PLAN: readonly string[] = [ + 'admin', + 'manager', + 'direccion_general', + 'director_ventas', + // Legacy: la matriz de roles los homologa a dirección de ventas + 'coordinador', + 'corporativo', +] + +/** Middleware: sin rol autorizado no se edita el plan (403) */ +export function requireEditaPlan(req: Request, _res: Response, next: NextFunction): void { + const rol = req.usuarioToken?.rol + if (!rol || !ROLES_EDITAN_PLAN.includes(rol)) { + throw new AppError(403, 'Solo admin, manager o dirección pueden modificar el plan') + } + next() +} + +const actualizarPlanSchema = z.object({ + // Tasa mensual en % sobre el restante de cada pago vencido (0 = sin mora) + tasaMoraMensual: z + .number() + .min(0, 'La tasa de mora no puede ser negativa') + .max(100, 'La tasa de mora mensual no puede exceder 100%'), +}) + +/** + * PATCH /api/planes/:id — actualiza la tasa de mora mensual del plan. + * (El calendario de pagos no se edita: cambiarlo requiere recrear el plan.) + */ +export async function actualizarPlan(req: Request, res: Response): Promise { + const body = actualizarPlanSchema.parse(req.body) + const plan = await cargarPlan(String(req.params['id'])) + const actualizado = await prisma.planPago.update({ + where: { id: plan.id }, + data: { tasaMoraMensual: body.tasaMoraMensual }, + include: includeCompleto, + }) + res.json(conCalculados(aDominio(actualizado))) +} diff --git a/api-gateway/src/controllers/recordatorios.controller.test.ts b/api-gateway/src/controllers/recordatorios.controller.test.ts new file mode 100644 index 0000000..e4d121b --- /dev/null +++ b/api-gateway/src/controllers/recordatorios.controller.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { exigirRolCobranza } from './recordatorios.controller.js' + +describe('exigirRolCobranza (motor de recordatorios)', () => { + it('permite los roles con el módulo de cobranza', () => { + for (const rol of [ + 'admin', + 'manager', + 'administracion_venta', + 'direccion_general', + 'director_ventas', + 'escrituracion', + 'coordinador', // legacy → director_ventas + 'corporativo', // legacy → director_ventas + ]) { + expect(() => exigirRolCobranza(rol)).not.toThrow() + } + }) + + it('rechaza al vendedor (no tiene el módulo) y a "cobranza", que es módulo y no rol', () => { + expect(() => exigirRolCobranza('vendedor')).toThrow('Tu rol no tiene acceso a la cobranza') + expect(() => exigirRolCobranza('cobranza')).toThrow('Tu rol no tiene acceso a la cobranza') + }) + + it('sin token (AUTH_ENABLED=false) no bloquea el modo sin autenticación', () => { + expect(() => exigirRolCobranza(undefined)).not.toThrow() + }) +}) diff --git a/api-gateway/src/controllers/recordatorios.controller.ts b/api-gateway/src/controllers/recordatorios.controller.ts index 2ceb529..29b5585 100644 --- a/api-gateway/src/controllers/recordatorios.controller.ts +++ b/api-gateway/src/controllers/recordatorios.controller.ts @@ -3,11 +3,35 @@ import { prisma } from '../services/prisma.js' import { recordatoriosCobranzaService } from '../services/recordatorios-cobranza.service.js' import { AppError } from '../utils/errors.js' -// Roles que pueden disparar una pasada manual del motor de recordatorios -const ROLES_RUN: readonly string[] = ['admin', 'cobranza'] +// Roles del ecosistema cuyo perfil incluye el módulo de cobranza (matriz de +// config/roles.ts): los que pueden ver y disparar el motor de recordatorios. +// 'cobranza' es un MÓDULO, no un rol: no existe como rol de usuario. +const ROLES_COBRANZA: readonly string[] = [ + 'admin', + 'manager', + 'administracion_venta', + 'direccion_general', + 'director_ventas', + 'escrituracion', + // Roles legacy (se homologan a director_ventas en la matriz) + 'coordinador', + 'corporativo', +] + +/** + * Exige rol con cobranza. Sin token (AUTH_ENABLED=false) se permite, igual que + * el resto de la API en ese modo. + */ +export function exigirRolCobranza(rol: string | undefined): void { + if (!rol) return + if (!ROLES_COBRANZA.includes(rol)) { + throw new AppError(403, 'Tu rol no tiene acceso a la cobranza') + } +} /** GET /api/cobranza/recordatorios — historial del log (más recientes primero) */ export async function listarRecordatorios(req: Request, res: Response): Promise { + exigirRolCobranza(req.usuarioToken?.rol) const limite = Math.min(Number(req.query['limite'] ?? 200) || 200, 500) const registros = await prisma.recordatorioLog.findMany({ orderBy: { createdAt: 'desc' }, @@ -17,15 +41,12 @@ export async function listarRecordatorios(req: Request, res: Response): Promise< } /** - * POST /api/cobranza/recordatorios/run — pasada manual del motor (admin/cobranza). - * Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir en el log. + * POST /api/cobranza/recordatorios/run — pasada manual del motor (roles con + * cobranza). Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir + * en el log. */ export async function correrRecordatorios(req: Request, res: Response): Promise { - const token = req.usuarioToken - // Sin token (AUTH_ENABLED=false) se permite; con token se exige rol admin/cobranza - if (token && !ROLES_RUN.includes(token.rol)) { - throw new AppError(403, 'Solo admin o cobranza pueden ejecutar el motor de recordatorios') - } + exigirRolCobranza(req.usuarioToken?.rol) const dryRun = req.query['dry'] === '1' res.json(await recordatoriosCobranzaService.ejecutarPasada({ dryRun })) } diff --git a/api-gateway/src/controllers/soporte.controller.test.ts b/api-gateway/src/controllers/soporte.controller.test.ts new file mode 100644 index 0000000..40377c0 --- /dev/null +++ b/api-gateway/src/controllers/soporte.controller.test.ts @@ -0,0 +1,478 @@ +import { Readable } from 'node:stream' +import fs from 'node:fs' +import path from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' + +// UPLOADS_DIR propio del test: se fija antes de que config lo lea (dotenv no +// pisa una variable ya definida), así la carpeta queda aislada del repo. +const uploadsDir = vi.hoisted(() => { + const dir = `${process.env['TMPDIR'] ?? '/tmp'}/frasa-soporte-test-${Date.now()}` + process.env['UPLOADS_DIR'] = dir + return dir +}) + +const prismaMock = vi.hoisted(() => ({ + solicitudSoporte: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})) + +const auditoriaMock = vi.hoisted(() => ({ registrar: vi.fn() })) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/auditoria.service.js', () => auditoriaMock) + +import { config } from '../config/index.js' +import { + cambiarEstadoSolicitudEndpoint, + crearSolicitudSoporteEndpoint, + descargarArchivoSoporteEndpoint, + listarSolicitudesSoporteEndpoint, + requireAdminOManager, + uploadArchivosSoporte, +} from './soporte.controller.js' + +// ── Helpers ────────────────────────────────────────────────────────── + +type ReqConId = Request & { solicitudSoporteId?: string } + +function reqFake(overrides: Partial = {}): ReqConId { + return { + body: {}, + params: {}, + query: {}, + usuarioToken: { sub: 'u-1', username: 'vendedor', rol: 'vendedor' }, + ...overrides, + } as ReqConId +} + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + const setHeader = vi.fn() + const sendFile = vi.fn() + return { + res: { json, status, setHeader, sendFile } as unknown as Response, + status, + json, + setHeader, + sendFile, + } +} + +function filaFake(overrides: Record = {}) { + return { + id: 'sol-1', + usuarioId: 'u-1', + titulo: 'Impresora', + descripcion: 'No imprime', + departamento: 'Sistemas', + prioridad: 'Media', + responsable: null, + fechaSolicitud: new Date('2026-09-21T00:00:00.000Z'), + fechaLimite: null, + notas: null, + archivos: null, + solicitante: 'Vendedor', + solicitanteEmail: null, + estado: 'ABIERTA', + createdAt: new Date('2026-09-21T00:00:00.000Z'), + updatedAt: new Date('2026-09-21T00:00:00.000Z'), + ...overrides, + } +} + +/** Request multipart real (stream) para ejercitar el middleware de multer */ +function reqMultipart(partes: Array<{ name: string; filename?: string; tipo: string; contenido: string }>): ReqConId { + const boundary = '----frasaTestBoundary' + const trozos = partes.map((p) => { + const disposicion = p.filename + ? `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n` + : `Content-Disposition: form-data; name="${p.name}"\r\n` + return Buffer.from( + `--${boundary}\r\n${disposicion}Content-Type: ${p.tipo}\r\n\r\n${p.contenido}\r\n`, + 'binary', + ) + }) + const cuerpo = Buffer.concat([...trozos, Buffer.from(`--${boundary}--\r\n`, 'binary')]) + const req = Readable.from([cuerpo]) as unknown as ReqConId + // type-is (usado por multer) exige content-type + content-length para + // considerar la petición "multipart con body" + req.headers = { + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'content-length': String(cuerpo.length), + } as ReqConId['headers'] + return req +} + +const bodyValido = { + titulo: 'Impresora', + descripcion: 'No imprime', + departamento: 'Sistemas', + prioridad: 'Media', + fechaSolicitud: '2026-09-21', + email: 'vendedor@frasa.mx', +} + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.solicitudSoporte.create.mockImplementation(async ({ data }: { data: Record }) => + filaFake(data), + ) + prismaMock.solicitudSoporte.update.mockImplementation(async ({ data }: { data: Record }) => + filaFake(data), + ) +}) + +afterAll(() => { + fs.rmSync(uploadsDir, { recursive: true, force: true }) +}) + +// ── Subida real de adjuntos (multer + carpeta por solicitud) ────────── + +describe('uploadArchivosSoporte', () => { + function subir(req: ReqConId): Promise { + return new Promise((resolve) => { + uploadArchivosSoporte(req, {} as Response, (err?: unknown) => resolve((err as Error) ?? null)) + }) + } + + it('guarda el archivo en uploads/soporte// conservando el nombre', async () => { + const req = reqMultipart([ + { name: 'archivos', filename: 'carta poder.pdf', tipo: 'application/pdf', contenido: '%PDF-1.4 hola' }, + ]) + req.solicitudSoporteId = 'sol-abc' + + expect(await subir(req)).toBeNull() + + const archivos = req.files as Express.Multer.File[] + expect(archivos).toHaveLength(1) + const relativa = path.relative(config.uploadsDir, archivos[0]!.path) + expect(relativa.startsWith(`soporte${path.sep}sol-abc${path.sep}`)).toBe(true) + expect(relativa.endsWith('-carta poder.pdf')).toBe(true) + expect(fs.readFileSync(archivos[0]!.path, 'utf8')).toBe('%PDF-1.4 hola') + }) + + it('rechaza tipos no permitidos (texto plano)', async () => { + const req = reqMultipart([ + { name: 'archivos', filename: 'notas.txt', tipo: 'text/plain', contenido: 'hola' }, + ]) + req.solicitudSoporteId = 'sol-txt' + + const err = await subir(req) + expect(err).toBeInstanceOf(Error) + expect(err?.message).toContain('Tipo de archivo no permitido') + }) + + it('si multer corta (demasiados archivos) borra la carpeta de la solicitud', async () => { + const partes = Array.from({ length: 11 }, (_, i) => ({ + name: 'archivos', + filename: `doc-${i}.pdf`, + tipo: 'application/pdf', + contenido: 'pdf', + })) + const req = reqMultipart(partes) + req.solicitudSoporteId = 'sol-limite' + + const err = await subir(req) + + expect(err).toBeInstanceOf(Error) + expect(err?.message).toContain('Too many files') + expect(fs.existsSync(path.join(config.uploadsDir, 'soporte', 'sol-limite'))).toBe(false) + }) + + it('sanea el nombre original (no permite rutas)', async () => { + const req = reqMultipart([ + { name: 'archivos', filename: '../../evil.png', tipo: 'image/png', contenido: 'PNG' }, + ]) + req.solicitudSoporteId = 'sol-evil' + + expect(await subir(req)).toBeNull() + const archivos = req.files as Express.Multer.File[] + const relativa = path.relative(config.uploadsDir, archivos[0]!.path) + expect(relativa).not.toContain('..') + expect(relativa.startsWith(`soporte${path.sep}sol-evil${path.sep}`)).toBe(true) + }) +}) + +// ── POST /api/soporte ──────────────────────────────────────────────── + +describe('crearSolicitudSoporteEndpoint', () => { + it('201 guardando las rutas relativas de los adjuntos subidos', async () => { + const solicitado = 'a1b2c3d4-factura.pdf' + const rutaAbsoluta = path.join(config.uploadsDir, 'soporte', 'sol-1', solicitado) + fs.mkdirSync(path.dirname(rutaAbsoluta), { recursive: true }) + fs.writeFileSync(rutaAbsoluta, 'pdf') + + const req = reqFake({ + body: bodyValido, + solicitudSoporteId: 'sol-1', + files: [ + { + path: rutaAbsoluta, + originalname: 'factura.pdf', + mimetype: 'application/pdf', + size: 3, + } as Express.Multer.File, + ], + }) + const { res, status, json } = resFake() + + await crearSolicitudSoporteEndpoint(req, res) + + expect(status).toHaveBeenCalledWith(201) + const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record } + expect(data.data['id']).toBe('sol-1') + expect(JSON.parse(String(data.data['archivos']))).toEqual([ + path.join('soporte', 'sol-1', solicitado), + ]) + expect(data.data['solicitanteEmail']).toBe('vendedor@frasa.mx') + expect(json).toHaveBeenCalled() + expect(auditoriaMock.registrar).toHaveBeenCalled() + }) + + it('si el body no valida borra la carpeta de adjuntos y propaga el error', async () => { + const carpeta = path.join(config.uploadsDir, 'soporte', 'sol-mala') + fs.mkdirSync(carpeta, { recursive: true }) + fs.writeFileSync(path.join(carpeta, 'x.pdf'), 'pdf') + + const req = reqFake({ body: { ...bodyValido, fechaSolicitud: '' }, solicitudSoporteId: 'sol-mala' }) + + await expect(crearSolicitudSoporteEndpoint(req, resFake().res)).rejects.toThrow() + expect(fs.existsSync(carpeta)).toBe(false) + expect(prismaMock.solicitudSoporte.create).not.toHaveBeenCalled() + }) + + it('acepta JSON sin adjuntos (compatibilidad) y guarda archivos: null', async () => { + const req = reqFake({ body: bodyValido, solicitudSoporteId: 'sol-json' }) + const { res, status } = resFake() + + await crearSolicitudSoporteEndpoint(req, res) + + expect(status).toHaveBeenCalledWith(201) + const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record } + expect(data.data['archivos']).toBeNull() + }) +}) + +// ── GET /api/soporte (visibilidad + filtros) ───────────────────────── + +describe('listarSolicitudesSoporteEndpoint', () => { + it('el manager ve TODAS las solicitudes (sin filtro por usuario)', async () => { + prismaMock.solicitudSoporte.findMany.mockResolvedValue([]) + const { res } = resFake() + + await listarSolicitudesSoporteEndpoint( + reqFake({ usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' } }), + res, + ) + + expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { createdAt: 'desc' }, + }) + }) + + it('el admin ve TODAS las solicitudes', async () => { + prismaMock.solicitudSoporte.findMany.mockResolvedValue([]) + await listarSolicitudesSoporteEndpoint( + reqFake({ usuarioToken: { sub: 'a-1', username: 'admin', rol: 'admin' } }), + resFake().res, + ) + expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: {} }), + ) + }) + + it('el vendedor solo ve las suyas', async () => { + prismaMock.solicitudSoporte.findMany.mockResolvedValue([]) + await listarSolicitudesSoporteEndpoint(reqFake({ usuarioToken: { sub: 'v-9', username: 'v', rol: 'vendedor' } }), resFake().res) + expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({ + where: { usuarioId: 'v-9' }, + orderBy: { createdAt: 'desc' }, + }) + }) + + it('aplica filtros de estado y departamento', async () => { + prismaMock.solicitudSoporte.findMany.mockResolvedValue([]) + await listarSolicitudesSoporteEndpoint( + reqFake({ + query: { estado: 'EN_PROCESO', departamento: 'Cobranza' }, + usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' }, + }), + resFake().res, + ) + expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({ + where: { estado: 'EN_PROCESO', departamento: 'Cobranza' }, + orderBy: { createdAt: 'desc' }, + }) + }) + + it('ignora filtros vacíos y rechaza estados inválidos', async () => { + prismaMock.solicitudSoporte.findMany.mockResolvedValue([]) + await listarSolicitudesSoporteEndpoint( + reqFake({ query: { estado: '', departamento: '' } }), + resFake().res, + ) + expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { usuarioId: 'u-1' } }), + ) + + await expect( + listarSolicitudesSoporteEndpoint(reqFake({ query: { estado: 'LOQUESEA' } }), resFake().res), + ).rejects.toThrow() + }) + + it('serializa archivos JSON a array', async () => { + prismaMock.solicitudSoporte.findMany.mockResolvedValue([ + filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }), + ]) + const { res, json } = resFake() + await listarSolicitudesSoporteEndpoint(reqFake(), res) + expect((json.mock.calls[0]![0] as Array<{ archivos: string[] }>)[0]!.archivos).toEqual(['soporte/sol-1/a.pdf']) + }) +}) + +// ── GET /api/soporte/:id/archivos/:indice ──────────────────────────── + +describe('descargarArchivoSoporteEndpoint', () => { + function crearArchivo(relativa: string, contenido = 'contenido'): string { + const absoluta = path.join(config.uploadsDir, relativa) + fs.mkdirSync(path.dirname(absoluta), { recursive: true }) + fs.writeFileSync(absoluta, contenido) + return absoluta + } + + it('el dueño descarga su adjunto', async () => { + const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.pdf')) + prismaMock.solicitudSoporte.findUnique.mockResolvedValue( + filaFake({ usuarioId: 'u-1', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }), + ) + const { res, setHeader, sendFile } = resFake() + + await descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), res) + + expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf') + expect(sendFile).toHaveBeenCalledWith(absoluta) + }) + + it('el manager descarga el adjunto de otro usuario', async () => { + const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.png')) + prismaMock.solicitudSoporte.findUnique.mockResolvedValue( + filaFake({ usuarioId: 'otro', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }), + ) + const { res, sendFile } = resFake() + + await descargarArchivoSoporteEndpoint( + reqFake({ + params: { id: 'sol-1', indice: '0' }, + usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' }, + }), + res, + ) + + expect(sendFile).toHaveBeenCalledWith(absoluta) + }) + + it('403 si la solicitud es de otro usuario y no es admin/manager', async () => { + prismaMock.solicitudSoporte.findUnique.mockResolvedValue( + filaFake({ usuarioId: 'otro', archivos: JSON.stringify(['soporte/otro/x.pdf']) }), + ) + await expect( + descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res), + ).rejects.toThrow('Sin permiso') + }) + + it('404 si la solicitud o el índice no existen', async () => { + prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null) + await expect( + descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'nope', indice: '0' } }), resFake().res), + ).rejects.toThrow('Solicitud no encontrada') + + prismaMock.solicitudSoporte.findUnique.mockResolvedValue( + filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }), + ) + await expect( + descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '5' } }), resFake().res), + ).rejects.toThrow('Archivo no encontrado') + }) + + it('400 si la ruta guardada apunta fuera de uploads (path traversal)', async () => { + prismaMock.solicitudSoporte.findUnique.mockResolvedValue( + filaFake({ archivos: JSON.stringify(['../../etc/passwd']) }), + ) + await expect( + descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res), + ).rejects.toThrow('Ruta de archivo inválida') + }) + + it('400 si el índice no es un entero válido', async () => { + await expect( + descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: 'x' } }), resFake().res), + ).rejects.toThrow('Índice de archivo inválido') + }) +}) + +// ── PATCH /api/soporte/:id ─────────────────────────────────────────── + +describe('cambiarEstadoSolicitudEndpoint', () => { + it('cambia el estado y deja auditoría', async () => { + prismaMock.solicitudSoporte.findUnique.mockResolvedValue(filaFake({ estado: 'ABIERTA' })) + const { res, json } = resFake() + + await cambiarEstadoSolicitudEndpoint( + reqFake({ + params: { id: 'sol-1' }, + body: { estado: 'EN_PROCESO' }, + usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' }, + }), + res, + ) + + expect(prismaMock.solicitudSoporte.update).toHaveBeenCalledWith({ + where: { id: 'sol-1' }, + data: { estado: 'EN_PROCESO' }, + }) + expect(json).toHaveBeenCalledWith(expect.objectContaining({ estado: 'EN_PROCESO' })) + expect(auditoriaMock.registrar).toHaveBeenCalledWith( + 'm-1', + 'CAMBIAR_ESTADO_SOPORTE', + 'soporte', + 'sol-1', + 'ABIERTA→EN_PROCESO', + ) + }) + + it('404 si la solicitud no existe', async () => { + prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null) + await expect( + cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'nope' }, body: { estado: 'CERRADA' } }), resFake().res), + ).rejects.toThrow('Solicitud no encontrada') + }) + + it('400 si el estado no es válido', async () => { + await expect( + cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'sol-1' }, body: { estado: 'VOLANDO' } }), resFake().res), + ).rejects.toThrow() + }) +}) + +describe('requireAdminOManager', () => { + it('deja pasar a admin y manager', () => { + for (const rol of ['admin', 'manager']) { + const next = vi.fn() + requireAdminOManager(reqFake({ usuarioToken: { sub: 'x', username: 'x', rol } }), resFake().res, next) + expect(next).toHaveBeenCalled() + } + }) + + it('rechaza a un vendedor', () => { + expect(() => + requireAdminOManager(reqFake(), resFake().res, vi.fn()), + ).toThrow('Se requiere rol admin o manager') + }) +}) diff --git a/api-gateway/src/controllers/soporte.controller.ts b/api-gateway/src/controllers/soporte.controller.ts index a20985d..2a9a497 100644 --- a/api-gateway/src/controllers/soporte.controller.ts +++ b/api-gateway/src/controllers/soporte.controller.ts @@ -1,21 +1,44 @@ -import type { Request, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' +import fs from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import multer from 'multer' import { z } from 'zod' +import { config } from '../config/index.js' import { prisma } from '../services/prisma.js' +import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js' +import { esAdminOManager } from '../utils/roles.js' import * as auditoria from '../services/auditoria.service.js' /** * Módulo Soporte: solicitudes dirigidas a los departamentos internos * (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección). * - * - POST /api/soporte — crear solicitud (cualquier usuario autenticado). - * - GET /api/soporte — listar solicitudes: propias para cualquier usuario, - * TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA). + * - POST /api/soporte — crear solicitud (cualquier usuario autenticado). Acepta + * multipart con los adjuntos reales en el campo `archivos` (PDF/JPG/PNG, + * máx 10 MB c/u, hasta 10) o JSON sin adjuntos. + * - GET /api/soporte — listar solicitudes: propias para un vendedor, TODAS + * para admin y manager, con filtros opcionales ?estado= y ?departamento=. + * - GET /api/soporte/:id/archivos/:indice — descargar un adjunto (dueño, + * admin o manager). + * - PATCH /api/soporte/:id — cambiar el estado (solo admin/manager). */ const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const +const MIME_PERMITIDOS_SOPORTE = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']) +const EXT_POR_MIME_SOPORTE: Record = { + 'application/pdf': 'pdf', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', +} + +const MAX_ARCHIVOS_SOPORTE = 10 +const MAX_BYTES_SOPORTE = 10 * 1024 * 1024 + const crearSolicitudSchema = z.object({ titulo: z.string().trim().min(1, 'El título es obligatorio'), descripcion: z.string().trim().min(1, 'La descripción es obligatoria'), @@ -29,16 +52,104 @@ const crearSolicitudSchema = z.object({ // username del token si viene vacío solicitante: z.string().trim().min(1).optional(), email: z.string().trim().email('Email inválido').optional(), - // Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda - // nombres; se guardan como JSON array) + // Rutas relativas de adjuntos YA subidos; con multipart se ignoran y se toman + // los archivos reales que entregó multer (nadie declara rutas a mano) archivos: z.array(z.string().trim().min(1)).optional(), }) const listarSolicitudSchema = z.object({ - estado: z.enum(ESTADOS_SOPORTE).optional(), + // '' = sin filtro: el front a veces manda el select vacío + estado: z.enum(ESTADOS_SOPORTE).or(z.literal('')).optional(), + departamento: z.enum(DEPARTAMENTOS_SOPORTE).or(z.literal('')).optional(), }) -/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */ +const cambiarEstadoSchema = z.object({ + estado: z.enum(ESTADOS_SOPORTE), +}) + +/** Request con el id de la solicitud ya reservado para la carpeta de adjuntos */ +type RequestConIdSoporte = Request & { solicitudSoporteId?: string } + +/** + * Id de la solicitud en curso. Se reserva ANTES de multer para que los adjuntos + * aterricen en uploads/soporte// con el mismo id que tendrá la fila. + */ +function idSolicitudDe(req: Request): string { + const conId = req as RequestConIdSoporte + conId.solicitudSoporteId ??= randomUUID() + return conId.solicitudSoporteId +} + +/** Carpeta de adjuntos de una solicitud: uploads/soporte// */ +function carpetaSoporte(id: string): string { + return path.join(config.uploadsDir, 'soporte', id) +} + +/** Nombre seguro y único: uuid corto + nombre original saneado (sin rutas) */ +function nombreSeguro(original: string): string { + const base = path.basename(original).replace(/[^\w.\- ]+/g, '_').slice(-80).trim() + return base.length > 0 ? base : 'archivo' +} + +const storageSoporte = multer.diskStorage({ + destination: (req, _file, cb) => { + const dir = carpetaSoporte(idSolicitudDe(req)) + fs.mkdirSync(dir, { recursive: true }) + cb(null, dir) + }, + filename: (_req, file, cb) => { + const ext = EXT_POR_MIME_SOPORTE[file.mimetype] ?? 'bin' + const base = nombreSeguro(file.originalname) + cb(null, `${randomUUID().slice(0, 8)}-${base.endsWith(`.${ext}`) ? base : `${base}.${ext}`}`) + }, +}) + +const multerArchivosSoporte = multer({ + storage: storageSoporte, + limits: { fileSize: MAX_BYTES_SOPORTE, files: MAX_ARCHIVOS_SOPORTE }, + fileFilter: (_req, file, cb) => { + if (!MIME_PERMITIDOS_SOPORTE.has(file.mimetype)) { + cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype} (solo PDF, JPG o PNG)`)) + return + } + cb(null, true) + }, +}).array('archivos', MAX_ARCHIVOS_SOPORTE) + +/** + * Middleware de subida: recibe hasta 10 adjuntos en el campo `archivos`. Si + * multer corta a mitad (tipo no permitido, archivo > 10 MB, demasiados + * archivos) borra lo que haya quedado en la carpeta de la solicitud. + */ +export function uploadArchivosSoporte(req: Request, res: Response, next: NextFunction): void { + multerArchivosSoporte(req, res, (err?: unknown) => { + if (err) { + borrarCarpetaSoporte(idSolicitudDe(req)) + next(err) + return + } + next() + }) +} + +/** Borra la carpeta de adjuntos de una solicitud que no llegó a crearse */ +function borrarCarpetaSoporte(id: string): void { + fs.rmSync(carpetaSoporte(id), { recursive: true, force: true }) +} + +/** archivos JSON → array de rutas relativas (tolerante a basura) */ +function parsearArchivos(archivos: string | null): string[] { + if (!archivos) return [] + try { + const parseado: unknown = JSON.parse(archivos) + if (Array.isArray(parseado)) return parseado.filter((a): a is string => typeof a === 'string') + return [] + } catch { + return [] + } +} + +/** Serializa la fila para el front: archivos JSON → array de rutas */ function aPublico(fila: { id: string usuarioId: string @@ -57,24 +168,40 @@ function aPublico(fila: { createdAt: Date updatedAt: Date }) { - let archivos: string[] = [] - if (fila.archivos) { - try { - const parseado: unknown = JSON.parse(fila.archivos) - if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string') - } catch { - archivos = [] - } - } - return { ...fila, archivos } + return { ...fila, archivos: parsearArchivos(fila.archivos) } } -/** POST /api/soporte — crear solicitud de soporte */ +/** MIME por extensión para servir el adjunto descargado */ +function mimeDeArchivo(ruta: string): string { + const ext = path.extname(ruta).toLowerCase() + if (ext === '.pdf') return 'application/pdf' + if (ext === '.png') return 'image/png' + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' + return 'application/octet-stream' +} + +/** POST /api/soporte — crear solicitud de soporte (JSON o multipart) */ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise { - const body = crearSolicitudSchema.parse(req.body) + const id = idSolicitudDe(req) + let body: z.infer + try { + body = crearSolicitudSchema.parse(req.body) + } catch (err) { + // Los adjuntos ya están en disco: sin solicitud que los referencie, sobran + borrarCarpetaSoporte(id) + throw err + } + + const subidos = (req.files ?? []) as Express.Multer.File[] + const archivos = + subidos.length > 0 + ? subidos.map((f) => path.relative(config.uploadsDir, f.path)) + : (body.archivos ?? []) + const usuarioId = req.usuarioToken?.sub ?? 'desconocido' const fila = await prisma.solicitudSoporte.create({ data: { + id, usuarioId, titulo: body.titulo, descripcion: body.descripcion, @@ -84,7 +211,7 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response) fechaSolicitud: body.fechaSolicitud, fechaLimite: body.fechaLimite ?? null, notas: body.notas ?? null, - archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null, + archivos: archivos.length > 0 ? JSON.stringify(archivos) : null, solicitante: body.solicitante ?? req.usuarioToken?.username ?? '', solicitanteEmail: body.email ?? null, }, @@ -93,15 +220,18 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response) auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento) } -/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */ +/** + * GET /api/soporte — listar solicitudes. Admin y manager ven TODAS (la UI les + * dice "Todas las solicitudes"); un vendedor solo ve las suyas. + */ export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise { - const { estado } = listarSolicitudSchema.parse(req.query) + const { estado, departamento } = listarSolicitudSchema.parse(req.query) const where: Record = {} - // Admin ve todas; el resto solo las suyas - if (req.usuarioToken?.rol !== 'admin') { + if (!esAdminOManager(req.usuarioToken?.rol)) { where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido' } - if (estado !== undefined) where['estado'] = estado + if (estado) where['estado'] = estado + if (departamento) where['departamento'] = departamento const filas = await prisma.solicitudSoporte.findMany({ where, @@ -109,3 +239,68 @@ export async function listarSolicitudesSoporteEndpoint(req: Request, res: Respon }) res.json(filas.map(aPublico)) } + +/** Middleware: solo admin/manager pueden mover solicitudes de estado */ +export function requireAdminOManager(req: Request, _res: Response, next: NextFunction): void { + if (!esAdminOManager(req.usuarioToken?.rol)) { + throw new AppError(403, 'Se requiere rol admin o manager') + } + next() +} + +/** PATCH /api/soporte/:id — cambiar estado (admin/manager) */ +export async function cambiarEstadoSolicitudEndpoint(req: Request, res: Response): Promise { + const id = String(req.params['id']) + const { estado } = cambiarEstadoSchema.parse(req.body) + + const existente = await prisma.solicitudSoporte.findUnique({ where: { id } }) + if (!existente) throw new NotFoundError('Solicitud no encontrada') + + const fila = await prisma.solicitudSoporte.update({ where: { id }, data: { estado } }) + res.json(aPublico(fila)) + auditoria.registrar( + req.usuarioToken?.sub ?? 'desconocido', + 'CAMBIAR_ESTADO_SOPORTE', + 'soporte', + fila.id, + `${existente.estado}→${estado}`, + ) +} + +/** + * GET /api/soporte/:id/archivos/:indice — descarga un adjunto de la solicitud. + * Solo el dueño, un admin o un manager. El índice es la posición en + * `archivos`, así el cliente nunca propone una ruta. + */ +export async function descargarArchivoSoporteEndpoint(req: Request, res: Response): Promise { + const id = String(req.params['id']) + const indice = Number(req.params['indice']) + if (!Number.isInteger(indice) || indice < 0) { + throw new BadRequestError('Índice de archivo inválido') + } + + const fila = await prisma.solicitudSoporte.findUnique({ where: { id } }) + if (!fila) throw new NotFoundError('Solicitud no encontrada') + + const propio = fila.usuarioId === (req.usuarioToken?.sub ?? 'desconocido') + if (!propio && !esAdminOManager(req.usuarioToken?.rol)) { + throw new AppError(403, 'Sin permiso para ver los adjuntos de esta solicitud') + } + + const archivo = parsearArchivos(fila.archivos)[indice] + if (archivo === undefined) throw new NotFoundError('Archivo no encontrado') + + // Guarda anti path-traversal: la ruta guardada debe vivir dentro de uploadsDir + const rutaAbsoluta = path.resolve(config.uploadsDir, archivo) + if (!rutaAbsoluta.startsWith(config.uploadsDir + path.sep)) { + throw new BadRequestError('Ruta de archivo inválida') + } + if (!fs.existsSync(rutaAbsoluta)) throw new NotFoundError('Archivo no encontrado en disco') + + res.setHeader('Content-Type', mimeDeArchivo(rutaAbsoluta)) + res.setHeader( + 'Content-Disposition', + `attachment; filename="${path.basename(rutaAbsoluta).replace(/[^\w.\- ]+/g, '_')}"`, + ) + res.sendFile(rutaAbsoluta) +} diff --git a/api-gateway/src/controllers/unidades.controller.test.ts b/api-gateway/src/controllers/unidades.controller.test.ts index ef3ac66..ec7d31c 100644 --- a/api-gateway/src/controllers/unidades.controller.test.ts +++ b/api-gateway/src/controllers/unidades.controller.test.ts @@ -9,6 +9,9 @@ const prismaMock = vi.hoisted(() => ({ findMany: vi.fn(), findUnique: vi.fn(), }, + capturaDepartamento: { + findMany: vi.fn(), + }, })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) @@ -37,6 +40,7 @@ beforeEach(() => { vi.clearAllMocks() prismaMock.unidad.findMany.mockResolvedValue([filaLocal]) prismaMock.unidad.findUnique.mockResolvedValue(filaLocal) + prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) }) describe('unidades.controller (local)', () => { @@ -58,6 +62,18 @@ describe('unidades.controller (local)', () => { }) }) + it('la fecha de entrega sale de la captura del departamento (ESCRITURACION/OBRA)', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' }, + ]) + const { res, json } = resFake() + + await listarUnidades({ query: {} } as unknown as Request, res) + + const lista = json.mock.calls[0]?.[0] as Record[] + expect(lista[0]).toMatchObject({ fechaEntrega: '2027-10-08' }) + }) + it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => { prismaMock.unidad.findMany.mockResolvedValue([ filaLocal, diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 58bca6e..62b9ed6 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -36,6 +36,7 @@ import { uploadDocumentosPublico, } from '../controllers/liga.controller.js' import { + actualizarPlan, agregarNota, cancelarPlan, cobranzaHoy, @@ -43,6 +44,7 @@ import { listarPlanes, obtenerPlan, registrarAbonoEndpoint, + requireEditaPlan, timelinePlan, } from '../controllers/planes.controller.js' import { @@ -148,13 +150,18 @@ import { import { listarEndpoint as listarMaestroEndpoint, exportarEndpoint as exportarMaestroEndpoint, + catalogosEndpoint as catalogosMaestroEndpoint, obtenerEndpoint as obtenerMaestroEndpoint, importarEndpoint as importarMaestroEndpoint, requireAdmin as requireAdminMaestro, } from '../controllers/cliente-maestro.controller.js' import { + cambiarEstadoSolicitudEndpoint, crearSolicitudSoporteEndpoint, + descargarArchivoSoporteEndpoint, listarSolicitudesSoporteEndpoint, + requireAdminOManager, + uploadArchivosSoporte, } from '../controllers/soporte.controller.js' export const apiRouter = Router() @@ -229,6 +236,8 @@ apiRouter.get('/planes/:id/timeline', requireModuloCobranza, timelinePlan) apiRouter.post('/planes/:id/abonos', requireModuloCobranza, registrarAbonoEndpoint) apiRouter.post('/planes/:id/notas', requireModuloCobranza, agregarNota) apiRouter.post('/planes/:id/cancelar', requireModuloCobranza, cancelarPlan) +// Editar la tasa de mora del plan: solo admin/manager/dirección +apiRouter.patch('/planes/:id', requireModuloCobranza, requireEditaPlan, actualizarPlan) apiRouter.get('/cobranza/recordatorios', listarRecordatorios) apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios) @@ -345,11 +354,14 @@ apiRouter.post('/push/desuscribir', desuscribirEndpoint) // ── Clientes Maestro ────────────────────────────────────────────── apiRouter.get('/clientes-maestro', listarMaestroEndpoint) -// Exportar va ANTES de /:numCliente para no colisionar con el parámetro +// Rutas fijas ANTES de /:numCliente para no colisionar con el parámetro +apiRouter.get('/clientes-maestro/catalogos', catalogosMaestroEndpoint) apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint) apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint) apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint) // ── Módulo Soporte: solicitudes a departamentos internos ────────── -apiRouter.post('/soporte', crearSolicitudSoporteEndpoint) +apiRouter.post('/soporte', uploadArchivosSoporte, crearSolicitudSoporteEndpoint) apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint) +apiRouter.get('/soporte/:id/archivos/:indice', descargarArchivoSoporteEndpoint) +apiRouter.patch('/soporte/:id', requireAdminOManager, cambiarEstadoSolicitudEndpoint) diff --git a/api-gateway/src/services/cliente-maestro.service.test.ts b/api-gateway/src/services/cliente-maestro.service.test.ts index e7fc142..144c66a 100644 --- a/api-gateway/src/services/cliente-maestro.service.test.ts +++ b/api-gateway/src/services/cliente-maestro.service.test.ts @@ -18,6 +18,7 @@ import { listar, obtener, contar, + catalogos, exportar, importarDesdeExcel, } from './cliente-maestro.service.js' @@ -49,23 +50,31 @@ describe('listar', () => { await listar({ busqueda: 'Juan', pagina: 1, limite: 50 }) expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: expect.objectContaining({ - OR: expect.arrayContaining([ - { nombre: { contains: 'Juan' } }, - ]), - }), + where: { + AND: [ + expect.objectContaining({ + OR: expect.arrayContaining([{ nombre: { contains: 'Juan' } }]), + }), + ], + }, }), ) }) - it('aplica filtro de desarrollo', async () => { + it('aplica filtro de desarrollo (valor exacto o con sufijo " - ...")', async () => { prismaMock.clienteMaestro.findMany.mockResolvedValue([]) prismaMock.clienteMaestro.count.mockResolvedValue(0) await listar({ desarrollo: 'SENDA', pagina: 1, limite: 50 }) expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: expect.objectContaining({ desarrollo: 'SENDA' }), + where: { + AND: [ + { + OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }], + }, + ], + }, }), ) }) @@ -77,10 +86,48 @@ describe('listar', () => { await listar({ estatus: 'VENDIDO', pagina: 1, limite: 50 }) expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: expect.objectContaining({ estatus: 'VENDIDO' }), + where: { AND: [{ estatus: 'VENDIDO' }] }, }), ) }) + + it('combina búsqueda, desarrollo y estatus en la misma consulta', async () => { + prismaMock.clienteMaestro.findMany.mockResolvedValue([]) + prismaMock.clienteMaestro.count.mockResolvedValue(0) + + await listar({ busqueda: 'Ana', desarrollo: 'SOLE', estatus: 'ENTREGA', pagina: 1, limite: 50 }) + const llamada = prismaMock.clienteMaestro.findMany.mock.calls[0]![0] as { where: { AND: unknown[] } } + expect(llamada.where.AND).toHaveLength(3) + }) + + it('sin filtros no arma condiciones', async () => { + prismaMock.clienteMaestro.findMany.mockResolvedValue([]) + prismaMock.clienteMaestro.count.mockResolvedValue(0) + + await listar({ pagina: 1, limite: 50 }) + expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: {} }), + ) + }) +}) + +// ── catalogos ──────────────────────────────────────────────────────── + +describe('catalogos', () => { + it('devuelve los desarrollos y estatus reales, sin vacíos ni nulos', async () => { + prismaMock.clienteMaestro.findMany + .mockResolvedValueOnce([{ desarrollo: 'MUTUO - EDIFICACION' }, { desarrollo: 'SOLE' }, { desarrollo: null }, { desarrollo: ' ' }]) + .mockResolvedValueOnce([{ estatus: 'ENTREGA' }, { estatus: 'VENDIDO' }, { estatus: null }]) + + const result = await catalogos() + expect(result.desarrollos).toEqual(['MUTUO - EDIFICACION', 'SOLE']) + expect(result.estatuses).toEqual(['ENTREGA', 'VENDIDO']) + expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith({ + distinct: ['desarrollo'], + select: { desarrollo: true }, + orderBy: { desarrollo: 'asc' }, + }) + }) }) // ── obtener ────────────────────────────────────────────────────────── @@ -113,7 +160,11 @@ describe('contar', () => { prismaMock.clienteMaestro.count.mockResolvedValue(5) expect(await contar({ desarrollo: 'SENDA' })).toBe(5) expect(prismaMock.clienteMaestro.count).toHaveBeenCalledWith( - expect.objectContaining({ where: { desarrollo: 'SENDA' } }), + { + where: { + AND: [{ OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }] }], + }, + }, ) }) }) diff --git a/api-gateway/src/services/cliente-maestro.service.ts b/api-gateway/src/services/cliente-maestro.service.ts index 6bc7fa4..42ab09a 100644 --- a/api-gateway/src/services/cliente-maestro.service.ts +++ b/api-gateway/src/services/cliente-maestro.service.ts @@ -44,26 +44,37 @@ export type ClienteMaestroDTO = { // ── Helpers ───────────────────────────────────────────────────────── function construirWhere(filtros: FiltrosClientesMaestro) { - const where: Record = {} + const condiciones: Record[] = [] - if (filtros.desarrollo) { - where.desarrollo = filtros.desarrollo + const desarrollo = filtros.desarrollo?.trim() + if (desarrollo) { + // El mismo desarrollo convive con sufijos en la base ("ATELIER" y + // "ATELIER - EDIFICACION"): se acepta el valor exacto o su prefijo, así el + // filtro funciona tanto con el valor crudo como con el agrupado. + condiciones.push({ + OR: [{ desarrollo }, { desarrollo: { startsWith: `${desarrollo} -` } }], + }) } - if (filtros.estatus) { - where.estatus = filtros.estatus + + const estatus = filtros.estatus?.trim() + if (estatus) { + condiciones.push({ estatus }) } + if (filtros.busqueda) { const term = filtros.busqueda.trim() - where.OR = [ - { nombre: { contains: term } }, - { apellidoPaterno: { contains: term } }, - { apellidoMaterno: { contains: term } }, - { telefono: { contains: term } }, - { numCliente: { contains: term } }, - ] + condiciones.push({ + OR: [ + { nombre: { contains: term } }, + { apellidoPaterno: { contains: term } }, + { apellidoMaterno: { contains: term } }, + { telefono: { contains: term } }, + { numCliente: { contains: term } }, + ], + }) } - return where + return condiciones.length > 0 ? { AND: condiciones } : {} } // ── Service ───────────────────────────────────────────────────────── @@ -102,6 +113,33 @@ export async function obtener(numCliente: string): Promise { + const [desarrollos, estatuses] = await Promise.all([ + prisma.clienteMaestro.findMany({ + distinct: ['desarrollo'], + select: { desarrollo: true }, + orderBy: { desarrollo: 'asc' }, + }), + prisma.clienteMaestro.findMany({ + distinct: ['estatus'], + select: { estatus: true }, + orderBy: { estatus: 'asc' }, + }), + ]) + + const limpiar = (valores: Array): string[] => + valores.filter((v): v is string => v !== null && v.trim() !== '') + + return { + desarrollos: limpiar(desarrollos.map((d) => d.desarrollo)), + estatuses: limpiar(estatuses.map((e) => e.estatus)), + } +} + /** * Cuenta clientes con filtros. */ diff --git a/api-gateway/src/services/cobranza.service.test.ts b/api-gateway/src/services/cobranza.service.test.ts index cea8718..8b9fbc2 100644 --- a/api-gateway/src/services/cobranza.service.test.ts +++ b/api-gateway/src/services/cobranza.service.test.ts @@ -8,6 +8,7 @@ import { generarCalendario, moraDePago, pagadoTotal, + pagoAtrasado, proximoVencimiento, round2, saldoPlan, @@ -203,6 +204,23 @@ describe('saldo, avance y status del plan', () => { expect(statusPlan(plan)).toBe('CANCELADO') }) + it('sin calendario capturado → SIN_PLAN, no LIQUIDADO', () => { + const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 } + expect(statusPlan(sinPagos)).toBe('SIN_PLAN') + // con pagos pero todos en cero (captura incompleta): tampoco es liquidado + const enCeros: PlanPago = { + ...planDePrueba(), + engancheTotal: 0, + pagos: planDePrueba().pagos.map((p) => ({ ...p, monto: 0 })), + } + expect(statusPlan(enCeros)).toBe('SIN_PLAN') + }) + + it('un plan SIN_PLAN no entra en la cobranza del día', () => { + const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 } + expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0) + }) + it('alCorriente es falso cuando hay pagos vencidos', () => { expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false) expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true) @@ -233,10 +251,13 @@ describe('proximoVencimiento y diasAtraso', () => { expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0) }) - it('un pago PARCIAL vencido no cuenta como atraso', () => { + it('un pago PARCIAL vencido SÍ cuenta como atraso (corrección: antes se ignoraba)', () => { let plan = planDePrueba() plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' }) - expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0) + // pago 1 vence 10-ene y sigue sin cubrir (abono de 100) → 22 días al 1-feb + expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(22) + expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(false) + expect(pagoAtrasado(plan.pagos[0]!, new Date('2026-02-01T00:00:00'))).toBe(true) }) }) diff --git a/api-gateway/src/services/cobranza.service.ts b/api-gateway/src/services/cobranza.service.ts index 4f59db6..a7a9879 100644 --- a/api-gateway/src/services/cobranza.service.ts +++ b/api-gateway/src/services/cobranza.service.ts @@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto' */ export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL' -export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' +/** SIN_PLAN = no hay calendario capturado (no debe confundirse con LIQUIDADO) */ +export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' | 'SIN_PLAN' export interface AplicacionPago { id: string @@ -137,7 +138,8 @@ export function generarCalendario( * PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos: * VENCIDO si hoy > fechaVencimiento, si no PENDIENTE. * Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo - * PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`. + * PARCIAL (tiene abono aplicado); el atraso se señala con `pagoAtrasado`, + * `diasAtraso` y `alCorriente`, que sí lo cuentan como vencido. */ export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago { if (gte(pago.montoPagado, pago.monto)) return 'PAGADO' @@ -184,10 +186,10 @@ export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date( return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000) } -/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */ +/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin cubrir) */ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number { const vencidos = plan.pagos - .filter((p) => statusPago(p, hoy) === 'VENCIDO') + .filter((p) => pagoAtrasado(p, hoy)) .sort( (a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(), @@ -197,15 +199,39 @@ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number { return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000) } -/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */ +/** + * ¿El pago está atrasado? Un pago sin cubrir cuya fecha de vencimiento ya + * pasó: incluye el PARCIAL vencido (tiene abono pero no cubre el monto), que + * es un atraso real aunque `statusPago` lo siga etiquetando PARCIAL. + */ +export function pagoAtrasado(pago: Pago, hoy: Date = new Date()): boolean { + if (estaCubierto(pago)) return false + return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 +} + +/** + * Status derivado del plan: + * - CANCELADO: solo manual + * - SIN_PLAN: no hay calendario capturado (0 pagos o montos en 0). Distinto de + * LIQUIDADO: el adeudo real es desconocido, no saldado; queda fuera de la + * cobranza del día hasta que se capture el plan. + * - LIQUIDADO: saldo == 0 + * - ACTIVO: tiene saldo pendiente + */ export function statusPlan(plan: PlanPago): StatusPlan { if (plan.status === 'CANCELADO') return 'CANCELADO' + if (!tieneCalendario(plan)) return 'SIN_PLAN' return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO' } -/** Al corriente = sin pagos VENCIDOS */ +/** ¿El plan tiene calendario capturado? (al menos un pago con monto > 0) */ +export function tieneCalendario(plan: PlanPago): boolean { + return plan.pagos.some((p) => p.monto > EPS) +} + +/** Al corriente = sin pagos atrasados (un PARCIAL vencido también es atraso) */ export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean { - return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO') + return !plan.pagos.some((p) => pagoAtrasado(p, hoy)) } /** @@ -236,10 +262,11 @@ export interface CobranzaItem { } /** - * Lista plana de "cobranza del día" a través de todos los planes ACTIVOS: - * pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos - * `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual - * atraso, el vencimiento más próximo primero. + * Lista plana de "cobranza del día" a través de todos los planes ACTIVOS + * (los SIN_PLAN quedan fuera: sin calendario capturado no hay nada que + * cobrar): pagos vencidos (incluye PARCIAL vencido) más los que vencen en los + * próximos `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a + * igual atraso, el vencimiento más próximo primero. */ export function cobranzaDelDia( planes: PlanPago[], diff --git a/api-gateway/src/services/contactos-detalle.service.ts b/api-gateway/src/services/contactos-detalle.service.ts index d4b9415..ff340be 100644 --- a/api-gateway/src/services/contactos-detalle.service.ts +++ b/api-gateway/src/services/contactos-detalle.service.ts @@ -48,6 +48,56 @@ export function claveTelefono(telefono: string): string { return digitos.length >= 10 ? digitos.slice(-10) : '' } +/** + * Clave de nombre para deduplicar/relacionar contactos que no traen teléfono ni + * email (las altas fallidas dejan un registro hueco con el mismo nombre). + * Sin acentos, sin mayúsculas y con los espacios colapsados. + */ +export function claveNombre(nombre: string): string { + return nombre + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' ') +} + +/** + * Aplana el JSON de perfil a un mapa campo→valor no vacío: sirve tanto para el + * `payloadJson` del alta de cliente (anidado por secciones) como para el + * `datosJson` del perfilamiento de una captura de afluencia. + * + * Mismo criterio que el reporte de clientes: gana el primer valor no vacío y se + * ignoran las secciones que repiten los mismos campos vacíos (coacreditado) o + * que no son de perfil. Así una sección posterior no pisa un dato real. + */ +export function aplanarPerfil(json: string | null | undefined): Record { + if (!json) return {} + try { + const datos = JSON.parse(json) as Record + const plano: Record = {} + const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias']) + const recorrer = (obj: Record, raiz: boolean): void => { + for (const [k, v] of Object.entries(obj)) { + if (v === null || v === undefined) continue + if (typeof v === 'object' && !Array.isArray(v)) { + if (raiz && SECCIONES_IGNORADAS.has(k)) continue + recorrer(v as Record, false) + continue + } + if (typeof v === 'object') continue + const texto = String(v) + if (texto.trim() === '') continue + if (plano[k] === undefined) plano[k] = texto + } + } + recorrer(datos, true) + return plano + } catch { + return {} + } +} + // ── Perfil Completo del Contacto (modo local) ───────────────────────── export interface PerfilCompletoContacto { diff --git a/api-gateway/src/services/contactos-local.service.test.ts b/api-gateway/src/services/contactos-local.service.test.ts index 7ca4313..2f251ca 100644 --- a/api-gateway/src/services/contactos-local.service.test.ts +++ b/api-gateway/src/services/contactos-local.service.test.ts @@ -118,6 +118,99 @@ describe('listarContactosLocal', () => { expect(lista[0]?.id).toBe('c1') }) + it('deduplica por nombre a los registros sin teléfono ni email (gana la ficha con más datos)', async () => { + // Alta fallida: Cliente hueco (payload vacío, sin teléfono ni email) con el + // mismo nombre que su ficha real + prismaMock.cliente.findMany.mockResolvedValue([ + clienteFake({ + id: 'hueco', + nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA', + payloadJson: '{}', + createdAt: new Date('2026-09-11T10:00:00'), + }), + clienteFake({ + id: 'completo', + nombreCompleto: 'Luis Enrique Salgado Zavaleta', + celular: '+52 5586880444', + email: 'luisenriquesalgadozavaleta@gmail.com', + curp: 'SAZL990504HDFLVS06', + rfc: 'SAZL990504KY0', + proyecto: 'SOLANA', + unidadCodigo: '9', + payloadJson: '{"datosGenerales":{"estadoCivil":"SOLTERO"}}', + }), + ]) + + const lista = (await listarContactosLocal()).contactos + + expect(lista).toHaveLength(1) + expect(lista[0]).toMatchObject({ + id: 'completo', + nombre: 'Luis Enrique Salgado Zavaleta', + telefono: '+52 5586880444', + email: 'luisenriquesalgadozavaleta@gmail.com', + estado: 'Cliente', + }) + }) + + it('fusiona el registro hueco aunque llegue después de la ficha completa', async () => { + prismaMock.cliente.findMany.mockResolvedValue([ + clienteFake({ + id: 'completo', + nombreCompleto: 'GRACIELA BAUTISTA VELASCO', + celular: '+52 6641159092', + email: 'gbautista1304@gmail.com', + }), + clienteFake({ id: 'hueco', nombreCompleto: 'Graciela Bautista Velasco', payloadJson: '{}' }), + ]) + + const lista = (await listarContactosLocal()).contactos + expect(lista).toHaveLength(1) + expect(lista[0]?.id).toBe('completo') + }) + + it('no fusiona a dos personas distintas con el mismo nombre si tienen teléfonos distintos', async () => { + prismaMock.cliente.findMany.mockResolvedValue([ + clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567' }), + clienteFake({ id: 'c2', nombreCompleto: 'JUAN PÉREZ', celular: '6649998877' }), + ]) + + const lista = (await listarContactosLocal()).contactos + expect(lista.map((c) => c.id).sort()).toEqual(['c1', 'c2']) + }) + + it('un Cliente hueco se fusiona con la captura del mismo nombre (gana la que trae más datos)', async () => { + prismaMock.cliente.findMany.mockResolvedValue([ + clienteFake({ + id: 'hueco', + nombreCompleto: 'Guadalupe', + curp: '', + rfc: '', + payloadJson: '{}', + createdAt: new Date('2026-09-15T10:00:00'), + }), + ]) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ + leadId: 'loc_g', + nombreLead: 'Guadalupe', + telefono: '+52 664 222 6090', + email: 'lupisintj1@hotmail.com', + etapa: 'Contactado', + }), + ]) + + const lista = (await listarContactosLocal()).contactos + + expect(lista).toHaveLength(1) + expect(lista[0]).toMatchObject({ + id: 'loc_g', + telefono: '+52 664 222 6090', + email: 'lupisintj1@hotmail.com', + estado: 'Contactado', + }) + }) + it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => { // 101 clientes: la página 1 llena 100 y detecta que hay más prismaMock.cliente.findMany.mockResolvedValue( @@ -233,4 +326,110 @@ describe('detalleContactoLocal', () => { expect(detalle.relaciones.oportunidades).toHaveLength(1) expect(detalle.historial.length).toBeGreaterThan(0) }) + + it('completa el perfil con el payloadJson del alta (estado civil, nacimiento, actividad, dirección)', async () => { + prismaMock.cliente.findFirst.mockResolvedValue( + clienteFake({ + id: 'c1', + nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA', + celular: '5586880444', + email: 'luis@correo.mx', + curp: '', + rfc: '', + payloadJson: JSON.stringify({ + datosGenerales: { + estadoCivil: 'SOLTERO', + fechaNacimiento: '1999-05-04', + actividadOcupacion: 'PILOTO AVIADOR', + direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA', + }, + empleo: { compania: 'FRASA', direccion: 'AV COLINA DE SAN PABLO' }, + // El co-acreditado repite los campos vacíos: no debe pisar los reales + coacreditado: { tieneCoacreditado: false, generales: { estadoCivil: '', fechaNacimiento: '', actividadOcupacion: '', direccion: '' } }, + }), + }), + ) + + const detalle = await detalleContactoLocal('c1') + expect(detalle?.contacto).toMatchObject({ + estadoCivil: 'SOLTERO', + fechaNacimiento: '1999-05-04', + actividadEconomica: 'PILOTO AVIADOR', + direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA', + }) + }) + + it('toma el perfilamiento del datosJson de la captura cuando no hay Cliente', async () => { + const captura = capturaFake({ + leadId: 'loc_1', + nombreLead: 'RAMON MORALES', + telefono: '6643535353', + email: 'ramon.morales@gmail.com', + datosJson: JSON.stringify({ + edad: 41, + estadoCivil: 'Casado(a)', + ocupacion: 'empleado', + rangoSalarial: '50000', + }), + }) + prismaMock.leadCaptura.findUnique.mockResolvedValue(captura) + prismaMock.leadCaptura.findMany.mockResolvedValue([captura]) + + const detalle = await detalleContactoLocal('loc_1') + expect(detalle?.contacto).toMatchObject({ + nombre: 'RAMON MORALES', + estadoCivil: 'Casado(a)', + actividadEconomica: 'empleado', + }) + // No se inventa fecha de nacimiento a partir de la edad + expect(detalle?.contacto['fechaNacimiento']).toBe('') + expect(detalle?.contacto['direccion']).toBe('') + }) + + it('incluye los documentos del registro hueco de la misma persona (no se pierden al fusionar)', async () => { + prismaMock.cliente.findFirst.mockResolvedValue( + clienteFake({ + id: 'c1', + nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', + celular: '6643998451', + email: 'daleman@frasa.com.mx', + }), + ) + prismaMock.cliente.findMany.mockResolvedValue([ + { id: 'hueco', nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', celular: null, email: null }, + { id: 'otro', nombreCompleto: 'OTRA PERSONA', celular: null, email: null }, + ]) + prismaMock.documento.findMany.mockResolvedValue([ + { id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') }, + ]) + + const detalle = await detalleContactoLocal('c1') + + expect(prismaMock.documento.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { clienteId: { in: ['c1', 'hueco'] } } }), + ) + expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true) + }) + + it('si la ficha visible es la captura, también se muestran los documentos de su Cliente hueco', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue( + capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }), + ) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }), + ]) + prismaMock.cliente.findMany.mockResolvedValue([ + { id: 'hueco', nombreCompleto: 'Guadalupe', celular: null, email: null }, + ]) + prismaMock.documento.findMany.mockResolvedValue([ + { id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') }, + ]) + + const detalle = await detalleContactoLocal('loc_g') + + expect(prismaMock.documento.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { clienteId: { in: ['hueco'] } } }), + ) + expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true) + }) }) diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts index 86284cf..50a1a81 100644 --- a/api-gateway/src/services/contactos-local.service.ts +++ b/api-gateway/src/services/contactos-local.service.ts @@ -1,4 +1,11 @@ -import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js' +import { + aplanarPerfil, + claveNombre, + claveTelefono, + type DetalleContacto, + type EventoHistorialContacto, + type RelacionCrm, +} from './contactos-detalle.service.js' import { prisma } from './prisma.js' /** @@ -20,10 +27,10 @@ export interface ContactoListaLocal { /** * Lista local: Clientes (alta de clientes) + capturas del embudo en boards - * OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la - * ficha completa), más reciente primero. Paginada de 100 en 100 (el front - * pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual: - * clientes capturados por él + leads asignados a él. + * OPORTUNIDADES/APARTADO, deduplicados (gana el Cliente: tiene la ficha + * completa), más reciente primero. Paginada de 100 en 100 (el front pagina con + * `?pagina=N` y muestra el límite). Filtra por usuario actual: clientes + * capturados por él + leads asignados a él. */ export const CONTACTOS_POR_PAGINA = 100 @@ -50,6 +57,37 @@ export async function esContactoDelUsuario(contactoId: string, usuarioId: string return cliente !== null || captura !== null } +/** Candidato de la lista antes de deduplicar */ +interface Candidato { + contacto: ContactoListaLocal + /** La ficha de Cliente (alta completa) gana a la captura al fusionar */ + esCliente: boolean + /** Cuántos datos trae el registro: desempata la fusión de duplicados */ + peso: number +} + +/** Cuenta los valores no vacíos de la ficha (para saber cuál está más completa) */ +function pesoDeFicha(valores: readonly (string | null | undefined)[], conPerfil: boolean): number { + const llenos = valores.filter((v) => v !== null && v !== undefined && v.trim() !== '').length + return llenos + (conPerfil ? 1 : 0) +} + +/** true si el JSON trae algo más que un objeto/contenedor vacío */ +function tieneDatos(json: string | null | undefined): boolean { + if (!json) return false + return Object.keys(aplanarPerfil(json)).length > 0 +} + +/** + * Lista de contactos locales deduplicada en dos pasos: + * 1. por teléfono (últimos 10 dígitos), como siempre; + * 2. por nombre para los registros SIN teléfono ni email — las altas fallidas + * dejan un Cliente hueco con el mismo nombre que su ficha real, y antes se + * repetía siempre porque la clave de teléfono era ''. + * Al fusionar se conserva el registro con más datos y se completan sus vacíos + * con los del otro (por teléfono gana el Cliente: es la ficha del alta), sin + * perder documentos ni relaciones: el detalle une también las dos fichas. + */ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise { const porPagina = CONTACTOS_POR_PAGINA const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1 @@ -71,43 +109,104 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom take: porPagina + 1, }) - const vistos = new Set() - const lista: ContactoListaLocal[] = [] - const agregar = (entrada: ContactoListaLocal): void => { - // Clave por teléfono; si no hay, por nombre + email (antes los contactos sin - // teléfono se duplicaban siempre) - const tel = claveTelefono(entrada.telefono) - const clave = tel !== '' - ? tel - : `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}` - if (vistos.has(clave)) return - vistos.add(clave) - lista.push(entrada) + const lista: Candidato[] = [] + const porTelefono = new Map() + const porNombre = new Map() + + /** + * Deja en la posición `i` la ficha ganadora, completando sus vacíos con los + * del otro registro. Por teléfono gana el Cliente (mismo teléfono = misma + * persona, y su alta es la ficha completa); por nombre gana el que tiene más + * datos, que es el criterio de la fusión de duplicados huecos. + */ + const fusionar = (i: number, nueva: Candidato, tel: string, nombre: string, porNombreClave: boolean): void => { + const previa = lista[i]! + const ganaNueva = porNombreClave + ? nueva.peso !== previa.peso + ? nueva.peso > previa.peso + : nueva.esCliente + : nueva.esCliente !== previa.esCliente + ? nueva.esCliente + : nueva.peso > previa.peso + const [ganadora, perdedora] = ganaNueva ? [nueva, previa] : [previa, nueva] + lista[i] = { + esCliente: ganadora.esCliente, + peso: ganadora.peso, + contacto: { + ...ganadora.contacto, + nombre: ganadora.contacto.nombre.trim() !== '' ? ganadora.contacto.nombre : perdedora.contacto.nombre, + telefono: ganadora.contacto.telefono !== '' ? ganadora.contacto.telefono : perdedora.contacto.telefono, + email: ganadora.contacto.email !== '' ? ganadora.contacto.email : perdedora.contacto.email, + estado: ganadora.contacto.estado !== '' ? ganadora.contacto.estado : perdedora.contacto.estado, + }, + } + if (tel !== '') porTelefono.set(tel, i) + porNombre.set(nombre, i) + } + + /** ¿El registro ya agregado en esa posición no tiene teléfono ni email? */ + const esHueco = (i: number): boolean => { + const c = lista[i]!.contacto + return c.telefono.trim() === '' && c.email.trim() === '' + } + + const agregar = (nueva: Candidato): void => { + const tel = claveTelefono(nueva.contacto.telefono) + const nombre = claveNombre(nueva.contacto.nombre) + const email = nueva.contacto.email.trim() + const iNombre = porNombre.get(nombre) + // Sin teléfono ni email el nombre es lo único que identifica a la persona. + // Con teléfono o email sólo se une a un registro hueco del mismo nombre (la + // misma persona antes de completar su ficha), nunca a otro teléfono. + const iSinContacto = + tel === '' && email === '' + ? iNombre + : iNombre !== undefined && esHueco(iNombre) + ? iNombre + : undefined + const iTelefono = tel !== '' ? porTelefono.get(tel) : undefined + const i = iTelefono ?? iSinContacto + if (i !== undefined) { + fusionar(i, nueva, tel, nombre, iTelefono === undefined) + return + } + lista.push(nueva) + const indice = lista.length - 1 + if (tel !== '') porTelefono.set(tel, indice) + porNombre.set(nombre, indice) } for (const c of clientes) { agregar({ - id: c.id, - nombre: c.nombreCompleto, - telefono: c.celular ?? '', - email: c.email ?? '', - estado: c.proyecto.trim() !== '' ? 'Cliente' : '', - updatedAt: c.createdAt.toISOString(), - fuente: 'local', + contacto: { + id: c.id, + nombre: c.nombreCompleto, + telefono: c.celular ?? '', + email: c.email ?? '', + estado: c.proyecto.trim() !== '' ? 'Cliente' : '', + updatedAt: c.createdAt.toISOString(), + fuente: 'local', + }, + esCliente: true, + peso: pesoDeFicha([c.celular, c.email, c.curp, c.rfc, c.proyecto, c.unidadCodigo], tieneDatos(c.payloadJson)), }) } for (const c of capturas) { agregar({ - id: c.leadId, - nombre: c.nombreLead, - telefono: c.telefono ?? '', - email: c.email ?? '', - estado: c.etapa, - updatedAt: c.updatedAt.toISOString(), - fuente: 'local', + contacto: { + id: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + estado: c.etapa, + updatedAt: c.updatedAt.toISOString(), + fuente: 'local', + }, + esCliente: false, + peso: pesoDeFicha([c.telefono, c.email, c.desarrollo, c.modelo], tieneDatos(c.datosJson)), }) } - const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) const hayMas = ordenada.length > porPagina return { contactos: ordenada.slice(0, porPagina), @@ -117,11 +216,34 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom } } +/** + * Clientes huecos con el mismo nombre que el contacto: un alta fallida deja un + * registro sin teléfono ni email (payload vacío) que la lista fusiona con su + * ficha real. Sólo se miran los huecos — nombre + un teléfono/email propios no + * identifican a la misma persona. `excluirId` omite el propio contacto. + */ +async function huecosConMismoNombre(nombre: string, excluirId: string | null): Promise { + const huecos = await prisma.cliente.findMany({ + where: { + AND: [ + ...(excluirId !== null ? [{ id: { not: excluirId } }] : []), + { OR: [{ celular: null }, { celular: '' }] }, + { OR: [{ email: null }, { email: '' }] }, + ], + }, + select: { id: true, nombreCompleto: true }, + }) + const clave = claveNombre(nombre) + return huecos.filter((c) => claveNombre(c.nombreCompleto) === clave).map((c) => c.id) +} + /** * Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id * heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se * arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el * historial se deriva de capturas + alta + documentos + cotizaciones locales. + * El perfil (estado civil, fecha de nacimiento, actividad económica, dirección) + * se completa con el payloadJson del alta y el datosJson de las capturas. * null si no existe en ninguna tabla. */ export async function detalleContactoLocal(contactoId: string): Promise { @@ -160,8 +282,22 @@ export async function detalleContactoLocal(contactoId: string): Promise b.fechaISO.localeCompare(a.fechaISO)) + // Perfil del alta (payloadJson del Cliente) y de las capturas de afluencia + // (datosJson): de ahí salen estado civil, fecha de nacimiento, actividad + // económica y dirección, que no son columnas de la BD. Gana el primer valor + // no vacío, así una fuente posterior no pisa un dato real con un vacío. + const perfiles = [ + aplanarPerfil(cliente?.payloadJson), + ...[capturaBase, ...relacionadas].map((c) => aplanarPerfil(c?.datosJson)), + ] + const dePerfil = (...claves: string[]): string => { + for (const clave of claves) { + for (const perfil of perfiles) { + const valor = (perfil[clave] ?? '').trim() + if (valor !== '') return valor + } + } + return '' + } + const contacto: Record = cliente !== null ? { @@ -252,14 +406,24 @@ export async function detalleContactoLocal(contactoId: string): Promise() + const claveNombre = (nombre: string): string => nombre.trim().toLowerCase().replace(/\s+/g, ' ') for (const c of clientes) { - const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10) - if (clave !== '' && vistos.has(clave)) continue - if (clave !== '') vistos.add(clave) + const claveTelefono = (c.celular ?? '').replace(/\D/g, '').slice(-10) + const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(c.nombreCompleto)}` + if (vistos.has(clave)) continue + vistos.add(clave) filas.push({ Nombre: c.nombreCompleto, Teléfono: c.celular ?? '', @@ -314,9 +483,10 @@ export async function exportarCSV( } for (const cap of capturas) { - const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10) - if (clave !== '' && vistos.has(clave)) continue - if (clave !== '') vistos.add(clave) + const claveTelefono = (cap.telefono ?? '').replace(/\D/g, '').slice(-10) + const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(cap.nombreLead)}` + if (vistos.has(clave)) continue + vistos.add(clave) filas.push({ Nombre: cap.nombreLead, Teléfono: cap.telefono ?? '', diff --git a/api-gateway/src/services/cotizacion-desarrollos.service.test.ts b/api-gateway/src/services/cotizacion-desarrollos.service.test.ts index 7665234..954a001 100644 --- a/api-gateway/src/services/cotizacion-desarrollos.service.test.ts +++ b/api-gateway/src/services/cotizacion-desarrollos.service.test.ts @@ -122,7 +122,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0, N = 4)', () => { }) describe('SENDA — unidad sin fecha de entrega', () => { - it('N = null, mensualidades "—" y total pagado null', () => { + it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => { const sinFecha = unidad({ codigo: 'SENDA-411-25-A101', desarrollo: 'SENDA', @@ -131,10 +131,12 @@ describe('SENDA — unidad sin fecha de entrega', () => { precioConAcabados: 1948000, }) const p = calcularPropuesta(sinFecha, 'BANCARIO', false, APARTADO_DEFAULT) - expect(p.numMensualidades).toBeNull() - expect(p.mensualidad).toBeNull() - expect(p.notaMensualidades).toBe('sin fecha de entrega') - expect(totalPagado(p, APARTADO_DEFAULT)).toBeNull() + // Antes salía numMensualidades/mensualidad null y "— (sin fecha de entrega)" + expect(p.numMensualidades).toBe(6) // nDefault de SENDA + expect(p.notaMensualidades).toBeUndefined() + expect(p.mensualidadesTotal).toBeCloseTo(97400, 2) + expect(p.mensualidad).toBeCloseTo(97400 / 6, 4) + expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2) expect(p.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2) expect(p.pagoFinal).toBeCloseTo(0.9 * 1948000, 2) }) @@ -149,12 +151,13 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => { mesesParaFirma: 1, }) - it('INFONAVIT con enganche 0% (guarda: no resta el apartado)', () => { + it('INFONAVIT con enganche 0%: el apartado se descuenta (no se cobra encima del precio)', () => { const p = calcularPropuesta(casa6, 'INFONAVIT', false, APARTADO_DEFAULT) expect(p.enganche).toBe(0) expect(p.numMensualidades).toBe(1) - expect(p.mensualidadesTotal).toBeCloseTo(128000, 2) + expect(p.mensualidadesTotal).toBeCloseTo(108000, 2) expect(p.pagoFinal).toBeCloseTo(2432000, 2) + expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2) }) it('BANCARIO 5% sobre final', () => { @@ -164,3 +167,82 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => { expect(p.pagoFinal).toBeCloseTo(2304000, 2) }) }) + +describe('cuadre del total: apartado y enganche personalizado', () => { + const TIPOS = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO'] as const + const CASOS = [ + { desarrollo: 'DIAGONAL', precio: 4_415_000 }, + { desarrollo: 'ATELIER', precio: 3_997_500 }, + { desarrollo: 'ESTANZA', precio: 2_437_250 }, + { desarrollo: 'SENDA', precio: 2_451_750 }, + { desarrollo: 'SOLANA', precio: 2_560_000 }, + ] + const unidadDe = (desarrollo: string, precio: number) => + unidad({ + codigo: `${desarrollo}-1`, + desarrollo, + precioSinAcabados: precio, + precioConAcabados: precio, + }) + + it('las 4 propuestas de cada desarrollo cuadran con su precio final', () => { + for (const caso of CASOS) { + for (const tipo of TIPOS) { + const p = calcularPropuesta(unidadDe(caso.desarrollo, caso.precio), tipo, false, APARTADO_DEFAULT) + expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2) + } + } + }) + + it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => { + for (const caso of CASOS) { + for (const tipo of TIPOS) { + for (const apartado of [0, APARTADO_DEFAULT, 200_000]) { + for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) { + const p = calcularPropuesta( + unidadDe(caso.desarrollo, caso.precio), + tipo, + false, + apartado, + undefined, + enganchePct, + ) + expect(p.enganche).toBeGreaterThanOrEqual(0) + expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0) + expect(p.pagoFinal).toBeGreaterThanOrEqual(0) + expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2) + } + } + } + } + }) + + it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => { + const u101 = unidad({ + codigo: 'U-101', + desarrollo: 'DIAGONAL', + precioSinAcabados: 4_415_000, + precioConAcabados: 4_579_000, + }) + const p = calcularPropuesta(u101, 'BANCARIO', false, APARTADO_DEFAULT, undefined, 0.2) + // Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000 + expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2) + expect(p.mensualidadesTotal).toBe(0) + expect(p.pagoFinal).toBeCloseTo(4_415_000 - APARTADO_DEFAULT - p.enganche, 2) + }) + + it('apartado mayor al enganche: el enganche queda en 0 (nunca negativo) y el total cuadra', () => { + const u101 = unidad({ + codigo: 'U-101', + desarrollo: 'DIAGONAL', + precioSinAcabados: 4_415_000, + precioConAcabados: 4_579_000, + }) + // 2.5% de 4,415,000 = 110,375; con apartado 200,000 el neto sería −89,625 + const p = calcularPropuesta(u101, 'BANCARIO', false, 200_000) + expect(p.enganche).toBe(0) + // El apartado de más se acredita en las mensualidades (remanente real) + expect(p.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - p.pagoFinal, 2) + expect(totalPagado(p, 200_000)).toBeCloseTo(4_415_000, 2) + }) +}) diff --git a/api-gateway/src/services/cotizacion.service.ts b/api-gateway/src/services/cotizacion.service.ts index 51d06e3..c422acc 100644 --- a/api-gateway/src/services/cotizacion.service.ts +++ b/api-gateway/src/services/cotizacion.service.ts @@ -69,6 +69,12 @@ interface ParametrosDesarrollo { precioUnico: boolean fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad' nFijo?: number + /** + * N por defecto del desarrollo: se usa cuando el inventario no trae fecha de + * entrega ni meses para firma. Garantiza que la mensualidad y el total + * siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts). + */ + nDefault?: number propuestas: PropuestaConfig[] } @@ -81,6 +87,9 @@ export const DESARROLLOS: Record = { id: 'DIAGONAL', precioUnico: false, fuenteDeN: 'fechaEntrega', + // Último recurso si la unidad no trae fecha de entrega (mediana del + // inventario disponible 2026-09: 13 meses hasta la terminación de obra) + nDefault: 13, propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' }, @@ -116,6 +125,9 @@ export const DESARROLLOS: Record = { id: 'SENDA', precioUnico: true, fuenteDeN: 'fechaEntrega', + // Último recurso si la unidad no trae fecha de entrega (mediana del + // inventario disponible 2026-09: 6 mensualidades) + nDefault: 6, propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, @@ -127,6 +139,9 @@ export const DESARROLLOS: Record = { id: 'SOLANA', precioUnico: true, fuenteDeN: 'porUnidad', + // Último recurso si la unidad no trae "meses para firma" (las casas + // vendibles del inventario Excel están en 1 mes) + nDefault: 1, propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true }, @@ -174,7 +189,9 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES)) } -/** Resuelve N según la fuente del desarrollo (null si no se puede conocer) */ +/** Resuelve N según la fuente del desarrollo; cae al plazo por defecto del + * desarrollo (`nDefault`) cuando el inventario no trae el dato, para que la + * mensualidad y el total SIEMPRE se calculen. */ export function resolverNumMensualidades( unidad: UnidadCotizable, params: ParametrosDesarrollo, @@ -183,9 +200,11 @@ export function resolverNumMensualidades( case 'fijo': return params.nFijo ?? 1 case 'porUnidad': - return unidad.mesesParaFirma ?? null + return unidad.mesesParaFirma ?? params.nDefault ?? null case 'fechaEntrega': - return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null + return unidad.fechaEntrega + ? calcularNumMensualidades(unidad.fechaEntrega) + : (params.nDefault ?? null) } } @@ -196,6 +215,42 @@ function baseComun(precioLista: number, descuento: number) { return { montoDescuento, precioFinal } } +/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */ +interface BloquesPago { + enganche: number + mensualidades: number | null + pagoFinal: number +} + +/** + * Cuadra los bloques para que apartado + enganche + mensualidades + pago final + * sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la + * propuesta), aunque el apartado o un enganche personalizado descuadren las + * fórmulas: + * - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando + * el apartado supera el enganche, ni mensualidades negativas con enganche + * alto); + * - el excedente se recorta de las mensualidades (bloque flexible del + * remanente) y, si no alcanza, del pago final. Nunca se suma por encima del + * precio. + */ +function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago { + const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal + const enganche = Math.max(0, bloques.enganche) + let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades) + let pagoFinal = Math.max(0, bloques.pagoFinal) + + let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total + if (excedente > 0 && mensualidades !== null) { + const recorte = Math.min(mensualidades, excedente) + mensualidades -= recorte + excedente -= recorte + } + if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente) + + return { enganche, mensualidades, pagoFinal } +} + /** Calcula una propuesta comercial con los parámetros dados */ function calcularPropuestaConfig( cfg: PropuestaConfig, @@ -217,17 +272,21 @@ function calcularPropuestaConfig( cfg.pctEnganche <= 0.05 const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche + // Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de + // SOLANA lo deja en 0 cuando el % es 0 const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal - const enganche = + const engancheBruto = cfg.engancheCeroSinApartado && pctEnganche === 0 ? 0 - : pctEnganche * baseEnganche - apartado + : pctEnganche * baseEnganche + // Fórmula del Excel: enganche neto del apartado (puede salir negativo si el + // apartado supera el enganche; cuadrarBloques lo acota a 0) + const engancheExcel = engancheBruto - apartado const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal - const pagoFinal = cfg.pctPagoFinal * basePagoFinal + const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal - let mensualidadesTotal: number | null = null - let mensualidad: number | null = null + let mensualidadesExcel: number | null = null let notaMensualidades: string | undefined if (cfg.modoMensualidades !== 'ninguna') { if (n === null) { @@ -235,22 +294,26 @@ function calcularPropuestaConfig( } else { switch (cfg.modoMensualidades) { case 'remanente': - mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal + mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel break case 'remanenteLista': // Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal - mensualidadesTotal = precioLista - pagoFinal - apartado - enganche + mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel break case 'porcentaje': - mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal) + mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal) break } - // Con enganche alto el remanente puede quedar en negativo: se acota a 0 - mensualidadesTotal = Math.max(0, mensualidadesTotal) - mensualidad = mensualidadesTotal / n } } + const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, { + enganche: engancheExcel, + mensualidades: mensualidadesExcel, + pagoFinal: pagoFinalExcel, + }) + const mensualidad = mensualidades === null || n === null ? null : mensualidades / n + return { tipo: cfg.tipo, titulo: cfg.titulo, @@ -260,7 +323,7 @@ function calcularPropuestaConfig( enganche, numMensualidades: n, mensualidad, - mensualidadesTotal, + mensualidadesTotal: mensualidades, pagoFinal, ...(notaMensualidades !== undefined ? { notaMensualidades } : {}), } @@ -321,6 +384,8 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API * `descuento` opcional (fracción); si no viene, usa el default de la propuesta. * `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con * crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo. + * `numMeses` opcional: número de mensualidades elegido por el vendedor en el + * cotizador; si no viene se resuelve con la fuente del desarrollo. */ export function calcularPropuesta( unidad: UnidadCotizable, @@ -329,6 +394,7 @@ export function calcularPropuesta( apartado: number = APARTADO_DEFAULT, descuento?: number, enganchePct?: number, + numMeses?: number, ): PropuestaResultado { const params = obtenerDesarrollo(unidad.desarrollo) const tipoInterno = TIPO_PROPUESTA_API[tipo] @@ -337,7 +403,7 @@ export function calcularPropuesta( throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`) } const precioLista = obtenerPrecioLista(unidad, acabados) - const n = resolverNumMensualidades(unidad, params) + const n = numMeses ?? resolverNumMensualidades(unidad, params) const d = descuento ?? cfg.descuentoDefault return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null) } diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 61ec96c..353c0b6 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -344,8 +344,16 @@ export class CrmLocalService { * oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de * board. Idempotente con el mismo dedup `apartado-creado:` de las * automatizaciones. + * + * `opciones.unidadCodigo` deja la unidad apartada en el `datosJson` del + * apartado (la cotización del vendedor o la unidad de interés): es lo que + * después leen el bloqueo de inventario y Mi día. */ - async apartar(oportunidadIdLocal: string, usuarioId?: string) { + async apartar( + oportunidadIdLocal: string, + usuarioId?: string, + opciones: { unidadCodigo?: string | undefined } = {}, + ) { // Sombra para ids heredados sin fila local const oportunidad = await this.filaLocal( oportunidadIdLocal, @@ -362,6 +370,7 @@ export class CrmLocalService { } const apartadoId = idLocal() + const datosJson = conUnidadCodigo(oportunidad.datosJson, opciones.unidadCodigo) const resultados = await prisma.$transaction([ prisma.leadCaptura.update({ where: { leadId: oportunidadIdLocal }, @@ -379,11 +388,11 @@ export class CrmLocalService { modelo: oportunidad.modelo, board: 'APARTADO', etapa: 'Pago Apartado', - datosJson: oportunidad.datosJson, + datosJson, // Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados // por vencer", el cron de avisos y la lista de Mi día (antes sólo se // calculaba en el JSON del alta y la columna quedaba vacía). - fechaLimite: fechaLimiteApartado(oportunidad.datosJson), + fechaLimite: fechaLimiteApartado(datosJson), pendienteSync: true, }, }), @@ -472,6 +481,25 @@ function soloDigitos(telefono: string): string { return telefono.replace(/\D/g, '') } +/** + * `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva + * fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo + * devuelve el JSON original tal cual. + */ +function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): string | null { + const unidad = unidadCodigo?.trim() + if (!unidad) return datosJson + let datos: Record = {} + if (datosJson) { + try { + datos = JSON.parse(datosJson) as Record + } catch { + datos = {} + } + } + return JSON.stringify({ ...datos, unidadCodigo: unidad }) +} + /** * Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero. * Así el mismo número no se duplica entre Leads, Oportunidades y Apartados. diff --git a/api-gateway/src/services/reporte-mi-tablero.service.test.ts b/api-gateway/src/services/reporte-mi-tablero.service.test.ts index 4f25f72..16e12bc 100644 --- a/api-gateway/src/services/reporte-mi-tablero.service.test.ts +++ b/api-gateway/src/services/reporte-mi-tablero.service.test.ts @@ -11,6 +11,8 @@ function captura( origen: string | null desarrollo: string | null createdAt: Date + updatedAt: Date + datosJson: string | null }> = {}, ) { return { @@ -20,6 +22,8 @@ function captura( origen: overrides.origen ?? 'Facebook', desarrollo: overrides.desarrollo ?? 'DIAGONAL', createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'), + updatedAt: overrides.updatedAt ?? overrides.createdAt ?? new Date('2026-09-05T10:00:00'), + datosJson: overrides.datosJson ?? null, } } @@ -85,15 +89,41 @@ describe('construirMiTablero', () => { expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO }) - it('calcula ventas del mes correctamente', () => { + it('calcula ventas del mes con ventas CERRADAS (no cotizaciones)', () => { + // Las cotizaciones ya no cuentan como ventas: se cuentan los apartados con + // firma de cliente o posteriores, sin repetir la misma unidad. const cots = [ cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }), - cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }), - cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior ] - const tablero = construirMiTablero([], cots, [], AHORA) + const capturas = [ + captura({ + leadId: 'loc_apt_1', + board: 'APARTADO', + etapa: 'Firma Cliente', + updatedAt: new Date('2026-09-10T12:00:00'), + datosJson: JSON.stringify({ precioFinal: 1_500_000 }), + }), + captura({ + leadId: 'loc_apt_2', + board: 'APARTADO', + etapa: 'Venta', + updatedAt: new Date('2026-09-12T12:00:00'), + datosJson: JSON.stringify({ precioFinal: 2_000_000 }), + }), + // Mismo apartado recotizado: no debe duplicarse (mismo leadId) + captura({ + leadId: 'loc_apt_1', + board: 'APARTADO', + etapa: 'Firma Cliente', + updatedAt: new Date('2026-09-15T12:00:00'), + datosJson: JSON.stringify({ precioFinal: 1_500_000 }), + }), + captura({ board: 'APARTADO', etapa: 'Pago Apartado', updatedAt: new Date('2026-09-11T12:00:00') }), + captura({ board: 'APARTADO', etapa: 'Cancelacion Apartado', updatedAt: new Date('2026-09-11T12:00:00') }), + ] + const tablero = construirMiTablero(capturas, cots, [], AHORA) expect(tablero.ventasMes.unidades).toBe(2) - expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000 + expect(tablero.ventasMes.monto).toBe(3_500_000) }) it('agrupa por origen con conteo de convertidos', () => { diff --git a/api-gateway/src/services/reporte-mi-tablero.service.ts b/api-gateway/src/services/reporte-mi-tablero.service.ts index 67fa634..41c717e 100644 --- a/api-gateway/src/services/reporte-mi-tablero.service.ts +++ b/api-gateway/src/services/reporte-mi-tablero.service.ts @@ -80,6 +80,9 @@ export function construirMiTablero( origen: string | null desarrollo: string | null createdAt: Date + /** Necesarios para las ventas cerradas del mes (fecha del cambio y precio) */ + updatedAt?: Date + datosJson?: string | null }[], cotizaciones: { precioFinal: number @@ -130,13 +133,35 @@ export function construirMiTablero( const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length // -- Ventas del mes -- + // Antes sumaba COTIZACIONES (un inmueble recotizado contaba varias veces y una + // cotización no es una venta). Ahora cuenta ventas cerradas del mes: apartados + // con firma de cliente o posteriores (Venta, Escriturado, Titulado), sin + // repetir la misma unidad. const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) - const cotizacionesMes = cotizaciones.filter( - (ct) => ct.createdAt >= inicioMes, + const esVentaCerrada = (etapa: string): boolean => { + const e = etapa.toUpperCase() + return ( + e.includes('FIRMA') || e.includes('VENTA') || e.includes('ESCRITURADO') || e.includes('TITULADO') + ) && !e.includes('CANCEL') + } + const ventasDelMes = capturas.filter( + (c) => c.board === 'APARTADO' && esVentaCerrada(c.etapa) && (c.updatedAt ?? c.createdAt) >= inicioMes, ) + const unidadesVendidas = new Map() + for (const v of ventasDelMes) { + let monto = 0 + try { + const datos = JSON.parse(v.datosJson ?? '{}') as Record + const precio = Number(datos['precioFinal'] ?? datos['precioVenta'] ?? 0) + monto = Number.isFinite(precio) ? precio : 0 + } catch { + monto = 0 + } + unidadesVendidas.set(v.leadId, monto) + } const ventasMes = { - monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100, - unidades: cotizacionesMes.length, + monto: Math.round([...unidadesVendidas.values()].reduce((a, b) => a + b, 0) * 100) / 100, + unidades: unidadesVendidas.size, } // -- Por origen -- @@ -212,6 +237,8 @@ export async function obtenerMiTablero(usuarioId: string): Promise { origen: true, desarrollo: true, createdAt: true, + updatedAt: true, + datosJson: true, }, }) diff --git a/api-gateway/src/services/reportes.service.test.ts b/api-gateway/src/services/reportes.service.test.ts index 8447237..66986ad 100644 --- a/api-gateway/src/services/reportes.service.test.ts +++ b/api-gateway/src/services/reportes.service.test.ts @@ -217,7 +217,7 @@ describe('construirResumen', () => { plan('a', { pagos: [ pago('p1', '2026-08-01', 50000), // vencido sin abono - pago('p2', '2026-08-10', 50000, 20000), // parcial (no cuenta como VENCIDO) + pago('p2', '2026-08-10', 50000, 20000), // parcial VENCIDO: sí cuenta como atraso pago('p3', '2026-09-01', 50000), // pendiente futuro ], }), @@ -228,9 +228,10 @@ describe('construirResumen', () => { }, AHORA, ) - expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1 }) - expect(r.cobranza.pagosVencidos).toBe(2) - expect(r.cobranza.montoVencido).toBe(80000) + expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1, sinPlan: 0 }) + // p1 (vencido sin abono) + p2 (PARCIAL vencido, antes se ignoraba) + p4 + expect(r.cobranza.pagosVencidos).toBe(3) + expect(r.cobranza.montoVencido).toBe(110000) // 50,000 + 30,000 restantes + 30,000 expect(r.cobranza.planesAlCorriente).toBe(0) expect(r.cobranza.planesConVencidos).toBe(2) }) diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index 228b3c1..a01a5be 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -1,4 +1,4 @@ -import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' +import { pagoAtrasado, statusPlan, type PlanPago } from './cobranza.service.js' import type { UnidadCotizable } from './cotizacion.service.js' import { ETAPAS_PIPELINE } from './etapas.js' import { prisma } from './prisma.js' @@ -266,7 +266,9 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number .map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })), ] - const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 } + // `sinPlan` = plan sin calendario capturado (no es lo mismo que cancelado: + // 3 planes del maestro llegaron sin pagos y antes se contaban como cancelados) + const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0, sinPlan: 0 } let planesAlCorriente = 0 let planesConVencidos = 0 let pagosVencidos = 0 @@ -275,9 +277,12 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number const status = statusPlan(plan) if (status === 'ACTIVO') planesPorStatus.activos++ else if (status === 'LIQUIDADO') planesPorStatus.liquidados++ + else if (status === 'SIN_PLAN') planesPorStatus.sinPlan++ else planesPorStatus.cancelados++ if (status !== 'ACTIVO') continue - const vencidos = plan.pagos.filter((p) => statusPago(p, ahora) === 'VENCIDO') + // Un pago PARCIAL vencido también es atraso (antes no se contaba y había + // planes "al corriente" con meses de retraso) + const vencidos = plan.pagos.filter((p) => pagoAtrasado(p, ahora)) if (vencidos.length === 0) planesAlCorriente++ else planesConVencidos++ pagosVencidos += vencidos.length @@ -435,7 +440,7 @@ export async function cargarDatosLocalesPrisma(): Promise // Contar actividades abiertas const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } }) - return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto), actividadesAbiertas } + return { leads, oportunidades, unidades: unidadesRows.map((u) => unidadLocalADto(u)), actividadesAbiertas } } export class ReportesService { diff --git a/api-gateway/src/services/unidades-local.service.test.ts b/api-gateway/src/services/unidades-local.service.test.ts index 1ff2e0f..baa33a3 100644 --- a/api-gateway/src/services/unidades-local.service.test.ts +++ b/api-gateway/src/services/unidades-local.service.test.ts @@ -7,11 +7,15 @@ const prismaMock = vi.hoisted(() => ({ findMany: vi.fn(), findUnique: vi.fn(), }, + capturaDepartamento: { + findMany: vi.fn(), + }, })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) import { + CAMPOS_FECHA_ENTREGA, getUnidadesLocal, getUnidadLocal, unidadLocalADto, @@ -37,10 +41,11 @@ beforeEach(() => { vi.clearAllMocks() prismaMock.unidad.findMany.mockResolvedValue([]) prismaMock.unidad.findUnique.mockResolvedValue(null) + prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) }) describe('unidadLocalADto', () => { - it('mapea la fila local al DTO Unidad', () => { + it('mapea la fila local al DTO Unidad (sin fecha de entrega por defecto)', () => { const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' })) expect(dto).toEqual({ codigo: 'SENDA-101', @@ -58,6 +63,10 @@ describe('unidadLocalADto', () => { }) }) + it('con fecha de entrega (captura de departamentos) la deja en el DTO', () => { + expect(unidadLocalADto(fila({ codigo: 'U-101' }), '2027-10-08').fechaEntrega).toBe('2027-10-08') + }) + it('etapa vacía → null', () => { expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull() }) @@ -80,11 +89,41 @@ describe('getUnidadesLocal / getUnidadLocal', () => { expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3']) }) - it('getUnidadLocal: null si no existe', async () => { + it('completa fechaEntrega desde la captura: entrega programada > habitabilidad > inicio de obra', async () => { + prismaMock.unidad.findMany.mockResolvedValue([ + fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }), + fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }), + fila({ codigo: 'S-1', desarrollo: 'SENDA' }), + fila({ codigo: 'S-2', desarrollo: 'SENDA' }), + ]) + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' }, + { unidadCodigo: 'U-1', campo: 'habitabilidad', valor: '2027-10-08' }, + { unidadCodigo: 'U-1', campo: 'entrega_programada_fecha', valor: '2027-11-20' }, + { unidadCodigo: 'S-1', campo: 'habitabilidad', valor: '2027-05-05' }, + // Valor vacío / no-fecha: no cuenta como dato + { unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' }, + { unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' }, + ]) + + const todas = await getUnidadesLocal() + + expect(todas.map((u) => u.fechaEntrega)).toEqual(['2027-11-20', null, '2027-05-05', null]) + expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }), + ) + }) + + it('getUnidadLocal: null si no existe; trae la fecha de su captura', async () => { prismaMock.unidad.findUnique.mockImplementation(async ({ where }) => where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null, ) - expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9') + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-9', campo: 'habitabilidad', valor: '2027-10-08' }, + ]) + const dto = await getUnidadLocal('U-9') + expect(dto?.codigo).toBe('U-9') + expect(dto?.fechaEntrega).toBe('2027-10-08') expect(await getUnidadLocal('U-X')).toBeNull() }) }) diff --git a/api-gateway/src/services/unidades-local.service.ts b/api-gateway/src/services/unidades-local.service.ts index fa4ac0f..4a97be1 100644 --- a/api-gateway/src/services/unidades-local.service.ts +++ b/api-gateway/src/services/unidades-local.service.ts @@ -6,10 +6,52 @@ import type { UnidadCotizable } from './cotizacion.service.js' * scripts/seed-unidades-locales.js). Es la única fuente de lectura del * inventario: misma forma de DTO que consumen controllers y fronts. * - * La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el - * seed curado): salen en null, que el cotizador ya tolera (plazo desconocido). + * La tabla `Unidad` no guarda la fecha de entrega: se completa desde la captura + * de obra/escrituración (`CapturaDepartamento`), que es la que llenan los + * departamentos. Sin ese dato el cotizador asume el plazo por defecto del + * desarrollo (ver DESARROLLOS en cotizacion.service.ts). */ +/** + * Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda, + * en orden de prioridad: + * 1. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda (ESCRITURACION) + * 2. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL + * 3. `inicio_obra_fecha` — último recurso (ESCRITURACION) + */ +export const CAMPOS_FECHA_ENTREGA = [ + 'entrega_programada_fecha', + 'habitabilidad', + 'inicio_obra_fecha', +] as const + +/** Normaliza 'YYYY-MM-DD' (o ISO completo); null si el valor no es una fecha usable */ +function fechaIso(valor: string | null | undefined): string | null { + if (typeof valor !== 'string') return null + const fecha = valor.trim().slice(0, 10) + return /^\d{4}-\d{2}-\d{2}$/.test(fecha) ? fecha : null +} + +/** Fecha de entrega (ISO) por código de unidad, según la captura de los departamentos */ +async function fechasEntregaPorUnidad(codigos?: string[]): Promise> { + const filas = await prisma.capturaDepartamento.findMany({ + where: { + campo: { in: [...CAMPOS_FECHA_ENTREGA] }, + ...(codigos ? { unidadCodigo: { in: codigos } } : {}), + }, + select: { unidadCodigo: true, campo: true, valor: true }, + }) + const porUnidad = new Map() + for (const campo of CAMPOS_FECHA_ENTREGA) { + for (const fila of filas) { + if (fila.campo !== campo || porUnidad.has(fila.unidadCodigo)) continue + const fecha = fechaIso(fila.valor) + if (fecha !== null) porUnidad.set(fila.unidadCodigo, fecha) + } + } + return porUnidad +} + /** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */ export interface FilaUnidad { codigo: string @@ -25,11 +67,11 @@ export interface FilaUnidad { } /** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */ -export function unidadLocalADto(u: FilaUnidad): UnidadCotizable { +export function unidadLocalADto(u: FilaUnidad, fechaEntrega: string | null = null): UnidadCotizable { return { codigo: u.codigo, modelo: u.modelo, - fechaEntrega: null, // no está en el espejo local: plazo desconocido + fechaEntrega, supEstacionamiento: u.supEstacionamiento, supConstruccion: u.supConstruccion, supExcedente: u.supExcedente, @@ -47,7 +89,8 @@ export async function getUnidadesLocal( filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {}, ): Promise { const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } }) - let lista = rows.map(unidadLocalADto) + const fechas = await fechasEntregaPorUnidad() + let lista = rows.map((u) => unidadLocalADto(u, fechas.get(u.codigo) ?? null)) if (filtro.soloDisponibles === true) { lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE') } @@ -61,5 +104,7 @@ export async function getUnidadesLocal( /** Una unidad local por código; null si no existe en el espejo */ export async function getUnidadLocal(codigo: string): Promise { const row = await prisma.unidad.findUnique({ where: { codigo } }) - return row === null ? null : unidadLocalADto(row) + if (row === null) return null + const fechas = await fechasEntregaPorUnidad([codigo]) + return unidadLocalADto(row, fechas.get(codigo) ?? null) } diff --git a/apps/cobranza/src/components/Layout.tsx b/apps/cobranza/src/components/Layout.tsx index ebb3537..5518567 100644 --- a/apps/cobranza/src/components/Layout.tsx +++ b/apps/cobranza/src/components/Layout.tsx @@ -1,8 +1,12 @@ import type { ReactNode } from 'react' -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import DarkModeToggle from './DarkModeToggle' export default function Layout({ children }: { children: ReactNode }) { + // El toggle "Mis planes / Todos" vive en ?vista=: se conserva al navegar + // entre Cobranza y Cobranza del día para que el alcance no se pierda. + const { search } = useLocation() + return (
@@ -23,13 +27,13 @@ export default function Layout({ children }: { children: ReactNode }) {