A3 收口报销发票 OCR 主链路

这个提交包含在:
2026-05-10 10:37:23 +08:00
父节点 9d6abc7d63
当前提交 e79d252b7d
共修改 7 个文件,包含 111 行新增和 53 行删除
+5 -5
查看文件
@@ -177,7 +177,7 @@ export function InvoicePoolPage() {
if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") { if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") {
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
if (currentStatus === "available") { if (currentStatus === "available") {
toast.success("票据识别已完成,可继续绑定或验真"); toast.success("票据识别已完成,可继续添加到业务单据或财务复核");
} else if (currentStatus === "ocr_failed") { } else if (currentStatus === "ocr_failed") {
toast.error(`票据识别未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`); toast.error(`票据识别未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`);
} }
@@ -385,9 +385,9 @@ export function InvoicePoolPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]); ]);
setError(""); setError("");
toast.success("验真已触发"); toast.success("发票核验已提交");
}, },
onError: (err) => handleError(err, "触发验真失败,请稍后重试"), onError: (err) => handleError(err, "提交发票核验失败,请稍后重试"),
}); });
const retryVerificationMutation = useMutation({ const retryVerificationMutation = useMutation({
@@ -398,9 +398,9 @@ export function InvoicePoolPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]); ]);
setError(""); setError("");
toast.success("验真重试已触发"); toast.success("发票核验已重新提交");
}, },
onError: (err) => handleError(err, "重试验真失败,请稍后重试"), onError: (err) => handleError(err, "重新提交发票核验失败,请稍后重试"),
}); });
const overrideDuplicateMutation = useMutation({ const overrideDuplicateMutation = useMutation({
@@ -449,7 +449,7 @@ export function MobileReimbursementDetailPage() {
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => { const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
const message = items 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(";"); .join(";");
setInvoiceActionError(message); setInvoiceActionError(message);
setInvoiceActionMessage(""); setInvoiceActionMessage("");
@@ -940,7 +940,7 @@ export function MobileReimbursementDetailPage() {
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div> <div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600"> <span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{INVOICE_STATUS_LABEL[invoice.status] ?? invoice.status} {INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"}
</span> </span>
</div> </div>
</div> </div>
+36 -27
查看文件
@@ -102,7 +102,7 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
} }
function reimbursementStatusLabel(status: ReimbursementStatus) { function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? status; return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
} }
function invoiceStatusLabel(status: string) { function invoiceStatusLabel(status: string) {
@@ -118,21 +118,30 @@ function resolveErpSyncStatus(
| null, | null,
) { ) {
if (!exportState) { if (!exportState) {
return "未同步"; return "未导出";
} }
if (exportState.lastOutboxStatus === "pending") { if (exportState.lastOutboxStatus === "pending") {
return "已入队"; return "待导出";
} }
if (exportState.lastOutboxStatus === "processing") { if (exportState.lastOutboxStatus === "processing") {
return "处理中"; return "导出中";
} }
if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") { if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") {
return "已失败"; return "导出失败";
} }
if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) { if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) {
return "已成功"; return "已导出";
} }
return "未同步"; return "未导出";
}
function budgetControlModeLabel(controlMode: string | null | undefined) {
const labels: Record<string, string> = {
block: "超额阻断",
warn: "超额提醒",
manual_review: "人工复核",
};
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
} }
export function ReimbursementDetailPage() { 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 handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
const message = items 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(";"); .join(";");
setInvoiceActionError(message); setInvoiceActionError(message);
toast.error("部分发票需要处理"); toast.error("部分发票需要处理");
@@ -1356,10 +1365,10 @@ export function ReimbursementDetailPage() {
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount} 本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%) ({budgetHint.nextUsagePercent.toFixed(2)}%)
</div> </div>
<div>控制模式:{budgetHint.controlMode}</div> <div>控制方式:{budgetControlModeLabel(budgetHint.controlMode)}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? ( {budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600"> <p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。 当前金额将超出预算上限,提交时会被阻断。
</p> </p>
) : budgetHint.exceedsThreshold ? ( ) : budgetHint.exceedsThreshold ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700"> <p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
@@ -1376,8 +1385,8 @@ export function ReimbursementDetailPage() {
{canManageIntegration ? ( {canManageIntegration ? (
<SectionPanel <SectionPanel
title="ERP 同步状态" title="财务导出状态"
description="付款后同步到 ERP。" description="付款后可生成财务导出记录。"
actions={ actions={
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -1389,18 +1398,18 @@ export function ReimbursementDetailPage() {
预览凭证 预览凭证
</Button> </Button>
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}> <Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"} {erpExportMutation.isPending ? "导出中..." : "生成导出"}
</Button> </Button>
</div> </div>
} }
> >
{!isPaidReimbursement ? ( {!isPaidReimbursement ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"> <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
未满足导出条件,仅已付款报销可导出到 ERP。 未满足导出条件,仅已付款报销可生成财务导出记录。
</p> </p>
) : null} ) : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载 ERP 凭证预览...</p> : null} {erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载财务凭证预览...</p> : null}
{erpPreviewQuery.isError ? ( {erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"> <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error) {isApiError(erpPreviewQuery.error)
@@ -1409,25 +1418,25 @@ export function ReimbursementDetailPage() {
erpPreviewQuery.error.details, erpPreviewQuery.error.details,
erpPreviewQuery.error.message, erpPreviewQuery.error.message,
)}` )}`
: "加载 ERP 凭证预览失败"} : "加载财务凭证预览失败"}
</p> </p>
) : null} ) : null}
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4" data-testid="reimbursement-erp-export"> <div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4" data-testid="reimbursement-erp-export">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">同步状态</div> <div className="text-xs text-zinc-500">导出状态</div>
<div className="mt-1 font-medium text-zinc-900">{erpSyncStatus}</div> <div className="mt-1 font-medium text-zinc-900">{erpSyncStatus}</div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">最后同步</div> <div className="text-xs text-zinc-500">最近导出</div>
<div className="mt-1 font-medium text-zinc-900">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div> <div className="mt-1 font-medium text-zinc-900">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">同步队列</div> <div className="text-xs text-zinc-500">导出任务</div>
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div> <div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">外部 ID</div> <div className="text-xs text-zinc-500">外部凭证号</div>
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div> <div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div>
</div> </div>
</div> </div>
@@ -1656,8 +1665,8 @@ export function ReimbursementDetailPage() {
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}> <Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto"> <DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>ERP 凭证预览</DialogTitle> <DialogTitle>财务凭证预览</DialogTitle>
<DialogDescription>查看凭证内容和最近同步状态。</DialogDescription> <DialogDescription>查看凭证内容和最近导出状态。</DialogDescription>
</DialogHeader> </DialogHeader>
{erpPreviewQuery.data ? ( {erpPreviewQuery.data ? (
@@ -1674,16 +1683,16 @@ export function ReimbursementDetailPage() {
</div> </div>
<div className="space-y-2 rounded-lg border border-zinc-200 p-4"> <div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">导出状态</h4> <h4 className="font-medium text-zinc-900">导出状态</h4>
<div>同步状态:{erpSyncStatus}</div> <div>导出状态:{erpSyncStatus}</div>
<div>外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div> <div>外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>队列 ID:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div> <div>任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div> <div>任务状态:{erpSyncStatus}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div> <div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div> <div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm text-zinc-500">暂无可预览的 ERP 凭证数据。</p> <p className="text-sm text-zinc-500">暂无可预览的财务凭证数据。</p>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -38,7 +38,12 @@ const TRAVEL_STATUS_LABEL: Record<string, string> = {
function statusLabel(status: string | null | undefined, labels: Record<string, string>) { function statusLabel(status: string | null | undefined, labels: Record<string, string>) {
if (!status) return "未知状态"; 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 = { export type InvoiceBindCardProps = {
@@ -107,7 +112,7 @@ export function InvoiceBindCard({
<option value="">选择票据</option> <option value="">选择票据</option>
{(listQuery.data?.items ?? []).map((item) => ( {(listQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{`${item.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} (${statusLabel(item.status, INVOICE_STATUS_LABEL)})`} {`${invoiceOptionLabel(item)} (${statusLabel(item.status, INVOICE_STATUS_LABEL)})`}
</option> </option>
))} ))}
</select> </select>
@@ -54,7 +54,49 @@ function invoiceStatusLabel(status: string) {
ocr_failed: "识别失败", ocr_failed: "识别失败",
duplicate_suspected: "疑似重复", duplicate_suspected: "疑似重复",
}; };
return labels[status] ?? status; return labels[status] ?? "未知状态";
}
function verificationStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
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<string, string> = {
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({ export function InvoiceDetailPanel({
@@ -130,16 +172,18 @@ export function InvoiceDetailPanel({
<div><span className="text-zinc-500">文件名:</span>{detail.originalName || "-"}</div> <div><span className="text-zinc-500">文件名:</span>{detail.originalName || "-"}</div>
<div className="col-span-2 flex flex-col gap-1"> <div className="col-span-2 flex flex-col gap-1">
<div> <div>
<span className="text-zinc-500">验真状态:</span> <span className="text-zinc-500">财务核验:</span>
{detail.verification ? `${detail.verification.status} (${detail.verification.provider})` : "未触发"} {detail.verification
? `${verificationStatusLabel(detail.verification.status)} · ${verificationProviderLabel(detail.verification.provider)}`
: "待财务复核"}
</div> </div>
{detail.verification?.resultMessage ? ( {detail.verification?.resultMessage ? (
<div className="text-zinc-900 bg-zinc-50 px-2 py-1.5 rounded-md mt-1 border border-zinc-100">{detail.verification.resultMessage}</div> <div className="text-zinc-900 bg-zinc-50 px-2 py-1.5 rounded-md mt-1 border border-zinc-100">{verificationMessage(detail.verification.resultMessage)}</div>
) : null} ) : null}
{detail.verification?.errorMessage ? ( {detail.verification?.errorMessage ? (
<div className="text-red-700 bg-red-50 px-2 py-1.5 rounded-md mt-1 border border-red-100 flex items-center gap-1.5"> <div className="text-red-700 bg-red-50 px-2 py-1.5 rounded-md mt-1 border border-red-100 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px]">error</span> <span className="material-symbols-outlined text-[16px]">error</span>
{detail.verification.errorMessage} {verificationMessage(detail.verification.errorMessage)}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -172,7 +216,7 @@ export function InvoiceDetailPanel({
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"} disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
onClick={() => triggerVerificationMutation.mutate(detail.id)} onClick={() => triggerVerificationMutation.mutate(detail.id)}
> >
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"} {triggerVerificationMutation.isPending ? "提交中..." : "发起辅助核验"}
</Button> </Button>
) : null} ) : null}
@@ -186,7 +230,7 @@ export function InvoiceDetailPanel({
disabled={retryVerificationMutation.isPending} disabled={retryVerificationMutation.isPending}
onClick={() => retryVerificationMutation.mutate(detail.id)} onClick={() => retryVerificationMutation.mutate(detail.id)}
> >
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"} {retryVerificationMutation.isPending ? "重试中..." : "重新核验"}
</Button> </Button>
) : null} ) : null}
@@ -266,29 +310,29 @@ export function InvoiceDetailPanel({
{canVerifyInvoice ? ( {canVerifyInvoice ? (
<div className="mt-4 rounded-lg bg-zinc-50 border border-zinc-200 p-4" data-testid="invoice-verification-records"> <div className="mt-4 rounded-lg bg-zinc-50 border border-zinc-200 p-4" data-testid="invoice-verification-records">
<p className="mb-3 text-sm font-semibold text-zinc-900 border-b border-zinc-200/60 pb-2">验真记录</p> <p className="mb-3 text-sm font-semibold text-zinc-900 border-b border-zinc-200/60 pb-2">辅助核验记录</p>
{(verificationQuery.data?.items ?? []).length ? ( {(verificationQuery.data?.items ?? []).length ? (
<div className="space-y-3 text-xs"> <div className="space-y-3 text-xs">
{(verificationQuery.data?.items ?? []).map((record, index) => ( {(verificationQuery.data?.items ?? []).map((record, index) => (
<div key={record.id} className="rounded-md border border-zinc-200 bg-white px-3 py-2 shadow-sm"> <div key={record.id} className="rounded-md border border-zinc-200 bg-white px-3 py-2 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 mb-1"> <div className="flex flex-wrap items-center justify-between gap-2 mb-1">
<span className="font-medium text-zinc-700">记录 {index + 1}</span> <span className="font-medium text-zinc-700">记录 {index + 1}</span>
<span className={`font-semibold ${record.status === "failed" || record.status === "error" ? "text-red-600" : "text-emerald-600"}`}> <span className={`font-semibold ${verificationStatusTone(record.status)}`}>
{record.status} {verificationStatusLabel(record.status)}
</span> </span>
</div> </div>
<div className="text-zinc-600 break-words"> <div className="text-zinc-600 break-words">
服务商: <strong className="font-medium text-zinc-800">{record.provider}</strong> | 来源: <strong className="font-medium text-zinc-800">{verificationProviderLabel(record.provider)}</strong> |
重试次数: <strong className="font-medium text-zinc-800">{record.attempts}</strong> | 重试次数: <strong className="font-medium text-zinc-800">{record.attempts}</strong> |
验真时间: <strong className="font-medium text-zinc-800">{record.requestedAt || "-"}</strong> 核验时间: <strong className="font-medium text-zinc-800">{record.requestedAt || "-"}</strong>
</div> </div>
{record.resultMessage ? <div className="mt-1 text-zinc-800 py-1 px-2 bg-zinc-50 rounded italic">{record.resultMessage}</div> : null} {record.resultMessage ? <div className="mt-1 text-zinc-800 py-1 px-2 bg-zinc-50 rounded italic">{verificationMessage(record.resultMessage)}</div> : null}
{record.errorMessage ? <div className="mt-1 text-red-600 font-medium py-1 px-2 border border-red-100 bg-red-50 rounded">{record.errorMessage}</div> : null} {record.errorMessage ? <div className="mt-1 text-red-600 font-medium py-1 px-2 border border-red-100 bg-red-50 rounded">{verificationMessage(record.errorMessage)}</div> : null}
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-zinc-500">暂无验真记录</p> <p className="text-xs text-zinc-500">暂无辅助核验记录</p>
)} )}
</div> </div>
) : null} ) : null}
+1 -1
查看文件
@@ -27,7 +27,7 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
const reimbursementId = await createBlankReimbursement(page); const reimbursementId = await createBlankReimbursement(page);
await page.goto(`/reimbursements/${reimbursementId}`); await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`));
await reimbursementDetail.assertLoaded(); await reimbursementDetail.assertLoaded();
await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click(); await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click();
@@ -1,7 +1,7 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
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 并写回滚策略。" 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 均通过。" 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。" 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,不扩大到完整考勤系统。" 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/完整差旅/重型项目管理不进入首轮开发。" 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/完整差旅/重型项目管理不进入首轮开发。"
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 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 均通过。
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 DONE TODO DONE TODO 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"] 涉及报销和发票状态;默认不改 OCR provider 和数据库结构,若需 schema/API 变更另拆 Issue。 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。
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 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。
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,不扩大到完整考勤系统。
7 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/完整差旅/重型项目管理不进入首轮开发。