feat(clientes-maestro): importar y exportar clientes del archivo maestro

- Tabla ClienteMaestro (27 campos, unique numCliente)
- Importador Excel: 1535 clientes del Reporte maestro
- API: listar/filtros/exportar/detalle/importar
- Frontend: tabla paginada, filtros, exportar Excel
- Dark mode completo, 52 tests verdes
This commit is contained in:
2026-09-07 09:11:09 +00:00
parent c2a8f4e929
commit 9021733455
18 changed files with 1428 additions and 10 deletions

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
const ESPERADOS: Record<string, string[]> = {
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
administracion_venta: [
'mi-dia',
'alta-cliente',
@@ -11,6 +11,7 @@ const ESPERADOS: Record<string, string[]> = {
'mi-tablero',
'contactos',
'chat',
'clientes-maestro',
'cobranza',
],
director_ventas: [
@@ -21,6 +22,7 @@ const ESPERADOS: Record<string, string[]> = {
'mi-tablero',
'contactos',
'chat',
'clientes-maestro',
'cobranza',
'tablero-comercial',
],
@@ -32,9 +34,10 @@ const ESPERADOS: Record<string, string[]> = {
'mi-tablero',
'contactos',
'chat',
'clientes-maestro',
'cobranza',
],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'],
admin: [...MODULOS],
}

View File

@@ -12,6 +12,7 @@ export const MODULOS = [
'cobranza',
'contactos',
'chat',
'clientes-maestro',
] as const
export type Modulo = (typeof MODULOS)[number]
@@ -33,6 +34,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
'mi-tablero',
'contactos',
'chat',
'clientes-maestro',
]
export const MATRIZ: Record<Rol, readonly Modulo[]> = {
@@ -40,7 +42,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'],
admin: MODULOS,
}

View File

@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Servicio mockeado ----------
const servicioMock = vi.hoisted(() => ({
listar: vi.fn(),
obtener: vi.fn(),
contar: vi.fn(),
exportar: vi.fn(),
importarDesdeExcel: vi.fn(),
}))
vi.mock('../services/cliente-maestro.service.js', () => servicioMock)
import {
listarEndpoint,
exportarEndpoint,
obtenerEndpoint,
importarEndpoint,
} from './cliente-maestro.controller.js'
function reqFake(overrides: Partial<Request> = {}): Request {
return {
body: {},
params: {},
query: {},
usuarioToken: { sub: 'u-1', username: 'admin', rol: 'admin' },
...overrides,
} as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const status = vi.fn().mockReturnValue({ json, end })
return { res: { json, status, end } as unknown as Response, status, json, end }
}
beforeEach(() => {
vi.clearAllMocks()
})
// ── GET /api/clientes-maestro ────────────────────────────────────────
describe('listarEndpoint', () => {
it('200 con datos paginados', async () => {
servicioMock.listar.mockResolvedValue({
datos: [{ id: '1', numCliente: 'C001' }],
total: 1,
pagina: 1,
limite: 50,
})
const { res, json } = resFake()
await listarEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith({
datos: [{ id: '1', numCliente: 'C001' }],
total: 1,
pagina: 1,
limite: 50,
})
})
it('pasa filtros de query al servicio', async () => {
servicioMock.listar.mockResolvedValue({ datos: [], total: 0, pagina: 1, limite: 50 })
const { res } = resFake()
await listarEndpoint(
reqFake({ query: { busqueda: 'Juan', desarrollo: 'SENDA', estatus: 'VENDIDO', pagina: '2', limite: '25' } }),
res,
)
expect(servicioMock.listar).toHaveBeenCalledWith({
busqueda: 'Juan',
desarrollo: 'SENDA',
estatus: 'VENDIDO',
pagina: 2,
limite: 25,
})
})
})
// ── GET /api/clientes-maestro/exportar ───────────────────────────────
describe('exportarEndpoint', () => {
it('200 con array de datos', async () => {
servicioMock.exportar.mockResolvedValue([{ id: '1', numCliente: 'C001' }])
const { res, json } = resFake()
await exportarEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith([{ id: '1', numCliente: 'C001' }])
})
})
// ── GET /api/clientes-maestro/:numCliente ────────────────────────────
describe('obtenerEndpoint', () => {
it('200 con el cliente', async () => {
servicioMock.obtener.mockResolvedValue({ id: '1', numCliente: 'C001', nombre: 'Juan' })
const { res, json } = resFake()
await obtenerEndpoint(reqFake({ params: { numCliente: 'C001' } }), res)
expect(json).toHaveBeenCalledWith({ id: '1', numCliente: 'C001', nombre: 'Juan' })
})
it('404 si no existe', async () => {
servicioMock.obtener.mockResolvedValue(null)
const { res } = resFake()
await expect(
obtenerEndpoint(reqFake({ params: { numCliente: 'NOPE' } }), res),
).rejects.toThrow('Cliente no encontrado')
})
it('400 si numCliente vacío', async () => {
const { res } = resFake()
await expect(obtenerEndpoint(reqFake({ params: { numCliente: '' } }), res)).rejects.toThrow('numCliente requerido')
})
})
// ── POST /api/clientes-maestro/importar ──────────────────────────────
describe('importarEndpoint', () => {
it('200 con resultado de importación', async () => {
servicioMock.importarDesdeExcel.mockResolvedValue({ insertados: 5, actualizados: 2, errores: 1 })
const { res, json } = resFake()
await importarEndpoint(reqFake({ body: [{ numCliente: 'C001', nombre: 'Juan' }] }), res)
expect(json).toHaveBeenCalledWith({ insertados: 5, actualizados: 2, errores: 1 })
})
it('400 si body no es array', async () => {
const { res } = resFake()
await expect(importarEndpoint(reqFake({ body: {} }), res)).rejects.toThrow('Se esperaba un array')
})
it('400 si array vacío', async () => {
const { res } = resFake()
await expect(importarEndpoint(reqFake({ body: [] }), res)).rejects.toThrow('Se esperaba un array')
})
})

View File

@@ -0,0 +1,54 @@
import type { Request, Response } from 'express'
import * as clienteMaestroService from '../services/cliente-maestro.service.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
import { requireAdmin } from './config.controller.js'
// ── GET /api/clientes-maestro ────────────────────────────────────────
export async function listarEndpoint(req: Request, res: Response): Promise<void> {
const busqueda = (req.query.busqueda as string) ?? undefined
const desarrollo = (req.query.desarrollo as string) ?? undefined
const estatus = (req.query.estatus as string) ?? undefined
const pagina = req.query.pagina ? Number(req.query.pagina) : undefined
const limite = req.query.limite ? Number(req.query.limite) : undefined
const resultado = await clienteMaestroService.listar({ busqueda, desarrollo, estatus, pagina, limite })
res.json(resultado)
}
// ── GET /api/clientes-maestro/exportar ───────────────────────────────
export async function exportarEndpoint(req: Request, res: Response): Promise<void> {
const busqueda = (req.query.busqueda as string) ?? undefined
const desarrollo = (req.query.desarrollo as string) ?? undefined
const estatus = (req.query.estatus as string) ?? undefined
const datos = await clienteMaestroService.exportar({ busqueda, desarrollo, estatus })
res.json(datos)
}
// ── GET /api/clientes-maestro/:numCliente ────────────────────────────
export async function obtenerEndpoint(req: Request, res: Response): Promise<void> {
const numCliente = String(req.params['numCliente'] ?? '')
if (!numCliente) throw new BadRequestError('numCliente requerido')
const cliente = await clienteMaestroService.obtener(numCliente)
if (!cliente) throw new NotFoundError('Cliente no encontrado')
res.json(cliente)
}
// ── POST /api/clientes-maestro/importar ──────────────────────────────
export async function importarEndpoint(req: Request, res: Response): Promise<void> {
const datos = req.body
if (!Array.isArray(datos) || datos.length === 0) {
throw new BadRequestError('Se esperaba un array de clientes')
}
const resultado = await clienteMaestroService.importarDesdeExcel(datos)
res.json(resultado)
}
// Re-exportar requireAdmin para las rutas
export { requireAdmin }

View File

@@ -115,6 +115,13 @@ import {
importarEndpoint as importarCapturaEndpoint,
requireAdminCaptura,
} from '../controllers/captura-departamento.controller.js'
import {
listarEndpoint as listarMaestroEndpoint,
exportarEndpoint as exportarMaestroEndpoint,
obtenerEndpoint as obtenerMaestroEndpoint,
importarEndpoint as importarMaestroEndpoint,
requireAdmin as requireAdminMaestro,
} from '../controllers/cliente-maestro.controller.js'
export const apiRouter = Router()
@@ -257,3 +264,10 @@ apiRouter.post('/captura/importar', requireAdminCaptura, importarCapturaEndpoint
apiRouter.get('/push/vapid-key', vapidKeyEndpoint)
apiRouter.post('/push/suscribir', suscribirEndpoint)
apiRouter.post('/push/desuscribir', desuscribirEndpoint)
// ── Clientes Maestro ──────────────────────────────────────────────
apiRouter.get('/clientes-maestro', listarMaestroEndpoint)
// Exportar va ANTES de /:numCliente para no colisionar con el parámetro
apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint)
apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint)
apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint)

