From 3d1e4c43f6f397eb8ef585e092d06dd64b2e33bc Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 12 May 2026 13:02:00 +0800 Subject: [PATCH] =?UTF-8?q?LP6=20=E5=AE=8C=E5=96=84=E8=AF=B7=E5=81=87?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/errorMessages.ts | 4 + client/src/api/leave.ts | 22 +++ client/src/api/purchaseRequests.ts | 10 + client/src/lib/approvalDisplay.ts | 172 ++++++++++++++++++ client/src/pages/LeaveRequestPage.tsx | 49 ++++- client/src/pages/MobileLeavePage.tsx | 19 +- client/src/pages/PurchaseRequestsPage.tsx | 63 +++++-- client/src/pages/WorkflowTaskDetailPage.tsx | 3 +- client/tests/e2e/leave-flow.spec.ts | 41 ++++- client/tests/e2e/procurement-flow.spec.ts | 4 + docs/trialrun/manual-trialrun-report.md | 8 +- ...1-45-00-r6-leave-procurement-polish-v1.csv | 8 +- server/src/routes/leave.js | 81 +++++++++ server/src/routes/purchaseRequests.js | 57 ++++-- 14 files changed, 502 insertions(+), 39 deletions(-) create mode 100644 client/src/lib/approvalDisplay.ts diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 73134ce3..3ffd64b1 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -299,6 +299,10 @@ export function mapApiErrorMessage( return "当前报销金额超出预算,系统已阻断提交。"; } + if (details?.budget === "missing_budget_pool") { + return "未匹配到预算池;当前强预算校验已阻断提交,请联系管理员补充预算配置。"; + } + if (details?.leave === "conflict_range") { return "当前时间段与已有请假记录冲突,请调整后再提交。"; } diff --git a/client/src/api/leave.ts b/client/src/api/leave.ts index 2c6db939..26bf0536 100644 --- a/client/src/api/leave.ts +++ b/client/src/api/leave.ts @@ -66,6 +66,28 @@ export type LeaveRequestItem = { employeeNo: string; name: string; } | null; + pendingWorkflow: { + instanceId: string; + currentStatus: string; + currentStepId: string; + task: { + id: string; + stepId: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + } | null; + } | null; + approvalHistory: Array<{ + id: string; + taskId: string | null; + actorId: string; + actorName: string | null; + actorEmployeeNo: string | null; + action: string; + comment: string | null; + createdAt: string | null; + }>; createdAt: string | null; updatedAt: string | null; }; diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 6e62fea2..03b96e15 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -199,6 +199,16 @@ export type PurchaseRequestDetail = PurchaseRequestSummary & { assigneeRole: string | null; } | null; } | null; + approvalHistory: Array<{ + id: string; + taskId: string | null; + actorId: string; + actorName: string | null; + actorEmployeeNo: string | null; + action: string; + comment: string | null; + createdAt: string | null; + }>; archiveOrder: PurchaseOrderArchive | null; permissions: { canEditDraft: boolean; diff --git a/client/src/lib/approvalDisplay.ts b/client/src/lib/approvalDisplay.ts new file mode 100644 index 00000000..5caac535 --- /dev/null +++ b/client/src/lib/approvalDisplay.ts @@ -0,0 +1,172 @@ +import { isUuidLike } from "./reimbursementDisplay"; + +export type ApprovalHistoryDisplayItem = { + id: string; + action: string; + comment: string | null; + actorName: string | null; + actorEmployeeNo?: string | null; + createdAt: string | null; +}; + +type PendingWorkflowLike = { + currentStatus?: string | null; + task?: { + stepName?: string | null; + assigneeRole?: string | null; + } | null; +} | null; + +type LeaveStatusLike = { + status: string; + pendingWorkflow?: PendingWorkflowLike; + approvalHistory?: ApprovalHistoryDisplayItem[]; +}; + +type PurchaseStatusLike = { + status: string; + requestNo?: string | null; + title?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + budgetPool?: { name?: string | null } | null; + pendingWorkflow?: PendingWorkflowLike; + approvalHistory?: ApprovalHistoryDisplayItem[]; +}; + +export function workflowActionLabel(action: string | null | undefined) { + const labels: Record = { + start: "提交", + submit: "提交", + approve: "通过", + reject: "驳回", + return: "退回", + transfer: "转审", + withdraw: "撤回", + remind: "催办", + overdue_remind: "超时提醒", + skip: "跳过", + cancel: "取消", + }; + + return action ? labels[action] ?? action : "-"; +} + +export function formatApprovalHistoryTime(value: string | null | undefined) { + return value ? value.slice(0, 19).replace("T", " ") : "-"; +} + +export function latestDecisionHistory(items: ApprovalHistoryDisplayItem[] | null | undefined) { + return (items ?? []).find((item) => ["return", "reject", "withdraw", "approve"].includes(item.action)) ?? null; +} + +function commentSuffix(item: ApprovalHistoryDisplayItem | null) { + if (!item?.comment?.trim()) { + return ""; + } + return `:${item.comment.trim()}`; +} + +export function resolveLeaveStatusHint(item: LeaveStatusLike) { + const latest = latestDecisionHistory(item.approvalHistory); + + if (item.status === "draft") { + if (latest?.action === "return") { + return `审批已退回${commentSuffix(latest)}。修改后可重新提交。`; + } + if (latest?.action === "withdraw") { + return "已撤回为草稿,可修改后重新提交。"; + } + return "草稿可继续编辑,确认后提交审批。"; + } + + if (item.status === "reviewing") { + const stepName = item.pendingWorkflow?.task?.stepName; + return stepName ? `当前节点:${stepName},等待审批人处理。` : "已提交,等待审批人处理。"; + } + + if (item.status === "approved") { + return "已通过,系统会按请假结果更新额度和团队状态。"; + } + + if (item.status === "rejected") { + return latest?.action === "reject" + ? `审批已驳回${commentSuffix(latest)}。可修改后重新提交。` + : "审批已驳回,可修改后重新提交。"; + } + + if (item.status === "cancelled") { + return "已取消,不再进入审批。"; + } + + return "请查看审批记录确认下一步。"; +} + +export function resolvePurchaseDisplayCode(item: PurchaseStatusLike) { + const requestNo = item.requestNo?.trim(); + if (requestNo && !isUuidLike(requestNo)) { + return requestNo; + } + + const title = item.title?.trim(); + if (title) { + return title; + } + + const date = item.updatedAt?.slice(0, 10) ?? item.createdAt?.slice(0, 10) ?? ""; + if (item.status === "draft" || item.status === "rejected") { + return date ? `采购草稿 · ${date}` : "采购草稿"; + } + + return date ? `未编号采购 · ${date}` : "未编号采购"; +} + +export function resolvePurchaseBudgetHint(item: PurchaseStatusLike) { + if (item.budgetPool?.name) { + return `已匹配预算池:${item.budgetPool.name}`; + } + + return "未匹配预算池;当前试运行按预警继续提交审批,如需强预算校验请由管理员补充预算配置。"; +} + +export function resolvePurchaseSubmitWarning(warnings: string[]) { + if (warnings.includes("missing_budget_pool")) { + return "采购申请已提交;未匹配预算池,已按当前试运行规则继续进入审批。"; + } + return "采购申请已提交"; +} + +export function resolvePurchaseStatusHint(item: PurchaseStatusLike) { + const latest = latestDecisionHistory(item.approvalHistory); + + if (item.status === "draft") { + if (latest?.action === "return") { + return `审批已退回${commentSuffix(latest)}。修改后可重新提交。`; + } + if (latest?.action === "withdraw") { + return "已撤回为草稿,可修改后重新提交。"; + } + return "草稿可继续编辑,补齐明细和附件后提交审批。"; + } + + if (item.status === "reviewing") { + const stepName = item.pendingWorkflow?.task?.stepName; + return stepName ? `当前节点:${stepName},等待审批人处理。` : "已提交,等待审批人处理。"; + } + + if (item.status === "approved") { + return "已通过,可由有权限人员完成 PO/合同归档或后续处理。"; + } + + if (item.status === "rejected") { + return latest?.action === "reject" + ? `审批已驳回${commentSuffix(latest)}。可修改后重新提交。` + : "审批已驳回,可修改后重新提交。"; + } + + if (item.status === "cancelled") { + return "已取消,不再进入审批。"; + } + + return "请查看审批记录确认下一步。"; +} diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx index e8dcfaac..093baab6 100644 --- a/client/src/pages/LeaveRequestPage.tsx +++ b/client/src/pages/LeaveRequestPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; @@ -33,6 +33,11 @@ import { Label } from "../components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Textarea } from "../components/ui/textarea"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; +import { + formatApprovalHistoryTime, + resolveLeaveStatusHint, + workflowActionLabel, +} from "../lib/approvalDisplay"; const PAGE_SIZE = 10; const CALENDAR_PAGE_SIZE = 100; @@ -218,6 +223,7 @@ export function LeaveRequestPage() { const [reason, setReason] = useState(""); const [attachmentFile, setAttachmentFile] = useState(null); const [attachmentInputKey, setAttachmentInputKey] = useState(0); + const attachmentInputRef = useRef(null); const [editingLeaveRequest, setEditingLeaveRequest] = useState(null); const [formError, setFormError] = useState(""); const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null); @@ -340,6 +346,9 @@ export function LeaveRequestPage() { setReason(""); setAttachmentFile(null); setAttachmentInputKey((current) => current + 1); + if (attachmentInputRef.current) { + attachmentInputRef.current.value = ""; + } setFormError(""); } @@ -353,6 +362,9 @@ export function LeaveRequestPage() { setReason(item.reason ?? ""); setAttachmentFile(null); setAttachmentInputKey((current) => current + 1); + if (attachmentInputRef.current) { + attachmentInputRef.current.value = ""; + } setFormError(""); } @@ -564,7 +576,22 @@ export function LeaveRequestPage() { {formatRangeLabel(item)} {item.requestedDays} - + +
+ +
+ {resolveLeaveStatusHint(item)} +
+ {item.approvalHistory.length ? ( +
+ 最近审批:{workflowActionLabel(item.approvalHistory[0].action)} + {item.approvalHistory[0].comment ? ` · ${item.approvalHistory[0].comment}` : ""} + {" · "} + {formatApprovalHistoryTime(item.approvalHistory[0].createdAt)} +
+ ) : null} +
+
{item.attachment?.originalName ?? "-"} @@ -856,9 +883,11 @@ export function LeaveRequestPage() {
- { @@ -866,6 +895,20 @@ export function LeaveRequestPage() { setAttachmentFile(nextFile); }} /> +
+ + + {currentAttachmentLabel ?? "未选择文件"} + +

{selectedLeaveType?.requiresAttachment ? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})` diff --git a/client/src/pages/MobileLeavePage.tsx b/client/src/pages/MobileLeavePage.tsx index 15c24f66..5bfc6943 100644 --- a/client/src/pages/MobileLeavePage.tsx +++ b/client/src/pages/MobileLeavePage.tsx @@ -19,6 +19,11 @@ import { Button } from "../components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { resolveErrorMessage } from "../lib/resolveErrorMessage"; +import { + formatApprovalHistoryTime, + resolveLeaveStatusHint, + workflowActionLabel, +} from "../lib/approvalDisplay"; type LeaveStatusFilter = "all" | LeaveRequestStatus; @@ -27,6 +32,7 @@ const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [ { value: "draft", label: "草稿" }, { value: "reviewing", label: "审批中" }, { value: "approved", label: "已通过" }, + { value: "rejected", label: "已驳回" }, ]; const STATUS_LABEL: Record = { @@ -85,6 +91,17 @@ function LeaveRecordCard({

{item.reason ?

{item.reason}

: null} +
+ {resolveLeaveStatusHint(item)} +
+ {item.approvalHistory.length ? ( +
+ 最近审批:{workflowActionLabel(item.approvalHistory[0].action)} + {item.approvalHistory[0].comment ? ` · ${item.approvalHistory[0].comment}` : ""} + {" · "} + {formatApprovalHistoryTime(item.approvalHistory[0].createdAt)} +
+ ) : null} {canEdit || canSubmit ? (
@@ -403,7 +420,7 @@ export function MobileLeavePage() {

请假记录

setStatusFilter(value as LeaveStatusFilter)}> - + {STATUS_TABS.map((item) => ( {item.label} diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index b55c8599..cdcc72fe 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -47,6 +47,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ". import { Textarea } from "../components/ui/textarea"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { buildPrecheckDialogMessage } from "../lib/precheck"; +import { + formatApprovalHistoryTime, + resolvePurchaseBudgetHint, + resolvePurchaseDisplayCode, + resolvePurchaseStatusHint, + resolvePurchaseSubmitWarning, + workflowActionLabel, +} from "../lib/approvalDisplay"; const PAGE_SIZE = 20; @@ -496,9 +504,7 @@ export function PurchaseRequestsPage() { toast.success( data.precheckDecision === "manual_review" ? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险` - : data.warnings.includes("missing_budget_pool") - ? "采购申请已提交(未匹配预算池)" - : "采购申请已提交", + : resolvePurchaseSubmitWarning(data.warnings), ); }, onError: (error) => { @@ -884,7 +890,7 @@ export function PurchaseRequestsPage() {
- {item.requestNo || item.title || item.id} + {resolvePurchaseDisplayCode(item)}
{item.title || "未命名采购申请"}
@@ -928,7 +934,7 @@ export function PurchaseRequestsPage() { }} > -
{item.requestNo || item.title || item.id}
+
{resolvePurchaseDisplayCode(item)}
{item.title || "未命名采购申请"}
{item.applicant?.name ?? "-"} @@ -976,8 +982,11 @@ export function PurchaseRequestsPage() { <>
-

{detail.requestNo ?? "采购草稿"}

+

{resolvePurchaseDisplayCode(detail)}

申请人:{detail.applicant?.name ?? "-"}

+

+ {resolvePurchaseStatusHint(detail)} +

@@ -1042,6 +1051,26 @@ export function PurchaseRequestsPage() { + + {detail.approvalHistory.length ? ( +
+ {detail.approvalHistory.map((item) => ( +
+
+ + {workflowActionLabel(item.action)} · {item.actorName ?? item.actorId} + + {formatApprovalHistoryTime(item.createdAt)} +
+
{item.comment || "无补充说明"}
+
+ ))} +
+ ) : ( +
暂无审批记录。
+ )} +
+
@@ -1119,14 +1148,19 @@ export function PurchaseRequestsPage() {
-
预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}
+
预算池:{detail.budgetPool?.name ?? "未匹配"}
待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}
总金额:{totalAmountPreview}
- {!detail.budgetPool ? ( -
- 未匹配预算池时仍会按当前规则提交审批;如需强预算校验,请由管理员补充预算配置。 -
- ) : null} +
+ {resolvePurchaseBudgetHint(detail)} +
@@ -1355,7 +1389,10 @@ export function PurchaseRequestsPage() {
-

附件

+
+

附件

+

用于订单、报价、验收或情况说明;草稿状态可增删,下载会留痕。

+
-
采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}
+
采购单号:{resolvePurchaseDisplayCode(detail.purchaseRequest)}
标题:{detail.purchaseRequest.title || "-"}
申请人:{detail.purchaseRequest.applicant?.name ?? "-"}
供应商:{detail.purchaseRequest.supplier?.name ?? "-"}
diff --git a/client/tests/e2e/leave-flow.spec.ts b/client/tests/e2e/leave-flow.spec.ts index 237a9df2..4162544e 100644 --- a/client/tests/e2e/leave-flow.spec.ts +++ b/client/tests/e2e/leave-flow.spec.ts @@ -89,6 +89,7 @@ test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async ( const draftRow = page.getByRole("row").filter({ hasText: startDate }).first(); await expect(draftRow).toContainText("草稿", { timeout: 30_000 }); + await expect(draftRow).toContainText("草稿可继续编辑", { timeout: 30_000 }); const updateResponsePromise = page.waitForResponse((response) => { return response.request().method() === "PATCH" && new URL(response.url()).pathname === `/api/leave-requests/${leaveId}` && response.ok(); @@ -110,6 +111,7 @@ test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async ( await submitLeave(employeeClient.api, leaveId); await page.goto("/leave-requests"); await expect(updatedRow).toContainText("审批中", { timeout: 30_000 }); + await expect(updatedRow).toContainText("当前节点", { timeout: 30_000 }); } finally { await employeeClient.dispose(); } @@ -157,9 +159,46 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a await login(page, EMPLOYEE); await page.goto("/leave-requests"); - await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 }); + const approvedRow = page.getByRole("row").filter({ hasText: secondDate }).first(); + await expect(approvedRow).toContainText("已通过", { timeout: 30_000 }); + await expect(approvedRow).toContainText("已通过,系统会按请假结果更新额度和团队状态", { timeout: 30_000 }); await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 }); } finally { await employeeClient.dispose(); } }); + +test("E2E 请假退回说明:审批退回后员工可看到处理意见", async ({ page, workflowDetail }) => { + const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); + const baseOffset = uniqueFutureOffset(5200); + const leaveDate = formatDate(baseOffset); + const returnComment = `请补充外出交接安排 ${Date.now()}`; + + try { + await login(page, EMPLOYEE); + await (await openNavLink(page, "请假")).click(); + await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); + + const leaveId = await createLeaveDraft(page, leaveDate, leaveDate, `Playwright 退回请假 ${Date.now()}`); + const submitResult = await submitLeave(employeeClient.api, leaveId); + + await logout(page, EMPLOYEE.name); + + await login(page, MANAGER); + await page.goto(`/workflow/tasks/${submitResult.currentTaskId}`); + await workflowDetail.assertLoaded(); + await workflowDetail.returnTask(returnComment); + await expect(workflowDetail.summary).toContainText(/状态:(已处理|completed|已完成)/); + + await logout(page, MANAGER.name); + + await login(page, EMPLOYEE); + await page.goto("/leave-requests"); + const returnedRow = page.getByRole("row").filter({ hasText: leaveDate }).first(); + await expect(returnedRow).toContainText("草稿", { timeout: 30_000 }); + await expect(returnedRow).toContainText("审批已退回", { timeout: 30_000 }); + await expect(returnedRow).toContainText(returnComment, { timeout: 30_000 }); + } finally { + await employeeClient.dispose(); + } +}); diff --git a/client/tests/e2e/procurement-flow.spec.ts b/client/tests/e2e/procurement-flow.spec.ts index 92f546d1..e5ffe723 100644 --- a/client/tests/e2e/procurement-flow.spec.ts +++ b/client/tests/e2e/procurement-flow.spec.ts @@ -61,6 +61,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async ( await itemDescriptionInputs.first().fill("Playwright Laptop"); await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("2"); await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("100"); + await expect(page.getByTestId("purchase-budget-hint")).toContainText("未匹配预算池"); const submitResponsePromise = page.waitForResponse( (response) => @@ -78,6 +79,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async ( }; const currentTaskId = submitPayload.data.currentTaskId; await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 }); + await expect(page.getByTestId("purchase-status-hint")).toContainText("当前节点", { timeout: 30_000 }); requestNo = (await page.getByRole("heading", { level: 3 }).filter({ hasText: /^CG-/ }).first().textContent())?.trim() ?? ""; expect(requestNo).toMatch(/^CG-/); @@ -103,6 +105,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async ( await purchaseRow.click(); await expect(page.getByTestId("purchase-archive-po-no")).toBeVisible(); + await expect(page.getByTestId("purchase-status-hint")).toContainText("已通过", { timeout: 30_000 }); + await expect(page.getByTestId("purchase-approval-history")).toContainText("通过", { timeout: 30_000 }); await page.getByTestId("purchase-archive-po-no").fill("PO-E2E-001"); await page.getByTestId("purchase-archive-contract-no").fill("CT-E2E-001"); await page.getByTestId("purchase-archive-save").click(); diff --git a/docs/trialrun/manual-trialrun-report.md b/docs/trialrun/manual-trialrun-report.md index 73d0524b..cc98122d 100644 --- a/docs/trialrun/manual-trialrun-report.md +++ b/docs/trialrun/manual-trialrun-report.md @@ -14,8 +14,8 @@ | 项目 | 记录 | | --- | --- | | 公网地址 | `https://oa.hyinda.com` | -| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6 后续优化已完成;下一批进入 LP6 请假采购体验完善 | -| 当前代码基线 | RI6 收口提交;公网 `https://oa.hyinda.com` 已部署报销票据体验完善,并完成 `/ready`、public bundle 和 live smoke readiness 复检 | +| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;下一批进入 ME6 移动二轮体验 | +| 当前代码基线 | LP6 收口提交待生成;公网 `https://oa.hyinda.com` 已部署请假采购体验完善,并完成 `/ready`、public bundle 和 live smoke readiness 复检 | | 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` | | 回滚联系人 | `wangshunli` | | 问题登记负责人 | `wangshunli` | @@ -92,6 +92,10 @@ AP6 收口(2026-05-12):审批效率 v1 已完成并部署。代码提交 ` RI6 收口(2026-05-12):报销票据体验完善已完成并部署。PC/移动报销详情统一复用发票状态文案和下一步提示,普通员工继续从报销详情添加 PDF 发票,不恢复“票据中心/发票池”主入口;移动可添加发票显示明确“可添加”状态,识别失败、重复、已归档或已报销进入可解释终态。本地 `client typecheck/build`、`check-ui-design-rules --strict`、R111/R125 专项 4 passed、R109 移动专项 1 passed、`BUSINESS_FLOW_GROUPS=reimbursement` 10 passed / 1 skipped;已部署到 `https://oa.hyinda.com`,公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness 通过,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-120138`。下一批默认进入 LP6 请假采购体验完善;R7 尚未启动,需在 RI6/LP6 至少完成一轮后另建合同。 +LP6 收口(2026-05-12):请假采购体验完善已完成并部署。请假 PC/移动记录增加状态下一步提示、当前审批节点、最近审批动作和退回/驳回意见;请假附件上传改为中文自定义控件。采购列表和详情优先展示业务编号或标题,不再把 UUID 作为主展示;缺预算池 warning 改为“未匹配预算池,已按当前试运行规则继续进入审批/请管理员补充预算配置”的用户可理解提示;采购详情补审批记录分区和附件用途/下载留痕说明。后端未新增 API、未改 Prisma schema,仅在既有请假/采购响应中补充 `pendingWorkflow` 与 `approvalHistory`。 + +LP6 本地验证:`leave-flow.spec.ts`、`leave-team-calendar.spec.ts`、`procurement-flow.spec.ts`、`workflow-quick-approve.spec.ts` 共 8 passed;`permission-direct-access.spec.ts -g "PA6 请假和采购自审"` 1 passed,确认请假/采购自审和 admin-only 业务审批仍由服务端拒绝;`BUSINESS_FLOW_GROUPS=leave,procurement bash scripts/qa/run-business-flow-gates.sh local` 在临时库通过,报告 `artifacts/qa-flow/business-flow-20260512-124643`;`server prisma:validate`、`node --check src/routes/leave.js`、`node --check src/routes/purchaseRequests.js`、`client typecheck/build`、`check-ui-design-rules --strict`、`git diff --check` 均通过。视觉证据输出到 `output/playwright/lp6-leave-procurement-polish/`,覆盖 PC 1440x960 请假/采购/审批与移动 390x844 请假/审批,均无横向溢出、无可见原生文件控件、无 `Choose File` 文案、无 console error。已部署到 `https://oa.hyinda.com`,公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness 通过,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-125740`;本地未提供完整 live smoke 凭据和真实 OCR 样票,脚本按规则跳过网页 live smoke 与真实 OCR,不影响 LP6 请假采购体验验收。下一批默认进入 ME6 移动二轮体验;R7 仍需在 LP6/ME6 至少完成一轮后另建合同。 + ## 结论 R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。 diff --git a/issues/2026-05-12_01-45-00-r6-leave-procurement-polish-v1.csv b/issues/2026-05-12_01-45-00-r6-leave-procurement-polish-v1.csv index 64b4cecb..eef22e7a 100644 --- a/issues/2026-05-12_01-45-00-r6-leave-procurement-polish-v1.csv +++ b/issues/2026-05-12_01-45-00-r6-leave-procurement-polish-v1.csv @@ -1,6 +1,6 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes LP6-1,请假采购轻量完善合同建档,规划 R6 后请假与采购轻量体验完善。,"1) 本 CSV 校验通过;2) 不扩复杂预算、供应商准入、招投标、ERP;3) 不改审批链硬规则;4) 统一审批展示保持一致。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-45-00-r6-leave-procurement-polish-v1.csv;人工核对产品设计规则和统一审批中心规约。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-45-00-r6-leave-procurement-polish-v1.csv,[],轻量完善只覆盖主链路体验。 -LP6-2,请假体验完善,优化请假提交、经理审批、状态查看、退回和驳回说明。,"1) 员工能清楚查看天数、日期、原因和状态;2) 经理审批动作清晰;3) 退回/驳回说明可见;4) 移动轻量展示。","流程级验证:员工提交请假 -> 经理通过/驳回/退回 -> 员工查看状态和说明 -> PC/移动均可理解。",playwright|node,TODO,TODO,TODO,server/src/routes/leave.js|client/src/pages/LeaveRequestPage.tsx|client/src/pages/MobileLeavePage.tsx,"[""LP6-1""]",不接企业微信打卡和排班。 -LP6-3,采购体验完善,优化采购提交、经理审批、历史查看、附件说明和缺预算池 warning 解释。,"1) 员工提交采购不暴露内部错误;2) 缺预算池 warning 可理解;3) 审批历史清晰;4) 不扩预算和 ERP。","流程级验证:员工提交采购 -> 缺预算池返回可解释 warning -> 经理审批 -> 查看历史和状态说明。",playwright|node,TODO,TODO,TODO,server/src/routes/purchaseRequests.js|client/src/pages/PurchaseRequestsPage.tsx,"[""LP6-2""]",PS6 热修后的 warning 需要转为用户可理解文案。 -LP6-4,统一审批展示一致性,请假和采购详情页审批流展示与报销一致,自审和 admin-only 禁止审批继续验证。,"1) 三类单据审批流视觉和字段一致;2) 自审阻断;3) admin-only 无业务审批按钮;4) 服务端越权拒绝。","流程级验证:报销、请假、采购各提交一单 -> 审批流展示对比 -> 自审和 admin-only 路径验证。",playwright|node,TODO,TODO,TODO,server/src/routes/workflow.js|client/src/pages,"[""LP6-3""]",涉及审批权限需 reviewer 复核。 -LP6-5,请假采购回归部署,完成轻量完善后执行业务流、视觉、权限和公网复检。,"1) leave/procurement 业务流通过;2) PC/移动视觉通过;3) 权限回归通过;4) 部署后 live smoke 通过。","流程级验证:BUSINESS_FLOW_GROUPS=leave,procurement bash scripts/qa/run-business-flow-gates.sh local;client typecheck/build;deploy;live smoke readiness。",bash|npm|playwright,TODO,TODO,TODO,client|server|deploy/trialrun,"[""LP6-4""]",不把采购轻量完善扩成复杂招采。 +LP6-2,请假体验完善,优化请假提交、经理审批、状态查看、退回和驳回说明。,"1) 员工能清楚查看天数、日期、原因和状态;2) 经理审批动作清晰;3) 退回/驳回说明可见;4) 移动轻量展示。","流程级验证:员工提交请假 -> 经理通过/驳回/退回 -> 员工查看状态和说明 -> PC/移动均可理解。",playwright|node,DONE,DONE,DONE,server/src/routes/leave.js|client/src/pages/LeaveRequestPage.tsx|client/src/pages/MobileLeavePage.tsx,"[""LP6-1""]",已通过 leave-flow 3 passed、leave-team-calendar 3 passed,并在业务流门禁 leave 组 6 passed;不接企业微信打卡和排班。 +LP6-3,采购体验完善,优化采购提交、经理审批、历史查看、附件说明和缺预算池 warning 解释。,"1) 员工提交采购不暴露内部错误;2) 缺预算池 warning 可理解;3) 审批历史清晰;4) 不扩预算和 ERP。","流程级验证:员工提交采购 -> 缺预算池返回可解释 warning -> 经理审批 -> 查看历史和状态说明。",playwright|node,DONE,DONE,DONE,server/src/routes/purchaseRequests.js|client/src/pages/PurchaseRequestsPage.tsx,"[""LP6-2""]",已通过 procurement-flow 1 passed;缺预算池展示用户可理解 warning,不扩预算和 ERP。 +LP6-4,统一审批展示一致性,请假和采购详情页审批流展示与报销一致,自审和 admin-only 禁止审批继续验证。,"1) 三类单据审批流视觉和字段一致;2) 自审阻断;3) admin-only 无业务审批按钮;4) 服务端越权拒绝。","流程级验证:报销、请假、采购各提交一单 -> 审批流展示对比 -> 自审和 admin-only 路径验证。",playwright|node,DONE,DONE,DONE,server/src/routes/workflow.js|client/src/pages,"[""LP6-3""]",审批状态与权限语义未改;workflow-quick-approve 通过;permission-direct-access 中请假/采购自审与 admin-only 拒绝专项 1 passed。 +LP6-5,请假采购回归部署,完成轻量完善后执行业务流、视觉、权限和公网复检。,"1) leave/procurement 业务流通过;2) PC/移动视觉通过;3) 权限回归通过;4) 部署后 live smoke 通过。","流程级验证:BUSINESS_FLOW_GROUPS=leave,procurement bash scripts/qa/run-business-flow-gates.sh local;client typecheck/build;deploy;live smoke readiness。",bash|npm|playwright,DONE,DONE,DONE,client|server|deploy/trialrun,"[""LP6-4""]",本地业务流门禁 report=artifacts/qa-flow/business-flow-20260512-124643;视觉截图 output/playwright/lp6-leave-procurement-polish;已部署公网,/ready 正常,public bundle findings=0,live readiness PASS report=artifacts/qa-flow/live-smoke-readiness-20260512-125740。 diff --git a/server/src/routes/leave.js b/server/src/routes/leave.js index b3edbef8..d1fbcc81 100644 --- a/server/src/routes/leave.js +++ b/server/src/routes/leave.js @@ -94,6 +94,46 @@ const LEAVE_TYPE_SELECT = { }, }; +const LEAVE_WORKFLOW_SELECT = { + orderBy: { created_at: "desc" }, + take: 1, + select: { + id: true, + current_status: true, + current_step_id: true, + workflow_tasks: { + where: { status: "pending" }, + orderBy: { created_at: "asc" }, + take: 1, + select: { + id: true, + step_id: true, + step_name: true, + assignee_id: true, + assignee_role: true, + }, + }, + workflow_history: { + orderBy: { created_at: "desc" }, + take: 8, + select: { + id: true, + task_id: true, + actor_id: true, + action: true, + comment: true, + created_at: true, + actor: { + select: { + employee_no: true, + name: true, + }, + }, + }, + }, + }, +}; + function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", @@ -347,7 +387,43 @@ function mapLeaveTypeSummary(leaveType) { }; } +function mapWorkflowHistoryItem(item) { + return { + id: item.id, + taskId: item.task_id ?? null, + actorId: item.actor_id, + actorName: item.actor?.name ?? null, + actorEmployeeNo: item.actor?.employee_no ?? null, + action: item.action, + comment: item.comment ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + }; +} + +function mapPendingWorkflow(instance) { + if (!instance || instance.current_status !== "pending") { + return null; + } + + const task = instance.workflow_tasks?.[0] ?? null; + return { + instanceId: instance.id, + currentStatus: instance.current_status, + currentStepId: instance.current_step_id, + task: task + ? { + id: task.id, + stepId: task.step_id, + stepName: task.step_name, + assigneeId: task.assignee_id, + assigneeRole: task.assignee_role, + } + : null, + }; +} + function mapLeaveRequestResponse(leave) { + const latestWorkflow = leave.workflow_instances?.[0] ?? null; return { id: leave.id, ownerId: leave.owner_id, @@ -374,6 +450,8 @@ function mapLeaveRequestResponse(leave) { name: leave.owner.name, } : null, + pendingWorkflow: mapPendingWorkflow(latestWorkflow), + approvalHistory: (latestWorkflow?.workflow_history ?? []).map(mapWorkflowHistoryItem), createdAt: leave.created_at?.toISOString?.() ?? null, updatedAt: leave.updated_at?.toISOString?.() ?? null, }; @@ -562,6 +640,7 @@ async function leaveRoutes(fastify) { name: true, }, }, + workflow_instances: LEAVE_WORKFLOW_SELECT, }, }), fastify.prisma.leaveRequest.count({ where }), @@ -668,6 +747,7 @@ async function leaveRoutes(fastify) { name: true, }, }, + workflow_instances: LEAVE_WORKFLOW_SELECT, }, }); @@ -849,6 +929,7 @@ async function leaveRoutes(fastify) { name: true, }, }, + workflow_instances: LEAVE_WORKFLOW_SELECT, }, }); diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index c2e42198..d0ee5acd 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -225,10 +225,6 @@ const PURCHASE_DETAIL_SELECT = { }, }, workflow_instances: { - where: { - entity_type: "purchase_request", - current_status: "pending", - }, orderBy: { created_at: "desc" }, take: 1, select: { @@ -247,6 +243,24 @@ const PURCHASE_DETAIL_SELECT = { assignee_role: true, }, }, + workflow_history: { + orderBy: { created_at: "desc" }, + take: 8, + select: { + id: true, + task_id: true, + actor_id: true, + action: true, + comment: true, + created_at: true, + actor: { + select: { + employee_no: true, + name: true, + }, + }, + }, + }, }, }, }; @@ -537,20 +551,34 @@ function mapPurchaseOrderArchive(item) { }; } +function mapWorkflowHistoryItem(item) { + return { + id: item.id, + taskId: item.task_id ?? null, + actorId: item.actor_id, + actorName: item.actor?.name ?? null, + actorEmployeeNo: item.actor?.employee_no ?? null, + action: item.action, + comment: item.comment ?? null, + createdAt: toIsoDateTime(item.created_at), + }; +} + function mapPurchaseRequestDetail(item, request) { const summary = mapPurchaseRequestSummary(item); - const pendingWorkflow = item.workflow_instances?.[0] + const latestWorkflow = item.workflow_instances?.[0] ?? null; + const pendingWorkflow = latestWorkflow?.current_status === "pending" ? { - instanceId: item.workflow_instances[0].id, - currentStatus: item.workflow_instances[0].current_status, - currentStepId: item.workflow_instances[0].current_step_id, - task: item.workflow_instances[0].workflow_tasks?.[0] + instanceId: latestWorkflow.id, + currentStatus: latestWorkflow.current_status, + currentStepId: latestWorkflow.current_step_id, + task: latestWorkflow.workflow_tasks?.[0] ? { - id: item.workflow_instances[0].workflow_tasks[0].id, - stepId: item.workflow_instances[0].workflow_tasks[0].step_id, - stepName: item.workflow_instances[0].workflow_tasks[0].step_name, - assigneeId: item.workflow_instances[0].workflow_tasks[0].assignee_id, - assigneeRole: item.workflow_instances[0].workflow_tasks[0].assignee_role, + id: latestWorkflow.workflow_tasks[0].id, + stepId: latestWorkflow.workflow_tasks[0].step_id, + stepName: latestWorkflow.workflow_tasks[0].step_name, + assigneeId: latestWorkflow.workflow_tasks[0].assignee_id, + assigneeRole: latestWorkflow.workflow_tasks[0].assignee_role, } : null, } @@ -580,6 +608,7 @@ function mapPurchaseRequestDetail(item, request) { })), archiveOrder: mapPurchaseOrderArchive(item.order_archive), pendingWorkflow, + approvalHistory: (latestWorkflow?.workflow_history ?? []).map(mapWorkflowHistoryItem), permissions: { canEditDraft: item.status === "draft" && item.applicant_id === request.employee.id, canSubmit: item.status === "draft" && item.applicant_id === request.employee.id && request.employee.permissionCodes.includes("procurement.submit"),