## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。
## 口径(这里最容易写反,所以定死)
新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让
「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**:
| 项目 `compare` | 含义 | 队内最差 = |
| --- | --- | --- |
| `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 |
| `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 |
方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`,
两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。
## 改动
- **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**,
取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回**
(父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。
- **引擎**:`scoreByAggregate` 支持第三种口径。
- **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。
- **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**),
提示文案写明两种方向,不丢一个 `worst` 让人猜。
- **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」,
这次不允许再开一个新口子。
- 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。
## 两条边界(都是刻意的)
- **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。
否则谁都可以挂一个弃赛的人在队里垫底。
- **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。
若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。
## 验证
- **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。
- 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。
## 版本
`dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
647 lines
26 KiB
TypeScript
647 lines
26 KiB
TypeScript
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<ChannelScope, string> = {
|
||
athlete: '个人名次',
|
||
team: '团体名次',
|
||
}
|
||
|
||
/** 通道「名次来源」的界面称谓。 */
|
||
export const CHANNEL_BASIS_LABELS: Record<ChannelBasis, string> = {
|
||
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<number, number>()
|
||
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<number>()
|
||
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<number, number>
|
||
/** 每行折算到单位的贡献(个人通道分 × 系数 + 团体通道分),用于预览差值。 */
|
||
rowTeamPoints: Map<number, number>
|
||
/**
|
||
* 上一行贡献按份额拆到各成员单位的结果,普通报名只有一条(主单位)。
|
||
*
|
||
* 「已发布的分要从单位总分里扣掉」时不能按 `row.teamId` 累加 rowTeamPoints:
|
||
* 联队那条的整份分会全记在主单位头上,扣出来的差值在 B 单位那边是错的。
|
||
*/
|
||
rowTeamShares: Map<number, RowTeamShare[]>
|
||
/** 每行展示名次;多通道时优先个人通道,与 applyComputedRanks 一致。 */
|
||
ranks: Map<number, number | null>
|
||
/** 按单位聚合的得分增量,用于预览面板。 */
|
||
teamPoints: Map<number, number>
|
||
/**
|
||
* 不挂在任何报名项上的单位分(entryId = 0 的名次:按单位录的团体名次、按队内平均的团体成绩)。
|
||
*
|
||
* 「已发布的分从单位总分里扣掉」时要把它一起扣:这些分已经随发布 / 保存团体名次进了榜单,
|
||
* 只扣行分会让预览把它们当成新增量报出去(现场那张预览正是发布确认框「最大分差」的来源)。
|
||
*/
|
||
unitPoints: Map<number, number>
|
||
/** 配置错误(非 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<number, number>()
|
||
const rowTeamPoints = new Map<number, number>()
|
||
const rowShares = new Map<number, RowTeamShare[]>()
|
||
const ranks = new Map<number, number | null>()
|
||
const rankScope = new Map<number, ChannelScope>()
|
||
const teamPoints = new Map<number, number>()
|
||
const unitPoints = new Map<number, number>()
|
||
|
||
// 配置本身就出不来分的(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<number, readonly { teamId: number; weight: number }[]>()
|
||
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 }
|
||
}
|