feat: 新增客户用电回路全链路功能
新增客户用电回路表及相关数据库迁移、后端服务、控制器、DTO、实体类和权限配置;新增前端API封装,同时调整路由名称以匹配新业务模块,复用现有菜单权限避免403错误。
This commit is contained in:
1 parent
3596462eca
commit
f72644c24f
12 files changed
+1975
-411
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { CustomerCircuitService } from "../service/customer_circuit.service";
|
||||
|
||||
@Controller("/api/customer-circuits")
|
||||
export class CustomerCircuitController {
|
||||
@Inject() ctx!: Context;
|
||||
@Inject() customerCircuitService!: CustomerCircuitService;
|
||||
|
||||
@Get("/") async list(@Query() query: any) { return this.customerCircuitService.list(query, this.ctx); }
|
||||
// 注意:静态路径 /topology 必须声明在 /:id 之前,避免被参数路由捕获
|
||||
@Get("/topology") async topology(@Query() query: any) { return this.customerCircuitService.topology(query, this.ctx); }
|
||||
@Get("/:id") async detail(@Param("id") id: string) { return this.customerCircuitService.detail(Number(id), this.ctx); }
|
||||
@Post("/") async create(@Body() body: any) { return this.customerCircuitService.create(body, this.ctx); }
|
||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerCircuitService.update(Number(id), body, this.ctx); }
|
||||
@Del("/:id") async delete(@Param("id") id: string) { return this.customerCircuitService.delete(Number(id), this.ctx); }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator";
|
||||
|
||||
export class CustomerCircuitQueryDTO {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(500) pageSize?: number = 10;
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@IsOptional() @IsInt() customerId?: number;
|
||||
@IsOptional() @IsInt() buildingId?: number;
|
||||
@IsOptional() @IsInt() roomId?: number;
|
||||
@IsOptional() @IsString() @MaxLength(16) floorNo?: string;
|
||||
}
|
||||
|
||||
export class CustomerCircuitCreateDTO {
|
||||
@IsNotEmpty({ message: '回路编号不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode!: string;
|
||||
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
|
||||
@IsNotEmpty({ message: '所属建筑不能为空' }) @IsInt() buildingId!: number;
|
||||
@IsNotEmpty({ message: '回路名称不能为空' }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName!: string;
|
||||
@IsOptional() @IsInt() roomId?: number;
|
||||
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
|
||||
@IsOptional() @IsInt() status?: number;
|
||||
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
|
||||
@IsOptional() @IsInt() sortOrder?: number;
|
||||
}
|
||||
|
||||
export class CustomerCircuitUpdateDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode?: string;
|
||||
@IsOptional() @IsInt() customerId?: number;
|
||||
@IsOptional() @IsInt() buildingId?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName?: string;
|
||||
@IsOptional() @IsInt() roomId?: number;
|
||||
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
|
||||
@IsOptional() @IsInt() status?: number;
|
||||
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
|
||||
@IsOptional() @IsInt() sortOrder?: number;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import Customer from "./customer.entity";
|
||||
import CustomerBuilding from "./customer_building.entity";
|
||||
import CustomerRoom from "./customer_room.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "customer_circuit",
|
||||
comment: "客户用电回路表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{ name: "idx_circuit_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_circuit_building", fields: ["building_id"] },
|
||||
{ name: "idx_circuit_room", fields: ["room_id"] }
|
||||
]
|
||||
})
|
||||
export default class CustomerCircuit extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@ForeignKey(() => Customer)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
|
||||
customerId!: number;
|
||||
|
||||
@ForeignKey(() => CustomerBuilding)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "building_id", comment: "所属建筑ID" })
|
||||
buildingId!: number;
|
||||
|
||||
@ForeignKey(() => CustomerRoom)
|
||||
@Column({ type: DataType.BIGINT, field: "room_id", comment: "所属房间ID(可空)" })
|
||||
roomId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
|
||||
floorNo!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "circuit_code", comment: "回路编号(全局唯一)" })
|
||||
circuitCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), allowNull: false, field: "circuit_name", comment: "回路名称" })
|
||||
circuitName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "loop_spec", comment: "回路规格" })
|
||||
loopSpec!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(32), field: "load_type", comment: "负荷类型" })
|
||||
loadType!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(32), field: "rated_voltage", comment: "额定电压" })
|
||||
ratedVoltage!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(32), field: "rated_current", comment: "额定电流" })
|
||||
ratedCurrent!: string;
|
||||
|
||||
@Column({ type: DataType.DECIMAL(10, 2), field: "rated_power", comment: "额定功率kW" })
|
||||
ratedPower!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1正常/0停用" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.TEXT, comment: "备注" })
|
||||
remark!: string;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "排序值" })
|
||||
sortOrder!: number;
|
||||
|
||||
@BelongsTo(() => Customer)
|
||||
customer!: Customer;
|
||||
|
||||
@BelongsTo(() => CustomerBuilding)
|
||||
building!: CustomerBuilding;
|
||||
|
||||
@BelongsTo(() => CustomerRoom)
|
||||
room!: CustomerRoom;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -23,6 +23,9 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
"/api/upload": "customer:visit",
|
||||
"/api/customer-buildings": "customer:building",
|
||||
"/api/customer-rooms": "customer:room",
|
||||
// 用电回路接口复用页面级权限码 customer:topology(该菜单已在 sys_menu 登记并分配给角色),
|
||||
// 因此不再新增按钮级权限码,避免非 ADMIN 角色因权限码未登记而被 403。
|
||||
"/api/customer-circuits": "customer:topology",
|
||||
"/api/customer-equipments": "device:list",
|
||||
"/api/smart-devices": "device:model",
|
||||
"/api/survey-orders": "project:survey",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import CustomerCircuit from "../entity/customer_circuit.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerBuilding from "../entity/customer_building.entity";
|
||||
import CustomerRoom from "../entity/customer_room.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
|
||||
// 楼层排序:数值楼层升序(-1 < 1 < 2 …),"未标注楼层"(空串)排最后,其余按字符串排序
|
||||
function compareFloor(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (a === "") return 1;
|
||||
if (b === "") return -1;
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
const aIsNum = !Number.isNaN(na);
|
||||
const bIsNum = !Number.isNaN(nb);
|
||||
if (aIsNum && bIsNum) return na - nb;
|
||||
if (aIsNum) return -1;
|
||||
if (bIsNum) return 1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
@Provide()
|
||||
export class CustomerCircuitService {
|
||||
@InjectRepository(CustomerCircuit)
|
||||
circuitModel!: Repository<CustomerCircuit>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(CustomerBuilding)
|
||||
buildingModel!: Repository<CustomerBuilding>;
|
||||
|
||||
@InjectRepository(CustomerRoom)
|
||||
roomModel!: Repository<CustomerRoom>;
|
||||
|
||||
@InjectRepository(SysUserRole)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
|
||||
@InjectRepository(SysRole)
|
||||
roleModel!: Repository<SysRole>;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
}
|
||||
|
||||
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
|
||||
if (await this.isAdmin(userId)) return true;
|
||||
const customer = await this.customerModel.findByPk(customerId);
|
||||
return !!customer && customer.salesmanId === userId;
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
const userId = ctx.state.user.userId;
|
||||
const where: any = {};
|
||||
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ circuitName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ circuitCode: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.customerId !== undefined) where.customerId = query.customerId;
|
||||
if (query.buildingId !== undefined) where.buildingId = query.buildingId;
|
||||
if (query.roomId !== undefined) where.roomId = query.roomId;
|
||||
if (query.floorNo !== undefined) where.floorNo = query.floorNo;
|
||||
|
||||
const emptyResult = { code: 200, message: "获取用电回路列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
|
||||
if (!(await this.isAdmin(userId))) {
|
||||
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
|
||||
const ids = myCustomers.map((c) => c.id);
|
||||
if (ids.length === 0) return emptyResult;
|
||||
if (where.customerId !== undefined) {
|
||||
if (!ids.includes(where.customerId)) return emptyResult;
|
||||
} else {
|
||||
where.customerId = { [Op.in]: ids };
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows } = await this.circuitModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["sort_order", "ASC"], ["created_at", "DESC"]]
|
||||
});
|
||||
const list = rows.map((r) => r.toJSON());
|
||||
return { code: 200, message: "获取用电回路列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
|
||||
}
|
||||
|
||||
// 一次返回整棵「建筑 → 楼层 → 房间 → 回路」结构,避免前端逐建筑请求(N+1)
|
||||
async topology(query: any, ctx: Context): Promise<any> {
|
||||
const customerId = Number(query.customerId);
|
||||
if (!customerId) return { code: 400, message: "缺少 customerId 参数", data: null };
|
||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, customerId))) {
|
||||
return { code: 403, message: "无权限访问该客户的空间拓扑", data: null };
|
||||
}
|
||||
|
||||
const [buildings, rooms, circuits] = await Promise.all([
|
||||
this.buildingModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
|
||||
this.roomModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
|
||||
this.circuitModel.findAll({ where: { customerId }, order: [["sort_order", "ASC"], ["id", "ASC"]] })
|
||||
]);
|
||||
|
||||
const circuitJson = circuits.map((c) => c.toJSON()) as any[];
|
||||
|
||||
const data = buildings.map((b) => {
|
||||
const bRooms = rooms.filter((r) => r.buildingId === b.id);
|
||||
const bCircuits = circuitJson.filter((c) => c.buildingId === b.id);
|
||||
const floorSet = Array.from(
|
||||
new Set([...bRooms.map((r) => r.floorNo || ""), ...bCircuits.map((c) => c.floorNo || "")])
|
||||
).sort(compareFloor);
|
||||
|
||||
return {
|
||||
...b.toJSON(),
|
||||
floors: floorSet.map((floorNo) => ({
|
||||
floorNo,
|
||||
rooms: bRooms
|
||||
.filter((r) => (r.floorNo || "") === floorNo)
|
||||
.map((r) => ({
|
||||
...r.toJSON(),
|
||||
circuits: bCircuits.filter((c) => c.roomId === r.id)
|
||||
})),
|
||||
// 公共区域回路:未挂房间,直接归属楼层
|
||||
circuits: bCircuits.filter((c) => !c.roomId && (c.floorNo || "") === floorNo)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
return { code: 200, message: "获取空间用电拓扑成功", data: { buildings: data } };
|
||||
}
|
||||
|
||||
async detail(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.circuitModel.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 };
|
||||
return { code: 200, message: "获取用电回路详情成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
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 exists = await this.circuitModel.findOne({ where: { circuitCode: data.circuitCode } });
|
||||
if (exists) return { code: 400, message: "回路编号已存在,请更换", data: null };
|
||||
const created = await this.circuitModel.create(data);
|
||||
return { code: 200, message: "创建用电回路成功", data: created.toJSON() };
|
||||
}
|
||||
|
||||
async update(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.circuitModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "用电回路不存在", data: null };
|
||||
if (!(await this.isAdmin(ctx.state.user.userId))) {
|
||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限修改该回路", data: null };
|
||||
delete data.customerId;
|
||||
}
|
||||
if (data.circuitCode && data.circuitCode !== record.circuitCode) {
|
||||
const exists = await this.circuitModel.findOne({ where: { circuitCode: data.circuitCode, id: { [Op.ne]: id } } });
|
||||
if (exists) return { code: 400, message: "回路编号已存在,请更换", data: null };
|
||||
}
|
||||
await record.update(data);
|
||||
return { code: 200, message: "更新用电回路成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async delete(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.circuitModel.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 };
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除用电回路成功", data: null };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user