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:
@@ -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
|
||||
|
||||
48
api-gateway/src/config/campos-departamento.test.ts
Normal file
48
api-gateway/src/config/campos-departamento.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
28
api-gateway/src/controllers/recordatorios.controller.test.ts
Normal file
28
api-gateway/src/controllers/recordatorios.controller.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
478
api-gateway/src/controllers/soporte.controller.test.ts
Normal file
478
api-gateway/src/controllers/soporte.controller.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -' } }] }],
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,26 +44,37 @@ export type ClienteMaestroDTO = {
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function construirWhere(filtros: FiltrosClientesMaestro) {
|
||||
const where: Record<string, unknown> = {}
|
||||
const condiciones: Record<string, unknown>[] = []
|
||||
|
||||
if (filtros.desarrollo) {
|
||||
where.desarrollo = filtros.desarrollo
|
||||
const desarrollo = filtros.desarrollo?.trim()
|
||||
if (desarrollo) {
|
||||
// El mismo desarrollo convive con sufijos en la base ("ATELIER" y
|
||||
// "ATELIER - EDIFICACION"): se acepta el valor exacto o su prefijo, así el
|
||||
// filtro funciona tanto con el valor crudo como con el agrupado.
|
||||
condiciones.push({
|
||||
OR: [{ desarrollo }, { desarrollo: { startsWith: `${desarrollo} -` } }],
|
||||
})
|
||||
}
|
||||
if (filtros.estatus) {
|
||||
where.estatus = filtros.estatus
|
||||
|
||||
const estatus = filtros.estatus?.trim()
|
||||
if (estatus) {
|
||||
condiciones.push({ estatus })
|
||||
}
|
||||
|
||||
if (filtros.busqueda) {
|
||||
const term = filtros.busqueda.trim()
|
||||
where.OR = [
|
||||
{ nombre: { contains: term } },
|
||||
{ apellidoPaterno: { contains: term } },
|
||||
{ apellidoMaterno: { contains: term } },
|
||||
{ telefono: { contains: term } },
|
||||
{ numCliente: { contains: term } },
|
||||
]
|
||||
condiciones.push({
|
||||
OR: [
|
||||
{ nombre: { contains: term } },
|
||||
{ apellidoPaterno: { contains: term } },
|
||||
{ apellidoMaterno: { contains: term } },
|
||||
{ telefono: { contains: term } },
|
||||
{ numCliente: { contains: term } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return where
|
||||
return condiciones.length > 0 ? { AND: condiciones } : {}
|
||||
}
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────────
|
||||
@@ -102,6 +113,33 @@ export async function obtener(numCliente: string): Promise<ClienteMaestroDTO | n
|
||||
return prisma.clienteMaestro.findUnique({ where: { numCliente } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Catálogos reales de los filtros: los desarrollos y estatus que existen hoy en
|
||||
* la base (no una lista fija). El front arma los selectores con esto.
|
||||
*/
|
||||
export async function catalogos(): Promise<{ desarrollos: string[]; estatuses: string[] }> {
|
||||
const [desarrollos, estatuses] = await Promise.all([
|
||||
prisma.clienteMaestro.findMany({
|
||||
distinct: ['desarrollo'],
|
||||
select: { desarrollo: true },
|
||||
orderBy: { desarrollo: 'asc' },
|
||||
}),
|
||||
prisma.clienteMaestro.findMany({
|
||||
distinct: ['estatus'],
|
||||
select: { estatus: true },
|
||||
orderBy: { estatus: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const limpiar = (valores: Array<string | null>): string[] =>
|
||||
valores.filter((v): v is string => v !== null && v.trim() !== '')
|
||||
|
||||
return {
|
||||
desarrollos: limpiar(desarrollos.map((d) => d.desarrollo)),
|
||||
estatuses: limpiar(estatuses.map((e) => e.estatus)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta clientes con filtros.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
|
||||
import {
|
||||
aplanarPerfil,
|
||||
claveNombre,
|
||||
claveTelefono,
|
||||
type DetalleContacto,
|
||||
type EventoHistorialContacto,
|
||||
type RelacionCrm,
|
||||
} from './contactos-detalle.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
@@ -20,10 +27,10 @@ export interface ContactoListaLocal {
|
||||
|
||||
/**
|
||||
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
|
||||
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
|
||||
* ficha completa), más reciente primero. Paginada de 100 en 100 (el front
|
||||
* pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual:
|
||||
* clientes capturados por él + leads asignados a él.
|
||||
* OPORTUNIDADES/APARTADO, deduplicados (gana el Cliente: tiene la ficha
|
||||
* completa), más reciente primero. Paginada de 100 en 100 (el front pagina con
|
||||
* `?pagina=N` y muestra el límite). Filtra por usuario actual: clientes
|
||||
* capturados por él + leads asignados a él.
|
||||
*/
|
||||
export const CONTACTOS_POR_PAGINA = 100
|
||||
|
||||
@@ -50,6 +57,37 @@ export async function esContactoDelUsuario(contactoId: string, usuarioId: string
|
||||
return cliente !== null || captura !== null
|
||||
}
|
||||
|
||||
/** Candidato de la lista antes de deduplicar */
|
||||
interface Candidato {
|
||||
contacto: ContactoListaLocal
|
||||
/** La ficha de Cliente (alta completa) gana a la captura al fusionar */
|
||||
esCliente: boolean
|
||||
/** Cuántos datos trae el registro: desempata la fusión de duplicados */
|
||||
peso: number
|
||||
}
|
||||
|
||||
/** Cuenta los valores no vacíos de la ficha (para saber cuál está más completa) */
|
||||
function pesoDeFicha(valores: readonly (string | null | undefined)[], conPerfil: boolean): number {
|
||||
const llenos = valores.filter((v) => v !== null && v !== undefined && v.trim() !== '').length
|
||||
return llenos + (conPerfil ? 1 : 0)
|
||||
}
|
||||
|
||||
/** true si el JSON trae algo más que un objeto/contenedor vacío */
|
||||
function tieneDatos(json: string | null | undefined): boolean {
|
||||
if (!json) return false
|
||||
return Object.keys(aplanarPerfil(json)).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de contactos locales deduplicada en dos pasos:
|
||||
* 1. por teléfono (últimos 10 dígitos), como siempre;
|
||||
* 2. por nombre para los registros SIN teléfono ni email — las altas fallidas
|
||||
* dejan un Cliente hueco con el mismo nombre que su ficha real, y antes se
|
||||
* repetía siempre porque la clave de teléfono era ''.
|
||||
* Al fusionar se conserva el registro con más datos y se completan sus vacíos
|
||||
* con los del otro (por teléfono gana el Cliente: es la ficha del alta), sin
|
||||
* perder documentos ni relaciones: el detalle une también las dos fichas.
|
||||
*/
|
||||
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
|
||||
const porPagina = CONTACTOS_POR_PAGINA
|
||||
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
||||
@@ -71,43 +109,104 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
take: porPagina + 1,
|
||||
})
|
||||
|
||||
const vistos = new Set<string>()
|
||||
const lista: ContactoListaLocal[] = []
|
||||
const agregar = (entrada: ContactoListaLocal): void => {
|
||||
// Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
|
||||
// teléfono se duplicaban siempre)
|
||||
const tel = claveTelefono(entrada.telefono)
|
||||
const clave = tel !== ''
|
||||
? tel
|
||||
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}`
|
||||
if (vistos.has(clave)) return
|
||||
vistos.add(clave)
|
||||
lista.push(entrada)
|
||||
const lista: Candidato[] = []
|
||||
const porTelefono = new Map<string, number>()
|
||||
const porNombre = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Deja en la posición `i` la ficha ganadora, completando sus vacíos con los
|
||||
* del otro registro. Por teléfono gana el Cliente (mismo teléfono = misma
|
||||
* persona, y su alta es la ficha completa); por nombre gana el que tiene más
|
||||
* datos, que es el criterio de la fusión de duplicados huecos.
|
||||
*/
|
||||
const fusionar = (i: number, nueva: Candidato, tel: string, nombre: string, porNombreClave: boolean): void => {
|
||||
const previa = lista[i]!
|
||||
const ganaNueva = porNombreClave
|
||||
? nueva.peso !== previa.peso
|
||||
? nueva.peso > previa.peso
|
||||
: nueva.esCliente
|
||||
: nueva.esCliente !== previa.esCliente
|
||||
? nueva.esCliente
|
||||
: nueva.peso > previa.peso
|
||||
const [ganadora, perdedora] = ganaNueva ? [nueva, previa] : [previa, nueva]
|
||||
lista[i] = {
|
||||
esCliente: ganadora.esCliente,
|
||||
peso: ganadora.peso,
|
||||
contacto: {
|
||||
...ganadora.contacto,
|
||||
nombre: ganadora.contacto.nombre.trim() !== '' ? ganadora.contacto.nombre : perdedora.contacto.nombre,
|
||||
telefono: ganadora.contacto.telefono !== '' ? ganadora.contacto.telefono : perdedora.contacto.telefono,
|
||||
email: ganadora.contacto.email !== '' ? ganadora.contacto.email : perdedora.contacto.email,
|
||||
estado: ganadora.contacto.estado !== '' ? ganadora.contacto.estado : perdedora.contacto.estado,
|
||||
},
|
||||
}
|
||||
if (tel !== '') porTelefono.set(tel, i)
|
||||
porNombre.set(nombre, i)
|
||||
}
|
||||
|
||||
/** ¿El registro ya agregado en esa posición no tiene teléfono ni email? */
|
||||
const esHueco = (i: number): boolean => {
|
||||
const c = lista[i]!.contacto
|
||||
return c.telefono.trim() === '' && c.email.trim() === ''
|
||||
}
|
||||
|
||||
const agregar = (nueva: Candidato): void => {
|
||||
const tel = claveTelefono(nueva.contacto.telefono)
|
||||
const nombre = claveNombre(nueva.contacto.nombre)
|
||||
const email = nueva.contacto.email.trim()
|
||||
const iNombre = porNombre.get(nombre)
|
||||
// Sin teléfono ni email el nombre es lo único que identifica a la persona.
|
||||
// Con teléfono o email sólo se une a un registro hueco del mismo nombre (la
|
||||
// misma persona antes de completar su ficha), nunca a otro teléfono.
|
||||
const iSinContacto =
|
||||
tel === '' && email === ''
|
||||
? iNombre
|
||||
: iNombre !== undefined && esHueco(iNombre)
|
||||
? iNombre
|
||||
: undefined
|
||||
const iTelefono = tel !== '' ? porTelefono.get(tel) : undefined
|
||||
const i = iTelefono ?? iSinContacto
|
||||
if (i !== undefined) {
|
||||
fusionar(i, nueva, tel, nombre, iTelefono === undefined)
|
||||
return
|
||||
}
|
||||
lista.push(nueva)
|
||||
const indice = lista.length - 1
|
||||
if (tel !== '') porTelefono.set(tel, indice)
|
||||
porNombre.set(nombre, indice)
|
||||
}
|
||||
|
||||
for (const c of clientes) {
|
||||
agregar({
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
|
||||
updatedAt: c.createdAt.toISOString(),
|
||||
fuente: 'local',
|
||||
contacto: {
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
|
||||
updatedAt: c.createdAt.toISOString(),
|
||||
fuente: 'local',
|
||||
},
|
||||
esCliente: true,
|
||||
peso: pesoDeFicha([c.celular, c.email, c.curp, c.rfc, c.proyecto, c.unidadCodigo], tieneDatos(c.payloadJson)),
|
||||
})
|
||||
}
|
||||
for (const c of capturas) {
|
||||
agregar({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.etapa,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
fuente: 'local',
|
||||
contacto: {
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.etapa,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
fuente: 'local',
|
||||
},
|
||||
esCliente: false,
|
||||
peso: pesoDeFicha([c.telefono, c.email, c.desarrollo, c.modelo], tieneDatos(c.datosJson)),
|
||||
})
|
||||
}
|
||||
const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
const hayMas = ordenada.length > porPagina
|
||||
return {
|
||||
contactos: ordenada.slice(0, porPagina),
|
||||
@@ -117,11 +216,34 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clientes huecos con el mismo nombre que el contacto: un alta fallida deja un
|
||||
* registro sin teléfono ni email (payload vacío) que la lista fusiona con su
|
||||
* ficha real. Sólo se miran los huecos — nombre + un teléfono/email propios no
|
||||
* identifican a la misma persona. `excluirId` omite el propio contacto.
|
||||
*/
|
||||
async function huecosConMismoNombre(nombre: string, excluirId: string | null): Promise<string[]> {
|
||||
const huecos = await prisma.cliente.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
...(excluirId !== null ? [{ id: { not: excluirId } }] : []),
|
||||
{ OR: [{ celular: null }, { celular: '' }] },
|
||||
{ OR: [{ email: null }, { email: '' }] },
|
||||
],
|
||||
},
|
||||
select: { id: true, nombreCompleto: true },
|
||||
})
|
||||
const clave = claveNombre(nombre)
|
||||
return huecos.filter((c) => claveNombre(c.nombreCompleto) === clave).map((c) => c.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
|
||||
* heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se
|
||||
* arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el
|
||||
* historial se deriva de capturas + alta + documentos + cotizaciones locales.
|
||||
* El perfil (estado civil, fecha de nacimiento, actividad económica, dirección)
|
||||
* se completa con el payloadJson del alta y el datosJson de las capturas.
|
||||
* null si no existe en ninguna tabla.
|
||||
*/
|
||||
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
|
||||
@@ -160,8 +282,22 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
const documentos = cliente
|
||||
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
|
||||
// Duplicados huecos de la misma persona (altas fallidas: mismo nombre, sin
|
||||
// teléfono ni email). La lista los fusiona en una sola ficha, así que el
|
||||
// detalle muestra también sus documentos para no perderlos, tanto si el
|
||||
// contacto visible es el Cliente como si es la captura.
|
||||
const huecos =
|
||||
cliente !== null
|
||||
? await huecosConMismoNombre(cliente.nombreCompleto, cliente.id)
|
||||
: capturaBase !== null
|
||||
? await huecosConMismoNombre(capturaBase.nombreLead, null)
|
||||
: []
|
||||
const idsConDocumentos = [...(cliente !== null ? [cliente.id] : []), ...huecos]
|
||||
const documentos = idsConDocumentos.length
|
||||
? await prisma.documento.findMany({
|
||||
where: { clienteId: { in: idsConDocumentos } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
|
||||
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
|
||||
@@ -245,6 +381,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
})),
|
||||
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||||
|
||||
// Perfil del alta (payloadJson del Cliente) y de las capturas de afluencia
|
||||
// (datosJson): de ahí salen estado civil, fecha de nacimiento, actividad
|
||||
// económica y dirección, que no son columnas de la BD. Gana el primer valor
|
||||
// no vacío, así una fuente posterior no pisa un dato real con un vacío.
|
||||
const perfiles = [
|
||||
aplanarPerfil(cliente?.payloadJson),
|
||||
...[capturaBase, ...relacionadas].map((c) => aplanarPerfil(c?.datosJson)),
|
||||
]
|
||||
const dePerfil = (...claves: string[]): string => {
|
||||
for (const clave of claves) {
|
||||
for (const perfil of perfiles) {
|
||||
const valor = (perfil[clave] ?? '').trim()
|
||||
if (valor !== '') return valor
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const contacto: Record<string, string> =
|
||||
cliente !== null
|
||||
? {
|
||||
@@ -252,14 +406,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
telefono: cliente.celular ?? '',
|
||||
email: cliente.email ?? '',
|
||||
estado: 'Cliente',
|
||||
curp: cliente.curp,
|
||||
rfc: cliente.rfc,
|
||||
curp: cliente.curp || dePerfil('curp'),
|
||||
rfc: cliente.rfc || dePerfil('rfc'),
|
||||
estadoCivil: dePerfil('estadoCivil'),
|
||||
fechaNacimiento: dePerfil('fechaNacimiento'),
|
||||
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
|
||||
direccion: dePerfil('direccion'),
|
||||
}
|
||||
: {
|
||||
nombre: capturaBase!.nombreLead,
|
||||
telefono: capturaBase!.telefono ?? '',
|
||||
email: capturaBase!.email ?? '',
|
||||
estado: capturaBase!.etapa,
|
||||
curp: dePerfil('curp'),
|
||||
rfc: dePerfil('rfc'),
|
||||
estadoCivil: dePerfil('estadoCivil'),
|
||||
fechaNacimiento: dePerfil('fechaNacimiento'),
|
||||
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
|
||||
direccion: dePerfil('direccion'),
|
||||
}
|
||||
|
||||
return { contacto, propiedad, relaciones, historial }
|
||||
@@ -297,12 +461,17 @@ export async function exportarCSV(
|
||||
}
|
||||
|
||||
const filas: FilaCSV[] = []
|
||||
// Dedupe en dos pasos (igual que el listado): por teléfono y, cuando no hay
|
||||
// teléfono, por nombre normalizado — antes un contacto sin teléfono salía
|
||||
// duplicado en el CSV.
|
||||
const vistos = new Set<string>()
|
||||
const claveNombre = (nombre: string): string => nombre.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
|
||||
for (const c of clientes) {
|
||||
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
const claveTelefono = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(c.nombreCompleto)}`
|
||||
if (vistos.has(clave)) continue
|
||||
vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: c.nombreCompleto,
|
||||
Teléfono: c.celular ?? '',
|
||||
@@ -314,9 +483,10 @@ export async function exportarCSV(
|
||||
}
|
||||
|
||||
for (const cap of capturas) {
|
||||
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
const claveTelefono = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(cap.nombreLead)}`
|
||||
if (vistos.has(clave)) continue
|
||||
vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: cap.nombreLead,
|
||||
Teléfono: cap.telefono ?? '',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
46
apps/cobranza/src/components/ToggleVista.tsx
Normal file
46
apps/cobranza/src/components/ToggleVista.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
84
apps/cobranza/src/lib/usePlanesCobranza.ts
Normal file
84
apps/cobranza/src/lib/usePlanesCobranza.ts
Normal 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 }
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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%)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,17 +297,17 @@ 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), {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
}
|
||||
// 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), {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
}
|
||||
generarPdf({
|
||||
unidad,
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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') */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -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()
|
||||
|
||||
176
apps/landing/src/services/soporte.test.ts
Normal file
176
apps/landing/src/services/soporte.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user