5.5.2026 update
This commit is contained in:
@@ -16,6 +16,28 @@ import {
|
||||
resolveStateReadPath,
|
||||
} from "./lib/runtime-paths.mjs";
|
||||
import { parseArgs, splitCsv } from "./lib/cli-shared.mjs";
|
||||
import { quoteLiteral, toSqlList } from "./lib/sql-literals.mjs";
|
||||
import {
|
||||
SA_ATTACH_DEFAULT_ALL_STATUSES,
|
||||
SA_ATTACH_DEFAULT_CLUSTER,
|
||||
SA_ATTACH_DEFAULT_MIN_OPPORTUNITY_PROBABILITY,
|
||||
SA_ATTACH_DEFAULT_OPEN_STATUSES,
|
||||
SA_ATTACH_DEFAULT_PRODUCT_LOBS,
|
||||
SA_ATTACH_DEFAULT_REVENUE_TYPE_GROUPS,
|
||||
SA_ATTACH_DEFAULT_SALES_CREDIT_TYPE,
|
||||
SA_ATTACH_DEFAULT_SOLUTIONS,
|
||||
SA_ATTACH_DEFAULT_WON_STATUSES,
|
||||
applyCloseWindowIntentDefaults,
|
||||
applyCurrentWorkloadIntentDefaults,
|
||||
resolveCommercialClusterFilter,
|
||||
resolveCoverageLimit,
|
||||
resolveManagerEmailFilter,
|
||||
resolveManagerEmailListFilter,
|
||||
resolveOptionalClusterFilter,
|
||||
resolveOwnerEmailFilter,
|
||||
resolveResourceFilter,
|
||||
resolveSaAttachFilters,
|
||||
} from "./lib/sa-attach-filters.mjs";
|
||||
import {
|
||||
cookieHeaderFor,
|
||||
extractClientBin,
|
||||
@@ -38,8 +60,13 @@ import {
|
||||
buildWarnings,
|
||||
emitJson,
|
||||
mapRows,
|
||||
parseNumericValue,
|
||||
} from "./lib/output-contract.mjs";
|
||||
import {
|
||||
buildSrCoverageKey,
|
||||
hasUsableOpportunityId,
|
||||
summarizeCurrentTeamOpportunityCoverage,
|
||||
summarizeOrgWideRevenueLineCoverage,
|
||||
} from "./lib/coverage-summary.mjs";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://salesintelligence-dv.oracle.com";
|
||||
const DEFAULT_WORKBOOK_URL =
|
||||
@@ -104,20 +131,6 @@ const DATASET_REGISTRY = {
|
||||
allowedScopes: [SCOPE_MY_TEAM],
|
||||
},
|
||||
};
|
||||
const SA_ATTACH_DEFAULT_CLUSTER = "Brazil";
|
||||
const SA_ATTACH_DEFAULT_SOLUTIONS = [
|
||||
"Cloud Infrastructure - Workloads",
|
||||
"Hardware - Bookings",
|
||||
"On-Premise Technology - Bookings",
|
||||
];
|
||||
const SA_ATTACH_DEFAULT_PRODUCT_LOBS = ["Cloud Infrastructure", "Hardware", "License"];
|
||||
const SA_ATTACH_DEFAULT_REVENUE_TYPE_GROUPS = ["NEW", "EXPANSION", "WORKLOAD"];
|
||||
const SA_ATTACH_DEFAULT_OPEN_STATUSES = ["Open"];
|
||||
const SA_ATTACH_DEFAULT_WON_STATUSES = ["Won", "Won Pending"];
|
||||
const SA_ATTACH_DEFAULT_ALL_STATUSES = [
|
||||
...SA_ATTACH_DEFAULT_OPEN_STATUSES,
|
||||
...SA_ATTACH_DEFAULT_WON_STATUSES,
|
||||
];
|
||||
const VIEW_SCOPE_REGISTRY = {
|
||||
"current-fiscal-year": { sourceName: SOURCE_DV_SE_TEAM, requiresMyTeam: true },
|
||||
"data-quality-session-context": { sourceName: SOURCE_DV_SE_TEAM, requiresMyTeam: true },
|
||||
@@ -136,21 +149,6 @@ const VIEW_SCOPE_REGISTRY = {
|
||||
"srs-by-resource-email": { sourceName: SOURCE_DV_SE_TEAM, requiresMyTeam: true },
|
||||
"won-and-open-by-se-opty": { sourceName: SOURCE_DV_SE_TEAM, requiresMyTeam: true },
|
||||
};
|
||||
const SA_ATTACH_DEFAULT_SALES_CREDIT_TYPE = "QUOTA";
|
||||
const SA_ATTACH_DEFAULT_MIN_OPPORTUNITY_PROBABILITY = 0;
|
||||
const SA_ATTACH_PRODUCT_LOB_ALIASES = {
|
||||
"cloud applications": ["Cloud Applications"],
|
||||
"cloud infrastructure": ["Cloud Infrastructure"],
|
||||
hardware: ["Hardware"],
|
||||
license: ["License"],
|
||||
"license and hardware": ["License", "Hardware"],
|
||||
};
|
||||
const SA_ATTACH_REVENUE_TYPE_GROUP_ALIASES = {
|
||||
new: "NEW",
|
||||
expansion: "EXPANSION",
|
||||
workload: "WORKLOAD",
|
||||
workloads: "WORKLOAD",
|
||||
};
|
||||
const OPPORTUNITY_DETAILS_COLUMNS = [
|
||||
{ name: "opportunityId", expr: '"Opportunity"."Opportunity ID"', kind: "text" },
|
||||
{ name: "opportunityHours", expr: '"Effort"."# Opportunity Hours"', kind: "scalar" },
|
||||
@@ -367,7 +365,6 @@ const WORKLOAD_MANAGER_RESOURCE_CLOSE_WINDOW_COLUMNS = [
|
||||
"resourceDetails",
|
||||
];
|
||||
|
||||
const TEAM_EMAIL_COLUMN_NAMES = Array.from({ length: 8 }, (_, index) => `seTeamLevel${index + 1}EmailAddress`);
|
||||
const SQL_NULL_TEXT = `CAST(NULL AS VARCHAR(${VARCHAR_SIZE}))`;
|
||||
const SQL_NULL_SCALAR = "CAST(NULL AS DOUBLE)";
|
||||
|
||||
@@ -2126,51 +2123,7 @@ const INTENT_CATALOG = {
|
||||
"List commercial opportunities/workloads in a close-date window and enrich them with current-team presales/SR coverage for the executing user when present. Org-wide opportunity rows are preserved even when no current-team resource matches.",
|
||||
view: "opportunities-close-window",
|
||||
parameters: [
|
||||
{
|
||||
name: "fromDate",
|
||||
cli: "--from-date",
|
||||
required: false,
|
||||
default: "today",
|
||||
description: "Start of the close-date window. Accepts YYYY-MM-DD, today, +7d, -3d, or today+7d.",
|
||||
},
|
||||
{
|
||||
name: "toDate",
|
||||
cli: "--to-date",
|
||||
required: false,
|
||||
default: "+7d",
|
||||
description: "End of the close-date window. Accepts YYYY-MM-DD, today, +7d, -3d, or today+7d.",
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
cli: "--date",
|
||||
required: false,
|
||||
description: "Exact close date. Shortcut for a one-day window.",
|
||||
},
|
||||
{
|
||||
name: "month",
|
||||
cli: "--month",
|
||||
required: false,
|
||||
description: "Calendar month in YYYY-MM format. Shortcut for a full-month window.",
|
||||
},
|
||||
{
|
||||
name: "quarter",
|
||||
cli: "--quarter",
|
||||
required: false,
|
||||
description: "Fiscal quarter in FY26-Q4 or Q4 FY26 format. Shortcut for a full fiscal-quarter window.",
|
||||
},
|
||||
{
|
||||
name: "week",
|
||||
cli: "--week",
|
||||
required: false,
|
||||
description: "Annual week shortcut anchored on June 1. Accepts 1..53, w1..w53, or week 1..week 53.",
|
||||
},
|
||||
{
|
||||
name: "weekYear",
|
||||
cli: "--week-year",
|
||||
required: false,
|
||||
default: "current June-cycle start year",
|
||||
description: "Cycle start year used with --week. For example, 2025 means the 2025-06-01 through 2026-05-31 cycle.",
|
||||
},
|
||||
...closeWindowDateParameters(),
|
||||
{
|
||||
name: "optyStatus",
|
||||
cli: "--opty-status",
|
||||
@@ -2205,51 +2158,7 @@ const INTENT_CATALOG = {
|
||||
required: true,
|
||||
description: "Oracle user identifier. Accepts resource.member or resource.member@oracle.com.",
|
||||
},
|
||||
{
|
||||
name: "fromDate",
|
||||
cli: "--from-date",
|
||||
required: false,
|
||||
default: "today",
|
||||
description: "Start of the close-date window. Accepts YYYY-MM-DD, today, +7d, -3d, or today+7d.",
|
||||
},
|
||||
{
|
||||
name: "toDate",
|
||||
cli: "--to-date",
|
||||
required: false,
|
||||
default: "+7d",
|
||||
description: "End of the close-date window. Accepts YYYY-MM-DD, today, +7d, -3d, or today+7d.",
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
cli: "--date",
|
||||
required: false,
|
||||
description: "Exact close date. Shortcut for a one-day window.",
|
||||
},
|
||||
{
|
||||
name: "month",
|
||||
cli: "--month",
|
||||
required: false,
|
||||
description: "Calendar month in YYYY-MM format. Shortcut for a full-month window.",
|
||||
},
|
||||
{
|
||||
name: "quarter",
|
||||
cli: "--quarter",
|
||||
required: false,
|
||||
description: "Fiscal quarter in FY26-Q4 or Q4 FY26 format. Shortcut for a full fiscal-quarter window.",
|
||||
},
|
||||
{
|
||||
name: "week",
|
||||
cli: "--week",
|
||||
required: false,
|
||||
description: "Annual week shortcut anchored on June 1. Accepts 1..53, w1..w53, or week 1..week 53.",
|
||||
},
|
||||
{
|
||||
name: "weekYear",
|
||||
cli: "--week-year",
|
||||
required: false,
|
||||
default: "current June-cycle start year",
|
||||
description: "Cycle start year used with --week. For example, 2025 means the 2025-06-01 through 2026-05-31 cycle.",
|
||||
},
|
||||
...closeWindowDateParameters(),
|
||||
{
|
||||
name: "optyStatus",
|
||||
cli: "--opty-status",
|
||||
@@ -2283,51 +2192,7 @@ const INTENT_CATALOG = {
|
||||
required: true,
|
||||
description: "Manager email to match in the coverage hierarchy. Accepts resource.manager or resource.manager@oracle.com.",
|
||||
},
|
||||
{
|
||||
name: "fromDate",
|
||||
cli: "--from-date",
|
||||
required: false,
|
||||
default: "today",
|
||||
description: "Start of the close-date window. Accepts YYYY-MM-DD, today, +7d, -3d, or today+7d.",
|
||||
},
|
||||
{
|
||||
name: "toDate",
|
||||
cli: "--to-date",
|
||||
required: false,
|
||||
default: "+7d",
|
||||
description: "End of the close-date window. Accepts YYYY-MM-DD, today, +7d, -3d, or today+7d.",
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
cli: "--date",
|
||||
required: false,
|
||||
description: "Exact close date. Shortcut for a one-day window.",
|
||||
},
|
||||
{
|
||||
name: "month",
|
||||
cli: "--month",
|
||||
required: false,
|
||||
description: "Calendar month in YYYY-MM format. Shortcut for a full-month window.",
|
||||
},
|
||||
{
|
||||
name: "quarter",
|
||||
cli: "--quarter",
|
||||
required: false,
|
||||
description: "Fiscal quarter in FY26-Q4 or Q4 FY26 format. Shortcut for a full fiscal-quarter window.",
|
||||
},
|
||||
{
|
||||
name: "week",
|
||||
cli: "--week",
|
||||
required: false,
|
||||
description: "Annual week shortcut anchored on June 1. Accepts 1..53, w1..w53, or week 1..week 53.",
|
||||
},
|
||||
{
|
||||
name: "weekYear",
|
||||
cli: "--week-year",
|
||||
required: false,
|
||||
default: "current June-cycle start year",
|
||||
description: "Cycle start year used with --week. For example, 2025 means the 2025-06-01 through 2026-05-31 cycle.",
|
||||
},
|
||||
...closeWindowDateParameters(),
|
||||
{
|
||||
name: "optyStatus",
|
||||
cli: "--opty-status",
|
||||
@@ -3199,28 +3064,6 @@ custom SQL may use placeholders like {{field:closeDate}} and {{macro:securityEnt
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeSaAttachProductLobs(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values.flatMap((value) => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return SA_ATTACH_PRODUCT_LOB_ALIASES[normalized] || [String(value).trim()];
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSaAttachRevenueTypeGroups(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values.map((value) => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return SA_ATTACH_REVENUE_TYPE_GROUP_ALIASES[normalized] || String(value).trim().toUpperCase();
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function withConsoleLogRoutedToStderr(callback) {
|
||||
const originalLog = console.log;
|
||||
console.log = (...args) => console.error(...args);
|
||||
@@ -3307,14 +3150,6 @@ function hasFreshSessionContextCache(meta) {
|
||||
return Date.now() - capturedAtMs <= SESSION_CONTEXT_CACHE_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
function quoteLiteral(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function toSqlList(values) {
|
||||
return values.map(quoteLiteral).join(", ");
|
||||
}
|
||||
|
||||
function normalizeScopeInput(value = SCOPE_AUTO) {
|
||||
const normalized = String(value || SCOPE_AUTO).trim().toLowerCase();
|
||||
if (!normalized || normalized === SCOPE_AUTO) return SCOPE_AUTO;
|
||||
@@ -4292,176 +4127,6 @@ function buildQueryModeSql(args) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMatchRole(value) {
|
||||
const normalized = String(value || "team").trim().toLowerCase();
|
||||
if (["team", "member", "resource", "participant"].includes(normalized)) {
|
||||
return "team";
|
||||
}
|
||||
if (["lead", "leader", "owner"].includes(normalized)) {
|
||||
return "lead";
|
||||
}
|
||||
throw new Error(`Unsupported match role "${value}". Use team or lead.`);
|
||||
}
|
||||
|
||||
function normalizeOracleEmailInput(value, missingMessage) {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
const email = raw && raw.includes("@") ? raw : raw ? `${raw}@oracle.com` : "";
|
||||
if (!email) {
|
||||
throw new Error(missingMessage);
|
||||
}
|
||||
return {
|
||||
rawInput: raw,
|
||||
email,
|
||||
emailSql: quoteLiteral(email),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveResourceFilter(args) {
|
||||
const { rawInput, email, emailSql } = normalizeOracleEmailInput(
|
||||
args["resource-email"] || args.resource || "",
|
||||
"Pass --resource-email or --resource when using a resource-scoped query."
|
||||
);
|
||||
const matchRole = normalizeMatchRole(args["match-role"] || "team");
|
||||
return {
|
||||
resourceInput: rawInput,
|
||||
resourceEmail: email,
|
||||
resourceEmailSql: emailSql,
|
||||
matchRole,
|
||||
srStatuses: splitCsv(args["sr-status"], []),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManagerEmailFilter(args) {
|
||||
const { rawInput, email, emailSql } = normalizeOracleEmailInput(
|
||||
args["manager-email"] || "",
|
||||
"Pass --manager-email when using a manager-scoped opportunity query."
|
||||
);
|
||||
return {
|
||||
managerInput: rawInput,
|
||||
managerEmail: email,
|
||||
managerEmailSql: emailSql,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManagerEmailListFilter(args) {
|
||||
const tokens = [
|
||||
...splitCsv(args["manager-email-list"], []),
|
||||
...splitCsv(args["manager-email"], []),
|
||||
];
|
||||
const normalizedEmails = Array.from(
|
||||
new Set(
|
||||
tokens
|
||||
.map((token) =>
|
||||
normalizeOracleEmailInput(
|
||||
token,
|
||||
"Pass --manager-email-list or --manager-email when using a manager/resource workload query."
|
||||
).email
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
).sort();
|
||||
if (normalizedEmails.length === 0) {
|
||||
throw new Error("Pass --manager-email-list or --manager-email when using a manager/resource workload query.");
|
||||
}
|
||||
return {
|
||||
managerEmails: normalizedEmails,
|
||||
managerEmailsSqlList: toSqlList(normalizedEmails),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOwnerEmailFilter(args, flagName = "owner-email", missingMessage = "Pass --owner-email when using an owner-scoped opportunity query.") {
|
||||
const { rawInput, email, emailSql } = normalizeOracleEmailInput(
|
||||
args[flagName] || "",
|
||||
missingMessage
|
||||
);
|
||||
return {
|
||||
ownerInput: rawInput,
|
||||
ownerEmail: email,
|
||||
ownerEmailSql: emailSql,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCurrentWorkloadIntentDefaults(args) {
|
||||
const nextArgs = { ...args };
|
||||
if (!hasAnyDateWindowArgs(nextArgs)) {
|
||||
nextArgs.quarter = "current-quarter";
|
||||
}
|
||||
if (!nextArgs.solution) {
|
||||
nextArgs.solution = "Cloud Infrastructure - Workloads";
|
||||
}
|
||||
if (!nextArgs["revenue-type-group"]) {
|
||||
nextArgs["revenue-type-group"] = "WORKLOAD";
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
function applyCloseWindowIntentDefaults(args) {
|
||||
const nextArgs = { ...args };
|
||||
if (!hasAnyDateWindowArgs(nextArgs)) {
|
||||
nextArgs["from-date"] = "today";
|
||||
nextArgs["to-date"] = "+7d";
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
function resolveSaAttachFilters(args, definition) {
|
||||
const statuses = splitCsv(args["revenue-line-status"], definition.defaultStatuses || SA_ATTACH_DEFAULT_ALL_STATUSES);
|
||||
const cluster = String(args.cluster || SA_ATTACH_DEFAULT_CLUSTER).trim();
|
||||
const solutions = splitCsv(args.solution, SA_ATTACH_DEFAULT_SOLUTIONS);
|
||||
const productLobs = normalizeSaAttachProductLobs(
|
||||
splitCsv(args["executive-product-lob"], SA_ATTACH_DEFAULT_PRODUCT_LOBS)
|
||||
);
|
||||
const revenueTypeGroups = normalizeSaAttachRevenueTypeGroups(
|
||||
splitCsv(args["revenue-type-group"], SA_ATTACH_DEFAULT_REVENUE_TYPE_GROUPS)
|
||||
);
|
||||
const salesCreditType = String(args["sales-credit-type"] || SA_ATTACH_DEFAULT_SALES_CREDIT_TYPE).trim();
|
||||
const minOpportunityProbability = Number(
|
||||
args["min-opportunity-probability"] || SA_ATTACH_DEFAULT_MIN_OPPORTUNITY_PROBABILITY
|
||||
);
|
||||
const ownerEmail = args["owner-email"]
|
||||
? normalizeOracleEmailInput(args["owner-email"], "Pass --owner-email with a valid Oracle email.").email
|
||||
: null;
|
||||
const ownerManagerEmail = args["owner-manager-email"]
|
||||
? normalizeOracleEmailInput(args["owner-manager-email"], "Pass --owner-manager-email with a valid Oracle email.").email
|
||||
: null;
|
||||
const forecastTypes = splitCsv(args["forecast-type"], []);
|
||||
|
||||
if (!cluster) {
|
||||
throw new Error("Cluster cannot be empty for SA Attach views.");
|
||||
}
|
||||
if (solutions.length === 0) {
|
||||
throw new Error("Pass at least one --solution value for SA Attach views.");
|
||||
}
|
||||
if (statuses.length === 0) {
|
||||
throw new Error("Pass at least one --revenue-line-status value for SA Attach views.");
|
||||
}
|
||||
if (productLobs.length === 0) {
|
||||
throw new Error("Pass at least one --executive-product-lob value for SA Attach views.");
|
||||
}
|
||||
if (revenueTypeGroups.length === 0) {
|
||||
throw new Error("Pass at least one --revenue-type-group value for SA Attach views.");
|
||||
}
|
||||
if (!salesCreditType) {
|
||||
throw new Error("Sales credit type cannot be empty for SA Attach views.");
|
||||
}
|
||||
if (!Number.isFinite(minOpportunityProbability)) {
|
||||
throw new Error("Pass a numeric --min-opportunity-probability value for SA Attach views.");
|
||||
}
|
||||
|
||||
return {
|
||||
cluster,
|
||||
solutions,
|
||||
statuses,
|
||||
productLobs,
|
||||
revenueTypeGroups,
|
||||
salesCreditType,
|
||||
minOpportunityProbability,
|
||||
ownerEmail,
|
||||
ownerManagerEmail,
|
||||
forecastTypes,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSrResourcePredicate(resourceEmailSql, matchRole) {
|
||||
if (matchRole === "lead") {
|
||||
return `"Service Request"."Lead Email" = ${resourceEmailSql}`;
|
||||
@@ -4604,19 +4269,6 @@ function buildManagerResourceCoverageSqlForOpportunityIds(opportunityIds, manage
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOptionalClusterFilter(args) {
|
||||
const cluster = typeof args.cluster === "string" ? args.cluster.trim() : "";
|
||||
return cluster || null;
|
||||
}
|
||||
|
||||
function resolveCommercialClusterFilter(args, fallback = SA_ATTACH_DEFAULT_CLUSTER) {
|
||||
return resolveOptionalClusterFilter(args) || fallback;
|
||||
}
|
||||
|
||||
function resolveCoverageLimit(limit) {
|
||||
return Math.min(10000, Math.max(5000, Number(limit) * 8));
|
||||
}
|
||||
|
||||
function buildWorkloadCloseWindowSql({
|
||||
fromDateSql,
|
||||
toDateSql,
|
||||
@@ -5325,286 +4977,6 @@ function buildIntentScopeDescriptor(name, definition) {
|
||||
return buildViewScopeDescriptor(definition.view, VIEW_DEFS[definition.view] || {});
|
||||
}
|
||||
|
||||
function hasUsableOpportunityId(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return Boolean(normalized && normalized.toLowerCase() !== "unspecified");
|
||||
}
|
||||
|
||||
function buildCustomerMatchKey(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeEmailAddress(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toSortedUniqueStrings(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(values || [])
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function sortCandidateInternalSrDetails(details) {
|
||||
return Array.from(
|
||||
new Map(
|
||||
(details || [])
|
||||
.filter((detail) => detail?.srNumber)
|
||||
.map((detail) => [String(detail.srNumber || "").trim(), detail])
|
||||
).values()
|
||||
).sort((left, right) => String(left.srNumber || "").localeCompare(String(right.srNumber || "")));
|
||||
}
|
||||
|
||||
function buildSrLinkageMode(directSrNumbers, candidateInternalSrDetails) {
|
||||
const directCount = toSortedUniqueStrings(directSrNumbers).length;
|
||||
const candidateCount = sortCandidateInternalSrDetails(candidateInternalSrDetails).length;
|
||||
if (directCount > 0 && candidateCount > 0) return "direct-plus-candidate-internal";
|
||||
if (directCount > 0) return "direct-only";
|
||||
if (candidateCount > 0) return "candidate-internal-only";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function buildResolvedSrNumbers(directSrNumbers, candidateInternalSrDetails) {
|
||||
return toSortedUniqueStrings([
|
||||
...toSortedUniqueStrings(directSrNumbers),
|
||||
...sortCandidateInternalSrDetails(candidateInternalSrDetails).map((detail) => detail.srNumber),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildInternalSrAwareResourceDetails({
|
||||
opportunityId,
|
||||
customerName,
|
||||
resourceEmails,
|
||||
srNumbersByResourceOpportunity = new Map(),
|
||||
candidateInternalSrDetailsByResource = new Map(),
|
||||
}) {
|
||||
const customerMatchKey = buildCustomerMatchKey(customerName);
|
||||
const normalizedResourceEmails = toSortedUniqueStrings(
|
||||
(resourceEmails || []).map((email) => normalizeEmailAddress(email))
|
||||
);
|
||||
return normalizedResourceEmails.map((resourceEmail) => {
|
||||
const directSrNumbers = toSortedUniqueStrings(
|
||||
Array.from(srNumbersByResourceOpportunity.get(buildSrCoverageKey(opportunityId, resourceEmail)) || [])
|
||||
);
|
||||
const candidateInternalSrDetails = sortCandidateInternalSrDetails(
|
||||
Array.from(candidateInternalSrDetailsByResource.get(resourceEmail)?.values() || []).filter(
|
||||
(candidate) => buildCustomerMatchKey(candidate.customerName) === customerMatchKey
|
||||
)
|
||||
);
|
||||
return {
|
||||
resourceEmail,
|
||||
srNumbers: buildResolvedSrNumbers(directSrNumbers, candidateInternalSrDetails),
|
||||
directSrNumbers,
|
||||
candidateInternalSrNumbers: candidateInternalSrDetails.map((candidate) => candidate.srNumber),
|
||||
candidateInternalSrDetails,
|
||||
srLinkageMode: buildSrLinkageMode(directSrNumbers, candidateInternalSrDetails),
|
||||
companionEmails: normalizedResourceEmails.filter((email) => email !== resourceEmail),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeCurrentTeamOpportunityCoverage(opportunities, coverageRows) {
|
||||
const opportunityMap = new Map(
|
||||
opportunities
|
||||
.filter((record) => record?.opportunityId)
|
||||
.map((record) => [
|
||||
record.opportunityId,
|
||||
{
|
||||
...record,
|
||||
bookingValue: parseNumericValue(record.bookingValue),
|
||||
workloadAmount: parseNumericValue(record.workloadAmount),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const coverageByOpportunity = new Map();
|
||||
for (const row of coverageRows) {
|
||||
const opportunityId = row?.opportunityId;
|
||||
if (!opportunityId || !opportunityMap.has(opportunityId)) continue;
|
||||
const entry = coverageByOpportunity.get(opportunityId) || {
|
||||
opportunityId,
|
||||
teamMemberEmails: new Set(),
|
||||
currentTeamOpportunityHours: 0,
|
||||
coverageRows: 0,
|
||||
};
|
||||
for (const fieldName of TEAM_EMAIL_COLUMN_NAMES) {
|
||||
const email = row?.[fieldName];
|
||||
if (email) entry.teamMemberEmails.add(email);
|
||||
}
|
||||
entry.currentTeamOpportunityHours += parseNumericValue(
|
||||
row?.opportunityHoursAttribute ?? row?.opportunityHours
|
||||
);
|
||||
entry.coverageRows += 1;
|
||||
coverageByOpportunity.set(opportunityId, entry);
|
||||
}
|
||||
|
||||
const totalWindowOpportunities = opportunityMap.size;
|
||||
const records = Array.from(opportunityMap.values())
|
||||
.map((opportunity) => {
|
||||
const coverage = coverageByOpportunity.get(opportunity.opportunityId) || {
|
||||
opportunityId: opportunity.opportunityId,
|
||||
teamMemberEmails: new Set(),
|
||||
currentTeamOpportunityHours: 0,
|
||||
coverageRows: 0,
|
||||
};
|
||||
return {
|
||||
opportunityId: opportunity.opportunityId,
|
||||
opportunityName: opportunity.opportunityName,
|
||||
customerName: opportunity.customerName,
|
||||
opportunityStatus: opportunity.opportunityStatus,
|
||||
closeDate: opportunity.closeDate,
|
||||
salesStage: opportunity.salesStage,
|
||||
bookingValue: opportunity.bookingValue,
|
||||
workloadAmount: opportunity.workloadAmount,
|
||||
teamMemberEmails: Array.from(coverage.teamMemberEmails).sort(),
|
||||
currentTeamOpportunityHours: coverage.currentTeamOpportunityHours,
|
||||
coverageRows: coverage.coverageRows,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const closeDateCompare = String(left.closeDate || "").localeCompare(String(right.closeDate || ""));
|
||||
if (closeDateCompare !== 0) return closeDateCompare;
|
||||
return String(left.opportunityId || "").localeCompare(String(right.opportunityId || ""));
|
||||
});
|
||||
|
||||
const summary = records.reduce(
|
||||
(totals, record) => {
|
||||
const matched = parseNumericValue(record.coverageRows) > 0;
|
||||
return {
|
||||
totalWindowOpportunities,
|
||||
matchedOpportunities: totals.matchedOpportunities + (matched ? 1 : 0),
|
||||
unmatchedOpportunities: totals.unmatchedOpportunities + (matched ? 0 : 1),
|
||||
totalBookingValue: totals.totalBookingValue + parseNumericValue(record.bookingValue),
|
||||
totalWorkloadAmount: totals.totalWorkloadAmount + parseNumericValue(record.workloadAmount),
|
||||
matchedBookingValue:
|
||||
totals.matchedBookingValue + (matched ? parseNumericValue(record.bookingValue) : 0),
|
||||
matchedWorkloadAmount:
|
||||
totals.matchedWorkloadAmount + (matched ? parseNumericValue(record.workloadAmount) : 0),
|
||||
totalCurrentTeamOpportunityHours:
|
||||
totals.totalCurrentTeamOpportunityHours + parseNumericValue(record.currentTeamOpportunityHours),
|
||||
};
|
||||
},
|
||||
{
|
||||
totalWindowOpportunities,
|
||||
matchedOpportunities: 0,
|
||||
unmatchedOpportunities: 0,
|
||||
totalBookingValue: 0,
|
||||
totalWorkloadAmount: 0,
|
||||
matchedBookingValue: 0,
|
||||
matchedWorkloadAmount: 0,
|
||||
totalCurrentTeamOpportunityHours: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return { records, summary };
|
||||
}
|
||||
|
||||
function summarizeOrgWideRevenueLineCoverage(
|
||||
workloadRows,
|
||||
coverageRows,
|
||||
srNumbersByResourceOpportunity = new Map(),
|
||||
candidateInternalSrDetailsByResource = new Map()
|
||||
) {
|
||||
const includeSrDetails =
|
||||
srNumbersByResourceOpportunity.size > 0 || candidateInternalSrDetailsByResource.size > 0;
|
||||
const workloadRecords = workloadRows
|
||||
.filter((record) => record?.revenueLineId && record?.opportunityId)
|
||||
.map((record) => ({
|
||||
closeDate: record.closeDate,
|
||||
revenueLineId: record.revenueLineId,
|
||||
opportunityId: record.opportunityId,
|
||||
opportunityName: record.opportunityName,
|
||||
customerName: record.customerName,
|
||||
workloadAmount: parseNumericValue(record.workloadAmount),
|
||||
}));
|
||||
|
||||
const matchedOpportunities = new Map();
|
||||
for (const row of coverageRows) {
|
||||
const opportunityId = row?.opportunityId;
|
||||
const managerEmail = row?.seTeamLevel4EmailAddress;
|
||||
const resourceEmail = row?.seTeamLevel5EmailAddress;
|
||||
if (!opportunityId || !managerEmail || !resourceEmail) continue;
|
||||
const entry = matchedOpportunities.get(opportunityId) || {
|
||||
managerEmails: new Set(),
|
||||
resourceEmails: new Set(),
|
||||
};
|
||||
entry.managerEmails.add(normalizeEmailAddress(managerEmail));
|
||||
entry.resourceEmails.add(normalizeEmailAddress(resourceEmail));
|
||||
matchedOpportunities.set(opportunityId, entry);
|
||||
}
|
||||
|
||||
const matchedRecords = workloadRecords
|
||||
.filter((record) => matchedOpportunities.has(record.opportunityId))
|
||||
.map((record) => {
|
||||
const coverage = matchedOpportunities.get(record.opportunityId);
|
||||
const resourceEmails = toSortedUniqueStrings(Array.from(coverage.resourceEmails));
|
||||
const resourceDetails = includeSrDetails
|
||||
? buildInternalSrAwareResourceDetails({
|
||||
opportunityId: record.opportunityId,
|
||||
customerName: record.customerName,
|
||||
resourceEmails,
|
||||
srNumbersByResourceOpportunity,
|
||||
candidateInternalSrDetailsByResource,
|
||||
})
|
||||
: resourceEmails.map((resourceEmail) => ({
|
||||
resourceEmail,
|
||||
companionEmails: resourceEmails.filter((email) => email !== resourceEmail),
|
||||
}));
|
||||
return {
|
||||
...record,
|
||||
managerEmails: toSortedUniqueStrings(Array.from(coverage.managerEmails)),
|
||||
resourceEmails,
|
||||
resourceDetails,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const closeDateCompare = String(left.closeDate || "").localeCompare(String(right.closeDate || ""));
|
||||
if (closeDateCompare !== 0) return closeDateCompare;
|
||||
const opportunityCompare = String(left.opportunityId || "").localeCompare(String(right.opportunityId || ""));
|
||||
if (opportunityCompare !== 0) return opportunityCompare;
|
||||
return String(left.revenueLineId || "").localeCompare(String(right.revenueLineId || ""));
|
||||
});
|
||||
|
||||
const distinctMatchedOpportunities = new Set(matchedRecords.map((record) => record.opportunityId));
|
||||
const distinctMatchedRevenueLines = new Set(matchedRecords.map((record) => record.revenueLineId));
|
||||
const summary = matchedRecords.reduce(
|
||||
(totals, record) => ({
|
||||
totalWindowRevenueLines: totals.totalWindowRevenueLines,
|
||||
matchedRevenueLines: totals.matchedRevenueLines + 1,
|
||||
matchedOpportunities: totals.matchedOpportunities,
|
||||
totalWorkloadAmount: totals.totalWorkloadAmount + parseNumericValue(record.workloadAmount),
|
||||
managersRequested: totals.managersRequested,
|
||||
resourcesMatched: totals.resourcesMatched,
|
||||
}),
|
||||
{
|
||||
totalWindowRevenueLines: workloadRecords.length,
|
||||
matchedRevenueLines: 0,
|
||||
matchedOpportunities: distinctMatchedOpportunities.size,
|
||||
totalWorkloadAmount: 0,
|
||||
managersRequested: 0,
|
||||
resourcesMatched: Array.from(
|
||||
new Set(
|
||||
matchedRecords.flatMap((record) => record.resourceEmails || [])
|
||||
)
|
||||
).length,
|
||||
}
|
||||
);
|
||||
summary.matchedRevenueLines = distinctMatchedRevenueLines.size;
|
||||
return { records: matchedRecords, summary };
|
||||
}
|
||||
|
||||
function buildSrCoverageKey(opportunityId, resourceEmail) {
|
||||
return `${String(opportunityId || "").trim()}::${String(resourceEmail || "").trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
async function fetchResourceOpportunitySrCoverage({
|
||||
resourceEmails,
|
||||
opportunityIds,
|
||||
|
||||
266
scripts/lib/coverage-summary.mjs
Normal file
266
scripts/lib/coverage-summary.mjs
Normal file
@@ -0,0 +1,266 @@
|
||||
import { parseNumericValue } from "./output-contract.mjs";
|
||||
|
||||
const TEAM_EMAIL_COLUMN_NAMES = Array.from({ length: 8 }, (_, index) => `seTeamLevel${index + 1}EmailAddress`);
|
||||
|
||||
// Objetivo: validar se um opportunityId pode ser usado em joins. Entrada: valor bruto. Saida: booleano.
|
||||
export function hasUsableOpportunityId(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return Boolean(normalized && normalized.toLowerCase() !== "unspecified");
|
||||
}
|
||||
|
||||
// Objetivo: normalizar nome de cliente para comparacao local. Entrada: nome bruto. Saida: chave sem acentos e em minusculas.
|
||||
function buildCustomerMatchKey(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Objetivo: normalizar email para comparacao. Entrada: valor bruto. Saida: email em minusculas.
|
||||
function normalizeEmailAddress(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Objetivo: remover vazios/duplicados e ordenar textos. Entrada: lista de valores. Saida: strings unicas ordenadas.
|
||||
function toSortedUniqueStrings(values) {
|
||||
return Array.from(
|
||||
new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
// Objetivo: deduplicar SRs internos candidatos por numero. Entrada: detalhes de SR. Saida: detalhes ordenados por SR.
|
||||
function sortCandidateInternalSrDetails(details) {
|
||||
return Array.from(
|
||||
new Map(
|
||||
(details || [])
|
||||
.filter((detail) => detail?.srNumber)
|
||||
.map((detail) => [String(detail.srNumber || "").trim(), detail])
|
||||
).values()
|
||||
).sort((left, right) => String(left.srNumber || "").localeCompare(String(right.srNumber || "")));
|
||||
}
|
||||
|
||||
// Objetivo: classificar como SRs foram ligados ao recurso. Entrada: SRs diretos e candidatos internos. Saida: modo textual.
|
||||
function buildSrLinkageMode(directSrNumbers, candidateInternalSrDetails) {
|
||||
const directCount = toSortedUniqueStrings(directSrNumbers).length;
|
||||
const candidateCount = sortCandidateInternalSrDetails(candidateInternalSrDetails).length;
|
||||
if (directCount > 0 && candidateCount > 0) return "direct-plus-candidate-internal";
|
||||
if (directCount > 0) return "direct-only";
|
||||
if (candidateCount > 0) return "candidate-internal-only";
|
||||
return "none";
|
||||
}
|
||||
|
||||
// Objetivo: unir SRs diretos e candidatos. Entrada: listas de SR. Saida: SRs unicos ordenados.
|
||||
function buildResolvedSrNumbers(directSrNumbers, candidateInternalSrDetails) {
|
||||
return toSortedUniqueStrings([
|
||||
...toSortedUniqueStrings(directSrNumbers),
|
||||
...sortCandidateInternalSrDetails(candidateInternalSrDetails).map((detail) => detail.srNumber),
|
||||
]);
|
||||
}
|
||||
|
||||
// Objetivo: construir chave de coverage por oportunidade e recurso. Entrada: opportunityId e email. Saida: chave normalizada.
|
||||
export function buildSrCoverageKey(opportunityId, resourceEmail) {
|
||||
return `${String(opportunityId || "").trim()}::${normalizeEmailAddress(resourceEmail)}`;
|
||||
}
|
||||
|
||||
// Objetivo: montar detalhes de recursos com SRs diretos e candidatos internos. Entrada: oportunidade, recursos e mapas de SR. Saida: detalhes por recurso.
|
||||
function buildInternalSrAwareResourceDetails({
|
||||
opportunityId,
|
||||
customerName,
|
||||
resourceEmails,
|
||||
srNumbersByResourceOpportunity = new Map(),
|
||||
candidateInternalSrDetailsByResource = new Map(),
|
||||
}) {
|
||||
const customerMatchKey = buildCustomerMatchKey(customerName);
|
||||
const normalizedResourceEmails = toSortedUniqueStrings((resourceEmails || []).map(normalizeEmailAddress));
|
||||
return normalizedResourceEmails.map((resourceEmail) => {
|
||||
const directSrNumbers = toSortedUniqueStrings(
|
||||
Array.from(srNumbersByResourceOpportunity.get(buildSrCoverageKey(opportunityId, resourceEmail)) || [])
|
||||
);
|
||||
const candidateInternalSrDetails = sortCandidateInternalSrDetails(
|
||||
Array.from(candidateInternalSrDetailsByResource.get(resourceEmail)?.values() || []).filter(
|
||||
(candidate) => buildCustomerMatchKey(candidate.customerName) === customerMatchKey
|
||||
)
|
||||
);
|
||||
return {
|
||||
resourceEmail,
|
||||
srNumbers: buildResolvedSrNumbers(directSrNumbers, candidateInternalSrDetails),
|
||||
directSrNumbers,
|
||||
candidateInternalSrNumbers: candidateInternalSrDetails.map((candidate) => candidate.srNumber),
|
||||
candidateInternalSrDetails,
|
||||
srLinkageMode: buildSrLinkageMode(directSrNumbers, candidateInternalSrDetails),
|
||||
companionEmails: normalizedResourceEmails.filter((email) => email !== resourceEmail),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Objetivo: sumarizar oportunidades comerciais e coverage do time atual preservando nao casadas. Entrada: oportunidades e linhas coverage. Saida: records e summary.
|
||||
export function summarizeCurrentTeamOpportunityCoverage(opportunities, coverageRows) {
|
||||
const opportunityMap = new Map(
|
||||
opportunities
|
||||
.filter((record) => record?.opportunityId)
|
||||
.map((record) => [
|
||||
record.opportunityId,
|
||||
{
|
||||
...record,
|
||||
bookingValue: parseNumericValue(record.bookingValue),
|
||||
workloadAmount: parseNumericValue(record.workloadAmount),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const coverageByOpportunity = new Map();
|
||||
for (const row of coverageRows) {
|
||||
const opportunityId = row?.opportunityId;
|
||||
if (!opportunityId || !opportunityMap.has(opportunityId)) continue;
|
||||
const entry = coverageByOpportunity.get(opportunityId) || {
|
||||
opportunityId,
|
||||
teamMemberEmails: new Set(),
|
||||
currentTeamOpportunityHours: 0,
|
||||
coverageRows: 0,
|
||||
};
|
||||
for (const fieldName of TEAM_EMAIL_COLUMN_NAMES) {
|
||||
const email = row?.[fieldName];
|
||||
if (email) entry.teamMemberEmails.add(email);
|
||||
}
|
||||
entry.currentTeamOpportunityHours += parseNumericValue(row?.opportunityHoursAttribute ?? row?.opportunityHours);
|
||||
entry.coverageRows += 1;
|
||||
coverageByOpportunity.set(opportunityId, entry);
|
||||
}
|
||||
|
||||
const totalWindowOpportunities = opportunityMap.size;
|
||||
const records = Array.from(opportunityMap.values())
|
||||
.map((opportunity) => {
|
||||
const coverage = coverageByOpportunity.get(opportunity.opportunityId) || {
|
||||
opportunityId: opportunity.opportunityId,
|
||||
teamMemberEmails: new Set(),
|
||||
currentTeamOpportunityHours: 0,
|
||||
coverageRows: 0,
|
||||
};
|
||||
return {
|
||||
opportunityId: opportunity.opportunityId,
|
||||
opportunityName: opportunity.opportunityName,
|
||||
customerName: opportunity.customerName,
|
||||
opportunityStatus: opportunity.opportunityStatus,
|
||||
closeDate: opportunity.closeDate,
|
||||
salesStage: opportunity.salesStage,
|
||||
bookingValue: opportunity.bookingValue,
|
||||
workloadAmount: opportunity.workloadAmount,
|
||||
teamMemberEmails: Array.from(coverage.teamMemberEmails).sort(),
|
||||
currentTeamOpportunityHours: coverage.currentTeamOpportunityHours,
|
||||
coverageRows: coverage.coverageRows,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const closeDateCompare = String(left.closeDate || "").localeCompare(String(right.closeDate || ""));
|
||||
return closeDateCompare || String(left.opportunityId || "").localeCompare(String(right.opportunityId || ""));
|
||||
});
|
||||
|
||||
const summary = records.reduce(
|
||||
(totals, record) => {
|
||||
const matched = parseNumericValue(record.coverageRows) > 0;
|
||||
return {
|
||||
totalWindowOpportunities,
|
||||
matchedOpportunities: totals.matchedOpportunities + (matched ? 1 : 0),
|
||||
unmatchedOpportunities: totals.unmatchedOpportunities + (matched ? 0 : 1),
|
||||
totalBookingValue: totals.totalBookingValue + parseNumericValue(record.bookingValue),
|
||||
totalWorkloadAmount: totals.totalWorkloadAmount + parseNumericValue(record.workloadAmount),
|
||||
matchedBookingValue: totals.matchedBookingValue + (matched ? parseNumericValue(record.bookingValue) : 0),
|
||||
matchedWorkloadAmount: totals.matchedWorkloadAmount + (matched ? parseNumericValue(record.workloadAmount) : 0),
|
||||
totalCurrentTeamOpportunityHours:
|
||||
totals.totalCurrentTeamOpportunityHours + parseNumericValue(record.currentTeamOpportunityHours),
|
||||
};
|
||||
},
|
||||
{
|
||||
totalWindowOpportunities,
|
||||
matchedOpportunities: 0,
|
||||
unmatchedOpportunities: 0,
|
||||
totalBookingValue: 0,
|
||||
totalWorkloadAmount: 0,
|
||||
matchedBookingValue: 0,
|
||||
matchedWorkloadAmount: 0,
|
||||
totalCurrentTeamOpportunityHours: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return { records, summary };
|
||||
}
|
||||
|
||||
// Objetivo: sumarizar workloads org-wide cruzados com coverage de recursos. Entrada: workloads, coverage e SRs opcionais. Saida: records e summary.
|
||||
export function summarizeOrgWideRevenueLineCoverage(
|
||||
workloadRows,
|
||||
coverageRows,
|
||||
srNumbersByResourceOpportunity = new Map(),
|
||||
candidateInternalSrDetailsByResource = new Map()
|
||||
) {
|
||||
const includeSrDetails = srNumbersByResourceOpportunity.size > 0 || candidateInternalSrDetailsByResource.size > 0;
|
||||
const workloadRecords = workloadRows
|
||||
.filter((record) => record?.revenueLineId && record?.opportunityId)
|
||||
.map((record) => ({
|
||||
closeDate: record.closeDate,
|
||||
revenueLineId: record.revenueLineId,
|
||||
opportunityId: record.opportunityId,
|
||||
opportunityName: record.opportunityName,
|
||||
customerName: record.customerName,
|
||||
workloadAmount: parseNumericValue(record.workloadAmount),
|
||||
}));
|
||||
|
||||
const matchedOpportunities = new Map();
|
||||
for (const row of coverageRows) {
|
||||
const opportunityId = row?.opportunityId;
|
||||
const managerEmail = row?.seTeamLevel4EmailAddress;
|
||||
const resourceEmail = row?.seTeamLevel5EmailAddress;
|
||||
if (!opportunityId || !managerEmail || !resourceEmail) continue;
|
||||
const entry = matchedOpportunities.get(opportunityId) || { managerEmails: new Set(), resourceEmails: new Set() };
|
||||
entry.managerEmails.add(normalizeEmailAddress(managerEmail));
|
||||
entry.resourceEmails.add(normalizeEmailAddress(resourceEmail));
|
||||
matchedOpportunities.set(opportunityId, entry);
|
||||
}
|
||||
|
||||
const matchedRecords = workloadRecords
|
||||
.filter((record) => matchedOpportunities.has(record.opportunityId))
|
||||
.map((record) => {
|
||||
const coverage = matchedOpportunities.get(record.opportunityId);
|
||||
const resourceEmails = toSortedUniqueStrings(Array.from(coverage.resourceEmails));
|
||||
const resourceDetails = includeSrDetails
|
||||
? buildInternalSrAwareResourceDetails({
|
||||
opportunityId: record.opportunityId,
|
||||
customerName: record.customerName,
|
||||
resourceEmails,
|
||||
srNumbersByResourceOpportunity,
|
||||
candidateInternalSrDetailsByResource,
|
||||
})
|
||||
: resourceEmails.map((resourceEmail) => ({
|
||||
resourceEmail,
|
||||
companionEmails: resourceEmails.filter((email) => email !== resourceEmail),
|
||||
}));
|
||||
return {
|
||||
...record,
|
||||
managerEmails: toSortedUniqueStrings(Array.from(coverage.managerEmails)),
|
||||
resourceEmails,
|
||||
resourceDetails,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const closeDateCompare = String(left.closeDate || "").localeCompare(String(right.closeDate || ""));
|
||||
if (closeDateCompare !== 0) return closeDateCompare;
|
||||
const opportunityCompare = String(left.opportunityId || "").localeCompare(String(right.opportunityId || ""));
|
||||
return opportunityCompare || String(left.revenueLineId || "").localeCompare(String(right.revenueLineId || ""));
|
||||
});
|
||||
|
||||
const distinctMatchedOpportunities = new Set(matchedRecords.map((record) => record.opportunityId));
|
||||
const distinctMatchedRevenueLines = new Set(matchedRecords.map((record) => record.revenueLineId));
|
||||
const summary = {
|
||||
totalWindowRevenueLines: workloadRecords.length,
|
||||
matchedRevenueLines: distinctMatchedRevenueLines.size,
|
||||
matchedOpportunities: distinctMatchedOpportunities.size,
|
||||
totalWorkloadAmount: matchedRecords.reduce(
|
||||
(total, record) => total + parseNumericValue(record.workloadAmount),
|
||||
0
|
||||
),
|
||||
managersRequested: 0,
|
||||
resourcesMatched: new Set(matchedRecords.flatMap((record) => record.resourceEmails || [])).size,
|
||||
};
|
||||
return { records: matchedRecords, summary };
|
||||
}
|
||||
211
scripts/lib/sa-attach-filters.mjs
Normal file
211
scripts/lib/sa-attach-filters.mjs
Normal file
@@ -0,0 +1,211 @@
|
||||
import { splitCsv } from "./cli-shared.mjs";
|
||||
import { hasAnyDateWindowArgs } from "./date-windows.mjs";
|
||||
import { quoteLiteral, toSqlList } from "./sql-literals.mjs";
|
||||
|
||||
export const SA_ATTACH_DEFAULT_CLUSTER = "Brazil";
|
||||
export const SA_ATTACH_DEFAULT_SOLUTIONS = [
|
||||
"Cloud Infrastructure - Workloads",
|
||||
"Hardware - Bookings",
|
||||
"On-Premise Technology - Bookings",
|
||||
];
|
||||
export const SA_ATTACH_DEFAULT_PRODUCT_LOBS = ["Cloud Infrastructure", "Hardware", "License"];
|
||||
export const SA_ATTACH_DEFAULT_REVENUE_TYPE_GROUPS = ["NEW", "EXPANSION", "WORKLOAD"];
|
||||
export const SA_ATTACH_DEFAULT_OPEN_STATUSES = ["Open"];
|
||||
export const SA_ATTACH_DEFAULT_WON_STATUSES = ["Won", "Won Pending"];
|
||||
export const SA_ATTACH_DEFAULT_ALL_STATUSES = [
|
||||
...SA_ATTACH_DEFAULT_OPEN_STATUSES,
|
||||
...SA_ATTACH_DEFAULT_WON_STATUSES,
|
||||
];
|
||||
export const SA_ATTACH_DEFAULT_SALES_CREDIT_TYPE = "QUOTA";
|
||||
export const SA_ATTACH_DEFAULT_MIN_OPPORTUNITY_PROBABILITY = 0;
|
||||
|
||||
const SA_ATTACH_PRODUCT_LOB_ALIASES = {
|
||||
"cloud applications": ["Cloud Applications"],
|
||||
"cloud infrastructure": ["Cloud Infrastructure"],
|
||||
hardware: ["Hardware"],
|
||||
license: ["License"],
|
||||
"license and hardware": ["License", "Hardware"],
|
||||
};
|
||||
|
||||
const SA_ATTACH_REVENUE_TYPE_GROUP_ALIASES = {
|
||||
new: "NEW",
|
||||
expansion: "EXPANSION",
|
||||
workload: "WORKLOAD",
|
||||
workloads: "WORKLOAD",
|
||||
};
|
||||
|
||||
// Objetivo: expandir aliases de product LOB mantendo unicidade. Entrada: valores CLI/catalogo. Saida: lista normalizada.
|
||||
export function normalizeSaAttachProductLobs(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values.flatMap((value) => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return SA_ATTACH_PRODUCT_LOB_ALIASES[normalized] || [String(value).trim()];
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Objetivo: expandir aliases de revenue type group. Entrada: valores CLI/catalogo. Saida: lista normalizada em maiusculas.
|
||||
export function normalizeSaAttachRevenueTypeGroups(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values.map((value) => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return SA_ATTACH_REVENUE_TYPE_GROUP_ALIASES[normalized] || String(value).trim().toUpperCase();
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Objetivo: converter variantes humanas do papel SR em modo suportado. Entrada: texto opcional. Saida: team ou lead.
|
||||
export function normalizeMatchRole(value) {
|
||||
const normalized = String(value || "team").trim().toLowerCase();
|
||||
if (["team", "member", "resource", "participant"].includes(normalized)) return "team";
|
||||
if (["lead", "leader", "owner"].includes(normalized)) return "lead";
|
||||
throw new Error(`Unsupported match role "${value}". Use team or lead.`);
|
||||
}
|
||||
|
||||
// Objetivo: normalizar uid curto ou email Oracle completo. Entrada: valor e mensagem de erro. Saida: input, email e literal SQL.
|
||||
export function normalizeOracleEmailInput(value, missingMessage) {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
const email = raw && raw.includes("@") ? raw : raw ? `${raw}@oracle.com` : "";
|
||||
if (!email) throw new Error(missingMessage);
|
||||
return { rawInput: raw, email, emailSql: quoteLiteral(email) };
|
||||
}
|
||||
|
||||
// Objetivo: resolver filtro de recurso para SR/coverage. Entrada: args CLI. Saida: email, literal, papel e status SR.
|
||||
export function resolveResourceFilter(args) {
|
||||
const { rawInput, email, emailSql } = normalizeOracleEmailInput(
|
||||
args["resource-email"] || args.resource || "",
|
||||
"Pass --resource-email or --resource when using a resource-scoped query."
|
||||
);
|
||||
return {
|
||||
resourceInput: rawInput,
|
||||
resourceEmail: email,
|
||||
resourceEmailSql: emailSql,
|
||||
matchRole: normalizeMatchRole(args["match-role"] || "team"),
|
||||
srStatuses: splitCsv(args["sr-status"], []),
|
||||
};
|
||||
}
|
||||
|
||||
// Objetivo: resolver filtro de um manager de recurso. Entrada: args CLI. Saida: input, email e literal SQL.
|
||||
export function resolveManagerEmailFilter(args) {
|
||||
const { rawInput, email, emailSql } = normalizeOracleEmailInput(
|
||||
args["manager-email"] || "",
|
||||
"Pass --manager-email when using a manager-scoped opportunity query."
|
||||
);
|
||||
return { managerInput: rawInput, managerEmail: email, managerEmailSql: emailSql };
|
||||
}
|
||||
|
||||
// Objetivo: mesclar lista e flag avulsa de managers. Entrada: args CLI. Saida: emails unicos e lista SQL.
|
||||
export function resolveManagerEmailListFilter(args) {
|
||||
const normalizedEmails = Array.from(
|
||||
new Set(
|
||||
[...splitCsv(args["manager-email-list"], []), ...splitCsv(args["manager-email"], [])]
|
||||
.map((token) =>
|
||||
normalizeOracleEmailInput(
|
||||
token,
|
||||
"Pass --manager-email-list or --manager-email when using a manager/resource workload query."
|
||||
).email
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
).sort();
|
||||
if (normalizedEmails.length === 0) {
|
||||
throw new Error("Pass --manager-email-list or --manager-email when using a manager/resource workload query.");
|
||||
}
|
||||
return { managerEmails: normalizedEmails, managerEmailsSqlList: toSqlList(normalizedEmails) };
|
||||
}
|
||||
|
||||
// Objetivo: resolver filtro de owner/owner-manager comercial. Entrada: args CLI e nome da flag. Saida: input, email e literal SQL.
|
||||
export function resolveOwnerEmailFilter(
|
||||
args,
|
||||
flagName = "owner-email",
|
||||
missingMessage = "Pass --owner-email when using an owner-scoped opportunity query."
|
||||
) {
|
||||
const { rawInput, email, emailSql } = normalizeOracleEmailInput(args[flagName] || "", missingMessage);
|
||||
return { ownerInput: rawInput, ownerEmail: email, ownerEmailSql: emailSql };
|
||||
}
|
||||
|
||||
// Objetivo: aplicar defaults de workload atual quando a data nao veio explicita. Entrada: args CLI. Saida: nova copia dos args.
|
||||
export function applyCurrentWorkloadIntentDefaults(args) {
|
||||
const nextArgs = { ...args };
|
||||
if (!hasAnyDateWindowArgs(nextArgs)) nextArgs.quarter = "current-quarter";
|
||||
if (!nextArgs.solution) nextArgs.solution = "Cloud Infrastructure - Workloads";
|
||||
if (!nextArgs["revenue-type-group"]) nextArgs["revenue-type-group"] = "WORKLOAD";
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
// Objetivo: aplicar janela padrao curta para intents close-window. Entrada: args CLI. Saida: nova copia dos args.
|
||||
export function applyCloseWindowIntentDefaults(args) {
|
||||
const nextArgs = { ...args };
|
||||
if (!hasAnyDateWindowArgs(nextArgs)) {
|
||||
nextArgs["from-date"] = "today";
|
||||
nextArgs["to-date"] = "+7d";
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
// Objetivo: consolidar filtros SA Attach com defaults de catalogo. Entrada: args CLI e definicao da view. Saida: filtros validados.
|
||||
export function resolveSaAttachFilters(args, definition) {
|
||||
const statuses = splitCsv(args["revenue-line-status"], definition.defaultStatuses || SA_ATTACH_DEFAULT_ALL_STATUSES);
|
||||
const cluster = String(args.cluster || SA_ATTACH_DEFAULT_CLUSTER).trim();
|
||||
const solutions = splitCsv(args.solution, SA_ATTACH_DEFAULT_SOLUTIONS);
|
||||
const productLobs = normalizeSaAttachProductLobs(
|
||||
splitCsv(args["executive-product-lob"], SA_ATTACH_DEFAULT_PRODUCT_LOBS)
|
||||
);
|
||||
const revenueTypeGroups = normalizeSaAttachRevenueTypeGroups(
|
||||
splitCsv(args["revenue-type-group"], SA_ATTACH_DEFAULT_REVENUE_TYPE_GROUPS)
|
||||
);
|
||||
const salesCreditType = String(args["sales-credit-type"] || SA_ATTACH_DEFAULT_SALES_CREDIT_TYPE).trim();
|
||||
const minOpportunityProbability = Number(
|
||||
args["min-opportunity-probability"] || SA_ATTACH_DEFAULT_MIN_OPPORTUNITY_PROBABILITY
|
||||
);
|
||||
const ownerEmail = args["owner-email"]
|
||||
? normalizeOracleEmailInput(args["owner-email"], "Pass --owner-email with a valid Oracle email.").email
|
||||
: null;
|
||||
const ownerManagerEmail = args["owner-manager-email"]
|
||||
? normalizeOracleEmailInput(args["owner-manager-email"], "Pass --owner-manager-email with a valid Oracle email.").email
|
||||
: null;
|
||||
const forecastTypes = splitCsv(args["forecast-type"], []);
|
||||
|
||||
if (!cluster) throw new Error("Cluster cannot be empty for SA Attach views.");
|
||||
if (solutions.length === 0) throw new Error("Pass at least one --solution value for SA Attach views.");
|
||||
if (statuses.length === 0) throw new Error("Pass at least one --revenue-line-status value for SA Attach views.");
|
||||
if (productLobs.length === 0) throw new Error("Pass at least one --executive-product-lob value for SA Attach views.");
|
||||
if (revenueTypeGroups.length === 0) throw new Error("Pass at least one --revenue-type-group value for SA Attach views.");
|
||||
if (!salesCreditType) throw new Error("Sales credit type cannot be empty for SA Attach views.");
|
||||
if (!Number.isFinite(minOpportunityProbability)) {
|
||||
throw new Error("Pass a numeric --min-opportunity-probability value for SA Attach views.");
|
||||
}
|
||||
|
||||
return {
|
||||
cluster,
|
||||
solutions,
|
||||
statuses,
|
||||
productLobs,
|
||||
revenueTypeGroups,
|
||||
salesCreditType,
|
||||
minOpportunityProbability,
|
||||
ownerEmail,
|
||||
ownerManagerEmail,
|
||||
forecastTypes,
|
||||
};
|
||||
}
|
||||
|
||||
// Objetivo: ler cluster somente quando explicitado. Entrada: args CLI. Saida: cluster ou null.
|
||||
export function resolveOptionalClusterFilter(args) {
|
||||
const cluster = typeof args.cluster === "string" ? args.cluster.trim() : "";
|
||||
return cluster || null;
|
||||
}
|
||||
|
||||
// Objetivo: aplicar cluster comercial com fallback. Entrada: args CLI e default. Saida: cluster efetivo.
|
||||
export function resolveCommercialClusterFilter(args, fallback = SA_ATTACH_DEFAULT_CLUSTER) {
|
||||
return resolveOptionalClusterFilter(args) || fallback;
|
||||
}
|
||||
|
||||
// Objetivo: limitar volume de coverage sem estourar limite Oracle. Entrada: limite base. Saida: limite entre 5000 e 10000.
|
||||
export function resolveCoverageLimit(limit) {
|
||||
return Math.min(10000, Math.max(5000, Number(limit) * 8));
|
||||
}
|
||||
9
scripts/lib/sql-literals.mjs
Normal file
9
scripts/lib/sql-literals.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
// Objetivo: escapar um valor JS para literal SQL Oracle. Entrada: valor escalar. Saida: string SQL entre aspas.
|
||||
export function quoteLiteral(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
// Objetivo: montar lista SQL segura a partir de valores escalares. Entrada: array de valores. Saida: literais separados por virgula.
|
||||
export function toSqlList(values) {
|
||||
return values.map((value) => quoteLiteral(value)).join(", ");
|
||||
}
|
||||
@@ -8,6 +8,9 @@ const cliShared = await import(pathToFileURL(path.join(skillHome, "scripts", "li
|
||||
const outputContract = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "output-contract.mjs")).href);
|
||||
const runtimePaths = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "runtime-paths.mjs")).href);
|
||||
const authShared = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "auth-shared.mjs")).href);
|
||||
const sqlLiterals = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "sql-literals.mjs")).href);
|
||||
const saAttachFilters = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "sa-attach-filters.mjs")).href);
|
||||
const coverageSummary = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "coverage-summary.mjs")).href);
|
||||
|
||||
test("cli-shared parses flags and csv values for agent-built commands", () => {
|
||||
assert.deepEqual(
|
||||
@@ -161,3 +164,38 @@ test("auth-shared classifies refresh-required errors", () => {
|
||||
assert.equal(authShared.isAuthRefreshRequiredError(new Error("Could not find auth state at /tmp/auth-state.json")), true);
|
||||
assert.equal(authShared.isAuthRefreshRequiredError(new Error("Unsupported --limit abc")), false);
|
||||
});
|
||||
|
||||
test("extracted sales helpers keep literals, filters, and coverage summaries stable", () => {
|
||||
assert.equal(sqlLiterals.quoteLiteral("Brazil's South"), "'Brazil''s South'");
|
||||
assert.equal(sqlLiterals.toSqlList(["Open", "Won"]), "'Open', 'Won'");
|
||||
|
||||
assert.deepEqual(
|
||||
saAttachFilters.resolveManagerEmailListFilter({
|
||||
"manager-email-list": " Resource.Manager.B , resource.manager.a@oracle.com",
|
||||
"manager-email": "resource.manager.a",
|
||||
}).managerEmails,
|
||||
["resource.manager.a@oracle.com", "resource.manager.b@oracle.com"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
saAttachFilters.applyCurrentWorkloadIntentDefaults({ "owner-email": "territory.owner@oracle.com" }),
|
||||
{
|
||||
"owner-email": "territory.owner@oracle.com",
|
||||
quarter: "current-quarter",
|
||||
solution: "Cloud Infrastructure - Workloads",
|
||||
"revenue-type-group": "WORKLOAD",
|
||||
}
|
||||
);
|
||||
assert.equal(saAttachFilters.resolveCoverageLimit(10000), 10000);
|
||||
|
||||
const result = coverageSummary.summarizeOrgWideRevenueLineCoverage(
|
||||
[{ revenueLineId: "RL-1", opportunityId: "OPP-1", customerName: "Acme", workloadAmount: 120000 }],
|
||||
[{
|
||||
opportunityId: "OPP-1",
|
||||
seTeamLevel4EmailAddress: "manager@oracle.com",
|
||||
seTeamLevel5EmailAddress: "resource@oracle.com",
|
||||
}]
|
||||
);
|
||||
assert.equal(coverageSummary.buildSrCoverageKey("OPP-1", "RESOURCE@ORACLE.COM"), "OPP-1::resource@oracle.com");
|
||||
assert.equal(result.summary.totalWindowRevenueLines, 1);
|
||||
assert.deepEqual(result.records[0].resourceEmails, ["resource@oracle.com"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user