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

@@ -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");

View File

@@ -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])
}

View 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()

View File

@@ -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]

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 }
}