brackt/app/lib/unmatched-team-reconciliation.ts
2026-05-14 16:45:02 -07:00

154 lines
4.5 KiB
TypeScript

import { normalizeTeamName } from "~/lib/normalize-team-name";
export type ReconciliationParticipant = {
id: string;
name: string;
externalId?: string | null;
};
export type MatchConfidence = "exact" | "partial" | "review" | "none";
export type CandidateMatch = {
participantId: string;
participantName: string;
confidence: MatchConfidence;
score: number;
};
export type UnmatchedTeamResolutionView = {
suggestedParticipantId: string | null;
suggestedParticipantName: string | null;
confidence: MatchConfidence;
topCandidates: CandidateMatch[];
orderedParticipants: ReconciliationParticipant[];
};
function tokenSet(value: string): Set<string> {
return new Set(
normalizeTeamName(value)
.split(" ")
.map((token) => token.trim())
.filter(Boolean)
);
}
function reviewScore(apiTeamName: string, participantName: string): number {
const normalizedApi = normalizeTeamName(apiTeamName);
const normalizedParticipant = normalizeTeamName(participantName);
if (!normalizedApi || !normalizedParticipant) return 0;
const apiTokens = tokenSet(apiTeamName);
const participantTokens = tokenSet(participantName);
const sharedTokens = [...apiTokens].filter((token) => participantTokens.has(token));
let score = 0;
if (sharedTokens.length > 0) {
score += sharedTokens.length * 20;
}
if (normalizedApi.startsWith(normalizedParticipant) || normalizedParticipant.startsWith(normalizedApi)) {
score += 12;
}
if (normalizedApi[0] === normalizedParticipant[0]) {
score += 4;
}
const lengthDelta = Math.abs(normalizedApi.length - normalizedParticipant.length);
score -= Math.min(lengthDelta, 10);
return Math.max(score, 0);
}
export function rankParticipantMatches(
apiTeamName: string,
participants: ReconciliationParticipant[]
): CandidateMatch[] {
const normalizedApi = normalizeTeamName(apiTeamName);
return participants
.map((participant) => {
const normalizedParticipant = normalizeTeamName(participant.name);
if (!normalizedApi || !normalizedParticipant) {
return {
participantId: participant.id,
participantName: participant.name,
confidence: "none" as const,
score: 0,
};
}
if (normalizedParticipant === normalizedApi) {
return {
participantId: participant.id,
participantName: participant.name,
confidence: "exact" as const,
score: 100,
};
}
if (
normalizedApi.length >= 4 &&
normalizedParticipant.length >= 4 &&
(normalizedApi.includes(normalizedParticipant) ||
normalizedParticipant.includes(normalizedApi))
) {
return {
participantId: participant.id,
participantName: participant.name,
confidence: "partial" as const,
score: 80,
};
}
return {
participantId: participant.id,
participantName: participant.name,
confidence: "review" as const,
score: reviewScore(apiTeamName, participant.name),
};
})
.toSorted((a, b) => b.score - a.score || a.participantName.localeCompare(b.participantName));
}
export function buildUnmatchedTeamResolutionView(
apiTeamName: string,
participants: ReconciliationParticipant[]
): UnmatchedTeamResolutionView {
const rankedCandidates = rankParticipantMatches(apiTeamName, participants);
const topCandidates = rankedCandidates.filter((candidate) => candidate.score > 0).slice(0, 3);
const bestCandidate = rankedCandidates[0];
const confidence =
bestCandidate?.confidence === "exact" || bestCandidate?.confidence === "partial"
? bestCandidate.confidence
: topCandidates.length > 0
? "review"
: "none";
const suggestedParticipantId =
confidence === "exact" || confidence === "partial" ? bestCandidate.participantId : null;
const suggestedParticipantName =
confidence === "none" ? null : (bestCandidate?.participantName ?? null);
const topCandidateIds = new Set(topCandidates.map((candidate) => candidate.participantId));
const orderedParticipants = [
...topCandidates
.map((candidate) =>
participants.find((participant) => participant.id === candidate.participantId) ?? null
)
.filter((participant): participant is ReconciliationParticipant => participant !== null),
...participants.filter((participant) => !topCandidateIds.has(participant.id)),
];
return {
suggestedParticipantId,
suggestedParticipantName,
confidence,
topCandidates,
orderedParticipants,
};
}