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 = [
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,22 +109,76 @@ 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({
contacto: {
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
@@ -94,10 +186,14 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
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({
contacto: {
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
@@ -105,9 +201,12 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
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)
}

View File

@@ -1,8 +1,12 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import DarkModeToggle from './DarkModeToggle'
export default function Layout({ children }: { children: ReactNode }) {
// El toggle "Mis planes / Todos" vive en ?vista=: se conserva al navegar
// entre Cobranza y Cobranza del día para que el alcance no se pierda.
const { search } = useLocation()
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
@@ -23,13 +27,13 @@ export default function Layout({ children }: { children: ReactNode }) {
<nav className="flex items-center gap-3 text-sm sm:gap-6">
<DarkModeToggle />
<Link
to="/"
to={`/${search}`}
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
>
Cobranza
</Link>
<Link
to="/hoy"
to={`/hoy${search}`}
className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5"
>
Cobranza del día

View File

@@ -1,13 +1,14 @@
import type { PlanPago } from '../types'
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza'
import { diasParaProximoVencimiento, pagoAtrasado, statusPago, statusPlan } from '../lib/cobranza'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS' | 'SIN_PLAN'
const ETIQUETA: Record<Semaforo, string> = {
VERDE: 'Al corriente',
AMBAR: 'Por vencer / parcial',
ROJO: 'Vencido',
GRIS: 'Cerrado',
SIN_PLAN: 'Sin plan capturado',
}
const CLASE_DOT: Record<Semaforo, string> = {
@@ -15,20 +16,23 @@ const CLASE_DOT: Record<Semaforo, string> = {
AMBAR: 'bg-amber-50 text-amber-700',
ROJO: 'bg-red-50 text-red-700',
GRIS: 'bg-gray-100 text-gray-600',
SIN_PLAN: 'bg-orange-50 text-orange-700',
}
/**
* Semáforo del plan:
* - "sin plan capturado": no hay calendario de pagos (no es lo mismo que liquidado)
* - gris: LIQUIDADO o CANCELADO
* - rojo: tiene pagos VENCIDOS
* - rojo: tiene pagos VENCIDOS o PARCIALES vencidos
* - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos
* - verde: al corriente
*/
export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
if (statusPlan(plan) !== 'ACTIVO') return 'GRIS'
const estados = plan.pagos.map((p) => statusPago(p, hoy))
if (estados.includes('VENCIDO')) return 'ROJO'
if (estados.includes('PARCIAL')) return 'AMBAR'
const status = statusPlan(plan)
if (status === 'SIN_PLAN') return 'SIN_PLAN'
if (status !== 'ACTIVO') return 'GRIS'
if (plan.pagos.some((p) => pagoAtrasado(p, hoy))) return 'ROJO'
if (plan.pagos.some((p) => statusPago(p, hoy) === 'PARCIAL')) return 'AMBAR'
const dias = diasParaProximoVencimiento(plan, hoy)
if (dias !== null && dias <= 7) return 'AMBAR'
return 'VERDE'

View File

@@ -0,0 +1,46 @@
import type { VistaPlanes } from '../lib/usePlanesCobranza'
interface Props {
mios: boolean
/** null vuelve al valor por defecto del rol */
onCambiar: (vista: VistaPlanes | null) => void
}
/**
* Toggle "Mis planes / Todos". La elección se guarda en ?vista= de la URL, de
* modo que se comparte entre el dashboard y "Cobranza del día".
*/
export default function ToggleVista({ mios, onCambiar }: Props) {
const claseActiva = 'bg-accent text-white shadow-sm'
const claseInactiva =
'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
return (
<div
className="inline-flex rounded-lg border border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]"
role="group"
aria-label="Alcance de planes"
>
<button
type="button"
onClick={() => onCambiar(mios ? null : 'mios')}
aria-pressed={mios}
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? claseActiva : claseInactiva
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => onCambiar(mios ? 'todos' : null)}
aria-pressed={!mios}
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? claseInactiva : claseActiva
}`}
>
Todos
</button>
</div>
)
}

View File

@@ -186,6 +186,29 @@ 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, numPagos: 0 }
expect(statusPlan(sinPagos)).toBe('SIN_PLAN')
// con pagos pero en cero (plan capturado a medias): 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,
numPagos: 0,
fechaInicio: '2026-01-01',
}
expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0)
})
it('alCorriente es falso cuando hay pagos vencidos', () => {
const hoy = new Date('2026-02-15T00:00:00') // pago 1 (10-ene) ya venció
expect(alCorriente(planDePrueba(), hoy)).toBe(false)
@@ -225,10 +248,19 @@ describe('proximoVencimiento y diasAtraso', () => {
expect(diasAtraso(plan, 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 = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
// pago 1 es PARCIAL (vencido pero con abono); pago 2 vence 10-feb
// 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)
})
it('alCorriente ignora los vencidos ya cubiertos', () => {
let plan = planDePrueba()
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A' })
// con el pago 1 cubierto y el 2 (10-feb) aún vigente, está al corriente
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(true)
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
})
})

View File

@@ -85,7 +85,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'
@@ -132,10 +133,20 @@ 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) */
/**
* ¿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
}
/** 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(),
@@ -145,15 +156,29 @@ 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 */
/**
* 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))
}
/**
@@ -194,10 +219,11 @@ export function telefonoParaEnlace(telefono: string): string {
}
/**
* 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

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { PlanPago } from '../../types'
import { generarCalendario, registrarAbono } from '../cobranza'
import { crearDocumento, folioRecibo } from './reciboAbono'
import { generarCalendario, registrarAbono, saldoPlan } from '../cobranza'
import { crearDocumento, folioRecibo, saldosDelRecibo } from './reciboAbono'
function planConAbono(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
@@ -47,4 +47,56 @@ describe('reciboAbono PDF', () => {
// smoke: el contenido interno es un PDF válido
expect(doc.output('datauristring')).toContain('application/pdf')
})
it('saldosDelRecibo usa los totales del plan, no solo los abonos registrados', () => {
const plan = planConAbono()
const pago = plan.pagos[0]
const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
expect(restantePago).toBe(28333.33)
expect(saldo).toBe(saldoPlan(plan))
expect(saldo).toBe(95000)
})
it('el saldo incluye el apartado/enganche histórico sin fila de AplicacionPago', () => {
// Caso real del maestro importado: el pago inicial (numPago 0) trae
// montoPagado == monto pero sin aplicaciones. Antes el recibo restaba solo
// aplicaciones y mostraba un saldo inflado (o negativo).
const base = planConAbono()
const pagoInicial = {
id: 'pago-0',
numPago: 0,
fechaVencimiento: '2026-01-01',
monto: 20000,
montoPagado: 20000,
aplicaciones: [],
}
const plan: PlanPago = {
...base,
pagos: [pagoInicial, ...base.pagos],
engancheTotal: base.engancheTotal,
}
const pago = plan.pagos[1]
const aplicacion = pago.aplicaciones[0]
const { saldoPlan: saldo, restantePago } = saldosDelRecibo(plan, pago)
// saldo real = Σ monto − Σ montoPagado = 120000 − 25000
expect(saldo).toBe(95000)
expect(restantePago).toBe(28333.33)
expect(crearDocumento(plan, pago, aplicacion).doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
})
it('un abono mayor al monto de su pago no produce saldo negativo (dato importado)', () => {
// El importador viejo guardaba el valor crudo en AplicacionPago.monto
// aunque montoPagado se topara con Math.min: el recibo restaba de más.
const base = planConAbono()
const pago = base.pagos[0]
const inflado: PlanPago = {
...base,
pagos: base.pagos.map((p) =>
p.id === pago.id
? { ...p, aplicaciones: [{ ...p.aplicaciones[0], monto: 500000 }] }
: p,
),
}
expect(saldosDelRecibo(inflado, inflado.pagos[0]).saldoPlan).toBe(95000)
})
})

View File

@@ -1,7 +1,7 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AplicacionPago, Pago, PlanPago } from '../../types'
import { round2 } from '../cobranza'
import { round2, saldoPlan } from '../cobranza'
import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters'
import type { DocumentoPdf } from './estadoCuenta'
@@ -12,11 +12,25 @@ export function folioRecibo(aplicacion: AplicacionPago): string {
return aplicacion.id.slice(0, 8).toUpperCase()
}
/**
* Saldos que imprime el recibo, tomados de los TOTALES del plan y del pago
* (suma de montos − suma de pagado), no del historial de `AplicacionPago`:
* el enganche/apartado histórico vive en `Pago.montoPagado` sin fila de
* aplicación, así que acumular solo aplicaciones daba saldos falsos
* (incluso negativos). El saldo nunca puede ser negativo porque un abono
* jamás excede el restante de su pago.
*/
export function saldosDelRecibo(plan: PlanPago, pago: Pago): { restantePago: number; saldoPlan: number } {
return {
restantePago: round2(pago.monto - pago.montoPagado),
saldoPlan: saldoPlan(plan),
}
}
/**
* Construye el recibo de pago de una aplicación (abono) ya registrada.
* El restante del pago y el saldo del plan se calculan acumulando las
* aplicaciones registradas hasta esa aplicación (inclusive), de modo que el
* recibo refleja los montos vigentes justo tras el abono.
* El restante del pago y el saldo del plan son los totales vigentes del plan
* (ver `saldosDelRecibo`).
*/
export function crearDocumento(
plan: PlanPago,
@@ -28,23 +42,7 @@ export function crearDocumento(
const doc = new jsPDF()
let y = MARGEN
// Aplicado acumulado (plan y pago) hasta la aplicación indicada, en orden de registro
let aplicadoPlan = 0
let aplicadoPago = 0
let encontrada = false
for (const p of plan.pagos) {
for (const a of p.aplicaciones) {
aplicadoPlan += a.monto
if (p.id === pago.id) aplicadoPago += a.monto
if (a.id === aplicacion.id) {
encontrada = true
break
}
}
if (encontrada) break
}
const saldoTrasAbono = round2(plan.engancheTotal - aplicadoPlan)
const restanteTrasAbono = round2(pago.monto - aplicadoPago)
const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
// 1. Encabezado
doc.setFontSize(14)
@@ -72,8 +70,8 @@ export function crearDocumento(
['FECHA DE PAGO', formatoFechaCorta(aplicacion.fechaPago)],
['MONTO DEL ABONO', formatoMoneda(aplicacion.monto)],
['REFERENCIA', aplicacion.referencia || '—'],
['RESTANTE DEL PAGO', formatoMoneda(restanteTrasAbono)],
['SALDO DEL PLAN TRAS EL ABONO', formatoMoneda(saldoTrasAbono)],
['RESTANTE DEL PAGO', formatoMoneda(restantePago)],
['SALDO DEL PLAN (a la fecha de emisión)', formatoMoneda(saldo)],
],
theme: 'plain',
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } },

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { getSesion } from '../services/auth'
import {
listarPlanes,
miosPorDefecto,
sinPlanesAsignados,
type ResultadoPlanes,
} from '../services/repository'
/** Alcance del listado: solo los planes del usuario o todos */
export type VistaPlanes = 'mios' | 'todos'
export interface EstadoPlanes {
mios: boolean
/** Cambia el toggle; null borra el parámetro y vuelve al valor por defecto del rol */
definirVista: (vista: VistaPlanes | null) => void
resultado: ResultadoPlanes | null
errorCarga: string | null
/**
* true cuando "Mis planes" vino vacío (el usuario no tiene planes asignados:
* los históricos tienen vendedorId nulo) y se está mostrando el listado
* completo para no dejar la pantalla en blanco.
*/
sinAsignados: boolean
}
/**
* Carga de planes compartida por el dashboard y "Cobranza del día": el toggle
* "Mis planes / Todos" vive en la URL (?vista=mios|todos) para que ambas
* pantallas respeten la misma elección; sin parámetro arranca según el rol.
*/
export function usePlanesCobranza(): EstadoPlanes {
const [searchParams, setSearchParams] = useSearchParams()
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const [sinAsignados, setSinAsignados] = useState(false)
useEffect(() => {
let activo = true
setResultado(null)
setErrorCarga(null)
void (async () => {
try {
const r = await listarPlanes(undefined, mios)
if (!activo) return
if (sinPlanesAsignados(r, mios)) {
const todos = await listarPlanes(undefined, false)
if (!activo) return
setSinAsignados(true)
setResultado(todos)
return
}
setSinAsignados(false)
setResultado(r)
} catch (e) {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
}
})()
return () => {
activo = false
}
}, [mios])
const definirVista = useCallback(
(vista: VistaPlanes | null) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
if (vista === null) next.delete('vista')
else next.set('vista', vista)
return next
},
{ replace: true },
)
},
[setSearchParams],
)
return { mios, definirVista, resultado, errorCarga, sinAsignados }
}

View File

@@ -1,5 +1,5 @@
import { SkeletonList } from "../components/Skeleton"
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import {
Bar,
@@ -15,8 +15,7 @@ import {
YAxis,
} from 'recharts'
import type { StatusPlan } from '../types'
import { listarPlanes, miosPorDefecto, type ResultadoPlanes } from '../services/repository'
import { getSesion } from '../services/auth'
import { usePlanesCobranza } from '../lib/usePlanesCobranza'
import {
alCorriente,
avancePlan,
@@ -33,6 +32,7 @@ import { useHoy } from '../lib/useHoy'
import MoraDashboard from "../components/MoraDashboard"
import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
import ToggleVista from '../components/ToggleVista'
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
const COLORES_PIE = ['#2563EB', '#CBD5E1']
@@ -49,8 +49,6 @@ function claveMes(fecha: Date): string {
}
export default function DashboardPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
// Filtros y ordenamiento persistidos en la URL para que sean compartibles
const [searchParams, setSearchParams] = useSearchParams()
const busqueda = searchParams.get('q') ?? ''
@@ -59,30 +57,13 @@ export default function DashboardPage() {
const paramOrden = searchParams.get('sort')
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
// Toggle "Mis planes / Todos": persiste en la URL (?vista=mios|todos); sin
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos"
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
// Toggle "Mis planes / Todos" (persiste en ?vista=): lo comparte con
// "Cobranza del día" y avisa si el usuario no tiene planes asignados
const { mios, definirVista, resultado, errorCarga, sinAsignados } = usePlanesCobranza()
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro)
const hoy = useHoy()
useEffect(() => {
let activo = true
setResultado(null)
setErrorCarga(null)
listarPlanes(undefined, mios)
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
@@ -239,6 +220,11 @@ export default function DashboardPage() {
: 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'}
</p>
)}
{sinAsignados && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
No tienes planes asignados a tu usuario; se muestra el listado de <strong>todos</strong> los planes.
</p>
)}
{/* KPIs */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
@@ -302,29 +288,8 @@ export default function DashboardPage() {
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{/* Filtros */}
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
<div className="flex rounded-lg border border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]" role="group" aria-label="Alcance de planes">
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
aria-pressed={mios}
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
aria-pressed={!mios}
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]' : 'bg-accent text-white shadow-sm'
}`}
>
Todos
</button>
</div>
{/* Toggle Mis planes / Todos (?vista= filtra en el API por el vendedor del token) */}
<ToggleVista mios={mios} onCambiar={definirVista} />
<label htmlFor="filtro-busqueda" className="sr-only">
Buscar por cliente o unidad
</label>
@@ -348,6 +313,7 @@ export default function DashboardPage() {
<option value="TODOS">Todos los status</option>
<option value="ACTIVO">ACTIVO</option>
<option value="LIQUIDADO">LIQUIDADO</option>
<option value="SIN_PLAN">SIN PLAN CAPTURADO</option>
<option value="CANCELADO">CANCELADO</option>
</select>
<label htmlFor="filtro-proyecto" className="sr-only">

View File

@@ -1,34 +1,24 @@
import { SkeletonList } from "../components/Skeleton"
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import { useHoy } from '../lib/useHoy'
import { usePlanesCobranza } from '../lib/usePlanesCobranza'
import KpiCard from '../components/KpiCard'
import ToggleVista from '../components/ToggleVista'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
/**
* Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de los
* planes activos del alcance elegido (el mismo toggle "Mis planes / Todos" del
* dashboard, compartido por la URL).
*/
export default function HoyPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const { mios, definirVista, resultado, errorCarga, sinAsignados } = usePlanesCobranza()
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro)
const hoy = useHoy()
useEffect(() => {
let activo = true
listarPlanes()
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
@@ -51,8 +41,18 @@ export default function HoyPage() {
<div>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cobranza del día</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
Pagos vencidos y vencimientos de los próximos 7 días, en los planes activos
{mios ? ' de tu alcance' : ' de todos los proyectos'}.
</p>
<div className="mt-3">
{/* Mismo toggle que el dashboard: la elección viaja en ?vista= */}
<ToggleVista mios={mios} onCambiar={(vista) => definirVista(vista)} />
</div>
{sinAsignados && (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
No tienes planes asignados a tu usuario; se muestra la cobranza de <strong>todos</strong> los planes.
</p>
)}
{resultado.origen === 'local' && (
<p className="mt-2 text-xs text-amber-700">
Modo offline: sin conexión al API; mostrando datos guardados en este navegador.
@@ -123,7 +123,8 @@ export default function HoyPage() {
{items.length === 0 && (
<tr>
<td colSpan={9} className="py-8 text-center text-neutral-500">
No hay pagos vencidos ni vencimientos en los próximos 7 días.
No hay pagos vencidos ni vencimientos en los próximos 7 días
{mios ? ' en tus planes.' : '.'}
</td>
</tr>
)}

View File

@@ -2,11 +2,14 @@ import { Fragment, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types'
import {
actualizarPlanAsync,
agregarNotaAsync,
cancelarPlanAsync,
obtenerPlanPorId,
puedeEditarMora,
registrarAbonoAsync,
} from '../services/repository'
import { getSesion } from '../services/auth'
import {
avancePlan,
diasAtraso,
@@ -43,6 +46,12 @@ export default function PlanDetallePage() {
const [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('')
const [errorNota, setErrorNota] = useState<string | null>(null)
// Captura de la tasa de mora (vive en el plan, editable por admin/manager/dirección)
const [tasaInput, setTasaInput] = useState('0')
const [guardandoMora, setGuardandoMora] = useState(false)
const [errorMora, setErrorMora] = useState<string | null>(null)
const [avisoMora, setAvisoMora] = useState<string | null>(null)
const puedeEditarTasa = puedeEditarMora(getSesion()?.usuario.rol)
// "Hoy" se recalcula al enfocar la ventana (evita atrasos/mora congelados si
// la pestaña queda abierta de un día para otro)
const hoy = useHoy()
@@ -70,6 +79,12 @@ export default function PlanDetallePage() {
}
}, [id])
// La caja de la tasa se prellena al cargar el plan (o 0 si no tiene). No se
// reinicia con cada recarga del plan para no borrar lo que el usuario escribe.
useEffect(() => {
if (plan) setTasaInput(String(plan.tasaMoraMensual ?? 0))
}, [plan?.id])
if (cargando) {
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
}
@@ -94,6 +109,11 @@ export default function PlanDetallePage() {
const atraso = diasAtraso(plan, hoy)
const tasaMora = plan.tasaMoraMensual ?? 0
const mora = totalMora(plan, hoy)
// Mora que resultaría con lo capturado en la caja (vista previa antes de guardar)
const tasaCapturada = Number(tasaInput)
const tasaCapturadaValida = Number.isFinite(tasaCapturada) && tasaCapturada >= 0 && tasaCapturada <= 100
const moraConTasaCapturada = tasaCapturadaValida ? totalMora({ ...plan, tasaMoraMensual: tasaCapturada }, hoy) : 0
const tasaSinCambios = tasaCapturadaValida && round2(tasaCapturada) === round2(tasaMora)
const toggleExpandido = (pagoId: string) => {
setExpandidos((prev) => {
@@ -154,6 +174,31 @@ export default function PlanDetallePage() {
}
}
/** Guarda la tasa de mora mensual del plan (PATCH /planes/:id) */
const guardarTasaMora = async () => {
if (!tasaCapturadaValida) {
setErrorMora('Captura una tasa mensual entre 0 y 100 (%)')
return
}
setGuardandoMora(true)
setErrorMora(null)
setAvisoMora(null)
try {
const r = await actualizarPlanAsync(plan.id, { tasaMoraMensual: round2(tasaCapturada) })
setPlan(r.plan)
setOffline(r.origen === 'local')
setAvisoMora(
r.origen === 'local'
? 'Sin conexión al API: la tasa se guardó solo en este navegador.'
: 'Tasa de mora actualizada.',
)
} catch (e) {
setErrorMora(e instanceof Error ? e.message : 'Error al guardar la tasa de mora')
} finally {
setGuardandoMora(false)
}
}
const avance = avancePlan(plan)
/** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */
@@ -175,7 +220,9 @@ export default function PlanDetallePage() {
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{status === 'SIN_PLAN' ? 'SIN PLAN CAPTURADO' : status}
</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
@@ -201,6 +248,14 @@ export default function PlanDetallePage() {
</p>
)}
{status === 'SIN_PLAN' && (
<div className="rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
<strong>Sin plan capturado:</strong> este plan no tiene calendario de pagos (enganche y
mensualidades en cero), así que no aparece en “Cobranza del día” ni en el reporte de mora. No es
un plan liquidado: hay que capturar su calendario para poder cobrarlo.
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
@@ -421,6 +476,75 @@ export default function PlanDetallePage() {
</div>
</section>
{/* Mora del plan: la tasa vive en el plan y la editan admin/manager/dirección */}
<section
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
aria-labelledby="titulo-mora"
>
<h2 id="titulo-mora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Mora por atraso
</h2>
{puedeEditarTasa ? (
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault()
void guardarTasaMora()
}}
>
<label htmlFor="tasa-mora" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Tasa de mora mensual (%)
</label>
<input
id="tasa-mora"
type="number"
min={0}
max={100}
step="0.01"
inputMode="decimal"
value={tasaInput}
onChange={(e) => {
setTasaInput(e.target.value)
setErrorMora(null)
setAvisoMora(null)
}}
className="mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
La tasa es <strong>mensual en %</strong> y se prorratea por día (tasa ÷ 30) sobre el restante
de cada pago vencido, incluidos los parciales vencidos. 0 = sin mora.
</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Mora acumulada con esta tasa:{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(moraConTasaCapturada)}
</span>
</p>
{errorMora && <p className="mt-1 text-xs text-red-700">{errorMora}</p>}
{avisoMora && <p className="mt-1 text-xs text-green-700">{avisoMora}</p>}
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={guardandoMora || !tasaCapturadaValida || tasaSinCambios}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
>
{guardandoMora ? 'Guardando…' : 'Guardar tasa'}
</button>
</div>
</form>
) : (
<div className="mt-4 space-y-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
<p>
Tasa: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{tasaMora}% mensual</span>
</p>
<p>
Mora acumulada: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(mora)}</span>
</p>
<p className="text-xs">Solo admin, manager y dirección pueden modificar la tasa.</p>
</div>
)}
</section>
{/* Bitácora del plan */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">

View File

@@ -379,3 +379,68 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
expect(r.planes.length).toBeGreaterThan(0)
})
})
describe('edición del plan y alcance sin planes asignados', () => {
it('sinPlanesAsignados: solo cuando "míos" vino vacío desde el API', async () => {
const { sinPlanesAsignados } = await import('./repository')
expect(sinPlanesAsignados({ planes: [], origen: 'api', planesSoloLocales: false }, true)).toBe(true)
// con resultados, con vista "todos" o en modo offline no aplica
expect(sinPlanesAsignados({ planes: [planLocal()], origen: 'api', planesSoloLocales: false }, true)).toBe(false)
expect(sinPlanesAsignados({ planes: [], origen: 'api', planesSoloLocales: false }, false)).toBe(false)
expect(sinPlanesAsignados({ planes: [], origen: 'local', planesSoloLocales: false }, true)).toBe(false)
})
it('puedeEditarMora: admin/manager/dirección sí; vendedor y roles de operación no', async () => {
const { puedeEditarMora } = await import('./repository')
expect(puedeEditarMora('admin')).toBe(true)
expect(puedeEditarMora('manager')).toBe(true)
expect(puedeEditarMora('direccion_general')).toBe(true)
expect(puedeEditarMora('director_ventas')).toBe(true)
expect(puedeEditarMora('coordinador')).toBe(true) // legacy de dirección
expect(puedeEditarMora('vendedor')).toBe(false)
expect(puedeEditarMora('administracion_venta')).toBe(false)
expect(puedeEditarMora('escrituracion')).toBe(false)
expect(puedeEditarMora(null)).toBe(false)
expect(puedeEditarMora(undefined)).toBe(false)
})
it('actualizarPlanAsync hace PATCH /planes/:id con la tasa de mora', async () => {
const { actualizarPlanAsync } = await import('./repository')
const fetchMock = fetchQue(async () => respuestaJson(planApi({ tasaMoraMensual: 2.5 })))
vi.stubGlobal('fetch', fetchMock)
const r = await actualizarPlanAsync('api-1', { tasaMoraMensual: 2.5 })
expect(r.origen).toBe('api')
expect(r.plan.tasaMoraMensual).toBe(2.5)
expect(fetchMock).toHaveBeenCalledWith(
`${API_URL}/planes/api-1`,
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ tasaMoraMensual: 2.5 }) }),
)
})
it('actualizarPlanAsync propaga el 403 del API (rol sin permiso) sin tocar localStorage', async () => {
const { actualizarPlanAsync } = await import('./repository')
vi.stubGlobal(
'fetch',
fetchQue(async () => respuestaJson({ error: 'Solo admin, manager o dirección pueden modificar el plan' }, 403)),
)
await expect(actualizarPlanAsync('api-1', { tasaMoraMensual: 2 }, crearStorageStub())).rejects.toBeInstanceOf(ErrorApi)
})
it('actualizarPlanAsync cae a localStorage cuando falla la red', async () => {
const { actualizarPlanAsync } = await import('./repository')
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
const r = await actualizarPlanAsync('local-1', { tasaMoraMensual: 3 }, storage)
expect(r.origen).toBe('local')
expect(r.plan.tasaMoraMensual).toBe(3)
const guardado = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[]
expect(guardado[0].tasaMoraMensual).toBe(3)
})
})

View File

@@ -183,11 +183,30 @@ export function rutaListadoPlanes(mios: boolean): string {
/**
* Vista inicial del toggle "Mis planes / Todos": los vendedores arrancan en
* "Mis planes"; el resto de roles (admin, coordinador, corporativo) en "Todos".
* Ojo: los planes históricos tienen `vendedorId` nulo, así que "Mis planes"
* puede legítimamente venir vacío; las páginas detectan ese caso y muestran
* todos los planes con un aviso (ver `sinPlanesAsignados`).
*/
export function miosPorDefecto(rol: string | null | undefined): boolean {
return rol === 'vendedor'
}
/**
* Roles que pueden editar parámetros del plan (tasa de mora). Espejo de
* ROLES_EDITAN_PLAN del API (PATCH /planes/:id): admin, manager y dirección
* (más los roles legacy que la matriz homologa a dirección de ventas).
*/
export function puedeEditarMora(rol: string | null | undefined): boolean {
return (
rol === 'admin' ||
rol === 'manager' ||
rol === 'direccion_general' ||
rol === 'director_ventas' ||
rol === 'coordinador' ||
rol === 'corporativo'
)
}
/**
* GET /planes. Ante error de red cae a localStorage (sembrando si está vacío).
* Si el API responde vacío pero hay planes locales, los conserva solo localmente
@@ -306,3 +325,43 @@ export async function cancelarPlanAsync(
return { plan: cancelarPlan(id, storage), origen: 'local' }
}
}
/** Campos del plan que se pueden editar (PATCH /planes/:id) */
export interface EdicionPlan {
/** Tasa de mora mensual en % sobre el restante vencido; 0 = sin mora */
tasaMoraMensual: number
}
/**
* PATCH /planes/:id. El API exige rol admin/manager/dirección (403 → se
* propaga). Solo la red cae a localStorage.
*/
export async function actualizarPlanAsync(
id: string,
cambios: EdicionPlan,
storage: StorageLike = storageDefault(),
): Promise<ResultadoPlan> {
try {
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(cambios),
})
return { plan: desdeApi(plan), origen: 'api' }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; los cambios se guardaron solo en localStorage:', err)
const local = obtenerPlan(id, storage)
if (!local) throw new Error('Plan no encontrado')
return { plan: actualizarPlan({ ...local, ...cambios }, storage), origen: 'local' }
}
}
/**
* ¿El listado "Mis planes" vino vacío? El API respondió sin error pero el
* usuario no tiene planes asignados (los planes históricos tienen
* `vendedorId` nulo). Las páginas lo usan para no dejar al usuario viendo una
* lista vacía: caen a todos los planes y lo avisan.
*/
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
return mios && resultado.origen === 'api' && resultado.planes.length === 0
}

View File

@@ -1,4 +1,5 @@
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 type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export interface PlanPago {

View File

@@ -23,18 +23,24 @@ interface Fila {
/**
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
* con la propuesta activa calculada por el motor con los parámetros de cada
* desarrollo.
* desarrollo. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza)
* inventario apartado, vendido o bloqueado.
*/
export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) {
const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = useState('')
const [codigoB, setCodigoB] = useState('')
const disponibles = useMemo(
() => unidades.filter((u) => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'),
[unidades],
)
const porCodigo = useMemo(() => {
const m = new Map<string, Unidad>()
for (const u of unidades) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
return m
}, [unidades])
}, [disponibles])
const unidadA = porCodigo.get(codigoA) ?? null
const unidadB = porCodigo.get(codigoB) ?? null
@@ -82,7 +88,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
>
<option value="">Selecciona una unidad…</option>
{LISTA_DESARROLLOS.map((dev) => {
const delDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}>
@@ -172,8 +178,8 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
</tbody>
</table>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Cada unidad se calcula con los parámetros de su desarrollo y el mismo apartado.
El enganche ya incluye la deducción del apartado.
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
</p>
</div>
)}

View File

@@ -27,6 +27,15 @@ const formatoFechaHora = (iso: string): string =>
minute: '2-digit',
})
/**
* Mensualidades guardadas: "N × $m". Las cotizaciones viejas (plazo 0/null, sin
* mensualidad) muestran "—".
*/
const formatoMensualidades = (c: CotizacionGuardada): string => {
if (c.mensualidad === null || c.numMensualidades === 0) return '—'
return `${c.numMensualidades} × ${formatoMoneda(c.mensualidad)}`
}
/** Sección colapsable "Mis cotizaciones": historial del vendedor logueado */
export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Props) {
const [abierto, setAbierto] = useState(false)
@@ -65,7 +74,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Precio final', ''].map(
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Mensualidades', 'Precio final', ''].map(
(h) => (
<th
key={h}
@@ -95,6 +104,9 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta}
</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{formatoMensualidades(c)}
</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap">
{formatoMoneda(c.precioFinal)}
</td>

View File

@@ -12,6 +12,9 @@ const DIAGONAL: ParametrosDesarrollo = {
nombre: 'Diagonal',
precioUnico: false,
fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega: mediana de la
// terminación de obra del inventario disponible (2026-09) = 13 mensualidades
nDefault: 13,
cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA',
propuestas: [
@@ -180,7 +183,10 @@ const SENDA: ParametrosDesarrollo = {
id: 'SENDA',
nombre: 'Senda',
precioUnico: true,
fuenteDeN: 'fechaEntrega', // unidades sin fecha en TERM => N null (mensualidades "—")
fuenteDeN: 'fechaEntrega', // N = meses hasta TERM. OBRA (captura ESCRITURACION)
// Último recurso si la unidad no trae fecha: mediana del inventario
// disponible (2026-09) = 6 mensualidades
nDefault: 6,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
@@ -236,6 +242,9 @@ const SOLANA: ParametrosDesarrollo = {
nombre: 'Solana',
precioUnico: true,
fuenteDeN: 'porUnidad', // MESES PARA FIRMA APROX del inventario (1–4)
// Último recurso si la unidad no trae meses para firma: las casas vendibles
// del inventario Excel están en 1 mes
nDefault: 1,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [

View File

@@ -55,6 +55,8 @@ export interface EstadoRestaurar {
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
descuento?: number
propuestaIndex?: number
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
numMensualidades?: number | null
}
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */
@@ -96,6 +98,7 @@ export function useCotizacion(): UseCotizacionResult {
}
setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
}
const nueva = () => {

View File

@@ -169,7 +169,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0 en el Excel, N = 4)', ()
describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-25-A101')
it('N = null y mensualidades "—" con nota; enganche y pago final sí se calculan', () => {
it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
expect(unidad.fechaEntrega).toBeNull()
const [bancario] = calcularPropuestas({
unidad,
@@ -177,13 +177,13 @@ describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('SENDA'),
})
expect(bancario.numMensualidades).toBeNull()
expect(bancario.mensualidad).toBeNull()
expect(bancario.mensualidadesTotal).toBeNull()
expect(bancario.notaMensualidades).toBe('sin fecha de entrega')
// Antes: numMensualidades/mensualidad null y "— (sin fecha de entrega)"
expect(bancario.numMensualidades).toBe(DESARROLLOS['SENDA'].nDefault)
expect(bancario.notaMensualidades).toBeUndefined()
expect(bancario.mensualidad).not.toBeNull()
expect(bancario.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
expect(bancario.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeNull()
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
})
})
@@ -209,11 +209,12 @@ describe('SOLANA — casa 6 (L = 2,560,000, apartado 20,000, mesesParaFirma = 1)
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
})
it('INFONAVIT: enganche 0% con guarda (no resta el apartado)', () => {
it('INFONAVIT: enganche 0% y el apartado se descuenta de las mensualidades', () => {
const [, p] = calcular()
expect(p.enganche).toBe(0)
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('CONTADO', () => {
@@ -240,19 +241,115 @@ describe('SOLANA — casa 9 (L = 2,605,000, mesesParaFirma = 1)', () => {
descuentos: descuentosDefault('SOLANA'),
})
it('las 4 propuestas al centavo', () => {
it('las 4 propuestas al centavo (Infonavit descuenta el apartado)', () => {
const [bancario, infonavit, contado, semi] = calcular()
expect(bancario.enganche).toBeCloseTo(110250, 2)
expect(bancario.mensualidadesTotal).toBeCloseTo(130250, 2)
expect(bancario.pagoFinal).toBeCloseTo(2344500, 2)
expect(infonavit.enganche).toBe(0)
expect(infonavit.mensualidadesTotal).toBeCloseTo(130250, 2)
expect(infonavit.mensualidadesTotal).toBeCloseTo(110250, 2)
expect(infonavit.pagoFinal).toBeCloseTo(2474750, 2)
expect(contado.enganche).toBeCloseTo(2324500, 2)
expect(contado.pagoFinal).toBeCloseTo(260500, 2)
expect(semi.enganche).toBeCloseTo(1282500, 2)
expect(semi.mensualidadesTotal).toBeCloseTo(1042000, 2)
expect(semi.pagoFinal).toBeCloseTo(260500, 2)
for (const p of [bancario, infonavit, contado, semi]) {
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2605000, 2)
}
})
})
describe('cuadre del total: apartado y enganche personalizado', () => {
const TIPOS = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'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}-TEST`,
modelo: 'X',
desarrollo,
fechaEntrega: null,
mesesParaFirma: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: precio,
precioConAcabados: precio,
status: 'DISPONIBLE',
})
const calcular = (caso: (typeof CASOS)[number], tipo: (typeof TIPOS)[number], apartado: number, enganchePct?: number) =>
calcularPropuestas({
unidad: unidadDe(caso.desarrollo, caso.precio),
acabados: false,
apartado,
descuentos: descuentosDefault(caso.desarrollo),
enganchePct: enganchePct ?? null,
}).find((p) => p.tipo === tipo)!
it('las 4 propuestas de cada desarrollo cuestan lo que el precio final: ni más ni menos', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
const p = calcular(caso, tipo, 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 = calcular(caso, tipo, apartado, 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 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('DIAGONAL'),
enganchePct: 0.2,
})
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
expect(bancario.mensualidadesTotal).toBe(0)
expect(bancario.enganche).toBeCloseTo(0.2 * 4_415_000 - APARTADO_DEFAULT, 2)
})
it('apartado mayor al enganche: el enganche queda en 0 y el total cuadra', () => {
const u101 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
// 2.5% de 4,415,000 = 110,375 → con apartado 200,000 el neto sería −89,625
apartado: 200_000,
descuentos: descuentosDefault('DIAGONAL'),
})
expect(bancario.enganche).toBe(0)
expect(bancario.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - bancario.pagoFinal, 2)
expect(totalPagado(bancario, 200_000)).toBeCloseTo(4_415_000, 2)
})
})

View File

@@ -261,11 +261,16 @@ describe('enganche personalizado (mínimo 2.5% del valor de la propiedad)', () =
})
describe('validarEnganchePct', () => {
it('null es válido (sin override); mínimo 2.5%; máximo 100%', () => {
it('null es válido (sin override); rango válido del Excel: 2.5% a 5%', () => {
expect(validarEnganchePct(null)).toBeNull()
expect(validarEnganchePct(2.5)).toBeNull()
expect(validarEnganchePct(50)).toBeNull()
expect(validarEnganchePct(5)).toBeNull()
expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad')
expect(validarEnganchePct(101)).toBe('El enganche no puede ser mayor al 100%')
expect(validarEnganchePct(5.5)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
expect(validarEnganchePct(20)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
})
})

View File

@@ -23,16 +23,22 @@ export const APARTADO_DEFAULT = 20000
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
export const ENGANCHE_MINIMO_PCT = 0.025
/** Enganche máximo que permite el Excel: 5% (lista de validación 0%, 2.5%, 5%) */
export const ENGANCHE_MAXIMO_PCT = 5
/**
* Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es
* inválido; null si es válido. null de entrada = sin override (default Excel).
* El rango válido es el del Excel: 2.5% a 5%.
*/
export function validarEnganchePct(pct: number | null): string | null {
if (pct === null) return null
if (!Number.isFinite(pct) || pct < 2.5) {
return 'El enganche mínimo es 2.5% del valor de la propiedad'
}
if (pct > 100) return 'El enganche no puede ser mayor al 100%'
if (pct > ENGANCHE_MAXIMO_PCT) {
return 'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)'
}
return null
}
@@ -100,6 +106,10 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
* - fijo: nFijo (entrega inmediata)
* - porUnidad: mesesParaFirma del inventario (null si falta)
* - fechaEntrega: meses desde hoy (null si la unidad no tiene fecha)
*
* Cuando el inventario no trae el dato se usa el plazo por defecto del
* desarrollo (`nDefault`), para que la mensualidad y el total SIEMPRE se
* calculen (antes salían "— (sin fecha de entrega)" y sin total).
*/
export function resolverNumMensualidades(
unidad: Unidad,
@@ -109,9 +119,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)
}
}
@@ -122,6 +134,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,
@@ -140,18 +188,18 @@ function calcularPropuestaConfig(
? Math.max(ENGANCHE_MINIMO_PCT, enganchePct)
: cfg.pctEnganche
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
// 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 =
cfg.engancheCeroSinApartado && pctEnganche === 0
? 0
: pctEnganche * baseEnganche - apartado
const engancheBruto = cfg.engancheCeroSinApartado && pctEnganche === 0 ? 0 : 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) {
@@ -160,23 +208,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
// (ya no hay nada que diferir; el cliente paga de más al inicio)
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,
@@ -186,7 +237,7 @@ function calcularPropuestaConfig(
enganche,
numMensualidades: n,
mensualidad,
mensualidadesTotal,
mensualidadesTotal: mensualidades,
pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
}

View File

@@ -5,6 +5,7 @@ import { compararUnidades } from './comparadorUnidades'
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
import { DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters'
describe('folioCotizacion', () => {
@@ -106,10 +107,11 @@ describe('compararUnidades', () => {
expect(r.a.precioLista).toBe(diagonal.precioSinAcabados)
})
it('total null cuando el plazo no se puede conocer (sin fecha de entrega)', () => {
it('una unidad sin fecha de entrega usa el plazo por defecto del desarrollo', () => {
const sinFecha = { ...diagonal, fechaEntrega: null }
const r = compararUnidades(sinFecha, diagonal, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true)
expect(r.a.total).toBeNull()
expect(r.a.propuesta.numMensualidades).toBe(DESARROLLOS['DIAGONAL']?.nDefault)
expect(r.a.total).not.toBeNull()
expect(r.b.total).not.toBeNull()
})
})

View File

@@ -20,6 +20,7 @@ const respuesta = (data: unknown, status = 200) =>
const almacenamiento = new Map<string, string>()
beforeEach(() => {
almacenamiento.clear()
window.history.replaceState({}, '', '/')
vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
@@ -88,4 +89,58 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
})
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
})
it('no deja cotizar una unidad que ya no está disponible: avisa y bloquea las acciones', async () => {
const vendida = { ...unidad, codigo: 'U-900', status: 'VENDIDO' as const }
const cotizacionVendida = {
id: 'cot-vendida',
folio: 'COT-VENDID',
unidadCodigo: vendida.codigo,
desarrollo: 'DIAGONAL',
clienteNombre: 'Cliente Antiguo',
clienteTelefono: null,
clienteEmail: null,
tipoPropuesta: 'BANCARIO',
acabados: false,
apartado: 20000,
descuento: 0,
precioLista: vendida.precioConAcabados,
precioFinal: 0,
enganche: 0,
numMensualidades: 12,
mensualidad: 1000,
pagoFinal: 0,
createdAt: '2026-09-01T00:00:00.000Z',
}
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad, vendida]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([cotizacionVendida]))
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
})
vi.stubGlobal('fetch', fetchMock)
// Prellenado desde Mi día con una unidad que ya se vendió
window.history.replaceState({}, '', `/?unidad=${vendida.codigo}`)
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
// El prellenado NO la preselecciona y lo dice
expect(await screen.findByText(/ya no está disponible \(estatus: VENDIDO\)/)).toBeTruthy()
// Retomar la cotización vieja de esa unidad: se restaura, pero avisa y
// deja Guardar/Compartir/Generar PDF bloqueados
fireEvent.click(await screen.findByRole('button', { name: /Ver historial/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Retomar' }))
const alerta = await screen.findByRole('alert')
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
}
})
})

View File

@@ -21,18 +21,22 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import {
getMisCotizaciones,
guardarCotizacion,
urlPublicaCotizacion,
ErrorApiCotizacion,
TIPO_A_API,
TIPO_DE_API,
type CotizacionGuardada,
} from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
import type { Cliente } from '../types'
import type { Cliente, Unidad } from '../types'
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
const esquemaCliente = z.object({
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
@@ -162,11 +166,21 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Preselecciona la unidad cuando el inventario terminó de cargar
// Preselecciona la unidad cuando el inventario terminó de cargar. Solo
// unidades DISPONIBLES: si la pedida ya se apartó/vendió (o no está en el
// inventario) se avisa y no se preselecciona nada.
useEffect(() => {
if (unidadPendiente === null || cargando) return
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
if (encontrada) {
if (!encontrada) {
setAviso(
`La unidad ${unidadPendiente} no está en el inventario actual: elige otra unidad disponible.`,
)
} else if (!esDisponible(encontrada)) {
setAviso(
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
)
} else {
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
setUnidad(encontrada)
@@ -180,7 +194,9 @@ export default function CotizadorPage() {
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
@@ -234,6 +250,8 @@ export default function CotizadorPage() {
* Persiste la cotización en el API. Si no responde, la guarda localmente
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
* Si el servidor RECHAZA la cotización (unidad no disponible, datos inválidos)
* devuelve null: se avisa y no se guarda nada.
*/
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null
@@ -247,6 +265,7 @@ export default function CotizadorPage() {
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
...(numMeses !== null ? { numMensualidades: numMeses } : {}),
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
...(oportunidadId !== null ? { oportunidadId } : {}),
})
@@ -256,6 +275,12 @@ export default function CotizadorPage() {
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio
} catch (err) {
// El servidor contestó y rechazó la cotización: no se guarda ni local
if (err instanceof ErrorApiCotizacion) {
toast.error(err.message)
setAviso(err.message)
return null
}
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
const local = cotizacionLocal(cliente)
void guardarCotizacionLocal(local)
@@ -272,10 +297,11 @@ export default function CotizadorPage() {
setGuardando(true)
try {
const guardada = await guardarEnApi(cliente)
let folio: string | undefined
// Rechazada por el servidor (p. ej. la unidad dejó de estar disponible):
// no se genera el PDF
if (!guardada) return
const folio = guardada.folio ?? folioCotizacion(guardada.id)
let qrDataUrl: string | undefined
if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id)
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
@@ -283,7 +309,6 @@ export default function CotizadorPage() {
width: 200,
})
}
}
generarPdf({
unidad,
acabados,
@@ -293,7 +318,7 @@ export default function CotizadorPage() {
propuestas: propuestaActiva ? [propuestaActiva] : [],
cliente,
desarrollo: params,
...(folio ? { folio } : {}),
folio,
...(qrDataUrl ? { qrDataUrl } : {}),
vigencia: calcularVigencia(),
})
@@ -402,12 +427,17 @@ export default function CotizadorPage() {
apartado: c.apartado,
descuento: c.descuento,
propuestaIndex: idx >= 0 ? idx : 0,
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null,
})
setValue('nombre', c.clienteNombre, { shouldValidate: true })
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c)
setAviso(`Cotización ${c.folio ?? ''} retomada.`)
setAviso(
esDisponible(u)
? `Cotización ${c.folio ?? ''} retomada.`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`,
)
}
const handleNueva = () => {
@@ -442,6 +472,17 @@ export default function CotizadorPage() {
</p>
)}
{unidadNoDisponible && unidad && (
<p
role="alert"
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-800"
>
La unidad {unidad.codigo} no está disponible (estatus: {unidad.status}): no se puede
cotizar ni apartar. Elige otra unidad disponible; Guardar, Compartir y Generar PDF
quedan bloqueados.
</p>
)}
<div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */}
<aside className="space-y-6 lg:sticky lg:top-24">
@@ -549,7 +590,7 @@ export default function CotizadorPage() {
id="enganche"
type="number"
min={2.5}
max={100}
max={ENGANCHE_MAXIMO_PCT}
step={0.5}
placeholder="Según propuesta"
value={enganchePct ?? ''}
@@ -568,7 +609,8 @@ export default function CotizadorPage() {
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5%
Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
</p>
)}

View File

@@ -87,10 +87,27 @@ export interface GuardarCotizacionPayload {
acabados: boolean
apartado?: number
descuento?: number
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
numMensualidades?: number
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string
}
/**
* Error del API al guardar una cotización (status HTTP + mensaje del servidor).
* Se distingue de los fallos de red: el servidor SÍ contestó y rechazó la
* cotización (p. ej. la unidad ya no está disponible), así que no se guarda.
*/
export class ErrorApiCotizacion extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ErrorApiCotizacion'
}
}
/**
* URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA:
* un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el
@@ -111,7 +128,11 @@ export async function guardarCotizacion(payload: GuardarCotizacionPayload): Prom
body: JSON.stringify(payload),
}))
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
if (!resp.ok) {
// El API explica el rechazo (unidad no disponible, datos inválidos, …)
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
throw new ErrorApiCotizacion(resp.status, cuerpo?.error ?? `No se pudo guardar la cotización (HTTP ${resp.status})`)
}
return (await resp.json()) as CotizacionGuardada
}

View File

@@ -86,6 +86,12 @@ export interface ParametrosDesarrollo {
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
/** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / term. obra) */
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.
*/
nDefault?: number
/** Cuota mensual de mantenimiento (MXN); null => se muestra "PENDIENTE" */
cuotaMantenimiento: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */

View File

@@ -1,5 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
import {
listarClientesMaestro,
exportarClientesMaestro,
obtenerCatalogosClientesMaestro,
type ClienteMaestro,
} from '../services/clientes-maestro'
import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
import ModalClienteMaestro from '../components/ModalClienteMaestro'
import toast from 'react-hot-toast'
@@ -8,13 +13,11 @@ interface Props {
onVolver: () => void
}
/** Desarrollos disponibles para el filtro */
const DESARROLLOS = ['', 'SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA'] as const
const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCELADO'] as const
/**
* Página / clientes-maestro: tabla paginada con filtros de búsqueda,
* desarrollo y estatus. Botón exportar genera CSV desde los datos filtrados.
* desarrollo y estatus. Las opciones de los selectores salen de la base
* (GET /clientes-maestro/catalogos), no de una lista fija. Botón exportar
* genera CSV desde los datos filtrados.
*/
export function ClientesMaestroPage({ onVolver }: Props) {
const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -26,6 +29,20 @@ export function ClientesMaestroPage({ onVolver }: Props) {
const [filtroDesarrollo, setFiltroDesarrollo] = useState('')
const [filtroEstatus, setFiltroEstatus] = useState('')
const [busquedaDebounced, setBusquedaDebounced] = useState('')
const [desarrollos, setDesarrollos] = useState<string[]>([])
const [estatuses, setEstatuses] = useState<string[]>([])
// Catálogos reales de la base para los filtros
useEffect(() => {
obtenerCatalogosClientesMaestro()
.then((catalogos) => {
setDesarrollos(catalogos.desarrollos)
setEstatuses(catalogos.estatuses)
})
.catch(() => {
toast.error('No se pudieron cargar los catálogos de filtros.')
})
}, [])
// Debounce búsqueda
useEffect(() => {
@@ -135,7 +152,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Todos los desarrollos</option>
{DESARROLLOS.filter(Boolean).map((d) => (
{desarrollos.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
@@ -145,7 +162,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Todos los estatus</option>
{ESTATUSES.filter(Boolean).map((e) => (
{estatuses.map((e) => (
<option key={e} value={e}>{e}</option>
))}
</select>

View File

@@ -1,9 +1,16 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import {
DEPARTAMENTOS_SOPORTE,
ESTADOS_SOPORTE,
MAX_ADJUNTOS_SOPORTE,
PRIORIDADES_SOPORTE,
cambiarEstadoSolicitud,
crearSolicitudSoporte,
descargarArchivoSolicitud,
listarSolicitudesSoporte,
nombreArchivoSoporte,
validarAdjuntoSoporte,
type EstadoSoporte,
type SolicitudSoporte,
} from '../services/soporte'
import { getSesion } from '../services/auth'
@@ -23,6 +30,16 @@ const ETIQUETA_ESTADO: Record<string, string> = {
CERRADA: 'Cerrada',
}
const OPCIONES_ESTADO = [
{ valor: '', etiqueta: 'Todos los estatus' },
...ESTADOS_SOPORTE.map((e) => ({ valor: e, etiqueta: ETIQUETA_ESTADO[e] ?? e })),
]
const OPCIONES_DEPARTAMENTO = [
{ valor: '', etiqueta: 'Todos los departamentos' },
...DEPARTAMENTOS_SOPORTE.map((d) => ({ valor: d, etiqueta: d })),
]
const CLASE_ESTADO: Record<string, string> = {
ABIERTA: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
EN_PROCESO: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
@@ -39,6 +56,7 @@ function hoyISO(): string {
export function SoportePage({ onVolver }: Props) {
const sesion = getSesion()
const puedeGestionar = sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager'
const [titulo, setTitulo] = useState('')
const [descripcion, setDescripcion] = useState('')
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
@@ -47,23 +65,33 @@ export function SoportePage({ onVolver }: Props) {
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
const [fechaLimite, setFechaLimite] = useState('')
const [notas, setNotas] = useState('')
const [archivos, setArchivos] = useState<string[]>([])
const [email, setEmail] = useState('')
const [archivos, setArchivos] = useState<File[]>([])
const [errorArchivos, setErrorArchivos] = useState('')
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
const [cargando, setCargando] = useState(true)
const [filtroEstado, setFiltroEstado] = useState('')
const [filtroDepartamento, setFiltroDepartamento] = useState('')
const [ocupado, setOcupado] = useState('')
const cargar = useCallback(async () => {
setCargando(true)
try {
setSolicitudes(await listarSolicitudesSoporte())
setSolicitudes(
await listarSolicitudesSoporte({
...(filtroEstado ? { estado: filtroEstado } : {}),
...(filtroDepartamento ? { departamento: filtroDepartamento } : {}),
}),
)
} catch {
// La lista es secundaria: el form sigue usable si el listado falla
} finally {
setCargando(false)
}
}, [])
}, [filtroEstado, filtroDepartamento])
useEffect(() => {
void cargar()
@@ -71,7 +99,19 @@ export function SoportePage({ onVolver }: Props) {
const agregarArchivos = (lista: FileList | null) => {
if (lista === null) return
setArchivos((prev) => [...prev, ...Array.from(lista).map((f) => f.name)])
const nuevos: File[] = []
const rechazados: string[] = []
for (const archivo of Array.from(lista)) {
const problema = validarAdjuntoSoporte(archivo)
if (problema) rechazados.push(problema)
else nuevos.push(archivo)
}
const combinados = [...archivos, ...nuevos]
if (combinados.length > MAX_ADJUNTOS_SOPORTE) {
rechazados.push(`Máximo ${MAX_ADJUNTOS_SOPORTE} archivos por solicitud.`)
}
setArchivos(combinados.slice(0, MAX_ADJUNTOS_SOPORTE))
setErrorArchivos(rechazados.join(' '))
}
const enviar = async (e: FormEvent) => {
@@ -94,6 +134,7 @@ export function SoportePage({ onVolver }: Props) {
...(fechaLimite ? { fechaLimite } : {}),
...(notas.trim() ? { notas: notas.trim() } : {}),
...(sesion?.usuario.nombre ? { solicitante: sesion.usuario.nombre } : {}),
...(email.trim() ? { email: email.trim() } : {}),
...(archivos.length > 0 ? { archivos } : {}),
})
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
@@ -102,6 +143,7 @@ export function SoportePage({ onVolver }: Props) {
setResponsable('')
setNotas('')
setArchivos([])
setErrorArchivos('')
setFechaLimite('')
await cargar()
} catch (err) {
@@ -111,6 +153,42 @@ export function SoportePage({ onVolver }: Props) {
}
}
const descargar = async (solicitud: SolicitudSoporte, indice: number) => {
const ruta = solicitud.archivos[indice] ?? ''
setError('')
setOcupado(`${solicitud.id}:${indice}`)
try {
const blob = await descargarArchivoSolicitud(solicitud.id, indice)
const url = URL.createObjectURL(blob)
const enlace = document.createElement('a')
enlace.href = url
enlace.download = nombreArchivoSoporte(ruta)
document.body.appendChild(enlace)
enlace.click()
enlace.remove()
URL.revokeObjectURL(url)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo descargar el archivo.')
} finally {
setOcupado('')
}
}
const cambiarEstado = async (solicitud: SolicitudSoporte, estado: string) => {
setAviso('')
setError('')
setOcupado(solicitud.id)
try {
const actualizada = await cambiarEstadoSolicitud(solicitud.id, estado as EstadoSoporte)
setSolicitudes((prev) => prev.map((s) => (s.id === actualizada.id ? actualizada : s)))
setAviso(`"${actualizada.titulo}" ahora está ${ETIQUETA_ESTADO[actualizada.estado] ?? actualizada.estado}.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar el estado.')
} finally {
setOcupado('')
}
}
return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
{/* Header */}
@@ -206,6 +284,18 @@ export function SoportePage({ onVolver }: Props) {
/>
</label>
<label htmlFor="soporte-email" className="block">
<span className={labelClase}>Correo del solicitante</span>
<input
id="soporte-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="nombre@frasa.mx"
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-limite" className="block">
<span className={labelClase}>Fecha límite</span>
<input
@@ -218,11 +308,12 @@ export function SoportePage({ onVolver }: Props) {
</label>
<label htmlFor="soporte-archivos" className="block">
<span className={labelClase}>Archivos</span>
<span className={labelClase}>Archivos (PDF, JPG o PNG · máx 10 MB)</span>
<input
id="soporte-archivos"
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
onChange={(e) => agregarArchivos(e.target.files)}
className={`${inputClase} file:mr-3 file:rounded-md file:border-0 file:bg-[#F1F5F9] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-[#374151]`}
/>
@@ -242,15 +333,15 @@ export function SoportePage({ onVolver }: Props) {
{archivos.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2">
{archivos.map((nombre, i) => (
{archivos.map((archivo, i) => (
<li
key={`${nombre}-${i}`}
key={`${archivo.name}-${i}`}
className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-1.5 text-xs text-[#374151] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
>
{nombre}
{archivo.name}
<button
type="button"
aria-label={`Quitar ${nombre}`}
aria-label={`Quitar ${archivo.name}`}
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
>
@@ -261,6 +352,12 @@ export function SoportePage({ onVolver }: Props) {
</ul>
)}
{errorArchivos && (
<p role="alert" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
{errorArchivos}
</p>
)}
{error && (
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{error}
@@ -282,14 +379,49 @@ export function SoportePage({ onVolver }: Props) {
</form>
{/* Listado de solicitudes */}
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
<section aria-label={puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'} className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager' ? 'Todas las solicitudes' : 'Mis solicitudes'}
{puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2>
{/* Filtros del listado */}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label htmlFor="soporte-filtro-estado" className="block">
<span className={labelClase}>Filtrar por estatus</span>
<select
id="soporte-filtro-estado"
value={filtroEstado}
onChange={(e) => setFiltroEstado(e.target.value)}
className={inputClase}
>
{OPCIONES_ESTADO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
<label htmlFor="soporte-filtro-departamento" className="block">
<span className={labelClase}>Filtrar por departamento</span>
<select
id="soporte-filtro-departamento"
value={filtroDepartamento}
onChange={(e) => setFiltroDepartamento(e.target.value)}
className={inputClase}
>
{OPCIONES_DEPARTAMENTO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
</div>
{cargando ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
) : solicitudes.length === 0 ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Aún no hay solicitudes registradas.</p>
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">
{filtroEstado || filtroDepartamento
? 'Ninguna solicitud coincide con los filtros.'
: 'Aún no hay solicitudes registradas.'}
</p>
) : (
<ul className="mt-4 space-y-3">
{solicitudes.map((s) => (
@@ -299,21 +431,48 @@ export function SoportePage({ onVolver }: Props) {
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{s.titulo}</p>
<div className="flex items-center gap-2">
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
>
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
</span>
{puedeGestionar && (
<select
aria-label={`Cambiar estatus de ${s.titulo}`}
value={s.estado}
disabled={ocupado === s.id}
onChange={(e) => void cambiarEstado(s, e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
{ESTADOS_SOPORTE.map((e) => (
<option key={e} value={e}>{ETIQUETA_ESTADO[e] ?? e}</option>
))}
</select>
)}
</div>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'} ·{' '}
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'}
{s.solicitanteEmail ? ` (${s.solicitanteEmail})` : ''} ·{' '}
{new Date(s.fechaSolicitud).toLocaleDateString('es-MX')}
</p>
<p className="mt-2 text-sm text-[#374151] dark:text-[#CBD5E1]">{s.descripcion}</p>
{s.archivos.length > 0 && (
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Archivos: {s.archivos.join(', ')}
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{s.archivos.map((ruta, i) => (
<li key={`${ruta}-${i}`}>
<button
type="button"
onClick={() => void descargar(s, i)}
disabled={ocupado === `${s.id}:${i}`}
className="rounded-lg border border-[#D1D5DB] bg-[#F9FAFB] px-2.5 py-1 text-xs text-[#374151] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
⬇ {nombreArchivoSoporte(ruta)}
</button>
</li>
))}
</ul>
)}
</li>
))}

View File

@@ -48,6 +48,12 @@ export interface ResultadoListado {
limite: number
}
/** Valores reales de la base para armar los filtros (no listas fijas) */
export interface CatalogosClientesMaestro {
desarrollos: string[]
estatuses: string[]
}
// ─── API calls ──────────────────────────────────────────────────────
/** GET /api/clientes-maestro — listar con filtros y paginación */
@@ -64,6 +70,17 @@ export async function listarClientesMaestro(filtros: FiltrosClientesMaestro = {}
return (await resp.json()) as ResultadoListado
}
/**
* GET /api/clientes-maestro/catalogos — desarrollos y estatus que existen hoy
* en la base; el front arma los selectores de filtro con esto.
*/
export async function obtenerCatalogosClientesMaestro(): Promise<CatalogosClientesMaestro> {
const resp = await fetch(`${API_URL}/clientes-maestro/catalogos`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as CatalogosClientesMaestro
}
/** GET /api/clientes-maestro/exportar — array de objetos para generar CSV */
export async function exportarClientesMaestro(filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'> = {}): Promise<ClienteMaestro[]> {
const params = new URLSearchParams()

View File

@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ESTADOS_SOPORTE,
cambiarEstadoSolicitud,
crearSolicitudSoporte,
descargarArchivoSolicitud,
listarSolicitudesSoporte,
nombreArchivoSoporte,
validarAdjuntoSoporte,
MAX_BYTES_ADJUNTO_SOPORTE,
} from './soporte'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const jsonOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
const solicitudFake = {
id: 'sol-1',
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
responsable: null,
fechaSolicitud: '2026-09-21T00:00:00.000Z',
fechaLimite: null,
notas: null,
archivos: ['soporte/sol-1/a1b2c3d4-factura.pdf'],
solicitante: 'Vendedor',
solicitanteEmail: 'vendedor@frasa.mx',
estado: 'ABIERTA',
createdAt: '2026-09-21T00:00:00.000Z',
updatedAt: '2026-09-21T00:00:00.000Z',
}
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
// ── crearSolicitudSoporte (multipart con adjuntos reales) ────────────
describe('crearSolicitudSoporte', () => {
it('manda multipart con los campos y los archivos adjuntos', async () => {
fetchMock.mockResolvedValue(jsonOk(solicitudFake))
const archivo = new File(['%PDF-1.4'], 'factura.pdf', { type: 'application/pdf' })
await crearSolicitudSoporte({
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
fechaSolicitud: '2026-09-21',
email: 'vendedor@frasa.mx',
archivos: [archivo],
})
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toBe('/api/soporte')
expect(init?.method).toBe('POST')
const form = init?.body as FormData
expect(form).toBeInstanceOf(FormData)
expect(form.get('titulo')).toBe('Impresora')
expect(form.get('fechaSolicitud')).toBe('2026-09-21')
expect(form.get('email')).toBe('vendedor@frasa.mx')
const adjuntos = form.getAll('archivos')
expect(adjuntos).toHaveLength(1)
expect((adjuntos[0] as File).name).toBe('factura.pdf')
})
it('muestra el mensaje real del servidor con los issues de validación', async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
error: 'Validación fallida',
issues: [{ path: ['fechaSolicitud'], message: 'Invalid date' }],
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
)
await expect(
crearSolicitudSoporte({
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
fechaSolicitud: '',
}),
).rejects.toThrow('Validación fallida: fechaSolicitud: Invalid date')
})
})
// ── listarSolicitudesSoporte (filtros) ──────────────────────────────
describe('listarSolicitudesSoporte', () => {
it('sin filtros no agrega query', async () => {
fetchMock.mockResolvedValue(jsonOk([]))
await listarSolicitudesSoporte()
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte')
})
it('manda estado y departamento como query params', async () => {
fetchMock.mockResolvedValue(jsonOk([]))
await listarSolicitudesSoporte({ estado: 'EN_PROCESO', departamento: 'Cobranza' })
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte?estado=EN_PROCESO&departamento=Cobranza')
})
it('ignora filtros vacíos', async () => {
fetchMock.mockResolvedValue(jsonOk([]))
await listarSolicitudesSoporte({ estado: '', departamento: '' })
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte')
})
})
// ── cambiarEstadoSolicitud ──────────────────────────────────────────
describe('cambiarEstadoSolicitud', () => {
it('hace PATCH con el estado nuevo', async () => {
fetchMock.mockResolvedValue(jsonOk({ ...solicitudFake, estado: 'CERRADA' }))
const actualizada = await cambiarEstadoSolicitud('sol-1', 'CERRADA')
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toBe('/api/soporte/sol-1')
expect(init?.method).toBe('PATCH')
expect(JSON.parse(String(init?.body))).toEqual({ estado: 'CERRADA' })
expect(actualizada.estado).toBe('CERRADA')
expect(ESTADOS_SOPORTE).toContain('CERRADA')
})
})
// ── descargarArchivoSolicitud ───────────────────────────────────────
describe('descargarArchivoSolicitud', () => {
it('pide el adjunto por índice y devuelve el blob', async () => {
fetchMock.mockResolvedValue(new Response('contenido', { status: 200 }))
const blob = await descargarArchivoSolicitud('sol-1', 0)
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte/sol-1/archivos/0')
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe('contenido'.length)
})
it('propaga el mensaje del servidor si falla', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: 'Archivo no encontrado en disco' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
)
await expect(descargarArchivoSolicitud('sol-1', 3)).rejects.toThrow('Archivo no encontrado en disco')
})
})
// ── Helpers de UI ───────────────────────────────────────────────────
describe('helpers de adjuntos', () => {
it('valida tipo y tamaño', () => {
expect(validarAdjuntoSoporte(new File(['x'], 'a.pdf', { type: 'application/pdf' }))).toBeNull()
expect(validarAdjuntoSoporte(new File(['x'], 'a.txt', { type: 'text/plain' }))).toContain('a.txt')
const grande = new File(['x'], 'a.pdf', { type: 'application/pdf' })
Object.defineProperty(grande, 'size', { value: MAX_BYTES_ADJUNTO_SOPORTE + 1 })
expect(validarAdjuntoSoporte(grande)).toContain('10 MB')
})
it('nombreArchivoSoporte quita carpeta y prefijo uuid', () => {
expect(nombreArchivoSoporte('soporte/sol-1/a1b2c3d4-factura.pdf')).toBe('factura.pdf')
expect(nombreArchivoSoporte('a1b2c3d4-foto.png')).toBe('foto.png')
})
})

View File

@@ -13,8 +13,17 @@ export const DEPARTAMENTOS_SOPORTE = [
/** Prioridades de una solicitud */
export const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
/** Estados por los que pasa una solicitud (los mueve admin/manager) */
export const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
export type DepartamentoSoporte = (typeof DEPARTAMENTOS_SOPORTE)[number]
export type PrioridadSoporte = (typeof PRIORIDADES_SOPORTE)[number]
export type EstadoSoporte = (typeof ESTADOS_SOPORTE)[number]
/** Tipos y tamaños de adjunto aceptados (igual que el API) */
export const MIMES_ADJUNTO_SOPORTE = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']
export const MAX_BYTES_ADJUNTO_SOPORTE = 10 * 1024 * 1024
export const MAX_ADJUNTOS_SOPORTE = 10
export interface SolicitudSoporte {
id: string
@@ -26,6 +35,7 @@ export interface SolicitudSoporte {
fechaSolicitud: string
fechaLimite: string | null
notas: string | null
/** Rutas relativas de los adjuntos dentro de uploads/ */
archivos: string[]
solicitante: string
solicitanteEmail: string | null
@@ -34,7 +44,44 @@ export interface SolicitudSoporte {
updatedAt: string
}
/** POST /api/soporte — crear solicitud de soporte */
export interface FiltrosSoporte {
estado?: string
departamento?: string
}
/**
* Valida un adjunto antes de subirlo: devuelve el motivo del rechazo o null si
* es válido (el API valida lo mismo, aquí es para no esperar la subida).
*/
export function validarAdjuntoSoporte(archivo: File): string | null {
if (!MIMES_ADJUNTO_SOPORTE.includes(archivo.type)) {
return `${archivo.name}: solo se aceptan PDF, JPG o PNG.`
}
if (archivo.size > MAX_BYTES_ADJUNTO_SOPORTE) {
return `${archivo.name}: excede 10 MB.`
}
return null
}
/** Nombre legible de un adjunto: sin carpeta ni prefijo uuid */
export function nombreArchivoSoporte(ruta: string): string {
const base = ruta.split(/[\\/]/).pop() ?? ruta
return base.replace(/^[0-9a-f]{8}-/i, '')
}
/** Mensaje real del servidor: el error handler responde { error, issues? } */
async function mensajeDeError(resp: Response): Promise<string> {
const body = (await resp.json().catch(() => null)) as
| { error?: string; issues?: Array<{ path?: Array<string | number>; message?: string }> }
| null
const detalles = (body?.issues ?? [])
.map((i) => [i.path?.join('.'), i.message].filter(Boolean).join(': '))
.filter((d) => d.length > 0)
if (detalles.length > 0) return `${body?.error ?? 'Validación fallida'}: ${detalles.join('; ')}`
return body?.error ?? `HTTP ${resp.status}`
}
/** POST /api/soporte — crear solicitud de soporte (multipart si hay adjuntos) */
export async function crearSolicitudSoporte(payload: {
titulo: string
descripcion: string
@@ -46,28 +93,64 @@ export async function crearSolicitudSoporte(payload: {
notas?: string
solicitante?: string
email?: string
archivos?: string[]
/** Adjuntos reales: viajan en el multipart, el API los guarda en disco */
archivos?: File[]
}): Promise<SolicitudSoporte> {
const resp = await fetch(`${API_URL}/soporte`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}))
const form = new FormData()
form.set('titulo', payload.titulo)
form.set('descripcion', payload.descripcion)
form.set('departamento', payload.departamento)
form.set('prioridad', payload.prioridad)
form.set('fechaSolicitud', payload.fechaSolicitud)
if (payload.responsable) form.set('responsable', payload.responsable)
if (payload.fechaLimite) form.set('fechaLimite', payload.fechaLimite)
if (payload.notas) form.set('notas', payload.notas)
if (payload.solicitante) form.set('solicitante', payload.solicitante)
if (payload.email) form.set('email', payload.email)
for (const archivo of payload.archivos ?? []) form.append('archivos', archivo)
// Sin Content-Type explícito: el navegador pone el boundary del multipart
const resp = await fetch(`${API_URL}/soporte`, conAuth({ method: 'POST', body: form }))
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte
}
/** GET /api/soporte — solicitudes propias (admin: todas) */
export async function listarSolicitudesSoporte(): Promise<SolicitudSoporte[]> {
const resp = await fetch(`${API_URL}/soporte`, conAuth())
/** GET /api/soporte — solicitudes propias (admin/manager: todas) con filtros */
export async function listarSolicitudesSoporte(filtros: FiltrosSoporte = {}): Promise<SolicitudSoporte[]> {
const params = new URLSearchParams()
if (filtros.estado) params.set('estado', filtros.estado)
if (filtros.departamento) params.set('departamento', filtros.departamento)
const qs = params.toString()
const resp = await fetch(`${API_URL}/soporte${qs ? `?${qs}` : ''}`, conAuth())
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte[]
}
/** PATCH /api/soporte/:id — cambiar el estado (admin/manager) */
export async function cambiarEstadoSolicitud(id: string, estado: EstadoSoporte): Promise<SolicitudSoporte> {
const resp = await fetch(
`${API_URL}/soporte/${encodeURIComponent(id)}`,
conAuth({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado }),
}),
)
manejar401(resp)
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte
}
/** GET /api/soporte/:id/archivos/:indice — descarga un adjunto (blob) */
export async function descargarArchivoSolicitud(id: string, indice: number): Promise<Blob> {
const resp = await fetch(
`${API_URL}/soporte/${encodeURIComponent(id)}/archivos/${indice}`,
conAuth(),
)
manejar401(resp)
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return await resp.blob()
}