From e79d252b7daa002f12887a5326b68532b10c02b6 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 10 May 2026 10:37:23 +0800 Subject: [PATCH] =?UTF-8?q?A3=20=E6=94=B6=E5=8F=A3=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E5=8F=91=E7=A5=A8=20OCR=20=E4=B8=BB=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/InvoicePoolPage.tsx | 10 +-- .../pages/MobileReimbursementDetailPage.tsx | 4 +- client/src/pages/ReimbursementDetailPage.tsx | 63 +++++++++------- .../pages/invoice-pool/InvoiceBindCard.tsx | 9 ++- .../pages/invoice-pool/InvoiceDetailPanel.tsx | 74 +++++++++++++++---- client/tests/e2e/invoice-ocr-flow.spec.ts | 2 +- ...5-09_20-57-19-mvp-mainline-dev-kickoff.csv | 2 +- 7 files changed, 111 insertions(+), 53 deletions(-) diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index e72c1319..9cb5456d 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -177,7 +177,7 @@ export function InvoicePoolPage() { if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") { void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); if (currentStatus === "available") { - toast.success("票据识别已完成,可继续绑定或验真"); + toast.success("票据识别已完成,可继续添加到业务单据或财务复核"); } else if (currentStatus === "ocr_failed") { toast.error(`票据识别未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`); } @@ -385,9 +385,9 @@ export function InvoicePoolPage() { queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), ]); setError(""); - toast.success("验真已触发"); + toast.success("发票核验已提交"); }, - onError: (err) => handleError(err, "触发验真失败,请稍后重试"), + onError: (err) => handleError(err, "提交发票核验失败,请稍后重试"), }); const retryVerificationMutation = useMutation({ @@ -398,9 +398,9 @@ export function InvoicePoolPage() { queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), ]); setError(""); - toast.success("验真重试已触发"); + toast.success("发票核验已重新提交"); }, - onError: (err) => handleError(err, "重试验真失败,请稍后重试"), + onError: (err) => handleError(err, "重新提交发票核验失败,请稍后重试"), }); const overrideDuplicateMutation = useMutation({ diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 24aeeb13..687ec0b6 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -449,7 +449,7 @@ export function MobileReimbursementDetailPage() { const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => { const message = items - .map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.availabilityMessage ?? item.message}`) + .map((item) => `${item.originalName ?? "未命名发票"}:${item.availabilityMessage ?? item.message}`) .join(";"); setInvoiceActionError(message); setInvoiceActionMessage(""); @@ -940,7 +940,7 @@ export function MobileReimbursementDetailPage() {
{formatCurrency(invoice.totalAmount)}
- {INVOICE_STATUS_LABEL[invoice.status] ?? invoice.status} + {INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"}
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 39acdad4..4d3fda34 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -102,7 +102,7 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) { } function reimbursementStatusLabel(status: ReimbursementStatus) { - return REIMBURSEMENT_STATUS_LABEL[status] ?? status; + return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态"; } function invoiceStatusLabel(status: string) { @@ -118,21 +118,30 @@ function resolveErpSyncStatus( | null, ) { if (!exportState) { - return "未同步"; + return "未导出"; } if (exportState.lastOutboxStatus === "pending") { - return "已入队"; + return "待导出"; } if (exportState.lastOutboxStatus === "processing") { - return "处理中"; + return "导出中"; } if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") { - return "已失败"; + return "导出失败"; } if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) { - return "已成功"; + return "已导出"; } - return "未同步"; + return "未导出"; +} + +function budgetControlModeLabel(controlMode: string | null | undefined) { + const labels: Record = { + block: "超额阻断", + warn: "超额提醒", + manual_review: "人工复核", + }; + return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理"; } export function ReimbursementDetailPage() { @@ -533,7 +542,7 @@ export function ReimbursementDetailPage() { const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => { const message = items - .map((item) => `${item.originalName ?? item.id.slice(0, 8)}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`) + .map((item) => `${item.originalName ?? "未命名发票"}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`) .join(";"); setInvoiceActionError(message); toast.error("部分发票需要处理"); @@ -1356,10 +1365,10 @@ export function ReimbursementDetailPage() { 本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount} ({budgetHint.nextUsagePercent.toFixed(2)}%) -
控制模式:{budgetHint.controlMode}
+
控制方式:{budgetControlModeLabel(budgetHint.controlMode)}
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (

- 当前金额将超出预算上限,提交时会被阻断(block 模式)。 + 当前金额将超出预算上限,提交时会被阻断。

) : budgetHint.exceedsThreshold ? (

@@ -1376,8 +1385,8 @@ export function ReimbursementDetailPage() { {canManageIntegration ? ( } > {!isPaidReimbursement ? (

- 未满足导出条件,仅已付款报销可导出到 ERP。 + 未满足导出条件,仅已付款报销可生成财务导出记录。

) : null} - {erpPreviewQuery.isLoading ?

正在加载 ERP 凭证预览...

: null} + {erpPreviewQuery.isLoading ?

正在加载财务凭证预览...

: null} {erpPreviewQuery.isError ? (

{isApiError(erpPreviewQuery.error) @@ -1409,25 +1418,25 @@ export function ReimbursementDetailPage() { erpPreviewQuery.error.details, erpPreviewQuery.error.message, )}` - : "加载 ERP 凭证预览失败"} + : "加载财务凭证预览失败"}

) : null}
-
同步状态
+
导出状态
{erpSyncStatus}
-
最后同步
+
最近导出
{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}
-
同步队列
+
导出任务
{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}
-
外部 ID
+
外部凭证号
{erpPreviewQuery.data?.exportState.externalId ?? "-"}
@@ -1656,8 +1665,8 @@ export function ReimbursementDetailPage() { - ERP 凭证预览 - 查看凭证内容和最近同步状态。 + 财务凭证预览 + 查看凭证内容和最近导出状态。 {erpPreviewQuery.data ? ( @@ -1674,16 +1683,16 @@ export function ReimbursementDetailPage() {

导出状态

-
同步状态:{erpSyncStatus}
-
外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}
-
队列 ID:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}
-
队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}
+
导出状态:{erpSyncStatus}
+
外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}
+
任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}
+
任务状态:{erpSyncStatus}
最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}
最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}
) : ( -

暂无可预览的 ERP 凭证数据。

+

暂无可预览的财务凭证数据。

)}
diff --git a/client/src/pages/invoice-pool/InvoiceBindCard.tsx b/client/src/pages/invoice-pool/InvoiceBindCard.tsx index ddff7962..bc7b9151 100644 --- a/client/src/pages/invoice-pool/InvoiceBindCard.tsx +++ b/client/src/pages/invoice-pool/InvoiceBindCard.tsx @@ -38,7 +38,12 @@ const TRAVEL_STATUS_LABEL: Record = { function statusLabel(status: string | null | undefined, labels: Record) { if (!status) return "未知状态"; - return labels[status] ?? status; + return labels[status] ?? "未知状态"; +} + +function invoiceOptionLabel(item: InvoicePoolListResponse["items"][number]) { + const invoiceNo = [item.invoiceCode, item.invoiceNumber].filter(Boolean).join("-"); + return invoiceNo || item.originalName || "待识别票据"; } export type InvoiceBindCardProps = { @@ -107,7 +112,7 @@ export function InvoiceBindCard({ {(listQuery.data?.items ?? []).map((item) => ( ))} diff --git a/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx b/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx index 86dbec12..46c5790b 100644 --- a/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx +++ b/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx @@ -54,7 +54,49 @@ function invoiceStatusLabel(status: string) { ocr_failed: "识别失败", duplicate_suspected: "疑似重复", }; - return labels[status] ?? status; + return labels[status] ?? "未知状态"; +} + +function verificationStatusLabel(status: string | null | undefined) { + const labels: Record = { + pending: "核验中", + processing: "核验中", + passed: "核验通过", + success: "核验通过", + succeeded: "核验通过", + failed: "核验未通过", + error: "核验异常", + }; + return status ? labels[status] ?? "待财务复核" : "待财务复核"; +} + +function verificationStatusTone(status: string | null | undefined) { + if (status === "failed" || status === "error") return "text-red-600"; + if (status === "passed" || status === "success" || status === "succeeded") return "text-emerald-600"; + if (status === "pending" || status === "processing") return "text-amber-600"; + return "text-zinc-600"; +} + +function verificationProviderLabel(provider: string | null | undefined) { + const labels: Record = { + mock: "本地核验", + tax: "税务接口", + tencent: "第三方接口", + aliyun: "第三方接口", + manual: "人工复核", + }; + return provider ? labels[provider] ?? "系统核验" : "系统核验"; +} + +function verificationMessage(message: string | null | undefined) { + if (!message) return null; + const normalized = message.toLowerCase(); + if (normalized.includes("invoice verified")) return "核验通过"; + if (normalized.includes("invoice metadata mismatch")) return "票面信息与核验结果不一致"; + if (normalized.includes("mock provider transport error")) return "核验服务暂不可用"; + if (normalized.includes("tax provider is not configured")) return "当前环境未配置自动核验服务"; + if (normalized.includes("verification_disabled")) return "当前未开启自动核验"; + return message; } export function InvoiceDetailPanel({ @@ -130,16 +172,18 @@ export function InvoiceDetailPanel({
文件名:{detail.originalName || "-"}
- 验真状态: - {detail.verification ? `${detail.verification.status} (${detail.verification.provider})` : "未触发"} + 财务核验: + {detail.verification + ? `${verificationStatusLabel(detail.verification.status)} · ${verificationProviderLabel(detail.verification.provider)}` + : "待财务复核"}
{detail.verification?.resultMessage ? ( -
{detail.verification.resultMessage}
+
{verificationMessage(detail.verification.resultMessage)}
) : null} {detail.verification?.errorMessage ? (
error - {detail.verification.errorMessage} + {verificationMessage(detail.verification.errorMessage)}
) : null}
@@ -172,7 +216,7 @@ export function InvoiceDetailPanel({ disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"} onClick={() => triggerVerificationMutation.mutate(detail.id)} > - {triggerVerificationMutation.isPending ? "触发中..." : "触发验真"} + {triggerVerificationMutation.isPending ? "提交中..." : "发起辅助核验"} ) : null} @@ -186,7 +230,7 @@ export function InvoiceDetailPanel({ disabled={retryVerificationMutation.isPending} onClick={() => retryVerificationMutation.mutate(detail.id)} > - {retryVerificationMutation.isPending ? "重试中..." : "重试验真"} + {retryVerificationMutation.isPending ? "重试中..." : "重新核验"} ) : null} @@ -266,29 +310,29 @@ export function InvoiceDetailPanel({ {canVerifyInvoice ? (
-

验真记录

+

辅助核验记录

{(verificationQuery.data?.items ?? []).length ? (
{(verificationQuery.data?.items ?? []).map((record, index) => (
记录 {index + 1} - - {record.status} + + {verificationStatusLabel(record.status)}
- 服务商: {record.provider} | + 来源: {verificationProviderLabel(record.provider)} | 重试次数: {record.attempts} | - 验真时间: {record.requestedAt || "-"} + 核验时间: {record.requestedAt || "-"}
- {record.resultMessage ?
{record.resultMessage}
: null} - {record.errorMessage ?
{record.errorMessage}
: null} + {record.resultMessage ?
{verificationMessage(record.resultMessage)}
: null} + {record.errorMessage ?
{verificationMessage(record.errorMessage)}
: null}
))}
) : ( -

暂无验真记录

+

暂无辅助核验记录

)}
) : null} diff --git a/client/tests/e2e/invoice-ocr-flow.spec.ts b/client/tests/e2e/invoice-ocr-flow.spec.ts index 9a4ce8f2..dbaa8dda 100644 --- a/client/tests/e2e/invoice-ocr-flow.spec.ts +++ b/client/tests/e2e/invoice-ocr-flow.spec.ts @@ -27,7 +27,7 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({ await login(page, EMPLOYEE); const reimbursementId = await createBlankReimbursement(page); - await page.goto(`/reimbursements/${reimbursementId}`); + await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`)); await reimbursementDetail.assertLoaded(); await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click(); diff --git a/issues/2026-05-09_20-57-19-mvp-mainline-dev-kickoff.csv b/issues/2026-05-09_20-57-19-mvp-mainline-dev-kickoff.csv index 7aeab55c..3083e0ef 100644 --- a/issues/2026-05-09_20-57-19-mvp-mainline-dev-kickoff.csv +++ b/issues/2026-05-09_20-57-19-mvp-mainline-dev-kickoff.csv @@ -1,7 +1,7 @@ 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",DONE,DONE,DONE,"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 开发、审查和回归已完成;A2.1 修正详情页 pending 文案;本地 DB 漂移已通过 hyb_oa_regression 隔离库解阻;workflow 后端回归、PC 快捷审批 E2E、移动审批 E2E、client typecheck/build 均通过。" -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。" +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",DONE,DONE,DONE,"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""]","A3 已完成:前端收口报销/票据/OCR文案、隐藏内部码值和 UUID 兜底、财务导出不再面向报销页显示 ERP 术语;未新增 API、未新增迁移、未改变 OCR provider 或状态机。验证通过:prisma validate、node --check reimbursements/invoicePool、regression-dedup、regression-ocr、client typecheck/build、E2E 报销主流程+invoice-ocr-flow,视觉截图 output/playwright/a3-reimbursement-ocr。" 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",DONE,DONE,DONE,"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""]","A4 已完成:企业微信入口测试默认 /mobile;通知 deeplink 限定为 OA 相对路径并补回归;未接企业微信原生审批、飞书或打卡同步;真实企业微信凭据/真机 live smoke 留到 A8,缺配置时 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,不扩大到完整考勤系统。" A6,P1采购合同项目最小可用收口,"只做现有 P1 能力的最小可用检查和缺口修复,不扩展预算、支付、ERP、AI、复杂招采或重型项目管理。","1) 采购/费用可申请并进入 OA 审批;2) 合同台账可登记、查询、记录编号、负责人和归档位置;3) 项目能关联关键事项并展示摘要;4) P2 能力不作为普通员工主路径。","流程级验证:创建采购申请 -> OA 审批 -> 项目页查看关联;登记合同台账 -> 查询合同摘要;项目页查看任务/里程碑/风险/业务关联;命令:cd server && ./scripts/regression-procurement.sh;命令:cd client && npm run test:e2e -- procurement-flow.spec.ts project-workbench.spec.ts;合同台账执行人工或补 E2E。","bash|npm|playwright|manual",TODO,TODO,TODO,"server/src/routes/purchaseRequests.js|server/src/routes/contractsRegistry.js|server/src/routes/projects.js|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/ContractsRegistryPage.tsx|client/src/pages/ProjectsPage.tsx|client/src/pages/ProjectDetailPage.tsx","[""A1"",""A2""]","P1 只收口最小可用;预算/ERP/AI/完整差旅/重型项目管理不进入首轮开发。"