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
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user