feat(scope): 报考范围聚合与一致性体检(M2-04/05)—— 把"人眼核对"换成"接口约束"

一、先纠正上一节里我自己写错的待办
   §一·二十三 的下一步写着「sys_eval_plan 那几张表还没落地,需要先定计划从哪来」——
   这是错的。PRD V2.0(L508)已明确:那三张计划表**作废删除**,因为机构根本不下发
   计划清单,真实名单里报考三项老师已经填好(28/28)。所以范围 = 那三列的去重组合。
   教训与 §七 同类:引用/怀疑已有设计之前先查文档,不凭记忆。

二、落地
   后端 PlanScopeMapper / PlanScopeService / PlanScopeVO / PlanScopeSaveReq / Controller
   前端 apis/xt/plan-scope.ts + 名册抽屉里的「报考范围」区块
   实体补上 plan_scope_json / plan_scope_confirmed(V1 建过列,只是没映射)

三、这一步真正的价值:确认时要求**恰好覆盖全员**
   rosterIds 并集 ≠ 名册全员 → 40024 并点名。漏一个人,导出包就缺人,
   而打开导出包时**不会有人数出 27 个**。所以"排除某人"的正确做法是把他所在的分组
   标成 EXCLUDED,而不是从 rosterIds 里删掉 —— 删掉会校验失败,提示正好告诉他
   "请明确排除,不要悄悄丢掉"。
   体检也必须说得出**是哪几个人**(每条 issue 带 rosterIds,服务端还把名字写进 detail)。

四、五类体检实现四类,一类有理由地缓做
   ✅ SCOPE_UNFILLED(只判职业:「无」是合法值)
   ✅ SCOPE_LEVEL_MIXED(**只把少数派算异常**,多数派是"正常那一批")
   ✅ SCOPE_TYPEO_MISMATCH
   ✅ SCOPE_OUT_OF_TASK(只在任务显式声明 jobName 时才判;**等级不比** ——
      名单写「四级/中级工」而任务写「四级」,精确比必然误报)
   ⛔ SCOPE_FREE_TEXT:要国标职业字典,而 sys_occupation_ref 一行数据都没有(FR-312 是 P2)。
      硬编一份列表会在机构用别的写法时全部误报,而误报会让人整体忽略体检 —— 比不做更糟。

   ⚠️ level 的口径:契约只给了值、没给语义,且 UNFILLED=1 与 FREE_TEXT=1 严重性明显不同。
   所以代码里只把 level 当排序/展示用,判 passed 时一视同仁 —— ignoredIssueCodes 的存在
   正说明"要不要放过"由人决定,不该按级别猜规则。

五、FR-311(追加名单告警,P0)一并做了
   范围确认后导入,出现范围外的报考组合 → 导入体检里出 SCOPE_OUT_OF_TASK 警告。
   范围内不告警 —— 两个方向都验了(不然就是"全报"或"全不报")。

六、E2E:scripts/e2e-m2-scope.py → 38/38
   夹具刻意做出:6×车工/四级 + 1×车工/三级(少数派)+ 1×铣工(任务声明车工)+ 1×漏填。
   浏览器实测:分组表、4 条体检(级别 + 说明 + 涉及谁 + 忽略勾选)、
   确认按钮在未处理时禁用、勾选忽略后解禁、确认后显示「已于 … 确认」且体检变通过。

七、顺带抓到的两个(都在我的测试代码里)
   1. 夹具身份证号出生日期写成 0000 → 9 行全被阻断,而导入接口仍回 code=0(只回体检报告)
      → 看着像"导进去了"。所以 E2E 除了判 code 还必须判 created==9,已补。
   2. call() 回元组、upload_roster() 回字典,我在新脚本里弄混了。同类:新写的代码最容易在细节上错。

八、回归
   全量 660 条断言、0 失败(新增 38);vue-tsc 0 错、eslint 0 错;verify-docs 19 项通过

