feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
listar,
|
||||
obtener,
|
||||
contar,
|
||||
catalogos,
|
||||
exportar,
|
||||
importarDesdeExcel,
|
||||
} from './cliente-maestro.service.js'
|
||||
@@ -49,23 +50,31 @@ describe('listar', () => {
|
||||
await listar({ busqueda: 'Juan', pagina: 1, limite: 50 })
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: expect.arrayContaining([
|
||||
{ nombre: { contains: 'Juan' } },
|
||||
]),
|
||||
}),
|
||||
where: {
|
||||
AND: [
|
||||
expect.objectContaining({
|
||||
OR: expect.arrayContaining([{ nombre: { contains: 'Juan' } }]),
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('aplica filtro de desarrollo', async () => {
|
||||
it('aplica filtro de desarrollo (valor exacto o con sufijo " - ...")', 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' }),
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -77,10 +86,48 @@ describe('listar', () => {
|
||||
await listar({ estatus: 'VENDIDO', pagina: 1, limite: 50 })
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ estatus: 'VENDIDO' }),
|
||||
where: { AND: [{ estatus: 'VENDIDO' }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('combina búsqueda, desarrollo y estatus en la misma consulta', async () => {
|
||||
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
|
||||
prismaMock.clienteMaestro.count.mockResolvedValue(0)
|
||||
|
||||
await listar({ busqueda: 'Ana', desarrollo: 'SOLE', estatus: 'ENTREGA', pagina: 1, limite: 50 })
|
||||
const llamada = prismaMock.clienteMaestro.findMany.mock.calls[0]![0] as { where: { AND: unknown[] } }
|
||||
expect(llamada.where.AND).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('sin filtros no arma condiciones', async () => {
|
||||
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
|
||||
prismaMock.clienteMaestro.count.mockResolvedValue(0)
|
||||
|
||||
await listar({ pagina: 1, limite: 50 })
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: {} }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── catalogos ────────────────────────────────────────────────────────
|
||||
|
||||
describe('catalogos', () => {
|
||||
it('devuelve los desarrollos y estatus reales, sin vacíos ni nulos', async () => {
|
||||
prismaMock.clienteMaestro.findMany
|
||||
.mockResolvedValueOnce([{ desarrollo: 'MUTUO - EDIFICACION' }, { desarrollo: 'SOLE' }, { desarrollo: null }, { desarrollo: ' ' }])
|
||||
.mockResolvedValueOnce([{ estatus: 'ENTREGA' }, { estatus: 'VENDIDO' }, { estatus: null }])
|
||||
|
||||
const result = await catalogos()
|
||||
expect(result.desarrollos).toEqual(['MUTUO - EDIFICACION', 'SOLE'])
|
||||
expect(result.estatuses).toEqual(['ENTREGA', 'VENDIDO'])
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith({
|
||||
distinct: ['desarrollo'],
|
||||
select: { desarrollo: true },
|
||||
orderBy: { desarrollo: 'asc' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── obtener ──────────────────────────────────────────────────────────
|
||||
@@ -113,7 +160,11 @@ describe('contar', () => {
|
||||
prismaMock.clienteMaestro.count.mockResolvedValue(5)
|
||||
expect(await contar({ desarrollo: 'SENDA' })).toBe(5)
|
||||
expect(prismaMock.clienteMaestro.count).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { desarrollo: 'SENDA' } }),
|
||||
{
|
||||
where: {
|
||||
AND: [{ OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }] }],
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,26 +44,37 @@ export type ClienteMaestroDTO = {
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function construirWhere(filtros: FiltrosClientesMaestro) {
|
||||
const where: Record<string, unknown> = {}
|
||||
const condiciones: Record<string, unknown>[] = []
|
||||
|
||||
if (filtros.desarrollo) {
|
||||
where.desarrollo = filtros.desarrollo
|
||||
const desarrollo = filtros.desarrollo?.trim()
|
||||
if (desarrollo) {
|
||||
// El mismo desarrollo convive con sufijos en la base ("ATELIER" y
|
||||
// "ATELIER - EDIFICACION"): se acepta el valor exacto o su prefijo, así el
|
||||
// filtro funciona tanto con el valor crudo como con el agrupado.
|
||||
condiciones.push({
|
||||
OR: [{ desarrollo }, { desarrollo: { startsWith: `${desarrollo} -` } }],
|
||||
})
|
||||
}
|
||||
if (filtros.estatus) {
|
||||
where.estatus = filtros.estatus
|
||||
|
||||
const estatus = filtros.estatus?.trim()
|
||||
if (estatus) {
|
||||
condiciones.push({ 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 } },
|
||||
]
|
||||
condiciones.push({
|
||||
OR: [
|
||||
{ nombre: { contains: term } },
|
||||
{ apellidoPaterno: { contains: term } },
|
||||
{ apellidoMaterno: { contains: term } },
|
||||
{ telefono: { contains: term } },
|
||||
{ numCliente: { contains: term } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return where
|
||||
return condiciones.length > 0 ? { AND: condiciones } : {}
|
||||
}
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────────
|
||||
@@ -102,6 +113,33 @@ export async function obtener(numCliente: string): Promise<ClienteMaestroDTO | n
|
||||
return prisma.clienteMaestro.findUnique({ where: { numCliente } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Catálogos reales de los filtros: los desarrollos y estatus que existen hoy en
|
||||
* la base (no una lista fija). El front arma los selectores con esto.
|
||||
*/
|
||||
export async function catalogos(): Promise<{ desarrollos: string[]; estatuses: string[] }> {
|
||||
const [desarrollos, estatuses] = await Promise.all([
|
||||
prisma.clienteMaestro.findMany({
|
||||
distinct: ['desarrollo'],
|
||||
select: { desarrollo: true },
|
||||
orderBy: { desarrollo: 'asc' },
|
||||
}),
|
||||
prisma.clienteMaestro.findMany({
|
||||
distinct: ['estatus'],
|
||||
select: { estatus: true },
|
||||
orderBy: { estatus: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const limpiar = (valores: Array<string | null>): string[] =>
|
||||
valores.filter((v): v is string => v !== null && v.trim() !== '')
|
||||
|
||||
return {
|
||||
desarrollos: limpiar(desarrollos.map((d) => d.desarrollo)),
|
||||
estatuses: limpiar(estatuses.map((e) => e.estatus)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta clientes con filtros.
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
generarCalendario,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
pagoAtrasado,
|
||||
proximoVencimiento,
|
||||
round2,
|
||||
saldoPlan,
|
||||
@@ -203,6 +204,23 @@ describe('saldo, avance y status del plan', () => {
|
||||
expect(statusPlan(plan)).toBe('CANCELADO')
|
||||
})
|
||||
|
||||
it('sin calendario capturado → SIN_PLAN, no LIQUIDADO', () => {
|
||||
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
|
||||
expect(statusPlan(sinPagos)).toBe('SIN_PLAN')
|
||||
// con pagos pero todos en cero (captura incompleta): tampoco es liquidado
|
||||
const enCeros: PlanPago = {
|
||||
...planDePrueba(),
|
||||
engancheTotal: 0,
|
||||
pagos: planDePrueba().pagos.map((p) => ({ ...p, monto: 0 })),
|
||||
}
|
||||
expect(statusPlan(enCeros)).toBe('SIN_PLAN')
|
||||
})
|
||||
|
||||
it('un plan SIN_PLAN no entra en la cobranza del día', () => {
|
||||
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
|
||||
expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('alCorriente es falso cuando hay pagos vencidos', () => {
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false)
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
|
||||
@@ -233,10 +251,13 @@ describe('proximoVencimiento y diasAtraso', () => {
|
||||
expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0)
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido no cuenta como atraso', () => {
|
||||
it('un pago PARCIAL vencido SÍ cuenta como atraso (corrección: antes se ignoraba)', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
|
||||
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
|
||||
// pago 1 vence 10-ene y sigue sin cubrir (abono de 100) → 22 días al 1-feb
|
||||
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(22)
|
||||
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(false)
|
||||
expect(pagoAtrasado(plan.pagos[0]!, new Date('2026-02-01T00:00:00'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto'
|
||||
*/
|
||||
|
||||
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
|
||||
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
|
||||
/** SIN_PLAN = no hay calendario capturado (no debe confundirse con LIQUIDADO) */
|
||||
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' | 'SIN_PLAN'
|
||||
|
||||
export interface AplicacionPago {
|
||||
id: string
|
||||
@@ -137,7 +138,8 @@ export function generarCalendario(
|
||||
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
|
||||
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
|
||||
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
|
||||
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
|
||||
* PARCIAL (tiene abono aplicado); el atraso se señala con `pagoAtrasado`,
|
||||
* `diasAtraso` y `alCorriente`, que sí lo cuentan como vencido.
|
||||
*/
|
||||
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
|
||||
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
|
||||
@@ -184,10 +186,10 @@ export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date(
|
||||
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
|
||||
}
|
||||
|
||||
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
|
||||
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin cubrir) */
|
||||
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const vencidos = plan.pagos
|
||||
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
.filter((p) => pagoAtrasado(p, hoy))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
@@ -197,15 +199,39 @@ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
|
||||
}
|
||||
|
||||
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
|
||||
/**
|
||||
* ¿El pago está atrasado? Un pago sin cubrir cuya fecha de vencimiento ya
|
||||
* pasó: incluye el PARCIAL vencido (tiene abono pero no cubre el monto), que
|
||||
* es un atraso real aunque `statusPago` lo siga etiquetando PARCIAL.
|
||||
*/
|
||||
export function pagoAtrasado(pago: Pago, hoy: Date = new Date()): boolean {
|
||||
if (estaCubierto(pago)) return false
|
||||
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Status derivado del plan:
|
||||
* - CANCELADO: solo manual
|
||||
* - SIN_PLAN: no hay calendario capturado (0 pagos o montos en 0). Distinto de
|
||||
* LIQUIDADO: el adeudo real es desconocido, no saldado; queda fuera de la
|
||||
* cobranza del día hasta que se capture el plan.
|
||||
* - LIQUIDADO: saldo == 0
|
||||
* - ACTIVO: tiene saldo pendiente
|
||||
*/
|
||||
export function statusPlan(plan: PlanPago): StatusPlan {
|
||||
if (plan.status === 'CANCELADO') return 'CANCELADO'
|
||||
if (!tieneCalendario(plan)) return 'SIN_PLAN'
|
||||
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
|
||||
}
|
||||
|
||||
/** Al corriente = sin pagos VENCIDOS */
|
||||
/** ¿El plan tiene calendario capturado? (al menos un pago con monto > 0) */
|
||||
export function tieneCalendario(plan: PlanPago): boolean {
|
||||
return plan.pagos.some((p) => p.monto > EPS)
|
||||
}
|
||||
|
||||
/** Al corriente = sin pagos atrasados (un PARCIAL vencido también es atraso) */
|
||||
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
|
||||
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
return !plan.pagos.some((p) => pagoAtrasado(p, hoy))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,10 +262,11 @@ export interface CobranzaItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
|
||||
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
||||
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
|
||||
* atraso, el vencimiento más próximo primero.
|
||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS
|
||||
* (los SIN_PLAN quedan fuera: sin calendario capturado no hay nada que
|
||||
* cobrar): pagos vencidos (incluye PARCIAL vencido) más los que vencen en los
|
||||
* próximos `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a
|
||||
* igual atraso, el vencimiento más próximo primero.
|
||||
*/
|
||||
export function cobranzaDelDia(
|
||||
planes: PlanPago[],
|
||||
|
||||
@@ -48,6 +48,56 @@ export function claveTelefono(telefono: string): string {
|
||||
return digitos.length >= 10 ? digitos.slice(-10) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Clave de nombre para deduplicar/relacionar contactos que no traen teléfono ni
|
||||
* email (las altas fallidas dejan un registro hueco con el mismo nombre).
|
||||
* Sin acentos, sin mayúsculas y con los espacios colapsados.
|
||||
*/
|
||||
export function claveNombre(nombre: string): string {
|
||||
return nombre
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplana el JSON de perfil a un mapa campo→valor no vacío: sirve tanto para el
|
||||
* `payloadJson` del alta de cliente (anidado por secciones) como para el
|
||||
* `datosJson` del perfilamiento de una captura de afluencia.
|
||||
*
|
||||
* Mismo criterio que el reporte de clientes: gana el primer valor no vacío y se
|
||||
* ignoran las secciones que repiten los mismos campos vacíos (coacreditado) o
|
||||
* que no son de perfil. Así una sección posterior no pisa un dato real.
|
||||
*/
|
||||
export function aplanarPerfil(json: string | null | undefined): Record<string, string> {
|
||||
if (!json) return {}
|
||||
try {
|
||||
const datos = JSON.parse(json) as Record<string, unknown>
|
||||
const plano: Record<string, string> = {}
|
||||
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
|
||||
const recorrer = (obj: Record<string, unknown>, raiz: boolean): void => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v === null || v === undefined) continue
|
||||
if (typeof v === 'object' && !Array.isArray(v)) {
|
||||
if (raiz && SECCIONES_IGNORADAS.has(k)) continue
|
||||
recorrer(v as Record<string, unknown>, false)
|
||||
continue
|
||||
}
|
||||
if (typeof v === 'object') continue
|
||||
const texto = String(v)
|
||||
if (texto.trim() === '') continue
|
||||
if (plano[k] === undefined) plano[k] = texto
|
||||
}
|
||||
}
|
||||
recorrer(datos, true)
|
||||
return plano
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Perfil Completo del Contacto (modo local) ─────────────────────────
|
||||
|
||||
export interface PerfilCompletoContacto {
|
||||
|
||||
@@ -118,6 +118,99 @@ describe('listarContactosLocal', () => {
|
||||
expect(lista[0]?.id).toBe('c1')
|
||||
})
|
||||
|
||||
it('deduplica por nombre a los registros sin teléfono ni email (gana la ficha con más datos)', async () => {
|
||||
// Alta fallida: Cliente hueco (payload vacío, sin teléfono ni email) con el
|
||||
// mismo nombre que su ficha real
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({
|
||||
id: 'hueco',
|
||||
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
|
||||
payloadJson: '{}',
|
||||
createdAt: new Date('2026-09-11T10:00:00'),
|
||||
}),
|
||||
clienteFake({
|
||||
id: 'completo',
|
||||
nombreCompleto: 'Luis Enrique Salgado Zavaleta',
|
||||
celular: '+52 5586880444',
|
||||
email: 'luisenriquesalgadozavaleta@gmail.com',
|
||||
curp: 'SAZL990504HDFLVS06',
|
||||
rfc: 'SAZL990504KY0',
|
||||
proyecto: 'SOLANA',
|
||||
unidadCodigo: '9',
|
||||
payloadJson: '{"datosGenerales":{"estadoCivil":"SOLTERO"}}',
|
||||
}),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
|
||||
expect(lista).toHaveLength(1)
|
||||
expect(lista[0]).toMatchObject({
|
||||
id: 'completo',
|
||||
nombre: 'Luis Enrique Salgado Zavaleta',
|
||||
telefono: '+52 5586880444',
|
||||
email: 'luisenriquesalgadozavaleta@gmail.com',
|
||||
estado: 'Cliente',
|
||||
})
|
||||
})
|
||||
|
||||
it('fusiona el registro hueco aunque llegue después de la ficha completa', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({
|
||||
id: 'completo',
|
||||
nombreCompleto: 'GRACIELA BAUTISTA VELASCO',
|
||||
celular: '+52 6641159092',
|
||||
email: 'gbautista1304@gmail.com',
|
||||
}),
|
||||
clienteFake({ id: 'hueco', nombreCompleto: 'Graciela Bautista Velasco', payloadJson: '{}' }),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
expect(lista).toHaveLength(1)
|
||||
expect(lista[0]?.id).toBe('completo')
|
||||
})
|
||||
|
||||
it('no fusiona a dos personas distintas con el mismo nombre si tienen teléfonos distintos', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567' }),
|
||||
clienteFake({ id: 'c2', nombreCompleto: 'JUAN PÉREZ', celular: '6649998877' }),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
expect(lista.map((c) => c.id).sort()).toEqual(['c1', 'c2'])
|
||||
})
|
||||
|
||||
it('un Cliente hueco se fusiona con la captura del mismo nombre (gana la que trae más datos)', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({
|
||||
id: 'hueco',
|
||||
nombreCompleto: 'Guadalupe',
|
||||
curp: '',
|
||||
rfc: '',
|
||||
payloadJson: '{}',
|
||||
createdAt: new Date('2026-09-15T10:00:00'),
|
||||
}),
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({
|
||||
leadId: 'loc_g',
|
||||
nombreLead: 'Guadalupe',
|
||||
telefono: '+52 664 222 6090',
|
||||
email: 'lupisintj1@hotmail.com',
|
||||
etapa: 'Contactado',
|
||||
}),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
|
||||
expect(lista).toHaveLength(1)
|
||||
expect(lista[0]).toMatchObject({
|
||||
id: 'loc_g',
|
||||
telefono: '+52 664 222 6090',
|
||||
email: 'lupisintj1@hotmail.com',
|
||||
estado: 'Contactado',
|
||||
})
|
||||
})
|
||||
|
||||
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
|
||||
// 101 clientes: la página 1 llena 100 y detecta que hay más
|
||||
prismaMock.cliente.findMany.mockResolvedValue(
|
||||
@@ -233,4 +326,110 @@ describe('detalleContactoLocal', () => {
|
||||
expect(detalle.relaciones.oportunidades).toHaveLength(1)
|
||||
expect(detalle.historial.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('completa el perfil con el payloadJson del alta (estado civil, nacimiento, actividad, dirección)', async () => {
|
||||
prismaMock.cliente.findFirst.mockResolvedValue(
|
||||
clienteFake({
|
||||
id: 'c1',
|
||||
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
|
||||
celular: '5586880444',
|
||||
email: 'luis@correo.mx',
|
||||
curp: '',
|
||||
rfc: '',
|
||||
payloadJson: JSON.stringify({
|
||||
datosGenerales: {
|
||||
estadoCivil: 'SOLTERO',
|
||||
fechaNacimiento: '1999-05-04',
|
||||
actividadOcupacion: 'PILOTO AVIADOR',
|
||||
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
|
||||
},
|
||||
empleo: { compania: 'FRASA', direccion: 'AV COLINA DE SAN PABLO' },
|
||||
// El co-acreditado repite los campos vacíos: no debe pisar los reales
|
||||
coacreditado: { tieneCoacreditado: false, generales: { estadoCivil: '', fechaNacimiento: '', actividadOcupacion: '', direccion: '' } },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const detalle = await detalleContactoLocal('c1')
|
||||
expect(detalle?.contacto).toMatchObject({
|
||||
estadoCivil: 'SOLTERO',
|
||||
fechaNacimiento: '1999-05-04',
|
||||
actividadEconomica: 'PILOTO AVIADOR',
|
||||
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
|
||||
})
|
||||
})
|
||||
|
||||
it('toma el perfilamiento del datosJson de la captura cuando no hay Cliente', async () => {
|
||||
const captura = capturaFake({
|
||||
leadId: 'loc_1',
|
||||
nombreLead: 'RAMON MORALES',
|
||||
telefono: '6643535353',
|
||||
email: 'ramon.morales@gmail.com',
|
||||
datosJson: JSON.stringify({
|
||||
edad: 41,
|
||||
estadoCivil: 'Casado(a)',
|
||||
ocupacion: 'empleado',
|
||||
rangoSalarial: '50000',
|
||||
}),
|
||||
})
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(captura)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([captura])
|
||||
|
||||
const detalle = await detalleContactoLocal('loc_1')
|
||||
expect(detalle?.contacto).toMatchObject({
|
||||
nombre: 'RAMON MORALES',
|
||||
estadoCivil: 'Casado(a)',
|
||||
actividadEconomica: 'empleado',
|
||||
})
|
||||
// No se inventa fecha de nacimiento a partir de la edad
|
||||
expect(detalle?.contacto['fechaNacimiento']).toBe('')
|
||||
expect(detalle?.contacto['direccion']).toBe('')
|
||||
})
|
||||
|
||||
it('incluye los documentos del registro hueco de la misma persona (no se pierden al fusionar)', async () => {
|
||||
prismaMock.cliente.findFirst.mockResolvedValue(
|
||||
clienteFake({
|
||||
id: 'c1',
|
||||
nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA',
|
||||
celular: '6643998451',
|
||||
email: 'daleman@frasa.com.mx',
|
||||
}),
|
||||
)
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'hueco', nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', celular: null, email: null },
|
||||
{ id: 'otro', nombreCompleto: 'OTRA PERSONA', celular: null, email: null },
|
||||
])
|
||||
prismaMock.documento.findMany.mockResolvedValue([
|
||||
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
|
||||
])
|
||||
|
||||
const detalle = await detalleContactoLocal('c1')
|
||||
|
||||
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clienteId: { in: ['c1', 'hueco'] } } }),
|
||||
)
|
||||
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
|
||||
})
|
||||
|
||||
it('si la ficha visible es la captura, también se muestran los documentos de su Cliente hueco', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(
|
||||
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
|
||||
)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'hueco', nombreCompleto: 'Guadalupe', celular: null, email: null },
|
||||
])
|
||||
prismaMock.documento.findMany.mockResolvedValue([
|
||||
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
|
||||
])
|
||||
|
||||
const detalle = await detalleContactoLocal('loc_g')
|
||||
|
||||
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clienteId: { in: ['hueco'] } } }),
|
||||
)
|
||||
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
|
||||
import {
|
||||
aplanarPerfil,
|
||||
claveNombre,
|
||||
claveTelefono,
|
||||
type DetalleContacto,
|
||||
type EventoHistorialContacto,
|
||||
type RelacionCrm,
|
||||
} from './contactos-detalle.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
@@ -20,10 +27,10 @@ export interface ContactoListaLocal {
|
||||
|
||||
/**
|
||||
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
|
||||
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
|
||||
* ficha completa), más reciente primero. Paginada de 100 en 100 (el front
|
||||
* pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual:
|
||||
* clientes capturados por él + leads asignados a él.
|
||||
* OPORTUNIDADES/APARTADO, deduplicados (gana el Cliente: tiene la ficha
|
||||
* completa), más reciente primero. Paginada de 100 en 100 (el front pagina con
|
||||
* `?pagina=N` y muestra el límite). Filtra por usuario actual: clientes
|
||||
* capturados por él + leads asignados a él.
|
||||
*/
|
||||
export const CONTACTOS_POR_PAGINA = 100
|
||||
|
||||
@@ -50,6 +57,37 @@ export async function esContactoDelUsuario(contactoId: string, usuarioId: string
|
||||
return cliente !== null || captura !== null
|
||||
}
|
||||
|
||||
/** Candidato de la lista antes de deduplicar */
|
||||
interface Candidato {
|
||||
contacto: ContactoListaLocal
|
||||
/** La ficha de Cliente (alta completa) gana a la captura al fusionar */
|
||||
esCliente: boolean
|
||||
/** Cuántos datos trae el registro: desempata la fusión de duplicados */
|
||||
peso: number
|
||||
}
|
||||
|
||||
/** Cuenta los valores no vacíos de la ficha (para saber cuál está más completa) */
|
||||
function pesoDeFicha(valores: readonly (string | null | undefined)[], conPerfil: boolean): number {
|
||||
const llenos = valores.filter((v) => v !== null && v !== undefined && v.trim() !== '').length
|
||||
return llenos + (conPerfil ? 1 : 0)
|
||||
}
|
||||
|
||||
/** true si el JSON trae algo más que un objeto/contenedor vacío */
|
||||
function tieneDatos(json: string | null | undefined): boolean {
|
||||
if (!json) return false
|
||||
return Object.keys(aplanarPerfil(json)).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de contactos locales deduplicada en dos pasos:
|
||||
* 1. por teléfono (últimos 10 dígitos), como siempre;
|
||||
* 2. por nombre para los registros SIN teléfono ni email — las altas fallidas
|
||||
* dejan un Cliente hueco con el mismo nombre que su ficha real, y antes se
|
||||
* repetía siempre porque la clave de teléfono era ''.
|
||||
* Al fusionar se conserva el registro con más datos y se completan sus vacíos
|
||||
* con los del otro (por teléfono gana el Cliente: es la ficha del alta), sin
|
||||
* perder documentos ni relaciones: el detalle une también las dos fichas.
|
||||
*/
|
||||
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
|
||||
const porPagina = CONTACTOS_POR_PAGINA
|
||||
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
||||
@@ -71,43 +109,104 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
take: porPagina + 1,
|
||||
})
|
||||
|
||||
const vistos = new Set<string>()
|
||||
const lista: ContactoListaLocal[] = []
|
||||
const agregar = (entrada: ContactoListaLocal): void => {
|
||||
// Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
|
||||
// teléfono se duplicaban siempre)
|
||||
const tel = claveTelefono(entrada.telefono)
|
||||
const clave = tel !== ''
|
||||
? tel
|
||||
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}`
|
||||
if (vistos.has(clave)) return
|
||||
vistos.add(clave)
|
||||
lista.push(entrada)
|
||||
const lista: Candidato[] = []
|
||||
const porTelefono = new Map<string, number>()
|
||||
const porNombre = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Deja en la posición `i` la ficha ganadora, completando sus vacíos con los
|
||||
* del otro registro. Por teléfono gana el Cliente (mismo teléfono = misma
|
||||
* persona, y su alta es la ficha completa); por nombre gana el que tiene más
|
||||
* datos, que es el criterio de la fusión de duplicados huecos.
|
||||
*/
|
||||
const fusionar = (i: number, nueva: Candidato, tel: string, nombre: string, porNombreClave: boolean): void => {
|
||||
const previa = lista[i]!
|
||||
const ganaNueva = porNombreClave
|
||||
? nueva.peso !== previa.peso
|
||||
? nueva.peso > previa.peso
|
||||
: nueva.esCliente
|
||||
: nueva.esCliente !== previa.esCliente
|
||||
? nueva.esCliente
|
||||
: nueva.peso > previa.peso
|
||||
const [ganadora, perdedora] = ganaNueva ? [nueva, previa] : [previa, nueva]
|
||||
lista[i] = {
|
||||
esCliente: ganadora.esCliente,
|
||||
peso: ganadora.peso,
|
||||
contacto: {
|
||||
...ganadora.contacto,
|
||||
nombre: ganadora.contacto.nombre.trim() !== '' ? ganadora.contacto.nombre : perdedora.contacto.nombre,
|
||||
telefono: ganadora.contacto.telefono !== '' ? ganadora.contacto.telefono : perdedora.contacto.telefono,
|
||||
email: ganadora.contacto.email !== '' ? ganadora.contacto.email : perdedora.contacto.email,
|
||||
estado: ganadora.contacto.estado !== '' ? ganadora.contacto.estado : perdedora.contacto.estado,
|
||||
},
|
||||
}
|
||||
if (tel !== '') porTelefono.set(tel, i)
|
||||
porNombre.set(nombre, i)
|
||||
}
|
||||
|
||||
/** ¿El registro ya agregado en esa posición no tiene teléfono ni email? */
|
||||
const esHueco = (i: number): boolean => {
|
||||
const c = lista[i]!.contacto
|
||||
return c.telefono.trim() === '' && c.email.trim() === ''
|
||||
}
|
||||
|
||||
const agregar = (nueva: Candidato): void => {
|
||||
const tel = claveTelefono(nueva.contacto.telefono)
|
||||
const nombre = claveNombre(nueva.contacto.nombre)
|
||||
const email = nueva.contacto.email.trim()
|
||||
const iNombre = porNombre.get(nombre)
|
||||
// Sin teléfono ni email el nombre es lo único que identifica a la persona.
|
||||
// Con teléfono o email sólo se une a un registro hueco del mismo nombre (la
|
||||
// misma persona antes de completar su ficha), nunca a otro teléfono.
|
||||
const iSinContacto =
|
||||
tel === '' && email === ''
|
||||
? iNombre
|
||||
: iNombre !== undefined && esHueco(iNombre)
|
||||
? iNombre
|
||||
: undefined
|
||||
const iTelefono = tel !== '' ? porTelefono.get(tel) : undefined
|
||||
const i = iTelefono ?? iSinContacto
|
||||
if (i !== undefined) {
|
||||
fusionar(i, nueva, tel, nombre, iTelefono === undefined)
|
||||
return
|
||||
}
|
||||
lista.push(nueva)
|
||||
const indice = lista.length - 1
|
||||
if (tel !== '') porTelefono.set(tel, indice)
|
||||
porNombre.set(nombre, indice)
|
||||
}
|
||||
|
||||
for (const c of clientes) {
|
||||
agregar({
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
|
||||
updatedAt: c.createdAt.toISOString(),
|
||||
fuente: 'local',
|
||||
contacto: {
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
|
||||
updatedAt: c.createdAt.toISOString(),
|
||||
fuente: 'local',
|
||||
},
|
||||
esCliente: true,
|
||||
peso: pesoDeFicha([c.celular, c.email, c.curp, c.rfc, c.proyecto, c.unidadCodigo], tieneDatos(c.payloadJson)),
|
||||
})
|
||||
}
|
||||
for (const c of capturas) {
|
||||
agregar({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.etapa,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
fuente: 'local',
|
||||
contacto: {
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
estado: c.etapa,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
fuente: 'local',
|
||||
},
|
||||
esCliente: false,
|
||||
peso: pesoDeFicha([c.telefono, c.email, c.desarrollo, c.modelo], tieneDatos(c.datosJson)),
|
||||
})
|
||||
}
|
||||
const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
const hayMas = ordenada.length > porPagina
|
||||
return {
|
||||
contactos: ordenada.slice(0, porPagina),
|
||||
@@ -117,11 +216,34 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clientes huecos con el mismo nombre que el contacto: un alta fallida deja un
|
||||
* registro sin teléfono ni email (payload vacío) que la lista fusiona con su
|
||||
* ficha real. Sólo se miran los huecos — nombre + un teléfono/email propios no
|
||||
* identifican a la misma persona. `excluirId` omite el propio contacto.
|
||||
*/
|
||||
async function huecosConMismoNombre(nombre: string, excluirId: string | null): Promise<string[]> {
|
||||
const huecos = await prisma.cliente.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
...(excluirId !== null ? [{ id: { not: excluirId } }] : []),
|
||||
{ OR: [{ celular: null }, { celular: '' }] },
|
||||
{ OR: [{ email: null }, { email: '' }] },
|
||||
],
|
||||
},
|
||||
select: { id: true, nombreCompleto: true },
|
||||
})
|
||||
const clave = claveNombre(nombre)
|
||||
return huecos.filter((c) => claveNombre(c.nombreCompleto) === clave).map((c) => c.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
|
||||
* heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se
|
||||
* arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el
|
||||
* historial se deriva de capturas + alta + documentos + cotizaciones locales.
|
||||
* El perfil (estado civil, fecha de nacimiento, actividad económica, dirección)
|
||||
* se completa con el payloadJson del alta y el datosJson de las capturas.
|
||||
* null si no existe en ninguna tabla.
|
||||
*/
|
||||
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
|
||||
@@ -160,8 +282,22 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
const documentos = cliente
|
||||
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
|
||||
// Duplicados huecos de la misma persona (altas fallidas: mismo nombre, sin
|
||||
// teléfono ni email). La lista los fusiona en una sola ficha, así que el
|
||||
// detalle muestra también sus documentos para no perderlos, tanto si el
|
||||
// contacto visible es el Cliente como si es la captura.
|
||||
const huecos =
|
||||
cliente !== null
|
||||
? await huecosConMismoNombre(cliente.nombreCompleto, cliente.id)
|
||||
: capturaBase !== null
|
||||
? await huecosConMismoNombre(capturaBase.nombreLead, null)
|
||||
: []
|
||||
const idsConDocumentos = [...(cliente !== null ? [cliente.id] : []), ...huecos]
|
||||
const documentos = idsConDocumentos.length
|
||||
? await prisma.documento.findMany({
|
||||
where: { clienteId: { in: idsConDocumentos } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
|
||||
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
|
||||
@@ -245,6 +381,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
})),
|
||||
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||||
|
||||
// Perfil del alta (payloadJson del Cliente) y de las capturas de afluencia
|
||||
// (datosJson): de ahí salen estado civil, fecha de nacimiento, actividad
|
||||
// económica y dirección, que no son columnas de la BD. Gana el primer valor
|
||||
// no vacío, así una fuente posterior no pisa un dato real con un vacío.
|
||||
const perfiles = [
|
||||
aplanarPerfil(cliente?.payloadJson),
|
||||
...[capturaBase, ...relacionadas].map((c) => aplanarPerfil(c?.datosJson)),
|
||||
]
|
||||
const dePerfil = (...claves: string[]): string => {
|
||||
for (const clave of claves) {
|
||||
for (const perfil of perfiles) {
|
||||
const valor = (perfil[clave] ?? '').trim()
|
||||
if (valor !== '') return valor
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const contacto: Record<string, string> =
|
||||
cliente !== null
|
||||
? {
|
||||
@@ -252,14 +406,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
telefono: cliente.celular ?? '',
|
||||
email: cliente.email ?? '',
|
||||
estado: 'Cliente',
|
||||
curp: cliente.curp,
|
||||
rfc: cliente.rfc,
|
||||
curp: cliente.curp || dePerfil('curp'),
|
||||
rfc: cliente.rfc || dePerfil('rfc'),
|
||||
estadoCivil: dePerfil('estadoCivil'),
|
||||
fechaNacimiento: dePerfil('fechaNacimiento'),
|
||||
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
|
||||
direccion: dePerfil('direccion'),
|
||||
}
|
||||
: {
|
||||
nombre: capturaBase!.nombreLead,
|
||||
telefono: capturaBase!.telefono ?? '',
|
||||
email: capturaBase!.email ?? '',
|
||||
estado: capturaBase!.etapa,
|
||||
curp: dePerfil('curp'),
|
||||
rfc: dePerfil('rfc'),
|
||||
estadoCivil: dePerfil('estadoCivil'),
|
||||
fechaNacimiento: dePerfil('fechaNacimiento'),
|
||||
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
|
||||
direccion: dePerfil('direccion'),
|
||||
}
|
||||
|
||||
return { contacto, propiedad, relaciones, historial }
|
||||
@@ -297,12 +461,17 @@ export async function exportarCSV(
|
||||
}
|
||||
|
||||
const filas: FilaCSV[] = []
|
||||
// Dedupe en dos pasos (igual que el listado): por teléfono y, cuando no hay
|
||||
// teléfono, por nombre normalizado — antes un contacto sin teléfono salía
|
||||
// duplicado en el CSV.
|
||||
const vistos = new Set<string>()
|
||||
const claveNombre = (nombre: string): string => nombre.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
|
||||
for (const c of clientes) {
|
||||
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
const claveTelefono = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(c.nombreCompleto)}`
|
||||
if (vistos.has(clave)) continue
|
||||
vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: c.nombreCompleto,
|
||||
Teléfono: c.celular ?? '',
|
||||
@@ -314,9 +483,10 @@ export async function exportarCSV(
|
||||
}
|
||||
|
||||
for (const cap of capturas) {
|
||||
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
const claveTelefono = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(cap.nombreLead)}`
|
||||
if (vistos.has(clave)) continue
|
||||
vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: cap.nombreLead,
|
||||
Teléfono: cap.telefono ?? '',
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0, N = 4)', () => {
|
||||
})
|
||||
|
||||
describe('SENDA — unidad sin fecha de entrega', () => {
|
||||
it('N = null, mensualidades "—" y total pagado null', () => {
|
||||
it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
|
||||
const sinFecha = unidad({
|
||||
codigo: 'SENDA-411-25-A101',
|
||||
desarrollo: 'SENDA',
|
||||
@@ -131,10 +131,12 @@ describe('SENDA — unidad sin fecha de entrega', () => {
|
||||
precioConAcabados: 1948000,
|
||||
})
|
||||
const p = calcularPropuesta(sinFecha, 'BANCARIO', false, APARTADO_DEFAULT)
|
||||
expect(p.numMensualidades).toBeNull()
|
||||
expect(p.mensualidad).toBeNull()
|
||||
expect(p.notaMensualidades).toBe('sin fecha de entrega')
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeNull()
|
||||
// Antes salía numMensualidades/mensualidad null y "— (sin fecha de entrega)"
|
||||
expect(p.numMensualidades).toBe(6) // nDefault de SENDA
|
||||
expect(p.notaMensualidades).toBeUndefined()
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(97400, 2)
|
||||
expect(p.mensualidad).toBeCloseTo(97400 / 6, 4)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
|
||||
expect(p.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
|
||||
})
|
||||
@@ -149,12 +151,13 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
|
||||
mesesParaFirma: 1,
|
||||
})
|
||||
|
||||
it('INFONAVIT con enganche 0% (guarda: no resta el apartado)', () => {
|
||||
it('INFONAVIT con enganche 0%: el apartado se descuenta (no se cobra encima del precio)', () => {
|
||||
const p = calcularPropuesta(casa6, 'INFONAVIT', false, APARTADO_DEFAULT)
|
||||
expect(p.enganche).toBe(0)
|
||||
expect(p.numMensualidades).toBe(1)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
|
||||
})
|
||||
|
||||
it('BANCARIO 5% sobre final', () => {
|
||||
@@ -164,3 +167,82 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
|
||||
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cuadre del total: apartado y enganche personalizado', () => {
|
||||
const TIPOS = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO'] as const
|
||||
const CASOS = [
|
||||
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
|
||||
{ desarrollo: 'ATELIER', precio: 3_997_500 },
|
||||
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
|
||||
{ desarrollo: 'SENDA', precio: 2_451_750 },
|
||||
{ desarrollo: 'SOLANA', precio: 2_560_000 },
|
||||
]
|
||||
const unidadDe = (desarrollo: string, precio: number) =>
|
||||
unidad({
|
||||
codigo: `${desarrollo}-1`,
|
||||
desarrollo,
|
||||
precioSinAcabados: precio,
|
||||
precioConAcabados: precio,
|
||||
})
|
||||
|
||||
it('las 4 propuestas de cada desarrollo cuadran con su precio final', () => {
|
||||
for (const caso of CASOS) {
|
||||
for (const tipo of TIPOS) {
|
||||
const p = calcularPropuesta(unidadDe(caso.desarrollo, caso.precio), tipo, false, APARTADO_DEFAULT)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
|
||||
for (const caso of CASOS) {
|
||||
for (const tipo of TIPOS) {
|
||||
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
|
||||
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
|
||||
const p = calcularPropuesta(
|
||||
unidadDe(caso.desarrollo, caso.precio),
|
||||
tipo,
|
||||
false,
|
||||
apartado,
|
||||
undefined,
|
||||
enganchePct,
|
||||
)
|
||||
expect(p.enganche).toBeGreaterThanOrEqual(0)
|
||||
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
|
||||
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
|
||||
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
|
||||
const u101 = unidad({
|
||||
codigo: 'U-101',
|
||||
desarrollo: 'DIAGONAL',
|
||||
precioSinAcabados: 4_415_000,
|
||||
precioConAcabados: 4_579_000,
|
||||
})
|
||||
const p = calcularPropuesta(u101, 'BANCARIO', false, APARTADO_DEFAULT, undefined, 0.2)
|
||||
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
|
||||
expect(p.mensualidadesTotal).toBe(0)
|
||||
expect(p.pagoFinal).toBeCloseTo(4_415_000 - APARTADO_DEFAULT - p.enganche, 2)
|
||||
})
|
||||
|
||||
it('apartado mayor al enganche: el enganche queda en 0 (nunca negativo) y el total cuadra', () => {
|
||||
const u101 = unidad({
|
||||
codigo: 'U-101',
|
||||
desarrollo: 'DIAGONAL',
|
||||
precioSinAcabados: 4_415_000,
|
||||
precioConAcabados: 4_579_000,
|
||||
})
|
||||
// 2.5% de 4,415,000 = 110,375; con apartado 200,000 el neto sería −89,625
|
||||
const p = calcularPropuesta(u101, 'BANCARIO', false, 200_000)
|
||||
expect(p.enganche).toBe(0)
|
||||
// El apartado de más se acredita en las mensualidades (remanente real)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - p.pagoFinal, 2)
|
||||
expect(totalPagado(p, 200_000)).toBeCloseTo(4_415_000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,6 +69,12 @@ interface ParametrosDesarrollo {
|
||||
precioUnico: boolean
|
||||
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
|
||||
nFijo?: number
|
||||
/**
|
||||
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
|
||||
* entrega ni meses para firma. Garantiza que la mensualidad y el total
|
||||
* siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts).
|
||||
*/
|
||||
nDefault?: number
|
||||
propuestas: PropuestaConfig[]
|
||||
}
|
||||
|
||||
@@ -81,6 +87,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
id: 'DIAGONAL',
|
||||
precioUnico: false,
|
||||
fuenteDeN: 'fechaEntrega',
|
||||
// Último recurso si la unidad no trae fecha de entrega (mediana del
|
||||
// inventario disponible 2026-09: 13 meses hasta la terminación de obra)
|
||||
nDefault: 13,
|
||||
propuestas: [
|
||||
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' },
|
||||
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' },
|
||||
@@ -116,6 +125,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
id: 'SENDA',
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fechaEntrega',
|
||||
// Último recurso si la unidad no trae fecha de entrega (mediana del
|
||||
// inventario disponible 2026-09: 6 mensualidades)
|
||||
nDefault: 6,
|
||||
propuestas: [
|
||||
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
|
||||
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
|
||||
@@ -127,6 +139,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
id: 'SOLANA',
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'porUnidad',
|
||||
// Último recurso si la unidad no trae "meses para firma" (las casas
|
||||
// vendibles del inventario Excel están en 1 mes)
|
||||
nDefault: 1,
|
||||
propuestas: [
|
||||
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
|
||||
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true },
|
||||
@@ -174,7 +189,9 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
|
||||
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
|
||||
}
|
||||
|
||||
/** Resuelve N según la fuente del desarrollo (null si no se puede conocer) */
|
||||
/** Resuelve N según la fuente del desarrollo; cae al plazo por defecto del
|
||||
* desarrollo (`nDefault`) cuando el inventario no trae el dato, para que la
|
||||
* mensualidad y el total SIEMPRE se calculen. */
|
||||
export function resolverNumMensualidades(
|
||||
unidad: UnidadCotizable,
|
||||
params: ParametrosDesarrollo,
|
||||
@@ -183,9 +200,11 @@ export function resolverNumMensualidades(
|
||||
case 'fijo':
|
||||
return params.nFijo ?? 1
|
||||
case 'porUnidad':
|
||||
return unidad.mesesParaFirma ?? null
|
||||
return unidad.mesesParaFirma ?? params.nDefault ?? null
|
||||
case 'fechaEntrega':
|
||||
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null
|
||||
return unidad.fechaEntrega
|
||||
? calcularNumMensualidades(unidad.fechaEntrega)
|
||||
: (params.nDefault ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +215,42 @@ function baseComun(precioLista: number, descuento: number) {
|
||||
return { montoDescuento, precioFinal }
|
||||
}
|
||||
|
||||
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
|
||||
interface BloquesPago {
|
||||
enganche: number
|
||||
mensualidades: number | null
|
||||
pagoFinal: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
|
||||
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
|
||||
* propuesta), aunque el apartado o un enganche personalizado descuadren las
|
||||
* fórmulas:
|
||||
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
|
||||
* el apartado supera el enganche, ni mensualidades negativas con enganche
|
||||
* alto);
|
||||
* - el excedente se recorta de las mensualidades (bloque flexible del
|
||||
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
|
||||
* precio.
|
||||
*/
|
||||
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
|
||||
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
|
||||
const enganche = Math.max(0, bloques.enganche)
|
||||
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
|
||||
let pagoFinal = Math.max(0, bloques.pagoFinal)
|
||||
|
||||
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
|
||||
if (excedente > 0 && mensualidades !== null) {
|
||||
const recorte = Math.min(mensualidades, excedente)
|
||||
mensualidades -= recorte
|
||||
excedente -= recorte
|
||||
}
|
||||
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
|
||||
|
||||
return { enganche, mensualidades, pagoFinal }
|
||||
}
|
||||
|
||||
/** Calcula una propuesta comercial con los parámetros dados */
|
||||
function calcularPropuestaConfig(
|
||||
cfg: PropuestaConfig,
|
||||
@@ -217,17 +272,21 @@ function calcularPropuestaConfig(
|
||||
cfg.pctEnganche <= 0.05
|
||||
const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche
|
||||
|
||||
// Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
|
||||
// SOLANA lo deja en 0 cuando el % es 0
|
||||
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
||||
const enganche =
|
||||
const engancheBruto =
|
||||
cfg.engancheCeroSinApartado && pctEnganche === 0
|
||||
? 0
|
||||
: pctEnganche * baseEnganche - apartado
|
||||
: pctEnganche * baseEnganche
|
||||
// Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
|
||||
// apartado supera el enganche; cuadrarBloques lo acota a 0)
|
||||
const engancheExcel = engancheBruto - apartado
|
||||
|
||||
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
||||
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
||||
const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
|
||||
|
||||
let mensualidadesTotal: number | null = null
|
||||
let mensualidad: number | null = null
|
||||
let mensualidadesExcel: number | null = null
|
||||
let notaMensualidades: string | undefined
|
||||
if (cfg.modoMensualidades !== 'ninguna') {
|
||||
if (n === null) {
|
||||
@@ -235,22 +294,26 @@ function calcularPropuestaConfig(
|
||||
} else {
|
||||
switch (cfg.modoMensualidades) {
|
||||
case 'remanente':
|
||||
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
|
||||
mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
|
||||
break
|
||||
case 'remanenteLista':
|
||||
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
|
||||
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||
mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
|
||||
break
|
||||
case 'porcentaje':
|
||||
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||
mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||
break
|
||||
}
|
||||
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
|
||||
mensualidadesTotal = Math.max(0, mensualidadesTotal)
|
||||
mensualidad = mensualidadesTotal / n
|
||||
}
|
||||
}
|
||||
|
||||
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
|
||||
enganche: engancheExcel,
|
||||
mensualidades: mensualidadesExcel,
|
||||
pagoFinal: pagoFinalExcel,
|
||||
})
|
||||
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
|
||||
|
||||
return {
|
||||
tipo: cfg.tipo,
|
||||
titulo: cfg.titulo,
|
||||
@@ -260,7 +323,7 @@ function calcularPropuestaConfig(
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad,
|
||||
mensualidadesTotal,
|
||||
mensualidadesTotal: mensualidades,
|
||||
pagoFinal,
|
||||
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
|
||||
}
|
||||
@@ -321,6 +384,8 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
|
||||
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
|
||||
* `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con
|
||||
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
|
||||
* `numMeses` opcional: número de mensualidades elegido por el vendedor en el
|
||||
* cotizador; si no viene se resuelve con la fuente del desarrollo.
|
||||
*/
|
||||
export function calcularPropuesta(
|
||||
unidad: UnidadCotizable,
|
||||
@@ -329,6 +394,7 @@ export function calcularPropuesta(
|
||||
apartado: number = APARTADO_DEFAULT,
|
||||
descuento?: number,
|
||||
enganchePct?: number,
|
||||
numMeses?: number,
|
||||
): PropuestaResultado {
|
||||
const params = obtenerDesarrollo(unidad.desarrollo)
|
||||
const tipoInterno = TIPO_PROPUESTA_API[tipo]
|
||||
@@ -337,7 +403,7 @@ export function calcularPropuesta(
|
||||
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`)
|
||||
}
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = resolverNumMensualidades(unidad, params)
|
||||
const n = numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
const d = descuento ?? cfg.descuentoDefault
|
||||
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null)
|
||||
}
|
||||
|
||||
@@ -344,8 +344,16 @@ export class CrmLocalService {
|
||||
* oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
|
||||
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
|
||||
* automatizaciones.
|
||||
*
|
||||
* `opciones.unidadCodigo` deja la unidad apartada en el `datosJson` del
|
||||
* apartado (la cotización del vendedor o la unidad de interés): es lo que
|
||||
* después leen el bloqueo de inventario y Mi día.
|
||||
*/
|
||||
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
|
||||
async apartar(
|
||||
oportunidadIdLocal: string,
|
||||
usuarioId?: string,
|
||||
opciones: { unidadCodigo?: string | undefined } = {},
|
||||
) {
|
||||
// Sombra para ids heredados sin fila local
|
||||
const oportunidad = await this.filaLocal(
|
||||
oportunidadIdLocal,
|
||||
@@ -362,6 +370,7 @@ export class CrmLocalService {
|
||||
}
|
||||
|
||||
const apartadoId = idLocal()
|
||||
const datosJson = conUnidadCodigo(oportunidad.datosJson, opciones.unidadCodigo)
|
||||
const resultados = await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: oportunidadIdLocal },
|
||||
@@ -379,11 +388,11 @@ export class CrmLocalService {
|
||||
modelo: oportunidad.modelo,
|
||||
board: 'APARTADO',
|
||||
etapa: 'Pago Apartado',
|
||||
datosJson: oportunidad.datosJson,
|
||||
datosJson,
|
||||
// Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados
|
||||
// por vencer", el cron de avisos y la lista de Mi día (antes sólo se
|
||||
// calculaba en el JSON del alta y la columna quedaba vacía).
|
||||
fechaLimite: fechaLimiteApartado(oportunidad.datosJson),
|
||||
fechaLimite: fechaLimiteApartado(datosJson),
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
@@ -472,6 +481,25 @@ function soloDigitos(telefono: string): string {
|
||||
return telefono.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva
|
||||
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
|
||||
* devuelve el JSON original tal cual.
|
||||
*/
|
||||
function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): string | null {
|
||||
const unidad = unidadCodigo?.trim()
|
||||
if (!unidad) return datosJson
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (datosJson) {
|
||||
try {
|
||||
datos = JSON.parse(datosJson) as Record<string, unknown>
|
||||
} catch {
|
||||
datos = {}
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ ...datos, unidadCodigo: unidad })
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero.
|
||||
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados.
|
||||
|
||||
@@ -11,6 +11,8 @@ function captura(
|
||||
origen: string | null
|
||||
desarrollo: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
datosJson: string | null
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
@@ -20,6 +22,8 @@ function captura(
|
||||
origen: overrides.origen ?? 'Facebook',
|
||||
desarrollo: overrides.desarrollo ?? 'DIAGONAL',
|
||||
createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
|
||||
updatedAt: overrides.updatedAt ?? overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
|
||||
datosJson: overrides.datosJson ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,15 +89,41 @@ describe('construirMiTablero', () => {
|
||||
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
||||
})
|
||||
|
||||
it('calcula ventas del mes correctamente', () => {
|
||||
it('calcula ventas del mes con ventas CERRADAS (no cotizaciones)', () => {
|
||||
// Las cotizaciones ya no cuentan como ventas: se cuentan los apartados con
|
||||
// firma de cliente o posteriores, sin repetir la misma unidad.
|
||||
const cots = [
|
||||
cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }),
|
||||
cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }),
|
||||
cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior
|
||||
]
|
||||
const tablero = construirMiTablero([], cots, [], AHORA)
|
||||
const capturas = [
|
||||
captura({
|
||||
leadId: 'loc_apt_1',
|
||||
board: 'APARTADO',
|
||||
etapa: 'Firma Cliente',
|
||||
updatedAt: new Date('2026-09-10T12:00:00'),
|
||||
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
|
||||
}),
|
||||
captura({
|
||||
leadId: 'loc_apt_2',
|
||||
board: 'APARTADO',
|
||||
etapa: 'Venta',
|
||||
updatedAt: new Date('2026-09-12T12:00:00'),
|
||||
datosJson: JSON.stringify({ precioFinal: 2_000_000 }),
|
||||
}),
|
||||
// Mismo apartado recotizado: no debe duplicarse (mismo leadId)
|
||||
captura({
|
||||
leadId: 'loc_apt_1',
|
||||
board: 'APARTADO',
|
||||
etapa: 'Firma Cliente',
|
||||
updatedAt: new Date('2026-09-15T12:00:00'),
|
||||
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
|
||||
}),
|
||||
captura({ board: 'APARTADO', etapa: 'Pago Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
|
||||
captura({ board: 'APARTADO', etapa: 'Cancelacion Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, cots, [], AHORA)
|
||||
expect(tablero.ventasMes.unidades).toBe(2)
|
||||
expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000
|
||||
expect(tablero.ventasMes.monto).toBe(3_500_000)
|
||||
})
|
||||
|
||||
it('agrupa por origen con conteo de convertidos', () => {
|
||||
|
||||
@@ -80,6 +80,9 @@ export function construirMiTablero(
|
||||
origen: string | null
|
||||
desarrollo: string | null
|
||||
createdAt: Date
|
||||
/** Necesarios para las ventas cerradas del mes (fecha del cambio y precio) */
|
||||
updatedAt?: Date
|
||||
datosJson?: string | null
|
||||
}[],
|
||||
cotizaciones: {
|
||||
precioFinal: number
|
||||
@@ -130,13 +133,35 @@ export function construirMiTablero(
|
||||
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
||||
|
||||
// -- Ventas del mes --
|
||||
// Antes sumaba COTIZACIONES (un inmueble recotizado contaba varias veces y una
|
||||
// cotización no es una venta). Ahora cuenta ventas cerradas del mes: apartados
|
||||
// con firma de cliente o posteriores (Venta, Escriturado, Titulado), sin
|
||||
// repetir la misma unidad.
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
const cotizacionesMes = cotizaciones.filter(
|
||||
(ct) => ct.createdAt >= inicioMes,
|
||||
const esVentaCerrada = (etapa: string): boolean => {
|
||||
const e = etapa.toUpperCase()
|
||||
return (
|
||||
e.includes('FIRMA') || e.includes('VENTA') || e.includes('ESCRITURADO') || e.includes('TITULADO')
|
||||
) && !e.includes('CANCEL')
|
||||
}
|
||||
const ventasDelMes = capturas.filter(
|
||||
(c) => c.board === 'APARTADO' && esVentaCerrada(c.etapa) && (c.updatedAt ?? c.createdAt) >= inicioMes,
|
||||
)
|
||||
const unidadesVendidas = new Map<string, number>()
|
||||
for (const v of ventasDelMes) {
|
||||
let monto = 0
|
||||
try {
|
||||
const datos = JSON.parse(v.datosJson ?? '{}') as Record<string, unknown>
|
||||
const precio = Number(datos['precioFinal'] ?? datos['precioVenta'] ?? 0)
|
||||
monto = Number.isFinite(precio) ? precio : 0
|
||||
} catch {
|
||||
monto = 0
|
||||
}
|
||||
unidadesVendidas.set(v.leadId, monto)
|
||||
}
|
||||
const ventasMes = {
|
||||
monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100,
|
||||
unidades: cotizacionesMes.length,
|
||||
monto: Math.round([...unidadesVendidas.values()].reduce((a, b) => a + b, 0) * 100) / 100,
|
||||
unidades: unidadesVendidas.size,
|
||||
}
|
||||
|
||||
// -- Por origen --
|
||||
@@ -212,6 +237,8 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
|
||||
origen: true,
|
||||
desarrollo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
datosJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('construirResumen', () => {
|
||||
plan('a', {
|
||||
pagos: [
|
||||
pago('p1', '2026-08-01', 50000), // vencido sin abono
|
||||
pago('p2', '2026-08-10', 50000, 20000), // parcial (no cuenta como VENCIDO)
|
||||
pago('p2', '2026-08-10', 50000, 20000), // parcial VENCIDO: sí cuenta como atraso
|
||||
pago('p3', '2026-09-01', 50000), // pendiente futuro
|
||||
],
|
||||
}),
|
||||
@@ -228,9 +228,10 @@ describe('construirResumen', () => {
|
||||
},
|
||||
AHORA,
|
||||
)
|
||||
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1 })
|
||||
expect(r.cobranza.pagosVencidos).toBe(2)
|
||||
expect(r.cobranza.montoVencido).toBe(80000)
|
||||
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1, sinPlan: 0 })
|
||||
// p1 (vencido sin abono) + p2 (PARCIAL vencido, antes se ignoraba) + p4
|
||||
expect(r.cobranza.pagosVencidos).toBe(3)
|
||||
expect(r.cobranza.montoVencido).toBe(110000) // 50,000 + 30,000 restantes + 30,000
|
||||
expect(r.cobranza.planesAlCorriente).toBe(0)
|
||||
expect(r.cobranza.planesConVencidos).toBe(2)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
|
||||
import { pagoAtrasado, statusPlan, type PlanPago } from './cobranza.service.js'
|
||||
import type { UnidadCotizable } from './cotizacion.service.js'
|
||||
import { ETAPAS_PIPELINE } from './etapas.js'
|
||||
import { prisma } from './prisma.js'
|
||||
@@ -266,7 +266,9 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
|
||||
.map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })),
|
||||
]
|
||||
|
||||
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 }
|
||||
// `sinPlan` = plan sin calendario capturado (no es lo mismo que cancelado:
|
||||
// 3 planes del maestro llegaron sin pagos y antes se contaban como cancelados)
|
||||
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0, sinPlan: 0 }
|
||||
let planesAlCorriente = 0
|
||||
let planesConVencidos = 0
|
||||
let pagosVencidos = 0
|
||||
@@ -275,9 +277,12 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
|
||||
const status = statusPlan(plan)
|
||||
if (status === 'ACTIVO') planesPorStatus.activos++
|
||||
else if (status === 'LIQUIDADO') planesPorStatus.liquidados++
|
||||
else if (status === 'SIN_PLAN') planesPorStatus.sinPlan++
|
||||
else planesPorStatus.cancelados++
|
||||
if (status !== 'ACTIVO') continue
|
||||
const vencidos = plan.pagos.filter((p) => statusPago(p, ahora) === 'VENCIDO')
|
||||
// Un pago PARCIAL vencido también es atraso (antes no se contaba y había
|
||||
// planes "al corriente" con meses de retraso)
|
||||
const vencidos = plan.pagos.filter((p) => pagoAtrasado(p, ahora))
|
||||
if (vencidos.length === 0) planesAlCorriente++
|
||||
else planesConVencidos++
|
||||
pagosVencidos += vencidos.length
|
||||
@@ -435,7 +440,7 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
|
||||
// Contar actividades abiertas
|
||||
const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } })
|
||||
|
||||
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto), actividadesAbiertas }
|
||||
return { leads, oportunidades, unidades: unidadesRows.map((u) => unidadLocalADto(u)), actividadesAbiertas }
|
||||
}
|
||||
|
||||
export class ReportesService {
|
||||
|
||||
@@ -7,11 +7,15 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
capturaDepartamento: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import {
|
||||
CAMPOS_FECHA_ENTREGA,
|
||||
getUnidadesLocal,
|
||||
getUnidadLocal,
|
||||
unidadLocalADto,
|
||||
@@ -37,10 +41,11 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(null)
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('unidadLocalADto', () => {
|
||||
it('mapea la fila local al DTO Unidad', () => {
|
||||
it('mapea la fila local al DTO Unidad (sin fecha de entrega por defecto)', () => {
|
||||
const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' }))
|
||||
expect(dto).toEqual({
|
||||
codigo: 'SENDA-101',
|
||||
@@ -58,6 +63,10 @@ describe('unidadLocalADto', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('con fecha de entrega (captura de departamentos) la deja en el DTO', () => {
|
||||
expect(unidadLocalADto(fila({ codigo: 'U-101' }), '2027-10-08').fechaEntrega).toBe('2027-10-08')
|
||||
})
|
||||
|
||||
it('etapa vacía → null', () => {
|
||||
expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull()
|
||||
})
|
||||
@@ -80,11 +89,41 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
|
||||
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
|
||||
})
|
||||
|
||||
it('getUnidadLocal: null si no existe', async () => {
|
||||
it('completa fechaEntrega desde la captura: entrega programada > habitabilidad > inicio de obra', async () => {
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
|
||||
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
|
||||
fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
|
||||
fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
|
||||
])
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
|
||||
{ unidadCodigo: 'U-1', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||
{ unidadCodigo: 'U-1', campo: 'entrega_programada_fecha', valor: '2027-11-20' },
|
||||
{ unidadCodigo: 'S-1', campo: 'habitabilidad', valor: '2027-05-05' },
|
||||
// Valor vacío / no-fecha: no cuenta como dato
|
||||
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
|
||||
{ unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' },
|
||||
])
|
||||
|
||||
const todas = await getUnidadesLocal()
|
||||
|
||||
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2027-11-20', null, '2027-05-05', null])
|
||||
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('getUnidadLocal: null si no existe; trae la fecha de su captura', async () => {
|
||||
prismaMock.unidad.findUnique.mockImplementation(async ({ where }) =>
|
||||
where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null,
|
||||
)
|
||||
expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9')
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-9', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||
])
|
||||
const dto = await getUnidadLocal('U-9')
|
||||
expect(dto?.codigo).toBe('U-9')
|
||||
expect(dto?.fechaEntrega).toBe('2027-10-08')
|
||||
expect(await getUnidadLocal('U-X')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,52 @@ import type { UnidadCotizable } from './cotizacion.service.js'
|
||||
* scripts/seed-unidades-locales.js). Es la única fuente de lectura del
|
||||
* inventario: misma forma de DTO que consumen controllers y fronts.
|
||||
*
|
||||
* La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el
|
||||
* seed curado): salen en null, que el cotizador ya tolera (plazo desconocido).
|
||||
* La tabla `Unidad` no guarda la fecha de entrega: se completa desde la captura
|
||||
* de obra/escrituración (`CapturaDepartamento`), que es la que llenan los
|
||||
* departamentos. Sin ese dato el cotizador asume el plazo por defecto del
|
||||
* desarrollo (ver DESARROLLOS en cotizacion.service.ts).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
|
||||
* en orden de prioridad:
|
||||
* 1. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda (ESCRITURACION)
|
||||
* 2. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL
|
||||
* 3. `inicio_obra_fecha` — último recurso (ESCRITURACION)
|
||||
*/
|
||||
export const CAMPOS_FECHA_ENTREGA = [
|
||||
'entrega_programada_fecha',
|
||||
'habitabilidad',
|
||||
'inicio_obra_fecha',
|
||||
] as const
|
||||
|
||||
/** Normaliza 'YYYY-MM-DD' (o ISO completo); null si el valor no es una fecha usable */
|
||||
function fechaIso(valor: string | null | undefined): string | null {
|
||||
if (typeof valor !== 'string') return null
|
||||
const fecha = valor.trim().slice(0, 10)
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(fecha) ? fecha : null
|
||||
}
|
||||
|
||||
/** Fecha de entrega (ISO) por código de unidad, según la captura de los departamentos */
|
||||
async function fechasEntregaPorUnidad(codigos?: string[]): Promise<Map<string, string>> {
|
||||
const filas = await prisma.capturaDepartamento.findMany({
|
||||
where: {
|
||||
campo: { in: [...CAMPOS_FECHA_ENTREGA] },
|
||||
...(codigos ? { unidadCodigo: { in: codigos } } : {}),
|
||||
},
|
||||
select: { unidadCodigo: true, campo: true, valor: true },
|
||||
})
|
||||
const porUnidad = new Map<string, string>()
|
||||
for (const campo of CAMPOS_FECHA_ENTREGA) {
|
||||
for (const fila of filas) {
|
||||
if (fila.campo !== campo || porUnidad.has(fila.unidadCodigo)) continue
|
||||
const fecha = fechaIso(fila.valor)
|
||||
if (fecha !== null) porUnidad.set(fila.unidadCodigo, fecha)
|
||||
}
|
||||
}
|
||||
return porUnidad
|
||||
}
|
||||
|
||||
/** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */
|
||||
export interface FilaUnidad {
|
||||
codigo: string
|
||||
@@ -25,11 +67,11 @@ export interface FilaUnidad {
|
||||
}
|
||||
|
||||
/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */
|
||||
export function unidadLocalADto(u: FilaUnidad): UnidadCotizable {
|
||||
export function unidadLocalADto(u: FilaUnidad, fechaEntrega: string | null = null): UnidadCotizable {
|
||||
return {
|
||||
codigo: u.codigo,
|
||||
modelo: u.modelo,
|
||||
fechaEntrega: null, // no está en el espejo local: plazo desconocido
|
||||
fechaEntrega,
|
||||
supEstacionamiento: u.supEstacionamiento,
|
||||
supConstruccion: u.supConstruccion,
|
||||
supExcedente: u.supExcedente,
|
||||
@@ -47,7 +89,8 @@ export async function getUnidadesLocal(
|
||||
filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {},
|
||||
): Promise<UnidadCotizable[]> {
|
||||
const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } })
|
||||
let lista = rows.map(unidadLocalADto)
|
||||
const fechas = await fechasEntregaPorUnidad()
|
||||
let lista = rows.map((u) => unidadLocalADto(u, fechas.get(u.codigo) ?? null))
|
||||
if (filtro.soloDisponibles === true) {
|
||||
lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE')
|
||||
}
|
||||
@@ -61,5 +104,7 @@ export async function getUnidadesLocal(
|
||||
/** Una unidad local por código; null si no existe en el espejo */
|
||||
export async function getUnidadLocal(codigo: string): Promise<UnidadCotizable | null> {
|
||||
const row = await prisma.unidad.findUnique({ where: { codigo } })
|
||||
return row === null ? null : unidadLocalADto(row)
|
||||
if (row === null) return null
|
||||
const fechas = await fechasEntregaPorUnidad([codigo])
|
||||
return unidadLocalADto(row, fechas.get(codigo) ?? null)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user