修复稳定化:接入考勤人工调整闭环并增加住宿与门禁历史删除防护

This commit is contained in:
wangzhibiao committed 2026-09-20 14:09:45 +08:00
1 parent 5bcb6273ef
commit b1433dcaa1
7 files changed
+491 -9

No files matched your search

@@ -0,0 +1,233 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormAuthMapper;
import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl;
import com.ruoyi.train.service.impl.TrainDormAuthServiceImpl;
/**
* 试用前最终内部缺陷修复 (PRE-CUSTOMER FINAL INTERNAL FIXES) 契约与防护测试:
* 1. 考勤人工修正 Controller 契约与权限一致性 (train:attendance:edit)
* 2. 住宿历史记录物理删除拦截 (ACCOMMODATION_HISTORY_PHYSICAL_DELETE_BLOCKED)
* 3. 门禁注销授权与操作流水物理删除拦截 (DOOR_AUTH_HISTORY_PHYSICAL_DELETE_BLOCKED)
*/
public class PreCustomerStabilizationFinalFixesContractTest
{
private TrainAccommodationServiceImpl accommodationService;
private TrainDormAuthServiceImpl dormAuthService;
private Map<Long, TrainAccommodation> accommodationStore;
private Map<Long, TrainDormAuth> authStore;
@Before
public void setUp() throws Exception
{
accommodationService = new TrainAccommodationServiceImpl();
dormAuthService = new TrainDormAuthServiceImpl();
accommodationStore = new HashMap<>();
authStore = new HashMap<>();
TrainAccommodationMapper mockAccMapper = (TrainAccommodationMapper) java.lang.reflect.Proxy.newProxyInstance(
TrainAccommodationMapper.class.getClassLoader(),
new Class<?>[]{TrainAccommodationMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("selectTrainAccommodationByAccommodationId".equals(name))
{
return accommodationStore.get(args[0]);
}
if ("deleteTrainAccommodationByAccommodationId".equals(name))
{
return accommodationStore.remove(args[0]) != null ? 1 : 0;
}
if (method.getReturnType().equals(int.class)) return 0;
return null;
}
);
TrainDormAuthMapper mockAuthMapper = (TrainDormAuthMapper) java.lang.reflect.Proxy.newProxyInstance(
TrainDormAuthMapper.class.getClassLoader(),
new Class<?>[]{TrainDormAuthMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("selectTrainDormAuthByAuthId".equals(name))
{
return authStore.get(args[0]);
}
if ("deleteTrainDormAuthByAuthId".equals(name))
{
return authStore.remove(args[0]) != null ? 1 : 0;
}
if (method.getReturnType().equals(int.class)) return 0;
return null;
}
);
Field accField = TrainAccommodationServiceImpl.class.getDeclaredField("trainAccommodationMapper");
accField.setAccessible(true);
accField.set(accommodationService, mockAccMapper);
Field authField = TrainDormAuthServiceImpl.class.getDeclaredField("trainDormAuthMapper");
authField.setAccessible(true);
authField.set(dormAuthService, mockAuthMapper);
}
/**
* 1. 验证 AttendanceController 的人工调整契约与权限注解严格为 train:attendance:edit
*/
@Test
public void testAttendanceAdjustControllerContract() throws Exception
{
Method adjustMethod = AttendanceController.class.getMethod("adjust", Long.class, AttendanceAdjustDTO.class);
assertNotNull("adjust 方法必须存在", adjustMethod);
PutMapping putMapping = adjustMethod.getAnnotation(PutMapping.class);
assertNotNull("必须声明 @PutMapping", putMapping);
assertEquals("/{checkinId}", putMapping.value()[0]);
PreAuthorize preAuth = adjustMethod.getAnnotation(PreAuthorize.class);
assertNotNull("必须声明 @PreAuthorize 权限校验", preAuth);
assertEquals("@ss.hasPermi('train:attendance:edit')", preAuth.value());
}
/**
* 2. 住宿历史删除防护:
* A. 预分配错误/尚未真实入住:允许物理清理
* B. 活跃在住或已退宿真实历史:强行拦截并保护历史事实
*/
@Test
public void testAccommodationHistoricalDeleteGuard()
{
// Case A: 预分配错误 (STATUS_PREALLOCATED = 0)
TrainAccommodation draft = new TrainAccommodation();
draft.setAccommodationId(9001L);
draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
accommodationStore.put(draft.getAccommodationId(), draft);
int draftDeleted = accommodationService.deleteTrainAccommodationByAccommodationId(9001L);
assertEquals("未实际入住的预分配记录允许纠错删除", 1, draftDeleted);
// Case B1: 活跃在住 (STATUS_CHECKED_IN = 1)
TrainAccommodation active = new TrainAccommodation();
active.setAccommodationId(9002L);
active.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
accommodationStore.put(active.getAccommodationId(), active);
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(9002L);
fail("在住记录禁止物理删除");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("当前学员正处于已入住状态"));
}
// Case B2: 已退宿历史记录 (STATUS_CHECKED_OUT = 2) - 核心历史履历防护
TrainAccommodation historical = new TrainAccommodation();
historical.setAccommodationId(9003L);
historical.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT);
accommodationStore.put(historical.getAccommodationId(), historical);
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(9003L);
fail("已退宿真实历史记录禁止物理删除,必须保留历史履历事实");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史履历")
|| e.getMessage().contains("真实历史入住与退宿事实"));
}
assertNotNull("历史退宿记录在库中依然完好保存", accommodationStore.get(9003L));
}
/**
* 3. 门禁注销授权与操作流水物理删除拦截防护:
* A. 初始草稿尚未产生操作:允许删除
* B. 生效中/授权中:必须先注销
* C. 已注销/已关联外部流水:严禁物理删除,保全审计溯源链
*/
@Test
public void testDoorAuthHistoryPhysicalDeleteGuard()
{
// Case A: 初始草稿待下发 (STATUS_PENDING / AUTH_STATUS_PENDING_GRANT)
TrainDormAuth draft = new TrainDormAuth();
draft.setAuthId(8001L);
draft.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING);
draft.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
authStore.put(draft.getAuthId(), draft);
int draftDeleted = dormAuthService.deleteTrainDormAuthByAuthId(8001L);
assertEquals("无外部流水草稿允许清理", 1, draftDeleted);
// Case B: 生效中授权 (ACTIVE)
TrainDormAuth active = new TrainDormAuth();
active.setAuthId(8002L);
active.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE);
active.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
authStore.put(active.getAuthId(), active);
try
{
dormAuthService.deleteTrainDormAuthByAuthId(8002L);
fail("生效中门禁授权禁止直接删除");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("当前门禁授权处于生效或授权中状态"));
}
// Case C: 已注销授权 (REVOKED) - 核心安全审计链防护
TrainDormAuth revoked = new TrainDormAuth();
revoked.setAuthId(8003L);
revoked.setFaceSyncStatus(TrainDormAuth.STATUS_REVOKED);
revoked.setAuthStatus(TrainDormAuth.AUTH_STATUS_REVOKED);
authStore.put(revoked.getAuthId(), revoked);
try
{
dormAuthService.deleteTrainDormAuthByAuthId(8003L);
fail("已注销门禁授权严禁物理删除,以保障审计链完整");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史凭证")
|| e.getMessage().contains("已进入授权生命周期"));
}
assertNotNull("已注销门禁授权依然完好存在", authStore.get(8003L));
// Case D: 带有外部操作流水关联 (latestOperationId != null)
TrainDormAuth opLinked = new TrainDormAuth();
opLinked.setAuthId(8004L);
opLinked.setLatestOperationId(6666L);
authStore.put(opLinked.getAuthId(), opLinked);
try
{
dormAuthService.deleteTrainDormAuthByAuthId(8004L);
fail("关联外部流水的门禁授权严禁物理删除");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("产生外部审计流水"));
}
}
}
@@ -364,10 +364,20 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
{ {
return 0; return 0;
} }
// 业务历史保护:若为活跃已入住状态,应先退宿而不是直接物理删除 // 业务活跃保护:若为活跃已入住状态,禁止物理删除住宿安排
if (TrainAccommodation.STATUS_CHECKED_IN.equals(exist.getAccommodationStatus())) if (TrainAccommodation.STATUS_CHECKED_IN.equals(exist.getAccommodationStatus()))
{ {
throw new ServiceException("当前学员正处于已入住状态,请先办理退宿后再执行清理"); throw new ServiceException("当前学员正处于已入住状态,禁止物理删除住宿安排");
}
// 历史履历保护:若为已退宿状态,说明曾产生真实入住与退宿事实,禁止物理删除历史履历
if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus()))
{
throw new ServiceException("该住宿记录已产生真实历史入住与退宿事实,禁止物理删除历史履历");
}
// 安全策略:仅允许清理尚未实际入住的预分配草稿/错误记录 (STATUS_PREALLOCATED)
if (!TrainAccommodation.STATUS_PREALLOCATED.equals(exist.getAccommodationStatus()))
{
throw new ServiceException("当前住宿记录非预分配状态,禁止物理删除");
} }
return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId); return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId);
} }
@@ -213,11 +213,28 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService
{ {
return 0; return 0;
} }
// 门禁授权生效保护:生效中或授权中状态,禁止直接清理,必须走标准注销流程
if (TrainDormAuth.STATUS_ACTIVE.equals(exist.getFaceSyncStatus()) if (TrainDormAuth.STATUS_ACTIVE.equals(exist.getFaceSyncStatus())
|| TrainDormAuth.AUTH_STATUS_ACTIVE.equals(exist.getAuthStatus()) || TrainDormAuth.AUTH_STATUS_ACTIVE.equals(exist.getAuthStatus())
|| TrainDormAuth.AUTH_STATUS_GRANTING.equals(exist.getAuthStatus())) || TrainDormAuth.AUTH_STATUS_GRANTING.equals(exist.getAuthStatus()))
{ {
throw new ServiceException("当前门禁授权处于生效或授权中状态,请先执行注销后再清理"); throw new ServiceException("当前门禁授权处于生效或授权中状态,请先执行注销后再处理");
}
// 门禁审计链完整性保护:已注销、注销流转中或已关联外部操作流水的历史授权,严禁物理删除
if (TrainDormAuth.STATUS_REVOKED.equals(exist.getFaceSyncStatus())
|| TrainDormAuth.AUTH_STATUS_REVOKED.equals(exist.getAuthStatus())
|| TrainDormAuth.AUTH_STATUS_PENDING_REVOKE.equals(exist.getAuthStatus())
|| TrainDormAuth.AUTH_STATUS_REVOKING.equals(exist.getAuthStatus())
|| exist.getLatestOperationId() != null
|| exist.getExternalAuthId() != null)
{
throw new ServiceException("该门禁授权已进入授权生命周期或产生外部审计流水,禁止物理删除历史凭证");
}
// 安全底线:仅允许清理尚未执行过任何下发动作的初始待授权草稿 (STATUS_PENDING / AUTH_STATUS_PENDING_GRANT)
if ((exist.getFaceSyncStatus() != null && !TrainDormAuth.STATUS_PENDING.equals(exist.getFaceSyncStatus()))
|| (exist.getAuthStatus() != null && !TrainDormAuth.AUTH_STATUS_PENDING_GRANT.equals(exist.getAuthStatus())))
{
throw new ServiceException("当前门禁授权非待下发草稿状态,禁止物理删除");
} }
return trainDormAuthMapper.deleteTrainDormAuthByAuthId(authId); return trainDormAuthMapper.deleteTrainDormAuthByAuthId(authId);
} }
@@ -447,6 +447,79 @@ public class AccommodationServiceTest
assertEquals(1, activeCount); assertEquals(1, activeCount);
} }
/**
* 验证错误预分配记录允许物理删除纠错
*/
@Test
public void testDeleteAccommodation_PreallocatedCanBeDeleted()
{
TrainAccommodation draft = new TrainAccommodation();
draft.setEnrollmentId(2001L);
draft.setDormId("DORM_101");
draft.setBedNo("3号床");
draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
mockAccommodationMapper.insertTrainAccommodation(draft);
Long id = draft.getAccommodationId();
int deleted = accommodationService.deleteTrainAccommodationByAccommodationId(id);
assertEquals(1, deleted);
assertNull(accommodationService.selectTrainAccommodationByAccommodationId(id));
}
/**
* 验证活跃在住记录禁止物理删除
*/
@Test
public void testDeleteAccommodation_CheckedInIsBlocked()
{
TrainAccommodation active = new TrainAccommodation();
active.setEnrollmentId(2002L);
active.setDormId("DORM_101");
active.setBedNo("4号床");
active.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
mockAccommodationMapper.insertTrainAccommodation(active);
Long id = active.getAccommodationId();
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(id);
fail("在住记录禁止物理删除,必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("当前学员正处于已入住状态"));
}
assertNotNull(accommodationService.selectTrainAccommodationByAccommodationId(id));
}
/**
* 验证已退宿的历史记录禁止物理删除,保护历史住宿事实履历完整
*/
@Test
public void testDeleteAccommodation_CheckedOutIsBlockedForHistoricalIntegrity()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setEnrollmentId(2003L);
acc.setDormId("DORM_101");
acc.setBedNo("5号床");
acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT);
mockAccommodationMapper.insertTrainAccommodation(acc);
Long id = acc.getAccommodationId();
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(id);
fail("已退宿历史记录禁止物理删除以保护历史履历,必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史履历")
|| e.getMessage().contains("真实历史入住与退宿事实"));
}
// 确认历史记录依然存在于库中
assertNotNull(accommodationService.selectTrainAccommodationByAccommodationId(id));
}
// ================================= 内部 Mock 类 ================================= // ================================= 内部 Mock 类 =================================
private static class MockAccommodationMapper implements TrainAccommodationMapper private static class MockAccommodationMapper implements TrainAccommodationMapper
@@ -328,10 +328,10 @@ public class DormAuthAlignmentTest
} }
/** /**
* 验证已注销(REVOKED)门禁授权可以安全物理清理 * 验证已注销(REVOKED)门禁授权禁止物理删除,以保全设备操作与安全审计追溯链
*/ */
@Test @Test
public void testDeleteTrainDormAuth_RevokedCanBeDeleted() public void testDeleteTrainDormAuth_RevokedIsBlockedForAuditIntegrity()
{ {
TrainAccommodation acc = new TrainAccommodation(); TrainAccommodation acc = new TrainAccommodation();
acc.setAccommodationId(5004L); acc.setAccommodationId(5004L);
@@ -346,6 +346,38 @@ public class DormAuthAlignmentTest
// 办理注销 // 办理注销
dormAuthService.revokeAuth(authId, "admin"); dormAuthService.revokeAuth(authId, "admin");
try
{
dormAuthService.deleteTrainDormAuthByAuthId(authId);
fail("已注销门禁授权应禁止物理删除以保护审计追溯链,抛出 ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史凭证")
|| e.getMessage().contains("已进入授权生命周期"));
}
// 确认记录依然完整保留在库中
assertNotNull(dormAuthService.selectTrainDormAuthByAuthId(authId));
}
/**
* 验证尚未触发外部操作且处于初始待授权状态的草稿记录允许被清理
*/
@Test
public void testDeleteTrainDormAuth_DraftWithoutOperationsCanBeDeleted()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setAccommodationId(5005L);
acc.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
mockAccommodationMapper.save(acc);
TrainDormAuth draft = new TrainDormAuth();
draft.setAccommodationId(5005L);
draft.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING);
draft.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
dormAuthService.insertTrainDormAuth(draft);
Long authId = draft.getAuthId();
int deleted = dormAuthService.deleteTrainDormAuthByAuthId(authId); int deleted = dormAuthService.deleteTrainDormAuthByAuthId(authId);
assertEquals(1, deleted); assertEquals(1, deleted);
assertNull(dormAuthService.selectTrainDormAuthByAuthId(authId)); assertNull(dormAuthService.selectTrainDormAuthByAuthId(authId));
+106 -2
View File
@@ -33,7 +33,7 @@
</el-col> </el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" /> <right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row> </el-row>
<el-alert title="考勤由课次签到、签退或留痕的人工修正流程生成,本页面仅供查询。" type="info" :closable="false" show-icon class="mb8" /> <el-alert title="考勤由课次签到、签退生成;具备管理权限时,可对异常或争议记录执行全留痕人工修正。" type="info" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" :data="rows"> <el-table v-loading="loading" :data="rows">
<el-table-column label="培训计划" prop="planName" min-width="150" show-overflow-tooltip /> <el-table-column label="培训计划" prop="planName" min-width="150" show-overflow-tooltip />
<el-table-column label="课程" prop="courseName" min-width="140" show-overflow-tooltip /> <el-table-column label="课程" prop="courseName" min-width="140" show-overflow-tooltip />
@@ -52,6 +52,20 @@
<el-table-column label="人工修正" align="center" width="90"> <el-table-column label="人工修正" align="center" width="90">
<template slot-scope="scope">{{ scope.row.manualFlag === 1 ? "是" : "否" }}</template> <template slot-scope="scope">{{ scope.row.manualFlag === 1 ? "是" : "否" }}</template>
</el-table-column> </el-table-column>
<el-table-column label="备注/调整说明" prop="remark" min-width="150" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.remark || "-" }}</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:attendance:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleAdjust(scope.row)"
>调整</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<pagination <pagination
v-show="total > 0" v-show="total > 0"
@@ -60,11 +74,49 @@
:limit.sync="queryParams.pageSize" :limit.sync="queryParams.pageSize"
@pagination="getList" @pagination="getList"
/> />
<!-- 人工调整考勤对话框 (UC-19 / P0) -->
<el-dialog title="人工调整考勤 (Attendance Adjustment)" :visible.sync="adjustOpen" width="520px" append-to-body>
<el-form ref="adjustForm" :model="adjustForm" :rules="adjustRules" label-width="100px">
<el-form-item label="学员姓名">
<span>{{ currentRecord.studentName || '-' }}</span>
</el-form-item>
<el-form-item label="培训计划">
<span>{{ currentRecord.planName || '-' }}</span>
</el-form-item>
<el-form-item label="课程名称">
<span>{{ currentRecord.courseName || '-' }}</span>
</el-form-item>
<el-form-item label="当前状态">
<el-tag size="small">{{ labelOf(attendanceStatuses, currentRecord.attendanceStatus) }}</el-tag>
</el-form-item>
<el-form-item label="目标状态" prop="attendanceStatus">
<el-select v-model="adjustForm.attendanceStatus" placeholder="请选择调整后的考勤状态" style="width: 100%;">
<el-option v-for="item in attendanceStatuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="调整原因" prop="remark">
<el-input
v-model="adjustForm.remark"
type="textarea"
:rows="3"
placeholder="请输入人工调整原因或核验说明(如:班牌漏刷补录、事假证明核销,将全留痕记入审计流水)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :loading="adjustSubmitting" @click="submitAdjust">确 定</el-button>
<el-button @click="cancelAdjust">取 消</el-button>
</div>
</el-dialog>
</div> </div>
</template> </template>
<script> <script>
import { listCheckin } from '@/api/train/checkin' import { listCheckin } from '@/api/train/checkin'
import { adjustAttendance } from '@/api/train/attendance'
export default { export default {
name: 'Checkin', name: 'Checkin',
@@ -82,7 +134,20 @@ export default {
{ value: 0, label: '缺勤' }, { value: 1, label: '正常' }, { value: 0, label: '缺勤' }, { value: 1, label: '正常' },
{ value: 2, label: '迟到' }, { value: 3, label: '早退' }, { value: 4, label: '请假' } { value: 2, label: '迟到' }, { value: 3, label: '早退' }, { value: 4, label: '请假' }
], ],
queryParams: { pageNum: 1, pageSize: 10, planName: null, studentName: null, courseName: null, attendanceStatus: null } queryParams: { pageNum: 1, pageSize: 10, planName: null, studentName: null, courseName: null, attendanceStatus: null },
adjustOpen: false,
adjustSubmitting: false,
currentRecord: {},
adjustForm: {
checkinId: null,
attendanceStatus: null,
remark: ''
},
adjustRules: {
attendanceStatus: [
{ required: true, message: '请选择调整后的考勤状态', trigger: 'change' }
]
}
} }
}, },
created() { this.getList() }, created() { this.getList() },
@@ -102,6 +167,45 @@ export default {
resetQuery() { this.resetForm('queryForm'); this.handleQuery() }, resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
handleExport() { handleExport() {
this.download('train/checkin/export', { ...this.queryParams }, `checkin_${new Date().getTime()}.xlsx`) this.download('train/checkin/export', { ...this.queryParams }, `checkin_${new Date().getTime()}.xlsx`)
},
handleAdjust(row) {
this.currentRecord = row
this.adjustForm = {
checkinId: row.checkinId,
attendanceStatus: row.attendanceStatus,
remark: row.remark || ''
}
this.adjustOpen = true
this.$nextTick(() => {
if (this.$refs.adjustForm) {
this.$refs.adjustForm.clearValidate()
}
})
},
cancelAdjust() {
this.adjustOpen = false
this.currentRecord = {}
this.adjustForm = {
checkinId: null,
attendanceStatus: null,
remark: ''
}
},
submitAdjust() {
this.$refs.adjustForm.validate(valid => {
if (!valid) return
this.adjustSubmitting = true
adjustAttendance(this.adjustForm.checkinId, {
attendanceStatus: this.adjustForm.attendanceStatus,
remark: this.adjustForm.remark
}).then(() => {
this.$modal.msgSuccess('考勤调整成功并已记录审计留痕')
this.adjustOpen = false
this.getList()
}).finally(() => {
this.adjustSubmitting = false
})
})
} }
} }
} }
+15 -2
View File
@@ -96,7 +96,20 @@
--- ---
## 四、P2 后续规划与二期候选清单(共 8 项) ## 四、核心业务生命周期与纠错规则待确认项(CUSTOMER_CONFIRMATION_REQUIRED,共 4 项)
> **说明**:当前系统通过后台级联校验与状态拦截保障了历史数据的安全,但针对录入错误修正、业务中途废止与资源维修停用等管理出口,当前需求规则尚未确认,需要甲方确认后决定实现范围和版本,避免预设规则与实际管理习惯不符。
| 编号 | 优先级 | 需要确认的具体业务规则 | 为什么必须确认 | 当前默认处理 (CURRENT_REQUIREMENT_NOT_CONFIRMED) | 甲方答复 | 确认日期 |
| :---: | :---: | :--- | :--- | :--- | :--- | :--- |
| **Q-CONF-01** | **P1** | **培训计划取消/废止规则 (TRAINING_PLAN_CANCEL_RULE)**<br>1. 处于哪些状态的培训计划允许取消或废止?(如:仅草稿未发布?已发布但未开班?进行中班级是否允许中途废止?)<br>2. 若计划已生成排课与课次,取消计划时关联课次是物理删除、置为无效还是保留历史?<br>3. 计划下已导入的参训学员名单与档案如何处理?(自动解绑、保留报名历史、还是同步注销?) | 区分“已结项(教学圆满完成)”与“计划废止/流标/录错”,避免误操作破坏教务关联。 | **当前处理**:系统仅提供正常结项(`completePlan`),暂未开放取消/废止操作。具体废止状态流转与历史级联规则待甲方确认。 | | |
| **Q-CONF-02** | **P1** | **现场报到撤销/冲正规则 (REPORT_CORRECTION_RULE)**<br>1. 哪些角色拥有撤销学员报到的权限?(如:仅总管理员?授课教师能否操作?)<br>2. 撤销报到时是否强制要求填写撤销原因/审批单号?<br>3. 撤销后是否需要保留原报到时间与历史轨迹备查?自动触发的门禁授权与住宿安排是否联动撤回? | 真实开班中可能存在误点击报到、替报到或学员临时退训情况,需明确冲正与联动责任。 | **当前处理**:报到确认后单向生效,暂未开放撤销冲正接口。具体撤销权限边界与追溯规则待甲方确认。 | | |
| **Q-CONF-03** | **P1** | **自主学习异常数据纠错规则 (SELF_STUDY_CORRECTION_RULE)**<br>1. 自主学习时长的权威数据源是什么?(外部网课平台推送?班牌设备打卡?线上自学上报?)<br>2. 教务管理员或教师是否具有人工调整/补录时长的权限?<br>3. 若因网络重发产生重复或异常溢出记录,是否允许标记作废?作废时是否保留原始上报流水留痕? | 避免异常数据污染学员学情档案与考核统计,明确数据修正边界。 | **当前处理**:系统提供时长台账只读查询与导出,暂未开放人工修改/作废。具体数据源与纠错规则待甲方确认。 | | |
| **Q-CONF-04** | **P1** | **宿舍资源停用/维修维护规则 (DORMITORY_DEACTIVATION_RULE)**<br>1. 宿舍房间需要支持哪些生命周期状态?(如:正常可用、维修维护中、封存停用、保洁中等)<br>2. 房间置为维修/停用期间,排房分配算法是否强制过滤并禁止人工强选?<br>3. 若房间设为维修时已有已入住或预分配学员,系统应如何提示与处理?(强制先调房?自动腾退?) | 保障宿舍管理符合后勤公寓实际维保调度流程,防止故障房被误排入住。 | **当前处理**:当前接口保持只读与新增,暂未开放修改状态接口。具体维修停用状态定义与在住处置规则待甲方确认。 | | |
---
## 五、P2 后续规划与二期候选清单(共 8 项)
> ⚠️ **重要声明**:本节所列事项明确属于二期或未来长期演进能力,一期系统未做交付承诺。列出本节旨在澄清技术边界,防止会议发散讨论项被误列入现场验收范围。 > ⚠️ **重要声明**:本节所列事项明确属于二期或未来长期演进能力,一期系统未做交付承诺。列出本节旨在澄清技术边界,防止会议发散讨论项被误列入现场验收范围。
@@ -113,7 +126,7 @@
--- ---
## 五、资料依据与事实出处 ## 六、资料依据与事实出处
本文档严格基于以下工程事实与资料编制,绝无凭空编造: 本文档严格基于以下工程事实与资料编制,绝无凭空编造: