feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名
口径(用户确认): - 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数) - 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分 - 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、 没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存 服务端: - 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team; events 加 avg_mode(默认 valid) - 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team → 默认 athlete(result, 不计入单位) + team(aggregate 队内平均) - 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名 (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与) 前端: - 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」 - 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存 - 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致) - 大屏/管理端团体成绩只显示数值,不出现「平均」字样 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
This commit is contained in:
1 parent
592e47aef7
commit
6bd7bd1627
47 files changed
+1770
-153
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import type {
|
||||
AvgMode,
|
||||
ChannelBasis,
|
||||
ChannelScope,
|
||||
CompareMode,
|
||||
@@ -15,7 +17,7 @@ import type {
|
||||
*
|
||||
* 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
|
||||
* 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
|
||||
* 个人通道分再按 standings.individual_to_team_weight 折算进单位总分。
|
||||
* 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。
|
||||
* 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
|
||||
*/
|
||||
|
||||
@@ -50,6 +52,8 @@ interface Candidate {
|
||||
rankIn: number
|
||||
/** 聚合通道的排名主体(单位 id 或运动员 id)。 */
|
||||
subject: number
|
||||
/** 该候选计入单位总分时的归属单位。 */
|
||||
teamId: number
|
||||
}
|
||||
|
||||
interface RankedRow {
|
||||
@@ -124,11 +128,11 @@ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): C
|
||||
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 })
|
||||
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 })
|
||||
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0, teamId: row.teamId })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -178,26 +182,51 @@ function sortManual(cands: Candidate[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合通道:先把同一主体的成绩求和,再按值排名(scoring.go scoreByAggregate)。
|
||||
* 运动员维度缺 athleteId 的行按服务端口径直接跳过(subject == 0)。
|
||||
* 聚合通道:先把同一主体的成绩取平均,再按平均值排名(scoring.go scoreByAggregate)。
|
||||
*
|
||||
* 分母由 avgMode 决定:valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,
|
||||
* 没成绩的按 0 计入。scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
|
||||
*/
|
||||
function aggregateCandidates(rows: readonly ScorableRow[], channel: ScoreChannel): Candidate[] {
|
||||
function aggregateCandidates(
|
||||
rows: readonly ScorableRow[],
|
||||
channel: ScoreChannel,
|
||||
avgMode: AvgMode,
|
||||
): Candidate[] {
|
||||
const order: number[] = []
|
||||
const buckets = new Map<number, { row: ScorableRow; total: number }>()
|
||||
for (const candidate of collectCandidates(rows, 'result')) {
|
||||
const subject = channel.scope === 'team' ? candidate.row.teamId : (candidate.row.athleteId ?? 0)
|
||||
const buckets = new Map<number, { row: ScorableRow; total: number; valid: number; registered: number }>()
|
||||
for (const row of rows) {
|
||||
const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0)
|
||||
if (subject === 0) continue
|
||||
const bucket = buckets.get(subject)
|
||||
const value = row.status === 'ok' ? row.resultValue : null
|
||||
let bucket = buckets.get(subject)
|
||||
if (bucket === undefined) {
|
||||
buckets.set(subject, { row: candidate.row, total: candidate.value })
|
||||
// valid 口径下没有任何有效成绩的主体不参与排名。
|
||||
if (value === null && avgMode !== 'all') continue
|
||||
bucket = { row, total: 0, valid: 0, registered: 0 }
|
||||
buckets.set(subject, bucket)
|
||||
order.push(subject)
|
||||
} else {
|
||||
bucket.total += candidate.value
|
||||
}
|
||||
bucket.registered += 1
|
||||
if (value !== null) {
|
||||
bucket.total += value
|
||||
bucket.valid += 1
|
||||
}
|
||||
}
|
||||
return order.map((subject) => {
|
||||
const bucket = buckets.get(subject) as { row: ScorableRow; total: number }
|
||||
return { row: bucket.row, value: bucket.total, rankIn: 0, subject }
|
||||
const bucket = buckets.get(subject) as {
|
||||
row: ScorableRow
|
||||
total: number
|
||||
valid: number
|
||||
registered: number
|
||||
}
|
||||
const count = avgMode === 'all' ? bucket.registered : bucket.valid
|
||||
return {
|
||||
row: bucket.row,
|
||||
value: count > 0 ? bucket.total / count : 0,
|
||||
rankIn: 0,
|
||||
subject,
|
||||
teamId: bucket.row.teamId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,7 +245,7 @@ function scoreChannel(
|
||||
|
||||
let groups: TieGroup[]
|
||||
if (basis === 'aggregate') {
|
||||
const aggregated = aggregateCandidates(rows, channel)
|
||||
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode))
|
||||
sortByValue(aggregated, event.compare)
|
||||
groups = groupByValue(aggregated)
|
||||
} else if (basis === 'manual') {
|
||||
@@ -242,7 +271,7 @@ function scoreChannel(
|
||||
for (const member of group.members) {
|
||||
out.push({
|
||||
entryId: basis === 'aggregate' ? 0 : member.row.entryId,
|
||||
teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.row.teamId,
|
||||
teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.teamId,
|
||||
scope: channel.scope,
|
||||
rank: group.startRank,
|
||||
points,
|
||||
@@ -262,6 +291,7 @@ function rankOnlyChannel(event: Event): ScoreChannel {
|
||||
basis: event.resultMode === 'manual' ? 'manual' : 'result',
|
||||
profileId: 0,
|
||||
sortOrder: 0,
|
||||
countToTeam: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,11 +338,15 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
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)
|
||||
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
|
||||
const countToTeam = normalizeCountToTeam(channel.countToTeam)
|
||||
for (const row of ranked) {
|
||||
if (row.entryId > 0) {
|
||||
rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points)
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
|
||||
if (countToTeam) {
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
|
||||
}
|
||||
// 名次优先取个人通道;同一优先级下先处理的通道胜出。
|
||||
const current = rankScope.get(row.entryId)
|
||||
if (current === undefined || (current === 'team' && row.scope === 'athlete')) {
|
||||
@@ -320,6 +354,7 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
rankScope.set(row.entryId, row.scope)
|
||||
}
|
||||
}
|
||||
if (!countToTeam) continue
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
teamPoints.set(row.teamId, (teamPoints.get(row.teamId) ?? 0) + teamContribution)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user