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:
2026-09-21 08:34:53 +00:00
parent b5a20aed4e
commit 2fc80e7f9e
66 changed files with 3792 additions and 436 deletions

View File

@@ -52,9 +52,40 @@ const cambiarEtapaSchema = z.object({
fecha: z.string().optional(),
})
/**
* Estatus de inventario que un apartado nunca debe pisar: la unidad ya se
* vendió, se escrituró o no está a la venta (BLOQUEADO).
*/
export const STATUS_NO_BLOQUEABLES = ['VENDIDO', 'ESCRITURADO', 'BLOQUEADO']
/**
* Unidad asignada a la oportunidad, leída de su `datosJson` (la deja el alta de
* clientes / la afluencia) o, si no está ahí, de la cotización más reciente
* ligada a ella (`Cotizacion.mondayItemId`): el vendedor cotiza y después
* aparta, así que la unidad cotizada ES la que se está apartando.
*/
async function unidadDeOportunidad(itemId: string, datosJson: string | null): Promise<string | null> {
if (datosJson) {
try {
const datos = JSON.parse(datosJson) as Record<string, unknown>
if (typeof datos['unidadCodigo'] === 'string' && datos['unidadCodigo'].trim() !== '') {
return datos['unidadCodigo'].trim()
}
} catch { /* datosJson inválido: se ignora */ }
}
const cotizacion = await prisma.cotizacion.findFirst({
where: { mondayItemId: itemId },
orderBy: { createdAt: 'desc' },
select: { unidadCodigo: true },
})
const codigo = cotizacion?.unidadCodigo.trim()
return codigo !== undefined && codigo !== '' ? codigo : null
}
/**
* Cambio de etapa local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar).
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
* dejando la unidad apartada en su `datosJson`.
* Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad).
*/
async function cambiarEtapaLocal(
@@ -64,17 +95,12 @@ async function cambiarEtapaLocal(
usuarioId: string | undefined,
): Promise<string | null> {
if (etapa === 'Aparto') {
await crmLocal.apartar(itemId, usuarioId)
// Leer la oportunidad local para obtener el unidadCodigo y bloquear la unidad
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
let unidadCodigo = ''
if (captura?.datosJson) {
try {
const datos = JSON.parse(captura.datosJson) as Record<string, unknown>
if (typeof datos['unidadCodigo'] === 'string') unidadCodigo = datos['unidadCodigo']
} catch { /* datosJson inválido: se ignora */ }
}
return unidadCodigo.trim() !== '' ? unidadCodigo.trim() : null
const unidadCodigo = await unidadDeOportunidad(itemId, captura?.datosJson ?? null)
await crmLocal.apartar(itemId, usuarioId, {
...(unidadCodigo !== null ? { unidadCodigo } : {}),
})
return unidadCodigo
}
await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', {
fecha,
@@ -89,10 +115,15 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub)
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada.
// Nunca se degrada una unidad ya VENDIDA/ESCRITURADA/BLOQUEADA: el apartado
// solo bloquea inventario vendible.
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } })
await prisma.unidad.updateMany({
where: { codigo: unidadCodigo, status: { notIn: STATUS_NO_BLOQUEABLES } },
data: { status: 'APARTADO' },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)