ソースを参照

feat(wm-patrol): #87 巡查设置(区域/路线/表单/模版)

- PatrolAreaService: 区域CRUD/树形列表/详情统计(PAT-16)
- PatrolRouteSetupService: 路线CRUD/巡检点管理/复制路线(PAT-17)
- PatrolFormService: 自定义表单CRUD/表单挂接绑定(PAT-18+PAT-19)
- PatrolTemplateService: 模板CRUD/应用模板生成巡检计划(PAT-20)
- PatrolSetupController: 统一控制器25个端点(/patrol/setup)
- V87__patrol_setup.sql: patrol_area/checkpoint/form/binding/template建表
- JdbcTemplate风格, 返回Map, R<T>统一响应
bot_dev2 4 日 前
コミット
54b5ee6a28

+ 68
- 0
sql/V86__patrol_core.sql ファイルの表示

@@ -0,0 +1,68 @@
1
+-- Issue #86: 巡检管理核心(总览/轨迹/台账/设备/工单)
2
+
3
+-- 1. 巡检设备台账
4
+CREATE TABLE IF NOT EXISTS patrol_device (
5
+    id              BIGSERIAL PRIMARY KEY,
6
+    device_no       VARCHAR(50) UNIQUE,
7
+    device_name     VARCHAR(100) NOT NULL,
8
+    device_type     VARCHAR(30) NOT NULL,        -- valve/meter/hydrant/pump/sensor
9
+    location        VARCHAR(300),
10
+    lng             DOUBLE PRECISION,
11
+    lat             DOUBLE PRECISION,
12
+    status          VARCHAR(20) DEFAULT 'normal', -- normal/abnormal/offline/maintenance
13
+    area            VARCHAR(50),
14
+    point_seq       INT DEFAULT 0,
15
+    remark          TEXT,
16
+    last_maintenance_date TIMESTAMPTZ,
17
+    created_at      TIMESTAMPTZ DEFAULT NOW(),
18
+    updated_at      TIMESTAMPTZ DEFAULT NOW()
19
+);
20
+CREATE INDEX IF NOT EXISTS idx_patrol_device_area ON patrol_device(area);
21
+CREATE INDEX IF NOT EXISTS idx_patrol_device_type ON patrol_device(device_type);
22
+CREATE INDEX IF NOT EXISTS idx_patrol_device_status ON patrol_device(status);
23
+
24
+-- 2. GPS轨迹点
25
+CREATE TABLE IF NOT EXISTS patrol_track_point (
26
+    id              BIGSERIAL PRIMARY KEY,
27
+    task_id         BIGINT NOT NULL REFERENCES patrol_task(id),
28
+    worker_id       BIGINT,
29
+    lng             DOUBLE PRECISION NOT NULL,
30
+    lat             DOUBLE PRECISION NOT NULL,
31
+    speed           DOUBLE PRECISION DEFAULT 0,
32
+    accuracy        DOUBLE PRECISION DEFAULT 0,
33
+    recorded_at     TIMESTAMPTZ DEFAULT NOW()
34
+);
35
+CREATE INDEX IF NOT EXISTS idx_track_task ON patrol_track_point(task_id, recorded_at);
36
+
37
+-- 3. 巡检工单增强(如果 patrol_work_order 已存在则 ALTER,否则 CREATE)
38
+-- 先检查 patrol_work_order 是否存在
39
+DO $$
40
+BEGIN
41
+    IF NOT EXISTS (SELECT FROM pg_tables WHERE tablename = 'patrol_work_order') THEN
42
+        CREATE TABLE patrol_work_order (
43
+            id              BIGSERIAL PRIMARY KEY,
44
+            order_no        VARCHAR(50) UNIQUE NOT NULL,
45
+            task_id         BIGINT REFERENCES patrol_task(id),
46
+            issue_type      VARCHAR(30) NOT NULL,     -- leak/damage/abnormal/pollution/other
47
+            description     TEXT,
48
+            photos          TEXT,                      -- JSON array
49
+            severity        VARCHAR(10) DEFAULT 'medium', -- low/medium/high/critical
50
+            assignee_id     BIGINT,
51
+            assignee_name   VARCHAR(50),
52
+            status          VARCHAR(20) DEFAULT 'pending', -- pending/assigned/processing/resolved/closed
53
+            location        VARCHAR(300),
54
+            lng             DOUBLE PRECISION,
55
+            lat             DOUBLE PRECISION,
56
+            resolution      TEXT,
57
+            created_at      TIMESTAMPTZ DEFAULT NOW(),
58
+            resolved_at     TIMESTAMPTZ,
59
+            closed_at       TIMESTAMPTZ
60
+        );
61
+        CREATE INDEX IF NOT EXISTS idx_pwo_status ON patrol_work_order(status);
62
+        CREATE INDEX IF NOT EXISTS idx_pwo_assignee ON patrol_work_order(assignee_id);
63
+    END IF;
64
+END $$;
65
+
66
+-- 4. 设备巡检点检记录扩展(给 patrol_record 加 device_status 字段)
67
+ALTER TABLE patrol_record ADD COLUMN IF NOT EXISTS device_status VARCHAR(20) DEFAULT 'normal';
68
+ALTER TABLE patrol_record ADD COLUMN IF NOT EXISTS remark TEXT;

+ 70
- 0
sql/V87__patrol_setup.sql ファイルの表示

@@ -0,0 +1,70 @@
1
+-- Issue #87: 巡查设置(区域/路线/表单/模版)
2
+
3
+-- PAT-16: 巡检区域
4
+CREATE TABLE IF NOT EXISTS patrol_area (
5
+    id              BIGSERIAL PRIMARY KEY,
6
+    name            VARCHAR(100) NOT NULL,
7
+    code            VARCHAR(50) UNIQUE,
8
+    parent_id       BIGINT DEFAULT 0,
9
+    description     TEXT,
10
+    lng             DOUBLE PRECISION,
11
+    lat             DOUBLE PRECISION,
12
+    radius          DOUBLE PRECISION DEFAULT 0,
13
+    status          VARCHAR(20) DEFAULT 'active',
14
+    created_at      TIMESTAMPTZ DEFAULT NOW(),
15
+    updated_at      TIMESTAMPTZ DEFAULT NOW()
16
+);
17
+CREATE INDEX IF NOT EXISTS idx_patrol_area_parent ON patrol_area(parent_id);
18
+
19
+-- PAT-17: 巡检路线扩展
20
+-- patrol_route 已在 V1__production.sql 创建,此处补充缺失字段
21
+ALTER TABLE patrol_route ADD COLUMN IF NOT EXISTS area_id BIGINT;
22
+ALTER TABLE patrol_route ADD COLUMN IF NOT EXISTS description TEXT;
23
+ALTER TABLE patrol_route ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
24
+
25
+-- 巡检路线巡检点
26
+CREATE TABLE IF NOT EXISTS patrol_route_checkpoint (
27
+    id              BIGSERIAL PRIMARY KEY,
28
+    route_id        BIGINT NOT NULL,
29
+    checkpoint_seq  INT NOT NULL,
30
+    device_id       BIGINT,
31
+    device_name     VARCHAR(100),
32
+    lng             DOUBLE PRECISION,
33
+    lat             DOUBLE PRECISION,
34
+    check_items     TEXT,  -- JSON array
35
+    created_at      TIMESTAMPTZ DEFAULT NOW()
36
+);
37
+CREATE INDEX IF NOT EXISTS idx_route_checkpoint ON patrol_route_checkpoint(route_id);
38
+
39
+-- PAT-18: 自定义巡检表单
40
+CREATE TABLE IF NOT EXISTS patrol_form (
41
+    id              BIGSERIAL PRIMARY KEY,
42
+    name            VARCHAR(100) NOT NULL,
43
+    type            VARCHAR(30) NOT NULL,  -- daily/special/safety/quality
44
+    fields          TEXT NOT NULL,          -- JSON array of field definitions
45
+    status          VARCHAR(20) DEFAULT 'active',
46
+    created_at      TIMESTAMPTZ DEFAULT NOW(),
47
+    updated_at      TIMESTAMPTZ DEFAULT NOW()
48
+);
49
+
50
+-- PAT-19: 表单绑定关系
51
+CREATE TABLE IF NOT EXISTS patrol_form_binding (
52
+    id              BIGSERIAL PRIMARY KEY,
53
+    form_id         BIGINT NOT NULL,
54
+    target_type     VARCHAR(20) NOT NULL,  -- route/device/area
55
+    target_id       BIGINT NOT NULL,
56
+    created_at      TIMESTAMPTZ DEFAULT NOW(),
57
+    UNIQUE(form_id, target_type, target_id)
58
+);
59
+CREATE INDEX IF NOT EXISTS idx_form_binding_target ON patrol_form_binding(target_type, target_id);
60
+
61
+-- PAT-20: 巡检模板
62
+CREATE TABLE IF NOT EXISTS patrol_template (
63
+    id              BIGSERIAL PRIMARY KEY,
64
+    name            VARCHAR(100) NOT NULL,
65
+    type            VARCHAR(20) NOT NULL,  -- daily/weekly/monthly/special
66
+    config          TEXT NOT NULL,          -- JSON: frequency, routes, workers, schedule
67
+    status          VARCHAR(20) DEFAULT 'active',
68
+    created_at      TIMESTAMPTZ DEFAULT NOW(),
69
+    updated_at      TIMESTAMPTZ DEFAULT NOW()
70
+);

+ 241
- 0
wm-patrol/src/main/java/com/water/patrol/controller/PatrolSetupController.java ファイルの表示

@@ -0,0 +1,241 @@
1
+package com.water.patrol.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.patrol.service.PatrolAreaService;
5
+import com.water.patrol.service.PatrolFormService;
6
+import com.water.patrol.service.PatrolRouteSetupService;
7
+import com.water.patrol.service.PatrolTemplateService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 巡查设置控制器 - Issue #87
18
+ * PAT-16 区域设置 / PAT-17 路线设置 / PAT-18 自定义表单 / PAT-19 表单挂接 / PAT-20 模版设置
19
+ */
20
+@Tag(name = "巡查设置")
21
+@RestController
22
+@RequestMapping("/patrol/setup")
23
+@RequiredArgsConstructor
24
+public class PatrolSetupController {
25
+
26
+    private final PatrolAreaService areaService;
27
+    private final PatrolRouteSetupService routeService;
28
+    private final PatrolFormService formService;
29
+    private final PatrolTemplateService templateService;
30
+
31
+    // ========== PAT-16 区域设置 ==========
32
+
33
+    @PostMapping("/area")
34
+    @Operation(summary = "创建巡检区域")
35
+    public R<Map<String, Object>> createArea(@RequestBody Map<String, Object> req) {
36
+        return R.ok(areaService.create(
37
+            (String) req.get("name"),
38
+            (String) req.get("code"),
39
+            req.get("parentId") != null ? Long.parseLong(String.valueOf(req.get("parentId"))) : null,
40
+            (String) req.get("description"),
41
+            req.get("lng") != null ? ((Number) req.get("lng")).doubleValue() : null,
42
+            req.get("lat") != null ? ((Number) req.get("lat")).doubleValue() : null,
43
+            req.get("radius") != null ? ((Number) req.get("radius")).doubleValue() : null));
44
+    }
45
+
46
+    @PutMapping("/area/{id}")
47
+    @Operation(summary = "更新巡检区域")
48
+    public R<Map<String, Object>> updateArea(@PathVariable Long id, @RequestBody Map<String, Object> req) {
49
+        return R.ok(areaService.update(id,
50
+            (String) req.get("name"),
51
+            (String) req.get("code"),
52
+            req.get("parentId") != null ? Long.parseLong(String.valueOf(req.get("parentId"))) : null,
53
+            (String) req.get("description"),
54
+            req.get("lng") != null ? ((Number) req.get("lng")).doubleValue() : null,
55
+            req.get("lat") != null ? ((Number) req.get("lat")).doubleValue() : null,
56
+            req.get("radius") != null ? ((Number) req.get("radius")).doubleValue() : null));
57
+    }
58
+
59
+    @DeleteMapping("/area/{id}")
60
+    @Operation(summary = "删除巡检区域")
61
+    public R<Void> deleteArea(@PathVariable Long id) {
62
+        areaService.delete(id);
63
+        return R.ok(null);
64
+    }
65
+
66
+    @GetMapping("/area/list")
67
+    @Operation(summary = "区域列表(支持树形)")
68
+    public R<Map<String, Object>> listAreas(@RequestParam(required = false) Long parentId) {
69
+        return R.ok(areaService.list(parentId));
70
+    }
71
+
72
+    @GetMapping("/area/{id}")
73
+    @Operation(summary = "区域详情(含统计)")
74
+    public R<Map<String, Object>> getAreaDetail(@PathVariable Long id) {
75
+        Map<String, Object> detail = areaService.getDetail(id);
76
+        if (detail == null) return R.fail("区域不存在");
77
+        return R.ok(detail);
78
+    }
79
+
80
+    // ========== PAT-17 路线设置 ==========
81
+
82
+    @PostMapping("/route")
83
+    @Operation(summary = "创建巡检路线")
84
+    @SuppressWarnings("unchecked")
85
+    public R<Map<String, Object>> createRoute(@RequestBody Map<String, Object> req) {
86
+        List<Map<String, Object>> checkpoints = (List<Map<String, Object>>) req.get("checkpoints");
87
+        return R.ok(routeService.create(
88
+            (String) req.get("name"),
89
+            req.get("areaId") != null ? Long.parseLong(String.valueOf(req.get("areaId"))) : null,
90
+            checkpoints,
91
+            req.get("estimDuration") != null ? ((Number) req.get("estimDuration")).intValue() : null,
92
+            (String) req.get("description")));
93
+    }
94
+
95
+    @PutMapping("/route/{id}")
96
+    @Operation(summary = "更新巡检路线")
97
+    @SuppressWarnings("unchecked")
98
+    public R<Map<String, Object>> updateRoute(@PathVariable Long id, @RequestBody Map<String, Object> req) {
99
+        List<Map<String, Object>> checkpoints = (List<Map<String, Object>>) req.get("checkpoints");
100
+        return R.ok(routeService.update(id,
101
+            (String) req.get("name"),
102
+            req.get("areaId") != null ? Long.parseLong(String.valueOf(req.get("areaId"))) : null,
103
+            checkpoints,
104
+            req.get("estimDuration") != null ? ((Number) req.get("estimDuration")).intValue() : null,
105
+            (String) req.get("description")));
106
+    }
107
+
108
+    @DeleteMapping("/route/{id}")
109
+    @Operation(summary = "删除巡检路线")
110
+    public R<Void> deleteRoute(@PathVariable Long id) {
111
+        routeService.delete(id);
112
+        return R.ok(null);
113
+    }
114
+
115
+    @GetMapping("/route/list")
116
+    @Operation(summary = "路线列表")
117
+    public R<Map<String, Object>> listRoutes(
118
+            @RequestParam(required = false) Long areaId,
119
+            @RequestParam(defaultValue = "1") int page,
120
+            @RequestParam(defaultValue = "20") int size) {
121
+        return R.ok(routeService.list(areaId, page, size));
122
+    }
123
+
124
+    @GetMapping("/route/{id}")
125
+    @Operation(summary = "路线详情(含巡检点列表)")
126
+    public R<Map<String, Object>> getRouteDetail(@PathVariable Long id) {
127
+        Map<String, Object> detail = routeService.getDetail(id);
128
+        if (detail == null) return R.fail("路线不存在");
129
+        return R.ok(detail);
130
+    }
131
+
132
+    @PostMapping("/route/{id}/copy")
133
+    @Operation(summary = "复制路线")
134
+    public R<Map<String, Object>> copyRoute(@PathVariable Long id) {
135
+        return R.ok(routeService.copyRoute(id));
136
+    }
137
+
138
+    // ========== PAT-18 自定义表单 + PAT-19 表单挂接 ==========
139
+
140
+    @PostMapping("/form")
141
+    @Operation(summary = "创建巡检表单")
142
+    @SuppressWarnings("unchecked")
143
+    public R<Map<String, Object>> createForm(@RequestBody Map<String, Object> req) {
144
+        List<Map<String, Object>> fields = (List<Map<String, Object>>) req.get("fields");
145
+        return R.ok(formService.createForm(
146
+            (String) req.get("name"),
147
+            (String) req.get("type"),
148
+            fields));
149
+    }
150
+
151
+    @GetMapping("/form/list")
152
+    @Operation(summary = "表单列表")
153
+    public R<Map<String, Object>> listForms(@RequestParam(required = false) String type) {
154
+        return R.ok(formService.listForms(type));
155
+    }
156
+
157
+    @GetMapping("/form/{id}")
158
+    @Operation(summary = "表单详情(含字段定义)")
159
+    public R<Map<String, Object>> getFormDetail(@PathVariable Long id) {
160
+        Map<String, Object> form = formService.getForm(id);
161
+        if (form == null) return R.fail("表单不存在");
162
+        return R.ok(form);
163
+    }
164
+
165
+    @PostMapping("/form/{id}/bind")
166
+    @Operation(summary = "挂接表单")
167
+    public R<Map<String, Object>> bindForm(@PathVariable Long id, @RequestBody Map<String, Object> req) {
168
+        String targetType = (String) req.get("targetType");
169
+        Long targetId = Long.parseLong(String.valueOf(req.get("targetId")));
170
+        return R.ok(formService.bindForm(id, targetType, targetId));
171
+    }
172
+
173
+    @PostMapping("/form/{id}/unbind")
174
+    @Operation(summary = "解绑表单")
175
+    public R<Map<String, Object>> unbindForm(@PathVariable Long id, @RequestBody Map<String, Object> req) {
176
+        String targetType = (String) req.get("targetType");
177
+        Long targetId = Long.parseLong(String.valueOf(req.get("targetId")));
178
+        return R.ok(formService.unbindForm(id, targetType, targetId));
179
+    }
180
+
181
+    @GetMapping("/form/bound")
182
+    @Operation(summary = "获取已绑定的表单")
183
+    public R<Map<String, Object>> getBoundForms(
184
+            @RequestParam String targetType,
185
+            @RequestParam Long targetId) {
186
+        return R.ok(formService.getBoundForms(targetType, targetId));
187
+    }
188
+
189
+    // ========== PAT-20 模版设置 ==========
190
+
191
+    @PostMapping("/template")
192
+    @Operation(summary = "创建巡检模板")
193
+    @SuppressWarnings("unchecked")
194
+    public R<Map<String, Object>> createTemplate(@RequestBody Map<String, Object> req) {
195
+        Map<String, Object> config = (Map<String, Object>) req.get("config");
196
+        return R.ok(templateService.create(
197
+            (String) req.get("name"),
198
+            (String) req.get("type"),
199
+            config));
200
+    }
201
+
202
+    @PutMapping("/template/{id}")
203
+    @Operation(summary = "更新巡检模板")
204
+    @SuppressWarnings("unchecked")
205
+    public R<Map<String, Object>> updateTemplate(@PathVariable Long id, @RequestBody Map<String, Object> req) {
206
+        Map<String, Object> config = (Map<String, Object>) req.get("config");
207
+        return R.ok(templateService.update(id,
208
+            (String) req.get("name"),
209
+            (String) req.get("type"),
210
+            config));
211
+    }
212
+
213
+    @DeleteMapping("/template/{id}")
214
+    @Operation(summary = "删除巡检模板")
215
+    public R<Void> deleteTemplate(@PathVariable Long id) {
216
+        templateService.delete(id);
217
+        return R.ok(null);
218
+    }
219
+
220
+    @GetMapping("/template/list")
221
+    @Operation(summary = "模板列表")
222
+    public R<Map<String, Object>> listTemplates(@RequestParam(required = false) String type) {
223
+        return R.ok(templateService.list(type));
224
+    }
225
+
226
+    @GetMapping("/template/{id}")
227
+    @Operation(summary = "模板详情(含关联路线/表单)")
228
+    public R<Map<String, Object>> getTemplateDetail(@PathVariable Long id) {
229
+        Map<String, Object> detail = templateService.getDetail(id);
230
+        if (detail == null) return R.fail("模板不存在");
231
+        return R.ok(detail);
232
+    }
233
+
234
+    @PostMapping("/template/{id}/apply")
235
+    @Operation(summary = "应用模板生成巡检计划")
236
+    @SuppressWarnings("unchecked")
237
+    public R<Map<String, Object>> applyTemplate(@PathVariable Long id, @RequestBody Map<String, Object> req) {
238
+        Map<String, String> dateRange = (Map<String, String>) req.get("dateRange");
239
+        return R.ok(templateService.applyTemplate(id, dateRange));
240
+    }
241
+}

