AP6 增加审批催办与效率摘要
这个提交包含在:
@@ -167,6 +167,21 @@ export function mapApiErrorMessage(
|
|||||||
return "自己的申请不能审批,请等待下一审批人处理。";
|
return "自己的申请不能审批,请等待下一审批人处理。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (details?.workflow === "self_remind_not_allowed") {
|
||||||
|
return "不能催办分配给自己的审批任务。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.workflow === "remind_owner_required") {
|
||||||
|
return "只有申请人或提交人可以催办该审批。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details?.workflow === "remind_too_frequent") {
|
||||||
|
const nextAllowedAt = typeof details?.nextAllowedAt === "string" ? details.nextAllowedAt : null;
|
||||||
|
return nextAllowedAt
|
||||||
|
? `24 小时内已催办过,请在 ${nextAllowedAt.slice(0, 19).replace("T", " ")} 后再试。`
|
||||||
|
: "24 小时内已催办过。";
|
||||||
|
}
|
||||||
|
|
||||||
if (details?.workflow === "business_approval_role_required") {
|
if (details?.workflow === "business_approval_role_required") {
|
||||||
return "系统管理员不自动获得业务审批权,请配置正式审批角色后处理。";
|
return "系统管理员不自动获得业务审批权,请配置正式审批角色后处理。";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,31 @@ export type WorkflowTaskListResponse = {
|
|||||||
items: WorkflowTaskSummary[];
|
items: WorkflowTaskSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkflowTaskSummaryBucket = {
|
||||||
|
key: string;
|
||||||
|
pendingTotal: number;
|
||||||
|
overdueTotal: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTaskProductivitySummary = {
|
||||||
|
scope: "mine" | "system";
|
||||||
|
pendingTotal: number;
|
||||||
|
overdueTotal: number;
|
||||||
|
earliestPendingAt: string | null;
|
||||||
|
averagePendingAgeHours: number;
|
||||||
|
byEntityType: WorkflowTaskSummaryBucket[];
|
||||||
|
byStep: WorkflowTaskSummaryBucket[];
|
||||||
|
byAssigneeRole: WorkflowTaskSummaryBucket[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTaskRemindResponse = {
|
||||||
|
taskId: string;
|
||||||
|
outcome: "reminded";
|
||||||
|
workflowInstanceId: string;
|
||||||
|
nextAllowedAt: string;
|
||||||
|
notificationCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowHistoryItem = {
|
export type WorkflowHistoryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
@@ -208,6 +233,10 @@ export type WorkflowTaskListQuery = {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkflowTaskSummaryQuery = {
|
||||||
|
scope?: "mine" | "system";
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowApproverQuery = {
|
export type WorkflowApproverQuery = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -249,6 +278,12 @@ export async function fetchWorkflowTasks(query: WorkflowTaskListQuery) {
|
|||||||
return apiRequestData<WorkflowTaskListResponse>(`/api/workflow/tasks${toQueryString(query)}`);
|
return apiRequestData<WorkflowTaskListResponse>(`/api/workflow/tasks${toQueryString(query)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkflowTaskSummary(query: WorkflowTaskSummaryQuery = {}) {
|
||||||
|
return apiRequestData<WorkflowTaskProductivitySummary>(
|
||||||
|
`/api/workflow/tasks/summary${toQueryString(query)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWorkflowTaskDetail(taskId: string) {
|
export async function fetchWorkflowTaskDetail(taskId: string) {
|
||||||
return apiRequestData<WorkflowTaskDetail>(`/api/workflow/tasks/${taskId}`);
|
return apiRequestData<WorkflowTaskDetail>(`/api/workflow/tasks/${taskId}`);
|
||||||
}
|
}
|
||||||
@@ -266,6 +301,13 @@ export async function approveWorkflowTask(taskId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function remindWorkflowTask(taskId: string, comment?: string) {
|
||||||
|
return apiRequestData<WorkflowTaskRemindResponse>(`/api/workflow/tasks/${taskId}/remind`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(comment ? { comment } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function rejectWorkflowTask(taskId: string, comment: string) {
|
export async function rejectWorkflowTask(taskId: string, comment: string) {
|
||||||
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/reject`, {
|
return apiRequestData<Record<string, unknown>>(`/api/workflow/tasks/${taskId}/reject`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const PAGE_SIZE = 20;
|
|||||||
|
|
||||||
const NOTIFICATION_TYPE_OPTIONS = [
|
const NOTIFICATION_TYPE_OPTIONS = [
|
||||||
{ label: "全部", value: "" },
|
{ label: "全部", value: "" },
|
||||||
{ label: "审批待办", value: "workflow.task_assigned" },
|
{ label: "审批待办", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
|
||||||
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
||||||
{ label: "请假", value: "leave.approved,leave.rejected" },
|
{ label: "请假", value: "leave.approved,leave.rejected" },
|
||||||
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchNotifications } from "../api/notifications";
|
import { fetchNotifications } from "../api/notifications";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { fetchWorkflowTasks } from "../api/workflow";
|
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
@@ -172,6 +172,12 @@ export function MobilePortalPage() {
|
|||||||
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
|
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const workflowSummaryQuery = useQuery({
|
||||||
|
enabled: canAccessApprovals,
|
||||||
|
queryKey: ["mobile", "workflow", "summary"],
|
||||||
|
queryFn: () => fetchWorkflowTaskSummary(),
|
||||||
|
});
|
||||||
|
|
||||||
const notificationsQuery = useQuery({
|
const notificationsQuery = useQuery({
|
||||||
queryKey: ["mobile", "portal", "notifications"],
|
queryKey: ["mobile", "portal", "notifications"],
|
||||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
|
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
|
||||||
@@ -210,7 +216,10 @@ export function MobilePortalPage() {
|
|||||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||||
const attendanceAction = resolveAttendanceAction(attendanceToday);
|
const attendanceAction = resolveAttendanceAction(attendanceToday);
|
||||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||||
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
|
const pendingApprovalCount = canAccessApprovals
|
||||||
|
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||||
|
: 0;
|
||||||
|
const overdueApprovalCount = canAccessApprovals ? workflowSummaryQuery.data?.overdueTotal ?? 0 : 0;
|
||||||
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||||||
const notifications = notificationsQuery.data?.items ?? [];
|
const notifications = notificationsQuery.data?.items ?? [];
|
||||||
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
||||||
@@ -220,7 +229,11 @@ export function MobilePortalPage() {
|
|||||||
? {
|
? {
|
||||||
key: "approvals",
|
key: "approvals",
|
||||||
label: "审批",
|
label: "审批",
|
||||||
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
|
meta: overdueApprovalCount > 0
|
||||||
|
? `${pendingApprovalCount} 待处理 / ${overdueApprovalCount} 超时`
|
||||||
|
: pendingApprovalCount > 0
|
||||||
|
? `${pendingApprovalCount} 条待处理`
|
||||||
|
: "暂无待办",
|
||||||
icon: "fact_check",
|
icon: "fact_check",
|
||||||
to: "/mobile/approvals",
|
to: "/mobile/approvals",
|
||||||
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
||||||
@@ -339,6 +352,24 @@ export function MobilePortalPage() {
|
|||||||
{resolveErrorMessage(pendingCountQuery.error)}
|
{resolveErrorMessage(pendingCountQuery.error)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{workflowSummaryQuery.data ? (
|
||||||
|
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||||
|
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||||
|
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
|
||||||
|
<div className="mt-1 text-zinc-500">待办</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||||
|
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
|
||||||
|
{workflowSummaryQuery.data.overdueTotal}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-zinc-500">超时</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||||
|
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.byEntityType.length}</div>
|
||||||
|
<div className="mt-1 text-zinc-500">类型</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
|
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
|
||||||
<p className="text-sm text-zinc-500">没有待处理审批。</p>
|
<p className="text-sm text-zinc-500">没有待处理审批。</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type ReimbursementItem,
|
type ReimbursementItem,
|
||||||
type ReimbursementStatus,
|
type ReimbursementStatus,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
|
import { remindWorkflowTask } from "../api/workflow";
|
||||||
import type { PrecheckResponse } from "../api/feeControl";
|
import type { PrecheckResponse } from "../api/feeControl";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
@@ -119,6 +120,20 @@ function formatDateTime(value: string | null | undefined) {
|
|||||||
return value.slice(0, 19).replace("T", " ");
|
return value.slice(0, 19).replace("T", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowActionLabel(action: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
start: "提交",
|
||||||
|
approve: "通过",
|
||||||
|
reject: "驳回",
|
||||||
|
return: "退回",
|
||||||
|
transfer: "转审",
|
||||||
|
withdraw: "撤回",
|
||||||
|
remind: "催办",
|
||||||
|
overdue_remind: "超时提醒",
|
||||||
|
};
|
||||||
|
return labels[action] ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
function formatInvoiceTitle(invoice: {
|
function formatInvoiceTitle(invoice: {
|
||||||
invoiceCode?: string | null;
|
invoiceCode?: string | null;
|
||||||
invoiceNumber?: string | null;
|
invoiceNumber?: string | null;
|
||||||
@@ -205,6 +220,13 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||||
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
|
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
|
||||||
const canEdit = isEditableStatus(detail?.status);
|
const canEdit = isEditableStatus(detail?.status);
|
||||||
|
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
|
||||||
|
const canRemindPendingWorkflow = Boolean(
|
||||||
|
detail?.status === "reviewing" &&
|
||||||
|
pendingTaskId &&
|
||||||
|
isParticipant &&
|
||||||
|
detail?.pendingWorkflow?.task?.assignee_id !== me?.id,
|
||||||
|
);
|
||||||
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
|
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
|
||||||
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||||
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
|
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
|
||||||
@@ -393,6 +415,21 @@ export function MobileReimbursementDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const remindMutation = useMutation({
|
||||||
|
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
invalidateDetail(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||||
|
]);
|
||||||
|
toast.success("已催办当前审批人");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const bindInvoicesMutation = useMutation({
|
const bindInvoicesMutation = useMutation({
|
||||||
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
|
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
|
||||||
const results: Array<{ invoiceId: string; success: boolean; message?: string }> = [];
|
const results: Array<{ invoiceId: string; success: boolean; message?: string }> = [];
|
||||||
@@ -800,6 +837,49 @@ export function MobileReimbursementDetailPage() {
|
|||||||
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{detail.status === "reviewing" || detail.approvalHistory.length ? (
|
||||||
|
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={remindMutation.isPending}
|
||||||
|
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||||||
|
>
|
||||||
|
{remindMutation.isPending ? "催办中..." : "催办"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.approvalHistory.length ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{detail.approvalHistory.map((item) => (
|
||||||
|
<article key={item.id} className="mobile-list-card">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
|
||||||
|
<div className="shrink-0 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||||
|
</div>
|
||||||
|
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type PortalRecentActivity,
|
type PortalRecentActivity,
|
||||||
type PortalTeamStatusItem,
|
type PortalTeamStatusItem,
|
||||||
} from "../api/portal";
|
} from "../api/portal";
|
||||||
|
import { fetchWorkflowTaskSummary, type WorkflowTaskProductivitySummary } from "../api/workflow";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
|
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
|
||||||
@@ -46,6 +47,15 @@ type SummaryItem = {
|
|||||||
tone?: "normal" | "warning";
|
tone?: "normal" | "warning";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function workflowEntityLabel(entityType: string) {
|
||||||
|
if (entityType === "reimbursement") return "报销";
|
||||||
|
if (entityType === "leave") return "请假";
|
||||||
|
if (entityType === "purchase_request") return "采购";
|
||||||
|
if (entityType === "travel_request") return "差旅";
|
||||||
|
if (entityType === "unknown") return "未知业务";
|
||||||
|
return entityType;
|
||||||
|
}
|
||||||
|
|
||||||
type TodoAggregateItem = {
|
type TodoAggregateItem = {
|
||||||
title: string;
|
title: string;
|
||||||
sourceLabel: string;
|
sourceLabel: string;
|
||||||
@@ -435,6 +445,52 @@ function FinanceSummaryPanel({ summary }: { summary: PortalHomePayload["financeS
|
|||||||
return <SummaryPanel title="财务待处理" items={items} />;
|
return <SummaryPanel title="财务待处理" items={items} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ApprovalProductivityPanel({
|
||||||
|
summary,
|
||||||
|
title = "审批效率摘要",
|
||||||
|
}: {
|
||||||
|
summary: WorkflowTaskProductivitySummary | null;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeDistribution = summary.byEntityType
|
||||||
|
.filter((item) => item.pendingTotal > 0)
|
||||||
|
.map((item) => `${workflowEntityLabel(item.key)} ${item.pendingTotal}`)
|
||||||
|
.join(" / ");
|
||||||
|
const items: SummaryItem[] = [
|
||||||
|
{
|
||||||
|
key: "workflow-pending-total",
|
||||||
|
href: "/workflow/tasks",
|
||||||
|
label: "当前待办",
|
||||||
|
value: `${summary.pendingTotal} 条`,
|
||||||
|
note: summary.earliestPendingAt
|
||||||
|
? `最早待处理:${formatRelativeTime(summary.earliestPendingAt)}`
|
||||||
|
: "暂无待处理审批。",
|
||||||
|
tone: summary.pendingTotal > 0 ? "warning" : "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workflow-overdue-total",
|
||||||
|
href: "/workflow/tasks",
|
||||||
|
label: "超时待办",
|
||||||
|
value: `${summary.overdueTotal} 条`,
|
||||||
|
note: `平均等待 ${summary.averagePendingAgeHours} 小时`,
|
||||||
|
tone: summary.overdueTotal > 0 ? "warning" : "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workflow-entity-distribution",
|
||||||
|
href: "/workflow/tasks",
|
||||||
|
label: "业务分布",
|
||||||
|
value: typeDistribution || "暂无",
|
||||||
|
note: "按报销、请假、采购、差旅汇总。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <SummaryPanel title={title} items={items} />;
|
||||||
|
}
|
||||||
|
|
||||||
function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||||
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
|
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
|
||||||
|
|
||||||
@@ -875,6 +931,16 @@ export function PortalHomePage() {
|
|||||||
queryFn: () => fetchMyTodayAttendance(),
|
queryFn: () => fetchMyTodayAttendance(),
|
||||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||||
});
|
});
|
||||||
|
const workflowSummaryQuery = useQuery({
|
||||||
|
enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
|
||||||
|
queryKey: ["workflow", "tasks", "summary", "mine"],
|
||||||
|
queryFn: () => fetchWorkflowTaskSummary(),
|
||||||
|
});
|
||||||
|
const workflowSystemSummaryQuery = useQuery({
|
||||||
|
enabled: hasAnyPermission(permissions, ["system.audit.view"]),
|
||||||
|
queryKey: ["workflow", "tasks", "summary", "system"],
|
||||||
|
queryFn: () => fetchWorkflowTaskSummary({ scope: "system" }),
|
||||||
|
});
|
||||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1281,6 +1347,15 @@ export function PortalHomePage() {
|
|||||||
{roleCapabilities.canApproveTasks ? (
|
{roleCapabilities.canApproveTasks ? (
|
||||||
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
|
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{roleCapabilities.canApproveTasks ? (
|
||||||
|
<ApprovalProductivityPanel summary={workflowSummaryQuery.data ?? null} />
|
||||||
|
) : null}
|
||||||
|
{roleCapabilities.canManageSystem ? (
|
||||||
|
<ApprovalProductivityPanel
|
||||||
|
title="全局审批效率摘要"
|
||||||
|
summary={workflowSystemSummaryQuery.data ?? null}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{roleCapabilities.canUseFinance ? (
|
{roleCapabilities.canUseFinance ? (
|
||||||
<FinanceSummaryPanel summary={payload.financeSummary ?? null} />
|
<FinanceSummaryPanel summary={payload.financeSummary ?? null} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
fetchLegalEntities,
|
fetchLegalEntities,
|
||||||
fetchSuppliers,
|
fetchSuppliers,
|
||||||
} from "../api/masterData";
|
} from "../api/masterData";
|
||||||
|
import { remindWorkflowTask } from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
@@ -201,7 +202,7 @@ function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseReques
|
|||||||
|
|
||||||
export function PurchaseRequestsPage() {
|
export function PurchaseRequestsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const canCreate = hasPermission(permissions, "procurement.create");
|
const canCreate = hasPermission(permissions, "procurement.create");
|
||||||
@@ -531,6 +532,24 @@ export function PurchaseRequestsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const remindMutation = useMutation({
|
||||||
|
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||||
|
]);
|
||||||
|
toast.success("已催办当前审批人");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
@@ -698,6 +717,16 @@ export function PurchaseRequestsPage() {
|
|||||||
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
||||||
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
|
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
|
||||||
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
|
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
|
||||||
|
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
|
||||||
|
const isPurchaseParticipant = Boolean(
|
||||||
|
detail && me?.id && [detail.applicantId, detail.submitterId].includes(me.id),
|
||||||
|
);
|
||||||
|
const canRemindPendingWorkflow = Boolean(
|
||||||
|
detail?.status === "reviewing" &&
|
||||||
|
pendingTaskId &&
|
||||||
|
isPurchaseParticipant &&
|
||||||
|
detail?.pendingWorkflow?.task?.assigneeId !== me?.id,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -978,6 +1007,17 @@ export function PurchaseRequestsPage() {
|
|||||||
>
|
>
|
||||||
{withdrawMutation.isPending ? "撤回中..." : "撤回"}
|
{withdrawMutation.isPending ? "撤回中..." : "撤回"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||||
|
<Button
|
||||||
|
data-testid="purchase-remind-workflow"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={remindMutation.isPending}
|
||||||
|
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||||||
|
>
|
||||||
|
{remindMutation.isPending ? "催办中..." : "催办审批"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type ReimbursementItem,
|
type ReimbursementItem,
|
||||||
type ReimbursementStatus,
|
type ReimbursementStatus,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
|
import { remindWorkflowTask } from "../api/workflow";
|
||||||
import {
|
import {
|
||||||
bindInvoice,
|
bindInvoice,
|
||||||
fetchBindableInvoices,
|
fetchBindableInvoices,
|
||||||
@@ -109,6 +110,20 @@ function invoiceStatusLabel(status: string) {
|
|||||||
return INVOICE_STATUS_LABEL[status] ?? "未知状态";
|
return INVOICE_STATUS_LABEL[status] ?? "未知状态";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowActionLabel(action: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
start: "提交",
|
||||||
|
approve: "通过",
|
||||||
|
reject: "驳回",
|
||||||
|
return: "退回",
|
||||||
|
transfer: "转审",
|
||||||
|
withdraw: "撤回",
|
||||||
|
remind: "催办",
|
||||||
|
overdue_remind: "超时提醒",
|
||||||
|
};
|
||||||
|
return labels[action] ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveErpSyncStatus(
|
function resolveErpSyncStatus(
|
||||||
exportState:
|
exportState:
|
||||||
| {
|
| {
|
||||||
@@ -298,6 +313,23 @@ export function ReimbursementDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const remindMutation = useMutation({
|
||||||
|
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
|
]);
|
||||||
|
toast.success("已催办当前审批人");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(buildActionErrorMessage(error, "催办失败,请稍后重试"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const itemSaveMutation = useMutation({
|
const itemSaveMutation = useMutation({
|
||||||
mutationFn: async (data: ItemEditorSaveData) => {
|
mutationFn: async (data: ItemEditorSaveData) => {
|
||||||
if (!reimbursementId) {
|
if (!reimbursementId) {
|
||||||
@@ -766,6 +798,13 @@ export function ReimbursementDetailPage() {
|
|||||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||||
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
|
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
|
||||||
|
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
|
||||||
|
const canRemindPendingWorkflow = Boolean(
|
||||||
|
detail.status === "reviewing" &&
|
||||||
|
pendingTaskId &&
|
||||||
|
isReimbursementParticipant &&
|
||||||
|
detail.pendingWorkflow?.task?.assignee_id !== me?.id,
|
||||||
|
);
|
||||||
const canOpenInvoiceBindDialog =
|
const canOpenInvoiceBindDialog =
|
||||||
canEditItems &&
|
canEditItems &&
|
||||||
isReimbursementParticipant &&
|
isReimbursementParticipant &&
|
||||||
@@ -944,6 +983,17 @@ export function ReimbursementDetailPage() {
|
|||||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||||
<Link to="/reimbursements">返回列表</Link>
|
<Link to="/reimbursements">返回列表</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursement-remind-workflow"
|
||||||
|
disabled={remindMutation.isPending}
|
||||||
|
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{remindMutation.isPending ? "催办中..." : "催办审批"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{canDeleteReimbursement ? (
|
{canDeleteReimbursement ? (
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursement-delete-draft"
|
data-testid="reimbursement-delete-draft"
|
||||||
@@ -1540,6 +1590,27 @@ export function ReimbursementDetailPage() {
|
|||||||
</DetailSideMeta>
|
</DetailSideMeta>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<DetailSideMeta title="审批记录">
|
||||||
|
{detail.approvalHistory.length ? (
|
||||||
|
<div className="space-y-3" data-testid="reimbursement-approval-history">
|
||||||
|
{detail.approvalHistory.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
|
||||||
|
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||||
|
</div>
|
||||||
|
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500">暂无审批记录。</div>
|
||||||
|
)}
|
||||||
|
</DetailSideMeta>
|
||||||
|
|
||||||
<ReimbursementActions
|
<ReimbursementActions
|
||||||
status={detail.status}
|
status={detail.status}
|
||||||
canPay={canPay}
|
canPay={canPay}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ function workflowActionLabel(action: string | undefined | null) {
|
|||||||
return: "退回",
|
return: "退回",
|
||||||
transfer: "转审",
|
transfer: "转审",
|
||||||
withdraw: "撤回",
|
withdraw: "撤回",
|
||||||
|
remind: "催办",
|
||||||
|
overdue_remind: "超时提醒",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
skip: "跳过",
|
skip: "跳过",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { approveWorkflowTask, fetchWorkflowTasks, type WorkflowTaskSummary } from "../api/workflow";
|
import {
|
||||||
|
approveWorkflowTask,
|
||||||
|
fetchWorkflowTasks,
|
||||||
|
fetchWorkflowTaskSummary,
|
||||||
|
type WorkflowTaskSummary,
|
||||||
|
} from "../api/workflow";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Pagination } from "../components/common/Pagination";
|
import { Pagination } from "../components/common/Pagination";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
@@ -103,6 +108,13 @@ function displayMetric(task: WorkflowTaskSummary) {
|
|||||||
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
|
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSummaryTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return value.slice(0, 19).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
function isAlreadyProcessedTaskError(error: unknown) {
|
function isAlreadyProcessedTaskError(error: unknown) {
|
||||||
return isApiError(error) && error.details?.task === "already_processed";
|
return isApiError(error) && error.details?.task === "already_processed";
|
||||||
}
|
}
|
||||||
@@ -143,6 +155,11 @@ export function WorkflowTasksPage() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: ["workflow", "tasks", "summary", "mine"],
|
||||||
|
queryFn: () => fetchWorkflowTaskSummary(),
|
||||||
|
});
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
|
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -173,6 +190,30 @@ export function WorkflowTasksPage() {
|
|||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
<PageHeader title="审批中心" subtitle="集中处理待办、查看已办和业务审批历史。" />
|
<PageHeader title="审批中心" subtitle="集中处理待办、查看已办和业务审批历史。" />
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4" data-testid="workflow-task-summary">
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs text-zinc-500">待办总数</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-zinc-900">{summaryQuery.data?.pendingTotal ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs text-zinc-500">超时待办</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-amber-700">{summaryQuery.data?.overdueTotal ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs text-zinc-500">最早待处理</div>
|
||||||
|
<div className="mt-2 text-sm font-medium text-zinc-900">{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs text-zinc-500">业务分布</div>
|
||||||
|
<div className="mt-2 truncate text-sm font-medium text-zinc-900">
|
||||||
|
{(summaryQuery.data?.byEntityType ?? [])
|
||||||
|
.filter((item) => item.pendingTotal > 0)
|
||||||
|
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
|
||||||
|
.join(" / ") || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="card-std space-y-4 p-4 md:p-5">
|
||||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ type WorkflowSubmitResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type WorkflowTaskDetailResponse = {
|
type WorkflowTaskDetailResponse = {
|
||||||
|
task?: {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
history: Array<{
|
history: Array<{
|
||||||
action: string;
|
action: string;
|
||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
@@ -67,6 +70,24 @@ type NotificationDeliveryListResponse = {
|
|||||||
items: unknown[];
|
items: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NotificationListResponse = {
|
||||||
|
items: Array<{
|
||||||
|
type: string;
|
||||||
|
targetId: string | null;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowTaskSummaryResponse = {
|
||||||
|
pendingTotal: number;
|
||||||
|
overdueTotal: number;
|
||||||
|
byEntityType: Array<{
|
||||||
|
key: string;
|
||||||
|
pendingTotal: number;
|
||||||
|
overdueTotal: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
type LeaveTypeListResponse = {
|
type LeaveTypeListResponse = {
|
||||||
items: Array<{
|
items: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -640,6 +661,73 @@ test.describe("R3 权限直连审计", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("AP6 审批催办与待办汇总:申请人可催办且重复催办被限制", async () => {
|
||||||
|
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
|
const manager = await createAuthenticatedApiClient(MANAGER);
|
||||||
|
const admin = await createAuthenticatedApiClient(ADMIN);
|
||||||
|
const clients = [employee, manager, admin];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leave = await createSubmittedLeave(employee.api, `ap6-remind-${Date.now()}`);
|
||||||
|
|
||||||
|
const summaryBefore = await apiGetData<WorkflowTaskSummaryResponse>(manager.api, "/api/workflow/tasks/summary");
|
||||||
|
expect(summaryBefore.pendingTotal).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(summaryBefore.byEntityType.some((item) => item.key === "leave" && item.pendingTotal > 0)).toBe(true);
|
||||||
|
|
||||||
|
const managerRemind = await manager.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} });
|
||||||
|
expectStatus(managerRemind.status(), [403], "manager POST workflow remind should not act as applicant");
|
||||||
|
|
||||||
|
const adminRemind = await admin.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} });
|
||||||
|
expectStatus(adminRemind.status(), [403], "admin-only POST workflow remind should not act as applicant");
|
||||||
|
|
||||||
|
const reminded = await apiPostData<{ outcome: string; notificationCount: number }>(
|
||||||
|
employee.api,
|
||||||
|
`/api/workflow/tasks/${leave.currentTaskId}/remind`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(reminded.outcome).toBe("reminded");
|
||||||
|
expect(reminded.notificationCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const duplicate = await employee.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} });
|
||||||
|
expectStatus(duplicate.status(), [409], "duplicate remind should hit 24h cooldown");
|
||||||
|
|
||||||
|
const workflowDetail = await apiGetData<WorkflowTaskDetailResponse>(manager.api, `/api/workflow/tasks/${leave.currentTaskId}`);
|
||||||
|
expect(workflowDetail.task?.status).toBe("pending");
|
||||||
|
expect(workflowDetail.history.some((item) => item.action === "remind" && item.actorId === employee.me.id)).toBe(true);
|
||||||
|
expectNoSensitiveKeys(workflowDetail.history);
|
||||||
|
|
||||||
|
const notifications = await apiGetData<NotificationListResponse>(
|
||||||
|
manager.api,
|
||||||
|
"/api/notifications?type=workflow.task_reminded&page=1&pageSize=20",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
notifications.items.some((item) => item.targetId === leave.currentTaskId && item.type === "workflow.task_reminded"),
|
||||||
|
).toBe(true);
|
||||||
|
expectNoSensitiveKeys(notifications);
|
||||||
|
|
||||||
|
await expectAuditAction(
|
||||||
|
admin.api,
|
||||||
|
"WORKFLOW_TASK_REMINDED",
|
||||||
|
(item) => item.targetId === leave.currentTaskId && item.actorId === employee.me.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemSummary = await apiGetData<WorkflowTaskSummaryResponse>(
|
||||||
|
admin.api,
|
||||||
|
"/api/workflow/tasks/summary?scope=system",
|
||||||
|
);
|
||||||
|
expect(systemSummary.pendingTotal).toBeGreaterThanOrEqual(summaryBefore.pendingTotal);
|
||||||
|
await expectApiStatus({
|
||||||
|
role: "employee",
|
||||||
|
api: employee.api,
|
||||||
|
path: "/api/workflow/tasks/summary?scope=system",
|
||||||
|
statuses: [403],
|
||||||
|
note: "普通员工不得查看全局审批效率摘要",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await Promise.all(clients.map((client) => client.dispose()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("PA6 审计留痕:登录、审批历史、附件下载、导出与通知投递可追溯且脱敏", async () => {
|
test("PA6 审计留痕:登录、审批历史、附件下载、导出与通知投递可追溯且脱敏", async () => {
|
||||||
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
const manager = await createAuthenticatedApiClient(MANAGER);
|
const manager = await createAuthenticatedApiClient(MANAGER);
|
||||||
|
|||||||
+2
-2
@@ -325,8 +325,8 @@
|
|||||||
R6 首轮试运行只验证已有审批主链路稳定性,不新增复杂审批能力。R6 后续优化可单独拆分:
|
R6 首轮试运行只验证已有审批主链路稳定性,不新增复杂审批能力。R6 后续优化可单独拆分:
|
||||||
|
|
||||||
- 待办聚合:PC 和移动统一展示待办、已办、抄送和通知入口。
|
- 待办聚合:PC 和移动统一展示待办、已办、抄送和通知入口。
|
||||||
- 催办与超时提醒:按业务类型、金额或天数配置提醒策略,催办动作必须留痕。
|
- 催办与超时提醒:AP6 v1 允许申请人/提交人对本人审批中单据的 pending 任务催办,24 小时内同一任务最多 1 次;超时提醒只提醒 pending 待办,不改变审批状态,通知失败不回滚业务,但必须进入审计和通知投递记录。
|
||||||
- 审批效率统计:按节点、角色和业务类型统计平均处理时长、超时数量和积压数量。
|
- 审批效率统计:AP6 v1 聚合待办总数、超时数、最早待处理时间、平均等待小时数,并按业务类型、节点和角色汇总;`scope=system` 仅 `system.audit.view` 可读,返回聚合数,不返回申请人明细。
|
||||||
- 流程配置治理:把金额阈值、兜底审批人、转审规则和通知路由纳入管理员可理解的配置页。
|
- 流程配置治理:把金额阈值、兜底审批人、转审规则和通知路由纳入管理员可理解的配置页。
|
||||||
|
|
||||||
涉及状态机、权限模型、数据库迁移或审批规则调整的 Issue,必须在技术方案中写明回滚策略和回归范围。
|
涉及状态机、权限模型、数据库迁移或审批规则调整的 Issue,必须在技术方案中写明回滚策略和回归范围。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
AP6-1,审批效率 v1 合同建档,在 R6 通过后规划催办、超时提醒、待办聚合和效率摘要第一版。,"1) 本 CSV 校验通过;2) 不进入 R6 门禁;3) 不改现有审批状态机;4) 不做复杂 BPM。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv;人工核对统一审批中心规约。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv,[],涉及审批权限、状态机或通知需 reviewer 复核。
|
AP6-1,审批效率 v1 合同建档,在 R6 通过后规划催办、超时提醒、待办聚合和效率摘要第一版。,"1) 本 CSV 校验通过;2) 不进入 R6 门禁;3) 不改现有审批状态机;4) 不做复杂 BPM。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv;人工核对统一审批中心规约。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv,[],涉及审批权限、状态机或通知需 reviewer 复核。
|
||||||
AP6-2,审批催办能力,申请人可对自己的待审批单据发起催办,催办留痕并通过企业微信个人应用消息提醒审批人。,"1) 仅申请人或提交人可催办自己的待审批单据;2) 催办有频率限制;3) 通知失败不阻断业务且可审计;4) 催办记录可在详情页查看。","流程级验证:员工提交单据 -> 催办 -> 审批人收到站内通知和企微投递审计 -> 重复催办触发频率限制 -> 详情页留痕。",node|playwright|manual,TODO,TODO,TODO,server/src/routes/workflow.js|server/src/services/notificationDispatcher.js|client/src/pages/WorkflowTasksPage.tsx,"[""AP6-1""]","新增接口和审计字段需保持向后兼容,不记录 secret。"
|
AP6-2,审批催办能力,申请人可对自己的待审批单据发起催办,催办留痕并通过企业微信个人应用消息提醒审批人。,"1) 仅申请人或提交人可催办自己的待审批单据;2) 催办有频率限制;3) 通知失败不阻断业务且可审计;4) 催办记录可在详情页查看。","流程级验证:员工提交单据 -> 催办 -> 审批人收到站内通知和企微投递审计 -> 重复催办触发频率限制 -> 详情页留痕。",node|playwright|manual,DONE,DONE,DONE,server/src/routes/workflow.js|server/src/constants/notificationTypes.js|server/src/services/notifications/workflowCopy.js|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx,"[""AP6-1""]","新增接口和审计字段需保持向后兼容,不记录 secret;本地业务门禁通过:artifacts/qa-flow/business-flow-20260512-102829/summary.txt;采购页面补充回归通过:artifacts/qa-flow/business-flow-20260512-103809/summary.txt。"
|
||||||
AP6-3,超时提醒任务,按业务类型和节点配置首版固定阈值,自动提醒超时待办。,"1) 超时任务只提醒 pending 待办;2) 不改变审批状态;3) 提醒记录可审计;4) 可重复运行且幂等。","流程级验证:构造超时待办 -> 运行提醒脚本或 job -> 检查通知投递、审计和幂等。",node|manual,TODO,TODO,TODO,server/src/jobs|server/src/services,"[""AP6-2""]",首版可用固定策略,复杂配置后续拆分。
|
AP6-3,超时提醒任务,按业务类型和节点配置首版固定阈值,自动提醒超时待办。,"1) 超时任务只提醒 pending 待办;2) 不改变审批状态;3) 提醒记录可审计;4) 可重复运行且幂等。","流程级验证:构造超时待办 -> 运行提醒脚本或 job -> 检查通知投递、审计和幂等。",node|manual,DONE,DONE,DONE,server/src/jobs/workflowOverdueReminderJob.js|server/src/scripts/run-workflow-overdue-reminders.js|server/src/constants/notificationTypes.js,"[""AP6-2""]","首版可用固定策略,复杂配置后续拆分;单测通过:workflowOverdueReminderJob.test.js。"
|
||||||
AP6-4,待办聚合与效率摘要,PC 和移动展示待办数量、最早待处理时间、业务类型分布,并提供管理端只读效率摘要。,"1) 不影响审批动作;2) 统计按业务类型、节点和角色聚合;3) 管理端只读;4) 普通员工只看自己的摘要。","流程级验证:创建报销/请假/采购待办 -> 检查 PC/移动摘要 -> 管理端查看平均处理时长、超时数量和积压数量。",node|playwright,TODO,TODO,TODO,server/src/routes/workflow.js|client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx,"[""AP6-3""]",统计口径写入统一审批中心规约或接口注释。
|
AP6-4,待办聚合与效率摘要,PC 和移动展示待办数量、最早待处理时间、业务类型分布,并提供管理端只读效率摘要。,"1) 不影响审批动作;2) 统计按业务类型、节点和角色聚合;3) 管理端只读;4) 普通员工只看自己的摘要。","流程级验证:创建报销/请假/采购待办 -> 检查 PC/移动摘要 -> 管理端查看平均处理时长、超时数量和积压数量。",node|playwright,DONE,DONE,DONE,server/src/routes/workflow.js|client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx,"[""AP6-3""]","统计口径已写入统一审批中心规约;本地业务门禁通过:artifacts/qa-flow/business-flow-20260512-102829/summary.txt。"
|
||||||
AP6-5,审批效率 v1 回归部署,完成审批效率 v1 后做权限、通知、PC/移动和公网复检。,"1) 后端语法和单测通过;2) PC/移动 E2E 通过;3) 企业微信投递审计通过;4) 部署后 live smoke 通过。","流程级验证:cd server && npm run prisma:validate;node --check workflow/notification 文件;cd client && npm run typecheck && npm run build;部署和 live smoke。",node|npm|bash|playwright,TODO,TODO,TODO,server|client|deploy/trialrun,"[""AP6-4""]","不得把催办误用为强制审批或状态跳转。"
|
AP6-5,审批效率 v1 回归部署,完成审批效率 v1 后做权限、通知、PC/移动和公网复检。,"1) 后端语法和单测通过;2) PC/移动 E2E 通过;3) 企业微信投递审计通过;4) 部署后 live smoke 通过。","流程级验证:cd server && npm run prisma:validate;node --check workflow/notification 文件;cd client && npm run typecheck && npm run build;部署和 live smoke。",node|npm|bash|playwright,TODO,TODO,TODO,server|client|deploy/trialrun,"[""AP6-4""]","不得把催办误用为强制审批或状态跳转。"
|
||||||
|
|||||||
|
@@ -25,6 +25,7 @@
|
|||||||
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js",
|
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js",
|
||||||
"job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
|
"job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
|
||||||
"job:verification-once": "node src/scripts/run-verification-once.js",
|
"job:verification-once": "node src/scripts/run-verification-once.js",
|
||||||
|
"job:workflow-overdue-reminders": "node src/scripts/run-workflow-overdue-reminders.js",
|
||||||
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const NOTIFICATION_DOMAIN_SCOPES = Object.freeze({
|
|||||||
|
|
||||||
const NOTIFICATION_TYPES = Object.freeze({
|
const NOTIFICATION_TYPES = Object.freeze({
|
||||||
workflowTaskAssigned: 'workflow.task_assigned',
|
workflowTaskAssigned: 'workflow.task_assigned',
|
||||||
|
workflowTaskReminded: 'workflow.task_reminded',
|
||||||
|
workflowTaskOverdue: 'workflow.task_overdue',
|
||||||
reimbursementApproved: 'reimbursement.approved',
|
reimbursementApproved: 'reimbursement.approved',
|
||||||
reimbursementRejected: 'reimbursement.rejected',
|
reimbursementRejected: 'reimbursement.rejected',
|
||||||
reimbursementReturned: 'reimbursement.returned',
|
reimbursementReturned: 'reimbursement.returned',
|
||||||
@@ -28,6 +30,8 @@ const NOTIFICATION_TYPES = Object.freeze({
|
|||||||
|
|
||||||
const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
||||||
NOTIFICATION_TYPES.workflowTaskAssigned,
|
NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||||
|
NOTIFICATION_TYPES.workflowTaskReminded,
|
||||||
|
NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||||
NOTIFICATION_TYPES.reimbursementApproved,
|
NOTIFICATION_TYPES.reimbursementApproved,
|
||||||
NOTIFICATION_TYPES.reimbursementRejected,
|
NOTIFICATION_TYPES.reimbursementRejected,
|
||||||
NOTIFICATION_TYPES.reimbursementReturned,
|
NOTIFICATION_TYPES.reimbursementReturned,
|
||||||
@@ -46,6 +50,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
|||||||
|
|
||||||
const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||||
[NOTIFICATION_TYPES.workflowTaskAssigned]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
[NOTIFICATION_TYPES.workflowTaskAssigned]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||||
|
[NOTIFICATION_TYPES.workflowTaskReminded]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||||
|
[NOTIFICATION_TYPES.workflowTaskOverdue]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||||
[NOTIFICATION_TYPES.reimbursementApproved]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
[NOTIFICATION_TYPES.reimbursementApproved]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
[NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
[NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
[NOTIFICATION_TYPES.reimbursementReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
[NOTIFICATION_TYPES.reimbursementReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||||
|
const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications");
|
||||||
|
const { buildOverdueTaskCopy, buildWorkflowEntityMeta } = require("../services/notifications/workflowCopy");
|
||||||
|
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
|
||||||
|
|
||||||
|
const WORKFLOW_OVERDUE_ACTION = "overdue_remind";
|
||||||
|
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const WORKFLOW_OVERDUE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function uniq(values) {
|
||||||
|
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDueAt(task, now = new Date()) {
|
||||||
|
if (task?.due_date) {
|
||||||
|
return new Date(task.due_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = task?.created_at ? new Date(task.created_at) : now;
|
||||||
|
return new Date(createdAt.getTime() + WORKFLOW_DEFAULT_DUE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEntityRecord(workflowInstance) {
|
||||||
|
if (!workflowInstance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowInstance.entity_type === "reimbursement") {
|
||||||
|
return workflowInstance.reimbursement_form ?? null;
|
||||||
|
}
|
||||||
|
if (workflowInstance.entity_type === "leave") {
|
||||||
|
return workflowInstance.leave_request ?? null;
|
||||||
|
}
|
||||||
|
if (workflowInstance.entity_type === "purchase_request") {
|
||||||
|
return workflowInstance.purchase_request ?? null;
|
||||||
|
}
|
||||||
|
if (workflowInstance.entity_type === "travel_request") {
|
||||||
|
return workflowInstance.travel_request ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverduePendingTasks(prisma, now, batchSize) {
|
||||||
|
const fallbackCutoff = new Date(now.getTime() - WORKFLOW_DEFAULT_DUE_MS);
|
||||||
|
return prisma.workflowTask.findMany({
|
||||||
|
where: {
|
||||||
|
status: "pending",
|
||||||
|
OR: [
|
||||||
|
{ due_date: { lte: now } },
|
||||||
|
{ due_date: null, created_at: { lte: fallbackCutoff } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
take: batchSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
instance_id: true,
|
||||||
|
step_id: true,
|
||||||
|
step_name: true,
|
||||||
|
assignee_id: true,
|
||||||
|
assignee_role: true,
|
||||||
|
status: true,
|
||||||
|
created_at: true,
|
||||||
|
due_date: true,
|
||||||
|
workflow_instance: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
entity_type: true,
|
||||||
|
entity_id: true,
|
||||||
|
reimbursement_form: {
|
||||||
|
select: {
|
||||||
|
form_number: true,
|
||||||
|
total_amount: true,
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
leave_request: {
|
||||||
|
select: {
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
requested_days: true,
|
||||||
|
leave_type: { select: { name: true } },
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
purchase_request: {
|
||||||
|
select: {
|
||||||
|
request_no: true,
|
||||||
|
total_amount: true,
|
||||||
|
applicant: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
travel_request: {
|
||||||
|
select: {
|
||||||
|
request_no: true,
|
||||||
|
estimated_amount: true,
|
||||||
|
applicant: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remindOverdueWorkflowTask(tx, task, now) {
|
||||||
|
const recentSince = new Date(now.getTime() - WORKFLOW_OVERDUE_COOLDOWN_MS);
|
||||||
|
const recent = await tx.workflowHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
task_id: task.id,
|
||||||
|
action: WORKFLOW_OVERDUE_ACTION,
|
||||||
|
created_at: { gte: recentSince },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (recent) {
|
||||||
|
return { skipped: true, reason: "cooldown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = uniq(
|
||||||
|
await resolveRecipientsForAssignee(tx, {
|
||||||
|
assigneeId: task.assignee_id,
|
||||||
|
assigneeRole: task.assignee_role,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!recipients.length) {
|
||||||
|
return { skipped: true, reason: "no_recipients" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowInstance = task.workflow_instance;
|
||||||
|
if (!workflowInstance) {
|
||||||
|
return { skipped: true, reason: "missing_workflow_instance" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueAt = calculateDueAt(task, now);
|
||||||
|
const actorId = recipients[0];
|
||||||
|
await tx.workflowHistory.create({
|
||||||
|
data: {
|
||||||
|
instance_id: workflowInstance.id,
|
||||||
|
task_id: task.id,
|
||||||
|
actor_id: actorId,
|
||||||
|
action: WORKFLOW_OVERDUE_ACTION,
|
||||||
|
comment: "系统超时提醒",
|
||||||
|
payload: {
|
||||||
|
from_step_id: task.step_id,
|
||||||
|
due_at: dueAt.toISOString(),
|
||||||
|
reminder_window_hours: 24,
|
||||||
|
actor_mode: "system",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
request_id: `job:workflow-overdue:${now.getTime()}:${task.id}`,
|
||||||
|
actor_id: actorId,
|
||||||
|
action: "WORKFLOW_TASK_OVERDUE_NOTIFIED",
|
||||||
|
target_type: "workflow_task",
|
||||||
|
target_id: task.id,
|
||||||
|
ip_address: null,
|
||||||
|
user_agent: "system/node-cron",
|
||||||
|
extra: {
|
||||||
|
workflowInstanceId: workflowInstance.id,
|
||||||
|
entityType: workflowInstance.entity_type,
|
||||||
|
entityId: workflowInstance.entity_id,
|
||||||
|
assigneeId: task.assignee_id,
|
||||||
|
assigneeRole: task.assignee_role,
|
||||||
|
dueAt: dueAt.toISOString(),
|
||||||
|
actorMode: "system",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entityMeta = buildWorkflowEntityMeta(
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
pickEntityRecord(workflowInstance),
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const copy = buildOverdueTaskCopy(entityMeta);
|
||||||
|
const notifications = await createNotificationsWithRecords(tx, {
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
type: NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||||
|
title: copy.title,
|
||||||
|
body: copy.body,
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: task.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
skipped: false,
|
||||||
|
dispatchTask: {
|
||||||
|
notifications,
|
||||||
|
type: NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||||
|
title: copy.title,
|
||||||
|
body: copy.body,
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: task.id,
|
||||||
|
targetPath: `/mobile/approvals?taskId=${task.id}`,
|
||||||
|
actorId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remindOverdueWorkflowTasks(prisma, logger, config, options = {}) {
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const batchSize = Math.max(1, Number(options.batchSize ?? 100));
|
||||||
|
const tasks = await loadOverduePendingTasks(prisma, now, batchSize);
|
||||||
|
let reminded = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const dispatchTasks = [];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const result = await prisma.$transaction((tx) => remindOverdueWorkflowTask(tx, task, now));
|
||||||
|
if (result.skipped) {
|
||||||
|
skipped += 1;
|
||||||
|
logger?.info?.({ taskId: task.id, reason: result.reason }, "Workflow overdue reminder skipped");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
reminded += 1;
|
||||||
|
dispatchTasks.push(result.dispatchTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatchResults = await flushNotificationDispatchTasks({
|
||||||
|
prisma,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
requestId: `job:workflow-overdue:${now.getTime()}`,
|
||||||
|
tasks: dispatchTasks,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger?.info?.(
|
||||||
|
{ checked: tasks.length, reminded, skipped, dispatchTasks: dispatchTasks.length },
|
||||||
|
"Workflow overdue reminder batch finished",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
checked: tasks.length,
|
||||||
|
reminded,
|
||||||
|
skipped,
|
||||||
|
dispatchResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
WORKFLOW_OVERDUE_ACTION,
|
||||||
|
calculateDueAt,
|
||||||
|
loadOverduePendingTasks,
|
||||||
|
remindOverdueWorkflowTask,
|
||||||
|
remindOverdueWorkflowTasks,
|
||||||
|
};
|
||||||
@@ -141,7 +141,16 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
|
|||||||
car: "travel_taxi",
|
car: "travel_taxi",
|
||||||
other: "transport",
|
other: "transport",
|
||||||
};
|
};
|
||||||
const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]);
|
const PRINTABLE_WORKFLOW_ACTIONS = new Set([
|
||||||
|
"start",
|
||||||
|
"approve",
|
||||||
|
"reject",
|
||||||
|
"return",
|
||||||
|
"transfer",
|
||||||
|
"withdraw",
|
||||||
|
"remind",
|
||||||
|
"overdue_remind",
|
||||||
|
]);
|
||||||
const ATTACHMENT_MIME_ALLOWLIST = new Set([
|
const ATTACHMENT_MIME_ALLOWLIST = new Set([
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ const {
|
|||||||
} = require("../services/notifications");
|
} = require("../services/notifications");
|
||||||
const {
|
const {
|
||||||
buildApprovedCopy,
|
buildApprovedCopy,
|
||||||
|
buildOverdueTaskCopy,
|
||||||
buildPendingTaskCopy,
|
buildPendingTaskCopy,
|
||||||
buildRejectedCopy,
|
buildRejectedCopy,
|
||||||
|
buildRemindedTaskCopy,
|
||||||
buildReturnedCopy,
|
buildReturnedCopy,
|
||||||
buildWorkflowEntityMeta,
|
buildWorkflowEntityMeta,
|
||||||
normalizeWorkflowEntityType,
|
normalizeWorkflowEntityType,
|
||||||
@@ -48,6 +50,9 @@ const BATCH_REJECT_BODY_SCHEMA = z.object({
|
|||||||
taskIds: z.array(z.string().uuid()).min(1).max(50),
|
taskIds: z.array(z.string().uuid()).min(1).max(50),
|
||||||
comment: z.string().trim().min(1).max(500),
|
comment: z.string().trim().min(1).max(500),
|
||||||
});
|
});
|
||||||
|
const REMIND_TASK_BODY_SCHEMA = z.object({
|
||||||
|
comment: z.string().trim().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const TASK_LIST_QUERY_SCHEMA = z.object({
|
const TASK_LIST_QUERY_SCHEMA = z.object({
|
||||||
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
||||||
@@ -55,6 +60,14 @@ const TASK_LIST_QUERY_SCHEMA = z.object({
|
|||||||
page: z.coerce.number().int().min(1).optional(),
|
page: z.coerce.number().int().min(1).optional(),
|
||||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||||
});
|
});
|
||||||
|
const TASK_SUMMARY_QUERY_SCHEMA = z.object({
|
||||||
|
scope: z.enum(["mine", "system"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WORKFLOW_REMIND_ACTION = "remind";
|
||||||
|
const WORKFLOW_OVERDUE_ACTION = "overdue_remind";
|
||||||
|
const WORKFLOW_REMINDER_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const APPROVER_LIST_QUERY_SCHEMA = z.object({
|
const APPROVER_LIST_QUERY_SCHEMA = z.object({
|
||||||
keyword: z.string().trim().max(100).optional(),
|
keyword: z.string().trim().max(100).optional(),
|
||||||
@@ -155,6 +168,38 @@ function parseBatchRejectBody(body) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRemindTaskBody(body) {
|
||||||
|
const parsed = REMIND_TASK_BODY_SCHEMA.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comment: parsed.data.comment?.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTaskSummaryQuery(query) {
|
||||||
|
const parsed = TASK_SUMMARY_QUERY_SCHEMA.safeParse(query ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope: parsed.data.scope ?? "mine",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePurchaseApprovalErpSync({
|
async function handlePurchaseApprovalErpSync({
|
||||||
tx,
|
tx,
|
||||||
config,
|
config,
|
||||||
@@ -404,6 +449,77 @@ function buildTaskBusinessSummary(task) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calculateWorkflowTaskDueAt(task, now = new Date()) {
|
||||||
|
if (task?.due_date) {
|
||||||
|
return new Date(task.due_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = task?.created_at ? new Date(task.created_at) : now;
|
||||||
|
return new Date(createdAt.getTime() + WORKFLOW_DEFAULT_DUE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkflowTaskOverdue(task, now = new Date()) {
|
||||||
|
if (task?.status && task.status !== "pending") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateWorkflowTaskDueAt(task, now).getTime() <= now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementSummaryBucket(map, key, task, now) {
|
||||||
|
const normalizedKey = key || "未分配";
|
||||||
|
const current = map.get(normalizedKey) ?? {
|
||||||
|
key: normalizedKey,
|
||||||
|
pendingTotal: 0,
|
||||||
|
overdueTotal: 0,
|
||||||
|
};
|
||||||
|
current.pendingTotal += 1;
|
||||||
|
if (isWorkflowTaskOverdue(task, now)) {
|
||||||
|
current.overdueTotal += 1;
|
||||||
|
}
|
||||||
|
map.set(normalizedKey, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowTaskSummaryPayload(tasks, scope = "mine", now = new Date()) {
|
||||||
|
const byEntityType = new Map();
|
||||||
|
const byStep = new Map();
|
||||||
|
const byAssigneeRole = new Map();
|
||||||
|
let earliestPendingAt = null;
|
||||||
|
let overdueTotal = 0;
|
||||||
|
let totalPendingAgeMs = 0;
|
||||||
|
|
||||||
|
for (const task of tasks ?? []) {
|
||||||
|
const createdAt = task.created_at ? new Date(task.created_at) : null;
|
||||||
|
if (createdAt && (!earliestPendingAt || createdAt < earliestPendingAt)) {
|
||||||
|
earliestPendingAt = createdAt;
|
||||||
|
}
|
||||||
|
if (createdAt) {
|
||||||
|
totalPendingAgeMs += Math.max(0, now.getTime() - createdAt.getTime());
|
||||||
|
}
|
||||||
|
if (isWorkflowTaskOverdue(task, now)) {
|
||||||
|
overdueTotal += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementSummaryBucket(byEntityType, task.workflow_instance?.entity_type ?? "unknown", task, now);
|
||||||
|
incrementSummaryBucket(byStep, task.step_name ?? task.step_id ?? "unknown", task, now);
|
||||||
|
incrementSummaryBucket(byAssigneeRole, task.assignee_role ?? (task.assignee_id ? "指定审批人" : "未分配"), task, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingTotal = tasks?.length ?? 0;
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
pendingTotal,
|
||||||
|
overdueTotal,
|
||||||
|
earliestPendingAt: isoOrNull(earliestPendingAt),
|
||||||
|
averagePendingAgeHours: pendingTotal
|
||||||
|
? Number((totalPendingAgeMs / pendingTotal / (60 * 60 * 1000)).toFixed(1))
|
||||||
|
: 0,
|
||||||
|
byEntityType: Array.from(byEntityType.values()),
|
||||||
|
byStep: Array.from(byStep.values()),
|
||||||
|
byAssigneeRole: Array.from(byAssigneeRole.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function buildTaskVisibilityFilters(tx, request) {
|
async function buildTaskVisibilityFilters(tx, request) {
|
||||||
const roleFilters = request.employee.roleCodes.length
|
const roleFilters = request.employee.roleCodes.length
|
||||||
? [{ assignee_role: { in: request.employee.roleCodes } }]
|
? [{ assignee_role: { in: request.employee.roleCodes } }]
|
||||||
@@ -842,6 +958,69 @@ async function queueTaskAssignedNotifications({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queueWorkflowTaskPromptNotifications({
|
||||||
|
tx,
|
||||||
|
actorId,
|
||||||
|
emailTasks,
|
||||||
|
dispatchTasks,
|
||||||
|
task,
|
||||||
|
workflowInstance,
|
||||||
|
notificationType,
|
||||||
|
copyBuilder,
|
||||||
|
excludeActor = true,
|
||||||
|
}) {
|
||||||
|
const recipientEmployeeIds = uniq(
|
||||||
|
await resolveRecipientsForAssignee(tx, {
|
||||||
|
assigneeId: task.assignee_id,
|
||||||
|
assigneeRole: task.assignee_role,
|
||||||
|
}),
|
||||||
|
).filter((employeeId) => !excludeActor || employeeId !== actorId);
|
||||||
|
if (!recipientEmployeeIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationContext = await loadWorkflowNotifyContext(
|
||||||
|
tx,
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const entityMeta = buildWorkflowEntityMeta(
|
||||||
|
workflowInstance.entity_type,
|
||||||
|
notificationContext.record,
|
||||||
|
workflowInstance.entity_id,
|
||||||
|
);
|
||||||
|
const copy = copyBuilder(entityMeta);
|
||||||
|
const notifications = await createNotificationsWithRecords(tx, {
|
||||||
|
recipientEmployeeIds,
|
||||||
|
type: notificationType,
|
||||||
|
title: copy.title,
|
||||||
|
body: copy.body,
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: task.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchTasks.push({
|
||||||
|
notifications,
|
||||||
|
type: notificationType,
|
||||||
|
title: copy.title,
|
||||||
|
body: copy.body,
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: task.id,
|
||||||
|
targetPath: `/mobile/approvals?taskId=${task.id}`,
|
||||||
|
actorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
emailTasks.push({
|
||||||
|
type: notificationType,
|
||||||
|
recipientEmployeeIds,
|
||||||
|
subject: copy.title,
|
||||||
|
body: copy.body,
|
||||||
|
targetPath: `/workflow/tasks/${task.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
async function queueWorkflowOutcomeNotifications({
|
async function queueWorkflowOutcomeNotifications({
|
||||||
tx,
|
tx,
|
||||||
actorId,
|
actorId,
|
||||||
@@ -1023,6 +1202,150 @@ function ensureWorkflowEntityType(workflowInstance, entityType) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request) {
|
||||||
|
const applicantIds = await loadWorkflowApplicantIds(tx, workflowInstance);
|
||||||
|
if (!applicantIds.includes(request.employee.id)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_002",
|
||||||
|
message: "无权催办该审批",
|
||||||
|
statusCode: 403,
|
||||||
|
details: { workflow: "remind_owner_required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientEmployeeIds = await resolveRecipientsForAssignee(tx, {
|
||||||
|
assigneeId: task.assignee_id,
|
||||||
|
assigneeRole: task.assignee_role,
|
||||||
|
});
|
||||||
|
if (recipientEmployeeIds.includes(request.employee.id)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { workflow: "self_remind_not_allowed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicantIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWorkflowReminderCooldown(tx, taskId, actorId, now) {
|
||||||
|
const since = new Date(now.getTime() - WORKFLOW_REMINDER_COOLDOWN_MS);
|
||||||
|
const existing = await tx.workflowHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
task_id: taskId,
|
||||||
|
actor_id: actorId,
|
||||||
|
action: WORKFLOW_REMIND_ACTION,
|
||||||
|
created_at: { gte: since },
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAllowedAt = new Date(existing.created_at.getTime() + WORKFLOW_REMINDER_COOLDOWN_MS);
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "催办过于频繁",
|
||||||
|
statusCode: 409,
|
||||||
|
details: {
|
||||||
|
workflow: "remind_too_frequent",
|
||||||
|
nextAllowedAt: nextAllowedAt.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRemindWorkflowTask({
|
||||||
|
fastify,
|
||||||
|
request,
|
||||||
|
taskId,
|
||||||
|
comment = null,
|
||||||
|
}) {
|
||||||
|
let result = null;
|
||||||
|
const emailTasks = [];
|
||||||
|
const dispatchTasks = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const lockedTask = await lockWorkflowTask(tx, taskId);
|
||||||
|
ensurePendingTask(lockedTask);
|
||||||
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, lockedTask.instance_id);
|
||||||
|
await ensureWorkflowReminderAllowed(tx, workflowInstance, lockedTask, request);
|
||||||
|
await ensureWorkflowReminderCooldown(tx, taskId, request.employee.id, now);
|
||||||
|
|
||||||
|
const safeComment = comment || "申请人催办";
|
||||||
|
await tx.workflowHistory.create({
|
||||||
|
data: {
|
||||||
|
instance_id: workflowInstance.id,
|
||||||
|
task_id: taskId,
|
||||||
|
actor_id: request.employee.id,
|
||||||
|
action: WORKFLOW_REMIND_ACTION,
|
||||||
|
comment: safeComment,
|
||||||
|
payload: {
|
||||||
|
from_step_id: lockedTask.step_id,
|
||||||
|
assignee_id: lockedTask.assignee_id,
|
||||||
|
assignee_role: lockedTask.assignee_role,
|
||||||
|
reminder_window_hours: 24,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "WORKFLOW_TASK_REMINDED",
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: taskId,
|
||||||
|
extra: {
|
||||||
|
workflowInstanceId: workflowInstance.id,
|
||||||
|
entityType: workflowInstance.entity_type,
|
||||||
|
entityId: workflowInstance.entity_id,
|
||||||
|
assigneeId: lockedTask.assignee_id,
|
||||||
|
assigneeRole: lockedTask.assignee_role,
|
||||||
|
reminderWindowHours: 24,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let notificationCount = 0;
|
||||||
|
try {
|
||||||
|
const notifications = await queueWorkflowTaskPromptNotifications({
|
||||||
|
tx,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
emailTasks,
|
||||||
|
dispatchTasks,
|
||||||
|
task: lockedTask,
|
||||||
|
workflowInstance,
|
||||||
|
notificationType: NOTIFICATION_TYPES.workflowTaskReminded,
|
||||||
|
copyBuilder: buildRemindedTaskCopy,
|
||||||
|
});
|
||||||
|
notificationCount = notifications.length;
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, taskId, workflowInstanceId: workflowInstance.id },
|
||||||
|
"Failed to create workflow reminder notifications",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
taskId,
|
||||||
|
outcome: "reminded",
|
||||||
|
workflowInstanceId: workflowInstance.id,
|
||||||
|
nextAllowedAt: new Date(now.getTime() + WORKFLOW_REMINDER_COOLDOWN_MS).toISOString(),
|
||||||
|
notificationCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushWorkflowSideEffects({ fastify, request, emailTasks, dispatchTasks });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function executeApproveWorkflowTask({
|
async function executeApproveWorkflowTask({
|
||||||
fastify,
|
fastify,
|
||||||
request,
|
request,
|
||||||
@@ -1530,6 +1853,55 @@ function mapBatchTaskError(taskId, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function workflowRoutes(fastify) {
|
async function workflowRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
"/workflow/tasks/summary",
|
||||||
|
{
|
||||||
|
schema: { tags: ["Workflow"], summary: "Workflow Task Summary" },
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseTaskSummaryQuery(request.query);
|
||||||
|
|
||||||
|
if (query.scope === "system" && !hasPermission(request.employee.permissionCodes ?? [], "system.audit.view")) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
details: { permission: "system.audit.view" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityFilters =
|
||||||
|
query.scope === "system" ? null : await buildTaskVisibilityFilters(fastify.prisma, request);
|
||||||
|
const where = {
|
||||||
|
status: "pending",
|
||||||
|
...(visibilityFilters ? { OR: visibilityFilters } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasks = await fastify.prisma.workflowTask.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
step_id: true,
|
||||||
|
step_name: true,
|
||||||
|
assignee_id: true,
|
||||||
|
assignee_role: true,
|
||||||
|
status: true,
|
||||||
|
created_at: true,
|
||||||
|
due_date: true,
|
||||||
|
workflow_instance: {
|
||||||
|
select: {
|
||||||
|
entity_type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, buildWorkflowTaskSummaryPayload(tasks, query.scope));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/workflow/tasks",
|
"/workflow/tasks",
|
||||||
{
|
{
|
||||||
@@ -2168,6 +2540,24 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
"/workflow/tasks/:taskId/remind",
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const taskId = parseTaskId(request.params?.taskId);
|
||||||
|
const body = parseRemindTaskBody(request.body);
|
||||||
|
const result = await executeRemindWorkflowTask({
|
||||||
|
fastify,
|
||||||
|
request,
|
||||||
|
taskId,
|
||||||
|
comment: body.comment,
|
||||||
|
});
|
||||||
|
return sendData(reply, request, result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/workflow/tasks/:taskId/approve",
|
"/workflow/tasks/:taskId/approve",
|
||||||
{
|
{
|
||||||
@@ -2662,11 +3052,16 @@ module.exports = workflowRoutes;
|
|||||||
|
|
||||||
module.exports.__testables = {
|
module.exports.__testables = {
|
||||||
buildApprovedCopy,
|
buildApprovedCopy,
|
||||||
|
buildOverdueTaskCopy,
|
||||||
buildPendingTaskCopy,
|
buildPendingTaskCopy,
|
||||||
buildRejectedCopy,
|
buildRejectedCopy,
|
||||||
|
buildRemindedTaskCopy,
|
||||||
buildReturnedCopy,
|
buildReturnedCopy,
|
||||||
|
buildWorkflowTaskSummaryPayload,
|
||||||
buildWorkflowEntityMeta,
|
buildWorkflowEntityMeta,
|
||||||
|
calculateWorkflowTaskDueAt,
|
||||||
handlePurchaseApprovalErpSync,
|
handlePurchaseApprovalErpSync,
|
||||||
|
isWorkflowTaskOverdue,
|
||||||
listAllowedActionsForTask,
|
listAllowedActionsForTask,
|
||||||
parseTaskListQuery,
|
parseTaskListQuery,
|
||||||
resolveWorkflowOutcomeNotificationType,
|
resolveWorkflowOutcomeNotificationType,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
require("dotenv/config");
|
||||||
|
|
||||||
|
const { getConfig } = require("../config");
|
||||||
|
const { createPrismaClient } = require("../prismaClient");
|
||||||
|
const { remindOverdueWorkflowTasks } = require("../jobs/workflowOverdueReminderJob");
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const config = getConfig();
|
||||||
|
const { prisma, pool } = createPrismaClient();
|
||||||
|
await prisma.$connect();
|
||||||
|
try {
|
||||||
|
const result = await remindOverdueWorkflowTasks(prisma, console, config);
|
||||||
|
process.stdout.write(
|
||||||
|
`checked=${result.checked} reminded=${result.reminded} skipped=${result.skipped}\n`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -174,6 +174,20 @@ function buildPendingTaskCopy(entityMeta, { transfer = false } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRemindedTaskCopy(entityMeta) {
|
||||||
|
return {
|
||||||
|
title: `【催办】${buildSubject(entityMeta)} 待审批`,
|
||||||
|
body: buildBody(entityMeta, "动作:申请人已催办,请及时处理审批任务。"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverdueTaskCopy(entityMeta) {
|
||||||
|
return {
|
||||||
|
title: `【超时提醒】${buildSubject(entityMeta)} 待审批`,
|
||||||
|
body: buildBody(entityMeta, "动作:该审批已超时,请尽快处理。"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildApprovedCopy(entityMeta) {
|
function buildApprovedCopy(entityMeta) {
|
||||||
return {
|
return {
|
||||||
title: `【已批准】${buildSubject(entityMeta)}`,
|
title: `【已批准】${buildSubject(entityMeta)}`,
|
||||||
@@ -204,8 +218,10 @@ function buildReturnedCopy(entityMeta, comment) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildApprovedCopy,
|
buildApprovedCopy,
|
||||||
|
buildOverdueTaskCopy,
|
||||||
buildPendingTaskCopy,
|
buildPendingTaskCopy,
|
||||||
buildRejectedCopy,
|
buildRejectedCopy,
|
||||||
|
buildRemindedTaskCopy,
|
||||||
buildReturnedCopy,
|
buildReturnedCopy,
|
||||||
buildWorkflowEntityMeta,
|
buildWorkflowEntityMeta,
|
||||||
normalizeWorkflowEntityType,
|
normalizeWorkflowEntityType,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const {
|
||||||
|
IM_SUPPORTED_NOTIFICATION_TYPES,
|
||||||
|
NOTIFICATION_DOMAIN_SCOPES,
|
||||||
|
NOTIFICATION_TYPES,
|
||||||
|
resolveNotificationDomainScope,
|
||||||
|
} = require("../../../src/constants/notificationTypes");
|
||||||
|
|
||||||
|
describe("notification types", () => {
|
||||||
|
it("allows workflow reminder and overdue notifications through IM routing", () => {
|
||||||
|
expect(IM_SUPPORTED_NOTIFICATION_TYPES).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
NOTIFICATION_TYPES.workflowTaskReminded,
|
||||||
|
NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(resolveNotificationDomainScope(NOTIFICATION_TYPES.workflowTaskReminded, "workflow_task")).toBe(
|
||||||
|
NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||||
|
);
|
||||||
|
expect(resolveNotificationDomainScope(NOTIFICATION_TYPES.workflowTaskOverdue, "workflow_task")).toBe(
|
||||||
|
NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const {
|
||||||
|
calculateDueAt,
|
||||||
|
remindOverdueWorkflowTask,
|
||||||
|
} = require("../../../src/jobs/workflowOverdueReminderJob");
|
||||||
|
const { NOTIFICATION_TYPES } = require("../../../src/constants/notificationTypes");
|
||||||
|
|
||||||
|
describe("workflow overdue reminder job", () => {
|
||||||
|
it("calculates fallback due time from task creation", () => {
|
||||||
|
const dueAt = calculateDueAt({
|
||||||
|
created_at: new Date("2026-05-11T10:00:00.000Z"),
|
||||||
|
due_date: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dueAt.toISOString()).toBe("2026-05-12T10:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates history, audit and notification once cooldown allows", async () => {
|
||||||
|
const task = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
instance_id: "22222222-2222-4222-8222-222222222222",
|
||||||
|
step_id: "manager",
|
||||||
|
step_name: "经理审批",
|
||||||
|
assignee_id: "33333333-3333-4333-8333-333333333333",
|
||||||
|
assignee_role: null,
|
||||||
|
created_at: new Date("2026-05-11T07:00:00.000Z"),
|
||||||
|
due_date: null,
|
||||||
|
workflow_instance: {
|
||||||
|
id: "22222222-2222-4222-8222-222222222222",
|
||||||
|
entity_type: "reimbursement",
|
||||||
|
entity_id: "44444444-4444-4444-8444-444444444444",
|
||||||
|
reimbursement_form: {
|
||||||
|
form_number: "BX-20260512-001",
|
||||||
|
total_amount: "120.00",
|
||||||
|
owner: { name: "测试员工" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createdHistory = [];
|
||||||
|
const createdAudit = [];
|
||||||
|
const createdNotifications = [];
|
||||||
|
const tx = {
|
||||||
|
workflowHistory: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||||
|
createdHistory.push(data);
|
||||||
|
return { id: "history-1", ...data };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||||
|
createdAudit.push(data);
|
||||||
|
return { id: "audit-1", ...data };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const row = { id: `notification-${createdNotifications.length + 1}`, ...data };
|
||||||
|
createdNotifications.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await remindOverdueWorkflowTask(tx, task, new Date("2026-05-12T08:00:00.000Z"));
|
||||||
|
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
expect(createdHistory[0]).toEqual(expect.objectContaining({ action: "overdue_remind" }));
|
||||||
|
expect(createdAudit[0]).toEqual(expect.objectContaining({ action: "WORKFLOW_TASK_OVERDUE_NOTIFIED" }));
|
||||||
|
expect(createdNotifications[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
recipient_employee_id: "33333333-3333-4333-8333-333333333333",
|
||||||
|
type: NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||||
|
target_type: "workflow_task",
|
||||||
|
target_id: task.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.dispatchTask).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: NOTIFICATION_TYPES.workflowTaskOverdue,
|
||||||
|
targetPath: `/mobile/approvals?taskId=${task.id}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,14 @@ const workflowRoutes = require("../../../src/routes/workflow");
|
|||||||
describe("workflow travel helpers", () => {
|
describe("workflow travel helpers", () => {
|
||||||
const {
|
const {
|
||||||
buildApprovedCopy,
|
buildApprovedCopy,
|
||||||
|
buildOverdueTaskCopy,
|
||||||
buildPendingTaskCopy,
|
buildPendingTaskCopy,
|
||||||
buildRejectedCopy,
|
buildRejectedCopy,
|
||||||
|
buildRemindedTaskCopy,
|
||||||
buildReturnedCopy,
|
buildReturnedCopy,
|
||||||
|
buildWorkflowTaskSummaryPayload,
|
||||||
|
calculateWorkflowTaskDueAt,
|
||||||
|
isWorkflowTaskOverdue,
|
||||||
buildWorkflowEntityMeta,
|
buildWorkflowEntityMeta,
|
||||||
resolveWorkflowOutcomeNotificationType,
|
resolveWorkflowOutcomeNotificationType,
|
||||||
} = workflowRoutes.__testables;
|
} = workflowRoutes.__testables;
|
||||||
@@ -82,6 +87,14 @@ describe("workflow travel helpers", () => {
|
|||||||
title: "【已退回】报销单 BX-20260325-001",
|
title: "【已退回】报销单 BX-20260325-001",
|
||||||
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回:请补发票",
|
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回:请补发票",
|
||||||
});
|
});
|
||||||
|
expect(buildRemindedTaskCopy(reimbursementMeta)).toEqual({
|
||||||
|
title: "【催办】报销单 BX-20260325-001 待审批",
|
||||||
|
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n动作:申请人已催办,请及时处理审批任务。",
|
||||||
|
});
|
||||||
|
expect(buildOverdueTaskCopy(reimbursementMeta)).toEqual({
|
||||||
|
title: "【超时提醒】报销单 BX-20260325-001 待审批",
|
||||||
|
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n动作:该审批已超时,请尽快处理。",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves approved and rejected notification types for travel workflows", () => {
|
it("resolves approved and rejected notification types for travel workflows", () => {
|
||||||
@@ -93,4 +106,47 @@ describe("workflow travel helpers", () => {
|
|||||||
);
|
);
|
||||||
expect(resolveWorkflowOutcomeNotificationType("travel_request", "returned")).toBeNull();
|
expect(resolveWorkflowOutcomeNotificationType("travel_request", "returned")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds pending task productivity summary with overdue buckets", () => {
|
||||||
|
const now = new Date("2026-05-12T08:00:00.000Z");
|
||||||
|
const tasks = [
|
||||||
|
{
|
||||||
|
id: "task-1",
|
||||||
|
status: "pending",
|
||||||
|
step_name: "经理审批",
|
||||||
|
assignee_role: "manager",
|
||||||
|
created_at: new Date("2026-05-11T07:00:00.000Z"),
|
||||||
|
due_date: null,
|
||||||
|
workflow_instance: { entity_type: "reimbursement" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-2",
|
||||||
|
status: "pending",
|
||||||
|
step_name: "财务审批",
|
||||||
|
assignee_role: "finance",
|
||||||
|
created_at: new Date("2026-05-12T07:00:00.000Z"),
|
||||||
|
due_date: new Date("2026-05-12T07:30:00.000Z"),
|
||||||
|
workflow_instance: { entity_type: "purchase_request" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(calculateWorkflowTaskDueAt(tasks[0], now).toISOString()).toBe("2026-05-12T07:00:00.000Z");
|
||||||
|
expect(isWorkflowTaskOverdue(tasks[0], now)).toBe(true);
|
||||||
|
const summary = buildWorkflowTaskSummaryPayload(tasks, "mine", now);
|
||||||
|
|
||||||
|
expect(summary).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
scope: "mine",
|
||||||
|
pendingTotal: 2,
|
||||||
|
overdueTotal: 2,
|
||||||
|
earliestPendingAt: "2026-05-11T07:00:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(summary.byEntityType).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ key: "reimbursement", pendingTotal: 1, overdueTotal: 1 },
|
||||||
|
{ key: "purchase_request", pendingTotal: 1, overdueTotal: 1 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户