import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam, splitSharePoints } from '@/shared' import type { AvgMode, ChannelBasis, ChannelScope, CompareMode, Event, EventCategory, ResultStatus, ScoreChannel, ScoringProfile, TeamRankEntry, TieMode, } from '@/shared' /** * 名次与积分的纯函数实现,只用于录入页的实时预览与 mock 数据源。 * * 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、 * 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加, * 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。 * 「按单位录名次」的通道(scope=team + basis=manual)另有一条候选来源, * 见 collectTeamManualCandidates——它读的是 channel_manual_ranks,不是行上的名次。 * 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。 */ /** 通道「名次范围」的界面称谓;管理端下拉与预览报错文案共用这一份,免得同一件事两处措辞不同。 */ export const CHANNEL_SCOPE_LABELS: Record = { athlete: '个人名次', team: '团体名次', } /** 通道「名次来源」的界面称谓。 */ export const CHANNEL_BASIS_LABELS: Record = { result: '按成绩自动排名', manual: '手动录名次', aggregate: '按队内平均', } /** 通道在界面上的全称,如「团体名次 / 手动录名次」。 */ export function channelLabelOf(scope: ChannelScope, basis: ChannelBasis): string { return `${CHANNEL_SCOPE_LABELS[scope]} / ${CHANNEL_BASIS_LABELS[basis]}` } export interface ScorableRow { entryId: number teamId: number /** 聚合到运动员的通道需要;缺失时该行不参与个人维度的聚合。 */ athleteId?: number | null resultValue: number | null rank: number | null status: ResultStatus isTie: boolean /** * 归属份额(联队,devdocs/19);普通报名不传 = 主单位独占 100%。 * * 引擎侧按份额把分拆给各成员单位(scoring.go 的 ledgerFromRanked),录入页的实时预览要同口径, * 否则联队那条会显示成「A +15」,而发布后 A、B 各 +7.5。 */ shares?: readonly { teamId: number; weight: number }[] } const EPSILON = 1e-9 /** 不计分项目的占位模板:名次要算,但不可能算出非零分。 */ const EMPTY_PROFILE: ScoringProfile = { id: 0, code: '', name: '', tieMode: 'skip', recordBonusMode: 'max', rules: [], } interface Candidate { row: ScorableRow /** 比较值(result / aggregate 通道)。 */ value: number /** 录入名次(manual 通道)。 */ rankIn: number /** 聚合通道的排名主体(单位 id 或运动员 id)。 */ subject: number /** 该候选计入单位总分时的归属单位。 */ teamId: number } interface RankedRow { /** 聚合通道不对应单条报名项,此时为 0。 */ entryId: number teamId: number scope: ChannelScope rank: number points: number } function effectiveMultiplier(value: number): number { return value > 0 ? value : 1 } function tieModeOf(profile: ScoringProfile): TieMode { return profile.tieMode === 'average' || profile.tieMode === 'higher' ? profile.tieMode : 'skip' } /** 查表得分并乘项目 / 组别权重;超出录取名次或表中无名次一律 0(scoring.go basePoints)。 */ function basePoints( profile: ScoringProfile, rank: number, eventMultiplier: number, categoryMultiplier: number, ): number { if (rank <= 0) return 0 if (profile.awardRanks !== undefined && profile.awardRanks > 0 && rank > profile.awardRanks) return 0 const rule = profile.rules.find((item) => item.rank === rank) if (rule === undefined) return 0 return rule.points * eventMultiplier * categoryMultiplier } /** * 一组并列者拿到的分(scoring.go groupPoints)。 * 先确定这组占用的名次区间 startRank .. startRank+size-1,再按并列口径取值。 */ function groupPoints( profile: ScoringProfile, startRank: number, size: number, lastRank: number, eventMultiplier: number, categoryMultiplier: number, ): number { if (startRank <= 0 || size <= 0) return 0 // lastPlaceZero 只抹「末位那一档」,判据是起始名次**正好落在这档**(scoring.go groupPoints)。 // 这种组必然只有一个人(startRank == lastRank 意味着 size == 1,lastRank 是所有组占位名次的 // 最大值)。判据若写成「区间跨过末位」,两人并列第 1(占 1、2,末位档 = 2)会被整组归零, // 而服务端照发第 1 名的分——这就是现场「预览 0、服务端 10」的来历。 if (profile.lastPlaceZero === true && startRank === lastRank) return 0 const mode = tieModeOf(profile) if (mode === 'average') { let sum = 0 for (let rank = startRank; rank < startRank + size; rank += 1) { sum += basePoints(profile, rank, eventMultiplier, categoryMultiplier) } return sum / size } if (mode === 'higher') { let best = 0 for (let rank = startRank; rank < startRank + size; rank += 1) { best = Math.max(best, basePoints(profile, rank, eventMultiplier, categoryMultiplier)) } return best } return basePoints(profile, startRank, eventMultiplier, categoryMultiplier) } /** 收集本通道真正参与排名的行:状态正常,且带有本通道所需数据。 */ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): Candidate[] { const out: Candidate[] = [] for (const row of rows) { if (row.status !== 'ok') continue if (basis === 'manual') { if (row.rank === null || row.rank <= 0) continue out.push({ row, value: 0, rankIn: row.rank, subject: 0, teamId: row.teamId }) continue } if (row.resultValue === null) continue out.push({ row, value: row.resultValue, rankIn: 0, subject: 0, teamId: row.teamId }) } return out } /** 一行的归属份额是否构成联队(与 Go 的 jointTeamEntry 同一判据:至少两个单位)。 */ function isJointShares(row: ScorableRow): boolean { return row.shares !== undefined && row.shares.length >= 2 } /** * 找「主单位 teamID 在本组别的联队报名」(scoring.go jointTeamEntry)。 * * 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上, * 所以录名次时得回头找这条报名,否则 A+B 的名次会整份记在 A 头上、B 一分没有。 * 只认团体行(athleteId 为 0):个人报名的名次是个人名次,不进团体通道。 */ function jointTeamRow(rows: readonly ScorableRow[], teamId: number): ScorableRow | null { let found: ScorableRow | null = null for (const row of rows) { if ((row.athleteId ?? 0) !== 0 || row.teamId !== teamId || !isJointShares(row)) continue // 同一主单位出现份额不一致的两条时宁可退回独占,也不能随便挑一条把分拆错。 if (found !== null && !sameShares(found, row)) return null found = row } return found } function sameShares(a: ScorableRow, b: ScorableRow): boolean { const left = a.shares ?? [] const right = b.shares ?? [] if (left.length !== right.length) return false return left.every((share, index) => share.teamId === right[index]?.teamId && share.weight === right[index]?.weight) } /** * 「按单位录名次」通道(scope=team + basis=manual)的候选来源(scoring.go collectTeamManualCandidates)。 * * 两个来源,且**只看团体行**(athleteId 为 0): * 1. teamRanks(channel_manual_ranks,录入页底部的「团体名次」卡片):没有对应的报名项, * 用合成行(entryId 0)承载,分直接记在单位头上; * 2. 兜底:团体行上录的名次(results.rank),纯团体项目(篮球等)沿用原录入流程。 * * 同一单位两个来源都有时以 teamRanks 为准;rank <= 0 视为未填 / 清空,直接忽略。 * 个人报名行(athleteId != 0)的名次绝不进团体通道——否则双通道项目第 2 次发布时会把 * 刚写回 results.rank 的个人名次当成团体名次再发一次分。 * 录入页的「团体名次」卡片此前完全没喂给预览,于是「名次填在卡片、预览全 0」成了现场的 P0。 */ function collectTeamManualCandidates( rows: readonly ScorableRow[], teamRanks: readonly TeamRankEntry[], ): Candidate[] { const overrideOf = new Map() const overrides: number[] = [] for (const entry of teamRanks) { if (entry.teamId === 0 || entry.rank <= 0) continue if (overrideOf.has(entry.teamId)) continue overrideOf.set(entry.teamId, entry.rank) overrides.push(entry.teamId) } const out: Candidate[] = [] const taken = new Set() for (const row of rows) { if ((row.athleteId ?? 0) !== 0 || row.status !== 'ok') continue if (row.rank === null || row.rank <= 0) continue const rankIn = overrideOf.get(row.teamId) ?? row.rank taken.add(row.teamId) out.push({ row, value: 0, rankIn, subject: 0, teamId: row.teamId }) } for (const teamId of overrides) { if (taken.has(teamId)) continue const rankIn = overrideOf.get(teamId) ?? 0 const joint = jointTeamRow(rows, teamId) if (joint !== null) { out.push({ row: joint, value: 0, rankIn, subject: 0, teamId }) continue } // 合成行:entryId 0 表示「不对应任何一条报名项」,团体名次的主体是单位。 out.push({ row: { entryId: 0, teamId, athleteId: 0, resultValue: null, rank: rankIn, status: 'ok', isTie: false }, value: 0, rankIn, subject: 0, teamId, }) } return out } function sortByValue(cands: Candidate[], compare: CompareMode): void { cands.sort((a, b) => { if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId return compare === 'desc' ? b.value - a.value : a.value - b.value }) } interface TieGroup { startRank: number members: Candidate[] } /** 值相同即并列,名次按「占位顺推」计算(skip 口径)。 */ function groupByValue(cands: readonly Candidate[]): TieGroup[] { const groups: TieGroup[] = [] let pos = 1 for (let i = 0; i < cands.length; ) { let j = i + 1 while (j < cands.length && Math.abs((cands[j] as Candidate).value - (cands[i] as Candidate).value) <= EPSILON) { j += 1 } groups.push({ startRank: pos, members: cands.slice(i, j) }) pos += j - i i = j } return groups } /** 录入名次就是展示名次,不做顺推改写。 */ function groupByManualRank(cands: readonly Candidate[]): TieGroup[] { const groups: TieGroup[] = [] for (let i = 0; i < cands.length; ) { let j = i + 1 while (j < cands.length && (cands[j] as Candidate).rankIn === (cands[i] as Candidate).rankIn) j += 1 groups.push({ startRank: (cands[i] as Candidate).rankIn, members: cands.slice(i, j) }) i = j } return groups } function sortManual(cands: Candidate[]): void { cands.sort((a, b) => (a.rankIn === b.rankIn ? a.row.entryId - b.row.entryId : a.rankIn - b.rankIn)) } /** * 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和 / worst 取队内最差),再按汇总值排名 * (scoring.go scoreByAggregate)。 * * 汇总口径来自通道的 aggregateMode(缺省 avg);平均值分母由 avgMode 决定: * valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,没成绩的按 0 计入。 * sum 只累加有效成绩,avgMode 在这里只决定「一个有效成绩都没有的主体算不算 0 分参与排名」。 * worst 没有分母,avgMode 在这里**不起作用**:一个有效成绩都没有的主体一律不参与排名 * (与 avg 的 valid 同一条规则)——不能把「没成绩」当成 0 去比:asc 里 0 最快、desc 里 0 最差, * 两边都会让「没人完赛」的队白拿名次。 * scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。 */ function aggregateCandidates( rows: readonly ScorableRow[], channel: ScoreChannel, avgMode: AvgMode, compare: CompareMode, ): Candidate[] { const mode = normalizeAggregateMode(channel.aggregateMode) const sumMode = mode === 'sum' const worstMode = mode === 'worst' const order: number[] = [] const buckets = new Map< number, { row: ScorableRow; total: number; valid: number; registered: number; worst: number | null } >() for (const row of rows) { const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0) if (subject === 0) continue const value = row.status === 'ok' ? row.resultValue : null let bucket = buckets.get(subject) if (bucket === undefined) { // valid 口径下没有任何有效成绩的主体不参与排名(三种汇总口径都一样)。 if (value === null && avgMode !== 'all') continue bucket = { row, total: 0, valid: 0, registered: 0, worst: null } buckets.set(subject, bucket) order.push(subject) } bucket.registered += 1 if (value !== null) { bucket.total += value bucket.valid += 1 if (bucket.worst === null || worseThan(value, bucket.worst, compare)) bucket.worst = value } } const out: Candidate[] = [] for (const subject of order) { const bucket = buckets.get(subject) as { row: ScorableRow total: number valid: number registered: number worst: number | null } if (worstMode && bucket.worst === null) continue const count = avgMode === 'all' ? bucket.registered : bucket.valid const value = worstMode ? (bucket.worst as number) : sumMode ? bucket.total : count > 0 ? bucket.total / count : 0 out.push({ row: bucket.row, value, rankIn: 0, subject, teamId: bucket.row.teamId }) } return out } /** * 判断 a 是不是比 b 更「差」——**方向由小项的 compare 决定,这是最容易写反的一处** * (scoring.go 的 worseValue 同款): * * asc (越小越好,如时间):更差 = 更大 →「队内最差」是**最后一名冲线**的那个; * desc(越大越好,如次数):更差 = 更小 →「队内最差」是**做得最少**的那个。 * * 写死成 min 或 max 都会让一半的项目算反(越野会取到跑得最快的人、引体向上会取到做得最多的人), * 而两种错法读起来都很正常,所以预览与后端各有 asc / desc 的双向用例钉着。 */ function worseThan(a: number, b: number, compare: CompareMode): boolean { return compare === 'desc' ? a < b : a > b } function scoreChannel( rows: readonly ScorableRow[], channel: ScoreChannel, profile: ScoringProfile, event: Event, category: EventCategory | null, scoreEnabled: boolean, teamRanks: readonly TeamRankEntry[], ): RankedRow[] { // compare=none 的降级(照旧按录入名次)已在这里去掉:服务端对这种配置直接返回 // ErrCompareRequired(发布 409),调用方先拦下并报错,见 computeEventScore。 const basis: ChannelBasis = channel.basis const eventMultiplier = effectiveMultiplier(event.multiplier) const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1) let groups: TieGroup[] if (basis === 'aggregate') { const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode), event.compare) sortByValue(aggregated, event.compare) groups = groupByValue(aggregated) } else if (basis === 'manual') { // 「按单位录名次」读 channel_manual_ranks(+ 团体行兜底),个人名次才读行上的 rank。 const cands = channel.scope === 'team' ? collectTeamManualCandidates(rows, teamRanks) : collectCandidates(rows, 'manual') sortManual(cands) groups = groupByManualRank(cands) } else { const cands = collectCandidates(rows, 'result') sortByValue(cands, event.compare) groups = groupByValue(cands) } let lastRank = 0 for (const group of groups) { lastRank = Math.max(lastRank, group.startRank + group.members.length - 1) } const out: RankedRow[] = [] for (const group of groups) { const points = scoreEnabled ? groupPoints(profile, group.startRank, group.members.length, lastRank, eventMultiplier, categoryMultiplier) : 0 for (const member of group.members) { out.push({ entryId: basis === 'aggregate' ? 0 : member.row.entryId, teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.teamId, scope: channel.scope, rank: group.startRank, points, }) } } return out } /** 没有计分通道时按小项自身的录入方式推导一个排名通道(scoring.go rankOnlyChannel)。 */ function rankOnlyChannel(event: Event): ScoreChannel { const scope: ChannelScope = event.eventType === 'team' || event.eventType === 'match' ? 'team' : 'athlete' return { id: 0, eventId: event.id, scope, basis: event.resultMode === 'manual' ? 'manual' : 'result', profileId: 0, sortOrder: 0, countToTeam: true, } } /** 一行折算到某个单位的贡献(联队时一行会有多条)。 */ export interface RowTeamShare { teamId: number points: number } /** * 预览与服务端**都算不出来**的通道配置。 * * 目前只有一种:compare=none 的小项配了 basis=result / basis=aggregate 的通道。 * 服务端 domain.ScoreEvent 对这种配置直接返回 ErrCompareRequired,发布会 409(STATE_INVALID); * 预览以前把它静默降级成「按录入名次」算出一份服务端根本拿不到的分,操作员照着这个数发布只会失败。 */ export interface ScoreConfigError { /** 出问题的通道 id(通道还没入库时可能是 0)。 */ channelId: number /** 通道在界面上的称谓,如「团体名次 / 按成绩自动排名」。 */ channelLabel: string /** 人话原因,页面直接显示给操作员。 */ message: string } export interface EventScoreResult { /** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */ rowPoints: Map /** 每行折算到单位的贡献(个人通道分 × 系数 + 团体通道分),用于预览差值。 */ rowTeamPoints: Map /** * 上一行贡献按份额拆到各成员单位的结果,普通报名只有一条(主单位)。 * * 「已发布的分要从单位总分里扣掉」时不能按 `row.teamId` 累加 rowTeamPoints: * 联队那条的整份分会全记在主单位头上,扣出来的差值在 B 单位那边是错的。 */ rowTeamShares: Map /** 每行展示名次;多通道时优先个人通道,与 applyComputedRanks 一致。 */ ranks: Map /** 按单位聚合的得分增量,用于预览面板。 */ teamPoints: Map /** * 不挂在任何报名项上的单位分(entryId = 0 的名次:按单位录的团体名次、按队内平均的团体成绩)。 * * 「已发布的分从单位总分里扣掉」时要把它一起扣:这些分已经随发布 / 保存团体名次进了榜单, * 只扣行分会让预览把它们当成新增量报出去(现场那张预览正是发布确认框「最大分差」的来源)。 */ unitPoints: Map /** 配置错误(非 null 时所有分都是空的,页面应显示 message 而不是数字)。 */ error: ScoreConfigError | null } /** compare=none 的小项排不出名次:basis=result / aggregate 的通道服务端一律拒绝(scoring.go 的两处 ErrCompareRequired)。 */ function compareRequiredError(event: Event, channel: ScoreChannel): ScoreConfigError | null { if (event.compare !== 'none') return null if (channel.basis !== 'result' && channel.basis !== 'aggregate') return null const label = channelLabelOf(channel.scope, channel.basis) return { channelId: channel.id, channelLabel: label, message: `通道「${label}」要按成绩排名,而小项「${event.name}」是「不比较」(compare=none):` + '服务端算不出名次、发布会直接拒绝(STATE_INVALID)。' + '请到「基础数据 → 计分通道」把它改成「手动录名次」,或先给小项设置比较方向(升序 / 降序)。', } } /** * 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。 * * 聚合名次(entryId = 0,主体是单位或运动员)与没有配份额的行一律「主单位独占」: * 引擎那边聚合并联队不拆份额(名次不对应报名项,读取侧回查不到份额),预览必须同口径, * 否则会出现「预览按份额拆、发布后整份记给主单位」。 */ function rowTeamShares( entryId: number, teamId: number, shares: readonly { teamId: number; weight: number }[] | undefined, contribution: number, ): RowTeamShare[] { if (entryId <= 0 || shares === undefined || shares.length < 2) { return [{ teamId, points: contribution }] } const parts = splitSharePoints(contribution, shares) return shares.map((share, index) => ({ teamId: share.teamId, points: parts[index] ?? 0 })) } export interface ScoreContext { event: Event category: EventCategory | null channels: readonly ScoreChannel[] profiles: readonly ScoringProfile[] /** standings.individualToTeamWeight,个人分折算到单位的系数。 */ weight: number /** * channel_manual_ranks:录入页「团体名次」卡片里按单位录的名次,只有 * scope=team + basis=manual 的通道读它(服务端 EventScoreInput.TeamRanks 同口径)。 * 不传 = 该组别还没读到团体名次(预览会按「一行都没有」算,而不是按行上的名次兜底)。 */ teamRanks?: readonly TeamRankEntry[] } /** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */ function sortedChannels(channels: readonly ScoreChannel[]): ScoreChannel[] { return [...channels].sort((a, b) => (a.sortOrder === b.sortOrder ? a.id - b.id : a.sortOrder - b.sortOrder)) } export function computeEventScore(rows: readonly ScorableRow[], context: ScoreContext): EventScoreResult { const event = context.event const rankOnly = context.channels.length === 0 // 只排名不计分:scored=false 或没有计分通道时都算出名次,但一律 0 分。 const scoreEnabled = event.scored && !rankOnly const channels = rankOnly ? [rankOnlyChannel(event)] : sortedChannels(context.channels) const weight = context.weight > 0 ? context.weight : 1 const teamRanks = context.teamRanks ?? [] const rowPoints = new Map() const rowTeamPoints = new Map() const rowShares = new Map() const ranks = new Map() const rankScope = new Map() const teamPoints = new Map() const unitPoints = new Map() // 配置本身就出不来分的(compare=none + basis=result/aggregate):与服务端一样停在这里, // 一分不算、名次也不给,页面按 error 显示原因——绝不再降级算出一份服务端拿不到的分。 for (const channel of channels) { const error = compareRequiredError(event, channel) if (error !== null) { for (const row of rows) ranks.set(row.entryId, null) return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error } } } const sharesByEntry = new Map() for (const row of rows) { if (row.shares !== undefined && row.shares.length > 0) sharesByEntry.set(row.entryId, row.shares) } for (const channel of channels) { const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null // 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。 const ranked = scoreChannel( rows, channel, profile ?? EMPTY_PROFILE, event, context.category, scoreEnabled && profile !== null, teamRanks, ) // countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。 const countToTeam = normalizeCountToTeam(channel.countToTeam) for (const row of ranked) { // 个人通道的分要按个人→单位系数折算,团体通道的分本来就是单位的分。 const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points const parts = countToTeam ? rowTeamShares(row.entryId, row.teamId, sharesByEntry.get(row.entryId), teamContribution) : [] if (row.entryId > 0) { rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points) if (countToTeam) { rowTeamPoints.set( row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution, ) // 多通道叠加时按单位合并,而不是按下标对齐:渠道顺序一变,按下标就会串位。 const merged = new Map((rowShares.get(row.entryId) ?? []).map((part) => [part.teamId, part.points])) for (const part of parts) { merged.set(part.teamId, (merged.get(part.teamId) ?? 0) + part.points) } rowShares.set( row.entryId, [...merged].map(([teamId, points]) => ({ teamId, points })), ) } // 名次优先取个人通道;同一优先级下先处理的通道胜出。 const current = rankScope.get(row.entryId) if (current === undefined || (current === 'team' && row.scope === 'athlete')) { ranks.set(row.entryId, row.rank) rankScope.set(row.entryId, row.scope) } } if (!countToTeam) continue for (const part of parts) { teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points) // 不挂在报名项上的那一份单独留一份账:它已经随发布 / 保存团体名次进过榜单, // 预览要扣已发布的分时得能把它扣掉(见 EventScoreResult.unitPoints)。 if (row.entryId <= 0) unitPoints.set(part.teamId, (unitPoints.get(part.teamId) ?? 0) + part.points) } } } for (const row of rows) { if (!ranks.has(row.entryId)) ranks.set(row.entryId, null) } return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error: null } }