LP6 完善请假采购体验与审批说明

这个提交包含在:
2026-05-12 13:02:00 +08:00
父节点 21887ce28a
当前提交 3d1e4c43f6
共修改 14 个文件,包含 502 行新增和 39 行删除
+4
查看文件
@@ -299,6 +299,10 @@ export function mapApiErrorMessage(
return "当前报销金额超出预算,系统已阻断提交。";
}
if (details?.budget === "missing_budget_pool") {
return "未匹配到预算池;当前强预算校验已阻断提交,请联系管理员补充预算配置。";
}
if (details?.leave === "conflict_range") {
return "当前时间段与已有请假记录冲突,请调整后再提交。";
}
+22
查看文件
@@ -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;
};
+10
查看文件
@@ -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;
+172
查看文件
@@ -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<string, string> = {
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 "请查看审批记录确认下一步。";
}
+46 -3
查看文件
@@ -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<File | null>(null);
const [attachmentInputKey, setAttachmentInputKey] = useState(0);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const [editingLeaveRequest, setEditingLeaveRequest] = useState<LeaveRequestItem | null>(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() {
</TableCell>
<TableCell>{formatRangeLabel(item)}</TableCell>
<TableCell>{item.requestedDays}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={item.status} />
<div className="max-w-xs text-xs text-zinc-500" data-testid="leave-status-hint">
{resolveLeaveStatusHint(item)}
</div>
{item.approvalHistory.length ? (
<div className="max-w-xs text-xs text-zinc-400">
最近审批:{workflowActionLabel(item.approvalHistory[0].action)}
{item.approvalHistory[0].comment ? ` · ${item.approvalHistory[0].comment}` : ""}
{" · "}
{formatApprovalHistoryTime(item.approvalHistory[0].createdAt)}
</div>
) : null}
</div>
</TableCell>
<TableCell className="text-xs text-zinc-500">
{item.attachment?.originalName ?? "-"}
</TableCell>
@@ -856,9 +883,11 @@ export function LeaveRequestPage() {
<div className="space-y-2">
<Label htmlFor="leave-attachment">附件</Label>
<Input
<input
key={attachmentInputKey}
ref={attachmentInputRef}
id="leave-attachment"
className="hidden"
type="file"
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
onChange={(event) => {
@@ -866,6 +895,20 @@ export function LeaveRequestPage() {
setAttachmentFile(nextFile);
}}
/>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={!selectedLeaveType}
onClick={() => attachmentInputRef.current?.click()}
>
选择附件
</Button>
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="leave-attachment-name">
{currentAttachmentLabel ?? "未选择文件"}
</span>
</div>
<p className="text-xs text-zinc-500">
{selectedLeaveType?.requiresAttachment
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
+18 -1
查看文件
@@ -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<LeaveRequestStatus, string> = {
@@ -85,6 +91,17 @@ function LeaveRecordCard({
</div>
{item.reason ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.reason}</p> : null}
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600" data-testid="mobile-leave-status-hint">
{resolveLeaveStatusHint(item)}
</div>
{item.approvalHistory.length ? (
<div className="mt-2 text-xs text-zinc-500">
最近审批:{workflowActionLabel(item.approvalHistory[0].action)}
{item.approvalHistory[0].comment ? ` · ${item.approvalHistory[0].comment}` : ""}
{" · "}
{formatApprovalHistoryTime(item.approvalHistory[0].createdAt)}
</div>
) : null}
{canEdit || canSubmit ? (
<div className="mobile-two-action-grid mt-4">
@@ -403,7 +420,7 @@ export function MobileLeavePage() {
<section className="mobile-card" data-testid="mobile-leave-records">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
<TabsList className="mt-3 grid h-auto w-full grid-cols-4">
<TabsList className="mt-3 grid h-auto w-full grid-cols-5">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
{item.label}
+48 -11
查看文件
@@ -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() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">
{item.requestNo || item.title || item.id}
{resolvePurchaseDisplayCode(item)}
</div>
<div className="mt-1 truncate text-xs text-zinc-500">{item.title || "未命名采购申请"}</div>
</div>
@@ -928,7 +934,7 @@ export function PurchaseRequestsPage() {
}}
>
<TableCell className="cursor-pointer">
<div className="font-medium text-zinc-900">{item.requestNo || item.title || item.id}</div>
<div className="font-medium text-zinc-900">{resolvePurchaseDisplayCode(item)}</div>
<div className="text-xs text-zinc-500">{item.title || "未命名采购申请"}</div>
</TableCell>
<TableCell>{item.applicant?.name ?? "-"}</TableCell>
@@ -976,8 +982,11 @@ export function PurchaseRequestsPage() {
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">{detail.requestNo ?? "采购草稿"}</h3>
<h3 className="text-lg font-semibold text-zinc-900">{resolvePurchaseDisplayCode(detail)}</h3>
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
<p className="mt-1 text-sm text-zinc-500" data-testid="purchase-status-hint">
{resolvePurchaseStatusHint(detail)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge data-testid="purchase-detail-status" status={detail.status} />
@@ -1042,6 +1051,26 @@ export function PurchaseRequestsPage() {
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detail.approvalHistory.length ? (
<div className="space-y-2" data-testid="purchase-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-zinc-900">
{workflowActionLabel(item.action)} · {item.actorName ?? item.actorId}
</span>
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-zinc-600">{item.comment || "无补充说明"}</div>
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</SectionPanel>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">标题</label>
@@ -1119,14 +1148,19 @@ export function PurchaseRequestsPage() {
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
<div>预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}</div>
<div>预算池:{detail.budgetPool?.name ?? "未匹配"}</div>
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
{!detail.budgetPool ? (
<div className="mt-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
未匹配预算池时仍会按当前规则提交审批;如需强预算校验,请由管理员补充预算配置。
<div
className={`mt-2 rounded-md border px-2 py-1 ${
detail.budgetPool
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
data-testid="purchase-budget-hint"
>
{resolvePurchaseBudgetHint(detail)}
</div>
) : null}
</div>
<div className="space-y-3">
@@ -1355,7 +1389,10 @@ export function PurchaseRequestsPage() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
<p className="text-sm text-zinc-500">用于订单、报价、验收或情况说明;草稿状态可增删,下载会留痕。</p>
</div>
<div className="flex min-w-0 items-center gap-3">
<input
data-testid="purchase-attachment-file-input"
+2 -1
查看文件
@@ -23,6 +23,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
function entityTitle(entityType: string | undefined) {
if (entityType === "leave") {
@@ -302,7 +303,7 @@ export function WorkflowTaskDetailPage() {
) : null}
{detail.purchaseRequest ? (
<>
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
<div>采购单号:{resolvePurchaseDisplayCode(detail.purchaseRequest)}</div>
<div>标题:{detail.purchaseRequest.title || "-"}</div>
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
+40 -1
查看文件
@@ -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();
}
});
@@ -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();
+6 -2
查看文件
@@ -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 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。
@@ -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。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 [] 轻量完善只覆盖主链路体验。
3 LP6-2 请假体验完善 优化请假提交、经理审批、状态查看、退回和驳回说明。 1) 员工能清楚查看天数、日期、原因和状态;2) 经理审批动作清晰;3) 退回/驳回说明可见;4) 移动轻量展示。 流程级验证:员工提交请假 -> 经理通过/驳回/退回 -> 员工查看状态和说明 -> PC/移动均可理解。 playwright|node TODO DONE TODO DONE TODO 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;不接企业微信打卡和排班。
4 LP6-3 采购体验完善 优化采购提交、经理审批、历史查看、附件说明和缺预算池 warning 解释。 1) 员工提交采购不暴露内部错误;2) 缺预算池 warning 可理解;3) 审批历史清晰;4) 不扩预算和 ERP。 流程级验证:员工提交采购 -> 缺预算池返回可解释 warning -> 经理审批 -> 查看历史和状态说明。 playwright|node TODO DONE TODO DONE TODO DONE server/src/routes/purchaseRequests.js|client/src/pages/PurchaseRequestsPage.tsx ["LP6-2"] PS6 热修后的 warning 需要转为用户可理解文案。 已通过 procurement-flow 1 passed;缺预算池展示用户可理解 warning,不扩预算和 ERP。
5 LP6-4 统一审批展示一致性 请假和采购详情页审批流展示与报销一致,自审和 admin-only 禁止审批继续验证。 1) 三类单据审批流视觉和字段一致;2) 自审阻断;3) admin-only 无业务审批按钮;4) 服务端越权拒绝。 流程级验证:报销、请假、采购各提交一单 -> 审批流展示对比 -> 自审和 admin-only 路径验证。 playwright|node TODO DONE TODO DONE TODO DONE server/src/routes/workflow.js|client/src/pages ["LP6-3"] 涉及审批权限需 reviewer 复核。 审批状态与权限语义未改;workflow-quick-approve 通过;permission-direct-access 中请假/采购自审与 admin-only 拒绝专项 1 passed。
6 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 DONE TODO DONE TODO 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。
+81
查看文件
@@ -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,
},
});
+43 -14
查看文件
@@ -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"),