Badge indicator showing whether an opportunity has an associated Service Request

This commit is contained in:
2026-09-24 14:03:34 -03:00
parent b8c493fc20
commit f9595b94e0
14 changed files with 656 additions and 8 deletions

View File

@@ -17,6 +17,23 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
- `dist/chromium/`: output for Google Chrome and Microsoft Edge. - `dist/chromium/`: output for Google Chrome and Microsoft Edge.
- `dist/firefox/`: output for Firefox. - `dist/firefox/`: output for Firefox.
## CRM Service Request Badges
Once per opening of the opportunities modal, `resquestCrmServiceRequest` fetches all pages of
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
query endpoint, independently of the opportunity query version. Calls appear in
the Service Requests debug category with masked credentials.
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
email and contact flags. A red badge indicates no match within that query's scope.
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
Badges follow Ramp Months and do not wait for product downloads. SR results remain
in memory only. Reference changes share the in-flight lookup and reuse its result
(including failures) without another request. Closing resets this state; reopening
starts a fresh lookup, and responses from previous openings are ignored.
## Build ## Build
Use Node.js: Use Node.js:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -29,6 +29,8 @@ module.exports = {
"src/platform/content-messages.js", "src/platform/content-messages.js",
"src/features/opportunities/controller.js", "src/features/opportunities/controller.js",
"src/services/fusion/opportunities.js", "src/services/fusion/opportunities.js",
"src/services/fusion/crm-service-requests.js",
"src/features/opportunities/crm-service-requests.js",
"src/features/opportunities/fiscal-periods.js", "src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js", "src/features/opportunities/table.js",
"src/features/opportunities/dashboard.js", "src/features/opportunities/dashboard.js",

View File

@@ -104,6 +104,8 @@ function createContentApplication(overrides = {}) {
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args), toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
@@ -176,6 +178,8 @@ function createContentApplication(overrides = {}) {
productsContentState, productsContentState,
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args), renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args), requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args), requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args) setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
@@ -193,6 +197,18 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods(); const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods();
const fusionCrmServiceRequests = createFusionCrmServiceRequests({
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
maskToken: (...args) => debugContentLog.maskToken(...args), environment
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunitiesTable = createOpportunitiesTable({ const opportunitiesTable = createOpportunitiesTable({
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL, ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS: columns, OPPORTUNITIES_COLUMNS: columns,
@@ -205,6 +221,7 @@ function createContentApplication(overrides = {}) {
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args), createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args), createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args), createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args), createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args), createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args), createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
@@ -468,6 +485,7 @@ function createContentApplication(overrides = {}) {
} }
return { return {
start, contracts, start, contracts,
fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState, authSessionState,
authSalesCommonState, authSalesCommonState,
opportunitiesQueryState, opportunitiesQueryState,

View File

@@ -51,6 +51,8 @@ function createContentModal({
renderStageFilterButtons, renderStageFilterButtons,
renderStatusFilterButtons, renderStatusFilterButtons,
requestOpportunitiesForPeriod, requestOpportunitiesForPeriod,
loadCrmServiceRequests,
resetCrmServiceRequests,
requestTokenRelayOnce, requestTokenRelayOnce,
resetCustomerAndOwnerFiltersForPeriod, resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable, resetOpportunitiesTable,
@@ -73,7 +75,8 @@ function createContentModal({
existingModal.querySelector("select").focus(); existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler(); ensureEscapeKeyHandler();
requestTokenRelayOnce().catch(() => {}); requestTokenRelayOnce().then(token => loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion))
.then(() => renderOpportunitiesTable()).catch(() => {});
return; return;
} }
@@ -429,6 +432,7 @@ function createContentModal({
} }
function closeOpportunitiesModal() { function closeOpportunitiesModal() {
resetCrmServiceRequests();
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
if (modal) { if (modal) {

View File

@@ -25,7 +25,7 @@ function createDebugContentView({
if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestGenerateAiSummary") return "ai"; if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase"; if (entry.name === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests"; if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption"; if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other"; return "other";
} }

View File

@@ -13,6 +13,8 @@ function createOpportunitiesController({
productsContentState, productsContentState,
renderProductsProgress, renderProductsProgress,
requestOpportunitiesPage, requestOpportunitiesPage,
loadCrmServiceRequests,
resetCrmServiceRequests,
requestOpptyProductsInBackground, requestOpptyProductsInBackground,
requestTokenRelayOnce, requestTokenRelayOnce,
setOpportunitiesTableState setOpportunitiesTableState
@@ -78,6 +80,8 @@ function createOpportunitiesController({
} }
try { try {
await loadCrmServiceRequests(token, requestVersion);
if (!isCurrentPeriodRequest(requestVersion)) return;
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1); const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
if (allItems && isCurrentPeriodRequest(requestVersion)) { if (allItems && isCurrentPeriodRequest(requestVersion)) {
@@ -102,6 +106,7 @@ function createOpportunitiesController({
} }
function resetOpportunitiesTable() { function resetOpportunitiesTable() {
resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS); opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS); opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
opportunitiesFilterState.selectedCustomerKeys = new Set(); opportunitiesFilterState.selectedCustomerKeys = new Set();

View File

@@ -0,0 +1,55 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
function reset() { generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
function load(token, version) {
if (!isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation);
return pending;
}
async function run(token, version, currentGeneration) {
const isActive = () => currentGeneration === generation;
status = "loading";
try {
const items = await requestCrmServiceRequest(token, version, isActive);
if (!items || !isActive()) return;
const grouped = new Map();
for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber;
if (!number) continue;
const key = String(number);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
}
byOpportunity = grouped;
status = "ready";
} catch {
if (isActive()) status = "error";
}
}
function createBadge(optyNumber) {
if (!optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement("span");
badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
badge.title = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
: items.map(item => [
`SR Number: ${display(item.SrNumber)}`, `Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n")).join("\n\n");
badge.setAttribute("aria-label", badge.title);
badge.tabIndex = 0;
return badge;
}
return { reset, load, createBadge };
}

View File

@@ -1,4 +1,23 @@
/* Compact filters use available CSS width, including Windows display scaling. */ /* Compact filters use available CSS width, including Windows display scaling. */
.opportunities-extension-crm-sr-badge {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #408254;
border-radius: 8px;
font-size: 10px;
line-height: 18px;
font-weight: 600;
white-space: nowrap;
color: #185b30;
background: #e1f1e5;
}
.opportunities-extension-crm-sr-badge[data-state="missing"] { color: #9f201b; background: #fde8e7; border-color: #c74640; }
.opportunities-extension-crm-sr-badge[data-state="error"] { color: #785000; background: #fff0cd; border-color: #987021; }
.opportunities-extension-crm-sr-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge { color: #b8e4c6; background: #214d34; border-color: #6eac82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="missing"] { color: #ffd9d5; background: #542824; border-color: #ef938b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="error"] { color: #ffe0a3; background: #584219; border-color: #c99b4b; }
.opportunities-extension-form { .opportunities-extension-form {
display: grid; display: grid;
grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content; grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content;

View File

@@ -10,6 +10,7 @@ function createOpportunitiesTable({
createDetailLink, createDetailLink,
createProductsCountButton, createProductsCountButton,
createProductsRampBadge, createProductsRampBadge,
createCrmServiceRequestBadge,
createProductsDetailRow, createProductsDetailRow,
createStageBadge, createStageBadge,
createStatusBadge, createStatusBadge,
@@ -134,6 +135,8 @@ function createOpportunitiesTable({
const rampBadge = createProductsRampBadge(productsResult); const rampBadge = createProductsRampBadge(productsResult);
if (rampBadge) productActions.append(rampBadge); if (rampBadge) productActions.append(rampBadge);
} }
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
if (srBadge) productActions.append(srBadge);
nameCellContent.append(opportunityLink, productActions); nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent); cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) { } else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -0,0 +1,60 @@
function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
const { fetch } = environment;
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
function createPayload(offset) {
return {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
q: { op: "$and", criteria: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] },
keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title",
"Owner.PersonFirstName", "Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c",
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
};
}
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version)) {
const items = [];
let offset = 0;
while (isActive()) {
const payload = createPayload(offset);
const headers = {
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
};
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset } });
try {
const response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`CRM service request query failed with status ${response.status}.`);
const data = await response.json();
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
throw new Error("CRM service request query returned an invalid response.");
}
if (!isActive()) return null;
items.push(...data.items);
if (!data.hasMore) return items;
const received = data.count === undefined ? data.items.length : Number(data.count);
const current = data.offset === undefined ? offset : Number(data.offset);
if (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset) {
throw new Error("CRM service request pagination did not advance.");
}
offset = current + received;
} catch (error) {
logRequestFailure(entry, error.message || "CRM service request query failed.");
throw error;
}
}
return null;
}
return { createPayload, requestCrmServiceRequest };
}

View File

@@ -0,0 +1,151 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 });
function setup(fetch) {
const document = { createElement: () => ({ attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
const app = loadApplication("content", { fetch, document, Intl, Date });
app.debugContentView.renderDebugPanel = () => {};
app.opportunitiesQueryState.periodRequestVersion = 1;
return app;
}
const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</script>", ReportedHours_c: 0,
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
test("CRM fetch is called without the environment object as its receiver", async () => {
let calls = 0;
const app = setup(async function (url, options) {
"use strict";
assert.equal(this, undefined, "Browser fetch must not receive the dependency container as this");
calls += 1;
const { offset } = JSON.parse(options.body);
return response({ items: [sr("TEST")], count: 1, offset, hasMore: offset === 0 });
});
const items = await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic-token", 1);
assert.equal(calls, 2);
assert.equal(items.length, 2);
});
test("CRM query preserves payload, paginates before opportunities, uses tokenrelay Bearer and logs each page", async () => {
const calls = [];
const app = setup(async (url, options) => {
const payload = JSON.parse(options.body);
calls.push({ url, options, payload });
return response({ items: [sr("TEST-1", `SR-${payload.offset}`)], count: 1, offset: payload.offset, hasMore: payload.offset === 0 });
});
const states = [];
app.opportunitiesTable.setOpportunitiesTableState = state => states.push(state);
app.productsContentJobs.renderProductsProgress = () => {};
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
app.fusionOpportunities.requestOpportunitiesPage = async () => {
assert.equal(calls.length, 2, "all CRM pages must finish first");
return [{ OptyNumber: "TEST-1" }];
};
await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1);
assert.equal(states.at(-1).status, "ready");
assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]);
const { payload, options, url } = calls[0];
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries");
assert.equal(options.method, "POST");
assert.equal(options.credentials, "include");
assert.equal(options.headers.Authorization, "Bearer synthetic-token");
assert.equal(options.headers["Content-Type"], "application/json");
assert.equal(options.headers.Origin, "https://eeho.fa.us2.oraclecloud.com");
assert.equal(options.headers.Preference, "transient");
assert.deepEqual(payload, {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest",
copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
q: { op: "$and", criteria: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] }, keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title", "Owner.PersonFirstName",
"Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
skipInValidFields: true, limit: 15, offset: 0
});
assert.equal(app.debugState.requestLog.length, 2);
for (const log of app.debugState.requestLog) {
assert.equal(log.name, "resquestCrmServiceRequest");
assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
assert.equal(log.requestHeaders.Preference, "transient");
assert.equal(log.status, "success");
assert.ok(log.responseBody);
assert.ok(log.durationMs >= 0);
assert.doesNotMatch(log.requestHeaders.Authorization, /synthetic-token/);
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
}
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");
assert.equal(found.textContent, "\u2705 SR");
assert.equal(found.attributes["data-state"], "matched");
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
assert.ok(found.title.includes(detail));
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
app.opportunityCrmServiceRequests.reset();
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
});
test("CRM failures do not mislabel missing SRs or block the opportunity list", async () => {
for (const fetch of [
async () => { throw new Error("Synthetic network failure"); },
async () => new Response("Denied", { status: 401 }),
async () => new Response("Forbidden", { status: 403 }),
async () => new Response("<html>Not JSON</html>"),
async () => response({ items: [] }),
async () => response({ items: [], count: 0, hasMore: true }),
async () => response({ items: [sr("TEST")], count: 1, offset: -1, hasMore: true })
]) {
const app = setup(fetch);
app.productsContentJobs.renderProductsProgress = () => {};
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
let loaded = false;
app.opportunitiesTable.setOpportunitiesTableState = () => {};
app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; };
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
assert.equal(loaded, true);
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "SR ?");
assert.equal(app.debugState.requestLog.at(-1).status, "failed");
}
});
test("stale CRM results cannot overwrite a reopened modal or continue pagination", async () => {
let resolveOld;
const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
const old = app.opportunityCrmServiceRequests.load("token", 1);
app.opportunitiesQueryState.periodRequestVersion = 2;
app.opportunityCrmServiceRequests.reset();
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
await app.opportunityCrmServiceRequests.load("token", 2);
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
await old;
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "\u26d4 SR");
assert.equal(app.debugState.requestLog.length, 1);
});
test("Reference changes share pending pagination and reuse completed CRM results", async () => {
let finish;
let calls = 0;
const app = setup(async () => {
calls += 1;
if (calls === 1) return new Promise(resolve => { finish = resolve; });
return response({ items: [sr("B")], hasMore: false });
});
const first = app.opportunityCrmServiceRequests.load("token", 1);
app.opportunitiesQueryState.periodRequestVersion = 2;
const second = app.opportunityCrmServiceRequests.load("token", 2);
assert.equal(first, second);
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
await second;
app.opportunitiesQueryState.periodRequestVersion = 3;
await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 2);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "\u2705 SR");
app.opportunityCrmServiceRequests.reset();
await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 3);
});

View File

@@ -116,6 +116,22 @@ test("products remain expandable with AI summary and a ten-column detail row aft
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary"); assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
}); });
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
const h = setup();
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
SrNumber: "SR-SYNTH", OpportunityName_Id_c: { OptyNumber: "TEST-1" }, ReportedHours_c: 0
}];
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "\u2705 SR");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
const actions = h.cell("name").children[0].children[1];
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR"]);
findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
});
test("product table distinguishes unavailable ramp months from numeric zero", () => { test("product table distinguishes unavailable ramp months from numeric zero", () => {
const h = setup(); const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {