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:
@@ -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' }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user