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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user