154 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|