5.5.2026 update
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user