+ 116
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolAreaService.java ファイルの表示

@@ -0,0 +1,116 @@
1
+package com.water.patrol.service;
2
+
3
+import lombok.RequiredArgsConstructor;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.jdbc.core.JdbcTemplate;
6
+import org.springframework.stereotype.Service;
7
+
8
+import java.util.*;
9
+
10
+/**
11
+ * 巡检区域设置服务 - PAT-16
12
+ */
13
+@Slf4j
14
+@Service
15
+@RequiredArgsConstructor
16
+public class PatrolAreaService {
17
+
18
+    private final JdbcTemplate jdbc;
19
+
20
+    public Map<String, Object> create(String name, String code, Long parentId,
21
+                                       String description, Double lng, Double lat, Double radius) {
22
+        jdbc.update(
23
+            "INSERT INTO patrol_area (name, code, parent_id, description, lng, lat, radius, status) " +
24
+            "VALUES (?,?,?,?,?,?,?,'active')",
25
+            name, code, parentId != null ? parentId : 0, description, lng, lat, radius != null ? radius : 0);
26
+        Map<String, Object> result = new LinkedHashMap<>();
27
+        result.put("name", name);
28
+        result.put("code", code);
29
+        result.put("created", true);
30
+        return result;
31
+    }
32
+
33
+    public Map<String, Object> update(Long id, String name, String code, Long parentId,
34
+                                       String description, Double lng, Double lat, Double radius) {
35
+        Map<String, Object> existing = getDetail(id);
36
+        if (existing == null) throw new RuntimeException("区域不存在: " + id);
37
+
38
+        StringBuilder sql = new StringBuilder("UPDATE patrol_area SET updated_at = NOW()");
39
+        List<Object> params = new ArrayList<>();
40
+
41
+        if (name != null) { sql.append(", name = ?"); params.add(name); }
42
+        if (code != null) { sql.append(", code = ?"); params.add(code); }
43
+        if (parentId != null) { sql.append(", parent_id = ?"); params.add(parentId); }
44
+        if (description != null) { sql.append(", description = ?"); params.add(description); }
45
+        if (lng != null) { sql.append(", lng = ?"); params.add(lng); }
46
+        if (lat != null) { sql.append(", lat = ?"); params.add(lat); }
47
+        if (radius != null) { sql.append(", radius = ?"); params.add(radius); }
48
+
49
+        sql.append(" WHERE id = ?");
50
+        params.add(id);
51
+        jdbc.update(sql.toString(), params.toArray());
52
+
53
+        return getDetail(id);
54
+    }
55
+
56
+    public void delete(Long id) {
57
+        long childCount = countQuery("SELECT COUNT(*) FROM patrol_area WHERE parent_id = ?", id);
58
+        if (childCount > 0) throw new RuntimeException("存在子区域,无法删除");
59
+        jdbc.update("DELETE FROM patrol_area WHERE id = ?", id);
60
+    }
61
+
62
+    public Map<String, Object> getDetail(Long id) {
63
+        List<Map<String, Object>> rows = jdbc.queryForList("SELECT * FROM patrol_area WHERE id = ?", id);
64
+        if (rows.isEmpty()) return null;
65
+
66
+        Map<String, Object> area = new LinkedHashMap<>(rows.get(0));
67
+
68
+        long deviceCount = countQuery(
69
+            "SELECT COUNT(*) FROM patrol_device WHERE area = (SELECT code FROM patrol_area WHERE id = ?)", id);
70
+        area.put("deviceCount", deviceCount);
71
+
72
+        long routeCount = countQuery("SELECT COUNT(*) FROM patrol_route WHERE area_id = ?", id);
73
+        area.put("routeCount", routeCount);
74
+
75
+        return area;
76
+    }
77
+
78
+    public Map<String, Object> list(Long parentId) {
79
+        List<Map<String, Object>> areas;
80
+        if (parentId != null) {
81
+            areas = jdbc.queryForList("SELECT * FROM patrol_area WHERE parent_id = ? ORDER BY name", parentId);
82
+        } else {
83
+            areas = jdbc.queryForList("SELECT * FROM patrol_area ORDER BY parent_id, name");
84
+        }
85
+
86
+        List<Map<String, Object>> tree = buildTree(areas, 0L);
87
+
88
+        Map<String, Object> result = new LinkedHashMap<>();
89
+        result.put("total", areas.size());
90
+        result.put("records", tree);
91
+        return result;
92
+    }
93
+
94
+    private List<Map<String, Object>> buildTree(List<Map<String, Object>> all, Long parentId) {
95
+        List<Map<String, Object>> tree = new ArrayList<>();
96
+        for (Map<String, Object> area : all) {
97
+            Object pid = area.get("parent_id");
98
+            long areaParentId = pid instanceof Number ? ((Number) pid).longValue() : 0L;
99
+            if (areaParentId == parentId) {
100
+                Map<String, Object> node = new LinkedHashMap<>(area);
101
+                Long areaId = ((Number) area.get("id")).longValue();
102
+                List<Map<String, Object>> children = buildTree(all, areaId);
103
+                if (!children.isEmpty()) {
104
+                    node.put("children", children);
105
+                }
106
+                tree.add(node);
107
+            }
108
+        }
109
+        return tree;
110
+    }
111
+
112
+    private long countQuery(String sql, Object... args) {
113
+        Long count = jdbc.queryForObject(sql, Long.class, args);
114
+        return count != null ? count : 0;
115
+    }
116
+}

