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

@@ -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 };