收紧付款复核资料与阶段编辑(08-01-reimbursement-notification-progress-clarity)
这个提交包含在:
@@ -343,7 +343,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
|
||||
defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }`.
|
||||
- Review: `POST /api/payment-batches/:id/review`.
|
||||
- Register completed payment: `POST /api/payment-batches/:id/execute`.
|
||||
- Payment metadata: `PATCH /api/payment-batches/:id/items/:itemId` accepts
|
||||
only `paymentVoucherNoOverride?` and `paymentDateOverride?`.
|
||||
- Receipt API: `GET|POST /api/payment-batches/:id/receipt-attachments`.
|
||||
- Receipt void API:
|
||||
`POST /api/payment-batches/:id/receipt-attachments/:attachmentId/void`.
|
||||
- Internal status machine:
|
||||
`draft -> reviewed -> executed|failed|cancelled`; there is no separate submit
|
||||
endpoint or submitted database status.
|
||||
@@ -353,9 +357,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- For the normal reimbursement UI, creation sends only `entityType` and
|
||||
selected `entityIds`. The server still accepts optional fields for controlled
|
||||
compatibility and exceptional item overrides.
|
||||
- The normal reimbursement UI sends `entityType`, selected `entityIds` and
|
||||
`defaultPaymentChannel`; the channel defaults to `bank` and becomes an
|
||||
immutable review fact after creation. Item PATCH must not accept a channel
|
||||
override. A persisted legacy item override remains readable/executable for
|
||||
compatibility but is never exposed as a new write path.
|
||||
- Missing name becomes `<company-local YYYY-MM-DD> 付款批次`; the server creates
|
||||
the unique batch number. Missing channel becomes `bank`. Missing actual
|
||||
payment date is resolved to the company-local registration date only when
|
||||
@@ -379,6 +385,23 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
|
||||
an active receipt exists may the UI expose `登记付款完成`. Loading or failed
|
||||
receipt queries expose neither action rather than guessing that the
|
||||
prerequisite is satisfied.
|
||||
- Stage editing is fixed: `draft` is read-only apart from cancel/review;
|
||||
`reviewed|failed` permits voucher/date and receipt upload/void;
|
||||
`executed|cancelled` is entirely read-only. Authorized receipt list/download
|
||||
remains available in every state.
|
||||
- Payment-batch responses never expose stored `snapshot_json` or a
|
||||
`snapshotJson` DTO field. Detail responses rebuild an allowlisted review DTO
|
||||
with only `payeeName`, `bankName` and a format-validated
|
||||
`bankAccountMasked`; list responses keep those detail-only fields null and do
|
||||
not decrypt owner accounts. New items persist the same low-sensitivity
|
||||
`payee` object in `snapshot_json`; full bank accounts and encrypted source
|
||||
values never enter new batch snapshots or any response.
|
||||
- A legacy or malformed stored payee is not trusted merely because its field is
|
||||
named `bankAccountMasked`. Invalid text, ciphertext-looking values and account
|
||||
values without a valid mask cause a read-only fallback to the current
|
||||
reimbursement owner. The fallback must not backfill history, and an existing
|
||||
but unreadable encrypted account fails closed rather than returning the raw
|
||||
stored value.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
@@ -395,6 +418,20 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
|
||||
shown independently and the user may retry.
|
||||
- Existing null-channel reimbursement batch -> project as bank and apply the
|
||||
receipt guard without updating `default_payment_channel`.
|
||||
- Item PATCH in `draft|executed|cancelled` -> `409 VALID_001` with
|
||||
`details.status=payment_metadata_not_editable`; zero item/audit writes.
|
||||
- Item PATCH contains `paymentChannelOverride` -> strict schema rejection;
|
||||
zero writes.
|
||||
- Receipt upload/void outside `reviewed|failed` -> `409 VALID_001` with
|
||||
`details.status=receipt_attachment_not_editable`; zero durable storage,
|
||||
attachment or audit side effects.
|
||||
- Receipt upload starts in `reviewed|failed` but the locked batch becomes
|
||||
`executed|cancelled` before DB write -> the transaction rejects and every
|
||||
object already written to storage is deleted by compensation; no attachment
|
||||
or audit row remains.
|
||||
- Missing legacy payee source -> return null display fields. Encryption-key or
|
||||
ciphertext authentication failure -> fail closed; never substitute a full
|
||||
account, ciphertext or unrelated employee.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
@@ -403,9 +440,14 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
|
||||
eligible non-maker receives the task notification.
|
||||
- Base: after review, finance completes the bank transfer outside OA, uploads
|
||||
the receipt, then registers payment completion using the company-local date.
|
||||
- Good: reviewer sees the payee name, bank and masked account while every
|
||||
payment-registration control and receipt mutation is absent in `draft`.
|
||||
- Bad: label `draft` as `草稿`, ask the user to find a nonexistent submit button,
|
||||
show `登记付款完成` before receipt status is known, or silently rewrite a
|
||||
legacy null channel in production.
|
||||
- Bad: permit voucher/channel/receipt changes while a reviewer is checking the
|
||||
batch, trust a persisted field name as proof of masking, or return
|
||||
`snapshotJson`, `bank_account_encrypted`, ciphertext or a full account.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
@@ -414,9 +456,18 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
|
||||
null-channel compatibility without mutation.
|
||||
- PostgreSQL concurrency regression asserts double create, double execute,
|
||||
execute/cancel and direct-pay/batch races remain mutually exclusive.
|
||||
- Client E2E asserts creation sends only `entityType+entityIds`, all consumers
|
||||
use the shared labels, and receipt upload switches the sole primary action
|
||||
from `上传银行回单` to `登记付款完成`.
|
||||
- Route tests assert the full state matrix, channel strict rejection, zero
|
||||
mutation side effects, masked new snapshots, legacy fallback and malicious
|
||||
stored snapshots without historical writes or full-account/ciphertext
|
||||
response values. They also assert list requests do not perform detail-level
|
||||
payee lookup/decryption.
|
||||
- Receipt tests cover both `reviewed` and `failed` positive paths, all three
|
||||
read-only states, zero stored files on immediate rejection, and storage
|
||||
compensation when the locked status changes after upload parsing.
|
||||
- Client E2E asserts creation sends the selected `defaultPaymentChannel`, all
|
||||
consumers use the shared labels, `draft` has no payment/receipt editors,
|
||||
`executed|cancelled` remains read-only, and receipt upload switches the sole
|
||||
primary action from `上传银行回单` to `登记付款完成`.
|
||||
- Build/typecheck, Prisma validation and focused payment-batch route/export tests
|
||||
are required before commit.
|
||||
|
||||
@@ -426,6 +477,7 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
|
||||
|
||||
```tsx
|
||||
const label = status === "draft" ? "草稿" : status;
|
||||
<ChannelSelect onChange={(value) => patchItem({ paymentChannelOverride: value })} />
|
||||
<Button onClick={execute}>执行付款</Button>
|
||||
```
|
||||
|
||||
@@ -433,5 +485,7 @@ const label = status === "draft" ? "草稿" : status;
|
||||
|
||||
```tsx
|
||||
<StatusBadge label={paymentBatchStatusLabel(batch.status)} />
|
||||
{batch.status === "draft" ? <PayeeReviewFacts item={item} /> : null}
|
||||
{["reviewed", "failed"].includes(batch.status) ? <PaymentRegistrationFields /> : null}
|
||||
{needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null}
|
||||
```
|
||||
|
||||
@@ -144,3 +144,23 @@
|
||||
- 在同一个 Prisma 交互事务中按固定顺序执行:取得咨询锁 → 查询当日严格新格式 `^PB-YYYYMMDD-[0-9]{3}$` 的最大序号 → 生成编号/自动标题 → 创建批次及既有项、审计、通知。
|
||||
- 不使用 `hashtext`,不把旧长格式纳入最大值,不在 P2002 后原事务内重试;PostgreSQL 唯一冲突会使事务进入 aborted 状态,任何兜底只能重试整个事务。本设计通过咨询锁消除正常同日并发冲突。
|
||||
- 无 schema 迁移;回滚时可恢复旧生成器,已经生成的新短编号仍满足唯一字符串合同,无需改写。
|
||||
|
||||
## 10. 付款复核对象与阶段编辑边界
|
||||
|
||||
### 10.1 状态门禁
|
||||
|
||||
- `draft` 是已提交待复核,不允许批次项 PATCH、回单上传或回单作废;财务仅可查看和取消,合格非制单人可复核。
|
||||
- `reviewed/failed` 允许财务登记凭证号、实际付款日期以及上传/作废回单;付款渠道已是复核事实,不再接受批次项覆盖。
|
||||
- `executed/cancelled` 的付款资料和回单全部只读;下载继续受既有权限、用途和附件安全门禁保护。
|
||||
- 客户端按状态渐进展示当前阶段控件,服务端以同一矩阵拒绝绕过请求。删除 `draft` 的可编辑路径后,不新增一条“待复核修改审计”来延续旧草稿语义。
|
||||
|
||||
### 10.2 渠道与复核事实
|
||||
|
||||
- 创建面板保留“选择报销”主任务,同时增加一个批次级付款渠道下拉框,默认 `bank`,请求继续使用既有 `defaultPaymentChannel` 字段。
|
||||
- 渠道和批次金额、收款信息一起构成复核输入;创建后锁定。确需改变渠道时取消未执行批次并重新创建,不在复核后静默改写。
|
||||
|
||||
### 10.3 收款信息 DTO
|
||||
|
||||
- 报销付款对象取 `ReimbursementForm.owner`,返回姓名、开户银行和经既有加密服务解密后立即脱敏的账户摘要;响应、快照、日志和测试均不得包含完整账号或密文。
|
||||
- 新批次把上述低敏摘要写入既有 `snapshot_json`,不新增 schema;既有批次缺快照字段时按 `entity_id` 查询当前报销归属人并只读补齐响应,不回写历史批次。
|
||||
- 复核详情继续显示单号、摘要、金额和状态,并补充付款渠道与收款摘要;页面四段结构和二次确认弹窗保持不变。
|
||||
|
||||
@@ -123,3 +123,19 @@
|
||||
- 部署后以新完整 SHA 重装 automatic-backup 与 WARCH3 合同;4 个 service 的工作目录、环境文件、构建 SHA 与规范根一致,4 个 timer 均为 enabled/active。
|
||||
- 已手工触发一次完整真实自动备份周期:源端成功状态新鲜且构建标识一致,`resolve_active_runtime` 的运行构建、附件挂载和 PGDATA 挂载三项断言通过;异机归档完成,随后 watchdog 证明异机恢复点新鲜。没有输出归档路径、文件名、远端标识或任何密钥。
|
||||
- 旧源码候选目录与旧环境文件继续保留,清理不属于本轮授权范围。任务仍保留运行观察项,不因本次部署归档。
|
||||
|
||||
## 10. 付款复核对象与阶段编辑收口(本轮)
|
||||
|
||||
- [x] 服务端将批次项编辑限制为 `reviewed/failed` 的凭证号与实际付款日期,拒绝任何创建后渠道覆盖;`draft/executed/cancelled` 均不可编辑。
|
||||
- [x] 服务端将回单上传/作废限制为 `reviewed/failed`,保留所有状态下的授权查看/下载;拒绝请求不写附件、存储对象或审计。
|
||||
- [x] 创建快照加入收款人姓名、开户银行和脱敏账户摘要;既有批次通过只读 fallback 补齐 DTO,不迁移或回写生产数据。
|
||||
- [x] 创建面板增加付款渠道选择并传既有 `defaultPaymentChannel`;默认银行转账,创建成功后重置。
|
||||
- [x] 详情待复核阶段隐藏付款登记输入和回单维护动作;复核后显示凭证号/日期与回单动作;已付款/已取消全部只读,四段结构和确认弹窗不变。
|
||||
- [x] 服务端测试覆盖状态矩阵、渠道锁定、零副作用和脱敏 DTO;前端测试覆盖创建 payload、待复核零编辑、复核后登记及已付款回单只读。
|
||||
- [x] 运行付款 focused Vitest、相关 Playwright、客户端 typecheck/build、Prisma validate、task/governance/process/sensitive/diff 门禁,并执行付款状态机只读审查。
|
||||
|
||||
本地验证(2026-08-02):付款路由与并发保护单测 58 passed;付款权限/高风险动作 Playwright 15 passed;客户端 typecheck/build、Prisma validate、Node syntax、隔离 PostgreSQL 付款并发门禁、task/governance/process/docs/sensitive/diff 门禁通过。首次 Playwright 运行因本地静态预览未启动而在登录页统一 `ERR_CONNECTION_REFUSED`,启动本地预览后原样重跑全绿,不属于业务断言失败。文档审计保持 0 error,既有 22 个未登记 docs 文件 warning 与本轮无关。
|
||||
|
||||
只读审查首轮发现:原始 `snapshotJson` 仍可透传异常/历史快照,列表执行不必要的收款资料解密,且缺少 `failed` 正向和回单存储补偿测试。已移除付款响应的原始快照字段,详情只返回格式校验后的固定收款字段,列表不再加载或解密详情级收款资料;补充恶意快照、`failed`、拒绝零文件残留和上传期间状态变化清理回归。未修改生产数据,未执行部署或真实付款动作。
|
||||
|
||||
只读复查随后发现付款页仍把精简列表项当详情使用,会让真实收款依据显示为空;已改为按所选批次 ID 加载详情,并将 E2E 锁定为“列表收款字段为空、详情返回脱敏字段且页面确实发起详情请求”。最终复查无 Blocker/High/Medium/Low,未修改文件。
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
- 付款页的列表、详情总额、批次项、确认弹窗和高风险阈值统一使用共享 `formatMoney()`;不得继续裸输出金额或在页面内新增货币格式化副本。
|
||||
- 新建付款批次编号使用公司时区日期和三位日序号 `PB-YYYYMMDD-NNN`;既有编号不迁移。未显式传名称时自动标题为 `YYYY-MM-DD 付款批次 NNN`,显式名称原样保留。
|
||||
- 日序号必须在创建批次的同一交互事务中,先取得双参数 PostgreSQL 事务级咨询锁,再查询当日严格新格式最大序号并创建;咨询锁使用固定 int4 命名空间和 int4 `YYYYMMDD`,不得依赖唯一冲突后的原事务内重试。
|
||||
- `draft` 已表示“创建并提交、待付款复核”,批次项不得继续沿用未提交草稿的编辑权限;付款凭证号、实际付款日期和银行回单只能在 `reviewed/failed` 财务处理阶段维护,`executed/cancelled` 全部只读。
|
||||
- 付款渠道在创建面板确定并作为复核事实提交,默认银行转账;创建后不得通过批次项覆盖改变,避免复核对象在复核前后漂移。
|
||||
- 付款复核明细必须显示收款人姓名、开户银行和脱敏银行账户摘要;不得向付款批次 API 暴露完整账号或加密原值。新批次固化脱敏快照,既有批次以当前报销归属人只读降级展示。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@@ -75,6 +78,10 @@
|
||||
- [ ] 付款页所有人民币金额统一显示为 `¥1,234.56` 形式,列表、详情、批次项、确认弹窗和高风险阈值无裸金额残留。
|
||||
- [ ] 新批次编号为公司时区 `PB-YYYYMMDD-NNN`,同日并发创建得到唯一递增序号,跨日从 `001` 开始;超过 `999` 时 fail-closed,不改变编号格式。
|
||||
- [ ] 自动标题在编号生成后使用相同日序号;调用方显式提供名称时不追加序号;旧编号保持原值且导出/AP/外部传递继续按不透明字符串兼容。
|
||||
- [ ] 待付款复核期间批次项和回单均不可编辑;后端直接 PATCH/上传/作废请求同样拒绝,且不产生静默未审计变更。
|
||||
- [ ] 创建批次时可选择付款渠道且默认银行转账;创建后渠道只读,复核人看到的渠道与创建事实一致。
|
||||
- [ ] 每条付款明细显示收款人姓名、开户银行和脱敏账户摘要,不返回完整账号、密文或其他身份信息;当前既有批次无需迁移即可展示。
|
||||
- [ ] `reviewed/failed` 允许登记凭证号、实际付款日期及维护回单;`executed/cancelled` 的回单与付款资料只读。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ export type PaymentBatchItem = {
|
||||
paymentVoucherNoOverride: string | null;
|
||||
paymentDateOverride: string | null;
|
||||
paymentChannelOverride: PaymentChannel | null;
|
||||
payeeName: string | null;
|
||||
bankName: string | null;
|
||||
bankAccountMasked: string | null;
|
||||
status: string;
|
||||
failureCode: string | null;
|
||||
failureMessage: string | null;
|
||||
snapshotJson: Record<string, unknown>;
|
||||
createdAt: string | null;
|
||||
openRiskFlags: RiskFlag[];
|
||||
};
|
||||
@@ -138,9 +140,8 @@ export async function createPaymentBatch(payload: {
|
||||
export async function updatePaymentBatchItem(batchId: string, itemId: string, payload: {
|
||||
paymentVoucherNoOverride?: string | null;
|
||||
paymentDateOverride?: string | null;
|
||||
paymentChannelOverride?: PaymentChannel | null;
|
||||
}) {
|
||||
return apiRequestData<{ item: Pick<PaymentBatchItem, "id" | "paymentVoucherNoOverride" | "paymentDateOverride" | "paymentChannelOverride"> }>(
|
||||
return apiRequestData<{ item: Pick<PaymentBatchItem, "id" | "paymentVoucherNoOverride" | "paymentDateOverride"> }>(
|
||||
`/api/payment-batches/${batchId}/items/${itemId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
|
||||
@@ -1402,10 +1402,21 @@ export function ApOperationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium text-zinc-700">快照</div>
|
||||
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-950/95 p-4 text-xs text-zinc-100">
|
||||
{prettyJson(failedDetailItem.snapshotJson)}
|
||||
</pre>
|
||||
<div className="mb-2 text-sm font-medium text-zinc-700">付款信息</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
||||
<div className="text-xs text-zinc-500">收款人</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.payeeName ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
||||
<div className="text-xs text-zinc-500">开户银行</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.bankName ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
||||
<div className="text-xs text-zinc-500">银行账户</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.bankAccountMasked ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
downloadPaymentBatchReceiptAttachment,
|
||||
executePaymentBatch,
|
||||
fetchPaymentBatchCandidates,
|
||||
fetchPaymentBatchDetail,
|
||||
fetchPaymentBatchReceiptAttachments,
|
||||
fetchPaymentBatches,
|
||||
reviewPaymentBatch,
|
||||
updatePaymentBatchItem,
|
||||
uploadPaymentBatchReceiptAttachments,
|
||||
voidPaymentBatchReceiptAttachment,
|
||||
type PaymentBatch,
|
||||
type PaymentBatchReceiptAttachment,
|
||||
type PaymentChannel,
|
||||
} from "../api/paymentBatches";
|
||||
@@ -102,6 +102,7 @@ export function PaymentBatchesPage() {
|
||||
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
|
||||
const [candidateSearch, setCandidateSearch] = useState("");
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
|
||||
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
|
||||
const receiptInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [showCreatePanel, setShowCreatePanel] = useState(false);
|
||||
@@ -131,9 +132,12 @@ export function PaymentBatchesPage() {
|
||||
|
||||
const selectedBatchId = searchParams.get("id") ?? "";
|
||||
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
||||
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
||||
return batches.find((item) => item.id === selectedBatchId) ?? null;
|
||||
}, [batches, selectedBatchId]);
|
||||
const selectedBatchQuery = useQuery({
|
||||
queryKey: ["payment-batches", "detail", selectedBatchId],
|
||||
enabled: Boolean(selectedBatchId),
|
||||
queryFn: () => fetchPaymentBatchDetail(selectedBatchId),
|
||||
});
|
||||
const selectedBatch = selectedBatchQuery.data?.item ?? null;
|
||||
const receiptAttachmentsQuery = useQuery({
|
||||
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
||||
enabled: Boolean(selectedBatch?.id),
|
||||
@@ -143,11 +147,11 @@ export function PaymentBatchesPage() {
|
||||
const canEditSelectedBatch =
|
||||
canManageBatch &&
|
||||
!!selectedBatch &&
|
||||
["draft", "reviewed", "failed"].includes(selectedBatch.status);
|
||||
["reviewed", "failed"].includes(selectedBatch.status);
|
||||
const canEditReceiptAttachments =
|
||||
canManageBatch &&
|
||||
!!selectedBatch &&
|
||||
selectedBatch.status !== "cancelled";
|
||||
["reviewed", "failed"].includes(selectedBatch.status);
|
||||
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
|
||||
const canReviewSelectedBatch =
|
||||
canReviewBatch &&
|
||||
@@ -194,11 +198,13 @@ export function PaymentBatchesPage() {
|
||||
createPaymentBatch({
|
||||
entityType: "reimbursement",
|
||||
entityIds: selectedCandidateIds,
|
||||
defaultPaymentChannel: selectedPaymentChannel,
|
||||
}),
|
||||
onSuccess: async ({ item }) => {
|
||||
setError("");
|
||||
setSelectedCandidateIds([]);
|
||||
setCandidateSearch("");
|
||||
setSelectedPaymentChannel("bank");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("id", item.id);
|
||||
setSearchParams(next, { replace: true });
|
||||
@@ -214,7 +220,9 @@ export function PaymentBatchesPage() {
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: async (batchId: string) => {
|
||||
const batch = listQuery.data?.items.find((item) => item.id === batchId);
|
||||
const batch = selectedBatch?.id === batchId
|
||||
? selectedBatch
|
||||
: listQuery.data?.items.find((item) => item.id === batchId);
|
||||
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
|
||||
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
|
||||
},
|
||||
@@ -261,11 +269,10 @@ export function PaymentBatchesPage() {
|
||||
});
|
||||
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; paymentChannelOverride?: PaymentChannel | null }) =>
|
||||
mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null }) =>
|
||||
updatePaymentBatchItem(payload.batchId, payload.itemId, {
|
||||
paymentVoucherNoOverride: payload.paymentVoucherNoOverride,
|
||||
paymentDateOverride: payload.paymentDateOverride,
|
||||
paymentChannelOverride: payload.paymentChannelOverride,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
@@ -430,8 +437,23 @@ export function PaymentBatchesPage() {
|
||||
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-w-sm space-y-2">
|
||||
<Label htmlFor="payment-batch-default-channel">付款渠道</Label>
|
||||
<select
|
||||
id="payment-batch-default-channel"
|
||||
data-testid="payment-batch-default-channel"
|
||||
className="select-std w-full"
|
||||
value={selectedPaymentChannel}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)}
|
||||
>
|
||||
{PAYMENT_CHANNEL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>{paymentChannelLabel(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。
|
||||
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
|
||||
@@ -441,7 +463,22 @@ export function PaymentBatchesPage() {
|
||||
) : null}
|
||||
|
||||
{selectedBatchId ? (
|
||||
selectedBatch ? (
|
||||
selectedBatchQuery.isLoading ? (
|
||||
<div className="finance-command-panel text-sm text-zinc-500">正在加载付款批次...</div>
|
||||
) : selectedBatchQuery.isError ? (
|
||||
<div className="finance-command-panel flex flex-wrap items-center justify-between gap-3 text-sm text-red-600">
|
||||
<span>{resolveError(selectedBatchQuery.error)}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={selectedBatchQuery.isFetching}
|
||||
onClick={() => void selectedBatchQuery.refetch()}
|
||||
>
|
||||
{selectedBatchQuery.isFetching ? "重新加载中..." : "重新加载"}
|
||||
</Button>
|
||||
</div>
|
||||
) : selectedBatch ? (
|
||||
<div className="space-y-5">
|
||||
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -586,31 +623,42 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-4">
|
||||
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
|
||||
</div>
|
||||
{["reviewed", "failed"].includes(selectedBatch.status) ? (
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3" data-testid="payment-batch-item-payment-fields">
|
||||
<Input
|
||||
aria-label={`付款凭证号 ${item.entityCode}`}
|
||||
defaultValue={item.paymentVoucherNoOverride ?? ""}
|
||||
placeholder="凭证号"
|
||||
placeholder="付款凭证号"
|
||||
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
|
||||
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
|
||||
/>
|
||||
<DateTextInput
|
||||
aria-label={`实际付款日期 ${item.entityCode}`}
|
||||
defaultValue={item.paymentDateOverride ?? ""}
|
||||
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 || !["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>
|
||||
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{paymentChannelLabel(option)}</option>)}
|
||||
</select>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
|
||||
</div>
|
||||
</div>
|
||||
) : ["executed", "cancelled"].includes(selectedBatch.status) ? (
|
||||
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-3" data-testid="payment-batch-item-payment-readonly">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">付款凭证号</div><div className="detail-fact-card__value">{item.paymentVoucherNoOverride || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">实际付款日期</div><div className="detail-fact-card__value">{formatDisplayDate(item.paymentDateOverride ?? selectedBatch.defaultPaymentDate)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">付款结果</div><div className="detail-fact-card__value">{item.failureMessage || paymentItemStatusLabel(item.status)}</div></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
付款复核通过后,由财务登记付款凭证号和实际付款日期。
|
||||
</div>
|
||||
)}
|
||||
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
|
||||
</div>
|
||||
))}
|
||||
@@ -621,7 +669,11 @@ export function PaymentBatchesPage() {
|
||||
<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">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{selectedBatch.status === "draft"
|
||||
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
|
||||
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
|
||||
</p>
|
||||
</div>
|
||||
{canUploadReceipt ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -686,12 +738,12 @@ export function PaymentBatchesPage() {
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
{canManageBatch ? (
|
||||
{canEditReceiptAttachments ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={selectedBatch.status === "cancelled" || Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
|
||||
disabled={Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionReason("");
|
||||
setReasonAction({
|
||||
|
||||
@@ -415,6 +415,8 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
|
||||
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
|
||||
|
||||
await candidateRow.getByRole("checkbox").check();
|
||||
await page.getByTestId("payment-batch-default-channel").selectOption("cash");
|
||||
await expect(page.getByText("按现金提交负责人复核", { exact: false })).toBeVisible();
|
||||
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`));
|
||||
@@ -423,6 +425,7 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
|
||||
expect(createPayload).toEqual({
|
||||
entityType: "reimbursement",
|
||||
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
|
||||
defaultPaymentChannel: "cash",
|
||||
});
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
@@ -156,7 +156,6 @@ function paymentBatch(status: "draft" | "reviewed" | "failed", suffix: string) {
|
||||
status: failed ? "failed" : "pending",
|
||||
failureCode: failed ? "BANK_RECEIPT_REQUIRED" : null,
|
||||
failureMessage: failed ? "缺少银行回单附件" : null,
|
||||
snapshotJson: {},
|
||||
createdAt: "2026-05-27T09:05:00.000Z",
|
||||
openRiskFlags: failed ? [riskFlag] : [],
|
||||
},
|
||||
|
||||
@@ -114,9 +114,16 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
|
||||
await expect(candidateRow).toBeVisible();
|
||||
await candidateRow.getByRole("checkbox").check();
|
||||
await createCard.getByTestId("payment-batch-default-channel").selectOption("bank");
|
||||
await createCard.getByRole("button", { name: "创建并提交复核" }).click();
|
||||
|
||||
const createBatchPayload = (await (await createBatchResponsePromise).json()) as {
|
||||
const createBatchResponse = await createBatchResponsePromise;
|
||||
expect(createBatchResponse.request().postDataJSON()).toMatchObject({
|
||||
entityType: "reimbursement",
|
||||
entityIds: [reimbursementId],
|
||||
defaultPaymentChannel: "bank",
|
||||
});
|
||||
const createBatchPayload = (await createBatchResponse.json()) as {
|
||||
data?: { item?: { id?: string; name?: string } };
|
||||
};
|
||||
const batchId = createBatchPayload.data?.item?.id;
|
||||
@@ -130,19 +137,28 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
await expect(batchDetailCard).toContainText("待付款复核");
|
||||
await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理");
|
||||
await expect(batchDetailCard).toContainText("0/1");
|
||||
const draftItemsPanel = page.getByTestId("payment-batch-items-panel");
|
||||
await expect(draftItemsPanel).toContainText(EMPLOYEE.name);
|
||||
await expect(draftItemsPanel).toContainText("开户银行");
|
||||
await expect(draftItemsPanel).toContainText("银行账户");
|
||||
await expect(draftItemsPanel).toContainText("银行转账");
|
||||
await expect(draftItemsPanel.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
|
||||
const draftReceiptPanel = page.getByTestId("payment-batch-receipt-panel");
|
||||
await expect(draftReceiptPanel).toContainText("付款复核通过后,由财务在线下完成付款并上传银行回单");
|
||||
await expect(draftReceiptPanel.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
|
||||
await expect(draftReceiptPanel.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
|
||||
|
||||
await logout(page, FINANCE.name);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto("/ap-operations");
|
||||
await page.getByRole("tab", { name: /待付款/ }).click();
|
||||
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
|
||||
await expect(pendingRow).toBeVisible();
|
||||
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
|
||||
await expect(page.getByRole("dialog")).toContainText(batchName);
|
||||
await page.getByPlaceholder("请输入复核意见").fill("R78 focused regression review");
|
||||
await page.getByRole("button", { name: "提交复核" }).click();
|
||||
await expect(pendingRow).toContainText("复核 1/1");
|
||||
await page.goto(`/payment-batches?id=${batchId}`);
|
||||
const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
|
||||
await expect(managerBatchDetail).toBeVisible();
|
||||
await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name);
|
||||
await managerBatchDetail.getByRole("button", { name: "复核通过" }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect(managerBatchDetail).toContainText("1/1");
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
@@ -153,6 +169,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
await expect(executedBatchDetailCard).toContainText("1/1");
|
||||
const receiptCard = page.getByTestId("payment-batch-receipt-panel");
|
||||
await expect(receiptCard).toBeVisible();
|
||||
await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible();
|
||||
await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
|
||||
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
|
||||
const receiptUploadResponsePromise = page.waitForResponse((response) => {
|
||||
@@ -181,6 +198,12 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await executeBatchResponsePromise;
|
||||
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
|
||||
await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible();
|
||||
await expect(receiptCard.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
|
||||
await expect(receiptCard.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
|
||||
await expect(receiptCard.getByRole("button", { name: "作废" })).toHaveCount(0);
|
||||
await expect(receiptCard.getByRole("button", { name: "下载" })).toBeVisible();
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
|
||||
@@ -124,7 +124,7 @@ function reimbursementDetail() {
|
||||
};
|
||||
}
|
||||
|
||||
function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = false) {
|
||||
function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" = "reviewed", highRisk = false) {
|
||||
return {
|
||||
id: paymentBatchId,
|
||||
batchNo: "PAY-ACTION-001",
|
||||
@@ -169,10 +169,12 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = fals
|
||||
paymentVoucherNoOverride: null,
|
||||
paymentDateOverride: null,
|
||||
paymentChannelOverride: null,
|
||||
payeeName: "合成收款员工",
|
||||
bankName: "合成银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
status: "pending",
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
snapshotJson: {},
|
||||
createdAt: "2026-05-15T08:00:00.000Z",
|
||||
openRiskFlags: [],
|
||||
},
|
||||
@@ -183,7 +185,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = fals
|
||||
async function mockSystemActionApis(
|
||||
page: Page,
|
||||
options: {
|
||||
batchStatus?: "draft" | "reviewed";
|
||||
batchStatus?: "draft" | "reviewed" | "executed" | "cancelled";
|
||||
hasReceipt?: boolean;
|
||||
receiptQueryError?: boolean;
|
||||
highRisk?: boolean;
|
||||
@@ -198,6 +200,7 @@ async function mockSystemActionApis(
|
||||
deleteReimbursementAttachment: 0,
|
||||
exportReimbursements: 0,
|
||||
executePaymentBatch: 0,
|
||||
paymentBatchDetail: 0,
|
||||
uploadPaymentReceipt: 0,
|
||||
voidPaymentReceipt: 0,
|
||||
attachmentDeleteReasons: [] as string[],
|
||||
@@ -330,7 +333,24 @@ async function mockSystemActionApis(
|
||||
}
|
||||
|
||||
if (path === "/api/payment-batches" && request.method() === "GET") {
|
||||
await fulfillJson(route, { items: [paymentBatch(batchStatus, options.highRisk)] });
|
||||
const listItem = paymentBatch(batchStatus, options.highRisk);
|
||||
await fulfillJson(route, {
|
||||
items: [{
|
||||
...listItem,
|
||||
items: listItem.items.map((item) => ({
|
||||
...item,
|
||||
payeeName: null,
|
||||
bankName: null,
|
||||
bankAccountMasked: null,
|
||||
})),
|
||||
}],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/payment-batches/${paymentBatchId}` && request.method() === "GET") {
|
||||
calls.paymentBatchDetail += 1;
|
||||
await fulfillJson(route, { item: paymentBatch(batchStatus, options.highRisk) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -502,6 +522,34 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单");
|
||||
});
|
||||
|
||||
test("付款详情按批次状态渐进展示收款依据和财务动作", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page, { batchStatus: "draft", hasReceipt: true });
|
||||
await login(page, user);
|
||||
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||
|
||||
const draftItems = page.getByTestId("payment-batch-items-panel");
|
||||
await expect.poll(() => calls.paymentBatchDetail).toBe(1);
|
||||
await expect(draftItems).toContainText("合成收款员工");
|
||||
await expect(draftItems).toContainText("合成银行");
|
||||
await expect(draftItems).toContainText("6222********5678");
|
||||
await expect(draftItems).toContainText("银行转账");
|
||||
await expect(draftItems.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
|
||||
await expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "作废" })).toHaveCount(0);
|
||||
|
||||
await page.unrouteAll({ behavior: "wait" });
|
||||
await mockSystemActionApis(page, { batchStatus: "executed", hasReceipt: true });
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
|
||||
await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible();
|
||||
await expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "作废" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "下载" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page);
|
||||
await login(page, user);
|
||||
|
||||
+132
-24
@@ -17,6 +17,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 { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
|
||||
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
buildAttachmentContentDisposition,
|
||||
@@ -61,7 +62,6 @@ const CREATE_SCHEMA = z.object({
|
||||
const PATCH_ITEM_SCHEMA = z.object({
|
||||
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
|
||||
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.nullable().optional(),
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const REVIEW_SCHEMA = z.object({
|
||||
@@ -492,7 +492,22 @@ async function lockPaymentEntities(tx, entityType, entityIds) {
|
||||
`);
|
||||
}
|
||||
|
||||
async function loadEntitySnapshots(tx, entityType, entityIds) {
|
||||
function buildPayeeSnapshot(config, owner) {
|
||||
const encryptedAccount = owner?.bank_account_encrypted;
|
||||
const bankAccount = encryptedAccount
|
||||
? decryptStringOrThrow(config, encryptedAccount)
|
||||
: null;
|
||||
if (encryptedAccount && !bankAccount) {
|
||||
throw new Error("Payee bank account could not be decrypted");
|
||||
}
|
||||
return {
|
||||
name: owner?.name ?? null,
|
||||
bankName: owner?.bank_name ?? null,
|
||||
bankAccountMasked: maskBankAccount(bankAccount),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadEntitySnapshots(tx, entityType, entityIds, config) {
|
||||
if (entityType === "reimbursement") {
|
||||
const items = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: entityIds } },
|
||||
@@ -507,6 +522,13 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
|
||||
payment_date: true,
|
||||
payment_channel: true,
|
||||
paid_at: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return items.map((item) => ({
|
||||
@@ -525,6 +547,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
|
||||
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannel: item.payment_channel ?? null,
|
||||
paidAt: item.paid_at?.toISOString?.() ?? null,
|
||||
payee: buildPayeeSnapshot(config, item.owner),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -577,7 +600,77 @@ function ensureBatchEntityStatus(entityType, snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBatch(batch, openRiskFlags = []) {
|
||||
function normalizeSnapshotText(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
const compactDigits = normalized.replace(/[\s-]/g, "");
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length > 100 ||
|
||||
normalized.startsWith("v1:") ||
|
||||
/^\d{9,32}$/.test(compactDigits)
|
||||
) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMaskedBankAccount(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > 64) return null;
|
||||
if (/^\*{1,8}$/.test(normalized)) return normalized;
|
||||
return /^[0-9A-Za-z]{4}\*+[0-9A-Za-z]{4}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function readSnapshotPayee(snapshotJson) {
|
||||
const payee = snapshotJson?.payee;
|
||||
if (!payee || typeof payee !== "object" || Array.isArray(payee)) {
|
||||
return null;
|
||||
}
|
||||
const bankAccountMasked = normalizeMaskedBankAccount(payee.bankAccountMasked);
|
||||
const name = normalizeSnapshotText(payee.name);
|
||||
const bankName = normalizeSnapshotText(payee.bankName);
|
||||
if (
|
||||
(payee.name != null && !name) ||
|
||||
(payee.bankName != null && !bankName) ||
|
||||
(payee.bankAccountMasked != null && !bankAccountMasked)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
bankName,
|
||||
bankAccountMasked,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLegacyPayees(tx, batch, config) {
|
||||
if (batch.entity_type !== "reimbursement") {
|
||||
return new Map();
|
||||
}
|
||||
const entityIds = (batch.items ?? [])
|
||||
.filter((item) => !readSnapshotPayee(item.snapshot_json))
|
||||
.map((item) => item.entity_id);
|
||||
if (!entityIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
const reimbursements = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: Array.from(new Set(entityIds)) } },
|
||||
select: {
|
||||
id: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return new Map(reimbursements.map((item) => [item.id, buildPayeeSnapshot(config, item.owner)]));
|
||||
}
|
||||
|
||||
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
|
||||
const includePayee = options.includePayee === true;
|
||||
const batchRisk = summarizeBatchRisk(batch);
|
||||
const riskMap = new Map();
|
||||
for (const item of openRiskFlags) {
|
||||
@@ -615,7 +708,11 @@ function mapBatch(batch, openRiskFlags = []) {
|
||||
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
|
||||
cancelledBy: batch.cancelled_by ?? null,
|
||||
cancelledReason: batch.cancelled_reason ?? null,
|
||||
items: (batch.items ?? []).map((item) => ({
|
||||
items: (batch.items ?? []).map((item) => {
|
||||
const payee = includePayee
|
||||
? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {}
|
||||
: {};
|
||||
return {
|
||||
id: item.id,
|
||||
entityId: item.entity_id,
|
||||
entityCode: item.entity_code,
|
||||
@@ -625,14 +722,22 @@ function mapBatch(batch, openRiskFlags = []) {
|
||||
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: item.payment_channel_override ?? null,
|
||||
payeeName: payee.name ?? null,
|
||||
bankName: payee.bankName ?? null,
|
||||
bankAccountMasked: payee.bankAccountMasked ?? null,
|
||||
status: item.status,
|
||||
failureCode: item.failure_code ?? null,
|
||||
failureMessage: item.failure_message ?? null,
|
||||
snapshotJson: item.snapshot_json,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
openRiskFlags: riskMap.get(item.entity_id) ?? [],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
|
||||
const legacyPayees = await loadLegacyPayees(tx, batch, config);
|
||||
return mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true });
|
||||
}
|
||||
|
||||
async function listOpenRiskFlagsForBatch(tx, batch) {
|
||||
@@ -1217,7 +1322,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
|
||||
await lockPaymentEntities(tx, body.entityType, entityIds);
|
||||
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds);
|
||||
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds, fastify.config);
|
||||
if (snapshots.length !== entityIds.length) {
|
||||
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
|
||||
}
|
||||
@@ -1312,7 +1417,8 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
|
||||
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } });
|
||||
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config);
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } });
|
||||
});
|
||||
|
||||
fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -1322,7 +1428,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
|
||||
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -1371,7 +1479,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const batchId = parseId(request.params?.id);
|
||||
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
if (batch.status === "cancelled") {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
||||
}
|
||||
|
||||
@@ -1397,7 +1505,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
createdItems = await fastify.prisma.$transaction(async (tx) => {
|
||||
const lockedBatch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch);
|
||||
if (lockedBatch.status === "cancelled") {
|
||||
if (!["reviewed", "failed"].includes(lockedBatch.status)) {
|
||||
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
||||
}
|
||||
|
||||
@@ -1653,7 +1761,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (batch.status === "cancelled") {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
||||
}
|
||||
const attachment = await tx.paymentBatchReceiptAttachment.findFirst({
|
||||
@@ -1705,8 +1813,8 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (!["draft", "reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "only_open_batch_editable" }, 409);
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "payment_metadata_not_editable" }, 409);
|
||||
}
|
||||
const item = batch.items.find((entry) => entry.id === itemId);
|
||||
if (!item) {
|
||||
@@ -1719,7 +1827,6 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const before = {
|
||||
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: item.payment_channel_override ?? null,
|
||||
};
|
||||
const next = await tx.paymentBatchItem.update({
|
||||
where: { id: itemId },
|
||||
@@ -1730,17 +1837,13 @@ async function paymentBatchesRoutes(fastify) {
|
||||
...(body.paymentDateOverride !== undefined
|
||||
? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null }
|
||||
: {}),
|
||||
...(body.paymentChannelOverride !== undefined
|
||||
? { payment_channel_override: body.paymentChannelOverride ?? null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const after = {
|
||||
paymentVoucherNoOverride: next.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: next.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: next.payment_channel_override ?? null,
|
||||
};
|
||||
if (batch.status !== "draft" && JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
@@ -1758,7 +1861,6 @@ async function paymentBatchesRoutes(fastify) {
|
||||
id: updated.id,
|
||||
paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: updated.payment_channel_override ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1859,7 +1961,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
|
||||
|
||||
return sendData(reply, request, { item: mapBatch(result, []) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -2027,7 +2131,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
}
|
||||
|
||||
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
|
||||
return sendData(reply, request, { item: mapBatch(result, openRiskFlags) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -2078,7 +2184,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
});
|
||||
return loadBatchWithItems(tx, batchId);
|
||||
});
|
||||
return sendData(reply, request, { item: mapBatch(cancelled, []) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { Client } = require("pg");
|
||||
const { buildApp } = require("../app");
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
const { encryptStringOrThrow } = require("../services/crypto");
|
||||
const { currentDateString } = require("../utils/date");
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
@@ -93,9 +94,9 @@ function validatePaymentConcurrencyEnvironment(env = process.env) {
|
||||
fail("database_confirmation_mismatch");
|
||||
}
|
||||
requireValue(env, "JWT_SECRET");
|
||||
requireValue(env, "FIELD_ENCRYPTION_KEY");
|
||||
const fieldEncryptionKey = requireValue(env, "FIELD_ENCRYPTION_KEY");
|
||||
|
||||
return { databaseName, databaseUrl };
|
||||
return { databaseName, databaseUrl, fieldEncryptionKey };
|
||||
}
|
||||
|
||||
function responseJson(response) {
|
||||
@@ -200,7 +201,7 @@ async function runBlockedRace({
|
||||
}
|
||||
}
|
||||
|
||||
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }) {
|
||||
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId, fieldEncryptionKey }) {
|
||||
return {
|
||||
id,
|
||||
employee_no: `QA-${runKey}-${kind}`,
|
||||
@@ -211,7 +212,10 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
|
||||
legal_entity_id: legalEntityId,
|
||||
title: "并发验收",
|
||||
hire_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
bank_account_encrypted: `synthetic-${runKey}`,
|
||||
bank_account_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey },
|
||||
`6222${runKey.slice(0, 8)}5678`,
|
||||
),
|
||||
bank_name: "合成银行",
|
||||
password_hash: "unused-by-token-based-concurrency-check",
|
||||
token_version: 0,
|
||||
@@ -219,7 +223,7 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSyntheticFixture(prisma) {
|
||||
async function seedSyntheticFixture(prisma, fieldEncryptionKey) {
|
||||
const runKey = crypto.randomBytes(6).toString("hex");
|
||||
const department = await prisma.department.create({
|
||||
data: { name: `合成部门-${runKey}` },
|
||||
@@ -244,6 +248,7 @@ async function seedSyntheticFixture(prisma) {
|
||||
kind,
|
||||
departmentId: department.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
fieldEncryptionKey,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -772,7 +777,7 @@ async function runPaymentPostgresConcurrencyCheck(env = process.env) {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
let app = null;
|
||||
try {
|
||||
const fixture = await seedSyntheticFixture(prisma);
|
||||
const fixture = await seedSyntheticFixture(prisma, context.fieldEncryptionKey);
|
||||
app = await buildApp({ startJobs: async () => undefined });
|
||||
await app.ready();
|
||||
const tokens = {
|
||||
|
||||
@@ -12,6 +12,7 @@ const notifications = require("../../../src/services/notifications");
|
||||
const email = require("../../../src/services/email");
|
||||
const notificationDispatcher = require("../../../src/services/notificationDispatcher");
|
||||
const erpJournal = require("../../../src/services/integrations/erpJournal");
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
|
||||
const evaluateFeatureFlagMock = vi.fn();
|
||||
const createNotificationsWithRecordsMock = vi.fn(async () => []);
|
||||
@@ -52,6 +53,8 @@ const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
|
||||
const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii");
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
const SYNTHETIC_BANK_ACCOUNT = `6222${"0".repeat(8)}5678`;
|
||||
|
||||
function buildMultipartRequest({ files, fields = {} }) {
|
||||
const boundary = "----codex-payment-batch-receipt-boundary";
|
||||
@@ -85,6 +88,24 @@ function buildMultipartRequest({ files, fields = {} }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function listStoredReceiptFiles(rootDir) {
|
||||
const files = [];
|
||||
const pending = [rootDir];
|
||||
while (pending.length) {
|
||||
const current = pending.pop();
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function buildPaymentBatch(overrides = {}) {
|
||||
return {
|
||||
id: BATCH_ID,
|
||||
@@ -153,6 +174,11 @@ function buildReimbursement(overrides = {}) {
|
||||
id: OWNER_ID,
|
||||
employee_no: "EMP001",
|
||||
name: "测试员工",
|
||||
bank_name: "测试银行",
|
||||
bank_account_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
),
|
||||
legal_entity: {
|
||||
code: "HYB",
|
||||
},
|
||||
@@ -366,7 +392,7 @@ function buildPrisma(options = {}) {
|
||||
options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1),
|
||||
),
|
||||
findMany: vi.fn(async ({ select } = {}) => {
|
||||
if (select?.owner) {
|
||||
if (select?.owner?.select?.employee_no) {
|
||||
return state.reservedEntityIds.includes(state.reimbursement.id) ? [] : [{ ...state.reimbursement }];
|
||||
}
|
||||
const reimbursements = [state.reimbursement, ...state.additionalReimbursements];
|
||||
@@ -518,6 +544,7 @@ async function buildApp(options = {}) {
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
appTimezone: options.appTimezone ?? "Asia/Shanghai",
|
||||
fieldEncryptionKey: FIELD_ENCRYPTION_KEY,
|
||||
storageDir: tempDir,
|
||||
maxUploadSizeBytes: 5 * 1024 * 1024,
|
||||
paymentBatchEnabled: true,
|
||||
@@ -905,8 +932,8 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows a manager with payment review permission to read the batch list and detail", async () => {
|
||||
const { app } = await buildApp({
|
||||
it("keeps the batch list compact and loads payee review facts only for detail", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: REVIEWER_ID,
|
||||
department_id: "dept-1",
|
||||
@@ -920,16 +947,68 @@ describe("payment batch reimbursement routes", () => {
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json().data.items[0].id).toBe(BATCH_ID);
|
||||
expect(listResponse.json().data.items[0].items[0]).toMatchObject({
|
||||
payeeName: null,
|
||||
bankName: null,
|
||||
bankAccountMasked: null,
|
||||
});
|
||||
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson");
|
||||
expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
const detailResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json().data.items[0].id).toBe(BATCH_ID);
|
||||
expect(detailResponse.statusCode).toBe(200);
|
||||
expect(detailResponse.json().data.item.id).toBe(BATCH_ID);
|
||||
expect(detailResponse.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("never exposes an untrusted stored payment snapshot", async () => {
|
||||
const ciphertextSentinel = "v1:synthetic:encrypted:payload";
|
||||
const { app } = await buildApp({
|
||||
batch: {
|
||||
items: [{
|
||||
...buildPaymentBatch().items[0],
|
||||
snapshot_json: {
|
||||
payee: {
|
||||
name: SYNTHETIC_BANK_ACCOUNT,
|
||||
bankName: ciphertextSentinel,
|
||||
bankAccountMasked: SYNTHETIC_BANK_ACCOUNT,
|
||||
},
|
||||
bank_account_encrypted: ciphertextSentinel,
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
|
||||
expect(response.body).not.toContain(SYNTHETIC_BANK_ACCOUNT);
|
||||
expect(response.body).not.toContain(ciphertextSentinel);
|
||||
expect(response.body).not.toContain("bank_account_encrypted");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -1068,10 +1147,76 @@ describe("payment batch reimbursement routes", () => {
|
||||
status: "draft",
|
||||
}),
|
||||
});
|
||||
expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
snapshot_json: expect.objectContaining({
|
||||
payee: {
|
||||
name: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(response.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
|
||||
expect(JSON.stringify(response.json())).not.toContain("bank_account_encrypted");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("persists the payment channel selected at creation as the review fact", async () => {
|
||||
const { app, tx } = await buildApp();
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
entityType: "reimbursement",
|
||||
entityIds: [REIMBURSEMENT_ID],
|
||||
defaultPaymentChannel: "alipay",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ default_payment_channel: "alipay" }),
|
||||
});
|
||||
expect(response.json().data.item.defaultPaymentChannel).toBe("alipay");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed before batch writes when the payee account cannot be decrypted", async () => {
|
||||
const unreadableAccount = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: Buffer.alloc(32, 8).toString("base64") },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
);
|
||||
const { app, tx } = await buildApp({
|
||||
reimbursement: {
|
||||
owner: {
|
||||
...buildReimbursement().owner,
|
||||
bank_account_encrypted: unreadableAccount,
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("notifies one active chief executive before falling back to managers", async () => {
|
||||
const chiefExecutiveId = "99999999-1111-4111-8111-111111111111";
|
||||
const { app, tx } = await buildApp({
|
||||
@@ -1370,6 +1515,13 @@ describe("payment batch reimbursement routes", () => {
|
||||
});
|
||||
expect(detail.statusCode).toBe(200);
|
||||
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
|
||||
expect(detail.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(state.batch.items[0].snapshot_json).toEqual({});
|
||||
expect(JSON.stringify(detail.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
|
||||
expect(state.batch.default_payment_channel).toBeNull();
|
||||
|
||||
const execute = await app.inject({
|
||||
@@ -1513,7 +1665,6 @@ describe("payment batch reimbursement routes", () => {
|
||||
payload: {
|
||||
paymentVoucherNoOverride: "BANK-RECEIPT-001",
|
||||
paymentDateOverride: "2026-04-30",
|
||||
paymentChannelOverride: "bank",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1521,11 +1672,10 @@ describe("payment batch reimbursement routes", () => {
|
||||
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",
|
||||
payment_channel_override: null,
|
||||
});
|
||||
expect(state.batch.items[0].payment_date_override).toEqual(new Date("2026-04-30T00:00:00.000Z"));
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
||||
@@ -1544,8 +1694,83 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uploads, downloads and voids bank receipt attachments without physical deletion", async () => {
|
||||
const { app, prisma, state, tempDir } = await buildApp({ receiptAttachments: [] });
|
||||
it("allows payment metadata updates while retrying a failed batch", async () => {
|
||||
const { app, state, tx } = await buildApp({
|
||||
batch: {
|
||||
status: "failed",
|
||||
items: [{ ...buildPaymentBatch().items[0], status: "failed" }],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { paymentVoucherNoOverride: "RETRY-001" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(state.batch.items[0].payment_voucher_no_override).toBe("RETRY-001");
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ action: "PAYMENT_BATCH_ITEM_METADATA_UPDATED" }),
|
||||
}),
|
||||
);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps draft payment metadata immutable and rejects channel overrides", async () => {
|
||||
const draft = await buildApp({ batch: { status: "draft", reviewed_at: null, reviewed_by: null } });
|
||||
const draftResponse = await draft.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { paymentVoucherNoOverride: "SHOULD-NOT-WRITE" },
|
||||
});
|
||||
|
||||
expect(draftResponse.statusCode).toBe(409);
|
||||
expect(draftResponse.json().error.details.status).toBe("payment_metadata_not_editable");
|
||||
expect(draft.tx.paymentBatchItem.update).not.toHaveBeenCalled();
|
||||
expect(draft.tx.auditLog.create).not.toHaveBeenCalled();
|
||||
await draft.app.close();
|
||||
|
||||
const reviewed = await buildApp();
|
||||
const channelResponse = await reviewed.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { paymentChannelOverride: "cash" },
|
||||
});
|
||||
|
||||
expect(channelResponse.statusCode).toBe(400);
|
||||
expect(reviewed.tx.paymentBatchItem.update).not.toHaveBeenCalled();
|
||||
expect(reviewed.tx.auditLog.create).not.toHaveBeenCalled();
|
||||
await reviewed.app.close();
|
||||
});
|
||||
|
||||
it.each(["executed", "cancelled"])("keeps %s payment metadata immutable", async (status) => {
|
||||
const { app, tx } = await buildApp({ batch: { status } });
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { paymentDateOverride: "2026-04-30" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.status).toBe("payment_metadata_not_editable");
|
||||
expect(tx.paymentBatchItem.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each(["reviewed", "failed"])(
|
||||
"uploads, downloads and voids bank receipt attachments in %s without physical deletion",
|
||||
async (status) => {
|
||||
const { app, prisma, state, tempDir } = await buildApp({
|
||||
batch: { status },
|
||||
receiptAttachments: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
@@ -1684,6 +1909,90 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["draft", "executed", "cancelled"])(
|
||||
"rejects receipt upload and void in %s without attachment side effects",
|
||||
async (status) => {
|
||||
const { app, tx, state, tempDir } = await buildApp({ batch: { status } });
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{
|
||||
filename: "blocked-receipt.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: SYNTHETIC_RECEIPT_PDF,
|
||||
}],
|
||||
});
|
||||
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(409);
|
||||
expect(uploadResponse.json().error.details.status).toBe("receipt_attachment_not_editable");
|
||||
expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled();
|
||||
expect(await listStoredReceiptFiles(tempDir)).toEqual([]);
|
||||
|
||||
const originalAttachment = { ...state.receiptAttachments[0] };
|
||||
const voidResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/void`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "状态不允许时不得作废" },
|
||||
});
|
||||
expect(voidResponse.statusCode).toBe(409);
|
||||
expect(voidResponse.json().error.details.status).toBe("receipt_attachment_not_editable");
|
||||
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||
expect(state.receiptAttachments[0]).toEqual(originalAttachment);
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("removes uploaded receipt objects when the locked batch becomes read-only", async () => {
|
||||
const { app, tx, state, tempDir } = await buildApp({ receiptAttachments: [] });
|
||||
tx.paymentBatch.findUnique
|
||||
.mockImplementationOnce(async () => ({
|
||||
...state.batch,
|
||||
items: state.batch.items.map((item) => ({ ...item })),
|
||||
_count: { receipt_attachments: 0 },
|
||||
}))
|
||||
.mockImplementationOnce(async () => ({
|
||||
...state.batch,
|
||||
status: "executed",
|
||||
items: state.batch.items.map((item) => ({ ...item })),
|
||||
_count: { receipt_attachments: 0 },
|
||||
}));
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{
|
||||
filename: "race-receipt.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: SYNTHETIC_RECEIPT_PDF,
|
||||
}],
|
||||
});
|
||||
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(409);
|
||||
expect(response.json().error.details.status).toBe("receipt_attachment_not_editable");
|
||||
expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
expect(await listStoredReceiptFiles(tempDir)).toEqual([]);
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("checks receipt upload permission before feature evaluation or multipart consumption", async () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户