Files
knowledge-one-view-cli/scripts/lib/coverage-summary.mjs
2026-05-05 15:23:26 -03:00

267 lines
12 KiB
JavaScript

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