From d15c908aefda9114611b339f242ed51fde7229bb Mon Sep 17 00:00:00 2001 From: chermack <505360893@qq.com> Date: Sat, 1 Aug 2026 04:14:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E6=97=A0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96PF01-PF15=E5=8A=9F=E8=83=BD=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...人机智能巡检平台无依赖功能实施记录_V1.0.md | 48 + frontend/src/services/api.ts | 165 ++ frontend/src/views/gis/GisView.vue | 36 +- frontend/src/views/planning/PlanningView.vue | 56 +- frontend/src/views/routes/RoutesView.vue | 41 +- .../uav-operations/UavOperationsView.vue | 42 +- .../service/CapabilityCompletionService.java | 1 + .../api/FoundationCompletionController.java | 272 +++- .../foundation/api/FoundationRequests.java | 121 ++ .../DependencyFreeCompletionService.java | 1408 +++++++++++++++++ .../service/FoundationCompletionService.java | 1 + .../foundation/service/PlanSchedulerJob.java | 26 + .../V21__dependency_free_pf01_pf15.sql | 577 +++++++ 13 files changed, 2775 insertions(+), 19 deletions(-) create mode 100644 platform/backend/src/main/java/com/ai/trackwalker/foundation/service/DependencyFreeCompletionService.java create mode 100644 platform/backend/src/main/java/com/ai/trackwalker/foundation/service/PlanSchedulerJob.java create mode 100644 platform/backend/src/main/resources/db/migration/V21__dependency_free_pf01_pf15.sql diff --git a/docs/铁路无人机智能巡检平台无依赖功能实施记录_V1.0.md b/docs/铁路无人机智能巡检平台无依赖功能实施记录_V1.0.md index fa125a1..3397dfd 100644 --- a/docs/铁路无人机智能巡检平台无依赖功能实施记录_V1.0.md +++ b/docs/铁路无人机智能巡检平台无依赖功能实施记录_V1.0.md @@ -181,3 +181,51 @@ frontend/src/components/gis/OperationalGisMap.vue ## 8. 后续实施入口 下一批可进入 B 类功能:PF-01、PF-02、PF-05、PF-08、PF-09、PF-10、PF-11、PF-15。开发可以使用明确标记的默认配置和 DEMO 数据,但生产发布仍需相应主数据、规则或设备参数。 + +## 9. B 类无依赖补全续实施记录 + +> 更新日期:2026-08-01 +> 本次范围:PF-01、PF-02、PF-05、PF-08、PF-09、PF-10、PF-11、PF-15 的软件闭环。生产关闭仍按设计文档要求等待权威主数据、设备矩阵、空间规则和现场参数。 + +### 9.1 新增数据库迁移 + +`V21__dependency_free_pf01_pf15.sql` 新增和扩展: + +- PF-01:对象导入映射、导入任务、导入行、对象不可变版本。 +- PF-02:业务日历、计划执行记录、应急授权,以及计划调度时区、补跑策略、执行时间字段。 +- PF-05:作业资源、资质、不可用窗口、资源预占、冲突检查记录。 +- PF-08:GIS 数据集版本、图层、空间要素。 +- PF-09:五类候选航线生成器配置和生成运行记录。 +- PF-10:航线编辑修订、通用动作 Schema、航线编辑锁字段。 +- PF-11:航线估算配置、估算运行记录和航线版本估算结果。 +- PF-15:空间规则集/版本、空间基线要素、豁免区、地理围栏、规则命中解释。 + +迁移同时写入 DEMO/REFERENCE 种子配置,并将 PF-01、02、05、08、09、10、11、15 登记到能力追踪表,阻塞原因明确标记为生产资料待到位。 + +### 9.2 后端接口 + +新增 `DependencyFreeCompletionService`、`PlanSchedulerJob` 和对应 `/api/v1` 端点,覆盖: + +- 对象导入:`/inspection/object-imports` 创建、预检、确认、回滚、行结果与对象版本查询。 +- 计划调度:`/business-calendars`、`/inspection/plans/{id}/preview-runs`、`/inspection/plans/{id}/executions`、`/inspection/plans/run-due`,后台作业每 30 秒扫描到期周期计划。 +- 资源冲突:`/operation-resources/sync`、`/operation-resources/calendar`、`/inspection/tasks/{id}/conflict-checks`、资源预占和取消。 +- GIS 图层:`/gis/layers`、`/gis/dataset-versions`、图层发布和图层要素查询。 +- 航线生成/编辑/估算:`/route-generation-profiles`、`/routes/generate`、`/route-action-schemas`、`/route-versions/{id}/waypoints`、`/route-versions/{id}/estimate`。 +- 空间规则:`/spatial-rule-sets`、`/spatial-exemptions`、`/geofences`、`/spatial-rule-hits/explain`。 + +### 9.3 前端落点 + +- 巡检计划页新增对象导入中心、导入确认/回滚、对象版本抽屉、计划执行预览、执行记录和到期计划触发。 +- 航线中心新增自动生成候选航线、生成器参数、航线估算、编辑修订保存、动作 Schema 和编辑审计查看。 +- GIS 态势页新增图层、规则版本、豁免区、地理围栏面板,并支持对选中点位执行空间命中解释。 +- 无人机运行页新增资源日历,支持同步作业资源、任务冲突检查和资源预占。 + +### 9.4 验证记录 + +| 验证项 | 结果 | +|---|---| +| Vue TypeScript 检查与 Vite 构建 | 通过 | +| Java 局部编译 | 通过,使用仓库 `.m2-repository` 依赖缓存和 `javac -proc:none` | +| Maven 全量测试 | 当前环境未提供 `mvn` 或 Maven Wrapper,未执行 | + +Vite 仍保留既有大分块提示和第三方 PURE 注释提示,不影响功能正确性。 diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4085a32..dbdfcaa 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -949,3 +949,168 @@ export async function createGisExport(payload: Record) { export function gisExportContentUrl(exportId: string) { return `/api/v1/gis/exports/${exportId}/content`; } + +export async function objectMappingProfiles() { + const { data } = await client.get("/inspection/object-mapping-profiles"); + return data.data.profiles; +} + +export async function objectImports() { + const { data } = await client.get("/inspection/object-imports"); + return data.data.imports; +} + +export async function createObjectImport(payload: Record) { + const { data } = await client.post("/inspection/object-imports", payload); + return data.data; +} + +export async function commitObjectImport(importJobId: string) { + const { data } = await client.post(`/inspection/object-imports/${importJobId}/commit`, {}, { params: { confirmedBy: "user-dispatcher" } }); + return data.data; +} + +export async function rollbackObjectImport(importJobId: string) { + const { data } = await client.post(`/inspection/object-imports/${importJobId}/rollback`, {}, { params: { requestedBy: "user-dispatcher" } }); + return data.data; +} + +export async function objectVersions(objectId: string) { + const { data } = await client.get(`/inspection/objects/${objectId}/versions`); + return data.data.versions; +} + +export async function businessCalendars() { + const { data } = await client.get("/business-calendars"); + return data.data.calendars; +} + +export async function previewPlanRuns(planId: string, count = 6) { + const { data } = await client.post(`/inspection/plans/${planId}/preview-runs`, { count }); + return data.data.runs; +} + +export async function planExecutions(planId: string) { + const { data } = await client.get(`/inspection/plans/${planId}/executions`); + return data.data.executions; +} + +export async function runDueInspectionPlans(limit = 20) { + const { data } = await client.post("/inspection/plans/run-due", { limit }); + return data.data; +} + +export async function syncOperationResources() { + const { data } = await client.post("/operation-resources/sync", {}); + return data.data; +} + +export async function operationResourceCalendar(params: Record = {}) { + const { data } = await client.get("/operation-resources/calendar", { params }); + return data.data; +} + +export async function checkTaskConflicts(taskId: string, payload: Record = {}) { + const { data } = await client.post(`/inspection/tasks/${taskId}/conflict-checks`, payload); + return data.data; +} + +export async function reserveTaskResources(taskId: string, payload: Record) { + const { data } = await client.post(`/inspection/tasks/${taskId}/reservations`, payload); + return data.data; +} + +export async function cancelTaskReservation(taskId: string, reservationId: string) { + const { data } = await client.delete(`/inspection/tasks/${taskId}/reservations/${reservationId}`); + return data.data; +} + +export async function gisLayers() { + const { data } = await client.get("/gis/layers"); + return data.data.layers; +} + +export async function gisDatasetVersions() { + const { data } = await client.get("/gis/dataset-versions"); + return data.data.dataset_versions; +} + +export async function gisLayerFeatures(layerCode: string) { + const { data } = await client.get(`/gis/layers/${layerCode}/features`); + return data.data; +} + +export async function routeGenerationProfiles() { + const { data } = await client.get("/route-generation-profiles"); + return data.data.profiles; +} + +export async function generateCandidateRoute(payload: Record) { + const { data } = await client.post("/routes/generate", payload); + return data.data; +} + +export async function routeActionSchemas() { + const { data } = await client.get("/route-action-schemas"); + return data.data.schemas; +} + +export async function updateRouteWaypoints(versionId: string, payload: Record) { + const { data } = await client.put(`/route-versions/${versionId}/waypoints`, payload); + return data.data; +} + +export async function routeEditorRevisions(versionId: string) { + const { data } = await client.get(`/route-versions/${versionId}/editor-revisions`); + return data.data.revisions; +} + +export async function routeEstimationProfiles() { + const { data } = await client.get("/route-estimation-profiles"); + return data.data.profiles; +} + +export async function estimateRouteVersion(versionId: string, payload: Record = {}) { + const { data } = await client.post(`/route-versions/${versionId}/estimate`, payload); + return data.data; +} + +export async function routeEstimations(versionId: string) { + const { data } = await client.get(`/route-versions/${versionId}/estimations`); + return data.data.estimations; +} + +export async function spatialRuleSets() { + const { data } = await client.get("/spatial-rule-sets"); + return data.data.rule_sets; +} + +export async function spatialExemptions() { + const { data } = await client.get("/spatial-exemptions"); + return data.data.exemptions; +} + +export async function createSpatialExemption(payload: Record) { + const { data } = await client.post("/spatial-exemptions", payload); + return data.data; +} + +export async function approveSpatialExemption(exemptionId: string) { + const { data } = await client.post(`/spatial-exemptions/${exemptionId}/approve`, {}, { params: { approvedBy: "user-approver" } }); + return data.data; +} + +export async function geofences() { + const { data } = await client.get("/geofences"); + return data.data.geofences; +} + +export async function createGeofence(payload: Record) { + const { data } = await client.post("/geofences", payload); + return data.data; +} + +export async function explainSpatialRuleHit(payload: Record) { + const { data } = await client.post("/spatial-rule-hits/explain", payload); + return data.data; +} diff --git a/frontend/src/views/gis/GisView.vue b/frontend/src/views/gis/GisView.vue index f5cfdd9..54abf4b 100644 --- a/frontend/src/views/gis/GisView.vue +++ b/frontend/src/views/gis/GisView.vue @@ -4,6 +4,7 @@ 刷新图层 任务回放 生成热力 + 空间解释 导出专题图 @@ -55,6 +56,17 @@ + + +
图层与空间规则PF-08 / PF-15 的图层、规则、豁免和围栏版本
生产数据待批准
+ + + + + + + +
@@ -65,15 +77,17 @@ import { ElMessage } from "element-plus"; import { Close, Refresh } from "@element-plus/icons-vue"; import PageHeader from "../../components/common/PageHeader.vue"; import OperationalGisMap from "../../components/gis/OperationalGisMap.vue"; -import { alarms, createGisExport, gisExportContentUrl, gisFeatures, gisHeatmap, gisPlaybackEvents, gisPlaybackManifest, gisPlaybackTrack, tasks, workorders } from "../../services/api"; +import { alarms, createGisExport, explainSpatialRuleHit, geofences, gisExportContentUrl, gisFeatures, gisHeatmap, gisLayers, gisPlaybackEvents, gisPlaybackManifest, gisPlaybackTrack, spatialExemptions, spatialRuleSets, tasks, workorders } from "../../services/api"; import { safeObject, severityLabel, statusLabel, type Row } from "../../types/demo-run"; const router = useRouter(); const route = useRoute(); const loading = ref(false); const alarmRows = ref([]); const taskRows = ref([]); const workorderRows = ref([]); +const gisLayerRows = ref([]); const ruleSetRows = ref([]); const exemptionRows = ref([]); const geofenceRows = ref([]); const featureCollection = ref({ type: "FeatureCollection", features: [] }); const selected = ref(null); const lineFilter = ref(""); const severityFilter = ref(""); +const spatialTab = ref("layers"); const spatialExplanation = ref(null); const selectedTaskId = ref(String(route.query.taskId || "")); const exportFormat = ref("PDF"); const playbackManifest = ref(null); const playbackTrack = ref(null); const playbackEvents = ref([]); const heatmapFeatures = ref([]); const playbackIndex = ref(0); @@ -119,9 +133,16 @@ const playbackDeviceFeature = computed(() => { }; }); const visiblePlaybackEvents = computed(() => playbackEvents.value.slice(Math.max(0, playbackIndex.value - 4), playbackIndex.value + 1)); +const explanationText = computed(() => { + if (!spatialExplanation.value) return ""; + const geofenceCount = Array.isArray(spatialExplanation.value.geofence_hits) ? spatialExplanation.value.geofence_hits.length : 0; + const exemptionCount = Array.isArray(spatialExplanation.value.exemption_hits) ? spatialExplanation.value.exemption_hits.length : 0; + return `围栏 ${geofenceCount} 项,豁免 ${exemptionCount} 项,规则版本 ${spatialExplanation.value.rule_version_id || "-"}`; +}); function taskLine(taskId: unknown) { return String(taskRows.value.find((row) => String(row.task_id) === String(taskId))?.line_id || "line-demo"); } function relatedWorkorder(alarmId: unknown) { return workorderRows.value.find((row) => String(row.alarm_id) === String(alarmId)); } function locationText(row: Row) { const location = safeObject(row.location); return [location.mileage, location.distance_to_track_m ? `距线路 ${location.distance_to_track_m}m` : ""].filter(Boolean).join(" / ") || "线路邻近"; } +function parseArray(value: unknown): string[] { try { return Array.isArray(value) ? value.map(String) : JSON.parse(String(value || "[]")); } catch { return []; } } function selectFeature(properties: Row) { selected.value = alarmRows.value.find((row) => String(row.alarm_id) === String(properties.alarm_id)) || properties; } function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; } async function loadPlayback() { @@ -169,7 +190,16 @@ async function exportThematicMap() { window.open(gisExportContentUrl(String(result.export_job_id)), "_blank", "noopener,noreferrer"); ElMessage.success(`${exportFormat.value} 专题成果已生成,摘要 ${String(result.checksum).slice(0, 16)}`); } -async function load() { loading.value = true; try { [alarmRows.value, taskRows.value, workorderRows.value, featureCollection.value] = await Promise.all([alarms(), tasks(), workorders(), gisFeatures()]); if (!selectedTaskId.value) selectedTaskId.value = String(taskRows.value[0]?.task_id || ""); selected.value = filteredAlarms.value[0] || null; } finally { loading.value = false; } } +async function explainSelected() { + if (!selected.value) return; + const geometry = safeObject(selected.value.location).geometry || selected.value.geometry; + if (!geometry) return ElMessage.warning("当前点位没有可解释空间几何"); + const result = await explainSpatialRuleHit({ task_id: selected.value.task_id, geometry, scene: selected.value.scene, object_type: selected.value.category }); + spatialExplanation.value = result; + spatialTab.value = "rules"; + ElMessage.success(`空间解释完成:${result.decision}`); +} +async function load() { loading.value = true; try { [alarmRows.value, taskRows.value, workorderRows.value, featureCollection.value, gisLayerRows.value, ruleSetRows.value, exemptionRows.value, geofenceRows.value] = await Promise.all([alarms(), tasks(), workorders(), gisFeatures(), gisLayers(), spatialRuleSets(), spatialExemptions(), geofences()]); if (!selectedTaskId.value) selectedTaskId.value = String(taskRows.value[0]?.task_id || ""); selected.value = filteredAlarms.value[0] || null; } finally { loading.value = false; } } onMounted(load); onBeforeUnmount(pausePlayback); @@ -185,4 +215,6 @@ onBeforeUnmount(pausePlayback); .playback-controls { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; color: var(--el-text-color-secondary); } .speed-select { width: 88px; } .export-format { width: 150px; } +.spatial-card { margin-top: 18px; } +.spatial-explanation { margin-top: 14px; } diff --git a/frontend/src/views/planning/PlanningView.vue b/frontend/src/views/planning/PlanningView.vue index 7d838d7..1c6fc13 100644 --- a/frontend/src/views/planning/PlanningView.vue +++ b/frontend/src/views/planning/PlanningView.vue @@ -2,6 +2,7 @@
刷新 + 执行到期计划 新建计划 @@ -32,7 +33,7 @@ -
新增对象
+
导入中心新增对象
@@ -42,8 +43,31 @@ +
+ + +
+
{{ importJobs.length }}导入任务预检、确认与回滚
+
{{ readyImportCount }}可确认无错误导入批次
+
{{ importRowsTotal }}导入行当前任务合计
+
{{ calendars.length }}业务日历周期计划引用
+
+ +
快速导入JSON 行数组,生产数据导入前保持 DEMO 标记
预检导入
+ +
+ + + + + + + + + +
@@ -60,7 +84,11 @@ - + + + + +
@@ -72,16 +100,21 @@ import { Plus, Refresh, Search } from "@element-plus/icons-vue"; import PageHeader from "../../components/common/PageHeader.vue"; import InspectionPlanForm from "../../components/planning/InspectionPlanForm.vue"; import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination"; -import { activateInspectionPlan, createInspectionObject, createInspectionPlan, generatePlanTasks, inspectionObjects, inspectionPlans } from "../../services/api"; +import { activateInspectionPlan, businessCalendars, commitObjectImport, createInspectionObject, createInspectionPlan, createObjectImport, generatePlanTasks, inspectionObjects, inspectionPlans, objectImports, objectVersions, planExecutions, previewPlanRuns, rollbackObjectImport, runDueInspectionPlans } from "../../services/api"; import { safeObject, type Row } from "../../types/demo-run"; const route = useRoute(); const router = useRouter(); -const plans = ref([]); const objects = ref([]); const loading = ref(false); const saving = ref(false); const activeTab = ref("plans"); const keyword = ref(""); const objectType = ref(""); -const planDialog = ref(false); const planFormKey = ref(0); const planTrigger = ref<"PERIODIC" | "MANUAL">("PERIODIC"); const objectDialog = ref(false); const detailVisible = ref(false); const selectedPlan = ref(null); +const plans = ref([]); const objects = ref([]); const importJobs = ref([]); const calendars = ref([]); const previewRunsRows = ref([]); const executionRows = ref([]); const objectVersionRows = ref([]); const loading = ref(false); const saving = ref(false); const activeTab = ref("plans"); const keyword = ref(""); const objectType = ref(""); +const planDialog = ref(false); const planFormKey = ref(0); const planTrigger = ref<"PERIODIC" | "MANUAL">("PERIODIC"); const objectDialog = ref(false); const detailVisible = ref(false); const versionVisible = ref(false); const selectedPlan = ref(null); const selectedObject = ref(null); const objectTypes = [{ value: "RAILWAY", label: "铁路线路" }, { value: "BRIDGE", label: "桥梁" }, { value: "TUNNEL", label: "隧道" }, { value: "FLOOD", label: "防洪设施" }, { value: "POWER", label: "电力设施" }]; const scenes = ["异物侵限", "护网破损", "桥梁裂缝", "隧道渗漏", "防洪体积变化", "接触网异物", "设备发热"]; const objectForm = reactive({ name: "", object_type: "RAILWAY", line_id: "line-demo", mileage_start: "", mileage_end: "", risk_level: "NORMAL", longitude: 116.1, latitude: 39.1 }); +const objectImportText = ref(JSON.stringify([ + { source_key: "demo-import-bridge-001", object_type: "BRIDGE", name: "导入桥梁样例", line_id: "line-demo", mileage_start: "K126+950", mileage_end: "K127+120", owner_org_id: "org-works", risk_level: "HIGH", longitude: 116.132, latitude: 39.116 }, + { source_key: "demo-import-power-001", object_type: "POWER", name: "导入接触网样例", line_id: "line-demo", mileage_start: "K128+500", mileage_end: "K128+620", owner_org_id: "org-power", risk_level: "NORMAL", longitude: 116.154, latitude: 39.107 } +], null, 2)); const periodicPlans = computed(() => plans.value.filter((item) => item.trigger_type !== "MANUAL").length); const manualPlans = computed(() => plans.value.filter((item) => item.trigger_type === "MANUAL").length); const highRiskObjects = computed(() => objects.value.filter((item) => ["HIGH", "CRITICAL"].includes(item.risk_level)).length); const generatedTasks = computed(() => plans.value.reduce((sum, item) => sum + Number(item.generated_task_count || 0), 0)); +const readyImportCount = computed(() => importJobs.value.filter((item) => item.status === "READY").length); const importRowsTotal = computed(() => importJobs.value.reduce((sum, item) => sum + Number(item.total_count || 0), 0)); const filteredObjects = computed(() => objects.value.filter((item) => (!objectType.value || item.object_type === objectType.value) && (!keyword.value || `${item.name} ${item.line_id} ${item.mileage_start}`.toLowerCase().includes(keyword.value.toLowerCase())))); const { currentPage: planPage, pageSize: planPageSize, pagedItems: pagedPlans } = usePagination(plans); const { currentPage: objectPage, pageSize: objectPageSize, pagedItems: pagedObjects, resetPage: resetObjectPage } = usePagination(filteredObjects); @@ -107,13 +140,22 @@ function parseNumberArray(value: unknown): number[] { return Array.isArray(value function planStatusLabel(row: Row) { if (row.trigger_type === "MANUAL") return Number(row.generated_task_count) ? "任务已生成" : "待创建任务"; return row.status === "ACTIVE" ? "已启用" : "草稿"; } function planStatusTag(row: Row): "success" | "warning" | "info" { if (row.trigger_type === "MANUAL") return Number(row.generated_task_count) ? "success" : "warning"; return row.status === "ACTIVE" ? "success" : "info"; } function objectNames(value: unknown) { const ids = parseArray(value); return ids.map((id) => objects.value.find((item) => item.object_id === id)?.name || id).join("、"); } -async function load() { loading.value = true; try { [plans.value, objects.value] = await Promise.all([inspectionPlans(), inspectionObjects()]); } finally { loading.value = false; } } +function importStatusLabel(value: unknown) { return ({ READY: "可确认", VALIDATED_WITH_ERRORS: "有错误", COMMITTED: "已入库", ROLLED_BACK: "已回滚", CREATED: "已创建" } as Record)[String(value)] || String(value || "-"); } +function importStatusTag(value: unknown): "success" | "warning" | "danger" | "info" { if (value === "READY") return "success"; if (value === "VALIDATED_WITH_ERRORS") return "danger"; if (value === "COMMITTED") return "warning"; return "info"; } +function importReport(value: unknown) { const data = safeObject(value); return String(data.production_gate || data.software_scope || "-"); } +async function load() { loading.value = true; try { [plans.value, objects.value, importJobs.value, calendars.value] = await Promise.all([inspectionPlans(), inspectionObjects(), objectImports(), businessCalendars()]); } finally { loading.value = false; } } function openPlanDialog(trigger: "PERIODIC" | "MANUAL" = "PERIODIC") { planTrigger.value = trigger; planFormKey.value += 1; planDialog.value = true; } async function savePlan(request: { payload: Record; generateTasks: boolean }) { saving.value = true; try { const created = await createInspectionPlan(request.payload); if (request.generateTasks) { const result = await generatePlanTasks(String(created.plan_id)); ElMessage.success(`手动巡检已创建 ${result.generated} 条任务`); } else { ElMessage.success("周期巡检计划已创建,请确认后启用"); } planDialog.value = false; await load(); } finally { saving.value = false; } } async function saveObject() { if (!objectForm.name || !objectForm.line_id) return ElMessage.warning("请填写对象名称和线路"); saving.value = true; try { await createInspectionObject({ ...objectForm, geometry: { type: "Point", coordinates: [objectForm.longitude, objectForm.latitude] }, owner_org_id: objectForm.object_type === "FLOOD" ? "org-flood" : "org-works", inspection_cycle_days: 7, source_crs: "EPSG:4326" }); objectDialog.value = false; ElMessage.success("巡检对象已创建"); await load(); } finally { saving.value = false; } } async function activate(row: Row) { await activateInspectionPlan(String(row.plan_id)); ElMessage.success("计划已启用"); await load(); } async function generate(row: Row) { const result = await generatePlanTasks(String(row.plan_id)); ElMessage.success(`已生成 ${result.generated} 个巡检任务`); await load(); } -function inspectPlan(row: Row) { selectedPlan.value = row; detailVisible.value = true; } +async function runDuePlans() { const result = await runDueInspectionPlans(); ElMessage.success(`调度扫描 ${result.scanned} 个到期计划`); await load(); } +async function createDemoImport() { let rows: Row[] = []; try { rows = JSON.parse(objectImportText.value); } catch { return ElMessage.warning("请输入合法 JSON 行数组"); } if (!Array.isArray(rows) || rows.length === 0) return ElMessage.warning("导入内容不能为空"); saving.value = true; try { const result = await createObjectImport({ file_name: `demo-object-import-${Date.now()}.json`, file_type: "JSON_ROWS", mapping_profile_id: "object-mapping-demo-line", declared_crs: "EPSG:4326", rows, created_by: "user-dispatcher" }); ElMessage[result.status === "READY" ? "success" : "warning"](`预检完成:有效 ${result.valid_count},错误 ${result.invalid_count}`); await load(); } finally { saving.value = false; } } +async function commitImport(row: Row) { const result = await commitObjectImport(String(row.import_job_id)); ElMessage.success(`已入库 ${result.object_ids?.length || 0} 个对象`); await load(); } +async function rollbackImport(row: Row) { const result = await rollbackObjectImport(String(row.import_job_id)); ElMessage.success(`已回滚 ${result.rolled_back_objects || 0} 个对象`); await load(); } +async function loadPlanRuntime(row: Row) { [previewRunsRows.value, executionRows.value] = await Promise.all([previewPlanRuns(String(row.plan_id)), planExecutions(String(row.plan_id))]); } +async function inspectPlan(row: Row) { selectedPlan.value = row; detailVisible.value = true; await loadPlanRuntime(row); } +async function showObjectVersions(row: Row) { selectedObject.value = row; objectVersionRows.value = await objectVersions(String(row.object_id)); versionVisible.value = true; } watch([keyword, objectType], resetObjectPage); onMounted(async () => { await load(); diff --git a/frontend/src/views/routes/RoutesView.vue b/frontend/src/views/routes/RoutesView.vue index 2a78f8a..83d0c70 100644 --- a/frontend/src/views/routes/RoutesView.vue +++ b/frontend/src/views/routes/RoutesView.vue @@ -2,6 +2,7 @@
刷新 + 自动生成 新建航线 @@ -21,9 +22,23 @@ - + + + + +
+ + + + + 米/秒 + +
+ +
+
米/秒
航点参数WGS84 坐标,高德底图仅在展示层转换
增加航点
@@ -31,7 +46,7 @@
- +
@@ -41,32 +56,46 @@ import { ElMessage } from "element-plus"; import { Delete, Plus, Refresh, Search } from "@element-plus/icons-vue"; import PageHeader from "../../components/common/PageHeader.vue"; import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination"; -import { createInspectionRoute, createRouteVersion, executeWorkflowAction, inspectionObjects, inspectionRoutes, publishRouteVersion, rollbackRoute, routeVersions, validateRouteVersion } from "../../services/api"; +import { createInspectionRoute, createRouteVersion, estimateRouteVersion, executeWorkflowAction, generateCandidateRoute, inspectionObjects, inspectionRoutes, publishRouteVersion, rollbackRoute, routeActionSchemas, routeEditorRevisions, routeGenerationProfiles, routeVersions, updateRouteWaypoints, validateRouteVersion } from "../../services/api"; import { safeObject, type Row } from "../../types/demo-run"; -const rows = ref([]); const objects = ref([]); const versionRows = ref([]); const loading = ref(false); const saving = ref(false); const createDialog = ref(false); const detailVisible = ref(false); const selected = ref(null); const keyword = ref(""); const statusFilter = ref(""); +const rows = ref([]); const objects = ref([]); const versionRows = ref([]); const generatorProfiles = ref([]); const revisionRows = ref([]); const actionSchemas = ref([]); const loading = ref(false); const saving = ref(false); const createDialog = ref(false); const generateDialog = ref(false); const detailVisible = ref(false); const selected = ref(null); const keyword = ref(""); const statusFilter = ref(""); const form = reactive({ name: "铁路沿线巡检航线", object_id: "object-line-demo", speed_mps: 7, rth_altitude_m: 100, waypoints: [{ longitude: 116.09, latitude: 39.095, altitude_m: 80, gimbal_pitch_deg: -45 }, { longitude: 116.125, latitude: 39.115, altitude_m: 80, gimbal_pitch_deg: -45 }, { longitude: 116.165, latitude: 39.105, altitude_m: 80, gimbal_pitch_deg: -45 }] }); +const generateForm = reactive({ object_id: "object-line-demo", generator_type: "", waypoint_count: 5, altitude_m: 80, speed_mps: 7, create_route: true }); const filteredRows = computed(() => rows.value.filter((row) => (!statusFilter.value || row.status === statusFilter.value) && (!keyword.value || `${row.name} ${row.object_name} ${row.template_name}`.toLowerCase().includes(keyword.value.toLowerCase())))); const publishedCount = computed(() => rows.value.filter((row) => row.status === "PUBLISHED").length); const draftCount = computed(() => rows.value.filter((row) => row.status !== "PUBLISHED").length); const totalWaypoints = computed(() => rows.value.reduce((sum, row) => sum + parseWaypoints(row.waypoints).length, 0)); +const generatorOptions = computed(() => generatorProfiles.value.filter((item) => !generateForm.object_id || item.object_type === objects.value.find((object) => object.object_id === generateForm.object_id)?.object_type)); const { currentPage: page, pageSize, pagedItems: pagedRows, resetPage } = usePagination(filteredRows); function parseWaypoints(value: unknown): Row[] { try { return Array.isArray(value) ? value as Row[] : JSON.parse(String(value || "[]")); } catch { return []; } } function validation(row: Row): Row { return safeObject(row.validation_result); } function activeVersionId(row: Row) { return String(row.display_version_id || row.working_version_id || row.current_version_id || ""); } function routeStatusLabel(value: unknown) { return ({ DRAFT: "草稿", PENDING_APPROVAL: "审批中", APPROVED: "已审批", PUBLISHED: "已发布", REJECTED: "已驳回", RETIRED: "已退役" } as Record)[String(value)] || String(value || "-"); } function approvalLabel(value: unknown) { return ({ DRAFT: "待提交", PENDING_APPROVAL: "审批中", APPROVED: "审批通过", REJECTED: "已驳回" } as Record)[String(value)] || String(value || "待提交"); } +function objectTypeLabel(value: unknown) { return ({ RAILWAY: "铁路线路", BRIDGE: "桥梁", TUNNEL: "隧道", FLOOD: "防洪", POWER: "电力" } as Record)[String(value)] || String(value || "-"); } function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; } function jsonText(value: unknown) { const data = typeof value === "string" ? safeObject(value) : value; return Object.entries(data as Row || {}).map(([key, item]) => `${key}: ${Array.isArray(item) ? item.length + '项' : item}`).join(",") || "-"; } +function routeEstimate(row: unknown) { return typeof row === "string" ? safeObject(row) : safeObject(row); } +function routeEstimateText(value: unknown) { const data = routeEstimate(value); return data.estimated_minutes ? `${data.estimated_minutes} 分钟 · 电量 ${data.battery_percent}% · ${data.storage_mb} MB` : "待估算"; } +function estimateSummary(row: Row) { return routeEstimateText(row.estimation_result); } function addWaypoint() { const previous = form.waypoints.at(-1) || { longitude: 116.1, latitude: 39.1, altitude_m: 80, gimbal_pitch_deg: -45 }; form.waypoints.push({ ...previous, longitude: previous.longitude + 0.005, latitude: previous.latitude + 0.003 }); } -async function inspect(row: Row) { selected.value = row; detailVisible.value = true; await loadVersions(row); } +async function inspect(row: Row) { selected.value = row; detailVisible.value = true; await Promise.all([loadVersions(row), loadRevisionRows(row)]); } function pointStyle(_point: Row, index: number) { const count = Math.max(2, parseWaypoints(selected.value?.waypoints).length); return { left: `${10 + index * 80 / (count - 1)}%`, top: `${60 - Math.sin(index * 1.4) * 25}%` }; } -async function load() { loading.value = true; try { [rows.value, objects.value] = await Promise.all([inspectionRoutes(), inspectionObjects()]); } finally { loading.value = false; } } +async function load() { loading.value = true; try { [rows.value, objects.value, generatorProfiles.value, actionSchemas.value] = await Promise.all([inspectionRoutes(), inspectionObjects(), routeGenerationProfiles(), routeActionSchemas()]); if (!generateForm.generator_type && generatorProfiles.value.length) generateForm.generator_type = String(generatorProfiles.value[0].generator_type); } finally { loading.value = false; } } async function save() { if (!form.name || form.waypoints.length < 2) return ElMessage.warning("请填写航线名称并配置至少两个航点"); saving.value = true; try { await createInspectionRoute({ name: form.name, object_id: form.object_id, template_id: "template-railway", waypoints: form.waypoints.map((point, index) => ({ ...point, sequence: index + 1, speed_mps: form.speed_mps, actions: ["TAKE_PHOTO"] })), flight_parameters: { speed_mps: form.speed_mps, rth_altitude_m: form.rth_altitude_m }, payload_actions: [{ type: "TAKE_PHOTO", interval_s: 2 }], created_by: "user-dispatcher" }); createDialog.value = false; ElMessage.success("航线已创建并完成校验"); await load(); } finally { saving.value = false; } } async function validateRow(row: Row) { const result = await validateRouteVersion(activeVersionId(row)); ElMessage[result.valid ? "success" : "error"](result.valid ? "航线校验通过" : result.errors.join(";")); await load(); } +async function estimateRow(row: Row) { const result = await estimateRouteVersion(activeVersionId(row), { created_by: "route-estimator" }); ElMessage[result.warnings?.length ? "warning" : "success"](`估算完成:${result.estimated_minutes} 分钟,电量 ${result.battery_percent}%`); await load(); if (selected.value?.route_id === row.route_id) selected.value = rows.value.find((item) => item.route_id === row.route_id) || selected.value; } async function submitRow(row: Row) { const action = String(row.approval_status) === "REJECTED" ? "RESUBMIT" : "SUBMIT"; await executeWorkflowAction("ROUTE_VERSION", activeVersionId(row), action as "SUBMIT" | "RESUBMIT", "航线校验完成,提交发布审批"); ElMessage.success("航线已进入审批队列"); await load(); } async function publishRow(row: Row) { try { await publishRouteVersion(activeVersionId(row)); ElMessage.success("航线版本已发布,历史版本已保留"); await load(); } catch { /* global error */ } } async function newVersion(row: Row) { const result = await createRouteVersion(String(row.route_id), { source_version_id: activeVersionId(row), change_summary: "基于当前版本创建编辑草稿", created_by: "航线管理员" }); ElMessage.success(`已创建 V${result.version_no} 草稿`); await load(); } async function loadVersions(row: Row) { versionRows.value = await routeVersions(String(row.route_id)); } +async function loadRevisionRows(row: Row) { revisionRows.value = await routeEditorRevisions(activeVersionId(row)); } async function rollbackVersion(version: Row) { if (!selected.value) return; const result = await rollbackRoute(String(selected.value.route_id), String(version.route_version_id), `回滚至 V${version.version_no}`); ElMessage.success(`已创建回滚草稿 V${result.version_no},请重新校验和审批`); await load(); const refreshed = rows.value.find((row) => row.route_id === selected.value?.route_id); if (refreshed) { selected.value = refreshed; await loadVersions(refreshed); } } +async function generateRouteFromObject() { if (!generateForm.object_id) return ElMessage.warning("请选择巡检对象"); saving.value = true; try { const result = await generateCandidateRoute({ object_id: generateForm.object_id, generator_type: generateForm.generator_type || undefined, create_route: generateForm.create_route, route_name: `${objects.value.find((item) => item.object_id === generateForm.object_id)?.name || "对象"} 候选航线`, parameters: { waypoint_count: generateForm.waypoint_count, altitude_m: generateForm.altitude_m, speed_mps: generateForm.speed_mps }, created_by: "route-generator" }); ElMessage.success(generateForm.create_route ? `已创建候选航线 ${result.created_route?.route_id}` : `已生成 ${result.waypoints?.length || 0} 个候选航点`); generateDialog.value = false; await load(); } finally { saving.value = false; } } +async function saveWaypointRevision(row: Row) { const waypoints = parseWaypoints(row.waypoints).map((point, index) => ({ ...point, sequence: index + 1 })); const result = await updateRouteWaypoints(activeVersionId(row), { waypoints, flight_parameters: safeObject(row.flight_parameters), payload_actions: parseWaypoints(row.payload_actions), change_summary: "前端地图编辑器保存航点修订", editor_id: "route-editor", expected_version_lock: Number(row.version_lock || 0) }); ElMessage.success(`已保存编辑修订 V${result.version_lock}`); await load(); const refreshed = rows.value.find((item) => item.route_id === row.route_id); if (refreshed) { selected.value = refreshed; await Promise.all([loadVersions(refreshed), loadRevisionRows(refreshed)]); } } watch([keyword, statusFilter], resetPage); +watch(() => generateForm.object_id, () => { + const first = generatorOptions.value[0]; + if (first) generateForm.generator_type = String(first.generator_type); +}); onMounted(load); diff --git a/frontend/src/views/uav-operations/UavOperationsView.vue b/frontend/src/views/uav-operations/UavOperationsView.vue index c8e30e3..d2aa996 100644 --- a/frontend/src/views/uav-operations/UavOperationsView.vue +++ b/frontend/src/views/uav-operations/UavOperationsView.vue @@ -30,6 +30,30 @@ + +
+ + + 同步资源 + 冲突检查 + 预占选中资源 +
+ + + + + + + + + + + +
资源预占与不可用窗口下发前硬门禁使用同一资源日历
+ +
+
+ @@ -63,11 +87,11 @@ import { ElMessage } from "element-plus"; import { CircleCheck, Position, Promotion, Refresh, Warning } from "@element-plus/icons-vue"; import PageHeader from "../../components/common/PageHeader.vue"; import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination"; -import { checkUavConnection, dispatchUavMission, executeUavMissionCommand, synchronizeUavDevices, tasks, uavConnections, uavDevices, uavMissions, uavMissionTelemetry } from "../../services/api"; +import { checkTaskConflicts, checkUavConnection, dispatchUavMission, executeUavMissionCommand, operationResourceCalendar, reserveTaskResources, syncOperationResources, synchronizeUavDevices, tasks, uavConnections, uavDevices, uavMissions, uavMissionTelemetry } from "../../services/api"; import { safeObject, type Row } from "../../types/demo-run"; const route = useRoute(); -const connections = ref([]); const devices = ref([]); const missions = ref([]); const taskRows = ref([]); const loading = ref(false); const saving = ref(false); const activeTab = ref(String(route.query.tab || "connections")); const dispatchDialog = ref(false); const healthVisible = ref(false); const healthResult = ref(null); const missionVisible = ref(false); const selectedMission = ref(null); const telemetry = ref([]); const deepLinkHandled = ref(false); +const connections = ref([]); const devices = ref([]); const missions = ref([]); const taskRows = ref([]); const resourceRows = ref([]); const reservationRows = ref([]); const unavailableRows = ref([]); const selectedResourceIds = ref([]); const conflictResult = ref(null); const resourceTaskId = ref(""); const loading = ref(false); const saving = ref(false); const activeTab = ref(String(route.query.tab || "connections")); const dispatchDialog = ref(false); const healthVisible = ref(false); const healthResult = ref(null); const missionVisible = ref(false); const selectedMission = ref(null); const telemetry = ref([]); const deepLinkHandled = ref(false); const dispatchForm = reactive({ task_id: "", route_id: "", connection_id: "connection-simulator", device_id: "uav-sim-01", dock_id: "dock-sim-01" }); const availableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE").length); const onlineDevices = computed(() => devices.value.filter((item) => item.status === "ONLINE").length); const activeMissions = computed(() => missions.value.filter((item) => ["DISPATCHING","DISPATCHED","PREPARING","FLYING","PAUSED","RETURNING"].includes(item.status)).length); const completedMissions = computed(() => missions.value.filter((item) => item.status === "COMPLETED").length); const dispatchableTasks = computed(() => taskRows.value.filter((item) => item.can_dispatch === true || String(item.can_dispatch).toLowerCase() === "true")); const selectedTask = computed(() => taskRows.value.find((item) => String(item.task_id) === dispatchForm.task_id)); const usableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE")); const availableDevices = computed(() => devices.value.filter((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE")); const { currentPage: connectionPage, pageSize: connectionPageSize, pagedItems: pagedConnections } = usePagination(connections); @@ -82,12 +106,24 @@ function missionTag(value: unknown): "success" | "warning" | "danger" | "info" | function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; } function timeOnly(value: unknown) { return value ? new Date(String(value)).toLocaleTimeString("zh-CN", { hour12: false }) : "-"; } function layerLabel(value: unknown) { return ({ NETWORK: "网络可达", AUTHENTICATION: "身份认证", CAPABILITIES: "能力读取", BUSINESS_HANDSHAKE: "业务握手" } as Record)[String(value)] || String(value); } function telemetrySummary(value: unknown) { const data = safeObject(value); return `电量 ${data.battery_percent ?? '-'}% · 链路 ${data.link_quality ?? '-'}% · RTK ${data.rtk_status || '-'}`; } -async function load() { loading.value = true; try { [connections.value, devices.value, missions.value, taskRows.value] = await Promise.all([uavConnections(), uavDevices(), uavMissions(), tasks()]); if (!dispatchForm.task_id && dispatchableTasks.value.length) dispatchForm.task_id = String(dispatchableTasks.value[0].task_id); selectTask(); if (!usableConnections.value.some((item) => item.connection_id === dispatchForm.connection_id)) dispatchForm.connection_id = String(usableConnections.value[0]?.connection_id || ""); selectConnection(); } finally { loading.value = false; } } +const conflictSummary = computed(() => { + if (!conflictResult.value) return ""; + const checks = Array.isArray(conflictResult.value.checks) ? conflictResult.value.checks as Row[] : []; + return checks.filter((item) => item.level !== "PASSED").map((item) => `${item.code}: ${item.message}`).join(";") || "所有资源窗口可用,天气和空域若未接入会显示 NOT_CHECKED"; +}); +function resourceTypeLabel(value: unknown) { return ({ AIRCRAFT: "无人机", DOCK: "机巢", PAYLOAD: "载荷", PILOT: "飞手" } as Record)[String(value)] || String(value || "-"); } +function resourceHealth(value: unknown) { const data = safeObject(value); return Object.entries(data).slice(0, 2).map(([key, item]) => `${key}:${item}`).join(" · ") || "-"; } +async function loadResourceCalendar() { const calendar = await operationResourceCalendar(); resourceRows.value = calendar.resources || []; reservationRows.value = calendar.reservations || []; unavailableRows.value = calendar.unavailable_windows || []; if (!resourceTaskId.value) resourceTaskId.value = String(dispatchableTasks.value[0]?.task_id || taskRows.value[0]?.task_id || ""); } +async function load() { loading.value = true; try { [connections.value, devices.value, missions.value, taskRows.value] = await Promise.all([uavConnections(), uavDevices(), uavMissions(), tasks()]); if (!dispatchForm.task_id && dispatchableTasks.value.length) dispatchForm.task_id = String(dispatchableTasks.value[0].task_id); if (!resourceTaskId.value) resourceTaskId.value = String(dispatchableTasks.value[0]?.task_id || taskRows.value[0]?.task_id || ""); selectTask(); if (!usableConnections.value.some((item) => item.connection_id === dispatchForm.connection_id)) dispatchForm.connection_id = String(usableConnections.value[0]?.connection_id || ""); selectConnection(); await loadResourceCalendar(); } finally { loading.value = false; } } function selectTask() { dispatchForm.route_id = String(selectedTask.value?.route_id || ""); } function selectConnection() { const first = devices.value.find((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE"); dispatchForm.device_id = first ? String(first.device_id) : ""; dispatchForm.dock_id = ""; } function openDispatchDialog(taskId?: string) { const target = taskId ? dispatchableTasks.value.find((item) => String(item.task_id) === taskId) : dispatchableTasks.value[0]; if (!target) return ElMessage.warning(taskId ? "该任务当前不可下发,请返回巡检任务查看准备状态" : "当前没有满足审批、航线和状态条件的待下发任务"); dispatchForm.task_id = String(target.task_id); selectTask(); activeTab.value = "missions"; dispatchDialog.value = true; } async function checkConnection(row: Row) { healthResult.value = await checkUavConnection(String(row.connection_id)); healthVisible.value = true; await load(); } async function syncDevices(row: Row) { const result = await synchronizeUavDevices(String(row.connection_id)); ElMessage.success(`已同步 ${result.synchronized} 台设备`); await load(); activeTab.value = "devices"; } +function selectResources(rows: Row[]) { selectedResourceIds.value = rows.map((row) => String(row.resource_id)); } +async function syncResources() { const result = await syncOperationResources(); ElMessage.success(`已同步作业资源:设备 ${result.devices},机巢 ${result.docks}`); await loadResourceCalendar(); } +async function checkResourceConflicts() { if (!resourceTaskId.value) return; const result = await checkTaskConflicts(resourceTaskId.value, { resource_ids: selectedResourceIds.value, duration_minutes: 90, requested_by: "user-dispatcher" }); conflictResult.value = result; ElMessage[result.blocking_count ? "error" : result.warning_count ? "warning" : "success"](`阻断 ${result.blocking_count || 0},警告 ${result.warning_count || 0}`); } +async function reserveResources() { if (!resourceTaskId.value || selectedResourceIds.value.length === 0) return; const result = await reserveTaskResources(resourceTaskId.value, { resource_ids: selectedResourceIds.value, duration_minutes: 90, reservation_type: "TASK_WINDOW", idempotency_key: `reserve-${resourceTaskId.value}-${Date.now()}`, requested_by: "user-dispatcher" }); ElMessage.success(`已预占 ${result.reservation_ids?.length || 0} 项资源`); await loadResourceCalendar(); } async function dispatch() { if (!dispatchForm.task_id || !dispatchForm.route_id || !dispatchForm.device_id) return ElMessage.warning("请选择任务、航线和无人机"); saving.value = true; try { const result = await dispatchUavMission({ ...dispatchForm, requested_by: "user-dispatcher", idempotency_key: `dispatch-${dispatchForm.task_id}-${Date.now()}` }); ElMessage.success(`任务已下发:${result.vendor_mission_id}`); dispatchDialog.value = false; activeTab.value = "missions"; await load(); } finally { saving.value = false; } } async function command(row: Row, action: string) { const result = await executeUavMissionCommand(String(row.mission_id), action, Number(row.version_no)); ElMessage.success(result.accepted ? `命令已受理,等待司空 2 状态回传:${missionLabel(result.status)}` : `命令完成,任务状态:${missionLabel(result.status)}`); await load(); } async function inspectMission(row: Row) { selectedMission.value = row; telemetry.value = await uavMissionTelemetry(String(row.mission_id)); missionVisible.value = true; } diff --git a/platform/backend/src/main/java/com/ai/trackwalker/capability/service/CapabilityCompletionService.java b/platform/backend/src/main/java/com/ai/trackwalker/capability/service/CapabilityCompletionService.java index 0484872..9ba881e 100644 --- a/platform/backend/src/main/java/com/ai/trackwalker/capability/service/CapabilityCompletionService.java +++ b/platform/backend/src/main/java/com/ai/trackwalker/capability/service/CapabilityCompletionService.java @@ -334,6 +334,7 @@ public class CapabilityCompletionService { rv.approval_status,ST_AsGeoJSON(rv.geom) as geometry, rv.waypoints::text as waypoints, rv.flight_parameters::text as flight_parameters, rv.payload_actions::text as payload_actions, rv.validation_result::text as validation_result, + rv.estimation_result::text as estimation_result,rv.version_lock,rv.edited_at,rv.edited_by, rv.checksum,rv.change_summary,rv.source_version_id, rv.published_at,rv.published_by,r.created_at,r.updated_at from routes r diff --git a/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationCompletionController.java b/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationCompletionController.java index 57e9c62..dfee70e 100644 --- a/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationCompletionController.java +++ b/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationCompletionController.java @@ -2,15 +2,18 @@ package com.ai.trackwalker.foundation.api; import com.ai.trackwalker.api.ApiResponse; import com.ai.trackwalker.capability.service.CapabilityCompletionService; +import com.ai.trackwalker.foundation.service.DependencyFreeCompletionService; import com.ai.trackwalker.foundation.service.FoundationCompletionService; import jakarta.validation.Valid; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -24,13 +27,16 @@ import java.util.Map; public class FoundationCompletionController { private final FoundationCompletionService service; private final CapabilityCompletionService capabilityService; + private final DependencyFreeCompletionService dependencyFreeService; public FoundationCompletionController( FoundationCompletionService service, - CapabilityCompletionService capabilityService + CapabilityCompletionService capabilityService, + DependencyFreeCompletionService dependencyFreeService ) { this.service = service; this.capabilityService = capabilityService; + this.dependencyFreeService = dependencyFreeService; } @GetMapping("/inspection/task-templates") @@ -269,4 +275,268 @@ public class FoundationCompletionController { .header("X-Content-SHA256", content.checksum()) .body(content.content()); } + + @GetMapping("/inspection/object-mapping-profiles") + public ApiResponse objectMappingProfiles() { + return ApiResponse.ok(Map.of("profiles", dependencyFreeService.objectMappingProfiles())); + } + + @GetMapping("/inspection/object-imports") + public ApiResponse objectImports() { + return ApiResponse.ok(Map.of("imports", dependencyFreeService.objectImports())); + } + + @PostMapping("/inspection/object-imports") + public ApiResponse createObjectImport( + @Valid @RequestBody FoundationRequests.CreateObjectImportRequest request + ) { + return ApiResponse.ok(dependencyFreeService.createObjectImport(request)); + } + + @GetMapping("/inspection/object-imports/{jobId}") + public ApiResponse objectImport(@PathVariable String jobId) { + return ApiResponse.ok(dependencyFreeService.objectImport(jobId)); + } + + @GetMapping("/inspection/object-imports/{jobId}/rows") + public ApiResponse objectImportRows( + @PathVariable String jobId, + @RequestParam(required = false) String status + ) { + return ApiResponse.ok(Map.of("rows", dependencyFreeService.objectImportRows(jobId, status))); + } + + @PostMapping("/inspection/object-imports/{jobId}/validate") + public ApiResponse validateObjectImport(@PathVariable String jobId) { + return ApiResponse.ok(dependencyFreeService.validateObjectImport(jobId)); + } + + @PostMapping("/inspection/object-imports/{jobId}/commit") + public ApiResponse commitObjectImport( + @PathVariable String jobId, + @RequestParam(defaultValue = "user-dispatcher") String confirmedBy + ) { + return ApiResponse.ok(dependencyFreeService.commitObjectImport(jobId, confirmedBy)); + } + + @PostMapping("/inspection/object-imports/{jobId}/rollback") + public ApiResponse rollbackObjectImport( + @PathVariable String jobId, + @RequestParam(defaultValue = "user-dispatcher") String requestedBy + ) { + return ApiResponse.ok(dependencyFreeService.rollbackObjectImport(jobId, requestedBy)); + } + + @GetMapping("/inspection/objects/{objectId}/versions") + public ApiResponse objectVersions(@PathVariable String objectId) { + return ApiResponse.ok(Map.of("versions", dependencyFreeService.objectVersions(objectId))); + } + + @GetMapping("/business-calendars") + public ApiResponse businessCalendars() { + return ApiResponse.ok(Map.of("calendars", dependencyFreeService.businessCalendars())); + } + + @PostMapping("/inspection/plans/{planId}/preview-runs") + public ApiResponse previewPlanRuns( + @PathVariable String planId, + @RequestBody(required = false) FoundationRequests.PreviewPlanRunsRequest request + ) { + return ApiResponse.ok(Map.of("runs", dependencyFreeService.previewPlanRuns( + planId, + request == null || request.count() == null ? 6 : request.count() + ))); + } + + @GetMapping("/inspection/plans/{planId}/executions") + public ApiResponse planExecutions(@PathVariable String planId) { + return ApiResponse.ok(Map.of("executions", dependencyFreeService.planExecutions(planId))); + } + + @PostMapping("/inspection/plans/run-due") + public ApiResponse runDuePlans( + @RequestBody(required = false) FoundationRequests.RunDuePlansRequest request + ) { + return ApiResponse.ok(dependencyFreeService.runDuePlans( + request == null || request.limit() == null ? 20 : request.limit() + )); + } + + @PostMapping("/operation-resources/sync") + public ApiResponse syncOperationResources() { + return ApiResponse.ok(dependencyFreeService.syncOperationResources()); + } + + @GetMapping("/operation-resources/calendar") + public ApiResponse operationResourceCalendar( + @RequestParam(required = false) String from, + @RequestParam(required = false) String to + ) { + return ApiResponse.ok(dependencyFreeService.operationResourceCalendar(from, to)); + } + + @PostMapping("/inspection/tasks/{taskId}/conflict-checks") + public ApiResponse checkTaskConflicts( + @PathVariable String taskId, + @RequestBody(required = false) FoundationRequests.ConflictCheckRequest request + ) { + return ApiResponse.ok(dependencyFreeService.checkTaskConflicts( + taskId, + request == null ? new FoundationRequests.ConflictCheckRequest(null, null, null, null, null, null) : request + )); + } + + @PostMapping("/inspection/tasks/{taskId}/reservations") + public ApiResponse reserveTaskResources( + @PathVariable String taskId, + @Valid @RequestBody FoundationRequests.ReserveResourcesRequest request + ) { + return ApiResponse.ok(dependencyFreeService.reserveTaskResources(taskId, request)); + } + + @DeleteMapping("/inspection/tasks/{taskId}/reservations/{reservationId}") + public ApiResponse cancelReservation( + @PathVariable String taskId, + @PathVariable String reservationId + ) { + return ApiResponse.ok(dependencyFreeService.cancelReservation(taskId, reservationId)); + } + + @GetMapping("/gis/layers") + public ApiResponse gisLayers() { + return ApiResponse.ok(Map.of("layers", dependencyFreeService.gisLayers())); + } + + @GetMapping("/gis/dataset-versions") + public ApiResponse gisDatasetVersions() { + return ApiResponse.ok(Map.of("dataset_versions", dependencyFreeService.gisDatasetVersions())); + } + + @PostMapping("/gis/layers/{layerId}/publish") + public ApiResponse publishGisLayer(@PathVariable String layerId) { + return ApiResponse.ok(dependencyFreeService.publishGisLayer(layerId)); + } + + @GetMapping("/gis/layers/{layerCode}/features") + public ApiResponse queryGisLayerFeatures(@PathVariable String layerCode) { + return ApiResponse.ok(dependencyFreeService.queryGisLayerFeatures(layerCode)); + } + + @GetMapping("/route-generation-profiles") + public ApiResponse routeGenerationProfiles() { + return ApiResponse.ok(Map.of("profiles", dependencyFreeService.routeGenerationProfiles())); + } + + @PostMapping("/routes/generate") + public ApiResponse generateRoute( + @Valid @RequestBody FoundationRequests.GenerateRouteRequest request + ) { + return ApiResponse.ok(dependencyFreeService.generateRoute(request)); + } + + @GetMapping("/route-action-schemas") + public ApiResponse routeActionSchemas() { + return ApiResponse.ok(Map.of("schemas", dependencyFreeService.routeActionSchemas())); + } + + @PutMapping("/route-versions/{versionId}/waypoints") + public ApiResponse updateRouteWaypoints( + @PathVariable String versionId, + @Valid @RequestBody FoundationRequests.UpdateRouteWaypointsRequest request + ) { + return ApiResponse.ok(dependencyFreeService.updateRouteWaypoints(versionId, request)); + } + + @GetMapping("/route-versions/{versionId}/editor-revisions") + public ApiResponse routeEditorRevisions(@PathVariable String versionId) { + return ApiResponse.ok(Map.of("revisions", dependencyFreeService.routeEditorRevisions(versionId))); + } + + @GetMapping("/route-estimation-profiles") + public ApiResponse routeEstimationProfiles() { + return ApiResponse.ok(Map.of("profiles", dependencyFreeService.routeEstimationProfiles())); + } + + @PostMapping("/route-versions/{versionId}/estimate") + public ApiResponse estimateRoute( + @PathVariable String versionId, + @RequestBody(required = false) FoundationRequests.EstimateRouteRequest request + ) { + return ApiResponse.ok(dependencyFreeService.estimateRoute( + versionId, + request == null ? new FoundationRequests.EstimateRouteRequest(null, null, null, null) : request + )); + } + + @GetMapping("/route-versions/{versionId}/estimations") + public ApiResponse routeEstimations(@PathVariable String versionId) { + return ApiResponse.ok(Map.of("estimations", dependencyFreeService.routeEstimations(versionId))); + } + + @GetMapping("/spatial-rule-sets") + public ApiResponse spatialRuleSets() { + return ApiResponse.ok(Map.of("rule_sets", dependencyFreeService.spatialRuleSets())); + } + + @PostMapping("/spatial-rule-sets") + public ApiResponse createSpatialRuleSet( + @Valid @RequestBody FoundationRequests.CreateSpatialRuleSetRequest request + ) { + return ApiResponse.ok(dependencyFreeService.createSpatialRuleSet(request)); + } + + @PostMapping("/spatial-rule-sets/{ruleSetId}/versions") + public ApiResponse createSpatialRuleVersion( + @PathVariable String ruleSetId, + @Valid @RequestBody FoundationRequests.CreateSpatialRuleVersionRequest request + ) { + return ApiResponse.ok(dependencyFreeService.createSpatialRuleVersion(ruleSetId, request)); + } + + @PostMapping("/spatial-rule-set-versions/{versionId}/publish") + public ApiResponse publishSpatialRuleVersion( + @PathVariable String versionId, + @RequestParam(defaultValue = "user-approver") String approvedBy + ) { + return ApiResponse.ok(dependencyFreeService.publishSpatialRuleVersion(versionId, approvedBy)); + } + + @GetMapping("/spatial-exemptions") + public ApiResponse spatialExemptions() { + return ApiResponse.ok(Map.of("exemptions", dependencyFreeService.spatialExemptions())); + } + + @PostMapping("/spatial-exemptions") + public ApiResponse createSpatialExemption( + @Valid @RequestBody FoundationRequests.CreateSpatialExemptionRequest request + ) { + return ApiResponse.ok(dependencyFreeService.createSpatialExemption(request)); + } + + @PostMapping("/spatial-exemptions/{exemptionId}/approve") + public ApiResponse approveSpatialExemption( + @PathVariable String exemptionId, + @RequestParam(defaultValue = "user-approver") String approvedBy + ) { + return ApiResponse.ok(dependencyFreeService.approveSpatialExemption(exemptionId, approvedBy)); + } + + @GetMapping("/geofences") + public ApiResponse geofences() { + return ApiResponse.ok(Map.of("geofences", dependencyFreeService.geofences())); + } + + @PostMapping("/geofences") + public ApiResponse createGeofence( + @Valid @RequestBody FoundationRequests.CreateGeofenceRequest request + ) { + return ApiResponse.ok(dependencyFreeService.createGeofence(request)); + } + + @PostMapping("/spatial-rule-hits/explain") + public ApiResponse explainSpatialHit( + @Valid @RequestBody FoundationRequests.ExplainSpatialHitRequest request + ) { + return ApiResponse.ok(dependencyFreeService.explainSpatialHit(request)); + } } diff --git a/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationRequests.java b/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationRequests.java index 8411e31..2058b49 100644 --- a/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationRequests.java +++ b/platform/backend/src/main/java/com/ai/trackwalker/foundation/api/FoundationRequests.java @@ -122,4 +122,125 @@ public final class FoundationRequests { String createdBy ) { } + + public record CreateObjectImportRequest( + @NotBlank String fileName, + String fileType, + String mappingProfileId, + String declaredCrs, + @NotEmpty List> rows, + String createdBy + ) { + } + + public record PreviewPlanRunsRequest( + Integer count + ) { + } + + public record RunDuePlansRequest( + Integer limit + ) { + } + + public record ConflictCheckRequest( + String startAt, + String endAt, + Integer durationMinutes, + List resourceIds, + String routeVersionId, + String requestedBy + ) { + } + + public record ReserveResourcesRequest( + @NotEmpty List resourceIds, + String startAt, + String endAt, + Integer durationMinutes, + String reservationType, + @NotBlank String idempotencyKey, + String requestedBy + ) { + } + + public record GenerateRouteRequest( + @NotBlank String objectId, + String generatorType, + Map parameters, + Boolean createRoute, + String routeName, + String createdBy + ) { + } + + public record UpdateRouteWaypointsRequest( + @NotEmpty List> waypoints, + Map flightParameters, + List> payloadActions, + String changeSummary, + String editorId, + Long expectedVersionLock + ) { + } + + public record EstimateRouteRequest( + String profileId, + Double cruiseSpeedMps, + Double reservePercent, + String createdBy + ) { + } + + public record CreateSpatialRuleSetRequest( + @NotBlank String code, + @NotBlank String name, + @NotBlank String ruleType, + String createdBy + ) { + } + + public record CreateSpatialRuleVersionRequest( + List> rules, + String effectiveFrom, + String effectiveTo, + String createdBy + ) { + } + + public record CreateSpatialExemptionRequest( + @NotBlank String exemptionType, + @NotNull Map geometry, + List appliesToScenes, + List appliesToObjectTypes, + @NotBlank String reason, + @NotBlank String effectiveFrom, + @NotBlank String effectiveTo, + String createdBy + ) { + } + + public record CreateGeofenceRequest( + @NotBlank String code, + @NotBlank String name, + @NotBlank String fenceType, + @NotNull Map geometry, + Double altitudeMinM, + Double altitudeMaxM, + @NotBlank String effectiveFrom, + @NotBlank String effectiveTo, + String severity, + String datasetVersionId, + String createdBy + ) { + } + + public record ExplainSpatialHitRequest( + String taskId, + String analysisResultId, + @NotNull Map geometry, + String scene, + String objectType + ) { + } } diff --git a/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/DependencyFreeCompletionService.java b/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/DependencyFreeCompletionService.java new file mode 100644 index 0000000..b266704 --- /dev/null +++ b/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/DependencyFreeCompletionService.java @@ -0,0 +1,1408 @@ +package com.ai.trackwalker.foundation.service; + +import com.ai.trackwalker.capability.api.CapabilityRequests; +import com.ai.trackwalker.capability.service.CapabilityCompletionService; +import com.ai.trackwalker.common.Ids; +import com.ai.trackwalker.common.Jsonb; +import com.ai.trackwalker.foundation.api.FoundationRequests; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.Timestamp; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class DependencyFreeCompletionService { + private static final String DEMO_MAPPING_PROFILE_ID = "object-mapping-demo-line"; + private static final String DEFAULT_ESTIMATION_PROFILE_ID = "route-estimate-demo-matrice4d"; + + private final JdbcTemplate jdbc; + private final KafkaTemplate kafkaTemplate; + private final CapabilityCompletionService capabilityService; + + public DependencyFreeCompletionService( + JdbcTemplate jdbc, + KafkaTemplate kafkaTemplate, + CapabilityCompletionService capabilityService + ) { + this.jdbc = jdbc; + this.kafkaTemplate = kafkaTemplate; + this.capabilityService = capabilityService; + } + + // PF-01: object import, validation, commit and lineage. + + public List> objectMappingProfiles() { + return jdbc.queryForList(""" + select id as mapping_profile_id,name,object_type,file_type, + field_mapping::text as field_mapping,value_mapping::text as value_mapping, + default_values::text as default_values,validation_profile::text as validation_profile, + status,version_no,created_at,updated_at + from inspection_object_mapping_profiles + order by updated_at desc + """); + } + + public List> objectImports() { + return jdbc.queryForList(""" + select id as import_job_id,file_name,file_type,mapping_profile_id,declared_crs,status, + total_count,valid_count,invalid_count,warning_count,dataset_version_id, + quality_report::text as quality_report,created_by,confirmed_by,confirmed_at,created_at,updated_at + from inspection_object_import_jobs + order by created_at desc + """); + } + + @Transactional + public Map createObjectImport(FoundationRequests.CreateObjectImportRequest request) { + String profileId = defaultString(request.mappingProfileId(), DEMO_MAPPING_PROFILE_ID); + requireRow("select id from inspection_object_mapping_profiles where id=? and status='ACTIVE'", profileId); + String jobId = Ids.next("object-import"); + Instant now = Instant.now(); + jdbc.update(""" + insert into inspection_object_import_jobs( + id,file_name,file_type,mapping_profile_id,declared_crs,status,total_count, + created_by,created_at,updated_at + ) values (?,?,?,?,?,'CREATED',?,?,?,?) + """, + jobId, request.fileName(), defaultString(request.fileType(), "JSON_ROWS").toUpperCase(Locale.ROOT), + profileId, defaultString(request.declaredCrs(), "EPSG:4326"), + request.rows().size(), defaultString(request.createdBy(), "user-dispatcher"), + Timestamp.from(now), Timestamp.from(now)); + int rowNo = 1; + for (Map row : request.rows()) { + Map canonical = canonicalObjectPayload(row); + Map geometry = canonicalGeometry(row); + String rowId = Ids.next("object-row"); + if (geometry.isEmpty()) { + jdbc.update(""" + insert into inspection_object_import_rows( + id,job_id,row_no,source_key,raw_attributes,canonical_payload, + validation_status,error_codes,warning_codes + ) values (?,?,?,?,?::jsonb,?::jsonb,'PENDING','[]'::jsonb,'[]'::jsonb) + """, + rowId, jobId, rowNo, stringValue(row, "source_key"), Jsonb.write(row), Jsonb.write(canonical)); + } else { + jdbc.update(""" + insert into inspection_object_import_rows( + id,job_id,row_no,source_key,raw_attributes,source_geometry,canonical_payload, + canonical_geometry,validation_status,error_codes,warning_codes + ) values (?,?,?,?,?::jsonb,?::jsonb,?::jsonb,ST_SetSRID(ST_GeomFromGeoJSON(?),4326), + 'PENDING','[]'::jsonb,'[]'::jsonb) + """, + rowId, jobId, rowNo, stringValue(row, "source_key"), Jsonb.write(row), + Jsonb.write(geometry), Jsonb.write(canonical), Jsonb.write(geometry)); + } + rowNo += 1; + } + publish("inspection.object-import.created", Map.of("import_job_id", jobId, "rows", request.rows().size())); + return validateObjectImport(jobId); + } + + @Transactional + public Map validateObjectImport(String jobId) { + requireRow("select id from inspection_object_import_jobs where id=? for update", jobId); + List> rows = jdbc.queryForList(""" + select id,source_key,canonical_payload::text as canonical_payload, + canonical_geometry is not null as has_geometry + from inspection_object_import_rows + where job_id=? + order by row_no + """, jobId); + Set seenKeys = new HashSet<>(); + int valid = 0; + int invalid = 0; + int warnings = 0; + for (Map row : rows) { + Map payload = Jsonb.map(String.valueOf(row.get("canonical_payload"))); + List errors = new ArrayList<>(); + List rowWarnings = new ArrayList<>(); + String objectType = upper(payload.get("object_type")); + String sourceKey = defaultString(row.get("source_key"), + defaultString(payload.get("source_key"), payload.get("name"))); + if (blank(sourceKey) || !seenKeys.add(sourceKey)) errors.add("OBJ_CODE_DUPLICATED"); + if (blank(payload.get("name")) || blank(payload.get("line_id")) || blank(objectType)) { + errors.add("OBJ_REQUIRED_FIELD"); + } + if (!List.of("RAILWAY", "BRIDGE", "TUNNEL", "FLOOD", "POWER").contains(objectType)) { + errors.add("OBJ_TYPE_UNSUPPORTED"); + } + if (!Boolean.TRUE.equals(row.get("has_geometry"))) errors.add("OBJ_GEOMETRY_INVALID"); + if (blank(payload.get("mileage_start")) || blank(payload.get("mileage_end"))) { + rowWarnings.add("OBJ_MILEAGE_INCOMPLETE"); + } + if (blank(payload.get("owner_org_id"))) rowWarnings.add("OBJ_ORG_UNSPECIFIED"); + if (!blank(payload.get("name")) && !blank(payload.get("line_id"))) { + Integer duplicateCount = jdbc.queryForObject(""" + select count(*) from inspection_objects + where line_id=? and name=? and status='ACTIVE' + """, Integer.class, payload.get("line_id"), payload.get("name")); + if (duplicateCount != null && duplicateCount > 0) rowWarnings.add("OBJ_SPATIAL_DUPLICATE"); + } + String status = errors.isEmpty() ? rowWarnings.isEmpty() ? "VALID" : "WARNING" : "INVALID"; + if (errors.isEmpty()) valid += 1; + else invalid += 1; + warnings += rowWarnings.size(); + jdbc.update(""" + update inspection_object_import_rows + set validation_status=?,error_codes=?::jsonb,warning_codes=?::jsonb + where id=? + """, status, Jsonb.write(errors), Jsonb.write(rowWarnings), row.get("id")); + } + Map report = Map.of( + "software_scope", "DEMO_IMPORT_VALIDATION", + "production_gate", "REQUIRES_AUTHORITY_FIELDS_AND_CRS_CONFIRMATION", + "validated_at", Instant.now().toString() + ); + String status = invalid > 0 ? "VALIDATED_WITH_ERRORS" : "READY"; + jdbc.update(""" + update inspection_object_import_jobs + set status=?,valid_count=?,invalid_count=?,warning_count=?,quality_report=?::jsonb,updated_at=? + where id=? + """, status, valid, invalid, warnings, Jsonb.write(report), Timestamp.from(Instant.now()), jobId); + publish("inspection.object-import.validated", Map.of( + "import_job_id", jobId, + "valid_count", valid, + "invalid_count", invalid, + "warning_count", warnings + )); + return objectImport(jobId); + } + + public Map objectImport(String jobId) { + Map job = requireRow(""" + select id as import_job_id,file_name,file_type,mapping_profile_id,declared_crs,status, + total_count,valid_count,invalid_count,warning_count,dataset_version_id, + quality_report::text as quality_report,created_by,confirmed_by,confirmed_at,created_at,updated_at + from inspection_object_import_jobs where id=? + """, jobId); + job.put("rows", objectImportRows(jobId, null)); + return job; + } + + public List> objectImportRows(String jobId, String status) { + return jdbc.queryForList(""" + select id as row_id,job_id,row_no,source_key,raw_attributes::text as raw_attributes, + source_geometry::text as source_geometry,canonical_payload::text as canonical_payload, + ST_AsGeoJSON(canonical_geometry) as canonical_geometry, + validation_status,error_codes::text as error_codes,warning_codes::text as warning_codes, + imported_object_id + from inspection_object_import_rows + where job_id=? and (cast(? as varchar) is null or validation_status=?) + order by row_no + """, jobId, blankToNull(status), blankToNull(status)); + } + + @Transactional + public Map commitObjectImport(String jobId, String confirmedBy) { + Map job = requireRow(""" + select id,status,invalid_count,created_by from inspection_object_import_jobs where id=? for update + """, jobId); + if ("COMMITTED".equals(job.get("status"))) return objectImport(jobId); + if (!"READY".equals(job.get("status"))) { + Map validated = validateObjectImport(jobId); + if (!"READY".equals(validated.get("status"))) throw conflict("导入仍存在错误,不能确认入库"); + } + List> rows = jdbc.queryForList(""" + select id,canonical_payload::text as canonical_payload + from inspection_object_import_rows + where job_id=? and validation_status in ('VALID','WARNING') and imported_object_id is null + order by row_no + """, jobId); + List objectIds = new ArrayList<>(); + Instant now = Instant.now(); + for (Map row : rows) { + Map payload = Jsonb.map(String.valueOf(row.get("canonical_payload"))); + String objectId = Ids.next("object"); + jdbc.update(""" + insert into inspection_objects( + id,object_type,name,line_id,mileage_start,mileage_end,geom,attributes, + owner_org_id,risk_level,inspection_cycle_days,source_crs,transform_version, + accuracy_m,status,created_at,updated_at + ) + select ?,?,?,?,?,?,canonical_geometry,?::jsonb,?,?,?,?,?::numeric,'ACTIVE',?,? + from inspection_object_import_rows where id=? + """, + objectId, upper(payload.get("object_type")), payload.get("name"), payload.get("line_id"), + payload.get("mileage_start"), payload.get("mileage_end"), + Jsonb.write(mapOrEmpty(castMap(payload.get("attributes")))), + defaultString(payload.get("owner_org_id"), "org-works"), + defaultString(payload.get("risk_level"), "NORMAL"), + intValue(payload.get("inspection_cycle_days"), 7), + defaultString(payload.get("source_crs"), "EPSG:4326"), + "DEMO_TRANSFORM_V1", 5.0, + Timestamp.from(now), Timestamp.from(now), row.get("id")); + jdbc.update(""" + insert into inspection_object_versions( + id,object_id,version_no,snapshot,geom,change_type,change_reason,source_job_id,created_by,created_at + ) + select ?,?,1,?::jsonb,canonical_geometry,'IMPORT','对象导入确认',?,?,? + from inspection_object_import_rows where id=? + """, + Ids.next("object-version"), objectId, Jsonb.write(payload), jobId, + defaultString(confirmedBy, "user-dispatcher"), Timestamp.from(now), row.get("id")); + jdbc.update("update inspection_object_import_rows set imported_object_id=? where id=?", objectId, row.get("id")); + objectIds.add(objectId); + } + String datasetVersionId = "object-dataset-" + jobId.substring(Math.max(0, jobId.length() - 12)); + jdbc.update(""" + update inspection_object_import_jobs + set status='COMMITTED',dataset_version_id=?,confirmed_by=?,confirmed_at=?,updated_at=? + where id=? + """, datasetVersionId, defaultString(confirmedBy, "user-dispatcher"), + Timestamp.from(now), Timestamp.from(now), jobId); + publish("inspection.object-import.committed", Map.of("import_job_id", jobId, "object_ids", objectIds)); + return Map.of("import_job_id", jobId, "status", "COMMITTED", "object_ids", objectIds, + "dataset_version_id", datasetVersionId); + } + + @Transactional + public Map rollbackObjectImport(String jobId, String requestedBy) { + Map job = requireRow("select id,status from inspection_object_import_jobs where id=? for update", jobId); + List objectIds = jdbc.queryForList(""" + select imported_object_id from inspection_object_import_rows + where job_id=? and imported_object_id is not null + """, String.class, jobId); + int referenced = objectIds.isEmpty() ? 0 : jdbc.queryForObject(""" + select count(*) from inspection_task_objects where object_id in (%s) + """.formatted(placeholders(objectIds.size())), Integer.class, objectIds.toArray()); + if (referenced > 0) throw conflict("导入对象已被任务引用,不能回滚"); + if (!objectIds.isEmpty()) { + jdbc.update("delete from inspection_object_versions where object_id in (%s)".formatted(placeholders(objectIds.size())), + objectIds.toArray()); + jdbc.update("delete from inspection_objects where id in (%s)".formatted(placeholders(objectIds.size())), + objectIds.toArray()); + } + jdbc.update(""" + update inspection_object_import_rows set imported_object_id=null where job_id=? + """, jobId); + jdbc.update(""" + update inspection_object_import_jobs + set status='ROLLED_BACK',confirmed_by=?,updated_at=? + where id=? + """, defaultString(requestedBy, "user-dispatcher"), Timestamp.from(Instant.now()), jobId); + publish("inspection.object-import.rolled-back", Map.of("import_job_id", job.get("id"), "object_ids", objectIds)); + return Map.of("import_job_id", jobId, "status", "ROLLED_BACK", "rolled_back_objects", objectIds.size()); + } + + public List> objectVersions(String objectId) { + return jdbc.queryForList(""" + select id as object_version_id,object_id,version_no,snapshot::text as snapshot, + ST_AsGeoJSON(geom) as geometry,change_type,change_reason,source_job_id,created_by,created_at + from inspection_object_versions where object_id=? + order by version_no desc + """, objectId); + } + + // PF-02: calendars, preview, execution records and dependency-free scheduler hook. + + public List> businessCalendars() { + return jdbc.queryForList(""" + select id as calendar_id,name,timezone,calendar_type,status,version_no,created_at,updated_at, + (select count(*) from business_calendar_dates d where d.calendar_id=c.id) as date_count + from business_calendars c + order by name + """); + } + + public List> previewPlanRuns(String planId, int count) { + Map plan = requireRow(""" + select id,trigger_type,schedule_rule,schedule_config::text as schedule_config, + schedule_timezone,next_run_at,effective_to + from inspection_plans where id=? + """, planId); + int limit = Math.max(1, Math.min(20, count)); + Instant cursor = plan.get("next_run_at") == null + ? Instant.now().truncatedTo(ChronoUnit.HOURS) + : ((Timestamp) plan.get("next_run_at")).toInstant(); + List> windows = new ArrayList<>(); + for (int index = 0; index < limit; index++) { + Instant start = index == 0 ? cursor : nextScheduledStart(plan, cursor); + Instant end = start.plus(1, ChronoUnit.HOURS); + windows.add(Map.of( + "sequence", index + 1, + "scheduled_window_start", start.toString(), + "scheduled_window_end", end.toString(), + "generation_key", sha256(planId + "|" + start + "|" + end), + "source", "SCHEDULE_PREVIEW" + )); + cursor = start; + } + return windows; + } + + public List> planExecutions(String planId) { + return jdbc.queryForList(""" + select id as plan_execution_id,plan_id,scheduled_window_start,scheduled_window_end, + generation_key,trigger_source,status,generated_task_ids::text as generated_task_ids, + skip_reason,error_message,started_at,completed_at + from plan_executions + where plan_id=? + order by scheduled_window_start desc + """, planId); + } + + @Transactional + public Map runDuePlans(int limit) { + List> plans = jdbc.queryForList(""" + select id,trigger_type,schedule_config::text as schedule_config,schedule_timezone,next_run_at, + missed_execution_policy,max_catch_up_count,effective_to + from inspection_plans + where status='ACTIVE' and trigger_type='PERIODIC' and next_run_at is not null and next_run_at<=? + order by next_run_at + limit ? + for update skip locked + """, Timestamp.from(Instant.now()), Math.max(1, Math.min(limit, 50))); + List> results = new ArrayList<>(); + for (Map plan : plans) { + results.add(runPlanExecution(plan, "SCHEDULER")); + } + return Map.of("scanned", plans.size(), "executions", results); + } + + // PF-05: resource calendar, conflict checking and reservations. + + @Transactional + public Map syncOperationResources() { + int devices = jdbc.update(""" + insert into operation_resources( + id,resource_type,source_id,name,capabilities,status,owner_org_id,geom,health_snapshot,created_at,updated_at + ) + select 'opres-device-' || id,'AIRCRAFT',id,coalesce(model,vendor_device_id),capabilities, + case when status='ONLINE' then 'AVAILABLE' else 'UNAVAILABLE' end, + 'org-works',geom,health_detail,now(),now() + from uav_devices + on conflict(resource_type, source_id) do update + set name=excluded.name,capabilities=excluded.capabilities,status=excluded.status, + geom=excluded.geom,health_snapshot=excluded.health_snapshot,updated_at=excluded.updated_at + """); + int docks = jdbc.update(""" + insert into operation_resources( + id,resource_type,source_id,name,capabilities,status,owner_org_id,geom,health_snapshot,created_at,updated_at + ) + select 'opres-dock-' || id,'DOCK',id,coalesce(model,vendor_dock_id),capabilities, + case when status='ONLINE' then 'AVAILABLE' else 'UNAVAILABLE' end, + 'org-works',geom,'{}'::jsonb,now(),now() + from uav_docks + on conflict(resource_type, source_id) do update + set name=excluded.name,capabilities=excluded.capabilities,status=excluded.status, + geom=excluded.geom,updated_at=excluded.updated_at + """); + publish("operation.resources.synchronized", Map.of("devices", devices, "docks", docks)); + return Map.of("devices", devices, "docks", docks, "status", "SYNCED"); + } + + public Map operationResourceCalendar(String from, String to) { + Instant start = from == null ? Instant.now().minus(1, ChronoUnit.HOURS) : parseInstant(from, "开始时间格式错误"); + Instant end = to == null ? start.plus(7, ChronoUnit.DAYS) : parseInstant(to, "结束时间格式错误"); + return Map.of( + "resources", jdbc.queryForList(""" + select id as resource_id,resource_type,source_id,name,capabilities::text as capabilities, + status,owner_org_id,ST_AsGeoJSON(geom) as geometry,health_snapshot::text as health_snapshot, + created_at,updated_at + from operation_resources + order by resource_type,name + """), + "reservations", jdbc.queryForList(""" + select rr.id as reservation_id,rr.task_id,rr.resource_id,r.name as resource_name, + rr.start_at,rr.end_at,rr.status,rr.reservation_type,rr.version_no + from resource_reservations rr join operation_resources r on r.id=rr.resource_id + where rr.status='ACTIVE' and rr.start_at? + order by rr.start_at + """, Timestamp.from(end), Timestamp.from(start)), + "unavailable_windows", jdbc.queryForList(""" + select w.id as window_id,w.resource_id,r.name as resource_name,w.start_at,w.end_at, + w.reason_type,w.reason,w.source + from resource_unavailable_windows w join operation_resources r on r.id=w.resource_id + where w.start_at? + order by w.start_at + """, Timestamp.from(end), Timestamp.from(start)) + ); + } + + @Transactional + public Map checkTaskConflicts(String taskId, FoundationRequests.ConflictCheckRequest request) { + Map task = requireRow(""" + select t.id,t.line_id,t.route_id,t.approval_status,t.status,t.planned_start_at, + r.current_version_id + from inspection_tasks t + left join routes r on r.id=t.route_id + where t.id=? + """, taskId); + Instant start = resolveStart(request.startAt(), task.get("planned_start_at")); + Instant end = resolveEnd(request.endAt(), start, request.durationMinutes()); + List resourceIds = request.resourceIds() == null || request.resourceIds().isEmpty() + ? jdbc.queryForList("select id from operation_resources where status='AVAILABLE' order by resource_type,id", String.class) + : request.resourceIds(); + String routeVersionId = defaultString(request.routeVersionId(), task.get("current_version_id")); + List> checks = new ArrayList<>(); + for (String resourceId : resourceIds) checks.add(checkResource(resourceId, start, end, taskId)); + if (routeVersionId == null || routeVersionId.isBlank()) { + checks.add(issue("ROUTE_MISSING", "BLOCKING", "任务没有绑定已发布航线", "ROUTE")); + } else { + List> routes = jdbc.queryForList(""" + select status,approval_status,validation_result::text as validation_result + from route_versions where id=? + """, routeVersionId); + if (routes.isEmpty() || !"PUBLISHED".equals(routes.get(0).get("status"))) { + checks.add(issue("ROUTE_NOT_PUBLISHED", "BLOCKING", "航线版本尚未发布", "ROUTE")); + } + } + checks.add(issue("WEATHER_NOT_CHECKED", "WARNING", "天气接口未接入,结果为 NOT_CHECKED", "EXTERNAL")); + checks.add(issue("AIRSPACE_NOT_CHECKED", "WARNING", "空域接口未接入,结果为 NOT_CHECKED", "EXTERNAL")); + int blocking = (int) checks.stream().filter(item -> "BLOCKING".equals(item.get("level"))).count(); + int warning = (int) checks.stream().filter(item -> "WARNING".equals(item.get("level"))).count(); + Map result = Map.of( + "window", Map.of("start_at", start.toString(), "end_at", end.toString()), + "resource_ids", resourceIds, + "checks", checks, + "production_gate", "WEATHER_AIRSPACE_DEVICE_MASTER_DATA_NOT_EVALUATED" + ); + String runId = Ids.next("conflict"); + jdbc.update(""" + insert into conflict_check_runs( + id,task_id,route_version_id,input_snapshot,status,blocking_count,warning_count,result,rule_version,checked_at + ) values (?,?,?,?,?,?,?,?,?,?) + """, + runId, taskId, blankToNull(routeVersionId), + Jsonb.write(Map.of("requested_resource_ids", resourceIds, "requested_by", + defaultString(request.requestedBy(), "user-dispatcher"))), + blocking > 0 ? "BLOCKED" : warning > 0 ? "PASSED_WITH_WARNINGS" : "PASSED", + blocking, warning, Jsonb.write(result), "dependency-free-v21", Timestamp.from(Instant.now())); + publish("operation.resource.conflict.checked", Map.of("conflict_check_run_id", runId, "task_id", taskId)); + Map response = new LinkedHashMap<>(result); + response.put("conflict_check_run_id", runId); + response.put("status", blocking > 0 ? "BLOCKED" : warning > 0 ? "PASSED_WITH_WARNINGS" : "PASSED"); + response.put("blocking_count", blocking); + response.put("warning_count", warning); + return response; + } + + @Transactional + public Map reserveTaskResources(String taskId, FoundationRequests.ReserveResourcesRequest request) { + List> replay = jdbc.queryForList(""" + select id as reservation_id,task_id,resource_id,start_at,end_at,status,reservation_type + from resource_reservations where idempotency_key like ? + order by created_at + """, request.idempotencyKey() + "%"); + if (!replay.isEmpty()) return Map.of("idempotent_replay", true, "reservations", replay); + Instant start = resolveStart(request.startAt(), null); + Instant end = resolveEnd(request.endAt(), start, request.durationMinutes()); + List> checks = request.resourceIds().stream() + .map(resourceId -> checkResource(resourceId, start, end, taskId)) + .filter(issue -> !"PASSED".equals(issue.get("level"))) + .toList(); + if (checks.stream().anyMatch(issue -> "BLOCKING".equals(issue.get("level")))) { + throw conflict("存在资源硬冲突,不能预占"); + } + Instant now = Instant.now(); + List ids = new ArrayList<>(); + for (String resourceId : request.resourceIds()) { + String id = Ids.next("reservation"); + jdbc.update(""" + insert into resource_reservations( + id,task_id,resource_id,start_at,end_at,status,reservation_type,idempotency_key,created_at,updated_at + ) values (?,?,?,?,?,'ACTIVE',?,?,?,?) + """, + id, taskId, resourceId, Timestamp.from(start), Timestamp.from(end), + defaultString(request.reservationType(), "TASK_WINDOW"), + request.idempotencyKey() + "-" + resourceId, + Timestamp.from(now), Timestamp.from(now)); + ids.add(id); + } + publish("operation.resources.reserved", Map.of("task_id", taskId, "reservation_ids", ids)); + return Map.of("idempotent_replay", false, "reservation_ids", ids, "warnings", checks); + } + + @Transactional + public Map cancelReservation(String taskId, String reservationId) { + int updated = jdbc.update(""" + update resource_reservations + set status='CANCELLED',version_no=version_no+1,updated_at=? + where id=? and task_id=? and status='ACTIVE' + """, Timestamp.from(Instant.now()), reservationId, taskId); + if (updated == 0) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "资源预占不存在或已取消"); + publish("operation.resources.reservation-cancelled", Map.of("task_id", taskId, "reservation_id", reservationId)); + return Map.of("reservation_id", reservationId, "status", "CANCELLED"); + } + + // PF-08: GIS layers and dataset versions. + + public List> gisLayers() { + return jdbc.queryForList(""" + select l.id as layer_id,l.code,l.name,l.layer_type,l.dataset_version_id, + d.name as dataset_name,d.source_type,d.quality_status, + l.style_spec::text as style_spec,l.visible_by_default,l.min_zoom,l.max_zoom, + l.status,l.version_no,l.created_at,l.updated_at + from gis_layers l + left join gis_dataset_versions d on d.id=l.dataset_version_id + order by l.code + """); + } + + public List> gisDatasetVersions() { + return jdbc.queryForList(""" + select id as dataset_version_id,code,name,dataset_type,source_type,crs,quality_status, + status,version_no,metadata::text as metadata,checksum,created_by,created_at,published_at + from gis_dataset_versions + order by code,version_no desc + """); + } + + @Transactional + public Map publishGisLayer(String layerId) { + Map layer = requireRow(""" + select l.id,l.dataset_version_id,d.status as dataset_status,d.quality_status + from gis_layers l left join gis_dataset_versions d on d.id=l.dataset_version_id + where l.id=? for update + """, layerId); + if (!"PUBLISHED".equals(layer.get("dataset_status")) || !"PASSED".equals(layer.get("quality_status"))) { + throw conflict("图层关联的数据集版本尚未通过质量检查"); + } + jdbc.update("update gis_layers set status='PUBLISHED',version_no=version_no+1,updated_at=? where id=?", + Timestamp.from(Instant.now()), layerId); + publish("gis.layer.published", Map.of("layer_id", layerId)); + return Map.of("layer_id", layerId, "status", "PUBLISHED"); + } + + public Map queryGisLayerFeatures(String layerCode) { + Map layer = requireRow(""" + select id,dataset_version_id,code,name,status from gis_layers + where code=? + """, layerCode); + List> features = jdbc.queryForList(""" + select id as feature_id,feature_type,business_key,name,ST_AsGeoJSON(geom) as geometry, + properties::text as properties,status,created_at + from gis_spatial_features + where dataset_version_id=? and status='ACTIVE' + order by feature_type,business_key + """, layer.get("dataset_version_id")); + return Map.of("layer", layer, "features", features); + } + + // PF-09: route generators. + + public List> routeGenerationProfiles() { + return jdbc.queryForList(""" + select id as profile_id,generator_type,object_type,name,parameters::text as parameters, + status,version_no,created_at,updated_at + from route_generation_profiles + order by object_type,generator_type + """); + } + + @Transactional + public Map generateRoute(FoundationRequests.GenerateRouteRequest request) { + Map object = requireRow(""" + select id as object_id,object_type,name,line_id,mileage_start,mileage_end, + ST_XMin(Box2D(geom)) as min_x,ST_YMin(Box2D(geom)) as min_y, + ST_XMax(Box2D(geom)) as max_x,ST_YMax(Box2D(geom)) as max_y, + ST_AsGeoJSON(geom) as geometry + from inspection_objects + where id=? and status='ACTIVE' + """, request.objectId()); + String generatorType = defaultString(request.generatorType(), defaultGeneratorFor(object.get("object_type"))); + Map profile = requireRow(""" + select id,parameters::text as parameters from route_generation_profiles + where generator_type=? and object_type=? and status='ACTIVE' + """, generatorType, object.get("object_type")); + Map params = new LinkedHashMap<>(Jsonb.map(String.valueOf(profile.get("parameters")))); + params.putAll(mapOrEmpty(request.parameters())); + List> waypoints = candidateWaypoints(object, generatorType, params); + Map result = new LinkedHashMap<>(); + result.put("generator_type", generatorType); + result.put("profile_id", profile.get("id")); + result.put("object_snapshot", object); + result.put("waypoints", waypoints); + result.put("production_gate", "CANDIDATE_REQUIRES_FIELD_REVIEW"); + String routeId = null; + String versionId = null; + if (Boolean.TRUE.equals(request.createRoute())) { + String templateId = templateForObject(upper(object.get("object_type"))); + Map created = capabilityService.createRoute(new CapabilityRequests.CreateRouteRequest( + defaultString(request.routeName(), object.get("name") + " 候选航线"), + request.objectId(), + templateId, + waypoints, + Map.of( + "speed_mps", number(params, "speed_mps", 6), + "rth_altitude_m", number(params, "rth_altitude_m", 100), + "generator_type", generatorType + ), + List.of(Map.of("type", "TAKE_PHOTO", "interval_s", intValue(params.get("photo_interval_s"), 2))), + defaultString(request.createdBy(), "route-generator") + )); + routeId = String.valueOf(created.get("route_id")); + versionId = String.valueOf(created.get("route_version_id")); + result.put("created_route", created); + } + String runId = Ids.next("route-gen"); + jdbc.update(""" + insert into route_generation_runs( + id,object_id,profile_id,generator_type,parameters,input_snapshot,status, + candidate_route_id,candidate_version_id,result,created_by,created_at,completed_at + ) values (?,?,?,?,?::jsonb,?::jsonb,'COMPLETED',?,?,?::jsonb,?,?,?) + """, + runId, request.objectId(), profile.get("id"), generatorType, Jsonb.write(params), Jsonb.write(object), + routeId, versionId, Jsonb.write(result), defaultString(request.createdBy(), "route-generator"), + Timestamp.from(Instant.now()), Timestamp.from(Instant.now())); + result.put("route_generation_run_id", runId); + publish("route.generated", Map.of("route_generation_run_id", runId, "object_id", request.objectId())); + return result; + } + + // PF-10: waypoint editing and action schemas. + + public List> routeActionSchemas() { + return jdbc.queryForList(""" + select id as action_schema_id,device_type,action_type,schema_spec::text as schema_spec, + status,version_no,created_at,updated_at + from route_action_schemas + order by device_type,action_type + """); + } + + @Transactional + public Map updateRouteWaypoints(String versionId, FoundationRequests.UpdateRouteWaypointsRequest request) { + Map version = requireRow(""" + select id,route_id,status,version_lock,waypoints::text as waypoints, + payload_actions::text as payload_actions,flight_parameters::text as flight_parameters + from route_versions where id=? for update + """, versionId); + if (!List.of("DRAFT", "REJECTED").contains(String.valueOf(version.get("status")))) { + throw conflict("只有草稿或驳回状态的航线版本允许编辑"); + } + if (request.expectedVersionLock() != null + && ((Number) version.get("version_lock")).longValue() != request.expectedVersionLock()) { + throw conflict("航线版本已被其他人编辑,请刷新后重试"); + } + if (request.waypoints().size() < 2) throw badRequest("航线至少需要两个航点"); + Map before = Map.of( + "waypoints", Jsonb.read(String.valueOf(version.get("waypoints"))), + "payload_actions", Jsonb.read(String.valueOf(version.get("payload_actions"))), + "flight_parameters", Jsonb.read(String.valueOf(version.get("flight_parameters"))) + ); + List> payloadActions = request.payloadActions() == null ? Jsonb.mapList(String.valueOf(version.get("payload_actions"))) : request.payloadActions(); + Map flightParameters = request.flightParameters() == null ? Jsonb.map(String.valueOf(version.get("flight_parameters"))) : request.flightParameters(); + jdbc.update("delete from route_waypoints where route_version_id=?", versionId); + insertWaypoints(versionId, request.waypoints(), flightParameters); + String checksum = sha256(versionId + "|" + Jsonb.write(request.waypoints()) + "|" + Jsonb.write(flightParameters) + "|" + Jsonb.write(payloadActions)); + jdbc.update(""" + update route_versions rv + set waypoints=?::jsonb,flight_parameters=?::jsonb,payload_actions=?::jsonb, + geom=(select ST_MakeLine(w.geom order by w.sequence_no) from route_waypoints w where w.route_version_id=rv.id), + validation_result='{}'::jsonb,checksum=?,version_lock=version_lock+1, + edited_at=?,edited_by=?,change_summary=coalesce(?,change_summary) + where rv.id=? + """, Jsonb.write(request.waypoints()), Jsonb.write(flightParameters), Jsonb.write(payloadActions), + checksum, Timestamp.from(Instant.now()), defaultString(request.editorId(), "route-editor"), + blankToNull(request.changeSummary()), versionId); + Integer revisionNo = jdbc.queryForObject( + "select coalesce(max(revision_no),0)+1 from route_editor_revisions where route_version_id=?", + Integer.class, versionId); + jdbc.update(""" + insert into route_editor_revisions( + id,route_version_id,revision_no,editor_id,change_summary,before_waypoints,after_waypoints, + before_actions,after_actions,checksum,created_at + ) values (?,?,?,?,?,?::jsonb,?::jsonb,?::jsonb,?::jsonb,?,?) + """, + Ids.next("route-revision"), versionId, revisionNo, + defaultString(request.editorId(), "route-editor"), defaultString(request.changeSummary(), "航点编辑"), + Jsonb.write(before.get("waypoints")), Jsonb.write(request.waypoints()), + Jsonb.write(before.get("payload_actions")), Jsonb.write(payloadActions), + checksum, Timestamp.from(Instant.now())); + publish("route.version.edited", Map.of("route_version_id", versionId, "revision_no", revisionNo)); + return Map.of("route_version_id", versionId, "version_lock", ((Number) version.get("version_lock")).longValue() + 1, + "checksum", checksum, "revision_no", revisionNo); + } + + public List> routeEditorRevisions(String versionId) { + return jdbc.queryForList(""" + select id as revision_id,route_version_id,revision_no,editor_id,change_summary, + before_waypoints::text as before_waypoints,after_waypoints::text as after_waypoints, + before_actions::text as before_actions,after_actions::text as after_actions, + checksum,created_at + from route_editor_revisions + where route_version_id=? + order by revision_no desc + """, versionId); + } + + // PF-11: route estimation. + + public List> routeEstimationProfiles() { + return jdbc.queryForList(""" + select id as profile_id,code,name,device_type,payload_type,parameters::text as parameters, + status,version_no,created_at,updated_at + from route_estimation_profiles + order by code + """); + } + + @Transactional + public Map estimateRoute(String versionId, FoundationRequests.EstimateRouteRequest request) { + Map version = requireRow(""" + select id,flight_parameters::text as flight_parameters,waypoints::text as waypoints, + coalesce(ST_Length(geom::geography),0) as route_length_m + from route_versions where id=? + """, versionId); + String profileId = defaultString(request.profileId(), DEFAULT_ESTIMATION_PROFILE_ID); + Map profile = requireRow("select id,parameters::text as parameters from route_estimation_profiles where id=? and status='ACTIVE'", profileId); + Map params = Jsonb.map(String.valueOf(profile.get("parameters"))); + Map flight = Jsonb.map(String.valueOf(version.get("flight_parameters"))); + double lengthM = number(version, "route_length_m", 0); + int waypointCount = Jsonb.mapList(String.valueOf(version.get("waypoints"))).size(); + double speed = request.cruiseSpeedMps() == null + ? number(flight, "speed_mps", number(params, "cruise_speed_mps", 6)) + : request.cruiseSpeedMps(); + double reservePercent = request.reservePercent() == null + ? number(params, "reserve_percent", 25) + : request.reservePercent(); + double flightSeconds = lengthM / Math.max(0.1, speed) + waypointCount * 15.0; + double batteryPercent = flightSeconds / 3600.0 * number(params, "power_w", 650) + / Math.max(1.0, number(params, "battery_capacity_wh", 180)) * 100.0; + double photoCount = Math.max(waypointCount, Math.ceil(flightSeconds / Math.max(1.0, number(params, "photo_interval_s", 2)))); + double storageMb = photoCount * number(params, "photo_size_mb", 8) + / Math.max(0.1, (1.0 - number(params, "storage_reserve_percent", 20) / 100.0)); + List warnings = new ArrayList<>(); + if (batteryPercent + reservePercent > 100) warnings.add("BATTERY_MARGIN_INSUFFICIENT"); + if (lengthM == 0) warnings.add("ROUTE_GEOMETRY_EMPTY"); + Map result = new LinkedHashMap<>(); + result.put("route_length_m", round(lengthM)); + result.put("waypoint_count", waypointCount); + result.put("speed_mps", speed); + result.put("estimated_minutes", round(flightSeconds / 60.0)); + result.put("battery_percent", round(batteryPercent)); + result.put("reserve_percent", reservePercent); + result.put("storage_mb", round(storageMb)); + result.put("photo_count", (long) photoCount); + result.put("warnings", warnings); + result.put("confidence", "REFERENCE"); + result.put("production_gate", "REQUIRES_DEVICE_CALIBRATION"); + String runId = Ids.next("route-estimate"); + jdbc.update(""" + insert into route_estimation_runs( + id,route_version_id,profile_id,input_snapshot,status,result,confidence,created_by,created_at,completed_at + ) values (?,?,?,?::jsonb,?,?::jsonb,?,?,?,?) + """, + runId, versionId, profileId, Jsonb.write(Map.of("profile", params, "flight_parameters", flight)), + warnings.isEmpty() ? "PASSED" : "PASSED_WITH_WARNINGS", Jsonb.write(result), "REFERENCE", + defaultString(request.createdBy(), "route-estimator"), Timestamp.from(Instant.now()), Timestamp.from(Instant.now())); + jdbc.update("update route_versions set estimation_result=?::jsonb where id=?", Jsonb.write(result), versionId); + publish("route.estimate.completed", Map.of("route_estimation_run_id", runId, "route_version_id", versionId)); + result.put("route_estimation_run_id", runId); + return result; + } + + public List> routeEstimations(String versionId) { + return jdbc.queryForList(""" + select id as route_estimation_run_id,route_version_id,profile_id,input_snapshot::text as input_snapshot, + status,result::text as result,confidence,created_by,created_at,completed_at + from route_estimation_runs + where route_version_id=? + order by created_at desc + """, versionId); + } + + // PF-15: spatial rule versions, exemptions, geofences and explanation. + + public List> spatialRuleSets() { + return jdbc.queryForList(""" + select s.id as rule_set_id,s.code,s.name,s.rule_type,s.status,s.current_version_id, + v.version_no,v.approval_status,v.status as version_status,v.rules::text as rules, + v.checksum,v.published_at,s.created_at,s.updated_at + from spatial_rule_sets s + left join spatial_rule_set_versions v on v.id=s.current_version_id + order by s.code + """); + } + + @Transactional + public Map createSpatialRuleSet(FoundationRequests.CreateSpatialRuleSetRequest request) { + String id = Ids.next("spatial-rules"); + Instant now = Instant.now(); + jdbc.update(""" + insert into spatial_rule_sets(id,code,name,rule_type,status,created_at,updated_at) + values (?,?,?,?,'DRAFT',?,?) + """, id, request.code().toUpperCase(Locale.ROOT), request.name(), + request.ruleType().toUpperCase(Locale.ROOT), Timestamp.from(now), Timestamp.from(now)); + publish("spatial.rule-set.created", Map.of("rule_set_id", id)); + return Map.of("rule_set_id", id, "status", "DRAFT"); + } + + @Transactional + public Map createSpatialRuleVersion(String ruleSetId, FoundationRequests.CreateSpatialRuleVersionRequest request) { + requireRow("select id from spatial_rule_sets where id=? for update", ruleSetId); + Integer next = jdbc.queryForObject("select coalesce(max(version_no),0)+1 from spatial_rule_set_versions where rule_set_id=?", + Integer.class, ruleSetId); + String id = Ids.next("spatial-rule-version"); + String checksum = sha256(Jsonb.write(listOrEmpty(request.rules()))); + jdbc.update(""" + insert into spatial_rule_set_versions( + id,rule_set_id,version_no,effective_from,effective_to,rules,checksum,approval_status,status,created_by,created_at + ) values (?,?,?,?,?,?::jsonb,?,'DRAFT','DRAFT',?,?) + """, + id, ruleSetId, next, + timestamp(request.effectiveFrom()), timestamp(request.effectiveTo()), + Jsonb.write(listOrEmpty(request.rules())), checksum, + defaultString(request.createdBy(), "spatial-admin"), Timestamp.from(Instant.now())); + jdbc.update("update spatial_rule_sets set status='DRAFT',updated_at=? where id=?", Timestamp.from(Instant.now()), ruleSetId); + publish("spatial.rule-version.created", Map.of("rule_set_id", ruleSetId, "rule_version_id", id)); + return Map.of("rule_set_id", ruleSetId, "rule_version_id", id, "version_no", next, "checksum", checksum); + } + + @Transactional + public Map publishSpatialRuleVersion(String versionId, String approvedBy) { + Map version = requireRow(""" + select id,rule_set_id from spatial_rule_set_versions where id=? for update + """, versionId); + Instant now = Instant.now(); + jdbc.update("update spatial_rule_set_versions set status='RETIRED' where rule_set_id=? and status='PUBLISHED' and id<>?", + version.get("rule_set_id"), versionId); + jdbc.update(""" + update spatial_rule_set_versions + set approval_status='APPROVED',status='PUBLISHED',published_at=? + where id=? + """, Timestamp.from(now), versionId); + jdbc.update(""" + update spatial_rule_sets set status='ACTIVE',current_version_id=?,updated_at=? + where id=? + """, versionId, Timestamp.from(now), version.get("rule_set_id")); + publish("spatial.rule-version.published", Map.of("rule_version_id", versionId, "approved_by", defaultString(approvedBy, "user-approver"))); + return Map.of("rule_version_id", versionId, "status", "PUBLISHED"); + } + + public List> spatialExemptions() { + return jdbc.queryForList(""" + select id as exemption_id,exemption_type,ST_AsGeoJSON(geom) as geometry, + applies_to_scenes::text as applies_to_scenes,applies_to_object_types::text as applies_to_object_types, + reason,effective_from,effective_to,approval_status,approved_by,status,created_by,created_at,updated_at + from spatial_exemptions + order by effective_to desc + """); + } + + @Transactional + public Map createSpatialExemption(FoundationRequests.CreateSpatialExemptionRequest request) { + String id = Ids.next("spatial-exemption"); + Instant now = Instant.now(); + jdbc.update(""" + insert into spatial_exemptions( + id,exemption_type,geom,applies_to_scenes,applies_to_object_types,reason, + effective_from,effective_to,approval_status,status,created_by,created_at,updated_at + ) values (?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326),?::jsonb,?::jsonb,?,?,?,'PENDING','DRAFT',?,?,?) + """, + id, request.exemptionType().toUpperCase(Locale.ROOT), Jsonb.write(request.geometry()), + Jsonb.write(listOrEmpty(request.appliesToScenes())), + Jsonb.write(listOrEmpty(request.appliesToObjectTypes())), + request.reason(), Timestamp.from(parseInstant(request.effectiveFrom(), "生效时间格式错误")), + Timestamp.from(parseInstant(request.effectiveTo(), "失效时间格式错误")), + defaultString(request.createdBy(), "spatial-admin"), Timestamp.from(now), Timestamp.from(now)); + publish("spatial.exemption.created", Map.of("exemption_id", id)); + return Map.of("exemption_id", id, "status", "DRAFT", "approval_status", "PENDING"); + } + + @Transactional + public Map approveSpatialExemption(String exemptionId, String approvedBy) { + int updated = jdbc.update(""" + update spatial_exemptions + set approval_status='APPROVED',status='ACTIVE',approved_by=?,updated_at=? + where id=? + """, defaultString(approvedBy, "user-approver"), Timestamp.from(Instant.now()), exemptionId); + if (updated == 0) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "豁免区不存在"); + publish("spatial.exemption.approved", Map.of("exemption_id", exemptionId)); + return Map.of("exemption_id", exemptionId, "status", "ACTIVE", "approval_status", "APPROVED"); + } + + public List> geofences() { + return jdbc.queryForList(""" + select id as geofence_id,code,name,fence_type,ST_AsGeoJSON(geom) as geometry, + altitude_min_m,altitude_max_m,effective_from,effective_to,severity,dataset_version_id, + status,created_by,created_at,updated_at + from geofences + order by code + """); + } + + @Transactional + public Map createGeofence(FoundationRequests.CreateGeofenceRequest request) { + String id = Ids.next("geofence"); + Instant now = Instant.now(); + jdbc.update(""" + insert into geofences( + id,code,name,fence_type,geom,altitude_min_m,altitude_max_m,effective_from,effective_to, + severity,dataset_version_id,status,created_by,created_at,updated_at + ) values (?,?,?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326),?,?,?,?,?,?, 'ACTIVE',?,?,?) + """, + id, request.code().toUpperCase(Locale.ROOT), request.name(), request.fenceType().toUpperCase(Locale.ROOT), + Jsonb.write(request.geometry()), request.altitudeMinM(), request.altitudeMaxM(), + Timestamp.from(parseInstant(request.effectiveFrom(), "生效时间格式错误")), + Timestamp.from(parseInstant(request.effectiveTo(), "失效时间格式错误")), + defaultString(request.severity(), "WARNING").toUpperCase(Locale.ROOT), + blankToNull(request.datasetVersionId()), defaultString(request.createdBy(), "spatial-admin"), + Timestamp.from(now), Timestamp.from(now)); + publish("spatial.geofence.created", Map.of("geofence_id", id)); + return Map.of("geofence_id", id, "status", "ACTIVE"); + } + + @Transactional + public Map explainSpatialHit(FoundationRequests.ExplainSpatialHitRequest request) { + Map activeRule = requireRow(""" + select current_version_id from spatial_rule_sets + where status='ACTIVE' and current_version_id is not null + order by updated_at desc limit 1 + """); + List> geofenceHits = jdbc.queryForList(""" + select id as geofence_id,code,name,severity + from geofences + where status='ACTIVE' + and now() between effective_from and effective_to + and ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(?),4326)) + order by severity desc,code + """, Jsonb.write(request.geometry())); + List> exemptionHits = jdbc.queryForList(""" + select id as exemption_id,exemption_type,reason + from spatial_exemptions + where status='ACTIVE' and approval_status='APPROVED' + and now() between effective_from and effective_to + and ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(?),4326)) + order by effective_to + """, Jsonb.write(request.geometry())); + String decision = !exemptionHits.isEmpty() ? "EXEMPTED" : !geofenceHits.isEmpty() ? "GEOFENCE_WARNING" : "CLEAR"; + Map explanation = Map.of( + "decision", decision, + "geofence_hits", geofenceHits, + "exemption_hits", exemptionHits, + "scene", defaultString(request.scene(), "UNKNOWN"), + "object_type", defaultString(request.objectType(), "UNKNOWN"), + "rule_version_id", activeRule.get("current_version_id"), + "production_gate", "USES_ACTIVE_RULE_VERSION_AND_APPROVED_EXEMPTIONS" + ); + String hitId = Ids.next("spatial-hit"); + jdbc.update(""" + insert into spatial_rule_hits( + id,task_id,analysis_result_id,rule_version_id,exemption_id,decision,explanation,created_at + ) values (?,?,?,?,?,?,?::jsonb,?) + """, + hitId, blankToNull(request.taskId()), blankToNull(request.analysisResultId()), + activeRule.get("current_version_id"), + exemptionHits.isEmpty() ? null : exemptionHits.get(0).get("exemption_id"), + decision, Jsonb.write(explanation), Timestamp.from(Instant.now())); + publish("spatial.rule-hit.explained", Map.of("spatial_rule_hit_id", hitId, "decision", decision)); + Map response = new LinkedHashMap<>(explanation); + response.put("spatial_rule_hit_id", hitId); + return response; + } + + private Map runPlanExecution(Map plan, String triggerSource) { + String planId = String.valueOf(plan.get("id")); + Instant start = ((Timestamp) plan.get("next_run_at")).toInstant(); + Instant end = start.plus(1, ChronoUnit.HOURS); + String generationKey = sha256(planId + "|" + start + "|" + end); + String executionId = Ids.next("plan-execution"); + int inserted = jdbc.update(""" + insert into plan_executions( + id,plan_id,scheduled_window_start,scheduled_window_end,generation_key, + trigger_source,status,started_at + ) values (?,?,?,?,?,?,'RUNNING',?) + on conflict(plan_id, scheduled_window_start, generation_key) do nothing + """, + executionId, planId, Timestamp.from(start), Timestamp.from(end), generationKey, + triggerSource, Timestamp.from(Instant.now())); + if (inserted == 0) { + return Map.of("plan_id", planId, "status", "SKIPPED", "skip_reason", "DUPLICATE_EXECUTION"); + } + try { + Map generated = capabilityService.generatePlanTasks(planId); + Instant next = nextScheduledStart(plan, start); + jdbc.update(""" + update plan_executions + set status='COMPLETED',generated_task_ids=?::jsonb,completed_at=? + where id=? + """, Jsonb.write(generated.get("task_ids")), Timestamp.from(Instant.now()), executionId); + jdbc.update(""" + update inspection_plans + set last_run_at=?,next_run_at=?,updated_at=?,version_no=version_no+1 + where id=? + """, Timestamp.from(start), Timestamp.from(next), Timestamp.from(Instant.now()), planId); + return Map.of("plan_execution_id", executionId, "plan_id", planId, + "status", "COMPLETED", "generated", generated.get("generated"), "next_run_at", next.toString()); + } catch (Exception exception) { + jdbc.update(""" + update plan_executions + set status='FAILED',error_message=?,completed_at=? + where id=? + """, exception.getMessage(), Timestamp.from(Instant.now()), executionId); + return Map.of("plan_execution_id", executionId, "plan_id", planId, + "status", "FAILED", "error_message", exception.getMessage()); + } + } + + private Instant nextScheduledStart(Map plan, Instant after) { + Map config = Jsonb.map(String.valueOf(plan.getOrDefault("schedule_config", "{}"))); + ZoneId zone = ZoneId.of(defaultString(plan.get("schedule_timezone"), "Asia/Shanghai")); + ZonedDateTime current = after.atZone(zone); + LocalTime executionTime = parseLocalTime(defaultString(config.get("execution_time"), "09:00")); + String frequency = defaultString(config.get("frequency"), defaultString(plan.get("schedule_rule"), "DAILY")).toUpperCase(Locale.ROOT); + ZonedDateTime next; + if ("WEEKLY".equals(frequency)) { + List days = weekdays(config.get("weekdays")); + next = current.plusDays(1).with(executionTime); + for (int i = 0; i < 14; i++) { + if (days.contains(next.getDayOfWeek())) return next.toInstant(); + next = next.plusDays(1); + } + } else if ("MONTHLY".equals(frequency)) { + List days = numberList(config.get("month_days")); + next = current.plusDays(1).with(executionTime); + for (int i = 0; i < 62; i++) { + if (days.contains(next.getDayOfMonth())) return next.toInstant(); + next = next.plusDays(1); + } + } else if ("SEASONAL".equals(frequency)) { + next = current.plusWeeks(1).with(executionTime); + } else { + int interval = Math.max(1, intValue(config.get("interval_days"), 1)); + next = current.plusDays(interval).with(executionTime); + } + return next.toInstant(); + } + + private Map checkResource(String resourceId, Instant start, Instant end, String taskId) { + Map resource = requireRow("select id,resource_type,name,status from operation_resources where id=?", resourceId); + if (!"AVAILABLE".equals(resource.get("status"))) { + return issue("RESOURCE_UNAVAILABLE", "BLOCKING", resource.get("name") + " 不可用", String.valueOf(resource.get("resource_type"))); + } + Integer unavailable = jdbc.queryForObject(""" + select count(*) from resource_unavailable_windows + where resource_id=? and start_at? + """, Integer.class, resourceId, Timestamp.from(end), Timestamp.from(start)); + if (unavailable != null && unavailable > 0) { + return issue("RESOURCE_MAINTENANCE_WINDOW", "BLOCKING", resource.get("name") + " 存在维护窗口", String.valueOf(resource.get("resource_type"))); + } + Integer overlap = jdbc.queryForObject(""" + select count(*) from resource_reservations + where resource_id=? and task_id<>? and status='ACTIVE' and start_at? + """, Integer.class, resourceId, taskId, Timestamp.from(end), Timestamp.from(start)); + if (overlap != null && overlap > 0) { + return issue("RESOURCE_TIME_OVERLAP", "BLOCKING", resource.get("name") + " 与其他任务预占重叠", String.valueOf(resource.get("resource_type"))); + } + if ("PILOT".equals(resource.get("resource_type"))) { + Integer activeQual = jdbc.queryForObject(""" + select count(*) from resource_qualifications + where resource_id=? and status='ACTIVE' + and (valid_from is null or valid_from<=?) + and (valid_to is null or valid_to>=?) + """, Integer.class, resourceId, Timestamp.from(start), Timestamp.from(end)); + if (activeQual == null || activeQual == 0) { + return issue("PILOT_QUALIFICATION_MISSING", "BLOCKING", resource.get("name") + " 缺少有效资质", "PILOT"); + } + } + return issue("RESOURCE_AVAILABLE", "PASSED", resource.get("name") + " 时间窗口可用", String.valueOf(resource.get("resource_type"))); + } + + private List> candidateWaypoints(Map object, String generatorType, Map params) { + double minX = number(object, "min_x", 116.09); + double maxX = number(object, "max_x", minX + 0.03); + double minY = number(object, "min_y", 39.09); + double maxY = number(object, "max_y", minY + 0.02); + if (Math.abs(maxX - minX) < 0.0001) maxX = minX + 0.01; + if (Math.abs(maxY - minY) < 0.0001) maxY = minY + 0.01; + int count = Math.max(2, Math.min(12, intValue(params.get("waypoint_count"), 5))); + double altitude = number(params, "altitude_m", 75); + double speed = number(params, "speed_mps", 6); + List> points = new ArrayList<>(); + for (int index = 0; index < count; index++) { + double ratio = count == 1 ? 0 : (double) index / (count - 1); + double longitude; + double latitude; + if ("BRIDGE_ORBIT".equals(generatorType)) { + double centerX = (minX + maxX) / 2.0; + double centerY = (minY + maxY) / 2.0; + double radiusX = Math.max(0.002, (maxX - minX) / 2.0 + 0.002); + double radiusY = Math.max(0.002, (maxY - minY) / 2.0 + 0.002); + double angle = Math.PI * 2 * ratio; + longitude = centerX + Math.cos(angle) * radiusX; + latitude = centerY + Math.sin(angle) * radiusY; + } else if ("FLOOD_CORRIDOR".equals(generatorType)) { + longitude = index % 2 == 0 ? minX : maxX; + latitude = minY + ratio * (maxY - minY); + } else if ("TUNNEL_PORTAL".equals(generatorType)) { + longitude = index < count / 2 ? minX : maxX; + latitude = minY + (index % Math.max(1, count / 2)) * (maxY - minY) / Math.max(1, count / 2); + } else { + longitude = minX + ratio * (maxX - minX); + latitude = minY + ratio * (maxY - minY); + } + points.add(Map.of( + "sequence", index + 1, + "longitude", round(longitude, 6), + "latitude", round(latitude, 6), + "altitude_m", altitude, + "speed_mps", speed, + "heading_deg", round(60 + ratio * 60), + "gimbal_pitch_deg", number(params, "gimbal_pitch_deg", -45), + "actions", List.of("TAKE_PHOTO") + )); + } + return points; + } + + private void insertWaypoints(String versionId, List> waypoints, Map flightParameters) { + for (int i = 0; i < waypoints.size(); i++) { + Map waypoint = waypoints.get(i); + double longitude = number(waypoint, "longitude", 0); + double latitude = number(waypoint, "latitude", 0); + double altitude = number(waypoint, "altitude_m", 80); + double speed = number(waypoint, "speed_mps", number(flightParameters, "speed_mps", 6)); + jdbc.update(""" + insert into route_waypoints( + id,route_version_id,sequence_no,geom,altitude_m,speed_mps, + heading_deg,gimbal_pitch_deg,actions + ) values (?,?,?,ST_SetSRID(ST_MakePoint(?,?,?),4326),?,?,?,?,?::jsonb) + """, + Ids.next("wp"), versionId, i + 1, longitude, latitude, altitude, altitude, speed, + nullableNumber(waypoint.get("heading_deg")), nullableNumber(waypoint.get("gimbal_pitch_deg")), + Jsonb.write(waypoint.getOrDefault("actions", List.of()))); + } + } + + private Map canonicalObjectPayload(Map row) { + Map payload = new LinkedHashMap<>(); + payload.put("source_key", stringValue(row, "source_key")); + payload.put("object_type", upper(firstPresent(row, "object_type", "type"))); + payload.put("name", firstPresent(row, "name", "object_name")); + payload.put("line_id", firstPresent(row, "line_id", "line")); + payload.put("mileage_start", firstPresent(row, "mileage_start", "start_mileage")); + payload.put("mileage_end", firstPresent(row, "mileage_end", "end_mileage")); + payload.put("owner_org_id", defaultString(firstPresent(row, "owner_org_id", "org_id"), "org-works")); + payload.put("risk_level", defaultString(firstPresent(row, "risk_level", "risk"), "NORMAL")); + payload.put("inspection_cycle_days", intValue(firstPresent(row, "inspection_cycle_days", "cycle_days"), 7)); + payload.put("source_crs", defaultString(firstPresent(row, "source_crs", "crs"), "EPSG:4326")); + payload.put("attributes", new LinkedHashMap<>(row)); + return payload; + } + + @SuppressWarnings("unchecked") + private Map canonicalGeometry(Map row) { + Object geometry = firstPresent(row, "geometry", "geom"); + if (geometry instanceof Map map) return (Map) map; + Double longitude = nullableNumber(firstPresent(row, "longitude", "lon", "lng")); + Double latitude = nullableNumber(firstPresent(row, "latitude", "lat")); + if (longitude == null || latitude == null) return Map.of(); + return Map.of("type", "Point", "coordinates", List.of(longitude, latitude)); + } + + private static Map issue(String code, String level, Object message, String domain) { + return Map.of("code", code, "level", level, "message", String.valueOf(message), "domain", domain); + } + + private static String defaultGeneratorFor(Object objectType) { + return switch (upper(objectType)) { + case "BRIDGE" -> "BRIDGE_ORBIT"; + case "TUNNEL" -> "TUNNEL_PORTAL"; + case "FLOOD" -> "FLOOD_CORRIDOR"; + case "POWER" -> "POWER_FOLLOW"; + default -> "RAILWAY_PARALLEL"; + }; + } + + private String templateForObject(String objectType) { + List templates = jdbc.queryForList(""" + select id from route_templates + where object_type=? and status='ACTIVE' + order by updated_at desc limit 1 + """, String.class, objectType); + if (!templates.isEmpty()) return templates.get(0); + return "BRIDGE".equals(objectType) ? "template-bridge" : "template-railway"; + } + + private static Object firstPresent(Map row, String... keys) { + for (String key : keys) { + if (row.containsKey(key) && row.get(key) != null && !String.valueOf(row.get(key)).isBlank()) return row.get(key); + } + return null; + } + + private static Instant resolveStart(String requested, Object fallback) { + if (!blank(requested)) return parseInstant(requested, "开始时间格式错误"); + if (fallback instanceof Timestamp timestamp) return timestamp.toInstant(); + return Instant.now().truncatedTo(ChronoUnit.MINUTES); + } + + private static Instant resolveEnd(String requested, Instant start, Integer durationMinutes) { + if (!blank(requested)) return parseInstant(requested, "结束时间格式错误"); + return start.plus(Math.max(15, durationMinutes == null ? 90 : durationMinutes), ChronoUnit.MINUTES); + } + + private static Timestamp timestamp(String value) { + return blank(value) ? null : Timestamp.from(parseInstant(value, "时间格式错误")); + } + + private static Instant parseInstant(String value, String message) { + try { + return Instant.parse(value); + } catch (Exception exception) { + throw badRequest(message); + } + } + + private static LocalTime parseLocalTime(String value) { + try { + return LocalTime.parse(value); + } catch (Exception ignored) { + return LocalTime.of(9, 0); + } + } + + private static List weekdays(Object value) { + List raw = value instanceof List list + ? list.stream().map(String::valueOf).toList() + : List.of("MON"); + List days = new ArrayList<>(); + for (String item : raw) { + days.add(switch (item.toUpperCase(Locale.ROOT)) { + case "TUE", "2" -> DayOfWeek.TUESDAY; + case "WED", "3" -> DayOfWeek.WEDNESDAY; + case "THU", "4" -> DayOfWeek.THURSDAY; + case "FRI", "5" -> DayOfWeek.FRIDAY; + case "SAT", "6" -> DayOfWeek.SATURDAY; + case "SUN", "7" -> DayOfWeek.SUNDAY; + default -> DayOfWeek.MONDAY; + }); + } + return days.stream().distinct().sorted(Comparator.naturalOrder()).toList(); + } + + private static List numberList(Object value) { + if (!(value instanceof List list)) return List.of(1); + return list.stream().map(item -> intValue(item, 1)).filter(day -> day >= 1 && day <= 28).distinct().sorted().toList(); + } + + private static String placeholders(int size) { + return String.join(",", java.util.Collections.nCopies(size, "?")); + } + + private Map requireRow(String sql, Object... args) { + List> rows = jdbc.queryForList(sql, args); + if (rows.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "业务对象不存在"); + return rows.get(0); + } + + private void publish(String eventType, Map payload) { + String id = Ids.next("event"); + jdbc.update("insert into platform_events(id,event_type,payload,created_at) values (?,?,?::jsonb,?)", + id, eventType, Jsonb.write(payload), Timestamp.from(Instant.now())); + kafkaTemplate.send("rail." + eventType, payload); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static String stringValue(Map value, String key) { + Object result = value.get(key); + return result == null || String.valueOf(result).isBlank() ? null : String.valueOf(result); + } + + private static String upper(Object value) { + return value == null ? "" : String.valueOf(value).trim().toUpperCase(Locale.ROOT); + } + + private static boolean blank(Object value) { + return value == null || String.valueOf(value).isBlank(); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private static String defaultString(Object value, Object fallback) { + return blank(value) ? fallback == null ? null : String.valueOf(fallback) : String.valueOf(value); + } + + private static List listOrEmpty(List value) { + return value == null ? List.of() : value; + } + + private static Map mapOrEmpty(Map value) { + return value == null ? Map.of() : value; + } + + @SuppressWarnings("unchecked") + private static Map castMap(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + private static double number(Map values, String key, double fallback) { + if (values == null || values.get(key) == null) return fallback; + Object value = values.get(key); + if (value instanceof Number number) return number.doubleValue(); + try { + return Double.parseDouble(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static int intValue(Object value, int fallback) { + if (value instanceof Number number) return number.intValue(); + try { + return value == null ? fallback : Integer.parseInt(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static Double nullableNumber(Object value) { + if (value == null) return null; + if (value instanceof Number number) return number.doubleValue(); + try { + return Double.parseDouble(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static double round(double value) { + return round(value, 2); + } + + private static double round(double value, int scale) { + double multiplier = Math.pow(10, scale); + return Math.round(value * multiplier) / multiplier; + } + + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/FoundationCompletionService.java b/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/FoundationCompletionService.java index fe9d0cf..08f586f 100644 --- a/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/FoundationCompletionService.java +++ b/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/FoundationCompletionService.java @@ -825,6 +825,7 @@ public class FoundationCompletionService { rv.waypoints::text as waypoints,rv.flight_parameters::text as flight_parameters, rv.payload_actions::text as payload_actions, rv.validation_result::text as validation_result,rv.checksum, + rv.estimation_result::text as estimation_result,rv.edited_at,rv.edited_by, rv.created_by,rv.approved_by,rv.submitted_at,rv.created_at, rv.published_at,rv.published_by,rv.retired_at,rv.rollback_target_version_id, rv.version_lock, diff --git a/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/PlanSchedulerJob.java b/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/PlanSchedulerJob.java new file mode 100644 index 0000000..6c6c9af --- /dev/null +++ b/platform/backend/src/main/java/com/ai/trackwalker/foundation/service/PlanSchedulerJob.java @@ -0,0 +1,26 @@ +package com.ai.trackwalker.foundation.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class PlanSchedulerJob { + private static final Logger log = LoggerFactory.getLogger(PlanSchedulerJob.class); + + private final DependencyFreeCompletionService service; + + public PlanSchedulerJob(DependencyFreeCompletionService service) { + this.service = service; + } + + @Scheduled(fixedDelayString = "${rail.planning.scheduler-delay-ms:30000}") + public void runDuePlans() { + try { + service.runDuePlans(20); + } catch (Exception exception) { + log.warn("Dependency-free plan scheduler pass failed: {}", exception.getMessage()); + } + } +} diff --git a/platform/backend/src/main/resources/db/migration/V21__dependency_free_pf01_pf15.sql b/platform/backend/src/main/resources/db/migration/V21__dependency_free_pf01_pf15.sql new file mode 100644 index 0000000..745cb9d --- /dev/null +++ b/platform/backend/src/main/resources/db/migration/V21__dependency_free_pf01_pf15.sql @@ -0,0 +1,577 @@ +-- Dependency-free software closure for PF-01, PF-02, PF-05, PF-08, PF-09, PF-10, PF-11 and PF-15. +-- Production use still requires approved railway master data, device matrices and spatial rule datasets. + +alter table inspection_plans + add column if not exists schedule_timezone varchar(64) not null default 'Asia/Shanghai', + add column if not exists calendar_id varchar(64), + add column if not exists missed_execution_policy varchar(32) not null default 'RUN_ONCE', + add column if not exists max_catch_up_count integer not null default 1, + add column if not exists effective_from timestamptz, + add column if not exists effective_to timestamptz, + add column if not exists last_run_at timestamptz, + add column if not exists version_no bigint not null default 0; + +alter table route_versions + add column if not exists estimation_result jsonb not null default '{}'::jsonb, + add column if not exists edited_at timestamptz, + add column if not exists edited_by varchar(64); + +create table if not exists inspection_object_mapping_profiles ( + id varchar(64) primary key, + name varchar(128) not null, + object_type varchar(32) not null, + file_type varchar(32) not null, + field_mapping jsonb not null default '{}'::jsonb, + value_mapping jsonb not null default '{}'::jsonb, + default_values jsonb not null default '{}'::jsonb, + validation_profile jsonb not null default '{}'::jsonb, + status varchar(32) not null, + version_no bigint not null default 0, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists inspection_object_import_jobs ( + id varchar(64) primary key, + file_name varchar(512) not null, + file_type varchar(32) not null, + object_key text, + mapping_profile_id varchar(64) references inspection_object_mapping_profiles(id), + declared_crs varchar(64) not null default 'EPSG:4326', + status varchar(32) not null, + total_count integer not null default 0, + valid_count integer not null default 0, + invalid_count integer not null default 0, + warning_count integer not null default 0, + dataset_version_id varchar(64), + quality_report jsonb not null default '{}'::jsonb, + created_by varchar(64) not null, + confirmed_by varchar(64), + confirmed_at timestamptz, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists inspection_object_import_rows ( + id varchar(64) primary key, + job_id varchar(64) not null references inspection_object_import_jobs(id) on delete cascade, + row_no integer not null, + source_key varchar(256), + raw_attributes jsonb not null default '{}'::jsonb, + source_geometry jsonb, + canonical_payload jsonb not null default '{}'::jsonb, + canonical_geometry geometry(Geometry, 4326), + validation_status varchar(32) not null, + error_codes jsonb not null default '[]'::jsonb, + warning_codes jsonb not null default '[]'::jsonb, + imported_object_id varchar(64) references inspection_objects(id), + unique(job_id, row_no) +); + +create table if not exists inspection_object_versions ( + id varchar(64) primary key, + object_id varchar(64) not null references inspection_objects(id), + version_no integer not null, + snapshot jsonb not null, + geom geometry(Geometry, 4326), + change_type varchar(32) not null, + change_reason text, + source_job_id varchar(64) references inspection_object_import_jobs(id), + created_by varchar(64) not null, + created_at timestamptz not null, + unique(object_id, version_no) +); + +create index if not exists idx_object_import_rows_job_status + on inspection_object_import_rows(job_id, validation_status, row_no); +create index if not exists idx_object_import_rows_geom + on inspection_object_import_rows using gist(canonical_geometry); +create index if not exists idx_object_versions_geom + on inspection_object_versions using gist(geom); + +create table if not exists business_calendars ( + id varchar(64) primary key, + name varchar(128) not null, + timezone varchar(64) not null, + calendar_type varchar(32) not null, + status varchar(32) not null, + version_no bigint not null default 0, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists business_calendar_dates ( + calendar_id varchar(64) not null references business_calendars(id) on delete cascade, + business_date date not null, + date_type varchar(32) not null, + labels jsonb not null default '[]'::jsonb, + enabled boolean not null default true, + primary key(calendar_id, business_date) +); + +create table if not exists plan_executions ( + id varchar(64) primary key, + plan_id varchar(64) not null references inspection_plans(id), + scheduled_window_start timestamptz not null, + scheduled_window_end timestamptz not null, + generation_key varchar(256) not null, + trigger_source varchar(32) not null, + status varchar(32) not null, + generated_task_ids jsonb not null default '[]'::jsonb, + skip_reason text, + error_message text, + started_at timestamptz not null, + completed_at timestamptz, + unique(plan_id, scheduled_window_start, generation_key) +); + +create table if not exists emergency_authorizations ( + id varchar(64) primary key, + plan_id varchar(64) references inspection_plans(id), + task_id varchar(64) references inspection_tasks(id), + event_source varchar(128), + authorized_by varchar(64) not null, + authorized_at timestamptz not null, + risk_confirmation text not null, + attachment_refs jsonb not null default '[]'::jsonb, + expires_at timestamptz not null, + status varchar(32) not null +); + +create index if not exists idx_plan_executions_plan_time + on plan_executions(plan_id, scheduled_window_start desc); +create index if not exists idx_inspection_plans_due + on inspection_plans(status, trigger_type, next_run_at); + +create table if not exists operation_resources ( + id varchar(64) primary key, + resource_type varchar(32) not null, + source_id varchar(64), + name varchar(128) not null, + capabilities jsonb not null default '[]'::jsonb, + status varchar(32) not null, + owner_org_id varchar(64) references organizations(id), + geom geometry(Geometry, 4326), + health_snapshot jsonb not null default '{}'::jsonb, + created_at timestamptz not null, + updated_at timestamptz not null, + unique(resource_type, source_id) +); + +create table if not exists resource_qualifications ( + id varchar(64) primary key, + resource_id varchar(64) not null references operation_resources(id) on delete cascade, + qualification_type varchar(64) not null, + qualification_no varchar(128), + valid_from timestamptz, + valid_to timestamptz, + scope jsonb not null default '{}'::jsonb, + status varchar(32) not null +); + +create table if not exists resource_unavailable_windows ( + id varchar(64) primary key, + resource_id varchar(64) not null references operation_resources(id) on delete cascade, + start_at timestamptz not null, + end_at timestamptz not null, + reason_type varchar(64) not null, + reason text, + source varchar(64) not null, + created_at timestamptz not null +); + +create table if not exists resource_reservations ( + id varchar(64) primary key, + task_id varchar(64) not null references inspection_tasks(id), + resource_id varchar(64) not null references operation_resources(id), + start_at timestamptz not null, + end_at timestamptz not null, + status varchar(32) not null, + reservation_type varchar(32) not null, + idempotency_key varchar(128) not null unique, + version_no bigint not null default 0, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists conflict_check_runs ( + id varchar(64) primary key, + task_id varchar(64) not null references inspection_tasks(id), + route_version_id varchar(64) references route_versions(id), + input_snapshot jsonb not null, + status varchar(32) not null, + blocking_count integer not null default 0, + warning_count integer not null default 0, + result jsonb not null default '{}'::jsonb, + rule_version varchar(64) not null, + checked_at timestamptz not null +); + +create index if not exists idx_resource_reservations_resource_time + on resource_reservations(resource_id, start_at, end_at, status); +create index if not exists idx_operation_resources_geom + on operation_resources using gist(geom); + +create table if not exists gis_dataset_versions ( + id varchar(64) primary key, + code varchar(64) not null, + name varchar(128) not null, + dataset_type varchar(32) not null, + source_type varchar(32) not null, + crs varchar(64) not null default 'EPSG:4326', + quality_status varchar(32) not null, + status varchar(32) not null, + version_no integer not null, + metadata jsonb not null default '{}'::jsonb, + checksum varchar(64) not null, + created_by varchar(64) not null, + created_at timestamptz not null, + published_at timestamptz, + unique(code, version_no) +); + +create table if not exists gis_layers ( + id varchar(64) primary key, + code varchar(64) not null unique, + name varchar(128) not null, + layer_type varchar(32) not null, + dataset_version_id varchar(64) references gis_dataset_versions(id), + style_spec jsonb not null default '{}'::jsonb, + visible_by_default boolean not null default true, + min_zoom numeric(5,2), + max_zoom numeric(5,2), + status varchar(32) not null, + version_no bigint not null default 0, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists gis_spatial_features ( + id varchar(64) primary key, + dataset_version_id varchar(64) not null references gis_dataset_versions(id) on delete cascade, + feature_type varchar(32) not null, + business_key varchar(128), + name varchar(128), + geom geometry(Geometry, 4326) not null, + properties jsonb not null default '{}'::jsonb, + status varchar(32) not null, + created_at timestamptz not null +); + +create index if not exists idx_gis_spatial_features_geom + on gis_spatial_features using gist(geom); +create index if not exists idx_gis_spatial_features_dataset + on gis_spatial_features(dataset_version_id, feature_type, status); + +create table if not exists route_generation_profiles ( + id varchar(64) primary key, + generator_type varchar(64) not null, + object_type varchar(32) not null, + name varchar(128) not null, + parameters jsonb not null default '{}'::jsonb, + status varchar(32) not null, + version_no bigint not null default 0, + created_at timestamptz not null, + updated_at timestamptz not null, + unique(generator_type, object_type) +); + +create table if not exists route_generation_runs ( + id varchar(64) primary key, + object_id varchar(64) references inspection_objects(id), + profile_id varchar(64) references route_generation_profiles(id), + generator_type varchar(64) not null, + parameters jsonb not null default '{}'::jsonb, + input_snapshot jsonb not null default '{}'::jsonb, + status varchar(32) not null, + candidate_route_id varchar(64) references routes(id), + candidate_version_id varchar(64) references route_versions(id), + result jsonb not null default '{}'::jsonb, + created_by varchar(64) not null, + created_at timestamptz not null, + completed_at timestamptz +); + +create table if not exists route_estimation_profiles ( + id varchar(64) primary key, + code varchar(64) not null unique, + name varchar(128) not null, + device_type varchar(64) not null, + payload_type varchar(64) not null, + parameters jsonb not null default '{}'::jsonb, + status varchar(32) not null, + version_no bigint not null default 0, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists route_estimation_runs ( + id varchar(64) primary key, + route_version_id varchar(64) not null references route_versions(id), + profile_id varchar(64) references route_estimation_profiles(id), + input_snapshot jsonb not null, + status varchar(32) not null, + result jsonb not null default '{}'::jsonb, + confidence varchar(32) not null, + created_by varchar(64) not null, + created_at timestamptz not null, + completed_at timestamptz +); + +create index if not exists idx_route_estimation_runs_version + on route_estimation_runs(route_version_id, created_at desc); + +create table if not exists route_editor_revisions ( + id varchar(64) primary key, + route_version_id varchar(64) not null references route_versions(id) on delete cascade, + revision_no integer not null, + editor_id varchar(64) not null, + change_summary text, + before_waypoints jsonb not null default '[]'::jsonb, + after_waypoints jsonb not null default '[]'::jsonb, + before_actions jsonb not null default '[]'::jsonb, + after_actions jsonb not null default '[]'::jsonb, + checksum varchar(64) not null, + created_at timestamptz not null, + unique(route_version_id, revision_no) +); + +create table if not exists route_action_schemas ( + id varchar(64) primary key, + device_type varchar(64) not null, + action_type varchar(64) not null, + schema_spec jsonb not null, + status varchar(32) not null, + version_no bigint not null default 0, + created_at timestamptz not null, + updated_at timestamptz not null, + unique(device_type, action_type) +); + +create table if not exists spatial_rule_sets ( + id varchar(64) primary key, + code varchar(64) not null unique, + name varchar(128) not null, + rule_type varchar(32) not null, + status varchar(32) not null, + current_version_id varchar(64), + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists spatial_rule_set_versions ( + id varchar(64) primary key, + rule_set_id varchar(64) not null references spatial_rule_sets(id), + version_no integer not null, + effective_from timestamptz, + effective_to timestamptz, + rules jsonb not null default '[]'::jsonb, + checksum varchar(64) not null, + approval_status varchar(32) not null, + status varchar(32) not null, + created_by varchar(64) not null, + created_at timestamptz not null, + published_at timestamptz, + unique(rule_set_id, version_no) +); + +create table if not exists spatial_baseline_features ( + id varchar(64) primary key, + rule_version_id varchar(64) not null references spatial_rule_set_versions(id) on delete cascade, + object_type varchar(32) not null, + business_key varchar(128) not null, + geom geometry(Geometry, 4326) not null, + feature_signature varchar(128) not null, + source_resource_id varchar(64), + valid_from timestamptz, + valid_to timestamptz, + attributes jsonb not null default '{}'::jsonb, + status varchar(32) not null +); + +create table if not exists spatial_exemptions ( + id varchar(64) primary key, + exemption_type varchar(32) not null, + geom geometry(Geometry, 4326) not null, + applies_to_scenes jsonb not null default '[]'::jsonb, + applies_to_object_types jsonb not null default '[]'::jsonb, + reason text not null, + effective_from timestamptz not null, + effective_to timestamptz not null, + approval_status varchar(32) not null, + approved_by varchar(64), + status varchar(32) not null, + created_by varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists geofences ( + id varchar(64) primary key, + code varchar(64) not null unique, + name varchar(128) not null, + fence_type varchar(32) not null, + geom geometry(Geometry, 4326) not null, + altitude_min_m numeric(10,2), + altitude_max_m numeric(10,2), + effective_from timestamptz not null, + effective_to timestamptz not null, + severity varchar(32) not null, + dataset_version_id varchar(64) references gis_dataset_versions(id), + status varchar(32) not null, + created_by varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null +); + +create table if not exists spatial_rule_hits ( + id varchar(64) primary key, + task_id varchar(64) references inspection_tasks(id), + analysis_result_id varchar(64) references ai_results(id), + rule_version_id varchar(64) references spatial_rule_set_versions(id), + matched_object_id varchar(64), + exemption_id varchar(64) references spatial_exemptions(id), + decision varchar(32) not null, + explanation jsonb not null default '{}'::jsonb, + created_at timestamptz not null +); + +create index if not exists idx_spatial_baseline_features_geom + on spatial_baseline_features using gist(geom); +create index if not exists idx_spatial_exemptions_geom + on spatial_exemptions using gist(geom); +create index if not exists idx_geofences_geom + on geofences using gist(geom); + +insert into inspection_object_mapping_profiles( + id,name,object_type,file_type,field_mapping,value_mapping,default_values,validation_profile, + status,version_no,created_at,updated_at +) values ( + 'object-mapping-demo-line', 'DEMO 巡检对象 CSV 映射', 'RAILWAY', 'JSON_ROWS', + '{"source_key":"source_key","name":"name","object_type":"object_type","line_id":"line_id","mileage_start":"mileage_start","mileage_end":"mileage_end","owner_org_id":"owner_org_id","risk_level":"risk_level"}'::jsonb, + '{}'::jsonb, + '{"source_type":"DEMO","source_version":"dependency-free-v21"}'::jsonb, + '{"allowed_object_types":["RAILWAY","BRIDGE","TUNNEL","FLOOD","POWER"],"requires_geometry":true}'::jsonb, + 'ACTIVE', 1, now(), now() +) on conflict (id) do nothing; + +insert into business_calendars(id,name,timezone,calendar_type,status,version_no,created_at,updated_at) values +('calendar-demo-standard', 'DEMO 标准工作日历', 'Asia/Shanghai', 'STANDARD', 'ACTIVE', 1, now(), now()), +('calendar-demo-flood', 'DEMO 汛期加密巡检日历', 'Asia/Shanghai', 'SEASONAL', 'ACTIVE', 1, now(), now()) +on conflict (id) do nothing; + +insert into operation_resources( + id,resource_type,source_id,name,capabilities,status,owner_org_id,geom,health_snapshot,created_at,updated_at +) values +('opres-device-sim-01','AIRCRAFT','uav-sim-01','铁路巡检模拟机','["RTK","VISIBLE","THERMAL","MISSION"]'::jsonb,'AVAILABLE','org-works',ST_GeomFromText('POINT(116.090 39.095)',4326),'{"source":"DEMO","battery_percent":92}'::jsonb,now(),now()), +('opres-dock-sim-01','DOCK','dock-sim-01','模拟无人值守机巢','["AUTO_CHARGE","WEATHER"]'::jsonb,'AVAILABLE','org-works',ST_GeomFromText('POINT(116.089 39.094)',4326),'{"source":"DEMO"}'::jsonb,now(),now()), +('opres-pilot-demo','PILOT','user-dispatcher','DEMO 持证飞手','["VISUAL_OBSERVER","MISSION_COMMAND"]'::jsonb,'AVAILABLE','org-works',null,'{"source":"DEMO"}'::jsonb,now(),now()) +on conflict (resource_type, source_id) do update +set capabilities=excluded.capabilities, + status=excluded.status, + health_snapshot=excluded.health_snapshot, + updated_at=excluded.updated_at; + +insert into resource_qualifications( + id,resource_id,qualification_type,qualification_no,valid_from,valid_to,scope,status +) values ( + 'qual-pilot-demo-uav', 'opres-pilot-demo', 'UAV_OPERATOR', 'DEMO-UAV-2026', + now() - interval '30 days', now() + interval '365 days', '{"line_ids":["line-demo"]}'::jsonb, 'ACTIVE' +) on conflict (id) do nothing; + +insert into gis_dataset_versions( + id,code,name,dataset_type,source_type,crs,quality_status,status,version_no,metadata,checksum,created_by,created_at,published_at +) values ( + 'gis-dataset-demo-line-v1','DEMO_LINE_CENTER','DEMO 线路中心线','LINE','DEMO','EPSG:4326','PASSED','PUBLISHED',1, + '{"line_id":"line-demo","accuracy":"DEMO"}'::jsonb, + md5('gis-dataset-demo-line-v1'),'system',now(),now() +) on conflict (id) do nothing; + +insert into gis_layers( + id,code,name,layer_type,dataset_version_id,style_spec,visible_by_default,min_zoom,max_zoom,status,version_no,created_at,updated_at +) values ( + 'gis-layer-demo-line','DEMO_LINE_CENTER','DEMO 线路中心线','LINE','gis-dataset-demo-line-v1', + '{"stroke":"#17365d","stroke_width":3}'::jsonb,true,4,20,'PUBLISHED',1,now(),now() +) on conflict (code) do nothing; + +insert into gis_spatial_features( + id,dataset_version_id,feature_type,business_key,name,geom,properties,status,created_at +) values ( + 'gis-feature-demo-line','gis-dataset-demo-line-v1','LINE','line-demo','试点线路中心线', + ST_GeomFromText('LINESTRING(116.090 39.095,116.125 39.115,116.165 39.105)',4326), + '{"source":"DEMO","line_id":"line-demo"}'::jsonb,'ACTIVE',now() +) on conflict (id) do nothing; + +insert into route_generation_profiles( + id,generator_type,object_type,name,parameters,status,version_no,created_at,updated_at +) values +('route-gen-railway-parallel','RAILWAY_PARALLEL','RAILWAY','沿线平行巡检生成器','{"altitude_m":80,"speed_mps":7,"offset_m":20,"waypoint_count":5}'::jsonb,'ACTIVE',1,now(),now()), +('route-gen-bridge-orbit','BRIDGE_ORBIT','BRIDGE','桥梁环绕巡检生成器','{"altitude_m":65,"speed_mps":4,"orbit_radius_m":35,"waypoint_count":6}'::jsonb,'ACTIVE',1,now(),now()), +('route-gen-tunnel-portal','TUNNEL_PORTAL','TUNNEL','隧道洞口巡检生成器','{"altitude_m":55,"speed_mps":4,"waypoint_count":4}'::jsonb,'ACTIVE',1,now(),now()), +('route-gen-flood-corridor','FLOOD_CORRIDOR','FLOOD','防洪走廊巡检生成器','{"altitude_m":75,"speed_mps":6,"waypoint_count":6}'::jsonb,'ACTIVE',1,now(),now()), +('route-gen-power-follow','POWER_FOLLOW','POWER','电力部件跟拍生成器','{"altitude_m":70,"speed_mps":5,"waypoint_count":5}'::jsonb,'ACTIVE',1,now(),now()) +on conflict (generator_type, object_type) do nothing; + +insert into route_estimation_profiles( + id,code,name,device_type,payload_type,parameters,status,version_no,created_at,updated_at +) values ( + 'route-estimate-demo-matrice4d','DEMO_MATRICE4D_VISIBLE','DEMO Matrice 4D 可见光估算','MATRICE_4D','VISIBLE', + '{"cruise_speed_mps":7,"battery_capacity_wh":180,"power_w":650,"reserve_percent":25,"photo_interval_s":2,"photo_size_mb":8,"storage_reserve_percent":20}'::jsonb, + 'ACTIVE',1,now(),now() +) on conflict (code) do nothing; + +insert into route_action_schemas( + id,device_type,action_type,schema_spec,status,version_no,created_at,updated_at +) values +('action-schema-visible-photo','GENERIC_UAV','TAKE_PHOTO','{"required":["interval_s"],"properties":{"interval_s":{"type":"number","minimum":1},"gimbal_pitch_deg":{"type":"number","minimum":-90,"maximum":30}}}'::jsonb,'ACTIVE',1,now(),now()), +('action-schema-thermal-photo','GENERIC_UAV','THERMAL_CAPTURE','{"required":["interval_s"],"properties":{"interval_s":{"type":"number","minimum":1},"emissivity":{"type":"number","minimum":0.1,"maximum":1}}}'::jsonb,'ACTIVE',1,now(),now()) +on conflict (device_type, action_type) do nothing; + +insert into spatial_rule_sets(id,code,name,rule_type,status,current_version_id,created_at,updated_at) values ( + 'spatial-rules-demo-safety','DEMO_SPATIAL_SAFETY','DEMO 空间安全规则','BASELINE_EXEMPTION','ACTIVE','spatial-rules-demo-safety-v1',now(),now() +) on conflict (code) do nothing; + +insert into spatial_rule_set_versions( + id,rule_set_id,version_no,effective_from,effective_to,rules,checksum,approval_status,status,created_by,created_at,published_at +) values ( + 'spatial-rules-demo-safety-v1','spatial-rules-demo-safety',1,now() - interval '1 day',now() + interval '365 days', + '[{"code":"DEMO_GEOFENCE_INTERSECTION","severity":"WARNING","description":"命中演示地理围栏时提示复核"},{"code":"DEMO_BASELINE_DISTANCE","severity":"INFO","description":"与基线对象保持可解释距离"}]'::jsonb, + md5('spatial-rules-demo-safety-v1'),'APPROVED','PUBLISHED','system',now(),now() +) on conflict (id) do nothing; + +insert into spatial_exemptions( + id,exemption_type,geom,applies_to_scenes,applies_to_object_types,reason,effective_from,effective_to, + approval_status,approved_by,status,created_by,created_at,updated_at +) values ( + 'spatial-exemption-demo-maintenance','MAINTENANCE_WINDOW', + ST_GeomFromText('POLYGON((116.121 39.111,116.134 39.111,116.134 39.118,116.121 39.118,116.121 39.111))',4326), + '["桥梁裂缝","异物侵限"]'::jsonb,'["BRIDGE","RAILWAY"]'::jsonb,'DEMO 施工维护窗口', + now() - interval '1 day',now() + interval '30 days','APPROVED','user-approver','ACTIVE','system',now(),now() +) on conflict (id) do nothing; + +insert into geofences( + id,code,name,fence_type,geom,altitude_min_m,altitude_max_m,effective_from,effective_to,severity, + dataset_version_id,status,created_by,created_at,updated_at +) values ( + 'geofence-demo-construction','DEMO_CONSTRUCTION_ZONE','DEMO 施工警示区','WARNING', + ST_GeomFromText('POLYGON((116.145 39.101,116.152 39.101,116.152 39.108,116.145 39.108,116.145 39.101))',4326), + 0,120,now() - interval '1 day',now() + interval '90 days','WARNING','gis-dataset-demo-line-v1','ACTIVE','system',now(),now() +) on conflict (code) do nothing; + +insert into capability_completion_status( + capability_code, capability_name, stage, status, implementation_ref, blocker_type, blocker_reason, updated_at +) values +('PF-01', '巡检对象台账与空间导入', 'M1', 'PARTIAL', 'inspection_object_import_jobs, inspection_object_versions', 'PRODUCTION_MASTER_DATA', '软件导入闭环已具备,生产验收需真实字段字典、责任单位和坐标说明', now()), +('PF-02', '日常、专项、应急计划调度', 'M1', 'PARTIAL', 'business_calendars, plan_executions, PlanSchedulerJob', 'BUSINESS_CALENDAR', '调度与补跑闭环已具备,生产验收需正式汛期、天窗和授权规则', now()), +('PF-05', '设备、航线和作业资源冲突检查', 'M1-M2', 'PARTIAL', 'operation_resources, conflict_check_runs, resource_reservations', 'RESOURCE_MASTER_DATA', '资源日历和冲突引擎已具备,生产验收需真实设备、机巢、载荷和飞手资质', now()), +('PF-08', 'GIS 图层与空间数据集管理', 'M1', 'PARTIAL', 'gis_dataset_versions, gis_layers, gis_spatial_features', 'AUTHORITATIVE_GIS_DATA', '图层版本和查询闭环已具备,生产验收需权威线路设施图层', now()), +('PF-09', '五类对象候选航线自动生成', 'M1-M3', 'PARTIAL', 'route_generation_profiles, route_generation_runs', 'FIELD_ROUTE_PARAMETERS', '五类生成器可产出候选航线,生产验收需现场安全参数和复核', now()), +('PF-10', '航线地图编辑与动作 Schema', 'M1', 'PARTIAL', 'route_editor_revisions, route_action_schemas', 'DEVICE_ACTION_MATRIX', '航点编辑、审计和通用动作 Schema 已具备,生产验收需厂商设备动作矩阵', now()), +('PF-11', '航程、电量、时长和容量估算', 'M1-M2', 'PARTIAL', 'route_estimation_profiles, route_estimation_runs', 'DEVICE_CALIBRATION', '估算闭环已具备,生产验收需真机能耗和载荷容量校准', now()), +('PF-15', '基线、豁免区和地理围栏', 'M2', 'PARTIAL', 'spatial_rule_sets, spatial_exemptions, geofences, spatial_rule_hits', 'SPATIAL_RULE_DATA', '规则版本、豁免和围栏管理已具备,生产验收需正式基线和审批规则', now()) +on conflict (capability_code) do update +set status=excluded.status, + implementation_ref=excluded.implementation_ref, + blocker_type=excluded.blocker_type, + blocker_reason=excluded.blocker_reason, + updated_at=excluded.updated_at;