Commit c0151ca8 authored by drigle's avatar drigle

feat: add forward OA and app notifications

parent a2314f0c
...@@ -27,6 +27,7 @@ import { ...@@ -27,6 +27,7 @@ import {
loadForwardEditableRecords, loadForwardEditableRecords,
loadForwardRecords, loadForwardRecords,
saveForwardEditableRecords, saveForwardEditableRecords,
dispatchForwardStateNotification,
updateForwardRecordStatus, updateForwardRecordStatus,
} from './modelMonitorService'; } from './modelMonitorService';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
...@@ -94,6 +95,7 @@ export default function ForwardApprovals() { ...@@ -94,6 +95,7 @@ export default function ForwardApprovals() {
const [editSaving, setEditSaving] = useState(false); const [editSaving, setEditSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const [notificationWarning, setNotificationWarning] = useState('');
const [activeRecordId, setActiveRecordId] = useState(''); const [activeRecordId, setActiveRecordId] = useState('');
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
...@@ -186,8 +188,22 @@ export default function ForwardApprovals() { ...@@ -186,8 +188,22 @@ export default function ForwardApprovals() {
async function handleUpdateRecord(record, nextStatuses) { async function handleUpdateRecord(record, nextStatuses) {
setSaving(true); setSaving(true);
setNotificationWarning('');
try { try {
await updateForwardRecordStatus(record.recordName, nextStatuses); const updatedRecord = await updateForwardRecordStatus(record.recordName, nextStatuses);
const notificationEvent = nextStatuses.approval_status === 'approved'
? 'first_approval_pending'
: nextStatuses.final_approval_status === 'approved'
? 'final_approval_pending'
: nextStatuses.approval_status === 'rejected'
? 'first_rejected'
: nextStatuses.final_approval_status === 'rejected'
? 'final_rejected'
: '';
const notificationResult = notificationEvent
? await dispatchForwardStateNotification(notificationEvent, updatedRecord, userInfo)
: { warning: '' };
setNotificationWarning(notificationResult.warning);
await refreshRecords('审批状态已更新。'); await refreshRecords('审批状态已更新。');
setError(''); setError('');
} catch (updateError) { } catch (updateError) {
...@@ -287,6 +303,7 @@ export default function ForwardApprovals() { ...@@ -287,6 +303,7 @@ export default function ForwardApprovals() {
} }
setEditSaving(true); setEditSaving(true);
setNotificationWarning('');
try { try {
if (editableRecords.length > 0) { if (editableRecords.length > 0) {
const saved = await saveForwardEditableRecords( const saved = await saveForwardEditableRecords(
...@@ -309,6 +326,12 @@ export default function ForwardApprovals() { ...@@ -309,6 +326,12 @@ export default function ForwardApprovals() {
}); });
setEditingRecord(updatedForwardRecord); setEditingRecord(updatedForwardRecord);
const notificationResult = await dispatchForwardStateNotification(
'first_approval_pending',
updatedForwardRecord,
userInfo,
);
setNotificationWarning(notificationResult.warning);
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
await refreshRecords('填写已完成,当前批次已进入待审批。'); await refreshRecords('填写已完成,当前批次已进入待审批。');
setError(''); setError('');
...@@ -460,6 +483,7 @@ export default function ForwardApprovals() { ...@@ -460,6 +483,7 @@ export default function ForwardApprovals() {
{loading ? <NoticeBanner>正在加载转发审批记录...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载转发审批记录...</NoticeBanner> : null}
{!loading && error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null} {!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{notificationWarning ? <NoticeBanner>{notificationWarning}</NoticeBanner> : null}
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={activeFilter} variant="primary" onSelectionChange={(key) => { setActiveFilter(String(key)); setPage(0); }}> <Tabs className="w-fit max-w-full min-w-0" selectedKey={activeFilter} variant="primary" onSelectionChange={(key) => { setActiveFilter(String(key)); setPage(0); }}>
......
...@@ -39,6 +39,7 @@ import { ...@@ -39,6 +39,7 @@ import {
loadModelBatch, loadModelBatch,
resolveForwardRowStatus, resolveForwardRowStatus,
saveForwardRecord, saveForwardRecord,
dispatchForwardStateNotification,
updateBatchHiddenRecords, updateBatchHiddenRecords,
} from './modelMonitorService'; } from './modelMonitorService';
...@@ -60,6 +61,7 @@ export default function ModelBatchData() { ...@@ -60,6 +61,7 @@ export default function ModelBatchData() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const [notificationWarning, setNotificationWarning] = useState('');
const [isHiddenDialogOpen, setIsHiddenDialogOpen] = useState(false); const [isHiddenDialogOpen, setIsHiddenDialogOpen] = useState(false);
const [isForwardDialogOpen, setIsForwardDialogOpen] = useState(false); const [isForwardDialogOpen, setIsForwardDialogOpen] = useState(false);
const [forwardForm, setForwardForm] = useState(createEmptyForwardDraft); const [forwardForm, setForwardForm] = useState(createEmptyForwardDraft);
...@@ -209,6 +211,8 @@ export default function ModelBatchData() { ...@@ -209,6 +211,8 @@ export default function ModelBatchData() {
}, userInfo); }, userInfo);
const deliverRecords = selectedRecords.filter((item) => deliverIds.includes(item.recordName || item.id)); const deliverRecords = selectedRecords.filter((item) => deliverIds.includes(item.recordName || item.id));
await ensureForwardRecordItems(record, deliverRecords, userInfo); await ensureForwardRecordItems(record, deliverRecords, userInfo);
const notificationResult = await dispatchForwardStateNotification('requested', record, userInfo);
setNotificationWarning(notificationResult.warning);
await refresh(); await refresh();
setSelectedRecordIds([]); setSelectedRecordIds([]);
setIsForwardDialogOpen(false); setIsForwardDialogOpen(false);
...@@ -227,6 +231,7 @@ export default function ModelBatchData() { ...@@ -227,6 +231,7 @@ export default function ModelBatchData() {
{loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null} {!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{notificationWarning ? <NoticeBanner>{notificationWarning}</NoticeBanner> : null}
{batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner tone="error">最近一次同步未完整成功:{batch.errorMessage || '该批次不可重试,请联系管理员排查。'}</NoticeBanner> : null} {batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner tone="error">最近一次同步未完整成功:{batch.errorMessage || '该批次不可重试,请联系管理员排查。'}</NoticeBanner> : null}
{!loading && batch ? ( {!loading && batch ? (
......
This diff is collapsed.
...@@ -132,7 +132,8 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -132,7 +132,8 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
3. 填写接收人、转发说明、一级审批人和终审人。 3. 填写接收人、转发说明、一级审批人和终审人。
4. 创建 `model_monitor_forward_record`,并为实际下发记录创建 `model_monitor_forward_item`;发起时不写入尚未填写的是否属实、整改方案和预计整改完成时间字段。 4. 创建 `model_monitor_forward_record`,并为实际下发记录创建 `model_monitor_forward_item`;发起时不写入尚未填写的是否属实、整改方案和预计整改完成时间字段。
5. 初始化 `editor_status = editing`、`approval_status = pending`、`final_approval_status = pending`。 5. 初始化 `editor_status = editing`、`approval_status = pending`、`final_approval_status = pending`。
6. 未结束流程中的 `excluded_record_ids` 在来源批次页暂时隐藏,终审通过后恢复展示。 6. 发起成功后为每位接收人创建 OA 流程待办,并给接收人发送站内通知;接收人 OA 任务 ID 写入 `oa_recipient_task_ids`。
7. 未结束流程中的 `excluded_record_ids` 在来源批次页暂时隐藏,终审通过后恢复展示。
### 4.8 填写与两级审批 ### 4.8 填写与两级审批
...@@ -143,8 +144,11 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -143,8 +144,11 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
5. 一级审批人通过或驳回;只有一级通过后终审人才可操作。 5. 一级审批人通过或驳回;只有一级通过后终审人才可操作。
6. 任一审批驳回后,填写状态恢复为 `editing`,清空上次完成人和完成时间,接收人可修改原回填内容。 6. 任一审批驳回后,填写状态恢复为 `editing`,清空上次完成人和完成时间,接收人可修改原回填内容。
7. 接收人重新完成填写时,`approval_status` 和 `final_approval_status` 均重置为 `pending`,流程从一级审批重新开始。 7. 接收人重新完成填写时,`approval_status` 和 `final_approval_status` 均重置为 `pending`,流程从一级审批重新开始。
8. 终审通过后状态为已最终审批,流程结束。 8. 终审通过后状态为已最终审批,流程结束;终审 OA 待办关闭,并给发起人、接收人和两级审批人发送结束通知。
9. 接收人填写、完成填写、一级审批和终审在前端同时执行按钮禁用与提交事件校验;用户身份或流程状态不满足时不发送更新请求,并提示状态已经变化或当前用户无权操作。 9. 接收人完成填写后关闭接收人 OA 待办,给一级审批人发送站内通知并创建其 OA 待办;一级通过后关闭一级 OA 待办,给终审人发送站内通知并创建其 OA 待办。
10. 一级或终审驳回后关闭当前环节 OA 待办,重新为接收人创建 OA 待办,并向接收人和发起人发送驳回站内通知。
11. 接收人填写、完成填写、一级审批和终审在前端同时执行按钮禁用与提交事件校验;用户身份或流程状态不满足时不发送更新请求,并提示状态已经变化或当前用户无权操作。
12. 通知和 OA 调用发生在业务状态成功更新之后;单个通知失败不会回滚填写、审批或转发状态,页面会显示“业务状态已更新,但通知处理存在异常”的提示,便于补发或排查。
## 5. 页面状态与异常处理 ## 5. 页面状态与异常处理
......
...@@ -17,6 +17,10 @@ import { ...@@ -17,6 +17,10 @@ import {
listSourceModels, listSourceModels,
loadSourceBatchData, loadSourceBatchData,
} from './ModelMonitorConnectorService'; } from './ModelMonitorConnectorService';
import {
dispatchForwardNotification,
formatForwardNotificationIssues,
} from './forwardNotificationService';
export const MODEL_GROUP_SUBJECT = 'model_monitor_group'; export const MODEL_GROUP_SUBJECT = 'model_monitor_group';
export const MODEL_SUBJECT = 'model_monitor_model'; export const MODEL_SUBJECT = 'model_monitor_model';
...@@ -172,6 +176,9 @@ const FIXED_SUBJECTS = [ ...@@ -172,6 +176,9 @@ const FIXED_SUBJECTS = [
{ name: 'final_approver_name', label: '终审人名称', type: 'text' }, { name: 'final_approver_name', label: '终审人名称', type: 'text' },
{ name: 'initiator_id', label: '发起人 ID', type: 'text' }, { name: 'initiator_id', label: '发起人 ID', type: 'text' },
{ name: 'initiator_name', label: '发起人名称', type: 'text' }, { name: 'initiator_name', label: '发起人名称', type: 'text' },
{ name: 'oa_recipient_task_ids', label: '接收人 OA 任务 ID', type: 'text', multiple: true },
{ name: 'oa_first_approver_task_id', label: '一级审批人 OA 任务 ID', type: 'text' },
{ name: 'oa_final_approver_task_id', label: '终审人 OA 任务 ID', type: 'text' },
], ],
}, },
{ {
...@@ -429,6 +436,11 @@ function normalizeForwardRecord(record = {}) { ...@@ -429,6 +436,11 @@ function normalizeForwardRecord(record = {}) {
finalApproverName: metadata.final_approver_name || '', finalApproverName: metadata.final_approver_name || '',
initiatorId: metadata.initiator_id || '', initiatorId: metadata.initiator_id || '',
initiatorName: metadata.initiator_name || '', initiatorName: metadata.initiator_name || '',
oaRecipientTaskIds: Array.isArray(metadata.oa_recipient_task_ids)
? metadata.oa_recipient_task_ids
: [],
oaFirstApproverTaskId: metadata.oa_first_approver_task_id || '',
oaFinalApproverTaskId: metadata.oa_final_approver_task_id || '',
createdAt: record.created_at || '', createdAt: record.created_at || '',
updatedAt: record.updated_at || '', updatedAt: record.updated_at || '',
}; };
...@@ -1141,6 +1153,9 @@ function createForwardPayload(values, user) { ...@@ -1141,6 +1153,9 @@ function createForwardPayload(values, user) {
final_approver_name: values.finalApproverName || '', final_approver_name: values.finalApproverName || '',
initiator_id: values.initiatorId || actor.id, initiator_id: values.initiatorId || actor.id,
initiator_name: values.initiatorName || actor.name, initiator_name: values.initiatorName || actor.name,
oa_recipient_task_ids: values.oaRecipientTaskIds || [],
oa_first_approver_task_id: values.oaFirstApproverTaskId || '',
oa_final_approver_task_id: values.oaFinalApproverTaskId || '',
}, },
}; };
} }
...@@ -1157,6 +1172,29 @@ export async function saveForwardRecord(values, user) { ...@@ -1157,6 +1172,29 @@ export async function saveForwardRecord(values, user) {
return normalizeForwardRecord(unwrapRecord(await createRecord(createForwardPayload(values, user)))); return normalizeForwardRecord(unwrapRecord(await createRecord(createForwardPayload(values, user))));
} }
async function saveForwardNotificationTasks(recordName, taskUpdates = {}) {
if (!Object.keys(taskUpdates).length) return normalizeForwardRecord(await loadRecord(recordName));
const current = normalizeForwardRecord(await loadRecord(recordName));
return normalizeForwardRecord(unwrapRecord(await updateRecord(recordName, createForwardPayload({
...current,
...taskUpdates,
}))));
}
async function dispatchForwardStateNotification(event, record, user) {
const result = await dispatchForwardNotification(event, record, user);
let updatedRecord = record;
try {
updatedRecord = await saveForwardNotificationTasks(record.recordName, result.taskUpdates);
} catch (saveError) {
result.errors.push(saveError?.message || 'OA 任务标识保存失败');
}
return {
record: updatedRecord,
warning: formatForwardNotificationIssues(result),
};
}
function createForwardItemPayload(values, user) { function createForwardItemPayload(values, user) {
const actor = currentUserValues(user); const actor = currentUserValues(user);
const hasFill = Boolean(values.truthStatus || values.rectificationPlan || values.expectedRectificationCompletedAt); const hasFill = Boolean(values.truthStatus || values.rectificationPlan || values.expectedRectificationCompletedAt);
...@@ -1309,3 +1347,8 @@ export async function updateForwardRecordStatus(recordName, nextMetadata = {}) { ...@@ -1309,3 +1347,8 @@ export async function updateForwardRecordStatus(recordName, nextMetadata = {}) {
}); });
return normalizeForwardRecord(unwrapRecord(await updateRecord(recordName, payload))); return normalizeForwardRecord(unwrapRecord(await updateRecord(recordName, payload)));
} }
export {
dispatchForwardStateNotification,
formatForwardNotificationIssues,
};
...@@ -285,3 +285,62 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及 ...@@ -285,3 +285,62 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及
``` ```
`:action` 与 `:methodName` 必须来自通知返回的动态 `actions` 配置,前端不得写死流程动作。 `:action` 与 `:methodName` 必须来自通知返回的动态 `actions` 配置,前端不得写死流程动作。
## 8. 模型监控流程通知
模型监控转发流程复用平台通知与已配置的 `oa_notice` Action。业务 Record 状态更新成功后才调用本节接口;接口异常不回滚业务状态,但页面必须展示通知异常摘要。
### 8.1 创建站内通知
`POST /notice`
```json
{
"title": "模型监控数据填写",
"content": "接收人已收到转发,请打开:https://example.com/model_analysis/models/forwards",
"type": "internal",
"category": "message",
"from": "发起人 _id",
"to": ["接收人 _id"],
"settings": {
"url": "/model_analysis/models/forwards"
}
}
```
### 8.2 发送站内通知
`PUT /notice/{name}/send`
请求路径中的 `name` 必须来自 `POST /notice` 返回值。`internal` 通知不可重复发送。
### 8.3 创建 OA 待办
`POST /action/execute/oa_notice:createProgress`
```json
{
"title": "模型监控数据填写:模型 / 批次",
"classify": "流程待办",
"contentType": "流程待办",
"receiveUserId": "接收人 _id",
"receiveUserName": "接收人账号",
"receiveDisplayName": "接收人显示名",
"sendUserId": "发起人 _id",
"sendUserName": "发起人账号",
"sendDisplayName": "发起人显示名",
"returnUrl": "https://example.com/model_analysis/models/forwards"
}
```
成功结果必须包含 `taskId`,并按环节保存到 `model_monitor_forward_record.metadata.oa_recipient_task_ids`、`oa_first_approver_task_id` 或 `oa_final_approver_task_id`。
### 8.4 关闭 OA 待办
`POST /action/execute/oa_notice:updateProgressStatus`
```json
{
"taskId": "待关闭的 OA 任务 ID"
}
```
...@@ -150,7 +150,7 @@ type Record = { ...@@ -150,7 +150,7 @@ type Record = {
### `model_monitor_forward_record`:模型转发审批记录 ### `model_monitor_forward_record`:模型转发审批记录
- 类型:`normal` - 类型:`normal`
- 字段数量:23 - 字段数量:26
- 说明:模型转发审批记录自动配置 - 说明:模型转发审批记录自动配置
| 字段标签 | 字段别名 | 字段类型 | 多值 | 必填 | 主键 | 关联/配置 | | 字段标签 | 字段别名 | 字段类型 | 多值 | 必填 | 主键 | 关联/配置 |
...@@ -178,6 +178,9 @@ type Record = { ...@@ -178,6 +178,9 @@ type Record = {
| 终审人名称 | `final_approver_name` | text | 否 | 否 | 否 | | | 终审人名称 | `final_approver_name` | text | 否 | 否 | 否 | |
| 发起人 ID | `initiator_id` | text | 否 | 否 | 否 | | | 发起人 ID | `initiator_id` | text | 否 | 否 | 否 | |
| 发起人名称 | `initiator_name` | text | 否 | 否 | 否 | | | 发起人名称 | `initiator_name` | text | 否 | 否 | 否 | |
| 接收人 OA 任务 ID | `oa_recipient_task_ids` | text | 是 | 否 | 否 | |
| 一级审批人 OA 任务 ID | `oa_first_approver_task_id` | text | 否 | 否 | 否 | |
| 终审人 OA 任务 ID | `oa_final_approver_task_id` | text | 否 | 否 | 否 | |
### `model_monitor_forward_item`:模型转发回填明细 ### `model_monitor_forward_item`:模型转发回填明细
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment