feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol

This commit is contained in:
2026-09-21 08:34:53 +00:00
parent b5a20aed4e
commit 2fc80e7f9e
66 changed files with 3792 additions and 436 deletions

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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<string, { campo: string; tipo: string; label: string; options?: string[]; conFecha?: string }[]> = {
OBRA: [
@@ -59,3 +60,44 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
}
export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const
// ── Permisos de captura por rol ───────────────────────────────────────
/**
* Departamentos que cada rol puede ver y capturar. Fuente de verdad del
* ecosistema: el front espeja esta misma matriz en services/captura.ts para
* ocultar pestañas, pero quien la hace cumplir es el API.
*
* Dirección y administración ven todos (incluye los roles legacy de la matriz
* de módulos: 'director', 'coordinador'); cada rol operativo sólo el suyo.
*/
export const DEPARTAMENTOS_POR_ROL: Record<string, readonly Departamento[]> = {
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)
}

View File

@@ -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)', () => {

View File

@@ -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<string> {
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<string, Record<string, string | null>>,
rol: string | undefined,
): Record<string, Record<string, string | null>> {
const permitidos = camposPermitidos(rol)
const salida: Record<string, Record<string, string | null>> = {}
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<void> {
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<void> {
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<void
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, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
await capturaService.guardarCampo(
@@ -124,6 +175,7 @@ export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
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 })),

View File

@@ -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', () => {

View File

@@ -22,6 +22,12 @@ export async function listarEndpoint(req: Request, res: Response): Promise<void>
res.json(resultado)
}
// ── GET /api/clientes-maestro/catalogos ──────────────────────────────
export async function catalogosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await clienteMaestroService.catalogos())
}
// ── GET /api/clientes-maestro/exportar ───────────────────────────────
export async function exportarEndpoint(req: Request, res: Response): Promise<void> {

View File

@@ -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', () => {

View File

@@ -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<void
// Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe
const unidad = await getUnidadLocal(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
// Solo se cotiza inventario disponible: evita cotizar (y apartar) dos veces
// el mismo inmueble ya apartado/vendido
validarUnidadDisponible(unidad)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct)
const propuesta = calcularPropuesta(
unidad,
body.tipoPropuesta,
body.acabados,
apartado,
body.descuento,
body.enganchePct,
body.numMensualidades,
)
const precioLista = obtenerPrecioLista(unidad, body.acabados)
const cotizacion = await prisma.cotizacion.create({
@@ -77,7 +103,9 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
precioLista,
precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche,
numMensualidades: propuesta.numMensualidades ?? 0, // 0 => 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(),

View File

@@ -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<unknown>[]) => 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',

View File

@@ -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<string | null> {
if (datosJson) {
try {
const datos = JSON.parse(datosJson) as Record<string, unknown>
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<string | null> {
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<string, unknown>
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)

View File

@@ -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()
})
})

View File

@@ -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<void> {
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<void> {
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)))
}

View File

@@ -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()
})
})

View File

@@ -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<void> {
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<void> {
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 }))
}

View File

@@ -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> = {}): 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<string, unknown> = {}) {
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<string, unknown> }) =>
filaFake(data),
)
prismaMock.solicitudSoporte.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) =>
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<Error | null> {
return new Promise((resolve) => {
uploadArchivosSoporte(req, {} as Response, (err?: unknown) => resolve((err as Error) ?? null))
})
}
it('guarda el archivo en uploads/soporte/<idSolicitud>/ 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<string, unknown> }
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<string, unknown> }
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')
})
})

View File

@@ -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<string, string> = {
'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/<id>/ 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/<id>/ */
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<void> {
const body = crearSolicitudSchema.parse(req.body)
const id = idSolicitudDe(req)
let body: z.infer<typeof crearSolicitudSchema>
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<void> {
const { estado } = listarSolicitudSchema.parse(req.query)
const { estado, departamento } = listarSolicitudSchema.parse(req.query)
const where: Record<string, unknown> = {}
// 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<void> {
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<void> {
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)
}

View File

@@ -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<string, unknown>[]
expect(lista[0]).toMatchObject({ fechaEntrega: '2027-10-08' })
})
it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
filaLocal,

View File

@@ -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)

View File

@@ -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 -' } }] }],
},
},
)
})
})

View File

