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:
1 parent
b631418a85
commit
830a4dbdb7
61 files changed
+5649
-121
No files matched your search
@@ -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
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user