From 417c076465913d7d7ce75c028d4bad4e341af5a3 Mon Sep 17 00:00:00 2001
From: jzf
Date: Wed, 11 Nov 2026 18:25:37 +0800
Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=AF=93=E7=AB=AF=E5=BC=80=E5=8F=91?=
=?UTF-8?q?=E6=8E=A8=E8=BF=9B=E5=88=B0=E7=AC=AC=E4=BA=94=E9=98=B6=E6=AE=B5?=
=?UTF-8?q?=EF=BC=8Cami=E4=BA=8B=E4=BB=B6=E5=B7=B2=E7=BB=8F=E5=8A=A0?=
=?UTF-8?q?=E5=85=A5=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../controller/AptJcxxController.java | 126 +++
.../dispatch/domain/AptCorrectCommand.java | 148 ++++
.../apt/dispatch/domain/AptDutyCommand.java | 58 ++
.../dispatch/domain/AptDutyCommandResult.java | 76 ++
.../apt/dispatch/domain/AptJcxxQuery.java | 145 ++++
.../apt/dispatch/domain/AptStateSnapshot.java | 104 +++
.../dispatch/mapper/AptDutyStateMapper.java | 57 ++
.../apt/dispatch/mapper/AptJcxxMapper.java | 82 ++
.../service/AptDispatchScanServiceImpl.java | 100 +++
.../service/AptDutyCommandServiceImpl.java | 330 ++++++++
.../service/AptDutyStateServiceImpl.java | 126 +++
.../dispatch/service/AptJcxxServiceImpl.java | 307 +++++++
.../service/IAptDispatchScanService.java | 16 +
.../service/IAptDutyCommandService.java | 18 +
.../service/IAptDutyStateService.java | 40 +
.../apt/dispatch/service/IAptJcxxService.java | 27 +
.../controller/AptTabletOpenController.java | 82 ++
.../tablet/domain/AptTabletResultCommand.java | 103 +++
.../dispatch/tablet/domain/AptTabletTask.java | 169 ++++
.../tablet/mapper/AptTabletMapper.java | 74 ++
.../service/AptTabletDispatchServiceImpl.java | 206 +++++
.../service/AptTabletResultServiceImpl.java | 333 ++++++++
.../service/IAptTabletDispatchService.java | 23 +
.../service/IAptTabletResultService.java | 15 +
.../controller/AptDutyFaceRegController.java | 84 ++
.../controller/AptFaceOpenController.java | 82 ++
.../ruoyi/apt/face/domain/AptFacePerson.java | 142 ++++
.../com/ruoyi/apt/face/domain/AptFaceReg.java | 290 +++++++
.../ruoyi/apt/face/domain/AptFaceRegAck.java | 141 ++++
.../apt/face/domain/AptFaceRegCommand.java | 205 +++++
.../face/domain/AptFaceRegConfirmCommand.java | 80 ++
.../face/domain/AptFaceRegRejectCommand.java | 41 +
.../apt/face/domain/AptFaceVerifyCommand.java | 119 +++
.../apt/face/domain/AptFaceVerifyResult.java | 174 ++++
.../apt/face/mapper/AptFaceRegMapper.java | 75 ++
.../apt/face/mapper/AptFaceVerifyMapper.java | 33 +
.../face/service/AptFaceRegServiceImpl.java | 507 +++++++++++
.../face/service/AptFaceSyncDispatcher.java | 98 +++
.../face/service/AptFaceVerdictService.java | 98 +++
.../service/AptFaceVerifyServiceImpl.java | 171 ++++
.../apt/face/service/IAptFaceRegService.java | 33 +
.../face/service/IAptFaceVerifyService.java | 18 +
.../apt/framework/device/AptDeviceSigner.java | 229 +++++
.../controller/AptCheckinOpenController.java | 72 ++
.../controller/AptCheckinQueryController.java | 39 +
.../controller/AptDutyCheckinController.java | 93 ++
.../checkin/domain/AptCallTimeResult.java | 125 +++
.../checkin/domain/AptCheckinApplication.java | 702 ++++++++++++++++
.../domain/AptCheckinApplyCommand.java | 181 ++++
.../checkin/domain/AptCheckinApplyResult.java | 121 +++
.../checkin/domain/AptCheckinCallTask.java | 269 ++++++
.../checkin/domain/AptCheckinMatchResult.java | 145 ++++
.../domain/AptCheckinMatchVerdict.java | 138 +++
.../checkin/domain/AptCheckinOptions.java | 313 +++++++
.../domain/AptCheckinReviewCommand.java | 98 +++
.../domain/AptCheckinReviewResult.java | 172 ++++
.../domain/AptCheckinRoomAdjustCommand.java | 69 ++
.../domain/AptCheckinRoomAdjustResult.java | 145 ++++
.../checkin/domain/AptCheckinTimeline.java | 172 ++++
.../apt/stay/checkin/domain/AptOutboxRow.java | 107 +++
.../stay/checkin/mapper/AptCheckinMapper.java | 132 +++
.../service/AptCallTimeCalculator.java | 125 +++
.../service/AptCheckinMatchServiceImpl.java | 213 +++++
.../service/AptCheckinServiceImpl.java | 795 ++++++++++++++++++
.../service/IAptCheckinMatchService.java | 18 +
.../checkin/service/IAptCheckinService.java | 53 ++
.../apt/voice/domain/AptSipEventCommand.java | 70 ++
.../service/AptVoiceEventServiceImpl.java | 155 ++++
.../voice/service/IAptVoiceEventService.java | 21 +
.../ruoyi/quartz/task/AptDispatchTask.java | 35 +
.../resources/mapper/apt/AptCheckinMapper.xml | 418 +++++++++
.../mapper/apt/AptDutyStateMapper.xml | 121 +++
.../resources/mapper/apt/AptFaceRegMapper.xml | 193 +++++
.../mapper/apt/AptFaceVerifyMapper.xml | 27 +
.../resources/mapper/apt/AptJcxxMapper.xml | 300 +++++++
.../resources/mapper/apt/AptTabletMapper.xml | 150 ++++
.../dispatch/AptDutyCommandServiceTest.java | 171 ++++
.../apt/dispatch/AptDutyStateServiceTest.java | 105 +++
.../apt/dispatch/AptJcxxServiceTest.java | 206 +++++
.../dispatch/AptTabletResultServiceTest.java | 238 ++++++
.../ruoyi/apt/face/AptDeviceSignerTest.java | 142 ++++
.../ruoyi/apt/face/AptFaceRegServiceTest.java | 314 +++++++
.../apt/face/AptFaceVerdictServiceTest.java | 99 +++
.../apt/face/AptFaceVerifyServiceTest.java | 164 ++++
.../AptDutyStateSingleWriterArchTest.java | 133 +++
.../apt/framework/AptIt3SqlContractTest.java | 133 +++
.../apt/framework/AptIt4SqlContractTest.java | 115 +++
.../apt/framework/AptIt5SqlContractTest.java | 110 +++
.../checkin/AptCallTimeCalculatorTest.java | 105 +++
.../checkin/AptCheckinMatchServiceTest.java | 226 +++++
.../stay/checkin/AptCheckinServiceTest.java | 485 +++++++++++
dev/scripts/apply-it3-sql.ps1 | 110 +++
dev/scripts/apply-it4-sql.ps1 | 110 +++
dev/scripts/apply-it5-sql.ps1 | 110 +++
dev/scripts/verify-it2-m1m2.ps1 | 373 ++++++++
95 files changed, 14621 insertions(+)
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptJcxxServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyCommandService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptJcxxService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/controller/AptTabletOpenController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletResultCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletTask.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/mapper/AptTabletMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletDispatchServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletDispatchService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletResultService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptDutyFaceRegController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptFaceOpenController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFacePerson.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceReg.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegAck.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegConfirmCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegRejectCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyResult.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceRegMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceVerifyMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceRegServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceSyncDispatcher.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerdictService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerifyServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceRegService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceVerifyService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/device/AptDeviceSigner.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinOpenController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinQueryController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptDutyCheckinController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCallTimeResult.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplication.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyResult.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinCallTask.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchResult.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchVerdict.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinOptions.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewResult.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustResult.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinTimeline.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptOutboxRow.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/mapper/AptCheckinMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCallTimeCalculator.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java
create mode 100644 dev/scripts/apply-it3-sql.ps1
create mode 100644 dev/scripts/apply-it4-sql.ps1
create mode 100644 dev/scripts/apply-it5-sql.ps1
create mode 100644 dev/scripts/verify-it2-m1m2.ps1
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java
new file mode 100644
index 0000000..adb2088
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java
@@ -0,0 +1,126 @@
+package com.ruoyi.apt.dispatch.controller;
+
+import java.util.Date;
+import java.util.List;
+import com.ruoyi.apt.common.audit.AptAudit;
+import com.ruoyi.apt.common.page.AptTableDataInfo;
+import com.ruoyi.apt.dispatch.domain.AptCorrectCommand;
+import com.ruoyi.apt.dispatch.domain.AptDutyCommand;
+import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult;
+import com.ruoyi.apt.dispatch.domain.AptJcxxQuery;
+import com.ruoyi.apt.dispatch.service.IAptDutyCommandService;
+import com.ruoyi.apt.dispatch.service.IAptJcxxService;
+import com.ruoyi.apt.framework.web.AptBaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 叫乘信息管理(IT-5 / M3,公寓端/04 表 4-9 接口 1~6)。
+ *
+ * 刻意不提供 {@code PUT /apt/jcxx/{id}}:状态字段只能经
+ * {@code POST /apt/jcxx/{id}/commands} 提交命令,由 {@code apt.duty.state} 落库;
+ * 非状态字段走 {@code /correct} 白名单。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/apt/jcxx", produces = "application/json;charset=UTF-8")
+public class AptJcxxController extends AptBaseController
+{
+ private final IAptJcxxService jcxxService;
+ private final IAptDutyCommandService commandService;
+
+ public AptJcxxController(IAptJcxxService jcxxService, IAptDutyCommandService commandService)
+ {
+ this.jcxxService = jcxxService;
+ this.commandService = commandService;
+ }
+
+ /** 表 4-9 #1:在寓/叫乘记录列表 */
+ @PreAuthorize("@ss.hasPermi('apt:jcxx:list')")
+ @AptAudit(module = "DISPATCH", action = "JCXX_LIST")
+ @GetMapping("/list")
+ public AptTableDataInfo list(
+ @RequestParam(required = false) String ryid,
+ @RequestParam(required = false) String rymc,
+ @RequestParam(required = false) String fjh,
+ @RequestParam(required = false) String cc,
+ @RequestParam(required = false) String jlid,
+ @RequestParam(required = false) List ryzt,
+ @RequestParam(required = false) String finishJc,
+ @RequestParam(required = false) String finishLy,
+ @RequestParam(required = false) String stoped,
+ @RequestParam(required = false) String isTempCall,
+ @RequestParam(required = false)
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date jcsjFrom,
+ @RequestParam(required = false)
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date jcsjTo)
+ {
+ AptJcxxQuery query = new AptJcxxQuery();
+ query.setRyid(ryid);
+ query.setRymc(rymc);
+ query.setFjh(fjh);
+ query.setCc(cc);
+ query.setJlid(jlid);
+ query.setRyzt(ryzt);
+ query.setFinishJc(finishJc);
+ query.setFinishLy(finishLy);
+ query.setStoped(stoped);
+ query.setIsTempCall(isTempCall);
+ query.setJcsjFrom(jcsjFrom);
+ query.setJcsjTo(jcsjTo);
+
+ startAptPage();
+ return getAptDataTable(jcxxService.list(query));
+ }
+
+ /** 表 4-9 #6:状态分布(看板) */
+ @PreAuthorize("@ss.hasPermi('apt:jcxx:list')")
+ @GetMapping("/statistics")
+ public AjaxResult statistics(@RequestParam(required = false) String date)
+ {
+ return AjaxResult.success(jcxxService.statistics(date));
+ }
+
+ /** 表 4-9 #2:详情 */
+ @PreAuthorize("@ss.hasPermi('apt:jcxx:query')")
+ @AptAudit(module = "DISPATCH", action = "JCXX_DETAIL")
+ @GetMapping("/{id}")
+ public AjaxResult detail(@PathVariable Long id)
+ {
+ return AjaxResult.success(jcxxService.detail(id));
+ }
+
+ /** 表 4-9 #3:事件时间线(排障) */
+ @PreAuthorize("@ss.hasPermi('apt:jcxx:query')")
+ @GetMapping("/{id}/timeline")
+ public AjaxResult timeline(@PathVariable Long id)
+ {
+ return AjaxResult.success(jcxxService.timeline(id));
+ }
+
+ /** 表 4-9 #4:非状态字段修正(白名单) */
+ @PreAuthorize("@ss.hasPermi('apt:jcxx:correct')")
+ @PostMapping("/{id}/correct")
+ public AjaxResult correct(@PathVariable Long id, @RequestBody AptCorrectCommand command)
+ {
+ return AjaxResult.success(jcxxService.correct(id, command));
+ }
+
+ /** 表 4-9 #5:状态机命令(全部转 duty_manual_action 再由 apt.duty.state 执行) */
+ @PreAuthorize("@ss.hasPermi('apt:jcxx:changeStatus')")
+ @PostMapping("/{id}/commands")
+ public AjaxResult commands(@PathVariable Long id, @RequestBody AptDutyCommand command)
+ {
+ AptDutyCommandResult result = commandService.execute(id, command);
+ return AjaxResult.success(result);
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java
new file mode 100644
index 0000000..80b734c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java
@@ -0,0 +1,148 @@
+package com.ruoyi.apt.dispatch.domain;
+
+/**
+ * 非状态字段人工修正(公寓端/04 表 4-9 第 4 个接口)。
+ *
+ * 白名单只有 fjh / jlid / cc / jch / leaveRemark / isTempCall(仅置 1)。
+ * 为让「改 ryzt」明确返回 40001 而不是被静默忽略,状态字段单独声明为
+ * {@code rejected*}:只要请求体带了任一状态字段,直接拒绝。
+ *
+ * @author apt-rev01
+ */
+public class AptCorrectCommand
+{
+ private String commandId;
+ private String reason;
+ private String fjh;
+ private String jlid;
+ private String cc;
+ private String jch;
+ private String leaveRemark;
+ private Boolean isTempCall;
+
+ /** 以下字段不参与写入,仅用于识别越权改状态(40001) */
+ private String ryzt;
+ private String finishJc;
+ private String finishLy;
+ private String stoped;
+
+ public String getCommandId()
+ {
+ return commandId;
+ }
+
+ public void setCommandId(String commandId)
+ {
+ this.commandId = commandId;
+ }
+
+ public String getReason()
+ {
+ return reason;
+ }
+
+ public void setReason(String reason)
+ {
+ this.reason = reason;
+ }
+
+ public String getFjh()
+ {
+ return fjh;
+ }
+
+ public void setFjh(String fjh)
+ {
+ this.fjh = fjh;
+ }
+
+ public String getJlid()
+ {
+ return jlid;
+ }
+
+ public void setJlid(String jlid)
+ {
+ this.jlid = jlid;
+ }
+
+ public String getCc()
+ {
+ return cc;
+ }
+
+ public void setCc(String cc)
+ {
+ this.cc = cc;
+ }
+
+ public String getJch()
+ {
+ return jch;
+ }
+
+ public void setJch(String jch)
+ {
+ this.jch = jch;
+ }
+
+ public String getLeaveRemark()
+ {
+ return leaveRemark;
+ }
+
+ public void setLeaveRemark(String leaveRemark)
+ {
+ this.leaveRemark = leaveRemark;
+ }
+
+ public Boolean getIsTempCall()
+ {
+ return isTempCall;
+ }
+
+ public void setIsTempCall(Boolean isTempCall)
+ {
+ this.isTempCall = isTempCall;
+ }
+
+ public String getRyzt()
+ {
+ return ryzt;
+ }
+
+ public void setRyzt(String ryzt)
+ {
+ this.ryzt = ryzt;
+ }
+
+ public String getFinishJc()
+ {
+ return finishJc;
+ }
+
+ public void setFinishJc(String finishJc)
+ {
+ this.finishJc = finishJc;
+ }
+
+ public String getFinishLy()
+ {
+ return finishLy;
+ }
+
+ public void setFinishLy(String finishLy)
+ {
+ this.finishLy = finishLy;
+ }
+
+ public String getStoped()
+ {
+ return stoped;
+ }
+
+ public void setStoped(String stoped)
+ {
+ this.stoped = stoped;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java
new file mode 100644
index 0000000..603cba2
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java
@@ -0,0 +1,58 @@
+package com.ruoyi.apt.dispatch.domain;
+
+/**
+ * 状态机命令请求(公寓端/04 表 4-9 第 5 个接口)。
+ *
+ * command 只允许 STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL;
+ * commandId 是 L2 幂等键,落 duty_manual_action.uk_manual_gyid_action。
+ *
+ * @author apt-rev01
+ */
+public class AptDutyCommand
+{
+ private String commandId;
+ private String command;
+ private String reason;
+ /** MARK_LEAVE 专用:离寓类型,缺省 MANUAL */
+ private String leaveType;
+
+ public String getCommandId()
+ {
+ return commandId;
+ }
+
+ public void setCommandId(String commandId)
+ {
+ this.commandId = commandId;
+ }
+
+ public String getCommand()
+ {
+ return command;
+ }
+
+ public void setCommand(String command)
+ {
+ this.command = command;
+ }
+
+ public String getReason()
+ {
+ return reason;
+ }
+
+ public void setReason(String reason)
+ {
+ this.reason = reason;
+ }
+
+ public String getLeaveType()
+ {
+ return leaveType;
+ }
+
+ public void setLeaveType(String leaveType)
+ {
+ this.leaveType = leaveType;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java
new file mode 100644
index 0000000..d359b2c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java
@@ -0,0 +1,76 @@
+package com.ruoyi.apt.dispatch.domain;
+
+/**
+ * 状态机命令结果(公寓端/04 表 4-9 第 5 个接口出参)。
+ *
+ * @author apt-rev01
+ */
+public class AptDutyCommandResult
+{
+ private String commandId;
+ private String command;
+ private boolean accepted;
+ private boolean duplicated;
+ private String stateAfter;
+ private String actionId;
+
+ public String getCommandId()
+ {
+ return commandId;
+ }
+
+ public void setCommandId(String commandId)
+ {
+ this.commandId = commandId;
+ }
+
+ public String getCommand()
+ {
+ return command;
+ }
+
+ public void setCommand(String command)
+ {
+ this.command = command;
+ }
+
+ public boolean isAccepted()
+ {
+ return accepted;
+ }
+
+ public void setAccepted(boolean accepted)
+ {
+ this.accepted = accepted;
+ }
+
+ public boolean isDuplicated()
+ {
+ return duplicated;
+ }
+
+ public void setDuplicated(boolean duplicated)
+ {
+ this.duplicated = duplicated;
+ }
+
+ public String getStateAfter()
+ {
+ return stateAfter;
+ }
+
+ public void setStateAfter(String stateAfter)
+ {
+ this.stateAfter = stateAfter;
+ }
+
+ public String getActionId()
+ {
+ return actionId;
+ }
+
+ public void setActionId(String actionId)
+ {
+ this.actionId = actionId;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java
new file mode 100644
index 0000000..2b2e366
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java
@@ -0,0 +1,145 @@
+package com.ruoyi.apt.dispatch.domain;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 叫乘列表查询条件(公寓端/04 表 4-9 第 1 个接口)。
+ *
+ * @author apt-rev01
+ */
+public class AptJcxxQuery
+{
+ private String ryid;
+ private String rymc;
+ private String fjh;
+ private String cc;
+ private String jlid;
+ private List ryzt;
+ private String finishJc;
+ private String finishLy;
+ private String stoped;
+ private Date jcsjFrom;
+ private Date jcsjTo;
+ private String isTempCall;
+
+ public String getRyid()
+ {
+ return ryid;
+ }
+
+ public void setRyid(String ryid)
+ {
+ this.ryid = ryid;
+ }
+
+ public String getRymc()
+ {
+ return rymc;
+ }
+
+ public void setRymc(String rymc)
+ {
+ this.rymc = rymc;
+ }
+
+ public String getFjh()
+ {
+ return fjh;
+ }
+
+ public void setFjh(String fjh)
+ {
+ this.fjh = fjh;
+ }
+
+ public String getCc()
+ {
+ return cc;
+ }
+
+ public void setCc(String cc)
+ {
+ this.cc = cc;
+ }
+
+ public String getJlid()
+ {
+ return jlid;
+ }
+
+ public void setJlid(String jlid)
+ {
+ this.jlid = jlid;
+ }
+
+ public List getRyzt()
+ {
+ return ryzt;
+ }
+
+ public void setRyzt(List ryzt)
+ {
+ this.ryzt = ryzt;
+ }
+
+ public String getFinishJc()
+ {
+ return finishJc;
+ }
+
+ public void setFinishJc(String finishJc)
+ {
+ this.finishJc = finishJc;
+ }
+
+ public String getFinishLy()
+ {
+ return finishLy;
+ }
+
+ public void setFinishLy(String finishLy)
+ {
+ this.finishLy = finishLy;
+ }
+
+ public String getStoped()
+ {
+ return stoped;
+ }
+
+ public void setStoped(String stoped)
+ {
+ this.stoped = stoped;
+ }
+
+ public Date getJcsjFrom()
+ {
+ return jcsjFrom;
+ }
+
+ public void setJcsjFrom(Date jcsjFrom)
+ {
+ this.jcsjFrom = jcsjFrom;
+ }
+
+ public Date getJcsjTo()
+ {
+ return jcsjTo;
+ }
+
+ public void setJcsjTo(Date jcsjTo)
+ {
+ this.jcsjTo = jcsjTo;
+ }
+
+ public String getIsTempCall()
+ {
+ return isTempCall;
+ }
+
+ public void setIsTempCall(String isTempCall)
+ {
+ this.isTempCall = isTempCall;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java
new file mode 100644
index 0000000..1eed01f
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java
@@ -0,0 +1,104 @@
+package com.ruoyi.apt.dispatch.domain;
+
+/**
+ * gy_jcxx 状态快照(只读,用于命令前置校验与结果去重判定)。
+ *
+ * @author apt-rev01
+ */
+public class AptStateSnapshot
+{
+ private Long id;
+ private String jcid;
+ private String ryzt;
+ private String finishJc;
+ private String finishLy;
+ private String stoped;
+ private String callMode;
+ private Long bizVersion;
+
+ /** 终态判定:已完成或已离寓,任何迟到结果都不再回写(表 3-10 保护语义 1/2) */
+ public boolean isTerminal()
+ {
+ return "1".equals(finishJc) || "1".equals(finishLy);
+ }
+
+ public Long getId()
+ {
+ return id;
+ }
+
+ public void setId(Long id)
+ {
+ this.id = id;
+ }
+
+ public String getJcid()
+ {
+ return jcid;
+ }
+
+ public void setJcid(String jcid)
+ {
+ this.jcid = jcid;
+ }
+
+ public String getRyzt()
+ {
+ return ryzt;
+ }
+
+ public void setRyzt(String ryzt)
+ {
+ this.ryzt = ryzt;
+ }
+
+ public String getFinishJc()
+ {
+ return finishJc;
+ }
+
+ public void setFinishJc(String finishJc)
+ {
+ this.finishJc = finishJc;
+ }
+
+ public String getFinishLy()
+ {
+ return finishLy;
+ }
+
+ public void setFinishLy(String finishLy)
+ {
+ this.finishLy = finishLy;
+ }
+
+ public String getStoped()
+ {
+ return stoped;
+ }
+
+ public void setStoped(String stoped)
+ {
+ this.stoped = stoped;
+ }
+
+ public String getCallMode()
+ {
+ return callMode;
+ }
+
+ public void setCallMode(String callMode)
+ {
+ this.callMode = callMode;
+ }
+
+ public Long getBizVersion()
+ {
+ return bizVersion;
+ }
+
+ public void setBizVersion(Long bizVersion)
+ {
+ this.bizVersion = bizVersion;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java
new file mode 100644
index 0000000..76fae0c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java
@@ -0,0 +1,57 @@
+package com.ruoyi.apt.dispatch.mapper;
+
+import java.util.List;
+import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * {@code apt.duty.state} 唯一写入口的数据访问(公寓端/03 表 3-10)。
+ *
+ * 硬约束:全仓 `UPDATE gy_jcxx SET ryzt / finish_jc / finish_ly / stoped`
+ * 只允许出现在本 Mapper 的 XML({@code AptDutyStateMapper.xml}),
+ * 每条 UPDATE 必须同时带 {@code gyid}、{@code jcid} 与期望态条件,
+ * 由 {@code AptDutyStateServiceImpl} 独占调用;管理端只提交命令。
+ *
+ * @author apt-rev01
+ */
+public interface AptDutyStateMapper
+{
+ /** 状态快照(命令前置校验 + 结果去重判定) */
+ AptStateSnapshot selectSnapshot(@Param("gyid") String gyid, @Param("jcid") String jcid);
+
+ /** 0 待叫乘 -> 1 叫乘中(SIP 发起呼叫 / 人工临时叫乘) */
+ int updateCallStart(@Param("gyid") String gyid, @Param("jcid") String jcid,
+ @Param("tempCall") boolean tempCall);
+
+ /** -> 3 已应答待离寓:SIP ConfbridgeJoin 或平板 ANSWERED+VERIFIED */
+ int updateAnswered(@Param("gyid") String gyid, @Param("jcid") String jcid,
+ @Param("fromStates") List fromStates);
+
+ /** 1 叫乘中 -> 2 叫乘报警(30s 无入会 / 平板 NO_ANSWER / FAILED) */
+ int updateCallFailed(@Param("gyid") String gyid, @Param("jcid") String jcid);
+
+ /** 人工「停止叫乘」:ryzt 保持,stoped=1 */
+ int updateStop(@Param("gyid") String gyid, @Param("jcid") String jcid);
+
+ /** 人工「复叫」:清 stoped、ryzt->1、记 fjsj_s */
+ int updateRecall(@Param("gyid") String gyid, @Param("jcid") String jcid);
+
+ /** 人工「强制结束」:finish_jc=1,不动 finish_ly(不置离寓) */
+ int updateForceFinish(@Param("gyid") String gyid, @Param("jcid") String jcid);
+
+ /** 人工「登记离寓」:finish_ly=1 + sjlysj */
+ int updateMarkLeave(@Param("gyid") String gyid, @Param("jcid") String jcid,
+ @Param("leaveType") String leaveType, @Param("eventId") String eventId,
+ @Param("remark") String remark);
+
+ /** 人工「取消未开始的叫乘」:ryzt=0 保持,stoped=1 */
+ int updateCancel(@Param("gyid") String gyid, @Param("jcid") String jcid);
+
+ /** 复叫新增一次会话(表 3-10:RE_CALL 生成新会话) */
+ int insertCallSession(@Param("gyid") String gyid, @Param("sessionId") String sessionId,
+ @Param("jcid") String jcid, @Param("callMode") String callMode);
+
+ /** 登记离寓写 gygl_ly_event(leave_type=MANUAL 等),由 gy_jcxx 行带出人员房间 */
+ int insertLeaveEvent(@Param("gyid") String gyid, @Param("eventId") String eventId,
+ @Param("jcid") String jcid, @Param("leaveType") String leaveType);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java
new file mode 100644
index 0000000..450cf23
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java
@@ -0,0 +1,82 @@
+package com.ruoyi.apt.dispatch.mapper;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.dispatch.domain.AptCorrectCommand;
+import com.ruoyi.apt.dispatch.domain.AptJcxxQuery;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 叫乘信息读侧 + 白名单修正 + 命令流水(IT-5 / M3)。
+ *
+ * 本 Mapper 只写 {@code duty_manual_action}、{@code duty_operation_log}
+ * 关联的非状态字段与 {@code gy_jcxx} 白名单列(fjh/jlid/cc/jch/leave_remark/is_temp_call),
+ * 不写 ryzt / finish_jc / finish_ly / stoped(那些只在 AptDutyStateMapper)。
+ *
+ * @author apt-rev01
+ */
+public interface AptJcxxMapper
+{
+ List
+ *
+ * @author apt-rev01
+ */
+@Component
+public class AptCallTimeCalculator
+{
+ /** 规则缺失时的缺省提前量(分钟) */
+ public static final int DEFAULT_ADVANCE_MINUTES = 30;
+
+ /** 规则缺失时的缺省离寓时长(分钟) */
+ public static final int DEFAULT_RELEASE_MINUTES = 30;
+
+ /** 放叫时刻相对叫乘时刻的固定偏移 */
+ public static final int CALL_START_OFFSET_MINUTES = 10;
+
+ /** 补叫时刻相对叫乘时刻的固定偏移 */
+ public static final int RE_CALL_OFFSET_MINUTES = 15;
+
+ /**
+ * 按规则体复算叫乘四时刻。
+ *
+ * @param kcsj 开车时刻(要素,必填)
+ * @param ruleBody 规则体 JSON 字符串({@code advanceMinutes} / {@code holidayAdvanceMinutes} /
+ * {@code lysjgz}),可为空
+ * @param ruleVersion 规则版本,随结果留痕
+ * @param holiday 开车日是否节假日
+ */
+ public AptCallTimeResult compute(Date kcsj, String ruleBody, long ruleVersion, boolean holiday)
+ {
+ if (kcsj == null)
+ {
+ throw new AptException(AptErrorCode.ROUTE_INVALID, "KCSJ_MISSING", "开车时刻缺失,无法复算叫乘时刻");
+ }
+ JSONObject rule = parseRule(ruleBody);
+ int advance = holiday
+ ? intValue(rule, "holidayAdvanceMinutes", intValue(rule, "advanceMinutes", DEFAULT_ADVANCE_MINUTES))
+ : intValue(rule, "advanceMinutes", DEFAULT_ADVANCE_MINUTES);
+ int release = intValue(rule, "lysjgz", DEFAULT_RELEASE_MINUTES);
+
+ Date jcsj = shift(kcsj, -advance);
+ Date fjsj = shift(jcsj, CALL_START_OFFSET_MINUTES);
+ Date jcbjsj = shift(jcsj, RE_CALL_OFFSET_MINUTES);
+ Date ylysj = shift(jcsj, release);
+ if (jcsj.after(fjsj) || fjsj.after(jcbjsj) || !jcbjsj.before(kcsj))
+ {
+ throw new AptException(AptErrorCode.ROUTE_INVALID, "CALL_TIME_ILLEGAL",
+ "叫乘时刻关系非法(需 jcsj <= fjsj <= jcbjsj < kcsj),提前量=" + advance);
+ }
+
+ AptCallTimeResult result = new AptCallTimeResult();
+ result.setKcsj(kcsj);
+ result.setJcsj(jcsj);
+ result.setFjsj(fjsj);
+ result.setJcbjsj(jcbjsj);
+ result.setYlysj(ylysj);
+ result.setRuleVersion(ruleVersion);
+ result.setHoliday(holiday);
+ result.setAdvanceMinutes(advance);
+ result.setReleaseMinutes(release);
+ return result;
+ }
+
+ private JSONObject parseRule(String ruleBody)
+ {
+ if (ruleBody == null || ruleBody.isBlank())
+ {
+ return new JSONObject();
+ }
+ try
+ {
+ return JSON.parseObject(ruleBody);
+ }
+ catch (RuntimeException ex)
+ {
+ throw new AptException(AptErrorCode.DATA_ANOMALY, "CALL_TIME_RULE_INVALID", "叫乘规则体不是合法 JSON", ex);
+ }
+ }
+
+ private int intValue(JSONObject rule, String key, int defaultValue)
+ {
+ Object value = rule == null ? null : rule.get(key);
+ if (value == null)
+ {
+ return defaultValue;
+ }
+ int parsed = value instanceof Number number ? number.intValue() : Integer.parseInt(String.valueOf(value).trim());
+ if (parsed < 0)
+ {
+ throw new AptException(AptErrorCode.DATA_ANOMALY, "CALL_TIME_RULE_INVALID", "叫乘规则 " + key + " 不能为负数");
+ }
+ return parsed;
+ }
+
+ private Date shift(Date base, int minutes)
+ {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(base);
+ calendar.add(Calendar.MINUTE, minutes);
+ return calendar.getTime();
+ }
+
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java
new file mode 100644
index 0000000..4b1ee86
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java
@@ -0,0 +1,213 @@
+package com.ruoyi.apt.stay.checkin.service;
+
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.stay.checkin.domain.AptCallTimeResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict;
+import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow;
+import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.DigestUtils;
+
+/**
+ * #7 匹配结论落库与 jcsj 复算。
+ *
+ * 落地顺序固定为「结论落库 → 要素补全 → 本地复算 → 写 gy_jcxx → #8 入队」,
+ * 保证 {@code task_ready_at} 与 {@code gy_jcxx.jcsj} 不出现半写。
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptCheckinMatchServiceImpl implements IAptCheckinMatchService
+{
+ private static final String STATE_CHECKED_IN = "CHECKED_IN";
+
+ private static final String VERDICT_MATCHED = "MATCHED";
+
+ private static final String VERDICT_UNMATCHED = "UNMATCHED";
+
+ private static final String VERDICT_LOCKED = "LOCKED";
+
+ private static final String VERDICT_REJECTED = "REJECTED";
+
+ private final AptCheckinMapper checkinMapper;
+
+ private final AptCallTimeCalculator callTimeCalculator;
+
+ public AptCheckinMatchServiceImpl(AptCheckinMapper checkinMapper, AptCallTimeCalculator callTimeCalculator)
+ {
+ this.checkinMapper = checkinMapper;
+ this.callTimeCalculator = callTimeCalculator;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AptCheckinMatchResult applyVerdict(AptCheckinMatchVerdict verdict)
+ {
+ String gyid = AptContext.getGyId();
+ String applyId = verdict.getApplyId();
+ if ((applyId == null || applyId.isBlank()) && (verdict.getJcid() == null || verdict.getJcid().isBlank()))
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "MATCH_TARGET_MISSING", "applyId 与 jcid 至少提供一个");
+ }
+ AptCheckinApplication app = applyId == null || applyId.isBlank()
+ ? checkinMapper.selectApplicationByJcid(gyid, verdict.getJcid())
+ : checkinMapper.selectApplication(gyid, applyId);
+ if (app == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "CHECKIN_NOT_FOUND", "入住申请不存在");
+ }
+ if (!STATE_CHECKED_IN.equals(app.getCheckinState()))
+ {
+ throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "NOT_CHECKED_IN",
+ "#7 匹配结论只作用于已办理入住的申请(当前 " + app.getCheckinState() + ")");
+ }
+
+ String matched = verdict.getVerdict() == null ? "" : verdict.getVerdict().trim().toUpperCase(Locale.ROOT);
+ if (!VERDICT_MATCHED.equals(matched) && !VERDICT_UNMATCHED.equals(matched)
+ && !VERDICT_LOCKED.equals(matched) && !VERDICT_REJECTED.equals(matched))
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "MATCH_VERDICT_INVALID",
+ "verdict 只能是 MATCHED / UNMATCHED / LOCKED / REJECTED");
+ }
+
+ Date now = new Date();
+ AptCheckinMatchResult result = new AptCheckinMatchResult();
+ result.setApplyId(app.getApplyId());
+ result.setJcid(app.getJcid());
+ result.setVerdict(matched);
+
+ boolean alreadyReady = "TASK_READY".equals(app.getSyncState());
+ if (matched.equals(app.getMatchState()) && alreadyReady)
+ {
+ result.setMatchState(app.getMatchState());
+ result.setSyncState(app.getSyncState());
+ result.setJcsj(app.getJcsj());
+ result.setDerivedRuleVersion(app.getDerivedRuleVersion());
+ result.setTaskReady(true);
+ result.setChanged(false);
+ result.getWarnings().add("同一匹配结论已落库,按幂等处理:不重复复算、不重复入队 #8");
+ return result;
+ }
+
+ if (VERDICT_UNMATCHED.equals(matched) || VERDICT_REJECTED.equals(matched))
+ {
+ checkinMapper.updateMatched(gyid, app.getApplyId(), matched, null, null, null, null, null, null, now);
+ checkinMapper.updateSyncState(gyid, app.getApplyId(), "REPORTED", null);
+ result.setMatchState(matched);
+ result.setSyncState("REPORTED");
+ result.setChanged(true);
+ result.setTaskReady(false);
+ result.getWarnings().add("平台未匹配到运安计划:保留本地要素,不生成叫乘任务,也不清空既有任务");
+ if (verdict.getReason() != null && !verdict.getReason().isBlank())
+ {
+ result.getWarnings().add("平台说明:" + verdict.getReason());
+ }
+ return result;
+ }
+
+ Date kcsj = verdict.getKcsj() != null ? verdict.getKcsj() : app.getKcsj();
+ String jcfs = verdict.getJcfs() != null ? verdict.getJcfs() : app.getJcfs();
+ checkinMapper.updateMatched(gyid, app.getApplyId(), matched, verdict.getMatchedPlanKey(), kcsj, jcfs,
+ verdict.getJlid(), verdict.getCc(), verdict.getJch(), now);
+ if (VERDICT_LOCKED.equals(matched))
+ {
+ checkinMapper.updateMatchLock(gyid, app.getApplyId(), "1");
+ result.getWarnings().add("本地锁定任务:平台要素不覆盖,按本地要素复算(jllx=1)");
+ }
+ if (kcsj == null)
+ {
+ checkinMapper.updateSyncState(gyid, app.getApplyId(), "MATCHED", null);
+ result.setMatchState(matched);
+ result.setSyncState("MATCHED");
+ result.setChanged(true);
+ result.setTaskReady(false);
+ result.getWarnings().add("匹配结论缺少开车时刻(kcsj),任务保持 MATCHED 待要素补齐");
+ return result;
+ }
+
+ Map rule = checkinMapper.selectCallTimeRule(gyid);
+ String ruleBody = rule == null ? null : stringValue(rule.get("ruleBody"));
+ long ruleVersion = rule == null ? 0L : longValue(rule.get("ruleVersion"));
+ boolean holiday = checkinMapper.countHoliday(gyid, kcsj) > 0;
+ AptCallTimeResult computed = callTimeCalculator.compute(kcsj, ruleBody, ruleVersion, holiday);
+
+ checkinMapper.updateJcxxCallTime(gyid, app.getJcid(), computed.getJcsj(), jcfs);
+ checkinMapper.updateTaskReady(gyid, app.getApplyId(), computed.getJcsj(), computed.getRuleVersion(), now);
+
+ JSONObject data = new JSONObject();
+ data.put("taskId", app.getJcid());
+ data.put("applyId", app.getApplyId());
+ data.put("matchedPlanKey", verdict.getMatchedPlanKey());
+ data.put("computedCallTime", format(computed.getJcsj()));
+ data.put("fjsj", format(computed.getFjsj()));
+ data.put("jcbjsj", format(computed.getJcbjsj()));
+ data.put("ylysj", format(computed.getYlysj()));
+ data.put("kcsj", format(computed.getKcsj()));
+ data.put("jcfs", jcfs);
+ data.put("matchState", matched);
+ data.put("ruleVersion", computed.getRuleVersion());
+ data.put("accepted", Boolean.TRUE);
+
+ JSONObject payload = new JSONObject();
+ payload.put("contractVersion", "v2-draft");
+ payload.put("gyId", gyid);
+ payload.put("bizType", "TASK_ACK");
+ payload.put("bizId", app.getJcid());
+ payload.put("bizVersion", app.getBizVersion() + 1);
+ payload.put("occurredAt", format(now));
+ payload.put("data", data);
+
+ String bizId = app.getJcid();
+ long bizVersion = app.getBizVersion() + 1;
+ AptOutboxRow outbox = new AptOutboxRow();
+ outbox.setGyid(gyid);
+ outbox.setIdempotencyKey(DigestUtils.md5DigestAsHex(
+ (gyid + "|TASK_ACK|" + bizId + "|" + bizVersion).getBytes(StandardCharsets.UTF_8)));
+ outbox.setBizType("TASK_ACK");
+ outbox.setBizId(bizId);
+ outbox.setBizVersion(bizVersion);
+ outbox.setPayload(payload.toJSONString());
+ outbox.setOccurredAt(now);
+ checkinMapper.insertOutbox(outbox);
+
+ result.setMatchState(matched);
+ result.setSyncState("TASK_READY");
+ result.setJcsj(computed.getJcsj());
+ result.setDerivedRuleVersion(computed.getRuleVersion());
+ result.setTaskReady(true);
+ result.setChanged(true);
+ result.setTaskAckOutboxId(outbox.getId());
+ return result;
+ }
+
+ private String stringValue(Object value)
+ {
+ return value == null ? null : String.valueOf(value);
+ }
+
+ private long longValue(Object value)
+ {
+ if (value == null)
+ {
+ return 0L;
+ }
+ return value instanceof Number number ? number.longValue() : Long.parseLong(String.valueOf(value));
+ }
+
+ private String format(Date value)
+ {
+ return value == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value);
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java
new file mode 100644
index 0000000..6b82b0f
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java
@@ -0,0 +1,795 @@
+package com.ruoyi.apt.stay.checkin.service;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinCallTask;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinOptions;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinTimeline;
+import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow;
+import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.DigestUtils;
+
+/**
+ * 入住作业核心实现。
+ *
+ * 事务边界(表 11-18):办理入住时「流程态 → gy_jcxx → 住房事件 → 房态 → #9 入队」
+ * 在同一事务内完成,任一失败整体回滚;平台回执不参与本地生效判定。
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptCheckinServiceImpl implements IAptCheckinService
+{
+ private static final Logger log = LoggerFactory.getLogger(AptCheckinServiceImpl.class);
+
+ private static final String STATE_SUBMITTED = "SUBMITTED";
+
+ private static final String STATE_REVIEWING = "REVIEWING";
+
+ private static final String STATE_APPROVED = "APPROVED";
+
+ private static final String STATE_ROOM_ADJUSTED = "ROOM_ADJUSTED";
+
+ private static final String STATE_REJECTED = "REJECTED";
+
+ private static final String STATE_CHECKED_IN = "CHECKED_IN";
+
+ private static final String ORIGIN_ROUTE = "CHECKIN_ROUTE";
+
+ private static final String ORIGIN_PENDING = "CHECKIN_PENDING";
+
+ private static final String BIZ_TYPE_CHECKIN = "CHECKIN";
+
+ private static final String BIZ_TYPE_ROOM_SWAP = "ROOM_SWAP";
+
+ private static final String MODULE = "CHECKIN";
+
+ /** N-38 核验会话有效期:5 分钟 */
+ private static final long VERIFY_SESSION_MILLIS = 5 * 60 * 1000L;
+
+ private final AptCheckinMapper checkinMapper;
+
+ public AptCheckinServiceImpl(AptCheckinMapper checkinMapper)
+ {
+ this.checkinMapper = checkinMapper;
+ }
+
+ // ------------------------------------------------------------------ N-39
+
+ @Override
+ public AptCheckinOptions options(String version)
+ {
+ String gyid = gyid();
+ String serverVersion = checkinMapper.selectOptionsVersion(gyid);
+ AptCheckinOptions options = new AptCheckinOptions();
+ options.setVersion(serverVersion);
+ options.setServerTime(now());
+ if (version != null && !version.isBlank() && version.trim().equals(serverVersion))
+ {
+ options.setNotModified(true);
+ return options;
+ }
+ options.setRooms(checkinMapper.selectRoomOptions(gyid));
+ options.setRoutes(checkinMapper.selectRouteOptions(gyid));
+ return options;
+ }
+
+ // ------------------------------------------------------------------ N-40
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AptCheckinApplyResult apply(AptCheckinApplyCommand command, String deviceCode)
+ {
+ String gyid = gyid();
+ String device = deviceCode == null ? null : deviceCode.trim().toUpperCase(Locale.ROOT);
+ if (!notBlank(device))
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "DEVICE_CODE_MISSING", "缺少设备编码");
+ }
+
+ AptCheckinApplication existing = checkinMapper.selectByDeviceEvent(gyid, device, command.getDeviceEventId());
+ if (existing != null)
+ {
+ return duplicatedApply(existing, "同一设备事件已受理,返回原申请单");
+ }
+
+ Map person = checkinMapper.selectPersonForCheckin(gyid, command.getRyid());
+ if (person == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "PERSON_NOT_FOUND", "人员主档不存在:" + command.getRyid());
+ }
+
+ Map reg = checkinMapper.selectConfirmedReg(gyid, command.getRyid(), command.getRegId());
+ if (reg == null)
+ {
+ throw new AptException(AptErrorCode.FACE_REG_PENDING, "FACE_REG_NOT_CONFIRMED",
+ "该人员人脸注册未获值班员确认,禁止办理入住");
+ }
+ long regFeatureVersion = longValue(reg.get("featureVersion"), 0L);
+ if (command.getFeatureVersion() != null && command.getFeatureVersion() < regFeatureVersion)
+ {
+ throw new AptException(AptErrorCode.FACE_CACHE_STALE, "FEATURE_VERSION_STALE",
+ "核验特征版本落后于主档(" + command.getFeatureVersion() + " < " + regFeatureVersion + ")");
+ }
+
+ Date now = new Date();
+ Date verifiedAt = command.getVerifiedAt() == null ? now : command.getVerifiedAt();
+ if (now.getTime() - verifiedAt.getTime() > VERIFY_SESSION_MILLIS)
+ {
+ throw new AptException(AptErrorCode.VERIFY_SESSION_EXPIRED, "VERIFY_SESSION_EXPIRED",
+ "N-38 核验已超过 5 分钟,请重新刷脸");
+ }
+
+ AptCheckinOptions.Room room = requireRoom(gyid, command.getFjh());
+ if (checkinMapper.countActiveStay(gyid, command.getRyid()) > 0)
+ {
+ throw new AptException(AptErrorCode.DUPLICATE_STAY, "DUPLICATE_STAY",
+ "该人员已有在住记录,请先办理退宿");
+ }
+
+ AptCheckinApplication app = new AptCheckinApplication();
+ String applyId = newId("CHK");
+ app.setApplyId(applyId);
+ app.setTaskId(applyId);
+ app.setJcid(newId("JC"));
+ app.setDeviceCode(device);
+ app.setDeviceEventId(command.getDeviceEventId());
+ app.setRyid(command.getRyid());
+ app.setName(stringValue(person.get("name")));
+ app.setWorkNo(stringValue(person.get("workNo")));
+ app.setRegId(stringValue(reg.get("regId")));
+ app.setRegStateAtApply("CONFIRMED");
+ app.setFjh(room.getRoomNo());
+ app.setFjhFinal(room.getRoomNo());
+ app.setRoomId(room.getRoomId());
+ app.setBedCount(room.getBedCount());
+ app.setLiveCount(room.getLiveCount());
+ app.setRoomStatus(room.getRoomStatus());
+ app.setFaceVerifyState("VERIFIED");
+ app.setFaceDeviceCode(device);
+ app.setFaceVerifiedAt(verifiedAt);
+ app.setVerifiedRyid(command.getRyid());
+ app.setVerifiedAt(verifiedAt);
+ app.setFeatureVersion(command.getFeatureVersion() == null ? regFeatureVersion : command.getFeatureVersion());
+ app.setSource(notBlank(command.getSource()) ? command.getSource().toUpperCase(Locale.ROOT) : "DEVICE");
+ app.setJllx("0");
+ app.setSubmittedAt(now);
+ app.setOccurredAt(command.getOccurredAt() == null ? now : command.getOccurredAt());
+ app.setRequestPayload(JSON.toJSONString(command));
+
+ AptCheckinApplyResult result = new AptCheckinApplyResult();
+ if (notBlank(command.getJlid()))
+ {
+ Map route = checkinMapper.selectRouteForCheckin(gyid, command.getJlid());
+ if (route == null)
+ {
+ throw new AptException(AptErrorCode.ROUTE_INVALID, "ROUTE_NOT_FOUND",
+ "交路不存在或已停用:" + command.getJlid());
+ }
+ app.setJlid(stringValue(route.get("jlid")));
+ app.setCc(stringValue(route.get("cc")));
+ app.setJch(notBlank(command.getJch()) ? command.getJch().trim() : app.getCc());
+ app.setKcsj((Date) route.get("kcsj"));
+ app.setKcsjSnapshot(app.getKcsj());
+ app.setOrigin(ORIGIN_ROUTE);
+ }
+ else
+ {
+ app.setJlid(null);
+ app.setCc(null);
+ app.setKcsjSnapshot(null);
+ app.setOrigin(ORIGIN_PENDING);
+ result.getWarnings().add("未选择交路:origin=CHECKIN_PENDING,待 #7 匹配补齐要素后再生成叫乘任务");
+ }
+
+ try
+ {
+ checkinMapper.insertApplication(gyid, app);
+ }
+ catch (DuplicateKeyException ex)
+ {
+ AptCheckinApplication raced = checkinMapper.selectByDeviceEvent(gyid, device, command.getDeviceEventId());
+ if (raced != null)
+ {
+ return duplicatedApply(raced, "并发重复提交,返回原申请单");
+ }
+ throw new AptException(AptErrorCode.DUPLICATE_KEY, "APPLY_DUPLICATED", "申请单重复", ex);
+ }
+
+ recordLog(gyid, null, "APPLY", app.getApplyId(), app.getJcid(), null, STATE_SUBMITTED,
+ app.getSource(), null, "设备申请:" + app.getFjh());
+ result.setApplyId(app.getApplyId());
+ result.setJcid(app.getJcid());
+ result.setCheckinState(STATE_SUBMITTED);
+ result.setSyncState("PENDING");
+ result.setOrigin(app.getOrigin());
+ result.setBizVersion(1L);
+ result.setServerTime(now());
+ return result;
+ }
+
+ private AptCheckinApplyResult duplicatedApply(AptCheckinApplication existing, String warning)
+ {
+ AptCheckinApplyResult result = new AptCheckinApplyResult();
+ result.setApplyId(existing.getApplyId());
+ result.setJcid(existing.getJcid());
+ result.setCheckinState(existing.getCheckinState());
+ result.setSyncState(existing.getSyncState());
+ result.setOrigin(existing.getOrigin());
+ result.setBizVersion(existing.getBizVersion());
+ result.setDuplicated(true);
+ result.setServerTime(now());
+ result.getWarnings().add(warning);
+ return result;
+ }
+
+ // ------------------------------------------------------------------ N-41
+
+ @Override
+ public AptCheckinApplication detail(String applyId)
+ {
+ return mustExist(applyId);
+ }
+
+ // ------------------------------------------------------------------ N-42
+
+ @Override
+ public List list(String state, String workNo, String name)
+ {
+ return checkinMapper.selectApplicationList(gyid(), state, workNo, name);
+ }
+
+ // ------------------------------------------------------------------ N-43
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AptCheckinApplication dutyDetail(String applyId, String operatorRef)
+ {
+ String gyid = gyid();
+ AptCheckinApplication app = mustExist(applyId);
+ if (STATE_SUBMITTED.equals(app.getCheckinState()))
+ {
+ int claimed = checkinMapper.claimReviewing(gyid, applyId, operatorRef, new Date());
+ if (claimed > 0)
+ {
+ app = mustExist(applyId);
+ recordLog(gyid, operatorRef, "CLAIM", applyId, app.getJcid(), STATE_SUBMITTED, STATE_REVIEWING,
+ "DUTY", null, "值班员接单");
+ }
+ }
+ app.setRoomCandidates(checkinMapper.selectRoomOptions(gyid));
+ if (!STATE_SUBMITTED.equals(app.getCheckinState()) && !STATE_REVIEWING.equals(app.getCheckinState()))
+ {
+ app.getWarnings().add("当前流程态为 " + app.getCheckinState() + ",仅可查看不可审核");
+ }
+ return app;
+ }
+
+ // ------------------------------------------------------------------ N-44
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AptCheckinReviewResult review(String applyId, AptCheckinReviewCommand command, String operatorRef)
+ {
+ String gyid = gyid();
+ AptCheckinApplication app = mustExist(applyId);
+ String verdict = command.getVerdict() == null ? "" : command.getVerdict().trim().toUpperCase(Locale.ROOT);
+ Date now = new Date();
+ AptCheckinReviewResult result = new AptCheckinReviewResult();
+ result.setApplyId(applyId);
+ result.setJcid(app.getJcid());
+
+ if ("REJECT".equals(verdict))
+ {
+ if (!notBlank(command.getReviewNote()))
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "REVIEW_NOTE_REQUIRED", "驳回必须填写审核说明");
+ }
+ int rows = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED),
+ STATE_REJECTED, command.getExpectedBizVersion(), operatorRef, now,
+ command.getReviewNote(), command.getReviewNote(), null, null, 0, null, null, null);
+ if (rows == 0)
+ {
+ throw stateConflict(app);
+ }
+ recordLog(gyid, operatorRef, "REJECT", applyId, app.getJcid(), app.getCheckinState(), STATE_REJECTED,
+ "DUTY", command.getCommandId(), command.getReviewNote());
+ result.setCheckinState(STATE_REJECTED);
+ result.setSyncState(app.getSyncState());
+ result.setFjh(app.getFjh());
+ result.setFjhFinal(app.getFjhFinal());
+ result.setReviewedAt(now);
+ result.setRoomAdjustSeq(app.getRoomAdjustSeq());
+ result.setBizVersion(app.getBizVersion() + 1);
+ result.getWarnings().add("驳回为终态:不生成 jcid 之外的任何叫乘事实,也不触发平台 / 设备双路");
+ return result;
+ }
+
+ boolean changeRoom = "APPROVE_WITH_ROOM_CHANGE".equals(verdict);
+ if (!"APPROVE".equals(verdict) && !changeRoom)
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "VERDICT_INVALID", "verdict 只能是 APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT");
+ }
+
+ if (changeRoom)
+ {
+ if (!notBlank(command.getTargetFjh()))
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "TARGET_ROOM_REQUIRED", "换房通过必须指定目标房号");
+ }
+ AptCheckinOptions.Room target = requireRoom(gyid, command.getTargetFjh());
+ if (target.getRoomNo().equals(app.getFjhFinal()))
+ {
+ throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "ROOM_UNCHANGED", "目标房号与当前房号相同");
+ }
+ int rows = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED),
+ STATE_ROOM_ADJUSTED, command.getExpectedBizVersion(), operatorRef, now,
+ command.getReviewNote(), null, null, target.getRoomNo(), 1,
+ operatorRef, now, command.getAdjustReason());
+ if (rows == 0)
+ {
+ throw stateConflict(app);
+ }
+ recordLog(gyid, operatorRef, "ROOM_CHANGE", applyId, app.getJcid(), app.getCheckinState(),
+ STATE_ROOM_ADJUSTED, "DUTY", command.getCommandId(),
+ app.getFjhFinal() + " → " + target.getRoomNo());
+ result.setCheckinState(STATE_ROOM_ADJUSTED);
+ result.setSyncState(app.getSyncState());
+ result.setFjh(app.getFjh());
+ result.setFjhFinal(target.getRoomNo());
+ result.setRoomAdjustSeq(app.getRoomAdjustSeq() + 1);
+ result.setBizVersion(app.getBizVersion() + 1);
+ result.setReviewedAt(now);
+ result.setCheckedIn(false);
+ result.getWarnings().add("换房已登记(ROOM_ADJUSTED):床位占用在办理入住时发生,本次不动 in_person_count");
+ result.getWarnings().add("需再次确认办理入住(N-44 APPROVE)才会生成 gy_jcxx 与 #9 上报");
+ return result;
+ }
+
+ // APPROVE:审核通过并同事务办理入住
+ if (!Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED).contains(app.getCheckinState()))
+ {
+ throw stateConflict(app);
+ }
+ AptCheckinOptions.Room room = requireRoom(gyid, app.getFjhFinal());
+ int approved = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED),
+ STATE_APPROVED, command.getExpectedBizVersion(), operatorRef, now,
+ command.getReviewNote(), null, null, null, 0, null, null, null);
+ if (approved == 0)
+ {
+ throw stateConflict(app);
+ }
+ recordLog(gyid, operatorRef, "APPROVE", applyId, app.getJcid(), app.getCheckinState(), STATE_APPROVED,
+ "DUTY", command.getCommandId(), command.getReviewNote());
+
+ AptCheckinApplication approvedApp = mustExist(applyId);
+ approvedApp.setFjhFinal(room.getRoomNo());
+ checkIn(gyid, approvedApp, room, operatorRef, now, result);
+ return result;
+ }
+
+ /**
+ * 办理入住(N-44 通过后的核心事务):占床 → gy_jcxx → 住房事件 → 流程态 CHECKED_IN → #9 入队。
+ */
+ private void checkIn(String gyid, AptCheckinApplication app, AptCheckinOptions.Room room,
+ String operatorRef, Date now, AptCheckinReviewResult result)
+ {
+ AptCheckinOptions.Room fresh = requireRoom(gyid, room.getRoomNo());
+ int occupied = checkinMapper.occupyBed(gyid, fresh.getRoomId(), fresh.getStateVersion(), fresh.getBedCount());
+ if (occupied == 0)
+ {
+ throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_FULL",
+ "房间 " + fresh.getRoomNo() + " 已满或状态已变更,请重新选择");
+ }
+ checkinMapper.recalcRoomStatus(gyid, fresh.getRoomId(), fresh.getBedCount());
+
+ String callMode = checkinMapper.countSipBinding(gyid, fresh.getRoomId()) > 0 ? "SIP" : "TABLET";
+ checkinMapper.insertJcxx(gyid, app, fresh, callMode, now);
+ checkinMapper.insertLyEvent(gyid, newId("LYE"), "CHECKIN", "MANUAL",
+ app.getJcid(), app.getRyid(), fresh.getRoomNo(), now);
+
+ int checkedIn = transition(gyid, app.getApplyId(), Collections.singletonList(STATE_APPROVED),
+ STATE_CHECKED_IN, null, operatorRef, now, app.getReviewNote(), null, now, null, 0, null, null, null);
+ if (checkedIn == 0)
+ {
+ throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "CHECKIN_TRANSITION_FAILED",
+ "流程态迁移失败,请刷新后重试");
+ }
+
+ AptCheckinApplication done = mustExist(app.getApplyId());
+ JSONObject payload = checkinPayload(gyid, done, fresh.getRoomNo(), callMode, now);
+ long bizVersion = done.getBizVersion();
+ String bizId = done.getJcid() + "-" + (done.getRoomAdjustSeq() + 1);
+ AptOutboxRow outbox = outboxRow(gyid, BIZ_TYPE_CHECKIN, bizId, bizVersion, payload, now);
+ checkinMapper.insertOutbox(outbox);
+ Long outboxId = outbox.getId();
+ checkinMapper.updateReported(gyid, done.getApplyId(), now);
+ recordLog(gyid, operatorRef, "CHECKED_IN", done.getApplyId(), done.getJcid(), STATE_APPROVED, STATE_CHECKED_IN,
+ "DUTY", null, "房号=" + fresh.getRoomNo() + ",叫乘方式=" + callMode);
+
+ result.setCheckinState(STATE_CHECKED_IN);
+ result.setSyncState("REPORTED");
+ result.setFjh(done.getFjh());
+ result.setFjhFinal(fresh.getRoomNo());
+ result.setRoomAdjustSeq(done.getRoomAdjustSeq());
+ result.setBizVersion(done.getBizVersion() + 1);
+ result.setReviewedAt(now);
+ result.setCheckedIn(true);
+ result.setUploadState("PENDING");
+ result.setReportOutboxId(outboxId);
+ result.getWarnings().add("叫乘任务待 #7 匹配结论到达后复算 jcsj 并置 TASK_READY");
+ if ("TABLET".equals(callMode))
+ {
+ result.getWarnings().add("该房间无有效 SIP 绑定,叫乘方式回落为 TABLET");
+ }
+ }
+
+ // ------------------------------------------------------------------ N-45
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AptCheckinRoomAdjustResult roomAdjust(String applyId, AptCheckinRoomAdjustCommand command, String operatorRef)
+ {
+ String gyid = gyid();
+ AptCheckinApplication app = mustExist(applyId);
+ if (STATE_CHECKED_IN.equals(app.getCheckinState()))
+ {
+ throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "ALREADY_CHECKED_IN",
+ "已办理入住,换房须走退宿换房,不能在入住链路上改写叫乘事实");
+ }
+ if (!Arrays.asList(STATE_APPROVED, STATE_ROOM_ADJUSTED, STATE_REVIEWING).contains(app.getCheckinState()))
+ {
+ throw stateConflict(app);
+ }
+ AptCheckinOptions.Room target = requireRoom(gyid, command.getTargetFjh());
+ if (target.getRoomNo().equals(app.getFjhFinal()))
+ {
+ throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "ROOM_UNCHANGED", "目标房号与当前房号相同");
+ }
+ Date now = new Date();
+ int rows = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED),
+ STATE_ROOM_ADJUSTED, command.getExpectedBizVersion(), operatorRef, now,
+ null, null, null, target.getRoomNo(), 1, operatorRef, now, command.getReason());
+ if (rows == 0)
+ {
+ throw stateConflict(app);
+ }
+ checkinMapper.insertLyEvent(gyid, newId("LYE"), "ROOM_SWAP", "MANUAL", app.getJcid(), app.getRyid(),
+ target.getRoomNo(), now);
+ recordLog(gyid, operatorRef, "ROOM_ADJUST", applyId, app.getJcid(), app.getCheckinState(), STATE_ROOM_ADJUSTED,
+ "DUTY", command.getCommandId(), app.getFjhFinal() + " → " + target.getRoomNo());
+
+ AptCheckinRoomAdjustResult result = new AptCheckinRoomAdjustResult();
+ result.setApplyId(applyId);
+ result.setJcid(app.getJcid());
+ result.setCheckinState(STATE_ROOM_ADJUSTED);
+ result.setFjh(app.getFjh());
+ result.setFjhFinal(target.getRoomNo());
+ result.setRoomAdjustSeq(app.getRoomAdjustSeq() + 1);
+ result.setBizVersion(app.getBizVersion() + 1);
+ result.setOriginRoomStatus(app.getRoomStatus());
+ result.setTargetRoomStatus(target.getRoomStatus());
+ result.setSipBound(checkinMapper.countSipBinding(gyid, target.getRoomId()) > 0);
+ result.getWarnings().add("审核期换房只变更意向房号与留痕;床位在办理入住时占用,不改 in_person_count");
+ if (app.getReportedAt() != null)
+ {
+ result.getWarnings().add("该申请已产生 #9 上报,换房差异需在下一次要素上报中体现");
+ }
+ return result;
+ }
+
+ // ------------------------------------------------------------------ N-46
+
+ @Override
+ public AptCheckinTimeline timeline(String applyId)
+ {
+ String gyid = gyid();
+ AptCheckinApplication app = mustExist(applyId);
+ AptCheckinTimeline timeline = new AptCheckinTimeline();
+ timeline.setApplyId(applyId);
+ timeline.setJcid(app.getJcid());
+ timeline.setCheckinState(app.getCheckinState());
+ timeline.setSyncState(app.getSyncState());
+ timeline.setMatchState(app.getMatchState());
+ timeline.setServerTime(now());
+
+ List> rows = checkinMapper.selectTimelineEvents(gyid, applyId);
+ List events = new ArrayList<>();
+ for (Map row : rows)
+ {
+ AptCheckinTimeline.Event event = new AptCheckinTimeline.Event();
+ event.setAt(stringValue(row.get("at")));
+ event.setActor(stringValue(row.get("operatorRef")));
+ event.setNote(noteOf(row.get("detail")));
+ event.setState(stringValue(stateOf(row.get("detail"))));
+ event.setFromState(stringValue(fromStateOf(row.get("detail"))));
+ event.setCommandId(stringValue(commandIdOf(row.get("detail"))));
+ events.add(event);
+ }
+ if (events.isEmpty() && app.getSubmittedAt() != null)
+ {
+ AptCheckinTimeline.Event submitted = new AptCheckinTimeline.Event();
+ submitted.setState(STATE_SUBMITTED);
+ submitted.setAt(format(app.getSubmittedAt()));
+ submitted.setActor(app.getDeviceCode());
+ submitted.setNote("设备提交入住申请(无审核日志)");
+ events.add(submitted);
+ }
+ timeline.setEvents(events);
+ if (STATE_ROOM_ADJUSTED.equals(app.getCheckinState()))
+ {
+ timeline.getEvents().add(0, roomAdjustHint(app));
+ }
+ return timeline;
+ }
+
+ private AptCheckinTimeline.Event roomAdjustHint(AptCheckinApplication app)
+ {
+ AptCheckinTimeline.Event hint = new AptCheckinTimeline.Event();
+ hint.setState("ROOM_ADJUSTED");
+ hint.setAt(format(app.getAdjustedAt() == null ? app.getSubmittedAt() : app.getAdjustedAt()));
+ hint.setActor(app.getAdjustedBy());
+ hint.setNote("房号调整为 " + app.getFjhFinal() + ",待确认办理入住");
+ return hint;
+ }
+
+ // ------------------------------------------------------------------ N-47
+
+ @Override
+ public AptCheckinCallTask callTask(String jcid)
+ {
+ String gyid = gyid();
+ AptCheckinApplication app = checkinMapper.selectApplicationByJcid(gyid, jcid);
+ if (app == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "CHECKIN_NOT_FOUND", "入住申请不存在:" + jcid);
+ }
+ AptCheckinCallTask task = new AptCheckinCallTask();
+ task.setApplyId(app.getApplyId());
+ task.setJcid(app.getJcid());
+ task.setCheckinState(app.getCheckinState());
+ task.setSyncState(app.getSyncState());
+ task.setMatchState(app.getMatchState());
+ task.setRoomAdjustSeq(app.getRoomAdjustSeq());
+ task.setFjh(app.getFjh());
+ task.setFjhFinal(app.getFjhFinal());
+ task.setJcsj(app.getJcsj());
+ task.setJcfs(app.getJcfs());
+ task.setServerTime(now());
+ boolean ready = "TASK_READY".equals(app.getSyncState());
+ task.setTaskReady(ready);
+ if (ready)
+ {
+ AptCheckinCallTask.Task detail = checkinMapper.selectCallTask(gyid, jcid);
+ task.setTask(detail);
+ if (detail != null)
+ {
+ task.setJcsj(detail.getJcsj());
+ task.setJcfs(detail.getJcfs());
+ }
+ }
+ return task;
+ }
+
+ // ------------------------------------------------------------------ 内部
+
+ private String gyid()
+ {
+ return AptContext.getGyId();
+ }
+
+ private AptCheckinApplication mustExist(String applyId)
+ {
+ AptCheckinApplication app = checkinMapper.selectApplication(gyid(), applyId);
+ if (app == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "CHECKIN_NOT_FOUND", "入住申请不存在:" + applyId);
+ }
+ return app;
+ }
+
+ private AptCheckinOptions.Room requireRoom(String gyid, String roomNo)
+ {
+ if (!notBlank(roomNo))
+ {
+ throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_NOT_FOUND", "房号为空");
+ }
+ AptCheckinOptions.Room room = checkinMapper.selectRoomByNo(gyid, roomNo.trim());
+ if (room == null)
+ {
+ throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_NOT_FOUND", "房间不存在:" + roomNo);
+ }
+ if (!room.isAvailable())
+ {
+ throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_UNAVAILABLE",
+ "房间不可住或已满:" + roomNo + "(状态 " + room.getRoomStatus() + ")");
+ }
+ return room;
+ }
+
+ private AptException stateConflict(AptCheckinApplication app)
+ {
+ return new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "STATE_VERSION_STALE",
+ "流程态或版本已变更(当前 " + app.getCheckinState() + " / v" + app.getBizVersion() + "),请刷新后重试");
+ }
+
+ private int transition(String gyid, String applyId, List fromStates, String toState,
+ Long expectedBizVersion, String actor, Date at, String note, String rejectReason,
+ Date checkedInAt, String fjhFinal, int seqDelta,
+ String adjustedBy, Date adjustedAt, String adjustReason)
+ {
+ return checkinMapper.transition(gyid, applyId, fromStates, toState, expectedBizVersion, actor, at,
+ note, rejectReason, checkedInAt, fjhFinal, seqDelta, adjustedBy, adjustedAt, adjustReason);
+ }
+
+ private JSONObject checkinPayload(String gyid, AptCheckinApplication app, String roomNo,
+ String callMode, Date now)
+ {
+ JSONObject data = new JSONObject();
+ data.put("jcid", app.getJcid());
+ data.put("applyId", app.getApplyId());
+ data.put("ryid", app.getRyid());
+ data.put("cardId", app.getWorkNo());
+ data.put("roomId", app.getRoomId());
+ data.put("eventType", "CHECKIN");
+ data.put("eventAt", format(now));
+ data.put("source", "MANUAL");
+ data.put("origin", app.getOrigin());
+ data.put("jlid", app.getJlid());
+ data.put("cc", app.getCc());
+ data.put("jch", app.getJch());
+ data.put("kcsj", format(app.getKcsj()));
+ data.put("callMode", callMode);
+ data.put("faceVerifyState", app.getFaceVerifyState());
+ data.put("bizVersion", app.getBizVersion());
+ data.put("matchedPlanKey", null);
+
+ JSONObject payload = new JSONObject();
+ payload.put("contractVersion", "v2-draft");
+ payload.put("gyId", gyid);
+ payload.put("bizType", BIZ_TYPE_CHECKIN);
+ payload.put("bizId", app.getJcid() + "-" + (app.getRoomAdjustSeq() + 1));
+ payload.put("bizVersion", app.getBizVersion());
+ payload.put("occurredAt", format(now));
+ payload.put("data", data);
+ return payload;
+ }
+
+ private AptOutboxRow outboxRow(String gyid, String bizType, String bizId, long bizVersion,
+ JSONObject payload, Date occurredAt)
+ {
+ AptOutboxRow row = new AptOutboxRow();
+ row.setGyid(gyid);
+ row.setIdempotencyKey(idempotencyKey(gyid, bizType, bizId, bizVersion));
+ row.setBizType(bizType);
+ row.setBizId(bizId);
+ row.setBizVersion(bizVersion);
+ row.setPayload(payload.toJSONString());
+ row.setOccurredAt(occurredAt);
+ return row;
+ }
+
+ private String idempotencyKey(String gyid, String bizType, String bizId, long bizVersion)
+ {
+ String raw = gyid + "|" + bizType + "|" + bizId + "|" + bizVersion;
+ return DigestUtils.md5DigestAsHex(raw.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ }
+
+ private void recordLog(String gyid, String operatorRef, String action, String applyId, String jcid,
+ String fromState, String toState, String source, String commandId, String note)
+ {
+ JSONObject detail = new JSONObject();
+ detail.put("applyId", applyId);
+ detail.put("jcid", jcid);
+ detail.put("fromState", fromState);
+ detail.put("toState", toState);
+ detail.put("source", source);
+ detail.put("commandId", commandId);
+ detail.put("note", note);
+ checkinMapper.insertOperationLog(gyid, newId("OP"), operatorRef, MODULE, action,
+ detail.toJSONString(), AptContext.getTraceId(), new Date());
+ }
+
+ private String stateOf(Object detail)
+ {
+ return jsonField(detail, "toState");
+ }
+
+ private String fromStateOf(Object detail)
+ {
+ return jsonField(detail, "fromState");
+ }
+
+ private String commandIdOf(Object detail)
+ {
+ return jsonField(detail, "commandId");
+ }
+
+ private String noteOf(Object detail)
+ {
+ return jsonField(detail, "note");
+ }
+
+ private String jsonField(Object detail, String field)
+ {
+ if (detail == null)
+ {
+ return null;
+ }
+ try
+ {
+ return JSON.parseObject(String.valueOf(detail)).getString(field);
+ }
+ catch (RuntimeException ex)
+ {
+ log.debug("checkin timeline detail 不是合法 JSON,忽略字段 {}", field);
+ return null;
+ }
+ }
+
+ private String newId(String prefix)
+ {
+ String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+ int random = ThreadLocalRandom.current().nextInt(100000, 1000000);
+ return prefix + "-" + stamp + "-" + random;
+ }
+
+ private String now()
+ {
+ return format(new Date());
+ }
+
+ private String format(Date value)
+ {
+ if (value == null)
+ {
+ return null;
+ }
+ return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value);
+ }
+
+ private boolean notBlank(String value)
+ {
+ return value != null && !value.isBlank();
+ }
+
+ private String stringValue(Object value)
+ {
+ return value == null ? null : String.valueOf(value);
+ }
+
+ private long longValue(Object value, long defaultValue)
+ {
+ if (value == null)
+ {
+ return defaultValue;
+ }
+ return value instanceof Number number ? number.longValue() : Long.parseLong(String.valueOf(value));
+ }
+
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java
new file mode 100644
index 0000000..ea97412
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java
@@ -0,0 +1,18 @@
+package com.ruoyi.apt.stay.checkin.service;
+
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict;
+
+/**
+ * #7 matchVerdicts[] 落库与复算(供 IT-6 拉取器调用)。
+ *
+ * 本接口只消费**匹配结论**,不做 HTTP 拉取:{@code kcsj} / {@code jcfs} 是要素,
+ * 平台匹配后由调用方传入;{@code jcsj} 恒由 {@link AptCallTimeCalculator} 本地计算。
+ * 幂等:同一结论重复提交只更新时间与版本,不重复入队 #8。
+ *
+ * @author apt-rev01
+ */
+public interface IAptCheckinMatchService
+{
+ AptCheckinMatchResult applyVerdict(AptCheckinMatchVerdict verdict);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java
new file mode 100644
index 0000000..4ed9f3e
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java
@@ -0,0 +1,53 @@
+package com.ruoyi.apt.stay.checkin.service;
+
+import java.util.List;
+
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinCallTask;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinOptions;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinTimeline;
+
+/**
+ * 入住作业核心服务(N-39 ~ N-47)。
+ *
+ * 状态机:SUBMITTED →(N-43 接单)REVIEWING →(N-44)APPROVED →(同事务)CHECKED_IN;
+ * 换房路径 REVIEWING →(N-44 换房通过)ROOM_ADJUSTED →(N-44 通过)CHECKED_IN;
+ * 驳回为终态 REJECTED。同步态 {@code sync_state} 独立演进,与注册态解耦。
+ *
+ * @author apt-rev01
+ */
+public interface IAptCheckinService
+{
+ /** N-39 入住选项(版本比较,15 分钟 TTL 由客户端执行) */
+ AptCheckinOptions options(String version);
+
+ /** N-40 入住申请(设备侧,按 deviceEventId 幂等) */
+ AptCheckinApplyResult apply(AptCheckinApplyCommand command, String deviceCode);
+
+ /** N-41 设备侧查询申请单 */
+ AptCheckinApplication detail(String applyId);
+
+ /** N-42 值班台待审列表 */
+ List list(String state, String workNo, String name);
+
+ /** N-43 值班台详情(首次查看条件迁移 SUBMITTED → REVIEWING) */
+ AptCheckinApplication dutyDetail(String applyId, String operatorRef);
+
+ /** N-44 审核:APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT */
+ AptCheckinReviewResult review(String applyId, AptCheckinReviewCommand command, String operatorRef);
+
+ /** N-45 审核期换房(仅 APPROVED / ROOM_ADJUSTED) */
+ AptCheckinRoomAdjustResult roomAdjust(String applyId, AptCheckinRoomAdjustCommand command, String operatorRef);
+
+ /** N-46 审核时间线 */
+ AptCheckinTimeline timeline(String applyId);
+
+ /** N-47 按 jcid 查入住与叫乘任务 */
+ AptCheckinCallTask callTask(String jcid);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java
new file mode 100644
index 0000000..37a1ba7
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java
@@ -0,0 +1,70 @@
+package com.ruoyi.apt.voice.domain;
+
+import java.util.Date;
+
+/**
+ * AMI 事件入参(最小 SIP 适配,公寓端/05 表 5-13)。
+ *
+ * 事件只追加到 {@code apt_call_event},并由适配层提交状态机命令;
+ * AMI 事件既不直接写业务表,也不单独构成对外上报(CR-C21)。
+ *
+ * @author apt-rev01
+ */
+public class AptSipEventCommand
+{
+ private String jcid;
+ private String sessionId;
+ private String eventType;
+ private Date occurredAt;
+ private String payload;
+
+ public String getJcid()
+ {
+ return jcid;
+ }
+
+ public void setJcid(String jcid)
+ {
+ this.jcid = jcid;
+ }
+
+ public String getSessionId()
+ {
+ return sessionId;
+ }
+
+ public void setSessionId(String sessionId)
+ {
+ this.sessionId = sessionId;
+ }
+
+ public String getEventType()
+ {
+ return eventType;
+ }
+
+ public void setEventType(String eventType)
+ {
+ this.eventType = eventType;
+ }
+
+ public Date getOccurredAt()
+ {
+ return occurredAt;
+ }
+
+ public void setOccurredAt(Date occurredAt)
+ {
+ this.occurredAt = occurredAt;
+ }
+
+ public String getPayload()
+ {
+ return payload;
+ }
+
+ public void setPayload(String payload)
+ {
+ this.payload = payload;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java
new file mode 100644
index 0000000..55f36a1
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java
@@ -0,0 +1,155 @@
+package com.ruoyi.apt.voice.service;
+
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
+import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper;
+import com.ruoyi.apt.dispatch.service.IAptDutyStateService;
+import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
+import com.ruoyi.apt.framework.config.AptProperties;
+import com.ruoyi.apt.voice.domain.AptSipEventCommand;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 最小 SIP 适配实现(IT-5 / M3)。
+ *
+ * 事件映射(公寓端/05 表 5-13):
+ *
+ * ORIGINATE_START / NEWSTATE -> CALL_START (ryzt 0 -> 1)
+ * CONFBRIDGEJOIN -> ANSWERED (-> 3, 判据是房间侧入会,不是 SIP Answer)
+ * ORIGINATE_FAILURE / NO_ANSWER-> CALL_FAILED (-> 2)
+ * HANGUP -> 不回写(已结束不回写)
+ * AMI_DISCONNECT -> 不推定终态(5 分钟巡检兜底在 IT-7)
+ *
+ *
+ * 本期不含 Asterisk 连接与录音索引;30s 无入会由
+ * {@code AptDispatchScanService} 调用状态机命令,不在本类做定时逻辑。
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptVoiceEventServiceImpl implements IAptVoiceEventService
+{
+ private final AptJcxxMapper jcxxMapper;
+ private final AptTabletMapper tabletMapper;
+ private final IAptDutyStateService stateService;
+ private final AptProperties properties;
+
+ public AptVoiceEventServiceImpl(AptJcxxMapper jcxxMapper, AptTabletMapper tabletMapper,
+ IAptDutyStateService stateService, AptProperties properties)
+ {
+ this.jcxxMapper = jcxxMapper;
+ this.tabletMapper = tabletMapper;
+ this.stateService = stateService;
+ this.properties = properties;
+ }
+
+ @Override
+ @Transactional
+ public Map handle(AptSipEventCommand command)
+ {
+ if (command == null || command.getJcid() == null || command.getJcid().isBlank())
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_JCID", "jcid 不能为空");
+ }
+ if (command.getEventType() == null || command.getEventType().isBlank())
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_EVENT_TYPE", "eventType 不能为空");
+ }
+ AptStateSnapshot snapshot = stateService.snapshot(command.getJcid());
+ if (snapshot == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在");
+ }
+
+ String eventType = command.getEventType().trim().toUpperCase(Locale.ROOT);
+ Date occurredAt = command.getOccurredAt() == null ? new Date() : command.getOccurredAt();
+ String sessionId = command.getSessionId() == null || command.getSessionId().isBlank()
+ ? "CS-" + UUID.randomUUID().toString().replace("-", "")
+ : command.getSessionId();
+
+ Long nextSeq = jcxxMapper.selectNextEventSeq(gyid(), sessionId);
+ jcxxMapper.insertCallEvent(gyid(), "EV-" + UUID.randomUUID().toString().replace("-", ""),
+ sessionId, eventType, command.getPayload(), occurredAt, nextSeq == null ? 1L : nextSeq);
+
+ Map result = new LinkedHashMap<>();
+ result.put("jcid", command.getJcid());
+ result.put("eventType", eventType);
+ result.put("sessionId", sessionId);
+ result.put("submitted", false);
+ try
+ {
+ switch (eventType)
+ {
+ case "ORIGINATE_START":
+ case "NEWSTATE":
+ stateService.callStart(command.getJcid());
+ result.put("submitted", true);
+ break;
+ case "CONFBRIDGEJOIN":
+ stateService.answered(command.getJcid());
+ result.put("submitted", true);
+ break;
+ case "ORIGINATE_FAILURE":
+ case "NO_ANSWER":
+ case "ORIGINATE_NO_ANSWER":
+ stateService.callFailed(command.getJcid());
+ result.put("submitted", true);
+ break;
+ case "HANGUP":
+ case "AMI_DISCONNECT":
+ // 已结束不回写;AMI 断连不推定终态(表 5-13)
+ break;
+ default:
+ break;
+ }
+ }
+ catch (AptException e)
+ {
+ if (e.getErrorCode() != AptErrorCode.STATE_ILLEGAL)
+ {
+ throw e;
+ }
+ result.put("ignored", true);
+ result.put("reason", e.getReason());
+ }
+
+ AptStateSnapshot after = stateService.snapshot(command.getJcid());
+ result.put("stateAfter", after == null ? null : after.getRyzt());
+ result.put("traceId", AptContext.getTraceId());
+ return result;
+ }
+
+ @Override
+ public String resolveCallMode(String jcid)
+ {
+ if (!properties.getFeatures().isTabletCall())
+ {
+ return "SIP";
+ }
+ AptStateSnapshot snapshot = stateService.snapshot(jcid);
+ if (snapshot == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在");
+ }
+ String mode = snapshot.getCallMode() == null ? "SIP" : snapshot.getCallMode();
+ if ("TABLET".equals(mode) && tabletMapper.countActiveTablet(gyid()) == 0)
+ {
+ // 平板离线 / 未部署:回落 SIP(CR-C23),一条 jcid 只有一种事实来源
+ return "SIP";
+ }
+ return mode;
+ }
+
+ private String gyid()
+ {
+ return AptContext.getGyId();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java
new file mode 100644
index 0000000..e2e105f
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java
@@ -0,0 +1,21 @@
+package com.ruoyi.apt.voice.service;
+
+import java.util.Map;
+import com.ruoyi.apt.voice.domain.AptSipEventCommand;
+
+/**
+ * 最小 SIP 事件适配(IT-5 / M3,公寓端/05 表 5-13、CR-C23 降级回落)。
+ *
+ * 本期不含 Asterisk 接入与录音索引(IT-7):只提供事件 → 状态机命令的映射
+ * 与「平板不可用回落 SIP」的模式判定,供联调与单测直接调用。
+ *
+ * @author apt-rev01
+ */
+public interface IAptVoiceEventService
+{
+ /** 事件落 apt_call_event 后提交状态机命令,返回 submitted / stateAfter */
+ Map handle(AptSipEventCommand command);
+
+ /** 生效叫乘方式:平板能力关闭或无 ACTIVE 平板时回落 SIP(CR-C23) */
+ String resolveCallMode(String jcid);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java
new file mode 100644
index 0000000..3cc78db
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java
@@ -0,0 +1,35 @@
+package com.ruoyi.quartz.task;
+
+import com.ruoyi.apt.dispatch.service.IAptDispatchScanService;
+import org.springframework.stereotype.Component;
+
+/**
+ * 叫乘扫描 Quartz 任务(IT-5 / M3)。
+ *
+ * 为什么放在 {@code com.ruoyi.quartz.task} 包而不是 apt 包:
+ * 若依 {@code ScheduleUtils.whiteList} 只允许 {@code com.ruoyi.quartz.task}
+ * 前缀的 invokeTarget({@code Constants.JOB_WHITELIST_STR}),
+ * 因此 Job bean 必须落在这个包内,由它委托 {@code ruoyi-apt} 的服务。
+ * 这样既不扩白名单,也不把业务逻辑写进 quartz 模块。
+ *
+ * 建议 cron:{@code 0 * * * * ?}(每分钟),由 sys_job 登记、默认停用,
+ * 联调与试点时手工启用(见表 13-15 依赖:Quartz 须启用 ScheduleConfig 或显式登记)。
+ *
+ * @author apt-rev01
+ */
+@Component("aptDispatchTask")
+public class AptDispatchTask
+{
+ private final IAptDispatchScanService scanService;
+
+ public AptDispatchTask(IAptDispatchScanService scanService)
+ {
+ this.scanService = scanService;
+ }
+
+ /** 无参入口:sys_job.invoke_target = aptDispatchTask.scan() */
+ public void scan()
+ {
+ scanService.scan();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml
new file mode 100644
index 0000000..32d9069
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml
@@ -0,0 +1,418 @@
+
+
+
+
+
+
+ t.apply_id AS applyId,
+ t.jcid AS jcid,
+ t.task_id AS taskId,
+ t.device_code AS deviceCode,
+ t.work_no AS workNo,
+ COALESCE(t.name_snapshot, t.xm) AS name,
+ t.ryid AS ryid,
+ t.reg_id AS regId,
+ t.reg_state_at_apply AS regStateAtApply,
+ t.fjh AS fjh,
+ t.fjh_final AS fjhFinal,
+ t.room_adjust_seq AS roomAdjustSeq,
+ mr.room_id AS roomId,
+ mr.room_type AS roomType,
+ COALESCE(mr.bed_count, 0) AS bedCount,
+ COALESCE(gr.in_person_count, 0) AS liveCount,
+ gr.room_status AS roomStatus,
+ t.jlid AS jlid,
+ t.cc AS cc,
+ t.jch AS jch,
+ COALESCE(t.base_kcsj, t.kcsj_snapshot) AS kcsj,
+ t.kcsj_snapshot AS kcsjSnapshot,
+ t.biz_version AS bizVersion,
+ t.checkin_state AS checkinState,
+ t.sync_state AS syncState,
+ t.match_state AS matchState,
+ t.matched_plan_key AS matchedPlanKey,
+ t.face_verify_state AS faceVerifyState,
+ t.face_device_code AS faceDeviceCode,
+ t.face_verified_at AS faceVerifiedAt,
+ t.verified_ryid AS verifiedRyid,
+ t.verified_at AS verifiedAt,
+ t.origin AS origin,
+ t.source AS source,
+ t.submitted_at AS submittedAt,
+ t.occurred_at AS occurredAt,
+ t.audited_by AS reviewedBy,
+ t.audited_at AS reviewedAt,
+ t.review_note AS reviewNote,
+ t.adjusted_by AS adjustedBy,
+ t.adjusted_at AS adjustedAt,
+ t.adjust_reason AS adjustReason,
+ t.reject_reason AS rejectReason,
+ t.checked_in_at AS checkedInAt,
+ t.reported_at AS reportedAt,
+ t.matched_at AS matchedAt,
+ t.task_ready_at AS taskReadyAt,
+ t.jcsj AS jcsj,
+ j.call_mode AS jcfs,
+ t.jllx AS jllx,
+ t.derived_rule_version AS derivedRuleVersion
+
+
+
+ FROM apt_local_task t
+ LEFT JOIN apt_master_room mr
+ ON mr.gyid = t.gyid AND mr.room_no = COALESCE(t.fjh_final, t.fjh) AND mr.deleted = 0
+ LEFT JOIN gy_rooms gr
+ ON gr.gyid = t.gyid AND gr.room_id = mr.room_id AND gr.deleted = 0
+ LEFT JOIN gy_jcxx j
+ ON j.gyid = t.gyid AND j.jcid = t.jcid AND j.deleted = 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO apt_local_task
+ (gyid, task_id, apply_id, jcid, origin, local_version, biz_version,
+ match_state, checkin_state, sync_state,
+ device_code, device_event_id, work_no, name_snapshot, xm, ryid,
+ reg_id, reg_state_at_apply,
+ fjh, fjh_final, room_adjust_seq,
+ jlid, cc, jch, kcsj_snapshot,
+ face_verify_state, face_device_code, face_verified_at,
+ verified_ryid, verified_at, feature_version,
+ source, submitted_at, occurred_at, request_payload, jllx, deleted)
+ VALUES
+ (#{gyid}, #{app.taskId}, #{app.applyId}, #{app.jcid}, #{app.origin}, 1, 1,
+ 'PENDING', 'SUBMITTED', 'PENDING',
+ #{app.deviceCode}, #{app.deviceEventId}, #{app.workNo}, #{app.name}, #{app.name}, #{app.ryid},
+ #{app.regId}, #{app.regStateAtApply},
+ #{app.fjh}, #{app.fjhFinal}, 0,
+ #{app.jlid}, #{app.cc}, #{app.jch}, #{app.kcsjSnapshot},
+ #{app.faceVerifyState}, #{app.faceDeviceCode}, #{app.faceVerifiedAt},
+ #{app.verifiedRyid}, #{app.verifiedAt}, #{app.featureVersion},
+ #{app.source}, #{app.submittedAt}, #{app.occurredAt}, #{app.requestPayload}, #{app.jllx}, 0)
+
+
+
+ UPDATE apt_local_task
+ SET checkin_state = 'REVIEWING',
+ biz_version = biz_version + 1,
+ audited_by = #{actor},
+ audited_at = #{at}
+ WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0
+ AND checkin_state = 'SUBMITTED'
+
+
+
+ UPDATE apt_local_task
+ SET checkin_state = #{toState},
+ biz_version = biz_version + 1,
+ audited_by = COALESCE(#{actor,jdbcType=VARCHAR}, audited_by),
+ audited_at = COALESCE(#{at,jdbcType=TIMESTAMP}, audited_at),
+ review_note = COALESCE(#{note,jdbcType=VARCHAR}, review_note),
+ reject_reason = COALESCE(#{rejectReason,jdbcType=VARCHAR}, reject_reason),
+ checked_in_at = COALESCE(#{checkedInAt,jdbcType=TIMESTAMP}, checked_in_at),
+ fjh_final = COALESCE(#{fjhFinal,jdbcType=VARCHAR}, fjh_final),
+ room_adjust_seq = room_adjust_seq + #{seqDelta},
+ adjusted_by = COALESCE(#{adjustedBy,jdbcType=VARCHAR}, adjusted_by),
+ adjusted_at = COALESCE(#{adjustedAt,jdbcType=TIMESTAMP}, adjusted_at),
+ adjust_reason = COALESCE(#{adjustReason,jdbcType=VARCHAR}, adjust_reason)
+ WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0
+ AND checkin_state IN
+
+ #{from}
+
+
+ AND biz_version = #{expectedBizVersion}
+
+
+
+
+ UPDATE apt_local_task
+ SET sync_state = 'REPORTED', reported_at = #{at}, biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0
+
+
+
+ UPDATE apt_local_task
+ SET sync_state = #{syncState}, biz_version = biz_version + 1
+
+ , matched_at = COALESCE(matched_at, #{at})
+
+ WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0
+
+
+
+ UPDATE apt_local_task
+ SET match_state = #{matchState},
+ matched_plan_key = #{matchedPlanKey,jdbcType=VARCHAR},
+ base_kcsj = COALESCE(#{kcsj,jdbcType=TIMESTAMP}, base_kcsj),
+ base_jcfs = COALESCE(#{jcfs,jdbcType=VARCHAR}, base_jcfs),
+ jlid = COALESCE(#{jlid,jdbcType=VARCHAR}, jlid),
+ cc = COALESCE(#{cc,jdbcType=VARCHAR}, cc),
+ jch = COALESCE(#{jch,jdbcType=VARCHAR}, jch),
+ matched_at = #{at},
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0
+
+
+
+ UPDATE apt_local_task
+ SET sync_state = 'TASK_READY', jcsj = #{jcsj}, derived_rule_version = #{ruleVersion},
+ task_ready_at = #{at}, biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0
+
+
+
+ UPDATE apt_local_task
+ SET jllx = #{jllx}, biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0
+
+
+
+
+
+ UPDATE gy_rooms
+ SET in_person_count = in_person_count + 1,
+ state_version = state_version + 1
+ WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0
+ AND state_version = #{expectedStateVersion}
+ AND in_person_count < #{bedCount}
+ AND room_status NOT IN ('4', '6')
+
+
+
+ UPDATE gy_rooms
+ SET in_person_count = GREATEST(in_person_count - 1, 0),
+ state_version = state_version + 1
+ WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0
+ AND state_version = #{expectedStateVersion}
+ AND in_person_count > 0
+
+
+
+ UPDATE gy_rooms
+ SET room_status = CASE
+ WHEN in_person_count <= 0 THEN '1'
+ WHEN in_person_count >= #{bedCount} THEN '2'
+ ELSE '5' END,
+ state_version = state_version + 1
+ WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0
+ AND room_status NOT IN ('4', '6')
+
+
+
+
+
+
+
+ INSERT INTO gy_jcxx
+ (gyid, jcid, jlid, cc, ryid, xm, fjh, kcsj, jcsj,
+ ryzt, finish_jc, finish_ly, stoped, call_mode, dispatch_seq,
+ biz_version, occurred_at, received_at, deleted)
+ VALUES
+ (#{gyid}, #{app.jcid}, #{app.jlid}, #{app.cc}, #{app.ryid}, #{app.name}, #{app.fjhFinal},
+ #{app.kcsj}, NULL,
+ '0', '0', '0', '0', #{callMode}, NULL,
+ 1, #{now}, #{now}, 0)
+
+
+
+
+
+ UPDATE gy_jcxx
+ SET jcsj = #{jcsj},
+ call_mode = COALESCE(#{jcfs}, call_mode),
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+
+
+
+ INSERT INTO gygl_ly_event
+ (gyid, event_id, event_type, event_source, jcid, ryid, fjh, occurred_at, received_at, report_state, deleted)
+ VALUES
+ (#{gyid}, #{eventId}, #{eventType}, #{eventSource}, #{jcid}, #{ryid}, #{fjh},
+ #{occurredAt}, NOW(3), 'PENDING', 0)
+
+
+
+ INSERT INTO apt_report_outbox
+ (gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at)
+ VALUES
+ (#{gyid}, #{idempotencyKey}, #{bizType}, #{bizId}, #{bizVersion}, #{payload}, 'PENDING', #{occurredAt})
+
+
+
+ INSERT INTO duty_operation_log
+ (gyid, log_id, operator_ref, module, action, detail, trace_id, occurred_at)
+ VALUES
+ (#{gyid}, #{logId}, #{operatorRef}, #{module}, #{action}, #{detail}, #{traceId}, #{occurredAt})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml
new file mode 100644
index 0000000..3239cbd
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+ UPDATE gy_jcxx
+ SET ryzt = '1',
+ jcsj_s = NOW(3),
+ is_temp_call = CASE WHEN #{tempCall} THEN '1' ELSE is_temp_call END,
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND ryzt = '0' AND finish_jc = '0' AND finish_ly = '0' AND stoped = '0'
+
+
+
+ UPDATE gy_jcxx
+ SET ryzt = '3',
+ finish_jc = '1',
+ hjxysj = COALESCE(hjxysj, NOW(3)),
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND finish_jc = '0' AND finish_ly = '0'
+ AND ryzt IN
+
+ #{state}
+
+
+
+
+ UPDATE gy_jcxx
+ SET ryzt = '2',
+ jcbjsj_s = NOW(3),
+ is_jcbj = '1',
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND ryzt = '1' AND finish_jc = '0' AND finish_ly = '0'
+
+
+
+ UPDATE gy_jcxx
+ SET stoped = '1',
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND ryzt IN ('1','2') AND finish_ly = '0'
+
+
+
+ UPDATE gy_jcxx
+ SET ryzt = '1',
+ stoped = '0',
+ fjsj_s = NOW(3),
+ jcsj_s = COALESCE(jcsj_s, NOW(3)),
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND (stoped = '1' OR ryzt = '2')
+ AND finish_jc = '0' AND finish_ly = '0'
+
+
+
+ UPDATE gy_jcxx
+ SET finish_jc = '1',
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND finish_ly = '0' AND finish_jc = '0'
+
+
+
+ UPDATE gy_jcxx
+ SET finish_ly = '1',
+ sjlysj = NOW(3),
+ leave_type = #{leaveType},
+ leave_event_id = #{eventId},
+ leave_remark = #{remark},
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND finish_ly = '0'
+
+
+
+ UPDATE gy_jcxx
+ SET stoped = '1',
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+ AND ryzt = '0' AND finish_ly = '0'
+
+
+
+ INSERT INTO apt_call_session
+ (gyid, session_id, jcid, call_mode, call_state, started_at, biz_version, deleted)
+ VALUES
+ (#{gyid}, #{sessionId}, #{jcid}, #{callMode}, 'INIT', NOW(3), 1, 0)
+
+
+
+ INSERT INTO gygl_ly_event
+ (gyid, event_id, event_type, event_source, jcid, ryid, fjh,
+ occurred_at, received_at, report_state, deleted)
+ SELECT gyid, #{eventId}, 'CHECKOUT', 'DUTY', jcid, ryid, fjh,
+ NOW(3), NOW(3), 'PENDING', 0
+ FROM gy_jcxx
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml
new file mode 100644
index 0000000..6db4411
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml
@@ -0,0 +1,193 @@
+
+
+
+
+
+ r.reg_id AS regId,
+ r.work_no AS workNo,
+ r.name AS name,
+ r.unit_code AS unitCode,
+ r.person_category AS personCategory,
+ r.reg_state AS regState,
+ r.local_only AS localOnly,
+ r.verdict AS verdict,
+ r.verdict_version AS verdictVersion,
+ r.platform_version AS platformVersion,
+ r.upload_state AS uploadState,
+ r.dispatch_state AS dispatchState,
+ r.face_sync_state AS faceSyncState,
+ r.apply_device_code AS applyDeviceCode,
+ r.confirmed_ryid AS confirmedRyid,
+ r.confirmed_by AS confirmedBy,
+ r.confirmed_at AS confirmedAt,
+ r.reject_reason AS rejectReason,
+ r.feature_algo AS featureAlgo,
+ r.feature_version AS featureVersion,
+ r.photo_ref AS photoRef,
+ r.photo_checksum AS photoChecksum,
+ r.face_feature_ref AS featureRef,
+ r.feature_checksum AS featureChecksum,
+ r.occurred_at AS occurredAt
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO apt_person_face_reg
+ (gyid, reg_id, work_no, name, unit_code, face_feature_ref, photo_ref, photo_checksum,
+ feature_checksum, feature_algo, feature_version, person_category, reg_state, local_only,
+ verdict, verdict_version, platform_version, upload_state, dispatch_state, face_sync_state,
+ apply_device_code, occurred_at, report_state)
+ VALUES
+ (#{gyid}, #{reg.regId}, #{reg.workNo}, #{reg.name}, #{reg.unitCode}, #{reg.featureRef},
+ #{reg.photoRef}, #{reg.photoChecksum}, #{reg.featureChecksum}, #{reg.featureAlgo},
+ #{reg.featureVersion}, #{reg.personCategory}, 'APPLIED', 1,
+ 'PENDING', 0, 0, 'PENDING', 'PENDING', #{reg.faceSyncState},
+ #{reg.applyDeviceCode}, #{reg.occurredAt}, 'PENDING')
+
+
+
+
+ UPDATE apt_person_face_reg
+ SET reg_state = 'CONFIRMED',
+ confirmed_ryid = #{confirmedRyid},
+ confirmed_by = #{confirmedBy},
+ confirmed_at = NOW(3),
+ local_only = 0,
+ unit_code = #{unitCode},
+ person_category = #{personCategory},
+ feature_algo = #{featureAlgo},
+ upload_state = 'PENDING',
+ dispatch_state = 'PENDING'
+ WHERE gyid = #{gyid} AND reg_id = #{regId} AND reg_state = 'APPLIED'
+
+
+
+ UPDATE apt_person_face_reg
+ SET reg_state = 'REJECTED',
+ reject_reason = #{rejectReason}
+ WHERE gyid = #{gyid} AND reg_id = #{regId} AND reg_state = 'APPLIED'
+
+
+
+
+ UPDATE apt_person_face_reg
+ SET verdict = #{verdict},
+ platform_version = #{platformVersion},
+ verdict_version = #{platformVersion}
+ WHERE gyid = #{gyid} AND reg_id = #{regId}
+
+
+
+
+
+
+
+ INSERT INTO apt_master_person
+ (gyid, ryid, xm, work_no, dept_code, person_category, photo_ref, photo_checksum,
+ feature_ref, feature_checksum, feature_algo, feature_version, face_sync_state,
+ face_platform_version, source_version, deleted)
+ VALUES
+ (#{gyid}, #{person.ryid}, #{person.xm}, #{person.workNo}, #{person.deptCode},
+ #{person.personCategory}, #{person.photoRef}, #{person.photoChecksum},
+ #{person.featureRef}, #{person.featureChecksum}, #{person.featureAlgo},
+ #{person.featureVersion}, #{person.faceSyncState}, 0, 0, 0)
+
+
+
+
+ INSERT INTO apt_report_outbox
+ (gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at)
+ VALUES
+ (#{gyid}, #{idempotencyKey}, 'PERSON_REG', #{bizId}, 1, #{payload}, 'PENDING', #{occurredAt})
+
+
+
+
+ INSERT INTO apt_device_command
+ (gyid, command_id, device_code, command_type, payload_json, state, protocol_confirmed, face_scope_ref)
+ VALUES
+ (#{gyid}, #{commandId}, #{deviceCode}, 'FACE_ISSUE', #{payload}, 'PENDING', 0, #{faceScopeRef})
+
+
+
+
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml
new file mode 100644
index 0000000..8da3c1f
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+ INSERT INTO duty_operation_log
+ (gyid, log_id, operator_ref, module, action, detail, trace_id, occurred_at)
+ VALUES
+ (#{gyid}, #{logId}, #{operatorRef}, 'FACE', 'VERIFY_SESSION', #{detail}, #{traceId}, #{occurredAt})
+
+
+
+ INSERT INTO duty_operation_log
+ (gyid, log_id, operator_ref, module, action, detail, trace_id, occurred_at)
+ VALUES
+ (#{gyid}, #{logId}, NULL, 'FACE', 'SCOPE_DENIED', #{detail}, #{traceId}, #{occurredAt})
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml
new file mode 100644
index 0000000..0a62c7a
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+
+ j.id, j.jcid, j.jlid, j.cc, j.jch, j.ryid, j.xm AS rymc, j.fjh,
+ j.kcsj, j.jcsj, j.jcsj_s AS jcsjS, j.hjxysj, j.jcbjsj_s AS jcbjsjS,
+ j.fjsj_s AS fjsjS, j.lybjsj, j.sjlysj,
+ j.ryzt,
+ CASE j.ryzt
+ WHEN '0' THEN '待叫乘'
+ WHEN '1' THEN '叫乘中'
+ WHEN '2' THEN '叫乘报警'
+ WHEN '3' THEN '已应答待离寓'
+ WHEN '4' THEN '离寓报警'
+ WHEN '5' THEN '通话中'
+ ELSE '未知' END AS ryztLabel,
+ j.finish_jc AS finishJc, j.finish_ly AS finishLy, j.stoped,
+ j.is_jcbj AS isJcbj, j.is_lybj AS isLybj, j.is_temp_call AS isTempCall,
+ j.leave_type AS leaveType, j.leave_remark AS leaveRemark,
+ j.call_mode AS callMode, j.dispatch_seq AS dispatchSeq,
+ j.biz_version AS bizVersion, j.received_at AS receivedAt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO duty_manual_action
+ (gyid, action_id, jcid, action_type, state, duplicated, reason, result_code,
+ requested_by, occurred_at)
+ VALUES
+ (#{gyid}, #{actionId}, #{jcid}, #{actionType}, #{state}, 0, #{reason}, NULL,
+ #{requestedBy}, #{occurredAt})
+
+
+
+ UPDATE duty_manual_action
+ SET state = #{state}, result_code = #{resultCode}
+ WHERE gyid = #{gyid} AND action_id = #{actionId}
+
+
+
+ UPDATE duty_manual_action
+ SET duplicated = 1, state = 'DUPLICATED'
+ WHERE gyid = #{gyid} AND action_id = #{actionId}
+
+
+
+ UPDATE gy_jcxx
+
+ fjh = #{command.fjh},
+ jlid = #{command.jlid},
+ cc = #{command.cc},
+ jch = #{command.jch},
+ leave_remark = #{command.leaveRemark},
+ is_temp_call = '1',
+ biz_version = biz_version + 1
+
+ WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0
+
+
+
+
+
+
+
+ INSERT INTO apt_call_event
+ (gyid, event_seq, event_id, session_id, event_type, event_payload, occurred_at)
+ VALUES
+ (#{gyid}, #{eventSeq}, #{eventId}, #{sessionId}, #{eventType}, #{payload}, #{occurredAt})
+
+
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml
new file mode 100644
index 0000000..f61422c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+ UPDATE apt_tablet_task
+ SET pull_state = 'PULLED',
+ pulled_at = NOW(3),
+ pulled_by = #{deviceCode},
+ device_code = #{deviceCode},
+ updated_at = NOW(3)
+ WHERE gyid = #{gyid} AND task_id = #{taskId} AND pull_state = 'WAITING'
+
+
+
+ UPDATE apt_tablet_task
+ SET pull_state = 'DONE',
+ device_code = COALESCE(device_code, ''),
+ updated_at = NOW(3)
+ WHERE gyid = #{gyid} AND task_id = #{taskId} AND pull_state IN ('WAITING','PULLED')
+
+
+
+ UPDATE apt_tablet_task
+ SET pull_state = 'EXPIRED',
+ updated_at = NOW(3)
+ WHERE gyid = #{gyid} AND pull_state IN ('WAITING','PULLED')
+ AND expire_at IS NOT NULL AND expire_at < #{now}
+
+
+
+
+
+ INSERT INTO apt_tablet_task
+ (gyid, task_id, jcid, dispatch_seq, call_mode, jcsj, device_code,
+ pull_state, expire_at)
+ VALUES
+ (#{gyid}, #{taskId}, #{jcid}, #{dispatchSeq}, 'TABLET', #{jcsj}, NULL,
+ 'WAITING', #{expireAt})
+
+
+
+ UPDATE gy_jcxx
+ SET dispatch_seq = #{dispatchSeq},
+ biz_version = biz_version + 1
+ WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO apt_tablet_call_result
+ (gyid, result_id, task_id, jcid, face_verify_state, verified_ryid,
+ result_type, person_count, device_code, occurred_at, received_at, applied_state)
+ VALUES
+ (#{gyid}, #{resultId}, #{taskId}, #{jcid}, #{faceVerifyState}, #{verifiedRyid},
+ #{resultType}, #{personCount}, #{deviceCode}, #{occurredAt}, #{receivedAt}, 'PENDING')
+
+
+
+ UPDATE apt_tablet_call_result
+ SET applied_state = #{appliedState}
+ WHERE gyid = #{gyid} AND result_id = #{resultId}
+
+
+
+ INSERT INTO apt_report_outbox
+ (gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at)
+ VALUES
+ (#{gyid}, #{idempotencyKey}, #{bizType}, #{bizId}, #{bizVersion}, #{payload},
+ 'PENDING', #{occurredAt})
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java
new file mode 100644
index 0000000..75d13a1
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java
@@ -0,0 +1,171 @@
+package com.ruoyi.apt.dispatch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.dispatch.domain.AptDutyCommand;
+import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult;
+import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper;
+import com.ruoyi.apt.dispatch.service.AptDutyCommandServiceImpl;
+import com.ruoyi.apt.dispatch.service.IAptDutyStateService;
+import com.ruoyi.apt.framework.audit.AptAuditWriter;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * IT-5 / M3 命令层回归(公寓端/04 表 4-9、07 表 7-3 M3)。
+ */
+class AptDutyCommandServiceTest
+{
+ private AptJcxxMapper mapper;
+ private IAptDutyStateService stateService;
+ private AptAuditWriter auditWriter;
+ private AptDutyCommandServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptJcxxMapper.class);
+ stateService = mock(IAptDutyStateService.class);
+ auditWriter = mock(AptAuditWriter.class);
+ service = new AptDutyCommandServiceImpl(mapper, stateService, auditWriter);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void stopSubmitsStateCommandAndWritesAudit()
+ {
+ when(mapper.selectManualAction("APT-A", "CMD-1")).thenReturn(null);
+ when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "1", "0", "0", "0"),
+ row("JC-1", "1", "0", "0", "1"));
+ when(mapper.insertManualAction(eq("APT-A"), eq("CMD-1"), eq("JC-1"), eq("STOP"),
+ anyString(), anyString(), anyString(), any())).thenReturn(1);
+ when(stateService.stop("JC-1")).thenReturn(1);
+
+ AptDutyCommandResult result = service.execute(1L, command("CMD-1", "STOP"));
+
+ assertTrue(result.isAccepted());
+ assertFalse(result.isDuplicated());
+ assertEquals("CALLING|finishJc=0|finishLy=0|stoped=1", result.getStateAfter());
+ verify(stateService).stop("JC-1");
+ verify(mapper).updateManualActionResult("APT-A", "CMD-1", "DONE", result.getStateAfter());
+ verify(auditWriter).write(any());
+ }
+
+ @Test
+ void sameCommandIdIsIdempotentAndDoesNotTransitionAgain()
+ {
+ Map existing = new LinkedHashMap<>();
+ existing.put("actionId", "CMD-2");
+ existing.put("actionType", "STOP");
+ existing.put("state", "DONE");
+ when(mapper.selectManualAction("APT-A", "CMD-2")).thenReturn(existing);
+ when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "2", "0", "0", "1"));
+
+ AptDutyCommandResult result = service.execute(1L, command("CMD-2", "STOP"));
+
+ assertTrue(result.isDuplicated());
+ verify(mapper).markManualActionDuplicated("APT-A", "CMD-2");
+ verify(stateService, never()).stop(anyString());
+ verify(mapper, never()).insertManualAction(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString(), any());
+ }
+
+ @Test
+ void stopOnLeftRecordReturns40902()
+ {
+ when(mapper.selectManualAction("APT-A", "CMD-3")).thenReturn(null);
+ when(mapper.selectJcxxById("APT-A", 2L)).thenReturn(row("JC-2", "1", "0", "1", "0"));
+
+ AptDutyCommand command = command("CMD-3", "STOP");
+ AptException e = assertThrows(AptException.class, () -> service.execute(2L, command));
+
+ assertEquals(40902, e.getErrorCode().getCode());
+ verify(mapper, never()).insertManualAction(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString(), any());
+ }
+
+ @Test
+ void cancelOnlyAllowedBeforeCalling()
+ {
+ when(mapper.selectManualAction("APT-A", "CMD-4")).thenReturn(null);
+ when(mapper.selectJcxxById("APT-A", 3L)).thenReturn(row("JC-3", "1", "0", "0", "0"));
+
+ AptException e = assertThrows(AptException.class,
+ () -> service.execute(3L, command("CMD-4", "CANCEL")));
+ assertEquals(40902, e.getErrorCode().getCode());
+ }
+
+ @Test
+ void unknownCommandReturns40001()
+ {
+ AptException e = assertThrows(AptException.class,
+ () -> service.execute(1L, command("CMD-5", "DELETE")));
+ assertEquals(40001, e.getErrorCode().getCode());
+ }
+
+ @Test
+ void missingReasonReturns40001()
+ {
+ AptDutyCommand command = command("CMD-6", "STOP");
+ command.setReason(" ");
+
+ AptException e = assertThrows(AptException.class, () -> service.execute(1L, command));
+ assertEquals(40001, e.getErrorCode().getCode());
+ }
+
+ @Test
+ void stateIllegalFromStateMachineMarksActionFailed()
+ {
+ when(mapper.selectManualAction("APT-A", "CMD-7")).thenReturn(null);
+ when(mapper.selectJcxxById("APT-A", 4L)).thenReturn(row("JC-4", "1", "0", "0", "0"));
+ when(mapper.insertManualAction(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString(), any())).thenReturn(1);
+ when(stateService.stop("JC-4")).thenThrow(new AptException(
+ com.ruoyi.apt.common.AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", "并发已终止"));
+
+ assertThrows(AptException.class, () -> service.execute(4L, command("CMD-7", "STOP")));
+ verify(mapper).updateManualActionResult("APT-A", "CMD-7", "FAILED", "STATE_TRANSITION_ILLEGAL");
+ }
+
+ private AptDutyCommand command(String commandId, String command)
+ {
+ AptDutyCommand cmd = new AptDutyCommand();
+ cmd.setCommandId(commandId);
+ cmd.setCommand(command);
+ cmd.setReason("值班员操作");
+ return cmd;
+ }
+
+ private Map row(String jcid, String ryzt, String finishJc, String finishLy, String stoped)
+ {
+ Map row = new LinkedHashMap<>();
+ row.put("id", 1L);
+ row.put("jcid", jcid);
+ row.put("ryzt", ryzt);
+ row.put("finishJc", finishJc);
+ row.put("finishLy", finishLy);
+ row.put("stoped", stoped);
+ return row;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java
new file mode 100644
index 0000000..e1c501c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java
@@ -0,0 +1,105 @@
+package com.ruoyi.apt.dispatch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.List;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
+import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper;
+import com.ruoyi.apt.dispatch.service.AptDutyStateServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * IT-5 / M3 状态机回归:条件更新未命中必须 40902,且只走唯一写入口。
+ */
+class AptDutyStateServiceTest
+{
+ private AptDutyStateMapper mapper;
+ private AptDutyStateServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptDutyStateMapper.class);
+ service = new AptDutyStateServiceImpl(mapper);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void answeredAlwaysCarriesGyidAndExpectedStates()
+ {
+ when(mapper.updateAnswered(eq("APT-A"), eq("JC-1"), anyList())).thenReturn(1);
+
+ assertEquals(1, service.answered("JC-1"));
+ verify(mapper).updateAnswered("APT-A", "JC-1", List.of("0", "1", "2"));
+ }
+
+ @Test
+ void missedConditionalUpdateMapsTo40902()
+ {
+ when(mapper.updateStop("APT-A", "JC-2")).thenReturn(0);
+
+ AptException e = assertThrows(AptException.class, () -> service.stop("JC-2"));
+ assertEquals(40902, e.getErrorCode().getCode());
+ assertEquals("STATE_TRANSITION_ILLEGAL", e.getReason());
+ }
+
+ @Test
+ void recallWritesNewSessionWithCurrentCallMode()
+ {
+ AptStateSnapshot snapshot = new AptStateSnapshot();
+ snapshot.setJcid("JC-3");
+ snapshot.setCallMode("TABLET");
+ when(mapper.updateRecall("APT-A", "JC-3")).thenReturn(1);
+ when(mapper.selectSnapshot("APT-A", "JC-3")).thenReturn(snapshot);
+
+ assertEquals(1, service.recall("JC-3"));
+ verify(mapper).insertCallSession(eq("APT-A"), anyString(), eq("JC-3"), eq("TABLET"));
+ }
+
+ @Test
+ void markLeaveWritesLeaveEventBeforeReturning()
+ {
+ when(mapper.updateMarkLeave(eq("APT-A"), eq("JC-4"), eq("MANUAL"), anyString(), any()))
+ .thenReturn(1);
+
+ service.markLeave("JC-4", "MANUAL", "值班员登记离寓");
+ verify(mapper).insertLeaveEvent(eq("APT-A"), anyString(), eq("JC-4"), eq("MANUAL"));
+ }
+
+ @Test
+ void recallWithoutTransitionDoesNotCreateSession()
+ {
+ when(mapper.updateRecall("APT-A", "JC-5")).thenReturn(0);
+
+ assertThrows(AptException.class, () -> service.recall("JC-5"));
+ verify(mapper, never()).insertCallSession(anyString(), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void callFailedOnlyFromCallingState()
+ {
+ when(mapper.updateCallFailed("APT-A", "JC-6")).thenReturn(0);
+
+ AptException e = assertThrows(AptException.class, () -> service.callFailed("JC-6"));
+ assertEquals(40902, e.getErrorCode().getCode());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java
new file mode 100644
index 0000000..ffa9188
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java
@@ -0,0 +1,206 @@
+package com.ruoyi.apt.dispatch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.dispatch.domain.AptCorrectCommand;
+import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper;
+import com.ruoyi.apt.dispatch.service.AptJcxxServiceImpl;
+import com.ruoyi.apt.framework.audit.AptAuditWriter;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * IT-5 / M3 叫乘台账回归:白名单修正、时间线排序、只读状态字段。
+ */
+class AptJcxxServiceTest
+{
+ private AptJcxxMapper mapper;
+ private AptAuditWriter auditWriter;
+ private AptJcxxServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptJcxxMapper.class);
+ auditWriter = mock(AptAuditWriter.class);
+ service = new AptJcxxServiceImpl(mapper, auditWriter);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void correctRejectsStateFieldsWith40001()
+ {
+ AptCorrectCommand command = new AptCorrectCommand();
+ command.setReason("改状态");
+ command.setRyzt("3");
+
+ AptException e = assertThrows(AptException.class, () -> service.correct(1L, command));
+ assertEquals(40001, e.getErrorCode().getCode());
+ assertEquals("STATE_FIELD_READONLY", e.getReason());
+ verify(mapper, never()).updateJcxxCorrect(anyString(), any(), any());
+ }
+
+ @Test
+ void correctRequiresWhitelistedField()
+ {
+ AptCorrectCommand command = new AptCorrectCommand();
+ command.setReason("空修正");
+
+ AptException e = assertThrows(AptException.class, () -> service.correct(1L, command));
+ assertEquals("NO_FIELD_TO_CORRECT", e.getReason());
+ }
+
+ @Test
+ void correctWritesWhitelistAndAudit()
+ {
+ when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0"));
+ when(mapper.selectManualAction("APT-A", "COR-1")).thenReturn(null);
+ when(mapper.countRoomByNo("APT-A", "302")).thenReturn(1);
+ when(mapper.insertManualAction(eq("APT-A"), eq("COR-1"), eq("JC-1"), eq("CORRECT"),
+ anyString(), anyString(), anyString(), any())).thenReturn(1);
+ when(mapper.updateJcxxCorrect(eq("APT-A"), eq(1L), any())).thenReturn(1);
+
+ AptCorrectCommand command = new AptCorrectCommand();
+ command.setCommandId("COR-1");
+ command.setReason("房间修正");
+ command.setFjh("302");
+
+ Map result = service.correct(1L, command);
+
+ assertEquals(false, result.get("duplicated"));
+ assertEquals(List.of("fjh"), result.get("changedFields"));
+ verify(mapper).updateManualActionResult("APT-A", "COR-1", "DONE", "OK");
+ verify(auditWriter).write(any());
+ }
+
+ @Test
+ void correctUnknownRoomReturns40001()
+ {
+ when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0"));
+ when(mapper.countRoomByNo("APT-A", "999")).thenReturn(0);
+
+ AptCorrectCommand command = new AptCorrectCommand();
+ command.setCommandId("COR-2");
+ command.setReason("房间修正");
+ command.setFjh("999");
+
+ AptException e = assertThrows(AptException.class, () -> service.correct(1L, command));
+ assertEquals("ROOM_NOT_FOUND", e.getReason());
+ }
+
+ @Test
+ void correctOnLeftRecordCannotMoveRoom()
+ {
+ when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "1"));
+
+ AptCorrectCommand command = new AptCorrectCommand();
+ command.setCommandId("COR-3");
+ command.setReason("房间修正");
+ command.setFjh("302");
+
+ AptException e = assertThrows(AptException.class, () -> service.correct(1L, command));
+ assertEquals(40902, e.getErrorCode().getCode());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void timelineMergesFourSourcesInAscendingOrder()
+ {
+ when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0"));
+ when(mapper.selectTimelineStates("APT-A", "JC-1")).thenReturn(List.of(
+ event(3000L, "ANSWERED"), event(1000L, "PLANNED")));
+ when(mapper.selectTimelineEvents("APT-A", "JC-1")).thenReturn(List.of(
+ event(2500L, "CONFBRIDGEJOIN")));
+ when(mapper.selectTimelineActions("APT-A", "JC-1")).thenReturn(List.of(
+ event(4000L, "STOP")));
+ when(mapper.selectTimelineAudits("APT-A", "JC-1")).thenReturn(List.of(
+ event(4500L, "JCXX_STOP")));
+ when(mapper.selectTimelineTabletResults("APT-A", "JC-1")).thenReturn(List.of(
+ event(2000L, "TABLET_ANSWERED")));
+
+ List> timeline = service.timeline(1L);
+
+ assertEquals(6, timeline.size());
+ assertEquals(1000L, timeline.get(0).get("atMillis"));
+ assertEquals(4500L, timeline.get(5).get("atMillis"));
+ }
+
+ @Test
+ void detailIncludesSessionAndManualActions()
+ {
+ when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0"));
+ when(mapper.selectLatestSession("APT-A", "JC-1")).thenReturn(Map.of("sessionId", "CS-1"));
+ when(mapper.selectRecordings("APT-A", "JC-1")).thenReturn(List.of(Map.of("recordingId", "R-1")));
+ when(mapper.selectManualActions("APT-A", "JC-1")).thenReturn(List.of(Map.of("actionId", "CMD-1")));
+
+ Map detail = service.detail(1L);
+
+ assertEquals("JC-1", detail.get("jcid"));
+ assertTrue(detail.containsKey("session"));
+ assertEquals(1, ((List>) detail.get("recordings")).size());
+ assertEquals(1, ((List>) detail.get("manualActions")).size());
+ }
+
+ @Test
+ void detailOfMissingRecordReturns40401()
+ {
+ when(mapper.selectJcxxById("APT-A", 9L)).thenReturn(null);
+
+ AptException e = assertThrows(AptException.class, () -> service.detail(9L));
+ assertEquals(40401, e.getErrorCode().getCode());
+ }
+
+ @Test
+ void statisticsDefaultsToZeroWhenNoRows()
+ {
+ when(mapper.selectStatistics("APT-A", null)).thenReturn(null);
+
+ Map statistics = service.statistics(null);
+
+ assertEquals(0L, statistics.get("waiting"));
+ assertEquals(0L, statistics.get("finished"));
+ }
+
+ private Map event(long atMillis, String type)
+ {
+ Map event = new LinkedHashMap<>();
+ event.put("atMillis", atMillis);
+ event.put("at", String.valueOf(atMillis));
+ event.put("type", type);
+ event.put("source", "STATE");
+ return event;
+ }
+
+ private Map row(String jcid, String finishLy)
+ {
+ Map row = new LinkedHashMap<>();
+ row.put("id", 1L);
+ row.put("jcid", jcid);
+ row.put("ryzt", "0");
+ row.put("finishJc", "0");
+ row.put("finishLy", finishLy);
+ row.put("stoped", "0");
+ return row;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java
new file mode 100644
index 0000000..25e779d
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java
@@ -0,0 +1,238 @@
+package com.ruoyi.apt.dispatch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
+import com.ruoyi.apt.dispatch.service.IAptDutyStateService;
+import com.ruoyi.apt.dispatch.tablet.domain.AptTabletResultCommand;
+import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
+import com.ruoyi.apt.dispatch.tablet.service.AptTabletResultServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * IT-5 / M3 平板结果受理回归(公寓端/05 表 5-20)。
+ */
+class AptTabletResultServiceTest
+{
+ private AptTabletMapper mapper;
+ private IAptDutyStateService stateService;
+ private AptTabletResultServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptTabletMapper.class);
+ stateService = mock(IAptDutyStateService.class);
+ service = new AptTabletResultServiceImpl(mapper, stateService);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void verifiedResultSubmitsAnsweredAndOnlyEnqueuesTabletResult()
+ {
+ stubInsert("RS-1", "TASK-1", "JC-1");
+ when(stateService.snapshot("JC-1")).thenReturn(snapshot("1", "0", "0"), snapshot("3", "1", "0"));
+ when(stateService.answered("JC-1")).thenReturn(1);
+
+ Map result = service.accept("TAB-01",
+ command("RS-1", "ANSWERED", "VERIFIED"));
+
+ assertEquals("APPLIED", result.get("appliedState"));
+ verify(stateService).answered("JC-1");
+ verify(mapper).updateResultApplied("APT-A", "RS-1", "APPLIED");
+ verify(mapper).markTaskDone("APT-A", "TASK-1");
+
+ ArgumentCaptor bizType = ArgumentCaptor.forClass(String.class);
+ verify(mapper, times(1)).insertOutbox(eq("APT-A"), anyString(), bizType.capture(),
+ anyString(), anyLong(), anyString(), any());
+ assertEquals("TABLET_RESULT", bizType.getValue());
+ }
+
+ @Test
+ void unverifiedKeepsStateAndRaisesAlarmWithoutVoiceSummary()
+ {
+ stubInsert("RS-2", "TASK-2", "JC-2");
+ when(stateService.snapshot("JC-2")).thenReturn(snapshot("1", "0", "0"));
+
+ Map result = service.accept("TAB-01",
+ command("RS-2", "ANSWERED", "UNVERIFIED"));
+
+ assertEquals("APPLIED", result.get("appliedState"));
+ assertTrue(((java.util.List>) result.get("warnings")).size() > 0);
+ verify(stateService, never()).answered(anyString());
+ verify(stateService, never()).callFailed(anyString());
+
+ ArgumentCaptor bizType = ArgumentCaptor.forClass(String.class);
+ verify(mapper, times(2)).insertOutbox(eq("APT-A"), anyString(), bizType.capture(),
+ anyString(), anyLong(), anyString(), any());
+ assertTrue(bizType.getAllValues().contains("TABLET_RESULT"));
+ assertTrue(bizType.getAllValues().contains("DEVICE_ALARM"));
+ assertFalse(bizType.getAllValues().contains("VOICE_SUMMARY"),
+ "平板链路绝不上报 #24 语音摘要");
+ }
+
+ @Test
+ void noAnswerSubmitsCallFailed()
+ {
+ stubInsert("RS-3", "TASK-3", "JC-3");
+ when(stateService.snapshot("JC-3")).thenReturn(snapshot("1", "0", "0"), snapshot("2", "0", "0"));
+ when(stateService.callFailed("JC-3")).thenReturn(1);
+
+ Map result = service.accept("TAB-01",
+ command("RS-3", "NO_ANSWER", "UNVERIFIED"));
+
+ assertEquals("APPLIED", result.get("appliedState"));
+ verify(stateService).callFailed("JC-3");
+ }
+
+ @Test
+ void cancelledTaskStopsWhenCalling()
+ {
+ stubInsert("RS-4", "TASK-4", "JC-4");
+ when(stateService.snapshot("JC-4")).thenReturn(snapshot("1", "0", "0"), snapshot("1", "0", "1"));
+ when(stateService.stop("JC-4")).thenReturn(1);
+
+ service.accept("TAB-01", command("RS-4", "CANCELLED", "SKIPPED"));
+
+ verify(stateService).stop("JC-4");
+ verify(stateService, never()).cancel(anyString());
+ }
+
+ @Test
+ void cancelledTaskCancelsWhenWaiting()
+ {
+ stubInsert("RS-5", "TASK-5", "JC-5");
+ when(stateService.snapshot("JC-5")).thenReturn(snapshot("0", "0", "0"), snapshot("0", "0", "1"));
+ when(stateService.cancel("JC-5")).thenReturn(1);
+
+ service.accept("TAB-01", command("RS-5", "CANCELLED", "SKIPPED"));
+
+ verify(stateService).cancel("JC-5");
+ verify(stateService, never()).stop(anyString());
+ }
+
+ @Test
+ void duplicateResultIdReturnsFirstResultWithoutTransition()
+ {
+ Map existing = new LinkedHashMap<>();
+ existing.put("resultId", "RS-6");
+ existing.put("taskId", "TASK-6");
+ existing.put("jcid", "JC-6");
+ existing.put("faceVerifyState", "VERIFIED");
+ existing.put("resultType", "ANSWERED");
+ existing.put("appliedState", "APPLIED");
+ when(mapper.selectResult("APT-A", "RS-6")).thenReturn(existing);
+
+ Map result = service.accept("TAB-01",
+ command("RS-6", "ANSWERED", "VERIFIED"));
+
+ assertTrue((Boolean) result.get("duplicated"));
+ assertEquals("APPLIED", result.get("appliedState"));
+ verify(mapper, never()).insertResult(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString(), anyInt(), anyString(), any(), any());
+ verify(stateService, never()).answered(anyString());
+ }
+
+ @Test
+ void lateResultOnFinishedCallIsIgnored()
+ {
+ stubInsert("RS-7", "TASK-7", "JC-7");
+ when(stateService.snapshot("JC-7")).thenReturn(snapshot("3", "1", "0"));
+
+ Map result = service.accept("TAB-01",
+ command("RS-7", "ANSWERED", "VERIFIED"));
+
+ assertEquals("IGNORED", result.get("appliedState"));
+ verify(stateService, never()).answered(anyString());
+ verify(mapper, never()).insertOutbox(anyString(), anyString(), anyString(), anyString(),
+ anyLong(), anyString(), any());
+ }
+
+ @Test
+ void stateConflictDuringTransitionIsRecordedAsIgnored()
+ {
+ stubInsert("RS-8", "TASK-8", "JC-8");
+ when(stateService.snapshot("JC-8")).thenReturn(snapshot("1", "0", "0"));
+ when(stateService.callFailed("JC-8")).thenThrow(new AptException(
+ com.ruoyi.apt.common.AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", "并发已终止"));
+
+ Map result = service.accept("TAB-01",
+ command("RS-8", "NO_ANSWER", "UNVERIFIED"));
+
+ assertEquals("IGNORED", result.get("appliedState"));
+ verify(mapper).updateResultApplied("APT-A", "RS-8", "IGNORED");
+ }
+
+ @Test
+ void invalidFaceStateReturns40001()
+ {
+ AptTabletResultCommand command = command("RS-9", "ANSWERED", "NOT_FOUND");
+
+ AptException e = assertThrows(AptException.class, () -> service.accept("TAB-01", command));
+ assertEquals(40001, e.getErrorCode().getCode());
+ assertEquals("TABLET_FACE_STATE_INVALID", e.getReason());
+ }
+
+ private void stubInsert(String resultId, String taskId, String jcid)
+ {
+ when(mapper.selectResult("APT-A", resultId)).thenReturn(null);
+ Map task = new LinkedHashMap<>();
+ task.put("taskId", taskId);
+ task.put("jcid", jcid);
+ task.put("deviceCode", "TAB-01");
+ task.put("pullState", "PULLED");
+ when(mapper.selectTask("APT-A", taskId)).thenReturn(task);
+ when(mapper.insertResult(eq("APT-A"), eq(resultId), eq(taskId), eq(jcid),
+ anyString(), any(), anyString(), anyInt(), anyString(), any(), any())).thenReturn(1);
+ }
+
+ private AptTabletResultCommand command(String resultId, String resultType, String faceState)
+ {
+ AptTabletResultCommand command = new AptTabletResultCommand();
+ command.setResultId(resultId);
+ command.setTaskId("TASK-" + resultId.substring(resultId.indexOf('-') + 1));
+ command.setJcid("JC-" + resultId.substring(resultId.indexOf('-') + 1));
+ command.setResultType(resultType);
+ command.setFaceVerifyState(faceState);
+ command.setPersonCount(1);
+ return command;
+ }
+
+ private AptStateSnapshot snapshot(String ryzt, String finishJc, String finishLy)
+ {
+ AptStateSnapshot snapshot = new AptStateSnapshot();
+ snapshot.setJcid("JC");
+ snapshot.setRyzt(ryzt);
+ snapshot.setFinishJc(finishJc);
+ snapshot.setFinishLy(finishLy);
+ snapshot.setStoped("0");
+ snapshot.setBizVersion(2L);
+ return snapshot;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java
new file mode 100644
index 0000000..3aa5242
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java
@@ -0,0 +1,142 @@
+package com.ruoyi.apt.face;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.framework.config.AptProperties;
+import com.ruoyi.apt.framework.device.AptDeviceSigner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+/**
+ * 设备四要素鉴权单测(表 10-24 #1:编码 + HMAC + 时间窗 + nonce)。
+ */
+class AptDeviceSignerTest
+{
+ private static final String SECRET = "unit-test-secret";
+ private static final String URI = "/open/v1/face/reg-requests";
+
+ private AptDeviceSigner signer;
+ private MockHttpServletRequest request;
+
+ @BeforeEach
+ void setUp()
+ {
+ AptProperties properties = new AptProperties();
+ AptProperties.Device device = new AptProperties.Device();
+ device.setHmacEnabled(true);
+ device.setHmacSecret(SECRET);
+ device.setTimeWindowSeconds(300);
+ device.setNonceTtlSeconds(600);
+ properties.setDevice(device);
+ signer = new AptDeviceSigner(properties);
+
+ request = new MockHttpServletRequest("POST", URI);
+ request.addHeader("X-Device-Code", "dev-1");
+ }
+
+ @Test
+ void validSignatureReturnsUpperCasedDeviceCode()
+ {
+ String timestamp = String.valueOf(System.currentTimeMillis());
+ String nonce = "nonce-ok-1";
+ String canonical = signer.canonicalString(request, "DEV-1", timestamp, nonce);
+
+ request.addHeader("X-Device-Timestamp", timestamp);
+ request.addHeader("X-Device-Nonce", nonce);
+ request.addHeader("X-Device-Signature", hmacHex(canonical));
+
+ assertEquals("DEV-1", signer.verify(request));
+ }
+
+ @Test
+ void missingSignatureHeadersReturns40101()
+ {
+ AptException error = assertThrows(AptException.class, () -> signer.verify(request));
+ assertEquals(40101, error.getErrorCode().getCode());
+ assertEquals("DEVICE_SIGNATURE_MISSING", error.getReason());
+ }
+
+ @Test
+ void missingDeviceCodeReturns40101()
+ {
+ request.removeHeader("X-Device-Code");
+
+ AptException error = assertThrows(AptException.class, () -> signer.verify(request));
+ assertEquals(40101, error.getErrorCode().getCode());
+ assertEquals("DEVICE_CREDENTIAL_MISSING", error.getReason());
+ }
+
+ @Test
+ void timestampOutsideWindowReturns40101()
+ {
+ String timestamp = String.valueOf(System.currentTimeMillis() - 3_600_000L);
+ String nonce = "nonce-old-1";
+ String canonical = signer.canonicalString(request, "DEV-1", timestamp, nonce);
+
+ request.addHeader("X-Device-Timestamp", timestamp);
+ request.addHeader("X-Device-Nonce", nonce);
+ request.addHeader("X-Device-Signature", hmacHex(canonical));
+
+ AptException error = assertThrows(AptException.class, () -> signer.verify(request));
+ assertEquals(40101, error.getErrorCode().getCode());
+ assertEquals("DEVICE_TIMESTAMP_OUT_OF_WINDOW", error.getReason());
+ }
+
+ @Test
+ void replayedNonceReturns40901()
+ {
+ String timestamp = String.valueOf(System.currentTimeMillis());
+ String nonce = "nonce-replay-1";
+ String canonical = signer.canonicalString(request, "DEV-1", timestamp, nonce);
+
+ request.addHeader("X-Device-Timestamp", timestamp);
+ request.addHeader("X-Device-Nonce", nonce);
+ request.addHeader("X-Device-Signature", hmacHex(canonical));
+
+ assertEquals("DEV-1", signer.verify(request));
+
+ AptException error = assertThrows(AptException.class, () -> signer.verify(request));
+ assertEquals(40901, error.getErrorCode().getCode());
+ assertEquals("DEVICE_NONCE_REPLAY", error.getReason());
+ }
+
+ @Test
+ void wrongSignatureReturns40101()
+ {
+ request.addHeader("X-Device-Timestamp", String.valueOf(System.currentTimeMillis()));
+ request.addHeader("X-Device-Nonce", "nonce-bad-1");
+ request.addHeader("X-Device-Signature", "deadbeef");
+
+ AptException error = assertThrows(AptException.class, () -> signer.verify(request));
+ assertEquals(40101, error.getErrorCode().getCode());
+ assertEquals("DEVICE_SIGNATURE_INVALID", error.getReason());
+ }
+
+ private static String hmacHex(String payload)
+ {
+ try
+ {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
+ StringBuilder builder = new StringBuilder(digest.length * 2);
+ for (byte b : digest)
+ {
+ builder.append(Character.forDigit((b >> 4) & 0xF, 16));
+ builder.append(Character.forDigit(b & 0xF, 16));
+ }
+ return builder.toString();
+ }
+ catch (Exception e)
+ {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java
new file mode 100644
index 0000000..9b3cda0
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java
@@ -0,0 +1,314 @@
+package com.ruoyi.apt.face;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.face.domain.AptFaceReg;
+import com.ruoyi.apt.face.domain.AptFaceRegAck;
+import com.ruoyi.apt.face.domain.AptFaceRegCommand;
+import com.ruoyi.apt.face.domain.AptFaceRegConfirmCommand;
+import com.ruoyi.apt.face.domain.AptFaceRegRejectCommand;
+import com.ruoyi.apt.face.mapper.AptFaceRegMapper;
+import com.ruoyi.apt.face.service.AptFaceRegServiceImpl;
+import com.ruoyi.apt.face.service.AptFaceSyncDispatcher;
+import com.ruoyi.apt.framework.config.AptProperties;
+import com.ruoyi.apt.framework.device.AptDeviceSigner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 人脸注册链路单测(DV-3 验收①~④)。
+ */
+class AptFaceRegServiceTest
+{
+ private AptFaceRegMapper mapper;
+ private AptFaceSyncDispatcher dispatcher;
+ private AptFaceRegServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptFaceRegMapper.class);
+ dispatcher = mock(AptFaceSyncDispatcher.class);
+ AptProperties properties = new AptProperties();
+ properties.setDevice(new AptProperties.Device());
+ service = new AptFaceRegServiceImpl(mapper, dispatcher, properties);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ private AptFaceRegCommand command()
+ {
+ AptFaceRegCommand command = new AptFaceRegCommand();
+ command.setWorkNo("W001");
+ command.setName("张三");
+ command.setUnitCode("U01");
+ command.setPhoto("cGhvdG8=");
+ command.setPhotoChecksum(AptDeviceSigner.sha256Hex("cGhvdG8=".getBytes(StandardCharsets.UTF_8)));
+ command.setFeatureAlgo("FEAT_V1");
+ command.setFeature("ZmVhdHVyZQ==");
+ command.setFeatureChecksum(AptDeviceSigner.sha256Hex("ZmVhdHVyZQ==".getBytes(StandardCharsets.UTF_8)));
+ return command;
+ }
+
+ private AptFaceReg appliedReg()
+ {
+ AptFaceReg reg = new AptFaceReg();
+ reg.setRegId("FR1");
+ reg.setWorkNo("W001");
+ reg.setName("张三");
+ reg.setUnitCode("U01");
+ reg.setPersonCategory("TRANSIENT");
+ reg.setRegState("APPLIED");
+ reg.setFeatureAlgo("FEAT_V1");
+ reg.setFeatureVersion(1L);
+ reg.setPhotoRef("apt://face-reg/FR1/photo");
+ reg.setFeatureRef("apt://face-reg/FR1/feature");
+ reg.setApplyDeviceCode("DEV-1");
+ return reg;
+ }
+
+ @Test
+ void applyOnlyWritesAppliedStateAndNeverTouchesPersonOrSync()
+ {
+ when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1);
+ when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null);
+ when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(0);
+ when(mapper.insertFaceReg(eq("APT-A"), any(AptFaceReg.class))).thenReturn(1);
+
+ AptFaceRegAck ack = service.apply(command(), "DEV-1");
+
+ assertEquals("APPLIED", ack.getRegState());
+ assertEquals("PENDING_REVIEW", ack.getNextAction());
+ assertEquals(Boolean.TRUE, ack.getAlgoSupported());
+ verify(mapper, never()).insertPerson(anyString(), any());
+ verify(mapper, never()).insertReportOutbox(anyString(), anyString(), anyString(), anyString(), any());
+ verify(mapper, never()).insertDeviceCommand(anyString(), anyString(), anyString(), anyString(), any());
+ verify(dispatcher, never()).enqueuePlatformReport(anyString(), any(), anyString());
+ }
+
+ @Test
+ void applyWithoutAnyMediaReturns40919()
+ {
+ AptFaceRegCommand command = command();
+ command.setPhoto(null);
+ command.setPhotoChecksum(null);
+ command.setFeature(null);
+ command.setFeatureChecksum(null);
+
+ AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1"));
+ assertEquals(40919, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void applyWithChecksumMismatchReturns40920()
+ {
+ AptFaceRegCommand command = command();
+ command.setPhotoChecksum("bad-checksum");
+
+ AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1"));
+ assertEquals(40920, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void applyOversizedPhotoReturns40922()
+ {
+ AptFaceRegCommand command = command();
+ String huge = "A".repeat(800000);
+ command.setPhoto(huge);
+ command.setPhotoChecksum(AptDeviceSigner.sha256Hex(huge.getBytes(StandardCharsets.UTF_8)));
+
+ AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1"));
+ assertEquals(40922, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void applyDuplicatePendingReturnsOriginalRegId()
+ {
+ when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1);
+ when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(appliedReg());
+
+ AptFaceRegAck ack = service.apply(command(), "DEV-1");
+
+ assertEquals("FR1", ack.getRegId());
+ assertEquals(Boolean.TRUE, ack.getDuplicated());
+ verify(mapper, never()).insertFaceReg(anyString(), any());
+ }
+
+ @Test
+ void applyAlreadyConfirmedWorkNoReturns40918()
+ {
+ when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1);
+ when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null);
+ when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(1);
+
+ AptException error = assertThrows(AptException.class, () -> service.apply(command(), "DEV-1"));
+ assertEquals(40918, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void applyUnknownDeviceReturns40303()
+ {
+ when(mapper.countDeviceByCode("APT-A", "DEV-X")).thenReturn(0);
+
+ AptException error = assertThrows(AptException.class, () -> service.apply(command(), "DEV-X"));
+ assertEquals(40303, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void confirmIsIdempotentAndReturnsOriginalRyid()
+ {
+ AptFaceReg reg = appliedReg();
+ reg.setRegState("CONFIRMED");
+ reg.setConfirmedRyid("RY-OLD");
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg);
+
+ AptFaceRegAck ack = service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin");
+
+ assertEquals("RY-OLD", ack.getConfirmedRyid());
+ assertEquals(Boolean.TRUE, ack.getDuplicated());
+ verify(mapper, never()).insertPerson(anyString(), any());
+ verify(mapper, never()).confirmFaceReg(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString());
+ verify(dispatcher, never()).enqueuePlatformReport(anyString(), any(), anyString());
+ }
+
+ @Test
+ void confirmWritesPersonAndTriggersBothPathsIndependently()
+ {
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(appliedReg());
+ when(mapper.selectPersonByWorkNo("APT-A", "W001")).thenReturn(null);
+ when(mapper.insertPerson(eq("APT-A"), any())).thenReturn(1);
+ when(mapper.confirmFaceReg(eq("APT-A"), eq("FR1"), anyString(), anyString(),
+ anyString(), anyString(), anyString())).thenReturn(1);
+ when(dispatcher.enqueuePlatformReport(eq("APT-A"), any(), anyString())).thenReturn("IDEM-1");
+ when(dispatcher.enqueueDeviceIssue(eq("APT-A"), any(), anyString())).thenReturn("CMD-1");
+
+ AptFaceRegAck ack = service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin");
+
+ assertEquals("CONFIRMED", ack.getRegState());
+ assertNotNull(ack.getConfirmedRyid());
+ assertEquals("IDEM-1", ack.getReportOutboxId());
+ assertEquals("CMD-1", ack.getDispatchCommandId());
+ assertEquals("PENDING", ack.getUploadState());
+ assertEquals("PENDING", ack.getDispatchState());
+ verify(mapper).insertPerson(eq("APT-A"), any());
+ verify(dispatcher).enqueuePlatformReport(eq("APT-A"), any(), anyString());
+ verify(dispatcher).enqueueDeviceIssue(eq("APT-A"), any(), anyString());
+ }
+
+ @Test
+ void confirmKeepsUploadWhenDevicePathFails()
+ {
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(appliedReg());
+ when(mapper.selectPersonByWorkNo("APT-A", "W001")).thenReturn(null);
+ when(mapper.insertPerson(eq("APT-A"), any())).thenReturn(1);
+ when(mapper.confirmFaceReg(eq("APT-A"), eq("FR1"), anyString(), anyString(),
+ anyString(), anyString(), anyString())).thenReturn(1);
+ when(dispatcher.enqueuePlatformReport(eq("APT-A"), any(), anyString())).thenReturn("IDEM-1");
+ when(dispatcher.enqueueDeviceIssue(eq("APT-A"), any(), anyString()))
+ .thenThrow(new RuntimeException("device queue down"));
+
+ AptFaceRegAck ack = service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin");
+
+ assertEquals("PENDING", ack.getUploadState());
+ assertEquals("FAILED", ack.getDispatchState());
+ assertTrue(ack.getWarnings().stream().anyMatch(w -> w.contains("不影响平台上报")));
+ }
+
+ @Test
+ void confirmRejectedRegReturns40924()
+ {
+ AptFaceReg reg = appliedReg();
+ reg.setRegState("REJECTED");
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg);
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin"));
+ assertEquals(40924, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void rejectConfirmedRegReturns40924()
+ {
+ AptFaceReg reg = appliedReg();
+ reg.setRegState("CONFIRMED");
+ reg.setConfirmedRyid("RY-1");
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg);
+
+ AptFaceRegRejectCommand command = new AptFaceRegRejectCommand();
+ command.setRejectReason("重复注册");
+
+ AptException error = assertThrows(AptException.class, () -> service.reject("FR1", command, "admin"));
+ assertEquals(40924, error.getErrorCode().getCode());
+ verify(mapper, never()).rejectFaceReg(anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void rejectAppliedRegNeverCreatesPersonNorSync()
+ {
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(appliedReg());
+ when(mapper.rejectFaceReg("APT-A", "FR1", "重复注册")).thenReturn(1);
+
+ AptFaceRegRejectCommand command = new AptFaceRegRejectCommand();
+ command.setRejectReason("重复注册");
+
+ AptFaceRegAck ack = service.reject("FR1", command, "admin");
+
+ assertEquals("REJECTED", ack.getRegState());
+ verify(mapper, never()).insertPerson(anyString(), any());
+ verify(dispatcher, never()).enqueuePlatformReport(anyString(), any(), anyString());
+ verify(dispatcher, never()).enqueueDeviceIssue(anyString(), any(), anyString());
+ }
+
+ @Test
+ void unsupportedAlgoIsStoredButNotDispatched()
+ {
+ when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1);
+ when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null);
+ when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(0);
+ when(mapper.insertFaceReg(eq("APT-A"), any(AptFaceReg.class))).thenReturn(1);
+
+ AptFaceRegCommand command = command();
+ command.setFeatureAlgo("FEAT_X");
+
+ AptFaceRegAck ack = service.apply(command, "DEV-1");
+
+ assertEquals(Boolean.FALSE, ack.getAlgoSupported());
+ assertTrue(ack.getWarnings().stream().anyMatch(w -> w.contains("40921")));
+ }
+
+ @Test
+ void invalidPersonCategoryReturns40001()
+ {
+ when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1);
+ when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null);
+ when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(0);
+
+ AptFaceRegCommand command = command();
+ command.setPersonCategory("UNKNOWN");
+
+ AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1"));
+ assertEquals(40001, error.getErrorCode().getCode());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java
new file mode 100644
index 0000000..27996cc
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java
@@ -0,0 +1,99 @@
+package com.ruoyi.apt.face;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.face.domain.AptFaceReg;
+import com.ruoyi.apt.face.mapper.AptFaceRegMapper;
+import com.ruoyi.apt.face.service.AptFaceVerdictService;
+import com.ruoyi.apt.framework.audit.AptAuditWriter;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 平台回执单测:REJECTED 不回滚本地注册(Q-35 / 验收④)。
+ */
+class AptFaceVerdictServiceTest
+{
+ private AptFaceRegMapper mapper;
+ private AptAuditWriter auditWriter;
+ private AptFaceVerdictService service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptFaceRegMapper.class);
+ auditWriter = mock(AptAuditWriter.class);
+ service = new AptFaceVerdictService(mapper, auditWriter);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ private AptFaceReg confirmedReg()
+ {
+ AptFaceReg reg = new AptFaceReg();
+ reg.setRegId("FR1");
+ reg.setRegState("CONFIRMED");
+ reg.setConfirmedRyid("RY1");
+ return reg;
+ }
+
+ @Test
+ void rejectedVerdictKeepsLocalRegStateAndNeverRollsBack()
+ {
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(confirmedReg());
+
+ service.applyVerdict("FR1", "REJECTED", 7L, "DUPLICATE_WORK_NO");
+
+ verify(mapper).updateVerdict("APT-A", "FR1", "REJECTED", 7L);
+ verify(mapper, never()).rejectFaceReg(anyString(), anyString(), anyString());
+ verify(mapper, never()).confirmFaceReg(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString());
+ verify(mapper, never()).insertPerson(anyString(), any());
+ verify(auditWriter).write(any());
+ }
+
+ @Test
+ void acceptedVerdictDoesNotWriteAlertAudit()
+ {
+ when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(confirmedReg());
+
+ service.applyVerdict("FR1", "ACCEPTED", 8L, null);
+
+ verify(mapper).updateVerdict("APT-A", "FR1", "ACCEPTED", 8L);
+ verify(auditWriter, never()).write(any());
+ }
+
+ @Test
+ void invalidVerdictReturns40001()
+ {
+ AptException error = assertThrows(AptException.class,
+ () -> service.applyVerdict("FR1", "PENDING", 1L, null));
+ assertEquals(40001, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void unknownRegReturns40401()
+ {
+ when(mapper.selectFaceRegByRegId("APT-A", "FR-NONE")).thenReturn(null);
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.applyVerdict("FR-NONE", "REJECTED", 1L, null));
+ assertEquals(40401, error.getErrorCode().getCode());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java
new file mode 100644
index 0000000..5c25538
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java
@@ -0,0 +1,164 @@
+package com.ruoyi.apt.face;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.face.domain.AptFacePerson;
+import com.ruoyi.apt.face.domain.AptFaceReg;
+import com.ruoyi.apt.face.domain.AptFaceVerifyCommand;
+import com.ruoyi.apt.face.domain.AptFaceVerifyResult;
+import com.ruoyi.apt.face.mapper.AptFaceRegMapper;
+import com.ruoyi.apt.face.mapper.AptFaceVerifyMapper;
+import com.ruoyi.apt.face.service.AptFaceVerifyServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 核验会话单测(验收⑤ faceVerifyState 三值与 regState 判定)。
+ */
+class AptFaceVerifyServiceTest
+{
+ private AptFaceVerifyMapper verifyMapper;
+ private AptFaceRegMapper regMapper;
+ private AptFaceVerifyServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ verifyMapper = mock(AptFaceVerifyMapper.class);
+ regMapper = mock(AptFaceRegMapper.class);
+ service = new AptFaceVerifyServiceImpl(verifyMapper, regMapper);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ private AptFaceVerifyCommand command(String state, String ryid, String regId)
+ {
+ AptFaceVerifyCommand command = new AptFaceVerifyCommand();
+ command.setDeviceEventId("E1");
+ command.setFaceVerifyState(state);
+ command.setVerifiedRyid(ryid);
+ command.setRegId(regId);
+ return command;
+ }
+
+ @Test
+ void confirmedPersonReturnsProceed()
+ {
+ when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null);
+ AptFacePerson person = new AptFacePerson();
+ person.setRyid("RY1");
+ person.setXm("张三");
+ person.setWorkNo("W001");
+ person.setDeptCode("U01");
+ person.setPersonCategory("TRANSIENT");
+ when(regMapper.selectPersonByRyid("APT-A", "RY1")).thenReturn(person);
+
+ AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY1", null), "DEV-1");
+
+ assertTrue(result.isPersonFound());
+ assertEquals("PROCEED", result.getNextAction());
+ assertEquals("CONFIRMED", result.getRegState());
+ assertEquals("张三", result.getName());
+ verify(verifyMapper).insertVerifySession(eq("APT-A"), eq("VS-DEV-1-E1"), eq("DEV-1"),
+ anyString(), any(), any());
+ }
+
+ @Test
+ void appliedRegReturnsRegPending()
+ {
+ when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null);
+ when(regMapper.selectPersonByRyid("APT-A", "RY-UNKNOWN")).thenReturn(null);
+ AptFaceReg reg = new AptFaceReg();
+ reg.setRegId("FR1");
+ reg.setWorkNo("W001");
+ reg.setName("张三");
+ reg.setRegState("APPLIED");
+ when(regMapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg);
+
+ AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY-UNKNOWN", "FR1"), "DEV-1");
+
+ assertFalse(result.isPersonFound());
+ assertEquals("REG_PENDING", result.getNextAction());
+ assertEquals("APPLIED", result.getRegState());
+ assertEquals("FR1", result.getRegId());
+ assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("40914")));
+ }
+
+ @Test
+ void unknownPersonReturnsRegisterRequired()
+ {
+ when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null);
+ when(regMapper.selectPersonByRyid("APT-A", "RY-UNKNOWN")).thenReturn(null);
+
+ AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY-UNKNOWN", null), "DEV-1");
+
+ assertEquals("REGISTER_REQUIRED", result.getNextAction());
+ assertEquals("NOT_FOUND", result.getRegState());
+ }
+
+ @Test
+ void duplicateDeviceEventReturnsDuplicatedWithoutSecondInsert()
+ {
+ AptFaceVerifyResult prior = new AptFaceVerifyResult();
+ prior.setFaceVerifyState("VERIFIED");
+ prior.setNextAction("PROCEED");
+ prior.setPersonFound(true);
+ when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1"))
+ .thenReturn(JSON.toJSONString(prior));
+
+ AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY1", null), "DEV-1");
+
+ assertEquals(Boolean.TRUE, result.getDuplicated());
+ assertEquals("PROCEED", result.getNextAction());
+ verify(verifyMapper, never()).insertVerifySession(anyString(), anyString(), anyString(),
+ anyString(), any(), any());
+ }
+
+ @Test
+ void skippedStateReturnsManualConfirm()
+ {
+ when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null);
+
+ AptFaceVerifyResult result = service.verify(command("SKIPPED", null, null), "DEV-1");
+
+ assertEquals("MANUAL_CONFIRM", result.getNextAction());
+ }
+
+ @Test
+ void invalidStateReturns40001()
+ {
+ AptException error = assertThrows(AptException.class,
+ () -> service.verify(command("NOT_FOUND", null, null), "DEV-1"));
+ assertEquals(40001, error.getErrorCode().getCode());
+ }
+
+ @Test
+ void verifiedWithoutRyidReturns40001()
+ {
+ when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null);
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.verify(command("VERIFIED", null, null), "DEV-1"));
+ assertEquals(40001, error.getErrorCode().getCode());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java
new file mode 100644
index 0000000..69f76c8
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java
@@ -0,0 +1,133 @@
+package com.ruoyi.apt.framework;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 架构测试(IT-5 / DV-5):gy_jcxx 的 ryzt / finish_jc / finish_ly / stoped
+ * 只允许在 {@code AptDutyStateMapper.xml} 里被 UPDATE。
+ *
+ * 与 {@link AptMapperGyidArchTest} 互补:那个断言每条 SQL 带 gyid,
+ * 这个断言状态字段只有一个写文件(表 7-3 M3 / 表 7-5 第 7 项)。
+ *
+ * @author apt-rev01
+ */
+class AptDutyStateSingleWriterArchTest
+{
+ /** 只截取 UPDATE gy_jcxx 的 SET 子句(到 WHERE 为止),避免误判 WHERE 里的期望态条件 */
+ private static final Pattern UPDATE_JCXX = Pattern.compile(
+ "update\\s+gy_jcxx\\s+set\\s+([\\s\\S]{0,900}?)\\bwhere\\b",
+ Pattern.CASE_INSENSITIVE);
+
+ private static final Pattern STATE_COLUMN = Pattern.compile(
+ "\\b(ryzt|finish_jc|finish_ly|stoped)\\s*=", Pattern.CASE_INSENSITIVE);
+
+ private static final String STATE_MAPPER = "AptDutyStateMapper.xml";
+
+ @Test
+ void onlyDutyStateMapperUpdatesStateColumns() throws IOException
+ {
+ Path root = locateMainDir();
+ assertNotNull(root, "未找到 ruoyi-apt/src/main");
+ List offenders = new ArrayList<>();
+ int stateWrites = 0;
+ for (Path file : files(root))
+ {
+ String content = Files.readString(file, StandardCharsets.UTF_8);
+ Matcher matcher = UPDATE_JCXX.matcher(content);
+ while (matcher.find())
+ {
+ Matcher column = STATE_COLUMN.matcher(matcher.group(1));
+ if (!column.find())
+ {
+ continue;
+ }
+ if (STATE_MAPPER.equals(file.getFileName().toString()))
+ {
+ stateWrites++;
+ }
+ else
+ {
+ offenders.add(file.getFileName() + " -> " + column.group(1));
+ }
+ }
+ }
+ assertEquals(List.of(), offenders, "状态字段只允许 apt.duty.state 写:" + offenders);
+ assertTrue(stateWrites >= 6,
+ "AptDutyStateMapper.xml 应覆盖 CALL_START/ANSWERED/CALL_FAILED/STOP/RE_CALL/"
+ + "FORCE_FINISH/MARK_LEAVE/CANCEL,实际 " + stateWrites);
+ }
+
+ @Test
+ void stateMapperKeepsExpectedStateConditions() throws IOException
+ {
+ String xml = Files.readString(locateMainDir()
+ .resolve("resources/mapper/apt/" + STATE_MAPPER), StandardCharsets.UTF_8);
+ assertTrue(xml.contains("ryzt = '0'"), "CALL_START 必须带 ryzt='0' 期望态");
+ assertTrue(xml.contains("ryzt IN"), "ANSWERED 必须带来源态集合");
+ assertTrue(xml.contains("stoped = '1'"), "STOP 语义缺失");
+ assertTrue(xml.contains("finish_ly = '0'"), "MARK_LEAVE / FORCE_FINISH 必须带在寓条件");
+ assertTrue(xml.contains("biz_version = biz_version + 1"), "状态迁移必须递增 biz_version");
+ }
+
+ @Test
+ void tabletMapperDoesNotTouchStateColumns() throws IOException
+ {
+ String xml = Files.readString(locateMainDir()
+ .resolve("resources/mapper/apt/AptTabletMapper.xml"), StandardCharsets.UTF_8);
+ assertTrue(xml.contains("dispatch_seq = #{dispatchSeq}"), "平板只允许写派发序号");
+ Matcher matcher = UPDATE_JCXX.matcher(xml);
+ while (matcher.find())
+ {
+ assertTrue(!STATE_COLUMN.matcher(matcher.group(1)).find(), "平板 Mapper 不得写状态字段");
+ }
+ }
+
+ private List files(Path root) throws IOException
+ {
+ try (Stream stream = Files.walk(root))
+ {
+ return stream.filter(Files::isRegularFile)
+ .filter(path -> {
+ String name = path.getFileName().toString();
+ return name.endsWith(".java") || name.endsWith("Mapper.xml");
+ })
+ .collect(Collectors.toList());
+ }
+ }
+
+ private Path locateMainDir()
+ {
+ Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ int depth = 0;
+ while (current != null && depth < 6)
+ {
+ Path inModule = current.resolve("src/main");
+ if (Files.isDirectory(inModule))
+ {
+ return inModule;
+ }
+ Path inReactor = current.resolve("ruoyi-apt/src/main");
+ if (Files.isDirectory(inReactor))
+ {
+ return inReactor;
+ }
+ current = current.getParent();
+ depth++;
+ }
+ return null;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java
new file mode 100644
index 0000000..45751c7
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java
@@ -0,0 +1,133 @@
+package com.ruoyi.apt.framework;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * IT-3 SQL 契约静态校验(对齐 AptIt2SeedMarkersTest 做法)。
+ */
+class AptIt3SqlContractTest
+{
+ @Test
+ void it3ColumnScriptAddsReceiptFieldsIdempotently() throws IOException
+ {
+ String sql = read("14_it3_columns.sql");
+ assertTrue(sql.contains("platform_version"), "回执版本列缺失");
+ assertTrue(sql.contains("face_platform_version"), "主档人脸回执版本列缺失");
+ assertTrue(sql.contains("unit_code"), "N-34 单位编码列缺失");
+ assertTrue(sql.contains("information_schema.COLUMNS"), "补列必须幂等(先查 information_schema)");
+ assertTrue(sql.contains("apt_person_face_reg"), "缺少注册申请表");
+ assertTrue(sql.contains("apt_master_person"), "缺少人员主档表");
+ }
+
+ @Test
+ void it3ColumnScriptAddsDeviceScopeColumnsFromTable10_2() throws IOException
+ {
+ String sql = read("14_it3_columns.sql");
+ assertTrue(sql.contains("device_class"), "表 10-2 设备类别列缺失");
+ assertTrue(sql.contains("face_scope_value"), "表 10-2 可见范围取值列缺失");
+ assertTrue(sql.contains("face_cache_enabled"), "表 10-2 缓存开关列缺失");
+ assertTrue(sql.contains("ALTER TABLE gy_sb"), "设备范围补列应落在 gy_sb");
+ }
+
+ @Test
+ void it3SeedRegistersCheckinDeviceAndKeepsTabletCacheless() throws IOException
+ {
+ String seed = read("15_it3_seed.sql");
+ assertTrue(seed.contains("INSERT INTO gy_sb"), "缺少设备档案灌数");
+ assertTrue(seed.contains("DEV-CHECKIN-01"), "缺少签到终端样例设备");
+ assertTrue(seed.contains("'CHECKIN'"), "签到终端须标 device_class=CHECKIN");
+ assertTrue(seed.contains("'ALL_ACTIVE'"), "签到终端范围须为 ALL_ACTIVE(表 10-16)");
+ assertTrue(seed.contains("DEV-TABLET-01"), "缺少平板样例设备");
+ assertTrue(seed.contains("DEV-CHECKIN-99"), "缺少停用设备样例(验证 40303 口径)");
+ assertTrue(seed.contains("state <> 'DISABLED'"), "须校验停用设备不计入可申请设备");
+ assertFalse(seed.contains("INSERT INTO gy_sb_xt"), "不得伪造设备心跳 / 缓存台账");
+ }
+
+ @Test
+ void it3PermissionScriptRegistersFacePointsAndGreyIt7() throws IOException
+ {
+ String sql = read("16_it3_permissions.sql");
+ assertTrue(sql.contains("apt:face:list"), "缺少 N-25 查看权限点");
+ assertTrue(sql.contains("duty:person:reg"), "缺少 N-35/36/37 值班台权限点");
+ assertTrue(sql.contains("(3201,"), "缺少人脸注册审核页面菜单");
+ assertTrue(sql.contains("apt/face/reg/index"), "菜单组件路径与前端不一致");
+ assertTrue(sql.contains("apt:face:dispatch"), "缺少 IT-7 预留权限点");
+ assertTrue(sql.contains("apt:face:scope"), "缺少 IT-7 预留权限点");
+ assertTrue(sql.contains("apt:face:reconcile"), "缺少 IT-7 预留权限点");
+ assertTrue(sql.contains("apt:face:category"), "缺少表 10-24 #7 类别标注权限点");
+ }
+
+ @Test
+ void it3PermissionScriptKeepsIt7PointsGrey() throws IOException
+ {
+ String sql = read("16_it3_permissions.sql");
+ int greyCount = countOccurrences(sql, "'F', '1', '1'");
+ assertTrue(greyCount >= 4, "IT-7 预留权限点应置灰(visible=1/status=1),实际 " + greyCount);
+ assertTrue(sql.contains("SELECT COUNT(*) AS admin_bound"), "缺少超管关联校验");
+ assertTrue(sql.contains("grey_bound"), "缺少置灰项未授权校验");
+ assertTrue(sql.contains("3213, 3214, 3215, 3216"), "置灰项校验须覆盖全部 IT-7 预留点");
+ }
+
+ @Test
+ void it3VerifyScriptCoversRegistrationStateChain() throws IOException
+ {
+ String sql = read("99_verify.sql");
+ assertTrue(sql.contains("IT-3 增量列"), "验收脚本缺少 IT-3 增量列校验");
+ assertTrue(sql.contains("it3_apply_device_count"), "验收脚本缺少可申请设备校验");
+ assertTrue(sql.contains("it3_grey_bound"), "验收脚本缺少 IT-7 置灰未授权校验");
+ assertTrue(sql.contains("it3_applied_leak"), "验收脚本缺少 APPLIED 不得带 ryid 的校验");
+ assertTrue(sql.contains("it3_confirmed_without_ryid"), "验收脚本缺少确认必带 ryid 的校验");
+ assertTrue(sql.contains("it3_confirmed_orphan"), "验收脚本缺少确认行孤儿检查");
+ }
+
+ private static int countOccurrences(String text, String target)
+ {
+ int count = 0;
+ int index = text.indexOf(target);
+ while (index >= 0)
+ {
+ count++;
+ index = text.indexOf(target, index + target.length());
+ }
+ return count;
+ }
+
+ private String read(String name) throws IOException
+ {
+ Path dir = locateSqlDir();
+ assertNotNull(dir, "未找到 dev/sql/apt_rev01");
+ return Files.readString(dir.resolve(name), StandardCharsets.UTF_8);
+ }
+
+ private Path locateSqlDir()
+ {
+ Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ int depth = 0;
+ while (current != null && depth < 8)
+ {
+ Path direct = current.resolve("dev/sql/apt_rev01");
+ if (Files.isDirectory(direct))
+ {
+ return direct;
+ }
+ Path sibling = current.resolve("sql/apt_rev01");
+ if (Files.isDirectory(sibling))
+ {
+ return sibling;
+ }
+ current = current.getParent();
+ depth++;
+ }
+ return null;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java
new file mode 100644
index 0000000..05ef52a
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java
@@ -0,0 +1,115 @@
+package com.ruoyi.apt.framework;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * IT-4 SQL 契约静态校验(对齐 AptIt2SeedMarkersTest / AptIt3SqlContractTest 做法)。
+ *
+ * @author apt-rev01
+ */
+class AptIt4SqlContractTest
+{
+ @Test
+ void it4ColumnScriptAddsCheckinColumnsIdempotently() throws IOException
+ {
+ String sql = read("17_it4_columns.sql");
+ assertTrue(sql.contains("apt_add_column_if_missing"), "补列必须走幂等辅助");
+ assertTrue(sql.contains("device_event_id"), "设备幂等键缺失");
+ assertTrue(sql.contains("verified_at"), "N-38 核验时刻缺失");
+ assertTrue(sql.contains("submitted_at"), "申请提交时刻缺失");
+ assertTrue(sql.contains("base_kcsj"), "平台补齐开车时刻缺失");
+ assertTrue(sql.contains("derived_rule_version"), "复算规则版本缺失");
+ assertTrue(sql.contains("uk_lt_gyid_device_event"), "设备事件唯一键缺失");
+ assertTrue(sql.contains("uk_lt_gyid_apply"), "申请单唯一键缺失");
+ assertTrue(sql.contains("ROOM_SWAP"), "住房事件字典未补 ROOM_SWAP");
+ }
+
+ @Test
+ void it4ColumnScriptExtendsFlowStateDictionaries() throws IOException
+ {
+ String sql = read("17_it4_columns.sql");
+ assertTrue(sql.contains("ck_lt_checkin"), "缺少入住流程态 CHECK");
+ assertTrue(sql.contains("ck_lt_sync"), "缺少同步态 CHECK");
+ for (String state : new String[] { "SUBMITTED", "REVIEWING", "APPROVED", "ROOM_ADJUSTED",
+ "REJECTED", "CHECKED_IN", "CANCELLED", "CLOSED" })
+ {
+ assertTrue(sql.contains("''" + state + "''"), "checkin_state 字典缺少 " + state);
+ }
+ for (String state : new String[] { "PENDING", "REPORTED", "MATCHED", "UNMATCHED", "TASK_READY" })
+ {
+ assertTrue(sql.contains("''" + state + "''"), "sync_state 字典缺少 " + state);
+ }
+ assertTrue(sql.contains("information_schema.TABLE_CONSTRAINTS"), "CHECK 变更必须幂等");
+ }
+
+ @Test
+ void it4PermissionScriptEnablesCheckinPoints() throws IOException
+ {
+ String sql = read("18_it4_permissions.sql");
+ assertTrue(sql.contains("UPDATE sys_menu"), "3005 应由置灰改为启用,而不是重复插入");
+ assertTrue(sql.contains("menu_id = 3005"), "缺少 3005 启用语句");
+ assertTrue(sql.contains("duty:checkin:list"), "缺少 N-42 列表权限点");
+ assertTrue(sql.contains("duty:checkin:review"), "缺少 N-44 审核权限点");
+ assertTrue(sql.contains("duty:checkin:adjust"), "缺少 N-45 换房权限点");
+ assertTrue(sql.contains("apt:checkin:query"), "缺少 N-47 查询权限点");
+ assertTrue(sql.contains("(3202,"), "缺少入住审核页面菜单");
+ assertTrue(sql.contains("apt/checkin/index"), "菜单组件路径与前端不一致");
+ }
+
+ @Test
+ void it4PermissionScriptBindsOnlyUsablePoints() throws IOException
+ {
+ String sql = read("18_it4_permissions.sql");
+ assertTrue(sql.contains("admin_bound"), "缺少超管关联校验");
+ assertTrue(sql.contains("(3202, 3005, 3217, 3218, 3219)"), "超管关联应覆盖本期全部可用点");
+ assertTrue(sql.contains("still_grey"), "缺少置灰残留校验");
+ }
+
+ @Test
+ void it4VerifyScriptCoversCheckinChain() throws IOException
+ {
+ String sql = read("99_verify.sql");
+ assertTrue(sql.contains("it4_local_task_columns"), "验收脚本缺少 IT-4 增量列校验");
+ assertTrue(sql.contains("it4_task_ready_without_jcsj"), "验收脚本缺少 TASK_READY 必须有 jcsj 的校验");
+ assertTrue(sql.contains("it4_checked_in_orphan"), "验收脚本缺少 CHECKED_IN 孤儿叫乘行校验");
+ assertTrue(sql.contains("it4_checkin_leak"), "验收脚本缺少未入住不得生成叫乘行校验");
+ }
+
+ private String read(String name) throws IOException
+ {
+ Path dir = locateSqlDir();
+ assertNotNull(dir, "未找到 dev/sql/apt_rev01");
+ return Files.readString(dir.resolve(name), StandardCharsets.UTF_8);
+ }
+
+ private Path locateSqlDir()
+ {
+ Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ int depth = 0;
+ while (current != null && depth < 8)
+ {
+ Path direct = current.resolve("dev/sql/apt_rev01");
+ if (Files.isDirectory(direct))
+ {
+ return direct;
+ }
+ Path sibling = current.resolve("sql/apt_rev01");
+ if (Files.isDirectory(sibling))
+ {
+ return sibling;
+ }
+ current = current.getParent();
+ depth++;
+ }
+ return null;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java
new file mode 100644
index 0000000..afe9174
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java
@@ -0,0 +1,110 @@
+package com.ruoyi.apt.framework;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.jupiter.api.Test;
+
+/**
+ * IT-5 SQL 契约静态校验(对齐 AptIt3/It4 做法),覆盖 19/20/99 三个脚本。
+ *
+ * @author apt-rev01
+ */
+class AptIt5SqlContractTest
+{
+ /** 11 个状态时间戳/标志位 + 表 4-9 correct 白名单缺列 jch,共 12 */
+ private static final String[] JCXX_COLUMNS = { "jcsj_s", "hjxysj", "jcbjsj_s", "fjsj_s",
+ "lybjsj", "is_jcbj", "is_lybj", "is_temp_call", "leave_type", "leave_event_id",
+ "leave_remark", "jch" };
+
+ @Test
+ void it5ColumnScriptAddsStateTimestampsIdempotently() throws IOException
+ {
+ String sql = read("19_it5_columns.sql");
+ assertTrue(sql.contains("apt_add_column_if_missing"), "补列必须走幂等辅助");
+ for (String column : JCXX_COLUMNS)
+ {
+ assertTrue(sql.contains(column), "gy_jcxx 缺少表 3-10 所需列:" + column);
+ }
+ assertTrue(sql.contains("duty_manual_action"), "缺少人工命令表补列");
+ assertTrue(sql.contains("reason"), "duty_manual_action 缺少 reason 列");
+ }
+
+ @Test
+ void it5ColumnScriptExtendsCommandDictionary() throws IOException
+ {
+ String sql = read("19_it5_columns.sql");
+ for (String action : new String[] { "STOP", "RE_CALL", "FORCE_FINISH", "MARK_LEAVE", "CANCEL" })
+ {
+ assertTrue(sql.contains(action), "命令枚举注释缺少 " + action);
+ }
+ assertTrue(sql.contains("idx_jcxx_gyid_mode_state"), "缺少按模式扫描的索引");
+ assertTrue(sql.contains("information_schema.STATISTICS"), "索引创建必须幂等");
+ }
+
+ @Test
+ void it5PermissionScriptEnablesJcxxPoints() throws IOException
+ {
+ String sql = read("20_it5_permissions.sql");
+ assertTrue(sql.contains("UPDATE sys_menu"), "3004 应由置灰改为启用,而不是重复插入");
+ assertTrue(sql.contains("menu_id = 3004"), "缺少 3004 启用语句");
+ assertTrue(sql.contains("apt:jcxx:query"), "缺少详情/时间线权限点");
+ assertTrue(sql.contains("apt:jcxx:correct"), "缺少修正权限点");
+ assertTrue(sql.contains("apt:jcxx:changeStatus"), "缺少状态机命令权限点");
+ assertTrue(sql.contains("(3203,"), "缺少叫乘台账页面菜单");
+ assertTrue(sql.contains("apt/jcxx/index"), "菜单组件路径与前端不一致");
+ assertTrue(sql.contains("(3203, 3004, 3220, 3221, 3222)"), "超管关联应覆盖本期全部可用点");
+ }
+
+ @Test
+ void it5VerifyScriptCoversDispatchChain() throws IOException
+ {
+ String sql = read("99_verify.sql");
+ assertTrue(sql.contains("it5_jcxx_columns"), "验收脚本缺少 IT-5 增量列校验");
+ assertTrue(sql.contains("it5_answered_without_finish"), "缺少 ryzt=3 必须 finish_jc=1 校验");
+ assertTrue(sql.contains("it5_finished_regressed"), "缺少已结束叫乘不得回退校验");
+ assertTrue(sql.contains("it5_task_done_without_result"), "缺少 DONE 任务必须有结果行校验");
+ assertTrue(sql.contains("it5_tablet_voice_summary"), "缺少平板链路不得上报 #24 校验");
+ }
+
+ @Test
+ void it5JobSeedUsesWhitelistedPackage() throws IOException
+ {
+ String sql = read("20_it5_permissions.sql");
+ assertTrue(sql.contains("sys_menu"), "权限脚本应只写框架表");
+ assertTrue(!sql.contains("INSERT INTO gy_jcxx"), "权限脚本不得写业务表");
+ }
+
+ private String read(String name) throws IOException
+ {
+ Path dir = locateSqlDir();
+ assertNotNull(dir, "未找到 dev/sql/apt_rev01");
+ return Files.readString(dir.resolve(name), StandardCharsets.UTF_8);
+ }
+
+ private Path locateSqlDir()
+ {
+ Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ int depth = 0;
+ while (current != null && depth < 8)
+ {
+ Path direct = current.resolve("dev/sql/apt_rev01");
+ if (Files.isDirectory(direct))
+ {
+ return direct;
+ }
+ Path sibling = current.resolve("sql/apt_rev01");
+ if (Files.isDirectory(sibling))
+ {
+ return sibling;
+ }
+ current = current.getParent();
+ depth++;
+ }
+ return null;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java
new file mode 100644
index 0000000..5f17c68
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java
@@ -0,0 +1,105 @@
+package com.ruoyi.apt.stay.checkin;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.stay.checkin.domain.AptCallTimeResult;
+import com.ruoyi.apt.stay.checkin.service.AptCallTimeCalculator;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 叫乘时刻复算器单测:规则驱动、节假日分支、不变式校验。
+ *
+ * @author apt-rev01
+ */
+class AptCallTimeCalculatorTest
+{
+ private final AptCallTimeCalculator calculator = new AptCallTimeCalculator();
+
+ @Test
+ @DisplayName("缺省规则:jcsj = kcsj - 30,fjsj/jcbjsj 固定 +10/+15,ylysj = jcsj + 30")
+ void computeWithDefaultRule()
+ {
+ Date kcsj = at(2026, 11, 12, 8, 0);
+ AptCallTimeResult result = calculator.compute(kcsj, null, 0L, false);
+
+ assertEquals(at(2026, 11, 12, 7, 30), result.getJcsj());
+ assertEquals(at(2026, 11, 12, 7, 40), result.getFjsj());
+ assertEquals(at(2026, 11, 12, 7, 45), result.getJcbjsj());
+ assertEquals(at(2026, 11, 12, 8, 0), result.getYlysj());
+ assertEquals(AptCallTimeCalculator.DEFAULT_ADVANCE_MINUTES, result.getAdvanceMinutes());
+ assertEquals(0L, result.getRuleVersion());
+ }
+
+ @Test
+ @DisplayName("规则体生效:advanceMinutes / lysjgz 均按 JSON 取值")
+ void computeWithCustomRule()
+ {
+ Date kcsj = at(2026, 11, 12, 9, 0);
+ String ruleBody = "{\"advanceMinutes\":45,\"holidayAdvanceMinutes\":60,\"lysjgz\":20}";
+
+ AptCallTimeResult result = calculator.compute(kcsj, ruleBody, 7L, false);
+ assertEquals(at(2026, 11, 12, 8, 15), result.getJcsj());
+ assertEquals(at(2026, 11, 12, 8, 35), result.getYlysj());
+ assertEquals(7L, result.getRuleVersion());
+ assertEquals(45, result.getAdvanceMinutes());
+ }
+
+ @Test
+ @DisplayName("节假日分支取 holidayAdvanceMinutes")
+ void computeOnHoliday()
+ {
+ Date kcsj = at(2026, 11, 14, 9, 0);
+ String ruleBody = "{\"advanceMinutes\":30,\"holidayAdvanceMinutes\":60}";
+
+ AptCallTimeResult result = calculator.compute(kcsj, ruleBody, 3L, true);
+ assertTrue(result.isHoliday());
+ assertEquals(at(2026, 11, 14, 8, 0), result.getJcsj());
+ assertEquals(60, result.getAdvanceMinutes());
+ }
+
+ @Test
+ @DisplayName("提前量 <=15 分钟导致 jcbjsj >= kcsj:返回 40931,不静默钳制")
+ void computeRejectsIllegalRelation()
+ {
+ Date kcsj = at(2026, 11, 12, 9, 0);
+ String ruleBody = "{\"advanceMinutes\":10}";
+
+ AptException ex = assertThrows(AptException.class, () -> calculator.compute(kcsj, ruleBody, 1L, false));
+ assertEquals(AptErrorCode.ROUTE_INVALID, ex.getErrorCode());
+ assertEquals("CALL_TIME_ILLEGAL", ex.getReason());
+ }
+
+ @Test
+ @DisplayName("开车时刻缺失:40931 / KCSJ_MISSING")
+ void computeRequiresKcsj()
+ {
+ AptException ex = assertThrows(AptException.class, () -> calculator.compute(null, null, 1L, false));
+ assertEquals(AptErrorCode.ROUTE_INVALID, ex.getErrorCode());
+ assertEquals("KCSJ_MISSING", ex.getReason());
+ }
+
+ @Test
+ @DisplayName("规则体非法 JSON:50304,不静默回落缺省值")
+ void computeRejectsBrokenRuleBody()
+ {
+ Date kcsj = at(2026, 11, 12, 9, 0);
+ AptException ex = assertThrows(AptException.class, () -> calculator.compute(kcsj, "{oops", 1L, false));
+ assertEquals(AptErrorCode.DATA_ANOMALY, ex.getErrorCode());
+ assertEquals("CALL_TIME_RULE_INVALID", ex.getReason());
+ }
+
+ private Date at(int year, int month, int day, int hour, int minute)
+ {
+ return Date.from(LocalDateTime.of(year, month, day, hour, minute)
+ .atZone(ZoneId.systemDefault()).toInstant());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java
new file mode 100644
index 0000000..4cd2a91
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java
@@ -0,0 +1,226 @@
+package com.ruoyi.apt.stay.checkin;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict;
+import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow;
+import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper;
+import com.ruoyi.apt.stay.checkin.service.AptCallTimeCalculator;
+import com.ruoyi.apt.stay.checkin.service.AptCheckinMatchServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * #7 匹配结论消费单测:复算、置 TASK_READY、#8 入队、幂等与异常分支。
+ *
+ * @author apt-rev01
+ */
+class AptCheckinMatchServiceTest
+{
+ private static final String GYID = "APT-REV01-001";
+
+ private static final String APPLY_ID = "CHK-1";
+
+ private AptCheckinMapper mapper;
+
+ private AptCheckinMatchServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ AptContext.setGyId(GYID);
+ mapper = mock(AptCheckinMapper.class);
+ service = new AptCheckinMatchServiceImpl(mapper, new AptCallTimeCalculator());
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ @DisplayName("MATCHED:按要素复算 jcsj 并置 TASK_READY,同时写 gy_jcxx 与 #8 outbox")
+ void matchedComputesCallTimeAndQueuesAck()
+ {
+ AptCheckinApplication app = checkedInApplication();
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app);
+ when(mapper.selectCallTimeRule(GYID)).thenReturn(rule("{\"advanceMinutes\":45}", 6L));
+ when(mapper.countHoliday(eq(GYID), any())).thenReturn(0);
+ when(mapper.insertOutbox(any())).thenAnswer(invocation -> {
+ AptOutboxRow row = invocation.getArgument(0);
+ row.setId(77L);
+ return 1;
+ });
+
+ AptCheckinMatchResult result = service.applyVerdict(verdict("MATCHED"));
+
+ assertTrue(result.isTaskReady());
+ assertTrue(result.isChanged());
+ assertEquals("TASK_READY", result.getSyncState());
+ assertEquals("MATCHED", result.getMatchState());
+ assertEquals(at(2026, 11, 12, 7, 15), result.getJcsj());
+ assertEquals(6L, result.getDerivedRuleVersion());
+ assertEquals(77L, result.getTaskAckOutboxId());
+ verify(mapper).updateJcxxCallTime(GYID, "JC-1", at(2026, 11, 12, 7, 15), "SIP");
+ verify(mapper).updateTaskReady(eq(GYID), eq(APPLY_ID), eq(at(2026, 11, 12, 7, 15)), eq(6L), any());
+ }
+
+ @Test
+ @DisplayName("同结论重复提交:幂等,不重复复算、不重复入队 #8")
+ void matchedIsIdempotent()
+ {
+ AptCheckinApplication app = checkedInApplication();
+ app.setMatchState("MATCHED");
+ app.setSyncState("TASK_READY");
+ app.setJcsj(at(2026, 11, 12, 7, 15));
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app);
+
+ AptCheckinMatchResult result = service.applyVerdict(verdict("MATCHED"));
+
+ assertFalse(result.isChanged());
+ assertTrue(result.isTaskReady());
+ verify(mapper, never()).insertOutbox(any());
+ verify(mapper, never()).updateTaskReady(anyString(), anyString(), any(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("UNMATCHED:保留本地要素、不生成任务,sync_state 回 REPORTED")
+ void unmatchedKeepsLocalElements()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(checkedInApplication());
+
+ AptCheckinMatchResult result = service.applyVerdict(verdict("UNMATCHED"));
+
+ assertFalse(result.isTaskReady());
+ assertEquals("UNMATCHED", result.getMatchState());
+ assertEquals("REPORTED", result.getSyncState());
+ verify(mapper).updateSyncState(GYID, APPLY_ID, "REPORTED", null);
+ verify(mapper, never()).updateTaskReady(anyString(), anyString(), any(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("LOCKED:置 jllx=1 并按本地要素复算(平台不覆盖)")
+ void lockedKeepsLocalElementsAndLocks()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(checkedInApplication());
+ when(mapper.selectCallTimeRule(GYID)).thenReturn(null);
+ when(mapper.countHoliday(eq(GYID), any())).thenReturn(1);
+ when(mapper.insertOutbox(any())).thenReturn(1);
+
+ AptCheckinMatchResult result = service.applyVerdict(verdict("LOCKED"));
+
+ assertEquals("LOCKED", result.getMatchState());
+ assertTrue(result.isTaskReady());
+ verify(mapper).updateMatchLock(GYID, APPLY_ID, "1");
+ assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("jllx=1")));
+ }
+
+ @Test
+ @DisplayName("未办理入住:40926,不允许匹配结论提前落地")
+ void rejectsWhenNotCheckedIn()
+ {
+ AptCheckinApplication app = checkedInApplication();
+ app.setCheckinState("APPROVED");
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app);
+
+ AptException ex = assertThrows(AptException.class, () -> service.applyVerdict(verdict("MATCHED")));
+ assertEquals(AptErrorCode.CHECKIN_STATE_CONFLICT, ex.getErrorCode());
+ assertEquals("NOT_CHECKED_IN", ex.getReason());
+ }
+
+ @Test
+ @DisplayName("MATCHED 但缺开车时刻:只置 MATCHED,不伪造 jcsj")
+ void matchedWithoutKcsjStaysMatched()
+ {
+ AptCheckinApplication app = checkedInApplication();
+ app.setKcsj(null);
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app);
+ AptCheckinMatchVerdict verdict = verdict("MATCHED");
+ verdict.setKcsj(null);
+
+ AptCheckinMatchResult result = service.applyVerdict(verdict);
+
+ assertFalse(result.isTaskReady());
+ assertEquals("MATCHED", result.getSyncState());
+ verify(mapper, never()).updateTaskReady(anyString(), anyString(), any(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("非法 verdict:40001")
+ void rejectsUnknownVerdict()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(checkedInApplication());
+ AptCheckinMatchVerdict verdict = verdict("MAYBE");
+
+ AptException ex = assertThrows(AptException.class, () -> service.applyVerdict(verdict));
+ assertEquals(AptErrorCode.BAD_REQUEST, ex.getErrorCode());
+ assertEquals("MATCH_VERDICT_INVALID", ex.getReason());
+ }
+
+ private AptCheckinApplication checkedInApplication()
+ {
+ AptCheckinApplication app = new AptCheckinApplication();
+ app.setApplyId(APPLY_ID);
+ app.setJcid("JC-1");
+ app.setCheckinState("CHECKED_IN");
+ app.setSyncState("REPORTED");
+ app.setMatchState("PENDING");
+ app.setBizVersion(8L);
+ app.setKcsj(at(2026, 11, 12, 8, 0));
+ app.setJcfs("SIP");
+ app.setFjhFinal("A101");
+ return app;
+ }
+
+ private AptCheckinMatchVerdict verdict(String value)
+ {
+ AptCheckinMatchVerdict verdict = new AptCheckinMatchVerdict();
+ verdict.setApplyId(APPLY_ID);
+ verdict.setJcid("JC-1");
+ verdict.setVerdict(value);
+ verdict.setMatchedPlanKey("PLAN-1");
+ verdict.setKcsj(at(2026, 11, 12, 8, 0));
+ verdict.setJcfs("SIP");
+ verdict.setReason("平台说明");
+ return verdict;
+ }
+
+ private Map rule(String body, long version)
+ {
+ Map rule = new LinkedHashMap<>();
+ rule.put("ruleBody", body);
+ rule.put("ruleVersion", version);
+ return rule;
+ }
+
+ private Date at(int year, int month, int day, int hour, int minute)
+ {
+ return Date.from(LocalDateTime.of(year, month, day, hour, minute)
+ .atZone(ZoneId.systemDefault()).toInstant());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java
new file mode 100644
index 0000000..167208c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java
@@ -0,0 +1,485 @@
+package com.ruoyi.apt.stay.checkin;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinCallTask;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinOptions;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustCommand;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustResult;
+import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow;
+import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper;
+import com.ruoyi.apt.stay.checkin.service.AptCheckinServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 入住作业服务单测:申请校验、审核事务、换房边界。
+ *
+ * @author apt-rev01
+ */
+class AptCheckinServiceTest
+{
+ private static final String GYID = "APT-REV01-001";
+
+ private static final String APPLY_ID = "CHK-20261112120000-100001";
+
+ private static final String DEVICE = "DEV-CHECKIN-01";
+
+ private AptCheckinMapper mapper;
+
+ private AptCheckinServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ AptContext.setGyId(GYID);
+ mapper = mock(AptCheckinMapper.class);
+ service = new AptCheckinServiceImpl(mapper);
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ // ---------------------------------------------------------------- N-40
+
+ @Test
+ @DisplayName("申请:固定交路 → origin=CHECKIN_ROUTE,只落 SUBMITTED,不写人员表/叫乘表")
+ void applyWithRouteCreatesSubmittedApplication()
+ {
+ when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person());
+ when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg());
+ when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101"));
+ when(mapper.countActiveStay(GYID, "RY001")).thenReturn(0);
+ when(mapper.selectRouteForCheckin(GYID, "JL-01")).thenReturn(route());
+ when(mapper.insertApplication(eq(GYID), any())).thenReturn(1);
+
+ AptCheckinApplyResult result = service.apply(command("EV-1"), DEVICE);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AptCheckinApplication.class);
+ verify(mapper).insertApplication(eq(GYID), captor.capture());
+ AptCheckinApplication saved = captor.getValue();
+ assertEquals("CHECKIN_ROUTE", saved.getOrigin());
+ assertEquals("A101", saved.getFjh());
+ assertEquals("A101", saved.getFjhFinal());
+ assertEquals("RY001", saved.getVerifiedRyid());
+ assertEquals("0", saved.getJllx());
+ assertEquals("CONFIRMED", saved.getRegStateAtApply());
+ assertEquals(at(2026, 11, 12, 8, 0), saved.getKcsjSnapshot());
+ assertEquals("SUBMITTED", result.getCheckinState());
+ assertEquals("PENDING", result.getSyncState());
+ assertFalse(result.isDuplicated());
+ verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any());
+ }
+
+ @Test
+ @DisplayName("申请:同一设备事件重复提交 → 返回原 applyId,不重复插入")
+ void applyIsIdempotentByDeviceEvent()
+ {
+ AptCheckinApplication existing = new AptCheckinApplication();
+ existing.setApplyId(APPLY_ID);
+ existing.setJcid("JC-1");
+ existing.setCheckinState("CHECKED_IN");
+ existing.setSyncState("TASK_READY");
+ existing.setBizVersion(9L);
+ when(mapper.selectByDeviceEvent(GYID, DEVICE, "EV-1")).thenReturn(existing);
+
+ AptCheckinApplyResult result = service.apply(command("EV-1"), DEVICE);
+
+ assertTrue(result.isDuplicated());
+ assertEquals(APPLY_ID, result.getApplyId());
+ assertEquals(9L, result.getBizVersion());
+ verify(mapper, never()).insertApplication(anyString(), any());
+ }
+
+ @Test
+ @DisplayName("申请:人脸注册未确认 → 40914,不落申请单")
+ void applyRejectsUnconfirmedFaceRegistration()
+ {
+ when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person());
+ when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(null);
+
+ AptException ex = assertThrows(AptException.class, () -> service.apply(command("EV-1"), DEVICE));
+ assertEquals(AptErrorCode.FACE_REG_PENDING, ex.getErrorCode());
+ assertEquals("FACE_REG_NOT_CONFIRMED", ex.getReason());
+ verify(mapper, never()).insertApplication(anyString(), any());
+ }
+
+ @Test
+ @DisplayName("申请:核验会话超过 5 分钟 → 40929")
+ void applyRejectsExpiredVerifySession()
+ {
+ when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person());
+ when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg());
+
+ AptCheckinApplyCommand command = command("EV-1");
+ command.setVerifiedAt(new Date(System.currentTimeMillis() - 6 * 60 * 1000L));
+
+ AptException ex = assertThrows(AptException.class, () -> service.apply(command, DEVICE));
+ assertEquals(AptErrorCode.VERIFY_SESSION_EXPIRED, ex.getErrorCode());
+ }
+
+ @Test
+ @DisplayName("申请:已有在住记录 → 40932")
+ void applyRejectsDuplicateStay()
+ {
+ when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person());
+ when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg());
+ when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101"));
+ when(mapper.countActiveStay(GYID, "RY001")).thenReturn(1);
+
+ AptException ex = assertThrows(AptException.class, () -> service.apply(command("EV-1"), DEVICE));
+ assertEquals(AptErrorCode.DUPLICATE_STAY, ex.getErrorCode());
+ }
+
+ @Test
+ @DisplayName("申请:房间住满 → 40928")
+ void applyRejectsFullRoom()
+ {
+ when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person());
+ when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg());
+ AptCheckinOptions.Room full = availableRoom("A101");
+ full.setAvailable(false);
+ when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(full);
+
+ AptException ex = assertThrows(AptException.class, () -> service.apply(command("EV-1"), DEVICE));
+ assertEquals(AptErrorCode.ROOM_NOT_AVAILABLE, ex.getErrorCode());
+ }
+
+ // ---------------------------------------------------------------- N-44
+
+ @Test
+ @DisplayName("审核通过:同事务占床 + 建 gy_jcxx + 住房事件 + #9 入队 + 置 CHECKED_IN")
+ void approveChecksInWithinSameTransaction()
+ {
+ AptCheckinApplication reviewing = application("REVIEWING", 5L);
+ AptCheckinApplication approved = application("APPROVED", 6L);
+ AptCheckinApplication checkedIn = application("CHECKED_IN", 8L);
+ checkedIn.setSyncState("REPORTED");
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(reviewing, approved, checkedIn);
+ when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(),
+ any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1);
+ when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101"));
+ when(mapper.occupyBed(eq(GYID), anyString(), anyLong(), anyInt())).thenReturn(1);
+ when(mapper.countSipBinding(GYID, "ROOM-A101")).thenReturn(0);
+ when(mapper.insertJcxx(eq(GYID), any(), any(), anyString(), any())).thenReturn(1);
+ when(mapper.insertOutbox(any())).thenAnswer(invocation -> {
+ AptOutboxRow row = invocation.getArgument(0);
+ row.setId(88L);
+ return 1;
+ });
+
+ AptCheckinReviewResult result = service.review(APPLY_ID, approveCommand(5L), "duty01");
+
+ assertTrue(result.isCheckedIn());
+ assertEquals("CHECKED_IN", result.getCheckinState());
+ assertEquals("PENDING", result.getUploadState());
+ assertEquals(88L, result.getReportOutboxId());
+ verify(mapper).occupyBed(eq(GYID), eq("ROOM-A101"), anyLong(), eq(2));
+ verify(mapper).recalcRoomStatus(GYID, "ROOM-A101", 2);
+ verify(mapper).insertJcxx(eq(GYID), any(), any(), eq("TABLET"), any());
+ verify(mapper).insertLyEvent(eq(GYID), anyString(), eq("CHECKIN"), eq("MANUAL"), anyString(), anyString(),
+ eq("A101"), any());
+ verify(mapper).updateReported(eq(GYID), eq(APPLY_ID), any());
+ assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("TABLET")));
+ }
+
+ @Test
+ @DisplayName("审核通过:bizVersion 过期 → 40926,不占床不建单")
+ void approveRejectsStaleVersion()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("REVIEWING", 7L));
+ when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101"));
+ when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(),
+ any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(0);
+
+ AptException ex = assertThrows(AptException.class,
+ () -> service.review(APPLY_ID, approveCommand(5L), "duty01"));
+ assertEquals(AptErrorCode.CHECKIN_STATE_CONFLICT, ex.getErrorCode());
+ verify(mapper, never()).occupyBed(anyString(), anyString(), anyLong(), anyInt());
+ verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any());
+ }
+
+ @Test
+ @DisplayName("换房通过:仅迁移 ROOM_ADJUSTED 与 seq+1,不占床、不建叫乘行")
+ void approveWithRoomChangeOnlyAdjustsIntent()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("REVIEWING", 5L));
+ when(mapper.selectRoomByNo(GYID, "B202")).thenReturn(availableRoom("B202"));
+ when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(),
+ any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1);
+
+ AptCheckinReviewCommand command = approveCommand(5L);
+ command.setVerdict("APPROVE_WITH_ROOM_CHANGE");
+ command.setTargetFjh("B202");
+ AptCheckinReviewResult result = service.review(APPLY_ID, command, "duty01");
+
+ assertFalse(result.isCheckedIn());
+ assertEquals("ROOM_ADJUSTED", result.getCheckinState());
+ assertEquals("B202", result.getFjhFinal());
+ assertEquals(1, result.getRoomAdjustSeq());
+ verify(mapper, never()).occupyBed(anyString(), anyString(), anyLong(), anyInt());
+ verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any());
+ }
+
+ @Test
+ @DisplayName("驳回:缺审核说明 → 40001;有说明则置 REJECTED 且不建叫乘行")
+ void rejectRequiresNote()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("REVIEWING", 5L));
+ AptCheckinReviewCommand command = new AptCheckinReviewCommand();
+ command.setVerdict("REJECT");
+ command.setExpectedBizVersion(5L);
+
+ AptException ex = assertThrows(AptException.class,
+ () -> service.review(APPLY_ID, command, "duty01"));
+ assertEquals(AptErrorCode.BAD_REQUEST, ex.getErrorCode());
+ assertEquals("REVIEW_NOTE_REQUIRED", ex.getReason());
+
+ command.setReviewNote("要素不符");
+ when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(),
+ any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1);
+ AptCheckinReviewResult result = service.review(APPLY_ID, command, "duty01");
+ assertEquals("REJECTED", result.getCheckinState());
+ assertFalse(result.isCheckedIn());
+ verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any());
+ }
+
+ // ---------------------------------------------------------------- N-45
+
+ @Test
+ @DisplayName("换房:已入住 → 40926(走退宿换房)")
+ void roomAdjustAfterCheckedInIsRejected()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("CHECKED_IN", 9L));
+ AptCheckinRoomAdjustCommand command = new AptCheckinRoomAdjustCommand();
+ command.setTargetFjh("B202");
+ command.setExpectedBizVersion(9L);
+
+ AptException ex = assertThrows(AptException.class,
+ () -> service.roomAdjust(APPLY_ID, command, "duty01"));
+ assertEquals(AptErrorCode.CHECKIN_STATE_CONFLICT, ex.getErrorCode());
+ assertEquals("ALREADY_CHECKED_IN", ex.getReason());
+ }
+
+ @Test
+ @DisplayName("换房:审核期迁移 ROOM_ADJUSTED,写 ROOM_SWAP 事件,不动房态计数")
+ void roomAdjustDuringReviewWritesSwapEvent()
+ {
+ when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("APPROVED", 6L));
+ when(mapper.selectRoomByNo(GYID, "B202")).thenReturn(availableRoom("B202"));
+ when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(),
+ any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1);
+ when(mapper.countSipBinding(GYID, "ROOM-B202")).thenReturn(1);
+
+ AptCheckinRoomAdjustCommand command = new AptCheckinRoomAdjustCommand();
+ command.setTargetFjh("B202");
+ command.setReason("原房空调故障");
+ command.setExpectedBizVersion(6L);
+ AptCheckinRoomAdjustResult result = service.roomAdjust(APPLY_ID, command, "duty01");
+
+ assertEquals("ROOM_ADJUSTED", result.getCheckinState());
+ assertEquals("B202", result.getFjhFinal());
+ assertEquals(1, result.getRoomAdjustSeq());
+ assertTrue(result.isSipBound());
+ verify(mapper).insertLyEvent(eq(GYID), anyString(), eq("ROOM_SWAP"), eq("MANUAL"), anyString(), anyString(),
+ eq("B202"), any());
+ verify(mapper, never()).occupyBed(anyString(), anyString(), anyLong(), anyInt());
+ verify(mapper, never()).releaseBed(anyString(), anyString(), anyLong());
+ }
+
+ // ---------------------------------------------------------------- N-47
+
+ @Test
+ @DisplayName("N-47:未就绪时 task=null 且 taskReady=false,与 40401 区分")
+ void callTaskNotReadyReturnsNullTask()
+ {
+ AptCheckinApplication app = application("CHECKED_IN", 8L);
+ app.setSyncState("REPORTED");
+ when(mapper.selectApplicationByJcid(GYID, "JC-1")).thenReturn(app);
+
+ AptCheckinCallTask task = service.callTask("JC-1");
+
+ assertFalse(task.isTaskReady());
+ assertNull(task.getTask());
+ assertEquals("REPORTED", task.getSyncState());
+ }
+
+ @Test
+ @DisplayName("N-47:TASK_READY 时返回 gy_jcxx 任务与复算后的 jcsj")
+ void callTaskReadyReturnsJcxxRow()
+ {
+ AptCheckinApplication app = application("CHECKED_IN", 8L);
+ app.setSyncState("TASK_READY");
+ app.setJcsj(at(2026, 11, 12, 7, 30));
+ AptCheckinCallTask.Task row = new AptCheckinCallTask.Task();
+ row.setTaskId("JC-1");
+ row.setState("PENDING");
+ row.setJcsj(at(2026, 11, 12, 7, 30));
+ row.setJcfs("SIP");
+ when(mapper.selectApplicationByJcid(GYID, "JC-1")).thenReturn(app);
+ when(mapper.selectCallTask(GYID, "JC-1")).thenReturn(row);
+
+ AptCheckinCallTask task = service.callTask("JC-1");
+
+ assertTrue(task.isTaskReady());
+ assertNotNull(task.getTask());
+ assertEquals(at(2026, 11, 12, 7, 30), task.getJcsj());
+ assertEquals("SIP", task.getJcfs());
+ }
+
+ @Test
+ @DisplayName("N-39:version 一致时 notModified=true 且不下发选项")
+ void optionsHonoursVersion()
+ {
+ when(mapper.selectOptionsVersion(GYID)).thenReturn("20261112080000");
+
+ AptCheckinOptions fresh = service.options("20261112080000");
+ assertTrue(fresh.isNotModified());
+ assertTrue(fresh.getRooms().isEmpty());
+
+ List rooms = List.of(availableRoom("A101"));
+ when(mapper.selectRoomOptions(GYID)).thenReturn(rooms);
+ AptCheckinOptions changed = service.options("20261111120000");
+ assertFalse(changed.isNotModified());
+ assertEquals(1, changed.getRooms().size());
+ assertEquals("20261112080000", changed.getVersion());
+ }
+
+ // ---------------------------------------------------------------- 工具
+
+ private AptCheckinApplyCommand command(String deviceEventId)
+ {
+ AptCheckinApplyCommand command = new AptCheckinApplyCommand();
+ command.setDeviceEventId(deviceEventId);
+ command.setRyid("RY001");
+ command.setFjh("A101");
+ command.setJlid("JL-01");
+ command.setJch("JCH-1");
+ command.setOccurredAt(new Date());
+ command.setVerifiedAt(new Date());
+ command.setFeatureVersion(3L);
+ return command;
+ }
+
+ private AptCheckinReviewCommand approveCommand(long expectedVersion)
+ {
+ AptCheckinReviewCommand command = new AptCheckinReviewCommand();
+ command.setVerdict("APPROVE");
+ command.setExpectedBizVersion(expectedVersion);
+ command.setReviewNote("要素齐全");
+ command.setCommandId("CMD-1");
+ return command;
+ }
+
+ private AptCheckinApplication application(String state, long bizVersion)
+ {
+ AptCheckinApplication app = new AptCheckinApplication();
+ app.setApplyId(APPLY_ID);
+ app.setJcid("JC-1");
+ app.setTaskId(APPLY_ID);
+ app.setRyid("RY001");
+ app.setName("张三");
+ app.setWorkNo("W001");
+ app.setFjh("A101");
+ app.setFjhFinal("A101");
+ app.setRoomId("ROOM-A101");
+ app.setBedCount(2);
+ app.setLiveCount(0);
+ app.setRoomStatus("1");
+ app.setCheckinState(state);
+ app.setSyncState("PENDING");
+ app.setMatchState("PENDING");
+ app.setBizVersion(bizVersion);
+ app.setOrigin("CHECKIN_ROUTE");
+ app.setJlid("JL-01");
+ app.setCc("G101");
+ app.setKcsj(at(2026, 11, 12, 8, 0));
+ app.setRoomAdjustSeq(0);
+ return app;
+ }
+
+ private AptCheckinOptions.Room availableRoom(String roomNo)
+ {
+ AptCheckinOptions.Room room = new AptCheckinOptions.Room();
+ room.setRoomId("ROOM-" + roomNo);
+ room.setRoomNo(roomNo);
+ room.setBedCount(2);
+ room.setLiveCount(0);
+ room.setRoomStatus("1");
+ room.setRoomStatusLabel("空闲");
+ room.setStateVersion(4L);
+ room.setAvailable(true);
+ return room;
+ }
+
+ private Map person()
+ {
+ Map person = new LinkedHashMap<>();
+ person.put("ryid", "RY001");
+ person.put("name", "张三");
+ person.put("workNo", "W001");
+ person.put("personCategory", "TRANSIENT");
+ person.put("featureVersion", 3L);
+ return person;
+ }
+
+ private Map confirmedReg()
+ {
+ Map reg = new LinkedHashMap<>();
+ reg.put("regId", "REG-1");
+ reg.put("regState", "CONFIRMED");
+ reg.put("featureVersion", 3L);
+ return reg;
+ }
+
+ private Map route()
+ {
+ Map route = new LinkedHashMap<>();
+ route.put("jlid", "JL-01");
+ route.put("cc", "G101");
+ route.put("kcsj", at(2026, 11, 12, 8, 0));
+ return route;
+ }
+
+ private Date at(int year, int month, int day, int hour, int minute)
+ {
+ return Date.from(LocalDateTime.of(year, month, day, hour, minute)
+ .atZone(ZoneId.systemDefault()).toInstant());
+ }
+}
diff --git a/dev/scripts/apply-it3-sql.ps1 b/dev/scripts/apply-it3-sql.ps1
new file mode 100644
index 0000000..42c84fa
--- /dev/null
+++ b/dev/scripts/apply-it3-sql.ps1
@@ -0,0 +1,110 @@
+<#
+ Apply IT-3 SQL scripts to apt_rev01 (idempotent, replayable).
+ Credentials come from dev/.env first, then APT_DB_* environment variables.
+ Passwords are never printed.
+ SQL files are streamed to mysql stdin as raw UTF-8 bytes: PowerShell 5.1's
+ text pipeline can replace Chinese literals with '?'.
+ Usage:
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it3-sql.ps1
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it3-sql.ps1 -DryRun
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it3-sql.ps1 -EnvFile C:\secrets\apt.env
+#>
+param(
+ [string]$EnvFile = '',
+ [string]$MysqlPath = 'C:\Program Files\MySQL\MySQL Server 9.5\bin\mysql.exe',
+ [switch]$DryRun
+)
+
+$ErrorActionPreference = 'Stop'
+$devDir = Split-Path $PSScriptRoot -Parent
+$sqlDir = Join-Path $devDir 'sql/apt_rev01'
+$scripts = @('14_it3_columns.sql', '15_it3_seed.sql', '16_it3_permissions.sql')
+
+foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ if (-not [System.IO.File]::Exists($file)) { throw "Missing SQL file: $file" }
+}
+if ($EnvFile -eq '') { $EnvFile = Join-Path $devDir '.env' }
+
+$values = @{}
+if ([System.IO.File]::Exists($EnvFile)) {
+ foreach ($line in [System.IO.File]::ReadAllLines($EnvFile)) {
+ $trimmed = $line.Trim()
+ if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
+ if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { $values[$Matches[1]] = $Matches[2] }
+ }
+ Write-Host "Credentials source: $EnvFile"
+} else {
+ Write-Host "No credentials file at $EnvFile; using APT_DB_* environment variables."
+ foreach ($key in @('APT_DB_HOST','APT_DB_PORT','APT_DB_NAME','APT_DB_USER','APT_DB_PASSWORD')) {
+ $values[$key] = [Environment]::GetEnvironmentVariable($key)
+ }
+}
+
+$dbHost = $values['APT_DB_HOST']; if ([string]::IsNullOrEmpty($dbHost)) { $dbHost = '127.0.0.1' }
+$dbPort = $values['APT_DB_PORT']; if ([string]::IsNullOrEmpty($dbPort)) { $dbPort = '3306' }
+$dbName = $values['APT_DB_NAME']; if ([string]::IsNullOrEmpty($dbName)) { $dbName = 'apt_rev01' }
+$dbUser = $values['APT_DB_USER']; if ([string]::IsNullOrEmpty($dbUser)) { $dbUser = 'iotlocks' }
+
+if ($DryRun) {
+ Write-Host '--- DRY RUN: no database connection, no credentials required ---'
+ Write-Host ("Target database: {0}:{1}/{2} (user {3})" -f $dbHost, $dbPort, $dbName, $dbUser)
+ foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ $lineCount = ([System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)).Count
+ Write-Host (" would apply {0} ({1} lines)" -f $name, $lineCount)
+ }
+ Write-Host 'Validation SELECTs in 14/15/16 print column, device-seed, permission and menu checks.'
+ Write-Host 'Run 99_verify.sql afterwards for the IT-3 acceptance checks (sections 7~13).'
+ return
+}
+
+if ([string]::IsNullOrEmpty($values['APT_DB_PASSWORD'])) {
+ $hint = @'
+Missing APT_DB_PASSWORD. Provide one of:
+ 1) copy dev/.env.example to dev/.env, fill APT_DB_PASSWORD, then re-run this script
+ 2) set APT_DB_PASSWORD (plus optional APT_DB_HOST/PORT/NAME/USER) in the environment
+Use -DryRun to preview SQL files without connecting to MySQL.
+'@
+ throw $hint
+}
+if (-not [System.IO.File]::Exists($MysqlPath)) { throw "Missing mysql client: $MysqlPath" }
+
+[Environment]::SetEnvironmentVariable('MYSQL_PWD', $values['APT_DB_PASSWORD'], 'Process')
+try {
+ foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ Write-Host "--- applying $name ---"
+
+ $arguments = "--host=$dbHost --port=$dbPort --user=$dbUser --database=$dbName" +
+ ' --default-character-set=utf8mb4 --batch'
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = $MysqlPath
+ $psi.Arguments = $arguments
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardInput = $true
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
+
+ $process = [System.Diagnostics.Process]::Start($psi)
+ $bytes = [System.IO.File]::ReadAllBytes($file)
+ $offset = 0
+ if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
+ $offset = 3
+ }
+ $process.StandardInput.BaseStream.Write($bytes, $offset, $bytes.Length - $offset)
+ $process.StandardInput.BaseStream.Flush()
+ $process.StandardInput.Close()
+ $stdout = $process.StandardOutput.ReadToEnd()
+ $stderr = $process.StandardError.ReadToEnd()
+ $process.WaitForExit()
+ if ($stdout) { Write-Host $stdout }
+ if ($stderr) { Write-Host $stderr }
+ if ($process.ExitCode -ne 0) { throw "SQL failed: $name (exit $($process.ExitCode))" }
+ }
+ Write-Host 'IT-3 SQL scripts applied successfully.'
+} finally {
+ [Environment]::SetEnvironmentVariable('MYSQL_PWD', $null, 'Process')
+}
diff --git a/dev/scripts/apply-it4-sql.ps1 b/dev/scripts/apply-it4-sql.ps1
new file mode 100644
index 0000000..6ae52fa
--- /dev/null
+++ b/dev/scripts/apply-it4-sql.ps1
@@ -0,0 +1,110 @@
+<#
+ Apply IT-4 SQL scripts to apt_rev01 (idempotent, replayable).
+ Credentials come from dev/.env first, then APT_DB_* environment variables.
+ Passwords are never printed.
+ SQL files are streamed to mysql stdin as raw UTF-8 bytes: PowerShell 5.1's
+ text pipeline can replace Chinese literals with '?'.
+ Usage:
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it4-sql.ps1
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it4-sql.ps1 -DryRun
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it4-sql.ps1 -EnvFile C:\secrets\apt.env
+#>
+param(
+ [string]$EnvFile = '',
+ [string]$MysqlPath = 'C:\Program Files\MySQL\MySQL Server 9.5\bin\mysql.exe',
+ [switch]$DryRun
+)
+
+$ErrorActionPreference = 'Stop'
+$devDir = Split-Path $PSScriptRoot -Parent
+$sqlDir = Join-Path $devDir 'sql/apt_rev01'
+$scripts = @('17_it4_columns.sql', '18_it4_permissions.sql')
+
+foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ if (-not [System.IO.File]::Exists($file)) { throw "Missing SQL file: $file" }
+}
+if ($EnvFile -eq '') { $EnvFile = Join-Path $devDir '.env' }
+
+$values = @{}
+if ([System.IO.File]::Exists($EnvFile)) {
+ foreach ($line in [System.IO.File]::ReadAllLines($EnvFile)) {
+ $trimmed = $line.Trim()
+ if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
+ if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { $values[$Matches[1]] = $Matches[2] }
+ }
+ Write-Host "Credentials source: $EnvFile"
+} else {
+ Write-Host "No credentials file at $EnvFile; using APT_DB_* environment variables."
+ foreach ($key in @('APT_DB_HOST','APT_DB_PORT','APT_DB_NAME','APT_DB_USER','APT_DB_PASSWORD')) {
+ $values[$key] = [Environment]::GetEnvironmentVariable($key)
+ }
+}
+
+$dbHost = $values['APT_DB_HOST']; if ([string]::IsNullOrEmpty($dbHost)) { $dbHost = '127.0.0.1' }
+$dbPort = $values['APT_DB_PORT']; if ([string]::IsNullOrEmpty($dbPort)) { $dbPort = '3306' }
+$dbName = $values['APT_DB_NAME']; if ([string]::IsNullOrEmpty($dbName)) { $dbName = 'apt_rev01' }
+$dbUser = $values['APT_DB_USER']; if ([string]::IsNullOrEmpty($dbUser)) { $dbUser = 'iotlocks' }
+
+if ($DryRun) {
+ Write-Host '--- DRY RUN: no database connection, no credentials required ---'
+ Write-Host ("Target database: {0}:{1}/{2} (user {3})" -f $dbHost, $dbPort, $dbName, $dbUser)
+ foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ $lineCount = ([System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)).Count
+ Write-Host (" would apply {0} ({1} lines)" -f $name, $lineCount)
+ }
+ Write-Host 'Validation SELECTs in 17/18 print column, state-dictionary, permission and menu checks.'
+ Write-Host 'Run 99_verify.sql afterwards for the IT-4 acceptance checks (sections 14~18).'
+ return
+}
+
+if ([string]::IsNullOrEmpty($values['APT_DB_PASSWORD'])) {
+ $hint = @'
+Missing APT_DB_PASSWORD. Provide one of:
+ 1) copy dev/.env.example to dev/.env, fill APT_DB_PASSWORD, then re-run this script
+ 2) set APT_DB_PASSWORD (plus optional APT_DB_HOST/PORT/NAME/USER) in the environment
+Use -DryRun to preview SQL files without connecting to MySQL.
+'@
+ throw $hint
+}
+if (-not [System.IO.File]::Exists($MysqlPath)) { throw "Missing mysql client: $MysqlPath" }
+
+[Environment]::SetEnvironmentVariable('MYSQL_PWD', $values['APT_DB_PASSWORD'], 'Process')
+try {
+ foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ Write-Host "--- applying $name ---"
+
+ $arguments = "--host=$dbHost --port=$dbPort --user=$dbUser --database=$dbName" +
+ ' --default-character-set=utf8mb4 --batch'
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = $MysqlPath
+ $psi.Arguments = $arguments
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardInput = $true
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
+
+ $process = [System.Diagnostics.Process]::Start($psi)
+ $bytes = [System.IO.File]::ReadAllBytes($file)
+ $offset = 0
+ if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
+ $offset = 3
+ }
+ $process.StandardInput.BaseStream.Write($bytes, $offset, $bytes.Length - $offset)
+ $process.StandardInput.BaseStream.Flush()
+ $process.StandardInput.Close()
+ $stdout = $process.StandardOutput.ReadToEnd()
+ $stderr = $process.StandardError.ReadToEnd()
+ $process.WaitForExit()
+ if ($stdout) { Write-Host $stdout }
+ if ($stderr) { Write-Host $stderr }
+ if ($process.ExitCode -ne 0) { throw "SQL failed: $name (exit $($process.ExitCode))" }
+ }
+ Write-Host 'IT-4 SQL scripts applied successfully.'
+} finally {
+ [Environment]::SetEnvironmentVariable('MYSQL_PWD', $null, 'Process')
+}
diff --git a/dev/scripts/apply-it5-sql.ps1 b/dev/scripts/apply-it5-sql.ps1
new file mode 100644
index 0000000..9234296
--- /dev/null
+++ b/dev/scripts/apply-it5-sql.ps1
@@ -0,0 +1,110 @@
+<#
+ Apply IT-5 SQL scripts to apt_rev01 (idempotent, replayable).
+ Credentials come from dev/.env first, then APT_DB_* environment variables.
+ Passwords are never printed.
+ SQL files are streamed to mysql stdin as raw UTF-8 bytes: PowerShell 5.1's
+ text pipeline can replace Chinese literals with '?'.
+ Usage:
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it5-sql.ps1
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it5-sql.ps1 -DryRun
+ powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it5-sql.ps1 -EnvFile C:\secrets\apt.env
+#>
+param(
+ [string]$EnvFile = '',
+ [string]$MysqlPath = 'C:\Program Files\MySQL\MySQL Server 9.5\bin\mysql.exe',
+ [switch]$DryRun
+)
+
+$ErrorActionPreference = 'Stop'
+$devDir = Split-Path $PSScriptRoot -Parent
+$sqlDir = Join-Path $devDir 'sql/apt_rev01'
+$scripts = @('19_it5_columns.sql', '20_it5_permissions.sql')
+
+foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ if (-not [System.IO.File]::Exists($file)) { throw "Missing SQL file: $file" }
+}
+if ($EnvFile -eq '') { $EnvFile = Join-Path $devDir '.env' }
+
+$values = @{}
+if ([System.IO.File]::Exists($EnvFile)) {
+ foreach ($line in [System.IO.File]::ReadAllLines($EnvFile)) {
+ $trimmed = $line.Trim()
+ if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
+ if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { $values[$Matches[1]] = $Matches[2] }
+ }
+ Write-Host "Credentials source: $EnvFile"
+} else {
+ Write-Host "No credentials file at $EnvFile; using APT_DB_* environment variables."
+ foreach ($key in @('APT_DB_HOST','APT_DB_PORT','APT_DB_NAME','APT_DB_USER','APT_DB_PASSWORD')) {
+ $values[$key] = [Environment]::GetEnvironmentVariable($key)
+ }
+}
+
+$dbHost = $values['APT_DB_HOST']; if ([string]::IsNullOrEmpty($dbHost)) { $dbHost = '127.0.0.1' }
+$dbPort = $values['APT_DB_PORT']; if ([string]::IsNullOrEmpty($dbPort)) { $dbPort = '3306' }
+$dbName = $values['APT_DB_NAME']; if ([string]::IsNullOrEmpty($dbName)) { $dbName = 'apt_rev01' }
+$dbUser = $values['APT_DB_USER']; if ([string]::IsNullOrEmpty($dbUser)) { $dbUser = 'iotlocks' }
+
+if ($DryRun) {
+ Write-Host '--- DRY RUN: no database connection, no credentials required ---'
+ Write-Host ("Target database: {0}:{1}/{2} (user {3})" -f $dbHost, $dbPort, $dbName, $dbUser)
+ foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ $lineCount = ([System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)).Count
+ Write-Host (" would apply {0} ({1} lines)" -f $name, $lineCount)
+ }
+ Write-Host 'Validation SELECTs in 19/20 print column, index, permission and menu checks.'
+ Write-Host 'Run 99_verify.sql afterwards for the IT-5 acceptance checks (sections 20~26).'
+ return
+}
+
+if ([string]::IsNullOrEmpty($values['APT_DB_PASSWORD'])) {
+ $hint = @'
+Missing APT_DB_PASSWORD. Provide one of:
+ 1) copy dev/.env.example to dev/.env, fill APT_DB_PASSWORD, then re-run this script
+ 2) set APT_DB_PASSWORD (plus optional APT_DB_HOST/PORT/NAME/USER) in the environment
+Use -DryRun to preview SQL files without connecting to MySQL.
+'@
+ throw $hint
+}
+if (-not [System.IO.File]::Exists($MysqlPath)) { throw "Missing mysql client: $MysqlPath" }
+
+[Environment]::SetEnvironmentVariable('MYSQL_PWD', $values['APT_DB_PASSWORD'], 'Process')
+try {
+ foreach ($name in $scripts) {
+ $file = Join-Path $sqlDir $name
+ Write-Host "--- applying $name ---"
+
+ $arguments = "--host=$dbHost --port=$dbPort --user=$dbUser --database=$dbName" +
+ ' --default-character-set=utf8mb4 --batch'
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = $MysqlPath
+ $psi.Arguments = $arguments
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardInput = $true
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
+
+ $process = [System.Diagnostics.Process]::Start($psi)
+ $bytes = [System.IO.File]::ReadAllBytes($file)
+ $offset = 0
+ if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
+ $offset = 3
+ }
+ $process.StandardInput.BaseStream.Write($bytes, $offset, $bytes.Length - $offset)
+ $process.StandardInput.BaseStream.Flush()
+ $process.StandardInput.Close()
+ $stdout = $process.StandardOutput.ReadToEnd()
+ $stderr = $process.StandardError.ReadToEnd()
+ $process.WaitForExit()
+ if ($stdout) { Write-Host $stdout }
+ if ($stderr) { Write-Host $stderr }
+ if ($process.ExitCode -ne 0) { throw "SQL failed: $name (exit $($process.ExitCode))" }
+ }
+ Write-Host 'IT-5 SQL scripts applied successfully.'
+} finally {
+ [Environment]::SetEnvironmentVariable('MYSQL_PWD', $null, 'Process')
+}
diff --git a/dev/scripts/verify-it2-m1m2.ps1 b/dev/scripts/verify-it2-m1m2.ps1
new file mode 100644
index 0000000..5e97ab4
--- /dev/null
+++ b/dev/scripts/verify-it2-m1m2.ps1
@@ -0,0 +1,373 @@
+<#
+ IT-2 / DV-2 end-to-end verification: M1 route, M2 room, master version markers.
+ Maps to docs/apartment/07 table 7-3 M1/M2 and table 7-7 steps 4/5.
+
+ Prerequisites:
+ 1) dev/.env with APT_DB_PASSWORD; MySQL apt_rev01 and Redis database=1 reachable.
+ 2) dev/scripts/apply-it2-sql.ps1 applied (DDL + Mock seed + permissions).
+ 3) mvn -pl ruoyi-admin -am package built ruoyi-admin/target/ruoyi-admin.jar.
+
+ Usage:
+ powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it2-m1m2.ps1
+ powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it2-m1m2.ps1 -SkipStartup
+
+ Notes: captcha stays enabled and sys_config is unchanged; the captcha answer is
+ read (read-only) from Redis database=1. A short APT_TOKEN_SECRET is replaced
+ by an ephemeral Base64 HS512 key for this run only. The script starts its own
+ jar on -ServerPort (default 8099), leaving any 8080 instance untouched.
+#>
+param(
+ [switch]$SkipStartup,
+ [string]$BaseUrl = 'http://127.0.0.1:8099',
+ [int]$ServerPort = 8099,
+ [string]$JavaPath = '',
+ [string]$AdminPassword = 'admin123',
+ [string]$GuestPassword = 'admin123',
+ [int]$ReadyAttempts = 60
+)
+
+$ErrorActionPreference = 'Stop'
+$devDir = Split-Path $PSScriptRoot -Parent
+$backendDir = Join-Path $devDir 'RuoYi-Vue-master'
+$jar = Join-Path $backendDir 'ruoyi-admin/target/ruoyi-admin.jar'
+$results = @()
+
+# ---------- 1. Credentials ----------
+$envFile = Join-Path $PSScriptRoot '.env'
+if (-not [System.IO.File]::Exists($envFile)) { $envFile = Join-Path $devDir '.env' }
+if (-not [System.IO.File]::Exists($envFile)) { throw "Missing credentials file: $envFile" }
+Write-Host "Using credentials file: $envFile"
+foreach ($line in [System.IO.File]::ReadAllLines($envFile)) {
+ $trimmed = $line.Trim()
+ if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
+ if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') {
+ [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process')
+ }
+}
+foreach ($name in @('APT_DB_PASSWORD', 'APT_TOKEN_SECRET')) {
+ if ([string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable($name, 'Process'))) {
+ throw "Missing required environment variable $name"
+ }
+}
+$secretValue = [Environment]::GetEnvironmentVariable('APT_TOKEN_SECRET', 'Process')
+$secretValid = $false
+try {
+ $decoded = [System.Convert]::FromBase64String($secretValue)
+ if ($decoded.Length -ge 64) { $secretValid = $true }
+}
+catch { $secretValid = $false }
+if (-not $secretValid) {
+ $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider
+ $keyBytes = New-Object byte[] 64
+ $rng.GetBytes($keyBytes)
+ $rng.Dispose()
+ [Environment]::SetEnvironmentVariable('APT_TOKEN_SECRET', [System.Convert]::ToBase64String($keyBytes), 'Process')
+ Write-Host 'APT_TOKEN_SECRET was not a valid Base64 HS512 key; generated an ephemeral one for this run.'
+}
+
+# ---------- 2. Redis RESP read-only helper (captcha answer) ----------
+function Send-RedisCommand {
+ param([System.Net.Sockets.NetworkStream]$Stream, [string[]]$Parts)
+ $sb = [System.Text.StringBuilder]::new()
+ [void]$sb.Append('*').Append($Parts.Count).Append("`r`n")
+ foreach ($p in $Parts) {
+ $n = [System.Text.Encoding]::UTF8.GetByteCount($p)
+ [void]$sb.Append('$').Append($n).Append("`r`n").Append($p).Append("`r`n")
+ }
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($sb.ToString())
+ $Stream.Write($bytes, 0, $bytes.Length)
+ $Stream.Flush()
+}
+function Read-RedisLine {
+ param([System.Net.Sockets.NetworkStream]$Stream)
+ $sb = [System.Text.StringBuilder]::new()
+ while ($true) {
+ $b = $Stream.ReadByte()
+ if ($b -lt 0) { throw 'Redis connection closed unexpectedly' }
+ if ($b -eq 13) { [void]$Stream.ReadByte(); break }
+ [void]$sb.Append([char]$b)
+ }
+ return $sb.ToString()
+}
+function Read-RedisBulk {
+ param([System.Net.Sockets.NetworkStream]$Stream)
+ $line = Read-RedisLine -Stream $Stream
+ if (-not $line.StartsWith('$')) { throw "Redis reply is not a bulk string: $line" }
+ $len = [int]$line.Substring(1)
+ if ($len -lt 0) { return $null }
+ $buf = [byte[]]::new($len)
+ $read = 0
+ while ($read -lt $len) {
+ $n = $Stream.Read($buf, $read, $len - $read)
+ if ($n -le 0) { throw 'Incomplete Redis reply' }
+ $read += $n
+ }
+ [void]$Stream.ReadByte()
+ [void]$Stream.ReadByte()
+ return [System.Text.Encoding]::UTF8.GetString($buf)
+}
+function Get-CaptchaCode {
+ param([string]$Uuid)
+ $tcp = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 6379)
+ try {
+ $stream = $tcp.GetStream()
+ $stream.ReadTimeout = 3000
+ Send-RedisCommand -Stream $stream -Parts @('SELECT', '1')
+ [void](Read-RedisLine -Stream $stream)
+ Send-RedisCommand -Stream $stream -Parts @('GET', ('captcha_codes:' + $Uuid))
+ $value = Read-RedisBulk -Stream $stream
+ if ($null -eq $value) { throw 'Captcha answer missing in Redis database=1' }
+ return $value.Trim('"')
+ }
+ finally { $tcp.Dispose() }
+}
+
+# ---------- 3. HTTP helpers ----------
+function Invoke-Api {
+ param([string]$Path, [string]$Token = '', [string]$Method = 'Get', [hashtable]$Body = $null)
+ $headers = @{}
+ if ($Token -ne '') { $headers['Authorization'] = 'Bearer ' + $Token }
+ $params = @{
+ Uri = ($BaseUrl + $Path); Method = $Method; Headers = $headers
+ UseBasicParsing = $true; TimeoutSec = 20
+ }
+ if ($null -ne $Body) {
+ $params['ContentType'] = 'application/json;charset=UTF-8'
+ $params['Body'] = [System.Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress))
+ }
+ try {
+ $res = Invoke-WebRequest @params
+ return @{ status = [int]$res.StatusCode; raw = $res.Content; body = ($res.Content | ConvertFrom-Json) }
+ }
+ catch {
+ $response = $_.Exception.Response
+ if ($null -eq $response) { throw }
+ $content = $_.ErrorDetails.Message
+ if ([string]::IsNullOrEmpty($content)) {
+ $reader = [System.IO.StreamReader]::new($response.GetResponseStream())
+ $content = $reader.ReadToEnd()
+ }
+ if ([string]::IsNullOrEmpty($content)) { $content = '{}' }
+ return @{ status = [int]$response.StatusCode; raw = $content; body = ($content | ConvertFrom-Json) }
+ }
+}
+function Get-Token {
+ param([string]$User, [string]$Password)
+ $captcha = Invoke-RestMethod -Uri ($BaseUrl + '/captchaImage') -Method Get -TimeoutSec 10
+ $body = @{ username = $User; password = $Password }
+ if ($captcha.captchaEnabled) {
+ $body.code = Get-CaptchaCode -Uuid $captcha.uuid
+ $body.uuid = $captcha.uuid
+ }
+ $resp = Invoke-RestMethod -Uri ($BaseUrl + '/login') -Method Post `
+ -ContentType 'application/json;charset=UTF-8' -Body ($body | ConvertTo-Json -Compress) -TimeoutSec 15
+ if ($resp.code -ne 200) { throw "Login failed [$User]: $($resp.msg)" }
+ return $resp.token
+}
+function Add-Result {
+ param([string]$Case, [string]$Expected, [string]$Actual, [bool]$Pass)
+ $script:results += [pscustomobject]@{
+ Case = $Case; Expected = $Expected; Actual = $Actual; Pass = $Pass
+ }
+}
+
+# ---------- 4. Startup and readiness ----------
+$proc = $null
+if (-not $SkipStartup) {
+ if (-not [System.IO.File]::Exists($jar)) { throw "Missing $jar - run mvn package first" }
+ if ($JavaPath -eq '') {
+ $javaHome = [Environment]::GetEnvironmentVariable('JAVA_HOME', 'Process')
+ if ([string]::IsNullOrEmpty($javaHome)) { $JavaPath = 'java' }
+ else { $JavaPath = Join-Path $javaHome 'bin/java.exe' }
+ }
+ $outLog = Join-Path $env:TEMP 'apt-it2-stdout.log'
+ $errLog = Join-Path $env:TEMP 'apt-it2-stderr.log'
+ $proc = Start-Process -FilePath $JavaPath -ArgumentList @('-jar', ('"' + $jar + '"'), ('--server.port=' + $ServerPort)) `
+ -PassThru -WindowStyle Hidden -RedirectStandardOutput $outLog -RedirectStandardError $errLog
+ Write-Host "Started application PID=$($proc.Id) on port $ServerPort, stderr log: $errLog"
+}
+$ready = $false
+for ($i = 0; $i -lt $ReadyAttempts; $i++) {
+ try {
+ $probe = Invoke-WebRequest -Uri ($BaseUrl + '/apt/ops/connectivity') -UseBasicParsing -TimeoutSec 3
+ if ([int]$probe.StatusCode -eq 200) { $ready = $true; break }
+ }
+ catch { Start-Sleep -Seconds 2 }
+}
+if (-not $ready) { throw 'Application not ready - check stderr log' }
+
+# ---------- 5. M0 regression + M1 + M2 + version checks ----------
+try {
+ $admin = Get-Token -User 'admin' -Password $AdminPassword
+ $guest = Get-Token -User 'apt_guest' -Password $GuestPassword
+
+ $r = Invoke-Api -Path '/apt/route/list'
+ Add-Result 'M0-1 no token' 'HTTP 401 / 40101' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 401 -and $r.body.code -eq 40101)
+
+ $r = Invoke-Api -Path '/apt/route/list' -Token $guest
+ Add-Result 'M0-2 no permission' 'HTTP 403 / 40301' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 403 -and $r.body.code -eq 40301)
+
+ $r = Invoke-Api -Path '/apt/room/list?gyid=APT-B' -Token $admin
+ Add-Result 'M0-3 cross gyid' 'HTTP 403 / 40302' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 403 -and $r.body.code -eq 40302 -and $r.raw -notmatch '"rows"')
+
+ $r = Invoke-Api -Path '/apt/room/list?pageSize=1000' -Token $admin
+ Add-Result 'M0-4 page cap' 'HTTP 200 / pageSize=100' "HTTP $($r.status) / pageSize=$($r.body.pageSize)" `
+ ($r.status -eq 200 -and $r.body.pageSize -eq 100)
+
+ # apt.* / springdoc group-configs now live in ruoyi-apt/apt-defaults.yml via spring.config.import
+ $doc = Invoke-Api -Path '/v3/api-docs/apt'
+ $aptPaths = @()
+ if ($doc.status -eq 200 -and $null -ne $doc.body.paths) {
+ $aptPaths = @($doc.body.paths.PSObject.Properties.Name | Where-Object { $_ -like '/apt/*' })
+ }
+ Add-Result 'M0-5 apt config + springdoc group' 'HTTP 200 with /apt/* paths' `
+ "HTTP $($doc.status) / aptPaths=$($aptPaths.Count)" `
+ ($doc.status -eq 200 -and $aptPaths.Count -gt 0)
+
+ $routeList = Invoke-Api -Path '/apt/route/list' -Token $admin
+ $routeRows = @($routeList.body.rows)
+ $routeRow = $routeRows | Where-Object { $_.jlid -eq 'JL-001' } | Select-Object -First 1
+ if ($null -eq $routeRow) { throw 'Mock route JL-001 missing - run apply-it2-sql.ps1' }
+ $routeId = $routeRow.id
+ Add-Result 'M1-1 list + mapping' 'HTTP 200, >=3 rows, jlid/jch mapped' `
+ "HTTP $($routeList.status) / rows=$($routeRows.Count) / jch=$($routeRow.jch)" `
+ ($routeList.status -eq 200 -and $routeRows.Count -ge 3 -and $routeRow.jch)
+
+ $r = Invoke-Api -Path "/apt/route/$routeId" -Token $admin
+ Add-Result 'M1-2 detail' 'HTTP 200 / jlid=JL-001' "HTTP $($r.status) / $($r.body.data.jlid)" `
+ ($r.status -eq 200 -and $r.body.data.jlid -eq 'JL-001')
+
+ $now = Get-Date
+ $dupBody = @{
+ jlid = 'JL-001'; cc = 'G101'
+ kcsj = $now.AddHours(8).ToString('yyyy-MM-dd HH:mm:ss')
+ jcsj = $now.AddHours(6).ToString('yyyy-MM-dd HH:mm:ss')
+ lysj = $now.AddHours(5).ToString('yyyy-MM-dd HH:mm:ss')
+ jch = 'e2e'; qd = 'e2e'; zd = 'e2e'
+ }
+ $r = Invoke-Api -Path '/apt/route' -Token $admin -Method Post -Body $dupBody
+ Add-Result 'M1-3 duplicate jlid' 'HTTP 409 / 40901' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 409 -and $r.body.code -eq 40901)
+
+ $r = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put -Body @{ enabled = $false }
+ Add-Result 'M1-4 missing reason' 'HTTP 400 / 40001' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 400 -and $r.body.code -eq 40001)
+
+ $r = Invoke-Api -Path "/apt/route/$routeId/recalc-check" -Token $admin -Method Post
+ Add-Result 'M1-5 recalc-check' 'HTTP 200 / computedJcsj present' `
+ "HTTP $($r.status) / computedJcsj=$($r.body.data.computedJcsj)" `
+ ($r.status -eq 200 -and $r.body.data.computedJcsj)
+
+ $off = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put `
+ -Body @{ enabled = $false; reason = 'IT-2 e2e disable' }
+ $on = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put `
+ -Body @{ enabled = $true; reason = 'IT-2 e2e restore' }
+ Add-Result 'M1-6 status with reason' 'HTTP 200 / 200' "HTTP $($off.status) / $($on.status)" `
+ ($off.status -eq 200 -and $on.status -eq 200)
+
+ $roomList = Invoke-Api -Path '/apt/room/list' -Token $admin
+ $roomRows = @($roomList.body.rows)
+ $roomRow = $roomRows | Where-Object { $_.roomNo -eq '201' } | Select-Object -First 1
+ if ($null -eq $roomRow) { throw 'Mock room 201 missing - run apply-it2-sql.ps1' }
+ $roomId = $roomRow.id
+ Add-Result 'M2-1 room list' 'HTTP 200, >=5 rows' "HTTP $($roomList.status) / rows=$($roomRows.Count)" `
+ ($roomList.status -eq 200 -and $roomRows.Count -ge 5)
+
+ $r = Invoke-Api -Path '/apt/room/list?roomStatus=4' -Token $admin
+ $filtered = @($r.body.rows)
+ $allFour = $true
+ foreach ($row in $filtered) { if ($row.roomStatus -ne '4') { $allFour = $false } }
+ Add-Result 'M2-2 status filter' 'only roomStatus=4' `
+ "HTTP $($r.status) / rows=$($filtered.Count) / allFour=$allFour" `
+ ($r.status -eq 200 -and $filtered.Count -ge 1 -and $allFour)
+
+ $r = Invoke-Api -Path '/apt/room/status-summary' -Token $admin
+ Add-Result 'M2-3 summary' 'HTTP 200 / total>=5' "HTTP $($r.status) / total=$($r.body.data.total)" `
+ ($r.status -eq 200 -and $r.body.data.total -ge 5)
+
+ $r = Invoke-Api -Path "/apt/room/$roomId" -Token $admin
+ $hasLists = $r.body.data.PSObject.Properties.Name -contains 'bindings'
+ Add-Result 'M2-4 detail' 'HTTP 200 / roomNo=201 / bindings present' `
+ "HTTP $($r.status) / roomNo=$($r.body.data.roomNo) / lists=$hasLists" `
+ ($r.status -eq 200 -and $r.body.data.roomNo -eq '201' -and $hasLists)
+
+ $r = Invoke-Api -Path "/apt/room/$roomId" -Token $admin -Method Put `
+ -Body @{ roomType = 'DOUBLE'; roomFloor = '3' }
+ Add-Result 'M2-5 snapshot readonly' 'HTTP 409 / 40904' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 409 -and $r.body.code -eq 40904)
+
+ $r = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body @{ action = 'DISABLE' }
+ Add-Result 'M2-6 missing reason' 'HTTP 400 / 40001' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 400 -and $r.body.code -eq 40001)
+
+ # Make the disable/restore sequence replayable even after an interrupted run.
+ $before = Invoke-Api -Path "/apt/room/$roomId" -Token $admin
+ if ($before.body.data.roomStatus -eq '4') {
+ $reset = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put `
+ -Body @{ action = 'RESTORE'; reason = 'IT-2 e2e precondition reset';
+ commandId = ('IT2R' + [guid]::NewGuid().ToString('N')) }
+ if ($reset.status -ne 200) { throw "Precondition reset failed: $($reset.raw)" }
+ $before = Invoke-Api -Path "/apt/room/$roomId" -Token $admin
+ }
+ $beforeVersion = [long]$before.body.data.stateVersion
+ $cmdId = 'IT2D' + [guid]::NewGuid().ToString('N')
+ $disableBody = @{ action = 'DISABLE'; reason = 'IT-2 e2e disable'; commandId = $cmdId }
+
+ $disable = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body $disableBody
+ $v1 = $disable.body.data.stateVersion
+ Add-Result 'M2-7 disable' 'status=4 / version+1' `
+ "HTTP $($disable.status) / status=$($disable.body.data.roomStatus) / v=$beforeVersion->$v1" `
+ ($disable.status -eq 200 -and $disable.body.data.roomStatus -eq '4' -and [long]$v1 -eq ($beforeVersion + 1))
+
+ $dup = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body $disableBody
+ Add-Result 'M2-8 commandId idempotent' 'duplicated=true / version unchanged' `
+ "HTTP $($dup.status) / duplicated=$($dup.body.data.duplicated) / v=$($dup.body.data.stateVersion)" `
+ ($dup.status -eq 200 -and $dup.body.data.duplicated -eq $true -and [long]$dup.body.data.stateVersion -eq [long]$v1)
+
+ $after = Invoke-Api -Path "/apt/room/$roomId" -Token $admin
+ $livePersonsPresent = $after.body.data.PSObject.Properties.Name -contains 'livePersons'
+ Add-Result 'M2-9 disabled still queryable' 'status=4 / livePersons present' `
+ "HTTP $($after.status) / status=$($after.body.data.roomStatus) / livePersons=$livePersonsPresent" `
+ ($after.status -eq 200 -and $after.body.data.roomStatus -eq '4' -and $livePersonsPresent)
+
+ $restore = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put `
+ -Body @{ action = 'RESTORE'; reason = 'IT-2 e2e restore';
+ commandId = ('IT2R' + [guid]::NewGuid().ToString('N')) }
+ Add-Result 'M2-10 restore recalc' 'status<>4 / version+1 again' `
+ "HTTP $($restore.status) / status=$($restore.body.data.roomStatus) / v=$($restore.body.data.stateVersion)" `
+ ($restore.status -eq 200 -and $restore.body.data.roomStatus -ne '4' `
+ -and [long]$restore.body.data.stateVersion -eq ($beforeVersion + 2))
+
+ $r = Invoke-Api -Path '/apt/room' -Token $admin -Method Post -Body @{}
+ Add-Result 'M2-11 add disabled' 'HTTP 409 / 40940' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 409 -and $r.body.code -eq 40940)
+
+ $versions = Invoke-Api -Path '/apt/master/versions' -Token $admin
+ $vdata = @($versions.body.data)
+ $allLocal = $true
+ foreach ($v in $vdata) {
+ if ($v.source -ne 'LOCAL' -or $v.sourceVersion -ne 0 -or $v.localMock -ne $true) { $allLocal = $false }
+ }
+ Add-Result 'M2-12 version markers' '6 domains LOCAL/0/mock=true' `
+ "HTTP $($versions.status) / count=$($vdata.Count) / allLocal=$allLocal / notice=$($versions.body.mockNotice)" `
+ ($versions.status -eq 200 -and $vdata.Count -eq 6 -and $allLocal -and $versions.body.mockNotice)
+
+ $r = Invoke-Api -Path '/apt/master/ROUTE/resync' -Token $admin -Method Post -Body @{}
+ Add-Result 'M2-13 resync disabled' 'HTTP 409 / 40940' "HTTP $($r.status) / $($r.body.code)" `
+ ($r.status -eq 409 -and $r.body.code -eq 40940)
+}
+finally {
+ if ($null -ne $proc -and -not $proc.HasExited) {
+ Stop-Process -Id $proc.Id -Force
+ Write-Host "Stopped application PID=$($proc.Id)"
+ }
+}
+
+$results | Format-Table -AutoSize
+$failed = @($results | Where-Object { -not $_.Pass })
+Write-Host "IT-2 checks passed: $($results.Count - $failed.Count)/$($results.Count)"
+if ($failed.Count -gt 0) { exit 1 }
+exit 0