5.5.2026 update

This commit is contained in:
2026-05-05 15:23:26 -03:00
parent 5ea33e353e
commit 8e2cd5dee9
5 changed files with 555 additions and 659 deletions

View 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 };
}

View 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));
}

View 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(", ");
}