diff --git a/client/src/api/surveyOrder.ts b/client/src/api/surveyOrder.ts index 316826c..388975b 100644 --- a/client/src/api/surveyOrder.ts +++ b/client/src/api/surveyOrder.ts @@ -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; + +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('/survey-orders', { params }), + // KPI 统计卡 + getStats: () => request.get('/survey-orders/stats'), + + // SOP 规范模板检查项 + getSopItems: (templateCode: string = SOP_TEMPLATE_CODE) => + request.get('/survey-orders/sop-items', { params: { templateCode } }), + + // 报酬测算:基础出场工时费 + 回路数 × 单价 + getQuote: (loopCount: number) => + request.get('/survey-orders/quote', { params: { loopCount } }), + getDetail: (id: number) => request.get(`/survey-orders/${id}`), create: (data: SurveyOrderCreateParams) => - request.post('/survey-orders', data), + request.post('/survey-orders', data), update: (id: number, data: SurveyOrderUpdateParams) => - request.put(`/survey-orders/${id}`, data), + request.put(`/survey-orders/${id}`, data), - delete: (id: number) => - request.delete(`/survey-orders/${id}`), -}; \ No newline at end of file + // 指派 / 改派共享电工 + assign: (id: number, data: SurveyOrderAssignParams) => + request.put(`/survey-orders/${id}/assign`, data), + + // 批量指定电工 + batchAssign: (data: SurveyOrderBatchAssignParams) => + request.post('/survey-orders/batch-assign', data), + + // 技术复核:通过归档 / 驳回退回现场 + review: (id: number, data: SurveyOrderReviewParams) => + request.put(`/survey-orders/${id}/review`, data), + + // 催办 + urge: (id: number) => request.post(`/survey-orders/${id}/urge`), + + // 取消工单 + cancel: (id: number, data: SurveyOrderCancelParams) => + request.put(`/survey-orders/${id}/cancel`, data), + + delete: (id: number) => request.delete(`/survey-orders/${id}`) +}; diff --git a/client/src/api/worker.ts b/client/src/api/worker.ts new file mode 100644 index 0000000..7918b22 --- /dev/null +++ b/client/src/api/worker.ts @@ -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('/workers', { params }), + + // 下拉选项(仅在岗电工,按评分倒序) + getOptions: () => request.get('/workers/options'), + + // 智能推荐电工:评分 50% + 资质 30% + 经验 20% 加权排序 + getRecommend: (params?: WorkerRecommendQuery) => + request.get('/workers/recommend', { params }) +}; diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 57b8631..e64ef4d 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -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", diff --git a/client/src/views/project/survey/form.vue b/client/src/views/project/survey/form.vue new file mode 100644 index 0000000..4b1d3ea --- /dev/null +++ b/client/src/views/project/survey/form.vue @@ -0,0 +1,1443 @@ + + + + + diff --git a/client/src/views/project/survey/index.vue b/client/src/views/project/survey/index.vue index 68e673b..c259aa2 100644 --- a/client/src/views/project/survey/index.vue +++ b/client/src/views/project/survey/index.vue @@ -1,236 +1,721 @@ \ No newline at end of file + +:deep(.ant-btn-primary) { + background: #0054cd; + border-color: #0054cd; +} + +:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) { + background: #0947ab; + border-color: #0947ab; +} + +:deep(.ant-table-selection-column) { + padding-left: 12px !important; +} + diff --git a/database/db_init.sql b/database/db_init.sql index 4e22758..919d33d 100644 --- a/database/db_init.sql +++ b/database/db_init.sql @@ -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(); diff --git a/database/db_seed.sql b/database/db_seed.sql index c1a19fa..338fe36 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -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 管理员 + 绑定关系) -- ============================================================ diff --git a/database/migrations/20260914_survey_dispatch_ext.sql b/database/migrations/20260914_survey_dispatch_ext.sql new file mode 100644 index 0000000..8f48b7f --- /dev/null +++ b/database/migrations/20260914_survey_dispatch_ext.sql @@ -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; diff --git a/server/src/controller/survey_order.controller.ts b/server/src/controller/survey_order.controller.ts index acb4a42..39b5bff 100644 --- a/server/src/controller/survey_order.controller.ts +++ b/server/src/controller/survey_order.controller.ts @@ -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); } -} \ No newline at end of file +} diff --git a/server/src/controller/worker.controller.ts b/server/src/controller/worker.controller.ts new file mode 100644 index 0000000..a4ad8ec --- /dev/null +++ b/server/src/controller/worker.controller.ts @@ -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); } +} diff --git a/server/src/dto/survey_order.dto.ts b/server/src/dto/survey_order.dto.ts index 431a61f..37bf86b 100644 --- a/server/src/dto/survey_order.dto.ts +++ b/server/src/dto/survey_order.dto.ts @@ -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; -} \ No newline at end of file +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; +} diff --git a/server/src/dto/worker.dto.ts b/server/src/dto/worker.dto.ts new file mode 100644 index 0000000..b71ab9e --- /dev/null +++ b/server/src/dto/worker.dto.ts @@ -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; +} diff --git a/server/src/entity/dispatch_price.entity.ts b/server/src/entity/dispatch_price.entity.ts new file mode 100644 index 0000000..f137104 --- /dev/null +++ b/server/src/entity/dispatch_price.entity.ts @@ -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; +} diff --git a/server/src/entity/survey_order.entity.ts b/server/src/entity/survey_order.entity.ts index 1dd1db0..d8918df 100644 --- a/server/src/entity/survey_order.entity.ts +++ b/server/src/entity/survey_order.entity.ts @@ -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; -} \ No newline at end of file + + @UpdatedAt + updated_at!: Date; +} diff --git a/server/src/entity/survey_sop_item.entity.ts b/server/src/entity/survey_sop_item.entity.ts new file mode 100644 index 0000000..47c848c --- /dev/null +++ b/server/src/entity/survey_sop_item.entity.ts @@ -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; +} diff --git a/server/src/entity/survey_sop_result.entity.ts b/server/src/entity/survey_sop_result.entity.ts new file mode 100644 index 0000000..85d6bfa --- /dev/null +++ b/server/src/entity/survey_sop_result.entity.ts @@ -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; +} diff --git a/server/src/entity/worker.entity.ts b/server/src/entity/worker.entity.ts new file mode 100644 index 0000000..a6ae015 --- /dev/null +++ b/server/src/entity/worker.entity.ts @@ -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; +} diff --git a/server/src/service/survey_order.service.ts b/server/src/service/survey_order.service.ts index b1ff2d7..1bc06d9 100644 --- a/server/src/service/survey_order.service.ts +++ b/server/src/service/survey_order.service.ts @@ -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; + @InjectRepository(Worker) + workerModel!: Repository; + + @InjectRepository(SurveySopItem) + sopItemModel!: Repository; + + @InjectRepository(SurveySopResult) + sopResultModel!: Repository; + + @InjectRepository(DispatchPrice) + priceModel!: Repository; + + @InjectRepository(SysUser) + userModel!: Repository; + @InjectRepository(SysUserRole) userRoleModel!: Repository; @@ -33,6 +64,21 @@ export class SurveyOrderService { return !!customer && customer.salesmanId === userId; } + // 工单可见性:管理员 / 责任业务员本人 / 客户归属业务员 + private async isOrderVisible(userId: number, order: SurveyOrder): Promise { + 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 { + 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 { const now = new Date(); const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); @@ -50,73 +96,459 @@ 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 { + 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 { + 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 { + 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 { 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 { + 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 { + 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 { + const data = await this.calcFee(Number(loopCount) || 0); + return { code: 200, message: "获取勘察报价成功", data }; } async detail(id: number, ctx: Context): Promise { 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 { + 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 { - 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 { 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 { + 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 { + 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(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 { + 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 { + 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 { + 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 { 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 }; } -} \ No newline at end of file +} diff --git a/server/src/service/user.service.ts b/server/src/service/user.service.ts index 104f4b7..691c756 100644 --- a/server/src/service/user.service.ts +++ b/server/src/service/user.service.ts @@ -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 }] } ]; diff --git a/server/src/service/worker.service.ts b/server/src/service/worker.service.ts new file mode 100644 index 0000000..840316d --- /dev/null +++ b/server/src/service/worker.service.ts @@ -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; + + // 列表:姓名/手机号/资质模糊检索 + 状态过滤 + 分页 + async list(query: any, ctx: Context): Promise { + 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 { + 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 { + 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 }; + } +} diff --git a/系统设计/db_design.html b/系统设计/db_design.html index fc1d11b..dcc536c 100644 --- a/系统设计/db_design.html +++ b/系统设计/db_design.html @@ -100,7 +100,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}

基于《鑫潼智电系统需求分析报告 V1.0》数据说明,设计 37 张数据表 + 3 个分区管理函数 + 1 个物化视图,覆盖客户、项目、设备、派单、报警、报修、维保、报酬、用电采集等全部业务域。采用逻辑隔离 + 时序分区方案。

数据库:PostgreSQL 15+ - 表数量:37 张 + 表数量:39 张 隔离方案:customer_id 逻辑隔离 时序分区:electricity_data 按月分区 自动分区:3 个管理函数 @@ -126,7 +126,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
-
02

业务域划分

14 个业务域 · 37 张表
+
02

业务域划分

14 个业务域 · 39 张表
@@ -183,6 +183,8 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
D6

派单域

survey_order勘察派单
+
survey_sop_item勘察SOP模板检查项
+
survey_sop_result勘察SOP工单回执
install_order安装派单
acceptance项目验收
visit_record客户回访
@@ -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 : "模板检查项"
@@ -325,7 +330,7 @@ erDiagram
-
05

全部数据表字段说明

37 张表 · 按业务域分组
+
05

全部数据表字段说明

39 张表 · 按业务域分组
D1

系统管理域

7 张表 · 用户/角色/菜单/字典/日志
@@ -593,7 +598,7 @@ erDiagram
-
D6