+ 103
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolFormService.java ファイルの表示

@@ -0,0 +1,103 @@
1
+package com.water.patrol.service;
2
+
3
+import lombok.RequiredArgsConstructor;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.jdbc.core.JdbcTemplate;
6
+import org.springframework.stereotype.Service;
7
+
8
+import java.util.*;
9
+
10
+/**
11
+ * 巡检自定义表单服务 - PAT-18 + PAT-19
12
+ */
13
+@Slf4j
14
+@Service
15
+@RequiredArgsConstructor
16
+public class PatrolFormService {
17
+
18
+    private final JdbcTemplate jdbc;
19
+
20
+    // ========== PAT-18: 自定义表单 ==========
21
+
22
+    public Map<String, Object> createForm(String name, String type, List<Map<String, Object>> fields) {
23
+        String fieldsJson = fields != null ? fields.toString() : "[]";
24
+        jdbc.update(
25
+            "INSERT INTO patrol_form (name, type, fields, status) VALUES (?,?,?::jsonb,'active')",
26
+            name, type, fieldsJson);
27
+
28
+        Map<String, Object> result = new LinkedHashMap<>();
29
+        result.put("name", name);
30
+        result.put("type", type);
31
+        result.put("fieldCount", fields != null ? fields.size() : 0);
32
+        result.put("created", true);
33
+        return result;
34
+    }
35
+
36
+    public Map<String, Object> listForms(String type) {
37
+        List<Map<String, Object>> forms;
38
+        if (type != null && !type.isEmpty()) {
39
+            forms = jdbc.queryForList("SELECT * FROM patrol_form WHERE type = ? ORDER BY created_at DESC", type);
40
+        } else {
41
+            forms = jdbc.queryForList("SELECT * FROM patrol_form ORDER BY created_at DESC");
42
+        }
43
+
44
+        Map<String, Object> result = new LinkedHashMap<>();
45
+        result.put("total", forms.size());
46
+        result.put("records", forms);
47
+        return result;
48
+    }
49
+
50
+    public Map<String, Object> getForm(Long id) {
51
+        List<Map<String, Object>> rows = jdbc.queryForList("SELECT * FROM patrol_form WHERE id = ?", id);
52
+        if (rows.isEmpty()) return null;
53
+        return new LinkedHashMap<>(rows.get(0));
54
+    }
55
+
56
+    // ========== PAT-19: 表单挂接 ==========
57
+
58
+    public Map<String, Object> bindForm(Long formId, String targetType, Long targetId) {
59
+        Map<String, Object> form = getForm(formId);
60
+        if (form == null) throw new RuntimeException("表单不存在: " + formId);
61
+
62
+        jdbc.update(
63
+            "INSERT INTO patrol_form_binding (form_id, target_type, target_id) VALUES (?,?,?) " +
64
+            "ON CONFLICT (form_id, target_type, target_id) DO NOTHING",
65
+            formId, targetType, targetId);
66
+
67
+        Map<String, Object> result = new LinkedHashMap<>();
68
+        result.put("formId", formId);
69
+        result.put("targetType", targetType);
70
+        result.put("targetId", targetId);
71
+        result.put("bound", true);
72
+        return result;
73
+    }
74
+
75
+    public Map<String, Object> unbindForm(Long formId, String targetType, Long targetId) {
76
+        int rows = jdbc.update(
77
+            "DELETE FROM patrol_form_binding WHERE form_id = ? AND target_type = ? AND target_id = ?",
78
+            formId, targetType, targetId);
79
+
80
+        Map<String, Object> result = new LinkedHashMap<>();
81
+        result.put("formId", formId);
82
+        result.put("targetType", targetType);
83
+        result.put("targetId", targetId);
84
+        result.put("unbound", rows > 0);
85
+        return result;
86
+    }
87
+
88
+    public Map<String, Object> getBoundForms(String targetType, Long targetId) {
89
+        List<Map<String, Object>> forms = jdbc.queryForList(
90
+            "SELECT pf.*, pfb.created_at as bound_at FROM patrol_form pf " +
91
+            "INNER JOIN patrol_form_binding pfb ON pf.id = pfb.form_id " +
92
+            "WHERE pfb.target_type = ? AND pfb.target_id = ? " +
93
+            "ORDER BY pfb.created_at DESC",
94
+            targetType, targetId);
95
+
96
+        Map<String, Object> result = new LinkedHashMap<>();
97
+        result.put("targetType", targetType);
98
+        result.put("targetId", targetId);
99
+        result.put("total", forms.size());
100
+        result.put("forms", forms);
101
+        return result;
102
+    }
103
+}

