import { dv, fv, odataString } from "./dataverse"; import { derivePipelineRow, segmentoDe } from "./derive"; const OPP_SELECT = [ "opportunityid", "name", "_ownerid_value", "alfa_typeopportunity", "alfa_tipodeventa", "_alfa_lineadenegocioid_value", "_alfa_servicioproductoid_value", "estimatedvalue", "actualvalue", "alfa_plandecierre", "alfa_envejecimiento", "alfa_diassinactividad", "estimatedclosedate", "actualclosedate", "stepname", "alfa_estadodelanegociacion", "_customerid_value", ].join(","); // Tubería: 5 tipos de la cartera "en pipeline" (ver ALFA_Estructura_Oportunidades_CRM.md). const PIPE_TYPE_FILTER = "(alfa_typeopportunity eq 100000000 or alfa_typeopportunity eq 100000003 or " + "alfa_typeopportunity eq 100000004 or alfa_typeopportunity eq 100000007 or alfa_typeopportunity eq 100000009)"; function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } export async function getSystemUsers(accessToken) { const data = await dv( "systemusers?$select=systemuserid,fullname,internalemailaddress&$filter=isdisabled eq false", accessToken ); return (data.value || []).map((u) => ({ id: u.systemuserid, name: u.fullname, email: (u.internalemailaddress || "").toLowerCase(), })); } export async function resolveOwnerId(email, accessToken) { if (!email) return null; const data = await dv( `systemusers?$select=systemuserid&$filter=${encodeURIComponent( `internalemailaddress eq '${odataString(email)}'` )}`, accessToken ); return data.value?.[0]?.systemuserid || null; } function mapOpportunityRow(o) { const tipo = fv(o, "alfa_typeopportunity"); return { id: o.opportunityid, name: o.name, owner: fv(o, "_ownerid_value"), tipo, segmento: segmentoDe(tipo), origen: fv(o, "alfa_tipodeventa"), linea: fv(o, "_alfa_lineadenegocioid_value"), servicio: fv(o, "_alfa_servicioproductoid_value"), est: o.estimatedvalue || 0, actual: o.actualvalue || 0, plan: fv(o, "alfa_plandecierre"), env: o.alfa_envejecimiento ?? null, dsa: o.alfa_diassinactividad ?? null, close: o.estimatedclosedate ? o.estimatedclosedate.slice(0, 10) : null, actualClose: o.actualclosedate ? o.actualclosedate.slice(0, 10) : null, step: o.stepname, estado: o.alfa_estadodelanegociacion || null, cliente: fv(o, "_customerid_value"), }; } async function getFaseActual(oppIds, accessToken) { const fase = {}; for (const batch of chunk(oppIds, 150)) { if (!batch.length) continue; const conditions = batch.map((id) => `${id}`).join(""); const fetchXml = `` + `` + `` + `${conditions}` + ``; const data = await dv(`alfa_stagedurations?fetchXml=${encodeURIComponent(fetchXml)}`, accessToken); for (const r of data.value || []) { const oid = r["_alfa_opportunity_value"] || r.alfa_opportunity; const started = r.alfa_started; const subj = (r.alfa_subject || "").replace("Fase: ", ""); if (!fase[oid] || (started && started > fase[oid].started)) { fase[oid] = { fase: subj, started }; } } } return fase; } // Motivo/competidor de cierre — viven en opportunityclose, no en opportunity (ver // contexto_tabla_cierre_oportunidad.md). alfa_motivodecierre es el campo principal (Alfa); // opportunitystatuscode es el respaldo estándar — en la práctica muchos cierres recientes // solo llenan el estándar. Nos quedamos con el cierre más reciente por oportunidad. export async function getMotivosCierre(oppIds, accessToken) { const motivos = {}; for (const batch of chunk(oppIds, 150)) { if (!batch.length) continue; const conditions = batch.map((id) => `${id}`).join(""); const fetchXml = `` + `` + `` + `` + `${conditions}` + ``; const data = await dv(`opportunitycloses?fetchXml=${encodeURIComponent(fetchXml)}`, accessToken); for (const r of data.value || []) { const oid = r["_opportunityid_value"]; const created = r.createdon; if (motivos[oid] && motivos[oid].createdon >= created) continue; motivos[oid] = { createdon: created, motivo: fv(r, "alfa_motivodecierre") || fv(r, "opportunitystatuscode") || null, competidor: fv(r, "_alfa_competitor_value"), }; } } return motivos; } // scope: { email, locked } de ownerFilter(). email null (admin, "todos") no filtra por propietario. export async function getPipeline(accessToken, scope) { let filter = `statecode eq 0 and estimatedclosedate ne null and msdyn_forecastcategory ne 100000001 and ${PIPE_TYPE_FILTER}`; if (scope.email) { const ownerId = await resolveOwnerId(scope.email, accessToken); if (!ownerId) return []; filter += ` and _ownerid_value eq ${ownerId}`; } const data = await dv( `opportunities?$select=${OPP_SELECT}&$filter=${encodeURIComponent(filter)}&$top=1000`, accessToken ); const rows = (data.value || []).map(mapOpportunityRow); const faseMap = await getFaseActual(rows.map((r) => r.id), accessToken); const today = new Date(); return rows.map((r) => derivePipelineRow(r, faseMap[r.id], today)); } // Resuelve una sola vez el systemuserid del scope (o null si no aplica) — para reusar en varias // llamadas a getClosed sin repetir la consulta a systemusers cada vez (ej. Tendencia, 6 meses). export async function resolveScopeOwnerId(scope, accessToken) { if (!scope.email) return { none: true }; const ownerId = await resolveOwnerId(scope.email, accessToken); return { none: false, ownerId }; } // statecode: 1 = ganadas, 2 = perdidas. month: "YYYY-MM", filtra por el mes de cierre real. // ownerId: opcional, ya resuelto vía resolveScopeOwnerId — evita resolver el email en cada llamada. export async function getClosed(accessToken, scope, statecode, month, resolvedOwnerId) { const monthStart = `${month}-01`; const [y, m] = month.split("-").map(Number); const nextMonth = new Date(y, m, 1).toISOString().slice(0, 10); let filter = `statecode eq ${statecode} and actualclosedate ge ${monthStart} and actualclosedate lt ${nextMonth}`; if (scope.email) { const ownerId = resolvedOwnerId !== undefined ? resolvedOwnerId : await resolveOwnerId(scope.email, accessToken); if (!ownerId) return []; filter += ` and _ownerid_value eq ${ownerId}`; } const data = await dv( `opportunities?$select=${OPP_SELECT}&$filter=${encodeURIComponent(filter)}&$top=1000`, accessToken ); return (data.value || []).map(mapOpportunityRow); }