CASHV 增加付款批次银行回单附件

这个提交包含在:
2026-05-17 09:35:37 +08:00
父节点 94d2cc8bf9
当前提交 ab80d481cb
共修改 11 个文件,包含 891 行新增和 16 行删除
+53 -1
查看文件
@@ -1,4 +1,4 @@
import { apiRequestData } from "./http";
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
import type { RiskFlag } from "./feeControl";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
@@ -58,6 +58,20 @@ export type PaymentBatch = {
items: PaymentBatchItem[];
};
export type PaymentBatchReceiptAttachment = {
id: string;
originalName: string;
mimeType: string;
fileSize: number;
fileSha256: string;
uploadedBy: {
id: string;
employeeNo: string;
name: string;
} | null;
createdAt: string | null;
};
export type PaymentBatchListResponse = { items: PaymentBatch[] };
export async function fetchPaymentBatches(query: { entityType?: PaymentBatchEntityType; status?: PaymentBatchStatus } = {}) {
@@ -120,3 +134,41 @@ export async function cancelPaymentBatch(batchId: string) {
body: JSON.stringify({}),
});
}
export async function fetchPaymentBatchReceiptAttachments(batchId: string) {
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
`/api/payment-batches/${batchId}/receipt-attachments`,
);
}
export async function uploadPaymentBatchReceiptAttachments(batchId: string, files: File[]) {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
`/api/payment-batches/${batchId}/receipt-attachments`,
{
method: "POST",
body: formData,
},
);
}
export function paymentBatchReceiptAttachmentFileUrl(batchId: string, attachmentId: string) {
return `/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/file`;
}
export async function downloadPaymentBatchReceiptAttachment(
batchId: string,
attachmentId: string,
): Promise<ApiBlobResponse> {
return apiRequestBlob(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId));
}
export async function deletePaymentBatchReceiptAttachment(batchId: string, attachmentId: string) {
return apiRequestData<{ attachmentId: string; outcome: "deleted" }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}`,
{ method: "DELETE" },
);
}
+189 -5
查看文件
@@ -1,14 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
deletePaymentBatchReceiptAttachment,
downloadPaymentBatchReceiptAttachment,
executePaymentBatch,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
type PaymentBatch,
type PaymentBatchReceiptAttachment,
type PaymentBatchEntityType,
type PaymentChannel,
} from "../api/paymentBatches";
@@ -22,6 +27,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { downloadBlob } from "../lib/download";
import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
@@ -41,6 +47,18 @@ function entityTypeLabel(value: string) {
return value || "-";
}
function formatFileSize(value: number) {
if (!Number.isFinite(value) || value <= 0) return "-";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function formatDateTime(value: string | null | undefined) {
if (!value) return "-";
return new Date(value).toLocaleString();
}
function resolveError(error: unknown) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
@@ -61,6 +79,8 @@ export function PaymentBatchesPage() {
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
const [voucherPrefix, setVoucherPrefix] = useState("");
const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
const [error, setError] = useState("");
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment" | "cancel";
@@ -79,6 +99,12 @@ export function PaymentBatchesPage() {
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
}, [listQuery.data?.items, selectedBatchId]);
const receiptAttachmentsQuery = useQuery({
queryKey: ["payment-batch-receipts", selectedBatch?.id],
enabled: Boolean(selectedBatch?.id),
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
});
useEffect(() => {
if (!selectedBatch) {
return;
@@ -91,7 +117,14 @@ export function PaymentBatchesPage() {
setSearchParams(next, { replace: true });
}, [searchParams, selectedBatch, selectedBatchId, setSearchParams]);
const canEditSelectedBatch = canManageBatch && selectedBatch?.status === "draft";
const canEditSelectedBatch =
canManageBatch &&
!!selectedBatch &&
["draft", "reviewed", "failed"].includes(selectedBatch.status);
const canEditReceiptAttachments =
canManageBatch &&
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
@@ -111,6 +144,7 @@ export function PaymentBatchesPage() {
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-receipts"] });
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
};
@@ -204,6 +238,69 @@ export function PaymentBatchesPage() {
},
});
const uploadReceiptMutation = useMutation({
mutationFn: async () => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
if (!selectedReceiptFiles.length) {
throw new Error("请选择银行回单附件");
}
return uploadPaymentBatchReceiptAttachments(selectedBatch.id, selectedReceiptFiles);
},
onSuccess: async () => {
setError("");
setSelectedReceiptFiles([]);
if (receiptInputRef.current) {
receiptInputRef.current.value = "";
}
await refresh();
toast.success("银行回单附件已上传");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const downloadReceiptMutation = useMutation({
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
return { response, attachment };
},
onSuccess: ({ response, attachment }) => {
downloadBlob(response.blob, response.filename ?? attachment.originalName);
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const deleteReceiptMutation = useMutation({
mutationFn: async (attachmentId: string) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
return deletePaymentBatchReceiptAttachment(selectedBatch.id, attachmentId);
},
onSuccess: async () => {
setError("");
await refresh();
toast.success("银行回单附件已删除");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
return (
<ListWorkspace>
<WorkspaceHeader title="支付批次" subtitle="财务批量付款、复核与执行;高风险批次达到 5 万元后需完成二次复核。" />
@@ -390,6 +487,93 @@ export function PaymentBatchesPage() {
</div>
</div>
<div className="card-std space-y-4 p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3>
<p className="mt-1 text-sm text-zinc-500">先在网银完成对公付款,再上传银行回单并执行 OA 已付款标记。</p>
</div>
{canManageBatch ? (
<div className="flex flex-wrap items-center gap-2">
<input
ref={receiptInputRef}
type="file"
className="hidden"
accept=".pdf,image/jpeg,image/png,image/webp"
multiple
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending}
onChange={(event) => setSelectedReceiptFiles(Array.from(event.target.files ?? []))}
/>
<Button
type="button"
variant="secondary"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
选择回单
</Button>
<Button
type="button"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending || selectedReceiptFiles.length === 0}
onClick={() => uploadReceiptMutation.mutate()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传"}
</Button>
</div>
) : null}
</div>
{selectedReceiptFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
待上传:{selectedReceiptFiles.map((file) => file.name).join("、")}
</div>
) : null}
{receiptAttachmentsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(receiptAttachmentsQuery.error)}
</div>
) : null}
<div className="space-y-2">
{(receiptAttachmentsQuery.data?.items ?? []).map((attachment) => (
<div key={attachment.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDateTime(attachment.createdAt)}
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="secondary"
disabled={downloadReceiptMutation.isPending}
onClick={() => downloadReceiptMutation.mutate(attachment)}
>
下载
</Button>
{canManageBatch ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditReceiptAttachments || deleteReceiptMutation.isPending}
onClick={() => deleteReceiptMutation.mutate(attachment.id)}
>
删除
</Button>
) : null}
</div>
</div>
))}
{!receiptAttachmentsQuery.isLoading && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单附件;默认银行渠道执行前需要上传至少一份回单。
</div>
) : null}
</div>
</div>
<div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
<div className="space-y-4">
@@ -406,19 +590,19 @@ export function PaymentBatchesPage() {
<Input
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号"
disabled={!canEditSelectedBatch || item.status !== "pending"}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<Input
type="date"
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || item.status !== "pending"}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
/>
<select
className="select-std"
defaultValue={item.paymentChannelOverride ?? ""}
disabled={!canEditSelectedBatch || item.status !== "pending"}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}
>
<option value="">默认渠道</option>
@@ -145,6 +145,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
await createCard.getByPlaceholder("用逗号或换行分隔 reimbursement / travel_order ID").fill(reimbursementId);
await createCard.locator('input[type="date"]').fill(paymentDate);
await createCard.locator("select").nth(1).selectOption("bank");
await createCard.getByPlaceholder("例如 PBX").fill("R78PB");
await createCard.getByRole("button", { name: "创建批次" }).click();
@@ -181,6 +182,23 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
const executedBatchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last();
await expect(executedBatchDetailCard).toBeVisible();
await expect(executedBatchDetailCard).toContainText("复核进度:1/1");
const receiptCard = page.locator(".card-std").filter({ hasText: "银行回单附件" }).first();
await expect(receiptCard).toBeVisible();
await receiptCard.locator('input[type="file"]').setInputFiles({
name: "r78-bank-receipt.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
response.ok();
});
await receiptCard.getByRole("button", { name: "上传" }).click();
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
const executeBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
@@ -213,6 +231,16 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
paymentAudit.items.some((item) => item.targetId === reimbursementId && item.extra?.idempotent === false),
"付款动作应写入报销付款审计",
).toBe(true);
const receiptAudit = await apiGetData<{
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
}>(
adminApi.api,
"/api/audit-logs?action=PAYMENT_BATCH_RECEIPT_UPLOADED&page=1&pageSize=20",
);
expect(
receiptAudit.items.some((item) => item.targetId === batchId && Array.isArray(item.extra?.attachmentIds)),
"银行回单上传应写入付款批次审计",
).toBe(true);
await page.goto("/ap-operations");
await expect(page.getByRole("tab", { name: /财务台账/ })).toBeVisible();
+3
查看文件
@@ -27,6 +27,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
- 2026-05-16 P2-FIN-LIVE-DECISION 已完成公网付款批次启用窗口决策:live smoke/PDF OCR readiness 通过,付款批次公网关闭态只读门禁通过;初始结论为 `HOLD_FOR_WINDOW`,随后用户批准立即短窗口、现有 live smoke 测试账号和新建测试报销,进入 `P2-FIN-LIVE`。
- 2026-05-17 P2-FIN-LIVE 已按用户批准完成公网付款批次短窗口受控测试:使用现有 live smoke 测试账号新建专用测试报销,临时打开 `payment_batch_v1`,完成付款批次创建、复核、执行、AP 导出和审计核验后强制回到 `flag_force_off`;未部署,不接真实支付,不写 ERP 实时凭证,不开放图片 OCR。
- 2026-05-17 PRELAUNCH 已按“系统尚未正式使用”的新前提完成正式启用前公网预回归:公网 `/ready`、public bundle、live smoke、PDF live OCR、远端 Caddy/compose/migration 只读基线、隔离核心业务流、付款批次公网 PRELAUNCH 窗口、公网移动矩阵和企业微信 UA/渲染矩阵均形成证据;公网测试数据均带 `PRELAUNCH` marker。最终 `payment_batch_v1=flag_force_off`,未部署、不接真实支付、不写 ERP 实时凭证、不开放图片 OCR。浏览器全链路 `all` 中报销 PDF 因样票重复记可解释 `SKIP`,采购分流程功能报告通过但手工 live spec 存在长等待/退出码稳定性缺口,后续作为 QA 自动化维护项。
- 2026-05-17 CASHIER-VOUCHER-V1 已按“无 ERP/独立财务系统、只有代账”的新业务前提完成正式启用前财务闭环补强:新增付款批次银行回单附件、复核后补凭证、银行渠道执行前回单阻断和 PC 付款批次详情操作区;业务口径为 `wangzhiling` 出纳执行、`wangshunli` 管理员复核,后续正式流程是 OA 内审批/台账/付款批次 + 银行对公付款回单 + 月度代账交接。本轮未部署,公网 `payment_batch_v1` 状态未改变,不接真实支付、不写 ERP、不开放图片 OCR、不导入银行流水;下一步需另建 `LAUNCH-DEPLOY-FINANCE`、`FIN-PROD-ENABLE` 和 `ACCOUNTING-HANDOFF-V1` 合同。
## 关键证据索引
@@ -61,6 +62,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
| P2-FIN-LIVE-DECISION | 已收口:公网付款批次启用窗口决策完成,初始结论 `HOLD_FOR_WINDOW`;readiness 和 flag-off 证据通过,后续由用户批准进入 P2-FIN-LIVE 短窗口 | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` |
| P2-FIN-LIVE | 已收口:公网短窗口受控测试 PASS;专用测试报销 `BX-20260517-0022` 付款闭环到 paid,票据 archived,AP 台账 paid,导出和审计核验通过;公网移动矩阵 6 passed,企业微信渲染矩阵 8 passed;最终 `payment_batch_v1=flag_force_off` | `issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv`、`artifacts/qa-flow/p2-fin-live-20260517-001022/summary.json`、`artifacts/qa-flow/p2-fin-live-20260517-001022/live-result.json` |
| PRELAUNCH | 已收口:正式启用前公网预回归完成;公网 readiness/PDF live OCR、远端只读基线、隔离核心业务流、PRELAUNCH 付款批次窗口、公网移动和企业微信渲染矩阵通过;最终 `payment_batch_v1=flag_force_off` | `issues/2026-05-17_00-46-49-prelaunch-production-readiness.csv`、`artifacts/qa-flow/p2-fin-live-20260517-014144/live-result.json`、`artifacts/qa-flow/p2-safe-regression-20260517-014436/summary.json`、`artifacts/prelaunch/browser-business/report.md`、`artifacts/prelaunch/browser-business-purchase-rerun/report.md` |
| CASHIER-VOUCHER-V1 | 已收口:正式启用前财务闭环与银行回单附件完成;后端 focused 回归 12 tests passed,前端 typecheck/build 通过,本地隔离 reimbursement 业务域 10 passed/1 skipped,governance 业务域 9 passed;未部署,公网 `payment_batch_v1` 未改变 | `issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv`、`artifacts/qa-flow/business-flow-20260517-091904/summary.txt`、`artifacts/qa-flow/business-flow-20260517-092824/summary.txt` |
## 当前门禁
@@ -85,6 +87,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
| P2-FIN-LIVE-DECISION | 已完成,未部署 | `check-live-smoke-env --strict` 通过,`check-payment-batch-flag-off --json` 通过,财务/员工/admin 对付款批次公网只读访问均为 409 `payment_batch_v1_disabled / flag_force_off`;初始 `HOLD_FOR_WINDOW`,后续用户批准立即短窗口并由 P2-FIN-LIVE 承接 |
| P2-FIN-LIVE 公网短窗口 | 通过,未部署,最终关闭 | `run-p2-fin-live-window.sh` PASS:live-env strict PASS,pre flag-off PASS,公网付款批次 live spec 1 passed,移动矩阵 6 passed,企业微信渲染矩阵 8 passed,final flag-off check PASS;测试中只临时开启 `payment_batch_v1`,未开启 `erp_sync`,未接真实支付,最终恢复 409 `payment_batch_v1_disabled / flag_force_off` |
| PRELAUNCH 正式启用前预回归 | 通过,含 QA 稳定性观察项,未部署 | 公网 live readiness PASS,远端 Caddy active、compose db/server Up、Prisma migration up to date;脱敏数据清点 activeEmployees=12、activeWecomBindings=5、pendingWorkflowTasks=7;`run-p2-fin-live-window.sh` PRELAUNCH PASS,测试报销 `BX-20260517-0024` 付款闭环 paid、票据 archived、AP 台账 paid;`run-p2-safe-regression.sh` PASS。浏览器 `all` 长链路中 PDF 样票重复和采购超时按证据分流:采购分流程报告 PASS,本地隔离 procurement PASS,记录为手工 live spec 稳定性维护项,不作为业务 P0/P1 阻断。 |
| CASHIER-VOUCHER-V1 财务闭环与银行回单附件 | 通过,未部署 | 新增付款批次回单附件模型/API/PC 操作区,银行渠道执行付款前必须上传回单,复核后仍可补凭证号/付款日期/付款渠道;`prisma validate`、`node --check`、后端 focused 单测、前端 typecheck/build、隔离 reimbursement 业务流和 governance 权限直连业务流均通过。正式启用前仍需另行部署、设置 `payment_batch_v1=force_on` 并完成代账交接 SOP。 |
## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进
+4 -3
查看文件
@@ -48,6 +48,7 @@
| `P2-FIN-LIVE-DECISION` | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | `EVIDENCE` | 公网付款批次启用窗口决策已收口;readiness 和 flag-off 只读门禁通过,初始结论 `HOLD_FOR_WINDOW`,随后用户批准进入 `P2-FIN-LIVE` |
| `P2-FIN-LIVE` | `issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv` | `EVIDENCE` | 公网付款批次短窗口受控测试已收口;测试报销、付款批次、AP 导出和审计核验通过,最终 `payment_batch_v1=flag_force_off`,未部署 |
| `PRELAUNCH` | `issues/2026-05-17_00-46-49-prelaunch-production-readiness.csv` | `EVIDENCE` | 正式启用前公网预回归已收口;公网 readiness、远端只读基线、隔离核心业务流、付款批次 PRELAUNCH 窗口、移动/企业微信矩阵和 Go/No-Go 摘要完成;最终 `payment_batch_v1=flag_force_off`,未部署 |
| `CASHIER-VOUCHER-V1` | `issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv` | `EVIDENCE` | 正式启用前财务闭环与银行回单附件已收口;新增付款批次银行回单附件、复核后补凭证和银行渠道执行前回单阻断;未部署,公网 `payment_batch_v1` 状态未改变 |
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
@@ -55,8 +56,8 @@
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 |
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 |
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
@@ -161,4 +162,4 @@
- `ME6-3` 代表性企业微信真机门禁已关闭。
- MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同和 PRELAUNCH 正式启用前公网预回归均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;P2-FIN-LIVE 和 PRELAUNCH 均验证付款批次公网短窗口并回到 `payment_batch_v1=flag_force_off`;P2 功能开发、部署、付款批次常开和正式启用公告必须另建或延续对应连续合同。
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归和 CASHIER-VOUCHER-V1 财务闭环与银行回单附件均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;P2-FIN-LIVE 和 PRELAUNCH 均验证付款批次公网短窗口并回到 `payment_batch_v1=flag_force_off`;CASHIER-VOUCHER-V1 代码尚未部署,后续部署最新代码、正式常开付款批次和代账交接 SOP 必须另建或延续对应连续合同。
+3 -1
查看文件
@@ -20,7 +20,7 @@
## 最近事实源链
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH。
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1。
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
@@ -42,6 +42,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
- P2-FIN-LIVE-DECISION 是 P2AF 后付款批次公网启用窗口决策合同:公网 readiness 和 flag-off 只读门禁通过,初始结论为 `HOLD_FOR_WINDOW`;随后用户批准立即短窗口、现有 live smoke 测试账号和新建测试报销,已进入并完成 `P2-FIN-LIVE`。
- P2-FIN-LIVE 已按用户批准完成公网付款批次短窗口受控测试:使用 live smoke 测试账号新建专用测试报销,临时打开 `payment_batch_v1`,完成创建/复核/执行、AP 导出和审计核验后强制回到 `flag_force_off`;不部署、不接真实支付、不写 ERP 实时凭证,不开放图片 OCR。
- PRELAUNCH 是未正式使用前的正式启用准备合同:公网作为准生产预启用环境承载带 `PRELAUNCH` marker 的测试数据和自动化回归;公网 readiness、远端只读基线、隔离核心业务流、付款批次 PRELAUNCH 窗口、移动/企业微信自动化和 Go/No-Go 摘要已完成。最终 `payment_batch_v1=flag_force_off`,未部署、不接真实支付、不写 ERP 实时凭证、不开放图片 OCR;采购公网手工 live spec 长等待/退出码问题作为 QA 自动化稳定性维护项。
- CASHIER-VOUCHER-V1 是正式启用前财务闭环与代账交接第一轮实现合同:基于“无 ERP/独立财务系统、只有代账”的业务事实,将付款闭环固定为 OA 内审批/台账/付款批次 + 银行对公付款回单附件 + 月度代账交接;已新增批次级银行回单附件模型/API/PC 操作区、复核后补凭证和银行渠道执行前回单阻断,未部署,公网 `payment_batch_v1` 状态未改变,不接真实支付、不写 ERP、不开放图片 OCR、不导入银行流水。
## 当前活跃治理流
@@ -70,6 +71,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| `P2-FIN-LIVE-DECISION` | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | 已收口:公网启用窗口决策完成,初始结论 `HOLD_FOR_WINDOW`,后续用户批准并进入 `P2-FIN-LIVE` | 公网 readiness、付款批次 flag-off 只读门禁、窗口/参与人/回滚联系人/测试数据前置、后续 P2-FIN-LIVE 启动条件 | 不在决策合同中打开公网 `payment_batch_v1`、不写付款批次、不部署、不接真实支付、不写 ERP 实时凭证 |
| `P2-FIN-LIVE` | `issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv` | 已收口:公网短窗口付款批次受控测试通过,最终 `payment_batch_v1=flag_force_off` | 公网测试报销、PDF 样票绑定、审批到 approved、付款批次创建/复核/执行、AP 导出审计、权限边界、移动/企业微信自动化 | 不保持公网开启、不部署、不接真实支付、不写 ERP 实时凭证、不开放图片 OCR |
| `PRELAUNCH` | `issues/2026-05-17_00-46-49-prelaunch-production-readiness.csv` | 已收口:正式启用前公网预回归完成,最终 `payment_batch_v1=flag_force_off` | 公网环境基线、远端只读状态、PRELAUNCH 测试数据、核心业务自动化、付款批次公网窗口、移动/企业微信矩阵、启用范围和 Go/No-Go 摘要 | 不部署、不执行生产恢复、不重置数据库、不打印远端 env、不接真实支付、不写 ERP 实时凭证、不开放图片 OCR |
| `CASHIER-VOUCHER-V1` | `issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv` | 已收口:正式启用前财务闭环与银行回单附件完成,未部署 | 付款批次银行回单附件、复核后补凭证、银行渠道执行前回单阻断、管理员复核审计、出纳执行、代账交接前置 | 不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水、不部署 |
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
@@ -0,0 +1,9 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
"CASHV-0","CASHIER-VOUCHER-V1 合同建档","建立正式启用前财务闭环与代账交接第一轮合同;锁定银行回单附件、管理员复核、出纳执行、ERP 冻结。","1) CSV 校验通过;2) 本轮不接真实支付接口、不接 ERP、不开放图片 OCR、不导入银行流水、不重置数据库;3) wangzhiling 作为出纳执行,wangshunli 作为管理员复核;4) payment_batch_v1 仍通过 feature flag 控制;5) 后续部署、正式常开和代账交接另建合同。","受影响流程:PRELAUNCH 收口 -> CASHIER-VOUCHER-V1 实现准备 -> 后续正式启用前财务闭环;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv;人工核对本轮边界。","python3|manual","DONE","DONE","DONE","issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv","[]","完成:CSV 初始校验通过;本轮边界固定为 OA 内付款批次 + 银行回单附件 + 代账交接前置,不接真实支付/ERP,不开放图片 OCR;后续部署、正式常开和代账交接另建合同。"
"CASHV-1","现状审计与实现边界确认","核对付款批次状态机、AP 导出、报销付款字段、附件存储和权限边界,确认最小改动范围。","1) 确认 PaymentBatch/PaymentBatchItem 当前状态机和权限;2) 确认报销付款字段可承载凭证号/日期/渠道;3) 确认现有 storage 和附件模式可复用;4) 确认 AP 导出不打印隐私或回单原件;5) 审计结论写入 Notes。","受影响流程:付款批次创建/复核/执行 -> 报销 paid -> AP 导出 -> 审计;验证步骤:rg -n 'PaymentBatch|payment_voucher_no|EXPORT_AP_OPERATIONS|storage_path|risk.review.manage|payment.batch.manage' server/src server/prisma client/src docs issues。","rg|manual","DONE","DONE","DONE","server/src/routes/paymentBatches.js|server/src/routes/exports.js|server/prisma/schema.prisma|client/src/pages/PaymentBatchesPage.tsx","[""CASHV-0""]","完成:现状确认 PaymentBatch 已有 maker-checker、payment.batch.manage/risk.review.manage、凭证号/付款日期/付款渠道、AP 导出和审计;storage/reimbursement attachment 模式可复用;AP 导出不输出回单原件。"
"CASHV-2","后端回单附件模型与 API","新增付款批次银行回单附件表与 API;复用现有 storage、上传大小限制和附件 MIME allowlist。","1) 新增 payment_batch_receipt_attachments Prisma 模型和 migration;2) GET/POST/GET file/DELETE 四个 API 受 payment_batch_v1 和权限保护;3) 支持 PDF/JPG/PNG/WEBP,遵守 maxUploadSizeBytes;4) 输出只含附件元数据,不直接输出 storage_path;5) 上传/下载/删除写审计。","受影响流程:出纳或管理员在付款批次详情上传银行回单 -> 查看附件列表 -> 下载回单 -> 删除未执行批次回单;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/routes/paymentBatches.js;新增或扩展 paymentBatches 单测覆盖上传/下载/删除/MIME/大小/权限。","npm|node|vitest","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/20260517090437_cashier_payment_receipts/migration.sql|server/src/routes/paymentBatches.js|server/tests/unit/routes/paymentBatches.reimbursement.test.js","[""CASHV-1""]","完成:新增 PaymentBatchReceiptAttachment 模型、migration 和四个 API;元数据不暴露 storage_path;审计动作 PAYMENT_BATCH_RECEIPT_UPLOADED/DOWNLOADED/DELETED;node --check 和 prisma validate 通过。"
"CASHV-3","付款凭证填写与执行硬化","调整付款批次 item 更新规则,允许出纳在批次 reviewed/failed 且 item 未 executed 前补填凭证号、付款日期和付款渠道;银行渠道执行前必须有回单附件。","1) draft/reviewed/failed 批次的 pending/failed item 可补凭证字段;2) executed/cancelled 批次不可改;3) bank 渠道执行前至少存在一份回单附件;4) 非 bank 渠道不强制回单;5) 错误返回可解释 409。","受影响流程:复核后网银付款 -> 补凭证 -> 上传回单 -> 执行 OA 已付款;验证步骤:cd server && npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js;核对 invalid 状态和无回单阻断。","npm|vitest","DONE","DONE","DONE","server/src/routes/paymentBatches.js|server/tests/unit/routes/paymentBatches.reimbursement.test.js","[""CASHV-2""]","完成:PATCH item 支持 reviewed/failed 未执行项补凭证;bank 渠道执行前无回单返回 409 receiptAttachments=bank_receipt_required;paymentBatches 单测 7 passed。"
"CASHV-4","前端付款批次回单附件体验","在付款批次详情页增加银行回单附件上传、查看、下载、删除;文案明确先网银付款,再上传回单并执行 OA 已付款标记。","1) 批次详情显示银行回单附件区;2) 出纳/管理员可上传 PDF/JPG/PNG/WEBP;3) 可下载附件且不暴露 storage_path;4) 批次执行后不允许删除回单;5) reviewed/failed 批次仍可编辑未执行 item 的凭证字段。","受影响流程:付款批次详情 PC 财务操作;验证步骤:cd client && npm run typecheck && npm run build;必要时补付款批次 E2E 断言。","npm|playwright","DONE","DONE","DONE","client/src/api/paymentBatches.ts|client/src/pages/PaymentBatchesPage.tsx|client/tests/e2e/reimbursement-payment-batch-flow.spec.ts","[""CASHV-3""]","完成:PaymentBatchesPage 增加银行回单附件区,支持选择/上传/下载/删除;reviewed/failed 未执行项可补凭证;client typecheck/build 通过;E2E 已上传 r78-bank-receipt.pdf 后执行付款。"
"CASHV-5","权限审计与管理员复核口径","按用户确认,wangzhiling 出纳执行,wangshunli 管理员复核;员工/审批人不可访问付款批次和回单附件。","1) admin wildcard 可作为复核人但 maker-checker 仍阻止创建人自审;2) employee/manager 无付款批次回单访问权限;3) 上传/下载/删除/复核/执行/导出审计可查;4) 不因 admin 角色开放普通员工入口;5) Notes 明确管理员复核是业务选择。","受影响流程:权限直连 -> 管理员复核 -> 出纳执行 -> 审计;验证步骤:cd client && E2E_BASE_URL=<local-or-isolated-url> npx playwright test tests/e2e/permission-direct-access.spec.ts --workers=1 --reporter=list;单测覆盖回单权限负例。","playwright|vitest|manual","DONE","DONE","DONE","server/src/routes/paymentBatches.js|client/tests/e2e/reimbursement-payment-batch-flow.spec.ts|docs/trialrun/人工试运行报告.md","[""CASHV-4""]","完成:权限继续复用 payment.batch.manage/risk.review.manage;admin wildcard 可复核但 maker-checker 仍生效;employee/manager 无付款批次入口和回单 API 权限;管理员复核为用户明确业务选择,审计可追踪;governance 业务流 9 passed,报告 artifacts/qa-flow/business-flow-20260517-092824/summary.txt。"
"CASHV-6","回归与独立 review","因涉及 Prisma migration、权限、财务状态和审计,执行后端、前端和 reviewer 复核。","1) prisma validate 通过;2) node --check 通过;3) paymentBatches/exports/financeLedger focused 单测通过;4) client typecheck/build 通过;5) reviewer 无阻断或已修复。","受影响流程:付款批次、AP 台账、报销付款、审计、导出;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/routes/paymentBatches.js src/routes/apOperations.js src/routes/exports.js;cd server && npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/routes/exports.apOperations.test.js tests/unit/services/financeLedger.test.js;cd client && npm run typecheck && npm run build。","npm|node|manual","DONE","DONE","DONE","server/src|client/src|server/tests|client/tests|artifacts/qa-flow/business-flow-20260517-091904|artifacts/qa-flow/business-flow-20260517-092824","[""CASHV-5""]","完成:prisma validate PASS;node --check PASS;focused 单测 3 files/12 tests passed;client typecheck/build PASS;本地隔离 reimbursement 业务域 10 passed/1 skipped,付款批次 E2E 覆盖回单上传后执行;governance 业务域 9 passed 覆盖权限直连和设置治理;自审未发现权限/状态机阻断。"
"CASHV-7","收口回填与提交","回填 CASHV CSV、索引、事实源矩阵和人工试运行报告,执行最终门禁并提交中文 commit。","1) 各行按证据回填 DONE 或保留 TODO 并说明原因;2) docs/文档索引.md、docs/文档事实源状态矩阵.md、docs/trialrun/人工试运行报告.md 状态更新;3) validate、audit-docs、rg、git diff --check 通过;4) 中文 commit 引用 CASHV;5) 明确未部署和后续 LAUNCH-DEPLOY-FINANCE/FIN-PROD-ENABLE/ACCOUNTING-HANDOFF-V1。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv;node scripts/qa/audit-docs.mjs --check-registry;rg -n 'CASHV|CASHIER-VOUCHER|银行回单|payment_batch_receipt|代账|payment_batch_v1|erp_sync' docs issues server client;git diff --check;git status --short。","python3|node|rg|git","DONE","DONE","DONE","issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[""CASHV-6""]","完成:CSV/docs 已回填;validate、audit-docs、rg 和 git diff --check 已执行,audit-docs 仅保留既有 unregistered docs warning;未部署,公网 payment_batch_v1 状态未改变;后续固定为 LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 CASHV-0 CASHIER-VOUCHER-V1 合同建档 建立正式启用前财务闭环与代账交接第一轮合同;锁定银行回单附件、管理员复核、出纳执行、ERP 冻结。 1) CSV 校验通过;2) 本轮不接真实支付接口、不接 ERP、不开放图片 OCR、不导入银行流水、不重置数据库;3) wangzhiling 作为出纳执行,wangshunli 作为管理员复核;4) payment_batch_v1 仍通过 feature flag 控制;5) 后续部署、正式常开和代账交接另建合同。 受影响流程:PRELAUNCH 收口 -> CASHIER-VOUCHER-V1 实现准备 -> 后续正式启用前财务闭环;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv;人工核对本轮边界。 python3|manual DONE DONE DONE issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv [] 完成:CSV 初始校验通过;本轮边界固定为 OA 内付款批次 + 银行回单附件 + 代账交接前置,不接真实支付/ERP,不开放图片 OCR;后续部署、正式常开和代账交接另建合同。
3 CASHV-1 现状审计与实现边界确认 核对付款批次状态机、AP 导出、报销付款字段、附件存储和权限边界,确认最小改动范围。 1) 确认 PaymentBatch/PaymentBatchItem 当前状态机和权限;2) 确认报销付款字段可承载凭证号/日期/渠道;3) 确认现有 storage 和附件模式可复用;4) 确认 AP 导出不打印隐私或回单原件;5) 审计结论写入 Notes。 受影响流程:付款批次创建/复核/执行 -> 报销 paid -> AP 导出 -> 审计;验证步骤:rg -n 'PaymentBatch|payment_voucher_no|EXPORT_AP_OPERATIONS|storage_path|risk.review.manage|payment.batch.manage' server/src server/prisma client/src docs issues。 rg|manual DONE DONE DONE server/src/routes/paymentBatches.js|server/src/routes/exports.js|server/prisma/schema.prisma|client/src/pages/PaymentBatchesPage.tsx ["CASHV-0"] 完成:现状确认 PaymentBatch 已有 maker-checker、payment.batch.manage/risk.review.manage、凭证号/付款日期/付款渠道、AP 导出和审计;storage/reimbursement attachment 模式可复用;AP 导出不输出回单原件。
4 CASHV-2 后端回单附件模型与 API 新增付款批次银行回单附件表与 API;复用现有 storage、上传大小限制和附件 MIME allowlist。 1) 新增 payment_batch_receipt_attachments Prisma 模型和 migration;2) GET/POST/GET file/DELETE 四个 API 受 payment_batch_v1 和权限保护;3) 支持 PDF/JPG/PNG/WEBP,遵守 maxUploadSizeBytes;4) 输出只含附件元数据,不直接输出 storage_path;5) 上传/下载/删除写审计。 受影响流程:出纳或管理员在付款批次详情上传银行回单 -> 查看附件列表 -> 下载回单 -> 删除未执行批次回单;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/routes/paymentBatches.js;新增或扩展 paymentBatches 单测覆盖上传/下载/删除/MIME/大小/权限。 npm|node|vitest DONE DONE DONE server/prisma/schema.prisma|server/prisma/migrations/20260517090437_cashier_payment_receipts/migration.sql|server/src/routes/paymentBatches.js|server/tests/unit/routes/paymentBatches.reimbursement.test.js ["CASHV-1"] 完成:新增 PaymentBatchReceiptAttachment 模型、migration 和四个 API;元数据不暴露 storage_path;审计动作 PAYMENT_BATCH_RECEIPT_UPLOADED/DOWNLOADED/DELETED;node --check 和 prisma validate 通过。
5 CASHV-3 付款凭证填写与执行硬化 调整付款批次 item 更新规则,允许出纳在批次 reviewed/failed 且 item 未 executed 前补填凭证号、付款日期和付款渠道;银行渠道执行前必须有回单附件。 1) draft/reviewed/failed 批次的 pending/failed item 可补凭证字段;2) executed/cancelled 批次不可改;3) bank 渠道执行前至少存在一份回单附件;4) 非 bank 渠道不强制回单;5) 错误返回可解释 409。 受影响流程:复核后网银付款 -> 补凭证 -> 上传回单 -> 执行 OA 已付款;验证步骤:cd server && npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js;核对 invalid 状态和无回单阻断。 npm|vitest DONE DONE DONE server/src/routes/paymentBatches.js|server/tests/unit/routes/paymentBatches.reimbursement.test.js ["CASHV-2"] 完成:PATCH item 支持 reviewed/failed 未执行项补凭证;bank 渠道执行前无回单返回 409 receiptAttachments=bank_receipt_required;paymentBatches 单测 7 passed。
6 CASHV-4 前端付款批次回单附件体验 在付款批次详情页增加银行回单附件上传、查看、下载、删除;文案明确先网银付款,再上传回单并执行 OA 已付款标记。 1) 批次详情显示银行回单附件区;2) 出纳/管理员可上传 PDF/JPG/PNG/WEBP;3) 可下载附件且不暴露 storage_path;4) 批次执行后不允许删除回单;5) reviewed/failed 批次仍可编辑未执行 item 的凭证字段。 受影响流程:付款批次详情 PC 财务操作;验证步骤:cd client && npm run typecheck && npm run build;必要时补付款批次 E2E 断言。 npm|playwright DONE DONE DONE client/src/api/paymentBatches.ts|client/src/pages/PaymentBatchesPage.tsx|client/tests/e2e/reimbursement-payment-batch-flow.spec.ts ["CASHV-3"] 完成:PaymentBatchesPage 增加银行回单附件区,支持选择/上传/下载/删除;reviewed/failed 未执行项可补凭证;client typecheck/build 通过;E2E 已上传 r78-bank-receipt.pdf 后执行付款。
7 CASHV-5 权限审计与管理员复核口径 按用户确认,wangzhiling 出纳执行,wangshunli 管理员复核;员工/审批人不可访问付款批次和回单附件。 1) admin wildcard 可作为复核人但 maker-checker 仍阻止创建人自审;2) employee/manager 无付款批次回单访问权限;3) 上传/下载/删除/复核/执行/导出审计可查;4) 不因 admin 角色开放普通员工入口;5) Notes 明确管理员复核是业务选择。 受影响流程:权限直连 -> 管理员复核 -> 出纳执行 -> 审计;验证步骤:cd client && E2E_BASE_URL=<local-or-isolated-url> npx playwright test tests/e2e/permission-direct-access.spec.ts --workers=1 --reporter=list;单测覆盖回单权限负例。 playwright|vitest|manual DONE DONE DONE server/src/routes/paymentBatches.js|client/tests/e2e/reimbursement-payment-batch-flow.spec.ts|docs/trialrun/人工试运行报告.md ["CASHV-4"] 完成:权限继续复用 payment.batch.manage/risk.review.manage;admin wildcard 可复核但 maker-checker 仍生效;employee/manager 无付款批次入口和回单 API 权限;管理员复核为用户明确业务选择,审计可追踪;governance 业务流 9 passed,报告 artifacts/qa-flow/business-flow-20260517-092824/summary.txt。
8 CASHV-6 回归与独立 review 因涉及 Prisma migration、权限、财务状态和审计,执行后端、前端和 reviewer 复核。 1) prisma validate 通过;2) node --check 通过;3) paymentBatches/exports/financeLedger focused 单测通过;4) client typecheck/build 通过;5) reviewer 无阻断或已修复。 受影响流程:付款批次、AP 台账、报销付款、审计、导出;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/routes/paymentBatches.js src/routes/apOperations.js src/routes/exports.js;cd server && npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/routes/exports.apOperations.test.js tests/unit/services/financeLedger.test.js;cd client && npm run typecheck && npm run build。 npm|node|manual DONE DONE DONE server/src|client/src|server/tests|client/tests|artifacts/qa-flow/business-flow-20260517-091904|artifacts/qa-flow/business-flow-20260517-092824 ["CASHV-5"] 完成:prisma validate PASS;node --check PASS;focused 单测 3 files/12 tests passed;client typecheck/build PASS;本地隔离 reimbursement 业务域 10 passed/1 skipped,付款批次 E2E 覆盖回单上传后执行;governance 业务域 9 passed 覆盖权限直连和设置治理;自审未发现权限/状态机阻断。
9 CASHV-7 收口回填与提交 回填 CASHV CSV、索引、事实源矩阵和人工试运行报告,执行最终门禁并提交中文 commit。 1) 各行按证据回填 DONE 或保留 TODO 并说明原因;2) docs/文档索引.md、docs/文档事实源状态矩阵.md、docs/trialrun/人工试运行报告.md 状态更新;3) validate、audit-docs、rg、git diff --check 通过;4) 中文 commit 引用 CASHV;5) 明确未部署和后续 LAUNCH-DEPLOY-FINANCE/FIN-PROD-ENABLE/ACCOUNTING-HANDOFF-V1。 流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv;node scripts/qa/audit-docs.mjs --check-registry;rg -n 'CASHV|CASHIER-VOUCHER|银行回单|payment_batch_receipt|代账|payment_batch_v1|erp_sync' docs issues server client;git diff --check;git status --short。 python3|node|rg|git DONE DONE DONE issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md ["CASHV-6"] 完成:CSV/docs 已回填;validate、audit-docs、rg 和 git diff --check 已执行,audit-docs 仅保留既有 unregistered docs warning;未部署,公网 payment_batch_v1 状态未改变;后续固定为 LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1。
@@ -0,0 +1,33 @@
-- CASHV: 付款批次银行回单附件。用于记录网银/银行侧付款完成后的回单证据,不接真实支付接口。
CREATE TABLE "payment_batch_receipt_attachments" (
"id" UUID NOT NULL,
"payment_batch_id" UUID NOT NULL,
"storage_path" TEXT NOT NULL,
"original_name" TEXT NOT NULL,
"mime_type" TEXT NOT NULL,
"file_size" INTEGER NOT NULL,
"file_sha256" TEXT NOT NULL,
"uploaded_by" UUID NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_batch_receipt_attachments_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "idx_payment_batch_receipts_batch_created"
ON "payment_batch_receipt_attachments"("payment_batch_id", "created_at");
CREATE INDEX "idx_payment_batch_receipts_uploader"
ON "payment_batch_receipt_attachments"("uploaded_by");
CREATE INDEX "idx_payment_batch_receipts_file_sha256"
ON "payment_batch_receipt_attachments"("file_sha256");
ALTER TABLE "payment_batch_receipt_attachments"
ADD CONSTRAINT "payment_batch_receipt_attachments_payment_batch_id_fkey"
FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "payment_batch_receipt_attachments"
ADD CONSTRAINT "payment_batch_receipt_attachments_uploaded_by_fkey"
FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
+22
查看文件
@@ -601,6 +601,7 @@ model Employee {
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
leave_requests LeaveRequest[]
leave_ledgers LeaveLedger[]
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
@@ -1741,6 +1742,7 @@ model PaymentBatch {
updated_by String? @db.Uuid
items PaymentBatchItem[]
receipt_attachments PaymentBatchReceiptAttachment[]
@@index([entity_type, status, created_at], map: "idx_payment_batches_entity_status_created")
@@map("payment_batches")
@@ -1770,6 +1772,26 @@ model PaymentBatchItem {
@@map("payment_batch_items")
}
model PaymentBatchReceiptAttachment {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
storage_path String
original_name String
mime_type String
file_size Int
file_sha256 String
uploaded_by String @db.Uuid
created_at DateTime @default(now())
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
uploader Employee @relation("PaymentBatchReceiptAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
@@index([file_sha256], map: "idx_payment_batch_receipts_file_sha256")
@@map("payment_batch_receipt_attachments")
}
model PortalAnnouncement {
id String @id @default(uuid()) @db.Uuid
title String
+299 -5
查看文件
@@ -1,9 +1,13 @@
const crypto = require("node:crypto");
const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { compactDateString, parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { createNotificationsWithRecords } = require("../services/notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
@@ -11,6 +15,7 @@ const { sendNotificationEmailSafe } = require("../services/email");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
@@ -42,6 +47,12 @@ const REVIEW_SCHEMA = z.object({
}).strict();
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
const RECEIPT_ATTACHMENT_MIME_ALLOWLIST = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
function buildValidationError(details, statusCode = 400) {
return new AppError({
@@ -76,6 +87,49 @@ function normalizeNullableString(value) {
return trimmed || null;
}
function sanitizeAttachmentFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
}
function mapReceiptAttachment(item) {
return {
id: item.id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
uploadedBy: item.uploader
? {
id: item.uploader.id,
employeeNo: item.uploader.employee_no,
name: item.uploader.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function resolveItemPaymentChannel(batch, item) {
return item.payment_channel_override ?? batch.default_payment_channel ?? null;
}
async function ensureBankReceiptForExecutableBatch(tx, batch) {
const requiresBankReceipt = (batch.items ?? []).some((item) =>
["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank",
);
if (!requiresBankReceipt) {
return;
}
const receiptCount = await tx.paymentBatchReceiptAttachment.count({
where: { payment_batch_id: batch.id },
});
if (receiptCount <= 0) {
throw buildValidationError({ receiptAttachments: "bank_receipt_required" }, 409);
}
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
@@ -625,6 +679,55 @@ function buildNotFoundError() {
return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
async function readReceiptUploadFiles(fastify, request) {
const files = [];
try {
for await (const part of request.parts()) {
if (part.type !== "file") {
continue;
}
const originalName = part.filename ?? "receipt";
const mimeType = part.mimetype;
if (!RECEIPT_ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) {
throw buildValidationError({ file: "unsupported_mime_type" });
}
const chunks = [];
let fileSize = 0;
for await (const chunk of part.file) {
fileSize += chunk.length;
if (fileSize > fastify.config.maxUploadSizeBytes) {
throw buildValidationError({ file: "file_too_large" });
}
chunks.push(chunk);
}
if (fileSize === 0) {
throw buildValidationError({ file: "empty_file" });
}
files.push({
buffer: Buffer.concat(chunks),
originalName,
mimeType,
fileSize,
});
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
}
throw error;
}
if (!files.length) {
throw buildValidationError({ file: "missing" });
}
return files;
}
async function paymentBatchesRoutes(fastify) {
fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
@@ -723,6 +826,196 @@ async function paymentBatchesRoutes(fastify) {
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) });
});
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const batchId = parseId(request.params?.id);
await loadBatchWithItems(fastify.prisma, batchId);
const items = await fastify.prisma.paymentBatchReceiptAttachment.findMany({
where: { payment_batch_id: batchId },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
return sendData(reply, request, { items: items.map(mapReceiptAttachment) });
});
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const files = await readReceiptUploadFiles(fastify, request);
const datePrefix = compactDateString(fastify.config.appTimezone);
const createdItems = [];
for (const file of files) {
const fileSha256 = sha256Hex(file.buffer);
const extension = path.extname(file.originalName ?? "").toLowerCase();
const objectKey = path.posix.join("payment-batch-receipts", datePrefix, `${batchId}-${crypto.randomUUID()}${extension}`);
const storagePath = await writeBuffer({
config: fastify.config,
key: objectKey,
buffer: file.buffer,
contentType: file.mimeType,
});
const created = await fastify.prisma.paymentBatchReceiptAttachment.create({
data: {
payment_batch_id: batchId,
storage_path: storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
createdItems.push(created);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_UPLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: {
attachmentIds: createdItems.map((item) => item.id),
attachmentCount: createdItems.length,
},
}),
});
return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) });
});
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const attachment = await fastify.prisma.paymentBatchReceiptAttachment.findFirst({
where: {
id: attachmentId,
payment_batch_id: batchId,
},
select: {
id: true,
storage_path: true,
original_name: true,
mime_type: true,
},
});
if (!attachment) {
throw buildNotFoundError();
}
let stream = null;
try {
const opened = await openReadStream({
config: fastify.config,
storagePath: attachment.storage_path,
});
stream = opened.stream;
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw buildNotFoundError();
}
throw error;
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: { attachmentId: attachment.id },
}),
});
reply.type(attachment.mime_type || "application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`,
);
return reply.send(stream);
});
fastify.delete("/payment-batches/:id/receipt-attachments/:attachmentId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const deleted = await tx.paymentBatchReceiptAttachment.deleteMany({
where: {
id: attachmentId,
payment_batch_id: batchId,
},
});
if (deleted.count === 0) {
throw buildNotFoundError();
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_DELETED",
targetType: "payment_batch",
targetId: batchId,
extra: { attachmentId },
}),
});
return { attachmentId };
});
return sendData(reply, request, { attachmentId: result.attachmentId, outcome: "deleted" });
});
fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
@@ -731,15 +1024,15 @@ async function paymentBatchesRoutes(fastify) {
const body = parseBody(PATCH_ITEM_SCHEMA, request.body);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
if (batch.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
if (!["draft", "reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_open_batch_editable" }, 409);
}
const item = batch.items.find((entry) => entry.id === itemId);
if (!item) {
throw buildNotFoundError();
}
if (item.status !== "pending") {
throw buildValidationError({ item: "only_pending_editable" }, 409);
if (!["pending", "failed"].includes(item.status)) {
throw buildValidationError({ item: "only_open_item_editable" }, 409);
}
const updated = await fastify.prisma.paymentBatchItem.update({
@@ -872,6 +1165,7 @@ async function paymentBatchesRoutes(fastify) {
review: batchRisk.highRisk ? "high_risk_second_review_required" : "batch_review_required",
}, 409);
}
await ensureBankReceiptForExecutableBatch(tx, batch);
let successCount = 0;
let failedCount = 0;
@@ -1,4 +1,9 @@
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const { AppError } = require("../../../src/utils/AppError");
const featureFlags = require("../../../src/services/featureFlags");
@@ -41,6 +46,31 @@ const BATCH_ID = "55555555-5555-4555-8555-555555555555";
const ITEM_ID = "66666666-6666-4666-8666-666666666666";
const OWNER_ID = "77777777-7777-4777-8777-777777777777";
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
function buildMultipartRequest({ files }) {
const boundary = "----codex-payment-batch-receipt-boundary";
const segments = [];
for (const file of files) {
segments.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
),
Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content, "utf8"),
Buffer.from("\r\n"),
);
}
segments.push(Buffer.from(`--${boundary}--\r\n`));
return {
payload: Buffer.concat(segments),
headers: {
"content-type": `multipart/form-data; boundary=${boundary}`,
},
};
}
function buildPaymentBatch(overrides = {}) {
return {
@@ -118,9 +148,28 @@ function buildReimbursement(overrides = {}) {
}
function buildPrisma(options = {}) {
const defaultReceiptAttachments = [
{
id: RECEIPT_ID,
payment_batch_id: BATCH_ID,
storage_path: "payment-batch-receipts/20260429/receipt.pdf",
original_name: "receipt.pdf",
mime_type: "application/pdf",
file_size: 12,
file_sha256: "sha256-receipt",
uploaded_by: ACTOR_ID,
created_at: new Date("2026-04-29T09:00:00.000Z"),
uploader: {
id: ACTOR_ID,
employee_no: "FIN001",
name: "出纳",
},
},
];
const state = {
batch: buildPaymentBatch(options.batch),
reimbursement: buildReimbursement(options.reimbursement),
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
invoice: {
linked_reimbursement_id: REIMBURSEMENT_ID,
status: "bound",
@@ -167,6 +216,43 @@ function buildPrisma(options = {}) {
return { ...item };
}),
},
paymentBatchReceiptAttachment: {
count: vi.fn(async ({ where }) =>
state.receiptAttachments.filter((item) => item.payment_batch_id === where.payment_batch_id).length,
),
findMany: vi.fn(async ({ where }) =>
state.receiptAttachments
.filter((item) => item.payment_batch_id === where.payment_batch_id)
.map((item) => ({ ...item })),
),
create: vi.fn(async ({ data }) => {
const created = {
id: `aaaaaaaa-aaaa-4aaa-8aaa-${String(state.receiptAttachments.length + 1).padStart(12, "0")}`,
...data,
created_at: new Date("2026-04-29T10:00:00.000Z"),
uploader: {
id: ACTOR_ID,
employee_no: "FIN001",
name: "出纳",
},
};
state.receiptAttachments.push(created);
return { ...created };
}),
findFirst: vi.fn(async ({ where }) => {
const item = state.receiptAttachments.find(
(candidate) => candidate.id === where.id && candidate.payment_batch_id === where.payment_batch_id,
);
return item ? { ...item } : null;
}),
deleteMany: vi.fn(async ({ where }) => {
const before = state.receiptAttachments.length;
state.receiptAttachments = state.receiptAttachments.filter(
(item) => !(item.id === where.id && item.payment_batch_id === where.payment_batch_id),
);
return { count: before - state.receiptAttachments.length };
}),
},
reimbursementForm: {
findUnique: vi.fn(async ({ where }) => {
if (where.id !== state.reimbursement.id) {
@@ -221,6 +307,7 @@ function buildPrisma(options = {}) {
async function buildApp(options = {}) {
const { prisma, tx, state } = buildPrisma(options);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-cashv-payment-receipts-"));
const app = Fastify();
const actor = options.actor ?? {
id: ACTOR_ID,
@@ -263,6 +350,9 @@ async function buildApp(options = {}) {
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
storageDir: tempDir,
maxUploadSizeBytes: 5 * 1024 * 1024,
paymentBatchEnabled: true,
erpSyncEnabled: true,
integrationOutboxMaxAttempts: 5,
@@ -302,9 +392,13 @@ async function buildApp(options = {}) {
});
});
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 5, fileSize: 5 * 1024 * 1024 },
});
await app.register(paymentBatchesRoutes, { prefix: "/api" });
return { app, prisma, tx, state };
return { app, prisma, tx, state, tempDir };
}
afterEach(() => {
@@ -417,6 +511,159 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("rejects bank-channel execution when no bank receipt attachment exists", async () => {
const { app, state } = await buildApp({ receiptAttachments: [] });
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/execute`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.receiptAttachments).toBe("bank_receipt_required");
expect(state.batch.status).toBe("reviewed");
expect(state.reimbursement.status).toBe("approved");
expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled();
await app.close();
});
it("allows payment metadata updates after review before execution", async () => {
const { app, state } = await buildApp();
const response = await app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: {
paymentVoucherNoOverride: "BANK-RECEIPT-001",
paymentDateOverride: "2026-04-30",
paymentChannelOverride: "bank",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
paymentVoucherNoOverride: "BANK-RECEIPT-001",
paymentDateOverride: "2026-04-30",
paymentChannelOverride: "bank",
});
expect(state.batch.items[0]).toMatchObject({
payment_voucher_no_override: "BANK-RECEIPT-001",
payment_channel_override: "bank",
});
await app.close();
});
it("uploads, downloads and deletes bank receipt attachments", async () => {
const { app, prisma, state, tempDir } = await buildApp({ receiptAttachments: [] });
try {
const requestPayload = buildMultipartRequest({
files: [
{
filename: "bank-receipt.pdf",
contentType: "application/pdf",
content: "%PDF bank receipt",
},
],
});
const uploadResponse = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: {
authorization: "Bearer test",
...requestPayload.headers,
},
payload: requestPayload.payload,
});
expect(uploadResponse.statusCode).toBe(200);
expect(uploadResponse.json().data.items).toHaveLength(1);
expect(uploadResponse.json().data.items[0]).toMatchObject({
originalName: "bank-receipt.pdf",
mimeType: "application/pdf",
});
const attachmentId = uploadResponse.json().data.items[0].id;
expect(prisma.paymentBatchReceiptAttachment.create).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PAYMENT_BATCH_RECEIPT_UPLOADED",
target_id: BATCH_ID,
}),
}),
);
const storedPath = state.receiptAttachments[0].storage_path;
const stored = await fs.readFile(path.join(tempDir, storedPath));
expect(stored.toString("utf8")).toBe("%PDF bank receipt");
const listResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: { authorization: "Bearer test" },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toHaveLength(1);
const downloadResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file`,
headers: { authorization: "Bearer test" },
});
expect(downloadResponse.statusCode).toBe(200);
expect(downloadResponse.body).toBe("%PDF bank receipt");
const deleteResponse = await app.inject({
method: "DELETE",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}`,
headers: { authorization: "Bearer test" },
});
expect(deleteResponse.statusCode).toBe(200);
expect(state.receiptAttachments).toHaveLength(0);
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects unsupported bank receipt attachment MIME types", async () => {
const { app, tempDir } = await buildApp({ receiptAttachments: [] });
try {
const requestPayload = buildMultipartRequest({
files: [
{
filename: "receipt.txt",
contentType: "text/plain",
content: "not a receipt attachment type",
},
],
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: {
authorization: "Bearer test",
...requestPayload.headers,
},
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details.file).toBe("unsupported_mime_type");
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => {
const { app, state } = await buildApp({
reimbursement: {