Implemented the feature to identify and update estimates based on the ACR value, consumption start date, and ramp-up

This commit is contained in:
2026-10-20 15:55:58 -03:00
parent 71d550dfb1
commit 60daabfe91
31 changed files with 2800 additions and 43 deletions

View File

@@ -115,6 +115,17 @@ function createBackgroundApplication(overrides = {}) {
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const workloadsCache = createWorkloadsCache({
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
const spaWorkloads = createSpaWorkloads({
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
getCachedWorkloads: (...args) => workloadsCache.get(...args),
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
});
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
@@ -146,6 +157,9 @@ function createBackgroundApplication(overrides = {}) {
function start() {
srRequestHeaders.start();
startBackgroundApplication({
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
@@ -173,6 +187,8 @@ function createBackgroundApplication(overrides = {}) {
httpBackgroundJson,
productsBackgroundJobs,
productsCache,
workloadsCache,
spaWorkloads,
fusionProducts,
authXsrfRecovery,
authXsrfCookies,

View File

@@ -17,6 +17,9 @@ function registerBackgroundMessages({
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestWorkloads,
requestWorkloadForecastUpdate,
invalidateWorkloadsCache,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
@@ -94,7 +97,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true;
}
if (message.type === messageTypes.getXsrfToken) {
@@ -183,6 +186,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
request(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",

View File

@@ -58,7 +58,7 @@ function createContentApplication(overrides = {}) {
const contentModal = createContentModal({
createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
@@ -233,6 +233,7 @@ function createContentApplication(overrides = {}) {
const opportunitiesController = createOpportunitiesController({
ensureCurrentUser: () => currentUser.ensure(),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState,
@@ -376,6 +377,7 @@ function createContentApplication(overrides = {}) {
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
@@ -475,6 +477,8 @@ function createContentApplication(overrides = {}) {
resetRelatedData: () => {
opportunityCrmServiceRequests.reset();
opportunityWorkloadActions.reset();
productWorkloads.reset();
rampProjectionView.close(false);
consumptionPlansView.closeAccountPlansModal();
installBaseView.closeInstallBaseModal();
serviceRequestsView.closeServiceRequestsModal();
@@ -518,6 +522,8 @@ function createContentApplication(overrides = {}) {
}
});
const productsContentJobs = createProductsContentJobs({
loadWorkloads: (...args) => productWorkloads.load(...args),
joinWorkloads: number => productWorkloads.joinProducts(number),
beginProductActivities: (...args) => productActivities.begin(...args),
completeProductActivities: (...args) => productActivities.complete(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
@@ -531,6 +537,14 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const productWorkloads = createProductWorkloads({
productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
requestConsumerTokenServiceOnce: () => authContentSession.requestConsumerTokenServiceOnce(),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
getPrincipal: () => currentUser.getUserId() || authSessionState.principal,
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const workloadActionOptions = createWorkloadActionOptions({
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
@@ -569,6 +583,7 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
@@ -578,6 +593,30 @@ function createContentApplication(overrides = {}) {
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment
});
const rampProjection = createRampProjection();
const workloadForecastUpdate = createWorkloadForecastUpdate({
compare: product => rampProjection.compare(product),
ensureCurrentUser: () => currentUser.ensure(),
getCurrentUserId: () => currentUser.getUserId(),
requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
productsContentState,
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const rampProjectionView = createRampProjectionView({
saveEstimates: product => workloadForecastUpdate.save(product),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
MODAL_ID: contracts.MODAL_ID,
compare: product => rampProjection.compare(product),
expandProducts: number => productsDetails.expandProducts(number),
createAlertIcon: () => contentIcons.createAlertIcon(),
createCloseIcon: () => contentIcons.createCloseIcon(),
formatAmount: (...args) => productsDetails.formatProductAmount(...args),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
environment
});
const aiSummaryContent = createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
@@ -772,12 +811,16 @@ function createContentApplication(overrides = {}) {
preferencesStorage,
preferencesView,
productsContentJobs,
productWorkloads,
productActivities,
activityProgress,
srSessionPreparation,
refreshAll,
activityCache,
productsDetails,
rampProjection,
workloadForecastUpdate,
rampProjectionView,
aiSummaryContent,
consumptionPlansAction,
installBaseAction,

View File

@@ -105,5 +105,47 @@ function createContentIcons({
svg.append(path);
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon };
function createAlertIcon() {
/* Lucide triangle-alert (https://lucide.dev/icons/triangle-alert), ISC License.
* Copyright (c) 2026 Lucide Icons and Contributors
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* Derived from Feather alert-triangle, MIT License.
* Copyright (c) 2013-present Cole Bemis
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
for (const [name, value] of Object.entries({ viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false",
fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" })) svg.setAttribute(name, value);
for (const d of ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"]) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon, createAlertIcon };
}

View File

@@ -15,6 +15,7 @@ function createOpportunitiesController({
requestOpportunitiesPage,
resetCrmServiceRequests,
resetWorkloadActions,
resetWorkloads,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
ensureCurrentUser,
@@ -24,6 +25,7 @@ function createOpportunitiesController({
const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
resetWorkloadActions();
resetWorkloads();
await ensureCurrentUser();
if (!isCurrentPeriodRequest(requestVersion)) return;
@@ -109,6 +111,7 @@ function createOpportunitiesController({
function resetOpportunitiesTable() {
resetWorkloadActions();
resetWorkloads();
resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);

View File

@@ -10,6 +10,7 @@ function createOpportunitiesTable({
createDetailLink,
createProductsCountButton,
createProductsRampBadge,
createProjectionIndicator,
createCrmServiceRequestBadge,
closeCrmSrPopover,
createWorkloadActionsBadge,
@@ -146,6 +147,9 @@ function createOpportunitiesTable({
if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
if (actionsBadge) productActions.append(actionsBadge);
const projectionIndicator = createProjectionIndicator(productsResult, item,
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
if (projectionIndicator) productActions.append(projectionIndicator);
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -4,11 +4,12 @@ function createFormRequestCache({ getHours, getUserId }) {
let generation = 0;
let owner = "";
const queries = new Set([
"requestWorkloads",
"requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
]);
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest", "requestWorkloadForecastUpdate"]);
function invalidate() { generation++; entries.clear(); pending.clear(); }
function maxAge() {
const hours = Number(getHours());
@@ -34,7 +35,10 @@ function createFormRequestCache({ getHours, getUserId }) {
if (result?.ok && writes.has(operation) && !(operation === "requestPostServiceRequest" && message.phase === "prepare")) invalidate();
return result;
}
const key = JSON.stringify(stable(message));
// Workloads are scoped to the user/account, not the rotating WWB token or the selected Reference.
const key = JSON.stringify(stable(operation === "requestWorkloads"
? { type: message.type, customerId: String(message.customerId), principal: String(message.principal || userId).trim().toLowerCase() }
: message));
const hit = entries.get(key);
if (hit && Date.now() >= hit.fetchedAt && Date.now() - hit.fetchedAt < duration) {
return { ...copy(hit.result), debugRequests: [], fromCache: true, cachedAt: hit.fetchedAt };
@@ -46,10 +50,14 @@ function createFormRequestCache({ getHours, getUserId }) {
}
const version = generation;
const promise = transport(request).then(result => {
if (result?.ok && version === generation) {
const workload = operation === "requestWorkloads";
const fetchedAt = workload && Number.isFinite(result?.cachedAt) ? result.cachedAt : Date.now();
const cacheable = !workload || (Array.isArray(result?.workloads) && (!result.fromCache || Number.isFinite(result.cachedAt)));
if (result?.ok && cacheable && version === generation) {
// Keep this per-page cache bounded and avoid persisting credentials or customer data to disk.
if (entries.size >= 500) entries.delete(entries.keys().next().value);
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt: Date.now() });
// Reusing a persistent workload entry must not restart its original TTL.
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt });
}
return result;
});

View File

@@ -9,6 +9,8 @@ function createProductsContentJobs({
renderOpportunitiesTable,
beginProductActivities,
completeProductActivities,
loadWorkloads,
joinWorkloads,
sendRuntimeMessage,
environment
}) {
@@ -65,6 +67,8 @@ function createProductsContentJobs({
};
renderProductsProgress();
loadWorkloads(opportunities, requestId);
if (optyNumbers.length === 0) {
return;
}
@@ -121,6 +125,7 @@ function createProductsContentJobs({
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
joinWorkloads(message.optyNumber);
}
productsContentState.productsProgress = {

View File

@@ -3,6 +3,8 @@ function createProductsContentState() {
currentProductsRequestId: "",
productsRenderQueued: false,
opptyProductsByNumber: new Map(),
workloadCustomerIds: [],
workloadsByCustomer: new Map(),
expandedProductOptyNumbers: new Set(),
productsProgress: {
state: "idle",

View File

@@ -2,6 +2,7 @@ function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
createProjectionIndicator,
formatOracleResponseDate,
productsContentState,
renderOpportunitiesTable,
@@ -24,6 +25,14 @@ function createProductsDetails({
return badge;
}
function expandProducts(optyNumber) {
const number = String(optyNumber);
if (productsContentState.expandedProductOptyNumbers.has(number)) return;
productsContentState.expandedProductOptyNumbers.add(number);
requestGenerateAiSummary(number);
renderOpportunitiesTable();
}
function createProductsCountButton(optyNumber, productsResult) {
const normalizedOptyNumber = String(optyNumber);
const button = document.createElement("button");
@@ -44,8 +53,8 @@ function createProductsDetails({
if (isExpanded) {
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
expandProducts(normalizedOptyNumber);
return;
}
renderOpportunitiesTable();
@@ -105,7 +114,8 @@ function createProductsDetails({
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) }
? "N/D" : formatProductNumber(item.RampMonths_c) },
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
];
const head = document.createElement("thead");
@@ -123,7 +133,10 @@ function createProductsDetails({
const productRow = document.createElement("tr");
columns.forEach((column) => {
const productCell = document.createElement("td");
productCell.textContent = String(column.value(product));
if (column.render) {
productCell.className = "opportunities-extension-projection-cell";
productCell.append(column.render(product));
} else productCell.textContent = String(column.value(product));
productRow.append(productCell);
});
body.append(productRow);
@@ -165,5 +178,5 @@ function createProductsDetails({
return Number.isFinite(number) ? `${number}%` : "-";
}
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
return { expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
}

View File

@@ -0,0 +1,47 @@
function createWorkloadForecastUpdate({ compare, ensureCurrentUser, getCurrentUserId, requestAuthorization,
sendRuntimeMessage, messageTypes, appendBackgroundRequestLog, productsContentState, queueProductsTableRender }) {
const pending = new Set();
async function save(product) {
const id = String(product?.workload?.id ?? "");
if (!/^\d+$/.test(id) || id !== String(product.RevnId)) throw new Error("The product does not have a matching workload ID.");
if (pending.has(id)) throw new Error("Estimates are already being updated.");
const comparison = compare(product);
if (comparison.status !== "divergent") throw new Error("No forecast differences are available to update.");
pending.add(id);
try {
await ensureCurrentUser();
const updatedBy = getCurrentUserId();
if (typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("The current user ID is unavailable.");
// Keep zero months before consumption unchanged, unless they also need correcting.
const payload = comparison.rows.filter(row => row.index >= 1 || row.divergent)
.map(row => ({ year: row.year, month: row.month, amount: row.calculated, updatedBy }));
const authorizations = await requestAuthorization();
const response = await sendRuntimeMessage({ type: messageTypes.requestWorkloadForecastUpdate,
workloadId: id, payload, authorization: authorizations?.workloadActions });
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to update workload estimates.");
const amounts = new Map(payload.map(item => [`${item.year}-${item.month}`, item.amount]));
const update = workload => {
if (String(workload?.id) !== id) return workload;
return { ...workload, forecast: workload.forecast.map(month => {
const key = `${Number(month.year)}-${Number(month.month)}`;
return amounts.has(key) ? { ...month, adjustedConsumptionAmount: amounts.get(key) } : month;
}) };
};
product.workload = update(product.workload);
for (const record of productsContentState.workloadsByCustomer.values()) {
if (record.status !== "ready") continue;
record.workloads = record.workloads.map(update);
record.byId = new Map(record.workloads.map(workload => [String(workload.id), workload]));
}
for (const record of productsContentState.opptyProductsByNumber.values()) {
record.items = record.items.map(item => ({ ...item, workload: update(item.workload) }));
}
queueProductsTableRender();
return response;
} finally {
pending.delete(id);
}
}
return { save };
}

View File

@@ -0,0 +1,216 @@
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const item = document.createElement(tag);
if (className) item.className = className;
if (text !== undefined) item.textContent = text;
return item;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving && restoreFocus) return true;
const previous = active;
active = null;
previous.modal.remove();
previous.siblings.forEach(([element, inert]) => { element.inert = inert; });
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createIndicator(product, opportunity) {
const result = compare(product);
if (result.status !== "divergent") {
const text = node("span", "opportunities-extension-projection-status", result.status === "matched" ? "OK" : "-");
text.title = result.status === "matched" ? "Monthly forecast matches the ramp calculation." : result.reason;
return text;
}
const button = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
button.type = "button";
button.title = `Consumption forecast differs in ${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"}. View comparison.`;
button.setAttribute("aria-label", button.title);
button.setAttribute("aria-haspopup", "dialog");
button.append(createAlertIcon());
button.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
open(product, opportunity, button);
});
return button;
}
function createOpportunityIndicator(productsResult, opportunity, isExpanded) {
const products = productsResult?.items || [];
const count = products.filter(product => compare(product).status === "divergent").length;
if (!count) return null;
const singleProduct = products.length === 1;
const indicator = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert opportunities-extension-opportunity-projection-alert");
indicator.type = "button";
indicator.title = `Consumption forecast differs in ${count} product${count === 1 ? "" : "s"}. ${singleProduct ? "View comparison." : "View products."}`;
if (singleProduct) indicator.setAttribute("aria-haspopup", "dialog");
else indicator.setAttribute("aria-expanded", String(Boolean(isExpanded)));
indicator.setAttribute("aria-label", indicator.title);
indicator.append(createAlertIcon());
indicator.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
if (singleProduct) open(products[0], opportunity, indicator);
else expandProducts(opportunity.OptyNumber);
});
return indicator;
}
function open(product, opportunity, trigger) {
const host = document.getElementById(MODAL_ID);
const result = compare(product);
if (!host || host.hidden || result.status !== "divergent") return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal opportunities-extension-projection-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-projection-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-projection-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Consumption projection");
title.id = "opportunities-extension-projection-title";
heading.append(title, node("p", "", `${opportunity.OptyNumber} - ${product.WorkloadName_c || product.workload.name || product.ProdGroupName || "Workload"}`));
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.title = "Close consumption projection";
dismiss.setAttribute("aria-label", dismiss.title);
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const body = node("div", "opportunities-extension-projection-body");
const details = node("dl", "opportunities-extension-projection-context");
const currency = product.RevnAmountCurcyCode || "USD";
for (const [label, value] of [
["Customer", opportunity.CustomerAccount?.PartyUniqueName || "-"], ["Product", product.ProdGroupName || "-"],
["Annual value", formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp)],
["Consumption start", formatDate(result.start)], ["First month", `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
]) details.append(node("dt", "", label), node("dd", "", value));
body.append(details);
const summary = node("p", "opportunities-extension-projection-summary", `${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"} with a difference`);
body.append(summary);
if (result.reason) body.append(node("p", "opportunities-extension-projection-notice", result.reason));
const viewport = node("div", "opportunities-extension-projection-viewport");
viewport.tabIndex = 0;
viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Monthly consumption comparison");
const table = node("table", "opportunities-extension-projection-table");
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
for (const label of ["Month", "Forecast", "Calculated", "Difference"]) {
const th = node("th", "", label); th.scope = "col"; headRow.append(th);
}
head.append(headRow);
for (const item of result.rows) {
const tr = node("tr");
tr.setAttribute("data-divergent", String(item.divergent));
const month = node("th", "", `${String(item.month).padStart(2, "0")}/${item.year}`);
month.scope = "row";
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
tr.append(month);
for (const value of [item.actual, item.calculated, item.difference]) tr.append(node("td", "", value === null ? "-" : formatAmount(value, currency)));
tbody.append(tr);
}
table.append(caption, head, tbody);
viewport.append(table);
body.append(viewport);
const errorMessage = node("p", "opportunities-extension-projection-error");
errorMessage.setAttribute("role", "alert");
errorMessage.hidden = true;
body.append(errorMessage);
const footer = node("footer", "opportunities-extension-projection-footer");
const updateButton = node("button", "opportunities-extension-projection-update", "Update estimates");
updateButton.type = "button";
footer.append(node("span", "", "Difference = forecast - calculated."), updateButton);
dialog.append(header, body, footer);
modal.append(dialog);
const siblings = Array.from(host.children).map(element => [element, element.inert]);
siblings.forEach(([element]) => { element.inert = true; });
const current = { modal, siblings, trigger, saving: false };
active = current;
async function updateEstimates() {
if (current.saving) return;
current.saving = true;
updateButton.disabled = true;
dismiss.disabled = true;
updateButton.textContent = "Updating...";
modal.setAttribute("aria-busy", "true");
errorMessage.hidden = true;
try {
await saveEstimates(product);
current.saving = false;
if (active === current) close();
notify("Workload estimates updated successfully.");
} catch (error) {
const message = error.message || "Unable to update workload estimates.";
if (active === current) {
errorMessage.textContent = message;
errorMessage.hidden = false;
}
notify(message, true);
} finally {
current.saving = false;
updateButton.disabled = false;
dismiss.disabled = false;
updateButton.textContent = "Update estimates";
modal.setAttribute("aria-busy", "false");
}
}
updateButton.addEventListener("click", () => {
if (current.saving || current.confirmation) return;
const overlay = node("div", "opportunities-extension-action-form-modal");
const confirmation = node("section", "opportunities-extension-action-form-dialog opportunities-extension-projection-confirm");
confirmation.setAttribute("role", "alertdialog");
confirmation.setAttribute("aria-modal", "true");
confirmation.setAttribute("aria-labelledby", "projection-confirm-title");
confirmation.setAttribute("aria-describedby", "projection-confirm-message");
const title = node("h2", "", "Update estimates");
title.id = "projection-confirm-title";
const message = node("p", "", "The estimates will be updated directly in the SPA using the values in the Calculated column. Would you like to confirm the update?");
message.id = "projection-confirm-message";
const actions = node("div", "opportunities-extension-projection-confirm-actions");
const cancel = node("button", "opportunities-extension-projection-cancel", "Cancel");
const confirm = node("button", "opportunities-extension-projection-update", "Confirm update");
cancel.type = confirm.type = "button";
function dismissConfirmation() {
overlay.remove();
current.confirmation = null;
dialog.inert = false;
updateButton.focus();
}
cancel.addEventListener("click", dismissConfirmation);
confirm.addEventListener("click", () => {
if (current.confirmation !== overlay || active !== current) return;
dismissConfirmation();
return updateEstimates();
});
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") {
event.preventDefault(); event.stopPropagation(); dismissConfirmation();
} else if (event.key === "Tab") {
event.preventDefault(); event.stopPropagation();
(document.activeElement === cancel ? confirm : cancel).focus();
}
});
actions.append(cancel, confirm);
confirmation.append(title, message, actions);
overlay.append(confirmation);
current.confirmation = overlay;
dialog.inert = true;
modal.append(overlay);
cancel.focus();
});
host.append(modal);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, viewport, updateButton].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
}
});
dismiss.focus();
}
return { createIndicator, createOpportunityIndicator, open, close };
}

View File

@@ -0,0 +1,52 @@
.opportunities-extension-products-table th:last-child { width: 90px; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-align: center; }
.opportunities-extension-products-table .opportunities-extension-projection-cell { text-align: center; vertical-align: middle; }
.opportunities-extension-projection-alert { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 0; color: #976000; width: 30px; height: 30px; flex: 0 0 30px; vertical-align: middle; }
.opportunities-extension-projection-alert svg { display: block; flex: none; width: 19px; height: 19px; }
.opportunities-extension-cell-actions > .opportunities-extension-opportunity-projection-alert { width: 20px; height: 20px; flex-basis: 20px; }
.opportunities-extension-projection-alert:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-action-form-dialog.opportunities-extension-projection-dialog { width: min(850px, 100%); display: flex; flex-direction: column; overflow: hidden; }
.opportunities-extension-projection-dialog > header { flex: 0 0 auto; padding: 18px 20px; }
.opportunities-extension-projection-dialog .opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-projection-body { min-height: 0; overflow: auto; padding: 16px 20px 0; }
.opportunities-extension-projection-context { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr); gap: 8px 12px; margin: 0 0 16px; font-size: 12px; }
.opportunities-extension-projection-context dt { font-weight: 600; }
.opportunities-extension-projection-context dd { margin: 0; overflow-wrap: anywhere; }
.opportunities-extension-projection-summary { font-size: 13px; font-weight: 600; color: #815400; margin: 0 0 12px; }
.opportunities-extension-projection-notice { font-size: 12px; }
.opportunities-extension-projection-viewport { overflow-x: auto; }
.opportunities-extension-projection-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 9px 12px; border-bottom: 1px solid #dedbd7; text-align: right; white-space: nowrap; }
.opportunities-extension-projection-table th:first-child { text-align: left; }
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
.opportunities-extension-projection-table tbody th { font-weight: 400; }
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }
.opportunities-extension-projection-prorated { display: inline; color: #625d58; font-size: 10px; }
.opportunities-extension-projection-footer { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0; padding: 14px 20px; font-size: 11px; color: #625d58; }
.opportunities-extension-projection-update { flex: none; padding: 8px 12px; border: 1px solid #006879; border-radius: 4px; background: #006879; color: #fff; font: inherit; font-size: 12px; cursor: pointer; }
.opportunities-extension-projection-update:hover:not(:disabled) { background: #005563; }
.opportunities-extension-projection-update:disabled { opacity: .6; cursor: wait; }
.opportunities-extension-projection-update:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-projection-confirm { padding: 20px; }
.opportunities-extension-projection-confirm h2 { margin: 0 0 12px; font-size: 18px; }
.opportunities-extension-projection-confirm p { margin: 0 0 20px; font-size: 13px; line-height: 1.5; }
.opportunities-extension-projection-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-projection-cancel { padding: 8px 12px; border: 1px solid #8b8580; border-radius: 4px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
.opportunities-extension-projection-error { color: #b3261e; font-size: 12px; overflow-wrap: anywhere; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-error { color: #ffb4ab; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-alert,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-summary,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #ffd080; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table thead th { background: #34312e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table td { border-color: #504b46; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] { background: #3e3425; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-prorated,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-footer { color: #c9c3bc; }
@media (max-width: 600px) {
.opportunities-extension-projection-context { grid-template-columns: max-content minmax(0, 1fr); }
.opportunities-extension-projection-dialog > header { padding: 14px 12px; }
.opportunities-extension-projection-body { padding: 12px 12px 0; }
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 8px 6px; }
.opportunities-extension-projection-footer { padding: 12px; }
}

View File

@@ -0,0 +1,63 @@
function createRampProjection() {
function number(value) {
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
const result = Number(value);
return Number.isFinite(result) ? result : null;
}
function startDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/.exec(String(value ?? ""));
if (!match) return null;
const [, year, month, day] = match.map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
return { year, month, day, days: new Date(Date.UTC(year, month, 0)).getUTCDate() };
}
function monthlyAmount(annual, ramp, month) {
if (month < 1) return 0;
// calculadora rampa.xlsx, rampas!D6:P17: exact values, before currency rounding.
return (2 * annual / (24 - ramp)) * Math.min(month / (ramp + 1), 1);
}
function cents(value) { return Math.round((value + Number.EPSILON * Math.abs(value)) * 100); }
function compare(product) {
const workload = product?.workload;
const unavailable = reason => ({ status: "unavailable", reason, rows: [], divergentCount: 0 });
if (!workload) return unavailable("Workload data is not available for this product.");
const annual = number(workload.adjustedACR);
const ramp = number(product.RampMonths_c);
const start = startDate(workload.consumptionStartDate);
if (annual === null || annual < 0) return unavailable("Annual value (adjustedACR) is missing or invalid.");
if (ramp === null || !Number.isInteger(ramp) || ramp < 0 || ramp > 11) {
return unavailable("RampMonths_c must be an integer from 0 to 11, as covered by the reference workbook.");
}
if (!start) return unavailable("Consumption start date is missing or invalid.");
if (!Array.isArray(workload.forecast) || !workload.forecast.length) return unavailable("Monthly forecast data is not available.");
const periods = new Set();
const rows = workload.forecast.map(forecast => {
const year = number(forecast?.year), month = number(forecast?.month);
const actual = number(forecast?.adjustedConsumptionAmount);
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12) return null;
const key = `${year}-${String(month).padStart(2, "0")}`;
if (periods.has(key)) return null;
periods.add(key);
const index = (year - start.year) * 12 + month - start.month + 1;
const prorated = index === 1 && start.day > 1;
const exact = monthlyAmount(annual, ramp, index) * (index === 1 ? (start.days - start.day + 1) / start.days : 1);
const calculatedCents = cents(exact);
const actualCents = actual === null ? null : cents(actual);
if (!Number.isSafeInteger(calculatedCents) || (actualCents !== null && !Number.isSafeInteger(actualCents))) return null;
const differenceCents = actualCents === null ? null : actualCents - calculatedCents;
return { key, year, month, index, prorated, actual: actualCents === null ? null : actualCents / 100,
calculated: calculatedCents / 100, difference: differenceCents === null ? null : differenceCents / 100,
divergent: differenceCents !== null && differenceCents !== 0 };
});
if (rows.some(row => row === null)) return unavailable("Forecast contains invalid or duplicate months, or values outside the supported numeric range.");
rows.sort((a, b) => a.key.localeCompare(b.key));
const divergentCount = rows.filter(row => row.divergent).length;
const missingCount = rows.filter(row => row.actual === null).length;
return { status: divergentCount ? "divergent" : missingCount ? "unavailable" : "matched",
reason: missingCount ? "Some months have no adjusted consumption amount." : "", rows, divergentCount, missingCount,
annual, ramp, start: `${start.year}-${String(start.month).padStart(2, "0")}-${String(start.day).padStart(2, "0")}`,
firstMonthDays: start.days - start.day + 1, firstMonthTotalDays: start.days };
}
return { compare, monthlyAmount };
}

View File

@@ -0,0 +1,51 @@
function createWorkloadsCache({ fetchAllWorkloads, storageLocalGet, storageLocalSet }) {
const KEY = "opportunitiesExtension.workloadsCache.v1";
let cachePromise = null;
let writePromise = Promise.resolve();
let generation = 0;
const inFlight = new Map();
function read() {
if (!cachePromise) cachePromise = storageLocalGet(KEY)
.then(result => result?.[KEY] && typeof result[KEY] === "object" && !Array.isArray(result[KEY]) ? result[KEY] : {})
.catch(() => ({}));
return cachePromise;
}
function write(cache) {
writePromise = writePromise.catch(() => {}).then(() => storageLocalSet({ [KEY]: cache }));
return writePromise;
}
async function invalidate() {
generation += 1;
inFlight.clear();
cachePromise = Promise.resolve({});
await write({});
}
async function get(customerId, token, maxAgeMs, principal) {
const scope = typeof principal === "string" ? principal.trim().toLowerCase() : "";
// isMyWorkload is user-specific. Never persist a result without a known principal.
if (!scope) return { ...await fetchAllWorkloads(customerId, token), fromCache: false, cachedAt: Date.now() };
const key = JSON.stringify([scope, customerId]);
const currentGeneration = generation;
const cache = await read();
if (generation !== currentGeneration) return get(customerId, token, maxAgeMs, principal);
const entry = cache[key];
const age = Date.now() - Number(entry?.fetchedAt);
if (Array.isArray(entry?.items) && age >= 0 && age < maxAgeMs) {
return { workloads: entry.items, cachedAt: Number(entry.fetchedAt), fromCache: true, debugRequests: [] };
}
if (!inFlight.has(key)) {
const flight = fetchAllWorkloads(customerId, token).then(async result => {
const cachedAt = Date.now();
if (generation === currentGeneration) {
cache[key] = { fetchedAt: cachedAt, items: result.workloads };
await write(cache).catch(() => {});
}
return { ...result, cachedAt, fromCache: false };
}).finally(() => { if (inFlight.get(key) === flight) inFlight.delete(key); });
inFlight.set(key, flight);
}
return inFlight.get(key);
}
return { get, invalidate };
}

View File

@@ -0,0 +1,78 @@
function createProductWorkloads({ productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, getPrincipal, queueProductsTableRender }) {
let cycle = null;
function reset() {
cycle = null;
productsContentState.workloadCustomerIds = [];
productsContentState.workloadsByCustomer = new Map();
}
function isCurrent(current) {
return cycle === current && current.requestId === productsContentState.currentProductsRequestId
&& current.version === opportunitiesQueryState.periodRequestVersion;
}
function joinProducts(optyNumber) {
if (!cycle || !isCurrent(cycle)) return;
const key = String(optyNumber);
const products = productsContentState.opptyProductsByNumber.get(key);
if (!products) return;
const customerId = cycle.customerByOpportunity.get(key);
const record = productsContentState.workloadsByCustomer.get(customerId);
products.items = products.items.map(product => ({ ...product,
workload: record?.status === "ready" ? record.byId.get(String(product.RevnId ?? "")) || null : null }));
}
async function load(opportunities, requestId) {
reset();
const current = { requestId, version: opportunitiesQueryState.periodRequestVersion,
customerByOpportunity: new Map(), opportunitiesByCustomer: new Map() };
cycle = current;
for (const opportunity of opportunities) {
const customerId = String(opportunity?.CustomerAccount?.PartyId ?? "").trim();
if (!/^\d+$/.test(customerId)) continue;
if (!current.opportunitiesByCustomer.has(customerId)) current.opportunitiesByCustomer.set(customerId, new Set());
if (opportunity.OptyNumber) {
const number = String(opportunity.OptyNumber);
current.customerByOpportunity.set(number, customerId);
current.opportunitiesByCustomer.get(customerId).add(number);
}
}
const ids = [...current.opportunitiesByCustomer.keys()];
productsContentState.workloadCustomerIds = ids;
const records = productsContentState.workloadsByCustomer;
for (const id of ids) records.set(id, { status: "loading", workloads: [], byId: new Map() });
if (!ids.length || !isCurrent(current)) return;
const principal = getPrincipal();
const cacheMaxAgeMs = preferencesState.opptyProductsCacheHours * 60 * 60 * 1000;
try {
const authorizations = await requestConsumerTokenServiceOnce();
if (!isCurrent(current)) return;
let next = 0;
const worker = async () => {
while (isCurrent(current) && next < ids.length) {
const customerId = ids[next++];
const record = records.get(customerId);
try {
const result = await sendRuntimeMessage({ type: messageTypes.requestWorkloads, customerId,
authorization: authorizations?.workloadActions, cacheMaxAgeMs, principal });
if (!isCurrent(current)) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok || !Array.isArray(result.workloads)) throw new Error(result?.error || "Unable to load workloads.");
Object.assign(record, { status: "ready", workloads: result.workloads,
byId: new Map(result.workloads.map(workload => [String(workload.id), workload])),
fromCache: Boolean(result.fromCache), cachedAt: Number(result.cachedAt) || 0 });
} catch (error) {
if (!isCurrent(current)) return;
record.status = "error";
record.error = error.message || "Unable to load workloads.";
}
for (const number of current.opportunitiesByCustomer.get(customerId)) joinProducts(number);
queueProductsTableRender();
}
};
await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker));
} catch (error) {
if (!isCurrent(current)) return;
for (const record of records.values()) { record.status = "error"; record.error = error.message || "Unable to load workloads."; }
}
}
return { load, joinProducts, reset };
}

View File

@@ -0,0 +1,79 @@
function createSpaWorkloads({ fetchJson, getCachedWorkloads, normalizeCacheMaxAge, invalidateWorkloadsCache }) {
async function requestWorkloadForecastUpdate(message) {
const debugRequests = [];
try {
const id = String(message.workloadId ?? "");
const token = message.authorization?.value;
if (!/^\d+$/.test(id)) throw new Error("A numeric workload ID is required.");
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
if (!Array.isArray(message.payload) || !message.payload.length) throw new Error("Monthly estimates are required.");
const periods = new Set();
const payload = message.payload.map(({ year, month, amount, updatedBy }) => {
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12
|| typeof amount !== "number" || !Number.isFinite(amount) || amount < 0
|| typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("Invalid monthly estimate.");
const period = `${year}-${month}`;
if (periods.has(period)) throw new Error("Duplicate forecast month.");
periods.add(period);
return { year, month, amount, updatedBy: updatedBy.trim() };
});
const result = await fetchJson(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads/${id}/forecast`, {
method: "PATCH", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", "wwb-provider-authorization": token.trim() },
body: JSON.stringify(payload)
}, "requestWorkloadForecastUpdate", debugRequests, { allowEmpty: true });
await invalidateWorkloadsCache().catch(() => {});
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestWorkloads(message) {
const customerId = String(message.customerId ?? "").trim();
const token = message.authorization?.value;
if (!/^\d+$/.test(customerId)) throw new Error("A numeric CustomerAccount.PartyId is required.");
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await getCachedWorkloads(customerId, token.trim(), normalizeCacheMaxAge(message.cacheMaxAgeMs), message.principal);
return { ok: true, customerId, ...result };
}
async function fetchAllWorkloads(customerId, token) {
const debugRequests = [];
const workloads = new Map();
let offset = 0;
let limit = 500;
try {
for (let page = 0; page < 1000; page += 1) {
const url = new URL(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers/${customerId}/workloads`);
for (const [name, value] of Object.entries({ workloadStatus: "Pipeline,Implementation,Live", opportunityForecastType: "All",
includeForEstimates: "ALL", financialYear: "ROLLING_4_QTRS", offset, limit })) url.searchParams.set(name, String(value));
const result = await fetchJson(url.toString(), {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "wwb-provider-authorization": token }
}, "requestWorkloads", debugRequests);
if (!result || !Array.isArray(result.workloads) || typeof result.hasNext !== "boolean"
|| result.workloads.some(item => !item || !/^\d+$/.test(String(item.id ?? "")))) {
throw new Error("requestWorkloads returned an invalid collection.");
}
const countBefore = workloads.size;
for (const item of result.workloads) workloads.set(String(item.id), item);
if (!result.hasNext) return { workloads: [...workloads.values()], debugRequests };
const responseOffset = Number(result.offset ?? offset);
const responseLimit = Number(result.limit ?? limit);
if (workloads.size === countBefore || !Number.isSafeInteger(responseOffset) || responseOffset !== offset
|| !Number.isSafeInteger(responseLimit) || responseLimit <= 0 || !Number.isSafeInteger(responseOffset + responseLimit)) {
throw new Error("requestWorkloads pagination did not advance.");
}
offset = responseOffset + responseLimit;
limit = responseLimit;
}
throw new Error("requestWorkloads exceeded the pagination limit.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
return { requestWorkloads, fetchAllWorkloads, requestWorkloadForecastUpdate };
}

View File

@@ -19,6 +19,8 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestWorkloads: "opportunitiesExtension.requestWorkloads",
requestWorkloadForecastUpdate: "opportunitiesExtension.requestWorkloadForecastUpdate",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",