From 219091f864bb691b8a6a00f40cdc6ffeb5594a45 Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Wed, 26 Aug 2026 14:08:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E6=8B=9C=E8=AE=BF=E3=80=81=E5=BB=BA=E7=AD=91=E3=80=81=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E5=8F=8A=E9=A1=B9=E7=9B=AE=E5=85=A8=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交新增了客户拜访、客户建筑、客户房间的完整业务模块,同时补充了项目勘察、项目方案、合同管理、项目验收、回访记录的全项目流程相关功能,包括: 1. 新增对应业务的实体类、DTO、控制器、服务层代码 2. 完善RBAC权限映射配置 3. 添加前端API、路由及页面菜单配置 4. 更新数据库种子数据补充菜单和权限绑定 --- client/src/api/acceptance.ts | 65 +++ client/src/api/contract.ts | 81 +++ client/src/api/customerBuilding.ts | 58 +++ client/src/api/customerRoom.ts | 65 +++ client/src/api/customerVisit.ts | 66 +++ client/src/api/projectPlan.ts | 76 +++ client/src/api/surveyOrder.ts | 63 +++ client/src/api/visitRecord.ts | 64 +++ client/src/router/index.ts | 50 +- client/src/views/customer/building/index.vue | 368 ++++++++++++++ client/src/views/customer/room/index.vue | 432 ++++++++++++++++ client/src/views/customer/visit/index.vue | 423 ++++++++++++++++ client/src/views/project/acceptance/index.vue | 353 +++++++++++++ client/src/views/project/contract/index.vue | 448 ++++++++++++++++ client/src/views/project/plan/index.vue | 477 ++++++++++++++++++ client/src/views/project/survey/index.vue | 380 ++++++++++++++ client/src/views/project/visit/index.vue | 361 +++++++++++++ database/db_seed.sql | 36 +- .../20260826_project_customer_ext.sql | 308 +++++++++++ .../src/controller/acceptance.controller.ts | 15 + server/src/controller/contract.controller.ts | 15 + .../customer_building.controller.ts | 15 + .../controller/customer_room.controller.ts | 15 + .../controller/customer_visit.controller.ts | 15 + .../src/controller/project_plan.controller.ts | 15 + .../src/controller/survey_order.controller.ts | 15 + .../src/controller/visit_record.controller.ts | 15 + server/src/dto/acceptance.dto.ts | 28 + server/src/dto/contract.dto.ts | 40 ++ server/src/dto/customer_building.dto.ts | 24 + server/src/dto/customer_room.dto.ts | 29 ++ server/src/dto/customer_visit.dto.ts | 29 ++ server/src/dto/project_plan.dto.ts | 39 ++ server/src/dto/survey_order.dto.ts | 27 + server/src/dto/visit_record.dto.ts | 28 + server/src/entity/acceptance.entity.ts | 51 ++ server/src/entity/contract.entity.ts | 69 +++ server/src/entity/customer_building.entity.ts | 37 ++ server/src/entity/customer_room.entity.ts | 53 ++ server/src/entity/customer_visit.entity.ts | 49 ++ server/src/entity/project_plan.entity.ts | 47 ++ .../src/entity/project_plan_detail.entity.ts | 48 ++ server/src/entity/survey_order.entity.ts | 46 ++ server/src/entity/visit_record.entity.ts | 46 ++ server/src/middleware/rbac.middleware.ts | 8 + server/src/service/acceptance.service.ts | 121 +++++ server/src/service/contract.service.ts | 151 ++++++ .../src/service/customer_building.service.ts | 98 ++++ server/src/service/customer_room.service.ts | 102 ++++ server/src/service/customer_visit.service.ts | 130 +++++ server/src/service/project_plan.service.ts | 154 ++++++ server/src/service/survey_order.service.ts | 122 +++++ server/src/service/visit_record.service.ts | 126 +++++ 53 files changed, 5990 insertions(+), 6 deletions(-) create mode 100644 client/src/api/acceptance.ts create mode 100644 client/src/api/contract.ts create mode 100644 client/src/api/customerBuilding.ts create mode 100644 client/src/api/customerRoom.ts create mode 100644 client/src/api/customerVisit.ts create mode 100644 client/src/api/projectPlan.ts create mode 100644 client/src/api/surveyOrder.ts create mode 100644 client/src/api/visitRecord.ts create mode 100644 client/src/views/customer/building/index.vue create mode 100644 client/src/views/customer/room/index.vue create mode 100644 client/src/views/customer/visit/index.vue create mode 100644 client/src/views/project/acceptance/index.vue create mode 100644 client/src/views/project/contract/index.vue create mode 100644 client/src/views/project/plan/index.vue create mode 100644 client/src/views/project/survey/index.vue create mode 100644 client/src/views/project/visit/index.vue create mode 100644 database/migrations/20260826_project_customer_ext.sql create mode 100644 server/src/controller/acceptance.controller.ts create mode 100644 server/src/controller/contract.controller.ts create mode 100644 server/src/controller/customer_building.controller.ts create mode 100644 server/src/controller/customer_room.controller.ts create mode 100644 server/src/controller/customer_visit.controller.ts create mode 100644 server/src/controller/project_plan.controller.ts create mode 100644 server/src/controller/survey_order.controller.ts create mode 100644 server/src/controller/visit_record.controller.ts create mode 100644 server/src/dto/acceptance.dto.ts create mode 100644 server/src/dto/contract.dto.ts create mode 100644 server/src/dto/customer_building.dto.ts create mode 100644 server/src/dto/customer_room.dto.ts create mode 100644 server/src/dto/customer_visit.dto.ts create mode 100644 server/src/dto/project_plan.dto.ts create mode 100644 server/src/dto/survey_order.dto.ts create mode 100644 server/src/dto/visit_record.dto.ts create mode 100644 server/src/entity/acceptance.entity.ts create mode 100644 server/src/entity/contract.entity.ts create mode 100644 server/src/entity/customer_building.entity.ts create mode 100644 server/src/entity/customer_room.entity.ts create mode 100644 server/src/entity/customer_visit.entity.ts create mode 100644 server/src/entity/project_plan.entity.ts create mode 100644 server/src/entity/project_plan_detail.entity.ts create mode 100644 server/src/entity/survey_order.entity.ts create mode 100644 server/src/entity/visit_record.entity.ts create mode 100644 server/src/service/acceptance.service.ts create mode 100644 server/src/service/contract.service.ts create mode 100644 server/src/service/customer_building.service.ts create mode 100644 server/src/service/customer_room.service.ts create mode 100644 server/src/service/customer_visit.service.ts create mode 100644 server/src/service/project_plan.service.ts create mode 100644 server/src/service/survey_order.service.ts create mode 100644 server/src/service/visit_record.service.ts diff --git a/client/src/api/acceptance.ts b/client/src/api/acceptance.ts new file mode 100644 index 0000000..212b18a --- /dev/null +++ b/client/src/api/acceptance.ts @@ -0,0 +1,65 @@ +import request from '@/utils/request'; + +export interface AcceptanceInfo { + id: number; + acceptNo: string; + customerId: number; + acceptor?: string; + acceptOpinion?: string; + acceptDate?: string; + installer?: string; + acceptFileUrl?: string; + contractId?: number; + createdAt: string; +} + +export interface AcceptanceQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; +} + +export interface AcceptanceListResult { + list: AcceptanceInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface AcceptanceCreateParams { + customerId: number; + acceptor?: string; + acceptOpinion?: string; + acceptDate?: string; + installer?: string; + acceptFileUrl?: string; + contractId?: number; +} + +export interface AcceptanceUpdateParams { + customerId?: number; + acceptor?: string; + acceptOpinion?: string; + acceptDate?: string; + installer?: string; + acceptFileUrl?: string; + contractId?: number; +} + +export const acceptanceApi = { + getList: (params: AcceptanceQuery) => + request.get('/acceptances', { params }), + + getDetail: (id: number) => + request.get(`/acceptances/${id}`), + + create: (data: AcceptanceCreateParams) => + request.post('/acceptances', data), + + update: (id: number, data: AcceptanceUpdateParams) => + request.put(`/acceptances/${id}`, data), + + delete: (id: number) => + request.delete(`/acceptances/${id}`), +}; \ No newline at end of file diff --git a/client/src/api/contract.ts b/client/src/api/contract.ts new file mode 100644 index 0000000..b9ceac2 --- /dev/null +++ b/client/src/api/contract.ts @@ -0,0 +1,81 @@ +import request from '@/utils/request'; + +export interface ContractInfo { + id: number; + customerId: number; + xtContractNo?: string; + contractAmount?: number; + payCycle?: string; + salesmanId?: number; + signDate?: string; + contractDesc?: string; + startDate?: string; + endDate?: string; + planId?: number; + contractFileUrl?: string; + status?: number; + createdAt: string; +} + +export interface ContractQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + salesmanId?: number; + status?: number; +} + +export interface ContractListResult { + list: ContractInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface ContractCreateParams { + customerId: number; + xtContractNo?: string; + contractAmount?: number; + payCycle?: string; + salesmanId?: number; + signDate?: string; + contractDesc?: string; + startDate?: string; + endDate?: string; + planId?: number; + contractFileUrl?: string; + status?: number; +} + +export interface ContractUpdateParams { + customerId?: number; + xtContractNo?: string; + contractAmount?: number; + payCycle?: string; + salesmanId?: number; + signDate?: string; + contractDesc?: string; + startDate?: string; + endDate?: string; + planId?: number; + contractFileUrl?: string; + status?: number; +} + +export const contractApi = { + getList: (params: ContractQuery) => + request.get('/contracts', { params }), + + getDetail: (id: number) => + request.get(`/contracts/${id}`), + + create: (data: ContractCreateParams) => + request.post('/contracts', data), + + update: (id: number, data: ContractUpdateParams) => + request.put(`/contracts/${id}`, data), + + delete: (id: number) => + request.delete(`/contracts/${id}`), +}; \ No newline at end of file diff --git a/client/src/api/customerBuilding.ts b/client/src/api/customerBuilding.ts new file mode 100644 index 0000000..f89a5b7 --- /dev/null +++ b/client/src/api/customerBuilding.ts @@ -0,0 +1,58 @@ +import request from '@/utils/request'; + +export interface CustomerBuildingInfo { + id: number; + customerId: number; + buildingName: string; + basicInfo?: string; + purpose?: string; + floorCount?: number; + createdAt: string; +} + +export interface CustomerBuildingQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; +} + +export interface CustomerBuildingListResult { + list: CustomerBuildingInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface CustomerBuildingCreateParams { + customerId: number; + buildingName: string; + basicInfo?: string; + purpose?: string; + floorCount?: number; +} + +export interface CustomerBuildingUpdateParams { + customerId?: number; + buildingName?: string; + basicInfo?: string; + purpose?: string; + floorCount?: number; +} + +export const customerBuildingApi = { + getList: (params: CustomerBuildingQuery) => + request.get('/customer-buildings', { params }), + + getDetail: (id: number) => + request.get(`/customer-buildings/${id}`), + + create: (data: CustomerBuildingCreateParams) => + request.post('/customer-buildings', data), + + update: (id: number, data: CustomerBuildingUpdateParams) => + request.put(`/customer-buildings/${id}`, data), + + delete: (id: number) => + request.delete(`/customer-buildings/${id}`), +}; \ No newline at end of file diff --git a/client/src/api/customerRoom.ts b/client/src/api/customerRoom.ts new file mode 100644 index 0000000..5037dbc --- /dev/null +++ b/client/src/api/customerRoom.ts @@ -0,0 +1,65 @@ +import request from '@/utils/request'; + +export interface CustomerRoomInfo { + id: number; + roomCode: string; + customerId: number; + roomName: string; + buildingId: number; + floorNo?: string; + roomPurpose?: string; + deptId?: number; + createdAt: string; +} + +export interface CustomerRoomQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + buildingId?: number; +} + +export interface CustomerRoomListResult { + list: CustomerRoomInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface CustomerRoomCreateParams { + roomCode: string; + customerId: number; + roomName: string; + buildingId: number; + floorNo?: string; + roomPurpose?: string; + deptId?: number; +} + +export interface CustomerRoomUpdateParams { + roomCode?: string; + customerId?: number; + roomName?: string; + buildingId?: number; + floorNo?: string; + roomPurpose?: string; + deptId?: number; +} + +export const customerRoomApi = { + getList: (params: CustomerRoomQuery) => + request.get('/customer-rooms', { params }), + + getDetail: (id: number) => + request.get(`/customer-rooms/${id}`), + + create: (data: CustomerRoomCreateParams) => + request.post('/customer-rooms', data), + + update: (id: number, data: CustomerRoomUpdateParams) => + request.put(`/customer-rooms/${id}`, data), + + delete: (id: number) => + request.delete(`/customer-rooms/${id}`), +}; \ No newline at end of file diff --git a/client/src/api/customerVisit.ts b/client/src/api/customerVisit.ts new file mode 100644 index 0000000..0e52fa2 --- /dev/null +++ b/client/src/api/customerVisit.ts @@ -0,0 +1,66 @@ +import request from '@/utils/request'; + +export interface CustomerVisitInfo { + id: number; + visitNo: string; + customerId: number; + visitPerson: string; + visitPhone?: string; + visitContent?: string; + visitTime?: string; + salesmanId?: number; + willPlan?: number; + createdAt: string; +} + +export interface CustomerVisitQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + salesmanId?: number; +} + +export interface CustomerVisitListResult { + list: CustomerVisitInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface CustomerVisitCreateParams { + customerId: number; + visitPerson: string; + visitPhone?: string; + visitContent?: string; + visitTime?: string; + salesmanId?: number; + willPlan?: number; +} + +export interface CustomerVisitUpdateParams { + customerId?: number; + visitPerson?: string; + visitPhone?: string; + visitContent?: string; + visitTime?: string; + salesmanId?: number; + willPlan?: number; +} + +export const customerVisitApi = { + getList: (params: CustomerVisitQuery) => + request.get('/customer-visits', { params }), + + getDetail: (id: number) => + request.get(`/customer-visits/${id}`), + + create: (data: CustomerVisitCreateParams) => + request.post('/customer-visits', data), + + update: (id: number, data: CustomerVisitUpdateParams) => + request.put(`/customer-visits/${id}`, data), + + delete: (id: number) => + request.delete(`/customer-visits/${id}`), +}; \ No newline at end of file diff --git a/client/src/api/projectPlan.ts b/client/src/api/projectPlan.ts new file mode 100644 index 0000000..6d11002 --- /dev/null +++ b/client/src/api/projectPlan.ts @@ -0,0 +1,76 @@ +import request from '@/utils/request'; + +export interface PlanDetail { + id?: number; + equipName?: string; + equipId?: number; + smartDeviceName?: string; + smartDeviceId?: number; + quantity?: number; + remark?: string; +} + +export interface ProjectPlanInfo { + id: number; + planNo: string; + customerId: number; + registrar?: string; + registerTime?: string; + planDesc?: string; + planFileUrl?: string; + status?: number; + createdAt: string; + details?: PlanDetail[]; +} + +export interface ProjectPlanQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + status?: number; +} + +export interface ProjectPlanListResult { + list: ProjectPlanInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface ProjectPlanCreateParams { + customerId: number; + registrar?: string; + registerTime?: string; + planDesc?: string; + planFileUrl?: string; + status?: number; + details?: PlanDetail[]; +} + +export interface ProjectPlanUpdateParams { + customerId?: number; + registrar?: string; + registerTime?: string; + planDesc?: string; + planFileUrl?: string; + status?: number; + details?: PlanDetail[]; +} + +export const projectPlanApi = { + getList: (params: ProjectPlanQuery) => + request.get('/project-plans', { params }), + + getDetail: (id: number) => + request.get(`/project-plans/${id}`), + + create: (data: ProjectPlanCreateParams) => + request.post('/project-plans', data), + + update: (id: number, data: ProjectPlanUpdateParams) => + request.put(`/project-plans/${id}`, data), + + delete: (id: number) => + request.delete(`/project-plans/${id}`), +}; \ No newline at end of file diff --git a/client/src/api/surveyOrder.ts b/client/src/api/surveyOrder.ts new file mode 100644 index 0000000..316826c --- /dev/null +++ b/client/src/api/surveyOrder.ts @@ -0,0 +1,63 @@ +import request from '@/utils/request'; + +export interface SurveyOrderInfo { + id: number; + surveyNo: string; + customerId: number; + surveyor?: string; + workerId?: number; + surveyTime?: string; + remark?: string; + status?: number; + createdAt: string; +} + +export interface SurveyOrderQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + status?: number; +} + +export interface SurveyOrderListResult { + list: SurveyOrderInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface SurveyOrderCreateParams { + customerId: number; + surveyor?: string; + workerId?: number; + surveyTime?: string; + remark?: string; + status?: number; +} + +export interface SurveyOrderUpdateParams { + customerId?: number; + surveyor?: string; + workerId?: number; + surveyTime?: string; + remark?: string; + status?: number; +} + +export const surveyOrderApi = { + getList: (params: SurveyOrderQuery) => + request.get('/survey-orders', { params }), + + getDetail: (id: number) => + request.get(`/survey-orders/${id}`), + + create: (data: SurveyOrderCreateParams) => + request.post('/survey-orders', data), + + update: (id: number, data: SurveyOrderUpdateParams) => + request.put(`/survey-orders/${id}`, data), + + delete: (id: number) => + request.delete(`/survey-orders/${id}`), +}; \ No newline at end of file diff --git a/client/src/api/visitRecord.ts b/client/src/api/visitRecord.ts new file mode 100644 index 0000000..d03493a --- /dev/null +++ b/client/src/api/visitRecord.ts @@ -0,0 +1,64 @@ +import request from '@/utils/request'; + +export interface VisitRecordInfo { + id: number; + customerId: number; + contactPerson?: string; + contactPhone?: string; + visitor?: string; + visitTime?: string; + customerOpinion?: string; + photos?: any[]; + createdAt: string; +} + +export interface VisitRecordQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; +} + +export interface VisitRecordListResult { + list: VisitRecordInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface VisitRecordCreateParams { + customerId: number; + contactPerson?: string; + contactPhone?: string; + visitor?: string; + visitTime?: string; + customerOpinion?: string; + photos?: any[]; +} + +export interface VisitRecordUpdateParams { + customerId?: number; + contactPerson?: string; + contactPhone?: string; + visitor?: string; + visitTime?: string; + customerOpinion?: string; + photos?: any[]; +} + +export const visitRecordApi = { + getList: (params: VisitRecordQuery) => + request.get('/visit-records', { params }), + + getDetail: (id: number) => + request.get(`/visit-records/${id}`), + + create: (data: VisitRecordCreateParams) => + request.post('/visit-records', data), + + update: (id: number, data: VisitRecordUpdateParams) => + request.put(`/visit-records/${id}`, data), + + delete: (id: number) => + request.delete(`/visit-records/${id}`), +}; \ No newline at end of file diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 77d6994..3c948aa 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -49,12 +49,60 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/customer/account/index.vue"), meta: { requiresAuth: true, title: "客户账号", permission: "customer:account" } }, + { + path: "customer/visit", + name: "CustomerVisit", + component: () => import("@/views/customer/visit/index.vue"), + meta: { requiresAuth: true, title: "客户拜访", permission: "customer:visit" } + }, + { + path: "customer/building", + name: "CustomerBuilding", + component: () => import("@/views/customer/building/index.vue"), + meta: { requiresAuth: true, title: "客户建筑", permission: "customer:building" } + }, + { + path: "customer/room", + name: "CustomerRoom", + component: () => import("@/views/customer/room/index.vue"), + meta: { requiresAuth: true, title: "客户房间", permission: "customer:room" } + }, { path: "project", name: "Project", - component: () => import("@/views/Placeholder.vue"), + redirect: "/project/survey", meta: { requiresAuth: true, title: "项目管理", permission: "project:list" } }, + { + path: "project/survey", + name: "ProjectSurvey", + component: () => import("@/views/project/survey/index.vue"), + meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" } + }, + { + path: "project/plan", + name: "ProjectPlan", + component: () => import("@/views/project/plan/index.vue"), + meta: { requiresAuth: true, title: "项目方案", permission: "project:plan" } + }, + { + path: "project/contract", + name: "ProjectContract", + component: () => import("@/views/project/contract/index.vue"), + meta: { requiresAuth: true, title: "合同管理", permission: "project:contract" } + }, + { + path: "project/acceptance", + name: "ProjectAcceptance", + component: () => import("@/views/project/acceptance/index.vue"), + meta: { requiresAuth: true, title: "项目验收", permission: "project:acceptance" } + }, + { + path: "project/visit", + name: "ProjectVisit", + component: () => import("@/views/project/visit/index.vue"), + meta: { requiresAuth: true, title: "回访记录", permission: "project:visit" } + }, { path: "dispatch", name: "Dispatch", diff --git a/client/src/views/customer/building/index.vue b/client/src/views/customer/building/index.vue new file mode 100644 index 0000000..4211b63 --- /dev/null +++ b/client/src/views/customer/building/index.vue @@ -0,0 +1,368 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/customer/room/index.vue b/client/src/views/customer/room/index.vue new file mode 100644 index 0000000..ae10d3b --- /dev/null +++ b/client/src/views/customer/room/index.vue @@ -0,0 +1,432 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/customer/visit/index.vue b/client/src/views/customer/visit/index.vue new file mode 100644 index 0000000..e0cfe9a --- /dev/null +++ b/client/src/views/customer/visit/index.vue @@ -0,0 +1,423 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/project/acceptance/index.vue b/client/src/views/project/acceptance/index.vue new file mode 100644 index 0000000..7fd2826 --- /dev/null +++ b/client/src/views/project/acceptance/index.vue @@ -0,0 +1,353 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/project/contract/index.vue b/client/src/views/project/contract/index.vue new file mode 100644 index 0000000..2f6a332 --- /dev/null +++ b/client/src/views/project/contract/index.vue @@ -0,0 +1,448 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/project/plan/index.vue b/client/src/views/project/plan/index.vue new file mode 100644 index 0000000..4994130 --- /dev/null +++ b/client/src/views/project/plan/index.vue @@ -0,0 +1,477 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/project/survey/index.vue b/client/src/views/project/survey/index.vue new file mode 100644 index 0000000..c6a217e --- /dev/null +++ b/client/src/views/project/survey/index.vue @@ -0,0 +1,380 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/project/visit/index.vue b/client/src/views/project/visit/index.vue new file mode 100644 index 0000000..2115a29 --- /dev/null +++ b/client/src/views/project/visit/index.vue @@ -0,0 +1,361 @@ + + + + + \ No newline at end of file diff --git a/database/db_seed.sql b/database/db_seed.sql index 37e794c..02b380a 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -47,7 +47,7 @@ SELECT setval('sys_role_id_seq', 4, true); -- ------------------------------------------------------------ INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES (1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 1, 'customer:list', 1), -(2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 2, 'project:list', 1), +(2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 1, 'project:list', 1), (3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 2, 'dispatch:list', 1), (4, NULL, '设备监控', '/device', NULL, 'MonitorOutlined', 4, 2, 'device:list', 1), (5, NULL, '统计报表', '/report', NULL, 'BarChartOutlined', 5, 2, 'report:list', 1), @@ -106,8 +106,32 @@ ON CONFLICT (id) DO UPDATE SET permission = EXCLUDED.permission, visible = EXCLUDED.visible; +-- 初始菜单(客户扩展子菜单 + 项目域子菜单,id:24-31) +-- 客户域:客户拜访(customer:visit) / 客户建筑(customer:building) / 客户房间(customer:room) +-- 项目域:项目勘察(project:survey) / 项目方案(project:plan) / 合同管理(project:contract) +-- 项目验收(project:acceptance) / 回访记录(project:visit) +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(24, 1, '客户拜访', '/customer/visit', 'customer/visit/index', 'AccountBookOutlined', 4, 2, 'customer:visit', 1), +(25, 1, '客户建筑', '/customer/building', 'customer/building/index', 'HomeOutlined', 5, 2, 'customer:building',1), +(26, 1, '客户房间', '/customer/room', 'customer/room/index', 'ApartmentOutlined', 6, 2, 'customer:room', 1), +(27, 2, '项目勘察', '/project/survey', 'project/survey/index', 'CarryOutOutlined', 1, 2, 'project:survey', 1), +(28, 2, '项目方案', '/project/plan', 'project/plan/index', 'FileSearchOutlined', 2, 2, 'project:plan', 1), +(29, 2, '合同管理', '/project/contract', 'project/contract/index', 'FileDoneOutlined', 3, 2, 'project:contract', 1), +(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 4, 2, 'project:acceptance',1), +(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 5, 2, 'project:visit', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + -- 重置 sys_menu 自增序列 -SELECT setval('sys_menu_id_seq', 23, true); +SELECT setval('sys_menu_id_seq', 31, true); -- ------------------------------------------------------------ -- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行) @@ -160,18 +184,20 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1) ON CONFLICT (user_id, role_id) DO NOTHING; -- ------------------------------------------------------------ --- 6. 角色-菜单绑定:ADMIN 拥有全部 23 条菜单,SALESMAN 拥有客户域+项目+统计报表 +-- 6. 角色-菜单绑定:ADMIN 拥有全部 31 条菜单,SALESMAN 拥有客户域+项目+统计报表 -- WORKER、CUSTOMER 不绑定任何菜单 -- ------------------------------------------------------------ INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11), (1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23), +(1, 24), (1, 25), (1, 26), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (2, 1), (2, 2), (2, 5), -(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23) +(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23), +(2, 24), (2, 25), (2, 26), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31) ON CONFLICT (role_id, menu_id) DO NOTHING; COMMIT; -- ============================================================ --- 完 · 7 张系统表种子数据(4 角色 + 23 菜单 + 20 字典 + 1 管理员 + 绑定关系) +-- 完 · 7 张系统表种子数据(4 角色 + 31 菜单 + 20 字典 + 1 管理员 + 绑定关系) -- ============================================================ diff --git a/database/migrations/20260826_project_customer_ext.sql b/database/migrations/20260826_project_customer_ext.sql new file mode 100644 index 0000000..9156470 --- /dev/null +++ b/database/migrations/20260826_project_customer_ext.sql @@ -0,0 +1,308 @@ +-- ============================================================ +-- 增量脚本:客户管理扩展 + 项目管理模块表结构 +-- 日期:2026-08-26 +-- 数据库:PostgreSQL 15+ (xintong) +-- 说明:新建客户域扩展(客户拜访/建筑/房间)与项目域 +-- (勘察派单/方案+明细/合同/验收/回访)共 9 张表。 +-- 沿用 Sequelize Entity 定义 + 幂等建表,可重复执行。 +-- +-- 兼容性说明: +-- - 使用 CREATE TABLE IF NOT EXISTS,已有表自动跳过。 +-- - project_plan_detail 对 customer_equipment / smart_device 仅预留 +-- equip_id / smart_device_id 数值列,不建外键(两表后续迭代实现)。 +-- - 对已实现表(customer / customer_department / customer_building / +-- project_plan / contract)保留外键约束。 +-- +-- 用法: +-- psql -U postgres -d xintong -f 20260826_project_customer_ext.sql +-- ============================================================ + +BEGIN; + +-- 依赖:trigger_set_updated_at() 已由 db_init.sql 创建(若缺失则这里兜底创建) +CREATE OR REPLACE FUNCTION trigger_set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================ +-- 1. 客户拜访信息表 customer_visit +-- ============================================================ +CREATE TABLE IF NOT EXISTS customer_visit ( + id BIGSERIAL PRIMARY KEY, + visit_no VARCHAR(64) NOT NULL UNIQUE, + customer_id BIGINT NOT NULL REFERENCES customer(id), + visit_person VARCHAR(64) NOT NULL, + visit_phone VARCHAR(20), + visit_content TEXT, + visit_time DATE, + salesman_id BIGINT, + will_plan SMALLINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE customer_visit IS '客户拜访信息表'; +COMMENT ON COLUMN customer_visit.id IS '自增主键'; +COMMENT ON COLUMN customer_visit.visit_no IS '拜访单号(唯一)'; +COMMENT ON COLUMN customer_visit.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN customer_visit.visit_person IS '拜访人'; +COMMENT ON COLUMN customer_visit.visit_phone IS '拜访人电话'; +COMMENT ON COLUMN customer_visit.visit_content IS '拜访内容'; +COMMENT ON COLUMN customer_visit.visit_time IS '拜访时间'; +COMMENT ON COLUMN customer_visit.salesman_id IS '业务员'; +COMMENT ON COLUMN customer_visit.will_plan IS '是否做方案:0不同意 1同意'; +COMMENT ON COLUMN customer_visit.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_visit_customer ON customer_visit (customer_id); +CREATE INDEX IF NOT EXISTS idx_visit_salesman ON customer_visit (salesman_id); + +-- ============================================================ +-- 2. 客户智电改造建筑信息表 customer_building +-- ============================================================ +CREATE TABLE IF NOT EXISTS customer_building ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL REFERENCES customer(id), + building_name VARCHAR(128) NOT NULL, + basic_info TEXT, + purpose VARCHAR(128), + floor_count INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE customer_building IS '客户智电改造建筑信息表'; +COMMENT ON COLUMN customer_building.id IS '自增主键'; +COMMENT ON COLUMN customer_building.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN customer_building.building_name IS '建筑名称'; +COMMENT ON COLUMN customer_building.basic_info IS '基本情况'; +COMMENT ON COLUMN customer_building.purpose IS '用途'; +COMMENT ON COLUMN customer_building.floor_count IS '层数'; +COMMENT ON COLUMN customer_building.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_building_customer ON customer_building (customer_id); + +-- ============================================================ +-- 3. 客户建筑房间信息表 customer_room +-- ============================================================ +CREATE TABLE IF NOT EXISTS customer_room ( + id BIGSERIAL PRIMARY KEY, + room_code VARCHAR(64) NOT NULL, + customer_id BIGINT NOT NULL REFERENCES customer(id), + room_name VARCHAR(128) NOT NULL, + building_id BIGINT NOT NULL REFERENCES customer_building(id), + floor_no VARCHAR(16), + room_purpose VARCHAR(128), + dept_id BIGINT REFERENCES customer_department(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE customer_room IS '客户建筑房间信息表'; +COMMENT ON COLUMN customer_room.id IS '自增主键'; +COMMENT ON COLUMN customer_room.room_code IS '房间编码'; +COMMENT ON COLUMN customer_room.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN customer_room.room_name IS '房间名称'; +COMMENT ON COLUMN customer_room.building_id IS '所属建筑 ID(→ customer_building)'; +COMMENT ON COLUMN customer_room.floor_no IS '所在楼层'; +COMMENT ON COLUMN customer_room.room_purpose IS '房间用途'; +COMMENT ON COLUMN customer_room.dept_id IS '房间所属客户部门(→ customer_department)'; +COMMENT ON COLUMN customer_room.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_room_customer ON customer_room (customer_id); +CREATE INDEX IF NOT EXISTS idx_room_building ON customer_room (building_id); + +-- ============================================================ +-- 4. 智电改造方案表 project_plan +-- ============================================================ +CREATE TABLE IF NOT EXISTS project_plan ( + id BIGSERIAL PRIMARY KEY, + plan_no VARCHAR(64) NOT NULL UNIQUE, + customer_id BIGINT NOT NULL REFERENCES customer(id), + registrar VARCHAR(64), + register_time DATE, + plan_desc TEXT, + plan_file_url VARCHAR(512), + status SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE project_plan IS '智电改造方案信息表'; +COMMENT ON COLUMN project_plan.id IS '自增主键'; +COMMENT ON COLUMN project_plan.plan_no IS '方案单号(唯一)'; +COMMENT ON COLUMN project_plan.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN project_plan.registrar IS '方案登记人(业务员)'; +COMMENT ON COLUMN project_plan.register_time IS '方案登记时间'; +COMMENT ON COLUMN project_plan.plan_desc IS '方案说明'; +COMMENT ON COLUMN project_plan.plan_file_url IS '方案最终文件'; +COMMENT ON COLUMN project_plan.status IS '状态:0草稿 1已确认'; +COMMENT ON COLUMN project_plan.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_plan_customer ON project_plan (customer_id); + +-- ============================================================ +-- 5. 项目方案智电设备明细表 project_plan_detail +-- equip_id / smart_device_id 仅预留数值列,不建外键 +-- ============================================================ +CREATE TABLE IF NOT EXISTS project_plan_detail ( + id BIGSERIAL PRIMARY KEY, + plan_id BIGINT NOT NULL REFERENCES project_plan(id) ON DELETE CASCADE, + customer_id BIGINT NOT NULL REFERENCES customer(id), + equip_name VARCHAR(128), + equip_id BIGINT, + smart_device_id BIGINT, + quantity NUMERIC(10,2) NOT NULL DEFAULT 1, + remark TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 兼容已有表:若 project_plan_detail 表已存在(旧脚本建表)则补齐 equip_name 列 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'project_plan_detail' AND column_name = 'equip_name' + ) THEN + ALTER TABLE project_plan_detail ADD COLUMN equip_name VARCHAR(128); + RAISE NOTICE 'project_plan_detail: 已新增列 equip_name'; + END IF; +END $$; + +COMMENT ON TABLE project_plan_detail IS '项目方案智电设备明细信息表'; +COMMENT ON COLUMN project_plan_detail.id IS '自增主键'; +COMMENT ON COLUMN project_plan_detail.plan_id IS '方案 ID(→ project_plan)'; +COMMENT ON COLUMN project_plan_detail.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN project_plan_detail.equip_name IS '设备名称(设备域未实现前为自由文本)'; +COMMENT ON COLUMN project_plan_detail.equip_id IS '客户设备 ID(预留)'; +COMMENT ON COLUMN project_plan_detail.smart_device_id IS '智电设备型号 ID(预留)'; +COMMENT ON COLUMN project_plan_detail.quantity IS '数量'; +COMMENT ON COLUMN project_plan_detail.remark IS '备注'; +COMMENT ON COLUMN project_plan_detail.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_pd_plan ON project_plan_detail (plan_id); +CREATE INDEX IF NOT EXISTS idx_pd_customer ON project_plan_detail (customer_id); + +-- ============================================================ +-- 6. 智电项目合同表 contract +-- ============================================================ +CREATE TABLE IF NOT EXISTS contract ( + id BIGSERIAL PRIMARY KEY, + contract_no VARCHAR(64) NOT NULL UNIQUE, + xt_contract_no VARCHAR(64), + customer_id BIGINT NOT NULL REFERENCES customer(id), + contract_amount NUMERIC(14,2), + pay_cycle VARCHAR(32), + salesman_id BIGINT, + sign_date DATE, + contract_desc TEXT, + start_date DATE, + end_date DATE, + plan_id BIGINT REFERENCES project_plan(id), + contract_file_url VARCHAR(512), + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE contract IS '智电项目合同信息表'; +COMMENT ON COLUMN contract.id IS '自增主键'; +COMMENT ON COLUMN contract.contract_no IS '合同单号(唯一)'; +COMMENT ON COLUMN contract.xt_contract_no IS '鑫潼合同号(XT+年份+序号)'; +COMMENT ON COLUMN contract.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN contract.contract_amount IS '合同金额'; +COMMENT ON COLUMN contract.pay_cycle IS '支付周期:按月/按年'; +COMMENT ON COLUMN contract.salesman_id IS '业务员'; +COMMENT ON COLUMN contract.sign_date IS '签订日期'; +COMMENT ON COLUMN contract.contract_desc IS '合同说明'; +COMMENT ON COLUMN contract.start_date IS '开始日期'; +COMMENT ON COLUMN contract.end_date IS '结束日期'; +COMMENT ON COLUMN contract.plan_id IS '方案 ID(→ project_plan)'; +COMMENT ON COLUMN contract.contract_file_url IS '签字盖章电子合同'; +COMMENT ON COLUMN contract.status IS '状态:1有效 0终止'; +COMMENT ON COLUMN contract.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_contract_customer ON contract (customer_id); +CREATE INDEX IF NOT EXISTS idx_contract_xt ON contract (xt_contract_no); + +-- ============================================================ +-- 7. 勘察派单表 survey_order +-- ============================================================ +CREATE TABLE IF NOT EXISTS 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() +); +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 IF NOT EXISTS idx_survey_customer ON survey_order (customer_id); +CREATE INDEX IF NOT EXISTS idx_survey_worker ON survey_order (worker_id); + +-- ============================================================ +-- 8. 项目验收表 acceptance +-- ============================================================ +CREATE TABLE IF NOT EXISTS acceptance ( + id BIGSERIAL PRIMARY KEY, + accept_no VARCHAR(64) NOT NULL UNIQUE, + customer_id BIGINT NOT NULL REFERENCES customer(id), + acceptor VARCHAR(64), + accept_opinion TEXT, + accept_date DATE, + installer VARCHAR(64), + accept_file_url VARCHAR(512), + contract_id BIGINT REFERENCES contract(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE acceptance IS '项目验收信息表'; +COMMENT ON COLUMN acceptance.id IS '自增主键'; +COMMENT ON COLUMN acceptance.accept_no IS '验收单号(唯一)'; +COMMENT ON COLUMN acceptance.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN acceptance.acceptor IS '验收人'; +COMMENT ON COLUMN acceptance.accept_opinion IS '验收意见'; +COMMENT ON COLUMN acceptance.accept_date IS '验收日期'; +COMMENT ON COLUMN acceptance.installer IS '安装人'; +COMMENT ON COLUMN acceptance.accept_file_url IS '验收文件电子版'; +COMMENT ON COLUMN acceptance.contract_id IS '合同 ID(→ contract)'; +COMMENT ON COLUMN acceptance.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_accept_customer ON acceptance (customer_id); + +-- ============================================================ +-- 9. 客户回访信息表 visit_record +-- ============================================================ +CREATE TABLE IF NOT EXISTS visit_record ( + id BIGSERIAL PRIMARY KEY, + visit_no VARCHAR(64) NOT NULL UNIQUE, + customer_id BIGINT NOT NULL REFERENCES customer(id), + contact_person VARCHAR(64), + contact_phone VARCHAR(20), + visitor VARCHAR(64), + visit_time DATE, + customer_opinion TEXT, + photos JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE visit_record IS '客户回访信息表'; +COMMENT ON COLUMN visit_record.id IS '自增主键'; +COMMENT ON COLUMN visit_record.visit_no IS '回访单号(唯一)'; +COMMENT ON COLUMN visit_record.customer_id IS '客户 ID(→ customer)'; +COMMENT ON COLUMN visit_record.contact_person IS '联系人'; +COMMENT ON COLUMN visit_record.contact_phone IS '电话'; +COMMENT ON COLUMN visit_record.visitor IS '回访人'; +COMMENT ON COLUMN visit_record.visit_time IS '回访时间'; +COMMENT ON COLUMN visit_record.customer_opinion IS '客户意见'; +COMMENT ON COLUMN visit_record.photos IS '回访照片(JSONB)'; +COMMENT ON COLUMN visit_record.created_at IS '创建时间'; +CREATE INDEX IF NOT EXISTS idx_visit_rec_customer ON visit_record (customer_id); + +COMMIT; + +-- ============================================================ +-- 验证查询(可选) +-- ============================================================ +SELECT tablename FROM pg_tables +WHERE schemaname = 'public' + AND tablename IN ('customer_visit','customer_building','customer_room', + 'project_plan','project_plan_detail','contract', + 'survey_order','acceptance','visit_record') +ORDER BY tablename; \ No newline at end of file diff --git a/server/src/controller/acceptance.controller.ts b/server/src/controller/acceptance.controller.ts new file mode 100644 index 0000000..d7671f8 --- /dev/null +++ b/server/src/controller/acceptance.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { AcceptanceService } from "../service/acceptance.service"; + +@Controller("/api/acceptances") +export class AcceptanceController { + @Inject() ctx!: Context; + @Inject() acceptanceService!: AcceptanceService; + + @Get("/") async list(@Query() query: any) { return this.acceptanceService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.acceptanceService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.acceptanceService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.acceptanceService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.acceptanceService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/controller/contract.controller.ts b/server/src/controller/contract.controller.ts new file mode 100644 index 0000000..dcafcb1 --- /dev/null +++ b/server/src/controller/contract.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { ContractService } from "../service/contract.service"; + +@Controller("/api/contracts") +export class ContractController { + @Inject() ctx!: Context; + @Inject() contractService!: ContractService; + + @Get("/") async list(@Query() query: any) { return this.contractService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.contractService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.contractService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.contractService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.contractService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/controller/customer_building.controller.ts b/server/src/controller/customer_building.controller.ts new file mode 100644 index 0000000..93c542a --- /dev/null +++ b/server/src/controller/customer_building.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { CustomerBuildingService } from "../service/customer_building.service"; + +@Controller("/api/customer-buildings") +export class CustomerBuildingController { + @Inject() ctx!: Context; + @Inject() customerBuildingService!: CustomerBuildingService; + + @Get("/") async list(@Query() query: any) { return this.customerBuildingService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.customerBuildingService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.customerBuildingService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerBuildingService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.customerBuildingService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/controller/customer_room.controller.ts b/server/src/controller/customer_room.controller.ts new file mode 100644 index 0000000..c89e2e7 --- /dev/null +++ b/server/src/controller/customer_room.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { CustomerRoomService } from "../service/customer_room.service"; + +@Controller("/api/customer-rooms") +export class CustomerRoomController { + @Inject() ctx!: Context; + @Inject() customerRoomService!: CustomerRoomService; + + @Get("/") async list(@Query() query: any) { return this.customerRoomService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.customerRoomService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.customerRoomService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerRoomService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.customerRoomService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/controller/customer_visit.controller.ts b/server/src/controller/customer_visit.controller.ts new file mode 100644 index 0000000..31f555c --- /dev/null +++ b/server/src/controller/customer_visit.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { CustomerVisitService } from "../service/customer_visit.service"; + +@Controller("/api/customer-visits") +export class CustomerVisitController { + @Inject() ctx!: Context; + @Inject() customerVisitService!: CustomerVisitService; + + @Get("/") async list(@Query() query: any) { return this.customerVisitService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.customerVisitService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.customerVisitService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerVisitService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.customerVisitService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/controller/project_plan.controller.ts b/server/src/controller/project_plan.controller.ts new file mode 100644 index 0000000..0d0b58d --- /dev/null +++ b/server/src/controller/project_plan.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { ProjectPlanService } from "../service/project_plan.service"; + +@Controller("/api/project-plans") +export class ProjectPlanController { + @Inject() ctx!: Context; + @Inject() projectPlanService!: ProjectPlanService; + + @Get("/") async list(@Query() query: any) { return this.projectPlanService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.projectPlanService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.projectPlanService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.projectPlanService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.projectPlanService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/controller/survey_order.controller.ts b/server/src/controller/survey_order.controller.ts new file mode 100644 index 0000000..acb4a42 --- /dev/null +++ b/server/src/controller/survey_order.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { SurveyOrderService } from "../service/survey_order.service"; + +@Controller("/api/survey-orders") +export class SurveyOrderController { + @Inject() ctx!: Context; + @Inject() surveyOrderService!: SurveyOrderService; + + @Get("/") async list(@Query() query: any) { return this.surveyOrderService.list(query, 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); } + @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/visit_record.controller.ts b/server/src/controller/visit_record.controller.ts new file mode 100644 index 0000000..5c0dfa0 --- /dev/null +++ b/server/src/controller/visit_record.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { VisitRecordService } from "../service/visit_record.service"; + +@Controller("/api/visit-records") +export class VisitRecordController { + @Inject() ctx!: Context; + @Inject() visitRecordService!: VisitRecordService; + + @Get("/") async list(@Query() query: any) { return this.visitRecordService.list(query, this.ctx); } + @Get("/:id") async detail(@Param("id") id: string) { return this.visitRecordService.detail(Number(id), this.ctx); } + @Post("/") async create(@Body() body: any) { return this.visitRecordService.create(body, this.ctx); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.visitRecordService.update(Number(id), body, this.ctx); } + @Del("/:id") async delete(@Param("id") id: string) { return this.visitRecordService.delete(Number(id), this.ctx); } +} \ No newline at end of file diff --git a/server/src/dto/acceptance.dto.ts b/server/src/dto/acceptance.dto.ts new file mode 100644 index 0000000..fe0d2c8 --- /dev/null +++ b/server/src/dto/acceptance.dto.ts @@ -0,0 +1,28 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, Matches, Min, Max, MaxLength } from "class-validator"; + +export class AcceptanceQueryDTO { + @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; +} + +export class AcceptanceCreateDTO { + @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) acceptor?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) acceptOpinion?: string; + @IsOptional() @IsString() @MaxLength(10) acceptDate?: string; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) installer?: string; + @IsOptional() @IsString() @MaxLength(512) acceptFileUrl?: string; + @IsOptional() @IsInt() contractId?: number; +} + +export class AcceptanceUpdateDTO { + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) acceptor?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) acceptOpinion?: string; + @IsOptional() @IsString() @MaxLength(10) acceptDate?: string; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) installer?: string; + @IsOptional() @IsString() @MaxLength(512) acceptFileUrl?: string; + @IsOptional() @IsInt() contractId?: number; +} \ No newline at end of file diff --git a/server/src/dto/contract.dto.ts b/server/src/dto/contract.dto.ts new file mode 100644 index 0000000..3c2a30e --- /dev/null +++ b/server/src/dto/contract.dto.ts @@ -0,0 +1,40 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator"; + +export class ContractQueryDTO { + @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() salesmanId?: number; + @IsOptional() @IsInt() status?: number; +} + +export class ContractCreateDTO { + @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) xtContractNo?: string; + @IsOptional() @IsNumber() @Min(0) contractAmount?: number; + @IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) payCycle?: string; + @IsOptional() @IsInt() salesmanId?: number; + @IsOptional() @IsString() @MaxLength(10) signDate?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) contractDesc?: string; + @IsOptional() @IsString() @MaxLength(10) startDate?: string; + @IsOptional() @IsString() @MaxLength(10) endDate?: string; + @IsOptional() @IsInt() planId?: number; + @IsOptional() @IsString() @MaxLength(512) contractFileUrl?: string; + @IsOptional() @IsInt() @Min(0) @Max(1) status?: number; +} + +export class ContractUpdateDTO { + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) xtContractNo?: string; + @IsOptional() @IsNumber() @Min(0) contractAmount?: number; + @IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) payCycle?: string; + @IsOptional() @IsInt() salesmanId?: number; + @IsOptional() @IsString() @MaxLength(10) signDate?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) contractDesc?: string; + @IsOptional() @IsString() @MaxLength(10) startDate?: string; + @IsOptional() @IsString() @MaxLength(10) endDate?: string; + @IsOptional() @IsInt() planId?: number; + @IsOptional() @IsString() @MaxLength(512) contractFileUrl?: string; + @IsOptional() @IsInt() @Min(0) @Max(1) status?: number; +} \ No newline at end of file diff --git a/server/src/dto/customer_building.dto.ts b/server/src/dto/customer_building.dto.ts new file mode 100644 index 0000000..45ec416 --- /dev/null +++ b/server/src/dto/customer_building.dto.ts @@ -0,0 +1,24 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, Matches, Min, Max, MaxLength } from "class-validator"; + +export class CustomerBuildingQueryDTO { + @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; +} + +export class CustomerBuildingCreateDTO { + @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; + @IsNotEmpty({ message: '建筑名称不能为空' }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) buildingName!: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string; + @IsOptional() @IsInt() @Min(0) floorCount?: number; +} + +export class CustomerBuildingUpdateDTO { + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) buildingName?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string; + @IsOptional() @IsInt() @Min(0) floorCount?: number; +} \ No newline at end of file diff --git a/server/src/dto/customer_room.dto.ts b/server/src/dto/customer_room.dto.ts new file mode 100644 index 0000000..c732f40 --- /dev/null +++ b/server/src/dto/customer_room.dto.ts @@ -0,0 +1,29 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, Matches, Min, Max, MaxLength } from "class-validator"; + +export class CustomerRoomQueryDTO { + @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() buildingId?: number; +} + +export class CustomerRoomCreateDTO { + @IsNotEmpty({ message: '房间编码不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) roomCode!: string; + @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; + @IsNotEmpty({ message: '房间名称不能为空' }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) roomName!: string; + @IsNotEmpty({ message: '所属建筑不能为空' }) @IsInt() buildingId!: number; + @IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) roomPurpose?: string; + @IsOptional() @IsInt() deptId?: number; +} + +export class CustomerRoomUpdateDTO { + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) roomCode?: string; + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) roomName?: string; + @IsOptional() @IsInt() buildingId?: number; + @IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) roomPurpose?: string; + @IsOptional() @IsInt() deptId?: number; +} \ No newline at end of file diff --git a/server/src/dto/customer_visit.dto.ts b/server/src/dto/customer_visit.dto.ts new file mode 100644 index 0000000..05d6967 --- /dev/null +++ b/server/src/dto/customer_visit.dto.ts @@ -0,0 +1,29 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator"; + +export class CustomerVisitQueryDTO { + @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() salesmanId?: number; +} + +export class CustomerVisitCreateDTO { + @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; + @IsNotEmpty({ message: '拜访人不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson!: string; + @IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' }) visitPhone?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) visitContent?: string; + @IsOptional() @IsString() @MaxLength(10) visitTime?: string; + @IsOptional() @IsInt() salesmanId?: number; + @IsOptional() @IsInt() @Min(0) @Max(1) willPlan?: number; +} + +export class CustomerVisitUpdateDTO { + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson?: string; + @IsOptional() @Matches(/^1[3-9]\d{9}$/) visitPhone?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) visitContent?: string; + @IsOptional() @IsString() @MaxLength(10) visitTime?: string; + @IsOptional() @IsInt() salesmanId?: number; + @IsOptional() @IsInt() @Min(0) @Max(1) willPlan?: number; +} \ No newline at end of file diff --git a/server/src/dto/project_plan.dto.ts b/server/src/dto/project_plan.dto.ts new file mode 100644 index 0000000..8c173eb --- /dev/null +++ b/server/src/dto/project_plan.dto.ts @@ -0,0 +1,39 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, IsArray, ValidateNested, Matches, Min, Max, MaxLength } from "class-validator"; +import { Type } from "class-transformer"; + +export class PlanDetailDTO { + @IsOptional() @IsInt() id?: number; + @IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) equipName?: string; + @IsOptional() @IsInt() equipId?: number; + @IsOptional() @IsInt() smartDeviceId?: number; + @IsOptional() @IsNumber() @Min(0.01) quantity?: number; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string; +} + +export class ProjectPlanQueryDTO { + @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; +} + +export class ProjectPlanCreateDTO { + @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) registrar?: string; + @IsOptional() @IsString() @MaxLength(10) registerTime?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) planDesc?: string; + @IsOptional() @IsString() @MaxLength(512) planFileUrl?: string; + @IsOptional() @IsInt() @Min(0) @Max(1) status?: number; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlanDetailDTO) details?: PlanDetailDTO[]; +} + +export class ProjectPlanUpdateDTO { + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) registrar?: string; + @IsOptional() @IsString() @MaxLength(10) registerTime?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) planDesc?: string; + @IsOptional() @IsString() @MaxLength(512) planFileUrl?: string; + @IsOptional() @IsInt() @Min(0) @Max(1) status?: number; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlanDetailDTO) details?: PlanDetailDTO[]; +} \ No newline at end of file diff --git a/server/src/dto/survey_order.dto.ts b/server/src/dto/survey_order.dto.ts new file mode 100644 index 0000000..431a61f --- /dev/null +++ b/server/src/dto/survey_order.dto.ts @@ -0,0 +1,27 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, 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; +} + +export class SurveyOrderCreateDTO { + @IsNotEmpty({ message: '所属客户不能为空' }) @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 { + @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 diff --git a/server/src/dto/visit_record.dto.ts b/server/src/dto/visit_record.dto.ts new file mode 100644 index 0000000..9f36d5e --- /dev/null +++ b/server/src/dto/visit_record.dto.ts @@ -0,0 +1,28 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, IsArray, IsObject, Matches, Min, Max, MaxLength } from "class-validator"; + +export class VisitRecordQueryDTO { + @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; +} + +export class VisitRecordCreateDTO { + @IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) contactPerson?: string; + @IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' }) contactPhone?: string; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitor?: string; + @IsOptional() @IsString() @MaxLength(10) visitTime?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) customerOpinion?: string; + @IsOptional() @IsArray() photos?: any[]; +} + +export class VisitRecordUpdateDTO { + @IsOptional() @IsInt() customerId?: number; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) contactPerson?: string; + @IsOptional() @Matches(/^1[3-9]\d{9}$/) contactPhone?: string; + @IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitor?: string; + @IsOptional() @IsString() @MaxLength(10) visitTime?: string; + @IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) customerOpinion?: string; + @IsOptional() @IsArray() photos?: any[]; +} \ No newline at end of file diff --git a/server/src/entity/acceptance.entity.ts b/server/src/entity/acceptance.entity.ts new file mode 100644 index 0000000..58d6651 --- /dev/null +++ b/server/src/entity/acceptance.entity.ts @@ -0,0 +1,51 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import Customer from "./customer.entity"; +import Contract from "./contract.entity"; + +@Table({ + tableName: "acceptance", + comment: "项目验收信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [{ name: "idx_accept_customer", fields: ["customer_id"] }] +}) +export default class Acceptance extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "accept_no", comment: "验收单号(唯一)" }) + acceptNo!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(64), comment: "验收人" }) + acceptor!: string; + + @Column({ type: DataType.TEXT, field: "accept_opinion", comment: "验收意见" }) + acceptOpinion!: string; + + @Column({ type: DataType.DATEONLY, field: "accept_date", comment: "验收日期" }) + acceptDate!: string; + + @Column({ type: DataType.STRING(64), comment: "安装人" }) + installer!: string; + + @Column({ type: DataType.STRING(512), field: "accept_file_url", comment: "验收文件" }) + acceptFileUrl!: string; + + @ForeignKey(() => Contract) + @Column({ type: DataType.BIGINT, field: "contract_id", comment: "合同ID" }) + contractId!: number; + + @BelongsTo(() => Customer) + customer!: Customer; + + @BelongsTo(() => Contract) + contract!: Contract; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/contract.entity.ts b/server/src/entity/contract.entity.ts new file mode 100644 index 0000000..76e349e --- /dev/null +++ b/server/src/entity/contract.entity.ts @@ -0,0 +1,69 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import Customer from "./customer.entity"; +import ProjectPlan from "./project_plan.entity"; + +@Table({ + tableName: "contract", + comment: "智电项目合同信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [ + { name: "idx_contract_customer", fields: ["customer_id"] }, + { name: "idx_contract_xt", fields: ["xt_contract_no"] } + ] +}) +export default class Contract extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "contract_no", comment: "合同单号(唯一)" }) + contractNo!: string; + + @Column({ type: DataType.STRING(64), field: "xt_contract_no", comment: "鑫潼合同号(XT+年份+序号)" }) + xtContractNo!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.DECIMAL(14, 2), field: "contract_amount", comment: "合同金额" }) + contractAmount!: number; + + @Column({ type: DataType.STRING(32), field: "pay_cycle", comment: "支付周期:按月/按年" }) + payCycle!: string; + + @Column({ type: DataType.BIGINT, field: "salesman_id", comment: "业务员" }) + salesmanId!: number; + + @Column({ type: DataType.DATEONLY, field: "sign_date", comment: "签订日期" }) + signDate!: string; + + @Column({ type: DataType.TEXT, field: "contract_desc", comment: "合同说明" }) + contractDesc!: string; + + @Column({ type: DataType.DATEONLY, field: "start_date", comment: "开始日期" }) + startDate!: string; + + @Column({ type: DataType.DATEONLY, field: "end_date", comment: "结束日期" }) + endDate!: string; + + @ForeignKey(() => ProjectPlan) + @Column({ type: DataType.BIGINT, field: "plan_id", comment: "方案ID" }) + planId!: number; + + @Column({ type: DataType.STRING(512), field: "contract_file_url", comment: "签字盖章合同文件" }) + contractFileUrl!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1有效 0终止" }) + status!: number; + + @BelongsTo(() => Customer) + customer!: Customer; + + @BelongsTo(() => ProjectPlan) + plan!: ProjectPlan; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/customer_building.entity.ts b/server/src/entity/customer_building.entity.ts new file mode 100644 index 0000000..1fa5b13 --- /dev/null +++ b/server/src/entity/customer_building.entity.ts @@ -0,0 +1,37 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import Customer from "./customer.entity"; + +@Table({ + tableName: "customer_building", + comment: "客户建筑信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [{ name: "idx_building_customer", fields: ["customer_id"] }] +}) +export default class CustomerBuilding extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(128), allowNull: false, field: "building_name", comment: "建筑名称" }) + buildingName!: string; + + @Column({ type: DataType.TEXT, field: "basic_info", comment: "基本情况" }) + basicInfo!: string; + + @Column({ type: DataType.STRING(128), comment: "用途" }) + purpose!: string; + + @Column({ type: DataType.INTEGER, field: "floor_count", comment: "层数" }) + floorCount!: number; + + @BelongsTo(() => Customer) + customer!: Customer; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/customer_room.entity.ts b/server/src/entity/customer_room.entity.ts new file mode 100644 index 0000000..b6759f5 --- /dev/null +++ b/server/src/entity/customer_room.entity.ts @@ -0,0 +1,53 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import Customer from "./customer.entity"; +import CustomerBuilding from "./customer_building.entity"; +import CustomerDepartment from "./customer_department.entity"; + +@Table({ + tableName: "customer_room", + comment: "客户房间信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [ + { name: "idx_room_customer", fields: ["customer_id"] }, + { name: "idx_room_building", fields: ["building_id"] } + ] +}) +export default class CustomerRoom extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, field: "room_code", comment: "房间编码" }) + roomCode!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(128), allowNull: false, field: "room_name", comment: "房间名称" }) + roomName!: string; + + @ForeignKey(() => CustomerBuilding) + @Column({ type: DataType.BIGINT, allowNull: false, field: "building_id", comment: "所属建筑ID" }) + buildingId!: number; + + @Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" }) + floorNo!: string; + + @Column({ type: DataType.STRING(128), field: "room_purpose", comment: "房间用途" }) + roomPurpose!: string; + + @ForeignKey(() => CustomerDepartment) + @Column({ type: DataType.BIGINT, field: "dept_id", comment: "房间所属部门ID" }) + deptId!: number; + + @BelongsTo(() => Customer) + customer!: Customer; + + @BelongsTo(() => CustomerBuilding) + building!: CustomerBuilding; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/customer_visit.entity.ts b/server/src/entity/customer_visit.entity.ts new file mode 100644 index 0000000..bce0076 --- /dev/null +++ b/server/src/entity/customer_visit.entity.ts @@ -0,0 +1,49 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import Customer from "./customer.entity"; + +@Table({ + tableName: "customer_visit", + comment: "客户拜访信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [ + { name: "idx_visit_customer", fields: ["customer_id"] }, + { name: "idx_visit_salesman", fields: ["salesman_id"] } + ] +}) +export default class CustomerVisit extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "visit_no", comment: "拜访单号(唯一)" }) + visitNo!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, field: "visit_person", comment: "拜访人" }) + visitPerson!: string; + + @Column({ type: DataType.STRING(20), field: "visit_phone", comment: "拜访人电话" }) + visitPhone!: string; + + @Column({ type: DataType.TEXT, field: "visit_content", comment: "拜访内容" }) + visitContent!: string; + + @Column({ type: DataType.DATEONLY, field: "visit_time", comment: "拜访时间" }) + visitTime!: string; + + @Column({ type: DataType.BIGINT, field: "salesman_id", comment: "业务员(关联sys_user.id)" }) + salesmanId!: number; + + @Column({ type: DataType.SMALLINT, defaultValue: 0, field: "will_plan", comment: "是否做方案:0不同意 1同意" }) + willPlan!: number; + + @BelongsTo(() => Customer) + customer!: Customer; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/project_plan.entity.ts b/server/src/entity/project_plan.entity.ts new file mode 100644 index 0000000..67b0a0b --- /dev/null +++ b/server/src/entity/project_plan.entity.ts @@ -0,0 +1,47 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo, HasMany } from "sequelize-typescript"; +import Customer from "./customer.entity"; +import ProjectPlanDetail from "./project_plan_detail.entity"; + +@Table({ + tableName: "project_plan", + comment: "智电改造方案信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [{ name: "idx_plan_customer", fields: ["customer_id"] }] +}) +export default class ProjectPlan extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "plan_no", comment: "方案单号(唯一)" }) + planNo!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(64), comment: "方案登记人(业务员)" }) + registrar!: string; + + @Column({ type: DataType.DATEONLY, field: "register_time", comment: "方案登记时间" }) + registerTime!: string; + + @Column({ type: DataType.TEXT, field: "plan_desc", comment: "方案说明" }) + planDesc!: string; + + @Column({ type: DataType.STRING(512), field: "plan_file_url", comment: "方案最终文件" }) + planFileUrl!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0草稿 1已确认" }) + status!: number; + + @BelongsTo(() => Customer) + customer!: Customer; + + @HasMany(() => ProjectPlanDetail, { foreignKey: "planId", onDelete: "CASCADE" }) + details!: ProjectPlanDetail[]; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/project_plan_detail.entity.ts b/server/src/entity/project_plan_detail.entity.ts new file mode 100644 index 0000000..7b1eadc --- /dev/null +++ b/server/src/entity/project_plan_detail.entity.ts @@ -0,0 +1,48 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import ProjectPlan from "./project_plan.entity"; +import Customer from "./customer.entity"; + +@Table({ + tableName: "project_plan_detail", + comment: "项目方案设备明细表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [ + { name: "idx_pd_plan", fields: ["plan_id"] }, + { name: "idx_pd_customer", fields: ["customer_id"] } + ] +}) +export default class ProjectPlanDetail extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @ForeignKey(() => ProjectPlan) + @Column({ type: DataType.BIGINT, allowNull: false, field: "plan_id", comment: "方案ID" }) + planId!: number; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(128), field: "equip_name", comment: "设备名称(设备域未实现前为自由文本)" }) + equipName!: string; + + @Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备ID(预留)" }) + equipId!: number; + + @Column({ type: DataType.BIGINT, field: "smart_device_id", comment: "智电设备型号ID(预留)" }) + smartDeviceId!: number; + + @Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 1, comment: "数量" }) + quantity!: number; + + @Column({ type: DataType.TEXT, comment: "备注" }) + remark!: string; + + @BelongsTo(() => ProjectPlan) + plan!: ProjectPlan; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/survey_order.entity.ts b/server/src/entity/survey_order.entity.ts new file mode 100644 index 0000000..1dd1db0 --- /dev/null +++ b/server/src/entity/survey_order.entity.ts @@ -0,0 +1,46 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import Customer from "./customer.entity"; + +@Table({ + tableName: "survey_order", + comment: "客户勘察派单信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [ + { name: "idx_survey_customer", fields: ["customer_id"] }, + { name: "idx_survey_worker", fields: ["worker_id"] } + ] +}) +export default class SurveyOrder extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "survey_no", comment: "勘察单号(唯一)" }) + surveyNo!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(64), comment: "勘察人(共享电工)" }) + surveyor!: string; + + @Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工ID" }) + workerId!: number; + + @Column({ type: DataType.DATE, field: "survey_time", comment: "勘察时间" }) + surveyTime!: Date; + + @Column({ type: DataType.TEXT, comment: "备注" }) + remark!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已接单 2已完成" }) + status!: number; + + @BelongsTo(() => Customer) + customer!: Customer; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/visit_record.entity.ts b/server/src/entity/visit_record.entity.ts new file mode 100644 index 0000000..2500f87 --- /dev/null +++ b/server/src/entity/visit_record.entity.ts @@ -0,0 +1,46 @@ +import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript"; +import Customer from "./customer.entity"; + +@Table({ + tableName: "visit_record", + comment: "客户回访信息表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [{ name: "idx_visit_rec_customer", fields: ["customer_id"] }] +}) +export default class VisitRecord extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "visit_no", comment: "回访单号(唯一)" }) + visitNo!: string; + + @ForeignKey(() => Customer) + @Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" }) + customerId!: number; + + @Column({ type: DataType.STRING(64), field: "contact_person", comment: "联系人" }) + contactPerson!: string; + + @Column({ type: DataType.STRING(20), field: "contact_phone", comment: "联系电话" }) + contactPhone!: string; + + @Column({ type: DataType.STRING(64), field: "visitor", comment: "回访人" }) + visitor!: string; + + @Column({ type: DataType.DATEONLY, field: "visit_time", comment: "回访时间" }) + visitTime!: string; + + @Column({ type: DataType.TEXT, field: "customer_opinion", comment: "客户意见" }) + customerOpinion!: string; + + @Column({ type: DataType.JSONB, field: "photos", comment: "回访照片(JSONB)" }) + photos!: any; + + @BelongsTo(() => Customer) + customer!: Customer; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/middleware/rbac.middleware.ts b/server/src/middleware/rbac.middleware.ts index cbb9838..26f81d6 100644 --- a/server/src/middleware/rbac.middleware.ts +++ b/server/src/middleware/rbac.middleware.ts @@ -16,6 +16,14 @@ const PERMISSION_MAP: Record = { '/api/customers': 'customer:list', '/api/customer-departments': 'customer:department', '/api/customer-accounts': 'customer:account', + '/api/customer-visits': 'customer:visit', + '/api/customer-buildings': 'customer:building', + '/api/customer-rooms': 'customer:room', + '/api/survey-orders': 'project:survey', + '/api/project-plans': 'project:plan', + '/api/contracts': 'project:contract', + '/api/acceptances': 'project:acceptance', + '/api/visit-records': 'project:visit', }; // 超级管理员角色编码,拥有全部权限 diff --git a/server/src/service/acceptance.service.ts b/server/src/service/acceptance.service.ts new file mode 100644 index 0000000..a4e3256 --- /dev/null +++ b/server/src/service/acceptance.service.ts @@ -0,0 +1,121 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import Acceptance from "../entity/acceptance.entity"; +import Customer from "../entity/customer.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class AcceptanceService { + @InjectRepository(Acceptance) + acceptModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + private async generateAcceptNo(retry: number = 0): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `YS${yyyymmdd}`; + const last = await this.acceptModel.findOne({ where: { acceptNo: { [Op.like]: `${prefix}%` } }, order: [["accept_no", "DESC"]] }); + let seq = 1; + if (last) seq = (parseInt(last.acceptNo.slice(-3), 10) || 0) + 1; + const result = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.acceptModel.findOne({ where: { acceptNo: result } }); + if (exists) { + if (retry < 1) return this.generateAcceptNo(retry + 1); + throw new Error("验收单号生成冲突,请重试"); + } + return result; + } + + 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] = [{ acceptor: { [Op.like]: `%${query.keyword}%` } }, { acceptOpinion: { [Op.like]: `%${query.keyword}%` } }]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + + 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 { count, rows } = await this.acceptModel.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) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.acceptModel.findByPk(id); + if (!record) return { code: 404, message: "项目验收不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该验收", data: null }; + return { code: 200, message: "获取项目验收详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建验收", data: null }; + try { + data.acceptNo = await this.generateAcceptNo(); + const created = await this.acceptModel.create(data); + return { code: 200, message: "创建项目验收成功", data: created.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError) { + data.acceptNo = await this.generateAcceptNo(); + const created = await this.acceptModel.create(data); + return { code: 200, message: "创建项目验收成功", data: created.toJSON() }; + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.acceptModel.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; + } + delete data.acceptNo; + await record.update(data); + return { code: 200, message: "更新项目验收成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.acceptModel.findByPk(id); + if (!record) return { code: 404, message: "项目验收不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该验收", data: null }; + await record.destroy(); + return { code: 200, message: "删除项目验收成功", data: null }; + } +} \ No newline at end of file diff --git a/server/src/service/contract.service.ts b/server/src/service/contract.service.ts new file mode 100644 index 0000000..a4de60e --- /dev/null +++ b/server/src/service/contract.service.ts @@ -0,0 +1,151 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import Contract from "../entity/contract.entity"; +import Customer from "../entity/customer.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class ContractService { + @InjectRepository(Contract) + contractModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + private async generateContractNo(retry: number = 0): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const yyyy = d.getUTCFullYear(); + const yyyymmdd = `${yyyy}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `HT${yyyymmdd}`; + const last = await this.contractModel.findOne({ where: { contractNo: { [Op.like]: `${prefix}%` } }, order: [["contract_no", "DESC"]] }); + let seq = 1; + if (last) seq = (parseInt(last.contractNo.slice(-3), 10) || 0) + 1; + const result = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.contractModel.findOne({ where: { contractNo: result } }); + if (exists) { + if (retry < 1) return this.generateContractNo(retry + 1); + throw new Error("合同单号生成冲突,请重试"); + } + return result; + } + + private async generateXtContractNo(): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const prefix = `XT${d.getUTCFullYear()}`; + const last = await this.contractModel.findOne({ where: { xtContractNo: { [Op.like]: `${prefix}%` } }, order: [["xt_contract_no", "DESC"]] }); + let seq = 1; + if (last) { + const lastNo = last.xtContractNo ?? ""; + const seqStr = lastNo.slice(-4); + seq = (parseInt(seqStr, 10) || 0) + 1; + } + return `${prefix}${String(seq).padStart(4, "0")}`; + } + + 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] = [{ xtContractNo: { [Op.like]: `%${query.keyword}%` } }, { contractDesc: { [Op.like]: `%${query.keyword}%` } }]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + if (query.salesmanId) where.salesmanId = query.salesmanId; + if (query.status !== undefined) where.status = query.status; + + 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 }; + } + delete where.salesmanId; + } + + const { count, rows } = await this.contractModel.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) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.contractModel.findByPk(id); + if (!record) return { code: 404, message: "项目合同不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该合同", data: null }; + return { code: 200, message: "获取项目合同详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + const userId = ctx.state.user.userId; + if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建合同", data: null }; + if (!(await this.isAdmin(userId))) data.salesmanId = userId; + try { + data.contractNo = await this.generateContractNo(); + if (!data.xtContractNo) data.xtContractNo = await this.generateXtContractNo(); + const created = await this.contractModel.create(data); + return { code: 200, message: "创建项目合同成功", data: created.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError) { + data.contractNo = await this.generateContractNo(); + const created = await this.contractModel.create(data); + return { code: 200, message: "创建项目合同成功", data: created.toJSON() }; + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.contractModel.findByPk(id); + if (!record) return { code: 404, message: "项目合同不存在", data: null }; + const isAdmin = await this.isAdmin(ctx.state.user.userId); + if (!isAdmin) { + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限修改该合同", data: null }; + delete data.salesmanId; + delete data.customerId; + } + delete data.contractNo; + await record.update(data); + return { code: 200, message: "更新项目合同成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.contractModel.findByPk(id); + if (!record) return { code: 404, message: "项目合同不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该合同", data: null }; + await record.destroy(); + return { code: 200, message: "删除项目合同成功", data: null }; + } + + async findOptions(customerId: number): Promise { + const where: any = {}; + if (customerId) where.customerId = customerId; + const records = await this.contractModel.findAll({ where, order: [["created_at", "DESC"]] }); + return { code: 200, message: "获取项目合同选项成功", data: records.map((r) => r.toJSON()) }; + } +} \ No newline at end of file diff --git a/server/src/service/customer_building.service.ts b/server/src/service/customer_building.service.ts new file mode 100644 index 0000000..610e1a6 --- /dev/null +++ b/server/src/service/customer_building.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 CustomerBuilding from "../entity/customer_building.entity"; +import Customer from "../entity/customer.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class CustomerBuildingService { + @InjectRepository(CustomerBuilding) + buildingModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + async list(query: any, ctx: Context): Promise { + 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] = [{ buildingName: { [Op.like]: `%${query.keyword}%` } }, { purpose: { [Op.like]: `%${query.keyword}%` } }]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + + 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 { count, rows } = await this.buildingModel.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) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.buildingModel.findByPk(id); + if (!record) return { code: 404, message: "客户建筑不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该建筑", data: null }; + return { code: 200, message: "获取客户建筑详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建建筑", data: null }; + const created = await this.buildingModel.create(data); + return { code: 200, message: "创建客户建筑成功", data: created.toJSON() }; + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.buildingModel.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; + } + await record.update(data); + return { code: 200, message: "更新客户建筑成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.buildingModel.findByPk(id); + if (!record) return { code: 404, message: "客户建筑不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该建筑", data: null }; + await record.destroy(); + return { code: 200, message: "删除客户建筑成功", data: null }; + } + + async findOptions(customerId: number): Promise { + const records = await this.buildingModel.findAll({ where: { customerId }, order: [["created_at", "DESC"]] }); + return { code: 200, message: "获取客户建筑选项成功", data: records.map((r) => r.toJSON()) }; + } +} \ No newline at end of file diff --git a/server/src/service/customer_room.service.ts b/server/src/service/customer_room.service.ts new file mode 100644 index 0000000..f614947 --- /dev/null +++ b/server/src/service/customer_room.service.ts @@ -0,0 +1,102 @@ +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 CustomerRoom from "../entity/customer_room.entity"; +import Customer from "../entity/customer.entity"; +import CustomerBuilding from "../entity/customer_building.entity"; +import CustomerDepartment from "../entity/customer_department.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class CustomerRoomService { + @InjectRepository(CustomerRoom) + roomModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(CustomerBuilding) + buildingModel!: Repository; + + @InjectRepository(CustomerDepartment) + deptModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + async list(query: any, ctx: Context): Promise { + 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] = [{ roomName: { [Op.like]: `%${query.keyword}%` } }, { roomCode: { [Op.like]: `%${query.keyword}%` } }]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + if (query.buildingId !== undefined) where.buildingId = query.buildingId; + + 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 { count, rows } = await this.roomModel.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) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.roomModel.findByPk(id); + if (!record) return { code: 404, message: "客户房间不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该房间", data: null }; + return { code: 200, message: "获取客户房间详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建房间", data: null }; + const created = await this.roomModel.create(data); + return { code: 200, message: "创建客户房间成功", data: created.toJSON() }; + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.roomModel.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; + } + await record.update(data); + return { code: 200, message: "更新客户房间成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.roomModel.findByPk(id); + if (!record) return { code: 404, message: "客户房间不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该房间", data: null }; + await record.destroy(); + return { code: 200, message: "删除客户房间成功", data: null }; + } +} \ No newline at end of file diff --git a/server/src/service/customer_visit.service.ts b/server/src/service/customer_visit.service.ts new file mode 100644 index 0000000..2a41a92 --- /dev/null +++ b/server/src/service/customer_visit.service.ts @@ -0,0 +1,130 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import CustomerVisit from "../entity/customer_visit.entity"; +import Customer from "../entity/customer.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class CustomerVisitService { + @InjectRepository(CustomerVisit) + visitModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + private async generateVisitNo(retry: number = 0): Promise { + const now = new Date(); + const offsetMs = now.getTime() + 8 * 60 * 60 * 1000; + const d = new Date(offsetMs); + const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `BF${yyyymmdd}`; + const last = await this.visitModel.findOne({ where: { visitNo: { [Op.like]: `${prefix}%` } }, order: [["visit_no", "DESC"]] }); + let seq = 1; + if (last) seq = (parseInt(last.visitNo.slice(-3), 10) || 0) + 1; + const result = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.visitModel.findOne({ where: { visitNo: result } }); + if (exists) { + if (retry < 1) return this.generateVisitNo(retry + 1); + throw new Error("拜访单号生成冲突,请重试"); + } + return result; + } + + 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] = [ + { visitPerson: { [Op.like]: `%${query.keyword}%` } }, + { visitPhone: { [Op.like]: `%${query.keyword}%` } } + ]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + if (query.salesmanId) where.salesmanId = query.salesmanId; + + 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 { count, rows } = await this.visitModel.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) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.visitModel.findByPk(id); + if (!record) return { code: 404, message: "客户拜访不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该拜访记录", data: null }; + return { code: 200, message: "获取客户拜访详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + const userId = ctx.state.user.userId; + if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建拜访记录", data: null }; + if (!(await this.isAdmin(userId))) data.salesmanId = userId; + try { + data.visitNo = await this.generateVisitNo(); + const created = await this.visitModel.create(data); + return { code: 200, message: "创建客户拜访成功", data: created.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError) { + data.visitNo = await this.generateVisitNo(); + const created = await this.visitModel.create(data); + return { code: 200, message: "创建客户拜访成功", data: created.toJSON() }; + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.visitModel.findByPk(id); + if (!record) return { code: 404, message: "客户拜访不存在", data: null }; + const isAdmin = await this.isAdmin(ctx.state.user.userId); + if (!isAdmin) { + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限修改该拜访记录", data: null }; + delete data.salesmanId; + delete data.customerId; + } + delete data.visitNo; + await record.update(data); + return { code: 200, message: "更新客户拜访成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.visitModel.findByPk(id); + if (!record) return { code: 404, message: "客户拜访不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该拜访记录", data: null }; + await record.destroy(); + return { code: 200, message: "删除客户拜访成功", data: null }; + } +} \ No newline at end of file diff --git a/server/src/service/project_plan.service.ts b/server/src/service/project_plan.service.ts new file mode 100644 index 0000000..a1d8006 --- /dev/null +++ b/server/src/service/project_plan.service.ts @@ -0,0 +1,154 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import ProjectPlan from "../entity/project_plan.entity"; +import ProjectPlanDetail from "../entity/project_plan_detail.entity"; +import Customer from "../entity/customer.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class ProjectPlanService { + @InjectRepository(ProjectPlan) + planModel!: Repository; + + @InjectRepository(ProjectPlanDetail) + detailModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + private async generatePlanNo(retry: number = 0): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `FA${yyyymmdd}`; + const last = await this.planModel.findOne({ where: { planNo: { [Op.like]: `${prefix}%` } }, order: [["plan_no", "DESC"]] }); + let seq = 1; + if (last) seq = (parseInt(last.planNo.slice(-3), 10) || 0) + 1; + const result = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.planModel.findOne({ where: { planNo: result } }); + if (exists) { + if (retry < 1) return this.generatePlanNo(retry + 1); + throw new Error("方案单号生成冲突,请重试"); + } + return result; + } + + 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] = [{ registrar: { [Op.like]: `%${query.keyword}%` } }, { planDesc: { [Op.like]: `%${query.keyword}%` } }]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + if (query.status !== undefined) where.status = query.status; + + 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 { count, rows } = await this.planModel.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) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.planModel.findByPk(id, { include: [this.detailModel] }); + if (!record) return { code: 404, message: "项目方案不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该方案", data: null }; + return { code: 200, message: "获取项目方案详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建方案", data: null }; + const details = data.details || []; + delete data.details; + try { + data.planNo = await this.generatePlanNo(); + const created = await this.planModel.create(data); + if (details.length > 0) { + await this.detailModel.bulkCreate(details.map((d: any) => ({ ...d, planId: created.id, customerId: created.customerId }))); + } + const result = await this.planModel.findByPk(created.id, { include: [this.detailModel] }); + return { code: 200, message: "创建项目方案成功", data: result?.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError) { + data.planNo = await this.generatePlanNo(); + const created = await this.planModel.create(data); + if (details.length > 0) { + await this.detailModel.bulkCreate(details.map((d: any) => ({ ...d, planId: created.id, customerId: created.customerId }))); + } + const result = await this.planModel.findByPk(created.id, { include: [this.detailModel] }); + return { code: 200, message: "创建项目方案成功", data: result?.toJSON() }; + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.planModel.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 details = data.details; + delete data.details; + delete data.planNo; + await record.update(data); + + if (details && Array.isArray(details)) { + await this.detailModel.destroy({ where: { planId: id } }); + if (details.length > 0) { + await this.detailModel.bulkCreate(details.map((d: any) => ({ ...d, planId: id, customerId: record.customerId }))); + } + } + const result = await this.planModel.findByPk(id, { include: [this.detailModel] }); + return { code: 200, message: "更新项目方案成功", data: result?.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.planModel.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.detailModel.destroy({ where: { planId: id } }); + await record.destroy(); + return { code: 200, message: "删除项目方案成功", data: null }; + } + + async findOptions(customerId: number): Promise { + const where: any = {}; + if (customerId) where.customerId = customerId; + const records = await this.planModel.findAll({ where, order: [["created_at", "DESC"]] }); + return { code: 200, message: "获取项目方案选项成功", data: records.map((r) => r.toJSON()) }; + } +} \ No newline at end of file diff --git a/server/src/service/survey_order.service.ts b/server/src/service/survey_order.service.ts new file mode 100644 index 0000000..b1ff2d7 --- /dev/null +++ b/server/src/service/survey_order.service.ts @@ -0,0 +1,122 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import SurveyOrder from "../entity/survey_order.entity"; +import Customer from "../entity/customer.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class SurveyOrderService { + @InjectRepository(SurveyOrder) + surveyModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + private async generateSurveyNo(retry: number = 0): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `KC${yyyymmdd}`; + const last = await this.surveyModel.findOne({ where: { surveyNo: { [Op.like]: `${prefix}%` } }, order: [["survey_no", "DESC"]] }); + let seq = 1; + if (last) seq = (parseInt(last.surveyNo.slice(-3), 10) || 0) + 1; + const result = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.surveyModel.findOne({ where: { surveyNo: result } }); + if (exists) { + if (retry < 1) return this.generateSurveyNo(retry + 1); + throw new Error("勘察单号生成冲突,请重试"); + } + return result; + } + + 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; + + 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 { 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) } }; + } + + 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() }; + } + + async create(data: any, ctx: Context): Promise { + if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null }; + try { + data.surveyNo = await this.generateSurveyNo(); + const created = await this.surveyModel.create(data); + return { code: 200, message: "创建勘察派单成功", data: created.toJSON() }; + } 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() }; + } + throw err; + } + } + + 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; + } + delete data.surveyNo; + await record.update(data); + return { code: 200, message: "更新勘察派单成功", data: record.toJSON() }; + } + + 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 record.destroy(); + return { code: 200, message: "删除勘察派单成功", data: null }; + } +} \ No newline at end of file diff --git a/server/src/service/visit_record.service.ts b/server/src/service/visit_record.service.ts new file mode 100644 index 0000000..e5d01cc --- /dev/null +++ b/server/src/service/visit_record.service.ts @@ -0,0 +1,126 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import VisitRecord from "../entity/visit_record.entity"; +import Customer from "../entity/customer.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +@Provide() +export class VisitRecordService { + @InjectRepository(VisitRecord) + visitModel!: Repository; + + @InjectRepository(Customer) + customerModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + private async isAdmin(userId: number): Promise { + const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] }); + return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN"); + } + + private async isCustomerOwner(userId: number, customerId: number): Promise { + if (await this.isAdmin(userId)) return true; + const customer = await this.customerModel.findByPk(customerId); + return !!customer && customer.salesmanId === userId; + } + + private async generateVisitNo(retry: number = 0): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `HF${yyyymmdd}`; + const last = await this.visitModel.findOne({ where: { visitNo: { [Op.like]: `${prefix}%` } }, order: [["visit_no", "DESC"]] }); + let seq = 1; + if (last) seq = (parseInt(last.visitNo.slice(-3), 10) || 0) + 1; + const result = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.visitModel.findOne({ where: { visitNo: result } }); + if (exists) { + if (retry < 1) return this.generateVisitNo(retry + 1); + throw new Error("回访单号生成冲突,请重试"); + } + return result; + } + + 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] = [ + { visitor: { [Op.like]: `%${query.keyword}%` } }, + { contactPerson: { [Op.like]: `%${query.keyword}%` } }, + { contactPhone: { [Op.like]: `%${query.keyword}%` } }, + { customerOpinion: { [Op.like]: `%${query.keyword}%` } } + ]; + } + if (query.customerId !== undefined) where.customerId = query.customerId; + + 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 { count, rows } = await this.visitModel.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) } }; + } + + async detail(id: number, ctx: Context): Promise { + const record = await this.visitModel.findByPk(id); + if (!record) return { code: 404, message: "客户回访不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该回访记录", data: null }; + return { code: 200, message: "获取客户回访详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建回访记录", data: null }; + try { + data.visitNo = await this.generateVisitNo(); + const created = await this.visitModel.create(data); + return { code: 200, message: "创建客户回访成功", data: created.toJSON() }; + } catch (err: any) { + if (err instanceof UniqueConstraintError) { + data.visitNo = await this.generateVisitNo(); + const created = await this.visitModel.create(data); + return { code: 200, message: "创建客户回访成功", data: created.toJSON() }; + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.visitModel.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; + } + delete data.visitNo; + await record.update(data); + return { code: 200, message: "更新客户回访成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.visitModel.findByPk(id); + if (!record) return { code: 404, message: "客户回访不存在", data: null }; + if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该回访记录", data: null }; + await record.destroy(); + return { code: 200, message: "删除客户回访成功", data: null }; + } +} \ No newline at end of file