feat: 新增可配置通知中心,支持扫描式/埋点式通知规则与手动群发
新增通知规则实体、扫描任务、API与管理界面,重构站内通知支持用户/电工双类型,移除硬编码的证照到期提醒,新增工单派单、电工审核结果等通知触发场景,调整菜单与数据库结构适配新通知体系。
This commit is contained in:
1 parent
91aa3c06f0
commit
6cf6a3d5b9
36 files changed
+3309
-246
No files matched your search
@@ -0,0 +1,171 @@
|
||||
# 可配置消息通知中心 · 实施计划
|
||||
|
||||
## Context(为什么做)
|
||||
|
||||
现状(代码核对结论):
|
||||
- 后端通知是**硬编码单点**:`notice.service.ts` 只支持「证照到期」一种,标题模板写死,**接收人写死为 ADMIN 角色**;由 `@midwayjs/cron` 的 `cert_expire.task.ts` 每日 08:00 触发。
|
||||
- **Web 管理端**消息中心已完整(铃铛 + 未读角标 + 抽屉 + 60s 轮询 + 已读),见 [layouts/index.vue](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/client/src/layouts/index.vue#L334-L380)。
|
||||
- **管理端 Flutter App**:顶栏铃铛 [workbench_tab.dart](file:///d:/MyProject/smart_grid_iot/smart_grid_iot_manage_app_flutter/lib/features/workbench/presentation/workbench_tab.dart#L139) 传了 `trailingIcon` 但没传 `onTrailingTap`,点击无反应;无 notice API、无消息中心页。
|
||||
- **电工端 Flutter App**:三处铃铛只弹 `showAppToast('暂无新通知')`;后端 `/api/worker-app/notices` 是**实时计算的证书临期清单**,不落库、无已读/未读数。
|
||||
|
||||
目标:建立「可配置 + 两端统一」的站内消息中心——**规则模板自动触发 + 手动群发**,接收对象覆盖**管理端用户与电工**,配置在 **Web 后台**,管理端/电工端 Flutter 各自拥有消息中心页。
|
||||
|
||||
### 已确认决策(来自需求澄清,不得擅自更改)
|
||||
1. 形态:**站内消息中心**(不做离线/厂商推送)。
|
||||
2. 能力:**规则模板自动触发** 与 **手动群发** 两者都要;手动群发**仅立即发送**(不做草稿/定时)。
|
||||
3. 接收对象:**管理端用户 + 电工端用户**;**完全靠配置**决定,代码不内置默认接收人。
|
||||
4. 数据模型:**改造现有 `sys_notice` 增加接收人类型,两端统一**。
|
||||
5. 事件捕获:**混合式** = 扫描式规则(时间/状态类)+ 业务代码埋点(一次性状态变更)。
|
||||
6. 配置入口:**Web 后台**。
|
||||
7. 首批事件域:**电工域 / 客户域 / 工单域**(项目域、验收、项目计划下期扩)。
|
||||
8. 两个 App 消息中心:**独立二级页**(go_router,复用 Cupertino 右滑转场),未读红点 + 60s 轮询(对齐 Web)。
|
||||
|
||||
---
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
业务事件 ──┬─ 扫描式:内置任务 notice_scan ─→ NoticeRuleService.executeScan()
|
||||
│ (复用自研调度器,可在「定时任务」页改 cron/启停/立即执行)
|
||||
└─ 埋点式:业务 service 直接调 NoticeService.deliver()
|
||||
↓
|
||||
NoticeService.deliver() ← 接收人解析(sys_user / worker)+ 幂等写入
|
||||
↓
|
||||
sys_notice(receiver_type=user|worker)
|
||||
↓
|
||||
┌──────────────┬───────────────────┬──────────────────┐
|
||||
Web 消息中心 管理端 App 消息中心 电工端 App 消息中心
|
||||
(已存在,改造) (新建页面) (新建页面)
|
||||
```
|
||||
|
||||
**复用现有资产**(不重复造):
|
||||
- 自研调度器 [job_scheduler.service.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/service/job_scheduler.service.ts#L416-L428) 与任务注册表 [job_registry.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/task/job_registry.ts#L22-L55):通过扩展 `JobRuntime` 注入"执行通知规则"能力(写法照搬 `cleanOperationLogs` 用 `app.createAnonymousContext()` 取 service)。
|
||||
- RBAC 路径映射 [rbac.middleware.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/middleware/rbac.middleware.ts#L10-L36)、菜单/权限迁移写法 [20261002_job_management.sql](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/database/migrations/20261002_job_management.sql#L87-L108)。
|
||||
- Web 列表页骨架 [dispatch/price/index.vue](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/client/src/views/dispatch/price/index.vue)。
|
||||
- Flutter 两端 `go_router` + `XPageShell`/`XTopBar`/`XCard` + Riverpod。
|
||||
|
||||
---
|
||||
|
||||
## 一、数据库(迁移 `database/migrations/20261004_notice_config.sql`)
|
||||
|
||||
### 1) 改造 `sys_notice`
|
||||
```sql
|
||||
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS receiver_type VARCHAR(16) NOT NULL DEFAULT 'user'; -- user|worker
|
||||
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS rule_id BIGINT; -- → sys_notice_rule.id(扫描式来源)
|
||||
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS batch_id BIGINT; -- → sys_notice_batch.id(手动群发来源)
|
||||
DROP INDEX IF EXISTS uk_sys_notice_biz;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_notice_deliver
|
||||
ON sys_notice (notice_type, biz_type, biz_id, receiver_type, receiver_id); -- 幂等键
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_notice_receiver_type ON sys_notice (receiver_type, receiver_id, is_read);
|
||||
```
|
||||
|
||||
### 2) 新增 `sys_notice_rule`(规则模板 + 接收对象)
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | BIGSERIAL PK | |
|
||||
| rule_key | VARCHAR(64) UNIQUE | 规则键,如 `cert_expire` / `worker_audit_result` / `contract_expire` / `visit_plan` / `dispatch_assign` / `order_timeout` |
|
||||
| rule_name | VARCHAR(64) | 规则名称 |
|
||||
| event_key | VARCHAR(64) | 事件注册表 key |
|
||||
| trigger_mode | SMALLINT | 1 扫描式 2 埋点式 |
|
||||
| enabled | SMALLINT DEFAULT 1 | 启停 |
|
||||
| advance_days | INT NULL | 提前天数(扫描式用) |
|
||||
| title_tpl | VARCHAR(128) | 标题模板,支持 `{变量}` |
|
||||
| content_tpl | VARCHAR(512) | 正文模板,支持 `{变量}` |
|
||||
| receiver_config | JSONB | `{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"ids\|all\|status","ids":[],"auditStatus":1,"status":1}}` |
|
||||
| remark / created_at / updated_at | | |
|
||||
|
||||
### 3) 新增 `sys_notice_batch`(手动群发批次/发送记录)
|
||||
`id`、`title`、`content`、`receiver_config JSONB`、`receiver_count INT`、`sent_by BIGINT(→sys_user.id)`、`sent_at TIMESTAMPTZ`。
|
||||
|
||||
### 4) 菜单与权限
|
||||
新增「通知管理」菜单(`menu_type=2`,路径 `/system/notice`,权限 `notice:rule`)+ 按钮权限 `notice:batch`(群发);绑定 ADMIN 角色(沿用 `ON CONFLICT` 幂等写法);同步 `database/db_seed.sql` 菜单段与序列。
|
||||
|
||||
---
|
||||
|
||||
## 二、后端(`server/src`)
|
||||
|
||||
### 新增文件
|
||||
- `entity/sys_notice_rule.entity.ts`、`entity/sys_notice_batch.entity.ts`
|
||||
- `task/notice_event_registry.ts`:**事件 provider 注册表**(`eventKey → { name, variables, scan(advanceDays) }`),扫描式事件在此实现数据扫描,返回 `{ bizType, bizId, vars }[]`。
|
||||
- `task/notice_scan.task.ts`:`BuiltinJob`(`jobKey='notice_scan'`,默认 cron `0 0 8 * * *`),handler 调用 `ctx.runtime.runNoticeRules()`。
|
||||
- `service/notice_rule.service.ts`:规则 CRUD;`executeScan()` 遍历 `enabled && trigger_mode=1` 的规则 → provider 扫描 → 解析接收人 → 模板渲染 → 调 `NoticeService.deliver()`;提供 `runForEvent(eventKey, biz...)` 供埋点式复用。
|
||||
- `service/notice_batch.service.ts`:手动群发(解析接收人 → 逐人写入 `sys_notice`,`notice_type='manual'`、`biz_type='manual_batch'`、`biz_id=batchId` → 写批次记录)。
|
||||
- `controller/notice_rule.controller.ts`:`/api/notice-rules`(list/detail/create/update/delete/enable/事件字典/立即执行)。
|
||||
- `controller/notice_batch.controller.ts`:`/api/notice-batches`(list/create 群发)。
|
||||
- `dto/notice_rule.dto.ts`、`dto/notice_batch.dto.ts`(class-validator)。
|
||||
|
||||
### 修改文件
|
||||
- `entity/sys_notice.entity.ts`:加 `receiverType/ruleId/batchId`,索引改为 `uk_sys_notice_deliver`。
|
||||
- `task/job_registry.ts`:`BUILTIN_JOBS` 注册 `notice_scan`;`JobRuntime` 增加 `runNoticeRules(): Promise<string>`。
|
||||
- `service/job_scheduler.service.ts`:`buildRuntime()` 增加 `runNoticeRules`(匿名上下文取 `NoticeRuleService`,照搬 L417-428 的 `LogService` 写法)。
|
||||
- `service/notice.service.ts`:抽出**统一投递** `deliver({...})`(幂等 ON CONFLICT)、**接收人解析**(roles→sys_user,workers→worker);`listMy/unreadCount/markRead/markAllRead` 增加 `receiverType` 维度(管理端固定 `user`;同时新增 worker 侧方法)。
|
||||
- `controller/notice.controller.ts`:管理端固定 `receiver_type='user'`。
|
||||
- `controller/worker_app.controller.ts` + `service/worker_app.service.ts`:`listNotices` **改为读 `sys_notice`**(`receiver_type='worker' AND receiver_id=workerId`),替换现有实时计算;新增 `/notices/unread-count`、`/notices/:id/read`、`/notices/read-all`。
|
||||
- `middleware/rbac.middleware.ts`:新增 `/api/notice-rules → notice:rule`、`/api/notice-batches → notice:batch`(`/api/notice*` 注意前缀匹配顺序,`-rules/-batches` 需在 `/api/notices` 之前不冲突——二者路径不同,无冲突)。
|
||||
|
||||
### 扫描式事件 provider(首批)
|
||||
| event_key | 数据源 | 条件 |
|
||||
|---|---|---|
|
||||
| `cert_expire` | `worker_cert.expireDate` | `expire_date <= today + advance_days`(含已过期) |
|
||||
| `contract_expire` | `contract.endDate` | 同上 |
|
||||
| `visit_plan` | `customer_visit` | `status=0` 且计划日期已到/超期 |
|
||||
| `order_timeout` | `repair_dispatch`/`survey_order`/`install_order`/`maintenance_order` | 派单后 `status` 滞留超 N 天未接单 |
|
||||
|
||||
> 现有 `cert_expire.task.ts`(`@midwayjs/cron`)**迁移进 `notice_scan`** 后删除,消除两套调度并存(这也是 `add-scheduled-job-management` spec Assumption 6 预留的后续动作)。
|
||||
|
||||
### 埋点式事件(在业务 service 内调用 `NoticeService.deliver()`,**必须 try/catch 包裹,通知失败不得阻断业务**)
|
||||
| event_key | 埋点位置 |
|
||||
|---|---|
|
||||
| `worker_audit_result` | [worker.service.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/service/worker.service.ts#L348-L418) 审核通过/驳回 → 通知电工本人 |
|
||||
| `dispatch_assign` | `repair_dispatch.service.ts`、`survey_order.service.ts`、`install_order.service.ts`、`maintenance.service.ts` 派单/指派 → 通知电工 |
|
||||
| `order_status` | 上述工单状态流转(按需) |
|
||||
|
||||
---
|
||||
|
||||
## 三、Web 管理端(`client/src`)
|
||||
- 新增 `api/noticeRule.ts`、`api/noticeBatch.ts`;`api/notice.ts` 按需加类型筛选。
|
||||
- 新增 `views/system/notice/index.vue`:Tabs「通知规则」(列表 + 编辑弹窗:事件、启停、提前天数、标题/正文模板、接收对象选择器)「手动群发」(标题/正文 + 接收对象 + 立即发送)「发送记录」。
|
||||
- `router/index.ts`:注册 `/system/notice`,`meta.permission='notice:rule'`(侧边栏由后端 `sys_menu` 动态生成,故必须配合迁移菜单)。
|
||||
- `layouts/index.vue`:`handleNoticeClick` 按 `noticeType/bizType` 映射跳转业务详情(管理端可跳电工登记页;无映射则仅标记已读)。
|
||||
|
||||
## 四、Flutter 管理端 App(`smart_grid_iot_manage_app_flutter`)
|
||||
- 新增 `features/notice/data/notice_api.dart`、`features/notice/presentation/notice_center_page.dart`(列表 + 未读高亮 + 全部已读 + 下拉刷新 + 分页)。
|
||||
- `core/providers.dart`:新增 `noticeUnreadCountProvider`(60s 轮询,登录态下生效)。
|
||||
- `app/router.dart`:注册 `/notice`(复用现有 Cupertino 右滑转场)。
|
||||
- `shared/widgets/x_top_bar.dart`:给 `trailing` 增加未读红点角标能力。
|
||||
- `workbench_tab.dart`:`onTrailingTap` → `context.push('/notice')`;其余 tab 顶栏统一挂红点。
|
||||
|
||||
## 五、Flutter 电工端 App(`smart_grid_iot_app_flutter`)
|
||||
- 同上新建 `features/notice/**` 与 `/notice` 路由、`noticeUnreadCountProvider`。
|
||||
- `order_tab.dart`/`earnings_tab.dart`/`profile_tab.dart` 三处铃铛:`onTrailingTap` 改为 `context.push('/notice')` + 红点。
|
||||
- 后端 `listNotices` 改为读 `sys_notice` 后,页面展示统一的消息中心(含证照到期、审核结果、派单通知)。
|
||||
|
||||
---
|
||||
|
||||
## 六、验证(端到端)
|
||||
|
||||
1. **后端**:`server` 下 `yarn dev` 无编译错误;`/api/notice-rules` CRUD、`/api/notice-batches` 群发返回 `{code:200}`;在「定时任务」页对 `notice_scan` 点「立即执行」。
|
||||
2. **幂等**:连续执行 `notice_scan` 两次,`sys_notice` 不新增重复(唯一索引 `uk_sys_notice_deliver` 生效)。
|
||||
3. **接收人解析**:配置规则接收对象为「角色 ADMIN + 指定电工」,确认管理端用户与电工各自收到且 `receiver_type` 正确。
|
||||
4. **埋点**:审核通过一个电工 → 该电工消息中心立即可见;且审核接口在通知写入失败时仍成功(断点/模拟异常验证)。
|
||||
5. **Web**:登录 ADMIN → 通知管理页配置规则 + 群发;顶栏铃铛角标与已读正常。
|
||||
6. **管理端 App**:登录 → workbench 铃铛红点 → 消息中心 → 标记已读 → 红点消失。
|
||||
7. **电工端 App**:登录电工账号 → 铃铛红点 → 消息中心 → 已读;审核通过/驳回后收到消息。
|
||||
8. **数据库**:核对 `sys_notice` 新旧索引替换、缓存数据保留。
|
||||
|
||||
---
|
||||
|
||||
## 七、分期与边界
|
||||
|
||||
- **本期**:电工域 / 客户域 / 工单域(扫描式 + 埋点式)+ 手动群发 + Web 配置页 + 两端 App 消息中心。
|
||||
- **下期**:项目域 / 验收 / 项目计划节点(仅需注册 provider 或在 service 埋点,框架不变)。
|
||||
- **不做**:离线/厂商推送(极光、个推、FCM)、消息撤销、已读回执、群发定时/草稿、发送失败重试、消息模板富文本。
|
||||
|
||||
## 八、假设与风险(如需调整请指出)
|
||||
|
||||
1. 管理端接口固定 `receiver_type='user'`,电工端固定 `'worker'`;两者 id 空间不同,靠 `receiver_type` 区分。
|
||||
2. 埋点会改动多个业务 service;一律 `try/catch` 且不参与业务事务提交(通知失败只记日志)。
|
||||
3. `sys_notice` 唯一索引需先 `DROP` 旧索引再建新索引,历史 cert 通知数据保留。
|
||||
4. 扫描式事件存在调度间隔延迟(默认每日 08:00,可在「定时任务」页按需调整)。
|
||||
5. `cert_expire.task.ts` 迁入 `notice_scan` 后删除;`@midwayjs/cron` 依赖是否保留取决于是否还有其它注解任务(`demo_heartbeat`/`log_clean` 已是注册表任务)。
|
||||
6. Flutter 两端新增页面的视觉细节(空态、时间格式)沿用各自现有组件与 `format_util`,不引入新设计。
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ReceiverConfig } from './noticeRule';
|
||||
|
||||
export interface NoticeBatch {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
receiverConfig?: ReceiverConfig;
|
||||
receiverCount: number;
|
||||
sentBy?: number | null;
|
||||
sentAt?: string | null;
|
||||
sentByName?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface NoticeBatchQuery {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface NoticeBatchListResult {
|
||||
list: NoticeBatch[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface NoticeBatchSendPayload {
|
||||
title: string;
|
||||
content: string;
|
||||
receiverConfig: ReceiverConfig;
|
||||
}
|
||||
|
||||
export interface NoticeBatchSendResult {
|
||||
batchId: number;
|
||||
receiverCount: number;
|
||||
}
|
||||
|
||||
// 手动群发(发送记录 + 立即发送)
|
||||
export const noticeBatchApi = {
|
||||
getList: (params: NoticeBatchQuery) =>
|
||||
request.get<NoticeBatchListResult>('/notice-batches', { params }),
|
||||
|
||||
send: (data: NoticeBatchSendPayload) =>
|
||||
request.post<NoticeBatchSendResult>('/notice-batches', data),
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 接收对象-管理端用户:按角色编码 + 按用户 id
|
||||
export interface ReceiverUsers {
|
||||
roles: string[];
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
// 接收对象-电工:none 不发 / event 事件关联电工 / all 全部在岗 / ids 指定 / status 按状态
|
||||
export type ReceiverWorkerMode = 'none' | 'event' | 'all' | 'ids' | 'status';
|
||||
|
||||
export interface ReceiverWorkers {
|
||||
mode: ReceiverWorkerMode;
|
||||
ids: number[];
|
||||
// 审核状态:0待审核 1已通过 2已驳回
|
||||
auditStatus?: number;
|
||||
// 在岗状态:1在岗 0离职
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ReceiverConfig {
|
||||
users: ReceiverUsers;
|
||||
workers: ReceiverWorkers;
|
||||
}
|
||||
|
||||
/** 生成默认接收对象配置(两端共用) */
|
||||
export function createDefaultReceiverConfig(): ReceiverConfig {
|
||||
return {
|
||||
users: { roles: [], ids: [] },
|
||||
workers: { mode: 'none', ids: [] },
|
||||
};
|
||||
}
|
||||
|
||||
export interface NoticeRule {
|
||||
id: number;
|
||||
ruleKey: string;
|
||||
ruleName: string;
|
||||
eventKey: string;
|
||||
// 1扫描式 2埋点式
|
||||
triggerMode: number;
|
||||
// 1启用 0停用
|
||||
enabled: number;
|
||||
advanceDays?: number | null;
|
||||
titleTpl: string;
|
||||
contentTpl: string;
|
||||
receiverConfig: ReceiverConfig;
|
||||
remark?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 模板可用变量 */
|
||||
export interface NoticeEventVariable {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 通知事件元数据 */
|
||||
export interface NoticeEventMeta {
|
||||
eventKey: string;
|
||||
eventName: string;
|
||||
// 1扫描式 2埋点式
|
||||
triggerMode: number;
|
||||
triggerModeText: string;
|
||||
variables: NoticeEventVariable[];
|
||||
}
|
||||
|
||||
export interface NoticeRuleQuery {
|
||||
keyword?: string;
|
||||
eventKey?: string;
|
||||
triggerMode?: number;
|
||||
enabled?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface NoticeRuleListResult {
|
||||
list: NoticeRule[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 新增/更新通知规则入参(更新时不传 ruleKey) */
|
||||
export interface NoticeRulePayload {
|
||||
ruleKey?: string;
|
||||
ruleName: string;
|
||||
eventKey: string;
|
||||
triggerMode: number;
|
||||
enabled: number;
|
||||
advanceDays?: number;
|
||||
titleTpl: string;
|
||||
contentTpl: string;
|
||||
receiverConfig: ReceiverConfig;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface NoticeScanResult {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// 通知规则(规则配置 + 立即扫描)
|
||||
export const noticeRuleApi = {
|
||||
getList: (params: NoticeRuleQuery) => request.get<NoticeRuleListResult>('/notice-rules', { params }),
|
||||
|
||||
getEvents: () => request.get<NoticeEventMeta[]>('/notice-rules/events'),
|
||||
|
||||
runScan: () => request.post<NoticeScanResult>('/notice-rules/run-scan'),
|
||||
|
||||
getDetail: (id: number) => request.get<NoticeRule>(`/notice-rules/${id}`),
|
||||
|
||||
create: (data: NoticeRulePayload) => request.post<NoticeRule>('/notice-rules', data),
|
||||
|
||||
update: (id: number, data: Omit<NoticeRulePayload, 'ruleKey'>) =>
|
||||
request.put<NoticeRule>(`/notice-rules/${id}`, data),
|
||||
|
||||
updateEnabled: (id: number, enabled: 0 | 1) =>
|
||||
request.put(`/notice-rules/${id}/enabled`, { enabled }),
|
||||
|
||||
remove: (id: number) => request.delete(`/notice-rules/${id}`),
|
||||
};
|
||||
@@ -373,18 +373,47 @@ function openNoticeDrawer() {
|
||||
async function handleNoticeClick(item: NoticeInfo) {
|
||||
if (item.isRead === 0) {
|
||||
try {
|
||||
await noticeApi.markRead(item.id)
|
||||
item.isRead = 1
|
||||
fetchUnreadCount()
|
||||
await noticeApi.markRead(item.id);
|
||||
item.isRead = 1;
|
||||
fetchUnreadCount();
|
||||
} catch (error) {
|
||||
// 标记失败不阻断后续跳转
|
||||
}
|
||||
}
|
||||
// 电工资质到期提醒 → 跳转电工登记管理
|
||||
if (item.bizType === 'worker_cert') {
|
||||
noticeVisible.value = false
|
||||
router.push('/dispatch/worker')
|
||||
const target = resolveNoticeRoute(item);
|
||||
if (target) {
|
||||
noticeVisible.value = false;
|
||||
router.push(target);
|
||||
}
|
||||
// 无映射关系时仅标记已读,不做跳转
|
||||
}
|
||||
|
||||
// 通知类型 / 业务类型 → 目标路由映射(无匹配返回 null)
|
||||
const NOTICE_TYPE_ROUTE: Record<string, string> = {
|
||||
// 电工资质证照到期提醒
|
||||
cert_expire: '/dispatch/worker',
|
||||
// 电工入驻审核结果通知
|
||||
worker_audit_result: '/dispatch/worker',
|
||||
// 兼容旧数据
|
||||
worker_cert: '/dispatch/worker',
|
||||
};
|
||||
|
||||
// 派单通知按工单业务类型跳转对应派单页
|
||||
const NOTICE_BIZ_ROUTE: Record<string, string> = {
|
||||
repair_dispatch: '/dispatch/repair',
|
||||
maintenance_order: '/dispatch/maintenance',
|
||||
install_order: '/project/install',
|
||||
survey_order: '/project/survey',
|
||||
};
|
||||
|
||||
function resolveNoticeRoute(item: NoticeInfo): string | null {
|
||||
const byType = NOTICE_TYPE_ROUTE[item.noticeType];
|
||||
if (byType) return byType;
|
||||
if (item.noticeType === 'dispatch_assign' && item.bizType) {
|
||||
return NOTICE_BIZ_ROUTE[item.bizType] || null;
|
||||
}
|
||||
// 业务类型兜底
|
||||
return item.bizType ? NOTICE_BIZ_ROUTE[item.bizType] || NOTICE_TYPE_ROUTE[item.bizType] || null : null;
|
||||
}
|
||||
|
||||
async function handleReadAll() {
|
||||
|
||||
@@ -179,7 +179,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: "dispatch/cost",
|
||||
name: "DispatchCost",
|
||||
component: () => import("@/views/dispatch/cost/index.vue"),
|
||||
meta: { requiresAuth: true, title: "电工派单成本价", permission: "dispatch:cost" }
|
||||
meta: { requiresAuth: true, title: "勘察费用", permission: "dispatch:cost" }
|
||||
},
|
||||
{
|
||||
path: "dispatch/worker",
|
||||
@@ -230,6 +230,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/system/job/index.vue"),
|
||||
meta: { requiresAuth: true, title: "定时任务", permission: "system:job" }
|
||||
},
|
||||
{
|
||||
path: "system/notice",
|
||||
name: "SystemNotice",
|
||||
component: () => import("@/views/system/notice/index.vue"),
|
||||
meta: { requiresAuth: true, title: "通知管理", permission: "notice:rule" }
|
||||
},
|
||||
{
|
||||
path: "log/operation",
|
||||
name: "OperationLog",
|
||||
|
||||
@@ -5,23 +5,23 @@
|
||||
<div class="breadcrumb">
|
||||
<span>运营管控体系</span>
|
||||
<span class="sep">/</span>
|
||||
<span>运营派单</span>
|
||||
<span>项目管理</span>
|
||||
<span class="sep">/</span>
|
||||
<span class="cur">电工派单成本价</span>
|
||||
<span class="cur">勘察费用</span>
|
||||
</div>
|
||||
<div class="header-main">
|
||||
<div class="header-left">
|
||||
<div class="header-row">
|
||||
<span class="title-bar"></span>
|
||||
<span class="main-title">电工派单成本价</span>
|
||||
<span class="sub-title">DISPATCH COST PRICE</span>
|
||||
<span class="main-title">勘察费用</span>
|
||||
<span class="sub-title">SURVEY FEE</span>
|
||||
</div>
|
||||
<p class="desc">维护共享电工派单的成本价目,按派单类型区分单价或总价提成,支持按次、按工时两种计价方式。</p>
|
||||
<p class="desc">维护勘察派单相关费用标准(基础出场费、回路测点补贴等),支持按次、按工时两种计价方式。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button v-if="canCreate" type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增成本价
|
||||
新增勘察费用
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="list-header">
|
||||
<div class="list-title">
|
||||
<span class="dot"></span>
|
||||
<span class="t">电工派单成本价清单</span>
|
||||
<span class="t">勘察费用清单</span>
|
||||
<span class="count">{{ total }}</span>
|
||||
</div>
|
||||
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
|
||||
@@ -152,7 +152,7 @@
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="canDelete"
|
||||
title="确定要删除该成本价吗?"
|
||||
title="确定要删除该勘察费用吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
ok-button-props="{ danger: true }"
|
||||
@@ -168,7 +168,7 @@
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<a-empty description="暂无成本价数据" />
|
||||
<a-empty description="暂无勘察费用数据" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
@@ -203,7 +203,7 @@
|
||||
<div class="section-head">
|
||||
<span class="section-no">01</span>
|
||||
<div class="section-text">
|
||||
<span class="section-name">成本价信息</span>
|
||||
<span class="section-name">勘察费用信息</span>
|
||||
<span class="section-sub">Cost Price Information</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,7 +367,7 @@ async function fetchList() {
|
||||
total.value = res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取电工派单成本价列表失败');
|
||||
message.error('获取勘察费用列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -399,7 +399,7 @@ function handleAdd() {
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
editCode.value = '';
|
||||
modalTitle.value = '新增成本价';
|
||||
modalTitle.value = '新增勘察费用';
|
||||
resetForm();
|
||||
modalVisible.value = true;
|
||||
}
|
||||
@@ -408,7 +408,7 @@ function handleEdit(record: DispatchPriceInfo) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
editCode.value = record.priceCode;
|
||||
modalTitle.value = '编辑成本价';
|
||||
modalTitle.value = '编辑勘察费用';
|
||||
resetForm();
|
||||
|
||||
formData.dispatchType = record.dispatchType || undefined;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="receiver-editor">
|
||||
<div class="re-section">
|
||||
<div class="re-section-title">管理端用户</div>
|
||||
<div class="re-field">
|
||||
<label class="re-label">按角色</label>
|
||||
<a-select
|
||||
v-model:value="config.users.roles"
|
||||
mode="multiple"
|
||||
placeholder="选择角色编码(可多选)"
|
||||
allow-clear
|
||||
:options="roleOptions"
|
||||
:loading="roleLoading"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="re-field">
|
||||
<label class="re-label">按用户</label>
|
||||
<a-select
|
||||
v-model:value="config.users.ids"
|
||||
mode="multiple"
|
||||
placeholder="选择具体用户(可多选)"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="userOptions"
|
||||
:loading="userLoading"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="re-section">
|
||||
<div class="re-section-title">电工</div>
|
||||
<div class="re-field re-field-block">
|
||||
<label class="re-label">接收方式</label>
|
||||
<a-radio-group v-model:value="config.workers.mode" class="re-radio">
|
||||
<a-radio value="none">不发送</a-radio>
|
||||
<a-radio v-if="allowEventMode" value="event">事件关联电工</a-radio>
|
||||
<a-radio value="all">全部在岗电工</a-radio>
|
||||
<a-radio value="ids">指定电工</a-radio>
|
||||
<a-radio value="status">按状态筛选</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-if="config.workers.mode === 'ids'" class="re-field">
|
||||
<label class="re-label">指定电工</label>
|
||||
<a-select
|
||||
v-model:value="config.workers.ids"
|
||||
mode="multiple"
|
||||
placeholder="选择电工(可多选)"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="workerOptions"
|
||||
:loading="workerLoading"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="config.workers.mode === 'status'" class="re-field">
|
||||
<label class="re-label">审核状态</label>
|
||||
<a-select
|
||||
v-model:value="config.workers.auditStatus"
|
||||
placeholder="全部审核状态"
|
||||
allow-clear
|
||||
:options="auditOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="config.workers.mode === 'status'" class="re-field">
|
||||
<label class="re-label">在岗状态</label>
|
||||
<a-select
|
||||
v-model:value="config.workers.status"
|
||||
placeholder="全部在岗状态"
|
||||
allow-clear
|
||||
:options="statusOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { roleApi } from '@/api/role';
|
||||
import { userApi } from '@/api/user';
|
||||
import { workerApi } from '@/api/worker';
|
||||
import type { ReceiverConfig } from '@/api/noticeRule';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
// 直接持有的响应式接收对象配置(嵌套字段就地修改,父级实时感知)
|
||||
config: ReceiverConfig;
|
||||
// 手动群发无「事件关联」上下文,可关闭该选项
|
||||
allowEventMode?: boolean;
|
||||
}>(),
|
||||
{ allowEventMode: true }
|
||||
);
|
||||
|
||||
const roleOptions = ref<{ value: string; label: string }[]>([]);
|
||||
const userOptions = ref<{ value: number; label: string }[]>([]);
|
||||
const workerOptions = ref<{ value: number; label: string }[]>([]);
|
||||
|
||||
const roleLoading = ref(false);
|
||||
const userLoading = ref(false);
|
||||
const workerLoading = ref(false);
|
||||
|
||||
const auditOptions = [
|
||||
{ value: 0, label: '待审核' },
|
||||
{ value: 1, label: '已通过' },
|
||||
{ value: 2, label: '已驳回' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 1, label: '在岗' },
|
||||
{ value: 0, label: '离职' },
|
||||
];
|
||||
|
||||
// 角色/用户/电工下拉为可选增强项,拉取失败(如无对应菜单权限)不阻断配置
|
||||
async function loadRoles() {
|
||||
roleLoading.value = true;
|
||||
try {
|
||||
const res = await roleApi.getAllRoles();
|
||||
if (res.code === 200) {
|
||||
roleOptions.value = (res.data || []).map((r) => ({
|
||||
value: r.roleCode,
|
||||
label: `${r.roleName}(${r.roleCode})`,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// 忽略:无权限时由用户手动维护角色编码
|
||||
} finally {
|
||||
roleLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
userLoading.value = true;
|
||||
try {
|
||||
const res = await userApi.getUserList({ page: 1, pageSize: 200 });
|
||||
if (res.code === 200) {
|
||||
userOptions.value = (res.data?.list || []).map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.realName || u.username}(${u.username})`,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
} finally {
|
||||
userLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
workerLoading.value = true;
|
||||
try {
|
||||
const res = await workerApi.getOptions();
|
||||
if (res.code === 200) {
|
||||
workerOptions.value = (res.data || []).map((w) => ({
|
||||
value: w.id,
|
||||
label: w.workerTitle ? `${w.workerName}(${w.workerTitle})` : w.workerName,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
} finally {
|
||||
workerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles();
|
||||
loadUsers();
|
||||
loadWorkers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.receiver-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.re-section {
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.re-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.re-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.re-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.re-field-block {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.re-label {
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
}
|
||||
|
||||
.re-radio {
|
||||
line-height: 2;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
+88
-13
@@ -132,10 +132,14 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- 初始菜单(运营派单子菜单,parent_id=3,id:36-37)
|
||||
-- 初始菜单(运营派单子菜单,parent_id=3,id:55-56)
|
||||
-- 运营派单域统一 id 分配(避免历史上 36/37 被两段 INSERT 复用而互相覆盖):
|
||||
-- 34 运维费定价 / 35 勘察费用(归属项目管理 parent_id=2) / 36 电工登记管理
|
||||
-- 37-46 按钮权限
|
||||
-- 55 报修派单 / 56 维保派单
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(36, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 1, 2, 'dispatch:repair', 1),
|
||||
(37, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 2, 2, 'dispatch:maintenance',1)
|
||||
(55, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 4, 2, 'dispatch:repair', 1),
|
||||
(56, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 5, 2, 'dispatch:maintenance',1)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
menu_name = EXCLUDED.menu_name,
|
||||
@@ -147,17 +151,17 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- 运营派单子菜单(parent_id=3,id:34-36 为菜单项,37-46 为按钮权限)
|
||||
-- 运营派单子菜单(parent_id=3;其中 35 勘察费用及其按钮 40/41/42 已归属项目管理 parent_id=2;id:34/36 为菜单项,37-39/43-46 为按钮权限)
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 1, 2, 'dispatch:price', 1),
|
||||
(35, 3, '电工派单成本价', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 2, 2, 'dispatch:cost', 1),
|
||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 3, 2, 'dispatch:worker', 1),
|
||||
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
|
||||
(35, 2, '勘察费用', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
|
||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 1, 2, 'dispatch:worker', 1),
|
||||
(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
|
||||
(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
|
||||
(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
|
||||
(40, 3, '新增成本价', NULL, NULL, NULL, 4, 3, 'cost:create', 1),
|
||||
(41, 3, '编辑成本价', NULL, NULL, NULL, 5, 3, 'cost:update', 1),
|
||||
(42, 3, '删除成本价', NULL, NULL, NULL, 6, 3, 'cost:delete', 1),
|
||||
(40, 2, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
|
||||
(41, 2, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
|
||||
(42, 2, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
|
||||
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
|
||||
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
|
||||
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
|
||||
@@ -256,11 +260,11 @@ ON CONFLICT (user_id, role_id) DO NOTHING;
|
||||
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, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 36), (1, 37),
|
||||
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 55), (1, 56),
|
||||
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46),
|
||||
(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, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 36), (2, 37)
|
||||
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 55), (2, 56)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 移除已废弃的菜单(客户建筑/客户房间,id=25/26)及其角色绑定(幂等清理,确保每次执行都收敛)
|
||||
@@ -301,8 +305,79 @@ INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, re
|
||||
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 9. 通知域菜单(系统管理 parent_id=6,id:51 为菜单项,52-54 为按钮权限)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(51, 6, '通知管理', '/system/notice', 'system/notice/index', 'BellOutlined', 7, 2, 'notice:rule', 1),
|
||||
(52, 51, '保存规则', NULL, NULL, NULL, 1, 3, 'notice:rule:update', 1),
|
||||
(53, 51, '删除规则', NULL, NULL, NULL, 2, 3, 'notice:rule:delete', 1),
|
||||
(54, 51, '群发消息', NULL, NULL, NULL, 3, 3, 'notice:batch', 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;
|
||||
|
||||
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 51-54
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 51), (1, 52), (1, 53), (1, 54)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列
|
||||
SELECT setval('sys_menu_id_seq', 56, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||
('notice_scan', '通知规则扫描', '0 0 8 * * *', '{}'::jsonb, 1, 300, 1,
|
||||
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_notice_rule (rule_key, rule_name, event_key, trigger_mode, enabled, advance_days, title_tpl, content_tpl, receiver_config, remark) VALUES
|
||||
('cert_expire', '电工资质证照到期提醒', 'cert_expire', 1, 1, 30,
|
||||
'电工【{workerName}】的{certType}{expireState}',
|
||||
'证书编号:{certNo};发证机关:{issueAuthority};有效期至:{expireDate};{remainDaysText},请及时提醒电工办理续期',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"event","ids":[]}}'::jsonb,
|
||||
'扫描 worker_cert 中 advance_days 天内到期或已过期的证照,通知 ADMIN 与证照所属电工'),
|
||||
('worker_audit_result', '电工入驻审核结果通知', 'worker_audit_result', 2, 1, NULL,
|
||||
'您的入驻审核{auditState}',
|
||||
'审核{auditState};{auditRemarkText}如资料有误,请修改后重新提交。',
|
||||
'{"users":{"roles":[],"ids":[]},"workers":{"mode":"event","ids":[]}}'::jsonb,
|
||||
'审核通过/驳回时实时通知电工本人(业务埋点触发)'),
|
||||
('dispatch_assign', '工单派单通知', 'dispatch_assign', 2, 1, NULL,
|
||||
'您有新的{orderTypeName}工单',
|
||||
'工单编号:{orderNo};客户:{customerName};{detailText}请及时在电工端查看并接单。',
|
||||
'{"users":{"roles":[],"ids":[]},"workers":{"mode":"event","ids":[]}}'::jsonb,
|
||||
'勘察/安装/维保/报修派单时实时通知被指派电工(业务埋点触发)'),
|
||||
('contract_expire', '客户合同到期提醒', 'contract_expire', 1, 1, 30,
|
||||
'客户【{customerName}】的合同{expireState}',
|
||||
'合同编号:{contractNo};合同名称:{contractName};到期日:{endDate};{remainDaysText},请及时跟进续签。',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb,
|
||||
'扫描 contract 中 advance_days 天内到期或已过期的合同,通知 ADMIN'),
|
||||
('visit_plan', '客户拜访计划提醒', 'visit_plan', 1, 1, 0,
|
||||
'客户拜访计划待执行:{customerName}',
|
||||
'计划拜访日期:{planDate};{remainDaysText}。请及时完成拜访。',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb,
|
||||
'扫描 customer_visit 中未执行且计划日期已到/超期的拜访计划,通知 ADMIN'),
|
||||
('order_timeout', '工单超时未接单提醒', 'order_timeout', 1, 1, 1,
|
||||
'工单超时未接单:{orderTypeName}',
|
||||
'工单编号:{orderNo};客户:{customerName};已派单 {staleDays} 天仍未接单,请及时跟进。',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb,
|
||||
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
|
||||
ON CONFLICT (rule_key) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================
|
||||
-- 完 · 系统表种子数据(4 角色 + 50 菜单 + 43 字典 + 1 管理员 + 2 定时任务 + 绑定关系)
|
||||
-- 完 · 系统表种子数据(4 角色 + 54 菜单 + 43 字典 + 1 管理员 + 3 定时任务 + 6 通知规则 + 绑定关系)
|
||||
-- ============================================================
|
||||
@@ -138,14 +138,14 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_notice_biz ON sys_notice (notice_type,
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 6) 菜单与权限
|
||||
-- 「运营派单」由顶级菜单项改为目录,运维费定价/成本价/电工登记作为其子菜单
|
||||
-- 「运营派单」由顶级菜单项改为目录,运维费定价/电工登记作为其子菜单(勘察费用归属「项目管理」)
|
||||
-- ------------------------------------------------------------
|
||||
UPDATE sys_menu SET menu_type = 1 WHERE id = 3;
|
||||
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 1, 2, 'dispatch:price', 1),
|
||||
(35, 3, '电工派单成本价', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 2, 2, 'dispatch:cost', 1),
|
||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 3, 2, 'dispatch:worker', 1)
|
||||
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
|
||||
(35, 2, '勘察费用', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
|
||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 1, 2, 'dispatch:worker', 1)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
menu_name = EXCLUDED.menu_name,
|
||||
@@ -162,9 +162,9 @@ INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort
|
||||
(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
|
||||
(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
|
||||
(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
|
||||
(40, 3, '新增成本价', NULL, NULL, NULL, 4, 3, 'cost:create', 1),
|
||||
(41, 3, '编辑成本价', NULL, NULL, NULL, 5, 3, 'cost:update', 1),
|
||||
(42, 3, '删除成本价', NULL, NULL, NULL, 6, 3, 'cost:delete', 1),
|
||||
(40, 2, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
|
||||
(41, 2, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
|
||||
(42, 2, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
|
||||
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
|
||||
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
|
||||
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
-- 可配置消息通知中心
|
||||
-- 背景:现有站内通知为硬编码单点(仅「证照到期」一种、标题模板写死、接收人写死为 ADMIN),
|
||||
-- 且仅 Web 管理端可见;两个移动端(管理端 App / 电工端 App)没有消息中心。
|
||||
-- 本期建立「可配置 + 两端统一」的站内消息中心:
|
||||
-- - 改造 sys_notice:新增 receiver_type(user|worker),两端共用一套消息数据;
|
||||
-- - 新增 sys_notice_rule(通知规则模板 + 接收对象配置);
|
||||
-- - 新增 sys_notice_batch(手动群发批次/发送记录);
|
||||
-- - 新增内置任务 notice_scan(扫描式规则统一入口)与首批规则种子;
|
||||
-- - 挂载系统管理下的「通知管理」菜单与按钮权限。
|
||||
-- 幂等:新增列带 DEFAULT + IF NOT EXISTS;建表/索引用 IF NOT EXISTS;菜单/绑定用 ON CONFLICT;
|
||||
-- 任务与规则种子用 ON CONFLICT DO NOTHING。
|
||||
-- 注意:菜单 id 51-54,避开 20261001(34-46)与 20261002(47-50)已占用区间。
|
||||
BEGIN;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1) 改造 sys_notice:增加接收人类型与来源引用
|
||||
-- ------------------------------------------------------------
|
||||
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS receiver_type VARCHAR(16) NOT NULL DEFAULT 'user'; -- 接收人类型:user-管理端用户 worker-电工
|
||||
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS rule_id BIGINT; -- 来源通知规则(→ sys_notice_rule.id)
|
||||
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS batch_id BIGINT; -- 来源群发批次(→ sys_notice_batch.id)
|
||||
|
||||
COMMENT ON COLUMN sys_notice.receiver_type IS '接收人类型:user-管理端用户 worker-电工';
|
||||
COMMENT ON COLUMN sys_notice.rule_id IS '来源通知规则(→ sys_notice_rule.id)';
|
||||
COMMENT ON COLUMN sys_notice.batch_id IS '来源群发批次(→ sys_notice_batch.id)';
|
||||
|
||||
-- 幂等键升级:加入 receiver_type(user 与 worker 的 id 空间不同,必须区分)
|
||||
DROP INDEX IF EXISTS uk_sys_notice_biz;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_notice_deliver
|
||||
ON sys_notice (notice_type, biz_type, biz_id, receiver_type, receiver_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_notice_receiver_type ON sys_notice (receiver_type, receiver_id, is_read);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2) 通知规则表(规则模板 + 接收对象配置)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sys_notice_rule (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rule_key VARCHAR(64) NOT NULL, -- 规则键(唯一)
|
||||
rule_name VARCHAR(64) NOT NULL, -- 规则名称
|
||||
event_key VARCHAR(64) NOT NULL, -- 事件键(对应代码事件注册表)
|
||||
trigger_mode SMALLINT NOT NULL DEFAULT 1, -- 触发方式:1扫描式 2埋点式
|
||||
enabled SMALLINT NOT NULL DEFAULT 1, -- 状态:1启用 0停用
|
||||
advance_days INT, -- 提前天数(扫描式)
|
||||
title_tpl VARCHAR(128) NOT NULL, -- 标题模板(支持 {变量})
|
||||
content_tpl VARCHAR(512) NOT NULL, -- 正文模板(支持 {变量})
|
||||
receiver_config JSONB NOT NULL DEFAULT '{"users":{"roles":[],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb, -- 接收对象配置
|
||||
remark VARCHAR(255), -- 备注
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE sys_notice_rule IS '通知规则表(模板 + 接收对象)';
|
||||
COMMENT ON COLUMN sys_notice_rule.id IS '自增主键';
|
||||
COMMENT ON COLUMN sys_notice_rule.rule_key IS '规则键(唯一)';
|
||||
COMMENT ON COLUMN sys_notice_rule.rule_name IS '规则名称';
|
||||
COMMENT ON COLUMN sys_notice_rule.event_key IS '事件键(对应代码事件注册表)';
|
||||
COMMENT ON COLUMN sys_notice_rule.trigger_mode IS '触发方式:1扫描式 2埋点式';
|
||||
COMMENT ON COLUMN sys_notice_rule.enabled IS '状态:1启用 0停用';
|
||||
COMMENT ON COLUMN sys_notice_rule.advance_days IS '提前天数(扫描式)';
|
||||
COMMENT ON COLUMN sys_notice_rule.title_tpl IS '标题模板(支持 {变量})';
|
||||
COMMENT ON COLUMN sys_notice_rule.content_tpl IS '正文模板(支持 {变量})';
|
||||
COMMENT ON COLUMN sys_notice_rule.receiver_config IS '接收对象配置:users{roles,ids} + workers{mode:none|event|all|ids|status,ids,auditStatus,status}';
|
||||
COMMENT ON COLUMN sys_notice_rule.remark IS '备注';
|
||||
COMMENT ON COLUMN sys_notice_rule.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN sys_notice_rule.updated_at IS '更新时间';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_notice_rule_key ON sys_notice_rule (rule_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_notice_rule_event ON sys_notice_rule (event_key, trigger_mode, enabled);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3) 手动群发批次表(发送记录)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sys_notice_batch (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(128) NOT NULL, -- 消息标题
|
||||
content VARCHAR(512) NOT NULL, -- 消息正文
|
||||
receiver_config JSONB NOT NULL DEFAULT '{"users":{"roles":[],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb, -- 接收对象快照
|
||||
receiver_count INT NOT NULL DEFAULT 0, -- 实际接收人数
|
||||
sent_by BIGINT, -- 发送人(→ sys_user.id)
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 发送时间
|
||||
);
|
||||
COMMENT ON TABLE sys_notice_batch IS '手动群发批次表(发送记录)';
|
||||
COMMENT ON COLUMN sys_notice_batch.id IS '自增主键';
|
||||
COMMENT ON COLUMN sys_notice_batch.title IS '消息标题';
|
||||
COMMENT ON COLUMN sys_notice_batch.content IS '消息正文';
|
||||
COMMENT ON COLUMN sys_notice_batch.receiver_config IS '接收对象快照';
|
||||
COMMENT ON COLUMN sys_notice_batch.receiver_count IS '实际接收人数';
|
||||
COMMENT ON COLUMN sys_notice_batch.sent_by IS '发送人(→ sys_user.id)';
|
||||
COMMENT ON COLUMN sys_notice_batch.sent_at IS '发送时间';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4) 菜单与权限(系统管理 parent_id=6 下新增「通知管理」+ 3 个按钮权限)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(51, 6, '通知管理', '/system/notice', 'system/notice/index', 'BellOutlined', 7, 2, 'notice:rule', 1),
|
||||
(52, 51, '保存规则', NULL, NULL, NULL, 1, 3, 'notice:rule:update', 1),
|
||||
(53, 51, '删除规则', NULL, NULL, NULL, 2, 3, 'notice:rule:delete', 1),
|
||||
(54, 51, '群发消息', NULL, NULL, NULL, 3, 3, 'notice:batch', 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;
|
||||
|
||||
-- 角色绑定:ADMIN(role_id=1)拥有 51~54
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 51), (1, 52), (1, 53), (1, 54)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列,避免后续插入 id 冲突
|
||||
SELECT setval('sys_menu_id_seq', 54, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 5) 内置任务种子:通知规则扫描(扫描式规则的统一入口)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||
('notice_scan', '通知规则扫描', '0 0 8 * * *', '{}'::jsonb, 1, 300, 1,
|
||||
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 6) 首批通知规则种子(按 rule_key 幂等;接收对象均可在「通知管理」页调整)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_notice_rule (rule_key, rule_name, event_key, trigger_mode, enabled, advance_days, title_tpl, content_tpl, receiver_config, remark) VALUES
|
||||
('cert_expire', '电工资质证照到期提醒', 'cert_expire', 1, 1, 30,
|
||||
'电工【{workerName}】的{certType}{expireState}',
|
||||
'证书编号:{certNo};发证机关:{issueAuthority};有效期至:{expireDate};{remainDaysText},请及时提醒电工办理续期',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"event","ids":[]}}'::jsonb,
|
||||
'扫描 worker_cert 中 advance_days 天内到期或已过期的证照,通知 ADMIN 与证照所属电工'),
|
||||
('worker_audit_result', '电工入驻审核结果通知', 'worker_audit_result', 2, 1, NULL,
|
||||
'您的入驻审核{auditState}',
|
||||
'审核{auditState};{auditRemarkText}如资料有误,请修改后重新提交。',
|
||||
'{"users":{"roles":[],"ids":[]},"workers":{"mode":"event","ids":[]}}'::jsonb,
|
||||
'审核通过/驳回时实时通知电工本人(业务埋点触发)'),
|
||||
('dispatch_assign', '工单派单通知', 'dispatch_assign', 2, 1, NULL,
|
||||
'您有新的{orderTypeName}工单',
|
||||
'工单编号:{orderNo};客户:{customerName};{detailText}请及时在电工端查看并接单。',
|
||||
'{"users":{"roles":[],"ids":[]},"workers":{"mode":"event","ids":[]}}'::jsonb,
|
||||
'勘察/安装/维保/报修派单时实时通知被指派电工(业务埋点触发)'),
|
||||
('contract_expire', '客户合同到期提醒', 'contract_expire', 1, 1, 30,
|
||||
'客户【{customerName}】的合同{expireState}',
|
||||
'合同编号:{contractNo};合同名称:{contractName};到期日:{endDate};{remainDaysText},请及时跟进续签。',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb,
|
||||
'扫描 contract 中 advance_days 天内到期或已过期的合同,通知 ADMIN'),
|
||||
('visit_plan', '客户拜访计划提醒', 'visit_plan', 1, 1, 0,
|
||||
'客户拜访计划待执行:{customerName}',
|
||||
'计划拜访日期:{planDate};{remainDaysText}。请及时完成拜访。',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb,
|
||||
'扫描 customer_visit 中未执行且计划日期已到/超期的拜访计划,通知 ADMIN'),
|
||||
('order_timeout', '工单超时未接单提醒', 'order_timeout', 1, 1, 1,
|
||||
'工单超时未接单:{orderTypeName}',
|
||||
'工单编号:{orderNo};客户:{customerName};已派单 {staleDays} 天仍未接单,请及时跟进。',
|
||||
'{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"none","ids":[]}}'::jsonb,
|
||||
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
|
||||
ON CONFLICT (rule_key) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -12,23 +12,23 @@ export class NoticeController {
|
||||
|
||||
@Get("/")
|
||||
async list(@Query() query: any) {
|
||||
return await this.noticeService.listMy(this.currentUserId(), query);
|
||||
return await this.noticeService.listMy("user", this.currentUserId(), query);
|
||||
}
|
||||
|
||||
// 注意:静态路径必须注册在动态路径 /:id/read 之前
|
||||
@Get("/unread-count")
|
||||
async unreadCount() {
|
||||
return await this.noticeService.unreadCount(this.currentUserId());
|
||||
return await this.noticeService.unreadCount("user", this.currentUserId());
|
||||
}
|
||||
|
||||
@Put("/read-all")
|
||||
async markAllRead() {
|
||||
return await this.noticeService.markAllRead(this.currentUserId());
|
||||
return await this.noticeService.markAllRead("user", this.currentUserId());
|
||||
}
|
||||
|
||||
@Put("/:id/read")
|
||||
async markRead(@Param("id") id: string) {
|
||||
return await this.noticeService.markRead(this.currentUserId(), Number(id));
|
||||
return await this.noticeService.markRead("user", this.currentUserId(), Number(id));
|
||||
}
|
||||
|
||||
private currentUserId(): number {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Post, Inject, Query, Body } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { NoticeBatchService } from "../service/notice_batch.service";
|
||||
import { NoticeBatchQueryDTO, NoticeBatchCreateDTO } from "../dto/notice_batch.dto";
|
||||
|
||||
@Controller("/api/notice-batches")
|
||||
export class NoticeBatchController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
noticeBatchService!: NoticeBatchService;
|
||||
|
||||
@Get("/")
|
||||
async list(@Query() query: NoticeBatchQueryDTO) {
|
||||
return this.noticeBatchService.list(query);
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
async send(@Body() dto: NoticeBatchCreateDTO) {
|
||||
return this.noticeBatchService.send(dto, this.currentUserId());
|
||||
}
|
||||
|
||||
private currentUserId(): number {
|
||||
return Number(this.ctx.state.user?.userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { NoticeRuleService } from "../service/notice_rule.service";
|
||||
import {
|
||||
NoticeRuleQueryDTO,
|
||||
NoticeRuleCreateDTO,
|
||||
NoticeRuleUpdateDTO,
|
||||
NoticeRuleEnabledDTO
|
||||
} from "../dto/notice_rule.dto";
|
||||
|
||||
@Controller("/api/notice-rules")
|
||||
export class NoticeRuleController {
|
||||
@Inject()
|
||||
noticeRuleService!: NoticeRuleService;
|
||||
|
||||
@Get("/")
|
||||
async list(@Query() query: NoticeRuleQueryDTO) {
|
||||
return this.noticeRuleService.list(query);
|
||||
}
|
||||
|
||||
// 静态路径必须声明在动态路径 /:id 之前
|
||||
@Get("/events")
|
||||
async events() {
|
||||
return this.noticeRuleService.eventOptions();
|
||||
}
|
||||
|
||||
@Post("/run-scan")
|
||||
async runScan() {
|
||||
return this.noticeRuleService.runScanNow();
|
||||
}
|
||||
|
||||
@Get("/:id")
|
||||
async detail(@Param("id") id: string) {
|
||||
return this.noticeRuleService.detail(Number(id));
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
async create(@Body() dto: NoticeRuleCreateDTO) {
|
||||
return this.noticeRuleService.create(dto);
|
||||
}
|
||||
|
||||
@Put("/:id")
|
||||
async update(@Param("id") id: string, @Body() dto: NoticeRuleUpdateDTO) {
|
||||
return this.noticeRuleService.update(Number(id), dto);
|
||||
}
|
||||
|
||||
@Put("/:id/enabled")
|
||||
async changeEnabled(@Param("id") id: string, @Body() dto: NoticeRuleEnabledDTO) {
|
||||
return this.noticeRuleService.changeEnabled(Number(id), dto.enabled);
|
||||
}
|
||||
|
||||
@Del("/:id")
|
||||
async remove(@Param("id") id: string) {
|
||||
return this.noticeRuleService.remove(Number(id));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { WorkerAppService } from "../service/worker_app.service";
|
||||
import { NoticeService } from "../service/notice.service";
|
||||
import {
|
||||
WorkerRegisterDTO,
|
||||
WorkerLoginDTO,
|
||||
@@ -16,6 +17,9 @@ export class WorkerAppController {
|
||||
@Inject()
|
||||
workerAppService!: WorkerAppService;
|
||||
|
||||
@Inject()
|
||||
noticeService!: NoticeService;
|
||||
|
||||
// 注册(匿名)
|
||||
@Post("/auth/register")
|
||||
async register(@Body() body: WorkerRegisterDTO) {
|
||||
@@ -66,10 +70,25 @@ export class WorkerAppController {
|
||||
return this.workerAppService.resubmit(this.currentWorkerId());
|
||||
}
|
||||
|
||||
// 证照到期提醒
|
||||
// 站内消息中心:静态路径必须声明在动态路径 /notices/:id/read 之前
|
||||
@Get("/notices")
|
||||
async listNotices() {
|
||||
return this.workerAppService.listNotices(this.currentWorkerId());
|
||||
async listNotices(@Query() query: any) {
|
||||
return this.noticeService.listMy("worker", this.currentWorkerId(), query);
|
||||
}
|
||||
|
||||
@Get("/notices/unread-count")
|
||||
async noticeUnreadCount() {
|
||||
return this.noticeService.unreadCount("worker", this.currentWorkerId());
|
||||
}
|
||||
|
||||
@Put("/notices/read-all")
|
||||
async markAllNoticesRead() {
|
||||
return this.noticeService.markAllRead("worker", this.currentWorkerId());
|
||||
}
|
||||
|
||||
@Put("/notices/:id/read")
|
||||
async markNoticeRead(@Param("id") id: string) {
|
||||
return this.noticeService.markRead("worker", this.currentWorkerId(), Number(id));
|
||||
}
|
||||
|
||||
// 订单池:静态路径必须声明在动态路径 /orders/:source/:id 之前
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsOptional, IsString, IsInt, IsNotEmpty, ValidateNested, MaxLength, Min, Max } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { ReceiverConfigDTO } from "./notice_rule.dto";
|
||||
|
||||
/** 群发批次列表查询 */
|
||||
export class NoticeBatchQueryDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||
}
|
||||
|
||||
/** 手动群发消息(仅立即发送) */
|
||||
export class NoticeBatchCreateDTO {
|
||||
@IsNotEmpty({ message: "消息标题不能为空" }) @IsString() @MaxLength(128) title!: string;
|
||||
@IsNotEmpty({ message: "消息正文不能为空" }) @IsString() @MaxLength(512) content!: string;
|
||||
@ValidateNested() @Type(() => ReceiverConfigDTO) receiverConfig!: ReceiverConfigDTO;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsInt,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
ValidateNested,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
/** 接收对象-管理端用户:按角色编码 + 按用户 id */
|
||||
export class ReceiverUsersDTO {
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) roles?: string[];
|
||||
@IsOptional() @IsArray() @IsInt({ each: true }) ids?: number[];
|
||||
}
|
||||
|
||||
/** 接收对象-电工:none 不发 / event 事件关联电工 / all 全部在岗 / ids 指定 / status 按状态 */
|
||||
export class ReceiverWorkersDTO {
|
||||
@IsOptional() @IsString() @IsIn(["none", "event", "all", "ids", "status"]) mode?: string = "none";
|
||||
@IsOptional() @IsArray() @IsInt({ each: true }) ids?: number[];
|
||||
// 审核状态:0待审核 1已通过 2已驳回
|
||||
@IsOptional() @IsInt() @IsIn([0, 1, 2]) auditStatus?: number;
|
||||
// 在岗状态:1在岗 0离职
|
||||
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
|
||||
}
|
||||
|
||||
/** 接收对象配置 */
|
||||
export class ReceiverConfigDTO {
|
||||
@IsOptional() @ValidateNested() @Type(() => ReceiverUsersDTO) users?: ReceiverUsersDTO;
|
||||
@IsOptional() @ValidateNested() @Type(() => ReceiverWorkersDTO) workers?: ReceiverWorkersDTO;
|
||||
}
|
||||
|
||||
/** 通知规则列表查询 */
|
||||
export class NoticeRuleQueryDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) eventKey?: string;
|
||||
@IsOptional() @IsInt() @IsIn([1, 2]) triggerMode?: number;
|
||||
@IsOptional() @IsInt() @IsIn([0, 1]) enabled?: number;
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||
}
|
||||
|
||||
/** 新增通知规则 */
|
||||
export class NoticeRuleCreateDTO {
|
||||
@IsNotEmpty({ message: "规则键不能为空" }) @IsString() @MaxLength(64) ruleKey!: string;
|
||||
@IsNotEmpty({ message: "规则名称不能为空" }) @IsString() @MaxLength(64) ruleName!: string;
|
||||
@IsNotEmpty({ message: "事件不能为空" }) @IsString() @MaxLength(64) eventKey!: string;
|
||||
@IsInt() @IsIn([1, 2], { message: "触发方式非法" }) triggerMode!: number;
|
||||
@IsOptional() @IsInt() @IsIn([0, 1]) enabled?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(3650) advanceDays?: number;
|
||||
@IsNotEmpty({ message: "标题模板不能为空" }) @IsString() @MaxLength(128) titleTpl!: string;
|
||||
@IsNotEmpty({ message: "正文模板不能为空" }) @IsString() @MaxLength(512) contentTpl!: string;
|
||||
@ValidateNested() @Type(() => ReceiverConfigDTO) receiverConfig!: ReceiverConfigDTO;
|
||||
@IsOptional() @IsString() @MaxLength(255) remark?: string;
|
||||
}
|
||||
|
||||
/** 更新通知规则 */
|
||||
export class NoticeRuleUpdateDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) ruleName?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) eventKey?: string;
|
||||
@IsOptional() @IsInt() @IsIn([1, 2]) triggerMode?: number;
|
||||
@IsOptional() @IsInt() @IsIn([0, 1]) enabled?: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(3650) advanceDays?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) titleTpl?: string;
|
||||
@IsOptional() @IsString() @MaxLength(512) contentTpl?: string;
|
||||
@IsOptional() @ValidateNested() @Type(() => ReceiverConfigDTO) receiverConfig?: ReceiverConfigDTO;
|
||||
@IsOptional() @IsString() @MaxLength(255) remark?: string;
|
||||
}
|
||||
|
||||
/** 启停通知规则 */
|
||||
export class NoticeRuleEnabledDTO {
|
||||
@IsInt() @IsIn([0, 1], { message: "状态值非法" }) enabled!: number;
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript"
|
||||
createdAt: "created_at",
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{ name: "idx_sys_notice_receiver", fields: ["receiver_id", "is_read"] },
|
||||
{ name: "uk_sys_notice_biz", unique: true, fields: ["notice_type", "biz_id", "receiver_id"] }
|
||||
{ name: "idx_sys_notice_receiver_type", fields: ["receiver_type", "receiver_id", "is_read"] },
|
||||
{ name: "uk_sys_notice_deliver", unique: true, fields: ["notice_type", "biz_type", "biz_id", "receiver_type", "receiver_id"] }
|
||||
]
|
||||
})
|
||||
export default class SysNotice extends Model {
|
||||
@@ -30,9 +30,18 @@ export default class SysNotice extends Model {
|
||||
@Column({ type: DataType.BIGINT, field: "biz_id", comment: "业务主键(→ worker_cert.id)" })
|
||||
bizId!: number;
|
||||
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "receiver_id", comment: "接收人(→ sys_user.id)" })
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "receiver_id", comment: "接收人(→ sys_user.id / worker.id)" })
|
||||
receiverId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "user", field: "receiver_type", comment: "接收人类型:user-管理端用户 worker-电工" })
|
||||
receiverType!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "rule_id", comment: "来源通知规则(→ sys_notice_rule.id)" })
|
||||
ruleId!: number | null;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "batch_id", comment: "来源群发批次(→ sys_notice_batch.id)" })
|
||||
batchId!: number | null;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "is_read", comment: "是否已读:0未读 1已读" })
|
||||
isRead!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Column, Model, Table, DataType } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "sys_notice_batch",
|
||||
comment: "手动群发批次表(发送记录)",
|
||||
timestamps: false
|
||||
})
|
||||
export default class SysNoticeBatch extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), allowNull: false, field: "title", comment: "消息标题" })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(512), allowNull: false, field: "content", comment: "消息正文" })
|
||||
content!: string;
|
||||
|
||||
@Column({ type: DataType.JSONB, allowNull: false, field: "receiver_config", comment: "接收对象快照" })
|
||||
receiverConfig!: Record<string, any>;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "receiver_count", comment: "实际接收人数" })
|
||||
receiverCount!: number;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "sent_by", comment: "发送人(→ sys_user.id)" })
|
||||
sentBy!: number | null;
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false, field: "sent_at", comment: "发送时间" })
|
||||
sentAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "sys_notice_rule",
|
||||
comment: "通知规则表(模板 + 接收对象)",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{ name: "uk_sys_notice_rule_key", unique: true, fields: ["rule_key"] },
|
||||
{ name: "idx_sys_notice_rule_event", fields: ["event_key", "trigger_mode", "enabled"] }
|
||||
]
|
||||
})
|
||||
export default class SysNoticeRule extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, field: "rule_key", comment: "规则键(唯一)" })
|
||||
ruleKey!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, field: "rule_name", comment: "规则名称" })
|
||||
ruleName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, field: "event_key", comment: "事件键(对应代码事件注册表)" })
|
||||
eventKey!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "trigger_mode", comment: "触发方式:1扫描式 2埋点式" })
|
||||
triggerMode!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "enabled", comment: "状态:1启用 0停用" })
|
||||
enabled!: number;
|
||||
|
||||
@Column({ type: DataType.INTEGER, field: "advance_days", comment: "提前天数(扫描式)" })
|
||||
advanceDays!: number | null;
|
||||
|
||||
@Column({ type: DataType.STRING(128), allowNull: false, field: "title_tpl", comment: "标题模板(支持 {变量})" })
|
||||
titleTpl!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(512), allowNull: false, field: "content_tpl", comment: "正文模板(支持 {变量})" })
|
||||
contentTpl!: string;
|
||||
|
||||
@Column({ type: DataType.JSONB, allowNull: false, field: "receiver_config", comment: "接收对象配置" })
|
||||
receiverConfig!: Record<string, any>;
|
||||
|
||||
@Column({ type: DataType.STRING(255), field: "remark", comment: "备注" })
|
||||
remark!: string | null;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
"/api/dicts": "system:dict",
|
||||
"/api/logs": "system:log",
|
||||
"/api/jobs": "system:job",
|
||||
"/api/notice-rules": "notice:rule",
|
||||
"/api/notice-batches": "notice:batch",
|
||||
"/api/customers": "customer:list",
|
||||
"/api/customer-departments": "customer:department",
|
||||
"/api/customer-accounts": "customer:account",
|
||||
|
||||
@@ -64,7 +64,7 @@ export class DispatchPriceService {
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取派单成本价目列表成功",
|
||||
message: "获取勘察费用列表成功",
|
||||
data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) }
|
||||
};
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export class DispatchPriceService {
|
||||
async detail(id: number): Promise<any> {
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
return { code: 200, message: "获取派单成本价目详情成功", data: record.toJSON() };
|
||||
return { code: 200, message: "获取勘察费用详情成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async create(data: any, ctx: Context): Promise<any> {
|
||||
@@ -89,14 +89,14 @@ export class DispatchPriceService {
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.dispatchPriceModel.create(payload);
|
||||
return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() };
|
||||
return { code: 200, message: "创建勘察费用成功", data: created.toJSON() };
|
||||
} catch (err: any) {
|
||||
// 唯一冲突:重新生成编码后重试 1 次
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.dispatchPriceModel.create(payload);
|
||||
return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() };
|
||||
return { code: 200, message: "创建勘察费用成功", data: created.toJSON() };
|
||||
} catch (_e) {
|
||||
return { code: 400, message: "价格编码已存在", data: null };
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export class DispatchPriceService {
|
||||
const payload = this.pickWritable(data);
|
||||
await record.update(payload);
|
||||
|
||||
return { code: 200, message: "更新派单成本价目成功", data: record.toJSON() };
|
||||
return { code: 200, message: "更新勘察费用成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async delete(id: number, ctx: Context): Promise<any> {
|
||||
@@ -120,7 +120,7 @@ export class DispatchPriceService {
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除派单成本价目成功", data: null };
|
||||
return { code: 200, message: "删除勘察费用成功", data: null };
|
||||
}
|
||||
|
||||
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
|
||||
@@ -130,6 +130,6 @@ export class DispatchPriceService {
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.update({ status });
|
||||
return { code: 200, message: "更新派单成本价目状态成功", data: null };
|
||||
return { code: 200, message: "更新勘察费用状态成功", data: null };
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import ProjectPlan from "../entity/project_plan.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
import { LogService } from "./log.service";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
|
||||
// 安装派单状态:0已派发 1已完成
|
||||
const STATUS = { DISPATCHED: 0, COMPLETED: 1 };
|
||||
@@ -37,6 +38,9 @@ export class InstallOrderService {
|
||||
@Inject()
|
||||
logService!: LogService;
|
||||
|
||||
@Inject()
|
||||
noticeRuleService!: NoticeRuleService;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
@@ -222,6 +226,22 @@ export class InstallOrderService {
|
||||
const vo: any = created.toJSON();
|
||||
await this.decorate([vo]);
|
||||
await this.logService.logAction(ctx, "安装派单", "创建", created.installNo, `创建安装派单 ${created.installNo}`);
|
||||
// 派单站内通知(埋点式;通知失败不得阻断业务)
|
||||
if (created.workerId) {
|
||||
try {
|
||||
await this.noticeRuleService.notifyDispatchAssign({
|
||||
orderTypeName: "安装",
|
||||
orderNo: created.installNo,
|
||||
customerId: Number(created.customerId),
|
||||
workerId: Number(created.workerId),
|
||||
detailText: created.startDate ? `计划开始:${created.startDate}。` : "",
|
||||
bizType: "install_order",
|
||||
bizId: Number(created.id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 安装派单通知失败:", err);
|
||||
}
|
||||
}
|
||||
return { code: 200, message: "创建安装派单成功", data: vo };
|
||||
}
|
||||
|
||||
@@ -245,11 +265,28 @@ export class InstallOrderService {
|
||||
}
|
||||
|
||||
const payload = this.normalizePayload(data);
|
||||
const oldWorkerId = record.workerId ? Number(record.workerId) : null;
|
||||
await record.update(payload);
|
||||
const fresh = await this.installModel.findByPk(id);
|
||||
const vo: any = fresh!.toJSON();
|
||||
await this.decorate([vo]);
|
||||
await this.logService.logAction(ctx, "安装派单", "更新", record.installNo, `更新安装派单 ${record.installNo}`);
|
||||
// 改派站内通知(派单对象变化时提醒新电工)
|
||||
if (record.workerId && Number(record.workerId) !== oldWorkerId) {
|
||||
try {
|
||||
await this.noticeRuleService.notifyDispatchAssign({
|
||||
orderTypeName: "安装",
|
||||
orderNo: record.installNo,
|
||||
customerId: Number(record.customerId),
|
||||
workerId: Number(record.workerId),
|
||||
detailText: record.startDate ? `计划开始:${record.startDate}。` : "",
|
||||
bizType: "install_order",
|
||||
bizId: Number(record.id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 安装派单通知失败:", err);
|
||||
}
|
||||
}
|
||||
return { code: 200, message: "更新安装派单成功", data: vo };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import dayjs from "dayjs";
|
||||
import SysJob from "../entity/sys_job.entity";
|
||||
import SysJobLog from "../entity/sys_job_log.entity";
|
||||
import { LogService } from "./log.service";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
@@ -423,7 +424,13 @@ export class JobSchedulerService {
|
||||
const logService = (await ctx.requestContext.getAsync(LogService)) as LogService;
|
||||
return logService.cleanExpiredLogs(keepDays);
|
||||
},
|
||||
cleanJobLogs: (keepDays: number) => this.cleanJobLogs(keepDays)
|
||||
cleanJobLogs: (keepDays: number) => this.cleanJobLogs(keepDays),
|
||||
// NoticeRuleService 同样是请求作用域,按需从匿名请求上下文获取
|
||||
runNoticeRules: async () => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService;
|
||||
return noticeRuleService.executeScan();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Worker from "../entity/worker.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
import { LogService } from "./log.service";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
|
||||
// 维保派单状态:0已派发 1已完成
|
||||
const STATUS = { DISPATCHED: 0, COMPLETED: 1 };
|
||||
@@ -37,6 +38,9 @@ export class MaintenanceService {
|
||||
@Inject()
|
||||
logService!: LogService;
|
||||
|
||||
@Inject()
|
||||
noticeRuleService!: NoticeRuleService;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
@@ -199,6 +203,22 @@ export class MaintenanceService {
|
||||
await this.decorate([vo]);
|
||||
vo.details = detailRows.map((d) => d.toJSON());
|
||||
await this.logService.logAction(ctx, "维保派单", "创建", created.maintenanceNo, `创建维保派单 ${created.maintenanceNo}`);
|
||||
// 派单站内通知(埋点式;通知失败不得阻断业务)
|
||||
if (created.workerId) {
|
||||
try {
|
||||
await this.noticeRuleService.notifyDispatchAssign({
|
||||
orderTypeName: "维保",
|
||||
orderNo: created.maintenanceNo,
|
||||
customerId: Number(created.customerId),
|
||||
workerId: Number(created.workerId),
|
||||
detailText: created.maintenanceDate ? `维保日期:${created.maintenanceDate}。` : "",
|
||||
bizType: "maintenance_order",
|
||||
bizId: Number(created.id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 维保派单通知失败:", err);
|
||||
}
|
||||
}
|
||||
return { code: 200, message: "创建维保派单成功", data: vo };
|
||||
}
|
||||
|
||||
@@ -228,6 +248,7 @@ export class MaintenanceService {
|
||||
|
||||
const t = await this.orderModel.sequelize!.transaction();
|
||||
const detailRows: MaintenanceDetail[] = [];
|
||||
const oldWorkerId = record.workerId ? Number(record.workerId) : null;
|
||||
try {
|
||||
await record.update(payload, { transaction: t });
|
||||
if (details.length) {
|
||||
@@ -247,6 +268,22 @@ export class MaintenanceService {
|
||||
await this.decorate([vo]);
|
||||
vo.details = detailRows.map((d) => d.toJSON());
|
||||
await this.logService.logAction(ctx, "维保派单", "更新", record.maintenanceNo, `更新维保派单 ${record.maintenanceNo}`);
|
||||
// 改派站内通知(派单对象变化时提醒新电工)
|
||||
if (record.workerId && Number(record.workerId) !== oldWorkerId) {
|
||||
try {
|
||||
await this.noticeRuleService.notifyDispatchAssign({
|
||||
orderTypeName: "维保",
|
||||
orderNo: record.maintenanceNo,
|
||||
customerId: Number(record.customerId),
|
||||
workerId: Number(record.workerId),
|
||||
detailText: record.maintenanceDate ? `维保日期:${record.maintenanceDate}。` : "",
|
||||
bizType: "maintenance_order",
|
||||
bizId: Number(record.id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 维保派单通知失败:", err);
|
||||
}
|
||||
}
|
||||
return { code: 200, message: "更新维保派单成功", data: vo };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,34 @@ import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import dayjs from "dayjs";
|
||||
import SysNotice from "../entity/sys_notice.entity";
|
||||
import WorkerCert from "../entity/worker_cert.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import SysUser from "../entity/sysUser.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
|
||||
// 通知类型:证照到期提醒
|
||||
const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
|
||||
// 业务类型:电工资质证照
|
||||
const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert";
|
||||
// 到期提醒提前天数
|
||||
const CERT_EXPIRE_WARN_DAYS = 30;
|
||||
// 超级管理员角色编码(拥有全部权限,作为证照提醒接收人)
|
||||
const ADMIN_ROLE_CODE = "ADMIN";
|
||||
// 分页上限,防止单次拉取过多数据
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
// 接收人类型:user-管理端用户 worker-电工
|
||||
export type ReceiverType = "user" | "worker";
|
||||
|
||||
// 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效)
|
||||
function toDateOnly(value: any): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const str = String(value);
|
||||
return str.length >= 10 ? str.slice(0, 10) : str;
|
||||
/** 接收对象配置(与 sys_notice_rule.receiver_config 同构) */
|
||||
export interface ReceiverConfig {
|
||||
users?: { roles?: string[]; ids?: number[] };
|
||||
workers?: { mode?: "none" | "event" | "all" | "ids" | "status"; ids?: number[]; auditStatus?: number; status?: number };
|
||||
}
|
||||
|
||||
/** 统一投递入参 */
|
||||
export interface DeliverInput {
|
||||
noticeType: string;
|
||||
title: string;
|
||||
content: string;
|
||||
bizType?: string | null;
|
||||
bizId?: number | null;
|
||||
receiverType: ReceiverType;
|
||||
receiverId: number;
|
||||
ruleId?: number | null;
|
||||
batchId?: number | null;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
@@ -33,12 +37,6 @@ export class NoticeService {
|
||||
@InjectRepository(SysNotice)
|
||||
noticeModel!: Repository<SysNotice>;
|
||||
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(SysUser)
|
||||
userModel!: Repository<SysUser>;
|
||||
|
||||
@@ -48,13 +46,16 @@ export class NoticeService {
|
||||
@InjectRepository(SysRole)
|
||||
roleModel!: Repository<SysRole>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
// 我的通知列表:未读优先,可选按已读状态筛选
|
||||
async listMy(userId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise<any> {
|
||||
async listMy(receiverType: ReceiverType, receiverId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise<any> {
|
||||
const page = Math.max(Number(query.page) || 1, 1);
|
||||
const rawPageSize = Number(query.pageSize) || 10;
|
||||
const pageSize = Math.min(Math.max(rawPageSize, 1), MAX_PAGE_SIZE);
|
||||
|
||||
const where: any = { receiverId: userId };
|
||||
const where: any = { receiverType, receiverId };
|
||||
if (query.isRead !== undefined && query.isRead !== "") {
|
||||
where.isRead = Number(query.isRead);
|
||||
}
|
||||
@@ -80,8 +81,8 @@ export class NoticeService {
|
||||
}
|
||||
|
||||
// 未读数量(顶栏红点)
|
||||
async unreadCount(userId: number): Promise<any> {
|
||||
const count = await this.noticeModel.count({ where: { receiverId: userId, isRead: 0 } });
|
||||
async unreadCount(receiverType: ReceiverType, receiverId: number): Promise<any> {
|
||||
const count = await this.noticeModel.count({ where: { receiverType, receiverId, isRead: 0 } as any });
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
@@ -91,8 +92,8 @@ export class NoticeService {
|
||||
}
|
||||
|
||||
// 单条标记已读(仅能操作自己的通知)
|
||||
async markRead(userId: number, id: number): Promise<any> {
|
||||
const notice = await this.noticeModel.findOne({ where: { id, receiverId: userId } });
|
||||
async markRead(receiverType: ReceiverType, receiverId: number, id: number): Promise<any> {
|
||||
const notice = await this.noticeModel.findOne({ where: { id, receiverType, receiverId } as any });
|
||||
|
||||
if (!notice) {
|
||||
return {
|
||||
@@ -114,8 +115,11 @@ export class NoticeService {
|
||||
}
|
||||
|
||||
// 全部标记已读
|
||||
async markAllRead(userId: number): Promise<any> {
|
||||
const updated = await this.noticeModel.update({ isRead: 1 }, { where: { receiverId: userId, isRead: 0 } });
|
||||
async markAllRead(receiverType: ReceiverType, receiverId: number): Promise<any> {
|
||||
const updated = await this.noticeModel.update(
|
||||
{ isRead: 1 },
|
||||
{ where: { receiverType, receiverId, isRead: 0 } as any }
|
||||
);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
@@ -124,128 +128,119 @@ export class NoticeService {
|
||||
};
|
||||
}
|
||||
|
||||
// 查询拥有 ADMIN 角色的所有用户ID(sys_user_role + sys_role 关联,并校验用户真实存在)
|
||||
async getAdminUserIds(): Promise<number[]> {
|
||||
const adminRole = await this.roleModel.findOne({
|
||||
where: { roleCode: ADMIN_ROLE_CODE },
|
||||
attributes: ["id"]
|
||||
});
|
||||
if (!adminRole) return [];
|
||||
/**
|
||||
* 统一投递(批量、幂等)。
|
||||
* 依赖唯一索引 uk_sys_notice_deliver(notice_type,biz_type,biz_id,receiver_type,receiver_id),
|
||||
* 以 ON CONFLICT DO NOTHING 去重,返回新增条数。
|
||||
*/
|
||||
async deliverMany(rows: DeliverInput[]): Promise<number> {
|
||||
if (!rows || rows.length === 0) return 0;
|
||||
|
||||
const userRoles = await this.userRoleModel.findAll({
|
||||
where: { roleId: Number(adminRole.id) },
|
||||
attributes: ["userId"]
|
||||
});
|
||||
const userIds = Array.from(new Set(userRoles.map((ur) => Number(ur.userId))));
|
||||
if (userIds.length === 0) return [];
|
||||
const placeholders = rows.map(() => "(?,?,?,?,?,?,?,?,?,0,NOW())").join(",");
|
||||
const replacements: any[] = [];
|
||||
for (const r of rows) {
|
||||
replacements.push(
|
||||
r.noticeType,
|
||||
r.title,
|
||||
r.content,
|
||||
r.bizType ?? null,
|
||||
r.bizId ?? null,
|
||||
r.receiverType,
|
||||
r.receiverId,
|
||||
r.ruleId ?? null,
|
||||
r.batchId ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO sys_notice
|
||||
(notice_type, title, content, biz_type, biz_id, receiver_type, receiver_id, rule_id, batch_id, is_read, created_at)
|
||||
VALUES ${placeholders}
|
||||
ON CONFLICT DO NOTHING`;
|
||||
|
||||
const [, affected] = await this.noticeModel.sequelize!.query(sql, { replacements });
|
||||
return Number(affected) || 0;
|
||||
}
|
||||
|
||||
// 单条投递
|
||||
async deliver(row: DeliverInput): Promise<boolean> {
|
||||
const created = await this.deliverMany([row]);
|
||||
return created > 0;
|
||||
}
|
||||
|
||||
/** 解析管理端接收人:角色编码 + 指定用户 id,并校验用户真实存在 */
|
||||
async resolveUserIds(config?: ReceiverConfig["users"]): Promise<number[]> {
|
||||
const ids = new Set<number>();
|
||||
const roleCodes = (config?.roles || []).filter(Boolean);
|
||||
|
||||
if (roleCodes.length > 0) {
|
||||
const roles = await this.roleModel.findAll({
|
||||
where: { roleCode: { [Op.in]: roleCodes } },
|
||||
attributes: ["id"]
|
||||
});
|
||||
const roleIds = roles.map((r) => Number(r.id));
|
||||
if (roleIds.length > 0) {
|
||||
const userRoles = await this.userRoleModel.findAll({
|
||||
where: { roleId: { [Op.in]: roleIds } },
|
||||
attributes: ["userId"]
|
||||
});
|
||||
for (const ur of userRoles) ids.add(Number(ur.userId));
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of config?.ids || []) ids.add(Number(id));
|
||||
if (ids.size === 0) return [];
|
||||
|
||||
const users = await this.userModel.findAll({
|
||||
where: { id: { [Op.in]: userIds } },
|
||||
where: { id: { [Op.in]: Array.from(ids) } },
|
||||
attributes: ["id"]
|
||||
});
|
||||
|
||||
return users.map((u) => Number(u.id));
|
||||
}
|
||||
|
||||
// 扫描 30 天内到期或已过期的电工资质证照,向所有 ADMIN 推送站内通知(幂等)
|
||||
async scanCertExpireAndNotify(): Promise<{ scanned: number; created: number }> {
|
||||
let scanned = 0;
|
||||
let created = 0;
|
||||
/**
|
||||
* 解析电工接收人。
|
||||
* mode: none 不发 / event 事件关联电工 / all 全部在岗 / ids 指定 / status 按审核与在岗状态
|
||||
*/
|
||||
async resolveWorkerIds(config?: ReceiverConfig["workers"], eventWorkerId?: number): Promise<number[]> {
|
||||
const mode = config?.mode || "none";
|
||||
if (mode === "none") return [];
|
||||
|
||||
try {
|
||||
// 使用本地日期字符串比较,避免时区偏差
|
||||
const today = dayjs().format("YYYY-MM-DD");
|
||||
const deadline = dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD");
|
||||
|
||||
// 1. 查出已设置有效期且在提醒窗口内的证书(含已过期)
|
||||
const certs = await this.certModel.findAll({
|
||||
where: {
|
||||
expireDate: { [Op.not]: null, [Op.lte]: deadline }
|
||||
},
|
||||
order: [["expire_date", "ASC"]]
|
||||
});
|
||||
scanned = certs.length;
|
||||
if (scanned === 0) return { scanned, created };
|
||||
|
||||
// 2. worker_cert 未声明 BelongsTo(Worker) 关联,单独查询电工姓名后按 Map 关联,避免 N+1
|
||||
const workerIds = Array.from(new Set(certs.map((c) => Number(c.workerId))));
|
||||
const workers = await this.workerModel.findAll({
|
||||
where: { id: { [Op.in]: workerIds } },
|
||||
attributes: ["id", "workerName"]
|
||||
});
|
||||
const workerNameMap = new Map<number, string>();
|
||||
for (const w of workers) workerNameMap.set(Number(w.id), w.workerName);
|
||||
|
||||
// 3. 接收人:全部 ADMIN
|
||||
const adminIds = await this.getAdminUserIds();
|
||||
if (adminIds.length === 0) return { scanned, created };
|
||||
|
||||
// 4. 幂等:按 (noticeType, bizType, bizId, receiverId) 先批量查出已存在的通知,已存在则跳过
|
||||
const certIds = certs.map((c) => Number(c.id));
|
||||
const existing = await this.noticeModel.findAll({
|
||||
where: {
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: { [Op.in]: certIds },
|
||||
receiverId: { [Op.in]: adminIds }
|
||||
},
|
||||
attributes: ["bizId", "receiverId"]
|
||||
});
|
||||
const existsSet = new Set(existing.map((n) => `${Number(n.bizId)}_${Number(n.receiverId)}`));
|
||||
|
||||
// 5. 证书 × ADMIN 逐条生成通知
|
||||
for (const cert of certs) {
|
||||
const expire = toDateOnly(cert.expireDate);
|
||||
if (!expire) continue;
|
||||
|
||||
const days = dayjs(expire).startOf("day").diff(dayjs(today).startOf("day"), "day");
|
||||
const workerName = workerNameMap.get(Number(cert.workerId)) || "未知电工";
|
||||
const certType = cert.certType || "资质证书";
|
||||
const title =
|
||||
days < 0
|
||||
? `电工【${workerName}】的${certType}已于 ${expire} 过期`
|
||||
: `电工【${workerName}】的${certType}将于 ${expire} 到期(剩余 ${days} 天)`;
|
||||
const content = [
|
||||
`证书编号:${cert.certNo || "—"}`,
|
||||
`发证机关:${cert.issueAuthority || "—"}`,
|
||||
`有效期至:${expire}`,
|
||||
days < 0 ? `已过期 ${Math.abs(days)} 天,请及时提醒电工办理续期` : `剩余 ${days} 天,请及时提醒电工办理续期`
|
||||
].join(";");
|
||||
|
||||
for (const adminId of adminIds) {
|
||||
if (existsSet.has(`${Number(cert.id)}_${adminId}`)) continue;
|
||||
|
||||
await this.noticeModel.create({
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
title,
|
||||
content,
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: Number(cert.id),
|
||||
receiverId: adminId,
|
||||
isRead: 0
|
||||
} as any);
|
||||
existsSet.add(`${Number(cert.id)}_${adminId}`);
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { scanned, created };
|
||||
} catch (err: any) {
|
||||
// 定时任务不允许抛出,失败仅记录日志
|
||||
console.error("[notice] 证照到期扫描失败:", err?.message || err);
|
||||
return { scanned, created };
|
||||
const where: any = {};
|
||||
if (mode === "event") {
|
||||
if (!eventWorkerId) return [];
|
||||
where.id = Number(eventWorkerId);
|
||||
} else if (mode === "ids") {
|
||||
const ids = (config?.ids || []).map(Number);
|
||||
if (ids.length === 0) return [];
|
||||
where.id = { [Op.in]: ids };
|
||||
} else if (mode === "all") {
|
||||
where.status = 1;
|
||||
} else if (mode === "status") {
|
||||
if (config?.status !== undefined && config?.status !== null) where.status = Number(config.status);
|
||||
if (config?.auditStatus !== undefined && config?.auditStatus !== null) where.auditStatus = Number(config.auditStatus);
|
||||
}
|
||||
|
||||
const workers = await this.workerModel.findAll({ where, attributes: ["id"] });
|
||||
return workers.map((w) => Number(w.id));
|
||||
}
|
||||
|
||||
// 清理指定证书的到期通知(证书续期/删除后调用,允许重新提醒)
|
||||
async clearByCertIds(certIds: number[]): Promise<number> {
|
||||
if (!certIds || certIds.length === 0) return 0;
|
||||
/**
|
||||
* 按接收对象配置投递:展开管理端用户 + 电工,逐人写入(幂等)。
|
||||
* base 提供通知类型/标题/正文/业务锚点;eventWorkerId 供 workers.mode=event 使用。
|
||||
*/
|
||||
async deliverToReceivers(
|
||||
base: Omit<DeliverInput, "receiverType" | "receiverId">,
|
||||
config: ReceiverConfig | null | undefined,
|
||||
options?: { eventWorkerId?: number }
|
||||
): Promise<number> {
|
||||
const userIds = await this.resolveUserIds(config?.users);
|
||||
const workerIds = await this.resolveWorkerIds(config?.workers, options?.eventWorkerId);
|
||||
|
||||
return await this.noticeModel.destroy({
|
||||
where: {
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: { [Op.in]: certIds }
|
||||
}
|
||||
});
|
||||
const rows: DeliverInput[] = [
|
||||
...userIds.map((id) => ({ ...base, receiverType: "user" as ReceiverType, receiverId: id })),
|
||||
...workerIds.map((id) => ({ ...base, receiverType: "worker" as ReceiverType, receiverId: id }))
|
||||
];
|
||||
|
||||
return this.deliverMany(rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import SysNoticeBatch from "../entity/sys_notice_batch.entity";
|
||||
import SysUser from "../entity/sysUser.entity";
|
||||
import { NoticeService, ReceiverConfig } from "./notice.service";
|
||||
import { NoticeBatchQueryDTO, NoticeBatchCreateDTO } from "../dto/notice_batch.dto";
|
||||
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
// 手动群发通知类型
|
||||
const NOTICE_TYPE_MANUAL = "manual";
|
||||
const BIZ_TYPE_MANUAL_BATCH = "manual_batch";
|
||||
|
||||
@Provide()
|
||||
export class NoticeBatchService {
|
||||
@InjectRepository(SysNoticeBatch)
|
||||
batchModel!: Repository<SysNoticeBatch>;
|
||||
|
||||
@InjectRepository(SysUser)
|
||||
userModel!: Repository<SysUser>;
|
||||
|
||||
@Inject()
|
||||
noticeService!: NoticeService;
|
||||
|
||||
/** 群发记录分页(附发送人姓名) */
|
||||
async list(query: NoticeBatchQueryDTO): Promise<any> {
|
||||
const page = Math.max(Number(query.page) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize) || 10, 1), MAX_PAGE_SIZE);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where[Op.or as any] = [
|
||||
{ title: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ content: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
const { count, rows } = await this.batchModel.findAndCountAll({
|
||||
where,
|
||||
order: [["id", "DESC"]],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
const senderIds = Array.from(new Set(rows.map(r => Number(r.sentBy)).filter(id => !!id)));
|
||||
const senders = senderIds.length > 0
|
||||
? await this.userModel.findAll({ where: { id: { [Op.in]: senderIds } } as any, attributes: ["id", "username", "realName"] })
|
||||
: [];
|
||||
const nameMap = new Map(senders.map(u => [Number(u.id), u.realName || u.username]));
|
||||
|
||||
const list = rows.map(row => ({
|
||||
...row.toJSON(),
|
||||
sentByName: row.sentBy ? nameMap.get(Number(row.sentBy)) || "—" : "—"
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取发送记录成功",
|
||||
data: { list, total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
/** 手动群发(仅立即发送):解析接收人 → 写入 sys_notice → 记录批次 */
|
||||
async send(dto: NoticeBatchCreateDTO, operatorUserId: number): Promise<any> {
|
||||
const receiverConfig = (dto.receiverConfig ?? {}) as ReceiverConfig;
|
||||
|
||||
const batch = await this.batchModel.create({
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
receiverConfig: receiverConfig as any,
|
||||
receiverCount: 0,
|
||||
sentBy: operatorUserId || null,
|
||||
sentAt: new Date()
|
||||
} as any);
|
||||
|
||||
const created = await this.noticeService.deliverToReceivers(
|
||||
{
|
||||
noticeType: NOTICE_TYPE_MANUAL,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
bizType: BIZ_TYPE_MANUAL_BATCH,
|
||||
bizId: Number(batch.id),
|
||||
batchId: Number(batch.id)
|
||||
},
|
||||
receiverConfig,
|
||||
{}
|
||||
);
|
||||
|
||||
if (created === 0) {
|
||||
await batch.destroy();
|
||||
return { code: 400, message: "未匹配到任何接收人,请检查接收对象配置", data: null };
|
||||
}
|
||||
|
||||
await batch.update({ receiverCount: created } as any);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: `发送成功,已送达 ${created} 人`,
|
||||
data: { batchId: Number(batch.id), receiverCount: created }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import dayjs from "dayjs";
|
||||
import SysNoticeRule from "../entity/sys_notice_rule.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import WorkerCert from "../entity/worker_cert.entity";
|
||||
import Contract from "../entity/contract.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerVisit from "../entity/customer_visit.entity";
|
||||
import RepairDispatch from "../entity/repair_dispatch.entity";
|
||||
import SurveyOrder from "../entity/survey_order.entity";
|
||||
import InstallOrder from "../entity/install_order.entity";
|
||||
import MaintenanceOrder from "../entity/maintenance_order.entity";
|
||||
import { NoticeService, ReceiverConfig } from "./notice.service";
|
||||
import {
|
||||
NoticeRuleQueryDTO,
|
||||
NoticeRuleCreateDTO,
|
||||
NoticeRuleUpdateDTO
|
||||
} from "../dto/notice_rule.dto";
|
||||
import { NOTICE_EVENTS, NoticeScanItem, getNoticeEvent } from "../task/notice_event_registry";
|
||||
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
/** 埋点式事件入参 */
|
||||
export interface RunEventParams {
|
||||
bizType?: string | null;
|
||||
bizId?: number | null;
|
||||
vars: Record<string, any>;
|
||||
/** workers.mode=event 时的目标电工 */
|
||||
eventWorkerId?: number | null;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
export class NoticeRuleService {
|
||||
@InjectRepository(SysNoticeRule)
|
||||
ruleModel!: Repository<SysNoticeRule>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(Contract)
|
||||
contractModel!: Repository<Contract>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(CustomerVisit)
|
||||
visitModel!: Repository<CustomerVisit>;
|
||||
|
||||
@InjectRepository(RepairDispatch)
|
||||
repairDispatchModel!: Repository<RepairDispatch>;
|
||||
|
||||
@InjectRepository(SurveyOrder)
|
||||
surveyOrderModel!: Repository<SurveyOrder>;
|
||||
|
||||
@InjectRepository(InstallOrder)
|
||||
installOrderModel!: Repository<InstallOrder>;
|
||||
|
||||
@InjectRepository(MaintenanceOrder)
|
||||
maintenanceModel!: Repository<MaintenanceOrder>;
|
||||
|
||||
@Inject()
|
||||
noticeService!: NoticeService;
|
||||
|
||||
/** 扫描式事件 → 扫描实现(按 advanceDays 扫描,返回命中行) */
|
||||
private scanProviders: Record<string, (advanceDays: number) => Promise<NoticeScanItem[]>> = {
|
||||
cert_expire: (d) => this.scanCertExpire(d),
|
||||
contract_expire: (d) => this.scanContractExpire(d),
|
||||
visit_plan: (d) => this.scanVisitPlan(d),
|
||||
order_timeout: (d) => this.scanOrderTimeout(d)
|
||||
};
|
||||
|
||||
// ==================== 规则 CRUD ====================
|
||||
|
||||
async list(query: NoticeRuleQueryDTO): Promise<any> {
|
||||
const page = Math.max(Number(query.page) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize) || 10, 1), MAX_PAGE_SIZE);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where[Op.or as any] = [
|
||||
{ ruleKey: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ ruleName: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.eventKey) where.eventKey = query.eventKey;
|
||||
if (query.triggerMode !== undefined && `${query.triggerMode}` !== "") where.triggerMode = Number(query.triggerMode);
|
||||
if (query.enabled !== undefined && `${query.enabled}` !== "") where.enabled = Number(query.enabled);
|
||||
|
||||
const { count, rows } = await this.ruleModel.findAndCountAll({
|
||||
where,
|
||||
order: [["id", "ASC"]],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取通知规则列表成功",
|
||||
data: { list: rows.map(r => this.toRuleView(r)), total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: number): Promise<any> {
|
||||
const rule = await this.ruleModel.findByPk(id);
|
||||
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
|
||||
return { code: 200, message: "获取通知规则成功", data: this.toRuleView(rule) };
|
||||
}
|
||||
|
||||
async create(dto: NoticeRuleCreateDTO): Promise<any> {
|
||||
const event = getNoticeEvent(dto.eventKey);
|
||||
if (!event) return { code: 400, message: "事件不存在", data: null };
|
||||
|
||||
const exists = await this.ruleModel.findOne({ where: { ruleKey: dto.ruleKey } as any });
|
||||
if (exists) return { code: 400, message: "规则键已存在", data: null };
|
||||
|
||||
const created = await this.ruleModel.create({
|
||||
ruleKey: dto.ruleKey,
|
||||
ruleName: dto.ruleName,
|
||||
eventKey: dto.eventKey,
|
||||
triggerMode: dto.triggerMode,
|
||||
enabled: dto.enabled ?? 1,
|
||||
advanceDays: dto.advanceDays ?? null,
|
||||
titleTpl: dto.titleTpl,
|
||||
contentTpl: dto.contentTpl,
|
||||
receiverConfig: (dto.receiverConfig ?? {}) as any,
|
||||
remark: dto.remark ?? null
|
||||
} as any);
|
||||
|
||||
return { code: 200, message: "新增通知规则成功", data: this.toRuleView(created) };
|
||||
}
|
||||
|
||||
async update(id: number, dto: NoticeRuleUpdateDTO): Promise<any> {
|
||||
const rule = await this.ruleModel.findByPk(id);
|
||||
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
|
||||
|
||||
if (dto.eventKey !== undefined && !getNoticeEvent(dto.eventKey)) {
|
||||
return { code: 400, message: "事件不存在", data: null };
|
||||
}
|
||||
|
||||
const payload: any = {};
|
||||
if (dto.ruleName !== undefined) payload.ruleName = dto.ruleName;
|
||||
if (dto.eventKey !== undefined) payload.eventKey = dto.eventKey;
|
||||
if (dto.triggerMode !== undefined) payload.triggerMode = dto.triggerMode;
|
||||
if (dto.enabled !== undefined) payload.enabled = dto.enabled;
|
||||
if (dto.advanceDays !== undefined) payload.advanceDays = dto.advanceDays;
|
||||
if (dto.titleTpl !== undefined) payload.titleTpl = dto.titleTpl;
|
||||
if (dto.contentTpl !== undefined) payload.contentTpl = dto.contentTpl;
|
||||
if (dto.receiverConfig !== undefined) payload.receiverConfig = dto.receiverConfig;
|
||||
if (dto.remark !== undefined) payload.remark = dto.remark;
|
||||
|
||||
await rule.update(payload);
|
||||
return { code: 200, message: "更新通知规则成功", data: this.toRuleView(rule) };
|
||||
}
|
||||
|
||||
async changeEnabled(id: number, enabled: number): Promise<any> {
|
||||
const rule = await this.ruleModel.findByPk(id);
|
||||
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
|
||||
await rule.update({ enabled });
|
||||
return { code: 200, message: enabled === 1 ? "规则已启用" : "规则已停用", data: null };
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<any> {
|
||||
const rule = await this.ruleModel.findByPk(id);
|
||||
if (!rule) return { code: 404, message: "通知规则不存在", data: null };
|
||||
await rule.destroy();
|
||||
return { code: 200, message: "删除通知规则成功", data: null };
|
||||
}
|
||||
|
||||
/** 事件字典(供 Web 下拉与模板变量提示) */
|
||||
async eventOptions(): Promise<any> {
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取事件字典成功",
|
||||
data: NOTICE_EVENTS.map(e => ({
|
||||
...e,
|
||||
triggerModeText: e.triggerMode === 1 ? "扫描式" : "埋点式"
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** 手动触发一次扫描(Web「立即执行」) */
|
||||
async runScanNow(): Promise<any> {
|
||||
const summary = await this.executeScan();
|
||||
return { code: 200, message: "扫描执行完成", data: { summary } };
|
||||
}
|
||||
|
||||
// ==================== 触发执行 ====================
|
||||
|
||||
/** 扫描式统一入口:遍历启用的扫描式规则并生成通知(供 notice_scan 任务调用) */
|
||||
async executeScan(): Promise<string> {
|
||||
const rules = await this.ruleModel.findAll({ where: { enabled: 1, triggerMode: 1 } as any });
|
||||
let totalCreated = 0;
|
||||
const details: string[] = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const provider = this.scanProviders[rule.eventKey];
|
||||
if (!provider) {
|
||||
details.push(`${rule.ruleKey}:无扫描实现`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await provider.call(this, Number(rule.advanceDays) || 0);
|
||||
let created = 0;
|
||||
for (const item of items) {
|
||||
created += await this.dispatchRule(
|
||||
rule,
|
||||
item.vars,
|
||||
{ noticeType: rule.eventKey, bizType: item.bizType, bizId: item.bizId },
|
||||
item.workerId ?? undefined
|
||||
);
|
||||
}
|
||||
totalCreated += created;
|
||||
details.push(`${rule.ruleKey}:命中 ${items.length} 条/新增 ${created} 条`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
details.push(`${rule.ruleKey}:失败(${msg})`);
|
||||
}
|
||||
}
|
||||
|
||||
return `扫描 ${rules.length} 条规则,新增通知 ${totalCreated} 条(${details.join(";") || "无"})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 埋点式统一入口:业务代码在关键动作处调用。
|
||||
* 返回新增通知条数;调用方须 try/catch,通知失败不得阻断业务。
|
||||
*/
|
||||
async runForEvent(eventKey: string, params: RunEventParams): Promise<number> {
|
||||
const rules = await this.ruleModel.findAll({
|
||||
where: { eventKey, enabled: 1, triggerMode: 2 } as any
|
||||
});
|
||||
|
||||
let created = 0;
|
||||
for (const rule of rules) {
|
||||
created += await this.dispatchRule(
|
||||
rule,
|
||||
params.vars,
|
||||
{
|
||||
noticeType: eventKey,
|
||||
bizType: params.bizType ?? null,
|
||||
bizId: params.bizId ?? null
|
||||
},
|
||||
params.eventWorkerId ?? undefined
|
||||
);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 按规则模板渲染 + 接收人解析 + 幂等投递 */
|
||||
private async dispatchRule(
|
||||
rule: SysNoticeRule,
|
||||
vars: Record<string, any>,
|
||||
base: { noticeType: string; bizType?: string | null; bizId?: number | null },
|
||||
eventWorkerId?: number
|
||||
): Promise<number> {
|
||||
const title = this.renderTemplate(rule.titleTpl, vars);
|
||||
const content = this.renderTemplate(rule.contentTpl, vars);
|
||||
|
||||
return this.noticeService.deliverToReceivers(
|
||||
{
|
||||
noticeType: base.noticeType,
|
||||
title,
|
||||
content,
|
||||
bizType: base.bizType ?? null,
|
||||
bizId: base.bizId ?? null,
|
||||
ruleId: Number(rule.id)
|
||||
},
|
||||
(rule.receiverConfig || null) as ReceiverConfig | null,
|
||||
{ eventWorkerId }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单通知(埋点式统一入口)。
|
||||
* 业务方只需传订单信息与电工,客户名称由本方法补齐;通知失败由调用方 try/catch 兜底。
|
||||
*/
|
||||
async notifyDispatchAssign(params: {
|
||||
orderTypeName: string;
|
||||
orderNo: string;
|
||||
customerId: number;
|
||||
workerId: number;
|
||||
detailText?: string;
|
||||
bizType: string;
|
||||
bizId: number;
|
||||
}): Promise<number> {
|
||||
if (!params.workerId) return 0;
|
||||
|
||||
let customerName = "未知客户";
|
||||
const customer = await this.customerModel.findByPk(params.customerId, { attributes: ["id", "customerName"] });
|
||||
if (customer) customerName = customer.customerName;
|
||||
|
||||
return this.runForEvent("dispatch_assign", {
|
||||
vars: {
|
||||
orderTypeName: params.orderTypeName,
|
||||
orderNo: params.orderNo,
|
||||
customerName,
|
||||
detailText: params.detailText || ""
|
||||
},
|
||||
bizType: params.bizType,
|
||||
bizId: params.bizId,
|
||||
eventWorkerId: Number(params.workerId)
|
||||
});
|
||||
}
|
||||
|
||||
/** 模板渲染:{变量} 占位符替换,未提供变量替换为空串 */
|
||||
private renderTemplate(tpl: string, vars: Record<string, any>): string {
|
||||
if (!tpl) return "";
|
||||
return tpl.replace(/\{(\w+)\}/g, (_m, key) => {
|
||||
const val = vars[key];
|
||||
return val === undefined || val === null ? "" : String(val);
|
||||
});
|
||||
}
|
||||
|
||||
private toRuleView(rule: SysNoticeRule): any {
|
||||
return rule.toJSON();
|
||||
}
|
||||
|
||||
// ==================== 扫描实现 ====================
|
||||
|
||||
/** 电工资质证照到期:expire_date <= today + advanceDays(含已过期) */
|
||||
private async scanCertExpire(advanceDays: number): Promise<NoticeScanItem[]> {
|
||||
const today = dayjs().startOf("day");
|
||||
const limit = today.add(advanceDays, "day").format("YYYY-MM-DD");
|
||||
|
||||
const certs = await this.certModel.findAll({
|
||||
where: { expireDate: { [Op.ne]: null, [Op.lte]: limit } } as any
|
||||
});
|
||||
if (certs.length === 0) return [];
|
||||
|
||||
const workerIds = Array.from(new Set(certs.map(c => Number(c.workerId))));
|
||||
const workers = await this.workerModel.findAll({
|
||||
where: { id: { [Op.in]: workerIds } } as any,
|
||||
attributes: ["id", "workerName"]
|
||||
});
|
||||
const nameMap = new Map(workers.map(w => [Number(w.id), w.workerName]));
|
||||
|
||||
return certs.map(cert => {
|
||||
const expireDate = String(cert.expireDate).slice(0, 10);
|
||||
const remainDays = dayjs(expireDate).diff(today, "day");
|
||||
const expired = remainDays < 0;
|
||||
return {
|
||||
bizType: "worker_cert",
|
||||
bizId: Number(cert.id),
|
||||
workerId: Number(cert.workerId),
|
||||
vars: {
|
||||
workerName: nameMap.get(Number(cert.workerId)) || "未知电工",
|
||||
certType: cert.certType || "证照",
|
||||
certNo: cert.certNo || "—",
|
||||
issueAuthority: cert.issueAuthority || "—",
|
||||
expireDate,
|
||||
remainDays,
|
||||
expireState: expired ? "已过期" : "即将到期",
|
||||
remainDaysText: this.remainDaysText(remainDays)
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户合同到期:end_date <= today + advanceDays(仅有效合同) */
|
||||
private async scanContractExpire(advanceDays: number): Promise<NoticeScanItem[]> {
|
||||
const today = dayjs().startOf("day");
|
||||
const limit = today.add(advanceDays, "day").format("YYYY-MM-DD");
|
||||
|
||||
const contracts = await this.contractModel.findAll({
|
||||
where: { status: 1, endDate: { [Op.ne]: null, [Op.lte]: limit } } as any
|
||||
});
|
||||
if (contracts.length === 0) return [];
|
||||
|
||||
const customerIds = Array.from(new Set(contracts.map(c => Number(c.customerId))));
|
||||
const customers = await this.customerModel.findAll({
|
||||
where: { id: { [Op.in]: customerIds } } as any,
|
||||
attributes: ["id", "customerName"]
|
||||
});
|
||||
const nameMap = new Map(customers.map(c => [Number(c.id), c.customerName]));
|
||||
|
||||
return contracts.map(contract => {
|
||||
const endDate = String(contract.endDate).slice(0, 10);
|
||||
const remainDays = dayjs(endDate).diff(today, "day");
|
||||
const expired = remainDays < 0;
|
||||
return {
|
||||
bizType: "contract",
|
||||
bizId: Number(contract.id),
|
||||
vars: {
|
||||
customerName: nameMap.get(Number(contract.customerId)) || "未知客户",
|
||||
contractNo: contract.contractNo || "—",
|
||||
contractName: contract.xtContractNo || contract.contractNo || "—",
|
||||
endDate,
|
||||
expireState: expired ? "已到期" : "即将到期",
|
||||
remainDaysText: this.remainDaysText(remainDays)
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户拜访计划:status=0(待执行)且计划时间已到/超期 */
|
||||
private async scanVisitPlan(advanceDays: number): Promise<NoticeScanItem[]> {
|
||||
const today = dayjs().startOf("day");
|
||||
const limit = today.add(advanceDays, "day").endOf("day").toDate();
|
||||
|
||||
const visits = await this.visitModel.findAll({
|
||||
where: { status: 0, planTime: { [Op.ne]: null, [Op.lte]: limit } } as any
|
||||
});
|
||||
if (visits.length === 0) return [];
|
||||
|
||||
const customerIds = Array.from(new Set(visits.map(v => Number(v.customerId))));
|
||||
const customers = await this.customerModel.findAll({
|
||||
where: { id: { [Op.in]: customerIds } } as any,
|
||||
attributes: ["id", "customerName"]
|
||||
});
|
||||
const nameMap = new Map(customers.map(c => [Number(c.id), c.customerName]));
|
||||
|
||||
return visits.map(visit => {
|
||||
const planDate = dayjs(visit.planTime).format("YYYY-MM-DD");
|
||||
const remainDays = dayjs(planDate).startOf("day").diff(today, "day");
|
||||
return {
|
||||
bizType: "customer_visit",
|
||||
bizId: Number(visit.id),
|
||||
vars: {
|
||||
customerName: nameMap.get(Number(visit.customerId)) || "未知客户",
|
||||
planDate,
|
||||
remainDaysText: remainDays < 0 ? `已超期 ${Math.abs(remainDays)} 天` : "计划日期已到"
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 工单超时未接单:派单后超过 advanceDays 天仍未接单(status=0) */
|
||||
private async scanOrderTimeout(advanceDays: number): Promise<NoticeScanItem[]> {
|
||||
const cutoff = dayjs().subtract(advanceDays, "day").toDate();
|
||||
const createdCond = { [Op.ne]: null, [Op.lte]: cutoff };
|
||||
|
||||
const [repairs, surveys, installs, maintenances] = await Promise.all([
|
||||
this.repairDispatchModel.findAll({ where: { status: 0, created_at: createdCond } as any }),
|
||||
this.surveyOrderModel.findAll({ where: { status: 0, workerId: { [Op.ne]: null }, created_at: createdCond } as any }),
|
||||
this.installOrderModel.findAll({ where: { status: 0, created_at: createdCond } as any }),
|
||||
this.maintenanceModel.findAll({ where: { status: 0, created_at: createdCond } as any })
|
||||
]);
|
||||
|
||||
const rows: { row: any; source: string; orderNo: string; typeName: string }[] = [
|
||||
...repairs.map(r => ({ row: r, source: "repair_dispatch", orderNo: r.dispatchNo, typeName: "报修" })),
|
||||
...surveys.map(r => ({ row: r, source: "survey_order", orderNo: r.surveyNo, typeName: "勘察" })),
|
||||
...installs.map(r => ({ row: r, source: "install_order", orderNo: r.installNo, typeName: "安装" })),
|
||||
...maintenances.map(r => ({ row: r, source: "maintenance_order", orderNo: r.maintenanceNo, typeName: "维保" }))
|
||||
];
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const customerIds = Array.from(new Set(rows.map(r => Number(r.row.customerId))));
|
||||
const customers = await this.customerModel.findAll({
|
||||
where: { id: { [Op.in]: customerIds } } as any,
|
||||
attributes: ["id", "customerName"]
|
||||
});
|
||||
const nameMap = new Map(customers.map(c => [Number(c.id), c.customerName]));
|
||||
|
||||
return rows.map(item => {
|
||||
const staleDays = Math.max(dayjs().diff(dayjs(item.row.created_at), "day"), 0);
|
||||
return {
|
||||
bizType: item.source,
|
||||
bizId: Number(item.row.id),
|
||||
vars: {
|
||||
orderTypeName: item.typeName,
|
||||
orderNo: item.orderNo || "—",
|
||||
customerName: nameMap.get(Number(item.row.customerId)) || "未知客户",
|
||||
staleDays
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private remainDaysText(remainDays: number): string {
|
||||
if (remainDays < 0) return `已过期 ${Math.abs(remainDays)} 天`;
|
||||
if (remainDays === 0) return "今天到期";
|
||||
return `剩余 ${remainDays} 天到期`;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Worker from "../entity/worker.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
import { LogService } from "./log.service";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
|
||||
// 报修单状态:0待处理 1已派单 2已完成
|
||||
const REQUEST_STATUS = { PENDING: 0, DISPATCHED: 1, COMPLETED: 2 };
|
||||
@@ -42,6 +43,9 @@ export class RepairDispatchService {
|
||||
@Inject()
|
||||
logService!: LogService;
|
||||
|
||||
@Inject()
|
||||
noticeRuleService!: NoticeRuleService;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
@@ -267,6 +271,20 @@ export class RepairDispatchService {
|
||||
const vo: any = created.record.toJSON();
|
||||
this.decorateDispatch([vo]);
|
||||
await this.logService.logAction(ctx, "报修派单", "创建", created.record.dispatchNo, `创建报修派单 ${created.record.dispatchNo}`);
|
||||
// 派单站内通知(埋点式;通知失败不得阻断业务)
|
||||
try {
|
||||
await this.noticeRuleService.notifyDispatchAssign({
|
||||
orderTypeName: "报修",
|
||||
orderNo: created.record.dispatchNo,
|
||||
customerId: Number(created.record.customerId),
|
||||
workerId: Number(created.record.workerId),
|
||||
detailText: `报修内容:${String(created.record.repairContent || "").slice(0, 60)}。`,
|
||||
bizType: "repair_dispatch",
|
||||
bizId: Number(created.record.id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 报修派单通知失败:", err);
|
||||
}
|
||||
return { code: 200, message: "创建报修派单成功", data: vo };
|
||||
}
|
||||
|
||||
@@ -285,10 +303,27 @@ export class RepairDispatchService {
|
||||
delete payload.repairId;
|
||||
delete payload.customerId;
|
||||
delete payload.status;
|
||||
const oldWorkerId = record.workerId ? Number(record.workerId) : null;
|
||||
await record.update(payload);
|
||||
const vo: any = record.toJSON();
|
||||
this.decorateDispatch([vo]);
|
||||
await this.logService.logAction(ctx, "报修派单", "更新", record.dispatchNo, `更新报修派单 ${record.dispatchNo}`);
|
||||
// 改派站内通知(派单对象变化时提醒新电工)
|
||||
if (record.workerId && Number(record.workerId) !== oldWorkerId) {
|
||||
try {
|
||||
await this.noticeRuleService.notifyDispatchAssign({
|
||||
orderTypeName: "报修",
|
||||
orderNo: record.dispatchNo,
|
||||
customerId: Number(record.customerId),
|
||||
workerId: Number(record.workerId),
|
||||
detailText: `报修内容:${String(record.repairContent || "").slice(0, 60)}。`,
|
||||
bizType: "repair_dispatch",
|
||||
bizId: Number(record.id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 报修派单通知失败:", err);
|
||||
}
|
||||
}
|
||||
return { code: 200, message: "更新报修派单成功", data: vo };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
import { UploadService } from "./upload.service";
|
||||
import { LogService } from "./log.service";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
|
||||
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
|
||||
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
|
||||
@@ -61,6 +62,9 @@ export class SurveyOrderService {
|
||||
@Inject()
|
||||
logService!: LogService;
|
||||
|
||||
@Inject()
|
||||
noticeRuleService!: NoticeRuleService;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
@@ -526,6 +530,20 @@ export class SurveyOrderService {
|
||||
};
|
||||
if (estimatedFee !== undefined && estimatedFee !== null) patch.estimatedFee = estimatedFee;
|
||||
await record.update(patch);
|
||||
// 指派站内通知(埋点式;通知失败不得阻断业务)
|
||||
try {
|
||||
await this.noticeRuleService.notifyDispatchAssign({
|
||||
orderTypeName: "勘察",
|
||||
orderNo: record.surveyNo,
|
||||
customerId: Number(record.customerId),
|
||||
workerId: Number(worker.id),
|
||||
detailText: record.taskTitle ? `任务:${record.taskTitle}。` : "",
|
||||
bizType: "survey_order",
|
||||
bizId: Number(record.id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 勘察派单通知失败:", err);
|
||||
}
|
||||
const vo = this.toVO(record);
|
||||
await this.decorate([vo]);
|
||||
return { ok: true, data: vo };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
@@ -11,6 +11,7 @@ import SurveyOrder from "../entity/survey_order.entity";
|
||||
import InstallOrder from "../entity/install_order.entity";
|
||||
import { WorkerCreateDTO, WorkerUpdateDTO, WorkerAuditDTO, WorkerCertDTO } from "../dto/worker.dto";
|
||||
import { hashPassword } from "../util/crypto";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
|
||||
// 电工状态:1在岗 0离职
|
||||
const STATUS_ON_DUTY = 1;
|
||||
@@ -92,6 +93,9 @@ export class WorkerService {
|
||||
@InjectRepository(InstallOrder)
|
||||
installOrderModel!: Repository<InstallOrder>;
|
||||
|
||||
@Inject()
|
||||
noticeRuleService!: NoticeRuleService;
|
||||
|
||||
// 资质状态推导所需的日期边界
|
||||
private certDateRange(): { today: string; warn: string } {
|
||||
return {
|
||||
@@ -375,6 +379,19 @@ export class WorkerService {
|
||||
});
|
||||
}
|
||||
|
||||
// 审核结果站内通知(埋点式;通知失败不得阻断审核业务)
|
||||
try {
|
||||
await this.noticeRuleService.runForEvent("worker_audit_result", {
|
||||
vars: {
|
||||
auditState: approved ? "已通过" : "已驳回",
|
||||
auditRemarkText: approved ? "" : `驳回理由:${(dto.remark || "").trim()}。`
|
||||
},
|
||||
eventWorkerId: Number(id)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[notice] 电工审核结果通知失败:", err);
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: approved ? "审核通过成功" : "审核驳回成功",
|
||||
|
||||
@@ -264,31 +264,6 @@ export class WorkerAppService {
|
||||
return { code: 200, message: "已重新提交,请等待审核" };
|
||||
}
|
||||
|
||||
// 到期提醒列表:基于 worker_cert 实时计算,包含临期与已过期(长期有效不计入)
|
||||
async listNotices(workerId: number): Promise<any> {
|
||||
const certs = await this.listCertRows(workerId);
|
||||
const today = this.todayStr();
|
||||
|
||||
const list = certs
|
||||
.filter(cert => !!cert.expireDate)
|
||||
.map(cert => {
|
||||
const expireDate = String(cert.expireDate).slice(0, 10);
|
||||
const remainDays = this.diffDays(today, expireDate);
|
||||
return {
|
||||
certId: cert.id,
|
||||
certType: cert.certType,
|
||||
certNo: cert.certNo,
|
||||
expireDate,
|
||||
remainDays,
|
||||
isExpired: expireDate < today
|
||||
};
|
||||
})
|
||||
.filter(item => item.isExpired || item.remainDays <= EXPIRING_DAYS)
|
||||
.sort((a, b) => a.remainDays - b.remainDays);
|
||||
|
||||
return { code: 200, message: "获取成功", data: list };
|
||||
}
|
||||
|
||||
// ================= 订单池 / 我的订单 / 订单详情 =================
|
||||
|
||||
// 订单来源是否合法(控制器据此返回 400)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Inject } from "@midwayjs/core";
|
||||
import { Job, IJob } from "@midwayjs/cron";
|
||||
import { NoticeService } from "../service/notice.service";
|
||||
|
||||
/**
|
||||
* 电工资质证照到期提醒定时任务
|
||||
* 每天 08:00 扫描 worker_cert 中 30 天内到期或已过期的证照,
|
||||
* 为每个 ADMIN 生成一条站内通知(幂等,已存在的提醒不会重复生成)。
|
||||
*/
|
||||
@Job({ cronTime: "0 0 8 * * *", start: true, timeZone: "Asia/Shanghai" })
|
||||
export class CertExpireTask implements IJob {
|
||||
@Inject()
|
||||
noticeService!: NoticeService;
|
||||
|
||||
async onTick() {
|
||||
const result = await this.noticeService.scanCertExpireAndNotify();
|
||||
console.log(`[cert-expire] 证照到期扫描完成:扫描 ${result.scanned} 条,新增通知 ${result.created} 条`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { demoHeartbeatJob } from "./demo_heartbeat.task";
|
||||
import { logCleanJob } from "./log_clean.task";
|
||||
import { noticeScanJob } from "./notice_scan.task";
|
||||
|
||||
export type JobParamType = "number" | "string";
|
||||
|
||||
@@ -26,6 +27,8 @@ export interface JobRuntime {
|
||||
cleanOperationLogs(keepDays: number): Promise<number>;
|
||||
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
|
||||
cleanJobLogs(keepDays: number): Promise<number>;
|
||||
/** 执行全部启用的扫描式通知规则,返回本次执行摘要 */
|
||||
runNoticeRules(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface JobHandlerContext {
|
||||
@@ -52,7 +55,7 @@ export interface BuiltinJob {
|
||||
handler: JobHandler;
|
||||
}
|
||||
|
||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob];
|
||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob];
|
||||
|
||||
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
|
||||
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 通知事件注册表(元数据)
|
||||
*
|
||||
* 事件分两类:
|
||||
* - 扫描式(triggerMode=1):由内置任务 notice_scan 定时扫描,扫描实现位于 NoticeRuleService
|
||||
* - 埋点式(triggerMode=2):业务代码在关键动作处调用 NoticeRuleService.runForEvent()
|
||||
*
|
||||
* 本文件只维护「事件元数据」(供 Web「通知管理」下拉与模板变量提示),
|
||||
* 具体扫描逻辑与业务埋点实现分别位于 NoticeRuleService 与各业务 service。
|
||||
*/
|
||||
|
||||
/** 模板可用变量({变量} 占位符) */
|
||||
export interface NoticeEventVariable {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 事件定义 */
|
||||
export interface NoticeEventDefinition {
|
||||
eventKey: string;
|
||||
eventName: string;
|
||||
/** 1扫描式 2埋点式 */
|
||||
triggerMode: 1 | 2;
|
||||
variables: NoticeEventVariable[];
|
||||
}
|
||||
|
||||
/** 扫描式事件单条命中结果 */
|
||||
export interface NoticeScanItem {
|
||||
bizType: string;
|
||||
bizId: number;
|
||||
/** workers.mode=event 时作为事件关联电工 */
|
||||
workerId?: number | null;
|
||||
vars: Record<string, any>;
|
||||
}
|
||||
|
||||
export const NOTICE_EVENTS: NoticeEventDefinition[] = [
|
||||
{
|
||||
eventKey: "cert_expire",
|
||||
eventName: "电工资质证照到期提醒",
|
||||
triggerMode: 1,
|
||||
variables: [
|
||||
{ key: "workerName", label: "电工姓名" },
|
||||
{ key: "certType", label: "证书类型" },
|
||||
{ key: "certNo", label: "证书编号" },
|
||||
{ key: "issueAuthority", label: "发证机关" },
|
||||
{ key: "expireDate", label: "有效期至" },
|
||||
{ key: "remainDays", label: "剩余天数" },
|
||||
{ key: "expireState", label: "到期状态(即将到期/已过期)" },
|
||||
{ key: "remainDaysText", label: "剩余天数文案" }
|
||||
]
|
||||
},
|
||||
{
|
||||
eventKey: "contract_expire",
|
||||
eventName: "客户合同到期提醒",
|
||||
triggerMode: 1,
|
||||
variables: [
|
||||
{ key: "customerName", label: "客户名称" },
|
||||
{ key: "contractNo", label: "合同编号" },
|
||||
{ key: "contractName", label: "合同名称" },
|
||||
{ key: "endDate", label: "到期日" },
|
||||
{ key: "expireState", label: "到期状态(即将到期/已过期)" },
|
||||
{ key: "remainDaysText", label: "剩余天数文案" }
|
||||
]
|
||||
},
|
||||
{
|
||||
eventKey: "visit_plan",
|
||||
eventName: "客户拜访计划提醒",
|
||||
triggerMode: 1,
|
||||
variables: [
|
||||
{ key: "customerName", label: "客户名称" },
|
||||
{ key: "planDate", label: "计划拜访日期" },
|
||||
{ key: "remainDaysText", label: "到期文案" }
|
||||
]
|
||||
},
|
||||
{
|
||||
eventKey: "order_timeout",
|
||||
eventName: "工单超时未接单提醒",
|
||||
triggerMode: 1,
|
||||
variables: [
|
||||
{ key: "orderTypeName", label: "工单类型" },
|
||||
{ key: "orderNo", label: "工单编号" },
|
||||
{ key: "customerName", label: "客户名称" },
|
||||
{ key: "staleDays", label: "滞留天数" }
|
||||
]
|
||||
},
|
||||
{
|
||||
eventKey: "worker_audit_result",
|
||||
eventName: "电工入驻审核结果通知",
|
||||
triggerMode: 2,
|
||||
variables: [
|
||||
{ key: "auditState", label: "审核结果(已通过/已驳回)" },
|
||||
{ key: "auditRemarkText", label: "驳回理由文案" }
|
||||
]
|
||||
},
|
||||
{
|
||||
eventKey: "dispatch_assign",
|
||||
eventName: "工单派单通知",
|
||||
triggerMode: 2,
|
||||
variables: [
|
||||
{ key: "orderTypeName", label: "工单类型" },
|
||||
{ key: "orderNo", label: "工单编号" },
|
||||
{ key: "customerName", label: "客户名称" },
|
||||
{ key: "detailText", label: "附加说明" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function getNoticeEvent(eventKey: string): NoticeEventDefinition | undefined {
|
||||
return NOTICE_EVENTS.find(e => e.eventKey === eventKey);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BuiltinJob } from "./job_registry";
|
||||
|
||||
/**
|
||||
* 通知规则扫描任务:遍历启用的扫描式通知规则,
|
||||
* 扫描业务数据并按模板 + 接收对象生成站内通知(幂等)。
|
||||
* 默认每日 08:00 执行,可在「定时任务」页调整 cron / 启停 / 立即执行。
|
||||
*/
|
||||
export const noticeScanJob: BuiltinJob = {
|
||||
jobKey: "notice_scan",
|
||||
jobName: "通知规则扫描",
|
||||
description: "遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等)",
|
||||
defaultCron: "0 0 8 * * *",
|
||||
defaultParams: {},
|
||||
paramFields: [],
|
||||
handler: async (_params, ctx) => {
|
||||
return ctx.runtime.runNoticeRules();
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user