feat: automatizaciones ronda 2 + app de dashboards /dash
- Escalado: lead asignado sin contactar >48h genera actividad URGENTE al coordinador (LEAD_ESCALADO_HORAS, MONDAY_COORDINADOR_USER_ID) - Oportunidad en Aparto crea item en Apartado (fecha límite +7d); en Ganado crea la Venta; ambos con dedup - GET /api/reportes/resumen: leads, funnel, unidades por desarrollo, cobranza y actividades en una respuesta (cache 60s) - App dashboards (/dash): 8 KPIs + 3 gráficas Recharts, con auth - Landing: tarjeta Tableros; docs/AUTOMATIZACIONES-MONDAY.md con las recetas nativas para activar en la UI - Tests: api 158, dashboards 7, landing 5 — todo verde
This commit is contained in:
@@ -32,11 +32,23 @@ interface ItemConFechas extends ItemMonday {
|
||||
export interface LeadMonday {
|
||||
id: string
|
||||
nombre: string
|
||||
createdAt: string // ISO (created_at del item en Monday)
|
||||
updatedAt: string // ISO (updated_at del item en Monday)
|
||||
estado: string // label de la columna status 'Estado' ('' si no tiene)
|
||||
origen: string // label de la columna status 'Origen' ('' si no tiene)
|
||||
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
||||
}
|
||||
|
||||
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
|
||||
export interface OportunidadMonday {
|
||||
id: string
|
||||
nombre: string // nombre del cliente
|
||||
createdAt: string // ISO
|
||||
etapa: string // label de la columna status 'Estado'
|
||||
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
||||
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
|
||||
}
|
||||
|
||||
/** Usuario del workspace (para la lista rotativa de vendedores) */
|
||||
export interface UsuarioMonday {
|
||||
id: number
|
||||
@@ -521,16 +533,131 @@ export class MondayService {
|
||||
vendedorIds = []
|
||||
}
|
||||
}
|
||||
const colOrigen = cols.get('Origen')
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
||||
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
|
||||
vendedorIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Items del tablero Oportunidades con etapa, unidad y precio final
|
||||
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
|
||||
*/
|
||||
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const colEstado = cols.get('Estado')
|
||||
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
|
||||
const colUnidad = cols.get('Unidad')
|
||||
const colPrecio = cols.get('Precio final')
|
||||
|
||||
const items: ItemConFechas[] = []
|
||||
let cursor: string | null = null
|
||||
do {
|
||||
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
|
||||
cursor === null
|
||||
? await this.graphql(
|
||||
`query ($ids: [ID!]) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 200) {
|
||||
cursor
|
||||
items { id name created_at updated_at column_values { id text value } }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId] },
|
||||
)
|
||||
: await this.graphql(
|
||||
`query ($ids: [ID!], $cursor: String!) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 200, cursor: $cursor) {
|
||||
cursor
|
||||
items { id name created_at updated_at column_values { id text value } }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId], cursor },
|
||||
)
|
||||
const page = data.boards[0]?.items_page
|
||||
if (!page) break
|
||||
items.push(...page.items)
|
||||
cursor = page.cursor
|
||||
} while (cursor !== null)
|
||||
|
||||
return items.map((item) => {
|
||||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
createdAt: item.created_at,
|
||||
etapa: porId.get(colEstado.id)?.text ?? '',
|
||||
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
||||
precioFinal: Number.isFinite(precio) ? precio : 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Item en el tablero Ventas (se crea cuando una oportunidad pasa a 'Ganado') */
|
||||
async crearVenta(datos: DatosVenta): Promise<string> {
|
||||
const boardId = config.monday.boards.ventas
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Unidad: datos.unidadCodigo,
|
||||
'Valor venta': datos.valorVenta,
|
||||
})
|
||||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||||
}
|
||||
|
||||
/**
|
||||
* Número de actividades con Estado 'Abierto' en el tablero Actividades.
|
||||
* Si el tablero no está configurado devuelve 0 (los reportes no deben caer por eso).
|
||||
*/
|
||||
async getActividadesAbiertas(): Promise<number> {
|
||||
const boardId = config.monday.boards.actividades
|
||||
if (boardId === '') return 0
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const colEstado = cols.get('Estado')
|
||||
if (!colEstado) return 0
|
||||
|
||||
let abiertas = 0
|
||||
let cursor: string | null = null
|
||||
do {
|
||||
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
|
||||
cursor === null
|
||||
? await this.graphql(
|
||||
`query ($ids: [ID!]) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 500) { cursor items { id column_values { id text } } }
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId] },
|
||||
)
|
||||
: await this.graphql(
|
||||
`query ($ids: [ID!], $cursor: String!) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 500, cursor: $cursor) { cursor items { id column_values { id text } } }
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId], cursor },
|
||||
)
|
||||
const page = data.boards[0]?.items_page
|
||||
if (!page) break
|
||||
for (const item of page.items) {
|
||||
const estado = item.column_values.find((cv) => cv.id === colEstado.id)?.text ?? ''
|
||||
if (estado.trim().toUpperCase() === 'ABIERTO') abiertas++
|
||||
}
|
||||
cursor = page.cursor
|
||||
} while (cursor !== null)
|
||||
return abiertas
|
||||
}
|
||||
|
||||
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
||||
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
||||
const boardId = config.monday.boards.leads
|
||||
@@ -615,6 +742,12 @@ export interface DatosApartado {
|
||||
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
|
||||
}
|
||||
|
||||
export interface DatosVenta {
|
||||
clienteNombre: string
|
||||
unidadCodigo?: string
|
||||
valorVenta?: number
|
||||
}
|
||||
|
||||
export interface DatosMotivoNoCompra {
|
||||
nombre: string // '<motivo> — <nombre del item de origen>'
|
||||
categoria: string // una de MOTIVOS_NO_COMPRA
|
||||
|
||||
Reference in New Issue
Block a user