feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3

## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。

## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`

## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
  medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
  各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
  `entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
  各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
  (`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。

## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
  (份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
  「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
  **表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。

## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
  点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
  **普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
  logo 数留位);底栏同理。

## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
  `aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
  差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。

## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
This commit is contained in:
AskaEth committed 2026-09-14 03:52:34 +08:00
1 parent b631418a85
commit 830a4dbdb7
61 files changed
+5649 -121

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError, MAX_JOINT_TEAM_MEMBERS } from '@/shared'
import type { EntryTeamShare, Team } from '@/shared'
import {
addShareDraftTeam,
entryTeamSharesPath,
parseShareWeightText,
removeShareDraftTeam,
selectableShareTeams,
setShareDraftWeight,
shareDraftDirty,
shareDraftIssues,
shareDraftOf,
shareDraftPayload,
shareDraftView,
submitEntryTeamShares,
} from '../pages/entryShares'
import type { ShareDraftRow } from '../pages/entryShares'
/**
* 「成员单位与份额」(联队)的编辑模型与写请求契约(devdocs/19)。要钉住的是:
* 1. **默认一个单位**:单单位报名打开编辑器就是孤零零一行主单位、权重留空,什么都不改
* 等于没动过(保存按钮不会亮,请求也不会发)——常规报名流程一点没被碰到;
* 2. 留空 / 0 的权重按 1 算(均分),2:1 显示成 67% 与 33%;
* 3. 请求走 PUT /api/entries/{id}/team-shares,body 只带 teamId / weight,
* 写请求必须有 CSRF 与同一个幂等键(header 与 body 一致);
* 4. 服务端的中文 400 原样抛出,界面直接展示那句话。
*/
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: unknown
credentials?: string
}
let lastRequest: Captured = {}
let reply: () => Response = () => jsonResponse({ ok: true, data: {} })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
credentials: init?.credentials,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
function team(id: number, over: Partial<Team> = {}): Team {
return {
id,
code: `T${id}`,
name: `第${id}分公司`,
shortName: null,
logoPath: null,
color: null,
sortOrder: id,
active: true,
deletedAt: null,
...over,
}
}
function entryOf(shares: EntryTeamShare[] | undefined) {
return { teamId: 1, teamCode: 'A', teamName: '第一分公司', teamShares: shares }
}
test('普通报名(单单位)打开编辑器:只有主单位一行、权重留空,且判定为「没改动」', () => {
const draft = shareDraftOf(entryOf(undefined))
assert.equal(draft.length, 1)
assert.deepEqual(draft[0], { teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' })
assert.equal(shareDraftDirty(draft, shareDraftOf(entryOf(undefined))), false)
assert.deepEqual(shareDraftIssues(draft, 1, '第一分公司'), [])
})
test('份额缺省权重 1,编辑器里留空(不写一个没有意义的 1)', () => {
const draft = shareDraftOf(
entryOf([
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 2, teamCode: 'B', teamName: '第二分公司' },
]),
)
assert.deepEqual(
draft.map((row) => row.weightText),
['', '2'],
)
})
test('留空 / 0 的权重按 1 算(均分);负数单独报错', () => {
assert.deepEqual(parseShareWeightText(''), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText(' '), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText('0'), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText('abc'), { value: 1, negative: false })
assert.deepEqual(parseShareWeightText('1.5'), { value: 1.5, negative: false })
assert.deepEqual(parseShareWeightText('-2'), { value: 1, negative: true })
})
test('2:1 显示成 67% 与 33%,名字写全', () => {
const draft = shareDraftOf(
entryOf([
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
]),
)
const view = shareDraftView(draft)
assert.deepEqual(
view.map((share) => `${share.teamName} ${share.percent}%`),
['第一分公司 67%', '第二分公司 33%'],
)
})
test('加单位 / 改权重 / 去单位:改过就是脏,改回去又不脏', () => {
const original = shareDraftOf(entryOf(undefined))
let draft = addShareDraftTeam(original, team(2, { code: 'B', name: '第二分公司' }))
assert.equal(draft.length, 2)
assert.equal(shareDraftDirty(draft, original), true)
draft = setShareDraftWeight(draft, 1, '2')
assert.equal(shareDraftDirty(draft, original), true)
// 权重改回缺省、单位去掉 → 与初值完全一致,不该再算改动。
draft = setShareDraftWeight(draft, 1, '')
draft = removeShareDraftTeam(draft, 2)
assert.equal(shareDraftDirty(draft, original), false)
})
test('同一个单位加不进来两次;已删(假删除)的单位不进候选', () => {
const draft = shareDraftOf(entryOf(undefined))
const again = addShareDraftTeam(draft, team(1))
assert.equal(again.length, 1)
const candidates = selectableShareTeams(
[team(1), team(2), team(3, { deletedAt: '2026-09-01T00:00:00Z' })],
draft,
)
assert.deepEqual(
candidates.map((item) => item.id),
[2],
)
})
test('校验:缺主单位 / 单位重复 / 权重为负 / 超过成员上限都要说出来', () => {
const missingPrimary: ShareDraftRow[] = []
const issues = shareDraftIssues(missingPrimary, 1, '第一分公司')
assert.equal(issues.length, 1)
assert.match(issues[0] ?? '', /至少要有一个成员单位/)
const withoutPrimary = shareDraftOf(
entryOf([
{ teamId: 2, weight: 1, teamName: '第二分公司' },
{ teamId: 3, weight: 1, teamName: '第三分公司' },
]),
)
assert.match(shareDraftIssues(withoutPrimary, 1, '第一分公司').join(';'), /主单位「第一分公司」不能去掉/)
const duplicated = [
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
]
assert.match(shareDraftIssues(duplicated, 1, '第一分公司').join(';'), /出现了两次/)
const negative = [
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
{ teamId: 2, teamCode: 'B', teamName: '第二分公司', weightText: '-1' },
]
assert.match(shareDraftIssues(negative, 1, '第一分公司').join(';'), /权重是负数/)
const tooMany = Array.from({ length: MAX_JOINT_TEAM_MEMBERS + 1 }, (_, index) => ({
teamId: index + 1,
teamCode: `T${index + 1}`,
teamName: `第${index + 1}分公司`,
weightText: '',
}))
assert.match(shareDraftIssues(tooMany, 1, '第一分公司').join(';'), /最多 5 个单位/)
})
test('写侧载荷只带 teamId / weight(读侧的 teamCode / teamName 不上行)', () => {
const draft = shareDraftOf(
entryOf([
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
]),
)
assert.deepEqual(shareDraftPayload(draft), [
{ teamId: 1, weight: 1 },
{ teamId: 2, weight: 1 },
])
})
test('保存走 PUT /api/entries/{id}/team-shares,带 CSRF 与同一个幂等键', async () => {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-9; b=2' }
try {
respondWith(() =>
jsonResponse({
ok: true,
data: {
id: 7,
teamId: 1,
teamShares: [
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
],
},
}),
)
const outcome = await submitEntryTeamShares(7, [
{ teamId: 1, weight: 2 },
{ teamId: 2, weight: 1 },
])
assert.equal(lastRequest.url, entryTeamSharesPath(7))
assert.equal(lastRequest.url, '/api/entries/7/team-shares')
assert.equal(lastRequest.method, 'PUT')
assert.equal(lastRequest.credentials, 'include')
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9')
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
const headerId = lastRequest.headers?.['X-Request-Id']
assert.equal(typeof headerId, 'string')
assert.equal((lastRequest.body as { requestId?: unknown }).requestId, headerId)
assert.deepEqual((lastRequest.body as { teamShares: unknown }).teamShares, [
{ teamId: 1, weight: 2 },
{ teamId: 2, weight: 1 },
])
// 服务端回显的份额交给页面直接回填。
assert.equal(outcome.teamShares?.length, 2)
assert.equal(outcome.entry?.id, 7)
} finally {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
}
})
test('服务端 400 的中文原文原样抛出,前端不改写', async () => {
respondWith(() =>
jsonResponse(
{ ok: false, error: { code: 'VALIDATION_FAILED', message: 'teamShares 必须包含这条报名的主单位 1:主单位不能从份额里去掉(换主单位要重新建报名)' } },
400,
),
)
await assert.rejects(
() => submitEntryTeamShares(7, [{ teamId: 2, weight: 1 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.match(error.message, /必须包含这条报名的主单位/)
return true
},
)
})
test('网络不可达时说清「没有改成功」,不能让操作员以为已经落库', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => submitEntryTeamShares(7, [{ teamId: 1, weight: 1 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /没有改成功/)
return true
},
)
})
+181
View File
@@ -0,0 +1,181 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Event, ScoreChannel, ScoringProfile } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
/**
* 联队份额在录入页实时预览里的口径(devdocs/19,逐条对齐服务端 domain/scoring.go)。
*
* 录入页的「实时积分预览」是照着引擎写的第二份实现,它必须与服务端同口径:
* 联队那条报名拿到的 15 分要拆成 A、B 各 7.5,而不是整份记在主单位头上——
* 否则操作员看到的是「预览说 A +15」,发布后却是「A、B 各 +7.5」。
* 同时钉住:**没有份额(普通报名)时逐位不变**。
*/
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'BBALL',
name: '篮球',
eventType: 'team',
resultMode: 'auto',
resultType: 'score',
compare: 'desc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides }
}
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return {
id: 1,
eventId: 1,
scope: 'team',
basis: 'result',
profileId: 1,
sortOrder: 1,
countToTeam: true,
...overrides,
}
}
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
}
/** 团体第 1 名 15 分、第 2 名 9 分。 */
const PROFILE = makeProfile({
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 9 },
],
})
function close(actual: number, expected: number): void {
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
}
test('普通报名(没有 shares)时逐位不变:分整份记在主单位头上', () => {
const result = computeEventScore(
[{ entryId: 1, teamId: 100, athleteId: null, resultValue: 90, rank: null, status: 'ok', isTie: false }],
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
)
close(result.rowPoints.get(1) ?? 0, 15)
close(result.teamPoints.get(100) ?? 0, 15)
assert.deepEqual(result.rowTeamShares.get(1), [{ teamId: 100, points: 15 }])
})
test('联队 1:1:15 分拆成两个单位各 7.5,总分不变', () => {
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: null,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
)
// 表格里的「积分」列是这条报名拿到的分(还没拆),仍是 15。
close(result.rowPoints.get(1) ?? 0, 15)
close(result.teamPoints.get(100) ?? 0, 7.5)
close(result.teamPoints.get(200) ?? 0, 7.5)
assert.deepEqual(result.rowTeamShares.get(1), [
{ teamId: 100, points: 7.5 },
{ teamId: 200, points: 7.5 },
])
})
test('联队 2:1:拆成 10 与 5,两份之和正好等于原来的那一份分', () => {
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: null,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 2 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
)
close(result.teamPoints.get(100) ?? 0, 10)
close(result.teamPoints.get(200) ?? 0, 5)
close((result.teamPoints.get(100) ?? 0) + (result.teamPoints.get(200) ?? 0), 15)
})
test('个人通道的联队:分先按个人→单位系数折算,再按份额拆', () => {
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: 5,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel({ scope: 'athlete' })], profiles: [PROFILE], weight: 0.5 }),
)
// 15 分 × 0.5(个人→单位系数)= 7.5,再两家对半:各 3.75。
close(result.rowTeamPoints.get(1) ?? 0, 7.5)
close(result.teamPoints.get(100) ?? 0, 3.75)
close(result.teamPoints.get(200) ?? 0, 3.75)
})
test('聚合通道不拆份额:名次主体是单位,与服务端口径一致', () => {
const rows: ScorableRow[] = [
{
entryId: 1,
teamId: 100,
athleteId: 5,
resultValue: 90,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
{ entryId: 2, teamId: 200, athleteId: 6, resultValue: 80, rank: null, status: 'ok', isTie: false },
]
const result = computeEventScore(rows, makeContext({ channels: [makeChannel({ basis: 'aggregate' })], profiles: [PROFILE] }))
// 名次按单位平均分排:100 → 90,200 → 80,所以 100 拿 15、200 拿 9,都整份记给主体单位。
close(result.teamPoints.get(100) ?? 0, 15)
close(result.teamPoints.get(200) ?? 0, 9)
})