+ 166
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolRouteSetupService.java ファイルの表示

@@ -0,0 +1,166 @@
1
+package com.water.patrol.service;
2
+
3
+import lombok.RequiredArgsConstructor;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.jdbc.core.JdbcTemplate;
6
+import org.springframework.stereotype.Service;
7
+
8
+import java.util.*;
9
+
10
+/**
11
+ * 巡检路线设置服务 - PAT-17
12
+ */
13
+@Slf4j
14
+@Service
15
+@RequiredArgsConstructor
16
+public class PatrolRouteSetupService {
17
+
18
+    private final JdbcTemplate jdbc;
19
+
20
+    public Map<String, Object> create(String name, Long areaId, List<Map<String, Object>> checkpoints,
21
+                                       Integer estimDuration, String description) {
22
+        jdbc.update(
23
+            "INSERT INTO patrol_route (route_name, area_id, estim_duration, description, status) " +
24
+            "VALUES (?,?,?,?,'active')",
25
+            name, areaId, estimDuration, description);
26
+
27
+        Long routeId = jdbc.queryForObject("SELECT currval('patrol_route_id_seq')", Long.class);
28
+
29
+        if (checkpoints != null && !checkpoints.isEmpty()) {
30
+            for (int i = 0; i < checkpoints.size(); i++) {
31
+                Map<String, Object> cp = checkpoints.get(i);
32
+                jdbc.update(
33
+                    "INSERT INTO patrol_route_checkpoint (route_id, checkpoint_seq, device_id, device_name, lng, lat, check_items) " +
34
+                    "VALUES (?,?,?,?,?,?,?::jsonb)",
35
+                    routeId, i + 1,
36
+                    cp.get("deviceId"),
37
+                    (String) cp.get("deviceName"),
38
+                    cp.get("lng") != null ? ((Number) cp.get("lng")).doubleValue() : null,
39
+                    cp.get("lat") != null ? ((Number) cp.get("lat")).doubleValue() : null,
40
+                    cp.get("checkItems") != null ? cp.get("checkItems").toString() : "[]");
41
+            }
42
+        }
43
+
44
+        Map<String, Object> result = new LinkedHashMap<>();
45
+        result.put("id", routeId);
46
+        result.put("name", name);
47
+        result.put("areaId", areaId);
48
+        result.put("checkpointCount", checkpoints != null ? checkpoints.size() : 0);
49
+        result.put("created", true);
50
+        return result;
51
+    }
52
+
53
+    public Map<String, Object> update(Long id, String name, Long areaId,
54
+                                       List<Map<String, Object>> checkpoints,
55
+                                       Integer estimDuration, String description) {
56
+        Map<String, Object> existing = getDetail(id);
57
+        if (existing == null) throw new RuntimeException("路线不存在: " + id);
58
+
59
+        StringBuilder sql = new StringBuilder("UPDATE patrol_route SET updated_at = NOW()");
60
+        List<Object> params = new ArrayList<>();
61
+
62
+        if (name != null) { sql.append(", route_name = ?"); params.add(name); }
63
+        if (areaId != null) { sql.append(", area_id = ?"); params.add(areaId); }
64
+        if (estimDuration != null) { sql.append(", estim_duration = ?"); params.add(estimDuration); }
65
+        if (description != null) { sql.append(", description = ?"); params.add(description); }
66
+
67
+        sql.append(" WHERE id = ?");
68
+        params.add(id);
69
+        jdbc.update(sql.toString(), params.toArray());
70
+
71
+        if (checkpoints != null) {
72
+            jdbc.update("DELETE FROM patrol_route_checkpoint WHERE route_id = ?", id);
73
+            for (int i = 0; i < checkpoints.size(); i++) {
74
+                Map<String, Object> cp = checkpoints.get(i);
75
+                jdbc.update(
76
+                    "INSERT INTO patrol_route_checkpoint (route_id, checkpoint_seq, device_id, device_name, lng, lat, check_items) " +
77
+                    "VALUES (?,?,?,?,?,?,?::jsonb)",
78
+                    id, i + 1,
79
+                    cp.get("deviceId"),
80
+                    (String) cp.get("deviceName"),
81
+                    cp.get("lng") != null ? ((Number) cp.get("lng")).doubleValue() : null,
82
+                    cp.get("lat") != null ? ((Number) cp.get("lat")).doubleValue() : null,
83
+                    cp.get("checkItems") != null ? cp.get("checkItems").toString() : "[]");
84
+            }
85
+        }
86
+
87
+        return getDetail(id);
88
+    }
89
+
90
+    public void delete(Long id) {
91
+        jdbc.update("DELETE FROM patrol_route_checkpoint WHERE route_id = ?", id);
92
+        jdbc.update("DELETE FROM patrol_route WHERE id = ?", id);
93
+    }
94
+
95
+    public Map<String, Object> getDetail(Long id) {
96
+        List<Map<String, Object>> rows = jdbc.queryForList("SELECT * FROM patrol_route WHERE id = ?", id);
97
+        if (rows.isEmpty()) return null;
98
+
99
+        Map<String, Object> route = new LinkedHashMap<>(rows.get(0));
100
+
101
+        List<Map<String, Object>> checkpoints = jdbc.queryForList(
102
+            "SELECT * FROM patrol_route_checkpoint WHERE route_id = ? ORDER BY checkpoint_seq", id);
103
+        route.put("checkpoints", checkpoints);
104
+
105
+        Object areaId = route.get("area_id");
106
+        if (areaId != null && areaId instanceof Number) {
107
+            List<Map<String, Object>> areaRows = jdbc.queryForList(
108
+                "SELECT name FROM patrol_area WHERE id = ?", ((Number) areaId).longValue());
109
+            if (!areaRows.isEmpty()) {
110
+                route.put("areaName", areaRows.get(0).get("name"));
111
+            }
112
+        }
113
+
114
+        return route;
115
+    }
116
+
117
+    public Map<String, Object> list(Long areaId, int page, int size) {
118
+        StringBuilder sql = new StringBuilder("SELECT * FROM patrol_route WHERE 1=1");
119
+        List<Object> params = new ArrayList<>();
120
+
121
+        if (areaId != null) {
122
+            sql.append(" AND area_id = ?");
123
+            params.add(areaId);
124
+        }
125
+        sql.append(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
126
+        params.add(size);
127
+        params.add((page - 1) * size);
128
+
129
+        List<Map<String, Object>> records = jdbc.queryForList(sql.toString(), params.toArray());
130
+
131
+        StringBuilder countSql = new StringBuilder("SELECT COUNT(*) FROM patrol_route WHERE 1=1");
132
+        List<Object> countParams = new ArrayList<>();
133
+        if (areaId != null) {
134
+            countSql.append(" AND area_id = ?");
135
+            countParams.add(areaId);
136
+        }
137
+        long total = countQuery(countSql.toString(), countParams.toArray());
138
+
139
+        Map<String, Object> result = new LinkedHashMap<>();
140
+        result.put("total", total);
141
+        result.put("page", page);
142
+        result.put("size", size);
143
+        result.put("records", records);
144
+        return result;
145
+    }
146
+
147
+    public Map<String, Object> copyRoute(Long id) {
148
+        Map<String, Object> source = getDetail(id);
149
+        if (source == null) throw new RuntimeException("路线不存在: " + id);
150
+
151
+        String newName = source.get("route_name") + " (副本)";
152
+        Long areaId = source.get("area_id") instanceof Number ? ((Number) source.get("area_id")).longValue() : null;
153
+        Integer estimDuration = source.get("estim_duration") instanceof Number ? ((Number) source.get("estim_duration")).intValue() : null;
154
+        String description = (String) source.get("description");
155
+
156
+        @SuppressWarnings("unchecked")
157
+        List<Map<String, Object>> checkpoints = (List<Map<String, Object>>) source.get("checkpoints");
158
+
159
+        return create(newName, areaId, checkpoints, estimDuration, description);
160
+    }
161
+
162
+    private long countQuery(String sql, Object... args) {
163
+        Long count = jdbc.queryForObject(sql, Long.class, args);
164
+        return count != null ? count : 0;
165
+    }
166
+}

+ 148
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolTemplateService.java ファイルの表示

@@ -0,0 +1,148 @@
1
+package com.water.patrol.service;
2
+
3
+import lombok.RequiredArgsConstructor;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.jdbc.core.JdbcTemplate;
6
+import org.springframework.stereotype.Service;
7
+
8
+import java.time.LocalDate;
9
+import java.time.LocalDateTime;
10
+import java.time.LocalTime;
11
+import java.util.*;
12
+
13
+/**
14
+ * 巡检模板设置服务 - PAT-20
15
+ */
16
+@Slf4j
17
+@Service
18
+@RequiredArgsConstructor
19
+public class PatrolTemplateService {
20
+
21
+    private final JdbcTemplate jdbc;
22
+
23
+    public Map<String, Object> create(String name, String type, Map<String, Object> config) {
24
+        String configJson = config != null ? config.toString() : "{}";
25
+        jdbc.update(
26
+            "INSERT INTO patrol_template (name, type, config, status) VALUES (?,?,?::jsonb,'active')",
27
+            name, type, configJson);
28
+
29
+        Map<String, Object> result = new LinkedHashMap<>();
30
+        result.put("name", name);
31
+        result.put("type", type);
32
+        result.put("created", true);
33
+        return result;
34
+    }
35
+
36
+    public Map<String, Object> update(Long id, String name, String type, Map<String, Object> config) {
37
+        Map<String, Object> existing = getDetail(id);
38
+        if (existing == null) throw new RuntimeException("模板不存在: " + id);
39
+
40
+        StringBuilder sql = new StringBuilder("UPDATE patrol_template SET updated_at = NOW()");
41
+        List<Object> params = new ArrayList<>();
42
+
43
+        if (name != null) { sql.append(", name = ?"); params.add(name); }
44
+        if (type != null) { sql.append(", type = ?"); params.add(type); }
45
+        if (config != null) { sql.append(", config = ?::jsonb"); params.add(config.toString()); }
46
+
47
+        sql.append(" WHERE id = ?");
48
+        params.add(id);
49
+        jdbc.update(sql.toString(), params.toArray());
50
+
51
+        return getDetail(id);
52
+    }
53
+
54
+    public void delete(Long id) {
55
+        jdbc.update("DELETE FROM patrol_template WHERE id = ?", id);
56
+    }
57
+
58
+    public Map<String, Object> getDetail(Long id) {
59
+        List<Map<String, Object>> rows = jdbc.queryForList("SELECT * FROM patrol_template WHERE id = ?", id);
60
+        if (rows.isEmpty()) return null;
61
+        return new LinkedHashMap<>(rows.get(0));
62
+    }
63
+
64
+    public Map<String, Object> list(String type) {
65
+        List<Map<String, Object>> templates;
66
+        if (type != null && !type.isEmpty()) {
67
+            templates = jdbc.queryForList(
68
+                "SELECT * FROM patrol_template WHERE type = ? ORDER BY created_at DESC", type);
69
+        } else {
70
+            templates = jdbc.queryForList("SELECT * FROM patrol_template ORDER BY created_at DESC");
71
+        }
72
+
73
+        Map<String, Object> result = new LinkedHashMap<>();
74
+        result.put("total", templates.size());
75
+        result.put("records", templates);
76
+        return result;
77
+    }
78
+
79
+    /**
80
+     * 应用模板生成巡检计划
81
+     */
82
+    public Map<String, Object> applyTemplate(Long templateId, Map<String, String> dateRange) {
83
+        Map<String, Object> template = getDetail(templateId);
84
+        if (template == null) throw new RuntimeException("模板不存在: " + templateId);
85
+
86
+        String type = (String) template.get("type");
87
+        String startDateStr = dateRange.get("startDate");
88
+        String endDateStr = dateRange.get("endDate");
89
+        LocalDate startDate = LocalDate.parse(startDateStr);
90
+        LocalDate endDate = LocalDate.parse(endDateStr);
91
+
92
+        List<LocalDate> taskDates = new ArrayList<>();
93
+        LocalDate current = startDate;
94
+
95
+        switch (type) {
96
+            case "daily":
97
+                while (!current.isAfter(endDate)) {
98
+                    taskDates.add(current);
99
+                    current = current.plusDays(1);
100
+                }
101
+                break;
102
+            case "weekly":
103
+                while (!current.isAfter(endDate)) {
104
+                    taskDates.add(current);
105
+                    current = current.plusWeeks(1);
106
+                }
107
+                break;
108
+            case "monthly":
109
+                while (!current.isAfter(endDate)) {
110
+                    taskDates.add(current);
111
+                    current = current.plusMonths(1);
112
+                }
113
+                break;
114
+            case "special":
115
+                taskDates.add(startDate);
116
+                break;
117
+            default:
118
+                taskDates.add(startDate);
119
+        }
120
+
121
+        int taskCount = 0;
122
+        for (LocalDate taskDate : taskDates) {
123
+            try {
124
+                jdbc.update(
125
+                    "INSERT INTO patrol_task (task_name, task_date, plan_start, plan_end, status) " +
126
+                    "VALUES (?, ?, ?, ?, 'pending')",
127
+                    template.get("name") + " - " + taskDate,
128
+                    taskDate,
129
+                    LocalDateTime.of(taskDate, LocalTime.of(9, 0)),
130
+                    LocalDateTime.of(taskDate, LocalTime.of(17, 0)));
131
+                taskCount++;
132
+            } catch (Exception e) {
133
+                log.warn("Failed to create task for date {}: {}", taskDate, e.getMessage());
134
+            }
135
+        }
136
+
137
+        Map<String, Object> result = new LinkedHashMap<>();
138
+        result.put("templateId", templateId);
139
+        result.put("templateName", template.get("name"));
140
+        result.put("type", type);
141
+        result.put("startDate", startDateStr);
142
+        result.put("endDate", endDateStr);
143
+        result.put("taskDates", taskDates.size());
144
+        result.put("tasksCreated", taskCount);
145
+        result.put("applied", true);
146
+        return result;
147
+    }
148
+}