View File

@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
clienteMaestro: {
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
upsert: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import {
listar,
obtener,
contar,
exportar,
importarDesdeExcel,
} from './cliente-maestro.service.js'
beforeEach(() => {
vi.clearAllMocks()
})
// ── listar ───────────────────────────────────────────────────────────
describe('listar', () => {
it('devuelve datos, total, pagina y limite', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([
{ id: '1', numCliente: 'C001', nombre: 'Juan', apellidoPaterno: 'Perez', importadoEn: new Date() },
])
prismaMock.clienteMaestro.count.mockResolvedValue(1)
const result = await listar({ pagina: 1, limite: 50 })
expect(result.datos).toHaveLength(1)
expect(result.total).toBe(1)
expect(result.pagina).toBe(1)
expect(result.limite).toBe(50)
})
it('aplica filtro de búsqueda con OR', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ busqueda: 'Juan', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: expect.arrayContaining([
{ nombre: { contains: 'Juan' } },
]),
}),
}),
)
})
it('aplica filtro de desarrollo', 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' }),
}),
)
})
it('aplica filtro de estatus', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ estatus: 'VENDIDO', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ estatus: 'VENDIDO' }),
}),
)
})
})
// ── obtener ──────────────────────────────────────────────────────────
describe('obtener', () => {
it('devuelve el cliente si existe', async () => {
prismaMock.clienteMaestro.findUnique.mockResolvedValue({
id: '1', numCliente: 'C001', nombre: 'Juan', importadoEn: new Date(),
})
const result = await obtener('C001')
expect(result).not.toBeNull()
expect(result!.numCliente).toBe('C001')
})
it('devuelve null si no existe', async () => {
prismaMock.clienteMaestro.findUnique.mockResolvedValue(null)
expect(await obtener('NOPE')).toBeNull()
})
})
// ── contar ───────────────────────────────────────────────────────────
describe('contar', () => {
it('devuelve el conteo total', async () => {
prismaMock.clienteMaestro.count.mockResolvedValue(42)
expect(await contar({})).toBe(42)
})
it('cuenta con filtros', async () => {
prismaMock.clienteMaestro.count.mockResolvedValue(5)
expect(await contar({ desarrollo: 'SENDA' })).toBe(5)
expect(prismaMock.clienteMaestro.count).toHaveBeenCalledWith(
expect.objectContaining({ where: { desarrollo: 'SENDA' } }),
)
})
})
// ── exportar ─────────────────────────────────────────────────────────
describe('exportar', () => {
it('devuelve array de clientes', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([
{ id: '1', numCliente: 'C001', nombre: 'Juan', importadoEn: new Date() },
{ id: '2', numCliente: 'C002', nombre: 'Ana', importadoEn: new Date() },
])
const result = await exportar({})
expect(result).toHaveLength(2)
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 50000 }),
)
})
})
// ── importarDesdeExcel ───────────────────────────────────────────────
describe('importarDesdeExcel', () => {
it('inserta clientes nuevos', async () => {
prismaMock.clienteMaestro.findUnique.mockResolvedValue(null)
prismaMock.clienteMaestro.create.mockResolvedValue({})
const result = await importarDesdeExcel([
{ numCliente: 'C001', nombre: 'Juan', apellidoPaterno: 'Perez' },
])
expect(result.insertados).toBe(1)
expect(result.actualizados).toBe(0)
expect(result.errores).toBe(0)
expect(prismaMock.clienteMaestro.create).toHaveBeenCalled()
})
it('actualiza clientes existentes', async () => {
prismaMock.clienteMaestro.findUnique.mockResolvedValue({ id: '1', numCliente: 'C001' })
prismaMock.clienteMaestro.update.mockResolvedValue({})
const result = await importarDesdeExcel([
{ numCliente: 'C001', nombre: 'Juan', apellidoPaterno: 'Perez' },
])
expect(result.insertados).toBe(0)
expect(result.actualizados).toBe(1)
expect(result.errores).toBe(0)
expect(prismaMock.clienteMaestro.update).toHaveBeenCalled()
})
it('salta registros sin numCliente o nombre', async () => {
const result = await importarDesdeExcel([
{ numCliente: '', nombre: 'Juan' },
{ numCliente: 'C001', nombre: '' },
])
expect(result.errores).toBe(2)
expect(prismaMock.clienteMaestro.create).not.toHaveBeenCalled()
})
it('maneja errores de DB como errores', async () => {
prismaMock.clienteMaestro.findUnique.mockResolvedValue(null)
prismaMock.clienteMaestro.create.mockRejectedValue(new Error('DB error'))
const result = await importarDesdeExcel([
{ numCliente: 'C001', nombre: 'Juan', apellidoPaterno: 'Perez' },
])
expect(result.errores).toBe(1)
expect(result.insertados).toBe(0)
})
})

View File

@@ -0,0 +1,188 @@
import { prisma } from './prisma.js'
// ── Tipos ───────────────────────────────────────────────────────────
export interface FiltrosClientesMaestro {
busqueda?: string
desarrollo?: string
estatus?: string
pagina?: number
limite?: number
}
export type ClienteMaestroDTO = {
id: string
numCliente: string
nombre: string
apellidoPaterno: string
apellidoMaterno: string | null
sexo: string | null
telefono: string | null
email: string | null
estadoCivil: string | null
rangoEdad: string | null
ocupacion: string | null
rangoIngresos: string | null
credito: string | null
institucion: string | null
medioContacto: string | null
canal: string | null
desarrollo: string | null
manzana: string | null
lote: string | null
interior: string | null
tipoCasa: string | null
etapa: string | null
valorTotal: number | null
estatus: string | null
fechaLiberacion: Date | null
vendedor: string | null
vendedorId: string | null
importadoEn: Date
}
// ── Helpers ─────────────────────────────────────────────────────────
function construirWhere(filtros: FiltrosClientesMaestro) {
const where: Record<string, unknown> = {}
if (filtros.desarrollo) {
where.desarrollo = filtros.desarrollo
}
if (filtros.estatus) {
where.estatus = filtros.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 } },
]
}
return where
}
// ── Service ─────────────────────────────────────────────────────────
/**
* Lista clientes maestro con filtros y paginación.
*/
export async function listar(filtros: FiltrosClientesMaestro): Promise<{
datos: ClienteMaestroDTO[]
total: number
pagina: number
limite: number
}> {
const pagina = filtros.pagina ?? 1
const limite = filtros.limite ?? 50
const skip = (pagina - 1) * limite
const where = construirWhere(filtros)
const [datos, total] = await Promise.all([
prisma.clienteMaestro.findMany({
where,
orderBy: [{ apellidoPaterno: 'asc' }, { nombre: 'asc' }],
skip,
take: limite,
}),
prisma.clienteMaestro.count({ where }),
])
return { datos, total, pagina, limite }
}
/**
* Obtiene un cliente por numCliente.
*/
export async function obtener(numCliente: string): Promise<ClienteMaestroDTO | null> {
return prisma.clienteMaestro.findUnique({ where: { numCliente } })
}
/**
* Cuenta clientes con filtros.
*/
export async function contar(filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'>): Promise<number> {
const where = construirWhere(filtros)
return prisma.clienteMaestro.count({ where })
}
/**
* Devuelve array de objetos para exportar a xlsx.
*/
export async function exportar(
filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'>,
): Promise<ClienteMaestroDTO[]> {
const where = construirWhere(filtros)
return prisma.clienteMaestro.findMany({
where,
orderBy: [{ apellidoPaterno: 'asc' }, { nombre: 'asc' }],
take: 50000, // safety cap
})
}
/**
* Bulk upsert: recibe un array de datos y los inserta/actualiza por numCliente.
*/
export async function importarDesdeExcel(datos: Array<Record<string, unknown>>): Promise<{ insertados: number; actualizados: number; errores: number }> {
let insertados = 0
let actualizados = 0
let errores = 0
for (const fila of datos) {
const numCliente = String(fila.numCliente ?? '').trim()
const nombre = String(fila.nombre ?? '').trim()
if (!numCliente || !nombre) {
errores++
continue
}
try {
const existente = await prisma.clienteMaestro.findUnique({ where: { numCliente } })
const data = {
numCliente,
nombre,
apellidoPaterno: String(fila.apellidoPaterno ?? '').trim(),
apellidoMaterno: fila.apellidoMaterno ? String(fila.apellidoMaterno).trim() : null,
sexo: fila.sexo ? String(fila.sexo).trim() : null,
telefono: fila.telefono ? String(fila.telefono).trim() : null,
email: fila.email ? String(fila.email).trim() : null,
estadoCivil: fila.estadoCivil ? String(fila.estadoCivil).trim() : null,
rangoEdad: fila.rangoEdad ? String(fila.rangoEdad).trim() : null,
ocupacion: fila.ocupacion ? String(fila.ocupacion).trim() : null,
rangoIngresos: fila.rangoIngresos ? String(fila.rangoIngresos).trim() : null,
credito: fila.credito ? String(fila.credito).trim() : null,
institucion: fila.institucion ? String(fila.institucion).trim() : null,
medioContacto: fila.medioContacto ? String(fila.medioContacto).trim() : null,
canal: fila.canal ? String(fila.canal).trim() : null,
desarrollo: fila.desarrollo ? String(fila.desarrollo).trim() : null,
manzana: fila.manzana ? String(fila.manzana).trim() : null,
lote: fila.lote ? String(fila.lote).trim() : null,
interior: fila.interior ? String(fila.interior).trim() : null,
tipoCasa: fila.tipoCasa ? String(fila.tipoCasa).trim() : null,
etapa: fila.etapa ? String(fila.etapa).trim() : null,
valorTotal: typeof fila.valorTotal === 'number' ? fila.valorTotal : null,
estatus: fila.estatus ? String(fila.estatus).trim() : null,
fechaLiberacion: fila.fechaLiberacion ? new Date(String(fila.fechaLiberacion)) : null,
vendedor: fila.vendedor ? String(fila.vendedor).trim() : null,
vendedorId: fila.vendedorId ? String(fila.vendedorId).trim() : null,
}
if (existente) {
await prisma.clienteMaestro.update({ where: { numCliente }, data })
actualizados++
} else {
await prisma.clienteMaestro.create({ data })
insertados++
}
} catch {
errores++
}
}
return { insertados, actualizados, errores }
}