feat: 新增现场勘察派单全流程业务能力

1.  新增共享电工管理模块:包含实体、DTO、控制器、服务以及前后端API
2.  重构勘察工单实体与查询逻辑,完善工单全状态流程与字段
3.  新增勘察SOP模板与工单回执业务体系
4.  更新数据库字典与初始化脚本,补充业务数据
5.  新增勘察派单增删改查、指派、复核、催办等全流程接口
6.  修复用户服务角色关联SQL生成问题
7.  新增勘察派单前后端完整交互链路
This commit is contained in:
andyesfly committed 2026-09-15 08:50:55 +08:00
1 parent 1c83e09fa1
commit 6b00f61550
21 files changed
+4733 -382

No files matched your search

+237 -18
View File
@@ -1,15 +1,143 @@
import request from '@/utils/request'; import request from '@/utils/request';
// 工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
export const SURVEY_STATUS = {
PENDING_ASSIGN: 0,
SURVEYING: 1,
PENDING_REVIEW: 2,
ARCHIVED: 3,
CANCELED: 5
};
export const SURVEY_STATUS_OPTIONS = [
{ value: 0, label: '待指派' },
{ value: 1, label: '现场勘察中' },
{ value: 2, label: '待技术复核' },
{ value: 3, label: '已归档' },
{ value: 5, label: '已取消' }
];
// 优先级:1特急P1 2加急P2 3常规P3
export const SURVEY_PRIORITY = {
URGENT: 1,
EXPEDITED: 2,
REGULAR: 3
};
export const SURVEY_PRIORITY_OPTIONS = [
{ value: 1, label: '特急 · P1' },
{ value: 2, label: '加急 · P2' },
{ value: 3, label: '常规 · P3' }
];
// SOP 规范模板编码与名称
export const SOP_TEMPLATE_CODE = 'standard_dist';
export const SOP_TEMPLATE_LABEL = '标准配电改造模板';
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
export const SURVEY_BASE_FEE = 350;
export const SURVEY_LOOP_FEE = 5;
export interface SurveyOrderCustomer {
id: number;
customerNo?: string;
customerName?: string;
customerLevel?: string;
contactPerson?: string;
contactPhone?: string;
address?: string;
city?: string;
salesmanId?: number;
}
export interface SurveyOrderWorker {
id: number;
workerName?: string;
phone?: string;
workerTitle?: string;
rating?: number;
certInfo?: string;
certVerified?: boolean;
completedCount?: number;
}
export interface SurveySopItem {
id: number;
templateCode: string;
sortOrder: number;
itemName: string;
itemDesc?: string;
// 回执形式:photo 现场照片 / form 清单表单 / meter 测仪截图
receiptType: 'photo' | 'form' | 'meter' | string;
receiptRequire?: string;
required: boolean;
}
export interface SurveyOrderSopResult {
id: number;
surveyOrderId: number;
sopItemId: number;
checked: boolean;
receiptValue?: string;
remark?: string;
checkedAt?: string;
// 由后端 join 的检查项信息
sopItem?: SurveySopItem;
itemName?: string;
receiptType?: string;
receiptRequire?: string;
sortOrder?: number;
}
export interface SurveyOrderInfo { export interface SurveyOrderInfo {
id: number; id: number;
surveyNo: string; surveyNo: string;
customerId: number; customerId: number;
customerName?: string;
customer?: SurveyOrderCustomer;
surveyor?: string; surveyor?: string;
workerId?: number; workerId?: number;
workerName?: string;
worker?: SurveyOrderWorker;
workerTitle?: string;
workerRating?: number;
workerCertInfo?: string;
surveyTime?: string; surveyTime?: string;
priority?: number;
taskTitle?: string;
siteLocation?: string;
source?: string;
loopSpec?: string;
loopCount?: number;
progressDone?: number;
progressTotal?: number;
drawingCount?: number;
salesmanId?: number;
salesmanName?: string;
salesmanPhone?: string;
techContact?: string;
planStart?: string;
planEnd?: string;
expectMinutes?: number;
expectArrivalAt?: string;
slaLevel?: string;
urgeCount?: number;
lastUrgedAt?: string;
isDraft?: number;
estimatedFee?: number | string;
actualFee?: number | string;
photos?: string[];
reviewRemark?: string;
reviewedAt?: string;
cancelReason?: string;
remark?: string; remark?: string;
status?: number; status: number;
createdAt: string; createdAt?: string;
updatedAt?: string;
created_at?: string;
updated_at?: string;
// 详情附带:SOP 回执
sopResults?: SurveyOrderSopResult[];
} }
export interface SurveyOrderQuery { export interface SurveyOrderQuery {
@@ -18,6 +146,12 @@ export interface SurveyOrderQuery {
keyword?: string; keyword?: string;
customerId?: number; customerId?: number;
status?: number; status?: number;
priority?: number;
workerId?: number;
salesmanId?: number;
planStartDate?: string;
planEndDate?: string;
includeDraft?: number;
} }
export interface SurveyOrderListResult { export interface SurveyOrderListResult {
@@ -27,37 +161,122 @@ export interface SurveyOrderListResult {
pageSize: number; pageSize: number;
} }
export interface SurveyOrderCreateParams { export interface SurveyOrderStats {
customerId: number; total: number;
surveyor?: string; monthGrowth: number;
workerId?: number; pendingAssign: number;
surveyTime?: string; pendingWarn: number;
remark?: string; surveying: number;
status?: number; onTimeRate: number;
pendingReview: number;
avgReviewHours: number;
archived: number;
planRate: number;
priorityCounts: {
urgent: number;
expedited: number;
regular: number;
};
} }
export interface SurveyOrderUpdateParams { export interface SurveyQuote {
customerId?: number; baseFee: number;
surveyor?: string; loopFee: number;
loopCount: number;
total: number;
}
export interface SurveyOrderCreateParams {
customerId: number;
taskTitle?: string;
siteLocation?: string;
priority?: number;
loopSpec?: string;
loopCount?: number;
techContact?: string;
planStart?: string;
planEnd?: string;
expectMinutes?: number;
slaLevel?: string;
workerId?: number; workerId?: number;
surveyTime?: string; salesmanId?: number;
estimatedFee?: number;
remark?: string; remark?: string;
status?: number; source?: string;
isDraft?: number;
templateCode?: string;
}
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
export interface SurveyOrderAssignParams {
workerId: number;
estimatedFee?: number;
}
export interface SurveyOrderBatchAssignParams {
ids: number[];
workerId: number;
}
export interface SurveyOrderBatchAssignResult {
successCount: number;
failCount: number;
failIds: number[];
}
export interface SurveyOrderReviewParams {
// pass 复核通过归档 / reject 驳回退回现场勘察中
result: 'pass' | 'reject';
remark?: string;
}
export interface SurveyOrderCancelParams {
reason?: string;
} }
export const surveyOrderApi = { export const surveyOrderApi = {
getList: (params: SurveyOrderQuery) => getList: (params: SurveyOrderQuery) =>
request.get<SurveyOrderListResult>('/survey-orders', { params }), request.get<SurveyOrderListResult>('/survey-orders', { params }),
// KPI 统计卡
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
// SOP 规范模板检查项
getSopItems: (templateCode: string = SOP_TEMPLATE_CODE) =>
request.get<SurveySopItem[]>('/survey-orders/sop-items', { params: { templateCode } }),
// 报酬测算:基础出场工时费 + 回路数 × 单价
getQuote: (loopCount: number) =>
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
getDetail: (id: number) => getDetail: (id: number) =>
request.get<SurveyOrderInfo>(`/survey-orders/${id}`), request.get<SurveyOrderInfo>(`/survey-orders/${id}`),
create: (data: SurveyOrderCreateParams) => create: (data: SurveyOrderCreateParams) =>
request.post('/survey-orders', data), request.post<SurveyOrderInfo>('/survey-orders', data),
update: (id: number, data: SurveyOrderUpdateParams) => update: (id: number, data: SurveyOrderUpdateParams) =>
request.put(`/survey-orders/${id}`, data), request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
delete: (id: number) => // 指派 / 改派共享电工
request.delete(`/survey-orders/${id}`), assign: (id: number, data: SurveyOrderAssignParams) =>
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
// 批量指定电工
batchAssign: (data: SurveyOrderBatchAssignParams) =>
request.post<SurveyOrderBatchAssignResult>('/survey-orders/batch-assign', data),
// 技术复核:通过归档 / 驳回退回现场
review: (id: number, data: SurveyOrderReviewParams) =>
request.put<SurveyOrderInfo>(`/survey-orders/${id}/review`, data),
// 催办
urge: (id: number) => request.post<SurveyOrderInfo>(`/survey-orders/${id}/urge`),
// 取消工单
cancel: (id: number, data: SurveyOrderCancelParams) =>
request.put<SurveyOrderInfo>(`/survey-orders/${id}/cancel`, data),
delete: (id: number) => request.delete(`/survey-orders/${id}`)
}; };
+66
View File
@@ -0,0 +1,66 @@
import request from '@/utils/request';
export interface WorkerInfo {
id: number;
workerName: string;
phone?: string;
idCard?: string;
workerTitle?: string;
rating?: number | string;
certInfo?: string;
certVerified?: boolean;
completedCount?: number;
status?: number;
createdAt?: string;
updatedAt?: string;
}
export interface WorkerOption {
id: number;
workerName: string;
workerTitle?: string;
rating?: number | string;
certInfo?: string;
certVerified?: boolean;
completedCount?: number;
}
export interface WorkerRecommend extends WorkerOption {
// 匹配度(0-100,保留 1 位小数)
matchRate: number;
rank: number;
// 是否为「首选推荐」
isTop: boolean;
}
export interface WorkerQuery {
page?: number;
pageSize?: number;
limit?: number;
keyword?: string;
status?: number;
}
export interface WorkerListResult {
list: WorkerInfo[];
total: number;
page: number;
pageSize: number;
}
export interface WorkerRecommendQuery {
loopCount?: number;
priority?: number;
certKeyword?: string;
}
export const workerApi = {
getList: (params?: WorkerQuery) => request.get<WorkerListResult>('/workers', { params }),
// 下拉选项(仅在岗电工,按评分倒序)
getOptions: () => request.get<WorkerOption[]>('/workers/options'),
// 智能推荐电工:评分 50% + 资质 30% + 经验 20% 加权排序
getRecommend: (params?: WorkerRecommendQuery) =>
request.get<WorkerRecommend[]>('/workers/recommend', { params })
};
+24
View File
@@ -97,6 +97,30 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/project/survey/index.vue"), component: () => import("@/views/project/survey/index.vue"),
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" } meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
}, },
{
path: "project/survey/create",
name: "ProjectSurveyCreate",
component: () => import("@/views/project/survey/form.vue"),
meta: {
requiresAuth: true,
title: "新建勘察派单",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/project/survey"
}
},
{
path: "project/survey/edit/:id",
name: "ProjectSurveyEdit",
component: () => import("@/views/project/survey/form.vue"),
meta: {
requiresAuth: true,
title: "编辑勘察派单",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/project/survey"
}
},
{ {
path: "project/plan", path: "project/plan",
name: "ProjectPlan", name: "ProjectPlan",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+148 -29
View File
@@ -3,7 +3,7 @@
-- 基于《鑫潼智电系统需求分析报告 V1.0》+ 数据库设计文档 -- 基于《鑫潼智电系统需求分析报告 V1.0》+ 数据库设计文档
-- 数据库: PostgreSQL 15+ -- 数据库: PostgreSQL 15+
-- 字符集: UTF8 -- 字符集: UTF8
-- 表数量: 37 张(含 1 张按月分区表) -- 表数量: 39 张(含 1 张按月分区表)
-- --
-- 用法: -- 用法:
-- 1. 先安装 pg_cron 扩展并配置 postgresql.conf(见文件末尾说明) -- 1. 先安装 pg_cron 扩展并配置 postgresql.conf(见文件末尾说明)
@@ -620,34 +620,143 @@ CREATE INDEX idx_lr_room ON line_room (room_id);
CREATE INDEX idx_lr_customer ON line_room (customer_id); CREATE INDEX idx_lr_customer ON line_room (customer_id);
-- ============================================================ -- ============================================================
-- 六、派单域(4 张表) -- 六、派单域(6 张表)
-- ============================================================ -- ============================================================
-- 21. 勘察派单表 -- 21. 勘察派单表
DROP TABLE IF EXISTS survey_order CASCADE; DROP TABLE IF EXISTS survey_order CASCADE;
CREATE TABLE survey_order ( CREATE TABLE survey_order (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
survey_no VARCHAR(64) NOT NULL UNIQUE, survey_no VARCHAR(64) NOT NULL UNIQUE,
customer_id BIGINT NOT NULL REFERENCES customer(id), customer_id BIGINT NOT NULL REFERENCES customer(id),
surveyor VARCHAR(64), surveyor VARCHAR(64),
worker_id BIGINT, worker_id BIGINT,
survey_time TIMESTAMPTZ, survey_time TIMESTAMPTZ,
remark TEXT, priority SMALLINT DEFAULT 3,
status SMALLINT NOT NULL DEFAULT 0, task_title VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() site_location VARCHAR(255),
source VARCHAR(32) DEFAULT 'ledger',
loop_spec VARCHAR(128),
loop_count INTEGER,
progress_done INTEGER DEFAULT 0,
progress_total INTEGER,
drawing_count INTEGER DEFAULT 0,
salesman_id BIGINT,
tech_contact VARCHAR(128),
plan_start TIMESTAMPTZ,
plan_end TIMESTAMPTZ,
expect_minutes INTEGER,
expect_arrival_at TIMESTAMPTZ,
sla_level VARCHAR(32),
urge_count INTEGER DEFAULT 0,
last_urged_at TIMESTAMPTZ,
is_draft SMALLINT NOT NULL DEFAULT 0,
estimated_fee NUMERIC(10,2),
actual_fee NUMERIC(10,2),
photos VARCHAR(1024),
review_remark TEXT,
reviewed_at TIMESTAMPTZ,
cancel_reason VARCHAR(255),
remark TEXT,
status SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
COMMENT ON TABLE survey_order IS '客户勘察派单信息表'; COMMENT ON TABLE survey_order IS '客户勘察派单信息表';
COMMENT ON COLUMN survey_order.id IS '自增主键'; COMMENT ON COLUMN survey_order.id IS '自增主键';
COMMENT ON COLUMN survey_order.survey_no IS '勘察单号(唯一)'; COMMENT ON COLUMN survey_order.survey_no IS '勘察单号(唯一)';
COMMENT ON COLUMN survey_order.customer_id IS '客户 ID(→ customer)'; COMMENT ON COLUMN survey_order.customer_id IS '客户 ID(→ customer)';
COMMENT ON COLUMN survey_order.surveyor IS '勘察人(共享电工)'; COMMENT ON COLUMN survey_order.surveyor IS '勘察人(共享电工)';
COMMENT ON COLUMN survey_order.worker_id IS '共享电工 ID'; COMMENT ON COLUMN survey_order.worker_id IS '共享电工 ID';
COMMENT ON COLUMN survey_order.survey_time IS '勘察时间'; COMMENT ON COLUMN survey_order.survey_time IS '勘察时间';
COMMENT ON COLUMN survey_order.remark IS '备注'; COMMENT ON COLUMN survey_order.priority IS '优先级:1特急P1 2加急P2 3常规P3';
COMMENT ON COLUMN survey_order.status IS '状态:0已派发 1已接单 2已完成'; COMMENT ON COLUMN survey_order.task_title IS '勘察任务标题';
COMMENT ON COLUMN survey_order.created_at IS '创建时间'; COMMENT ON COLUMN survey_order.site_location IS '具体勘察物理区域';
CREATE INDEX idx_survey_customer ON survey_order (customer_id); COMMENT ON COLUMN survey_order.source IS '工单来源:ledger台账派工 visit拜访转勘察 customer客户发起';
CREATE INDEX idx_survey_worker ON survey_order (worker_id); COMMENT ON COLUMN survey_order.loop_spec IS '主进线规约与断路器容量';
COMMENT ON COLUMN survey_order.loop_count IS '预估分支测绘回路数';
COMMENT ON COLUMN survey_order.progress_done IS '已完成测绘回路数';
COMMENT ON COLUMN survey_order.progress_total IS '应测绘回路总数';
COMMENT ON COLUMN survey_order.drawing_count IS '待技术复核图纸数';
COMMENT ON COLUMN survey_order.salesman_id IS '责任业务员ID(→ sys_user)';
COMMENT ON COLUMN survey_order.tech_contact IS '现场技术联系人';
COMMENT ON COLUMN survey_order.plan_start IS '计划到场窗口开始';
COMMENT ON COLUMN survey_order.plan_end IS '计划到场窗口结束';
COMMENT ON COLUMN survey_order.expect_minutes IS '要求接单后到场时限(分钟)';
COMMENT ON COLUMN survey_order.expect_arrival_at IS '期望到场时限(指派时按 expect_minutes 推算)';
COMMENT ON COLUMN survey_order.sla_level IS '履约质保要求';
COMMENT ON COLUMN survey_order.urge_count IS '催办次数';
COMMENT ON COLUMN survey_order.last_urged_at IS '最近催办时间';
COMMENT ON COLUMN survey_order.is_draft IS '是否草稿:0正式工单 1草稿';
COMMENT ON COLUMN survey_order.estimated_fee IS '预估劳务费(基础出场费+回路补贴)';
COMMENT ON COLUMN survey_order.actual_fee IS '实际结算劳务费';
COMMENT ON COLUMN survey_order.photos IS '现场取证照片对象key(JSON数组字符串)';
COMMENT ON COLUMN survey_order.review_remark IS '技术复核意见';
COMMENT ON COLUMN survey_order.reviewed_at IS '技术复核时间';
COMMENT ON COLUMN survey_order.cancel_reason IS '取消原因';
COMMENT ON COLUMN survey_order.remark IS '备注';
COMMENT ON COLUMN survey_order.status IS '状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消';
COMMENT ON COLUMN survey_order.created_at IS '创建时间';
COMMENT ON COLUMN survey_order.updated_at IS '更新时间';
CREATE INDEX idx_survey_customer ON survey_order (customer_id);
CREATE INDEX idx_survey_worker ON survey_order (worker_id);
CREATE INDEX idx_survey_status ON survey_order (status);
CREATE INDEX idx_survey_priority ON survey_order (priority);
CREATE INDEX idx_survey_salesman ON survey_order (salesman_id);
CREATE INDEX idx_survey_plan_start ON survey_order (plan_start);
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- 21.1 勘察 SOP 模板检查项表
DROP TABLE IF EXISTS survey_sop_item CASCADE;
CREATE TABLE survey_sop_item (
id BIGSERIAL PRIMARY KEY,
template_code VARCHAR(64) NOT NULL DEFAULT 'standard_dist',
sort_order INTEGER NOT NULL DEFAULT 0,
item_name VARCHAR(128) NOT NULL,
item_desc VARCHAR(255),
receipt_type VARCHAR(16) NOT NULL DEFAULT 'photo',
receipt_require VARCHAR(64),
required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_sop_item IS '勘察 SOP 模板检查项表';
COMMENT ON COLUMN survey_sop_item.id IS '自增主键';
COMMENT ON COLUMN survey_sop_item.template_code IS 'SOP模板编码:standard_dist标准配电改造模板';
COMMENT ON COLUMN survey_sop_item.sort_order IS '检查项序号';
COMMENT ON COLUMN survey_sop_item.item_name IS '检查项名称';
COMMENT ON COLUMN survey_sop_item.item_desc IS '检查项说明';
COMMENT ON COLUMN survey_sop_item.receipt_type IS '回执形式:photo现场照片 form清单表单 meter测仪截图';
COMMENT ON COLUMN survey_sop_item.receipt_require IS '回执指标要求,如「照片 ≥ 2 张」';
COMMENT ON COLUMN survey_sop_item.required IS '是否必填';
COMMENT ON COLUMN survey_sop_item.created_at IS '创建时间';
CREATE UNIQUE INDEX uk_sop_template_order ON survey_sop_item (template_code, sort_order);
-- 21.2 勘察 SOP 工单回执表
DROP TABLE IF EXISTS survey_sop_result CASCADE;
CREATE TABLE survey_sop_result (
id BIGSERIAL PRIMARY KEY,
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
checked BOOLEAN NOT NULL DEFAULT FALSE,
receipt_value VARCHAR(1024),
remark VARCHAR(255),
checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
COMMENT ON COLUMN survey_sop_result.survey_order_id IS '勘察工单ID(→ survey_order)';
COMMENT ON COLUMN survey_sop_result.sop_item_id IS 'SOP检查项ID(→ survey_sop_item)';
COMMENT ON COLUMN survey_sop_result.checked IS '是否已完成回执';
COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
CREATE UNIQUE INDEX uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
CREATE INDEX idx_sop_result_order ON survey_sop_result (survey_order_id);
-- 22. 安装派单表 -- 22. 安装派单表
DROP TABLE IF EXISTS install_order CASCADE; DROP TABLE IF EXISTS install_order CASCADE;
@@ -1033,18 +1142,28 @@ CREATE TABLE worker (
worker_name VARCHAR(64) NOT NULL, worker_name VARCHAR(64) NOT NULL,
phone VARCHAR(20) NOT NULL, phone VARCHAR(20) NOT NULL,
id_card VARCHAR(18), id_card VARCHAR(18),
worker_title VARCHAR(32),
rating NUMERIC(3,2) DEFAULT 5.00,
cert_info VARCHAR(128),
cert_verified BOOLEAN DEFAULT TRUE,
completed_count INTEGER DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1, status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
COMMENT ON TABLE worker IS '共享电工信息表'; COMMENT ON TABLE worker IS '共享电工信息表';
COMMENT ON COLUMN worker.id IS '自增主键'; COMMENT ON COLUMN worker.id IS '自增主键';
COMMENT ON COLUMN worker.worker_name IS '电工姓名'; COMMENT ON COLUMN worker.worker_name IS '电工姓名';
COMMENT ON COLUMN worker.phone IS '手机号'; COMMENT ON COLUMN worker.phone IS '手机号';
COMMENT ON COLUMN worker.id_card IS '身份证号'; COMMENT ON COLUMN worker.id_card IS '身份证号';
COMMENT ON COLUMN worker.status IS '状态:1在岗 0离职'; COMMENT ON COLUMN worker.worker_title IS '职衔:电工/资深班组长/特种高压电工';
COMMENT ON COLUMN worker.created_at IS '创建时间'; COMMENT ON COLUMN worker.rating IS '服务评分(0-5)';
COMMENT ON COLUMN worker.updated_at IS '更新时间'; COMMENT ON COLUMN worker.cert_info IS '持证资质';
COMMENT ON COLUMN worker.cert_verified IS '资质是否已核验';
COMMENT ON COLUMN worker.completed_count IS '历史累计勘察场次';
COMMENT ON COLUMN worker.status IS '状态:1在岗 0离职';
COMMENT ON COLUMN worker.created_at IS '创建时间';
COMMENT ON COLUMN worker.updated_at IS '更新时间';
CREATE INDEX idx_worker_phone ON worker (phone); CREATE INDEX idx_worker_phone ON worker (phone);
CREATE TRIGGER trg_worker_updated_at BEFORE UPDATE ON worker CREATE TRIGGER trg_worker_updated_at BEFORE UPDATE ON worker
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
+6 -4
View File
@@ -149,9 +149,11 @@ INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES
('device_type', '网关', '网关', 1), ('device_type', '网关', '网关', 1),
('device_type', '探测器', '探测器', 2), ('device_type', '探测器', '探测器', 2),
('device_type', '采集器', '采集器', 3), ('device_type', '采集器', '采集器', 3),
('dispatch_status', '已派发', '0', 1), ('dispatch_status', '待指派', '0', 1),
('dispatch_status', '已接单', '1', 2), ('dispatch_status', '现场勘察中', '1', 2),
('dispatch_status', '已完成', '2', 3), ('dispatch_status', '待技术复核', '2', 3),
('dispatch_status', '已归档', '3', 4),
('dispatch_status', '已取消', '5', 5),
('pay_cycle', '按月支付', '按月', 1), ('pay_cycle', '按月支付', '按月', 1),
('pay_cycle', '按年支付', '按年', 2), ('pay_cycle', '按年支付', '按年', 2),
('dispatch_type', '勘察派单', 'survey', 1), ('dispatch_type', '勘察派单', 'survey', 1),
@@ -203,5 +205,5 @@ DELETE FROM sys_menu WHERE id IN (25, 26);
COMMIT; COMMIT;
-- ============================================================ -- ============================================================
-- 完 · 7 张系统表种子数据(4 角色 + 32 菜单 + 20 字典 + 1 管理员 + 绑定关系) -- 完 · 7 张系统表种子数据(4 角色 + 32 菜单 + 22 字典 + 1 管理员 + 绑定关系)
-- ============================================================ -- ============================================================
@@ -0,0 +1,226 @@
-- 项目勘察(现场勘察派单)功能扩展
-- 背景:db_init.sql 中 survey_order 原为 8 字段的极简结构(0已派发/1已接单/2已完成),
-- 现按「现场勘察派单台账」「现场勘察派单向导」两份 UI 稿完整落地,并引入
-- SOP 模板检查项 + 工单回执两张表,同时补齐共享电工的评分/资质/履约数据。
-- 幂等:全部使用 IF NOT EXISTS / ON CONFLICT,可重复执行。
BEGIN;
-- ------------------------------------------------------------
-- 1) 状态机迁移(必须先于 updated_at 建列执行,用于判定是否已迁移过)
-- 旧:0已派发 1已接单 2已完成
-- 新:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
-- 映射:0→0、1→1、2→3
-- ------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'survey_order' AND column_name = 'updated_at'
) THEN
UPDATE survey_order SET status = 3 WHERE status = 2;
END IF;
END $$;
-- ------------------------------------------------------------
-- 2) survey_order 业务字段扩展
-- ------------------------------------------------------------
-- 派工属性
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS priority SMALLINT DEFAULT 3; -- 优先级:1特急P1 2加急P2 3常规P3
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS task_title VARCHAR(128); -- 勘察任务标题
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS site_location VARCHAR(255); -- 具体勘察物理区域
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS source VARCHAR(32) DEFAULT 'ledger'; -- 工单来源:ledger台账派工 visit拜访转勘察 customer客户发起
-- 现场测绘规格
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS loop_spec VARCHAR(128); -- 主进线规约与断路器容量
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS loop_count INTEGER; -- 预估分支测绘回路数
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS progress_done INTEGER DEFAULT 0; -- 已完成测绘回路数
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS progress_total INTEGER; -- 应测绘回路总数
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS drawing_count INTEGER DEFAULT 0; -- 待技术复核图纸数
-- 责任人与时效
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS salesman_id BIGINT; -- 责任业务员ID(→ sys_user)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS tech_contact VARCHAR(128); -- 现场技术联系人
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS plan_start TIMESTAMPTZ; -- 计划到场窗口开始
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS plan_end TIMESTAMPTZ; -- 计划到场窗口结束
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS expect_minutes INTEGER; -- 要求接单后到场时限(分钟)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS expect_arrival_at TIMESTAMPTZ; -- 期望到场时限(指派时按 expect_minutes 推算)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS sla_level VARCHAR(32); -- 履约质保要求
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS urge_count INTEGER DEFAULT 0; -- 催办次数
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS last_urged_at TIMESTAMPTZ; -- 最近催办时间
-- 派单草稿(向导「保存为草稿」,台账与统计默认排除草稿)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS is_draft SMALLINT NOT NULL DEFAULT 0;
-- 报酬
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS estimated_fee NUMERIC(10,2); -- 预估劳务费(基础出场费+回路补贴)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS actual_fee NUMERIC(10,2); -- 实际结算劳务费
-- 成果与复核
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS photos VARCHAR(1024); -- 现场取证照片对象key(JSON数组字符串)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS review_remark TEXT; -- 技术复核意见
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ; -- 技术复核时间
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS cancel_reason VARCHAR(255); -- 取消原因
-- 更新时间(Sequelize 实体已开启 updated_at,缺列会导致写入失败)
ALTER TABLE survey_order ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
-- 3) 字段注释
COMMENT ON COLUMN survey_order.priority IS '优先级:1特急P1 2加急P2 3常规P3';
COMMENT ON COLUMN survey_order.task_title IS '勘察任务标题';
COMMENT ON COLUMN survey_order.site_location IS '具体勘察物理区域';
COMMENT ON COLUMN survey_order.source IS '工单来源:ledger台账派工 visit拜访转勘察 customer客户发起';
COMMENT ON COLUMN survey_order.loop_spec IS '主进线规约与断路器容量';
COMMENT ON COLUMN survey_order.loop_count IS '预估分支测绘回路数';
COMMENT ON COLUMN survey_order.progress_done IS '已完成测绘回路数';
COMMENT ON COLUMN survey_order.progress_total IS '应测绘回路总数';
COMMENT ON COLUMN survey_order.drawing_count IS '待技术复核图纸数';
COMMENT ON COLUMN survey_order.salesman_id IS '责任业务员ID(→ sys_user)';
COMMENT ON COLUMN survey_order.tech_contact IS '现场技术联系人';
COMMENT ON COLUMN survey_order.plan_start IS '计划到场窗口开始';
COMMENT ON COLUMN survey_order.plan_end IS '计划到场窗口结束';
COMMENT ON COLUMN survey_order.expect_minutes IS '要求接单后到场时限(分钟)';
COMMENT ON COLUMN survey_order.expect_arrival_at IS '期望到场时限(指派时按 expect_minutes 推算)';
COMMENT ON COLUMN survey_order.sla_level IS '履约质保要求';
COMMENT ON COLUMN survey_order.urge_count IS '催办次数';
COMMENT ON COLUMN survey_order.last_urged_at IS '最近催办时间';
COMMENT ON COLUMN survey_order.is_draft IS '是否草稿:0正式工单 1草稿';
COMMENT ON COLUMN survey_order.estimated_fee IS '预估劳务费(基础出场费+回路补贴)';
COMMENT ON COLUMN survey_order.actual_fee IS '实际结算劳务费';
COMMENT ON COLUMN survey_order.photos IS '现场取证照片对象key(JSON数组字符串)';
COMMENT ON COLUMN survey_order.review_remark IS '技术复核意见';
COMMENT ON COLUMN survey_order.reviewed_at IS '技术复核时间';
COMMENT ON COLUMN survey_order.cancel_reason IS '取消原因';
COMMENT ON COLUMN survey_order.updated_at IS '更新时间';
COMMENT ON COLUMN survey_order.status IS '状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消';
-- 4) 查询索引(台账按状态/优先级/计划窗口/业务员过滤与排序)
CREATE INDEX IF NOT EXISTS idx_survey_status ON survey_order (status);
CREATE INDEX IF NOT EXISTS idx_survey_priority ON survey_order (priority);
CREATE INDEX IF NOT EXISTS idx_survey_salesman ON survey_order (salesman_id);
CREATE INDEX IF NOT EXISTS idx_survey_plan_start ON survey_order (plan_start);
-- 5) survey_order 更新时间触发器(与 customer 等表保持一致的库级约定)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_survey_order_updated_at') THEN
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
END IF;
END $$;
-- ------------------------------------------------------------
-- 6) 共享电工域:补齐评分/资质/履约数据(不做真实距离,无经纬度字段)
-- ------------------------------------------------------------
ALTER TABLE worker ADD COLUMN IF NOT EXISTS worker_title VARCHAR(32); -- 职衔:电工/资深班组长/特种高压电工
ALTER TABLE worker ADD COLUMN IF NOT EXISTS rating NUMERIC(3,2) DEFAULT 5.00; -- 服务评分(0-5)
ALTER TABLE worker ADD COLUMN IF NOT EXISTS cert_info VARCHAR(128); -- 持证资质
ALTER TABLE worker ADD COLUMN IF NOT EXISTS cert_verified BOOLEAN DEFAULT TRUE; -- 资质是否已核验
ALTER TABLE worker ADD COLUMN IF NOT EXISTS completed_count INTEGER DEFAULT 0; -- 历史累计勘察场次
COMMENT ON COLUMN worker.worker_title IS '职衔:电工/资深班组长/特种高压电工';
COMMENT ON COLUMN worker.rating IS '服务评分(0-5)';
COMMENT ON COLUMN worker.cert_info IS '持证资质';
COMMENT ON COLUMN worker.cert_verified IS '资质是否已核验';
COMMENT ON COLUMN worker.completed_count IS '历史累计勘察场次';
-- ------------------------------------------------------------
-- 7) 勘察 SOP 模板检查项表(向导第 2 步「标准配电改造模板」)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS survey_sop_item (
id BIGSERIAL PRIMARY KEY,
template_code VARCHAR(64) NOT NULL DEFAULT 'standard_dist',
sort_order INTEGER NOT NULL DEFAULT 0,
item_name VARCHAR(128) NOT NULL,
item_desc VARCHAR(255),
receipt_type VARCHAR(16) NOT NULL DEFAULT 'photo',
receipt_require VARCHAR(64),
required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_sop_item IS '勘察 SOP 模板检查项表';
COMMENT ON COLUMN survey_sop_item.id IS '自增主键';
COMMENT ON COLUMN survey_sop_item.template_code IS 'SOP模板编码:standard_dist标准配电改造模板';
COMMENT ON COLUMN survey_sop_item.sort_order IS '检查项序号';
COMMENT ON COLUMN survey_sop_item.item_name IS '检查项名称';
COMMENT ON COLUMN survey_sop_item.item_desc IS '检查项说明';
COMMENT ON COLUMN survey_sop_item.receipt_type IS '回执形式:photo现场照片 form清单表单 meter测仪截图';
COMMENT ON COLUMN survey_sop_item.receipt_require IS '回执指标要求,如「照片 ≥ 2 张」';
COMMENT ON COLUMN survey_sop_item.required IS '是否必填';
COMMENT ON COLUMN survey_sop_item.created_at IS '创建时间';
CREATE UNIQUE INDEX IF NOT EXISTS uk_sop_template_order ON survey_sop_item (template_code, sort_order);
-- ------------------------------------------------------------
-- 8) 勘察 SOP 工单回执表(每张勘察工单的执行回执)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS survey_sop_result (
id BIGSERIAL PRIMARY KEY,
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
checked BOOLEAN NOT NULL DEFAULT FALSE,
receipt_value VARCHAR(1024),
remark VARCHAR(255),
checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
COMMENT ON COLUMN survey_sop_result.survey_order_id IS '勘察工单ID(→ survey_order)';
COMMENT ON COLUMN survey_sop_result.sop_item_id IS 'SOP检查项ID(→ survey_sop_item)';
COMMENT ON COLUMN survey_sop_result.checked IS '是否已完成回执';
COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
CREATE UNIQUE INDEX IF NOT EXISTS uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
CREATE INDEX IF NOT EXISTS idx_sop_result_order ON survey_sop_result (survey_order_id);
-- ------------------------------------------------------------
-- 9) SOP 模板检查项种子数据(标准配电改造模板 5 项)
-- ------------------------------------------------------------
INSERT INTO survey_sop_item (template_code, sort_order, item_name, item_desc, receipt_type, receipt_require, required) VALUES
('standard_dist', 1, '变压器铭牌与备用容量', '拍摄变压器铭牌并核对备用容量', 'photo', '现场照片 ≥ 2 张', TRUE),
('standard_dist', 2, '母排厚度与电缆线径测绘', '测绘母排厚度与电缆线径', 'photo', '现场照片 ≥ 3 张', TRUE),
('standard_dist', 3, '老旧断路器脱扣特性清查', '清查老旧断路器脱扣特性', 'form', '清单格式·表单直填', TRUE),
('standard_dist', 4, 'DIN导轨剩余安装空间实测','实测配电箱内 DIN 导轨剩余空间','photo', '照片回传·含卷尺 1 张', TRUE),
('standard_dist', 5, '竖井4G/LoRa物联网场强测试','测试竖井物联网信号场强', 'meter', '手持测仪·信号截图', TRUE)
ON CONFLICT (template_code, sort_order) DO UPDATE SET
item_name = EXCLUDED.item_name,
item_desc = EXCLUDED.item_desc,
receipt_type = EXCLUDED.receipt_type,
receipt_require = EXCLUDED.receipt_require,
required = EXCLUDED.required;
-- ------------------------------------------------------------
-- 10) 共享电工种子数据(评分/资质/履约,供向导候选推荐使用)
-- ------------------------------------------------------------
INSERT INTO worker (worker_name, phone, status, worker_title, rating, cert_info, cert_verified, completed_count)
SELECT '赵志强', '13900000001', 1, '特种高压电工', 4.98, '特种低压电工证', TRUE, 24
WHERE NOT EXISTS (SELECT 1 FROM worker WHERE phone = '13900000001');
INSERT INTO worker (worker_name, phone, status, worker_title, rating, cert_info, cert_verified, completed_count)
SELECT '孙明远', '13900000002', 1, '继电保护技师', 4.92, '继电保护作业证', TRUE, 31
WHERE NOT EXISTS (SELECT 1 FROM worker WHERE phone = '13900000002');
INSERT INTO worker (worker_name, phone, status, worker_title, rating, cert_info, cert_verified, completed_count)
SELECT '王建国', '13900000003', 1, '资深班组长', 4.92, '高低压带电作业资质', TRUE, 68
WHERE NOT EXISTS (SELECT 1 FROM worker WHERE phone = '13900000003');
INSERT INTO worker (worker_name, phone, status, worker_title, rating, cert_info, cert_verified, completed_count)
SELECT '周正海', '13900000004', 1, '电气试验工程师', 4.88, '电气试验资质', TRUE, 12
WHERE NOT EXISTS (SELECT 1 FROM worker WHERE phone = '13900000004');
-- ------------------------------------------------------------
-- 11) 勘察派单价格种子数据(向导第 3 步报酬拆分规则)
-- 应付勘察劳务总报酬 = 基础出场工时费 350.00 + 回路数 × 5.00
-- ------------------------------------------------------------
INSERT INTO dispatch_price (price_code, dispatch_type, price_type, unit_price) VALUES
('SURVEY_BASE_FEE', 'survey', '基础出场工时费', 350.00),
('SURVEY_LOOP_FEE', 'survey', '回路测点补贴(元/路)', 5.00)
ON CONFLICT (price_code) DO UPDATE SET
dispatch_type = EXCLUDED.dispatch_type,
price_type = EXCLUDED.price_type,
unit_price = EXCLUDED.unit_price;
COMMIT;
@@ -8,8 +8,38 @@ export class SurveyOrderController {
@Inject() surveyOrderService!: SurveyOrderService; @Inject() surveyOrderService!: SurveyOrderService;
@Get("/") async list(@Query() query: any) { return this.surveyOrderService.list(query, this.ctx); } @Get("/") async list(@Query() query: any) { return this.surveyOrderService.list(query, this.ctx); }
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
@Get("/sop-items") async listSopItems(@Query("templateCode") templateCode: string) {
return this.surveyOrderService.listSopItems(templateCode);
}
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
// 批量指派
@Post("/batch-assign") async batchAssign(@Body() body: any) { return this.surveyOrderService.batchAssign(body, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.surveyOrderService.detail(Number(id), this.ctx); } @Get("/:id") async detail(@Param("id") id: string) { return this.surveyOrderService.detail(Number(id), this.ctx); }
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); } @Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); } @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
// 指派 / 改派共享电工
@Put("/:id/assign") async assign(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.assign(Number(id), body, this.ctx);
}
// 技术复核:pass 归档 / reject 退回现场
@Put("/:id/review") async review(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.review(Number(id), body, this.ctx);
}
// 催办
@Post("/:id/urge") async urge(@Param("id") id: string) { return this.surveyOrderService.urge(Number(id), this.ctx); }
// 取消工单
@Put("/:id/cancel") async cancel(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.cancel(Number(id), body, this.ctx);
}
@Del("/:id") async delete(@Param("id") id: string) { return this.surveyOrderService.delete(Number(id), this.ctx); } @Del("/:id") async delete(@Param("id") id: string) { return this.surveyOrderService.delete(Number(id), this.ctx); }
} }
@@ -0,0 +1,15 @@
import { Controller, Get, Inject, Query } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { WorkerService } from "../service/worker.service";
@Controller("/api/workers")
export class WorkerController {
@Inject() ctx!: Context;
@Inject() workerService!: WorkerService;
@Get("/") async list(@Query() query: any) { return this.workerService.list(query, this.ctx); }
// 静态路径必须声明在动态路径之前
@Get("/options") async options() { return this.workerService.options(this.ctx); }
@Get("/recommend") async recommend(@Query() query: any) { return this.workerService.recommend(query, this.ctx); }
}
+82 -12
View File
@@ -1,27 +1,97 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, Matches, Min, Max, MaxLength } from "class-validator"; import {
IsNotEmpty,
IsOptional,
IsString,
IsInt,
IsNumber,
IsArray,
IsDateString,
IsIn,
ArrayNotEmpty,
Matches,
Min,
Max,
MaxLength
} from "class-validator";
export class SurveyOrderQueryDTO { export class SurveyOrderQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1; @IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10; @IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string; @IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number; @IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() status?: number; @IsOptional() @IsInt() @Min(0) @Max(5) status?: number;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsInt() salesmanId?: number;
// 计划到场日期窗口(YYYY-MM-DD)
@IsOptional() @IsString() @MaxLength(10) planStartDate?: string;
@IsOptional() @IsString() @MaxLength(10) planEndDate?: string;
// 是否包含草稿:字符串或数字,服务层统一转 number 判定
@IsOptional() includeDraft?: string | number;
} }
export class SurveyOrderCreateDTO { export class SurveyOrderCreateDTO {
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) surveyor?: string; @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) taskTitle?: string;
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) siteLocation?: string;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
@IsOptional() @IsInt() @Min(0) loopCount?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) techContact?: string;
@IsOptional() @IsDateString() planStart?: string;
@IsOptional() @IsDateString() planEnd?: string;
@IsOptional() @IsInt() @Min(0) expectMinutes?: number;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) slaLevel?: string;
@IsOptional() @IsInt() workerId?: number; @IsOptional() @IsInt() workerId?: number;
@IsOptional() @IsString() surveyTime?: string; @IsOptional() @IsInt() salesmanId?: number;
@IsOptional() @IsNumber() estimatedFee?: number;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string; @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsInt() @Min(0) @Max(2) status?: number; @IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
// 向导选用的 SOP 模板编码,用于生成回执记录(非持久化列)
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) templateCode?: string;
} }
export class SurveyOrderUpdateDTO { export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@IsOptional() @IsInt() customerId?: number; @IsOptional() @IsInt() declare customerId: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) surveyor?: string; @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare taskTitle?: string;
@IsOptional() @IsInt() workerId?: number; @IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) declare siteLocation?: string;
@IsOptional() @IsString() surveyTime?: string; @IsOptional() @IsInt() @Min(1) @Max(3) declare priority?: number;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string; @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare loopSpec?: string;
@IsOptional() @IsInt() @Min(0) @Max(2) status?: number; @IsOptional() @IsInt() @Min(0) declare loopCount?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare techContact?: string;
@IsOptional() @IsDateString() declare planStart?: string;
@IsOptional() @IsDateString() declare planEnd?: string;
@IsOptional() @IsInt() @Min(0) declare expectMinutes?: number;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare slaLevel?: string;
@IsOptional() @IsInt() declare workerId?: number;
@IsOptional() @IsInt() declare salesmanId?: number;
@IsOptional() @IsNumber() declare estimatedFee?: number;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
}
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
export class SurveyOrderAssignDTO {
@IsNotEmpty({ message: '请选择共享电工' }) @IsInt() workerId!: number;
@IsOptional() @IsNumber() estimatedFee?: number;
}
// 批量指派
export class SurveyOrderBatchAssignDTO {
@IsArray() @ArrayNotEmpty({ message: '请至少选择一条工单' }) @IsInt({ each: true }) ids!: number[];
@IsNotEmpty({ message: '请选择共享电工' }) @IsInt() workerId!: number;
}
// 技术复核
export class SurveyOrderReviewDTO {
@IsIn(['pass', 'reject'], { message: '复核结果不合法' }) result!: 'pass' | 'reject';
@IsOptional() @IsString() @MaxLength(500) @Matches(/^[^<>]*$/) remark?: string;
}
// 取消工单
export class SurveyOrderCancelDTO {
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) reason?: string;
} }
+16
View File
@@ -0,0 +1,16 @@
import { IsOptional, IsString, IsInt, Min, Max, MaxLength } from "class-validator";
export class WorkerQueryDTO {
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() status?: number;
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
// 兼容部分前端分页参数命名
@IsOptional() @IsInt() @Min(1) @Max(200) limit?: number;
}
export class WorkerRecommendQueryDTO {
@IsOptional() @IsInt() @Min(0) loopCount?: number;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsString() @MaxLength(64) certKeyword?: string;
}
@@ -0,0 +1,28 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "dispatch_price",
comment: "共享电工派单价格信息表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class DispatchPrice extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "price_code", comment: "派单价格编码(唯一)" })
priceCode!: string;
@Column({ type: DataType.STRING(32), allowNull: false, field: "dispatch_type", comment: "派单类型:勘察/安装/预警/维修/维保" })
dispatchType!: string;
@Column({ type: DataType.STRING(32), field: "price_type", comment: "价格类型:单价/总价提成" })
priceType!: string;
@Column({ type: DataType.DECIMAL(10, 2), field: "unit_price", comment: "服务单价" })
unitPrice!: number;
@CreatedAt
created_at!: Date;
}
+92 -5
View File
@@ -1,15 +1,20 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity"; import Customer from "./customer.entity";
import Worker from "./worker.entity";
@Table({ @Table({
tableName: "survey_order", tableName: "survey_order",
comment: "客户勘察派单信息表", comment: "客户勘察派单信息表",
timestamps: true, timestamps: true,
createdAt: "created_at", createdAt: "created_at",
updatedAt: false, updatedAt: "updated_at",
indexes: [ indexes: [
{ name: "idx_survey_customer", fields: ["customer_id"] }, { name: "idx_survey_customer", fields: ["customer_id"] },
{ name: "idx_survey_worker", fields: ["worker_id"] } { name: "idx_survey_worker", fields: ["worker_id"] },
{ name: "idx_survey_status", fields: ["status"] },
{ name: "idx_survey_priority", fields: ["priority"] },
{ name: "idx_survey_salesman", fields: ["salesman_id"] },
{ name: "idx_survey_plan_start", fields: ["plan_start"] }
] ]
}) })
export default class SurveyOrder extends Model { export default class SurveyOrder extends Model {
@@ -26,21 +31,103 @@ export default class SurveyOrder extends Model {
@Column({ type: DataType.STRING(64), comment: "勘察人(共享电工)" }) @Column({ type: DataType.STRING(64), comment: "勘察人(共享电工)" })
surveyor!: string; surveyor!: string;
@ForeignKey(() => Worker)
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" }) @Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" })
workerId!: number; workerId!: number;
@Column({ type: DataType.DATE, field: "survey_time", comment: "勘察时间" }) @Column({ type: DataType.DATE, field: "survey_time", comment: "勘察时间" })
surveyTime!: Date; surveyTime!: Date;
@Column({ type: DataType.SMALLINT, defaultValue: 3, field: "priority", comment: "优先级:1特急P1 2加急P2 3常规P3" })
priority!: number;
@Column({ type: DataType.STRING(128), field: "task_title", comment: "勘察任务标题" })
taskTitle!: string;
@Column({ type: DataType.STRING(255), field: "site_location", comment: "具体勘察物理区域" })
siteLocation!: string;
@Column({ type: DataType.STRING(32), defaultValue: "ledger", field: "source", comment: "工单来源:ledger台账派工 visit拜访转勘察 customer客户发起" })
source!: string;
@Column({ type: DataType.STRING(128), field: "loop_spec", comment: "主进线规约与断路器容量" })
loopSpec!: string;
@Column({ type: DataType.INTEGER, field: "loop_count", comment: "预估分支测绘回路数" })
loopCount!: number;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "progress_done", comment: "已完成测绘回路数" })
progressDone!: number;
@Column({ type: DataType.INTEGER, field: "progress_total", comment: "应测绘回路总数" })
progressTotal!: number;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "drawing_count", comment: "待技术复核图纸数" })
drawingCount!: number;
@Column({ type: DataType.BIGINT, field: "salesman_id", comment: "责任业务员ID(→ sys_user)" })
salesmanId!: number;
@Column({ type: DataType.STRING(128), field: "tech_contact", comment: "现场技术联系人" })
techContact!: string;
@Column({ type: DataType.DATE, field: "plan_start", comment: "计划到场窗口开始" })
planStart!: Date;
@Column({ type: DataType.DATE, field: "plan_end", comment: "计划到场窗口结束" })
planEnd!: Date;
@Column({ type: DataType.INTEGER, field: "expect_minutes", comment: "要求接单后到场时限(分钟)" })
expectMinutes!: number;
@Column({ type: DataType.DATE, field: "expect_arrival_at", comment: "期望到场时限(指派时按 expect_minutes 推算)" })
expectArrivalAt!: Date;
@Column({ type: DataType.STRING(32), field: "sla_level", comment: "履约质保要求" })
slaLevel!: string;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "urge_count", comment: "催办次数" })
urgeCount!: number;
@Column({ type: DataType.DATE, field: "last_urged_at", comment: "最近催办时间" })
lastUrgedAt!: Date;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "is_draft", comment: "是否草稿:0正式工单 1草稿" })
isDraft!: number;
@Column({ type: DataType.DECIMAL(10, 2), field: "estimated_fee", comment: "预估劳务费(基础出场费+回路补贴)" })
estimatedFee!: number;
@Column({ type: DataType.DECIMAL(10, 2), field: "actual_fee", comment: "实际结算劳务费" })
actualFee!: number;
@Column({ type: DataType.STRING(1024), field: "photos", comment: "现场取证照片对象key(JSON数组字符串)" })
photos!: string;
@Column({ type: DataType.TEXT, field: "review_remark", comment: "技术复核意见" })
reviewRemark!: string;
@Column({ type: DataType.DATE, field: "reviewed_at", comment: "技术复核时间" })
reviewedAt!: Date;
@Column({ type: DataType.STRING(255), field: "cancel_reason", comment: "取消原因" })
cancelReason!: string;
@Column({ type: DataType.TEXT, comment: "备注" }) @Column({ type: DataType.TEXT, comment: "备注" })
remark!: string; remark!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已接单 2已完成" }) @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消" })
status!: number; status!: number;
@BelongsTo(() => Customer) @BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer; customer!: Customer;
@BelongsTo(() => Worker, { foreignKey: "workerId", as: "worker" })
worker!: Worker;
@CreatedAt @CreatedAt
created_at!: Date; created_at!: Date;
@UpdatedAt
updated_at!: Date;
} }
@@ -0,0 +1,40 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "survey_sop_item",
comment: "勘察 SOP 模板检查项表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "uk_sop_template_order", unique: true, fields: ["template_code", "sort_order"] }
]
})
export default class SurveySopItem extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, defaultValue: "standard_dist", field: "template_code", comment: "SOP模板编码:standard_dist标准配电改造模板" })
templateCode!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "检查项序号" })
sortOrder!: number;
@Column({ type: DataType.STRING(128), allowNull: false, field: "item_name", comment: "检查项名称" })
itemName!: string;
@Column({ type: DataType.STRING(255), field: "item_desc", comment: "检查项说明" })
itemDesc!: string;
@Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "photo", field: "receipt_type", comment: "回执形式:photo现场照片 form清单表单 meter测仪截图" })
receiptType!: string;
@Column({ type: DataType.STRING(64), field: "receipt_require", comment: "回执指标要求,如「照片 ≥ 2 张」" })
receiptRequire!: string;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: true, comment: "是否必填" })
required!: boolean;
@CreatedAt
created_at!: Date;
}
@@ -0,0 +1,45 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript";
import SurveyOrder from "./survey_order.entity";
import SurveySopItem from "./survey_sop_item.entity";
@Table({
tableName: "survey_sop_result",
comment: "勘察 SOP 工单回执表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "uk_sop_result_order_item", unique: true, fields: ["survey_order_id", "sop_item_id"] },
{ name: "idx_sop_result_order", fields: ["survey_order_id"] }
]
})
export default class SurveySopResult extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => SurveyOrder)
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
surveyOrderId!: number;
@ForeignKey(() => SurveySopItem)
@Column({ type: DataType.BIGINT, allowNull: false, field: "sop_item_id", comment: "SOP检查项ID(→ survey_sop_item)" })
sopItemId!: number;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: false, comment: "是否已完成回执" })
checked!: boolean;
@Column({ type: DataType.STRING(1024), field: "receipt_value", comment: "回执内容(照片对象key数组/表单值/截图key)" })
receiptValue!: string;
@Column({ type: DataType.STRING(255), comment: "回执备注" })
remark!: string;
@Column({ type: DataType.DATE, field: "checked_at", comment: "回执时间" })
checkedAt!: Date;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+47
View File
@@ -0,0 +1,47 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript";
@Table({
tableName: "worker",
comment: "共享电工信息表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [{ name: "idx_worker_phone", fields: ["phone"] }]
})
export default class Worker extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, field: "worker_name", comment: "电工姓名" })
workerName!: string;
@Column({ type: DataType.STRING(20), allowNull: false, comment: "手机号" })
phone!: string;
@Column({ type: DataType.STRING(18), field: "id_card", comment: "身份证号" })
idCard!: string;
@Column({ type: DataType.STRING(32), field: "worker_title", comment: "职衔:电工/资深班组长/特种高压电工" })
workerTitle!: string;
@Column({ type: DataType.DECIMAL(3, 2), defaultValue: 5.0, field: "rating", comment: "服务评分(0-5)" })
rating!: number;
@Column({ type: DataType.STRING(128), field: "cert_info", comment: "持证资质" })
certInfo!: string;
@Column({ type: DataType.BOOLEAN, defaultValue: true, field: "cert_verified", comment: "资质是否已核验" })
certVerified!: boolean;
@Column({ type: DataType.INTEGER, defaultValue: 0, field: "completed_count", comment: "历史累计勘察场次" })
completedCount!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在岗 0离职" })
status!: number;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+466 -34
View File
@@ -1,13 +1,29 @@
import { Provide } from "@midwayjs/core"; import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript"; import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize"; import { Op, UniqueConstraintError, literal } from "sequelize";
import { Context } from "@midwayjs/koa"; import { Context } from "@midwayjs/koa";
import SurveyOrder from "../entity/survey_order.entity"; import SurveyOrder from "../entity/survey_order.entity";
import Customer from "../entity/customer.entity"; import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import SurveySopItem from "../entity/survey_sop_item.entity";
import SurveySopResult from "../entity/survey_sop_result.entity";
import DispatchPrice from "../entity/dispatch_price.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity"; import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity"; import SysRole from "../entity/sysRole.entity";
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
// 默认 SOP 模板编码
const DEFAULT_TEMPLATE = "standard_dist";
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
const DEFAULT_EXPECT_MINUTES = 45;
const DEFAULT_BASE_FEE = 350;
const DEFAULT_LOOP_FEE = 5;
// 报酬相关的价格编码
const PRICE_CODES = ["SURVEY_BASE_FEE", "SURVEY_LOOP_FEE"];
@Provide() @Provide()
export class SurveyOrderService { export class SurveyOrderService {
@InjectRepository(SurveyOrder) @InjectRepository(SurveyOrder)
@@ -16,6 +32,21 @@ export class SurveyOrderService {
@InjectRepository(Customer) @InjectRepository(Customer)
customerModel!: Repository<Customer>; customerModel!: Repository<Customer>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SurveySopItem)
sopItemModel!: Repository<SurveySopItem>;
@InjectRepository(SurveySopResult)
sopResultModel!: Repository<SurveySopResult>;
@InjectRepository(DispatchPrice)
priceModel!: Repository<DispatchPrice>;
@InjectRepository(SysUser)
userModel!: Repository<SysUser>;
@InjectRepository(SysUserRole) @InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>; userRoleModel!: Repository<SysUserRole>;
@@ -33,6 +64,21 @@ export class SurveyOrderService {
return !!customer && customer.salesmanId === userId; return !!customer && customer.salesmanId === userId;
} }
// 工单可见性:管理员 / 责任业务员本人 / 客户归属业务员
private async isOrderVisible(userId: number, order: SurveyOrder): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
if (order.salesmanId === userId) return true;
return this.isCustomerOwner(userId, order.customerId);
}
// 业务负责人姓名
private async getUserRealName(userId: number): Promise<string> {
if (!userId) return "未知";
const user = await this.userModel.findByPk(userId, { attributes: ["id", "realName", "username"] });
return user?.realName || (user as any)?.username || "未知";
}
// 单号规则:KC + 东八区 yyyymmdd + 3 位序号,冲突重试 1 次
private async generateSurveyNo(retry: number = 0): Promise<string> { private async generateSurveyNo(retry: number = 0): Promise<string> {
const now = new Date(); const now = new Date();
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
@@ -50,72 +96,458 @@ export class SurveyOrderService {
return result; return result;
} }
// query 参数 → Sequelize where(关键字/客户/状态/优先级/电工/业务员/计划日期窗口/草稿)
private buildWhere(query: any): any {
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ surveyNo: { [Op.like]: `%${query.keyword}%` } },
{ taskTitle: { [Op.like]: `%${query.keyword}%` } },
{ siteLocation: { [Op.like]: `%${query.keyword}%` } },
{ remark: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
if (query.priority !== undefined && query.priority !== "") where.priority = Number(query.priority);
if (query.workerId !== undefined && query.workerId !== "") where.workerId = Number(query.workerId);
if (query.salesmanId !== undefined && query.salesmanId !== "") where.salesmanId = Number(query.salesmanId);
if (query.planStartDate || query.planEndDate) {
const range: any = {};
if (query.planStartDate) range[Op.gte] = new Date(`${query.planStartDate}T00:00:00+08:00`);
if (query.planEndDate) range[Op.lte] = new Date(`${query.planEndDate}T23:59:59+08:00`);
where.planStart = range;
}
// 台账与统计默认排除草稿
if (!Number(query.includeDraft)) where.isDraft = 0;
return where;
}
// 行级隔离:非管理员仅可见「责任业务员为自己」或「客户归属自己」的工单
private async scopeWhere(baseWhere: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const where: any = { ...baseWhere };
if (await this.isAdmin(userId)) return where;
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
const scopeOr: any[] = [{ salesmanId: userId }];
if (ids.length) scopeOr.push({ customerId: { [Op.in]: ids } });
// 与基础条件合并(避免覆盖 keyword 产生的 Op.or)
const existingAnd = where[Op.and] ? (Array.isArray(where[Op.and]) ? where[Op.and] : [where[Op.and]]) : [];
where[Op.and] = [...existingAnd, { [Op.or]: scopeOr }];
return where;
}
// 合并 where:Op.and / Op.or 做并集,其余键覆盖
private mergeWhere(base: any, extra: any): any {
const merged: any = { ...base };
for (const key of Object.keys(extra)) {
merged[key] = extra[key];
}
// Op.and / Op.or 为 symbol 键,不会被 Object.keys 返回,需显式取值合并
for (const opKey of [Op.and, Op.or] as any[]) {
if (!extra[opKey]) continue;
const b = merged[opKey] ? (Array.isArray(merged[opKey]) ? merged[opKey] : [merged[opKey]]) : [];
const e = Array.isArray(extra[opKey]) ? extra[opKey] : [extra[opKey]];
merged[opKey] = [...b, ...e];
}
return merged;
}
private parsePhotos(raw: any): string[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw;
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
// 输出前把 photos 从 JSON 字符串转为 key 数组
private toVO(record: any): any {
const json = record.toJSON ? record.toJSON() : { ...record };
json.photos = this.parsePhotos(json.photos);
return json;
}
// 批量补充客户/电工/业务员信息,避免 N+1
private async decorate(rows: any[]): Promise<any[]> {
if (rows.length === 0) return rows;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const workerIds = [...new Set(rows.map((r) => r.workerId).filter(Boolean))];
const salesmanIds = [...new Set(rows.map((r) => r.salesmanId).filter(Boolean))];
const [customers, workers, users] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName", "customerLevel", "contactPerson", "contactPhone", "address", "city", "salesmanId"]
})
: Promise.resolve([]),
workerIds.length
? this.workerModel.findAll({
where: { id: { [Op.in]: workerIds } },
attributes: ["id", "workerName", "phone", "workerTitle", "rating", "certInfo", "certVerified", "completedCount"]
})
: Promise.resolve([]),
salesmanIds.length
? this.userModel.findAll({ where: { id: { [Op.in]: salesmanIds } }, attributes: ["id", "realName", "phone"] })
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const workerMap = new Map(workers.map((w) => [w.id, w]));
const userMap = new Map(users.map((u) => [u.id, u]));
for (const row of rows) {
const customer: any = customerMap.get(row.customerId);
const worker: any = workerMap.get(row.workerId);
const user: any = userMap.get(row.salesmanId);
row.customer = customer ? customer.toJSON() : null;
row.customerName = customer?.customerName || "";
row.worker = worker ? worker.toJSON() : null;
row.workerName = worker?.workerName || "";
row.salesmanName = user?.realName || "";
row.salesmanPhone = user?.phone || "";
}
return rows;
}
// 统一清洗入参:空串转 null、时间字符串转 Date、photos 数组转 JSON 字符串
private normalizePayload(data: any): any {
const payload: any = { ...data };
const dateKeys = ["planStart", "planEnd", "expectArrivalAt", "reviewedAt", "surveyTime", "lastUrgedAt"];
for (const key of dateKeys) {
if (payload[key]) payload[key] = payload[key] instanceof Date ? payload[key] : new Date(payload[key]);
}
if (payload.photos !== undefined) {
const keys = this.parsePhotos(payload.photos);
payload.photos = keys.length ? JSON.stringify(keys) : null;
}
for (const key of Object.keys(payload)) {
if (payload[key] === "") payload[key] = null;
}
return payload;
}
// 报价:基础出场费 + 回路补贴 × 回路数
private async calcFee(loopCount: number): Promise<{ baseFee: number; loopFee: number; loopCount: number; total: number }> {
const count = Number(loopCount) || 0;
const prices = await this.priceModel.findAll({ where: { priceCode: { [Op.in]: PRICE_CODES } } });
const priceMap = new Map(prices.map((p) => [p.priceCode, Number(p.unitPrice)]));
const baseFee = priceMap.has("SURVEY_BASE_FEE") ? (priceMap.get("SURVEY_BASE_FEE") as number) : DEFAULT_BASE_FEE;
const loopFee = priceMap.has("SURVEY_LOOP_FEE") ? (priceMap.get("SURVEY_LOOP_FEE") as number) : DEFAULT_LOOP_FEE;
const total = Number((baseFee + loopFee * count).toFixed(2));
return { baseFee, loopFee, loopCount: count, total };
}
// 按模板批量生成回执记录(checked=false)
private async createSopResults(orderId: number, templateCode: string): Promise<void> {
const items = await this.sopItemModel.findAll({ where: { templateCode: templateCode || DEFAULT_TEMPLATE } });
if (items.length === 0) return;
await this.sopResultModel.bulkCreate(
items.map((item) => ({ surveyOrderId: orderId, sopItemId: item.id, checked: false })) as any
);
}
async list(query: any, ctx: Context): Promise<any> { async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1; const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10; const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) { const where = await this.scopeWhere(this.buildWhere(query), ctx);
where[Op.or] = [{ surveyor: { [Op.like]: `%${query.keyword}%` } }, { remark: { [Op.like]: `%${query.keyword}%` } }]; const { count, rows } = await this.surveyModel.findAndCountAll({
} where,
if (query.customerId !== undefined) where.customerId = query.customerId; limit: pageSize,
if (query.status !== undefined) where.status = query.status; offset: (page - 1) * pageSize,
order: [["created_at", "DESC"]],
distinct: true
});
if (!(await this.isAdmin(userId))) { const list = await this.decorate(rows.map((r) => this.toVO(r)));
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] }); return { code: 200, message: "获取勘察派单列表成功", data: { list, total: count, page, pageSize } };
const ids = myCustomers.map((c) => c.id); }
if (ids.length === 0) return { code: 200, message: "获取勘察派单列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
if (where.customerId !== undefined) { // KPI 统计卡(仅按当前用户可见范围统计,默认排除草稿)
if (!ids.includes(where.customerId)) return { code: 200, message: "获取勘察派单列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } }; async stats(ctx: Context): Promise<any> {
} else { const where = await this.scopeWhere({ isDraft: 0 }, ctx);
where.customerId = { [Op.in]: ids };
const countBy = (extra: any) => this.surveyModel.count({ where: this.mergeWhere(where, extra) });
const [total, thisMonth, lastMonth, pendingAssign, pendingWarn, surveying, pendingReview, archived, urgent, expedited, regular, onTimeCount] =
await Promise.all([
countBy({ status: { [Op.ne]: STATUS.CANCELED } }),
countBy({ [Op.and]: [literal("(created_at AT TIME ZONE 'Asia/Shanghai') >= date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai')")] }),
countBy({
[Op.and]: [
literal(
"(created_at AT TIME ZONE 'Asia/Shanghai') >= date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai') - interval '1 month'"
),
literal("(created_at AT TIME ZONE 'Asia/Shanghai') < date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai')")
]
}),
countBy({ status: STATUS.PENDING_ASSIGN }),
countBy({ status: STATUS.PENDING_ASSIGN, [Op.and]: [literal("expect_arrival_at IS NOT NULL AND expect_arrival_at <= (NOW() + interval '2 hours')")] }),
countBy({ status: STATUS.SURVEYING }),
countBy({ status: STATUS.PENDING_REVIEW }),
countBy({ status: STATUS.ARCHIVED }),
countBy({ priority: 1 }),
countBy({ priority: 2 }),
countBy({ priority: 3 }),
countBy({ status: STATUS.ARCHIVED, [Op.and]: [literal("(expect_arrival_at IS NULL OR survey_time <= expect_arrival_at)")] })
]);
const avgRow: any = await this.surveyModel.findOne({
attributes: [[literal("AVG(EXTRACT(EPOCH FROM (reviewed_at - created_at))/3600)"), "avgHours"]],
where: this.mergeWhere(where, { status: STATUS.ARCHIVED }),
raw: true
});
const monthGrowth = lastMonth > 0 ? Number((((thisMonth - lastMonth) / lastMonth) * 100).toFixed(1)) : 100;
const onTimeRate = archived > 0 ? Number(((onTimeCount / archived) * 100).toFixed(1)) : 0;
const avgReviewHours = Number(avgRow?.avgHours) > 0 ? Number(Number(avgRow.avgHours).toFixed(1)) : 0;
const planRate = total > 0 ? Number(((archived / total) * 100).toFixed(1)) : 0;
return {
code: 200,
message: "获取勘察派单统计成功",
data: {
total,
monthGrowth,
pendingAssign,
pendingWarn,
surveying,
onTimeRate,
pendingReview,
avgReviewHours,
archived,
planRate,
priorityCounts: { urgent, expedited, regular }
} }
} };
}
const { count, rows } = await this.surveyModel.findAndCountAll({ where, limit: pageSize, offset: (page - 1) * pageSize, order: [["created_at", "DESC"]] }); // SOP 模板检查项
return { code: 200, message: "获取勘察派单列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } }; async listSopItems(templateCode?: string): Promise<any> {
const code = templateCode || DEFAULT_TEMPLATE;
const rows = await this.sopItemModel.findAll({ where: { templateCode: code }, order: [["sort_order", "ASC"]] });
return { code: 200, message: "获取勘察SOP检查项成功", data: rows.map((r) => r.toJSON()) };
}
// 报酬试算
async quote(loopCount?: string): Promise<any> {
const data = await this.calcFee(Number(loopCount) || 0);
return { code: 200, message: "获取勘察报价成功", data };
} }
async detail(id: number, ctx: Context): Promise<any> { async detail(id: number, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id); const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null }; if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该勘察派单", data: null }; if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权限访问该勘察派单", data: null };
return { code: 200, message: "获取勘察派单详情成功", data: record.toJSON() };
const vo = this.toVO(record);
await this.decorate([vo]);
vo.sopResults = await this.getSopResults(id);
return { code: 200, message: "获取勘察派单详情成功", data: vo };
}
// 工单回执 + 检查项信息(手动 join,避免额外关联)
private async getSopResults(orderId: number): Promise<any[]> {
const results = await this.sopResultModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
if (results.length === 0) return [];
const itemIds = [...new Set(results.map((r) => r.sopItemId).filter(Boolean))];
const items = await this.sopItemModel.findAll({ where: { id: { [Op.in]: itemIds } } });
const itemMap = new Map(items.map((i) => [i.id, i]));
return results.map((r) => {
const json: any = r.toJSON();
const item: any = itemMap.get(json.sopItemId);
return {
...json,
itemName: item?.itemName || "",
itemDesc: item?.itemDesc || "",
receiptType: item?.receiptType || "",
receiptRequire: item?.receiptRequire || "",
required: item?.required ?? false
};
});
} }
async create(data: any, ctx: Context): Promise<any> { 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 userId = ctx.state.user.userId;
const isAdmin = await this.isAdmin(userId);
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
if (!isAdmin) data.salesmanId = userId;
const templateCode = data.templateCode || DEFAULT_TEMPLATE;
const payload = this.normalizePayload(data);
delete payload.templateCode;
payload.surveyNo = await this.generateSurveyNo();
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
if (payload.progressDone === undefined || payload.progressDone === null) payload.progressDone = 0;
if (!payload.source) payload.source = "ledger";
if (payload.status === undefined || payload.status === null) payload.status = STATUS.PENDING_ASSIGN;
if (payload.isDraft === undefined || payload.isDraft === null) payload.isDraft = 0;
if (payload.priority === undefined || payload.priority === null) payload.priority = 3;
// 未显式传价时按 dispatch_price 试算
if (payload.estimatedFee === undefined || payload.estimatedFee === null) {
payload.estimatedFee = (await this.calcFee(payload.loopCount || 0)).total;
}
let created: SurveyOrder;
try { try {
data.surveyNo = await this.generateSurveyNo(); created = await this.surveyModel.create(payload);
const created = await this.surveyModel.create(data);
return { code: 200, message: "创建勘察派单成功", data: created.toJSON() };
} catch (err: any) { } catch (err: any) {
if (err instanceof UniqueConstraintError) { if (err instanceof UniqueConstraintError) {
data.surveyNo = await this.generateSurveyNo(); payload.surveyNo = await this.generateSurveyNo();
const created = await this.surveyModel.create(data); created = await this.surveyModel.create(payload);
return { code: 200, message: "创建勘察派单成功", data: created.toJSON() }; } else {
throw err;
} }
throw err;
} }
await this.createSopResults(created.id, templateCode);
const vo = this.toVO(created);
await this.decorate([vo]);
return { code: 200, message: "创建勘察派单成功", data: vo };
} }
async update(id: number, data: any, ctx: Context): Promise<any> { async update(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id); const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null }; 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 }; const userId = ctx.state.user.userId;
delete data.customerId; const isAdmin = await this.isAdmin(userId);
if (!isAdmin) {
const visible = record.salesmanId === userId || (await this.isCustomerOwner(userId, record.customerId));
if (!visible) return { code: 403, message: "无权操作该工单", data: null };
delete data.salesmanId;
} }
delete data.surveyNo; delete data.surveyNo;
await record.update(data); delete data.templateCode;
return { code: 200, message: "更新勘察派单成功", data: record.toJSON() };
const payload = this.normalizePayload(data);
await record.update(payload);
const fresh = await this.surveyModel.findByPk(id);
const vo = this.toVO(fresh!);
await this.decorate([vo]);
return { code: 200, message: "更新勘察派单成功", data: vo };
}
// 指派 / 改派核心逻辑(批量指派复用)
private async assignCore(id: number, worker: Worker, estimatedFee?: number): Promise<{ ok: boolean; code?: number; message?: string; data?: any }> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { ok: false, code: 404, message: "勘察派单不存在" };
if (record.status !== STATUS.PENDING_ASSIGN && record.status !== STATUS.SURVEYING) {
return { ok: false, code: 400, message: "当前状态不可指派" };
}
const expectMinutes = record.expectMinutes || DEFAULT_EXPECT_MINUTES;
const patch: any = {
workerId: worker.id,
surveyor: worker.workerName,
status: STATUS.SURVEYING,
expectArrivalAt: new Date(Date.now() + expectMinutes * 60000)
};
if (estimatedFee !== undefined && estimatedFee !== null) patch.estimatedFee = estimatedFee;
await record.update(patch);
const vo = this.toVO(record);
await this.decorate([vo]);
return { ok: true, data: vo };
}
async assign(id: number, dto: any, 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 };
const worker = await this.workerModel.findByPk(Number(dto.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
const result = await this.assignCore(id, worker, dto.estimatedFee);
if (!result.ok) return { code: result.code, message: result.message, data: null };
return { code: 200, message: "派单成功", data: result.data };
}
async batchAssign(dto: any, ctx: Context): Promise<any> {
const worker = await this.workerModel.findByPk(Number(dto.workerId));
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
const rawIds: any[] = dto.ids || [];
const ids: number[] = [...new Set<number>(rawIds.map((v) => Number(v)).filter(Boolean))];
let successCount = 0;
const failIds: number[] = [];
for (const id of ids) {
try {
const result = await this.assignCore(id, worker);
if (result.ok) successCount++;
else failIds.push(id);
} catch {
failIds.push(id);
}
}
return {
code: 200,
message: `批量派单完成,成功 ${successCount} 条`,
data: { successCount, failCount: failIds.length, failIds }
};
}
// 技术复核:pass→已归档;reject→退回现场勘察中
async review(id: number, dto: any, ctx: Context): Promise<any> {
if (!(await this.isAdmin(ctx.state.user.userId))) return { code: 403, message: "无权限复核该工单", data: null };
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (record.status !== STATUS.PENDING_REVIEW) return { code: 400, message: "当前状态不可复核", data: null };
const patch: any = { reviewedAt: new Date(), reviewRemark: dto.remark || null };
if (dto.result === "pass") {
patch.status = STATUS.ARCHIVED;
patch.actualFee = record.actualFee ?? record.estimatedFee;
} else {
patch.status = STATUS.SURVEYING;
}
await record.update(patch);
const vo = this.toVO(record);
await this.decorate([vo]);
return { code: 200, message: dto.result === "pass" ? "复核通过,工单已归档" : "已退回现场勘察中", 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 };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该工单", data: null };
if (![STATUS.PENDING_ASSIGN, STATUS.SURVEYING, STATUS.PENDING_REVIEW].includes(record.status)) {
return { code: 400, message: "当前状态不可催办", data: null };
}
const urgeCount = (record.urgeCount || 0) + 1;
const lastUrgedAt = new Date();
await record.update({ urgeCount, lastUrgedAt });
return { code: 200, message: "催办成功", data: { urgeCount, lastUrgedAt } };
}
async cancel(id: number, dto: any, 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.ARCHIVED || record.status === STATUS.CANCELED) {
return { code: 400, message: "当前状态不可取消", data: null };
}
await record.update({ status: STATUS.CANCELED, cancelReason: dto.reason || null });
return { code: 200, message: "取消工单成功", data: this.toVO(record) };
} }
async delete(id: number, ctx: Context): Promise<any> { async delete(id: number, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id); const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null }; if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null }; if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
// 级联清理回执后删除工单
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
await record.destroy(); await record.destroy();
return { code: 200, message: "删除勘察派单成功", data: null }; return { code: 200, message: "删除勘察派单成功", data: null };
} }
+5 -7
View File
@@ -34,17 +34,15 @@ export class UserService {
where.status = query.status; where.status = query.status;
} }
// 角色过滤条件必须下移到 SysUserRole 自身的 roleId 上:
// 若改在内层 SysRole 上同时设置 where + required,Sequelize 在 findAndCountAll(subQuery+distinct)
// 下会生成缺少中间表 sys_user_role 的非法 SQL(42P01 丢失FROM子句项)
const includeOptions: any = [ const includeOptions: any = [
{ {
model: SysUserRole, model: SysUserRole,
required: !!query.roleId, required: !!query.roleId,
include: [ ...(query.roleId ? { where: { roleId: query.roleId } } : {}),
{ include: [{ model: SysRole, required: false }]
model: SysRole,
where: query.roleId ? { id: query.roleId } : {},
required: !!query.roleId
}
]
} }
]; ];
+98
View File
@@ -0,0 +1,98 @@
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 Worker from "../entity/worker.entity";
// 电工状态:1在岗 0离职
const STATUS_ON_DUTY = 1;
// 推荐位数量
const RECOMMEND_LIMIT = 5;
// 经验分基准场次(达到即满分)
const EXP_BASE_COUNT = 50;
@Provide()
export class WorkerService {
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
// 列表:姓名/手机号/资质模糊检索 + 状态过滤 + 分页
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize || query.limit) || 10;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ workerName: { [Op.like]: `%${query.keyword}%` } },
{ phone: { [Op.like]: `%${query.keyword}%` } },
{ certInfo: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
const { count, rows } = await this.workerModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["rating", "DESC"]],
distinct: true
});
return { code: 200, message: "获取共享电工列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page, pageSize } };
}
// 下拉选项:仅在岗电工
async options(ctx: Context): Promise<any> {
const rows = await this.workerModel.findAll({
where: { status: STATUS_ON_DUTY },
attributes: ["id", "workerName", "workerTitle", "rating", "certInfo", "certVerified", "completedCount"],
order: [["rating", "DESC"]]
});
return { code: 200, message: "获取共享电工选项成功", data: rows.map((r) => r.toJSON()) };
}
// 智能推荐:评分 50% + 资质 30% + 经验 20% 加权,取前 5 名
async recommend(query: any, ctx: Context): Promise<any> {
const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY } });
if (rows.length === 0) return { code: 200, message: "获取推荐电工成功", data: [] };
const certKeyword: string = query.certKeyword || "";
const list = rows.map((row) => {
const certInfo: string = row.certInfo || "";
const rating = Number(row.rating) || 0;
const completedCount = Number(row.completedCount) || 0;
const scoreRating = rating / 5;
let scoreCert = row.certVerified ? (certInfo ? 1 : 0.7) : 0.4;
// 关键词命中持证资质时资质分置满
if (certKeyword && certInfo.includes(certKeyword)) scoreCert = 1;
const scoreExp = Math.min(completedCount / EXP_BASE_COUNT, 1);
const score = 0.5 * scoreRating + 0.3 * scoreCert + 0.2 * scoreExp;
return {
id: row.id,
workerName: row.workerName,
workerTitle: row.workerTitle,
rating,
certInfo,
certVerified: row.certVerified,
completedCount,
matchRate: Number((score * 100).toFixed(1)),
rank: 0,
isTop: false
};
});
list.sort((a, b) => b.matchRate - a.matchRate || b.rating - a.rating || b.completedCount - a.completedCount);
const data = list.slice(0, RECOMMEND_LIMIT).map((item, index) => {
const rank = index + 1;
return { ...item, rank, isTop: rank === 1 };
});
return { code: 200, message: "获取推荐电工成功", data };
}
}
+68 -6
View File
@@ -100,7 +100,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
<p class="sub">基于《鑫潼智电系统需求分析报告 V1.0》数据说明,设计 37 张数据表 + 3 个分区管理函数 + 1 个物化视图,覆盖客户、项目、设备、派单、报警、报修、维保、报酬、用电采集等全部业务域。采用逻辑隔离 + 时序分区方案。</p> <p class="sub">基于《鑫潼智电系统需求分析报告 V1.0》数据说明,设计 37 张数据表 + 3 个分区管理函数 + 1 个物化视图,覆盖客户、项目、设备、派单、报警、报修、维保、报酬、用电采集等全部业务域。采用逻辑隔离 + 时序分区方案。</p>
<div class="meta"> <div class="meta">
<span>数据库:<b>PostgreSQL 15+</b></span> <span>数据库:<b>PostgreSQL 15+</b></span>
<span>表数量:<b>37 张</b></span> <span>表数量:<b>39 张</b></span>
<span>隔离方案:<b>customer_id 逻辑隔离</b></span> <span>隔离方案:<b>customer_id 逻辑隔离</b></span>
<span>时序分区:<b>electricity_data 按月分区</b></span> <span>时序分区:<b>electricity_data 按月分区</b></span>
<span>自动分区:<b>3 个管理函数</b></span> <span>自动分区:<b>3 个管理函数</b></span>
@@ -126,7 +126,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
<!-- 02 业务域划分 --> <!-- 02 业务域划分 -->
<section id="domains"> <section id="domains">
<div class="sh"><span class="sn">02</span><h2>业务域划分</h2><span class="d">14 个业务域 · 37 张表</span></div> <div class="sh"><span class="sn">02</span><h2>业务域划分</h2><span class="d">14 个业务域 · 39 张表</span></div>
<div class="domains"> <div class="domains">
<div class="dom dom-blue"> <div class="dom dom-blue">
@@ -183,6 +183,8 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
<div class="dom-head"><span class="dn">D6</span><h3>派单域</h3></div> <div class="dom-head"><span class="dn">D6</span><h3>派单域</h3></div>
<div class="dom-body"> <div class="dom-body">
<div class="tbl"><span class="tn">survey_order</span><span class="tc">勘察派单</span></div> <div class="tbl"><span class="tn">survey_order</span><span class="tc">勘察派单</span></div>
<div class="tbl"><span class="tn">survey_sop_item</span><span class="tc">勘察SOP模板检查项</span></div>
<div class="tbl"><span class="tn">survey_sop_result</span><span class="tc">勘察SOP工单回执</span></div>
<div class="tbl"><span class="tn">install_order</span><span class="tc">安装派单</span></div> <div class="tbl"><span class="tn">install_order</span><span class="tc">安装派单</span></div>
<div class="tbl"><span class="tn">acceptance</span><span class="tc">项目验收</span></div> <div class="tbl"><span class="tn">acceptance</span><span class="tc">项目验收</span></div>
<div class="tbl"><span class="tn">visit_record</span><span class="tc">客户回访</span></div> <div class="tbl"><span class="tn">visit_record</span><span class="tc">客户回访</span></div>
@@ -290,6 +292,9 @@ erDiagram
worker ||--o{ worker_income : "电工收入" worker ||--o{ worker_income : "电工收入"
worker_income ||--o{ worker_income_detail : "收入明细" worker_income ||--o{ worker_income_detail : "收入明细"
dispatch_price ||--o{ worker_income_detail : "价格参考" dispatch_price ||--o{ worker_income_detail : "价格参考"
survey_order ||--o{ survey_sop_result : "工单回执"
survey_sop_item ||--o{ survey_sop_result : "模板检查项"
</figure> </figure>
</section> </section>
@@ -325,7 +330,7 @@ erDiagram
<!-- 05 全部表字段说明 --> <!-- 05 全部表字段说明 -->
<section id="tables"> <section id="tables">
<div class="sh"><span class="sn">05</span><h2>全部数据表字段说明</h2><span class="d">37 张表 · 按业务域分组</span></div> <div class="sh"><span class="sn">05</span><h2>全部数据表字段说明</h2><span class="d">39 张表 · 按业务域分组</span></div>
<!-- D1 系统管理域 --> <!-- D1 系统管理域 -->
<div class="dom-div"><span class="dn">D1</span><h3>系统管理域</h3><span class="dc">7 张表 · 用户/角色/菜单/字典/日志</span></div> <div class="dom-div"><span class="dn">D1</span><h3>系统管理域</h3><span class="dc">7 张表 · 用户/角色/菜单/字典/日志</span></div>
@@ -593,7 +598,7 @@ erDiagram
</tbody></table></div> </tbody></table></div>
<!-- D6 派单域 --> <!-- D6 派单域 -->
<div class="dom-div"><span class="dn">D6</span><h3>派单域</h3><span class="dc">4 张表 · 勘察/安装/验收/回访</span></div> <div class="dom-div"><span class="dn">D6</span><h3>派单域</h3><span class="dc">6 张表 · 勘察/勘察SOP/安装/验收/回访</span></div>
<div class="tbl-title"><code>survey_order</code><span class="td">— 客户勘察派单信息表</span></div> <div class="tbl-title"><code>survey_order</code><span class="td">— 客户勘察派单信息表</span></div>
<div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody> <div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody>
@@ -603,9 +608,61 @@ erDiagram
<tr><td><code>surveyor</code></td><td>VARCHAR(64)</td><td></td><td>勘察人(共享电工)</td></tr> <tr><td><code>surveyor</code></td><td>VARCHAR(64)</td><td></td><td>勘察人(共享电工)</td></tr>
<tr><td><code>worker_id</code></td><td>BIGINT</td><td></td><td>共享电工 ID</td></tr> <tr><td><code>worker_id</code></td><td>BIGINT</td><td></td><td>共享电工 ID</td></tr>
<tr><td><code>survey_time</code></td><td>TIMESTAMPTZ</td><td></td><td>勘察时间</td></tr> <tr><td><code>survey_time</code></td><td>TIMESTAMPTZ</td><td></td><td>勘察时间</td></tr>
<tr><td><code>priority</code></td><td>SMALLINT</td><td><span class="pill p-pi">IDX</span></td><td>优先级:1特急P1 2加急P2 3常规P3</td></tr>
<tr><td><code>task_title</code></td><td>VARCHAR(128)</td><td></td><td>勘察任务标题</td></tr>
<tr><td><code>site_location</code></td><td>VARCHAR(255)</td><td></td><td>具体勘察物理区域</td></tr>
<tr><td><code>source</code></td><td>VARCHAR(32)</td><td></td><td>工单来源:ledger台账派工 visit拜访转勘察 customer客户发起</td></tr>
<tr><td><code>loop_spec</code></td><td>VARCHAR(128)</td><td></td><td>主进线规约与断路器容量</td></tr>
<tr><td><code>loop_count</code></td><td>INTEGER</td><td></td><td>预估分支测绘回路数</td></tr>
<tr><td><code>progress_done</code></td><td>INTEGER</td><td></td><td>已完成测绘回路数</td></tr>
<tr><td><code>progress_total</code></td><td>INTEGER</td><td></td><td>应测绘回路总数</td></tr>
<tr><td><code>drawing_count</code></td><td>INTEGER</td><td></td><td>待技术复核图纸数</td></tr>
<tr><td><code>salesman_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>责任业务员 ID(→ sys_user,数据隔离)</td></tr>
<tr><td><code>tech_contact</code></td><td>VARCHAR(128)</td><td></td><td>现场技术联系人</td></tr>
<tr><td><code>plan_start</code></td><td>TIMESTAMPTZ</td><td><span class="pill p-pi">IDX</span></td><td>计划到场窗口开始</td></tr>
<tr><td><code>plan_end</code></td><td>TIMESTAMPTZ</td><td></td><td>计划到场窗口结束</td></tr>
<tr><td><code>expect_minutes</code></td><td>INTEGER</td><td></td><td>要求接单后到场时限(分钟)</td></tr>
<tr><td><code>expect_arrival_at</code></td><td>TIMESTAMPTZ</td><td></td><td>期望到场时限(指派时按 expect_minutes 推算)</td></tr>
<tr><td><code>sla_level</code></td><td>VARCHAR(32)</td><td></td><td>履约质保要求</td></tr>
<tr><td><code>urge_count</code></td><td>INTEGER</td><td></td><td>催办次数</td></tr>
<tr><td><code>last_urged_at</code></td><td>TIMESTAMPTZ</td><td></td><td>最近催办时间</td></tr>
<tr><td><code>is_draft</code></td><td>SMALLINT</td><td></td><td>是否草稿:0正式工单 1草稿</td></tr>
<tr><td><code>estimated_fee</code></td><td>NUMERIC(10,2)</td><td></td><td>预估劳务费(基础出场费+回路补贴)</td></tr>
<tr><td><code>actual_fee</code></td><td>NUMERIC(10,2)</td><td></td><td>实际结算劳务费</td></tr>
<tr><td><code>photos</code></td><td>VARCHAR(1024)</td><td></td><td>现场取证照片对象key(JSON数组字符串)</td></tr>
<tr><td><code>review_remark</code></td><td>TEXT</td><td></td><td>技术复核意见</td></tr>
<tr><td><code>reviewed_at</code></td><td>TIMESTAMPTZ</td><td></td><td>技术复核时间</td></tr>
<tr><td><code>cancel_reason</code></td><td>VARCHAR(255)</td><td></td><td>取消原因</td></tr>
<tr><td><code>remark</code></td><td>TEXT</td><td></td><td>备注</td></tr> <tr><td><code>remark</code></td><td>TEXT</td><td></td><td>备注</td></tr>
<tr><td><code>status</code></td><td>SMALLINT</td><td></td><td>状态:0已派发 1已接单 2已完成</td></tr> <tr><td><code>status</code></td><td>SMALLINT</td><td><span class="pill p-pi">IDX</span></td><td>状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消</td></tr>
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr> <tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
<tr><td><code>updated_at</code></td><td>TIMESTAMPTZ</td><td></td><td>更新时间(触发器自动维护)</td></tr>
</tbody></table></div>
<div class="tbl-title"><code>survey_sop_item</code><span class="td">— 勘察 SOP 模板检查项表</span></div>
<div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody>
<tr><td><code>id</code></td><td>BIGSERIAL</td><td><span class="pill p-pk">PK</span></td><td>自增主键</td></tr>
<tr><td><code>template_code</code></td><td>VARCHAR(64)</td><td><span class="pill p-uk">UNIQUE</span></td><td>SOP模板编码:standard_dist 标准配电改造模板(与 sort_order 联合唯一)</td></tr>
<tr><td><code>sort_order</code></td><td>INTEGER</td><td></td><td>检查项序号</td></tr>
<tr><td><code>item_name</code></td><td>VARCHAR(128)</td><td></td><td>检查项名称</td></tr>
<tr><td><code>item_desc</code></td><td>VARCHAR(255)</td><td></td><td>检查项说明</td></tr>
<tr><td><code>receipt_type</code></td><td>VARCHAR(16)</td><td></td><td>回执形式:photo现场照片 form清单表单 meter测仪截图</td></tr>
<tr><td><code>receipt_require</code></td><td>VARCHAR(64)</td><td></td><td>回执指标要求,如「照片 ≥ 2 张」</td></tr>
<tr><td><code>required</code></td><td>BOOLEAN</td><td></td><td>是否必填</td></tr>
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
</tbody></table></div>
<div class="tbl-title"><code>survey_sop_result</code><span class="td">— 勘察 SOP 工单回执表</span></div>
<div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody>
<tr><td><code>id</code></td><td>BIGSERIAL</td><td><span class="pill p-pk">PK</span></td><td>自增主键</td></tr>
<tr><td><code>survey_order_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>勘察工单 ID(→ survey_order,级联删除)</td></tr>
<tr><td><code>sop_item_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>SOP 检查项 ID(→ survey_sop_item)</td></tr>
<tr><td><code>checked</code></td><td>BOOLEAN</td><td></td><td>是否已完成回执</td></tr>
<tr><td><code>receipt_value</code></td><td>VARCHAR(1024)</td><td></td><td>回执内容(照片对象key数组/表单值/截图key)</td></tr>
<tr><td><code>remark</code></td><td>VARCHAR(255)</td><td></td><td>回执备注</td></tr>
<tr><td><code>checked_at</code></td><td>TIMESTAMPTZ</td><td></td><td>回执时间</td></tr>
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
<tr><td><code>updated_at</code></td><td>TIMESTAMPTZ</td><td></td><td>更新时间</td></tr>
</tbody></table></div> </tbody></table></div>
<div class="tbl-title"><code>install_order</code><span class="td">— 项目安装任务派单信息表</span></div> <div class="tbl-title"><code>install_order</code><span class="td">— 项目安装任务派单信息表</span></div>
@@ -810,6 +867,11 @@ erDiagram
<tr><td><code>password_hash</code></td><td>VARCHAR(255)</td><td></td><td>密码哈希(BCrypt)</td></tr> <tr><td><code>password_hash</code></td><td>VARCHAR(255)</td><td></td><td>密码哈希(BCrypt)</td></tr>
<tr><td><code>id_card</code></td><td>VARCHAR(18)</td><td></td><td>身份证号</td></tr> <tr><td><code>id_card</code></td><td>VARCHAR(18)</td><td></td><td>身份证号</td></tr>
<tr><td><code>status</code></td><td>SMALLINT</td><td></td><td>状态:1在岗 0离职</td></tr> <tr><td><code>status</code></td><td>SMALLINT</td><td></td><td>状态:1在岗 0离职</td></tr>
<tr><td><code>worker_title</code></td><td>VARCHAR(32)</td><td></td><td>职衔:电工/资深班组长/特种高压电工</td></tr>
<tr><td><code>rating</code></td><td>NUMERIC(3,2)</td><td></td><td>服务评分(0-5,推荐算法权重 0.5)</td></tr>
<tr><td><code>cert_info</code></td><td>VARCHAR(128)</td><td></td><td>持证资质</td></tr>
<tr><td><code>cert_verified</code></td><td>BOOLEAN</td><td></td><td>资质是否已核验(推荐算法权重 0.3)</td></tr>
<tr><td><code>completed_count</code></td><td>INTEGER</td><td></td><td>历史累计勘察场次(推荐算法权重 0.2)</td></tr>
<tr><td><code>last_login_at</code></td><td>TIMESTAMPTZ</td><td></td><td>最后登录时间</td></tr> <tr><td><code>last_login_at</code></td><td>TIMESTAMPTZ</td><td></td><td>最后登录时间</td></tr>
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr> <tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
<tr><td><code>updated_at</code></td><td>TIMESTAMPTZ</td><td></td><td>更新时间</td></tr> <tr><td><code>updated_at</code></td><td>TIMESTAMPTZ</td><td></td><td>更新时间</td></tr>
@@ -1098,7 +1160,7 @@ flowchart LR
</section> </section>
<footer> <footer>
<p>鑫潼智电系统 PostgreSQL 数据库设计文档 · 基于《需求分析报告 V1.0》 · 37 张表 · 3 个分区管理函数 · 1 个物化视图 · pg_cron 自动调度 · 2026-08</p> <p>鑫潼智电系统 PostgreSQL 数据库设计文档 · 基于《需求分析报告 V1.0》 · 39 张表 · 3 个分区管理函数 · 1 个物化视图 · pg_cron 自动调度 · 2026-08</p>
</footer> </footer>
</div> </div>