@@ -44,26 +44,37 @@ export type ClienteMaestroDTO = {
// ── Helpers ─────────────────────────────────────────────────────────
function construirWhere(filtros: FiltrosClientesMaestro) {
const where: Record<string, unknown> = {}
const condiciones: Record<string, unknown>[] = []
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<ClienteMaestroDTO | n
return prisma.clienteMaestro.findUnique({ where: { numCliente } })
}
/**
* Catálogos reales de los filtros: los desarrollos y estatus que existen hoy en
* la base (no una lista fija). El front arma los selectores con esto.
*/
export async function catalogos(): Promise<{ desarrollos: string[]; estatuses: string[] }> {
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 | null>): 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.
*/

View File

@@ -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)
})
})

View File

@@ -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[],

View File

@@ -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<string, string> {
if (!json) return {}
try {
const datos = JSON.parse(json) as Record<string, unknown>
const plano: Record<string, string> = {}
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
const recorrer = (obj: Record<string, unknown>, 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<string, unknown>, 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 {

View File

@@ -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)
})
})

View File

@@ -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<ListaContactos> {
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<string>()
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<string, number>()
const porNombre = new Map<string, number>()
/**
* 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<string[]> {
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<DetalleContacto | null> {
@@ -160,8 +282,22 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
orderBy: { createdAt: 'desc' },
})
: []
const documentos = cliente
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
// Duplicados huecos de la misma persona (altas fallidas: mismo nombre, sin
// teléfono ni email). La lista los fusiona en una sola ficha, así que el
// detalle muestra también sus documentos para no perderlos, tanto si el
// contacto visible es el Cliente como si es la captura.
const huecos =
cliente !== null
? await huecosConMismoNombre(cliente.nombreCompleto, cliente.id)
: capturaBase !== null
? await huecosConMismoNombre(capturaBase.nombreLead, null)
: []
const idsConDocumentos = [...(cliente !== null ? [cliente.id] : []), ...huecos]
const documentos = idsConDocumentos.length
? await prisma.documento.findMany({
where: { clienteId: { in: idsConDocumentos } },
orderBy: { createdAt: 'desc' },
})
: []
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
@@ -245,6 +381,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
})),
].sort((a, b) => 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<string, string> =
cliente !== null
? {
@@ -252,14 +406,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
telefono: cliente.celular ?? '',
email: cliente.email ?? '',
estado: 'Cliente',
curp: cliente.curp,
rfc: cliente.rfc,
curp: cliente.curp || dePerfil('curp'),
rfc: cliente.rfc || dePerfil('rfc'),
estadoCivil: dePerfil('estadoCivil'),
fechaNacimiento: dePerfil('fechaNacimiento'),
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
direccion: dePerfil('direccion'),
}
: {
nombre: capturaBase!.nombreLead,
telefono: capturaBase!.telefono ?? '',
email: capturaBase!.email ?? '',
estado: capturaBase!.etapa,
curp: dePerfil('curp'),
rfc: dePerfil('rfc'),
estadoCivil: dePerfil('estadoCivil'),
fechaNacimiento: dePerfil('fechaNacimiento'),
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
direccion: dePerfil('direccion'),
}
return { contacto, propiedad, relaciones, historial }
@@ -297,12 +461,17 @@ export async function exportarCSV(
}
const filas: FilaCSV[] = []
// Dedupe en dos pasos (igual que el listado): por teléfono y, cuando no hay
// teléfono, por nombre normalizado — antes un contacto sin teléfono salía
// duplicado en el CSV.
const vistos = new Set<string>()
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 ?? '',

View File

@@ -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)
})
})

View File

@@ -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<string, ParametrosDesarrollo> = {
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<string, ParametrosDesarrollo> = {
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<string, ParametrosDesarrollo> = {
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)
}

View File

@@ -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:<opId>` 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<string, unknown> = {}
if (datosJson) {
try {
datos = JSON.parse(datosJson) as Record<string, unknown>
} 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.

View File

@@ -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', () => {

View File

@@ -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<string, number>()
for (const v of ventasDelMes) {
let monto = 0
try {
const datos = JSON.parse(v.datosJson ?? '{}') as Record<string, unknown>
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<MiTablero> {
origen: true,
desarrollo: true,
createdAt: true,
updatedAt: true,
datosJson: true,
},
})

View File

@@ -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)
})

View File

@@ -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<DatosLocalesReportes>
// 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 {

View File

@@ -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()
})
})

View File

@@ -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<Map<string, string>> {
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<string, string>()
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<UnidadCotizable[]> {
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<UnidadCotizable | null> {
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)
}