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:
@@ -0,0 +1,38 @@
|
||||
-- Tabla de clientes maestro (importado desde Excel).
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
-- CreateTable ClienteMaestro
|
||||
CREATE TABLE IF NOT EXISTS "ClienteMaestro" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"numCliente" TEXT NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"apellidoPaterno" TEXT NOT NULL,
|
||||
"apellidoMaterno" TEXT,
|
||||
"sexo" TEXT,
|
||||
"telefono" TEXT,
|
||||
"email" TEXT,
|
||||
"estadoCivil" TEXT,
|
||||
"rangoEdad" TEXT,
|
||||
"ocupacion" TEXT,
|
||||
"rangoIngresos" TEXT,
|
||||
"credito" TEXT,
|
||||
"institucion" TEXT,
|
||||
"medioContacto" TEXT,
|
||||
"canal" TEXT,
|
||||
"desarrollo" TEXT,
|
||||
"manzana" TEXT,
|
||||
"lote" TEXT,
|
||||
"interior" TEXT,
|
||||
"tipoCasa" TEXT,
|
||||
"etapa" TEXT,
|
||||
"valorTotal" REAL,
|
||||
"estatus" TEXT,
|
||||
"fechaLiberacion" DATETIME,
|
||||
"vendedor" TEXT,
|
||||
"vendedorId" TEXT,
|
||||
"importadoEn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ClienteMaestro_numCliente_key" ON "ClienteMaestro"("numCliente");
|
||||
CREATE INDEX IF NOT EXISTS "ClienteMaestro_desarrollo_idx" ON "ClienteMaestro"("desarrollo");
|
||||
CREATE INDEX IF NOT EXISTS "ClienteMaestro_estatus_idx" ON "ClienteMaestro"("estatus");
|
||||
CREATE INDEX IF NOT EXISTS "ClienteMaestro_vendedor_idx" ON "ClienteMaestro"("vendedor");
|
||||
@@ -443,3 +443,40 @@ model PushSubscription {
|
||||
@@unique([usuarioId])
|
||||
@@index([usuarioId])
|
||||
}
|
||||
|
||||
// ── Clientes Maestro (importado desde Excel) ──────────────────────────
|
||||
|
||||
model ClienteMaestro {
|
||||
id String @id @default(uuid())
|
||||
numCliente String @unique
|
||||
nombre String
|
||||
apellidoPaterno String
|
||||
apellidoMaterno String?
|
||||
sexo String?
|
||||
telefono String?
|
||||
email String?
|
||||
estadoCivil String?
|
||||
rangoEdad String?
|
||||
ocupacion String?
|
||||
rangoIngresos String?
|
||||
credito String?
|
||||
institucion String?
|
||||
medioContacto String?
|
||||
canal String?
|
||||
desarrollo String?
|
||||
manzana String?
|
||||
lote String?
|
||||
interior String?
|
||||
tipoCasa String?
|
||||
etapa String?
|
||||
valorTotal Float?
|
||||
estatus String?
|
||||
fechaLiberacion DateTime?
|
||||
vendedor String?
|
||||
vendedorId String?
|
||||
importadoEn DateTime @default(now())
|
||||
|
||||
@@index([desarrollo])
|
||||
@@index([estatus])
|
||||
@@index([vendedor])
|
||||
}
|
||||
|
||||
66
api-gateway/scripts/importar-clientes-maestro.js
Executable file
66
api-gateway/scripts/importar-clientes-maestro.js
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import XLSX from 'xlsx'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
const toStr = (v) => v == null ? null : String(v).trim() || null
|
||||
const toNum = (v) => {
|
||||
if (v == null) return null
|
||||
const n = Number(String(v).replace(/[$,\s]/g, ''))
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const wb = XLSX.readFile('/root/Reporte maestro de clientesAGO26.xlsx')
|
||||
const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, defval: null })
|
||||
console.log(`Filas: ${rows.length}`)
|
||||
|
||||
const map = new Map()
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const nc = toStr(rows[i][0])
|
||||
if (nc) map.set(nc, rows[i])
|
||||
}
|
||||
console.log(`Clientes: ${map.size}`)
|
||||
|
||||
let ok = 0, fail = 0
|
||||
let idx = 0
|
||||
for (const [nc, r] of map) {
|
||||
idx++
|
||||
const d = {
|
||||
numCliente: nc,
|
||||
nombre: toStr(r[1]) || 'SIN NOMBRE',
|
||||
apellidoPaterno: toStr(r[2]) || '',
|
||||
apellidoMaterno: toStr(r[3]),
|
||||
sexo: toStr(r[4]),
|
||||
telefono: toStr(r[5]),
|
||||
email: toStr(r[6]),
|
||||
estadoCivil: toStr(r[7]),
|
||||
rangoEdad: toStr(r[8]),
|
||||
ocupacion: toStr(r[16]),
|
||||
rangoIngresos: toStr(r[15]),
|
||||
credito: toStr(r[10]),
|
||||
institucion: toStr(r[11]),
|
||||
medioContacto: toStr(r[13]),
|
||||
canal: toStr(r[14]),
|
||||
desarrollo: toStr(r[19]),
|
||||
manzana: toStr(r[20]),
|
||||
lote: toStr(r[21]),
|
||||
interior: toStr(r[22]),
|
||||
tipoCasa: toStr(r[24]),
|
||||
etapa: toStr(r[27]),
|
||||
valorTotal: toNum(r[26]),
|
||||
estatus: toStr(r[27]),
|
||||
fechaLiberacion: r[28] instanceof Date ? r[28] : null,
|
||||
vendedor: toStr(r[29]),
|
||||
vendedorId: toStr(r[30]),
|
||||
}
|
||||
try {
|
||||
await prisma.clienteMaestro.upsert({ where: { numCliente: nc }, update: d, create: d })
|
||||
ok++
|
||||
} catch (e) {
|
||||
fail++
|
||||
if (fail <= 5) console.log(`ERR ${nc}: ${e.message.slice(0, 120)}`)
|
||||
}
|
||||
if (idx % 500 === 0) console.log(` ${idx}/${map.size} ok=${ok} fail=${fail}`)
|
||||
}
|
||||
console.log(`\nListo: ${ok} ok, ${fail} fail / ${map.size}`)
|
||||
await prisma.$disconnect()
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Importa el archivo maestro de estatus de venta (Reporte_EstatusVenta.xlsx)
|
||||
* Importa el archivo maestro de estatus de venta (/root/Reporte_EstatusVenta.xlsx)
|
||||
* a la tabla CapturaDepartamento.
|
||||
*
|
||||
* Uso:
|
||||
@@ -152,7 +152,7 @@ function detectarDesarrollo(nombreHoja) {
|
||||
// ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const rutaExcel = process.argv[2] || join(__dirname, '..', '..', 'Reporte_EstatusVenta.xlsx')
|
||||
const rutaExcel = process.argv[2] || '/root/Reporte_EstatusVenta.xlsx'
|
||||
const usuarioId = process.argv[3] || 'importacion-maestro'
|
||||
|
||||
console.log(`Leyendo archivo: ${rutaExcel}`)
|
||||
@@ -203,12 +203,19 @@ async function main() {
|
||||
let hojaActualizados = 0
|
||||
|
||||
for (const fila of filasDatos) {
|
||||
// Detectar unidad: primera columna con valor
|
||||
const unidadCodigo = String(fila[0] ?? '').trim()
|
||||
// Detectar unidad: columna 1 (Depto) o combinación UBICACIÓN+MZA+LOTE
|
||||
let unidadCodigo = String(fila[1] ?? '').trim() // columna 1 = Depto
|
||||
if (!unidadCodigo) {
|
||||
// Fallback: combinar UBICACIÓN (col 10) + MZA (col 11) + LOTE (col 12)
|
||||
const ubic = String(fila[10] ?? '').trim()
|
||||
const mza = String(fila[11] ?? '').trim()
|
||||
const lote = String(fila[12] ?? '').trim()
|
||||
if (ubic || mza || lote) unidadCodigo = [ubic, mza, lote].filter(Boolean).join('-')
|
||||
}
|
||||
if (!unidadCodigo) continue
|
||||
|
||||
// Skip rows that are clearly headers/section markers
|
||||
if (normalizeHeader(unidadCodigo).includes('TOTAL')) continue
|
||||
if (normalizeHeader(unidadCodigo).includes('TOTAL') || normalizeHeader(unidadCodigo).includes('DDI')) continue
|
||||
|
||||
for (let col = 0; col < headers.length; col++) {
|
||||
const mapeo = mapaColumnas[col]
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
134
api-gateway/src/controllers/cliente-maestro.controller.test.ts
Normal file
134
api-gateway/src/controllers/cliente-maestro.controller.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
54
api-gateway/src/controllers/cliente-maestro.controller.ts
Normal file
54
api-gateway/src/controllers/cliente-maestro.controller.ts
Normal 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 }
|
||||
@@ -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)
|
||||
|
||||
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