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:
1 parent
2d55947762
commit
dff366a823
19 files changed
+2095
-442
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) } });
|
||||
}
|
||||
|
||||
// 查看关联工程
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 字段保留为可空,兼容历史数据;新流程以设备树为主
|
||||
|
||||
Reference in new issue
Block a user