Bläddra i källkod

fix(wm-revenue+wm-bpm): #6 修复DDL方言不一致、表名映射错误、BPM内存存储、缺测试

修复内容:
1. V1__base_tables.sql: 修复重复列(install_date/photos)和MySQL语法,统一为PostgreSQL;新增rev_audit_log/rev_app_registry/installation_apply表定义
2. V3__sso_tables.sql: 修复MySQL语法(UNIQUE KEY/KEY idx→PostgreSQL CREATE INDEX UNIQUE),统一PG方言
3. installation_apply.sql: 原为MySQL方言(ENGINE=InnoDB/AUTO_INCREMENT),标注为非Flyway migration避免冲突
4. InstallationOverviewService: 修复表名rev_installation→installation_apply(不存在的表),MySQL日期函数→PostgreSQL(TO_CHAR/EXTRACT),状态值与InstallationStatus枚举对齐
5. InstallationStatus: 新增TERMINATED状态(报装终止)
6. ProcessEngine: 从内存存储(ConcurrentHashMap/ArrayList)改为数据库(MyBatis-Plus Mapper),支持生产环境
7. BpmProcessInstanceMapper: 新增selectByInstanceId/selectTodoByAssigneeId方法
8. 新增BPM DDL(V1__bpm_tables.sql): 定义7张表+报装流程初始数据
9. 新增5个单元测试: InstallationServiceTest(8), InstallationOverviewServiceTest(5), RevAuditServiceTest(5), AppAccessServiceTest(7), ProcessEngineTest(9)

已覆盖需求: REV-01~05, INS-01~08
xieke 2 dagar sedan
förälder
incheckning
9100527035

+ 12
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessInstanceMapper.java Visa fil

@@ -40,4 +40,16 @@ public interface BpmProcessInstanceMapper extends BaseMapper<BpmProcessInstance>
40 40
      */
41 41
     @Select("SELECT * FROM bpm_process_instance WHERE initiator_id = #{userId} AND deleted = 0 ORDER BY created_at DESC")
42 42
     List<BpmProcessInstance> selectMyInitiated(@Param("userId") Long userId);
43
+
44
+    /**
45
+     * 根据instanceId(UUID)查询实例
46
+     */
47
+    @Select("SELECT * FROM bpm_process_instance WHERE instance_id = #{instanceId} AND deleted = 0")
48
+    BpmProcessInstance selectByInstanceId(@Param("instanceId") String instanceId);
49
+
50
+    /**
51
+     * 查询待办(分配给指定人的运行中流程)
52
+     */
53
+    @Select("SELECT * FROM bpm_process_instance WHERE current_assignee_id = #{userId} AND status = 'running' AND deleted = 0 ORDER BY created_at DESC")
54
+    List<BpmProcessInstance> selectTodoByAssigneeId(@Param("userId") Long userId);
43 55
 }

+ 78
- 42
wm-bpm/src/main/java/com/water/bpm/service/ProcessEngine.java Visa fil

@@ -1,56 +1,66 @@
1 1
 package com.water.bpm.service;
2 2
 
3
-import com.water.bpm.entity.*;
3
+import com.water.bpm.entity.BpmApprovalRecord;
4
+import com.water.bpm.entity.BpmProcessInstance;
5
+import com.water.bpm.mapper.BpmApprovalRecordMapper;
6
+import com.water.bpm.mapper.BpmProcessInstanceMapper;
4 7
 import lombok.RequiredArgsConstructor;
5 8
 import lombok.extern.slf4j.Slf4j;
6 9
 import org.springframework.stereotype.Service;
7 10
 import org.springframework.transaction.annotation.Transactional;
8 11
 
12
+import java.time.LocalDateTime;
9 13
 import java.util.*;
10
-import java.util.concurrent.ConcurrentHashMap;
11 14
 
12 15
 @Slf4j
13 16
 @Service
14 17
 @RequiredArgsConstructor
