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:
185
api-gateway/src/services/cliente-maestro.service.test.ts
Normal file
185
api-gateway/src/services/cliente-maestro.service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
188
api-gateway/src/services/cliente-maestro.service.ts
Normal file
188
api-gateway/src/services/cliente-maestro.service.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user