修复培训:支持逾期报到人工确认并修复学员选择器字段映射
fix(train): handle overdue report confirmation and student picker fields
This commit is contained in:
1 parent
eb1ddfcaa8
commit
62085e8a49
4 files changed
+370
-21
No files matched your search
@@ -60,6 +60,7 @@ export function confirmReport(enrollmentId, data) {
|
||||
return request({
|
||||
url: `/train/enrollments/${enrollmentId}/report`,
|
||||
method: 'post',
|
||||
data: data
|
||||
data: data,
|
||||
silentErrorCodes: [40051]
|
||||
})
|
||||
}
|
||||
@@ -110,8 +110,14 @@ service.interceptors.response.use(res => {
|
||||
Message({ message: msg, type: 'warning' })
|
||||
return Promise.reject('error')
|
||||
} else if (code !== 200) {
|
||||
Notification.error({ title: msg })
|
||||
return Promise.reject(new Error(msg))
|
||||
const silentCodes = (res.config && res.config.silentErrorCodes) || []
|
||||
if (!silentCodes.includes(code)) {
|
||||
Notification.error({ title: msg })
|
||||
}
|
||||
const err = new Error(msg)
|
||||
err.code = code
|
||||
err.data = res.data
|
||||
return Promise.reject(err)
|
||||
} else {
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
<el-form :model="studentSearchQuery" size="small" :inline="true">
|
||||
<el-form-item label="学员检索">
|
||||
<el-input
|
||||
v-model="studentSearchQuery.studentName"
|
||||
v-model="studentSearchQuery.sName"
|
||||
placeholder="请输入姓名检索学员库"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@@ -180,12 +180,24 @@
|
||||
>
|
||||
<el-table-column label="选择" width="55" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-radio v-model="addDialog.selectedStudentId" :label="scope.row.sId" />
|
||||
<el-radio v-model="addDialog.selectedStudentId" :label="scope.row.sId || scope.row.studentId" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="姓名" prop="sName" min-width="100">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.sName || scope.row.studentName || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" prop="sGender" width="65" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ formatGender(scope.row.sGender !== undefined && scope.row.sGender !== null ? scope.row.sGender : scope.row.gender) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="身份证号" prop="idCardMasked" min-width="170" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.idCardMasked || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="姓名" prop="studentName" min-width="100" />
|
||||
<el-table-column label="性别" prop="gender" width="65" align="center" />
|
||||
<el-table-column label="身份证号" prop="idCardMasked" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="选派单位" prop="orgName" min-width="140" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
@@ -193,7 +205,7 @@
|
||||
<el-form ref="addForm" :model="addForm" :rules="addRules" label-width="110px" size="small">
|
||||
<el-form-item label="选中学员" prop="studentId">
|
||||
<el-input
|
||||
:value="addDialog.selectedStudent ? (addDialog.selectedStudent.studentName + (addDialog.selectedStudent.orgName ? ' - ' + addDialog.selectedStudent.orgName : '')) : ''"
|
||||
:value="selectedStudentText"
|
||||
readonly
|
||||
placeholder="请在上方列表点击选择学员"
|
||||
/>
|
||||
@@ -377,7 +389,7 @@ export default {
|
||||
studentCandidates: []
|
||||
},
|
||||
studentSearchQuery: {
|
||||
studentName: ''
|
||||
sName: ''
|
||||
},
|
||||
addForm: {
|
||||
studentId: '',
|
||||
@@ -408,6 +420,13 @@ export default {
|
||||
computed: {
|
||||
isPlanLocked() {
|
||||
return this.planStatus === 2 || this.planStatus === 3
|
||||
},
|
||||
selectedStudentText() {
|
||||
const s = this.addDialog.selectedStudent
|
||||
if (!s) return ''
|
||||
const name = (s.sName || s.studentName || '').trim()
|
||||
const org = s.orgName ? ` - ${s.orgName}` : ''
|
||||
return name ? `${name}${org}` : '-'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -503,12 +522,17 @@ export default {
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
formatGender(val) {
|
||||
if (val === '0' || val === 0 || val === '男') return '男'
|
||||
if (val === '1' || val === 1 || val === '女') return '女'
|
||||
return '-'
|
||||
},
|
||||
// ---- 添加单名学员 ----
|
||||
handleOpenAddModal() {
|
||||
this.addDialog.selectedStudentId = null
|
||||
this.addDialog.selectedStudent = null
|
||||
this.addDialog.studentCandidates = []
|
||||
this.studentSearchQuery.studentName = ''
|
||||
this.studentSearchQuery.sName = ''
|
||||
this.addForm = {
|
||||
studentId: '',
|
||||
sourceType: 1,
|
||||
@@ -520,10 +544,11 @@ export default {
|
||||
},
|
||||
searchStudentPool() {
|
||||
this.addDialog.searchLoading = true
|
||||
const keyword = (this.studentSearchQuery.sName || '').trim()
|
||||
listStudent({
|
||||
pageNum: 1,
|
||||
pageSize: 20,
|
||||
studentName: this.studentSearchQuery.studentName
|
||||
sName: keyword || undefined
|
||||
}).then(res => {
|
||||
this.addDialog.studentCandidates = res.rows || []
|
||||
}).finally(() => {
|
||||
@@ -532,9 +557,10 @@ export default {
|
||||
},
|
||||
handleSelectStudent(row) {
|
||||
if (row) {
|
||||
this.addDialog.selectedStudentId = row.sId
|
||||
const studentId = row.sId || row.studentId
|
||||
this.addDialog.selectedStudentId = studentId
|
||||
this.addDialog.selectedStudent = row
|
||||
this.addForm.studentId = row.sId
|
||||
this.addForm.studentId = studentId
|
||||
}
|
||||
},
|
||||
submitAddForm() {
|
||||
@@ -593,19 +619,45 @@ export default {
|
||||
})
|
||||
},
|
||||
handleConfirmReport(row) {
|
||||
this.$confirm(`确认学员【${row.studentName}】现场报到到班?`, '报到确认', {
|
||||
const studentName = row.studentName || row.sName || '该学员'
|
||||
this.$confirm(`确认学员【${studentName}】现场报到到班?`, '报到确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}).then(() => {
|
||||
return confirmReport(row.enrollmentId, {
|
||||
reportWay: 4,
|
||||
verifyNote: '管理员人工核验到班'
|
||||
})
|
||||
}).then(() => {
|
||||
this.doConfirmReport(row, false)
|
||||
}).catch(() => {})
|
||||
},
|
||||
doConfirmReport(row, forceOverride = false) {
|
||||
const payload = {
|
||||
reportWay: 4,
|
||||
verifyNote: '管理员人工核验到班'
|
||||
}
|
||||
if (forceOverride) {
|
||||
payload.forceOverride = true
|
||||
}
|
||||
return confirmReport(row.enrollmentId, payload).then(() => {
|
||||
this.$modal.msgSuccess('报到成功')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
}).catch(err => {
|
||||
const isOverdueWarning = err && (
|
||||
err.code === 40051 ||
|
||||
(err.message && (err.message.includes('40051') || err.message.includes('已超过本期培训报到截止时间') || err.message.includes('报到截止时间')))
|
||||
)
|
||||
if (!forceOverride && isOverdueWarning) {
|
||||
this.$confirm('该学员已超过本期培训报到截止时间,是否确认逾期报到?', '逾期报到提醒', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
this.doConfirmReport(row, true)
|
||||
}).catch(() => {
|
||||
// 用户取消:不产生任何状态变更
|
||||
})
|
||||
} else {
|
||||
this.$modal.msgError((err && err.message) || '报到失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const planPagePath = path.resolve(__dirname, '../../src/views/train/plan/index.vue');
|
||||
const enrollPanelPath = path.resolve(__dirname, '../../src/views/train/plan/components/EnrollmentPanel.vue');
|
||||
const requestPath = path.resolve(__dirname, '../../src/utils/request.js');
|
||||
const enrollApiPath = path.resolve(__dirname, '../../src/api/train/enrollment.js');
|
||||
|
||||
const planSource = fs.readFileSync(planPagePath, 'utf8');
|
||||
const enrollSource = fs.readFileSync(enrollPanelPath, 'utf8');
|
||||
const requestSource = fs.readFileSync(requestPath, 'utf8');
|
||||
const enrollApiSource = fs.readFileSync(enrollApiPath, 'utf8');
|
||||
|
||||
console.log('=== Running Phase 0 Product Defects Remediation Regression Suite ===\n');
|
||||
|
||||
// ==========================================
|
||||
// 1. DEFECT 1 — REPORT-OVERDUE-01 (BLOCKER)
|
||||
// ==========================================
|
||||
console.log('--- Checking DEFECT 1: REPORT-OVERDUE-01 ---');
|
||||
{
|
||||
// 1.1 request-level silentErrorCodes contract
|
||||
assert.strictEqual(
|
||||
requestSource.includes('const silentCodes = (res.config && res.config.silentErrorCodes) || []'),
|
||||
true,
|
||||
'request.js 必须通过 res.config.silentErrorCodes 按需抑制全局 Notification'
|
||||
);
|
||||
assert.strictEqual(
|
||||
requestSource.includes('!silentCodes.includes(code)'),
|
||||
true,
|
||||
'当业务错误码不在 silentErrorCodes 中时必须正常触发 Notification.error'
|
||||
);
|
||||
assert.strictEqual(
|
||||
enrollApiSource.includes('silentErrorCodes: [40051]'),
|
||||
true,
|
||||
'confirmReport API 请求必须显式声明 silentErrorCodes: [40051]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
requestSource.includes('err.code = code'),
|
||||
true,
|
||||
'request.js 必须在 rejected Error 上附加业务错误码 code'
|
||||
);
|
||||
|
||||
// 1.2 模拟 request.js 拦截器行为:仅当请求配置 silentErrorCodes 包含对应 code 时抑制
|
||||
let notifications = [];
|
||||
const mockNotification = {
|
||||
error: (opts) => { notifications.push(opts.title); }
|
||||
};
|
||||
|
||||
const simulateResponseInterceptor = (res) => {
|
||||
const code = res.data.code || 200;
|
||||
const msg = res.data.msg || '系统未知错误';
|
||||
if (code !== 200) {
|
||||
const silentCodes = (res.config && res.config.silentErrorCodes) || [];
|
||||
if (!silentCodes.includes(code)) {
|
||||
mockNotification.error({ title: msg });
|
||||
}
|
||||
const err = new Error(msg);
|
||||
err.code = code;
|
||||
err.data = res.data;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return Promise.resolve(res.data);
|
||||
};
|
||||
|
||||
// 测试用例 A: confirmReport 携带 silentErrorCodes: [40051],40051 被抑制
|
||||
notifications = [];
|
||||
try {
|
||||
await simulateResponseInterceptor({
|
||||
data: { code: 40051, msg: '报到时限提醒' },
|
||||
config: { url: '/train/enrollments/1/report', silentErrorCodes: [40051] }
|
||||
});
|
||||
} catch (e) {
|
||||
assert.strictEqual(e.code, 40051);
|
||||
}
|
||||
assert.strictEqual(notifications.length, 0, 'confirmReport 请求收到 40051 不得触发全局 Notification.error');
|
||||
|
||||
// 测试用例 B: 其他普通请求收到 40051,未配置 silentErrorCodes,必须正常弹出 Notification.error
|
||||
notifications = [];
|
||||
try {
|
||||
await simulateResponseInterceptor({
|
||||
data: { code: 40051, msg: '其他模块时限提醒' },
|
||||
config: { url: '/train/other/action' }
|
||||
});
|
||||
} catch (e) {
|
||||
assert.strictEqual(e.code, 40051);
|
||||
}
|
||||
assert.strictEqual(notifications.length, 1, '未配置 silentErrorCodes 的普通请求收到 40051 必须正常弹出全局 Notification.error');
|
||||
assert.strictEqual(notifications[0], '其他模块时限提醒');
|
||||
|
||||
// 1.3 EnrollmentPanel.vue: doConfirmReport & 循环防重控制
|
||||
assert.strictEqual(
|
||||
enrollSource.includes('!forceOverride && isOverdueWarning'),
|
||||
true,
|
||||
'二次确认弹窗必须严格限定在首次提交 (!forceOverride) 且发生逾期警告时触发'
|
||||
);
|
||||
assert.strictEqual(
|
||||
enrollSource.includes('this.$modal.msgError((err && err.message) || \'报到失败\')'),
|
||||
true,
|
||||
'override 请求失败或非逾期错误必须结束流程并展示实际错误,不得再次弹窗'
|
||||
);
|
||||
|
||||
// 1.4 模拟报到确认全生命周期:
|
||||
// 场景 A: initial 40051 -> confirm dialog exactly once -> cancel -> no mutation
|
||||
// 场景 B: initial 40051 -> confirm dialog exactly once -> confirm -> override success
|
||||
// 场景 C: initial 40051 -> confirm -> override request fails with 40051 again -> NO second dialog, error message displayed
|
||||
let dialogShownCount = 0;
|
||||
let modalErrors = [];
|
||||
|
||||
const simulateDoConfirmReport = async (row, forceOverride = false, backendBehavior = 'normal_overdue') => {
|
||||
const payload = {
|
||||
reportWay: 4,
|
||||
verifyNote: '管理员人工核验到班'
|
||||
};
|
||||
if (forceOverride) {
|
||||
payload.forceOverride = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!forceOverride) {
|
||||
const err = new Error('已超过本期培训报到截止时间,触发时限提醒');
|
||||
err.code = 40051;
|
||||
throw err;
|
||||
}
|
||||
if (backendBehavior === 'override_fails_again') {
|
||||
const err = new Error('强制逾期报到仍被拒绝:业务已封存');
|
||||
err.code = 40051;
|
||||
throw err;
|
||||
}
|
||||
return { code: 200, msg: '报到成功' };
|
||||
} catch (err) {
|
||||
const isOverdueWarning = err && (
|
||||
err.code === 40051 ||
|
||||
(err.message && (err.message.includes('40051') || err.message.includes('已超过本期培训报到截止时间') || err.message.includes('报到截止时间')))
|
||||
);
|
||||
if (!forceOverride && isOverdueWarning) {
|
||||
dialogShownCount++;
|
||||
// 模拟用户确认
|
||||
return simulateDoConfirmReport(row, true, backendBehavior);
|
||||
} else {
|
||||
modalErrors.push(err.message || '报到失败');
|
||||
return { failed: true, error: err };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 回归测试 1: 首次 40051 弹窗刚好 1 次,用户确认后二次提交成功
|
||||
dialogShownCount = 0;
|
||||
modalErrors = [];
|
||||
const resSuccess = await simulateDoConfirmReport({ enrollmentId: 101 }, false, 'normal_overdue');
|
||||
assert.strictEqual(dialogShownCount, 1, '逾期报到确认弹窗必须刚好出现 1 次');
|
||||
assert.strictEqual(resSuccess.code, 200, '二次确认携带 override 后成功');
|
||||
assert.strictEqual(modalErrors.length, 0, '成功流程不得展示错误弹窗');
|
||||
|
||||
// 回归测试 2: 二次提交 (forceOverride=true) 再次收到 40051,严禁再次弹窗,必须结束流程并展示实际错误
|
||||
dialogShownCount = 0;
|
||||
modalErrors = [];
|
||||
const resFail = await simulateDoConfirmReport({ enrollmentId: 101 }, false, 'override_fails_again');
|
||||
assert.strictEqual(dialogShownCount, 1, 'override 再次失败时不得出现第 2 次确认弹窗');
|
||||
assert.strictEqual(modalErrors.length, 1, 'override 失败必须展示实际错误');
|
||||
assert.strictEqual(modalErrors[0], '强制逾期报到仍被拒绝:业务已封存');
|
||||
|
||||
console.log('✔ PASS: REPORT-OVERDUE-01 verified (request-level suppression + no confirmation loop + single prompt)');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. DEFECT 2 — ENROLL-PICKER-01 (MAJOR)
|
||||
// ==========================================
|
||||
console.log('\n--- Checking DEFECT 2: ENROLL-PICKER-01 ---');
|
||||
{
|
||||
// 2.1 修复 sGender=0 的 falsy 缺陷
|
||||
assert.strictEqual(
|
||||
enrollSource.includes('scope.row.sGender !== undefined && scope.row.sGender !== null ? scope.row.sGender : scope.row.gender'),
|
||||
true,
|
||||
'性别列必须显式判断 scope.row.sGender !== undefined && !== null,禁止将 0 当作 falsy 过滤'
|
||||
);
|
||||
|
||||
const formatGender = (val) => {
|
||||
if (val === '0' || val === 0 || val === '男') return '男';
|
||||
if (val === '1' || val === 1 || val === '女') return '女';
|
||||
return '-';
|
||||
};
|
||||
|
||||
// 性别映射全覆盖测试
|
||||
assert.strictEqual(formatGender(0), '男', 'sGender=0 必须正确渲染为 男');
|
||||
assert.strictEqual(formatGender('0'), '男', 'sGender="0" 必须正确渲染为 男');
|
||||
assert.strictEqual(formatGender(1), '女', 'sGender=1 必须正确渲染为 女');
|
||||
assert.strictEqual(formatGender('1'), '女', 'sGender="1" 必须正确渲染为 女');
|
||||
assert.strictEqual(formatGender(null), '-', 'null 必须安全退化为 -');
|
||||
assert.strictEqual(formatGender(undefined), '-', 'undefined 必须安全退化为 -');
|
||||
assert.strictEqual(formatGender(''), '-', '空字符串必须安全退化为 -');
|
||||
|
||||
// 2.2 验证表格列字段映射
|
||||
assert.strictEqual(
|
||||
enrollSource.includes('prop="sName"'),
|
||||
true,
|
||||
'学员姓名列 prop 必须为 sName'
|
||||
);
|
||||
assert.strictEqual(
|
||||
enrollSource.includes('scope.row.sName || scope.row.studentName || \'-\''),
|
||||
true,
|
||||
'学员姓名展示必须优先取 sName'
|
||||
);
|
||||
assert.strictEqual(
|
||||
enrollSource.includes('prop="sGender"'),
|
||||
true,
|
||||
'学员性别列 prop 必须为 sGender'
|
||||
);
|
||||
assert.strictEqual(
|
||||
enrollSource.includes('scope.row.idCardMasked || \'-\''),
|
||||
true,
|
||||
'身份证号列必须绑定 idCardMasked'
|
||||
);
|
||||
|
||||
// 2.3 收敛 StudentQuery 检索字段:严格单字段 sName
|
||||
assert.strictEqual(
|
||||
enrollSource.includes("studentSearchQuery: {\n sName: ''\n }"),
|
||||
true,
|
||||
'studentSearchQuery 仅保留单一契约字段 sName,删除冗余的 studentName'
|
||||
);
|
||||
assert.strictEqual(
|
||||
enrollSource.includes("sName: keyword || undefined"),
|
||||
true,
|
||||
'searchStudentPool 请求参数仅发送 sName: keyword'
|
||||
);
|
||||
assert.strictEqual(
|
||||
enrollSource.includes("studentName: keyword"),
|
||||
false,
|
||||
'严禁在请求中同时冗余发送 studentName'
|
||||
);
|
||||
|
||||
// 2.4 selectedStudentText 缺失姓名时占位符修复:严禁退回 sId / studentId
|
||||
const getSelectedStudentText = (selectedStudent) => {
|
||||
if (!selectedStudent) return '';
|
||||
const name = (selectedStudent.sName || selectedStudent.studentName || '').trim();
|
||||
const org = selectedStudent.orgName ? ` - ${selectedStudent.orgName}` : '';
|
||||
return name ? `${name}${org}` : '-';
|
||||
};
|
||||
|
||||
// 测试用例 A: 完整学员
|
||||
const normalStudent = {
|
||||
sId: 'S1001',
|
||||
sName: '张伟',
|
||||
orgName: '大连供电段'
|
||||
};
|
||||
assert.strictEqual(getSelectedStudentText(normalStudent), '张伟 - 大连供电段');
|
||||
|
||||
// 测试用例 B: 姓名缺失但存在 sId / studentId 的异常情况
|
||||
const anonymousStudent = {
|
||||
sId: 'S9999',
|
||||
studentId: 9999,
|
||||
sName: '',
|
||||
orgName: '沈阳车辆段'
|
||||
};
|
||||
const textAnonymous = getSelectedStudentText(anonymousStudent);
|
||||
assert.strictEqual(textAnonymous, '-', '姓名缺失时必须展示明确占位符 -,严禁退化展示内部数字/编号 ID');
|
||||
assert.strictEqual(textAnonymous.includes('S9999'), false, '展示严禁包含 internal ID');
|
||||
assert.strictEqual(textAnonymous.includes('undefined'), false, '展示严禁包含 undefined');
|
||||
|
||||
console.log('✔ PASS: ENROLL-PICKER-01 verified (sGender falsy fixed + singular sName search + human-readable display)');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. AUDIT: PLAN-EDIT-01 & PLAN-DATE-RULE-01
|
||||
// ==========================================
|
||||
console.log('\n--- Auditing PLAN-EDIT-01 & PLAN-DATE-RULE-01 (Scope Rollback) ---');
|
||||
{
|
||||
assert.strictEqual(
|
||||
planSource.includes("this.form.startTime >= this.form.endTime"),
|
||||
true,
|
||||
'原始计划表单已包含 startTime >= endTime 校验'
|
||||
);
|
||||
assert.strictEqual(
|
||||
planSource.includes("this.form.reportDeadline > this.form.startTime"),
|
||||
true,
|
||||
'原始计划表单已包含 reportDeadline > startTime 校验'
|
||||
);
|
||||
assert.strictEqual(
|
||||
planSource.includes("this.$modal.msgError('报到截止时间不得晚于开班时间')"),
|
||||
true,
|
||||
'原始计划表单已包含明确错误提示阻止非法提交'
|
||||
);
|
||||
console.log('✔ PASS: Verified PLAN-EDIT-01 and PLAN-DATE-RULE-01 are cleanly retained at baseline with original checks intact.');
|
||||
}
|
||||
|
||||
console.log('\n================================================================');
|
||||
console.log('ALL PHASE 0 DEFECT REMEDIATION REGRESSION TESTS PASSED (0 FAIL)');
|
||||
console.log('================================================================');
|
||||
Reference in new issue
Block a user