九、客户端新提了一个架构要求(见 §一·二十五):租户按域名前缀区分(t1.xxx.com / t2.xxx.com)
This commit is contained in:
2026-09-29 21:28:49 +08:00
parent e85a25a97f
commit f964cf45fe
11 changed files with 1560 additions and 5 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* 报考范围接口(《03-接口设计》§7.7 / §7.8)。
*
* ## 它不是"从文件导入的计划"
*
* PRD V2.0 实测校正:**机构不下发计划清单**,而真实名单里「报考职业 / 报考工种 / 报考等级」
* 三列老师已经填好了(28/28)。所以这个范围就是**名册里这三列的去重组合**,
* 不是一份独立的文件。界面上要按这个事实说话:不是"上传计划",而是"核对从名单里
* 归出来的范围"。
*
* ## 确认按钮为什么会被禁用
*
* `PUT` 要求 `rosterIds` 的并集**恰好覆盖名册全员**(差一个人就 40024 并点名)——
* 少一个人,导出包就缺人,而打开导出包时不会有人数出 27 个。
* 所以界面上"排除某人"是把他所在的分组标成 `EXCLUDED`,而不是把他从名单里删掉。
*/
import http from '@/utils/http'
/** 一个分组 = 职业 × 工种 × 等级 的一个组合。 */
export interface PlanScopeGroup {
occupation: string | null
worktype: string | null
jobLevel: string | null
certType: number | null
count: number
rosterIds: number[]
/** `CONFIRMED` 已确认 / `NEED_REVIEW` 需人工确认 / `EXCLUDED` 已排除 */
status: string
issue: string | null
}
export interface PlanScopeIssue {
/** 契约没给语义词,只当排序与展示用 */
level: number
code: string
title: string
detail: string
/** ⚠️ 必须给:只说"存在不一致",管理员没法去核对 */
rosterIds: number[]
}
export interface PlanScopeHealth {
passed: boolean
issues: PlanScopeIssue[]
}
export interface PlanScopeVO {
source: string
updatedAt: string | null
rosterCount: number
confirmed: boolean
groups: PlanScopeGroup[]
healthCheck: PlanScopeHealth
}
export interface PlanScopeSaveReq {
groups: Array<{
occupation: string | null
worktype: string | null
jobLevel: string | null
certType?: number | null
rosterIds: number[]
status: string
}>
ignoredIssueCodes?: string[]
remark?: string
}
export function getPlanScope(taskId: number) {
return http.get<PlanScopeVO>(`/tasks/${taskId}/plan-scope`)
}
export function savePlanScope(taskId: number, data: PlanScopeSaveReq) {
return http.put<PlanScopeVO>(`/tasks/${taskId}/plan-scope`, data)
}
/** 问题码 → 一句短说明(详情服务端已经写了,这里只做标签) */
export const SCOPE_ISSUE_NAMES: Record<string, string> = {
SCOPE_UNFILLED: '漏填报考项',
SCOPE_LEVEL_MIXED: '同职业多等级',
SCOPE_TYPEO_MISMATCH: '认定类型与工种/等级不匹配',
SCOPE_OUT_OF_TASK: '任务范围外的职业',
SCOPE_FREE_TEXT: '与字典拼写不符'
}
/** 分组状态 */
export const SCOPE_STATUS: Record<string, { text: string, color: string }> = {
CONFIRMED: { text: '已确认', color: 'green' },
NEED_REVIEW: { text: '需确认', color: 'orange' },
EXCLUDED: { text: '已排除', color: 'gray' }
}
+226 -3
View File
@@ -271,7 +271,94 @@
</div>
</template>
<!-- ── 三、名册 ─────────────────────────────────── -->
<!-- ── 三、报考范围(从名册聚合,不是"上传计划")─────── -->
<a-divider />
<div class="roster__section">
报考范围({{ scope?.rosterCount ?? 0 }} 人)
<span v-if="scope?.confirmed">已于 {{ fmtTime(scope?.updatedAt) }} 确认</span>
<span v-else>尚未确认</span>
</div>
<a-alert type="info" :show-icon="false" class="roster__intro">
<template #title>
这里<strong>不是上传计划</strong>:范围是从名单里「报考职业 / 报考工种 / 报考等级」
三列<strong>归出来的</strong>(真实名单里这三列老师已经填好)。
确认之后,它就成为"追加名单是否出现范围外报考项"的基准。
</template>
</a-alert>
<a-spin :loading="scopeLoading">
<a-empty v-if="!scopeGroups.length" description="名册为空,还没有可聚合的报考范围" />
<template v-else>
<a-table :data="scopeGroups" :pagination="false" row-key="__key" size="small"
class="roster__table">
<template #columns>
<a-table-column title="报考组合(职业 / 工种 / 等级)" :width="330">
<template #cell="{ record }">
<div>{{ groupText(record) }}</div>
</template>
</a-table-column>
<a-table-column title="人数" :width="80" align="center" data-index="count" />
<a-table-column title="状态" :width="110" align="center">
<template #cell="{ record }">
<a-tag :color="SCOPE_STATUS[record.status]?.color ?? 'gray'" size="small">
{{ SCOPE_STATUS[record.status]?.text ?? record.status }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="110" align="center">
<template #cell="{ record }">
<a-link v-if="record.status !== 'EXCLUDED'" @click="toggleExclude(record)">
标为不参与
</a-link>
<a-link v-else @click="toggleExclude(record)">恢复参与</a-link>
</template>
</a-table-column>
</template>
</a-table>
<!-- 体检:必须说得出"是哪几个人" -->
<template v-if="scopeIssues.length">
<div class="roster__section roster__section--warn">
一致性体检发现 {{ scopeIssues.length }} 项,请逐项处理或确认忽略
</div>
<div v-for="is in scopeIssues" :key="is.code + is.title" class="roster__issue">
<div class="roster__issue-head">
<a-tag size="small" :color="is.level <= 1 ? 'red' : (is.level === 2 ? 'orange' : 'gray')">
级别 {{ is.level }}
</a-tag>
<strong>{{ is.title }}</strong>
<a-checkbox :model-value="ignoredCodes.includes(is.code)"
@change="toggleIgnore(is.code)">
已知悉,本次忽略
</a-checkbox>
</div>
<div class="roster__hint">{{ is.detail }}</div>
</div>
</template>
<a-alert v-else type="success" :show-icon="false" class="roster__intro">
体检通过:没有发现漏填、同职业多等级、范围外职业等问题。
</a-alert>
<div class="roster__footer">
<a-button type="primary" size="small" :loading="scopeSaving" :disabled="!canConfirmScope"
@click="confirmScope">
确认报考范围
</a-button>
<div class="roster__hint">
<template v-if="!canConfirmScope">
还有 {{ unhandledCount }} 项体检问题未处理:请修名单后重新导入,或逐项勾选「已知悉,本次忽略」。
</template>
<template v-else>
确认时会校验"名册里每个人都恰好被分到一组"——
漏掉一个人,导出包就会缺人,而打开导出包时看不出来。
</template>
</div>
</div>
</template>
</a-spin>
<!-- ── 四、名册明细 ─────────────────────────────── -->
<a-divider />
<div class="roster__section">
名册({{ total }} 人)
@@ -363,11 +450,14 @@
</template>
<script setup lang="ts">
import type { PlanScopeVO } from '@/apis/xt/plan-scope'
import type { RosterImportReport, RosterVO } from '@/apis/xt/roster'
import type { TaskVO } from '@/apis/xt/task'
import { Message, Modal } from '@arco-design/web-vue'
import { IconRefresh, IconUpload } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import * as scopeApi from '@/apis/xt/plan-scope'
import { SCOPE_STATUS } from '@/apis/xt/plan-scope'
import * as rosterApi from '@/apis/xt/roster'
import { SUBMIT_STATUS } from '@/apis/xt/roster'
@@ -591,7 +681,7 @@ async function open(task: TaskVO) {
query.submitStatus = undefined
query.bindStatus = undefined
query.pageNum = 1
await Promise.all([loadClasses(), loadList()])
await Promise.all([loadClasses(), loadList(), loadScope()])
}
function close() {
@@ -701,7 +791,8 @@ async function confirmImport() {
const removedPart = d?.removed ? `、移除 ${d.removed} 人` : ''
Message.success(`导入完成:新增 ${d?.created ?? 0} 人、更新 ${d?.updated ?? 0} 人${removedPart}`)
query.pageNum = 1
await loadList()
// 名册变了 → 报考范围也要重算(它是从名册聚合出来的)
await Promise.all([loadList(), loadScope()])
emit('save-success')
} finally {
importing.value = false
@@ -709,6 +800,121 @@ async function confirmImport() {
}
}
// ── 报考范围(§7.7 / §7.8)──────────────────────────
const scope = ref<PlanScopeVO | null>(null)
const scopeLoading = ref(false)
const scopeSaving = ref(false)
/** 本次勾选"已忽略"的问题码(确认时提交,服务端会把它从待办里去掉) */
const ignoredCodes = ref<string[]>([])
/** 本地改过的分组状态(确认之前不落库) */
const localStatus = ref<Record<string, string>>({})
/**
* 分组列表 —— 带上本地改过的状态。
*
* `__key` 是给表格用的 row-key:一个分组的身份就是「职业/工种/等级」三项,
* 用它当 key 比用下标稳(重算之后顺序可能变,下标当 key 会让"排除"跟错行)。
*/
const scopeGroups = computed(() =>
(scope.value?.groups ?? []).map((g) => {
const k = scopeKey(g)
return { ...g, __key: k, status: localStatus.value[k] ?? g.status }
})
)
const scopeIssues = computed(() => scope.value?.healthCheck?.issues ?? [])
/** 还没被处理(或明确忽略)的问题数 */
const unhandledCount = computed(
() => scopeIssues.value.filter((i) => !ignoredCodes.value.includes(i.code)).length
)
/**
* 能不能确认。
*
* ⚠️ 要求"体检问题都已处理或明确忽略"才让确认:`passed` 是给人看的,
* 而"我确认了"这件事必须是真的 —— 否则确认就变成了一个把红字变绿的按钮。
*/
const canConfirmScope = computed(
() => !!scope.value && scope.value.rosterCount > 0 && unhandledCount.value === 0
)
async function loadScope() {
if (!taskId.value) {
return
}
scopeLoading.value = true
try {
const res = await scopeApi.getPlanScope(taskId.value)
scope.value = res.data ?? null
localStatus.value = {}
// 服务端回来的是"已扣掉被忽略项"的清单,所以本地从空开始
ignoredCodes.value = []
} finally {
scopeLoading.value = false
}
}
function scopeKey(g: { occupation: string | null, worktype: string | null, jobLevel: string | null }) {
return `${g.occupation ?? ''}|${g.worktype ?? ''}|${g.jobLevel ?? ''}`
}
/** 组合的人话说法:「车工 / 数控车工 / 四级」;空的写「(未填)」而不是留白 */
function groupText(g: { occupation: string | null, worktype: string | null, jobLevel: string | null }) {
return [g.occupation ?? '(未填)', g.worktype ?? '无', g.jobLevel ?? '无'].join(' / ')
}
function fmtTime(t: string | null | undefined) {
return t ? String(t).replace('T', ' ').slice(0, 16) : '—'
}
/**
* 「标为不参与」不是把这个人从名单里删掉,而是**把他所在的分组标成 EXCLUDED**。
*
* 服务端要求名册全员恰好被覆盖一次:删掉人就过不了校验(40024 会点名他)。
* 所以界面上的措辞是"标为不参与",而不是"删除"—— 排除必须留下痕迹。
*/
function toggleExclude(g: { __key: string, status: string }) {
localStatus.value = {
...localStatus.value,
[g.__key]: g.status === 'EXCLUDED' ? 'CONFIRMED' : 'EXCLUDED'
}
}
function toggleIgnore(code: string) {
ignoredCodes.value = ignoredCodes.value.includes(code)
? ignoredCodes.value.filter((c) => c !== code)
: [...ignoredCodes.value, code]
}
async function confirmScope() {
if (!taskId.value || !scope.value) {
return
}
scopeSaving.value = true
try {
const res = await scopeApi.savePlanScope(taskId.value, {
groups: scopeGroups.value.map((g) => ({
occupation: g.occupation,
worktype: g.worktype,
jobLevel: g.jobLevel,
certType: g.certType,
rosterIds: g.rosterIds,
status: g.status
})),
ignoredIssueCodes: ignoredCodes.value
})
scope.value = res.data ?? null
localStatus.value = {}
ignoredCodes.value = []
Message.success('报考范围已确认')
emit('save-success')
} finally {
scopeSaving.value = false
}
}
defineExpose({ open })
</script>
@@ -776,5 +982,22 @@ defineExpose({ open })
&__footer { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; margin: 12px 0 4px; }
&__search { width: 240px; }
&__filter { width: 130px; }
/* 体检问题条目:左边一条竖线,让人一眼看出这是"待处理的事情"而不是一段说明 */
&__issue {
padding: 8px 12px;
margin-bottom: 8px;
background: var(--color-fill-1);
border-left: 3px solid rgb(var(--warning-6));
border-radius: 4px;
}
&__issue-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
}
</style>