informe-comercial-alfa/app/lib/queries.js

183 lines
7.1 KiB
JavaScript
Raw Normal View History

2026-09-21 23:11:53 +00:00
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);
}