182 lines
7.1 KiB
JavaScript
182 lines
7.1 KiB
JavaScript
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) => `<value>${id}</value>`).join("");
|
|
const fetchXml =
|
|
`<fetch><entity name="alfa_stageduration">` +
|
|
`<attribute name="alfa_subject"/><attribute name="alfa_started"/><attribute name="alfa_opportunity"/>` +
|
|
`<filter><condition attribute="statuscode" operator="eq" value="1"/>` +
|
|
`<condition attribute="alfa_opportunity" operator="in">${conditions}</condition></filter>` +
|
|
`</entity></fetch>`;
|
|
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) => `<value>${id}</value>`).join("");
|
|
const fetchXml =
|
|
`<fetch><entity name="opportunityclose">` +
|
|
`<attribute name="opportunityid"/><attribute name="alfa_motivodecierre"/>` +
|
|
`<attribute name="opportunitystatuscode"/><attribute name="alfa_competitor"/>` +
|
|
`<attribute name="createdon"/>` +
|
|
`<filter><condition attribute="opportunityid" operator="in">${conditions}</condition></filter>` +
|
|
`</entity></fetch>`;
|
|
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);
|
|
}
|