diff --git a/scripts/fetch-sales-intelligence.mjs b/scripts/fetch-sales-intelligence.mjs index 11e71c5..7b2a306 100644 --- a/scripts/fetch-sales-intelligence.mjs +++ b/scripts/fetch-sales-intelligence.mjs @@ -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, diff --git a/scripts/lib/coverage-summary.mjs b/scripts/lib/coverage-summary.mjs new file mode 100644 index 0000000..19a65d3 --- /dev/null +++ b/scripts/lib/coverage-summary.mjs @@ -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 }; +} diff --git a/scripts/lib/sa-attach-filters.mjs b/scripts/lib/sa-attach-filters.mjs new file mode 100644 index 0000000..a101267 --- /dev/null +++ b/scripts/lib/sa-attach-filters.mjs @@ -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)); +} diff --git a/scripts/lib/sql-literals.mjs b/scripts/lib/sql-literals.mjs new file mode 100644 index 0000000..7b48fb1 --- /dev/null +++ b/scripts/lib/sql-literals.mjs @@ -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(", "); +} diff --git a/tests/lib-contracts.test.mjs b/tests/lib-contracts.test.mjs index 01394a0..2d14eec 100644 --- a/tests/lib-contracts.test.mjs +++ b/tests/lib-contracts.test.mjs @@ -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"]); +});