@@ -190,6 +190,165 @@ class AdminService extends Service {
} ;
}
/**
* 获取「小组( 团队) 激活状态」清单( R84 续六新增 · 人工测试反馈需求)
*
* 需求原文:「在选手管理中加入一个查看已激活/未激活小组的功能,且必须携带小组所属学校名称」。
*
* 口径(本函数的**唯一真相源**,改动前请先读这里):
* - **小组的键 = (组别 group, 队伍号 teamUid)**。⛔ 绝不能用 teamUid 单独作键:
* 实测 `team_uid=1` 同时存在于 zhongzhi 与 benke( 见 `service/exam.js` checkTeamLock
* 的跨组复用防护注释),单独用 teamUid 会把两个不同组的小组并成一个。
* - **成员来源为双来源并集**(与 checkTeamLock 保持一致):
* ① `aggregation.team_uid`(报名表聚合填充,**同时是学校名称的来源**)
* ② `contestants.team_id`(导入流程填充,兼容旧数据)
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
* - **激活判据**: `activated` = 组内**全部**成员 `contestants.activated=1`。
* 三个细分状态:`activated`(全激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
* ⚠️ `partial` 在「已激活/未激活」二选一筛选里归入**未激活**(因为尚未全员可登录)。
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
*
* @param {Object} options - { group?, activated?, includeTest? }
* @returns {Promise<Object>} { list, summary, allSummary, filters }
*/
async getTeams ( options = { } ) {
const { ctx } = this ;
const { group , activated } = options ;
const includeTest = options . includeTest === true || options . includeTest === 'true' || options . includeTest === '1' ;
const GROUP _CN = { zhongzhi : '中职组' , gaozhi : '高职组' , benke : '本科组' , jiaoshi : '教师组' } ;
// ---- 1) 成员(选手):只取本功能所需列,避免把 password 等带出来 ----
const contestantWhere = { } ;
if ( group ) contestantWhere . group = group ;
if ( ! includeTest ) contestantWhere . test _flag = 0 ;
const contestants = await ctx . model . Contestant . findAll ( {
where : contestantWhere ,
attributes : [ 'id' , 'group' , 'team_id' , 'activated' ] ,
raw : true ,
} ) ;
const contestantById = new Map ( ) ;
for ( const c of contestants ) contestantById . set ( c . id , c ) ;
// ---- 2) 聚合报名(团队归属 + 学校名称)----
const aggs = await ctx . model . Aggregation . findAll ( {
attributes : [ 'account_uid' , 'team_uid' , 'organization_cn' ] ,
raw : true ,
} ) ;
// ---- 3) 团队元信息(人类可读队伍编号 team_id / team_number) ----
const teamWhere = group ? { group _name : group } : { } ;
const teamRows = await ctx . model . Team . findAll ( {
where : teamWhere ,
attributes : [ 'team_uid' , 'group_name' , 'team_id' , 'team_number' ] ,
raw : true ,
} ) ;
const teamMeta = new Map ( ) ;
for ( const t of teamRows ) teamMeta . set ( t . group _name + '#' + t . team _uid , t ) ;
// ---- 4) 归集:(组别, 队伍号) → 成员 / 激活数 / 学校 ----
const buckets = new Map ( ) ;
const keyOf = ( g , uid ) => g + '#' + uid ;
const touch = ( g , uid ) => {
const k = keyOf ( g , uid ) ;
let b = buckets . get ( k ) ;
if ( ! b ) {
b = { group : g , teamUid : uid , memberIds : new Set ( ) , activatedCount : 0 , schools : new Set ( ) } ;
buckets . set ( k , b ) ;
}
return b ;
} ;
// 4a) 来源一: aggregation.team_uid( 先跑, 作为「已归属」判定的基准)
const assigned = new Set ( ) ;
for ( const a of aggs ) {
if ( a . team _uid === null || a . team _uid === undefined ) continue ;
const c = contestantById . get ( a . account _uid ) ;
if ( ! c ) continue ; // 不在本次口径内(被组别/测试数据过滤掉)
const b = touch ( c . group , a . team _uid ) ;
if ( ! b . memberIds . has ( c . id ) ) {
b . memberIds . add ( c . id ) ;
if ( c . activated ) b . activatedCount += 1 ;
}
assigned . add ( c . id ) ;
const sch = typeof a . organization _cn === 'string' ? a . organization _cn . trim ( ) : '' ;
if ( sch ) b . schools . add ( sch ) ;
}
// 4b) 来源二: contestants.team_id( 兜底; 已由 aggregation 计过的选手跳过 ⇒ 防重复计数)
for ( const c of contestants ) {
if ( c . team _id === null || c . team _id === undefined ) continue ;
if ( assigned . has ( c . id ) ) continue ;
const b = touch ( c . group , c . team _id ) ;
if ( ! b . memberIds . has ( c . id ) ) {
b . memberIds . add ( c . id ) ;
if ( c . activated ) b . activatedCount += 1 ;
}
}
// ---- 5) 组装 + 状态判定 ----
const all = [ ] ;
for ( const b of buckets . values ( ) ) {
const memberCount = b . memberIds . size ;
const activatedCount = b . activatedCount ;
const meta = teamMeta . get ( keyOf ( b . group , b . teamUid ) ) || null ;
const status = activatedCount === 0
? 'inactive'
: ( activatedCount === memberCount ? 'activated' : 'partial' ) ;
all . push ( {
group : b . group ,
groupName : GROUP _CN [ b . group ] || b . group ,
teamUid : b . teamUid ,
// teams 表可能没有对应行(旧数据只写了 aggregation) ⇒ 回退为空,前端展示「—」
teamId : meta ? meta . team _id : null ,
teamNumber : meta ? ( meta . team _number || meta . team _id ) : null ,
schoolName : Array . from ( b . schools ) . join ( ' / ' ) ,
schoolConflict : b . schools . size > 1 ,
memberCount ,
activatedCount ,
activated : memberCount > 0 && activatedCount === memberCount ,
status ,
} ) ;
}
// 稳定排序:组别 → 队伍号(数值升序;非数值队号排后面)
const GROUP _ORDER = { zhongzhi : 0 , gaozhi : 1 , benke : 2 , jiaoshi : 3 } ;
all . sort ( ( x , y ) => {
const gx = GROUP _ORDER [ x . group ] === undefined ? 9 : GROUP _ORDER [ x . group ] ;
const gy = GROUP _ORDER [ y . group ] === undefined ? 9 : GROUP _ORDER [ y . group ] ;
if ( gx !== gy ) return gx - gy ;
return ( x . teamUid || 0 ) - ( y . teamUid || 0 ) ;
} ) ;
const summarize = arr => ( {
total : arr . length ,
activated : arr . filter ( t => t . status === 'activated' ) . length ,
partial : arr . filter ( t => t . status === 'partial' ) . length ,
inactive : arr . filter ( t => t . status === 'inactive' ) . length ,
memberTotal : arr . reduce ( ( s , t ) => s + t . memberCount , 0 ) ,
memberActivated : arr . reduce ( ( s , t ) => s + t . activatedCount , 0 ) ,
} ) ;
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
let list = all ;
if ( [ true , 1 , '1' , 'true' ] . includes ( activated ) ) {
list = all . filter ( t => t . activated === true ) ;
} else if ( [ false , 0 , '0' , 'false' ] . includes ( activated ) ) {
list = all . filter ( t => t . activated === false ) ;
}
return {
list ,
// summary 作用于**筛选前**的全集( 给总览用) ; filteredTotal 说明筛选后条数
summary : summarize ( all ) ,
filteredTotal : list . length ,
filters : { group : group || '' , activated : activated === undefined ? '' : activated , includeTest } ,
} ;
}
// ==================== Excel 导入 ====================
/**
@@ -226,6 +385,12 @@ class AdminService extends Service {
手机号 : [ '手机号' , '手机号码' , '联系电话' , 'phone' ] ,
组别 : [ '组别' , 'group' ] ,
身份 : [ '身份' , 'identity' ] ,
// R84 续六新增:上游「报名信息表」确实带「学校/单位名称全称」列,但此前列名契约
// **有意忽略**该列 ⇒ 落库时丢失 ⇒ 生产库 `aggregation.organization_cn`
// 600 行全为空( 2026-10-08 只读取证)⇒「小组所属学校」无从展示。
// 现补入契约,写入 `aggregation.organization_cn`(该列运行库既有,模型声明见
// `model/aggregation.js`)。列名以上游导出为准,另兼容几种常见写法以防改版。
学校 : [ '学校/单位名称全称' , '学校/单位名称全称(英文)' , '学校名称' , '单位名称' , '学校' , 'organization_cn' ] ,
} ;
// 按别名顺序取首个非空值(统一 trim, 返回字符串)
const pick = ( row , key ) => {
@@ -356,6 +521,8 @@ class AdminService extends Service {
const teamIdRaw = pick ( row , '队伍编号' ) ;
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
const identity = '选手' ;
// R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列)
const schoolName = pick ( row , '学校' ) ;
if ( ! idNumber ) {
result . errors . push ( { row : rowIndex , message : '身份证号为空' } ) ;
@@ -425,8 +592,16 @@ class AdminService extends Service {
account _uid : contestant . id ,
identity ,
is _registrant : true ,
// R84 续六:把「学校/单位名称全称」落库,供「选手管理 · 小组视图」展示所属学校。
// 取不到时写 null( 而非 ''),与「未填写」语义一致。
organization _cn : schoolName || null ,
} , { transaction : t } ) ;
}
// ⚠️ R84 续六注记(勿误以为「再导一次就能补学校」):
// 本函数在第 541-553 行已按 `id_number` 判重,**已存在的选手会整行 skip 并 rollback**,
// 根本走不到这里 ⇒ **重复导入不会回填历史行的学校**。
// 历史数据(生产 600 行 organization_cn 全空)必须走一次性回填工具:
// `scripts/backfill_organization_cn.py`(默认 dry-run, 只填空、不覆盖) 。
await t . commit ( ) ;
result . imported ++ ;