feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
—— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。
## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。
## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。
## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。
## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。
## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。
## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。
## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
This commit is contained in:
1 parent
9663d5bfb0
commit
7a3e4ad365
67 files changed
+4658
-228
No files matched your search
@@ -9,6 +9,7 @@ import type {
|
||||
ResultStatus,
|
||||
ScoreChannel,
|
||||
ScoringProfile,
|
||||
TeamRankEntry,
|
||||
TieMode,
|
||||
} from '@/shared'
|
||||
|
||||
@@ -18,9 +19,29 @@ import type {
|
||||
* 口径必须与服务端 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
|
||||
@@ -107,8 +128,11 @@ function groupPoints(
|
||||
categoryMultiplier: number,
|
||||
): number {
|
||||
if (startRank <= 0 || size <= 0) return 0
|
||||
// lastPlaceZero 覆盖「最后一名」;并列在末位的整组都按最后一名处理,保证并列同分。
|
||||
if (profile.lastPlaceZero === true && startRank <= lastRank && lastRank <= startRank + size - 1) 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') {
|
||||
@@ -144,6 +168,91 @@ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): C
|
||||
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
|
||||
@@ -249,9 +358,11 @@ function scoreChannel(
|
||||
event: Event,
|
||||
category: EventCategory | null,
|
||||
scoreEnabled: boolean,
|
||||
teamRanks: readonly TeamRankEntry[],
|
||||
): RankedRow[] {
|
||||
// compare=none 无法按值排名,退回录入名次;服务端当前会拒绝该组合(见 P1-6)。
|
||||
const basis: ChannelBasis = channel.basis === 'manual' || event.compare === 'none' ? 'manual' : channel.basis
|
||||
// compare=none 的降级(照旧按录入名次)已在这里去掉:服务端对这种配置直接返回
|
||||
// ErrCompareRequired(发布 409),调用方先拦下并报错,见 computeEventScore。
|
||||
const basis: ChannelBasis = channel.basis
|
||||
const eventMultiplier = effectiveMultiplier(event.multiplier)
|
||||
const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1)
|
||||
|
||||
@@ -261,7 +372,9 @@ function scoreChannel(
|
||||
sortByValue(aggregated, event.compare)
|
||||
groups = groupByValue(aggregated)
|
||||
} else if (basis === 'manual') {
|
||||
const cands = collectCandidates(rows, 'manual')
|
||||
// 「按单位录名次」读 channel_manual_ranks(+ 团体行兜底),个人名次才读行上的 rank。
|
||||
const cands =
|
||||
channel.scope === 'team' ? collectTeamManualCandidates(rows, teamRanks) : collectCandidates(rows, 'manual')
|
||||
sortManual(cands)
|
||||
groups = groupByManualRank(cands)
|
||||
} else {
|
||||
@@ -313,6 +426,22 @@ export interface RowTeamShare {
|
||||
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>
|
||||
@@ -329,8 +458,33 @@ export interface EventScoreResult {
|
||||
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 同口径)。
|
||||
*
|
||||
@@ -358,6 +512,12 @@ export interface ScoreContext {
|
||||
profiles: readonly ScoringProfile[]
|
||||
/** standings.individualToTeamWeight,个人分折算到单位的系数。 */
|
||||
weight: number
|
||||
/**
|
||||
* channel_manual_ranks:录入页「团体名次」卡片里按单位录的名次,只有
|
||||
* scope=team + basis=manual 的通道读它(服务端 EventScoreInput.TeamRanks 同口径)。
|
||||
* 不传 = 该组别还没读到团体名次(预览会按「一行都没有」算,而不是按行上的名次兜底)。
|
||||
*/
|
||||
teamRanks?: readonly TeamRankEntry[]
|
||||
}
|
||||
|
||||
/** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */
|
||||
@@ -372,6 +532,7 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
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>()
|
||||
@@ -379,6 +540,17 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
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) {
|
||||
@@ -388,7 +560,15 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
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)
|
||||
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) {
|
||||
@@ -424,6 +604,9 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,5 +614,5 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
for (const row of rows) {
|
||||
if (!ranks.has(row.entryId)) ranks.set(row.entryId, null)
|
||||
}
|
||||
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints }
|
||||
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error: null }
|
||||
}
|
||||
Reference in new issue
Block a user