267 lines
12 KiB
JavaScript
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 };
|
|
}
|