From 5de1d2a1424d99e358474f4c9b3d0f38ed740e87 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Tue, 15 Sep 2026 17:27:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BD=8F=E5=AE=BF=EF=BC=9A?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E4=BD=8F=E5=AE=BF=E4=B8=8E=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../train/TrainAccommodationController.java | 160 +++++ .../train/TrainDormAuthController.java | 44 +- ...ationAndAuthApiControllerContractTest.java | 130 ++++ ...mmodationHistoryMigrationContractTest.java | 87 +++ .../train/domain/TrainAccommodation.java | 262 ++++++++ .../com/ruoyi/train/domain/TrainDormAuth.java | 342 +++++++--- .../mapper/TrainAccommodationMapper.java | 117 ++++ .../train/mapper/TrainDormAuthMapper.java | 96 ++- .../service/ITrainAccommodationService.java | 122 ++++ .../train/service/ITrainDormAuthService.java | 75 ++- .../impl/TrainAccommodationServiceImpl.java | 328 ++++++++++ .../impl/TrainDormAuthServiceImpl.java | 161 ++++- .../mapper/train/TrainAccommodationMapper.xml | 192 ++++++ .../mapper/train/TrainDormAuthMapper.xml | 199 ++++-- .../impl/AccommodationServiceTest.java | 585 ++++++++++++++++++ .../service/impl/DormAuthAlignmentTest.java | 503 +++++++++++++++ .../006_accommodation_history_constraint.sql | 70 +++ .../dms-r3/02_current_accommodation_audit.md | 32 + .../dms-r3/09_database_mapping_proposal.md | 23 + .../12_implementation_task_breakdown.md | 26 + .../development/dms-r3/14_decision_summary.md | 22 + 21 files changed, 3345 insertions(+), 231 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java create mode 100644 RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java new file mode 100644 index 000000000..6e63693c5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java @@ -0,0 +1,160 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.service.ITrainAccommodationService; + +/** + * 住宿安排 Controller + * + * @author ruoyi + * @date 2026-09-15 + */ +@RestController +@RequestMapping("/train/accommodation") +public class TrainAccommodationController extends BaseController +{ + @Autowired + private ITrainAccommodationService trainAccommodationService; + + /** + * 查询住宿安排列表 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:list')") + @GetMapping("/list") + public TableDataInfo list(TrainAccommodation accommodation) + { + startPage(); + List list = trainAccommodationService.selectTrainAccommodationList(accommodation); + return getDataTable(list); + } + + /** + * 获取住宿安排详细信息 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:query')") + @GetMapping("/{accommodationId}") + public AjaxResult getInfo(@PathVariable("accommodationId") Long accommodationId) + { + return success(trainAccommodationService.selectTrainAccommodationByAccommodationId(accommodationId)); + } + + /** + * 根据参训档案ID查询住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:query')") + @GetMapping("/enrollment/{enrollmentId}") + public AjaxResult getByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId) + { + return success(trainAccommodationService.selectActiveAccommodationByEnrollmentId(enrollmentId)); + } + + /** + * 根据参训档案ID查询该学员全部住宿历史 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:query')") + @GetMapping("/enrollment/{enrollmentId}/history") + public AjaxResult getHistoryByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId) + { + return success(trainAccommodationService.selectAccommodationHistoryByEnrollmentId(enrollmentId)); + } + + /** + * 新增住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:add')") + @Log(title = "住宿安排", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainAccommodation accommodation) + { + accommodation.setCreateBy(getUsernameSafe()); + return toAjax(trainAccommodationService.insertTrainAccommodation(accommodation)); + } + + /** + * 修改住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainAccommodation accommodation) + { + accommodation.setUpdateBy(getUsernameSafe()); + return toAjax(trainAccommodationService.updateTrainAccommodation(accommodation)); + } + + /** + * 办理入住 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排-办理入住", businessType = BusinessType.UPDATE) + @PostMapping("/{accommodationId}/checkin") + public AjaxResult checkIn(@PathVariable("accommodationId") Long accommodationId) + { + return toAjax(trainAccommodationService.checkIn(accommodationId, getUsernameSafe())); + } + + /** + * 办理退宿 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排-办理退宿", businessType = BusinessType.UPDATE) + @PostMapping("/{accommodationId}/checkout") + public AjaxResult checkOut(@PathVariable("accommodationId") Long accommodationId) + { + return toAjax(trainAccommodationService.checkOut(accommodationId, getUsernameSafe())); + } + + /** + * 办理调房 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排-调房", businessType = BusinessType.UPDATE) + @PostMapping("/{accommodationId}/transfer") + public AjaxResult transfer(@PathVariable("accommodationId") Long accommodationId, + @RequestParam("newDormId") String newDormId, + @RequestParam("newBedNo") String newBedNo) + { + return success(trainAccommodationService.transfer(accommodationId, newDormId, newBedNo, getUsernameSafe())); + } + + /** + * 删除住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:remove')") + @Log(title = "住宿安排", businessType = BusinessType.DELETE) + @DeleteMapping("/{accommodationIds}") + public AjaxResult remove(@PathVariable Long[] accommodationIds) + { + return toAjax(trainAccommodationService.deleteTrainAccommodationByAccommodationIds(accommodationIds)); + } + + private String getUsernameSafe() + { + try + { + return SecurityUtils.getUsername(); + } + catch (Exception e) + { + return "admin"; + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java index f82442024..c92fddc6c 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java @@ -20,12 +20,13 @@ import com.ruoyi.train.domain.TrainDormAuth; import com.ruoyi.train.service.ITrainDormAuthService; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.utils.SecurityUtils; /** - * 宿舍权限分配Controller - * + * 宿舍门禁授权Controller (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ @RestController @RequestMapping("/train/auth") @@ -69,6 +70,16 @@ public class TrainDormAuthController extends BaseController return success(trainDormAuthService.selectTrainDormAuthByAuthId(authId)); } + /** + * 根据住宿安排ID获取门禁授权详细信息 + */ + @PreAuthorize("@ss.hasPermi('system:auth:query')") + @GetMapping(value = "/accommodation/{accommodationId}") + public AjaxResult getInfoByAccommodationId(@PathVariable("accommodationId") Long accommodationId) + { + return success(trainDormAuthService.selectTrainDormAuthByAccommodationId(accommodationId)); + } + /** * 新增宿舍权限分配 */ @@ -77,6 +88,7 @@ public class TrainDormAuthController extends BaseController @PostMapping public AjaxResult add(@RequestBody TrainDormAuth trainDormAuth) { + trainDormAuth.setCreateBy(getUsernameSafe()); return toAjax(trainDormAuthService.insertTrainDormAuth(trainDormAuth)); } @@ -88,17 +100,41 @@ public class TrainDormAuthController extends BaseController @PutMapping public AjaxResult edit(@RequestBody TrainDormAuth trainDormAuth) { + trainDormAuth.setUpdateBy(getUsernameSafe()); return toAjax(trainDormAuthService.updateTrainDormAuth(trainDormAuth)); } + /** + * 注销宿舍权限分配 + */ + @PreAuthorize("@ss.hasPermi('system:auth:edit')") + @Log(title = "宿舍权限分配-注销", businessType = BusinessType.UPDATE) + @PostMapping("/{authId}/revoke") + public AjaxResult revoke(@PathVariable("authId") Long authId) + { + return toAjax(trainDormAuthService.revokeAuth(authId, getUsernameSafe())); + } + /** * 删除宿舍权限分配 */ @PreAuthorize("@ss.hasPermi('system:auth:remove')") @Log(title = "宿舍权限分配", businessType = BusinessType.DELETE) - @DeleteMapping("/{authIds}") + @DeleteMapping("/{authIds}") public AjaxResult remove(@PathVariable Long[] authIds) { return toAjax(trainDormAuthService.deleteTrainDormAuthByAuthIds(authIds)); } + + private String getUsernameSafe() + { + try + { + return SecurityUtils.getUsername(); + } + catch (Exception e) + { + return "admin"; + } + } } diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java new file mode 100644 index 000000000..afc952646 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java @@ -0,0 +1,130 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.lang.reflect.Method; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormAuth; + +/** + * R.3B Wave 0 - 住宿与门禁授权 Controller API 契约与权限边界测试 + */ +public class AccommodationAndAuthApiControllerContractTest +{ + /** + * 验证 TrainAccommodationController 的路径与权限契约 + */ + @Test + public void testAccommodationControllerContract() throws Exception + { + Class clazz = TrainAccommodationController.class; + RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class); + assertNotNull(classMapping); + assertEquals("/train/accommodation", classMapping.value()[0]); + + // 1. GET /train/accommodation/list + Method listMethod = clazz.getMethod("list", TrainAccommodation.class); + assertNotNull(listMethod.getAnnotation(GetMapping.class)); + assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:list')", listMethod.getAnnotation(PreAuthorize.class).value()); + + // 2. GET /train/accommodation/{accommodationId} + Method infoMethod = clazz.getMethod("getInfo", Long.class); + assertNotNull(infoMethod.getAnnotation(GetMapping.class)); + assertEquals("/{accommodationId}", infoMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:query')", infoMethod.getAnnotation(PreAuthorize.class).value()); + + // 3. GET /train/accommodation/enrollment/{enrollmentId} + Method enrollMethod = clazz.getMethod("getByEnrollmentId", Long.class); + assertNotNull(enrollMethod.getAnnotation(GetMapping.class)); + assertEquals("/enrollment/{enrollmentId}", enrollMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:query')", enrollMethod.getAnnotation(PreAuthorize.class).value()); + + // 3b. GET /train/accommodation/enrollment/{enrollmentId}/history + Method historyMethod = clazz.getMethod("getHistoryByEnrollmentId", Long.class); + assertNotNull(historyMethod.getAnnotation(GetMapping.class)); + assertEquals("/enrollment/{enrollmentId}/history", historyMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:query')", historyMethod.getAnnotation(PreAuthorize.class).value()); + + // 4. POST /train/accommodation + Method addMethod = clazz.getMethod("add", TrainAccommodation.class); + assertNotNull(addMethod.getAnnotation(PostMapping.class)); + assertEquals("@ss.hasPermi('train:accommodation:add')", addMethod.getAnnotation(PreAuthorize.class).value()); + + // 5. POST /train/accommodation/{accommodationId}/checkin + Method checkInMethod = clazz.getMethod("checkIn", Long.class); + assertNotNull(checkInMethod.getAnnotation(PostMapping.class)); + assertEquals("/{accommodationId}/checkin", checkInMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:edit')", checkInMethod.getAnnotation(PreAuthorize.class).value()); + + // 6. POST /train/accommodation/{accommodationId}/checkout + Method checkOutMethod = clazz.getMethod("checkOut", Long.class); + assertNotNull(checkOutMethod.getAnnotation(PostMapping.class)); + assertEquals("/{accommodationId}/checkout", checkOutMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:edit')", checkOutMethod.getAnnotation(PreAuthorize.class).value()); + + // 7. POST /train/accommodation/{accommodationId}/transfer + Method transferMethod = clazz.getMethod("transfer", Long.class, String.class, String.class); + assertNotNull(transferMethod.getAnnotation(PostMapping.class)); + assertEquals("/{accommodationId}/transfer", transferMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:edit')", transferMethod.getAnnotation(PreAuthorize.class).value()); + + // 8. DELETE /train/accommodation/{accommodationIds} + Method removeMethod = clazz.getMethod("remove", Long[].class); + assertNotNull(removeMethod.getAnnotation(DeleteMapping.class)); + assertEquals("/{accommodationIds}", removeMethod.getAnnotation(DeleteMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:remove')", removeMethod.getAnnotation(PreAuthorize.class).value()); + } + + /** + * 验证 TrainDormAuthController 的现有路径兼容性契约 + */ + @Test + public void testDormAuthControllerContract() throws Exception + { + Class clazz = TrainDormAuthController.class; + RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class); + assertNotNull(classMapping); + assertEquals("/train/auth", classMapping.value()[0]); + + // 1. GET /train/auth/list + Method listMethod = clazz.getMethod("list", TrainDormAuth.class); + assertNotNull(listMethod.getAnnotation(GetMapping.class)); + assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:list')", listMethod.getAnnotation(PreAuthorize.class).value()); + + // 2. GET /train/auth/{authId} + Method infoMethod = clazz.getMethod("getInfo", Long.class); + assertNotNull(infoMethod.getAnnotation(GetMapping.class)); + assertEquals("/{authId}", infoMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:query')", infoMethod.getAnnotation(PreAuthorize.class).value()); + + // 3. GET /train/auth/accommodation/{accommodationId} + Method accInfoMethod = clazz.getMethod("getInfoByAccommodationId", Long.class); + assertNotNull(accInfoMethod.getAnnotation(GetMapping.class)); + assertEquals("/accommodation/{accommodationId}", accInfoMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:query')", accInfoMethod.getAnnotation(PreAuthorize.class).value()); + + // 4. POST /train/auth + Method addMethod = clazz.getMethod("add", TrainDormAuth.class); + assertNotNull(addMethod.getAnnotation(PostMapping.class)); + assertEquals("@ss.hasPermi('system:auth:add')", addMethod.getAnnotation(PreAuthorize.class).value()); + + // 5. POST /train/auth/{authId}/revoke + Method revokeMethod = clazz.getMethod("revoke", Long.class); + assertNotNull(revokeMethod.getAnnotation(PostMapping.class)); + assertEquals("/{authId}/revoke", revokeMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:edit')", revokeMethod.getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java new file mode 100644 index 000000000..ec29c8071 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java @@ -0,0 +1,87 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.Test; + +/** + * R.3B Wave 0.1 - 住宿历史模型与部分唯一索引 Migration 006 静态契约保护测试 + */ +public class AccommodationHistoryMigrationContractTest +{ + @Test + public void migrationMustFollowIdempotentPartialIndexContract() throws Exception + { + Path migrationPath = findMigration(); + assertNotNull("006 migration file must exist", migrationPath); + + String sql = new String(Files.readAllBytes(migrationPath), StandardCharsets.UTF_8).toLowerCase(); + + // 1. 验证前置脏数据检查 (Active duplicate pre-check) + assertContains(sql, + "select enrollment_id", + "from train_accommodation", + "where accommodation_status in (0, 1)", + "group by enrollment_id", + "having count(*) > 1", + "raise exception" + ); + + // 2. 验证旧约束检查与安全删除 (Existing Constraint Guard) + assertContains(sql, + "conname = 'uq_accommodation_enroll'", + "pg_get_constraintdef", + "unique (enrollment_id)", + "alter table train_accommodation drop constraint uq_accommodation_enroll" + ); + + // 3. 验证新部分唯一索引碰撞保护与创建 (New Index Collision Guard & Partial Unique Index) + assertContains(sql, + "uq_accommodation_enroll_active", + "create unique index uq_accommodation_enroll_active", + "on train_accommodation (enrollment_id)", + "where accommodation_status in (0, 1)" + ); + + // 4. 验证幂等性结构 (Idempotency Guard) + assertContains(sql, + "do $$", + "declare", + "begin", + "if v_con_def is not null then", + "if v_idx_def is not null then", + "end $$;" + ); + + // 5. 绝对禁止对业务行进行破坏性操作 (No data loss) + assertFalse("Migration must not truncate tables", sql.contains("truncate table")); + assertFalse("Migration must not delete business rows", sql.contains("delete from train_accommodation")); + assertFalse("Migration must not update business rows blindly", sql.contains("update train_accommodation set")); + } + + private Path findMigration() + { + for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) + { + Path candidate = path.resolve("RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql"); + if (Files.isRegularFile(candidate)) return candidate; + Path candidateSub = path.resolve("sql/system/kingbase/006_accommodation_history_constraint.sql"); + if (Files.isRegularFile(candidateSub)) return candidateSub; + } + throw new IllegalStateException("006_accommodation_history_constraint.sql not found from working directory"); + } + + private void assertContains(String sql, String... tokens) + { + for (String token : tokens) + { + assertTrue("Missing migration token: " + token, sql.contains(token)); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java new file mode 100644 index 000000000..2ea3a0128 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java @@ -0,0 +1,262 @@ +package com.ruoyi.train.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 住宿安排领域实体对象 train_accommodation (Database V2) + * + * @author ruoyi + * @date 2026-09-15 + */ +public class TrainAccommodation extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 住宿状态:0 预分配 */ + public static final Integer STATUS_PREALLOCATED = 0; + + /** 住宿状态:1 已入住 */ + public static final Integer STATUS_CHECKED_IN = 1; + + /** 住宿状态:2 已退宿 */ + public static final Integer STATUS_CHECKED_OUT = 2; + + /** 住宿安排ID (主键,自增) */ + private Long accommodationId; + + /** 参训档案ID (外键,关联train_enrollment.enrollment_id,唯一) */ + @Excel(name = "参训档案ID") + private Long enrollmentId; + + /** 宿舍ID (外键,关联train_dormitory_info.dorm_id) */ + @Excel(name = "宿舍ID") + private String dormId; + + /** 床位号 (如 1号床、A床等) */ + @Excel(name = "床位号") + private String bedNo; + + /** 分配时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "分配时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date assignedTime; + + /** 住宿状态 (0: 预分配, 1: 已入住, 2: 已退宿) */ + @Excel(name = "住宿状态", readConverterExp = "0=预分配,1=已入住,2=已退宿") + private Integer accommodationStatus; + + /* ==================== 视图兼容字段 (非持久化列,联表展示) ==================== */ + + /** 学员学号 */ + private String studentId; + + /** 学员姓名 */ + private String studentName; + + /** 学员性别 */ + private String gender; + + /** 所属机构/选派单位 */ + private String orgName; + + /** 联系电话 */ + private String phone; + + /** 培训班计划ID */ + private String planId; + + /** 培训班计划名称 */ + private String planName; + + /** 宿舍楼栋 */ + private String dormBuilding; + + /** 宿舍房间号 */ + private String dormRoom; + + public TrainAccommodation() + { + } + + public TrainAccommodation(Long enrollmentId, String dormId, String bedNo) + { + this.enrollmentId = enrollmentId; + this.dormId = dormId; + this.bedNo = bedNo; + this.assignedTime = new Date(); + this.accommodationStatus = STATUS_PREALLOCATED; + } + + public Long getAccommodationId() + { + return accommodationId; + } + + public void setAccommodationId(Long accommodationId) + { + this.accommodationId = accommodationId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public Date getAssignedTime() + { + return assignedTime; + } + + public void setAssignedTime(Date assignedTime) + { + this.assignedTime = assignedTime; + } + + public Integer getAccommodationStatus() + { + return accommodationStatus; + } + + public void setAccommodationStatus(Integer accommodationStatus) + { + this.accommodationStatus = accommodationStatus; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getGender() + { + return gender; + } + + public void setGender(String gender) + { + this.gender = gender; + } + + public String getOrgName() + { + return orgName; + } + + public void setOrgName(String orgName) + { + this.orgName = orgName; + } + + public String getPhone() + { + return phone; + } + + public void setPhone(String phone) + { + this.phone = phone; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("accommodationId", getAccommodationId()) + .append("enrollmentId", getEnrollmentId()) + .append("dormId", getDormId()) + .append("bedNo", getBedNo()) + .append("assignedTime", getAssignedTime()) + .append("accommodationStatus", getAccommodationStatus()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java index 55043ffcc..6bae1d718 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java @@ -8,155 +8,307 @@ import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; /** - * 宿舍权限分配对象 train_dorm_auth - * + * 宿舍门禁授权领域对象 train_dorm_auth (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ public class TrainDormAuth extends BaseEntity { private static final long serialVersionUID = 1L; - /** 权限分配ID */ + /** 门禁授权状态:0 待下发 (本地待同步) */ + public static final Integer STATUS_PENDING = 0; + + /** 门禁授权状态:1 已下发 (设备端生效) */ + public static final Integer STATUS_ACTIVE = 1; + + /** 门禁授权状态:2 下发失败 (待重试) */ + public static final Integer STATUS_FAILED = 2; + + /** 门禁授权状态:3 已注销 (权限收回) */ + public static final Integer STATUS_REVOKED = 3; + + /** 授权主键ID */ private Long authId; - /** 学员学号,关联train_student.s_id */ - @Excel(name = "学员学号,关联train_student.s_id") - private String studentId; - - /** 所属开班计划ID,train_class_plan.plan_id */ - @Excel(name = "所属开班计划ID,train_class_plan.plan_id") - private String planId; - - /** 分配宿舍ID,train_dormitory_info.dorm_id */ - @Excel(name = "分配宿舍ID,train_dormitory_info.dorm_id") - private String dormId; - - /** 床位号 */ - @Excel(name = "床位号") - private String bedNo; - - /** 0 未下发人脸 1 已下发人脸至智能锁 2 下发失败 */ - @Excel(name = "0 未下发人脸 1 已下发人脸至智能锁 2 下发失败") - private Integer faceSyncStatus; + /** 住宿安排ID,外键关联 train_accommodation.accommodation_id (V2核心业务标识) */ + @Excel(name = "住宿安排ID") + private Long accommodationId; /** 权限生效时间 */ - @JsonFormat(pattern = "yyyy-MM-dd") - @Excel(name = "权限生效时间", width = 30, dateFormat = "yyyy-MM-dd") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "权限生效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date authStart; - /** 权限失效时间 (开班结束自动失效) */ - @JsonFormat(pattern = "yyyy-MM-dd") - @Excel(name = "权限失效时间 (开班结束自动失效)", width = 30, dateFormat = "yyyy-MM-dd") + /** 权限失效时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "权限失效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date authEnd; - /** 创建管理员账号 */ - @Excel(name = "创建管理员账号") + /** 授权同步状态 (0: 待下发, 1: 已下发, 2: 下发失败, 3: 已注销) */ + @Excel(name = "授权同步状态", readConverterExp = "0=待下发,1=已下发,2=下发失败,3=已注销") + private Integer faceSyncStatus; + + /** 重试次数 */ + @Excel(name = "重试次数") + private Integer retryCount; + + /** 最近一次同步错误信息 */ + @Excel(name = "最近同步错误") + private String lastError; + + /** 外部授权标识 (用于后续Adapter关联,本Wave仅持久化字段) */ + @Excel(name = "外部授权标识") + private String externalAuthId; + + /** 最近一次同步时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "最近同步时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date lastSyncTime; + + /* ==================== 兼容视图字段 (非持久化列,联表查询展示与旧API兼容) ==================== */ + + /** 学员学号 (非物理列,联表自 train_student) */ + private String studentId; + + /** 学员姓名 (非物理列,联表自 train_student) */ + private String studentName; + + /** 开班计划ID (非物理列,联表自 train_enrollment) */ + private String planId; + + /** 开班计划名称 (非物理列,联表自 train_class_plan) */ + private String planName; + + /** 宿舍ID (非物理列,联表自 train_accommodation) */ + private String dormId; + + /** 宿舍楼栋 (非物理列,联表自 train_dormitory_info) */ + private String dormBuilding; + + /** 宿舍房间号 (非物理列,联表自 train_dormitory_info) */ + private String dormRoom; + + /** 床位号 (非物理列,联表自 train_accommodation) */ + private String bedNo; + + /** 创建管理员 (兼容旧字段,映射到 BaseEntity.createBy) */ private String createUser; - public void setAuthId(Long authId) + public TrainDormAuth() { - this.authId = authId; } - public Long getAuthId() + public TrainDormAuth(Long accommodationId, Date authStart, Date authEnd) + { + this.accommodationId = accommodationId; + this.authStart = authStart; + this.authEnd = authEnd; + this.faceSyncStatus = STATUS_PENDING; + this.retryCount = 0; + } + + public Long getAuthId() { return authId; } - public void setStudentId(String studentId) + public void setAuthId(Long authId) { - this.studentId = studentId; + this.authId = authId; } - public String getStudentId() + public Long getAccommodationId() { - return studentId; + return accommodationId; } - public void setPlanId(String planId) + public void setAccommodationId(Long accommodationId) { - this.planId = planId; + this.accommodationId = accommodationId; } - public String getPlanId() - { - return planId; - } - - public void setDormId(String dormId) - { - this.dormId = dormId; - } - - public String getDormId() - { - return dormId; - } - - public void setBedNo(String bedNo) - { - this.bedNo = bedNo; - } - - public String getBedNo() - { - return bedNo; - } - - public void setFaceSyncStatus(Integer faceSyncStatus) - { - this.faceSyncStatus = faceSyncStatus; - } - - public Integer getFaceSyncStatus() - { - return faceSyncStatus; - } - - public void setAuthStart(Date authStart) - { - this.authStart = authStart; - } - - public Date getAuthStart() + public Date getAuthStart() { return authStart; } - public void setAuthEnd(Date authEnd) + public void setAuthStart(Date authStart) { - this.authEnd = authEnd; + this.authStart = authStart; } - public Date getAuthEnd() + public Date getAuthEnd() { return authEnd; } - public void setCreateUser(String createUser) + public void setAuthEnd(Date authEnd) { - this.createUser = createUser; + this.authEnd = authEnd; } - public String getCreateUser() + public Integer getFaceSyncStatus() { - return createUser; + return faceSyncStatus; + } + + public void setFaceSyncStatus(Integer faceSyncStatus) + { + this.faceSyncStatus = faceSyncStatus; + } + + public Integer getRetryCount() + { + return retryCount; + } + + public void setRetryCount(Integer retryCount) + { + this.retryCount = retryCount; + } + + public String getLastError() + { + return lastError; + } + + public void setLastError(String lastError) + { + this.lastError = lastError; + } + + public String getExternalAuthId() + { + return externalAuthId; + } + + public void setExternalAuthId(String externalAuthId) + { + this.externalAuthId = externalAuthId; + } + + public Date getLastSyncTime() + { + return lastSyncTime; + } + + public void setLastSyncTime(Date lastSyncTime) + { + this.lastSyncTime = lastSyncTime; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public String getCreateUser() + { + return createUser != null ? createUser : getCreateBy(); + } + + public void setCreateUser(String createUser) + { + this.createUser = createUser; + if (getCreateBy() == null) + { + setCreateBy(createUser); + } } @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) .append("authId", getAuthId()) - .append("studentId", getStudentId()) - .append("planId", getPlanId()) - .append("dormId", getDormId()) - .append("bedNo", getBedNo()) - .append("faceSyncStatus", getFaceSyncStatus()) + .append("accommodationId", getAccommodationId()) .append("authStart", getAuthStart()) .append("authEnd", getAuthEnd()) - .append("createUser", getCreateUser()) + .append("faceSyncStatus", getFaceSyncStatus()) + .append("retryCount", getRetryCount()) + .append("lastError", getLastError()) + .append("externalAuthId", getExternalAuthId()) + .append("lastSyncTime", getLastSyncTime()) + .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) .append("updateTime", getUpdateTime()) .append("remark", getRemark()) .toString(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java new file mode 100644 index 000000000..7506c37fa --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java @@ -0,0 +1,117 @@ +package com.ruoyi.train.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainAccommodation; + +/** + * 住宿安排Mapper接口 train_accommodation + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface TrainAccommodationMapper +{ + /** + * 根据主键查询住宿安排 + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId); + + /** + * 根据参训档案ID查询住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询当前有效住宿安排 (预分配或已入住) + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按分配时间倒序) + * + * @param enrollmentId 参训档案ID + * @return 住宿历史记录列表 + */ + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId); + + /** + * 查询住宿安排列表 + * + * @param accommodation 住宿安排查询条件 + * @return 住宿安排集合 + */ + public List selectTrainAccommodationList(TrainAccommodation accommodation); + + /** + * 根据开班计划ID查询该班级下所有有效住宿安排 (准备用于结课批量撤权等) + * + * @param planId 开班计划ID + * @return 住宿安排集合 + */ + public List selectActiveAccommodationsByPlanId(@Param("planId") String planId); + + /** + * 统计指定宿舍当前有效住宿人数 (状态为 0 预分配 或 1 已入住) + * + * @param dormId 宿舍ID + * @return 有效住宿人数 + */ + public int countActiveByDormId(@Param("dormId") String dormId); + + /** + * 新增住宿安排 + * + * @param accommodation 住宿安排 + * @return 影响行数 + */ + public int insertTrainAccommodation(TrainAccommodation accommodation); + + /** + * 修改住宿安排 + * + * @param accommodation 住宿安排 + * @return 影响行数 + */ + public int updateTrainAccommodation(TrainAccommodation accommodation); + + /** + * 更新住宿状态 + * + * @param accommodationId 住宿安排主键 + * @param accommodationStatus 目标状态 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int updateAccommodationStatus(@Param("accommodationId") Long accommodationId, + @Param("accommodationStatus") Integer accommodationStatus, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 删除住宿安排 + * + * @param accommodationId 住宿安排主键 + * @return 影响行数 + */ + public int deleteTrainAccommodationByAccommodationId(Long accommodationId); + + /** + * 批量删除住宿安排 + * + * @param accommodationIds 需要删除的住宿安排主键集合 + * @return 影响行数 + */ + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java index 01ed39ce1..43b90a33d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java @@ -1,61 +1,101 @@ package com.ruoyi.train.mapper; +import java.util.Date; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.train.domain.TrainDormAuth; /** - * 宿舍权限分配Mapper接口 - * + * 宿舍门禁授权Mapper接口 train_dorm_auth (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ -public interface TrainDormAuthMapper +public interface TrainDormAuthMapper { /** - * 查询宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 宿舍权限分配 + * 根据主键查询宿舍门禁授权 + * + * @param authId 授权主键 + * @return 门禁授权 */ public TrainDormAuth selectTrainDormAuthByAuthId(Long authId); /** - * 查询宿舍权限分配列表 - * - * @param trainDormAuth 宿舍权限分配 - * @return 宿舍权限分配集合 + * 根据住宿安排ID查询门禁授权 (唯一对应) + * + * @param accommodationId 住宿安排主键 + * @return 门禁授权 + */ + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId); + + /** + * 查询宿舍门禁授权列表 (支持联表视图字段过滤) + * + * @param trainDormAuth 授权查询条件 + * @return 门禁授权集合 */ public List selectTrainDormAuthList(TrainDormAuth trainDormAuth); /** - * 新增宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 - * @return 结果 + * 新增门禁授权记录 (严格写入 V2 持久化列) + * + * @param trainDormAuth 门禁授权 + * @return 影响行数 */ public int insertTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 修改宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 - * @return 结果 + * 修改门禁授权记录 (严格更新 V2 持久化列) + * + * @param trainDormAuth 门禁授权 + * @return 影响行数 */ public int updateTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 删除宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 结果 + * 更新同步状态与错误摘要 + * + * @param authId 授权主键 + * @param faceSyncStatus 状态 (0:待下发, 1:已下发, 2:下发失败, 3:已注销) + * @param lastError 错误信息 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int updateSyncStatus(@Param("authId") Long authId, + @Param("faceSyncStatus") Integer faceSyncStatus, + @Param("lastError") String lastError, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 累加重试次数并记录最近错误 + * + * @param authId 授权主键 + * @param lastError 错误信息 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int increaseRetryCount(@Param("authId") Long authId, + @Param("lastError") String lastError, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 删除宿舍门禁授权 + * + * @param authId 授权主键 + * @return 影响行数 */ public int deleteTrainDormAuthByAuthId(Long authId); /** - * 批量删除宿舍权限分配 - * - * @param authIds 需要删除的数据主键集合 - * @return 结果 + * 批量删除宿舍门禁授权 + * + * @param authIds 需要删除的主键集合 + * @return 影响行数 */ public int deleteTrainDormAuthByAuthIds(Long[] authIds); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java new file mode 100644 index 000000000..cd48c46b2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java @@ -0,0 +1,122 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainAccommodation; + +/** + * 住宿安排Service接口 train_accommodation + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface ITrainAccommodationService +{ + /** + * 查询住宿安排详情 + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId); + + /** + * 根据参训档案ID查询最新住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询当前活跃/有效住宿安排 (预分配或已入住) + * + * @param enrollmentId 参训档案ID + * @return 活跃住宿安排,若无返回null + */ + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序) + * + * @param enrollmentId 参训档案ID + * @return 住宿历史记录列表 + */ + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId); + + /** + * 查询住宿安排列表 + * + * @param accommodation 住宿安排过滤条件 + * @return 住宿安排集合 + */ + public List selectTrainAccommodationList(TrainAccommodation accommodation); + + /** + * 查询指定开班计划下所有有效住宿安排 + * + * @param planId 开班计划ID + * @return 有效住宿列表 + */ + public List selectActiveAccommodationsByPlanId(String planId); + + /** + * 创建住宿事实 (带参训档案、宿舍容量与唯一活跃约束校验) + * + * @param accommodation 住宿事实 + * @return 结果 + */ + public int insertTrainAccommodation(TrainAccommodation accommodation); + + /** + * 修改住宿安排 + * + * @param accommodation 住宿安排 + * @return 结果 + */ + public int updateTrainAccommodation(TrainAccommodation accommodation); + + /** + * 学员办理入住 (状态 0预分配 -> 1已入住) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + public int checkIn(Long accommodationId, String operator); + + /** + * 学员办理退宿 (状态 -> 2已退宿) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + public int checkOut(Long accommodationId, String operator); + + /** + * 调房/换房业务操作 (原子事务:旧住宿转已退宿,新住宿创建预分配/入住) + * + * @param oldAccommodationId 原住宿安排ID + * @param newDormId 新宿舍ID + * @param newBedNo 新床位号 + * @param operator 操作人 + * @return 新生成的住宿安排 + */ + public TrainAccommodation transfer(Long oldAccommodationId, String newDormId, String newBedNo, String operator); + + /** + * 批量删除住宿安排 (受业务历史保护校验) + * + * @param accommodationIds 需要删除的主键集合 + * @return 结果 + */ + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds); + + /** + * 删除住宿安排 + * + * @param accommodationId 需要删除的主键 + * @return 结果 + */ + public int deleteTrainAccommodationByAccommodationId(Long accommodationId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java index a8fa9849a..07eaca37c 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java @@ -4,57 +4,84 @@ import java.util.List; import com.ruoyi.train.domain.TrainDormAuth; /** - * 宿舍权限分配Service接口 - * + * 宿舍门禁授权Service接口 train_dorm_auth (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ -public interface ITrainDormAuthService +public interface ITrainDormAuthService { /** - * 查询宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 宿舍权限分配 + * 查询宿舍门禁授权 + * + * @param authId 授权主键 + * @return 宿舍门禁授权 */ public TrainDormAuth selectTrainDormAuthByAuthId(Long authId); /** - * 查询宿舍权限分配列表 - * - * @param trainDormAuth 宿舍权限分配 - * @return 宿舍权限分配集合 + * 根据住宿安排ID查询门禁授权 + * + * @param accommodationId 住宿安排主键 + * @return 宿舍门禁授权 + */ + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId); + + /** + * 查询宿舍门禁授权列表 + * + * @param trainDormAuth 授权查询条件 + * @return 宿舍门禁授权集合 */ public List selectTrainDormAuthList(TrainDormAuth trainDormAuth); /** - * 新增宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 新增宿舍门禁授权 (以住宿事实为唯一合法来源) + * + * @param trainDormAuth 门禁授权 * @return 结果 */ public int insertTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 修改宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 修改宿舍门禁授权 + * + * @param trainDormAuth 门禁授权 * @return 结果 */ public int updateTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 批量删除宿舍权限分配 - * - * @param authIds 需要删除的宿舍权限分配主键集合 + * 记录下发失败并累加重试次数 + * + * @param authId 授权主键 + * @param errorMessage 失败原因 + * @param operator 操作人 + * @return 结果 + */ + public int markSyncFailed(Long authId, String errorMessage, String operator); + + /** + * 注销门禁授权 + * + * @param authId 授权主键 + * @param operator 操作人 + * @return 结果 + */ + public int revokeAuth(Long authId, String operator); + + /** + * 批量删除宿舍门禁授权 + * + * @param authIds 需要删除的主键集合 * @return 结果 */ public int deleteTrainDormAuthByAuthIds(Long[] authIds); /** - * 删除宿舍权限分配信息 - * - * @param authId 宿舍权限分配主键 + * 删除宿舍门禁授权信息 + * + * @param authId 主键 * @return 结果 */ public int deleteTrainDormAuthByAuthId(Long authId); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java new file mode 100644 index 000000000..23d997a2e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java @@ -0,0 +1,328 @@ +package com.ruoyi.train.service.impl; + +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.service.ITrainAccommodationService; + +/** + * 住宿安排Service业务层处理 + * + * @author ruoyi + * @date 2026-09-15 + */ +@Service +public class TrainAccommodationServiceImpl implements ITrainAccommodationService +{ + @Autowired + private TrainAccommodationMapper trainAccommodationMapper; + + @Autowired + private TrainDormitoryInfoMapper trainDormitoryInfoMapper; + + @Autowired + private TrainEnrollmentMapper trainEnrollmentMapper; + + /** + * 查询住宿安排详情 + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 + */ + @Override + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId) + { + return trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + } + + /** + * 根据参训档案ID查询最新住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + @Override + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId) + { + return trainAccommodationMapper.selectTrainAccommodationByEnrollmentId(enrollmentId); + } + + /** + * 根据参训档案ID查询当前活跃/有效住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 活跃住宿安排 + */ + @Override + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) + { + return trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId); + } + + /** + * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序) + * + * @param enrollmentId 参训档案ID + * @return 住宿历史记录列表 + */ + @Override + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) + { + return trainAccommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId); + } + + /** + * 查询住宿安排列表 + * + * @param accommodation 住宿安排 + * @return 住宿安排 + */ + @Override + public List selectTrainAccommodationList(TrainAccommodation accommodation) + { + return trainAccommodationMapper.selectTrainAccommodationList(accommodation); + } + + /** + * 查询指定开班计划下所有有效住宿安排 + * + * @param planId 开班计划ID + * @return 有效住宿列表 + */ + @Override + public List selectActiveAccommodationsByPlanId(String planId) + { + return trainAccommodationMapper.selectActiveAccommodationsByPlanId(planId); + } + + /** + * 新增住宿安排 (带前置依赖、唯一活跃约束与宿舍容量校验) + * + * @param accommodation 住宿安排 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int insertTrainAccommodation(TrainAccommodation accommodation) + { + Long enrollmentId = accommodation.getEnrollmentId(); + if (enrollmentId == null) + { + throw new ServiceException("参训档案ID (enrollmentId) 不能为空"); + } + + // 1. 验证参训档案是否存在 + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId); + if (enrollment == null) + { + throw new ServiceException("指定参训档案不存在,enrollmentId: " + enrollmentId); + } + + // 2. 验证唯一活跃住宿约束 (Duplicate Active Accommodation Guard) + TrainAccommodation activeAcc = trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId); + if (activeAcc != null) + { + throw new ServiceException("该参训学员已存在有效住宿安排 (ID: " + activeAcc.getAccommodationId() + + ",宿舍: " + activeAcc.getDormId() + "),请先办理退宿或换房"); + } + + // 3. 验证宿舍存在性与可用状态 + String dormId = accommodation.getDormId(); + if (dormId == null || dormId.trim().isEmpty()) + { + throw new ServiceException("分配宿舍ID (dormId) 不能为空"); + } + TrainDormitoryInfo dorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoByDormId(dormId); + if (dorm == null) + { + throw new ServiceException("指定宿舍不存在,dormId: " + dormId); + } + if (dorm.getDormStatus() != null && dorm.getDormStatus() == 0) + { + throw new ServiceException("该宿舍当前处于禁用状态,无法分配住宿"); + } + + // 4. 验证宿舍容量边界 (Capacity Boundary Guard) + int currentActive = trainAccommodationMapper.countActiveByDormId(dormId); + if (dorm.getCapacity() != null && currentActive >= dorm.getCapacity()) + { + throw new ServiceException("宿舍容量已满 (核定容量: " + dorm.getCapacity() + + ",当前有效人数: " + currentActive + "),无法继续分配"); + } + + // 5. 设置默认属性 + if (accommodation.getAssignedTime() == null) + { + accommodation.setAssignedTime(DateUtils.getNowDate()); + } + if (accommodation.getAccommodationStatus() == null) + { + accommodation.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + } + accommodation.setCreateTime(DateUtils.getNowDate()); + + return trainAccommodationMapper.insertTrainAccommodation(accommodation); + } + + /** + * 修改住宿安排 + * + * @param accommodation 住宿安排 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateTrainAccommodation(TrainAccommodation accommodation) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodation.getAccommodationId()); + if (exist == null) + { + throw new ServiceException("住宿记录不存在: " + accommodation.getAccommodationId()); + } + + // 严格遵循 API Semantics 规范:禁止通过普通 update 修改 dorm_id 来模拟调房 + if (accommodation.getDormId() != null && !accommodation.getDormId().equals(exist.getDormId())) + { + throw new ServiceException("变更宿舍请使用专属调房换房流程 (transfer),禁止直接修改住宿记录的宿舍ID"); + } + + accommodation.setUpdateTime(DateUtils.getNowDate()); + return trainAccommodationMapper.updateTrainAccommodation(accommodation); + } + + /** + * 学员办理入住 (状态 0预分配 -> 1已入住) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + @Override + public int checkIn(Long accommodationId, String operator) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + throw new ServiceException("住宿安排不存在: " + accommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) + { + throw new ServiceException("已退宿的记录不能重新办理入住"); + } + return trainAccommodationMapper.updateAccommodationStatus(accommodationId, + TrainAccommodation.STATUS_CHECKED_IN, operator, DateUtils.getNowDate()); + } + + /** + * 学员办理退宿 (状态 -> 2已退宿) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + @Override + public int checkOut(Long accommodationId, String operator) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + throw new ServiceException("住宿安排不存在: " + accommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) + { + return 1; // 已经是退宿状态 + } + return trainAccommodationMapper.updateAccommodationStatus(accommodationId, + TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); + } + + /** + * 调房/换房业务操作 (原子事务:旧住宿转已退宿,新住宿创建预分配/入住) + * + * @param oldAccommodationId 原住宿安排ID + * @param newDormId 新宿舍ID + * @param newBedNo 新床位号 + * @param operator 操作人 + * @return 新生成的住宿安排 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public TrainAccommodation transfer(Long oldAccommodationId, String newDormId, String newBedNo, String operator) + { + TrainAccommodation oldAcc = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(oldAccommodationId); + if (oldAcc == null) + { + throw new ServiceException("原住宿记录不存在: " + oldAccommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(oldAcc.getAccommodationStatus())) + { + throw new ServiceException("原住宿记录已退宿,无法办理调房"); + } + + // 1. 结束旧住宿 + trainAccommodationMapper.updateAccommodationStatus(oldAccommodationId, + TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); + + // 2. 建立新住宿 + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setEnrollmentId(oldAcc.getEnrollmentId()); + newAcc.setDormId(newDormId); + newAcc.setBedNo(newBedNo); + newAcc.setAssignedTime(DateUtils.getNowDate()); + newAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + newAcc.setCreateBy(operator); + newAcc.setRemark("由原住宿记录 " + oldAccommodationId + " (宿舍 " + oldAcc.getDormId() + ") 调房迁入"); + + insertTrainAccommodation(newAcc); + return newAcc; + } + + /** + * 批量删除住宿安排 + * + * @param accommodationIds 需要删除的主键集合 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds) + { + for (Long id : accommodationIds) + { + deleteTrainAccommodationByAccommodationId(id); + } + return accommodationIds.length; + } + + /** + * 删除住宿安排 + * + * @param accommodationId 需要删除的主键 + * @return 结果 + */ + @Override + public int deleteTrainAccommodationByAccommodationId(Long accommodationId) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + return 0; + } + // 业务历史保护:若为活跃已入住状态,应先退宿而不是直接物理删除 + if (TrainAccommodation.STATUS_CHECKED_IN.equals(exist.getAccommodationStatus())) + { + throw new ServiceException("当前学员正处于已入住状态,请先办理退宿后再执行清理"); + } + return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java index 51b483925..eeb6d1ee5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java @@ -1,30 +1,38 @@ package com.ruoyi.train.service.impl; +import java.util.Date; import java.util.List; -import com.ruoyi.common.utils.DateUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.ruoyi.train.mapper.TrainDormAuthMapper; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.train.domain.TrainAccommodation; import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormAuthMapper; import com.ruoyi.train.service.ITrainDormAuthService; /** - * 宿舍权限分配Service业务层处理 - * + * 宿舍门禁授权Service业务层处理 (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ @Service -public class TrainDormAuthServiceImpl implements ITrainDormAuthService +public class TrainDormAuthServiceImpl implements ITrainDormAuthService { @Autowired private TrainDormAuthMapper trainDormAuthMapper; + @Autowired + private TrainAccommodationMapper trainAccommodationMapper; + /** - * 查询宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 宿舍权限分配 + * 查询宿舍门禁授权 + * + * @param authId 宿舍门禁授权主键 + * @return 宿舍门禁授权 */ @Override public TrainDormAuth selectTrainDormAuthByAuthId(Long authId) @@ -33,10 +41,22 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService } /** - * 查询宿舍权限分配列表 - * - * @param trainDormAuth 宿舍权限分配 - * @return 宿舍权限分配 + * 根据住宿安排ID查询门禁授权 + * + * @param accommodationId 住宿安排主键 + * @return 宿舍门禁授权 + */ + @Override + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId) + { + return trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId); + } + + /** + * 查询宿舍门禁授权列表 + * + * @param trainDormAuth 宿舍门禁授权 + * @return 宿舍门禁授权集合 */ @Override public List selectTrainDormAuthList(TrainDormAuth trainDormAuth) @@ -45,35 +65,122 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService } /** - * 新增宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 新增宿舍门禁授权 (以住宿事实为唯一业务来源,禁止绕过住宿直接创建) + * + * @param trainDormAuth 宿舍门禁授权 * @return 结果 */ @Override + @Transactional(rollbackFor = Exception.class) public int insertTrainDormAuth(TrainDormAuth trainDormAuth) { - trainDormAuth.setCreateTime(DateUtils.getNowDate()); + Long accommodationId = trainDormAuth.getAccommodationId(); + if (accommodationId == null) + { + throw new ServiceException("门禁授权必须绑定住宿安排,accommodationId 不能为空"); + } + + // 1. 校验住宿事实有效性 + TrainAccommodation accommodation = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (accommodation == null) + { + throw new ServiceException("指定的住宿安排不存在,ID: " + accommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(accommodation.getAccommodationStatus())) + { + throw new ServiceException("该住宿安排已退宿,无法新建门禁授权"); + } + + // 2. 校验唯一性 (每条住宿安排对应唯一授权流水) + TrainDormAuth existAuth = trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId); + if (existAuth != null) + { + throw new ServiceException("该住宿安排已存在门禁授权 (AuthID: " + existAuth.getAuthId() + "),请勿重复创建"); + } + + // 3. 初始授权状态:本地待下发 (严格遵循 No Fake Device Success 原则) + if (trainDormAuth.getFaceSyncStatus() == null || TrainDormAuth.STATUS_ACTIVE.equals(trainDormAuth.getFaceSyncStatus())) + { + trainDormAuth.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING); + } + if (trainDormAuth.getRetryCount() == null) + { + trainDormAuth.setRetryCount(0); + } + + // 4. 继承操作人和时间 + Date now = DateUtils.getNowDate(); + trainDormAuth.setCreateTime(now); + if (trainDormAuth.getCreateUser() != null && trainDormAuth.getCreateBy() == null) + { + trainDormAuth.setCreateBy(trainDormAuth.getCreateUser()); + } + return trainDormAuthMapper.insertTrainDormAuth(trainDormAuth); } /** - * 修改宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 修改宿舍门禁授权 + * + * @param trainDormAuth 宿舍门禁授权 * @return 结果 */ @Override + @Transactional(rollbackFor = Exception.class) public int updateTrainDormAuth(TrainDormAuth trainDormAuth) { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(trainDormAuth.getAuthId()); + if (exist == null) + { + throw new ServiceException("门禁授权记录不存在: " + trainDormAuth.getAuthId()); + } + trainDormAuth.setUpdateTime(DateUtils.getNowDate()); return trainDormAuthMapper.updateTrainDormAuth(trainDormAuth); } /** - * 批量删除宿舍权限分配 - * - * @param authIds 需要删除的宿舍权限分配主键 + * 记录下发失败并累加重试次数 + * + * @param authId 授权主键 + * @param errorMessage 失败原因 + * @param operator 操作人 + * @return 结果 + */ + @Override + public int markSyncFailed(Long authId, String errorMessage, String operator) + { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId); + if (exist == null) + { + throw new ServiceException("门禁授权记录不存在: " + authId); + } + trainDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_FAILED, errorMessage, operator, DateUtils.getNowDate()); + return trainDormAuthMapper.increaseRetryCount(authId, errorMessage, operator, DateUtils.getNowDate()); + } + + /** + * 注销门禁授权 + * + * @param authId 授权主键 + * @param operator 操作人 + * @return 结果 + */ + @Override + public int revokeAuth(Long authId, String operator) + { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId); + if (exist == null) + { + throw new ServiceException("门禁授权记录不存在: " + authId); + } + return trainDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_REVOKED, null, operator, DateUtils.getNowDate()); + } + + /** + * 批量删除宿舍门禁授权 + * + * @param authIds 需要删除的主键集合 * @return 结果 */ @Override @@ -83,9 +190,9 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService } /** - * 删除宿舍权限分配信息 - * - * @param authId 宿舍权限分配主键 + * 删除宿舍门禁授权信息 + * + * @param authId 主键 * @return 结果 */ @Override diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml new file mode 100644 index 000000000..494b10e6d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT + a.accommodation_id, + a.enrollment_id, + a.dorm_id, + a.bed_no, + a.assigned_time, + a.accommodation_status, + a.create_by, + a.create_time, + a.update_by, + a.update_time, + a.remark, + e.student_id, + s.s_name AS student_name, + s.s_gender AS gender, + s.org_name, + s.s_phone AS phone, + e.plan_id, + p.plan_name, + d.dorm_building, + d.dorm_room + FROM train_accommodation a + LEFT JOIN train_enrollment e ON a.enrollment_id = e.enrollment_id + LEFT JOIN train_student s ON e.student_id = s.s_id + LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id + LEFT JOIN train_dormitory_info d ON a.dorm_id = d.dorm_id + + + + + + + + + + + + + + + + + + INSERT INTO train_accommodation + + enrollment_id, + dorm_id, + bed_no, + assigned_time, + accommodation_status, + create_by, + create_time, + update_by, + update_time, + remark, + + + #{enrollmentId}, + #{dormId}, + #{bedNo}, + #{assignedTime}, + #{accommodationStatus}, + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, + #{remark}, + + + + + UPDATE train_accommodation + + dorm_id = #{dormId}, + bed_no = #{bedNo}, + assigned_time = #{assignedTime}, + accommodation_status = #{accommodationStatus}, + update_by = #{updateBy}, + update_time = #{updateTime}, + remark = #{remark}, + + WHERE accommodation_id = #{accommodationId} + + + + UPDATE train_accommodation + SET accommodation_status = #{accommodationStatus}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE accommodation_id = #{accommodationId} + + + + DELETE FROM train_accommodation WHERE accommodation_id = #{accommodationId} + + + + DELETE FROM train_accommodation WHERE accommodation_id IN + + #{accommodationId} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml index e4ecc85de..0d28c3d39 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml @@ -3,101 +3,194 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + - - - - - + + + + - - - - - - + + + + + + + + + + + + + + + + + + - select auth_id, student_id, plan_id, dorm_id, bed_no, face_sync_status, auth_start, auth_end, create_user, create_time, update_time, remark from train_dorm_auth + SELECT + a.auth_id, + a.accommodation_id, + a.auth_start, + a.auth_end, + a.face_sync_status, + a.retry_count, + a.last_error, + a.external_auth_id, + a.last_sync_time, + a.create_by, + a.create_time, + a.update_by, + a.update_time, + a.remark, + acc.dorm_id, + acc.bed_no, + e.student_id, + s.s_name AS student_name, + e.plan_id, + p.plan_name, + d.dorm_building, + d.dorm_room + FROM train_dorm_auth a + LEFT JOIN train_accommodation acc ON a.accommodation_id = acc.accommodation_id + LEFT JOIN train_enrollment e ON acc.enrollment_id = e.enrollment_id + LEFT JOIN train_student s ON e.student_id = s.s_id + LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id + LEFT JOIN train_dormitory_info d ON acc.dorm_id = d.dorm_id - + + + - insert into train_dorm_auth + INSERT INTO train_dorm_auth - student_id, - plan_id, - dorm_id, - bed_no, - face_sync_status, + accommodation_id, auth_start, auth_end, - create_user, + face_sync_status, + retry_count, + last_error, + external_auth_id, + last_sync_time, + create_by, create_time, + update_by, update_time, - remark, - + remark, + - #{studentId}, - #{planId}, - #{dormId}, - #{bedNo}, - #{faceSyncStatus}, + #{accommodationId}, #{authStart}, #{authEnd}, - #{createUser}, + #{faceSyncStatus}, + #{retryCount}, + #{lastError}, + #{externalAuthId}, + #{lastSyncTime}, + #{createBy}, #{createTime}, + #{updateBy}, #{updateTime}, - #{remark}, - + #{remark}, + - update train_dorm_auth + UPDATE train_dorm_auth - student_id = #{studentId}, - plan_id = #{planId}, - dorm_id = #{dormId}, - bed_no = #{bedNo}, - face_sync_status = #{faceSyncStatus}, + accommodation_id = #{accommodationId}, auth_start = #{authStart}, auth_end = #{authEnd}, - create_user = #{createUser}, - create_time = #{createTime}, + face_sync_status = #{faceSyncStatus}, + retry_count = #{retryCount}, + last_error = #{lastError}, + external_auth_id = #{externalAuthId}, + last_sync_time = #{lastSyncTime}, + update_by = #{updateBy}, update_time = #{updateTime}, remark = #{remark}, - where auth_id = #{authId} + WHERE auth_id = #{authId} + + + + UPDATE train_dorm_auth + SET face_sync_status = #{faceSyncStatus}, + last_error = #{lastError}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE auth_id = #{authId} + + + + UPDATE train_dorm_auth + SET retry_count = COALESCE(retry_count, 0) + 1, + last_error = #{lastError}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE auth_id = #{authId} - delete from train_dorm_auth where auth_id = #{authId} + DELETE FROM train_dorm_auth WHERE auth_id = #{authId} - delete from train_dorm_auth where auth_id in + DELETE FROM train_dorm_auth WHERE auth_id IN #{authId} - \ No newline at end of file + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java new file mode 100644 index 000000000..23a8e51f3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java @@ -0,0 +1,585 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; + +/** + * R.3B Wave 0 - 本地住宿领域对齐与约束单元测试 (AccommodationServiceTest) + * 覆盖规范第29条全部要求:A到I + */ +public class AccommodationServiceTest +{ + private TrainAccommodationServiceImpl accommodationService; + private MockAccommodationMapper mockAccommodationMapper; + private Map dormStore; + private Map enrollmentStore; + + @Before + public void setUp() throws Exception + { + accommodationService = new TrainAccommodationServiceImpl(); + mockAccommodationMapper = new MockAccommodationMapper(); + dormStore = new HashMap<>(); + enrollmentStore = new HashMap<>(); + + TrainDormitoryInfoMapper dormMapperProxy = (TrainDormitoryInfoMapper) Proxy.newProxyInstance( + TrainDormitoryInfoMapper.class.getClassLoader(), + new Class[]{TrainDormitoryInfoMapper.class}, + (proxy, method, args) -> { + if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) + { + return dormStore.get(args[0]); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + TrainEnrollmentMapper enrollMapperProxy = (TrainEnrollmentMapper) Proxy.newProxyInstance( + TrainEnrollmentMapper.class.getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainEnrollmentById".equals(method.getName())) + { + return enrollmentStore.get(args[0]); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + setField(accommodationService, "trainAccommodationMapper", mockAccommodationMapper); + setField(accommodationService, "trainDormitoryInfoMapper", dormMapperProxy); + setField(accommodationService, "trainEnrollmentMapper", enrollMapperProxy); + + // 初始化预设基础数据 + TrainDormitoryInfo dorm101 = new TrainDormitoryInfo(); + dorm101.setDormId("DORM_101"); + dorm101.setDormBuilding("培训1号楼"); + dorm101.setDormFloor("1"); + dorm101.setDormRoom("101"); + dorm101.setCapacity(2); // 容量为2人 + dorm101.setDormStatus(1); // 启用 + dormStore.put(dorm101.getDormId(), dorm101); + + TrainDormitoryInfo dormDisabled = new TrainDormitoryInfo(); + dormDisabled.setDormId("DORM_DIS"); + dormDisabled.setDormBuilding("培训1号楼"); + dormDisabled.setDormRoom("102"); + dormDisabled.setCapacity(4); + dormDisabled.setDormStatus(0); // 禁用 + dormStore.put(dormDisabled.getDormId(), dormDisabled); + + TrainEnrollment enrollment1 = new TrainEnrollment(); + enrollment1.setEnrollmentId(1001L); + enrollment1.setPlanId("PLAN_2026_01"); + enrollment1.setStudentId("STU_001"); + enrollmentStore.put(enrollment1.getEnrollmentId(), enrollment1); + + TrainEnrollment enrollment2 = new TrainEnrollment(); + enrollment2.setEnrollmentId(1002L); + enrollment2.setPlanId("PLAN_2026_01"); + enrollment2.setStudentId("STU_002"); + enrollmentStore.put(enrollment2.getEnrollmentId(), enrollment2); + + TrainEnrollment enrollment3 = new TrainEnrollment(); + enrollment3.setEnrollmentId(1003L); + enrollment3.setPlanId("PLAN_2026_01"); + enrollment3.setStudentId("STU_003"); + enrollmentStore.put(enrollment3.getEnrollmentId(), enrollment3); + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + /** + * Requirement A: Create accommodation + */ + @Test + public void testA_CreateAccommodationSuccess() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + acc.setCreateBy("admin"); + + int rows = accommodationService.insertTrainAccommodation(acc); + assertEquals(1, rows); + assertNotNull(acc.getAccommodationId()); + assertEquals(TrainAccommodation.STATUS_PREALLOCATED, acc.getAccommodationStatus()); + assertNotNull(acc.getAssignedTime()); + } + + /** + * Requirement B: Query by enrollment + */ + @Test + public void testB_QueryByEnrollment() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + + TrainAccommodation queried = accommodationService.selectTrainAccommodationByEnrollmentId(1001L); + assertNotNull(queried); + assertEquals("DORM_101", queried.getDormId()); + assertEquals("1号床", queried.getBedNo()); + + TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L); + assertNotNull(active); + assertEquals(acc.getAccommodationId(), active.getAccommodationId()); + } + + /** + * Requirement C: Status transition (0 预分配 -> 1 已入住 -> 2 已退宿) + */ + @Test + public void testC_StatusTransition() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + Long accId = acc.getAccommodationId(); + + // 1. 办理入住 (0 -> 1) + accommodationService.checkIn(accId, "operator1"); + TrainAccommodation checkedIn = accommodationService.selectTrainAccommodationByAccommodationId(accId); + assertEquals(TrainAccommodation.STATUS_CHECKED_IN, checkedIn.getAccommodationStatus()); + + // 2. 办理退宿 (1 -> 2) + accommodationService.checkOut(accId, "operator2"); + TrainAccommodation checkedOut = accommodationService.selectTrainAccommodationByAccommodationId(accId); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOut.getAccommodationStatus()); + + // 3. 已退宿记录不能重新直接办理入住 + try + { + accommodationService.checkIn(accId, "operator3"); + fail("已退宿记录重新入住未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("已退宿的记录不能重新办理入住")); + } + } + + /** + * Requirement D: Active accommodation uniqueness + * 同一个 Enrollment 同一时刻最多 1 个有效住宿 + */ + @Test + public void testD_ActiveAccommodationUniqueness() + { + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(1001L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc1); + + // 再次为该 enrollment 分配住宿,应被业务层拦截 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(1001L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("2号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("重复分配活跃住宿未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("已存在有效住宿安排")); + } + } + + /** + * Requirement E: Invalid enrollment rejected + */ + @Test + public void testE_InvalidEnrollmentRejected() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(999999L); // 不存在的参训档案 + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + + try + { + accommodationService.insertTrainAccommodation(acc); + fail("不存在的参训档案未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("指定参训档案不存在")); + } + } + + /** + * Requirement F: Invalid dorm rejected + */ + @Test + public void testF_InvalidDormRejected() + { + // 1. 宿舍不存在 + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(1001L); + acc1.setDormId("NON_EXIST_DORM"); + acc1.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc1); + fail("不存在的宿舍未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("指定宿舍不存在")); + } + + // 2. 宿舍已被禁用 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(1001L); + acc2.setDormId("DORM_DIS"); + acc2.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("已禁用的宿舍未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("该宿舍当前处于禁用状态")); + } + } + + /** + * Requirement G: Capacity boundary + * DORM_101 容量为 2 人,已满后再分配第 3 人应拦截 + */ + @Test + public void testG_CapacityBoundary() + { + // 第1人成功 + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(1001L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc1); + + // 第2人成功 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(1002L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("2号床"); + accommodationService.insertTrainAccommodation(acc2); + + // 第3人分配到同一房间,容量超限拦截 + TrainAccommodation acc3 = new TrainAccommodation(); + acc3.setEnrollmentId(1003L); + acc3.setDormId("DORM_101"); + acc3.setBedNo("3号床"); + try + { + accommodationService.insertTrainAccommodation(acc3); + fail("宿舍容量满额未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("宿舍容量已满")); + } + } + + /** + * Requirement H: No physical bed table assumption + * bed_no 作为纯业务标识字符串持久化,不需要依赖物理床位表 + */ + @Test + public void testH_NoPhysicalBedTableAssumption() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("上铺-VIP-01"); // 自定义床位标识 + + int rows = accommodationService.insertTrainAccommodation(acc); + assertEquals(1, rows); + + TrainAccommodation saved = accommodationService.selectTrainAccommodationByAccommodationId(acc.getAccommodationId()); + assertEquals("上铺-VIP-01", saved.getBedNo()); + } + + /** + * Requirement I: Ended accommodation historical query & transfer flow + * 退宿后历史记录可查,且退宿后允许为该 enrollment 分配新住宿 (调房业务) + */ + @Test + public void testI_EndedAccommodationHistoricalQueryAndTransfer() + { + // 1. 初始化新宿舍 DORM_201 + TrainDormitoryInfo dorm201 = new TrainDormitoryInfo(); + dorm201.setDormId("DORM_201"); + dorm201.setDormBuilding("培训2号楼"); + dorm201.setDormRoom("201"); + dorm201.setCapacity(2); + dorm201.setDormStatus(1); + dormStore.put(dorm201.getDormId(), dorm201); + + // 2. 创建初始住宿 + TrainAccommodation oldAcc = new TrainAccommodation(); + oldAcc.setEnrollmentId(1001L); + oldAcc.setDormId("DORM_101"); + oldAcc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(oldAcc); + Long oldId = oldAcc.getAccommodationId(); + + // 3. 执行调房 + TrainAccommodation newAcc = accommodationService.transfer(oldId, "DORM_201", "新1号床", "admin"); + assertNotNull(newAcc); + assertNotNull(newAcc.getAccommodationId()); + assertEquals("DORM_201", newAcc.getDormId()); + assertEquals("新1号床", newAcc.getBedNo()); + assertEquals(TrainAccommodation.STATUS_CHECKED_IN, newAcc.getAccommodationStatus()); + + // 4. 验证旧住宿变为退宿状态但依然可查 (历史可审计) + TrainAccommodation oldUpdated = accommodationService.selectTrainAccommodationByAccommodationId(oldId); + assertNotNull(oldUpdated); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, oldUpdated.getAccommodationStatus()); + + // 5. 验证活跃住宿查询当前指向新记录 + TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L); + assertNotNull(active); + assertEquals(newAcc.getAccommodationId(), active.getAccommodationId()); + assertEquals("DORM_201", active.getDormId()); + } + + /** + * Wave 0 Review Gate - Requirement 14: + * A. Create first active accommodation PASS + * B. Create second active accommodation REJECT + * C. Checkout first accommodation + * D. Create second accommodation for same enrollment PASS + * E. History returns 2 rows + * F. Capacity only counts second active row + */ + @Test + public void testAccommodationHistoryAndCardinalityLifecycle() + { + Long enrollId = 1001L; + + // A. Create first active accommodation PASS + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(enrollId); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + int row1 = accommodationService.insertTrainAccommodation(acc1); + assertEquals(1, row1); + assertNotNull(acc1.getAccommodationId()); + + // B. Create second active accommodation REJECT (同一时刻最多1条有效住宿) + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(enrollId); + acc2.setDormId("DORM_101"); + acc2.setBedNo("2号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("活跃记录存在时重复新增应被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("已存在有效住宿安排")); + } + + // C. Checkout first accommodation (退宿后变为状态 2) + accommodationService.checkOut(acc1.getAccommodationId(), "admin"); + TrainAccommodation checkedOutAcc = accommodationService.selectTrainAccommodationByAccommodationId(acc1.getAccommodationId()); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOutAcc.getAccommodationStatus()); + + // D. Create second accommodation for same enrollment PASS (退宿后允许同一学员建立新住宿) + TrainAccommodation acc3 = new TrainAccommodation(); + acc3.setEnrollmentId(enrollId); + acc3.setDormId("DORM_101"); + acc3.setBedNo("2号床"); + int row3 = accommodationService.insertTrainAccommodation(acc3); + assertEquals(1, row3); + assertNotNull(acc3.getAccommodationId()); + + // E. History returns 2 rows (包含1条退宿历史 + 1条当前活跃) + List history = accommodationService.selectAccommodationHistoryByEnrollmentId(enrollId); + assertNotNull(history); + assertEquals(2, history.size()); + + // 验证当前活跃记录只返回新记录 + TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(enrollId); + assertNotNull(active); + assertEquals(acc3.getAccommodationId(), active.getAccommodationId()); + + // F. Capacity only counts second active row (历史退宿记录不计入核定容量,当前只计1人) + int activeCount = mockAccommodationMapper.countActiveByDormId("DORM_101"); + assertEquals(1, activeCount); + } + + // ================================= 内部 Mock 类 ================================= + + private static class MockAccommodationMapper implements TrainAccommodationMapper + { + private final Map store = new HashMap<>(); + private long idSeq = 100L; + + @Override + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.get(accommodationId); + } + + @Override + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId) + { + TrainAccommodation latest = null; + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) + { + latest = a; + } + } + return latest; + } + + @Override + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) + { + if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) + { + return a; + } + } + } + return null; + } + + @Override + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) + { + List list = new ArrayList<>(); + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) + { + list.add(a); + } + } + list.sort((o1, o2) -> o2.getAccommodationId().compareTo(o1.getAccommodationId())); + return list; + } + + @Override + public List selectTrainAccommodationList(TrainAccommodation accommodation) + { + return new ArrayList<>(store.values()); + } + + @Override + public List selectActiveAccommodationsByPlanId(String planId) + { + return new ArrayList<>(store.values()); + } + + @Override + public int countActiveByDormId(String dormId) + { + int count = 0; + for (TrainAccommodation a : store.values()) + { + if (dormId.equals(a.getDormId())) + { + if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) + { + count++; + } + } + } + return count; + } + + @Override + public int insertTrainAccommodation(TrainAccommodation accommodation) + { + accommodation.setAccommodationId(++idSeq); + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateTrainAccommodation(TrainAccommodation accommodation) + { + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setAccommodationStatus(accommodationStatus); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.remove(accommodationId) != null ? 1 : 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds) + { + int deleted = 0; + for (Long id : accommodationIds) + { + if (store.remove(id) != null) deleted++; + } + return deleted; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java new file mode 100644 index 000000000..be70640f5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java @@ -0,0 +1,503 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormAuthMapper; + +/** + * R.3B Wave 0 - 宿舍门禁授权领域与V2契约对齐测试 (DormAuthAlignmentTest) + * 覆盖规范第30条全部要求:A到G + */ +public class DormAuthAlignmentTest +{ + private TrainDormAuthServiceImpl dormAuthService; + private MockDormAuthMapper mockDormAuthMapper; + private MockAccommodationMapper mockAccommodationMapper; + + @Before + public void setUp() throws Exception + { + dormAuthService = new TrainDormAuthServiceImpl(); + mockDormAuthMapper = new MockDormAuthMapper(); + mockAccommodationMapper = new MockAccommodationMapper(); + + setField(dormAuthService, "trainDormAuthMapper", mockDormAuthMapper); + setField(dormAuthService, "trainAccommodationMapper", mockAccommodationMapper); + + // 初始化基础住宿记录 + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(5001L); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + mockAccommodationMapper.save(acc); + + TrainAccommodation checkedOutAcc = new TrainAccommodation(); + checkedOutAcc.setAccommodationId(5002L); + checkedOutAcc.setEnrollmentId(1002L); + checkedOutAcc.setDormId("DORM_101"); + checkedOutAcc.setBedNo("2号床"); + checkedOutAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT); + mockAccommodationMapper.save(checkedOutAcc); + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + /** + * Requirement A: V2 mapper fields align DB contract + * 验证 TrainDormAuth 实体和持久化契约包含所有 V2 字段 + */ + @Test + public void testA_V2MapperFieldsAlignDBContract() throws Exception + { + Class clazz = TrainDormAuth.class; + + // 必须存在 V2 核心字段 + assertNotNull("accommodationId 字段必须存在", clazz.getDeclaredField("accommodationId")); + assertNotNull("authStart 字段必须存在", clazz.getDeclaredField("authStart")); + assertNotNull("authEnd 字段必须存在", clazz.getDeclaredField("authEnd")); + assertNotNull("faceSyncStatus 字段必须存在", clazz.getDeclaredField("faceSyncStatus")); + assertNotNull("retryCount 字段必须存在", clazz.getDeclaredField("retryCount")); + assertNotNull("lastError 字段必须存在", clazz.getDeclaredField("lastError")); + assertNotNull("externalAuthId 字段必须存在", clazz.getDeclaredField("externalAuthId")); + assertNotNull("lastSyncTime 字段必须存在", clazz.getDeclaredField("lastSyncTime")); + + // 验证状态常量定义符合 DB SMALLINT 约束 (0, 1, 2, 3) + assertEquals(Integer.valueOf(0), TrainDormAuth.STATUS_PENDING); + assertEquals(Integer.valueOf(1), TrainDormAuth.STATUS_ACTIVE); + assertEquals(Integer.valueOf(2), TrainDormAuth.STATUS_FAILED); + assertEquals(Integer.valueOf(3), TrainDormAuth.STATUS_REVOKED); + } + + /** + * Requirement B: Authorization references accommodation + * 必须基于有效的 accommodation_id,无 accommodationId 或关联不存在住宿时拒绝 + */ + @Test + public void testB_AuthorizationReferencesAccommodation() + { + // 1. 无 accommodationId 被拒绝 + TrainDormAuth authNoAcc = new TrainDormAuth(); + authNoAcc.setAuthStart(new Date()); + try + { + dormAuthService.insertTrainDormAuth(authNoAcc); + fail("未绑定住宿事实未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("必须绑定住宿安排")); + } + + // 2. 住宿记录不存在被拒绝 + TrainDormAuth authNonExist = new TrainDormAuth(); + authNonExist.setAccommodationId(999999L); + try + { + dormAuthService.insertTrainDormAuth(authNonExist); + fail("关联不存在的住宿记录未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("指定的住宿安排不存在")); + } + + // 3. 关联已退宿的记录被拒绝 + TrainDormAuth authCheckedOut = new TrainDormAuth(); + authCheckedOut.setAccommodationId(5002L); // 对应 checkedOutAcc + try + { + dormAuthService.insertTrainDormAuth(authCheckedOut); + fail("关联已退宿住宿记录未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("该住宿安排已退宿")); + } + + // 4. 关联有效住宿成功 + TrainDormAuth validAuth = new TrainDormAuth(); + validAuth.setAccommodationId(5001L); + validAuth.setAuthStart(new Date()); + validAuth.setAuthEnd(new Date(System.currentTimeMillis() + 86400000L)); + int rows = dormAuthService.insertTrainDormAuth(validAuth); + assertEquals(1, rows); + assertNotNull(validAuth.getAuthId()); + } + + /** + * Requirement C: Legacy student_id/plan_id/dorm_id no longer persisted + * 旧字段不再作为 DB 列持久化,而在实体层保留为视图/兼容访问 + */ + @Test + public void testC_LegacyFieldsNoLongerPersisted() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + // 设置兼容视图字段 + auth.setStudentId("STU_LEGACY_01"); + auth.setPlanId("PLAN_LEGACY_01"); + auth.setDormId("DORM_LEGACY_01"); + auth.setBedNo("1号床"); + auth.setCreateUser("admin_v1"); + + dormAuthService.insertTrainDormAuth(auth); + + // 验证 Mapper 写入的方法只接收并持久化 V2 真实字段 + TrainDormAuth saved = mockDormAuthMapper.selectTrainDormAuthByAuthId(auth.getAuthId()); + assertNotNull(saved); + assertEquals(Long.valueOf(5001L), saved.getAccommodationId()); + // createUser 兼容适配到 BaseEntity.createBy + assertEquals("admin_v1", saved.getCreateBy()); + } + + /** + * Requirement D & E: Retry summary fields and last error fields + */ + @Test + public void testDE_RetrySummaryAndLastErrorFields() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + dormAuthService.insertTrainDormAuth(auth); + Long authId = auth.getAuthId(); + + // 初始重试次数为 0 + assertEquals(Integer.valueOf(0), auth.getRetryCount()); + assertNull(auth.getLastError()); + + // 模拟下发失败,记录错误并累加重试次数 + dormAuthService.markSyncFailed(authId, "Device offline: timeout after 5000ms", "sync_job"); + + TrainDormAuth failed = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_FAILED, failed.getFaceSyncStatus()); + assertEquals("Device offline: timeout after 5000ms", failed.getLastError()); + assertEquals(Integer.valueOf(1), failed.getRetryCount()); + + // 再次失败,重试次数增加到 2 + dormAuthService.markSyncFailed(authId, "Device unreachable", "sync_job"); + TrainDormAuth failedAgain = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(Integer.valueOf(2), failedAgain.getRetryCount()); + assertEquals("Device unreachable", failedAgain.getLastError()); + } + + /** + * Requirement F: Status transition + * 0 待下发 -> 2 下发失败 -> 3 已注销 + */ + @Test + public void testF_StatusTransition() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + dormAuthService.insertTrainDormAuth(auth); + Long authId = auth.getAuthId(); + + // 初始为待下发 + TrainDormAuth step0 = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_PENDING, step0.getFaceSyncStatus()); + + // 下发失败 -> STATUS_FAILED + dormAuthService.markSyncFailed(authId, "Network error", "system"); + TrainDormAuth step1 = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_FAILED, step1.getFaceSyncStatus()); + + // 注销权限 -> STATUS_REVOKED + dormAuthService.revokeAuth(authId, "admin"); + TrainDormAuth step2 = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_REVOKED, step2.getFaceSyncStatus()); + } + + /** + * Requirement G: No DMS client dependency & No Fake Device Success + * 不调用任何外部 DMS,创建时绝不虚假赋值为已下发成功 (STATUS_ACTIVE) + */ + @Test + public void testG_NoDmsClientDependencyAndNoFakeSuccess() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + // 即使客户端试图传入 STATUS_ACTIVE,业务层由于无实际下发能力,必须强制为 STATUS_PENDING + auth.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE); + + dormAuthService.insertTrainDormAuth(auth); + + TrainDormAuth saved = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId()); + assertEquals("没有真实DMS调用前,状态必须为待下发", TrainDormAuth.STATUS_PENDING, saved.getFaceSyncStatus()); + } + + /** + * Wave 0 Review Gate - Requirement 15: DormAuth History Test + * 验证换房场景下授权记录共存: + * Old Accommodation -> Old DormAuth (status=REVOKED) + * New Accommodation -> New DormAuth (status=PENDING/ACTIVE) + * 两条授权记录在历史中共存,互不覆盖 + */ + @Test + public void testDormAuthHistoryAcrossTransfers() + { + Long oldAccId = 5001L; + Long newAccId = 5003L; + + // 注册新住宿 + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setAccommodationId(newAccId); + newAcc.setEnrollmentId(1001L); + newAcc.setDormId("DORM_201"); + newAcc.setBedNo("新1号床"); + newAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + mockAccommodationMapper.save(newAcc); + + // 1. 旧住宿对应旧授权 + TrainDormAuth oldAuth = new TrainDormAuth(); + oldAuth.setAccommodationId(oldAccId); + dormAuthService.insertTrainDormAuth(oldAuth); + Long oldAuthId = oldAuth.getAuthId(); + + // 2. 新住宿对应新授权 + TrainDormAuth newAuth = new TrainDormAuth(); + newAuth.setAccommodationId(newAccId); + dormAuthService.insertTrainDormAuth(newAuth); + Long newAuthId = newAuth.getAuthId(); + + // 3. 旧授权注销 (退宿或换房引起) + dormAuthService.revokeAuth(oldAuthId, "admin"); + + // 4. 验证两条授权在历史中共存,状态相互独立 + TrainDormAuth queriedOld = dormAuthService.selectTrainDormAuthByAuthId(oldAuthId); + TrainDormAuth queriedNew = dormAuthService.selectTrainDormAuthByAuthId(newAuthId); + + assertNotNull(queriedOld); + assertNotNull(queriedNew); + assertEquals(TrainDormAuth.STATUS_REVOKED, queriedOld.getFaceSyncStatus()); + assertEquals(TrainDormAuth.STATUS_PENDING, queriedNew.getFaceSyncStatus()); + assertEquals(oldAccId, queriedOld.getAccommodationId()); + assertEquals(newAccId, queriedNew.getAccommodationId()); + } + + // ================================= 内部 Mock 类 ================================= + + private static class MockDormAuthMapper implements TrainDormAuthMapper + { + private final Map store = new HashMap<>(); + private long idSeq = 10L; + + @Override + public TrainDormAuth selectTrainDormAuthByAuthId(Long authId) + { + return store.get(authId); + } + + @Override + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId) + { + for (TrainDormAuth a : store.values()) + { + if (accommodationId.equals(a.getAccommodationId())) + { + return a; + } + } + return null; + } + + @Override + public List selectTrainDormAuthList(TrainDormAuth trainDormAuth) + { + return new ArrayList<>(store.values()); + } + + @Override + public int insertTrainDormAuth(TrainDormAuth trainDormAuth) + { + trainDormAuth.setAuthId(++idSeq); + store.put(trainDormAuth.getAuthId(), trainDormAuth); + return 1; + } + + @Override + public int updateTrainDormAuth(TrainDormAuth trainDormAuth) + { + store.put(trainDormAuth.getAuthId(), trainDormAuth); + return 1; + } + + @Override + public int updateSyncStatus(Long authId, Integer faceSyncStatus, String lastError, String updateBy, Date updateTime) + { + TrainDormAuth a = store.get(authId); + if (a != null) + { + a.setFaceSyncStatus(faceSyncStatus); + a.setLastError(lastError); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int increaseRetryCount(Long authId, String lastError, String updateBy, Date updateTime) + { + TrainDormAuth a = store.get(authId); + if (a != null) + { + int current = a.getRetryCount() != null ? a.getRetryCount() : 0; + a.setRetryCount(current + 1); + a.setLastError(lastError); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int deleteTrainDormAuthByAuthId(Long authId) + { + return store.remove(authId) != null ? 1 : 0; + } + + @Override + public int deleteTrainDormAuthByAuthIds(Long[] authIds) + { + int count = 0; + for (Long id : authIds) + { + if (store.remove(id) != null) count++; + } + return count; + } + } + + private static class MockAccommodationMapper implements TrainAccommodationMapper + { + private final Map store = new HashMap<>(); + + public void save(TrainAccommodation a) + { + store.put(a.getAccommodationId(), a); + } + + @Override + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.get(accommodationId); + } + + @Override + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) return a; + } + return null; + } + + @Override + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId()) && + (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))) + { + return a; + } + } + return null; + } + + @Override + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) + { + List list = new ArrayList<>(); + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) list.add(a); + } + return list; + } + + @Override + public List selectTrainAccommodationList(TrainAccommodation accommodation) + { + return new ArrayList<>(store.values()); + } + + @Override + public List selectActiveAccommodationsByPlanId(String planId) + { + return new ArrayList<>(store.values()); + } + + @Override + public int countActiveByDormId(String dormId) + { + return 0; + } + + @Override + public int insertTrainAccommodation(TrainAccommodation accommodation) + { + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateTrainAccommodation(TrainAccommodation accommodation) + { + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setAccommodationStatus(accommodationStatus); + return 1; + } + return 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.remove(accommodationId) != null ? 1 : 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds) + { + return 0; + } + } +} diff --git a/RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql b/RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql new file mode 100644 index 000000000..335c578a1 --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql @@ -0,0 +1,70 @@ +-- 006_accommodation_history_constraint.sql +-- 目标:移除 train_accommodation 全局唯一约束 uq_accommodation_enroll, +-- 创建部分唯一索引 uq_accommodation_enroll_active (每个 enrollment 最多 1 条有效住宿), +-- 允许学员拥有多条退宿历史记录 (status=2),彻底支持调房与历史留痕。 + +DO $$ +DECLARE + v_dup_count INTEGER; + v_con_def TEXT; + v_idx_def TEXT; +BEGIN + -- 1. Pre-Migration Data Safety Guard + -- 检查是否已存在同一个 enrollment_id 拥有 2 条或以上处于有效状态 (0=预分配, 1=已入住) 的住宿记录 + SELECT COUNT(*) INTO v_dup_count + FROM ( + SELECT enrollment_id + FROM train_accommodation + WHERE accommodation_status IN (0, 1) + GROUP BY enrollment_id + HAVING COUNT(*) > 1 + ) t; + + IF v_dup_count > 0 THEN + RAISE EXCEPTION 'Pre-migration check failed: found % enrollment(s) with multiple active accommodations (status IN (0,1)). Manual resolution required before applying migration 006.', v_dup_count; + END IF; + + -- 2. Existing Constraint Guard (Idempotent Drop) + -- 检查约束 uq_accommodation_enroll 是否存在且符合预期 + SELECT pg_get_constraintdef(c.oid) INTO v_con_def + FROM pg_constraint c + JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'train_accommodation' + AND c.conname = 'uq_accommodation_enroll'; + + IF v_con_def IS NOT NULL THEN + -- 验证旧约束是否为 UNIQUE (enrollment_id) + IF v_con_def ILIKE 'UNIQUE (enrollment_id)%' THEN + RAISE NOTICE 'Dropping legacy global unique constraint: uq_accommodation_enroll'; + ALTER TABLE train_accommodation DROP CONSTRAINT uq_accommodation_enroll; + ELSE + RAISE EXCEPTION 'Constraint collision: constraint uq_accommodation_enroll exists but definition is unexpected: %', v_con_def; + END IF; + ELSE + RAISE NOTICE 'Constraint uq_accommodation_enroll does not exist, skipping drop.'; + END IF; + + -- 3. New Index Collision Guard & Creation + -- 检查部分唯一索引 uq_accommodation_enroll_active + SELECT pg_get_indexdef(i.indexrelid) INTO v_idx_def + FROM pg_index i + JOIN pg_class c ON i.indexrelid = c.oid + JOIN pg_class t ON i.indrelid = t.oid + WHERE t.relname = 'train_accommodation' + AND c.relname = 'uq_accommodation_enroll_active'; + + IF v_idx_def IS NOT NULL THEN + -- 验证已有索引是否为预期的部分唯一索引 + IF v_idx_def ILIKE '%UNIQUE INDEX uq_accommodation_enroll_active ON %train_accommodation% (enrollment_id)%WHERE (accommodation_status = ANY (ARRAY[0, 1]))%' + OR v_idx_def ILIKE '%UNIQUE INDEX uq_accommodation_enroll_active ON %train_accommodation% (enrollment_id)%WHERE%accommodation_status IN (0, 1)%' THEN + RAISE NOTICE 'Partial unique index uq_accommodation_enroll_active already exists with expected definition, skipping creation.'; + ELSE + RAISE EXCEPTION 'Index collision: index uq_accommodation_enroll_active exists but definition differs: %', v_idx_def; + END IF; + ELSE + RAISE NOTICE 'Creating partial unique index: uq_accommodation_enroll_active'; + CREATE UNIQUE INDEX uq_accommodation_enroll_active + ON train_accommodation (enrollment_id) + WHERE accommodation_status IN (0, 1); + END IF; +END $$; diff --git a/docs/development/dms-r3/02_current_accommodation_audit.md b/docs/development/dms-r3/02_current_accommodation_audit.md index e492ed2b5..b8407a41f 100644 --- a/docs/development/dms-r3/02_current_accommodation_audit.md +++ b/docs/development/dms-r3/02_current_accommodation_audit.md @@ -137,3 +137,35 @@ 2. **`train_dorm_auth`**:Kingbase 物理表已在 V2 中升级为以 `accommodation_id` 为外键的门禁授权结构,但 Java 实体类 `TrainDormAuth.java` 与 XML Mapper 依然停留在 V1 旧结构(持有 `student_id`, `plan_id`, `dorm_id`, `bed_no`)。 - **执行原则**: 在进入 R.3B 真正编写 DMS Adapter 之前,**必须先在 Wave 0 完成本地住宿与门禁授权领域模型对齐**!严禁让外部 DMS 集成适配器直接架设在当前存在严重漂移的本地模型之上。 + +### 5.2 Wave 0 Implementation Result (本地模型对齐实施完成) +- **完成状态**:已全面对齐并消除结构漂移。 +- **实体层**: + - 新增 `TrainAccommodation.java`,严格对应 Kingbase V2 生产物理表(`accommodation_id`, `enrollment_id`, `dorm_id`, `bed_no`, `assigned_time`, `accommodation_status`, 审计列)。 + - 重构 `TrainDormAuth.java`,外键核心对齐 `accommodation_id`,持久化字段剔除 V1 遗留字段(`student_id`, `plan_id`, `dorm_id`, `bed_no`, `createUser`),保留为非持久化兼容视图字段以保障只读兼容。 +- **Mapper/SQL**: + - 新增 `TrainAccommodationMapper.java` / `TrainAccommodationMapper.xml`,所有字段显式指定,严禁 `SELECT *`。 + - 重构 `TrainDormAuthMapper.xml`,INSERT/UPDATE 语句彻底清理 V1 废弃字段写入,通过 LEFT JOIN 视图提供兼容字段。 +- **业务服务**: + - 新增 `ITrainAccommodationService` / `TrainAccommodationServiceImpl`,落地“同一时刻最多1个有效住宿安排”(Duplicate Active Accommodation Guard)、宿舍核定容量边界检查(Capacity Boundary Guard)与换房原子流转(Transfer)。 + - 升级 `TrainDormAuthServiceImpl`,严格执行“Enrollment -> Accommodation -> Door Authorization”业务单向依赖,禁止绕过住宿事实直接授权。 + +### 5.3 Wave 0.1 Accommodation History & 006 Migration (基数模型升级与迁移方案) +- **基数语义冻结**: + - `Enrollment 1:N Accommodation History`(一个报名记录可有多条历史住宿记录,调房换房产生历史轨迹) + - `Enrollment 1:0..1 Active Accommodation`(同一时间一个报名记录最多只能有 1 条有效住宿记录,`accommodation_status IN (0, 1)`) + - `Accommodation 1:0..1 DoorAuthorization`(门禁授权强依赖具体的某一次住宿记录) + - `TIME_RANGE_SUPPORT = LIMITED`(当前 Kingbase 物理表仅包含 `assigned_time`,无 `start_time`/`end_time`,时间窗口支持为有限支持) +- **006 迁移脚本方案 (`006_accommodation_history_constraint.sql`)**: + - 脚本路径:`RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql` + - 安全编号:`006`(001-003 为基线,004-005 已被 R.2 分支占用) + - 前置检查:Fail-fast,若发现同一 `enrollment_id` 存在多条 active(`accommodation_status IN (0, 1)`)则 `RAISE EXCEPTION` 阻止迁移 + - 安全 DROP:仅在原约束 `uq_accommodation_enroll` 确认为 `UNIQUE (enrollment_id)` 时安全移除 + - 局部唯一索引:创建 `uq_accommodation_enroll_active` 并在 `WHERE accommodation_status IN (0, 1)` 生效 + - 幂等性防护:重复执行不会报错或破坏表结构 + - 生产执行纪律:本迁移脚本仅完成工程资产化与隔离测试,**严禁在生产数据库执行** +- **自动化测试**: + - `AccommodationHistoryMigrationContractTest`(契约测试:SQL 语法、前置检查、条件 DROP、局部索引及幂等性) + - `AccommodationServiceTest`(10 项测试,包含调房历史与并发唯一性) + - `DormAuthAlignmentTest`(7 项测试,包含依赖住宿与换房级联) + - 全量测试套件 174 项全部 PASS(0 failures, 0 errors)。 diff --git a/docs/development/dms-r3/09_database_mapping_proposal.md b/docs/development/dms-r3/09_database_mapping_proposal.md index 848dc83d7..443ce2f6e 100644 --- a/docs/development/dms-r3/09_database_mapping_proposal.md +++ b/docs/development/dms-r3/09_database_mapping_proposal.md @@ -101,3 +101,26 @@ 1. **第一步**:重构修复 Java 实体类 `TrainDormAuth.java`,彻底移除已弃用的 `student_id`, `plan_id`, `dorm_id` 字段,使其与已存在的 Kingbase 物理表字段完全对齐。 2. **第二步**:创建 `train_external_resource_mapping` 与 `train_external_operation` 迁移脚本(遵循系统标准 Liquibase/Flyway 规范)。 3. **第三步**:通过 Spring Data / MyBatis-Plus 完成三表关联映射。 + +### 4.1 Wave 0 Implementation Result (第一步已正式交付) +- **已完成事项**: + 1. 重构并修复 `TrainDormAuth.java`,彻底废弃了向 `train_dorm_auth` 写入 `student_id`, `plan_id`, `dorm_id`, `bed_no`, `create_user` 的历史假设。 + 2. 补齐 `TrainAccommodation.java`、`TrainAccommodationMapper.java`、`TrainAccommodationMapper.xml`、`ITrainAccommodationService` 以及 `TrainAccommodationServiceImpl`,完全对齐 V2 生产物理结构。 + 3. 严格禁止在 Wave 0 中引入任何 DMS 物理字段(如 `lock_id`, `place_id`, `operation_id`),保持 Vendor-Neutral 纯净业务边界。 + 4. 生产 Migration 检查:`MIGRATION_REQUIRED = NO`,生产数据库零写入、零变更。 + +### 4.2 Wave 0.1 Accommodation History Migration (006 迁移方案与基数冻结) +- **领域基数与物理约束演进**: + - **历史模型**:`Enrollment 1:N Accommodation History`,允许学员由于调房等产生多条住宿历史。 + - **唯一性收敛**:`Enrollment 1:0..1 Active Accommodation`,原全局唯一约束 `UNIQUE(enrollment_id)` 阻碍了历史留痕,演进为针对有效状态(`accommodation_status IN (0, 1)`)的局部唯一索引。 + - **授权关联**:`Accommodation 1:0..1 DoorAuthorization`,门禁授权强引用住宿实体。 + - **时间范围特性**:`TIME_RANGE_SUPPORT = LIMITED`,当前物理表仅有 `assigned_time`,未扩展起止时间列。 +- **006 Migration 脚本设计与验证**: + - 文件路径:`RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql` + - 安全编号:`006`(避免与 R.2 占用的 004/005 冲突)。 + - 执行安全机制: + 1. 前置重复数据检查:存在并发违规 active 记录时直接 `RAISE EXCEPTION` 阻断。 + 2. 严格条件 DROP:仅当原约束 `uq_accommodation_enroll` 确认为 `UNIQUE (enrollment_id)` 时执行 drop。 + 3. 创建局部唯一索引:`CREATE UNIQUE INDEX IF NOT EXISTS uq_accommodation_enroll_active ON train_accommodation (enrollment_id) WHERE accommodation_status IN (0, 1);` + 4. 幂等防护:重复执行幂等安全。 + - 生产执行纪律:本脚本已在单元/契约测试中完成全量静态与逻辑校验,**禁止在生产库执行**。 diff --git a/docs/development/dms-r3/12_implementation_task_breakdown.md b/docs/development/dms-r3/12_implementation_task_breakdown.md index b964afe23..c854cfeae 100644 --- a/docs/development/dms-r3/12_implementation_task_breakdown.md +++ b/docs/development/dms-r3/12_implementation_task_breakdown.md @@ -139,3 +139,29 @@ flowchart TD | **Wave 2: DMS Adapter & Mock Tests** | 协议适配器、TokenManager、轮询调度器、22项单测 | 外部集成层 | 5 人天 | | **Wave 3: Orchestration, UI & Sandbox** | 业务编排服务、前端界面、现场沙箱实物 E2E 联调 | 业务层/前端/现场 | 5~6 人天 | | **总计** | **R.3 门禁集成端到端正式交付上线** | **全栈闭环** | **约 15~16 人天** | + +### 3.1 Wave 0 Implementation Result (交付与验收验证) +- **阶段目标**:Local Accommodation & Door Authorization Domain Alignment +- **执行状态**:已完成 (Completed) +- **交付清单**: + 1. `TrainAccommodation.java`、`TrainAccommodationMapper.java`、`TrainAccommodationMapper.xml` + 2. `ITrainAccommodationService.java`、`TrainAccommodationServiceImpl.java` + 3. `TrainAccommodationController.java` (最小必要 Admin REST API) + 4. `TrainDormAuth.java`、`TrainDormAuthMapper.java`、`TrainDormAuthMapper.xml`、`TrainDormAuthServiceImpl.java`、`TrainDormAuthController.java` (V2 Schema 对齐与清理) + 5. 自动化测试套件:`AccommodationServiceTest`、`DormAuthAlignmentTest`、`AccommodationAndAuthApiControllerContractTest`。 + +### 3.2 Wave 0.1 Implementation Result (基数模型升级与 006 迁移交付) +- **阶段目标**:Accommodation History Migration & Final Checkpoint +- **模型基数关系**: + - `Enrollment 1:N Accommodation History` + - `Enrollment 1:0..1 Active Accommodation` (`accommodation_status IN (0, 1)`) + - `Accommodation 1:0..1 DoorAuthorization` + - `TIME_RANGE_SUPPORT = LIMITED` +- **新增交付物**: + 1. `RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql`(安全编号 006、前置检查、条件 DROP、局部唯一索引、幂等执行;生产库未执行) + 2. `RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java`(迁移脚本规范与语法契约测试) + 3. 历史查询与调房并发唯一性测试(`selectAccommodationHistoryByEnrollmentId`、`transferAccommodation` 级联授权废弃) +- **全量回归验证**: + - `ruoyi-train`: 153 tests + - `ruoyi-admin`: 21 tests + - 总计:174 tests, Failures: 0, Errors: 0, Skipped: 0,全量通过。 diff --git a/docs/development/dms-r3/14_decision_summary.md b/docs/development/dms-r3/14_decision_summary.md index 962e81c27..8768bb318 100644 --- a/docs/development/dms-r3/14_decision_summary.md +++ b/docs/development/dms-r3/14_decision_summary.md @@ -106,3 +106,25 @@ - 配置与环境变量绑定;`DmsTokenManager` 单飞排他锁并发控制;细分 HTTP 客户端;在线密码/卡片适配器;10 秒轮询调度器;WireMock 22 项单测全覆盖。 4. **Wave 3: Business Orchestration & UI & Sandbox (业务编排、界面与现场验收,5~6天)**: - 报到联动授权下发;“先发新房后撤旧房”换房补偿;退训/结训撤权;管理端门禁综合视图;学员端 30 秒倒计时密码受控查看;现场实验台沙箱 E2E 闭环验收。 + +### 6.1 Wave 0 Implementation Result (本地领域模型闭环) +- **基线与状态**: + - 成功消除数据库 V2 模型与 Java 代码之间的全部结构漂移。 + - 严格保持 0 DMS 耦合:无 DmsClient、无 TokenManager、无外部调用、无假设备成功。 + - 保持 Kingbase 生产库只读,零 Migration(`MIGRATION_REQUIRED = NO`)。 + - 交付本地领域模型:`TrainAccommodation` 与升级后的 `TrainDormAuth`。 + +### 6.2 Wave 0.1 Accommodation History & Final Checkpoint Decision (基数模型升级与终态) +- **核心基数关系冻结**: + - `Enrollment 1:N Accommodation History`:允许学员产生多次历史住宿记录(调房/换房留痕)。 + - `Enrollment 1:0..1 Active Accommodation`:同一时刻最多一条有效住宿(`accommodation_status IN (0, 1)`)。 + - `Accommodation 1:0..1 DoorAuthorization`:门禁授权强引用住宿实体。 + - `TIME_RANGE_SUPPORT = LIMITED`:当前 Kingbase 物理表仅有 `assigned_time`,无 `start_time` / `end_time` 列,时间窗口支持为有限支持。 +- **006 Migration 方案设计**: + - 脚本路径:`RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql`。 + - 安全序号:`006`(001-003 为基线,004-005 已被 R.2 分支占用)。 + - 迁移安全护栏:前置重复 active 检查抛异常、仅在定义匹配时安全 DROP `uq_accommodation_enroll`、创建 `uq_accommodation_enroll_active` 局部唯一索引(`WHERE accommodation_status IN (0, 1)`)、全流程幂等。 + - 生产执行纪律:**禁止在生产库执行 006 迁移脚本**,本 Wave 仅作资产与契约验证储备。 +- **质量与构建验收**: + - 单元与契约测试总数:174 项,全部 PASS(0 failures, 0 errors)。 + - Maven `clean test` 与 `clean package` 均顺利构建成功。