feat: 新增可配置通知中心,支持扫描式/埋点式通知规则与手动群发

新增通知规则实体、扫描任务、API与管理界面,重构站内通知支持用户/电工双类型,移除硬编码的证照到期提醒,新增工单派单、电工审核结果等通知触发场景,调整菜单与数据库结构适配新通知体系。
This commit is contained in:
andyesfly committed 2026-10-06 11:53:02 +08:00
1 parent 91aa3c06f0
commit 6cf6a3d5b9
36 files changed
+3309 -246

No files matched your search

+171
View File
@@ -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`,不引入新设计。
+47
View File
@@ -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),
};
+121
View File
@@ -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}`),
};
+36 -7
View File
@@ -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() {
+7 -1
View File
@@ -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",
+13 -13
View File
@@ -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
View File
@@ -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;
+4 -4
View File
@@ -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));
}
}
+22 -3
View File
@@ -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 之前
+17
View File
@@ -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;
}
+77
View File
@@ -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;
}
+12 -3
View File
@@ -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;
}
+2
View File
@@ -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",
+7 -7
View File
@@ -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 };
}
+8 -1
View File
@@ -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();
}
};
}
}
+37
View File
@@ -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 };
}
+136 -141
View File
@@ -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);
}
}
}
+103
View File
@@ -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 }
};
}
}
+480
View File
@@ -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 };
+18 -1
View File
@@ -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 ? "审核通过成功" : "审核驳回成功",
-25
View File
@@ -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)
-19
View File
@@ -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} 条`);
}
}
+4 -1
View File
@@ -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);
+110
View File
@@ -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);
}
+18
View File
@@ -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();
}
};