feat: 新增现场勘察派单全流程业务能力
1. 新增共享电工管理模块:包含实体、DTO、控制器、服务以及前后端API 2. 重构勘察工单实体与查询逻辑,完善工单全状态流程与字段 3. 新增勘察SOP模板与工单回执业务体系 4. 更新数据库字典与初始化脚本,补充业务数据 5. 新增勘察派单增删改查、指派、复核、催办等全流程接口 6. 修复用户服务角色关联SQL生成问题 7. 新增勘察派单前后端完整交互链路
This commit is contained in:
1 parent
1c83e09fa1
commit
6b00f61550
21 files changed
+4733
-382
No files matched your search
+237
-18
@@ -1,15 +1,143 @@
|
||||
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 {
|
||||
id: number;
|
||||
surveyNo: string;
|
||||
customerId: number;
|
||||
customerName?: string;
|
||||
customer?: SurveyOrderCustomer;
|
||||
surveyor?: string;
|
||||
workerId?: number;
|
||||
workerName?: string;
|
||||
worker?: SurveyOrderWorker;
|
||||
workerTitle?: string;
|
||||
workerRating?: number;
|
||||
workerCertInfo?: 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;
|
||||
status?: number;
|
||||
createdAt: string;
|
||||
status: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// 详情附带:SOP 回执
|
||||
sopResults?: SurveyOrderSopResult[];
|
||||
}
|
||||
|
||||
export interface SurveyOrderQuery {
|
||||
@@ -18,6 +146,12 @@ export interface SurveyOrderQuery {
|
||||
keyword?: string;
|
||||
customerId?: number;
|
||||
status?: number;
|
||||
priority?: number;
|
||||
workerId?: number;
|
||||
salesmanId?: number;
|
||||
planStartDate?: string;
|
||||
planEndDate?: string;
|
||||
includeDraft?: number;
|
||||
}
|
||||
|
||||
export interface SurveyOrderListResult {
|
||||
@@ -27,37 +161,122 @@ export interface SurveyOrderListResult {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SurveyOrderCreateParams {
|
||||
customerId: number;
|
||||
surveyor?: string;
|
||||
workerId?: number;
|
||||
surveyTime?: string;
|
||||
remark?: string;
|
||||
status?: number;
|
||||
export interface SurveyOrderStats {
|
||||
total: number;
|
||||
monthGrowth: number;
|
||||
pendingAssign: number;
|
||||
pendingWarn: number;
|
||||
surveying: number;
|
||||
onTimeRate: number;
|
||||
pendingReview: number;
|
||||
avgReviewHours: number;
|
||||
archived: number;
|
||||
planRate: number;
|
||||
priorityCounts: {
|
||||
urgent: number;
|
||||
expedited: number;
|
||||
regular: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SurveyOrderUpdateParams {
|
||||
customerId?: number;
|
||||
surveyor?: string;
|
||||
export interface SurveyQuote {
|
||||
baseFee: number;
|
||||
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;
|
||||
surveyTime?: string;
|
||||
salesmanId?: number;
|
||||
estimatedFee?: number;
|
||||
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 = {
|
||||
getList: (params: SurveyOrderQuery) =>
|
||||
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) =>
|
||||
request.get<SurveyOrderInfo>(`/survey-orders/${id}`),
|
||||
|
||||
create: (data: SurveyOrderCreateParams) =>
|
||||
request.post('/survey-orders', data),
|
||||
request.post<SurveyOrderInfo>('/survey-orders', data),
|
||||
|
||||
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}`)
|
||||
};
|
||||
@@ -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 })
|
||||
};
|
||||
@@ -97,6 +97,30 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/project/survey/index.vue"),
|
||||
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",
|
||||
name: "ProjectPlan",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+148
-29
@@ -3,7 +3,7 @@
|
||||
-- 基于《鑫潼智电系统需求分析报告 V1.0》+ 数据库设计文档
|
||||
-- 数据库: PostgreSQL 15+
|
||||
-- 字符集: UTF8
|
||||
-- 表数量: 37 张(含 1 张按月分区表)
|
||||
-- 表数量: 39 张(含 1 张按月分区表)
|
||||
--
|
||||
-- 用法:
|
||||
-- 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);
|
||||
|
||||
-- ============================================================
|
||||
-- 六、派单域(4 张表)
|
||||
-- 六、派单域(6 张表)
|
||||
-- ============================================================
|
||||
|
||||
-- 21. 勘察派单表
|
||||
DROP TABLE IF EXISTS survey_order CASCADE;
|
||||
CREATE TABLE survey_order (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
survey_no VARCHAR(64) NOT NULL UNIQUE,
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||
surveyor VARCHAR(64),
|
||||
worker_id BIGINT,
|
||||
survey_time TIMESTAMPTZ,
|
||||
remark TEXT,
|
||||
status SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
survey_no VARCHAR(64) NOT NULL UNIQUE,
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||
surveyor VARCHAR(64),
|
||||
worker_id BIGINT,
|
||||
survey_time TIMESTAMPTZ,
|
||||
priority SMALLINT DEFAULT 3,
|
||||
task_title VARCHAR(128),
|
||||
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 COLUMN survey_order.id IS '自增主键';
|
||||
COMMENT ON COLUMN survey_order.survey_no IS '勘察单号(唯一)';
|
||||
COMMENT ON COLUMN survey_order.customer_id IS '客户 ID(→ customer)';
|
||||
COMMENT ON COLUMN survey_order.surveyor IS '勘察人(共享电工)';
|
||||
COMMENT ON COLUMN survey_order.worker_id IS '共享电工 ID';
|
||||
COMMENT ON COLUMN survey_order.survey_time IS '勘察时间';
|
||||
COMMENT ON COLUMN survey_order.remark IS '备注';
|
||||
COMMENT ON COLUMN survey_order.status IS '状态:0已派发 1已接单 2已完成';
|
||||
COMMENT ON COLUMN survey_order.created_at IS '创建时间';
|
||||
CREATE INDEX idx_survey_customer ON survey_order (customer_id);
|
||||
CREATE INDEX idx_survey_worker ON survey_order (worker_id);
|
||||
COMMENT ON COLUMN survey_order.id IS '自增主键';
|
||||
COMMENT ON COLUMN survey_order.survey_no IS '勘察单号(唯一)';
|
||||
COMMENT ON COLUMN survey_order.customer_id IS '客户 ID(→ customer)';
|
||||
COMMENT ON COLUMN survey_order.surveyor IS '勘察人(共享电工)';
|
||||
COMMENT ON COLUMN survey_order.worker_id IS '共享电工 ID';
|
||||
COMMENT ON COLUMN survey_order.survey_time IS '勘察时间';
|
||||
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.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. 安装派单表
|
||||
DROP TABLE IF EXISTS install_order CASCADE;
|
||||
@@ -1033,18 +1142,28 @@ CREATE TABLE worker (
|
||||
worker_name VARCHAR(64) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
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,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE worker IS '共享电工信息表';
|
||||
COMMENT ON COLUMN worker.id IS '自增主键';
|
||||
COMMENT ON COLUMN worker.worker_name IS '电工姓名';
|
||||
COMMENT ON COLUMN worker.phone IS '手机号';
|
||||
COMMENT ON COLUMN worker.id_card IS '身份证号';
|
||||
COMMENT ON COLUMN worker.status IS '状态:1在岗 0离职';
|
||||
COMMENT ON COLUMN worker.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN worker.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN worker.id IS '自增主键';
|
||||
COMMENT ON COLUMN worker.worker_name IS '电工姓名';
|
||||
COMMENT ON COLUMN worker.phone IS '手机号';
|
||||
COMMENT ON COLUMN worker.id_card IS '身份证号';
|
||||
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 '历史累计勘察场次';
|
||||
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 TRIGGER trg_worker_updated_at BEFORE UPDATE ON worker
|
||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||
|
||||
@@ -149,9 +149,11 @@ INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES
|
||||
('device_type', '网关', '网关', 1),
|
||||
('device_type', '探测器', '探测器', 2),
|
||||
('device_type', '采集器', '采集器', 3),
|
||||
('dispatch_status', '已派发', '0', 1),
|
||||
('dispatch_status', '已接单', '1', 2),
|
||||
('dispatch_status', '已完成', '2', 3),
|
||||
('dispatch_status', '待指派', '0', 1),
|
||||
('dispatch_status', '现场勘察中', '1', 2),
|
||||
('dispatch_status', '待技术复核', '2', 3),
|
||||
('dispatch_status', '已归档', '3', 4),
|
||||
('dispatch_status', '已取消', '5', 5),
|
||||
('pay_cycle', '按月支付', '按月', 1),
|
||||
('pay_cycle', '按年支付', '按年', 2),
|
||||
('dispatch_type', '勘察派单', 'survey', 1),
|
||||
@@ -203,5 +205,5 @@ DELETE FROM sys_menu WHERE id IN (25, 26);
|
||||
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;
|
||||
|
||||
@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); }
|
||||
@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/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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@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 {
|
||||
@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() @IsString() surveyTime?: string;
|
||||
@IsOptional() @IsInt() salesmanId?: number;
|
||||
@IsOptional() @IsNumber() estimatedFee?: number;
|
||||
@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 {
|
||||
@IsOptional() @IsInt() customerId?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) surveyor?: string;
|
||||
@IsOptional() @IsInt() workerId?: number;
|
||||
@IsOptional() @IsString() surveyTime?: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(2) status?: number;
|
||||
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
||||
@IsOptional() @IsInt() declare customerId: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare taskTitle?: string;
|
||||
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) declare siteLocation?: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3) declare priority?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) declare loopSpec?: string;
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 Worker from "./worker.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "survey_order",
|
||||
comment: "客户勘察派单信息表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: false,
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{ 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 {
|
||||
@@ -26,21 +31,103 @@ export default class SurveyOrder extends Model {
|
||||
@Column({ type: DataType.STRING(64), comment: "勘察人(共享电工)" })
|
||||
surveyor!: string;
|
||||
|
||||
@ForeignKey(() => Worker)
|
||||
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" })
|
||||
workerId!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "survey_time", comment: "勘察时间" })
|
||||
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: "备注" })
|
||||
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;
|
||||
|
||||
@BelongsTo(() => Customer)
|
||||
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
|
||||
customer!: Customer;
|
||||
|
||||
@BelongsTo(() => Worker, { foreignKey: "workerId", as: "worker" })
|
||||
worker!: Worker;
|
||||
|
||||
@CreatedAt
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import { Op, UniqueConstraintError, literal } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import SurveyOrder from "../entity/survey_order.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 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()
|
||||
export class SurveyOrderService {
|
||||
@InjectRepository(SurveyOrder)
|
||||
@@ -16,6 +32,21 @@ export class SurveyOrderService {
|
||||
@InjectRepository(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)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
|
||||
@@ -33,6 +64,21 @@ export class SurveyOrderService {
|
||||
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> {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
|
||||
@@ -50,72 +96,458 @@ export class SurveyOrderService {
|
||||
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> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
const userId = ctx.state.user.userId;
|
||||
const where: any = {};
|
||||
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [{ surveyor: { [Op.like]: `%${query.keyword}%` } }, { remark: { [Op.like]: `%${query.keyword}%` } }];
|
||||
}
|
||||
if (query.customerId !== undefined) where.customerId = query.customerId;
|
||||
if (query.status !== undefined) where.status = query.status;
|
||||
const where = await this.scopeWhere(this.buildWhere(query), ctx);
|
||||
const { count, rows } = await this.surveyModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["created_at", "DESC"]],
|
||||
distinct: true
|
||||
});
|
||||
|
||||
if (!(await this.isAdmin(userId))) {
|
||||
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
|
||||
const ids = myCustomers.map((c) => c.id);
|
||||
if (ids.length === 0) return { code: 200, message: "获取勘察派单列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
if (where.customerId !== undefined) {
|
||||
if (!ids.includes(where.customerId)) return { code: 200, message: "获取勘察派单列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
} else {
|
||||
where.customerId = { [Op.in]: ids };
|
||||
const list = await this.decorate(rows.map((r) => this.toVO(r)));
|
||||
return { code: 200, message: "获取勘察派单列表成功", data: { list, total: count, page, pageSize } };
|
||||
}
|
||||
|
||||
// KPI 统计卡(仅按当前用户可见范围统计,默认排除草稿)
|
||||
async stats(ctx: Context): Promise<any> {
|
||||
const where = await this.scopeWhere({ isDraft: 0 }, ctx);
|
||||
|
||||
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"]] });
|
||||
return { code: 200, message: "获取勘察派单列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } };
|
||||
// SOP 模板检查项
|
||||
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> {
|
||||
const record = await this.surveyModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该勘察派单", data: null };
|
||||
return { code: 200, message: "获取勘察派单详情成功", data: record.toJSON() };
|
||||
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权限访问该勘察派单", data: null };
|
||||
|
||||
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> {
|
||||
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 {
|
||||
data.surveyNo = await this.generateSurveyNo();
|
||||
const created = await this.surveyModel.create(data);
|
||||
return { code: 200, message: "创建勘察派单成功", data: created.toJSON() };
|
||||
created = await this.surveyModel.create(payload);
|
||||
} catch (err: any) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
data.surveyNo = await this.generateSurveyNo();
|
||||
const created = await this.surveyModel.create(data);
|
||||
return { code: 200, message: "创建勘察派单成功", data: created.toJSON() };
|
||||
payload.surveyNo = await this.generateSurveyNo();
|
||||
created = await this.surveyModel.create(payload);
|
||||
} 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> {
|
||||
const record = await this.surveyModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||
if (!(await this.isAdmin(ctx.state.user.userId))) {
|
||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限修改该勘察派单", data: null };
|
||||
delete data.customerId;
|
||||
|
||||
const userId = ctx.state.user.userId;
|
||||
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;
|
||||
await record.update(data);
|
||||
return { code: 200, message: "更新勘察派单成功", data: record.toJSON() };
|
||||
delete data.templateCode;
|
||||
|
||||
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> {
|
||||
const record = await this.surveyModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
|
||||
// 级联清理回执后删除工单
|
||||
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除勘察派单成功", data: null };
|
||||
}
|
||||
|
||||
@@ -34,17 +34,15 @@ export class UserService {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
// 角色过滤条件必须下移到 SysUserRole 自身的 roleId 上:
|
||||
// 若改在内层 SysRole 上同时设置 where + required,Sequelize 在 findAndCountAll(subQuery+distinct)
|
||||
// 下会生成缺少中间表 sys_user_role 的非法 SQL(42P01 丢失FROM子句项)
|
||||
const includeOptions: any = [
|
||||
{
|
||||
model: SysUserRole,
|
||||
required: !!query.roleId,
|
||||
include: [
|
||||
{
|
||||
model: SysRole,
|
||||
where: query.roleId ? { id: query.roleId } : {},
|
||||
required: !!query.roleId
|
||||
}
|
||||
]
|
||||
...(query.roleId ? { where: { roleId: query.roleId } } : {}),
|
||||
include: [{ model: SysRole, required: false }]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -100,7 +100,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
|
||||
<p class="sub">基于《鑫潼智电系统需求分析报告 V1.0》数据说明,设计 37 张数据表 + 3 个分区管理函数 + 1 个物化视图,覆盖客户、项目、设备、派单、报警、报修、维保、报酬、用电采集等全部业务域。采用逻辑隔离 + 时序分区方案。</p>
|
||||
<div class="meta">
|
||||
<span>数据库:<b>PostgreSQL 15+</b></span>
|
||||
<span>表数量:<b>37 张</b></span>
|
||||
<span>表数量:<b>39 张</b></span>
|
||||
<span>隔离方案:<b>customer_id 逻辑隔离</b></span>
|
||||
<span>时序分区:<b>electricity_data 按月分区</b></span>
|
||||
<span>自动分区:<b>3 个管理函数</b></span>
|
||||
@@ -126,7 +126,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
|
||||
|
||||
<!-- 02 业务域划分 -->
|
||||
<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="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-body">
|
||||
<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">acceptance</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_income ||--o{ worker_income_detail : "收入明细"
|
||||
dispatch_price ||--o{ worker_income_detail : "价格参考"
|
||||
|
||||
survey_order ||--o{ survey_sop_result : "工单回执"
|
||||
survey_sop_item ||--o{ survey_sop_result : "模板检查项"
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
@@ -325,7 +330,7 @@ erDiagram
|
||||
|
||||
<!-- 05 全部表字段说明 -->
|
||||
<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 系统管理域 -->
|
||||
<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>
|
||||
|
||||
<!-- 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="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>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>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>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>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>
|
||||
|
||||
<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>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>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>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>
|
||||
@@ -1098,7 +1160,7 @@ flowchart LR
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user