派单域

4 张表 · 勘察/安装/验收/回访
+
D6

派单域

6 张表 · 勘察/勘察SOP/安装/验收/回访
survey_order— 客户勘察派单信息表
@@ -603,9 +608,61 @@ erDiagram + + + + + + + + + + + + + + + + + + + + + + + + + - + + +
字段类型键说明
surveyorVARCHAR(64)勘察人(共享电工)
worker_idBIGINT共享电工 ID
survey_timeTIMESTAMPTZ勘察时间
prioritySMALLINTIDX优先级:1特急P1 2加急P2 3常规P3
task_titleVARCHAR(128)勘察任务标题
site_locationVARCHAR(255)具体勘察物理区域
sourceVARCHAR(32)工单来源:ledger台账派工 visit拜访转勘察 customer客户发起
loop_specVARCHAR(128)主进线规约与断路器容量
loop_countINTEGER预估分支测绘回路数
progress_doneINTEGER已完成测绘回路数
progress_totalINTEGER应测绘回路总数
drawing_countINTEGER待技术复核图纸数
salesman_idBIGINTIDX责任业务员 ID(→ sys_user,数据隔离)
tech_contactVARCHAR(128)现场技术联系人
plan_startTIMESTAMPTZIDX计划到场窗口开始
plan_endTIMESTAMPTZ计划到场窗口结束
expect_minutesINTEGER要求接单后到场时限(分钟)
expect_arrival_atTIMESTAMPTZ期望到场时限(指派时按 expect_minutes 推算)
sla_levelVARCHAR(32)履约质保要求
urge_countINTEGER催办次数
last_urged_atTIMESTAMPTZ最近催办时间
is_draftSMALLINT是否草稿:0正式工单 1草稿
estimated_feeNUMERIC(10,2)预估劳务费(基础出场费+回路补贴)
actual_feeNUMERIC(10,2)实际结算劳务费
photosVARCHAR(1024)现场取证照片对象key(JSON数组字符串)
review_remarkTEXT技术复核意见
reviewed_atTIMESTAMPTZ技术复核时间
cancel_reasonVARCHAR(255)取消原因
remarkTEXT备注
statusSMALLINT状态:0已派发 1已接单 2已完成
statusSMALLINTIDX状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
created_atTIMESTAMPTZ创建时间
updated_atTIMESTAMPTZ更新时间(触发器自动维护)
+ +
survey_sop_item— 勘察 SOP 模板检查项表
+
+ + + + + + + + + +
字段类型键说明
idBIGSERIALPK自增主键
template_codeVARCHAR(64)UNIQUESOP模板编码:standard_dist 标准配电改造模板(与 sort_order 联合唯一)
sort_orderINTEGER检查项序号
item_nameVARCHAR(128)检查项名称
item_descVARCHAR(255)检查项说明
receipt_typeVARCHAR(16)回执形式:photo现场照片 form清单表单 meter测仪截图
receipt_requireVARCHAR(64)回执指标要求,如「照片 ≥ 2 张」
requiredBOOLEAN是否必填
created_atTIMESTAMPTZ创建时间
+ +
survey_sop_result— 勘察 SOP 工单回执表
+
+ + + + + + + + +
字段类型键说明
idBIGSERIALPK自增主键
survey_order_idBIGINTFK勘察工单 ID(→ survey_order,级联删除)
sop_item_idBIGINTFKSOP 检查项 ID(→ survey_sop_item)
checkedBOOLEAN是否已完成回执
receipt_valueVARCHAR(1024)回执内容(照片对象key数组/表单值/截图key)
remarkVARCHAR(255)回执备注
checked_atTIMESTAMPTZ回执时间
created_atTIMESTAMPTZ创建时间
updated_atTIMESTAMPTZ更新时间
install_order— 项目安装任务派单信息表
@@ -810,6 +867,11 @@ erDiagram password_hashVARCHAR(255)密码哈希(BCrypt) id_cardVARCHAR(18)身份证号 statusSMALLINT状态:1在岗 0离职 + worker_titleVARCHAR(32)职衔:电工/资深班组长/特种高压电工 + ratingNUMERIC(3,2)服务评分(0-5,推荐算法权重 0.5) + cert_infoVARCHAR(128)持证资质 + cert_verifiedBOOLEAN资质是否已核验(推荐算法权重 0.3) + completed_countINTEGER历史累计勘察场次(推荐算法权重 0.2) last_login_atTIMESTAMPTZ最后登录时间 created_atTIMESTAMPTZ创建时间 updated_atTIMESTAMPTZ更新时间 @@ -1098,7 +1160,7 @@ flowchart LR