5.5.2026 update
This commit is contained in:
@@ -16,6 +16,28 @@ import {
|
|||||||
resolveStateReadPath,
|
resolveStateReadPath,
|
||||||
} from "./lib/runtime-paths.mjs";
|
} from "./lib/runtime-paths.mjs";
|
||||||
import { parseArgs, splitCsv } from "./lib/cli-shared.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 {
|
import {
|
||||||
cookieHeaderFor,
|
cookieHeaderFor,
|
||||||
extractClientBin,
|
extractClientBin,
|
||||||
@@ -38,8 +60,13 @@ import {
|
|||||||
buildWarnings,
|
buildWarnings,
|
||||||
emitJson,
|
emitJson,
|
||||||
mapRows,
|
mapRows,
|
||||||
parseNumericValue,
|
|
||||||
} from "./lib/output-contract.mjs";
|
} 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_BASE_URL = "https://salesintelligence-dv.oracle.com";
|
||||||
const DEFAULT_WORKBOOK_URL =
|
const DEFAULT_WORKBOOK_URL =
|
||||||
@@ -104,20 +131,6 @@ const DATASET_REGISTRY = {
|
|||||||
allowedScopes: [SCOPE_MY_TEAM],
|
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 = {
|
const VIEW_SCOPE_REGISTRY = {
|
||||||
"current-fiscal-year": { sourceName: SOURCE_DV_SE_TEAM, requiresMyTeam: true },
|
"current-fiscal-year": { sourceName: SOURCE_DV_SE_TEAM, requiresMyTeam: true },
|
||||||
"data-quality-session-context": { 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 },
|
"srs-by-resource-email": { sourceName: SOURCE_DV_SE_TEAM, requiresMyTeam: true },
|
||||||
"won-and-open-by-se-opty": { 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 = [
|
const OPPORTUNITY_DETAILS_COLUMNS = [
|
||||||
{ name: "opportunityId", expr: '"Opportunity"."Opportunity ID"', kind: "text" },
|
{ name: "opportunityId", expr: '"Opportunity"."Opportunity ID"', kind: "text" },
|
||||||
{ name: "opportunityHours", expr: '"Effort"."# Opportunity Hours"', kind: "scalar" },
|
{ name: "opportunityHours", expr: '"Effort"."# Opportunity Hours"', kind: "scalar" },
|
||||||
@@ -367,7 +365,6 @@ const WORKLOAD_MANAGER_RESOURCE_CLOSE_WINDOW_COLUMNS = [
|
|||||||
"resourceDetails",
|
"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_TEXT = `CAST(NULL AS VARCHAR(${VARCHAR_SIZE}))`;
|
||||||
const SQL_NULL_SCALAR = "CAST(NULL AS DOUBLE)";
|
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.",
|
"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",
|
view: "opportunities-close-window",
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
...closeWindowDateParameters(),
|
||||||
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.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "optyStatus",
|
name: "optyStatus",
|
||||||
cli: "--opty-status",
|
cli: "--opty-status",
|
||||||
@@ -2205,51 +2158,7 @@ const INTENT_CATALOG = {
|
|||||||
required: true,
|
required: true,
|
||||||
description: "Oracle user identifier. Accepts resource.member or resource.member@oracle.com.",
|
description: "Oracle user identifier. Accepts resource.member or resource.member@oracle.com.",
|
||||||
},
|
},
|
||||||
{
|
...closeWindowDateParameters(),
|
||||||
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.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "optyStatus",
|
name: "optyStatus",
|
||||||
cli: "--opty-status",
|
cli: "--opty-status",
|
||||||
@@ -2283,51 +2192,7 @@ const INTENT_CATALOG = {
|
|||||||
required: true,
|
required: true,
|
||||||
description: "Manager email to match in the coverage hierarchy. Accepts resource.manager or resource.manager@oracle.com.",
|
description: "Manager email to match in the coverage hierarchy. Accepts resource.manager or resource.manager@oracle.com.",
|
||||||
},
|
},
|
||||||
{
|
...closeWindowDateParameters(),
|
||||||
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.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "optyStatus",
|
name: "optyStatus",
|
||||||
cli: "--opty-status",
|
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) {
|
async function withConsoleLogRoutedToStderr(callback) {
|
||||||
const originalLog = console.log;
|
const originalLog = console.log;
|
||||||
console.log = (...args) => console.error(...args);
|
console.log = (...args) => console.error(...args);
|
||||||
@@ -3307,14 +3150,6 @@ function hasFreshSessionContextCache(meta) {
|
|||||||
return Date.now() - capturedAtMs <= SESSION_CONTEXT_CACHE_MAX_AGE_MS;
|
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) {
|
function normalizeScopeInput(value = SCOPE_AUTO) {
|
||||||
const normalized = String(value || SCOPE_AUTO).trim().toLowerCase();
|
const normalized = String(value || SCOPE_AUTO).trim().toLowerCase();
|
||||||
if (!normalized || normalized === SCOPE_AUTO) return SCOPE_AUTO;
|
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) {
|
function buildSrResourcePredicate(resourceEmailSql, matchRole) {
|
||||||
if (matchRole === "lead") {
|
if (matchRole === "lead") {
|
||||||
return `"Service Request"."Lead Email" = ${resourceEmailSql}`;
|
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({
|
function buildWorkloadCloseWindowSql({
|
||||||
fromDateSql,
|
fromDateSql,
|
||||||
toDateSql,
|
toDateSql,
|
||||||
@@ -5325,286 +4977,6 @@ function buildIntentScopeDescriptor(name, definition) {
|
|||||||
return buildViewScopeDescriptor(definition.view, VIEW_DEFS[definition.view] || {});
|
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({
|
async function fetchResourceOpportunitySrCoverage({
|
||||||
resourceEmails,
|
resourceEmails,
|
||||||
opportunityIds,
|
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 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 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 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", () => {
|
test("cli-shared parses flags and csv values for agent-built commands", () => {
|
||||||
assert.deepEqual(
|
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("Could not find auth state at /tmp/auth-state.json")), true);
|
||||||
assert.equal(authShared.isAuthRefreshRequiredError(new Error("Unsupported --limit abc")), false);
|
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