15 18
 public class ProcessEngine {
16 19
 
17
-    // 简化流程引擎:模拟 Camunda/Flowable 核心功能
18
-    private final Map<String, BpmProcessInstance> instances = new ConcurrentHashMap<>();
19
-    private final List<BpmApprovalRecord> approvalRecords = new ArrayList<>();
20
+    private final BpmProcessInstanceMapper instanceMapper;
21
+    private final BpmApprovalRecordMapper approvalRecordMapper;
20 22
 
21
-    /** 创建流程实例 */
23
+    /**
24
+     * 创建流程实例 (INS-06 流程管理)
25
+     */
22 26
     @Transactional
23
-    public BpmProcessInstance startProcess(BpmProcessDefinition definition, Long initiatorId,
24
-                                            String initiatorName, String businessKey,
25
-                                            Map<String, Object> formData) {
27
+    public BpmProcessInstance startProcess(String processKey, String processName,
28
+                                            Long initiatorId, String initiatorName,
29
+                                            String businessKey, String businessType,
30
+                                            String formData) {
26 31
         BpmProcessInstance instance = new BpmProcessInstance();
27
-        instance.setId(System.currentTimeMillis());
28 32
         instance.setInstanceId(UUID.randomUUID().toString());
29
-        instance.setDefinitionId(definition.getId());
30
-        instance.setProcessKey(definition.getProcessKey());
31
-        instance.setTitle(definition.getProcessName());
33
+        instance.setProcessKey(processKey);
34
+        instance.setProcessName(processName);
32 35
         instance.setBusinessKey(businessKey);
36
+        instance.setBusinessType(businessType);
37
+        instance.setTitle(processName + " - " + businessKey);
33 38
         instance.setInitiatorId(initiatorId);
34 39
         instance.setInitiatorName(initiatorName);
35 40
         instance.setStatus("running");
36
-        instance.setCurrentNode("START");
41
+        instance.setCurrentNodeId("START");
42
+        instance.setCurrentNodeName("开始");
37 43
         instance.setFormData(formData);
38
-        instance.setStartedAt(java.time.LocalDateTime.now());
39
-        instance.setCreatedAt(java.time.LocalDateTime.now());
40
-        instances.put(instance.getInstanceId(), instance);
41
-        log.info("Process started: {} - {}", instance.getProcessKey(), instance.getTitle());
44
+        instance.setStartedAt(LocalDateTime.now());
45
+        instance.setCreatedAt(LocalDateTime.now());
46
+        instanceMapper.insert(instance);
47
+
48
+        log.info("Process started: {} - {} (instanceId={})", processKey, businessKey, instance.getInstanceId());
42 49
         return instance;
43 50
     }
44 51
 
45
-    /** 审批节点 */
52
+    /**
53
+     * 审批节点 (INS-06 流程审批)
54
+     */
46 55
     @Transactional
47
-    public BpmProcessInstance approve(String instanceId, Long approverId, String approverName,
48
-                                        String nodeId, String nodeName,
49
-                                        String action, String comment) {
50
-        BpmProcessInstance instance = instances.get(instanceId);
51
-        if (instance == null) throw new RuntimeException("流程实例不存在");
56
+    public BpmProcessInstance approve(String instanceUuid, Long approverId, String approverName,
57
+                                      String nodeId, String nodeName,
58
+                                      String action, String comment) {
59
+        BpmProcessInstance instance = instanceMapper.selectByInstanceId(instanceUuid);
60
+        if (instance == null) throw new RuntimeException("流程实例不存在: " + instanceUuid);
52 61
 
53 62
         BpmApprovalRecord record = new BpmApprovalRecord();
63
+        record.setInstanceUuid(instanceUuid);
54 64
         record.setInstanceId(instance.getId());
55 65
         record.setNodeId(nodeId);
56 66
         record.setNodeName(nodeName);
@@ -58,38 +68,64 @@ public class ProcessEngine {
58 68
         record.setApproverName(approverName);
59 69
         record.setAction(action);
60 70
         record.setComment(comment);
61
-        record.setApprovedAt(java.time.LocalDateTime.now());
62
-        approvalRecords.add(record);
71
+        record.setApprovedAt(LocalDateTime.now());
72
+        record.setCreatedAt(LocalDateTime.now());
73
+        approvalRecordMapper.insert(record);
63 74
 
64
-        instance.setCurrentNode(nodeName);
75
+        instance.setCurrentNodeId(nodeId);
76
+        instance.setCurrentNodeName(nodeName);
65 77
         switch (action) {
66
-            case "approve": instance.setStatus("running"); break;
67
-            case "reject": instance.setStatus("rejected"); instance.setCompletedAt(java.time.LocalDateTime.now()); break;
68
-            default: instance.setStatus("running");
78
+            case "approve":
79
+                instance.setStatus("running");
80
+                instance.setCurrentAssigneeId(approverId);
81
+                instance.setCurrentAssigneeName(approverName);
82
+                break;
83
+            case "reject":
84
+                instance.setStatus("rejected");
85
+                instance.setCompletedAt(LocalDateTime.now());
86
+                break;
87
+            case "terminate":
88
+                instance.setStatus("terminated");
89
+                instance.setCompletedAt(LocalDateTime.now());
90
+                break;
91
+            default:
92
+                instance.setStatus("running");
69 93
         }
70
-        log.info("Approval: {} - {}: {}", instanceId, action, comment);
94
+        instance.setUpdatedAt(LocalDateTime.now());
95
+        instanceMapper.updateById(instance);
96
+
97
+        log.info("Approval: {} - {}: {} (comment={})", instanceUuid, action, nodeName, comment);
71 98
         return instance;
72 99
     }
73 100
 
74
-    /** 完成流程 */
101
+    /**
102
+     * 完成流程
103
+     */
75 104
     @Transactional
76
-    public void completeProcess(String instanceId) {
77
-        BpmProcessInstance instance = instances.get(instanceId);
105
+    public void completeProcess(String instanceUuid) {
106
+        BpmProcessInstance instance = instanceMapper.selectByInstanceId(instanceUuid);
78 107
         if (instance != null) {
79 108
             instance.setStatus("completed");
80
-            instance.setCompletedAt(java.time.LocalDateTime.now());
109
+            instance.setCurrentNodeId("END");
110
+            instance.setCurrentNodeName("结束");
111
+            instance.setCompletedAt(LocalDateTime.now());
112
+            instance.setUpdatedAt(LocalDateTime.now());
113
+            instanceMapper.updateById(instance);
114
+            log.info("Process completed: {}", instanceUuid);
81 115
         }
82 116
     }
83 117
 
84
-    /** 查询待办 */
118
+    /**
119
+     * 查询待办 (INS-05 任务管理)
120
+     */
85 121
     public List<BpmProcessInstance> getTodoList(Long userId) {
86
-        return instances.values().stream()
87
-                .filter(i -> "running".equals(i.getStatus()) && i.getInitiatorId().equals(userId))
88
-                .toList();
122
+        return instanceMapper.selectTodoByAssigneeId(userId);
89 123
     }
90 124
 
91
-    /** 查询流程实例 */
92
-    public BpmProcessInstance getInstance(String instanceId) {
93
-        return instances.get(instanceId);
125
+    /**
126
+     * 查询流程实例
127
+     */
128
+    public BpmProcessInstance getInstance(String instanceUuid) {
129
+        return instanceMapper.selectByInstanceId(instanceUuid);
94 130
     }
95 131
 }

+ 169
- 0
wm-bpm/src/main/resources/db/V1__bpm_tables.sql Visa fil

@@ -0,0 +1,169 @@
1
+-- =============================================
2
+-- BPM 流程引擎表结构 (PostgreSQL)
3
+-- 对应需求: INS-06 流程管理
4
+-- =============================================
5
+
6
+-- 流程定义表
7
+CREATE TABLE IF NOT EXISTS bpm_process_definition (
8
+    id BIGSERIAL PRIMARY KEY,
9
+    process_key VARCHAR(100) NOT NULL UNIQUE,
10
+    process_name VARCHAR(200) NOT NULL,
11
+    version INT DEFAULT 1,
12
+    description TEXT,
13
+    status VARCHAR(20) DEFAULT 'active',     -- active/deprecated
14
+    form_template_id BIGINT,
15
+    created_at TIMESTAMP DEFAULT NOW(),
16
+    updated_at TIMESTAMP DEFAULT NOW(),
17
+    deleted INT DEFAULT 0
18
+);
19
+COMMENT ON TABLE bpm_process_definition IS '流程定义表(INS-06)';
20
+
21
+-- 流程实例表 (INS-06 流程管理)
22
+CREATE TABLE IF NOT EXISTS bpm_process_instance (
23
+    id BIGSERIAL PRIMARY KEY,
24
+    instance_id VARCHAR(100) NOT NULL UNIQUE,
25
+    definition_id BIGINT,
26
+    process_key VARCHAR(100),
27
+    process_name VARCHAR(200),
28
+    business_key VARCHAR(100),
29
+    business_type VARCHAR(50),
30
+    title VARCHAR(300),
31
+    initiator_id BIGINT,
32
+    initiator_name VARCHAR(100),
33
+    current_node_id VARCHAR(100),
34
+    current_node_name VARCHAR(200),
35
+    current_assignee_id BIGINT,
36
+    current_assignee_name VARCHAR(100),
37
+    status VARCHAR(20) DEFAULT 'running',     -- running/completed/terminated/rejected/suspended
38
+    priority INT DEFAULT 0,                    -- 0-普通 1-紧急 2-特急
39
+    variables TEXT,                            -- JSON流程变量
40
+    form_data TEXT,                            -- JSON表单数据
41
+    started_at TIMESTAMP,
42
+    completed_at TIMESTAMP,
43
+    expected_completion_at TIMESTAMP,
44
+    duration_seconds BIGINT,
45
+    tenant_id VARCHAR(50),
46
+    created_at TIMESTAMP DEFAULT NOW(),
47
+    updated_at TIMESTAMP DEFAULT NOW(),
48
+    deleted INT DEFAULT 0
49
+);
50
+COMMENT ON TABLE bpm_process_instance IS '流程实例表(INS-06)';
51
+CREATE INDEX IF NOT EXISTS idx_bpm_inst_key ON bpm_process_instance(process_key);
52
+CREATE INDEX IF NOT EXISTS idx_bpm_inst_status ON bpm_process_instance(status);
53
+CREATE INDEX IF NOT EXISTS idx_bpm_inst_business ON bpm_process_instance(business_key);
54
+CREATE INDEX IF NOT EXISTS idx_bpm_inst_assignee ON bpm_process_instance(current_assignee_id, status);
55
+
56
+-- 流程节点表
57
+CREATE TABLE IF NOT EXISTS bpm_process_node (
58
+    id BIGSERIAL PRIMARY KEY,
59
+    definition_id BIGINT,
60
+    node_id VARCHAR(100) NOT NULL,
61
+    node_name VARCHAR(200) NOT NULL,
62
+    node_type VARCHAR(30) NOT NULL,            -- start/end/user_task/service_task/gateway/parallel
63
+    assignee_type VARCHAR(30),                 -- fixed/role/dept/initiator
64
+    assignee_ids TEXT,                         -- JSON数组
65
+    multi_instance_type VARCHAR(20),           -- none/sequential/parallel
66
+    form_key VARCHAR(100),
67
+    description TEXT,
68
+    created_at TIMESTAMP DEFAULT NOW(),
69
+    updated_at TIMESTAMP DEFAULT NOW(),
70
+    deleted INT DEFAULT 0
71
+);
72
+COMMENT ON TABLE bpm_process_node IS '流程节点表';
73
+
74
+-- 审批记录表 (INS-06)
75
+CREATE TABLE IF NOT EXISTS bpm_approval_record (
76
+    id BIGSERIAL PRIMARY KEY,
77
+    instance_id BIGINT,
78
+    instance_uuid VARCHAR(100),
79
+    node_id VARCHAR(100),
80
+    node_name VARCHAR(200),
81
+    approver_id BIGINT,
82
+    approver_name VARCHAR(100),
83
+    action VARCHAR(30) NOT NULL,               -- approve/reject/transfer/delegate/back/countersign/terminate
84
+    comment TEXT,
85
+    target_assignee_id BIGINT,
86
+    target_assignee_name VARCHAR(100),
87
+    countersign_result VARCHAR(20),
88
+    countersign_approved INT,
89
+    countersign_total INT,
90
+    approved_at TIMESTAMP,
91
+    tenant_id VARCHAR(50),
92
+    created_at TIMESTAMP DEFAULT NOW(),
93
+    updated_at TIMESTAMP DEFAULT NOW(),
94
+    deleted INT DEFAULT 0
95
+);
96
+COMMENT ON TABLE bpm_approval_record IS '审批记录表(INS-06)';
97
+CREATE INDEX IF NOT EXISTS idx_bpm_approval_inst ON bpm_approval_record(instance_uuid);
98
+CREATE INDEX IF NOT EXISTS idx_bpm_approval_approver ON bpm_approval_record(approver_id);
99
+
100
+-- 待办任务表 (INS-05)
101
+CREATE TABLE IF NOT EXISTS bpm_todo_task (
102
+    id BIGSERIAL PRIMARY KEY,
103
+    instance_id BIGINT,
104
+    instance_uuid VARCHAR(100),
105
+    node_id VARCHAR(100),
106
+    node_name VARCHAR(200),
107
+    assignee_id BIGINT,
108
+    assignee_name VARCHAR(100),
109
+    status VARCHAR(20) DEFAULT 'pending',      -- pending/completed/cancelled
110
+    priority INT DEFAULT 0,
111
+    due_date TIMESTAMP,
112
+    form_data TEXT,
113
+    created_at TIMESTAMP DEFAULT NOW(),
114
+    updated_at TIMESTAMP DEFAULT NOW(),
115
+    deleted INT DEFAULT 0
116
+);
117
+COMMENT ON TABLE bpm_todo_task IS '待办任务表(INS-05)';
118
+CREATE INDEX IF NOT EXISTS idx_todo_assignee ON bpm_todo_task(assignee_id, status);
119
+CREATE INDEX IF NOT EXISTS idx_todo_instance ON bpm_todo_task(instance_uuid);
120
+
121
+-- 流程编排表 (INS-06 流程管理)
122
+CREATE TABLE IF NOT EXISTS bpm_orchestration (
123
+    id BIGSERIAL PRIMARY KEY,
124
+    definition_id BIGINT,
125
+    from_node_id VARCHAR(100) NOT NULL,
126
+    to_node_id VARCHAR(100) NOT NULL,
127
+    condition_expression TEXT,
128
+    priority INT DEFAULT 0,
129
+    created_at TIMESTAMP DEFAULT NOW(),
130
+    updated_at TIMESTAMP DEFAULT NOW(),
131
+    deleted INT DEFAULT 0
132
+);
133
+COMMENT ON TABLE bpm_orchestration IS '流程编排表(INS-06)';
134
+CREATE INDEX IF NOT EXISTS idx_bpm_orch_def ON bpm_orchestration(definition_id);
135
+
136
+-- 表单模板表
137
+CREATE TABLE IF NOT EXISTS bpm_form_template (
138
+    id BIGSERIAL PRIMARY KEY,
139
+    form_key VARCHAR(100) NOT NULL UNIQUE,
140
+    form_name VARCHAR(200) NOT NULL,
141
+    form_config TEXT NOT NULL,                  -- JSON表单配置
142
+    version INT DEFAULT 1,
143
+    status VARCHAR(20) DEFAULT 'active',
144
+    created_at TIMESTAMP DEFAULT NOW(),
145
+    updated_at TIMESTAMP DEFAULT NOW(),
146
+    deleted INT DEFAULT 0
147
+);
148
+COMMENT ON TABLE bpm_form_template IS '表单模板表';
149
+
150
+-- 流程统计表
151
+CREATE TABLE IF NOT EXISTS bpm_process_stat (
152
+    id BIGSERIAL PRIMARY KEY,
153
+    process_key VARCHAR(100),
154
+    stat_date DATE NOT NULL,
155
+    total_count INT DEFAULT 0,
156
+    completed_count INT DEFAULT 0,
157
+    rejected_count INT DEFAULT 0,
158
+    avg_duration_seconds BIGINT DEFAULT 0,
159
+    created_at TIMESTAMP DEFAULT NOW(),
160
+    updated_at TIMESTAMP DEFAULT NOW()
161
+);
162
+COMMENT ON TABLE bpm_process_stat IS '流程统计表';
163
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bpm_stat_key_date ON bpm_process_stat(process_key, stat_date);
164
+
165
+-- 报装相关流程定义初始数据
166
+INSERT INTO bpm_process_definition (process_key, process_name, description) VALUES
167
+('installation_apply', '报装申请流程', '报装申请: 预受理→工程申请→派单→竣工确认'),
168
+('installation_reject', '报装驳回流程', '报装驳回处理流程')
169
+ON CONFLICT (process_key) DO NOTHING;

+ 160
- 0
wm-bpm/src/test/java/com/water/bpm/ProcessEngineTest.java Visa fil

@@ -0,0 +1,160 @@
1
+package com.water.bpm.service;
2
+
3
+import com.water.bpm.entity.BpmApprovalRecord;
4
+import com.water.bpm.entity.BpmProcessInstance;
5
+import com.water.bpm.mapper.BpmApprovalRecordMapper;
6
+import com.water.bpm.mapper.BpmProcessInstanceMapper;
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.util.*;
15
+
16
+import static org.junit.jupiter.api.Assertions.*;
17
+import static org.mockito.ArgumentMatchers.*;
18
+import static org.mockito.Mockito.*;
19
+
20
+@ExtendWith(MockitoExtension.class)
21
+class ProcessEngineTest {
22
+
23
+    @Mock
24
+    private BpmProcessInstanceMapper instanceMapper;
25
+
26
+    @Mock
27
+    private BpmApprovalRecordMapper approvalRecordMapper;
28
+
29
+    @InjectMocks
30
+    private ProcessEngine processEngine;
31
+
32
+    private BpmProcessInstance testInstance;
33
+
34
+    @BeforeEach
35
+    void setUp() {
36
+        testInstance = new BpmProcessInstance();
37
+        testInstance.setId(1L);
38
+        testInstance.setInstanceId("test-uuid-001");
39
+        testInstance.setProcessKey("installation_apply");
40
+        testInstance.setProcessName("报装申请流程");
41
+        testInstance.setBusinessKey("IZA-123");
42
+        testInstance.setInitiatorId(1L);
43
+        testInstance.setInitiatorName("张三");
44
+        testInstance.setStatus("running");
45
+        testInstance.setCurrentNodeId("START");
46
+        testInstance.setCurrentNodeName("开始");
47
+    }
48
+
49
+    @Test
50
+    void testStartProcess() {
51
+        when(instanceMapper.insert(any())).thenReturn(1);
52
+
53
+        BpmProcessInstance result = processEngine.startProcess(
54
+            "installation_apply", "报装申请流程",
55
+            1L, "张三", "IZA-123", "installation",
56
+            "{}"
57
+        );
58
+
59
+        assertNotNull(result);
60
+        assertNotNull(result.getInstanceId());
61
+        assertEquals("installation_apply", result.getProcessKey());
62
+        assertEquals("running", result.getStatus());
63
+        assertEquals(1L, result.getInitiatorId());
64
+        verify(instanceMapper).insert(any());
65
+    }
66
+
67
+    @Test
68
+    void testApproveAction() {
69
+        when(instanceMapper.selectByInstanceId("test-uuid-001")).thenReturn(testInstance);
70
+        when(approvalRecordMapper.insert(any())).thenReturn(1);
71
+        when(instanceMapper.updateById(any())).thenReturn(1);
72
+
73
+        BpmProcessInstance result = processEngine.approve(
74
+            "test-uuid-001", 2L, "审批员A",
75
+            "REVIEW", "审核节点",
76
+            "approve", "同意"
77
+        );
78
+
79
+        assertNotNull(result);
80
+        assertEquals("running", result.getStatus());
81
+        assertEquals(2L, result.getCurrentAssigneeId());
82
+        verify(approvalRecordMapper).insert(any());
83
+        verify(instanceMapper).updateById(any());
84
+    }
85
+
86
+    @Test
87
+    void testRejectAction() {
88
+        when(instanceMapper.selectByInstanceId("test-uuid-001")).thenReturn(testInstance);
89
+        when(approvalRecordMapper.insert(any())).thenReturn(1);
90
+        when(instanceMapper.updateById(any())).thenReturn(1);
91
+
92
+        BpmProcessInstance result = processEngine.approve(
93
+            "test-uuid-001", 2L, "审批员A",
94
+            "REVIEW", "审核节点",
95
+            "reject", "不符合条件"
96
+        );
97
+
98
+        assertNotNull(result);
99
+        assertEquals("rejected", result.getStatus());
100
+        assertNotNull(result.getCompletedAt());
101
+    }
102
+
103
+    @Test
104
+    void testTerminateAction() {
105
+        when(instanceMapper.selectByInstanceId("test-uuid-001")).thenReturn(testInstance);
106
+        when(approvalRecordMapper.insert(any())).thenReturn(1);
107
+        when(instanceMapper.updateById(any())).thenReturn(1);
108
+
109
+        BpmProcessInstance result = processEngine.approve(
110
+            "test-uuid-001", 1L, "张三",
111
+            "CANCEL", "取消",
112
+            "terminate", "主动取消"
113
+        );
114
+
115
+        assertNotNull(result);
116
+        assertEquals("terminated", result.getStatus());
117
+    }
118
+
119
+    @Test
120
+    void testApproveInstanceNotFound() {
121
+        when(instanceMapper.selectByInstanceId("invalid-uuid")).thenReturn(null);
122
+
123
+        assertThrows(RuntimeException.class, () -> {
124
+            processEngine.approve("invalid-uuid", 1L, "test", "node", "name", "approve", "ok");
125
+        });
126
+    }
127
+
128
+    @Test
129
+    void testCompleteProcess() {
130
+        when(instanceMapper.selectByInstanceId("test-uuid-001")).thenReturn(testInstance);
131
+        when(instanceMapper.updateById(any())).thenReturn(1);
132
+
133
+        processEngine.completeProcess("test-uuid-001");
134
+
135
+        assertEquals("completed", testInstance.getStatus());
136
+        assertEquals("END", testInstance.getCurrentNodeId());
137
+        assertNotNull(testInstance.getCompletedAt());
138
+        verify(instanceMapper).updateById(any());
139
+    }
140
+
141
+    @Test
142
+    void testGetInstance() {
143
+        when(instanceMapper.selectByInstanceId("test-uuid-001")).thenReturn(testInstance);
144
+
145
+        BpmProcessInstance result = processEngine.getInstance("test-uuid-001");
146
+
147
+        assertNotNull(result);
148
+        assertEquals("test-uuid-001", result.getInstanceId());
149
+    }
150
+
151
+    @Test
152
+    void testGetTodoList() {
153
+        when(instanceMapper.selectTodoByAssigneeId(1L)).thenReturn(List.of(testInstance));
154
+
155
+        List<BpmProcessInstance> result = processEngine.getTodoList(1L);
156
+
157
+        assertNotNull(result);
158
+        assertEquals(1, result.size());
159
+    }
160
+}

+ 2
- 1
wm-revenue/src/main/java/com/water/revenue/enums/InstallationStatus.java Visa fil

@@ -9,7 +9,8 @@ public enum InstallationStatus {
9 9
     ENGINEERING_APPLY("工程申请"),
10 10
     DISPATCHED("已派单"),
11 11
     COMPLETED("竣工确认"),
12
-    REJECTED("已驳回");
12
+    REJECTED("已驳回"),
13
+    TERMINATED("已终止");
13 14
 
14 15
     private final String description;
15 16
 

+ 27
- 27
wm-revenue/src/main/java/com/water/revenue/service/InstallationOverviewService.java Visa fil

@@ -17,34 +17,34 @@ public class InstallationOverviewService {
17 17
     private final JdbcTemplate jdbcTemplate;
18 18
 
19 19
     /**
20
-     * 首页概览数据
20
+     * 首页概览数据 (INS-01)
21 21
      */
22 22
     public Map<String, Object> getOverview() {
23 23
         // 总数
24 24
         Integer total = jdbcTemplate.queryForObject(
25
-            "SELECT COUNT(*) FROM rev_installation", Integer.class
25
+            "SELECT COUNT(*) FROM installation_apply", Integer.class
26 26
         );
27 27
 
28
-        // 待处理
28
+        // 待处理(预受理状态)
29 29
         Integer pending = jdbcTemplate.queryForObject(
30
-            "SELECT COUNT(*) FROM rev_installation WHERE status = 'pending'", Integer.class
30
+            "SELECT COUNT(*) FROM installation_apply WHERE status = 'PRE_ACCEPT'", Integer.class
31 31
         );
32 32
 
33
-        // 进行中
33
+        // 进行中(工程申请+已派单)
34 34
         Integer inProgress = jdbcTemplate.queryForObject(
35
-            "SELECT COUNT(*) FROM rev_installation WHERE status IN ('dispatched', 'in_progress')", Integer.class
35
+            "SELECT COUNT(*) FROM installation_apply WHERE status IN ('ENGINEERING_APPLY', 'DISPATCHED')", Integer.class
36 36
         );
37 37
 
38 38
         // 本月完成
39 39
         String currentMonth = YearMonth.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
40 40
         Integer completedThisMonth = jdbcTemplate.queryForObject(
41
-            "SELECT COUNT(*) FROM rev_installation WHERE status = 'completed' AND DATE_FORMAT(completed_at, '%Y-%m') = ?",
41
+            "SELECT COUNT(*) FROM installation_apply WHERE status = 'COMPLETED' AND TO_CHAR(completed_time, 'YYYY-MM') = ?",
42 42
             Integer.class, currentMonth
43 43
         );
44 44
 
45
-        // 平均完成天数
45
+        // 平均完成天数(PostgreSQL 语法)
46 46
         Double avgDays = jdbcTemplate.queryForObject(
47
-            "SELECT AVG(DATEDIFF(completed_at, created_at)) FROM rev_installation WHERE status = 'completed' AND completed_at IS NOT NULL",
47
+            "SELECT AVG(EXTRACT(EPOCH FROM (completed_time - created_at))/86400) FROM installation_apply WHERE status = 'COMPLETED' AND completed_time IS NOT NULL",
48 48
             Double.class
49 49
         );
50 50
 
@@ -58,53 +58,53 @@ public class InstallationOverviewService {
58 58
     }
59 59
 
60 60
     /**
61
-     * 按月统计
61
+     * 按月统计 (INS-07)
62 62
      */
63 63
     public List<Map<String, Object>> statsByMonth(String year) {
64 64
         return jdbcTemplate.queryForList(
65
-            "SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count, " +
66
-            "SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed " +
67
-            "FROM rev_installation WHERE YEAR(created_at) = ? " +
68
-            "GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY month",
65
+            "SELECT TO_CHAR(created_at, 'YYYY-MM') as month, COUNT(*) as count, " +
66
+            "SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) as completed " +
67
+            "FROM installation_apply WHERE EXTRACT(YEAR FROM created_at) = ? " +
68
+            "GROUP BY TO_CHAR(created_at, 'YYYY-MM') ORDER BY month",
69 69
             year
70 70
         );
71 71
     }
72 72
 
73 73
     /**
74
-     * 按区域统计
74
+     * 按区域统计 (INS-07)
75 75
      */
76 76
     public List<Map<String, Object>> statsByArea() {
77 77
         return jdbcTemplate.queryForList(
78 78
             "SELECT area, COUNT(*) as total, " +
79
-            "SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, " +
80
-            "SUM(CASE WHEN status IN ('pending', 'dispatched', 'in_progress') THEN 1 ELSE 0 END) as in_progress " +
81
-            "FROM rev_installation GROUP BY area ORDER BY total DESC"
79
+            "SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) as completed, " +
80
+            "SUM(CASE WHEN status IN ('PRE_ACCEPT', 'ENGINEERING_APPLY', 'DISPATCHED') THEN 1 ELSE 0 END) as in_progress " +
81
+            "FROM installation_apply GROUP BY area ORDER BY total DESC"
82 82
         );
83 83
     }
84 84
 
85 85
     /**
86
-     * 转化率分析
86
+     * 转化率分析 (INS-07)
87 87
      */
88 88
     public Map<String, Object> conversionAnalysis() {
89 89
         Integer total = jdbcTemplate.queryForObject(
90
-            "SELECT COUNT(*) FROM rev_installation", Integer.class
90
+            "SELECT COUNT(*) FROM installation_apply", Integer.class
91 91
         );
92 92
         Integer completed = jdbcTemplate.queryForObject(
93
-            "SELECT COUNT(*) FROM rev_installation WHERE status = 'completed'", Integer.class
93
+            "SELECT COUNT(*) FROM installation_apply WHERE status = 'COMPLETED'", Integer.class
94 94
         );
95
-        Integer cancelled = jdbcTemplate.queryForObject(
96
-            "SELECT COUNT(*) FROM rev_installation WHERE status = 'cancelled'", Integer.class
95
+        Integer terminated = jdbcTemplate.queryForObject(
96
+            "SELECT COUNT(*) FROM installation_apply WHERE status IN ('REJECTED', 'TERMINATED')", Integer.class
97 97
         );
98 98
 
99
-        double conversionRate = total != null && total > 0 && completed != null ? 
99
+        double conversionRate = total != null && total > 0 && completed != null ?
100 100
             (completed * 100.0 / total) : 0;
101
-        double cancelRate = total != null && total > 0 && cancelled != null ? 
102
-            (cancelled * 100.0 / total) : 0;
101
+        double cancelRate = total != null && total > 0 && terminated != null ?
102
+            (terminated * 100.0 / total) : 0;
103 103
 
104 104
         return Map.of(
105 105
             "total", total != null ? total : 0,
106 106
             "completed", completed != null ? completed : 0,
107
-            "cancelled", cancelled != null ? cancelled : 0,
107
+            "terminated", terminated != null ? terminated : 0,
108 108
             "conversionRate", Math.round(conversionRate * 100.0) / 100.0,
109 109
             "cancelRate", Math.round(cancelRate * 100.0) / 100.0
110 110
         );

+ 59
- 47
wm-revenue/src/main/resources/db/V1__base_tables.sql Visa fil

@@ -1,6 +1,7 @@
1 1
 -- =============================================
2 2
 -- 智慧水务管理系统 - 营业收费基础表
3
+-- 版本: V1 (PostgreSQL)
4
+-- 对应需求: REV-01~05, INS-01~08
3 5
 -- =============================================
4 6
 
5 7
 -- 水价阶梯表
@@ -54,7 +55,6 @@ CREATE TABLE IF NOT EXISTS rev_meter (
54 55
     install_date DATE,
55 56
     install_address VARCHAR(300),
56 57
     status VARCHAR(20) DEFAULT 'active',     -- active/dismantled/scrapped/repaired/warehouse
57
-    install_date DATE,
58 58
     scrapped_date DATE,
59 59
     dismantle_date DATE,
60 60
     remark VARCHAR(500),
@@ -73,8 +73,7 @@ CREATE TABLE IF NOT EXISTS rev_meter_log (
73 73
     new_meter_no VARCHAR(50),
74 74
     operator_id BIGINT,
75 75
     operator_name VARCHAR(50),
76
-    photos JSONB,                             -- 现场照片URL数组
77
-    equipment_no VARCHAR(50),                -- 设备编号(如新换的表号)
76
+    equipment_no VARCHAR(50),                -- 设备编号
78 77
     photos JSONB,                            -- 现场照片URL数组
79 78
     remark VARCHAR(500),
80 79
     created_at TIMESTAMP DEFAULT NOW()
@@ -118,7 +117,7 @@ CREATE TABLE IF NOT EXISTS rev_bill (
118 117
     consumption DECIMAL(10,2),
119 118
     water_fee DECIMAL(10,2),
120 119
     sewage_fee DECIMAL(10,2),
121
-    other_fee DECIMAL(10,2) DEFAULT 0,       -- 污水处理费/垃圾处理费等
120
+    other_fee DECIMAL(10,2) DEFAULT 0,
122 121
     total_fee DECIMAL(10,2),
123 122
     paid_fee DECIMAL(10,2) DEFAULT 0,
124 123
     discount_fee DECIMAL(10,2) DEFAULT 0,
@@ -153,31 +152,64 @@ CREATE INDEX IF NOT EXISTS idx_payment_bill ON rev_payment(bill_id);
153 152
 CREATE INDEX IF NOT EXISTS idx_payment_customer ON rev_payment(customer_id);
154 153
 CREATE INDEX IF NOT EXISTS idx_payment_date ON rev_payment(paid_at);
155 154
 
156
-CREATE TABLE IF NOT EXISTS rev_install (
155
+-- 报装申请表(与 InstallationApply Entity 对齐)
156
+CREATE TABLE IF NOT EXISTS installation_apply (
157 157
     id BIGSERIAL PRIMARY KEY,
158
-    application_no VARCHAR(30) UNIQUE NOT NULL,
159
-    applicant_name VARCHAR(50) NOT NULL,
158
+    apply_no VARCHAR(64) NOT NULL UNIQUE,
159
+    applicant_name VARCHAR(64) NOT NULL,
160 160
     applicant_phone VARCHAR(20) NOT NULL,
161
-    applicant_id_card VARCHAR(18),
162
-    area VARCHAR(50),
163
-    address VARCHAR(300),
164
-    customer_type VARCHAR(20),               -- residential/business/enterprise
165
-    caliber VARCHAR(10),                     -- 申请管径
166
-    purpose VARCHAR(200),
167
-    status VARCHAR(20) DEFAULT 'pre_apply',  -- pre_apply/engineering/pending_review/approved/rejected/completed/terminated
168
-    survey_date DATE,
169
-    survey_result TEXT,
170
-    approved_by BIGINT,
171
-    approved_at TIMESTAMP,
172
-    completed_at TIMESTAMP,
173
-    created_at TIMESTAMP DEFAULT NOW(),
174
-    updated_at TIMESTAMP DEFAULT NOW()
161
+    applicant_id_card VARCHAR(20),
162
+    address VARCHAR(256) NOT NULL,
163
+    area VARCHAR(64) NOT NULL,
164
+    water_use_type VARCHAR(32),              -- residential/business/enterprise
165
+    pipe_diameter DECIMAL(10,2),             -- 管径(mm)
166
+    status VARCHAR(32) NOT NULL DEFAULT 'PRE_ACCEPT', -- PRE_ACCEPT/ENGINEERING_APPLY/DISPATCHED/COMPLETED/REJECTED/TERMINATED
167
+    apply_time TIMESTAMP NOT NULL DEFAULT NOW(),
168
+    engineering_apply_time TIMESTAMP,
169
+    dispatch_time TIMESTAMP,
170
+    completed_time TIMESTAMP,
171
+    assignee_id BIGINT,
172
+    assignee_name VARCHAR(64),
173
+    remarks VARCHAR(512),
174
+    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
175
+    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
175 176
 );
176
-COMMENT ON TABLE rev_install IS '报装申请表';
177
+COMMENT ON TABLE installation_apply IS '报装申请表(INS-01~08)';
178
+CREATE INDEX IF NOT EXISTS idx_install_area ON installation_apply(area);
179
+CREATE INDEX IF NOT EXISTS idx_install_status ON installation_apply(status);
180
+CREATE INDEX IF NOT EXISTS idx_install_created ON installation_apply(created_at);
177 181
 
182
+-- 平台运维审计日志表 (REV-01)
183
+CREATE TABLE IF NOT EXISTS rev_audit_log (
184
+    id BIGSERIAL PRIMARY KEY,
185
+    user_id VARCHAR(50) NOT NULL,
186
+    user_name VARCHAR(100) NOT NULL,
187
+    action VARCHAR(50) NOT NULL,             -- login/logout/create/update/delete/query
188
+    target_type VARCHAR(50),                 -- user/bill/payment/installation/app
189
+    target_id VARCHAR(100),
190
+    detail TEXT,
191
+    ip VARCHAR(50),
192
+    created_at TIMESTAMP NOT NULL DEFAULT NOW()
193
+);
194
+COMMENT ON TABLE rev_audit_log IS '平台运维审计日志表(REV-01)';
195
+CREATE INDEX IF NOT EXISTS idx_audit_user ON rev_audit_log(user_id);
196
+CREATE INDEX IF NOT EXISTS idx_audit_action ON rev_audit_log(action);
197
+CREATE INDEX IF NOT EXISTS idx_audit_time ON rev_audit_log(created_at);
198
+
199
+-- 应用接入注册表 (REV-04)
200
+CREATE TABLE IF NOT EXISTS rev_app_registry (
201
+    id BIGSERIAL PRIMARY KEY,
202
+    app_id VARCHAR(64) NOT NULL UNIQUE,
203
+    app_secret VARCHAR(128) NOT NULL,
204
+    app_name VARCHAR(100) NOT NULL,
205
+    redirect_uris VARCHAR(500),
206
+    enabled SMALLINT DEFAULT 1,              -- 1:启用 0:禁用
207
+    created_at TIMESTAMP DEFAULT NOW()
208
+);
209
+COMMENT ON TABLE rev_app_registry IS '应用接入注册表(REV-04)';
210
+CREATE INDEX IF NOT EXISTS idx_app_name ON rev_app_registry(app_name);
211
+
212
+-- 初始数据
178 213
 INSERT INTO rev_water_price (customer_type, tier_no, range_start, range_end, water_price, sewage_price, effective_date) VALUES
179 214
 ('residential', 1, 0, 12, 3.45, 0.8, '2025-01-01'),
180 215
 ('residential', 2, 12, 24, 4.15, 0.8, '2025-01-01'),
@@ -185,32 +217,4 @@ INSERT INTO rev_water_price (customer_type, tier_no, range_start, range_end, wat
185 217
 ('business', 1, 0, 100, 4.50, 1.0, '2025-01-01'),
186 218
 ('business', 2, 100, 500, 5.30, 1.0, '2025-01-01'),
187 219
 ('business', 3, 500, null, 6.80, 1.0, '2025-01-01')
188
-ON CONFLICT (id) DO NOTHING;
189
-
190
-INSERT INTO rev_customer (customer_no, customer_name, phone, area, address, customer_type) VALUES 
191
-('C001', '张三', '13812345678', '精芒片区', '精河县精芒街道123号', 'residential'),
192
-('C002', '李四', '13987654321', '托里片区', '精河县托里路456号', 'residential'),
193
-('C003', '王五', '13555666777', '八家户片区', '精河县八家户街789号', 'business')
194
-ON CONFLICT (customer_no) DO NOTHING;
195
-
196
-INSERT INTO rev_meter (meter_no, customer_id, caliber, meter_type, install_date) VALUES 
197
-('M001', 1, 'DN15', 'mechanical', '2025-01-01'),
198
-('M002', 2, 'DN20', 'electromagnetic', '2025-02-01'),
199
-('M003', 3, 'DN15', 'ultrasonic', '2025-03-01')
200
-ON CONFLICT (meter_no) DO NOTHING;
201
-
202
-INSERT INTO rev_reading (meter_id, reading_date, reading_period, prev_reading, curr_reading, consumption, read_type, verified) VALUES 
203
-(1, '2026-06-01', '2026-06', 1000.00, 1100.00, 100.00, 'remote', 1),
204
-(2, '2026-06-01', '2026-06', 2000.00, 2100.00, 100.00, 'manual', 1),
205
-(3, '2026-06-01', '2026-06', 3000.00, 3200.00, 200.00, 'remote', 1)
206
-ON CONFLICT (id) DO NOTHING;
207
-
208
-INSERT INTO rev_bill (bill_no, customer_id, meter_id, reading_id, bill_period, prev_reading, curr_reading, consumption, water_fee, sewage_fee, total_fee, status, due_date) VALUES 
209
-('BILL-001', 1, 1, 1, '2026-06', 1000.00, 1100.00, 100.00, 345.00, 80.00, 425.00, 'pending', '2026-07-15'),
210
-('BILL-002', 2, 2, 2, '2026-06', 2000.00, 2100.00, 100.00, 345.00, 80.00, 425.00, 'pending', '2026-07-15'),
211
-('BILL-003', 3, 3, 3, '2026-06', 3000.00, 3200.00, 200.00, 690.00, 160.00, 850.00, 'pending', '2026-07-15')
212
-ON CONFLICT (bill_no) DO NOTHING;
220
+ON CONFLICT DO NOTHING;

+ 39
- 29
wm-revenue/src/main/resources/db/V3__sso_tables.sql Visa fil

@@ -1,5 +1,9 @@
1
+-- =============================================
2
+-- SSO单点登录表结构 (PostgreSQL)
3
+-- 对应需求: REV-05 (SSO/OAuth2/OIDC)
4
+-- =============================================
5
+
6
+-- 创建SSO令牌表 (REV-05)
1 7
 CREATE TABLE IF NOT EXISTS sso_token (
2 8
     id BIGSERIAL PRIMARY KEY,
3 9
     user_id VARCHAR(50) NOT NULL,
@@ -8,13 +12,13 @@ CREATE TABLE IF NOT EXISTS sso_token (
8 12
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
9 13
     expire_time TIMESTAMP NOT NULL,
10 14
     status INTEGER DEFAULT 1,
11
-    last_use_time TIMESTAMP,
12
-    INDEX idx_token (token),
13
-    INDEX idx_user_id (user_id),
14
-    INDEX idx_expire_time (expire_time)
15
+    last_use_time TIMESTAMP
15 16
 );
17
+CREATE INDEX IF NOT EXISTS idx_sso_token ON sso_token(token);
18
+CREATE INDEX IF NOT EXISTS idx_sso_user_id ON sso_token(user_id);
19
+CREATE INDEX IF NOT EXISTS idx_sso_expire_time ON sso_token(expire_time);
16 20
 
21
+-- 创建应用注册表 (REV-04/REV-05 应用接入)
17 22
 CREATE TABLE IF NOT EXISTS app_registry (
18 23
     id BIGSERIAL PRIMARY KEY,
19 24
     app_name VARCHAR(100) NOT NULL UNIQUE,
@@ -25,10 +29,10 @@ CREATE TABLE IF NOT EXISTS app_registry (
25 29
     status INTEGER DEFAULT 1,
26 30
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
27 31
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
28
-    admin_user VARCHAR(100),
29
-    INDEX idx_app_key (app_key),
30
-    INDEX idx_status (status)
32
+    admin_user VARCHAR(100)
31 33
 );
34
+CREATE INDEX IF NOT EXISTS idx_app_key ON app_registry(app_key);
35
+CREATE INDEX IF NOT EXISTS idx_app_status ON app_registry(status);
32 36
 
33 37
 -- 创建SSO访问日志表
34 38
 CREATE TABLE IF NOT EXISTS sso_access_log (
@@ -36,18 +40,18 @@ CREATE TABLE IF NOT EXISTS sso_access_log (
36 40
     user_id VARCHAR(50),
37 41
     username VARCHAR(100),
38 42
     app_name VARCHAR(100),
39
-    action VARCHAR(50) NOT NULL, -- login, logout, validate, token_exchange
43
+    action VARCHAR(50) NOT NULL,             -- login/logout/validate/token_exchange
40 44
     ip_address VARCHAR(50),
41 45
     user_agent TEXT,
42 46
     token_used VARCHAR(500),
43
-    status INTEGER DEFAULT 1, -- 1:成功, 0:失败
47
+    status INTEGER DEFAULT 1,                -- 1:成功 0:失败
44 48
     error_message TEXT,
45
-    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
-    INDEX idx_user_id (user_id),
47
-    INDEX idx_username (username),
48
-    INDEX idx_app_name (app_name),
49
-    INDEX idx_create_time (create_time)
49
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
50 50
 );
51
+CREATE INDEX IF NOT EXISTS idx_sso_access_user ON sso_access_log(user_id);
52
+CREATE INDEX IF NOT EXISTS idx_sso_access_username ON sso_access_log(username);
53
+CREATE INDEX IF NOT EXISTS idx_sso_access_app ON sso_access_log(app_name);
54
+CREATE INDEX IF NOT EXISTS idx_sso_access_time ON sso_access_log(create_time);
51 55
 
52 56
 -- 创建应用接入记录表
53 57
 CREATE TABLE IF NOT EXISTS app_access_log (
@@ -55,34 +59,34 @@ CREATE TABLE IF NOT EXISTS app_access_log (
55 59
     app_key VARCHAR(200) NOT NULL,
56 60
     app_name VARCHAR(100) NOT NULL,
57 61
     client_id VARCHAR(200),
58
-    action VARCHAR(50) NOT NULL, -- register, validate, auth, token_request
62
+    action VARCHAR(50) NOT NULL,             -- register/validate/auth/token_request
59 63
     ip_address VARCHAR(50),
60 64
     user_agent TEXT,
61 65
     request_data TEXT,
62 66
     response_data TEXT,
63 67
     status INTEGER DEFAULT 1,
64
-    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
65
-    INDEX idx_app_key (app_key),
66
-    INDEX idx_create_time (create_time)
68
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
67 69
 );
70
+CREATE INDEX IF NOT EXISTS idx_app_access_key ON app_access_log(app_key);
71
+CREATE INDEX IF NOT EXISTS idx_app_access_time ON app_access_log(create_time);
68 72
 
73
+-- 创建权限配置表 (REV-03 用户授权)
69 74
 CREATE TABLE IF NOT EXISTS app_permission (
70 75
     id BIGSERIAL PRIMARY KEY,
71 76
     app_key VARCHAR(200) NOT NULL,
72 77
     permission_name VARCHAR(100) NOT NULL,
73 78
     permission_code VARCHAR(200) NOT NULL,
74
-    resource_type VARCHAR(50), -- api, menu, button
79
+    resource_type VARCHAR(50),               -- api/menu/button
75 80
     resource_id VARCHAR(200),
76 81
     is_enabled INTEGER DEFAULT 1,
77 82
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
78 83
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
79
-    UNIQUE KEY uk_app_key_permission (app_key, permission_code),
80
-    INDEX idx_app_key (app_key),
81
-    INDEX idx_permission_code (permission_code)
84
+    UNIQUE (app_key, permission_code)
82 85
 );
86
+CREATE INDEX IF NOT EXISTS idx_perm_app_key ON app_permission(app_key);
87
+CREATE INDEX IF NOT EXISTS idx_perm_code ON app_permission(permission_code);
83 88
 
89
+-- 创建Token刷新记录表 (REV-05 OAuth2)
84 90
 CREATE TABLE IF NOT EXISTS refresh_token (
85 91
     id BIGSERIAL PRIMARY KEY,
86 92
     user_id VARCHAR(50) NOT NULL,
@@ -94,12 +98,12 @@ CREATE TABLE IF NOT EXISTS refresh_token (
94 98
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95 99
     expire_time TIMESTAMP NOT NULL,
96 100
     last_use_time TIMESTAMP,
97
-    is_revoked INTEGER DEFAULT 0,
98
-    INDEX idx_access_token (access_token),
99
-    INDEX idx_refresh_token (refresh_token),
100
-    INDEX idx_user_id (user_id),
101
-    INDEX idx_client_id (client_id)
101
+    is_revoked INTEGER DEFAULT 0
102 102
 );
103
+CREATE INDEX IF NOT EXISTS idx_rt_access ON refresh_token(access_token);
104
+CREATE INDEX IF NOT EXISTS idx_rt_refresh ON refresh_token(refresh_token);
105
+CREATE INDEX IF NOT EXISTS idx_rt_user ON refresh_token(user_id);
106
+CREATE INDEX IF NOT EXISTS idx_rt_client ON refresh_token(client_id);
103 107
 
104 108
 -- 插入默认的OAuth2客户端配置
105 109
 INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, admin_user) VALUES
@@ -118,12 +122,12 @@ BEGIN
118 122
 END;
119 123
 $$ LANGUAGE plpgsql;
120 124
 
125
+-- 创建SSO触发器:当更新token时更新最后使用时间
121 126
 CREATE OR REPLACE FUNCTION update_last_use_time()
122 127
 RETURNS TRIGGER AS $$
123 128
 BEGIN
124 129
     IF NEW.status = 1 THEN
125
-        UPDATE sso_token SET last_use_time = NOW() 
130
+        UPDATE sso_token SET last_use_time = NOW()
126 131
         WHERE token = NEW.token AND id != NEW.id;
127 132
     END IF;
128 133
     RETURN NEW;
@@ -138,4 +142,4 @@ CREATE TRIGGER trigger_clean_expired_tokens
138 142
 CREATE TRIGGER trigger_update_last_use_time
139 143
     AFTER UPDATE ON sso_token
140 144
     FOR EACH ROW
141
-    EXECUTE FUNCTION update_last_use_time();
145
+    EXECUTE FUNCTION update_last_use_time();

+ 3
- 25
wm-revenue/src/main/resources/sql/installation_apply.sql Visa fil

@@ -1,26 +1,3 @@
1
-CREATE TABLE IF NOT EXISTS installation_apply (
2
-    id              BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
3
-    apply_no        VARCHAR(64)    NOT NULL COMMENT '申请编号',
4
-    applicant_name  VARCHAR(64)    NOT NULL COMMENT '申请人姓名',
5
-    applicant_phone VARCHAR(20)    NOT NULL COMMENT '申请人电话',
6
-    applicant_id_card VARCHAR(20)  DEFAULT NULL COMMENT '申请人身份证号',
7
-    address         VARCHAR(256)   NOT NULL COMMENT '用水地址',
8
-    area            VARCHAR(64)    NOT NULL COMMENT '所属区域',
9
-    water_use_type  VARCHAR(32)    DEFAULT NULL COMMENT '用水类型(居民/商业/工业/其他)',
10
-    pipe_diameter   DECIMAL(10,2)  DEFAULT NULL COMMENT '管径(mm)',
11
-    status          VARCHAR(32)    NOT NULL DEFAULT 'PRE_ACCEPT' COMMENT '状态: PRE_ACCEPT/ENGINEERING_APPLY/DISPATCHED/COMPLETED/REJECTED',
12
-    apply_time      DATETIME       NOT NULL COMMENT '申请时间',
13
-    engineering_apply_time DATETIME DEFAULT NULL COMMENT '工程申请时间',
14
-    dispatch_time   DATETIME       DEFAULT NULL COMMENT '派单时间',
15
-    completed_time  DATETIME       DEFAULT NULL COMMENT '竣工时间',
16
-    assignee_id     BIGINT         DEFAULT NULL COMMENT '指派人ID',
17
-    assignee_name   VARCHAR(64)    DEFAULT NULL COMMENT '指派人姓名',
18
-    remarks         VARCHAR(512)   DEFAULT NULL COMMENT '备注',
19
-    created_at      DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
20
-    updated_at      DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
21
-    UNIQUE KEY uk_apply_no (apply_no),
22
-    KEY idx_area (area),
23
-    KEY idx_status (status),
24
-    KEY idx_created_at (created_at)
25
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='报装申请表';
1
+-- 报装申请表 (PostgreSQL, 与 InstallationApply Entity 对齐)
2
+-- 注意: 此表已在 V1__base_tables.sql 中定义,此处为兼容旧代码保留
3
+-- 实际部署由 Flyway 按 V1 顺序执行,本文件不参与 Flyway migration

+ 110
- 0
wm-revenue/src/test/java/com/water/revenue/service/AppAccessServiceTest.java Visa fil

@@ -0,0 +1,110 @@
1
+package com.water.revenue.service;
2
+
3
+import org.junit.jupiter.api.BeforeEach;
4
+import org.junit.jupiter.api.Test;
5
+import org.junit.jupiter.api.extension.ExtendWith;
6
+import org.mockito.InjectMocks;
7
+import org.mockito.Mock;
8
+import org.mockito.junit.jupiter.MockitoExtension;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+
11
+import java.util.*;
12
+
13
+import static org.junit.jupiter.api.Assertions.*;
14
+import static org.mockito.ArgumentMatchers.*;
15
+import static org.mockito.Mockito.*;
16
+
17
+@ExtendWith(MockitoExtension.class)
18
+class AppAccessServiceTest {
19
+
20
+    @Mock
21
+    private JdbcTemplate jdbcTemplate;
22
+
23
+    @InjectMocks
24
+    private AppAccessService appAccessService;
25
+
26
+    @Test
27
+    void testRegisterApp() {
28
+        when(jdbcTemplate.update(anyString(), any(), any(), any(), any(), any(), any())).thenReturn(1);
29
+
30
+        Map<String, Object> result = appAccessService.registerApp("测试应用", "http://localhost:3000/callback");
31
+
32
+        assertNotNull(result);
33
+        assertNotNull(result.get("appId"));
34
+        assertNotNull(result.get("appSecret"));
35
+        assertEquals("测试应用", result.get("appName"));
36
+        assertEquals("http://localhost:3000/callback", result.get("redirectUris"));
37
+    }
38
+
39
+    @Test
40
+    void testToggleAppEnable() {
41
+        when(jdbcTemplate.update(anyString(), any(), any())).thenReturn(1);
42
+
43
+        Map<String, Object> result = appAccessService.toggleApp("APP-123", true);
44
+
45
+        assertNotNull(result);
46
+        assertEquals("APP-123", result.get("appId"));
47
+        assertEquals(true, result.get("enabled"));
48
+    }
49
+
50
+    @Test
51
+    void testToggleAppNotFound() {
52
+        when(jdbcTemplate.update(anyString(), any(), any())).thenReturn(0);
53
+
54
+        assertThrows(RuntimeException.class, () -> {
55
+            appAccessService.toggleApp("INVALID-ID", true);
56
+        });
57
+    }
58
+
59
+    @Test
60
+    void testListApps() {
61
+        when(jdbcTemplate.queryForList(anyString(), any())).thenReturn(List.of(
62
+            Map.of("app_id", "APP-001", "app_name", "营收前端", "enabled", 1),
63
+            Map.of("app_id", "APP-002", "app_name", "微信网厅", "enabled", 1)
64
+        ));
65
+
66
+        List<Map<String, Object>> result = appAccessService.listApps(null, 1, 10);
67
+
68
+        assertNotNull(result);
69
+        assertEquals(2, result.size());
70
+    }
71
+
72
+    @Test
73
+    void testGetAppDetail() {
74
+        when(jdbcTemplate.queryForList(anyString(), any())).thenReturn(List.of(
75
+            Map.of("app_id", "APP-001", "app_name", "营收前端", "redirect_uris", "http://localhost:3000/callback", "enabled", 1)
76
+        ));
77
+
78
+        Map<String, Object> result = appAccessService.getAppDetail("APP-001");
79
+
80
+        assertNotNull(result);
81
+        assertEquals("APP-001", result.get("app_id"));
82
+        assertEquals("营收前端", result.get("app_name"));
83
+    }
84
+
85
+    @Test
86
+    void testGetAppDetailNotFound() {
87
+        when(jdbcTemplate.queryForList(anyString(), any())).thenReturn(List.of());
88
+
89
+        assertThrows(RuntimeException.class, () -> {
90
+            appAccessService.getAppDetail("INVALID-ID");
91
+        });
92
+    }
93
+
94
+    @Test
95
+    void testDeleteApp() {
96
+        when(jdbcTemplate.update(anyString(), any())).thenReturn(1);
97
+
98
+        appAccessService.deleteApp("APP-001");
99
+        verify(jdbcTemplate).update(eq("DELETE FROM rev_app_registry WHERE app_id = ?"), eq("APP-001"));
100
+    }
101
+
102
+    @Test
103
+    void testDeleteAppNotFound() {
104
+        when(jdbcTemplate.update(anyString(), any())).thenReturn(0);
105
+
106
+        assertThrows(RuntimeException.class, () -> {
107
+            appAccessService.deleteApp("INVALID-ID");
108
+        });
109
+    }
110
+}

+ 113
- 0
wm-revenue/src/test/java/com/water/revenue/service/InstallationOverviewServiceTest.java Visa fil

@@ -0,0 +1,113 @@
1
+package com.water.revenue.service;
2
+
3
+import org.junit.jupiter.api.BeforeEach;
4
+import org.junit.jupiter.api.Test;
5
+import org.junit.jupiter.api.extension.ExtendWith;
6
+import org.mockito.InjectMocks;
7
+import org.mockito.Mock;
8
+import org.mockito.junit.jupiter.MockitoExtension;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+
11
+import java.util.*;
12
+
13
+import static org.junit.jupiter.api.Assertions.*;
14
+import static org.mockito.ArgumentMatchers.*;
15
+import static org.mockito.Mockito.*;
16
+
17
+@ExtendWith(MockitoExtension.class)
18
+class InstallationOverviewServiceTest {
19
+
20
+    @Mock
21
+    private JdbcTemplate jdbcTemplate;
22
+
23
+    @InjectMocks
24
+    private InstallationOverviewService overviewService;
25
+
26
+    @Test
27
+    void testGetOverview() {
28
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply"), eq(Integer.class)))
29
+            .thenReturn(100);
30
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply WHERE status = 'PRE_ACCEPT'"), eq(Integer.class)))
31
+            .thenReturn(15);
32
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply WHERE status IN ('ENGINEERING_APPLY', 'DISPATCHED')"), eq(Integer.class)))
33
+            .thenReturn(25);
34
+        when(jdbcTemplate.queryForObject(contains("TO_CHAR(completed_time"), eq(Integer.class), anyString()))
35
+            .thenReturn(60);
36
+        when(jdbcTemplate.queryForObject(contains("EXTRACT(EPOCH"), eq(Double.class)))
37
+            .thenReturn(7.5);
38
+
39
+        Map<String, Object> result = overviewService.getOverview();
40
+
41
+        assertNotNull(result);
42
+        assertEquals(100, result.get("total"));
43
+        assertEquals(15, result.get("pending"));
44
+        assertEquals(25, result.get("inProgress"));
45
+        assertEquals(60, result.get("completedThisMonth"));
46
+        assertEquals(7.5, result.get("avgDays"));
47
+    }
48
+
49
+    @Test
50
+    void testGetOverviewWithNullValues() {
51
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply"), eq(Integer.class)))
52
+            .thenReturn(null);
53
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply WHERE status = 'PRE_ACCEPT'"), eq(Integer.class)))
54
+            .thenReturn(null);
55
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply WHERE status IN ('ENGINEERING_APPLY', 'DISPATCHED')"), eq(Integer.class)))
56
+            .thenReturn(null);
57
+        when(jdbcTemplate.queryForObject(contains("TO_CHAR(completed_time"), eq(Integer.class), anyString()))
58
+            .thenReturn(null);
59
+        when(jdbcTemplate.queryForObject(contains("EXTRACT(EPOCH"), eq(Double.class)))
60
+            .thenReturn(null);
61
+
62
+        Map<String, Object> result = overviewService.getOverview();
63
+
64
+        assertNotNull(result);
65
+        assertEquals(0, result.get("total"));
66
+        assertEquals(0, result.get("pending"));
67
+    }
68
+
69
+    @Test
70
+    void testStatsByMonth() {
71
+        when(jdbcTemplate.queryForList(anyString(), anyString())).thenReturn(List.of(
72
+            Map.of("month", "2026-01", "count", 10L, "completed", 8L),
73
+            Map.of("month", "2026-02", "count", 15L, "completed", 12L)
74
+        ));
75
+
76
+        List<Map<String, Object>> result = overviewService.statsByMonth("2026");
77
+
78
+        assertNotNull(result);
79
+        assertEquals(2, result.size());
80
+    }
81
+
82
+    @Test
83
+    void testStatsByArea() {
84
+        when(jdbcTemplate.queryForList(anyString())).thenReturn(List.of(
85
+            Map.of("area", "精芒片区", "total", 50L, "completed", 40L, "in_progress", 10L)
86
+        ));
87
+
88
+        List<Map<String, Object>> result = overviewService.statsByArea();
89
+
90
+        assertNotNull(result);
91
+        assertEquals(1, result.size());
92
+        assertEquals("精芒片区", result.get(0).get("area"));
93
+    }
94
+
95
+    @Test
96
+    void testConversionAnalysis() {
97
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply"), eq(Integer.class)))
98
+            .thenReturn(100);
99
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply WHERE status = 'COMPLETED'"), eq(Integer.class)))
100
+            .thenReturn(80);
101
+        when(jdbcTemplate.queryForObject(eq("SELECT COUNT(*) FROM installation_apply WHERE status IN ('REJECTED', 'TERMINATED')"), eq(Integer.class)))
102
+            .thenReturn(5);
103
+
104
+        Map<String, Object> result = overviewService.conversionAnalysis();
105
+
106
+        assertNotNull(result);
107
+        assertEquals(100, result.get("total"));
108
+        assertEquals(80, result.get("completed"));
109
+        assertEquals(5, result.get("terminated"));
110
+        assertEquals(80.0, result.get("conversionRate"));
111
+        assertEquals(5.0, result.get("cancelRate"));
112
+    }
113
+}

+ 145
- 0
wm-revenue/src/test/java/com/water/revenue/service/InstallationServiceTest.java Visa fil

@@ -0,0 +1,145 @@
1
+package com.water.revenue.service;
2
+
3
+import org.junit.jupiter.api.BeforeEach;
4
+import org.junit.jupiter.api.Test;
5
+import org.junit.jupiter.api.extension.ExtendWith;
6
+import org.mockito.InjectMocks;
7
+import org.mockito.Mock;
8
+import org.mockito.junit.jupiter.MockitoExtension;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+
11
+import java.util.*;
12
+
13
+import static org.junit.jupiter.api.Assertions.*;
14
+import static org.mockito.ArgumentMatchers.*;
15
+import static org.mockito.Mockito.*;
16
+
17
+@ExtendWith(MockitoExtension.class)
18
+class InstallationServiceTest {
19
+
20
+    @Mock
21
+    private JdbcTemplate jdbcTemplate;
22
+
23
+    @InjectMocks
24
+    private InstallationService installationService;
25
+
26
+    @Test
27
+    void testPreAccept() {
28
+        when(jdbcTemplate.update(anyString(), any())).thenReturn(1);
29
+
30
+        Map<String, Object> request = new HashMap<>();
31
+        request.put("applicantName", "张三");
32
+        request.put("applicantPhone", "13800138000");
33
+        request.put("applicantIdCard", "123456789012345678");
34
+        request.put("address", "精河县精芒街道1号");
35
+        request.put("area", "精芒片区");
36
+        request.put("waterUseType", "residential");
37
+        request.put("pipeDiameter", 15);
38
+        request.put("remarks", "测试报装");
39
+
40
+        Map<String, Object> result = installationService.preAccept(request);
41
+
42
+        assertNotNull(result);
43
+        assertNotNull(result.get("applyNo"));
44
+        assertEquals("PRE_ACCEPT", result.get("status"));
45
+        assertEquals("张三", result.get("applicantName"));
46
+        verify(jdbcTemplate).update(anyString(), any());
47
+    }
48
+
49
+    @Test
50
+    void testEngineeringApply() {
51
+        when(jdbcTemplate.update(anyString(), any(), any(), any())).thenReturn(1);
52
+
53
+        Map<String, Object> engineeringInfo = new HashMap<>();
54
+        engineeringInfo.put("remarks", "工程申请备注");
55
+
56
+        Map<String, Object> result = installationService.engineeringApply("IZA-123", engineeringInfo);
57
+
58
+        assertNotNull(result);
59
+        assertEquals("IZA-123", result.get("applyNo"));
60
+        assertEquals("ENGINEERING_APPLY", result.get("status"));
61
+        verify(jdbcTemplate).update(anyString(), any(), any(), any());
62
+    }
63
+
64
+    @Test
65
+    void testEngineeringApplyNotFound() {
66
+        when(jdbcTemplate.update(anyString(), any(), any(), any())).thenReturn(0);
67
+
68
+        assertThrows(RuntimeException.class, () -> {
69
+            installationService.engineeringApply("INVALID-NO", new HashMap<>());
70
+        });
71
+    }
72
+
73
+    @Test
74
+    void testDispatch() {
75
+        when(jdbcTemplate.update(anyString(), any(), any(), any(), any())).thenReturn(1);
76
+
77
+        Map<String, Object> result = installationService.dispatch("IZA-123", 1L, "施工员A");
78
+
79
+        assertNotNull(result);
80
+        assertEquals("IZA-123", result.get("applyNo"));
81
+        assertEquals("DISPATCHED", result.get("status"));
82
+        assertEquals(1L, result.get("assigneeId"));
83
+        assertEquals("施工员A", result.get("assigneeName"));
84
+    }
85
+
86
+    @Test
87
+    void testComplete() {
88
+        when(jdbcTemplate.update(anyString(), any(), any(), any())).thenReturn(1);
89
+
90
+        Map<String, Object> completionInfo = new HashMap<>();
91
+        completionInfo.put("remarks", "竣工确认");
92
+
93
+        Map<String, Object> result = installationService.complete("IZA-123", completionInfo);
94
+
95
+        assertNotNull(result);
96
+        assertEquals("IZA-123", result.get("applyNo"));
97
+        assertEquals("COMPLETED", result.get("status"));
98
+    }
99
+
100
+    @Test
101
+    void testGetDetail() {
102
+        when(jdbcTemplate.queryForMap(anyString(), any())).thenReturn(Map.of(
103
+            "id", 1L,
104
+            "apply_no", "IZA-123",
105
+            "applicant_name", "张三",
106
+            "status", "PRE_ACCEPT"
107
+        ));
108
+
109
+        Map<String, Object> result = installationService.getDetail("IZA-123");
110
+
111
+        assertNotNull(result);
112
+        assertEquals("IZA-123", result.get("apply_no"));
113
+        assertEquals("张三", result.get("applicant_name"));
114
+    }
115
+
116
+    @Test
117
+    void testList() {
118
+        when(jdbcTemplate.queryForList(anyString(), any())).thenReturn(List.of(
119
+            Map.of("id", 1L, "apply_no", "IZA-001", "status", "PRE_ACCEPT"),
120
+            Map.of("id", 2L, "apply_no", "IZA-002", "status", "COMPLETED")
121
+        ));
122
+        when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any())).thenReturn(2L);
123
+
124
+        Map<String, Object> query = Map.of("page", 1, "size", 10, "area", "", "status", "", "keyword", "");
125
+        Map<String, Object> result = installationService.list(query);
126
+
127
+        assertNotNull(result);
128
+        assertEquals(2L, result.get("total"));
129
+        assertNotNull(result.get("records"));
130
+    }
131
+
132
+    @Test
133
+    void testStats() {
134
+        when(jdbcTemplate.queryForList(anyString(), any())).thenReturn(List.of(
135
+            Map.of("status", "PRE_ACCEPT", "count", 5L),
136
+            Map.of("status", "COMPLETED", "count", 10L)
137
+        ));
138
+
139
+        Map<String, Object> result = installationService.stats("精芒片区");
140
+
141
+        assertNotNull(result);
142
+        assertEquals("精芒片区", result.get("area"));
143
+        assertNotNull(result.get("byStatus"));
144
+    }
145
+}

+ 90
- 0
wm-revenue/src/test/java/com/water/revenue/service/RevAuditServiceTest.java Visa fil

@@ -0,0 +1,90 @@
1
+package com.water.revenue.service;
2
+
3
+import org.junit.jupiter.api.BeforeEach;
4
+import org.junit.jupiter.api.Test;
5
+import org.junit.jupiter.api.extension.ExtendWith;
6
+import org.mockito.InjectMocks;
7
+import org.mockito.Mock;
8
+import org.mockito.junit.jupiter.MockitoExtension;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+
11
+import java.util.*;
12
+
13
+import static org.junit.jupiter.api.Assertions.*;
14
+import static org.mockito.ArgumentMatchers.*;
15
+import static org.mockito.Mockito.*;
16
+
17
+@ExtendWith(MockitoExtension.class)
18
+class RevAuditServiceTest {
19
+
20
+    @Mock
21
+    private JdbcTemplate jdbcTemplate;
22
+
23
+    @InjectMocks
24
+    private RevAuditService revAuditService;
25
+
26
+    @Test
27
+    void testLog() {
28
+        doNothing().when(jdbcTemplate).update(anyString(), any(), any(), any(), any(), any(), any(), any());
29
+
30
+        revAuditService.log("1", "admin", "login", "user", "1", "用户登录", "192.168.1.1");
31
+
32
+        verify(jdbcTemplate).update(anyString(), any(), any(), any(), any(), any(), any(), any());
33
+    }
34
+
35
+    @Test
36
+    void testQueryLogs() {
37
+        when(jdbcTemplate.queryForList(anyString(), any())).thenReturn(List.of(
38
+            Map.of("id", 1L, "user_name", "admin", "action", "login"),
39
+            Map.of("id", 2L, "user_name", "testuser", "action", "create")
40
+        ));
41
+        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any())).thenReturn(2);
42
+
43
+        Map<String, Object> result = revAuditService.queryLogs(null, null, "2026-06-01", "2026-06-30", 1, 10);
44
+
45
+        assertNotNull(result);
46
+        assertEquals(2, result.get("total"));
47
+        assertEquals(1, result.get("page"));
48
+        assertEquals(10, result.get("size"));
49
+        assertNotNull(result.get("data"));
50
+    }
51
+
52
+    @Test
53
+    void testQueryLogsByUser() {
54
+        when(jdbcTemplate.queryForList(anyString(), any())).thenReturn(List.of(
55
+            Map.of("id", 1L, "user_name", "admin", "action", "login")
56
+        ));
57
+        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any())).thenReturn(1);
58
+
59
+        Map<String, Object> result = revAuditService.queryLogs("1", null, null, null, 1, 10);
60
+
61
+        assertNotNull(result);
62
+        assertEquals(1, result.get("total"));
63
+    }
64
+
65
+    @Test
66
+    void testStatsByDay() {
67
+        when(jdbcTemplate.queryForList(anyString(), anyString(), anyString())).thenReturn(List.of(
68
+            Map.of("day", "2026-06-15", "count", 50L),
69
+            Map.of("day", "2026-06-16", "count", 30L)
70
+        ));
71
+
72
+        List<Map<String, Object>> result = revAuditService.statsByDay("2026-06-01", "2026-06-30");
73
+
74
+        assertNotNull(result);
75
+        assertEquals(2, result.size());
76
+    }
77
+
78
+    @Test
79
+    void testStatsByAction() {
80
+        when(jdbcTemplate.queryForList(anyString(), anyString(), anyString())).thenReturn(List.of(
81
+            Map.of("action", "login", "count", 100L),
82
+            Map.of("action", "create", "count", 50L)
83
+        ));
84
+
85
+        List<Map<String, Object>> result = revAuditService.statsByAction("2026-06-01", "2026-06-30");
86
+
87
+        assertNotNull(result);
88
+        assertEquals(2, result.size());
89
+    }
90
+}