A3 收口报销发票 OCR 主链路
这个提交包含在:
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
<div className="shrink-0 text-right">
|
||||
<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">
|
||||
{INVOICE_STATUS_LABEL[invoice.status] ?? invoice.status}
|
||||
{INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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)}%)
|
||||
</div>
|
||||
<div>控制模式:{budgetHint.controlMode}</div>
|
||||
<div>控制方式:{budgetControlModeLabel(budgetHint.controlMode)}</div>
|
||||
{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">
|
||||
当前金额将超出预算上限,提交时会被阻断(block 模式)。
|
||||
当前金额将超出预算上限,提交时会被阻断。
|
||||
</p>
|
||||
) : budgetHint.exceedsThreshold ? (
|
||||
<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 ? (
|
||||
<SectionPanel
|
||||
title="ERP 同步状态"
|
||||
description="付款后同步到 ERP。"
|
||||
title="财务导出状态"
|
||||
description="付款后可生成财务导出记录。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -1389,18 +1398,18 @@ export function ReimbursementDetailPage() {
|
||||
预览凭证
|
||||
</Button>
|
||||
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
|
||||
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
|
||||
{erpExportMutation.isPending ? "导出中..." : "生成导出"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!isPaidReimbursement ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
未满足导出条件,仅已付款报销可导出到 ERP。
|
||||
未满足导出条件,仅已付款报销可生成财务导出记录。
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(erpPreviewQuery.error)
|
||||
@@ -1409,25 +1418,25 @@ export function ReimbursementDetailPage() {
|
||||
erpPreviewQuery.error.details,
|
||||
erpPreviewQuery.error.message,
|
||||
)}`
|
||||
: "加载 ERP 凭证预览失败"}
|
||||
: "加载财务凭证预览失败"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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="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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -1656,8 +1665,8 @@ export function ReimbursementDetailPage() {
|
||||
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ERP 凭证预览</DialogTitle>
|
||||
<DialogDescription>查看凭证内容和最近同步状态。</DialogDescription>
|
||||
<DialogTitle>财务凭证预览</DialogTitle>
|
||||
<DialogDescription>查看凭证内容和最近导出状态。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{erpPreviewQuery.data ? (
|
||||
@@ -1674,16 +1683,16 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<h4 className="font-medium text-zinc-900">导出状态</h4>
|
||||
<div>同步状态:{erpSyncStatus}</div>
|
||||
<div>外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
||||
<div>队列 ID:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
||||
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
|
||||
<div>导出状态:{erpSyncStatus}</div>
|
||||
<div>外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
||||
<div>任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
||||
<div>任务状态:{erpSyncStatus}</div>
|
||||
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
|
||||
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无可预览的 ERP 凭证数据。</p>
|
||||
<p className="text-sm text-zinc-500">暂无可预览的财务凭证数据。</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -38,7 +38,12 @@ const TRAVEL_STATUS_LABEL: Record<string, string> = {
|
||||
|
||||
function statusLabel(status: string | null | undefined, labels: Record<string, string>) {
|
||||
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({
|
||||
<option value="">选择票据</option>
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -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<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({
|
||||
@@ -130,16 +172,18 @@ export function InvoiceDetailPanel({
|
||||
<div><span className="text-zinc-500">文件名:</span>{detail.originalName || "-"}</div>
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
<div>
|
||||
<span className="text-zinc-500">验真状态:</span>
|
||||
{detail.verification ? `${detail.verification.status} (${detail.verification.provider})` : "未触发"}
|
||||
<span className="text-zinc-500">财务核验:</span>
|
||||
{detail.verification
|
||||
? `${verificationStatusLabel(detail.verification.status)} · ${verificationProviderLabel(detail.verification.provider)}`
|
||||
: "待财务复核"}
|
||||
</div>
|
||||
{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}
|
||||
{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">
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{detail.verification.errorMessage}
|
||||
{verificationMessage(detail.verification.errorMessage)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -172,7 +216,7 @@ export function InvoiceDetailPanel({
|
||||
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
|
||||
onClick={() => triggerVerificationMutation.mutate(detail.id)}
|
||||
>
|
||||
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
|
||||
{triggerVerificationMutation.isPending ? "提交中..." : "发起辅助核验"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -186,7 +230,7 @@ export function InvoiceDetailPanel({
|
||||
disabled={retryVerificationMutation.isPending}
|
||||
onClick={() => retryVerificationMutation.mutate(detail.id)}
|
||||
>
|
||||
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
|
||||
{retryVerificationMutation.isPending ? "重试中..." : "重新核验"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -266,29 +310,29 @@ export function InvoiceDetailPanel({
|
||||
|
||||
{canVerifyInvoice ? (
|
||||
<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 ? (
|
||||
<div className="space-y-3 text-xs">
|
||||
{(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 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-semibold ${record.status === "failed" || record.status === "error" ? "text-red-600" : "text-emerald-600"}`}>
|
||||
{record.status}
|
||||
<span className={`font-semibold ${verificationStatusTone(record.status)}`}>
|
||||
{verificationStatusLabel(record.status)}
|
||||
</span>
|
||||
</div>
|
||||
<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.requestedAt || "-"}</strong>
|
||||
核验时间: <strong className="font-medium text-zinc-800">{record.requestedAt || "-"}</strong>
|
||||
</div>
|
||||
{record.resultMessage ? <div className="mt-1 text-zinc-800 py-1 px-2 bg-zinc-50 rounded italic">{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.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">{verificationMessage(record.errorMessage)}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-zinc-500">暂无验真记录</p>
|
||||
<p className="text-xs text-zinc-500">暂无辅助核验记录</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/完整差旅/重型项目管理不进入首轮开发。"
|
||||
|
||||
|
在新工单中引用
屏蔽一个用户