A2 收口统一审批中心入口与状态

这个提交包含在:
2026-05-09 21:39:47 +08:00
父节点 644852f333
当前提交 d871a40d95
共修改 6 个文件,包含 337 行新增和 39 行删除
+8
查看文件
@@ -17,6 +17,14 @@ export type WorkflowTaskSummary = {
entityId: string;
currentStatus: string;
} | null;
businessSummary: {
reference: string | null;
title: string | null;
applicantName: string | null;
amount: string | null;
duration: string | null;
businessStatus: string | null;
} | null;
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
};
+62 -11
查看文件
@@ -50,6 +50,9 @@ type VisibleTaskCard = {
entityType: string | null;
entityId: string | null;
reference: string | null;
applicantName: string | null;
metric: string | null;
businessStatus: string | null;
allowedActions: MobileAction[];
completedAt: string | null;
isCached: boolean;
@@ -99,6 +102,22 @@ function workflowStatusLabel(status: string) {
return status;
}
function businessStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
draft: "草稿",
pending: "待处理",
reviewing: "审批中",
submitted: "审批中",
approved: "已通过",
rejected: "已驳回",
returned: "已退回",
cancelled: "已取消",
completed: "已完成",
paid: "已付款",
};
return status ? labels[status] ?? status : "-";
}
function isInternalId(value: string | null | undefined) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
}
@@ -120,6 +139,10 @@ function resolveErrorMessage(error: unknown) {
return "动作执行失败";
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
function classifyReplayError(error: unknown): ReplayClassification {
if (!isApiError(error)) {
return "failed";
@@ -181,17 +204,23 @@ function matchesEntityFilter(entityType: string | null, filter: EntityFilter) {
function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
const entityType = task.workflowInstance?.entityType ?? null;
const summary = task.businessSummary;
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
const metric = summary?.amount ?? summary?.duration ?? null;
return {
id: task.id,
title: task.stepName,
title: summary?.title ?? task.stepName,
subtitle:
task.status === "completed"
? `业务:${entityLabel(entityType)} · 完成于 ${formatDateTime(task.completedAt)}`
: `业务:${entityLabel(entityType)}`,
? `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"} · 完成于 ${formatDateTime(task.completedAt)}`
: `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"}`,
status: task.status,
entityType,
entityId: task.workflowInstance?.entityId ?? null,
reference: publicReference(task.workflowInstance?.entityId),
reference,
applicantName: summary?.applicantName ?? null,
metric,
businessStatus: summary?.businessStatus ?? task.workflowInstance?.currentStatus ?? null,
allowedActions: task.allowedActions,
completedAt: task.completedAt,
isCached: false,
@@ -211,6 +240,9 @@ function matchesSearch(card: VisibleTaskCard, keyword: string) {
card.id,
card.entityId,
card.reference,
card.applicantName,
card.metric,
card.businessStatus,
]
.map((item) => normalizeSearchValue(item))
.join(" ");
@@ -362,6 +394,9 @@ export function MobileApprovalsPage() {
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
entityId: item.taskSnapshot.entityId,
reference: publicReference(item.taskSnapshot.reference),
applicantName: null,
metric: null,
businessStatus: item.detail.workflowInstance?.currentStatus ?? null,
allowedActions: item.detail.allowedActions,
completedAt: item.detail.task.completedAt,
isCached: true,
@@ -386,10 +421,14 @@ export function MobileApprovalsPage() {
() => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null,
[filteredTaskCards, selectedTaskId],
);
const selectedTaskCard = useMemo(
() => filteredTaskCards.find((task) => task.id === selectedTaskId) ?? null,
[filteredTaskCards, selectedTaskId],
);
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
const selectedDisplayReference = publicReference(selectedReference?.reference);
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
@@ -528,6 +567,9 @@ export function MobileApprovalsPage() {
toast.success("操作成功");
},
onError: (actionError) => {
if (isAlreadyProcessedTaskError(actionError)) {
void invalidateWorkflowQueries(selectedTaskId);
}
const nextError = resolveErrorMessage(actionError);
setError(nextError);
setMessage("");
@@ -754,7 +796,7 @@ export function MobileApprovalsPage() {
{!isOnline ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并起草通过 / 驳回草稿。
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
) : null}
@@ -857,7 +899,7 @@ export function MobileApprovalsPage() {
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
onClick={() => handleBatchAction("approve")}
>
{canUseRemote ? "批量通过" : "加入通过草稿"}
{canUseRemote ? "批量通过" : "保存通过草稿"}
</Button>
<Button
type="button"
@@ -867,7 +909,7 @@ export function MobileApprovalsPage() {
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
onClick={() => handleBatchAction("reject")}
>
{canUseRemote ? "批量驳回" : "加入驳回草稿"}
{canUseRemote ? "批量驳回" : "保存驳回草稿"}
</Button>
</div>
</div>
@@ -940,6 +982,9 @@ export function MobileApprovalsPage() {
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
<div className="mt-1 text-xs text-zinc-500">
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
</div>
@@ -962,6 +1007,9 @@ export function MobileApprovalsPage() {
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
<div className="mt-1 text-xs text-zinc-500">
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
);
@@ -997,6 +1045,9 @@ export function MobileApprovalsPage() {
<div>步骤:{selectedTask.task.stepName}</div>
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
<div>业务单号:{selectedDisplayReference ?? "-"}</div>
<div>业务状态:{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
<div>申请人:{selectedTaskCard?.applicantName ?? "-"}</div>
<div>金额/天数:{selectedTaskCard?.metric ?? "-"}</div>
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
{selectedTask.reimbursement ? (
@@ -1046,7 +1097,7 @@ export function MobileApprovalsPage() {
/>
{!canUseRemote ? (
<p className="text-xs text-zinc-500">离线仅支持通过 / 驳回草稿。</p>
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null}
<div className="grid gap-2 sm:grid-cols-3">
@@ -1063,7 +1114,7 @@ export function MobileApprovalsPage() {
}}
type="button"
>
{canUseRemote ? "通过" : "离线加入通过草稿"}
{canUseRemote ? "通过" : "保存通过草稿"}
</Button>
<Button
className="min-h-12 w-full"
@@ -1079,7 +1130,7 @@ export function MobileApprovalsPage() {
type="button"
variant="secondary"
>
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
{canUseRemote ? "驳回" : "保存驳回草稿"}
</Button>
<Button
className="min-h-12 w-full"
+53 -7
查看文件
@@ -53,8 +53,8 @@ function workflowStatusLabel(status: string | undefined) {
}
const labels: Record<string, string> = {
pending: "待派发",
completed: "已完成",
pending: "待处理",
completed: "已处理",
rejected: "已驳回",
cancelled: "已取消",
skipped: "已跳过",
@@ -63,6 +63,46 @@ function workflowStatusLabel(status: string | undefined) {
return labels[status] ?? status;
}
function workflowInstanceStatusLabel(status: string | undefined | null) {
if (!status) {
return "-";
}
const labels: Record<string, string> = {
active: "审批中",
approved: "已通过",
rejected: "已驳回",
terminated: "已终止",
cancelled: "已取消",
completed: "已完成",
};
return labels[status] ?? status;
}
function workflowActionLabel(action: string | undefined | null) {
if (!action) {
return "-";
}
const labels: Record<string, string> = {
submit: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
cancel: "取消",
skip: "跳过",
};
return labels[action] ?? action;
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
export function WorkflowTaskDetailPage() {
const { taskId = "" } = useParams();
const navigate = useNavigate();
@@ -125,6 +165,12 @@ export function WorkflowTaskDetailPage() {
toast.success(messages[action] ?? "操作成功");
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
}
let msg: string;
if (error instanceof Error && !isApiError(error)) {
msg = error.message;
@@ -178,8 +224,8 @@ export function WorkflowTaskDetailPage() {
}
const detail = detailQuery.data;
const isLeaveTask = detail.workflowInstance?.entityType === "leave";
const canTransfer = allowedActions.has("transfer");
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
return (
<PageScaffold>
@@ -220,7 +266,7 @@ export function WorkflowTaskDetailPage() {
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">可执行动作</div>
<div className="mt-2 font-medium text-zinc-900">{detail.allowedActions.length ? detail.allowedActions.join(" / ") : "-"}</div>
<div className="mt-2 font-medium text-zinc-900">{allowedActionLabels || "-"}</div>
</div>
</div>
@@ -374,7 +420,7 @@ export function WorkflowTaskDetailPage() {
</Button>
</div>
{canTransfer && !isLeaveTask ? (
{canTransfer ? (
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<p className="text-sm font-medium text-zinc-800">转审</p>
<select
@@ -421,7 +467,7 @@ export function WorkflowTaskDetailPage() {
detail.history.map((item) => (
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm" key={item.id}>
<div className="font-medium text-zinc-900">
{item.action} · {item.actorName || item.actorId}
{workflowActionLabel(item.action)} · {item.actorName || item.actorId}
</div>
<div className="text-zinc-600">{item.comment || "-"}</div>
<div className="text-xs text-zinc-500">{item.createdAt || "-"}</div>
@@ -437,7 +483,7 @@ export function WorkflowTaskDetailPage() {
<div className="space-y-6">
<DetailSideMeta title="任务摘要">
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{detail.workflowInstance?.currentStatus ?? "-"}</div>
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
+71 -20
查看文件
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { approveWorkflowTask, fetchWorkflowTasks } from "../api/workflow";
import { approveWorkflowTask, fetchWorkflowTasks, type WorkflowTaskSummary } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -31,6 +31,53 @@ function entityLabel(entityType: string | undefined) {
return entityType || "-";
}
function businessStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
draft: "草稿",
pending: "待处理",
reviewing: "审批中",
submitted: "审批中",
approved: "已通过",
rejected: "已驳回",
returned: "已退回",
cancelled: "已取消",
completed: "已完成",
paid: "已付款",
};
return status ? labels[status] ?? status : "-";
}
function isInternalId(value: string | null | undefined) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
}
function publicReference(value: string | null | undefined) {
if (!value || isInternalId(value)) {
return null;
}
return value;
}
function displayReference(task: WorkflowTaskSummary) {
return publicReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId) ?? "未生成业务编号";
}
function displayTitle(task: WorkflowTaskSummary) {
return task.businessSummary?.title ?? task.stepName;
}
function displayApplicant(task: WorkflowTaskSummary) {
return task.businessSummary?.applicantName ?? "-";
}
function displayMetric(task: WorkflowTaskSummary) {
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
@@ -61,6 +108,9 @@ export function WorkflowTasksPage() {
toast.success("审批已通过");
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] });
}
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
@@ -86,7 +136,7 @@ export function WorkflowTasksPage() {
<TabsList className="flex w-full flex-wrap justify-start gap-1">
{[
{ value: "pending", label: "待处理" },
{ value: "completed", label: "已通过" },
{ value: "completed", label: "已处理" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
{ value: "skipped", label: "已跳过" },
@@ -102,7 +152,7 @@ export function WorkflowTasksPage() {
</TabsList>
</Tabs>
{query.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{query.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -124,15 +174,17 @@ export function WorkflowTasksPage() {
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" key={task.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{task.stepName}</div>
<div className="truncate text-sm font-semibold text-zinc-900">{displayTitle(task)}</div>
<div className="mt-1 text-xs text-zinc-500">
{entityLabel(task.workflowInstance?.entityType)} · {new Date(task.createdAt).toLocaleString()}
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
</div>
</div>
<StatusBadge className="shrink-0" status={task.status} />
</div>
<div className="mt-3 truncate rounded-lg bg-zinc-50 px-3 py-2 font-mono text-xs text-zinc-500">
{task.workflowInstance?.entityId ?? task.id}
<div className="mt-3 grid gap-1 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
<div>申请人:{displayApplicant(task)}</div>
<div>金额/天数:{displayMetric(task)}</div>
<div>业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
@@ -172,9 +224,11 @@ export function WorkflowTasksPage() {
<Table data-testid="workflow-tasks-table">
<TableHeader>
<tr>
<TableHead>任务</TableHead>
<TableHead>业务对象</TableHead>
<TableHead>状态</TableHead>
<TableHead>审批事项</TableHead>
<TableHead>申请人</TableHead>
<TableHead>金额/天数</TableHead>
<TableHead>业务状态</TableHead>
<TableHead>任务状态</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-24">操作</TableHead>
</tr>
@@ -184,17 +238,14 @@ export function WorkflowTasksPage() {
taskItems.map((task) => (
<TableRow data-testid="workflow-task-row" key={task.id}>
<TableCell>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="text-xs text-zinc-500">{task.id}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">
{entityLabel(task.workflowInstance?.entityType)}
</div>
<div className="font-mono text-xs text-zinc-500">
{task.workflowInstance?.entityId ?? "-"}
<div className="font-medium text-zinc-900">{displayTitle(task)}</div>
<div className="text-xs text-zinc-500">
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
</div>
</TableCell>
<TableCell>{displayApplicant(task)}</TableCell>
<TableCell>{displayMetric(task)}</TableCell>
<TableCell>{businessStatusLabel(task.businessSummary?.businessStatus)}</TableCell>
<TableCell><StatusBadge status={task.status} /></TableCell>
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
<TableCell>
@@ -224,7 +275,7 @@ export function WorkflowTasksPage() {
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={5}>
<TableCell className="py-3 text-zinc-500" colSpan={7}>
暂无数据
</TableCell>
</TableRow>
@@ -1,6 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
A1,MVP主线代码差距盘点,"对照当前 PRD 和 7 份模块规约,盘点 workflow、reimbursements、invoicePool、wecomAuth、leave、purchaseRequests、contractsRegistry、projects 等现有实现、页面、回归脚本和缺口;本 Issue 不改业务代码。","1) 形成可复用实现、待修缺口和不重复造轮子的清单;2) 明确 A2-A8 的实际改动边界;3) 确认首轮默认不新增公共 API、不新增数据库表;4) 若发现必须新增接口、字段或迁移,拆独立后续 Issue。","流程级验证:按 PRD 的审批、报销、企业微信入口、请假、采购、合同、项目七条主线逐项映射到现有 server/client/tests 文件;命令:rg 检索关键路由、页面和测试;命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-09_20-57-19-mvp-mainline-dev-kickoff.csv。","ripgrep|manual|python3",DONE,DONE,DONE,"server/src/routes/*|client/src/pages/*|client/src/api/*|client/tests/e2e/*|docs/*.md","[]","A1 已完成只读差距盘点,不改业务代码;盘点文档:docs/MVP主线代码差距盘点.md;如后续发现需迁移或新增 API,必须另拆 Issue 并写回滚策略。"
A2,统一审批中心入口与状态收口,"收口 PC /workflow/tasks、移动 /mobile/approvals、业务详情页审批历史和通知 deeplink 的一致性,确保正式审批动作回到 OA 完成。","1) 待办、已办、业务详情页状态一致;2) 审批动作通过、驳回、退回、撤回、转审仍写入 OA 工作流历史;3) 企业微信消息只 deeplink 回 OA,不出现企业微信原生审批入口;4) 并发或已处理任务有明确提示。","流程级验证:提交一条报销或请假审批 -> PC 待办处理 -> 移动待办同步消失或进入已办 -> 业务详情审批历史一致;命令:cd server && ./scripts/regression-workflow-actions.sh;命令:cd client && npm run test:e2e -- workflow-quick-approve.spec.ts mobile-approvals-offline.spec.ts。","bash|npm|playwright|manual",TODO,TODO,TODO,"server/src/routes/workflow.js|server/src/utils/workflow.js|client/src/pages/WorkflowTasksPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx","[""A1""]","涉及 workflow 状态机和权限;实现方案必须说明回滚路径,默认以 revert commit 回滚,不做破坏性迁移。"
A2,统一审批中心入口与状态收口,"收口 PC /workflow/tasks、移动 /mobile/approvals、业务详情页审批历史和通知 deeplink 的一致性,确保正式审批动作回到 OA 完成。","1) 待办、已办、业务详情页状态一致;2) 审批动作通过、驳回、退回、撤回、转审仍写入 OA 工作流历史;3) 企业微信消息只 deeplink 回 OA,不出现企业微信原生审批入口;4) 并发或已处理任务有明确提示。","流程级验证:提交一条报销或请假审批 -> PC 待办处理 -> 移动待办同步消失或进入已办 -> 业务详情审批历史一致;命令:cd server && ./scripts/regression-workflow-actions.sh;命令:cd client && npm run test:e2e -- workflow-quick-approve.spec.ts mobile-approvals-offline.spec.ts。","bash|npm|playwright|manual",DONE,TODO,TODO,"server/src/routes/workflow.js|server/src/utils/workflow.js|client/src/pages/WorkflowTasksPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx","[""A1""]","A2 开发完成;prisma validate、workflow node --check、client typecheck/build 通过;回归与 E2E 在登录阶段被本地 DB 漂移阻断:employees.avatar_path 缺失,对应迁移 20260329194000_r43_employee_avatar_contracts,本轮不执行迁移。"
A3,报销发票OCR主链路收口,"收口上传、OCR 状态、查重、绑定明细、提交审批、付款标记和导出中的体验或状态缺口,优先复用现有 reimbursement 与 invoicePool 实现。","1) 员工能完成报销主流程:上传发票/附件 -> OCR/查重 -> 绑定明细 -> 提交审批;2) 财务能处理重复、付款标记和导出;3) 普通用户页面不出现内部状态码、发票池、绑定整单等不应见文案;4) OCR 失败、重复、附件缺失有业务提示。","流程级验证:创建报销草稿 -> 上传票据 -> OCR 进入终态 -> 绑定明细 -> 提交审批 -> 审批通过 -> 财务付款/导出;命令:cd server && ./scripts/regression-dedup.sh && ./scripts/regression-ocr.sh;命令:cd client && npm run test:e2e -- r102-reimbursement-main-flow.spec.ts invoice-ocr-flow.spec.ts。","bash|npm|playwright|manual",TODO,TODO,TODO,"server/src/routes/reimbursements.js|server/src/routes/invoicePool.js|server/src/routes/invoicePool/*|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/reimbursement-detail/*|client/src/pages/invoice-pool/*","[""A1"",""A2""]","涉及报销和发票状态;默认不改 OCR provider 和数据库结构,若需 schema/API 变更另拆 Issue。"
A4,企业微信入口绑定通知回OA,"收口企业微信 config、登录/绑定、移动落点、通知 deeplink 和脱敏消息,企业微信只作为入口、身份、通知和打卡原始采集边界。","1) 企业微信手机入口默认进入 /mobile;2) 已绑定用户直达,未绑定可回退账号密码;3) 停用员工不得从企业微信进入业务系统;4) 通知内容脱敏且 deeplink 回 OA;5) 通知失败不阻断主业务。","流程级验证:模拟企业微信 UA 访问入口 -> 已绑定进入移动工作台 -> 未绑定回退账号密码 -> 通知 deeplink 打开 OA 审批页;命令:cd client && npm run test:e2e -- wecom-entry.spec.ts wecom-first-bind.spec.ts r124-mobile-entry-matrix.spec.ts;后端补 node --check src/routes/wecomAuth.js。","npm|playwright|node|manual",TODO,TODO,TODO,"server/src/routes/wecomAuth.js|server/src/services/notifications/*|client/src/lib/wecomEntry.ts|client/src/lib/runtimeContext.ts|client/src/lib/notificationLinks.ts","[""A1"",""A2""]","不接企业微信原生审批、不接飞书;涉及真实企业微信凭据时 live smoke 缺配置必须标 SKIP。"
A5,请假补卡外出加班审批与考勤口径,"优先收口现有请假审批、团队状态、日历展示和考勤解释边界;首轮不做企业微信打卡 API 同步,不重造定位、人脸、Wi-Fi 或排班。","1) 请假审批状态正确;2) 已批准请假影响团队状态或日历展示;3) 补卡、外出、加班若现有实现不足,先记录缺口或做最小入口收口;4) 文案不引导员工去企业微信原生审批。","流程级验证:员工提交请假 -> 主管/HR 在 OA 审批 -> 团队状态或日历体现结果 -> 驳回/撤回后状态正确;命令:cd client && npm run test:e2e -- leave-flow.spec.ts leave-team-calendar.spec.ts;命令:cd server && node --check src/routes/leave.js。","npm|playwright|node|manual",TODO,TODO,TODO,"server/src/routes/leave.js|server/src/services/leave.js|client/src/pages/LeaveRequestPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/TeamPresencePage.tsx","[""A1"",""A2""]","首轮不接企业微信打卡同步;若补卡/外出/加班缺运行时能力,A1 后先拆最小 Issue,不扩大到完整考勤系统。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 MVP主线代码差距盘点 对照当前 PRD 和 7 份模块规约,盘点 workflow、reimbursements、invoicePool、wecomAuth、leave、purchaseRequests、contractsRegistry、projects 等现有实现、页面、回归脚本和缺口;本 Issue 不改业务代码。 1) 形成可复用实现、待修缺口和不重复造轮子的清单;2) 明确 A2-A8 的实际改动边界;3) 确认首轮默认不新增公共 API、不新增数据库表;4) 若发现必须新增接口、字段或迁移,拆独立后续 Issue。 流程级验证:按 PRD 的审批、报销、企业微信入口、请假、采购、合同、项目七条主线逐项映射到现有 server/client/tests 文件;命令:rg 检索关键路由、页面和测试;命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-09_20-57-19-mvp-mainline-dev-kickoff.csv。 ripgrep|manual|python3 DONE DONE DONE server/src/routes/*|client/src/pages/*|client/src/api/*|client/tests/e2e/*|docs/*.md [] A1 已完成只读差距盘点,不改业务代码;盘点文档:docs/MVP主线代码差距盘点.md;如后续发现需迁移或新增 API,必须另拆 Issue 并写回滚策略。
3 A2 统一审批中心入口与状态收口 收口 PC /workflow/tasks、移动 /mobile/approvals、业务详情页审批历史和通知 deeplink 的一致性,确保正式审批动作回到 OA 完成。 1) 待办、已办、业务详情页状态一致;2) 审批动作通过、驳回、退回、撤回、转审仍写入 OA 工作流历史;3) 企业微信消息只 deeplink 回 OA,不出现企业微信原生审批入口;4) 并发或已处理任务有明确提示。 流程级验证:提交一条报销或请假审批 -> PC 待办处理 -> 移动待办同步消失或进入已办 -> 业务详情审批历史一致;命令:cd server && ./scripts/regression-workflow-actions.sh;命令:cd client && npm run test:e2e -- workflow-quick-approve.spec.ts mobile-approvals-offline.spec.ts。 bash|npm|playwright|manual TODO DONE TODO TODO server/src/routes/workflow.js|server/src/utils/workflow.js|client/src/pages/WorkflowTasksPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx ["A1"] 涉及 workflow 状态机和权限;实现方案必须说明回滚路径,默认以 revert commit 回滚,不做破坏性迁移。 A2 开发完成;prisma validate、workflow node --check、client typecheck/build 通过;回归与 E2E 在登录阶段被本地 DB 漂移阻断:employees.avatar_path 缺失,对应迁移 20260329194000_r43_employee_avatar_contracts,本轮不执行迁移。
4 A3 报销发票OCR主链路收口 收口上传、OCR 状态、查重、绑定明细、提交审批、付款标记和导出中的体验或状态缺口,优先复用现有 reimbursement 与 invoicePool 实现。 1) 员工能完成报销主流程:上传发票/附件 -> OCR/查重 -> 绑定明细 -> 提交审批;2) 财务能处理重复、付款标记和导出;3) 普通用户页面不出现内部状态码、发票池、绑定整单等不应见文案;4) OCR 失败、重复、附件缺失有业务提示。 流程级验证:创建报销草稿 -> 上传票据 -> OCR 进入终态 -> 绑定明细 -> 提交审批 -> 审批通过 -> 财务付款/导出;命令:cd server && ./scripts/regression-dedup.sh && ./scripts/regression-ocr.sh;命令:cd client && npm run test:e2e -- r102-reimbursement-main-flow.spec.ts invoice-ocr-flow.spec.ts。 bash|npm|playwright|manual TODO TODO TODO server/src/routes/reimbursements.js|server/src/routes/invoicePool.js|server/src/routes/invoicePool/*|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/reimbursement-detail/*|client/src/pages/invoice-pool/* ["A1","A2"] 涉及报销和发票状态;默认不改 OCR provider 和数据库结构,若需 schema/API 变更另拆 Issue。
5 A4 企业微信入口绑定通知回OA 收口企业微信 config、登录/绑定、移动落点、通知 deeplink 和脱敏消息,企业微信只作为入口、身份、通知和打卡原始采集边界。 1) 企业微信手机入口默认进入 /mobile;2) 已绑定用户直达,未绑定可回退账号密码;3) 停用员工不得从企业微信进入业务系统;4) 通知内容脱敏且 deeplink 回 OA;5) 通知失败不阻断主业务。 流程级验证:模拟企业微信 UA 访问入口 -> 已绑定进入移动工作台 -> 未绑定回退账号密码 -> 通知 deeplink 打开 OA 审批页;命令:cd client && npm run test:e2e -- wecom-entry.spec.ts wecom-first-bind.spec.ts r124-mobile-entry-matrix.spec.ts;后端补 node --check src/routes/wecomAuth.js。 npm|playwright|node|manual TODO TODO TODO server/src/routes/wecomAuth.js|server/src/services/notifications/*|client/src/lib/wecomEntry.ts|client/src/lib/runtimeContext.ts|client/src/lib/notificationLinks.ts ["A1","A2"] 不接企业微信原生审批、不接飞书;涉及真实企业微信凭据时 live smoke 缺配置必须标 SKIP。
6 A5 请假补卡外出加班审批与考勤口径 优先收口现有请假审批、团队状态、日历展示和考勤解释边界;首轮不做企业微信打卡 API 同步,不重造定位、人脸、Wi-Fi 或排班。 1) 请假审批状态正确;2) 已批准请假影响团队状态或日历展示;3) 补卡、外出、加班若现有实现不足,先记录缺口或做最小入口收口;4) 文案不引导员工去企业微信原生审批。 流程级验证:员工提交请假 -> 主管/HR 在 OA 审批 -> 团队状态或日历体现结果 -> 驳回/撤回后状态正确;命令:cd client && npm run test:e2e -- leave-flow.spec.ts leave-team-calendar.spec.ts;命令:cd server && node --check src/routes/leave.js。 npm|playwright|node|manual TODO TODO TODO server/src/routes/leave.js|server/src/services/leave.js|client/src/pages/LeaveRequestPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/TeamPresencePage.tsx ["A1","A2"] 首轮不接企业微信打卡同步;若补卡/外出/加班缺运行时能力,A1 后先拆最小 Issue,不扩大到完整考勤系统。
+142
查看文件
@@ -282,6 +282,7 @@ function isoOrNull(value) {
}
function mapTaskSummary(task, allowedActions = []) {
const businessSummary = buildTaskBusinessSummary(task);
const workflowInstance = task.workflow_instance
? {
entity_type: task.workflow_instance.entity_type,
@@ -318,9 +319,89 @@ function mapTaskSummary(task, allowedActions = []) {
currentStatus: workflowInstance.current_status,
}
: null,
businessSummary,
};
}
function fallbackBusinessTitle(entityType) {
if (entityType === "reimbursement") return "报销单";
if (entityType === "leave") return "请假申请";
if (entityType === "purchase_request") return "采购申请";
if (entityType === "travel_request") return "差旅申请";
return "业务单据";
}
function buildTaskBusinessSummary(task) {
const workflowInstance = task.workflow_instance;
if (!workflowInstance) {
return null;
}
const fallback = {
reference: workflowInstance.entity_id,
title: fallbackBusinessTitle(workflowInstance.entity_type),
applicantName: null,
amount: null,
duration: null,
businessStatus: workflowInstance.current_status ?? null,
};
if (workflowInstance.entity_type === "reimbursement" && workflowInstance.reimbursement_form) {
const form = workflowInstance.reimbursement_form;
return {
...fallback,
reference: form.form_number ?? workflowInstance.entity_id,
title: form.description ? `报销:${form.description}` : "报销单",
applicantName: form.submitter?.name ?? form.owner?.name ?? null,
amount: toMoneyString(form.total_amount),
businessStatus: form.status ?? fallback.businessStatus,
};
}
if (workflowInstance.entity_type === "leave" && workflowInstance.leave_request) {
const leaveRequest = workflowInstance.leave_request;
return {
...fallback,
title: leaveRequest.leave_type?.name
? `请假:${leaveRequest.leave_type.name}`
: "请假申请",
applicantName: leaveRequest.owner?.name ?? null,
duration:
leaveRequest.requested_days == null
? null
: `${Number(leaveRequest.requested_days)}天`,
businessStatus: leaveRequest.status ?? fallback.businessStatus,
};
}
if (workflowInstance.entity_type === "purchase_request" && workflowInstance.purchase_request) {
const purchaseRequest = workflowInstance.purchase_request;
const amount = toMoneyString(purchaseRequest.total_amount);
return {
...fallback,
reference: purchaseRequest.request_no ?? workflowInstance.entity_id,
title: purchaseRequest.title || "采购申请",
applicantName: purchaseRequest.applicant?.name ?? null,
amount: amount ? `${amount} ${purchaseRequest.currency_code}` : null,
businessStatus: purchaseRequest.status ?? fallback.businessStatus,
};
}
if (workflowInstance.entity_type === "travel_request" && workflowInstance.travel_request) {
const travelRequest = workflowInstance.travel_request;
return {
...fallback,
reference: travelRequest.request_no ?? workflowInstance.entity_id,
title: travelRequest.purpose ? `差旅:${travelRequest.purpose}` : "差旅申请",
applicantName: travelRequest.applicant?.name ?? null,
amount: toMoneyString(travelRequest.estimated_amount),
businessStatus: travelRequest.status ?? fallback.businessStatus,
};
}
return fallback;
}
async function buildTaskVisibilityFilters(tx, request) {
const roleFilters = request.employee.roleCodes.length
? [{ assignee_role: { in: request.employee.roleCodes } }]
@@ -1380,6 +1461,67 @@ async function workflowRoutes(fastify) {
entity_type: true,
entity_id: true,
current_status: true,
reimbursement_form: {
select: {
form_number: true,
status: true,
total_amount: true,
description: true,
owner: {
select: {
name: true,
},
},
submitter: {
select: {
name: true,
},
},
},
},
leave_request: {
select: {
status: true,
requested_days: true,
leave_type: {
select: {
name: true,
},
},
owner: {
select: {
name: true,
},
},
},
},
purchase_request: {
select: {
request_no: true,
status: true,
title: true,
total_amount: true,
currency_code: true,
applicant: {
select: {
name: true,
},
},
},
},
travel_request: {
select: {
request_no: true,
status: true,
purpose: true,
estimated_amount: true,
applicant: {
select: {
name: true,
},
},
},
},
},
},
},