import type { Request, Response } from 'express' import { z } from 'zod' import { VOCABULARIO, type MotivoNoCompra } from '../config/vocabulario.js' import { prisma } from '../services/prisma.js' import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import * as auditoria from '../services/auditoria.service.js' /** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */ export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA /** * Subcategorías por categoría, tomadas del catálogo del tablero de referencia * "Motivos No Compra" del workspace CRM FRASA. NOTA: 'Mesualidad' y 'Cambo de * Planes' llevan las faltas de ortografía originales del catálogo del cliente. */ export const SUBCATEGORIAS_NO_COMPRA: Record = VOCABULARIO.SUBCATEGORIAS const perderSchema = z.object({ motivo: z.enum(MOTIVOS_NO_COMPRA), comentario: z.string().optional(), }) /** * Marca una oportunidad como perdida (etapa 'Perdido' + motivo/submotivo en la * fila local) y encola el DESCARTE. SIEMPRE local. */ export async function perderOportunidadEndpoint(req: Request, res: Response): Promise { const body = perderSchema.parse(req.body) const mondayItemId = String(req.params['mondayItemId']) await crmLocal.descartar(mondayItemId, body.motivo, { fuente: 'oportunidad', comentario: body.comentario, usuarioId: req.usuarioToken?.sub, sombra: !esIdLocal(mondayItemId), }) res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId: null }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo) } // ---------- Cambio de etapa desde Mi día ---------- /** * Etapas del funnel que el asesor puede mover desde los botones de * "Mis oportunidades" (vocabulario canónico del CRM — el frontend las manda * tal cual). */ export const ETAPAS_OPORTUNIDAD_PERMITIDAS = VOCABULARIO.FASES_OPORTUNIDAD const cambiarEtapaSchema = z.object({ etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS), // En 'Agendo Cita' puede setear la fecha de la cita 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 { if (datosJson) { try { const datos = JSON.parse(datosJson) as Record 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), * dejando la unidad apartada en su `datosJson`. * Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad). */ async function cambiarEtapaLocal( itemId: string, etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number], fecha: string | undefined, usuarioId: string | undefined, ): Promise { if (etapa === 'Aparto') { const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) 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, usuarioId, sombra: !esIdLocal(itemId), }) return null } /** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise { 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. // 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, 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) } } res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa) }