feat(survey): 勘察业务重构为设备树明细模式并新增勘察完成流转

- 设备树明细模式:customer_equipment 新增 parent_id 自引用与树形查询(getTree/tree 接口),勘察明细按设备节点挂接(survey_detail 新增 equip_id,saveDetails 全量替换),新增迁移 20261024_survey_equipment_tree.sql 并同步 db_init;客户拜访页发起勘察改为两步向导路由(CustomerVisitSurveyCreate),form.vue 重构为两步向导
- 勘察明细页(detail.vue):三栏布局(设备树/拓扑图/属性与勘察登记);设备拓扑图修复显示不完整(tree roam 拖拽缩放+全展开+弹窗增高);新增/编辑设备表单「所在位置」改为「所属建筑」下拉并增加「所在楼层」;选择建筑后按建筑地上/地下层数动态生成楼层下拉(负N层在前,切换建筑清空楼层)
- 设备管理台账页:设备新增弹窗同步「所属建筑/所在楼层」动态下拉
- 台账页:进度跟踪主按钮改为跳转勘察明细页(DispatchSurveyDetail)而非编辑页;「审核测绘成果」按钮与复核弹窗标题改名「技术复核」
- 勘察完成流转:新增 PUT /api/survey-orders/:id/complete(现场勘察中→待技术复核,可见性校验+审计日志),勘察明细页新增「勘察完成」按钮(先保存明细再流转状态,成功后跳回台账页)
- 验证:server tsc 与 client vue-tsc 编译通过;浏览器端到端验证通过(技术复核按钮/弹窗标题、勘察完成按钮仅勘察中显示、进度跟踪跳转数字id)
This commit is contained in:
wk authored and trae-solo-agent committed 2026-10-25 02:33:54 +08:00
1 parent 2d55947762
commit dff366a823
19 files changed
+2095 -442

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import request from '@/utils/request';
// 客户设备类型:配电间/配电井/配电箱/配电柜
export const EQUIP_TYPE_OPTIONS = [
{ label: '配电间', value: '配电间' },
{ label: '配电井', value: '配电井' },
{ label: '配电箱', value: '配电箱' },
{ label: '配电柜', value: '配电柜' },
];
export interface CustomerEquipmentNode {
id: number;
equipCode: string;
customerId: number;
equipName: string;
equipType?: string;
parentId?: number | null;
buildingId?: number | null;
floorNo?: string | null;
location?: string;
description?: string;
remark?: string;
photoUrl?: string;
createdAt?: string;
// 树形:子节点(tree 接口返回)
children?: CustomerEquipmentNode[];
}
export interface CustomerEquipmentCreateParams {
customerId: number;
equipName: string;
equipType: string;
parentId?: number | null;
buildingId?: number | null;
floorNo?: string | null;
location?: string;
description?: string;
remark?: string;
photoUrl?: string;
}
export type CustomerEquipmentUpdateParams = Partial<CustomerEquipmentCreateParams>;
export interface CustomerEquipmentQuery {
page?: number;
pageSize?: number;
keyword?: string;
customerId?: number;
equipType?: string;
}
export interface CustomerEquipmentListResult {
list: CustomerEquipmentNode[];
total: number;
page: number;
pageSize: number;
}
export const customerEquipmentApi = {
// 客户设备树(按 parentId 嵌套,根节点 parentId 为空)
getTree: (customerId: number) =>
request.get<CustomerEquipmentNode[]>('/customer-equipments/tree', { params: { customerId } }),
getList: (params: CustomerEquipmentQuery) =>
request.get<CustomerEquipmentListResult>('/customer-equipments', { params }),
getDetail: (id: number) =>
request.get<CustomerEquipmentNode>(`/customer-equipments/${id}`),
create: (data: CustomerEquipmentCreateParams) =>
request.post<CustomerEquipmentNode>('/customer-equipments', data),
update: (id: number, data: CustomerEquipmentUpdateParams) =>
request.put<CustomerEquipmentNode>(`/customer-equipments/${id}`, data),
delete: (id: number) => request.delete(`/customer-equipments/${id}`)
};
+18 -18
View File
@@ -4,16 +4,16 @@ import request from '@/utils/request';
export interface CustomerEquipment {
id: number;
equipmentNo: string;
equipmentName: string;
equipCode: string;
equipName: string;
equipType?: string;
customerId?: number;
customerName?: string;
building?: string;
floor?: string;
parentId?: number | null;
buildingId?: number | null;
buildingName?: string;
floorNo?: string;
location?: string;
photo?: string;
status?: number;
photoUrl?: string;
remark?: string;
createdAt: string;
updatedAt: string;
@@ -24,7 +24,7 @@ export interface CustomerEquipmentQuery {
pageSize?: number;
keyword?: string;
customerId?: number;
building?: string;
buildingId?: number;
equipType?: string;
}
@@ -36,26 +36,26 @@ export interface CustomerEquipmentListResult {
}
export interface CustomerEquipmentCreateParams {
equipmentName: string;
equipName: string;
equipType?: string;
customerId?: number;
building?: string;
floor?: string;
parentId?: number | null;
buildingId?: number | null;
floorNo?: string;
location?: string;
photo?: string;
status?: number;
photoUrl?: string;
remark?: string;
}
export interface CustomerEquipmentUpdateParams {
equipmentName?: string;
equipName?: string;
equipType?: string;
customerId?: number;
building?: string;
floor?: string;
parentId?: number | null;
buildingId?: number | null;
floorNo?: string;
location?: string;
photo?: string;
status?: number;
photoUrl?: string;
remark?: string;
}
+21 -2
View File
@@ -57,10 +57,12 @@ export interface SurveyOrderWorker {
completedCount?: number;
}
// 勘察明细:一条 = 建筑 + 楼层 + 房间(可空) + 现场照片
// 勘察明细:一条 = 设备节点(优先)+ 现场照片;兼容历史 建筑/楼层/房间
export interface SurveyOrderDetail {
id?: number;
surveyOrderId?: number;
// 客户设备 ID(→ customer_equipment,勘察明细挂接到设备节点)
equipId?: number | null;
buildingId?: number | null;
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
buildingName?: string | null;
@@ -75,6 +77,18 @@ export interface SurveyOrderDetail {
remark?: string | null;
}
// 客户设备树节点(工单详情附带,勘察明细按设备节点拓扑展示)
// 与 @/api/customerEquipment 的 CustomerEquipmentNode 对齐(equip_code/customer_id/equip_name 必填,equip_type 可空)
export interface SurveyEquipNode {
id: number;
equipCode: string;
customerId: number;
equipName: string;
equipType?: string;
parentId?: number | null;
children?: SurveyEquipNode[];
}
export interface SurveyOrderInfo {
id: number;
surveyNo: string;
@@ -124,8 +138,10 @@ export interface SurveyOrderInfo {
updatedAt?: string;
created_at?: string;
updated_at?: string;
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
// 详情附带:勘察明细(设备节点/建筑楼层房间 + 现场照片)
details?: SurveyOrderDetail[];
// 详情附带:客户设备树(勘察明细按设备节点拓扑展示)
equipmentTree?: SurveyEquipNode[];
}
export interface SurveyOrderQuery {
@@ -260,6 +276,9 @@ export const surveyOrderApi = {
review: (id: number, data: SurveyOrderReviewParams) =>
request.put<SurveyOrderInfo>(`/survey-orders/${id}/review`, data),
// 勘察完成:现场勘察中 → 待技术复核
complete: (id: number) => request.put<SurveyOrderInfo>(`/survey-orders/${id}/complete`),
// 催办
urge: (id: number) => request.post<SurveyOrderInfo>(`/survey-orders/${id}/urge`),
+24 -12
View File
@@ -79,6 +79,18 @@ const routes: RouteRecordRaw[] = [
parentPath: "/customer/visit"
}
},
{
path: "customer/visit/survey/create",
name: "CustomerVisitSurveyCreate",
component: () => import("@/views/dispatch/survey/form.vue"),
meta: {
requiresAuth: true,
title: "发起勘察派单",
permission: "customer:visit",
parentTitle: "客户拜访",
parentPath: "/customer/visit"
}
},
{
path: "customer/topology",
name: "CustomerTopology",
@@ -133,18 +145,6 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/dispatch/survey/index.vue"),
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
},
{
path: "dispatch/survey/create",
name: "DispatchSurveyCreate",
component: () => import("@/views/dispatch/survey/form.vue"),
meta: {
requiresAuth: true,
title: "新建勘察派单",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/dispatch/survey"
}
},
{
path: "dispatch/survey/edit/:id",
name: "DispatchSurveyEdit",
@@ -157,6 +157,18 @@ const routes: RouteRecordRaw[] = [
parentPath: "/dispatch/survey"
}
},
{
path: "dispatch/survey/:id/details",
name: "DispatchSurveyDetail",
component: () => import("@/views/dispatch/survey/detail.vue"),
meta: {
requiresAuth: true,
title: "勘察明细",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/dispatch/survey"
}
},
{
path: "dispatch/install",
name: "DispatchInstall",
+2 -2
View File
@@ -795,10 +795,10 @@ function handleFollow(record: CustomerVisitInfo) {
router.push({ name: 'CustomerVisitEdit', params: { id: record.id }, query: { action: 'follow' } });
}
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
// 一键发起勘察派单:跳转两步向导并预填客户(不创建、不回写状态)
function handleSurvey(record: CustomerVisitInfo) {
if (!record.customerId) return;
router.push({ path: '/dispatch/survey', query: { customerId: String(record.customerId) } });
router.push({ name: 'CustomerVisitSurveyCreate', query: { customerId: String(record.customerId) } });
}
// 查看关联工程
+225 -53
View File
@@ -38,11 +38,11 @@
</div>
<div class="filter-item">
<label class="filter-label">所属建筑</label>
<a-input
v-model:value="searchForm.building"
placeholder="建筑名称"
<a-select
v-model:value="searchForm.buildingId"
placeholder="全部建筑"
allow-clear
@pressEnter="handleSearch"
:options="searchBuildingOptions"
/>
</div>
<div class="filter-item">
@@ -103,12 +103,12 @@
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'equipmentNo'">
<span class="mono">{{ record.equipmentNo || '-' }}</span>
<template v-if="column.key === 'equipCode'">
<span class="mono">{{ record.equipCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'equipmentName'">
<span class="equip-name">{{ record.equipmentName }}</span>
<template v-else-if="column.key === 'equipName'">
<span class="equip-name">{{ record.equipName }}</span>
</template>
<template v-else-if="column.key === 'equipType'">
@@ -120,10 +120,10 @@
{{ customerNameMap[record.customerId] || '-' }}
</template>
<template v-else-if="column.key === 'photo'">
<template v-else-if="column.key === 'photoUrl'">
<a-image
v-if="record.photo"
:src="record.photo"
v-if="record.photoUrl"
:src="record.photoUrl"
:width="44"
:height="44"
style="border-radius: 6px"
@@ -175,7 +175,7 @@
<div v-if="isEdit && editId" class="no-bar">
<div class="no-item">
<span class="no-label">设备编码</span>
<span class="no-value mono">{{ editNo || '-' }}</span>
<span class="no-value mono">{{ editCode || '-' }}</span>
</div>
</div>
@@ -195,8 +195,8 @@
<span class="section-sub">Equipment Identity</span>
</div>
</div>
<a-form-item label="设备名称" name="equipmentName">
<a-input v-model:value="formData.equipmentName" placeholder="请输入设备名称" maxlength="128" />
<a-form-item label="设备名称" name="equipName">
<a-input v-model:value="formData.equipName" placeholder="请输入设备名称" maxlength="128" />
</a-form-item>
<a-form-item label="设备类型" name="equipType">
<a-select
@@ -208,6 +208,15 @@
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="父级设备" name="parentId">
<a-select
v-model:value="formData.parentId"
placeholder="不选则为根设备"
allow-clear
:options="visibleParentOptions"
:loading="parentLoading"
/>
</a-form-item>
<a-form-item label="所属客户" name="customerId">
<a-select
v-model:value="formData.customerId"
@@ -228,11 +237,18 @@
<span class="section-sub">Location</span>
</div>
</div>
<a-form-item label="所属建筑" name="building">
<a-input v-model:value="formData.building" placeholder="请输入所属建筑" maxlength="128" />
<a-form-item label="所属建筑" name="buildingId">
<a-select
v-model:value="formData.buildingId"
placeholder="请选择所属建筑"
allow-clear
:options="buildingOptions"
:loading="buildingLoading"
@change="handleBuildingChange"
/>
</a-form-item>
<a-form-item label="所在楼层" name="floor">
<a-input v-model:value="formData.floor" placeholder="请输入所在楼层" maxlength="64" />
<a-form-item label="所在楼层" name="floorNo">
<a-select v-model:value="formData.floorNo" placeholder="请选择所在楼层" allow-clear :options="floorOptions" :not-found-content="floorOptions.length ? null : '请先选择所属建筑'" />
</a-form-item>
<a-form-item label="具体位置" name="location">
<a-input v-model:value="formData.location" placeholder="请输入具体位置" maxlength="128" />
@@ -262,7 +278,7 @@
:disabled="photoUploading"
>
<a-button :loading="photoUploading">
{{ formData.photo ? '替换' : '上传' }}
{{ formData.photoUrl ? '替换' : '上传' }}
</a-button>
</a-upload>
</div>
@@ -291,29 +307,53 @@ import {
type CustomerEquipment,
type CustomerEquipmentQuery,
} from '@/api/device';
import {
customerEquipmentApi as equipmentTreeApi,
type CustomerEquipmentNode,
} from '@/api/customerEquipment';
import {
customerBuildingApi,
type CustomerBuildingInfo,
} from '@/api/customerBuilding';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { uploadApi } from '@/api/upload';
import { useTableScroll } from '@/composables/useTableScroll';
import { watch } from 'vue';
const { wrapperRef, tableHeight } = useTableScroll();
// 设备类型字典(配电间/配电井/配电箱)
const equipTypeOptions = ['配电间', '配电井', '配电箱'];
// 设备类型字典(配电间/配电井/配电箱/配电柜)
const equipTypeOptions = ['配电间', '配电井', '配电箱', '配电柜'];
const loading = ref(false);
const dataSource = ref<CustomerEquipment[]>([]);
const total = ref(0);
const editNo = ref<string>('');
const editCode = ref<string>('');
const searchForm = reactive<CustomerEquipmentQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
building: '',
buildingId: undefined as any,
equipType: undefined as any,
});
// 搜索/表单:建筑选项
const searchBuildingOptions = ref<{ label: string; value: number }[]>([]);
const buildingOptions = ref<{ label: string; value: number }[]>([]);
const buildingList = ref<CustomerBuildingInfo[]>([]);
const buildingLoading = ref(false);
// 表单:父设备选项(按所选客户设备树扁平化)
interface FlatEquipOption {
label: string;
value: number;
parentId: number | null;
depth: number;
}
const parentOptions = ref<FlatEquipOption[]>([]);
const parentLoading = ref(false);
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
@@ -328,30 +368,31 @@ const photoUploading = ref(false);
const photoPreview = ref<string>('');
const formData = reactive<Partial<CustomerEquipment>>({
equipmentName: '',
equipName: '',
equipType: undefined,
customerId: undefined,
building: '',
floor: '',
parentId: undefined as any,
customerId: undefined as any,
buildingId: undefined as any,
floorNo: '',
location: '',
photo: '',
photoUrl: '',
remark: '',
});
const formRules = {
equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
equipName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
equipType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
};
const columns = [
{ title: '设备编码', dataIndex: 'equipmentNo', key: 'equipmentNo', ellipsis: true, width: 150 },
{ title: '设备名称', dataIndex: 'equipmentName', key: 'equipmentName', ellipsis: true },
{ title: '设备编码', dataIndex: 'equipCode', key: 'equipCode', ellipsis: true, width: 150 },
{ title: '设备名称', dataIndex: 'equipName', key: 'equipName', ellipsis: true },
{ title: '设备类型', dataIndex: 'equipType', key: 'equipType', ellipsis: true, width: 100 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '所属建筑', dataIndex: 'building', key: 'building', ellipsis: true, width: 140 },
{ title: '所在楼层', dataIndex: 'floor', key: 'floor', ellipsis: true, width: 100 },
{ title: '所属建筑', dataIndex: 'buildingName', key: 'buildingName', ellipsis: true, width: 140 },
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 },
{ title: '具体位置', dataIndex: 'location', key: 'location', ellipsis: true },
{ title: '照片', dataIndex: 'photo', key: 'photo', align: 'center' as const, width: 70 },
{ title: '照片', dataIndex: 'photoUrl', key: 'photoUrl', align: 'center' as const, width: 70 },
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
];
@@ -367,8 +408,135 @@ const pagination = computed(() => ({
onMounted(() => {
fetchList();
fetchCustomerOptions();
loadSearchBuildings();
});
// ==================== 建筑/父设备选项 ====================
async function loadSearchBuildings(customerId?: number) {
try {
const res: any = await customerBuildingApi.getList({ customerId, page: 1, pageSize: 999 });
if (res.code === 200) {
searchBuildingOptions.value = (res.data.list as CustomerBuildingInfo[]).map((b) => ({
label: b.buildingName,
value: b.id,
}));
}
} catch {
/* 忽略加载失败 */
}
}
async function loadBuildingOptions(customerId?: number) {
buildingLoading.value = true;
try {
const res: any = await customerBuildingApi.getList({ customerId, page: 1, pageSize: 999 });
if (res.code === 200) {
buildingList.value = (res.data.list as CustomerBuildingInfo[]) || [];
buildingOptions.value = buildingList.value.map((b) => ({
label: b.buildingName,
value: b.id,
}));
} else {
buildingList.value = [];
buildingOptions.value = [];
}
} catch {
buildingList.value = [];
buildingOptions.value = [];
} finally {
buildingLoading.value = false;
}
}
// 按建筑地上/地下层数生成楼层选项(与空间拓扑页规则一致)
function buildFloorOptions(building?: CustomerBuildingInfo): { label: string; value: string }[] {
if (!building) return [];
const down = building.floorDownCount ?? 0;
const up = building.floorCount ?? 0;
const options: { label: string; value: string }[] = [];
for (let i = down; i >= 1; i--) options.push({ label: `负${i}层`, value: String(-i) });
for (let i = 1; i <= up; i++) options.push({ label: `${i}层`, value: String(i) });
return options;
}
const floorOptions = computed<{ label: string; value: string }[]>(() =>
buildFloorOptions(buildingList.value.find((b) => b.id === formData.buildingId))
);
// 切换所属建筑时清空已选楼层
function handleBuildingChange() {
formData.floorNo = '';
}
// 设备树扁平化:label 带层级缩进,供父设备下拉使用
function flattenEquipTree(nodes: CustomerEquipmentNode[], parentId: number | null = null, depth = 0): FlatEquipOption[] {
const out: FlatEquipOption[] = [];
for (const n of nodes || []) {
out.push({ label: `${' '.repeat(depth)}${n.equipName}`, value: n.id, parentId, depth });
out.push(...flattenEquipTree(n.children || [], n.id, depth + 1));
}
return out;
}
async function loadParentOptions(customerId?: number) {
if (!customerId) {
parentOptions.value = [];
return;
}
parentLoading.value = true;
try {
const res: any = await equipmentTreeApi.getTree(customerId);
if (res.code === 200) {
parentOptions.value = flattenEquipTree(res.data as CustomerEquipmentNode[]);
} else {
parentOptions.value = [];
}
} catch {
parentOptions.value = [];
} finally {
parentLoading.value = false;
}
}
// 编辑态:父设备下拉排除自身及其子孙(防循环)
const visibleParentOptions = computed(() => {
if (!isEdit.value || !editId.value) return parentOptions.value;
const banned = new Set<number>([editId.value]);
const childrenOf = new Map<number, number[]>();
for (const o of parentOptions.value) {
if (o.parentId == null) continue;
const arr = childrenOf.get(o.parentId) || [];
arr.push(o.value);
childrenOf.set(o.parentId, arr);
}
const stack = [...(childrenOf.get(editId.value) || [])];
while (stack.length) {
const n = stack.pop()!;
if (banned.has(n)) continue;
banned.add(n);
stack.push(...(childrenOf.get(n) || []));
}
return parentOptions.value.filter((o) => !banned.has(o.value));
});
// 表单所属客户变化 → 联动刷新建筑与父设备选项
watch(
() => formData.customerId,
(val) => {
loadBuildingOptions(val as any);
loadParentOptions(val as any);
}
);
// 搜索客户变化 → 联动刷新建筑选项并清空已选建筑
watch(
() => searchForm.customerId,
(val) => {
searchForm.buildingId = undefined as any;
loadSearchBuildings(val as any);
}
);
async function fetchList() {
loading.value = true;
try {
@@ -410,7 +578,7 @@ function handleSearch() {
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.building = '';
searchForm.buildingId = undefined as any;
searchForm.equipType = undefined as any;
searchForm.page = 1;
fetchList();
@@ -447,7 +615,7 @@ async function handlePhotoUpload(options: any) {
try {
const res: any = await uploadApi.uploadImage(f);
if (res.code === 200 && res.data) {
formData.photo = res.data.key;
formData.photoUrl = res.data.key;
photoPreview.value = res.data.url;
onSuccess?.(res.data);
message.success('上传成功');
@@ -466,7 +634,7 @@ async function handlePhotoUpload(options: any) {
function handleAdd() {
isEdit.value = false;
editId.value = null;
editNo.value = '';
editCode.value = '';
modalTitle.value = '新增设备';
resetForm();
modalVisible.value = true;
@@ -475,31 +643,34 @@ function handleAdd() {
function handleEdit(record: CustomerEquipment) {
isEdit.value = true;
editId.value = record.id;
editNo.value = record.equipmentNo;
editCode.value = record.equipCode;
modalTitle.value = '编辑设备';
resetForm();
formData.equipmentName = record.equipmentName;
// 先设客户,触发建筑/父设备选项联动加载;再设其余字段
formData.customerId = record.customerId as any;
formData.equipName = record.equipName;
formData.equipType = record.equipType || undefined;
formData.customerId = record.customerId;
formData.building = record.building || '';
formData.floor = record.floor || '';
formData.parentId = record.parentId ?? undefined as any;
formData.buildingId = record.buildingId ?? undefined as any;
formData.floorNo = record.floorNo || '';
formData.location = record.location || '';
formData.photo = record.photo || '';
formData.photoUrl = record.photoUrl || '';
formData.remark = record.remark || '';
photoPreview.value = record.photo || '';
photoPreview.value = record.photoUrl || '';
modalVisible.value = true;
}
function resetForm() {
formData.equipmentName = '';
formData.equipName = '';
formData.equipType = undefined;
formData.customerId = undefined;
formData.building = '';
formData.floor = '';
formData.parentId = undefined as any;
formData.customerId = undefined as any;
formData.buildingId = undefined as any;
formData.floorNo = '';
formData.location = '';
formData.photo = '';
formData.photoUrl = '';
formData.remark = '';
photoPreview.value = '';
@@ -518,13 +689,14 @@ async function handleSubmit() {
modalLoading.value = true;
try {
const submitData: any = {
equipmentName: formData.equipmentName,
equipName: formData.equipName,
equipType: formData.equipType,
customerId: formData.customerId,
building: formData.building,
floor: formData.floor,
parentId: formData.parentId ?? null,
buildingId: formData.buildingId ?? null,
floorNo: formData.floorNo,
location: formData.location,
photo: formData.photo,
photoUrl: formData.photoUrl,
remark: formData.remark,
};
File diff suppressed because it is too large. Load diff
+80 -314
View File
@@ -6,7 +6,7 @@
<div class="breadcrumb">
<span class="link" @click="goLedger">项目管理</span>
<span class="sep">/</span>
<span class="link" @click="goLedger">现场勘察派单</span>
<span class="link" @click="goLedger">{{ isEdit ? '现场勘察派单' : '客户拜访' }}</span>
<span class="sep">/</span>
<span v-if="surveyNo" class="mono-badge">{{ surveyNo }}</span>
<span v-else class="cur">{{ pageTitle }}</span>
@@ -35,7 +35,7 @@
</p>
</div>
<span v-if="isReadonly" class="step-badge ro">只读查看</span>
<span v-else class="step-badge">STEP 0{{ currentStep }} / 03</span>
<span v-else class="step-badge">STEP 0{{ currentStep }} / 02</span>
</div>
<div class="step-bar">
@@ -223,151 +223,55 @@
</div>
</section>
<!-- ==================== 第 2 步 ==================== -->
<section id="wizard-step-2" class="section">
<!-- ==================== 只读:勘察明细(按设备节点) ==================== -->
<section v-if="isReadonly" id="wizard-step-2" class="section">
<div class="section-head">
<div class="section-title">
<span class="bar bar-secondary"></span>
<FileDoneOutlined class="sec-icon clr-secondary" />
<h2>第 2 步:勘察明细</h2>
</div>
<div v-if="!isReadonly" class="head-actions">
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
<a-button size="small" type="primary" ghost @click="addDetailRow">
<template #icon><PlusOutlined /></template>
添加明细
</a-button>
<h2>勘察明细</h2>
</div>
<span class="head-hint">按客户设备节点登记 · 仅供查看</span>
</div>
<div class="detail-body">
<!-- 编辑态:明细编辑行 -->
<template v-if="!isReadonly">
<div v-if="detailRows.length === 0" class="detail-empty">
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
</div>
<div v-else class="detail-rows">
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
<div class="detail-row-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<a-button type="text" size="small" danger @click="removeDetailRow(idx)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</div>
<div class="detail-grid">
<div class="dfield">
<label class="dfield-label">建筑 <b class="req">*</b></label>
<a-select
v-model:value="row.buildingId"
placeholder="选择建筑"
:loading="topologyLoading"
:options="buildingSelectOptions"
:not-found-content="topologyLoading ? null : '该客户暂无建筑'"
style="width: 100%"
@change="handleRowBuildingChange(row)"
/>
</div>
<div class="dfield">
<label class="dfield-label">楼层 <b class="req">*</b></label>
<a-select
v-model:value="row.floorNo"
placeholder="选择楼层"
:disabled="!row.buildingId"
:options="floorOptionsFor(row.buildingId)"
:not-found-content="row.buildingId ? '该建筑未配置楼层数' : '请先选择建筑'"
style="width: 100%"
@change="handleRowFloorChange(row)"
/>
</div>
<div class="dfield">
<label class="dfield-label">房间 <span class="opt">选填</span></label>
<a-select
v-model:value="row.roomId"
placeholder="可不选"
allow-clear
:disabled="!row.floorNo"
:options="roomOptionsFor(row)"
:not-found-content="row.floorNo ? '该楼层暂无房间' : '请先选择楼层'"
style="width: 100%"
@change="handleRowRoomChange(row)"
/>
</div>
<div class="dfield span-all">
<label class="dfield-label">现场照片</label>
<div class="detail-photos">
<a-image-preview-group>
<div v-for="(p, pi) in row.photos" :key="pi" class="photo-thumb">
<a-image :src="p.url" :width="76" :height="57" />
<span class="photo-del" @click.stop="removePhoto(row, pi)">×</span>
</div>
</a-image-preview-group>
<a-upload
:show-upload-list="false"
:custom-request="(o: any) => handlePhotoUpload(o, row)"
:before-upload="beforePhotoUpload"
accept="image/*"
>
<button type="button" class="photo-add" :disabled="row.photos.length >= MAX_DETAIL_PHOTOS">
<PlusOutlined />
<span>上传照片</span>
</button>
</a-upload>
</div>
</div>
<div class="dfield span-all">
<label class="dfield-label">备注</label>
<input
v-model="row.remark"
class="ipt"
type="text"
maxlength="500"
placeholder="该条明细的现场说明,如设备位置、隐患点等"
/>
</div>
</div>
<div v-if="readonlyDetails.length === 0" class="detail-empty">暂无勘察明细</div>
<div v-else class="detail-rows">
<div v-for="(row, idx) in readonlyDetails" :key="idx" class="detail-ro-row">
<div class="detail-ro-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<span class="detail-ro-scope">
{{ row.equipName || row.buildingName || '未标注位置' }}
<template v-if="row.equipType"> · {{ row.equipType }}</template>
<template v-if="row.floorLabel && !row.equipName"> · {{ row.floorLabel }}</template>
<template v-if="row.roomName && !row.equipName"> · {{ row.roomName }}</template>
</span>
</div>
</div>
</template>
<!-- 只读态:明细展示 -->
<template v-else>
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</div>
<div v-else class="detail-rows">
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-ro-row">
<div class="detail-ro-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<span class="detail-ro-scope">
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
<template v-if="row.roomName"> · {{ row.roomName }}</template>
</span>
</div>
<div v-if="row.photos.length" class="detail-ro-photos">
<a-image-preview-group>
<a-image
v-for="(p, pi) in row.photos"
:key="pi"
:src="p.url"
:width="110"
:height="82"
style="object-fit: cover; border-radius: 8px"
/>
</a-image-preview-group>
</div>
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
<div v-if="row.photos.length" class="detail-ro-photos">
<a-image-preview-group>
<a-image
v-for="(p, pi) in row.photos"
:key="pi"
:src="p"
:width="110"
:height="82"
style="object-fit: cover; border-radius: 8px"
/>
</a-image-preview-group>
</div>
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
</div>
</template>
</div>
</div>
</section>
<!-- ==================== 第 3 步 ==================== -->
<section id="wizard-step-3" class="section">
<!-- ==================== 第 2 步 ==================== -->
<section id="wizard-step-2" class="section">
<div class="section-head">
<div class="section-title">
<span class="bar bar-tertiary"></span>
<ToolOutlined class="sec-icon clr-tertiary" />
<h2>第 3 步:指派电工与服务报酬</h2>
<h2>第 2 步:指派电工与服务报酬</h2>
</div>
<span class="head-hint clr-tertiary">根据持证资质与历史履约智能优选</span>
</div>
@@ -577,8 +481,6 @@ import {
CheckOutlined,
CheckCircleFilled,
StarFilled,
PlusOutlined,
DeleteOutlined,
ApartmentOutlined,
FileDoneOutlined,
ToolOutlined,
@@ -593,21 +495,17 @@ import {
type SurveyQuote,
type SurveyOrderCreateParams,
type SurveyOrderInfo,
type SurveyOrderDetail,
} from '@/api/surveyOrder';
import { workerApi, type WorkerRecommend } from '@/api/worker';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { customerCircuitApi, type CustomerTopologyResult, type TopologyBuilding } from '@/api/customerCircuit';
import { uploadApi } from '@/api/upload';
const router = useRouter();
const route = useRoute();
// ==================== 步骤元数据 ====================
// ==================== 步骤元数据(两步向导:基本信息 → 指派电工与报酬) ====================
const STEPS = [
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
{ value: 2, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
];
const SLA_OPTIONS = [
@@ -696,166 +594,48 @@ const customerSelectOptions = computed(() =>
);
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
// ==================== 客户空间拓扑 ====================
const topology = ref<CustomerTopologyResult | null>(null);
const topologyLoading = ref(false);
const buildingSelectOptions = computed(() =>
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
);
function buildingById(id?: number): TopologyBuilding | undefined {
if (!id) return undefined;
return (topology.value?.buildings || []).find((b) => b.id === id);
}
// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount;value 为字符串(地上 '1' / 地下 '-1')
function buildFloorOptions(b?: TopologyBuilding): { label: string; value: string }[] {
if (!b) return [];
const up = b.floorCount ?? 0;
const down = b.floorDownCount ?? 0;
const opts: { label: string; value: string }[] = [];
for (let i = down; i >= 1; i--) opts.push({ label: `负${i}层`, value: String(-i) });
for (let i = 1; i <= up; i++) opts.push({ label: `${i}层`, value: String(i) });
return opts;
}
function floorOptionsFor(buildingId?: number) {
return buildFloorOptions(buildingById(buildingId));
}
function floorLabel(floorNo?: string | null) {
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
const n = Number(floorNo);
if (Number.isNaN(n)) return String(floorNo);
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
}
function roomOptionsFor(row: DetailRow) {
const b = buildingById(row.buildingId);
if (!b || !row.floorNo) return [];
return b.floors
.filter((f) => (f.floorNo || '') === row.floorNo)
.flatMap((f) => f.rooms)
.map((r) => ({ label: r.roomName, value: r.id }));
}
async function loadTopology() {
if (!formState.customerId) {
topology.value = null;
return;
}
topologyLoading.value = true;
try {
const res: any = await customerCircuitApi.getTopology(formState.customerId);
if (res.code === 200 && res.data) {
topology.value = res.data;
} else {
topology.value = { buildings: [] };
}
} catch {
topology.value = { buildings: [] };
} finally {
topologyLoading.value = false;
}
}
// ==================== 勘察明细 ====================
interface DetailPhoto {
// 对象存储 key(提交时使用)
key: string;
// 预签名访问 URL(预览使用)
url: string;
}
interface DetailRow {
uid: number;
buildingId?: number;
// ==================== 只读态勘察明细(按设备节点展示) ====================
interface ReadonlyDetailRow {
equipName?: string;
equipType?: string;
buildingName?: string;
floorNo?: string;
roomId?: number;
floorLabel?: string;
roomName?: string;
photos: DetailPhoto[];
photos: string[];
remark: string;
}
const MAX_DETAIL_PHOTOS = 9;
let detailUidSeed = 0;
const nextDetailUid = () => ++detailUidSeed;
const detailRows = ref<DetailRow[]>([]);
function addDetailRow() {
detailRows.value.push({ uid: nextDetailUid(), photos: [], remark: '' });
}
function removeDetailRow(index: number) {
detailRows.value.splice(index, 1);
}
function handleRowBuildingChange(row: DetailRow) {
row.buildingName = buildingById(row.buildingId)?.buildingName;
row.floorNo = undefined;
row.roomId = undefined;
row.roomName = undefined;
}
function handleRowFloorChange(row: DetailRow) {
row.roomId = undefined;
row.roomName = undefined;
}
function handleRowRoomChange(row: DetailRow) {
const room = roomOptionsFor(row).find((r) => r.value === row.roomId);
row.roomName = room?.label;
}
function beforePhotoUpload(file: File) {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('仅支持上传图片文件');
return false;
}
if (file.size > 10 * 1024 * 1024) {
message.error('图片大小不能超过 10MB');
return false;
}
return true;
}
async function handlePhotoUpload(options: any, row: DetailRow) {
const { file, onSuccess, onError } = options;
try {
const res: any = await uploadApi.uploadImage(file as File);
if (res.code === 200 && res.data) {
row.photos.push({ key: res.data.key, url: res.data.url });
onSuccess?.(res.data);
} else {
throw new Error(res.message || '上传失败');
// 由设备树建立 equipId → 设备名/类型 映射,明细行按设备节点展示;历史建筑/楼层/房间数据兼容展示
const readonlyDetails = computed<ReadonlyDetailRow[]>(() => {
const d = detailRecord.value;
if (!d || !Array.isArray(d.details)) return [];
const equipMap = new Map<number, any>();
const walk = (nodes: any[]) => {
for (const n of nodes || []) {
if (n?.id != null) equipMap.set(Number(n.id), n);
walk(n?.children);
}
} catch (e: any) {
message.error(e?.message || '照片上传失败');
onError?.(e);
}
}
function removePhoto(row: DetailRow, index: number) {
row.photos.splice(index, 1);
}
// 仅保留建筑与楼层均完整的明细行(后端同样会丢弃缺项行)
function buildDetails(): SurveyOrderDetail[] {
return detailRows.value
.filter((row) => !!row.buildingId && !!row.floorNo)
.map((row) => ({
buildingId: row.buildingId,
buildingName: row.buildingName || buildingById(row.buildingId)?.buildingName || undefined,
floorNo: row.floorNo,
roomId: row.roomId ?? undefined,
roomName: row.roomName || undefined,
photos: row.photos.filter((p) => !!p.key).map((p) => p.key),
remark: row.remark.trim() || undefined,
}));
}
};
walk(d.equipmentTree);
return d.details.map((dt: any) => {
const eq = dt.equipId != null ? equipMap.get(Number(dt.equipId)) : undefined;
const floor = dt.floorNo === undefined || dt.floorNo === null || dt.floorNo === '' ? undefined : String(dt.floorNo);
let floorLabel = undefined;
if (floor !== undefined) {
const n = Number(floor);
floorLabel = Number.isNaN(n) ? floor : n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
}
return {
equipName: eq?.equipName ?? undefined,
equipType: eq?.equipType ?? undefined,
buildingName: dt.buildingName ?? undefined,
floorLabel,
roomName: dt.roomName ?? undefined,
photos: (dt.photoUrls || []).filter((u: string) => !!u),
remark: dt.remark || '',
};
});
});
// ==================== 报酬与电工 ====================
const quote = ref<SurveyQuote>({
@@ -876,8 +656,8 @@ const step1Done = computed(
!!formState.siteLocation.trim() &&
Number(formState.loopCount) > 0
);
const step3Done = computed(() => !!formState.workerId);
const currentStep = computed(() => (step1Done.value ? (step3Done.value ? 3 : 2) : 1));
const step2Done = computed(() => !!formState.workerId);
const currentStep = computed(() => (step1Done.value ? 2 : 1));
const filledCount = computed(
() => [!!formState.customerId, Number(formState.loopCount) > 0, !!formState.workerId].filter(Boolean).length
);
@@ -899,7 +679,8 @@ function clearError(key: string) {
}
function goLedger() {
router.push({ name: 'DispatchSurvey' });
// 编辑态返回勘察台账;新建态(从客户拜访发起)返回客户拜访
router.push({ name: isEdit.value ? 'DispatchSurvey' : 'CustomerVisit' });
}
function handleCustomerChange() {
@@ -913,9 +694,6 @@ function handleCustomerChange() {
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
}
}
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
detailRows.value = [];
loadTopology();
loadWorkers();
}
@@ -1002,17 +780,6 @@ async function loadDetail() {
d.planEnd ? dayjs(d.planEnd) : null,
];
}
// 回填勘察明细:photos 为对象 key,photoUrls 为预览地址(顺序一致)
detailRows.value = (d.details || []).map((dt) => ({
uid: nextDetailUid(),
buildingId: dt.buildingId ?? undefined,
buildingName: dt.buildingName ?? undefined,
floorNo: dt.floorNo ?? undefined,
roomId: dt.roomId ?? undefined,
roomName: dt.roomName ?? undefined,
photos: (dt.photos || []).map((key, i) => ({ key, url: dt.photoUrls?.[i] || '' })),
remark: dt.remark || '',
}));
} else {
message.error(res.message || '获取勘察派单详情失败');
}
@@ -1066,9 +833,8 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
workerId: formState.workerId,
estimatedFee: quote.value.total,
remark: formState.remark.trim() || undefined,
source: 'ledger',
source: isEdit.value ? 'ledger' : 'visit',
isDraft,
details: buildDetails(),
};
}
@@ -1122,16 +888,16 @@ async function handleSubmit(draft: boolean) {
}
onMounted(async () => {
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
// 客户拜访「一键发起勘察派单」跳转预填:/customer/visit/survey/create?customerId=xx
const preCustomerId = Number(route.query.customerId || 0);
if (preCustomerId && !isEdit.value) {
formState.customerId = preCustomerId;
}
await loadCustomers();
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
// 编辑态先取详情(含 customerId 回填)
await loadDetail();
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
await Promise.all([loadQuote(), loadWorkers()]);
});
</script>
@@ -1264,7 +1030,7 @@ onMounted(async () => {
/* 步骤条 */
.step-bar {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.step-item {
+149 -21
View File
@@ -31,10 +31,6 @@
<template #icon><ThunderboltOutlined /></template>
智能派单
</a-button>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
发起勘察派单
</a-button>
</div>
</div>
</div>
@@ -268,6 +264,7 @@
<template #overlay>
<a-menu @click="({ key }) => handleMore(key as string, record)">
<a-menu-item key="detail">查看详情</a-menu-item>
<a-menu-item key="details">勘察明细</a-menu-item>
<a-menu-item v-if="canEdit(record)" key="edit">编辑</a-menu-item>
<a-menu-item v-if="record.status === SURVEY_STATUS.SURVEYING" key="reassign">改派</a-menu-item>
<a-menu-item v-if="canUrge(record)" key="urge">催办</a-menu-item>
@@ -409,10 +406,10 @@
</div>
</a-modal>
<!-- 审核测绘成果 -->
<!-- 技术复核 -->
<a-modal
v-model:open="reviewVisible"
title="审核测绘成果"
title="技术复核"
:confirm-loading="reviewLoading"
width="560px"
@ok="handleReviewSubmit"
@@ -514,6 +511,15 @@
<div v-else class="detail-text">—</div>
</div>
<!-- 勘察明细拓扑图:客户设备树 + 明细登记标记 -->
<div v-if="detailRecord" class="detail-block">
<div class="detail-label">勘察明细拓扑图</div>
<div v-if="detailRecord?.equipmentTree?.length" class="detail-topo-wrap">
<div ref="detailTopoRef" class="detail-topo-chart"></div>
</div>
<div v-else class="detail-topo-empty">该客户暂无设备,录入设备后将在此按拓扑图展示勘察明细</div>
</div>
<div v-if="detailRecord.details?.length" class="detail-block">
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
<div class="detail-list">
@@ -521,8 +527,11 @@
<div class="detail-row-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<span class="detail-row-scope">
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
<template v-if="row.roomName"> · {{ row.roomName }}</template>
{{ equipNameOf(row.equipId) || row.buildingName || '—' }}
<template v-if="!equipNameOf(row.equipId)">
· {{ floorLabel(row.floorNo) }}
<template v-if="row.roomName"> · {{ row.roomName }}</template>
</template>
</span>
</div>
<div v-if="row.photoUrls?.length" class="photo-list">
@@ -547,13 +556,13 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import * as echarts from 'echarts';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DownloadOutlined,
FilterOutlined,
DownOutlined,
@@ -809,10 +818,7 @@ onMounted(() => {
fetchList();
fetchStats();
fetchWorkerOptions();
const preCustomerId = Number(route.query.customerId || 0);
if (preCustomerId) {
router.replace({ name: 'DispatchSurveyCreate', query: { customerId: String(preCustomerId) } });
}
// 勘察派单仅在客户拜访中发起:台账不再响应 ?customerId 自动跳转
});
// ==================== 筛选与分页 ====================
@@ -849,10 +855,6 @@ function handleExport() {
message.info('导出台账数据功能暂未开放');
}
function handleAdd() {
router.push({ name: 'DispatchSurveyCreate' });
}
// ==================== 行内操作 ====================
const canEdit = (record: SurveyOrderInfo) =>
record.status === SURVEY_STATUS.PENDING_ASSIGN || record.status === SURVEY_STATUS.SURVEYING;
@@ -866,7 +868,7 @@ const canCancel = (record: SurveyOrderInfo) =>
function primaryAction(record: SurveyOrderInfo): { label: string } | null {
if (record.status === SURVEY_STATUS.PENDING_ASSIGN) return { label: '指派电工' };
if (record.status === SURVEY_STATUS.SURVEYING) return { label: '进度跟踪' };
if (record.status === SURVEY_STATUS.PENDING_REVIEW) return { label: '审核测绘成果' };
if (record.status === SURVEY_STATUS.PENDING_REVIEW) return { label: '技术复核' };
if (record.status === SURVEY_STATUS.ARCHIVED) return { label: '生成方案' };
return null;
}
@@ -875,13 +877,14 @@ function handlePrimary(record: SurveyOrderInfo) {
const action = primaryAction(record);
if (!action) return;
if (action.label === '指派电工') return openAssign(record, false);
if (action.label === '进度跟踪') return goDetail(record);
if (action.label === '审核测绘成果') return openReview(record);
if (action.label === '进度跟踪') return router.push({ name: 'DispatchSurveyDetail', params: { id: record.id } });
if (action.label === '技术复核') return openReview(record);
if (action.label === '生成方案') return message.info('生成项目方案功能暂未开放');
}
function handleMore(key: string, record: SurveyOrderInfo) {
if (key === 'detail') return openDetail(record);
if (key === 'details') return router.push({ name: 'DispatchSurveyDetail', params: { id: record.id } });
if (key === 'edit') return goDetail(record);
if (key === 'reassign') return openAssign(record, true);
if (key === 'urge') return handleUrge(record);
@@ -1123,6 +1126,90 @@ async function handleCancelSubmit() {
const detailVisible = ref(false);
const detailRecord = ref<SurveyOrderInfo | null>(null);
// 详情拓扑图(勘察明细按设备树展示)
const detailTopoRef = ref<HTMLDivElement | null>(null);
let detailTopoChart: echarts.ECharts | null = null;
function equipNameOf(equipId?: number | null): string {
if (equipId == null) return '';
const find = (nodes: any[]): string => {
for (const n of nodes || []) {
if (Number(n.id) === Number(equipId)) return n.equipName || '';
const hit = find(n.children || []);
if (hit) return hit;
}
return '';
};
return find(detailRecord.value?.equipmentTree || []);
}
function renderDetailTopo() {
const record = detailRecord.value;
if (!record || !detailTopoRef.value) return;
if (!detailTopoChart) detailTopoChart = echarts.init(detailTopoRef.value);
const detailByEquip = new Map<number, { photos: number; remark: boolean }>();
for (const dt of record.details || []) {
if (dt.equipId == null) continue;
detailByEquip.set(Number(dt.equipId), {
photos: dt.photos?.length || 0,
remark: !!dt.remark,
});
}
const build = (nodes: any[]): any[] =>
(nodes || []).map((n) => {
const d = detailByEquip.get(Number(n.id));
const hasDetail = !!d;
return {
name: n.equipName || '未命名设备',
id: n.id,
equipType: n.equipType,
hasDetail,
photos: d?.photos || 0,
remark: !!d?.remark,
itemStyle: { color: hasDetail ? '#1677ff' : '#bcc6d4' },
children: build(n.children),
};
});
detailTopoChart.setOption(
{
tooltip: {
trigger: 'item',
triggerOn: 'mousemove',
formatter: (p: any) => {
const d = p.data;
if (!d?.equipType) return '';
const reg = d.hasDetail
? `已登记勘察(照片 ${d.photos} 张${d.remark ? ' · 含备注' : ''})`
: '未登记勘察';
return `${d.equipType}:${d.name}<br/>${reg}`;
},
},
series: [
{
type: 'tree',
data: [{ name: record.customerName || '客户', children: build(record.equipmentTree) }],
top: '6%',
left: '8%',
bottom: '8%',
right: '10%',
roam: true,
symbol: 'circle',
symbolSize: 10,
initialTreeDepth: -1,
expandAndCollapse: true,
label: { position: 'left', verticalAlign: 'middle', align: 'right', fontSize: 12 },
leaves: { label: { position: 'right', verticalAlign: 'middle', align: 'left' } },
emphasis: { focus: 'descendant' },
lineStyle: { color: '#c7d0da', width: 1.2 },
itemStyle: { borderColor: '#fff', borderWidth: 2 },
animationDuration: 400,
},
],
},
true
);
}
async function openDetail(record: SurveyOrderInfo) {
detailRecord.value = record;
detailVisible.value = true;
@@ -1132,8 +1219,22 @@ async function openDetail(record: SurveyOrderInfo) {
} catch {
// 详情获取失败时保留列表行数据
}
await nextTick();
renderDetailTopo();
}
watch(detailVisible, (open) => {
if (!open) {
detailTopoChart?.dispose();
detailTopoChart = null;
}
});
onUnmounted(() => {
detailTopoChart?.dispose();
detailTopoChart = null;
});
function goDetail(record: SurveyOrderInfo) {
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
}
@@ -1906,6 +2007,33 @@ function maskPhone(phone?: string) {
margin-top: 4px;
}
.detail-topo-wrap {
width: 100%;
height: 440px;
border: 1px solid #eef2f7;
border-radius: 10px;
background: #fbfdff;
overflow: hidden;
}
.detail-topo-empty {
width: 100%;
height: 440px;
display: flex;
align-items: center;
justify-content: center;
color: #a4b0bf;
font-size: 13px;
border: 1px dashed #dbe4ee;
border-radius: 10px;
background: #f8faff;
}
.detail-topo-chart {
width: 100%;
height: 100%;
}
.detail-label {
font-size: 12px;
font-weight: 600;
+14 -8
View File
@@ -438,6 +438,7 @@ CREATE TABLE customer_equipment (
customer_id BIGINT NOT NULL REFERENCES customer(id),
equip_name VARCHAR(128) NOT NULL,
equip_type VARCHAR(32) NOT NULL,
parent_id BIGINT REFERENCES customer_equipment(id),
building_id BIGINT REFERENCES customer_building(id),
floor_no VARCHAR(16),
location VARCHAR(256),
@@ -446,12 +447,13 @@ CREATE TABLE customer_equipment (
photo_url VARCHAR(512),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE customer_equipment IS '客户智电改造设备信息表(配电间/井/箱)';
COMMENT ON TABLE customer_equipment IS '客户智电改造设备信息表(配电间/井/箱/柜)';
COMMENT ON COLUMN customer_equipment.id IS '自增主键';
COMMENT ON COLUMN customer_equipment.equip_code IS '客户设备编码(唯一)';
COMMENT ON COLUMN customer_equipment.customer_id IS '客户 ID(→ customer)';
COMMENT ON COLUMN customer_equipment.equip_name IS '客户设备名称';
COMMENT ON COLUMN customer_equipment.equip_type IS '设备类型:配电间/配电井/配电箱';
COMMENT ON COLUMN customer_equipment.equip_type IS '设备类型:配电间/配电井/配电箱/配电柜';
COMMENT ON COLUMN customer_equipment.parent_id IS '父设备 ID(自引用,→ customer_equipment,空为根设备)';
COMMENT ON COLUMN customer_equipment.building_id IS '所属建筑 ID(→ customer_building)';
COMMENT ON COLUMN customer_equipment.floor_no IS '所在楼层';
COMMENT ON COLUMN customer_equipment.location IS '所在位置';
@@ -461,6 +463,7 @@ COMMENT ON COLUMN customer_equipment.photo_url IS '未改造前设备照片';
COMMENT ON COLUMN customer_equipment.created_at IS '创建时间';
CREATE INDEX idx_equip_customer ON customer_equipment (customer_id);
CREATE INDEX idx_equip_building ON customer_equipment (building_id);
CREATE INDEX idx_equip_parent ON customer_equipment (parent_id);
-- 14. 客户访问账号表
DROP TABLE IF EXISTS customer_account CASCADE;
@@ -768,6 +771,7 @@ DROP TABLE IF EXISTS survey_detail CASCADE;
CREATE TABLE survey_detail (
id BIGSERIAL PRIMARY KEY,
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
equip_id BIGINT REFERENCES customer_equipment(id),
building_id BIGINT,
building_name VARCHAR(128),
floor_no VARCHAR(16),
@@ -778,19 +782,21 @@ CREATE TABLE survey_detail (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
COMMENT ON TABLE survey_detail IS '勘察工单明细表(关联客户设备 + 现场照片)';
COMMENT ON COLUMN survey_detail.id IS '自增主键';
COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
COMMENT ON COLUMN survey_detail.equip_id IS '客户设备ID(→ customer_equipment,勘察明细挂接到设备节点)';
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building,兼容历史数据)';
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,兼容历史数据)';
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1(兼容历史数据)';
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空,兼容历史数据)';
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空,兼容历史数据)';
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
COMMENT ON COLUMN survey_detail.remark IS '备注';
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
CREATE INDEX idx_survey_detail_order ON survey_detail (survey_order_id);
CREATE INDEX idx_survey_detail_equip ON survey_detail (equip_id);
CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
@@ -0,0 +1,22 @@
-- ============================================================
-- 勘察派单重构:客户设备树形化 + 勘察明细关联设备
-- 1) customer_equipment 增加 parent_id(自引用,树形层级:配电间→配电箱/井/柜,支持多级)
-- 2) customer_equipment.equip_type 增加「配电柜」选项(注释同步)
-- 3) survey_detail 增加 equip_id(关联 customer_equipment,明细照片/备注挂到设备节点)
-- 幂等:仅新增缺失的列/索引,重复执行结果一致
-- ============================================================
-- 1. customer_equipment.parent_id
ALTER TABLE customer_equipment
ADD COLUMN IF NOT EXISTS parent_id BIGINT REFERENCES customer_equipment(id);
COMMENT ON COLUMN customer_equipment.parent_id IS '父设备ID(自引用,→ customer_equipment,空为根设备)';
CREATE INDEX IF NOT EXISTS idx_equip_parent ON customer_equipment (parent_id);
-- 2. 设备类型注释补充「配电柜」
COMMENT ON COLUMN customer_equipment.equip_type IS '设备类型:配电间/配电井/配电箱/配电柜';
-- 3. survey_detail.equip_id
ALTER TABLE survey_detail
ADD COLUMN IF NOT EXISTS equip_id BIGINT REFERENCES customer_equipment(id);
COMMENT ON COLUMN survey_detail.equip_id IS '客户设备ID(→ customer_equipment,勘察明细挂接到设备节点)';
CREATE INDEX IF NOT EXISTS idx_survey_detail_equip ON survey_detail (equip_id);
@@ -8,6 +8,10 @@ export class CustomerEquipmentController {
@Inject() customerEquipmentService!: CustomerEquipmentService;
@Get("/") async list(@Query() query: any) { return this.customerEquipmentService.list(query, this.ctx); }
// 静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
@Get("/tree") async tree(@Query() query: any) { return this.customerEquipmentService.tree(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.customerEquipmentService.detail(Number(id), this.ctx); }
@Post("/") async create(@Body() body: any) { return this.customerEquipmentService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerEquipmentService.update(Number(id), body, this.ctx); }
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { SurveyOrderService } from "../service/survey_order.service";
import { SurveyOrderSaveDetailsDTO } from "../dto/survey_order.dto";
@Controller("/api/survey-orders")
export class SurveyOrderController {
@@ -21,7 +22,7 @@ export class SurveyOrderController {
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: SurveyOrderSaveDetailsDTO) {
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
}
@@ -35,6 +36,11 @@ export class SurveyOrderController {
return this.surveyOrderService.review(Number(id), body, this.ctx);
}
// 勘察完成:现场勘察中 → 待技术复核
@Put("/:id/complete") async complete(@Param("id") id: string) {
return this.surveyOrderService.complete(Number(id), this.ctx);
}
// 催办
@Post("/:id/urge") async urge(@Param("id") id: string) { return this.surveyOrderService.urge(Number(id), this.ctx); }
+18 -1
View File
@@ -11,8 +11,10 @@ import {
Matches,
Min,
Max,
MaxLength
MaxLength,
ValidateNested
} from "class-validator";
import { Type } from "class-transformer";
export class SurveyOrderQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@@ -73,6 +75,21 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@IsOptional() @IsArray() declare details?: any[];
}
// 勘察明细条目:按设备节点维护(equipId 必填),photos 为对象 key 数组,remark 备注
export class SurveyDetailItemDTO {
@IsNotEmpty({ message: '设备不能为空' }) @IsInt() equipId!: number;
@IsOptional() @IsArray() @IsString({ each: true }) photos?: string[];
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) remark?: string;
}
// 保存勘察明细(全量替换;空数组表示清空明细)
export class SurveyOrderSaveDetailsDTO {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SurveyDetailItemDTO)
details!: SurveyDetailItemDTO[];
}
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
export class SurveyOrderAssignDTO {
@IsNotEmpty({ message: '请选择共享电工' }) @IsInt() workerId!: number;
@@ -3,11 +3,12 @@ import CustomerBuilding from "./customer_building.entity";
@Table({
tableName: "customer_equipment",
comment: "客户设备台账表",
comment: "客户设备台账表(树形层级)",
timestamps: false,
indexes: [
{ name: "idx_equip_customer", fields: ["customer_id"] },
{ name: "idx_equip_building", fields: ["building_id"] },
{ name: "idx_equip_parent", fields: ["parent_id"] },
{ name: "uk_equip_code", unique: true, fields: ["equip_code"] }
]
})
@@ -24,9 +25,12 @@ export default class CustomerEquipment extends Model {
@Column({ type: DataType.STRING(128), allowNull: false, field: "equip_name", comment: "设备名称" })
equipName!: string;
@Column({ type: DataType.STRING(32), field: "equip_type", comment: "设备类型" })
@Column({ type: DataType.STRING(32), field: "equip_type", comment: "设备类型:配电间/配电井/配电箱/配电柜" })
equipType!: string;
@Column({ type: DataType.BIGINT, field: "parent_id", comment: "父设备ID(自引用,空为根设备)" })
parentId!: number;
@ForeignKey(() => CustomerBuilding)
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑ID" })
buildingId!: number;
+11 -3
View File
@@ -1,13 +1,17 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import SurveyOrder from "./survey_order.entity";
import CustomerEquipment from "./customer_equipment.entity";
@Table({
tableName: "survey_detail",
comment: "勘察工单明细表(建筑/楼层/房间 + 现场照片)",
comment: "勘察工单明细表(关联客户设备 + 现场照片)",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [{ name: "idx_survey_detail_order", fields: ["survey_order_id"] }]
indexes: [
{ name: "idx_survey_detail_order", fields: ["survey_order_id"] },
{ name: "idx_survey_detail_equip", fields: ["equip_id"] }
]
})
export default class SurveyDetail extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
@@ -17,7 +21,11 @@ export default class SurveyDetail extends Model {
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
surveyOrderId!: number;
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building)" })
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备ID(→ customer_equipment,勘察明细挂接到设备节点)" })
equipId!: number;
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building,兼容历史数据)" })
buildingId!: number;
@Column({ type: DataType.STRING(128), field: "building_name", comment: "建筑名称(冗余,客户拓扑变更后仍可读)" })
@@ -110,10 +110,17 @@ export class CustomerEquipmentService {
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"]]
order: [["created_at", "DESC"]],
include: [{ model: this.buildingModel, as: "building", attributes: ["buildingName"], required: false }]
});
return { code: 200, message: "获取客户设备列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } };
const list = rows.map((r) => {
const j = r.toJSON();
j.buildingName = j.building?.buildingName ?? null;
delete j.building;
return j;
});
return { code: 200, message: "获取客户设备列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
}
async detail(id: number, ctx: Context): Promise<any> {
@@ -123,9 +130,72 @@ export class CustomerEquipmentService {
return { code: 200, message: "获取客户设备详情成功", data: record.toJSON() };
}
// 校验 parentId:父设备存在、同属客户、非自身/子节点(防循环)
private async validateParent(parentId: any, customerId: number, selfId?: number): Promise<string | null> {
if (parentId === undefined || parentId === null || parentId === "") return null;
const pid = Number(parentId);
if (!pid) return "父设备不存在";
if (selfId !== undefined && pid === selfId) return "父设备不能是自身";
if (selfId !== undefined) {
const descendants = await this.collectDescendants(selfId);
if (descendants.includes(pid)) return "不能将子设备设为父设备(会形成循环)";
}
const parent = await this.equipmentModel.findByPk(pid);
if (!parent) return "父设备不存在";
if (parent.customerId !== customerId) return "父设备不属于该客户";
return null;
}
// 收集节点所有子节点 id(直接查询子树,仅用于防循环校验)
private async collectDescendants(equipId: number): Promise<number[]> {
const ids: number[] = [];
const rows = await this.equipmentModel.findAll({ attributes: ["id", "parentId"] });
const byParent = new Map<number, number[]>();
for (const r of rows) {
const pid = r.parentId;
if (pid == null) continue;
const arr = byParent.get(Number(pid)) || [];
arr.push(Number(r.id));
byParent.set(Number(pid), arr);
}
const stack = [...(byParent.get(equipId) || [])];
while (stack.length) {
const n = stack.pop()!;
if (ids.includes(n)) continue;
ids.push(n);
stack.push(...(byParent.get(n) || []));
}
return ids;
}
// 按客户组装嵌套设备树(根节点 parent_id 为空)
async tree(query: any, ctx: Context): Promise<any> {
const customerId = Number(query.customerId);
if (!customerId) return { code: 400, message: "缺少客户ID参数", data: [] };
if (!(await this.isCustomerOwner(ctx.state.user.userId, customerId))) return { code: 403, message: "无权限访问该客户的设备", data: [] };
const rows = await this.equipmentModel.findAll({ where: { customerId }, order: [["created_at", "ASC"]] });
const list = rows.map((r) => r.toJSON());
const nodeMap = new Map<number, any>();
const roots: any[] = [];
// BIGINT 由 pg 返回为字符串,统一转 number 作为键,确保父子挂接正确
for (const node of list) nodeMap.set(Number(node.id), { ...node, children: [] });
for (const node of list) {
const mapped = nodeMap.get(Number(node.id))!;
const pid = node.parentId;
const parent = pid != null ? nodeMap.get(Number(pid)) : undefined;
if (parent) parent.children.push(mapped);
else roots.push(mapped);
}
return { code: 200, message: "获取客户设备树成功", data: roots };
}
async create(data: any, ctx: Context): Promise<any> {
if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建设备", data: null };
const parentErr = await this.validateParent(data.parentId, data.customerId);
if (parentErr) return { code: 400, message: parentErr, data: null };
try {
data.equipCode = await this.generateEquipCode();
const created = await this.equipmentModel.create(data);
@@ -152,6 +222,9 @@ export class CustomerEquipmentService {
delete data.customerId;
}
const parentErr = await this.validateParent(data.parentId, record.customerId, id);
if (parentErr) return { code: 400, message: parentErr, data: null };
delete data.equipCode;
delete data.createdAt;
delete data.created_at;
@@ -171,6 +244,8 @@ export class CustomerEquipmentService {
const record = await this.equipmentModel.findByPk(id);
if (!record) return { code: 404, message: "客户设备不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该设备", data: null };
const childCount = await this.equipmentModel.count({ where: { parentId: id } });
if (childCount > 0) return { code: 400, message: `该设备下还有 ${childCount} 个子设备,请先删除子设备`, data: null };
await record.destroy();
return { code: 200, message: "删除客户设备成功", data: null };
}
+57 -3
View File
@@ -7,6 +7,7 @@ import SurveyOrder from "../entity/survey_order.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import SurveyDetail from "../entity/survey_detail.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import DispatchPrice from "../entity/dispatch_price.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
@@ -38,6 +39,9 @@ export class SurveyOrderService {
@InjectRepository(SurveyDetail)
detailModel!: Repository<SurveyDetail>;
@InjectRepository(CustomerEquipment)
equipModel!: Repository<CustomerEquipment>;
@InjectRepository(DispatchPrice)
priceModel!: Repository<DispatchPrice>;
@@ -251,16 +255,19 @@ export class SurveyOrderService {
return { baseFee, loopFee, loopCount: count, total };
}
// 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
// 明细入参清洗:equipId(设备节点)优先;兼容历史 建筑/楼层/房间 格式;photos 数组转 JSON 字符串
// 有效行条件:有 equipId 或(建筑 + 楼层都非空),否则丢弃
private normalizeDetails(input: any): any[] {
if (!Array.isArray(input)) return [];
return input
.map((row) => {
const photos = this.parsePhotos(row?.photos);
const equipId = row?.equipId !== undefined && row?.equipId !== null && row?.equipId !== "" ? Number(row.equipId) : null;
const buildingId = Number(row?.buildingId) || null;
const floorNo = row?.floorNo === undefined || row?.floorNo === null ? "" : String(row.floorNo);
const roomId = Number(row?.roomId) || null;
return {
equipId,
buildingId,
buildingName: row?.buildingName ? String(row.buildingName).slice(0, 128) : null,
floorNo: floorNo.slice(0, 16),
@@ -270,8 +277,37 @@ export class SurveyOrderService {
remark: row?.remark ? String(row.remark).slice(0, 255) : null
};
})
// 建筑与楼层是明细的定位要素,缺失的行直接丢弃
.filter((row) => row.buildingId && row.floorNo);
.filter((row) => row.equipId || (row.buildingId && row.floorNo));
}
// 校验明细中的 equipId 均属于工单客户,返回错误信息(null 表示通过)
private async validateDetailEquips(order: SurveyOrder, details: any): Promise<string | null> {
const ids = [...new Set<number>((Array.isArray(details) ? details : []).map((d: any) => Number(d?.equipId)).filter(Boolean))];
if (ids.length === 0) return null;
const equips = await this.equipModel.findAll({ where: { id: { [Op.in]: ids }, customerId: order.customerId }, attributes: ["id"] });
const found = new Set<number>(equips.map((e) => Number(e.id)));
const missing = ids.filter((i) => !found.has(i));
if (missing.length) return `存在不属于该客户的设备明细(设备ID: ${missing.join(", ")})`;
return null;
}
// 组装客户设备树(详情拓扑图展示用)
private async buildEquipTree(customerId: number): Promise<any[]> {
if (!customerId) return [];
const rows = await this.equipModel.findAll({ where: { customerId }, order: [["created_at", "ASC"]] });
const list = rows.map((r) => r.toJSON());
const nodeMap = new Map<number, any>();
const roots: any[] = [];
// BIGINT 由 pg 返回为字符串,统一转 number 作为键,确保父子挂接正确
for (const node of list) nodeMap.set(Number(node.id), { ...node, children: [] });
for (const node of list) {
const mapped = nodeMap.get(Number(node.id))!;
const pid = node.parentId;
const parent = pid != null ? nodeMap.get(Number(pid)) : undefined;
if (parent) parent.children.push(mapped);
else roots.push(mapped);
}
return roots;
}
// 全量替换工单明细(管理端与电工端共用;调用方负责权限与状态校验)
@@ -387,6 +423,8 @@ export class SurveyOrderService {
const vo = this.toVO(record);
await this.decorate([vo]);
vo.details = await this.getDetails(id);
// 客户设备树(勘察明细按设备节点拓扑展示)
vo.equipmentTree = await this.buildEquipTree(Number(record.customerId));
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
const photoKeys: string[] = vo.photos || [];
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
@@ -403,6 +441,9 @@ export class SurveyOrderService {
return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
}
const equipErr = await this.validateDetailEquips(record, dto?.details);
if (equipErr) return { code: 400, message: equipErr, data: null };
await this.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
}
@@ -576,6 +617,19 @@ export class SurveyOrderService {
return { code: 200, message: dto.result === "pass" ? "复核通过,工单已归档" : "已退回现场勘察中", data: vo };
}
// 勘察完成:现场勘察中 → 待技术复核
async complete(id: number, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该工单", data: null };
if (record.status !== STATUS.SURVEYING) return { code: 400, message: "当前状态不可提交技术复核", data: null };
await record.update({ status: STATUS.PENDING_REVIEW });
const vo = this.toVO(record);
await this.decorate([vo]);
await this.logService.logAction(ctx, "勘察派单", "勘察完成", record.surveyNo, `勘察完成提交技术复核 勘察派单 ${record.surveyNo}`);
return { code: 200, message: "勘察完成,已提交技术复核", data: vo };
}
async urge(id: number, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
+17
View File
@@ -143,3 +143,20 @@ alarm_dispatch 表(报警派单信息表)
- 字段映射:info.nodeCode→node_code、info.deviceCode→device_code、info.deviceName→device_name、info.orgCode→org_code、info.orgName→org_name、info.alarmType→alarm_type、info.alarmGrade→alarm_grade、info.alarmStat→alarm_stat、info.isEvent→is_event、info.alarmDate(秒级时间戳)→alarm_time;line_id/line_code/customer_id 由 nodeCode/deviceCode 匹配 electric_line.line_code 联出
- 报警派单:从 device_alarm 派单写入 alarm_dispatch,dispatch_no 生成规则由后端实现(如 AD20261024001)
勘察派单重构(20261024):客户设备表树形化 + 勘察明细关联设备
背景:现场勘察流程中,客户设备(customer_equipment)需表达配电层级(配电间→配电箱/井/柜,支持多级嵌套);勘察明细(survey_detail)需按设备树维护并拓扑展示。
| 表 | 字段变更 | 类型/键 | 说明 |
| :-- | :------ | :----- | :--- |
| customer_equipment | 新增 parent_id | BIGINT,FK(自引用) | 父设备 ID,空为根设备,支持多级嵌套 |
| customer_equipment | equip_type 取值扩展 | VARCHAR(32) | 设备类型:配电间/配电井/配电箱/配电柜 |
| survey_detail | 新增 equip_id | BIGINT,FK(→ customer_equipment) | 勘察明细挂接到设备节点,photos/remark 仍挂在明细上 |
说明:
- 新增接口:`GET /api/customer-equipments/tree?customerId=xx` 返回按 parent_id 组装的嵌套设备树(数据权限与列表一致)
- 创建/更新设备支持 `parentId`,校验父设备存在、同属客户、非自身/子节点(防循环)
- 勘察明细保存(`PUT /api/survey-orders/:id/details`)新增 `equipId` 必填校验(设备属于工单客户),全量替换
- survey_detail 原 building/floor/room 字段保留为可空,兼容历史数据;新流程以设备树为主