实现企业银行账户与建行制单(corporate-bank-account-payment-order)

这个提交包含在:
2026-08-02 23:36:40 +08:00
父节点 f946d64539
当前提交 5261d2849b
共修改 46 个文件,包含 2751 行新增和 73 行删除
+7
查看文件
@@ -33,6 +33,13 @@ jobs:
working-directory: client
run: npm ci
- name: Install BIFF8 roundtrip verifier
run: python3 -m pip install --disable-pip-version-check xlrd==2.0.2
- name: Verify payment-order BIFF8 contract
working-directory: server
run: node scripts/check-payment-order-xls-roundtrip.js
- name: Run minimal checks
run: ./scripts/check-minimal.sh
+101
查看文件
@@ -18,6 +18,107 @@
redacted form.
- `scripts/qa/check-trellis-governance.mjs` must pass before commit.
## Scenario: Bank Account Snapshots And Payment-Order Export
### 1. Scope / Trigger
- Trigger: company payment-account maintenance, employee payee-bank maintenance,
payment-batch creation, full-account reveal or bank payment-order export.
- OA prepares payment evidence and a bank-import file; it does not call a bank
payment API or treat the generated file as proof of payment.
### 2. Signatures
- DB: `BankInstitution`, `PaymentAccount`, `Employee.bank_institution_id`,
`PaymentBatch.payment_account_snapshot_json`, item `snapshot_json.payee`, and
`payment_order_export_*` fields.
- Reveal APIs use `POST` with JSON `{ "reason": "2-500 chars" }`; ordinary read
APIs return only bank name and masked account.
- Payment-order API: `POST /api/payment-batches/:id/payment-order` with a reason;
success is an in-memory BIFF8 `.xls` download with `Cache-Control: no-store`.
### 3. Contracts
- Employee account editing remains HR/admin-owned. Never grant
`employee.bank.manage` to finance, finance manager or system administrator.
- Company account editing is finance-manager/admin-owned; ordinary finance is
masked read plus reason-audited full reveal.
- A new bank batch freezes the exact existing ciphertext for payer and payees.
Copy ciphertext directly; decrypt only to calculate the masked suffix. Never
decrypt and re-encrypt while building a snapshot.
- Review UI reads masked values from the frozen snapshot. Finance manual-payment
reveal also reads the frozen item snapshot, not the employee's current bank
profile. An employee profile change after review must not change the payment
target for that batch.
- Ordinary batch, AP and export DTOs must explicitly select safe fields and must
never return raw snapshot JSON, `v1:` ciphertext or a full account.
- Payment-order preflight returns a safe blocker array containing fixed codes
and, when useful, the reimbursement business number. Collect every batch and
item blocker before returning; do not restore fail-fast "fix one, find one"
behavior. The UI maps those fixed codes to natural Chinese and keeps manual
payment visible for legacy or unsupported batches.
- Legacy batches without ciphertext snapshots may still complete through manual
payment plus receipt, but they can neither export a payment order nor reveal a
supposed frozen account through a live-profile fallback.
- A payment-order file is an efficiency artifact. A bank receipt remains the
hard evidence required before registering bank payment complete.
### 4. Validation & Error Matrix
- Missing/mixed legal entity, no unique active payer, missing bank institution,
missing account or decrypt failure -> reject batch creation with `409` and no
batch/item writes.
- First release bank code is not `CCB` for any payer/payee -> reject the whole
export; do not emit `02`, skip a row or create a partial file.
- Batch not `reviewed`, legacy snapshot or malformed snapshot -> reject export
with `409`; do not increment export count or send file bytes.
- Reveal without permission or approved finance business role -> `403`.
- Reveal/export without a 2-500 character reason -> `400 VALID_001` and no full
account/file response.
- Successful export audit/update failure -> no download response.
### 5. Good/Base/Bad Cases
- Good: review shows a masked frozen CCB account; finance supplies a reason and
temporarily reveals that same frozen account for manual transfer.
- Base: a historical batch displays compatibility metadata, proceeds by manual
payment and receipt, and clearly has no payment-order capability.
- Bad: export or manual-transfer reveal re-queries the employee profile after
review, silently uses a changed account, or returns raw snapshot JSON.
### 6. Tests Required
- Permission matrix for employee/company account manage, masked read and full
reveal; include an explicit system-admin denial.
- Privacy-negative assertions over batch/list/detail/AP responses for raw
accounts, `v1:` and raw snapshots.
- Batch creation tests for single legal entity, direct ciphertext copy,
CCB-only, decrypt failure and legacy behavior.
- BIFF8 generator tests plus independent `xlrd==2.0.2` readback for three sheets,
15 headers, seven string columns, leading-zero `01`, long accounts, Chinese,
amount formatting and deterministic row order.
- Build one synthetic batch with at least two independent payer/payee blockers;
assert the DTO/error contains both safe blocker entries and emits no file,
export counter or success audit.
### 7. Wrong vs Correct
#### Wrong
```js
const liveEmployee = await prisma.employee.findUnique({ where: { id: ownerId } });
return decryptStringOrThrow(config, liveEmployee.bank_account_encrypted);
```
#### Correct
```js
const encrypted = paymentBatchItem.snapshot_json?.payee?.bankAccountEncrypted;
assertReviewedFinanceReveal({ batch, actor, reason, encrypted });
await writeRevealAudit();
return decryptStringOrThrow(config, encrypted);
```
## Scenario: Defensive Security Finding Summaries
### 1. Scope / Trigger
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,178 @@
# 企业银行账户与付款制单设计
## 1. 边界与交付顺序
本任务在 OA 内建立付款准备能力,不连接银行 API、不提交付款、不代替网银复核。交付按同一任务内的三个层次顺序完成:
1. 银行机构与公司付款账户主数据。
2. 员工收款信息默认脱敏、规范化银行标识和付款批次冻结快照。
3. 已复核批次的 BIFF8 制单导出、使用状态与审计。
三层共享同一迁移和权限设计,拆成独立任务会使中间版本产生无法制单或无法建批次的短暂合同,因此本轮保留为一个复杂 Trellis task,按提交批次分层验证。
## 2. 数据模型
### 2.1 BankInstitution
新增银行机构主数据:
- `id` UUID
- `code` 唯一、不可变的规范化代码;首版至少存在 `CCB`
- `name` 展示名称
- `enabled`、`archived_at`
- `created_at/by`、`updated_at/by`
银行机构由 `payment.account.manage` 管理。员工收款资料与公司付款账户都引用同一主数据,不把银行名称字符串当身份键。已被引用的代码不能修改或物理删除;停用只阻止新绑定。
### 2.2 PaymentAccount
新增独立付款账户表并关联 `LegalEntity`:
- `id`、`legal_entity_id`、`bank_institution_id`
- `account_name`
- `account_number_encrypted`
- `enabled`、`archived_at`
- `created_at/by`、`updated_at/by`
PostgreSQL 使用部分唯一索引保证同一法人主体最多一条 `enabled=true AND archived_at IS NULL` 的账户;服务端先做自然中文校验,数据库索引负责并发兜底。
已启用账户不原地覆盖账号。变更操作在同一事务内归档旧记录并创建新记录,保留历史身份;停用只改变旧记录状态。完整账号沿用现有字段加密服务,普通 DTO只计算脱敏尾号。
### 2.3 Employee 与 PaymentBatch
- `Employee` 增加可空 `bank_institution_id`。存量数据不通过自由文本自动猜测;由 HR/admin 在 UI 中确认。
- `PaymentBatch` 增加可空 `legal_entity_id`、`payment_account_id`、内部付款账户快照、制单次数、最近制单时间和操作者。
- 报销付款项的 `snapshot_json.payee` 增加内部密文和银行机构代码;既有姓名、银行显示名和脱敏尾号继续保留。
付款账户快照仅存内部使用字段:账户记录 ID、法人主体 ID、账户名称、银行机构代码/名称、原始密文和脱敏尾号。API 映射层只返回名称、银行名称和脱敏尾号。
迁移全部新增为可空,禁止回填历史完整账号。历史批次因此保持可查看、可人工付款,但制单明确失败关闭。
## 3. 权限与威胁模型
新增权限:
| 权限 | 角色 | 用途 |
| --- | --- | --- |
| `payment.account.manage` | `finance_mgr`、`admin/*` | 银行机构与公司付款账户新增、替换、停用 |
| `payment.account.read` | `finance`、`finance_mgr`、`admin/*` | 公司账户脱敏列表/详情 |
| `payment.account.read_full` | `finance`、`finance_mgr`、`admin/*` | 填理由查看公司完整账号 |
| `payment.order.export` | 明确具有 `finance` 或 `finance_mgr` 业务角色者 | 已复核批次制单;单有 `admin/*` 不作为财务业务角色代办 |
`employee.bank.manage` 不授予财务或 system_admin。员工完整收款账号查看继续使用独立读取权限,但现有 GET 默认改为脱敏;完整值通过带原因的 POST 动作获取并先写审计。
公司付款账户维护防的是误配置、错误来源账户和缺少及时知情,不新增双人审批。真实受益人欺诈边界由“HR/admin 维护员工收款资料”和“finance/finance_mgr 制单”分离控制。
## 4. 主数据 API 与页面
### 4.1 公司付款账户
新增专用路由而不复用 Feature Flag:
- 脱敏列出银行机构与付款账户。
- 创建首个账户、替换当前账户、停用当前账户。
- POST 完整账号查看动作,body 必须有 `reason`。
管理入口放在 PC `管理 -> 基础资料/财务配置 -> 企业银行账户`。默认卡片只显示法人主体、账户名称、银行名称、脱敏尾号和状态;完整账号查看、替换、停用均使用明确动作与后果确认。移动端不提供配置入口。
账户新增、替换或停用在事务内写脱敏审计,并为所有 active admin 与 CEO 创建 OA 通知;通知不带完整账号、密文或本地路径,也不要求 CEO 具备账户主数据页面权限。
### 4.2 员工收款信息
人员档案增加收款信息区:
- HR/admin 可选银行机构并录入账号。
- finance/finance_mgr 只能看银行名称和脱敏尾号。
- 员工本人在个人资料看到自己的银行名称和脱敏尾号。
- 授权角色查看完整账号时填写原因;服务端先审计后返回,前端不会把完整值缓存到长期状态或普通查询缓存。
## 5. 批次创建与冻结快照
对 `entityType=reimbursement` 且银行渠道的新批次:
1. 在同一事务锁定所选报销。
2. 解析每张报销所有人的 `legal_entity_id`,要求非空且全批次一致。
3. 查询该法人主体唯一启用的建行付款账户。
4. 要求每位收款人有 `CCB` 银行机构与可用账号密文。
5. 直接复制员工现有密文,不解密后重新加密;只为脱敏尾号做一次受控解密。
6. 冻结付款账户和每一项收款信息后创建批次。
缺法人主体、混合法人主体、缺付款账户、非建行、缺账号或解密失败时,创建失败并一次性返回自然中文处理建议。其他渠道及非报销实体沿用原流程,但不具备银行制单能力。
API 映射函数必须显式挑选安全字段。测试扫描所有普通批次/API/AP 响应,断言不存在 `v1:` 密文、完整账号或原始 `snapshot_json`。
## 6. 制单服务与接口
### 6.1 资格校验
POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业务角色、`payment.order.export` 权限和 `reviewed` 状态。它必须拒绝:
- 历史/不完整快照
- 非报销或非银行渠道
- 公司或任一员工不是 `CCB`
- 缺完整密文、账号解密失败
- 缺金额、业务编号或付款账户
- 其他四种批次状态
校验聚合所有业务阻塞项后统一返回;权限失败仍立即拒绝,不泄露批次内容。
### 6.2 确定性行与字段
项目按 `entity_code ASC, id ASC` 显式排序。唯一 ID 仅作同业务编号异常重复时的兜底,不进入银行文件。
- 序号:从 1 开始的字符串。
- 付款方账号/名称:付款账户快照。
- 行别代码:固定字符串 `01`。
- 收款方账号/名称、开户行:收款快照。
- 联行号:空字符串。
- 客户流水号:单一 `buildCustomerReference(batchNo, rowIndex)` 函数生成,写入前校验长度、字符集和批内唯一。
- 金额:初版数值单元格 + 两位小数格式。
- 用途:`报销款`。
- 备注:报销单编号。
- 短信标志:字符串 `0`;手机号与附加信息留空。
禁止部分成功:任意一项失败时不生成文件、不更新制单状态、不写成功导出审计。
### 6.3 BIFF8 与下载
- 使用 SheetJS 官方精确 `0.20.3` tarball并锁定 package-lock 完整性。
- 运行时代码只调用生成 API,不解析上传文件或用户提供的工作簿。
- 显式构建七个字符串字段的 `{ t: "s", v: value }` 单元格;金额使用数值类型和两位小数格式。
- 工作簿按顺序创建 `Sheet1`、`Sheet2`、`Sheet3`,Sheet1 固定 15 列表头。
- 内存生成 `bookType: "biff8"` buffer,先完成审计和制单计数事务,再设置 `application/vnd.ms-excel`、安全附件名、`nosniff`、`private, no-store` 等响应头并发送。
- 不写对象存储或服务器持久目录。网络中断后的已记录导出按保守口径视为已生成,用户可填写新原因重导。
## 7. 页面动作与进度
付款批次 DTO 返回脱敏付款账户摘要、`paymentOrderExportCount`、最近导出时间/人和 `canExportPaymentOrder`/阻塞原因。
- 新快照 + reviewed + 未导出:顶部主动作 `下载银行制单`;回单区仍可直接上传,人工路径不被阻断。
- 已导出且无回单:主动作 `上传银行回单`,`重新下载制单` 放入 `更多`。
- 重导确认固定展示:`重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
- 已有回单:主动作 `登记付款完成`。
- 旧批次或资料不完整:不显示可执行制单动作,展示具体原因;原人工付款动作保持。
- 详情显示 `未生成制单` 或 `已生成 N 次`;处理进度按是否导出显示已批准的两种文案。
整体详情页结构统一仍由后续独立任务负责,本任务只在当前操作组和处理进度数据中接入制单状态,不顺带重构页面骨架。
## 8. 审计、通知与隐私
审计动作至少覆盖:付款账户创建/替换/停用、公司完整账号查看、员工完整账号查看、银行制单首次导出/重导和失败阻断摘要。审计 extra 只存业务 ID、法人主体、批次号、笔数、合计金额、原因、导出次数和结果代码;不存完整账号、密文、工作簿 buffer 或原始快照。
普通日志只记录固定错误码、批次 ID/业务号和数量。依赖或解密错误返回自然中文业务错误,禁止裸 `Error` 使页面白屏,也禁止把底层加密异常文本返回客户端。
## 9. 验证与依赖门禁
- Node 单元/路由测试覆盖权限矩阵、单一启用账户并发约束、通知、审计、批次快照、历史拒绝、CCB-only、确定性排序、重导和隐私负向断言。
- 使用合成数据生成 `.xls`,由 CI 明确安装的 `xlrd==2.0.2` 独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性。
- 用合成非建行项证明整批失败且没有文件、制单计数或成功审计。
- Excel/WPS 人工打开验证中文、长字符串和金额;企业网银只有存在明确“仅校验”步骤时才使用虚构建行数据做最终确认。
- 安装依赖后运行 npm audit/许可证与 lockfile 审查;如官方精确 tarball不可重复获取或引入不可接受告警,暂停而非换用 registry 旧版。
## 10. 迁移、上线与回滚
- 迁移只新增表、可空关系、可空快照和制单统计字段,不修改或回填存量账号/批次。
- 部署后先由 admin 建立 CCB 主数据与公司付款账户,再由 HR/admin 为员工确认 CCB 机构;这两类生产写入必须另行批准,完整账号不经过聊天或仓库。
- 配置完成前暂停创建新的银行渠道付款批次,避免产生无法制单的新历史快照;已有杨俊批次继续人工付款。
- 公网 `/ready`、角色/隐私只读检查和一条合成/受控流程通过后才通知财务使用制单。
- 代码回滚保留新增表和可空字段,不删除已产生的审计、通知或制单统计;旧应用忽略新字段并继续人工付款。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,83 @@
# 企业银行账户与付款制单实施计划
## 1. 实施前门禁
- [x] 用户审查并批准 PRD、设计与本实施计划后,才运行 `task.py start`。
- [x] 运行 `trellis-before-dev`,读取 backend/frontend/hyb-oa 相关 spec 与研究材料。
- [x] 核对工作树,不提交本地空白银行模板、生成制单文件、真实账号或个人 workspace。
- [x] 记录当前 `FIELD_ENCRYPTION_KEY` 载荷兼容口径,但不读取或输出任何密钥/真实账号。
## 2. 依赖与迁移
- [x] 以精确官方 tarball安装 SheetJS `0.20.3`,锁定 package-lock 完整性;确认许可证与依赖门禁,不使用 npm registry 的 `[email protected]`。
- [x] 新增 `BankInstitution`、`PaymentAccount`,以及 Employee/PaymentBatch 可空关系、内部快照和制单统计字段。
- [x] 迁移增加同一法人主体单一 active 付款账户的 PostgreSQL 部分唯一索引;不回填存量批次或员工银行机构。
- [x] 更新 Prisma Client 并运行 `npm run prisma:validate`;核对 migration SQL 和回滚兼容说明。
## 3. 权限、账户与员工收款资料
- [x] 新增 `payment.account.manage/read/read_full` 与 `payment.order.export`,按设计分配角色;清理 `finance_mgr` 重复的 `payment.batch.manage` 条目。
- [x] 实现银行机构和付款账户脱敏 CRUD/替换/停用 API,事务内审计并通知 active admin/CEO。
- [x] 实现公司完整账号带原因查看:先写审计,再返回;负向路径不返回明文。
- [x] 扩展员工敏感资料接口:默认脱敏、HR/admin 可写银行机构与账号、finance/finance_mgr 只读;完整查看独立带原因审计。
- [x] 员工本人 API 返回银行名称与脱敏尾号,不返回完整账号。
- [x] 增加管理端企业银行账户页面和人员档案收款信息区;移动端不增加复杂配置入口。
## 4. 批次冻结快照
- [x] 对报销银行批次解析并锁定单一法人主体、唯一 active CCB 付款账户和完整员工 CCB 收款资料。
- [x] 直接复制现有账号密文进入内部快照;只为脱敏显示做受控解密,不进行解密后再加密。
- [x] 扩展付款 DTO,只映射付款账户/收款账户脱敏字段及制单统计,禁止原始 snapshot 或 `v1:` 密文外泄。
- [x] 旧批次继续显示兼容信息、允许人工付款,但永久不具备制单资格。
## 5. BIFF8 制单
- [x] 建立独立制单服务:固定 15 列和三个工作表、稳定排序、单一流水号函数、七列字符串类型、金额两位小数、中文和 CCB-only 校验。
- [x] 实现 POST 制单下载接口:finance/finance_mgr 业务角色 + 独立权限 + reviewed 状态 + 必填原因。
- [x] 在生成任何文件前聚合阻塞项;任何非 CCB、历史快照、缺资料或异常均整批失败,不生成部分文件。
- [x] 首次导出/重导在事务内写制单统计与脱敏审计,之后以内存 buffer 和 no-store 安全响应头下载,不持久化文件。
- [x] 重导确认固定展示重复付款风险原文,不暗示旧文件已作废。
## 6. 付款详情交互
- [x] DTO 提供制单次数、最近导出摘要、可制单状态与聚合阻塞原因,页面用自然中文逐项说明。
- [ ] 未导出新批次主动作显示“下载银行制单”;已导出无回单时主动作显示“上传银行回单”;当前重导为顶部次动作,待详情页结构统一任务再收进正式“更多”槽位。
- [x] 人工付款可直接上传回单,不把制单设为上传或登记付款的门禁。
- [x] 详情显示是否已生成制单;处理进度按未导出/已导出显示两种批准文案。
- [x] 不在本任务重构完整详情页骨架;与未来详情结构统一任务保持组件边界清楚。
## 7. 自动化与独立读回
- [x] 后端测试覆盖权限矩阵、active 唯一约束、账户替换/停用、通知、审计、默认脱敏和完整查看原因。
- [x] 付款批次测试覆盖单一法人主体、CCB-only、密文复制、员工资料后改不漂移、旧批次拒绝和普通响应无密文。
- [x] 制单测试覆盖 15 列、三个工作表、确定性排序、流水号校验、七列字符串、金额、中文、重导一致性、整批失败和无持久化。
- [x] 增加可复现的 `xlrd==2.0.2` 独立读回环境和 CI 步骤;合成文件仅存临时目录并自动清理。
- [ ] 前端测试覆盖角色可见性、员工本人脱敏、管理表单、导出原因、重导警告、主动作切换和旧批次人工兜底。
## 8. 全量验证与只读复核
- [x] `cd server && npm run prisma:validate`。
- [x] 对全部触及的服务端 JS 运行 `node --check`,运行付款批次/用户/导出/通知聚焦 Vitest。
- [x] `cd client && npm run typecheck && npm run build`;未新增或运行本任务前端 E2E。
- [x] 运行 BIFF8 独立读回脚本;Excel/WPS 人工打开仍待执行。
- [x] 运行依赖审计、Trellis governance、process contract、敏感扫描、`git diff --check`;依赖审计保留仓库既有 14 项告警,新增 SheetJS 未命中告警。
- [x] 对权限、状态机、迁移、密文数据流和 API 响应执行一次独立只读 review;发现的聚合阻塞项合同漂移已由主流程修复并复验。
## 9. 文档、提交与上线边界
- [x] 仅把稳定安全/付款合同更新到现有 spec、产品事实源和管理员手册,不新建无必要业务文档。
- [ ] 按逻辑分批中文提交,提交信息引用 `corporate-bank-account-payment-order`。
- [x] 不在代码任务中写入任何生产银行资料;生产配置、部署和员工资料确认分别取得明确批准。
- [ ] 部署后先由 admin 配置 CCB 与公司付款账户,再由 HR/admin 确认员工 CCB;配置完成前暂停新建银行批次。
- [x] 旧杨俊批次继续人工付款并上传回单,不补写快照、不尝试制单。
- [ ] 公网门禁通过后才通知王芝玲测试制单;没有独立网银校验步骤时不做生产网银导入试验。
## 10. 本地验证记录
- 服务端全量:176 个测试文件、1186 项测试通过。
- 本任务聚焦:付款批次与 BIFF8 服务 57 项测试通过;公司账户、员工敏感资料和个人资料测试包含在全量结果中。
- 前端:TypeScript 检查和生产构建通过。
- 数据与文件:Prisma schema 校验通过;`xlrd==2.0.2` 独立读回确认三个工作表、15 列、长账号字符串、`01` 字符串、数值金额与中文。
- 治理:Trellis task 校验、governance、process contract、敏感内容扫描和 `git diff --check` 通过;文档注册审计仅保留仓库既有 22 个未登记文件警告。
- 依赖:`[email protected]` 来自官方精确 tarball且锁定;生产依赖审计仍有 14 项仓库既有告警,未执行破坏性 `npm audit fix --force`。
- 尚未完成:Excel/WPS 人工打开、前端 E2E/截图、生产数据库迁移、真实资料配置、部署、公网检查和企业网银校验。
@@ -0,0 +1,137 @@
# 企业银行账户与付款制单
## Goal
为财务付款批次提供受控的公司付款账户、员工收款银行标识和企业网银 BIFF8 `.xls` 制单能力,使付款复核看到的收款账户与实际导出的账户同源,同时避免完整银行账号在普通页面、日志、审计或服务器文件中泄露。
## Background
- 用户提供的空白银行制单模板为传统 BIFF8 `.xls`,包含三个工作表;第一个工作表有 15 列表头,另两个工作表为空。
- 银行导入合同明确要求 `.xls`,不再把 `.xlsx` 或 CSV 作为首版候选。
- 模板必填字段为序号、付款方账号、付款方名称、收款方行别代码、收款方账号、收款方名称、金额和用途。
- 当前系统只有员工开户银行自由文本和加密账号;没有规范化银行标识、公司付款账户模型或银行制单导出。
- 当前付款批次快照只冻结收款人姓名、开户银行和脱敏尾号。历史兼容路径可用于展示,但不得用于真实划款制单。
- MVP 采用每个法人主体仅一条启用的公司付款账户;数据模型仍应允许将来存在历史账户或多账户,不把账号直接堆到 `LegalEntity`。
- 当前公司付款账户与员工工资卡均为中国建设银行;首版生产场景限定为建行本行转账。
## Scope
- 建立法人主体维度的受控企业银行账户、规范化银行机构和员工收款资料维护能力。
- 在新建银行付款批次中冻结公司付款账户与员工收款账户,普通页面与 API 仅展示脱敏摘要。
- 为符合条件的已复核建行批次生成确定性的 BIFF8 `.xls` 制单文件,并记录带原因的逐次审计。
- 保留历史批次和异常场景的人工付款、上传回单与登记完成路径;不连接银行、不自动提交或放款。
- 生产银行资料写入、部署、公网验证和企业网银导入不属于本次代码实施授权范围。
## Requirements
### R1 公司付款账户
- 新建隶属于法人主体的付款账户资料,至少包含账户名称、加密账号、规范化银行标识、银行显示名称、启用/归档状态及审计字段。
- MVP 对同一法人主体只允许一条启用记录;更换账户不得覆盖历史记录。
- 普通页面默认只展示开户行和脱敏尾号;完整账号不得随列表或普通详情响应返回。
- 公司付款账户不得放入 `.env`、Feature Flag、普通 JSON 偏好或明文配置值。
- 公司付款账户维护的主要风险是误操作、制单失效、错误公司账户出款和缺少及时知情,不将其描述为改变收款人的欺诈路径,也不为 MVP 增加审批流或双人确认。
- 公司付款账户新增、变更、停用仅授权 `finance_mgr` 与 `admin`;当前没有 active `finance_mgr` 时由 `admin` 维护。
- `finance` 只能读取公司付款账户的开户行与脱敏尾号并在授权状态下制单,不得新增、修改、停用或归档付款账户。
- `finance`、`finance_mgr` 与 `admin` 仅可通过独立“查看完整账号”动作填写原因后查看公司付款账户完整账号;每次查看必须先写脱敏审计,普通查询永不返回完整账号。
- `hr`、`manager`、`ceo` 与 `system_admin` 不获得公司付款账户主数据读取或维护权限;付款批次合法复核人仅可在该批次中看到冻结的付款账户脱敏尾号。`admin` 的全权限作为受控管理入口保留。
- 新增、变更、停用付款账户除写审计外,还必须即时通知所有 active `admin` 与 `ceo`;通知只含法人主体、动作、操作者、时间和脱敏尾号,不含完整账号或密文。
### R2 员工收款银行标识
- 员工收款资料增加从受控选项选择的规范化银行标识,不依赖“建设银行/建行/某某分行”等自由文本比较,也不直接保存“本行/他行”这种相对关系。
- 在人员档案提供“收款信息”入口:HR 与 `admin` 可维护规范化开户银行和账号;`finance`、`finance_mgr` 只读,不能编辑。
- 员工本人可查看自己的开户银行和脱敏尾号,但不直接查看完整账号、不自助修改;HR/admin/finance/finance_mgr 默认同样只看脱敏尾号。
- HR、`admin`、`finance` 与 `finance_mgr` 查看员工完整账号时必须通过独立动作填写原因并先写查看审计;现有默认返回完整账号的敏感资料读取路径必须收紧为默认脱敏。
- 首版仅支持规范化标识为 `CCB` 的公司付款账户和员工收款账户;双方均为建行时生成字符串 `01` 并保留前导零。
- 任一批次项的收款银行不是建行时,拒绝整份制单并列出对应员工及处理建议;不得生成 `02`、不得跳过该项、不得生成部分文件。
- `employee.bank.manage` 继续只授予 HR 与 `admin`,不得为了财务制单便利把该权限授予 `finance`、`finance_mgr` 或 `system_admin`。
- 真实资金欺诈的关键路径是“修改收款账号 -> 制单导出 -> 向错误受益人付款”;员工收款资料维护与财务制单必须保持角色分离。
### R3 可追溯收款快照
- 创建付款批次时直接复制员工已有的银行账号密文进入内部快照,不执行“解密后再加密”。
- 复核页面的脱敏尾号与制单导出的完整账号必须来自同一份批次快照。
- 员工资料在批次创建后发生变化,不得静默改变已复核批次的制单内容。
- 缺少加密账号快照的历史批次一律禁止制单导出,不允许实时回查员工资料兜底,并返回明确错误。
- 普通付款 DTO、AP 页面、日志和审计不得返回密文、完整账号或原始快照。
- 付款批次快照同时冻结所选公司付款账户的身份与脱敏尾号;付款复核页面应显示该脱敏尾号,使复核人能回答“从哪个账户付、付给谁、付多少”。
### R4 银行制单导出
- 从符合状态与权限要求的付款批次生成与空白模板一致的 BIFF8 `.xls`。
- 银行制单是推荐的效率工具而非付款完成硬门禁;旧快照、格式异常或人工付款场景仍可通过线下转账、上传回单和登记完成闭环,银行回单继续是银行渠道付款完成的证据门禁。
- 新批次已复核、资料完整且尚未制单时,顶部主动作显示“下载银行制单”;首次制单后主动作切换为“上传银行回单”,重新下载进入“更多”。已经人工付款的用户可直接在回单区域上传,不被制单状态阻断。
- 批次详情显示是否已生成制单;处理进度在未制单时显示“等待财务线下付款并上传回单”,制单后显示“已生成银行制单,等待付款完成并上传回单”。
- 仅 `reviewed`(待财务付款)批次允许制单;`draft`、`failed`、`cancelled` 与 `executed` 均禁止制单。
- `reviewed` 状态允许因文件丢失或本地操作失败重复导出,每次仍须填写原因并独立审计;重新下载前必须明确提示“重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。”重复导出的业务字段必须来自同一冻结快照,不允许临时覆盖账号、金额或用途。
- `executed` 后不得重新生成可导入网银的指令文件;付款归档依赖银行回单与付款台账,避免把归档需要和可重复付款指令混为一体。
- 原样保留三个工作表、15 列顺序与表头;不得把格式伪装成 `.xls` 扩展名。
- 用途固定为“报销款”;客户方流水号由付款批次编号与三位行序号生成;备注写对应报销单编号;开户行名称取冻结快照;付款凭证号不进入制单。
- 批次项必须使用显式、稳定且带唯一兜底键的排序,禁止依赖数据库自然顺序;同一批次重复导出时每项的行序号和客户流水号必须不变。
- 客户方流水号由单一生成函数负责,并在写文件前校验长度、字符集和批次内唯一性;不允许静默截断或分散内联拼接。首轮验证若证明银行只接受纯数字,应只调整该生成函数与对应合同。
- 付款方账号、收款方账号、联行号、客户流水号、手机号、行别代码和短信通知标志七列必须以字符串单元格写入,禁止数值类型推断;短信通知标志首版写字符串 `0`。
- 金额首版按数值写入并设置两位小数格式,必须无科学计数法和精度损失;若银行安全导入验证明确拒绝,再改为两位小数字符串。
- 收款人名称、用途与备注中文不得乱码。
- 收款方联行号首版固定留空;首次安全验证使用字符串 `01`、建行虚构账号和空联行号,覆盖真实生产主场景。跨行 `02` 与联行号留待支持非建行员工时另立增量任务。
- 导出文件通过响应直接下载,不持久化到服务器存储,不进入 Git,不记录原始制单内容。
- 导出使用独立权限、必填原因和逐次审计;审计只记录操作人、批次、笔数、合计金额及原因,不记录完整账号。
- 旧快照批次、非银行渠道、缺少付款账户、缺少员工银行标识或账号、公司或员工银行不是建行的批次必须在导出前一次性列出不可制单原因。
### R5 本地与人工验证
- 使用合成数据生成 `.xls`,再用独立于生成器的解析器读回验证工作表、表头、字段类型、中文与金额。
- 独立解析器及版本必须进入可复现的测试环境,不能依赖某台开发机偶然安装的 `xlrd`。
- 至少使用 Excel 或 WPS 人工打开一次,确认中文和账号显示。
- 只有企业网银存在明确的“仅校验、不提交”环节时,才允许用完全虚构数据做最终格式确认;否则不进行生产网银试验。
### R6 依赖安全
- BIFF8 生成依赖必须在设计阶段确定受支持版本、来源、锁定和完整性校验方式。
- 不得静默接受已知高危依赖告警;若只能使用存在已知解析漏洞的版本,必须证明运行路径只生成、不解析不可信输入,并单独获得风险例外批准。
## Test Plan
- 验证 Prisma schema 与迁移,检查单一 active 付款账户约束和存量数据兼容边界。
- 运行公司付款账户、员工敏感资料、付款批次、个人资料和 BIFF8 生成服务的聚焦测试及服务端全量测试。
- 使用合成数据生成 BIFF8 文件,并由固定版本 `xlrd` 独立读回工作表、表头、字段类型、金额与中文。
- 运行前端 TypeScript 检查和生产构建,核对权限可见性、默认脱敏、带原因 reveal、制单与人工付款动作。
- 执行权限、状态机、迁移、密文流和普通 API DTO 的只读复核,并运行 Trellis、流程、文档、敏感内容与 diff 门禁。
- Excel/WPS 人工打开、生产网银仅校验、公网部署与真实银行资料配置分别作为需额外授权的上线验收,不纳入本地自动化通过声明。
## Acceptance Criteria
- [ ] 管理入口可为法人主体建立、启用、停用和归档公司付款账户,默认仅显示开户行与脱敏尾号。
- [ ] 只有 `finance_mgr` 与 `admin` 能维护公司付款账户;`finance` 只能看脱敏信息和执行授权制单,HR/经理/CEO/system_admin 无主数据访问权。
- [ ] `finance`、`finance_mgr` 与 `admin` 查看公司完整账号时必须填写原因并留下成功查看审计,普通列表和详情响应不含完整账号。
- [ ] 公司付款账户新增、变更或停用后,active admin 与 CEO 收到不含完整账号的即时通知,并存在脱敏审计。
- [ ] 同一法人主体无法同时存在两条启用的付款账户,历史账户不会被覆盖。
- [ ] 员工收款资料通过受控选项维护规范化银行标识;公司和员工均为 `CCB` 时生成字符串 `01`,任一非建行项导致整批制单失败且不遗漏任何付款项。
- [ ] 员工本人可核对自己的开户银行和脱敏尾号;HR/admin 可维护,finance/finance_mgr 只读,所有授权完整账号查看均要求原因并留审计。
- [ ] `finance`、`finance_mgr` 与 `system_admin` 均不能修改员工收款账号或银行标识,现有 HR/财务职责分离没有被放宽。
- [ ] 新付款批次冻结加密完整账号;普通 API 与页面只返回脱敏信息。
- [ ] 修改员工收款资料后,既有批次的复核尾号和制单内容保持不变。
- [ ] 付款复核页面同时显示冻结的公司付款账户脱敏尾号、收款人、收款账户尾号和金额。
- [ ] 历史批次制单请求被明确拒绝,且不会实时读取当前员工完整账号。
- [ ] 未制单批次、已制单批次和人工付款路径均可按既定主动作/次动作继续,制单状态不是上传回单或登记付款完成的硬门禁。
- [ ] 批次详情和处理进度能区分“未生成制单”与“已生成制单待回单”,便于观察实际使用覆盖率。
- [ ] 授权财务人员填写原因后可下载符合模板的 BIFF8 `.xls`,下载行为留下脱敏审计。
- [ ] 重新下载前展示重复付款风险原文;每次重导独立记录原因,但不会宣称旧文件已失效。
- [ ] 只有 `reviewed` 批次可制单;同一 `reviewed` 批次可带新原因重导,其他四种状态均被明确拒绝。
- [ ] 批次进入 `executed` 后无法重新生成银行制单,银行回单与付款台账仍可用于归档核验。
- [ ] 七个标识/开关字段读回均为字符串,长账号无精度丢失,`01` 不丢前导零,短信标志精确为字符串 `0`。
- [ ] 批次项按显式确定性顺序生成;同一冻结批次重复导出时,每张报销的行号、客户流水号和业务内容逐项一致。
- [ ] 客户流水号的长度、字符集与唯一性校验失败时制单被明确阻断,不发生截断、替换或自动降级。
- [ ] 金额读回值精确到两位小数且无科学计数法;数值/字符串类型最终以安全银行验证结果固化。
- [ ] 使用字符串 `01`、建行合成账号和空联行号完成本地验证;合成非建行项证明整批失败且不会生成部分文件。
- [ ] 缺失资料或状态不合格时,页面一次性说明全部阻塞项及处理方式。
- [ ] 自动化、本地人工打开和可选网银校验均使用合成数据,不触发真实付款。
## Out of Scope
- 工资代发、供应商付款、跨境付款和多币种制单。
- 银行 API 直连、自动提交付款、自动放款或自动复核。
- 首版支持多个同时启用的公司付款账户。
- 非建行、跨行 `02`、联行号维护和混合银行批次制单。
- 为历史付款批次补写或回填完整账号快照。
- 将真实账号、模板业务数据或生成的制单文件提交到仓库。
@@ -0,0 +1,46 @@
# 企业银行账户与 BIFF8 制单研究
## 模板证据
- 用户提供的空白模板是真实 BIFF8 `.xls`(OLE Compound Document),代码页 936,无宏、无外部链接。
- 工作簿固定包含 `Sheet1`、`Sheet2`、`Sheet3`;只有 `Sheet1` 有 15 列表头,另两个工作表为空。
- 必填列为序号、付款方账号/名称、收款方行别代码、收款方账号/名称、金额、用途。
- 数据行多数使用文本格式;账号、行别代码、联行号、流水号、手机号和短信标志必须在写入值时显式使用字符串类型,不能依赖显示格式补救。
- 模板文件只作为本地分析输入,不进入 Git、Trellis 或运行时服务器。
## 当前数据与权限
- `Employee` 只有 `bank_name` 与 `bank_account_encrypted`,没有规范化银行机构关系;现有密文字段使用 `FIELD_ENCRYPTION_KEY` 的 `v1:` 载荷。
- `buildPayeeSnapshot()` 当前会解密员工账号后只保留脱敏尾号;付款 API 通过 `readSnapshotPayee()` 映射脱敏 DTO,历史项可用 `loadLegacyPayees()` 实时回查展示。
- 历史实时回查只适合兼容展示,不能用于制单;旧批次没有冻结完整密文时必须拒绝制单。
- `LegalEntity` 已是员工、成本中心、供应商等主数据锚点,独立 `PaymentAccount` 关系比直接向 `LegalEntity` 堆账号字段更适合保留历史和未来多账户。
- 当前角色:`finance` 有 `payment.batch.manage` 与 `employee.bank.read`;`finance_mgr` 另有 `system.settings`、`system.audit.view`;HR 有 `employee.bank.manage`;`system_admin` 没有银行读取权限;`admin` 为通配权限。
- `finance_mgr` 权限数组中 `payment.batch.manage` 重复两次,行为无害,可在本任务触及权限表时机械清理。
## 现有可复用模式
- 敏感查看/下载已有“填写原因 -> 服务端校验 -> 先写审计 -> 再返回内容”的模式,参考用户敏感资料查看、附件用途对话框和高敏附件下载合同。
- 通知可复用 `createNotificationsWithRecords()` 与按角色解析 active 员工的查询方式;付款账户通知只发送脱敏摘要。
- 财务导出已有内存生成、审计与 Blob 下载模式,但现有 `ExcelJS` 只能生成 `.xlsx`,不能满足 BIFF8 合同。
- 管理配置应进入 PC `管理` 路由下的明确子页面;复杂配置与制单不进入移动端。
## BIFF8 依赖结论
- npm registry 的官方 `xlsx` 版本停在 `0.18.5`,不能作为无例外默认依赖。
- SheetJS 官方 CDN 可取得 `0.20.3` tarball;实施时应使用精确版本 URL并让 `package-lock.json` 固定完整性,不使用浮动 URL。
- 运行时代码只调用工作簿/工作表构建和 `write(..., { bookType: "biff8" })`,不得开放 `read`/`readFile` 解析用户输入。
- 也存在 npm 上的第三方发布镜像,但官方精确 tarball的来源更直接;若正式安装前完整性或许可证门禁不通过,停止并重新审查,不静默换包。
## 独立读回验证
- 当前开发机可用 `xlrd 2.0.2`,但仓库没有 Python requirements/pyproject,不能把本机安装当成 CI 合同。
- 实施应增加一个最小、固定版本的独立验证环境:生成器由 Node/SheetJS 执行,读回器由 Python `xlrd==2.0.2` 执行;CI 明确安装该版本后运行合成文件验证。
- 验证文件必须写入临时目录并在结束后清理;失败输出只报告字段名和断言,不打印合成账号之外的任何内容。
## 关键设计约束
- 当前生产主场景是建设银行公司账户向建设银行员工工资卡付款;首版只生成字符串 `01`,跨行 `02` 明确不支持。
- 任何非 `CCB` 员工项必须使整批制单失败,不能跳行或生成部分文件。
- 批次项必须显式按稳定业务编号并以唯一 ID 兜底排序,确保重复导出的行号和流水号一致。
- 制单是推荐效率工具,不是付款完成门禁;旧批次和人工付款路径继续以银行回单为完成证据。
- 首次导出后的处理进度需要可见,但完整文件不在服务端留存;批次只记录导出次数、最近导出时间/人和审计证据。
@@ -0,0 +1,26 @@
{
"id": "corporate-bank-account-payment-order",
"name": "corporate-bank-account-payment-order",
"title": "企业银行账户与付款制单",
"description": "建立法人主体企业银行账户、员工受控收款信息、冻结付款快照与建设银行 BIFF8 制单能力。",
"status": "in_progress",
"dev_type": "feature",
"scope": "公司付款账户、员工收款资料、付款批次冻结快照与建设银行 BIFF8 制单",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+61
查看文件
@@ -0,0 +1,61 @@
import { apiRequestData } from "./http";
export type BankInstitution = { id: string; code: string; name: string };
export type PaymentAccount = {
id: string;
legalEntity: { id: string; code: string; name: string };
bankInstitution: BankInstitution;
accountName: string;
accountNumberMasked: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export async function fetchBankInstitutions() {
return apiRequestData<{ items: BankInstitution[] }>("/api/bank-institutions");
}
export async function fetchPaymentAccounts() {
return apiRequestData<{ items: PaymentAccount[] }>("/api/payment-accounts");
}
export async function createPaymentAccount(payload: {
legalEntityId: string;
bankInstitutionId: string;
accountName: string;
accountNumber: string;
}) {
return apiRequestData<PaymentAccount>("/api/payment-accounts", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function replacePaymentAccount(accountId: string, payload: {
legalEntityId: string;
bankInstitutionId: string;
accountName: string;
accountNumber: string;
}) {
return apiRequestData<PaymentAccount>(`/api/payment-accounts/${accountId}/replace`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function disablePaymentAccount(accountId: string) {
return apiRequestData<{ id: string; disabled: boolean }>(`/api/payment-accounts/${accountId}/disable`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function revealPaymentAccount(accountId: string, reason: string) {
return apiRequestData<{ accountNumber: string }>(`/api/payment-accounts/${accountId}/reveal`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
+27
查看文件
@@ -49,6 +49,18 @@ export type PaymentBatch = {
secondaryReviewComment: string | null;
executionSummaryJson: Record<string, unknown> | null;
receiptAttachmentCount?: number;
paymentAccount: {
accountName: string;
bankName: string;
bankInstitutionCode: string;
accountNumberMasked: string;
} | null;
paymentOrderExportCount: number;
paymentOrderLastExportedAt: string | null;
paymentOrderLastExportedBy: string | null;
canExportPaymentOrder: boolean;
paymentOrderExportBlockReason: string | null;
paymentOrderExportBlockers: Array<{ code: string; entityCode?: string }>;
createdAt: string | null;
createdBy: string | null;
reviewedAt: string | null;
@@ -171,6 +183,21 @@ export async function cancelPaymentBatch(batchId: string, reason: string) {
});
}
export async function downloadPaymentOrder(batchId: string, reason: string): Promise<ApiBlobResponse> {
return apiRequestBlob(`/api/payment-batches/${batchId}/payment-order`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason }),
});
}
export async function revealPaymentBatchPayeeBank(batchId: string, itemId: string, reason: string) {
return apiRequestData<{ bankAccount: string }>(
`/api/payment-batches/${batchId}/items/${itemId}/payee-bank/reveal`,
{ method: "POST", body: JSON.stringify({ reason }) },
);
}
export async function fetchPaymentBatchReceiptAttachments(batchId: string) {
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
`/api/payment-batches/${batchId}/receipt-attachments`,
+5
查看文件
@@ -42,6 +42,11 @@ export type MeResponse = {
status: string;
notificationPushEnabled: boolean;
roleCodes: string[];
bankAccount: {
bankName: string | null;
bankInstitutionCode: string | null;
accountNumberMasked: string | null;
};
profileCompleteness: ProfileCompleteness;
wecomBinding?: {
externalUserId: string;
+28
查看文件
@@ -0,0 +1,28 @@
import { apiRequestData } from "./http";
export type EmployeeBankSummary = {
bankInstitution: { id: string; code: string; name: string } | null;
bankName: string | null;
bankAccountMasked: string | null;
};
export async function fetchUserSensitive(userId: string) {
return apiRequestData<EmployeeBankSummary & { idNumber?: string | null }>(`/api/users/${userId}/sensitive`);
}
export async function updateUserBank(userId: string, payload: {
bankInstitutionId: string | null;
bankAccount?: string | null;
}) {
return apiRequestData<{ id: string; updatedFields: string[] }>(`/api/users/${userId}/sensitive`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function revealUserBank(userId: string, reason: string) {
return apiRequestData<{ bankAccount: string | null }>(`/api/users/${userId}/sensitive/bank/reveal`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
+7 -1
查看文件
@@ -100,6 +100,9 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.view_all",
"attendance.view_all",
"employee.bank.read",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
"payroll.manage",
],
finance_mgr: [
@@ -125,7 +128,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.inventory",
"system.settings",
"system.audit.view",
"payment.batch.manage",
"risk.review.manage",
"project.view_all",
"attendance.view_all",
@@ -133,6 +135,10 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"attendance.sync",
"payroll.manage",
"employee.bank.read",
"payment.account.manage",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
],
administration: [
"expense.proxy_submit",
@@ -27,7 +27,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/weekly-reports", label: "周报", icon: "article" },
{ to: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
+69
查看文件
@@ -33,6 +33,8 @@ import {
type WecomDirectoryState,
} from "../api/employees";
import { fetchEmployeePerformanceTimeline } from "../api/performance";
import { fetchBankInstitutions } from "../api/paymentAccounts";
import { fetchUserSensitive, revealUserBank, updateUserBank } from "../api/userSensitive";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -187,6 +189,9 @@ export function EmployeeProfilePage() {
const canManageSensitiveHr = hasPermission(permissions, "system.settings");
const canManageEmployeeContracts = hasPermission(permissions, "employee.contract.manage");
const canManagePayroll = hasPermission(permissions, "payroll.manage");
const canManageBank = hasPermission(permissions, "employee.bank.manage");
const canReadBank = canManageBank || hasPermission(permissions, "employee.bank.read") || me?.id === employeeId;
const canRevealBank = canManageBank || hasPermission(permissions, "employee.bank.read");
const canEditSelfProfile = me?.id === employeeId;
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
const canEditExtendedFields = canManageEmployeeProfile;
@@ -216,6 +221,21 @@ export function EmployeeProfilePage() {
emergencyContactPhone: "",
notes: "",
});
const [bankInstitutionId, setBankInstitutionId] = useState("");
const [bankAccountInput, setBankAccountInput] = useState("");
const [bankRevealReason, setBankRevealReason] = useState("");
const [revealedBankAccount, setRevealedBankAccount] = useState("");
const bankQuery = useQuery({
enabled: Boolean(employeeId) && canReadBank,
queryKey: ["employee-bank", employeeId],
queryFn: () => fetchUserSensitive(employeeId),
});
const bankInstitutionsQuery = useQuery({
enabled: canManageBank,
queryKey: ["bank-institutions"],
queryFn: fetchBankInstitutions,
});
const contractsQuery = useQuery({
enabled: Boolean(employeeId) && canManageEmployeeContracts,
@@ -265,6 +285,34 @@ export function EmployeeProfilePage() {
});
}, [profileQuery.data]);
useEffect(() => {
setBankInstitutionId(bankQuery.data?.bankInstitution?.id ?? "");
setBankAccountInput("");
setBankRevealReason("");
setRevealedBankAccount("");
}, [bankQuery.data, employeeId]);
const saveBankMutation = useMutation({
mutationFn: () => updateUserBank(employeeId, {
bankInstitutionId: bankInstitutionId || null,
...(bankAccountInput.trim() ? { bankAccount: bankAccountInput.trim() } : {}),
}),
onSuccess: async () => {
setBankAccountInput("");
await queryClient.invalidateQueries({ queryKey: ["employee-bank", employeeId] });
toast.success("收款信息已保存");
},
onError: (error) => toast.error(errorMessage(error, "保存收款信息失败")),
});
const revealBankMutation = useMutation({
mutationFn: () => revealUserBank(employeeId, bankRevealReason.trim()),
onSuccess: ({ bankAccount }) => {
setRevealedBankAccount(bankAccount ?? "");
window.setTimeout(() => revealBankMutation.reset(), 0);
},
onError: (error) => toast.error(errorMessage(error, "查看收款账号失败")),
});
useEffect(() => {
setActiveTab(requestedTab);
}, [employeeId, requestedTab]);
@@ -914,6 +962,27 @@ export function EmployeeProfilePage() {
</div>
<div className="space-y-6">
{canReadBank ? (
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">收款信息</h3>
<div className="grid gap-3 text-sm">
<div><div className="text-zinc-500">开户银行</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankName ?? "未填写"}</div></div>
<div><div className="text-zinc-500">银行账号</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankAccountMasked ?? "未填写"}</div></div>
</div>
{canManageBank ? (
<div className="space-y-3 border-t border-zinc-100 pt-4">
<div className="space-y-2"><label className="text-sm text-zinc-500">开户银行</label><select className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}><option value="">请选择</option>{(bankInstitutionsQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className="space-y-2"><label className="text-sm text-zinc-500">新银行账号</label><Input type="password" autoComplete="new-password" value={bankAccountInput} onChange={(event) => setBankAccountInput(event.target.value)} placeholder={bankQuery.data?.bankAccountMasked ? "不修改则留空" : "请输入完整账号"} /></div>
<Button type="button" disabled={saveBankMutation.isPending || !bankInstitutionId || (!bankQuery.data?.bankAccountMasked && !bankAccountInput.trim())} onClick={() => saveBankMutation.mutate()}>{saveBankMutation.isPending ? "保存中..." : "保存收款信息"}</Button>
</div>
) : null}
{canRevealBank ? (
<div className="space-y-2 border-t border-zinc-100 pt-4">
{revealedBankAccount ? <><div className="rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm break-all">{revealedBankAccount}</div><Button type="button" variant="secondary" onClick={() => { setRevealedBankAccount(""); setBankRevealReason(""); }}>隐藏完整账号</Button></> : <><label className="text-sm text-zinc-500">查看完整账号原因</label><Textarea rows={2} maxLength={500} value={bankRevealReason} onChange={(event) => setBankRevealReason(event.target.value)} /><Button type="button" variant="secondary" disabled={bankRevealReason.trim().length < 2 || revealBankMutation.isPending} onClick={() => revealBankMutation.mutate()}>查看完整账号</Button></>}
</div>
) : null}
</div>
) : null}
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
+7
查看文件
@@ -239,6 +239,13 @@ export function MobileProfilePage() {
</div>
</SectionPanel>
<SectionPanel title="收款信息">
<dl className="grid gap-2 text-sm">
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
</dl>
</SectionPanel>
<SectionPanel title="组织与角色摘要">
<details className="mobile-list-card text-sm">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
+169
查看文件
@@ -0,0 +1,169 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
createPaymentAccount,
disablePaymentAccount,
fetchBankInstitutions,
fetchPaymentAccounts,
replacePaymentAccount,
revealPaymentAccount,
type PaymentAccount,
} from "../api/paymentAccounts";
import { fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
function errorText(error: unknown) {
return isApiError(error) ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : "操作失败";
}
export function PaymentAccountsPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canManage = hasPermission(permissions, "payment.account.manage");
const canReveal = hasPermission(permissions, "payment.account.read_full");
const [editing, setEditing] = useState<PaymentAccount | null>(null);
const [legalEntityId, setLegalEntityId] = useState("");
const [bankInstitutionId, setBankInstitutionId] = useState("");
const [accountName, setAccountName] = useState("");
const [accountNumber, setAccountNumber] = useState("");
const [revealTarget, setRevealTarget] = useState<PaymentAccount | null>(null);
const [revealReason, setRevealReason] = useState("");
const [revealedAccount, setRevealedAccount] = useState("");
const [disableTarget, setDisableTarget] = useState<PaymentAccount | null>(null);
const accountsQuery = useQuery({ queryKey: ["payment-accounts"], queryFn: fetchPaymentAccounts });
const banksQuery = useQuery({ queryKey: ["bank-institutions"], queryFn: fetchBankInstitutions });
const legalEntitiesQuery = useQuery({
queryKey: ["master-data", "legal-entities", "payment-accounts"],
queryFn: () => fetchLegalEntities({ enabled: true }),
enabled: canManage,
});
const resetForm = () => {
setEditing(null);
setLegalEntityId("");
setBankInstitutionId("");
setAccountName("");
setAccountNumber("");
};
const refresh = () => queryClient.invalidateQueries({ queryKey: ["payment-accounts"] });
const saveMutation = useMutation({
mutationFn: () => {
const payload = { legalEntityId, bankInstitutionId, accountName, accountNumber };
return editing ? replacePaymentAccount(editing.id, payload) : createPaymentAccount(payload);
},
onSuccess: async () => {
toast.success(editing ? "企业银行账户已替换,历史账户已保留" : "企业银行账户已新增");
resetForm();
await refresh();
},
onError: (error) => toast.error(errorText(error)),
});
const disableMutation = useMutation({
mutationFn: disablePaymentAccount,
onSuccess: async () => {
setDisableTarget(null);
toast.success("企业银行账户已停用");
await refresh();
},
onError: (error) => toast.error(errorText(error)),
});
const revealMutation = useMutation({
mutationFn: () => revealPaymentAccount(revealTarget!.id, revealReason.trim()),
onSuccess: ({ accountNumber: value }) => {
setRevealedAccount(value);
window.setTimeout(() => revealMutation.reset(), 0);
},
onError: (error) => toast.error(errorText(error)),
});
const accounts = accountsQuery.data?.items ?? [];
return (
<PageScaffold>
<PageHeader title="企业银行账户" subtitle="维护法人主体的付款来源账户;完整账号仅在填写原因后临时显示。" />
<div className="mx-auto max-w-6xl space-y-5">
{canManage ? (
<SectionPanel title={editing ? "替换企业银行账户" : "新增企业银行账户"}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="payment-account-legal-entity">法人主体</Label>
<select id="payment-account-legal-entity" className="select-std w-full" value={legalEntityId} disabled={Boolean(editing)} onChange={(event) => setLegalEntityId(event.target.value)}>
<option value="">请选择</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-bank">开户银行</Label>
<select id="payment-account-bank" className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}>
<option value="">请选择</option>
{(banksQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-name">付款方账户名称</Label>
<Input id="payment-account-name" value={accountName} onChange={(event) => setAccountName(event.target.value)} autoComplete="off" />
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-number">付款方客户账号</Label>
<Input id="payment-account-number" type="password" value={accountNumber} onChange={(event) => setAccountNumber(event.target.value)} autoComplete="new-password" />
</div>
</div>
<div className="mt-4 flex gap-2">
<Button type="button" disabled={saveMutation.isPending || !legalEntityId || !bankInstitutionId || !accountName.trim() || !accountNumber.trim()} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? "保存中..." : editing ? "确认替换" : "保存账户"}</Button>
{editing ? <Button type="button" variant="secondary" onClick={resetForm}>取消替换</Button> : null}
</div>
</SectionPanel>
) : null}
<SectionPanel title="账户记录">
<div className="space-y-3">
{accounts.map((account) => (
<div key={account.id} className="detail-evidence-card flex flex-wrap items-center justify-between gap-4">
<div>
<div className="font-medium text-zinc-900">{account.legalEntity.name} · {account.accountName}</div>
<div className="mt-1 text-sm text-zinc-600">{account.bankInstitution.name} · {account.accountNumberMasked ?? "-"}</div>
<div className="mt-1 text-xs text-zinc-500">{account.enabled ? "当前启用" : "历史/已停用"}</div>
</div>
<div className="flex flex-wrap gap-2">
{canReveal ? <Button type="button" variant="secondary" onClick={() => { setRevealTarget(account); setRevealReason(""); setRevealedAccount(""); }}>查看完整账号</Button> : null}
{canManage && account.enabled ? <Button type="button" variant="secondary" onClick={() => { setEditing(account); setLegalEntityId(account.legalEntity.id); setBankInstitutionId(account.bankInstitution.id); setAccountName(account.accountName); setAccountNumber(""); }}>替换</Button> : null}
{canManage && account.enabled ? <Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => setDisableTarget(account)}>停用</Button> : null}
</div>
</div>
))}
{!accountsQuery.isLoading && !accounts.length ? <div className="text-sm text-zinc-500">尚未配置企业银行账户。</div> : null}
</div>
</SectionPanel>
</div>
<Dialog open={Boolean(revealTarget)} onOpenChange={(open) => { if (!open) { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); } }}>
<DialogContent className="max-w-md p-5">
<DialogHeader><DialogTitle>查看完整企业银行账号</DialogTitle><DialogDescription>本次查看会记录查看人、目标账户、时间和原因。</DialogDescription></DialogHeader>
{revealedAccount ? <div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedAccount}</div> : <div className="mt-4 space-y-2"><Label htmlFor="payment-account-reveal-reason">查看原因</Label><Textarea id="payment-account-reveal-reason" value={revealReason} onChange={(event) => setRevealReason(event.target.value)} maxLength={500} rows={3} /></div>}
<DialogFooter><Button type="button" variant="secondary" onClick={() => { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); }}>关闭</Button>{!revealedAccount ? <Button type="button" disabled={revealReason.trim().length < 2 || revealMutation.isPending} onClick={() => revealMutation.mutate()}>{revealMutation.isPending ? "读取中..." : "确认查看"}</Button> : null}</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(disableTarget)} onOpenChange={(open) => { if (!open) setDisableTarget(null); }}>
<DialogContent className="max-w-md p-5">
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "该法人主体"} 的当前账户吗?</DialogDescription></DialogHeader>
<DialogFooter><Button type="button" variant="secondary" onClick={() => setDisableTarget(null)}>返回</Button><Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => { if (disableTarget) disableMutation.mutate(disableTarget.id); }}>{disableMutation.isPending ? "停用中..." : "确认停用"}</Button></DialogFooter>
</DialogContent>
</Dialog>
</PageScaffold>
);
}
export default PaymentAccountsPage;
+131 -15
查看文件
@@ -5,12 +5,14 @@ import {
cancelPaymentBatch,
createPaymentBatch,
downloadPaymentBatchReceiptAttachment,
downloadPaymentOrder,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
revealPaymentBatchPayeeBank,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment,
@@ -92,6 +94,24 @@ function resolveError(error: unknown) {
: "操作失败";
}
function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }) {
const subject = blocker.entityCode ? `${blocker.entityCode}:` : "";
const messages: Record<string, string> = {
status_not_reviewed: "仅待财务付款批次可生成制单",
reimbursement_only: "仅报销付款批次支持制单",
bank_channel_required: "付款渠道不是银行转账",
legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款",
payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整",
payer_bank_institution_missing: "公司付款账户缺少规范化开户银行",
payer_bank_not_supported: "公司付款账户不是建设银行",
items_missing: "批次没有可制单的报销明细",
legacy_payee_snapshot_missing: "未冻结完整收款账号,仅支持人工付款",
payee_bank_institution_missing: "未冻结规范化开户银行,仅支持人工付款",
payee_bank_not_supported: "收款账户不是建设银行,首版请人工转账",
};
return `${subject}${messages[blocker.code] ?? "制单资料不完整,请联系管理员核对"}`;
}
export function PaymentBatchesPage() {
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
@@ -100,6 +120,12 @@ export function PaymentBatchesPage() {
const canReviewBatch =
hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const canExportPaymentOrder =
hasPermission(permissions, "payment.order.export") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const canRevealPayeeBank =
hasPermission(permissions, "employee.bank.read") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
@@ -116,9 +142,12 @@ export function PaymentBatchesPage() {
const [reasonAction, setReasonAction] = useState<
| { kind: "cancel"; batchId: string; batchNo: string }
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
| { kind: "paymentOrder"; batchId: string; batchNo: string; reexport: boolean }
| { kind: "payeeBank"; batchId: string; batchNo: string; itemId: string; entityCode: string }
| null
>(null);
const [actionReason, setActionReason] = useState("");
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
const listQuery = useQuery({
queryKey: ["payment-batches"],
@@ -357,6 +386,37 @@ export function PaymentBatchesPage() {
},
});
const paymentOrderMutation = useMutation({
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
downloadPaymentOrder(batchId, reason),
onSuccess: async (response) => {
downloadBlob(response.blob, response.filename ?? "payment-order.xls");
setReasonAction(null);
setActionReason("");
await refresh();
toast.success("银行制单已生成,请导入企业网银后核对结果");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const revealPayeeBankMutation = useMutation({
mutationFn: ({ batchId, itemId, reason }: { batchId: string; itemId: string; reason: string }) =>
revealPaymentBatchPayeeBank(batchId, itemId, reason),
onSuccess: ({ bankAccount }) => {
setRevealedPayeeAccount(bankAccount);
window.setTimeout(() => revealPayeeBankMutation.reset(), 0);
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
return (
<ListWorkspace>
{downloadReasonDialog}
@@ -507,6 +567,18 @@ export function PaymentBatchesPage() {
复核通过
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
<Button
type="button"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
}}
>
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
</Button>
) : null}
{needsBankReceipt ? (
<Button
type="button"
@@ -548,16 +620,41 @@ export function PaymentBatchesPage() {
取消
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
<Button
type="button"
variant="secondary"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
}}
>
重新下载制单
</Button>
) : null}
</div>
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-6">
<div className="detail-fact-grid text-sm xl:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{formatMoney(selectedBatch.totalAmount)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款账户</div><div className="detail-fact-card__value">{selectedBatch.paymentAccount ? `${selectedBatch.paymentAccount.accountName} · ${selectedBatch.paymentAccount.accountNumberMasked}` : "未冻结(仅支持人工付款)"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行制单</div><div className="detail-fact-card__value">{selectedBatch.paymentOrderExportCount > 0 ? `已生成 ${selectedBatch.paymentOrderExportCount} 次` : "尚未生成"}</div></div>
</div>
{selectedBatch.status === "reviewed" && selectedBatch.paymentOrderExportBlockers.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900">
<div className="font-medium">当前批次不能生成银行制单,可继续人工付款:</div>
<ul className="mt-1 list-disc space-y-1 pl-5">
{selectedBatch.paymentOrderExportBlockers.map((blocker, index) => (
<li key={`${blocker.code}-${blocker.entityCode ?? "batch"}-${index}`}>{paymentOrderBlockerLabel(blocker)}</li>
))}
</ul>
</div>
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
@@ -582,8 +679,10 @@ export function PaymentBatchesPage() {
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "线下付款并上传银行回单"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "制单已生成,完成付款并上传银行回单"
: "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
@@ -598,7 +697,9 @@ export function PaymentBatchesPage() {
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。"
? selectedBatch.paymentOrderExportCount > 0
? "已生成银行制单,等待付款完成并上传回单。"
: "付款复核已通过。等待财务线下付款并上传回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
@@ -626,7 +727,7 @@ export function PaymentBatchesPage() {
<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">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</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) ? (
@@ -896,34 +997,43 @@ export function PaymentBatchesPage() {
if (!open) {
setReasonAction(null);
setActionReason("");
setRevealedPayeeAccount("");
}
}}
>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"}</DialogTitle>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : reasonAction?.kind === "paymentOrder" ? (reasonAction.reexport ? "重新下载银行制单" : "下载银行制单") : reasonAction?.kind === "payeeBank" ? "查看收款完整账号" : "取消付款批次"}</DialogTitle>
<DialogDescription>
{reasonAction?.kind === "voidReceipt"
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
: reasonAction?.kind === "paymentOrder"
? reasonAction.reexport
? `批次:${reasonAction.batchNo}。重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
: `批次:${reasonAction.batchNo}。文件包含完整银行账号,仅用于导入企业网银。`
: reasonAction?.kind === "payeeBank"
? `报销单:${reasonAction.entityCode}。本次查看会记录查看人、批次项、时间和原因。`
: reasonAction
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
: "请填写原因。"}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
{reasonAction?.kind === "payeeBank" && revealedPayeeAccount ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedPayeeAccount}</div>
) : <div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : "批次取消原因"}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "paymentOrder" ? "制单下载原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : "说明批次为何不再继续"}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "paymentOrder" ? "说明本次制单用途" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
/>
<div className="flex items-center justify-between text-xs text-zinc-500">
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
<span>{actionReason.trim().length}/500</span>
</div>
</div>
</div>}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setReasonAction(null);
@@ -931,19 +1041,25 @@ export function PaymentBatchesPage() {
}}>
返回
</Button>
<Button
{!(reasonAction?.kind === "payeeBank" && revealedPayeeAccount) ? <Button
type="button"
variant="destructive"
variant={reasonAction?.kind === "paymentOrder" ? "default" : "destructive"}
disabled={
actionReason.trim().length < 2 ||
cancelMutation.isPending ||
voidReceiptMutation.isPending
voidReceiptMutation.isPending ||
paymentOrderMutation.isPending ||
revealPayeeBankMutation.isPending
}
onClick={() => {
if (!reasonAction) return;
const reason = actionReason.trim();
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "paymentOrder") {
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "payeeBank") {
revealPayeeBankMutation.mutate({ batchId: reasonAction.batchId, itemId: reasonAction.itemId, reason });
} else {
voidReceiptMutation.mutate({
batchId: reasonAction.batchId,
@@ -953,8 +1069,8 @@ export function PaymentBatchesPage() {
}
}}
>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : "确认取消"}
</Button>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : reasonAction?.kind === "paymentOrder" ? "确认下载" : reasonAction?.kind === "payeeBank" ? "确认查看" : "确认取消"}
</Button> : null}
</DialogFooter>
</DialogContent>
</Dialog>
+1 -1
查看文件
@@ -52,7 +52,7 @@ const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.vie
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage", "payment.account.read", "payment.account.manage"];
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
const PROJECT_PORTAL_PERMISSIONS = ["project.view_own", "project.view_all", "project.manage"];
const ASSET_PORTAL_PERMISSIONS = ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"];
+8
查看文件
@@ -134,6 +134,14 @@ export function ProfilePage() {
</dl>
</SectionPanel>
<SectionPanel title="收款信息">
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
<div><dt className="text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
<div><dt className="text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
</dl>
{!me?.profileCompleteness.hasBankAccount ? <p className="mt-3 text-sm text-amber-700">收款信息尚未填写,请联系 HR 或管理员补充。</p> : null}
</SectionPanel>
<SectionPanel title="账号安全">
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
<div>
+1
查看文件
@@ -24,6 +24,7 @@ const ITEMS: ManagementItem[] = [
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区", description: "菜单入口、模块显隐和小团队基线。", group: "系统与审计", anyOf: ["system.settings"] },
{ to: "/audit-logs", icon: "manage_search", title: "审计日志", description: "关键操作留痕和排障。", group: "系统与审计", anyOf: ["system.audit.view"] },
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
{ to: "/system/payment-accounts", icon: "account_balance", title: "企业银行账户", description: "维护付款来源账户、脱敏查看与变更留痕。", group: "功能管理", anyOf: ["payment.account.read", "payment.account.manage"] },
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
];
+11 -1
查看文件
@@ -48,6 +48,7 @@ import {
PayrollPage,
PerformancePage,
PaymentBatchesPage,
PaymentAccountsPage,
PeoplePage,
PeopleStatsPage,
PortalAnnouncementsPage,
@@ -582,6 +583,15 @@ const routeElements = (
}
/>
<Route
path="system/payment-accounts"
element={
<RequirePermission anyOf={["payment.account.read", "payment.account.manage"]}>
{withRouteFallback(<PaymentAccountsPage />)}
</RequirePermission>
}
/>
<Route
path="system/notification-channels"
element={
@@ -603,7 +613,7 @@ const routeElements = (
<Route
path="system/settings"
element={
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "payment.account.read", "payment.account.manage"]}>
{withRouteFallback(<SystemSettingsPage />)}
</RequirePermission>
}
@@ -0,0 +1 @@
export { PaymentAccountsPage as default } from "../../pages/PaymentAccountsPage";
+1
查看文件
@@ -30,6 +30,7 @@ export const NotificationChannelsPage = lazy(() => import("./NotificationChannel
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
export const PaymentAccountsPage = lazy(() => import("./PaymentAccountsPage"));
export const PeoplePage = lazy(() => import("./PeoplePage"));
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
export const PerformancePage = lazy(() => import("./PerformancePage"));
+3 -1
查看文件
@@ -58,7 +58,9 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
项目模块 V1 只做轻项目台账:项目状态、负责人/成员、里程碑/任务、风险、交付记录,以及关联报销、采购、合同和付款。项目页面不做完整项目管理软件,不承接甘特图、复杂任务依赖、工时、资源排期、OKR、项目组合管理或大型协同平台;项目页主要负责业务归集、风险提醒和跳转到对应业务详情。
财务台账 V1 只做付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要。付款、应付账款和票据页面不做完整财务软件,不承接银行直连真实支付、银行流水自动导入、总账、凭证生成、自动对账或税务申报;真实付款继续在线下银行或既有财务/代账流程完成,OA 只记录状态、证据和交接摘要。OA 不接 ERP,未来也不规划 ERP 接入。
财务台账 V1 只做付款准备、付款批次、企业网银导入制单、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要。制单只是效率工具,不代表付款成功,也不是登记付款完成的门禁;银行回单仍是银行渠道付款的硬证据。付款、应付账款和票据页面不做完整财务软件,不承接银行直连真实支付、银行流水自动导入、总账、会计凭证生成、自动对账或税务申报;真实付款继续在线下银行或既有财务/代账流程完成,OA 只记录状态、证据和交接摘要。OA 不接 ERP,未来也不规划 ERP 接入。
员工收款信息默认只展示开户银行和脱敏尾号,由 HR/管理员维护,员工本人可核对但不自助修改;财务因转账需要查看完整账号时必须填写原因并留审计。付款复核与后续人工转账/制单使用同一份批次冻结收款快照,员工资料事后变化不得漂移已复核批次。公司企业银行账户独立挂在法人主体下,普通财务只读,财务负责人/管理员维护;新增、替换和停用须留审计并通知管理员与负责人。
付款相关动作集中在 `付款` / 财务处理详情执行。报销、采购、合同、项目等列表和普通详情只显示付款状态、回单状态、`查看付款`、`补回单` 或进入付款记录入口,不直接放 `标记已付款`、`取消付款`、`批量付款`、`执行付款`;付款动作前必须核对金额、对象、线下付款说明、证据和回单。
+8
查看文件
@@ -747,3 +747,11 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
- 列表页:`PageHeader + FilterBar + 主列表`
- 详情页:`概览 -> 执行 -> 留痕 -> 归档/合规`
- 若某页面出现两个以上竞争级主按钮,应视为设计回退并在上线前修正。
## 20. 企业银行账户与收款信息
- 部署本能力后,先由管理员在“管理 → 企业银行账户”配置法人主体的建设银行付款账户,再由 HR/管理员在员工档案“收款信息”中选择建设银行并录入工资卡账号;不要通过聊天、工单或仓库传递完整账号。
- 同一法人主体只保留一条当前启用账户。换卡使用“替换”,系统归档旧记录;不再使用时选择“停用”,不要覆盖历史记录。
- 默认页面只显示脱敏尾号。查看公司或员工完整账号必须填写业务原因,系统记录查看审计;查看完成后及时关闭或隐藏。
- 新建银行付款批次会冻结付款方和收款方账号。旧批次没有冻结密文,永久不支持制单,仍按人工转账、上传回单、登记付款完成的路径处理。
- 银行制单仅支持建设银行本行,生成真实 BIFF8 `.xls`。重新下载不会作废旧文件;若旧文件已导入网银,不得重复提交。
- 制单下载不是付款证据。企业网银付款完成后仍须上传银行回单,才能登记付款完成。
+13
查看文件
@@ -41,6 +41,7 @@
"prom-client": "^15.1.0",
"sharp": "^0.34.5",
"tencentcloud-sdk-nodejs": "^4.1.14",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
},
@@ -9350,6 +9351,18 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
+1
查看文件
@@ -84,6 +84,7 @@
"prom-client": "^15.1.0",
"sharp": "^0.34.5",
"tencentcloud-sdk-nodejs": "^4.1.14",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
},
@@ -0,0 +1,78 @@
-- Create bank institution master data.
CREATE TABLE "bank_institutions" (
"id" UUID NOT NULL,
"code" VARCHAR(16) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "bank_institutions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "bank_institutions_code_key" ON "bank_institutions"("code");
CREATE INDEX "idx_bank_institutions_status" ON "bank_institutions"("enabled", "archived_at");
-- Create legal-entity-owned company payment accounts.
CREATE TABLE "payment_accounts" (
"id" UUID NOT NULL,
"legal_entity_id" UUID NOT NULL,
"bank_institution_id" UUID NOT NULL,
"account_name" VARCHAR(120) NOT NULL,
"account_number_encrypted" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "payment_accounts_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "idx_payment_accounts_legal_entity_status" ON "payment_accounts"("legal_entity_id", "enabled", "archived_at");
CREATE INDEX "idx_payment_accounts_bank_institution" ON "payment_accounts"("bank_institution_id");
CREATE UNIQUE INDEX "payment_accounts_one_active_per_legal_entity_ux"
ON "payment_accounts"("legal_entity_id")
WHERE "enabled" = true AND "archived_at" IS NULL;
ALTER TABLE "payment_accounts"
ADD CONSTRAINT "payment_accounts_legal_entity_id_fkey"
FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "payment_accounts"
ADD CONSTRAINT "payment_accounts_bank_institution_id_fkey"
FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Employee bank identity is intentionally nullable; existing free text is not guessed.
ALTER TABLE "employees" ADD COLUMN "bank_institution_id" UUID;
CREATE INDEX "idx_employees_bank_institution" ON "employees"("bank_institution_id");
ALTER TABLE "employees"
ADD CONSTRAINT "employees_bank_institution_id_fkey"
FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Historical payment batches remain nullable and cannot be order-exported.
ALTER TABLE "payment_batches"
ADD COLUMN "legal_entity_id" UUID,
ADD COLUMN "payment_account_id" UUID,
ADD COLUMN "payment_account_snapshot_json" JSONB,
ADD COLUMN "payment_order_export_count" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "payment_order_last_exported_at" TIMESTAMP(3),
ADD COLUMN "payment_order_last_exported_by" UUID;
CREATE INDEX "idx_payment_batches_legal_entity" ON "payment_batches"("legal_entity_id");
CREATE INDEX "idx_payment_batches_payment_account" ON "payment_batches"("payment_account_id");
CREATE INDEX "idx_payment_batches_order_exported_by" ON "payment_batches"("payment_order_last_exported_by");
ALTER TABLE "payment_batches"
ADD CONSTRAINT "payment_batches_legal_entity_id_fkey"
FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "payment_batches"
ADD CONSTRAINT "payment_batches_payment_account_id_fkey"
FOREIGN KEY ("payment_account_id") REFERENCES "payment_accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "payment_batches"
ADD CONSTRAINT "payment_batches_payment_order_last_exported_by_fkey"
FOREIGN KEY ("payment_order_last_exported_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+60
查看文件
@@ -598,6 +598,7 @@ model Employee {
bank_account_encrypted String
bank_name String
bank_institution_id String? @db.Uuid
id_number_encrypted String?
password_hash String
@@ -625,6 +626,7 @@ model Employee {
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull)
bank_institution BankInstitution? @relation(fields: [bank_institution_id], references: [id], onDelete: SetNull)
direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict)
subordinates Employee[] @relation("EmployeeToManager")
@@ -642,6 +644,7 @@ model Employee {
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
payment_batches_last_exported PaymentBatch[] @relation("PaymentOrderLastExportedBy")
leave_requests LeaveRequest[]
leave_ledgers LeaveLedger[]
wecom_checkin_records WecomCheckinRecord[]
@@ -724,6 +727,7 @@ model Employee {
@@index([department_id])
@@index([legal_entity_id], map: "idx_employees_legal_entity")
@@index([bank_institution_id], map: "idx_employees_bank_institution")
@@index([direct_manager_id])
@@map("employees")
}
@@ -985,12 +989,56 @@ model LegalEntity {
card_authorizations CardAuthorization[]
asset_cards AssetCard[]
budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity")
payment_accounts PaymentAccount[]
payment_batches PaymentBatch[]
@@index([enabled, archived_at], map: "idx_legal_entities_status")
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
@@map("legal_entities")
}
model BankInstitution {
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(16)
name String @db.VarChar(100)
enabled Boolean @default(true)
archived_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employees Employee[]
payment_accounts PaymentAccount[]
@@index([enabled, archived_at], map: "idx_bank_institutions_status")
@@map("bank_institutions")
}
model PaymentAccount {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
bank_institution_id String @db.Uuid
account_name String @db.VarChar(120)
account_number_encrypted String
enabled Boolean @default(true)
archived_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict)
bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict)
payment_batches PaymentBatch[]
@@index([legal_entity_id, enabled, archived_at], map: "idx_payment_accounts_legal_entity_status")
@@index([bank_institution_id], map: "idx_payment_accounts_bank_institution")
@@map("payment_accounts")
}
model CostCenter {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
@@ -1856,6 +1904,12 @@ model PaymentBatch {
review_comment String?
secondary_review_comment String?
execution_summary_json Json?
legal_entity_id String? @db.Uuid
payment_account_id String? @db.Uuid
payment_account_snapshot_json Json?
payment_order_export_count Int @default(0)
payment_order_last_exported_at DateTime?
payment_order_last_exported_by String? @db.Uuid
created_at DateTime @default(now())
created_by String? @db.Uuid
@@ -1873,8 +1927,14 @@ model PaymentBatch {
items PaymentBatchItem[]
receipt_attachments PaymentBatchReceiptAttachment[]
legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull)
payment_account PaymentAccount? @relation(fields: [payment_account_id], references: [id], onDelete: SetNull)
payment_order_last_exported_by_employee Employee? @relation("PaymentOrderLastExportedBy", fields: [payment_order_last_exported_by], references: [id], onDelete: SetNull)
@@index([entity_type, status, created_at], map: "idx_payment_batches_entity_status_created")
@@index([legal_entity_id], map: "idx_payment_batches_legal_entity")
@@index([payment_account_id], map: "idx_payment_batches_payment_account")
@@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by")
@@map("payment_batches")
}
@@ -0,0 +1,42 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { encryptStringOrThrow } = require("../src/services/crypto");
const { generatePaymentOrderXls } = require("../src/services/paymentOrderXls");
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-xls-"));
const outputPath = path.join(temporaryDirectory, "synthetic-order.xls");
try {
const fieldEncryptionKey = Buffer.alloc(32, 7).toString("base64");
const config = { fieldEncryptionKey };
const encrypt = (value) => encryptStringOrThrow(config, value);
const generated = generatePaymentOrderXls(config, {
batch_no: "PB-20260802-001",
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成测试公司", accountNumberEncrypted: encrypt("6217000000000012345") },
items: [{ id: "1", entity_code: "BX-20260802-0001", amount: "1586.00", snapshot_json: { payee: { name: "合成员工", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } } }],
});
fs.writeFileSync(outputPath, generated.buffer);
const check = spawnSync("python3", ["-c", [
"import sys,xlrd",
"book=xlrd.open_workbook(sys.argv[1])",
"assert book.sheet_names()==['Sheet1','Sheet2','Sheet3']",
"assert [(s.nrows,s.ncols) for s in book.sheets()]==[(2,15),(0,0),(0,0)]",
"sheet=book.sheet_by_index(0)",
"assert sheet.cell_type(1,1)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,1)=='6217000000000012345'",
"assert sheet.cell_type(1,3)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,3)=='01'",
"assert sheet.cell_type(1,4)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,4)=='6222000000000056789'",
"assert sheet.cell_type(1,8)==xlrd.XL_CELL_TEXT",
"assert sheet.cell_type(1,9)==xlrd.XL_CELL_NUMBER and sheet.cell_value(1,9)==1586.0",
"assert sheet.cell_value(1,10)=='报销款' and sheet.cell_value(1,5)=='合成员工'",
].join(";"), outputPath], { encoding: "utf8" });
if (check.status !== 0) {
process.stderr.write(check.stderr || check.stdout || "xlrd roundtrip failed\n");
process.exitCode = 1;
} else {
process.stdout.write("[payment-order-xls][PASS] xlrd BIFF8 roundtrip verified\n");
}
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
+2
查看文件
@@ -49,6 +49,7 @@ const budgetControlRulesRoutes = require("./routes/budgetControlRules");
const feeControlRoutes = require("./routes/feeControl");
const riskFlagsRoutes = require("./routes/riskFlags");
const paymentBatchesRoutes = require("./routes/paymentBatches");
const paymentAccountsRoutes = require("./routes/paymentAccounts");
const apOperationsRoutes = require("./routes/apOperations");
const portalRoutes = require("./routes/portal");
const searchRoutes = require("./routes/search");
@@ -373,6 +374,7 @@ async function buildApp(dependencies = {}) {
await app.register(feeControlRoutes, { prefix: "/api" });
await app.register(riskFlagsRoutes, { prefix: "/api" });
await app.register(paymentBatchesRoutes, { prefix: "/api" });
await app.register(paymentAccountsRoutes, { prefix: "/api" });
await app.register(apOperationsRoutes, { prefix: "/api" });
await app.register(portalRoutes, { prefix: "/api" });
await app.register(searchRoutes, { prefix: "/api" });
+7 -1
查看文件
@@ -189,6 +189,9 @@ const ROLE_PERMISSIONS = {
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
"expense.view_all",
"procurement.view_all",
"asset.view_all",
@@ -210,6 +213,10 @@ const ROLE_PERMISSIONS = {
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"payment.account.manage",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
"expense.view_all",
"expense.finance.override_duplicate",
"procurement.view_all",
@@ -219,7 +226,6 @@ const ROLE_PERMISSIONS = {
"asset.inventory",
"system.settings",
"system.audit.view",
"payment.batch.manage",
"risk.review.manage",
"project.view_all",
"attendance.view_all",
+20 -1
查看文件
@@ -3,6 +3,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const NOTIFICATION_PREFERENCES_SCHEMA = z
.object({
@@ -30,7 +31,9 @@ function parseNotificationPreferences(body) {
function buildProfileCompleteness(employee) {
const hasEmergencyContact = Boolean(employee?.emergency_contact_name && employee?.emergency_contact_phone);
const hasBankAccount = Boolean(employee?.bank_account_encrypted && employee?.bank_name);
const hasBankAccount = Boolean(
employee?.bank_account_encrypted && (employee?.bank_institution || employee?.bank_name),
);
const hasAvatar = Boolean(employee?.avatar_path);
const hasIdNumber = Boolean(employee?.id_number_encrypted);
const completedCount = [hasEmergencyContact, hasBankAccount, hasAvatar, hasIdNumber].filter(Boolean).length;
@@ -66,6 +69,7 @@ async function meRoutes(fastify) {
emergency_contact_phone: true,
bank_account_encrypted: true,
bank_name: true,
bank_institution: { select: { id: true, code: true, name: true } },
avatar_path: true,
id_number_encrypted: true,
notification_push_enabled: true,
@@ -76,6 +80,16 @@ async function meRoutes(fastify) {
const roleCodes = (employee?.roles ?? []).map((r) => r.role_code);
const profileCompleteness = buildProfileCompleteness(employee);
let bankAccountMasked = null;
try {
const bankAccount = employee?.bank_account_encrypted
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
: null;
bankAccountMasked = bankAccount ? maskBankAccount(bankAccount) : null;
} catch (error) {
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500 });
}
return sendData(reply, request, {
id: employee?.id ?? request.employee.id,
employeeNo: employee?.employee_no ?? "",
@@ -87,6 +101,11 @@ async function meRoutes(fastify) {
status: employee?.status ?? "active",
notificationPushEnabled: employee?.notification_push_enabled ?? true,
roleCodes,
bankAccount: {
bankName: employee?.bank_institution?.name ?? employee?.bank_name ?? null,
bankInstitutionCode: employee?.bank_institution?.code ?? null,
accountNumberMasked: bankAccountMasked,
},
profileCompleteness,
wecomBinding: employee?.wecom_identity_binding ? { externalUserId: employee.wecom_identity_binding.external_user_id, createdAt: employee.wecom_identity_binding.created_at?.toISOString?.() ?? null } : null,
});
+419
查看文件
@@ -0,0 +1,419 @@
const { z } = require("zod");
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { createNotifications } = require("../services/notifications");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const UUID_SCHEMA = z.string().uuid();
const ACCOUNT_BODY_SCHEMA = z.object({
legalEntityId: UUID_SCHEMA,
bankInstitutionId: UUID_SCHEMA,
accountName: z.string().trim().min(1).max(120),
accountNumber: z.string().trim().regex(/^\d{6,32}$/),
}).strict();
const REVEAL_BODY_SCHEMA = z.object({
reason: z.string().trim().min(2).max(500),
}).strict();
const BANK_BODY_SCHEMA = z.object({
code: z.string().trim().min(2).max(16).regex(/^[A-Za-z0-9_]+$/),
name: z.string().trim().min(1).max(100),
}).strict();
const BANK_RENAME_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100) }).strict();
function validationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parse(schema, value) {
const result = schema.safeParse(value ?? {});
if (!result.success) {
throw validationError(result.error.flatten());
}
return result.data;
}
function parseId(params) {
const result = UUID_SCHEMA.safeParse(params?.id);
if (!result.success) {
throw validationError({ id: "invalid_uuid" });
}
return result.data;
}
function assertAnyPermission(fastify, request, permissions) {
if (!permissions.some((permission) => fastify.hasPermission(request.employee, permission))) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function decryptAccount(fastify, encrypted) {
try {
return decryptStringOrThrow(fastify.config, encrypted);
} catch (error) {
fastify.log.error({ err: error }, "payment account decryption failed");
throw new AppError({ code: "PAYMENT_ACCOUNT_DECRYPT_FAILED", message: "付款账户读取失败", statusCode: 500 });
}
}
function mapAccount(fastify, record) {
const accountNumber = decryptAccount(fastify, record.account_number_encrypted);
return {
id: record.id,
legalEntity: {
id: record.legal_entity.id,
code: record.legal_entity.code,
name: record.legal_entity.name,
},
bankInstitution: {
id: record.bank_institution.id,
code: record.bank_institution.code,
name: record.bank_institution.name,
},
accountName: record.account_name,
accountNumberMasked: maskBankAccount(accountNumber),
enabled: Boolean(record.enabled && !record.archived_at),
archivedAt: record.archived_at?.toISOString?.() ?? null,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
const ACCOUNT_INCLUDE = {
legal_entity: { select: { id: true, code: true, name: true } },
bank_institution: { select: { id: true, code: true, name: true } },
};
async function ensureReferences(tx, body) {
const [legalEntity, bankInstitution] = await Promise.all([
tx.legalEntity.findFirst({
where: { id: body.legalEntityId, enabled: true, archived_at: null },
select: { id: true, name: true },
}),
tx.bankInstitution.findFirst({
where: { id: body.bankInstitutionId, enabled: true, archived_at: null },
select: { id: true, code: true, name: true },
}),
]);
if (!legalEntity) throw validationError({ legalEntityId: "not_available" }, 409);
if (!bankInstitution) throw validationError({ bankInstitutionId: "not_available" }, 409);
return { legalEntity, bankInstitution };
}
async function notifyAccountChange(tx, account, action) {
const recipients = await tx.employeeRole.findMany({
where: {
role_code: { in: ["admin", "ceo"] },
employee: { status: "active" },
},
select: { employee_id: true },
});
const actionLabel = action === "created" ? "新增" : action === "replaced" ? "替换" : "停用";
await createNotifications(tx, {
recipientEmployeeIds: recipients.map((item) => item.employee_id),
type: "PAYMENT_ACCOUNT_CHANGED",
title: `企业银行账户已${actionLabel}`,
body: `${account.legalEntity.name}的企业银行账户已${actionLabel},当前尾号为${account.accountNumberMasked?.slice(-4) ?? "未提供"}。`,
targetType: "payment_account",
targetId: account.id,
});
}
function handleWriteError(error) {
if (error?.code === "P2002") {
throw new AppError({
code: "PAYMENT_ACCOUNT_ACTIVE_CONFLICT",
message: "该法人主体已有启用的企业银行账户,请使用替换操作",
statusCode: 409,
});
}
throw error;
}
async function paymentAccountsRoutes(fastify) {
fastify.get("/bank-institutions", { preHandler: fastify.authenticate }, async (request, reply) => {
assertAnyPermission(fastify, request, [
"payment.account.read",
"payment.account.manage",
"employee.bank.read",
"employee.bank.manage",
]);
const rows = await fastify.prisma.bankInstitution.findMany({
where: { enabled: true, archived_at: null },
orderBy: [{ name: "asc" }, { code: "asc" }],
select: { id: true, code: true, name: true },
});
return sendData(reply, request, { items: rows });
});
fastify.post(
"/bank-institutions",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const body = parse(BANK_BODY_SCHEMA, request.body);
let bank;
try {
bank = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.bankInstitution.create({
data: {
code: body.code.toUpperCase(),
name: body.name,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: { id: true, code: true, name: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BANK_INSTITUTION_CREATED",
targetType: "bank_institution",
targetId: created.id,
extra: { code: created.code },
}),
});
return created;
});
} catch (error) {
if (error?.code === "P2002") {
throw validationError({ code: "already_exists" }, 409);
}
throw error;
}
return sendData(reply.code(201), request, bank);
},
);
fastify.patch(
"/bank-institutions/:id",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const bankId = parseId(request.params);
const body = parse(BANK_RENAME_BODY_SCHEMA, request.body);
const bank = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.bankInstitution.findUnique({
where: { id: bankId },
select: { id: true, code: true, name: true, archived_at: true },
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
const updated = await tx.bankInstitution.update({
where: { id: bankId },
data: { name: body.name, updated_by: request.employee.id },
select: { id: true, code: true, name: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BANK_INSTITUTION_RENAMED",
targetType: "bank_institution",
targetId: bankId,
extra: { code: current.code, beforeName: current.name, afterName: updated.name },
}),
});
return updated;
});
return sendData(reply, request, bank);
},
);
fastify.post(
"/bank-institutions/:id/disable",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const bankId = parseId(request.params);
const result = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.bankInstitution.findUnique({
where: { id: bankId },
select: { id: true, code: true, enabled: true, archived_at: true },
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
const inUse = await tx.paymentAccount.count({
where: { bank_institution_id: bankId, enabled: true, archived_at: null },
});
if (inUse > 0) throw validationError({ bankInstitution: "active_payment_account_exists" }, 409);
const now = new Date();
await tx.bankInstitution.update({
where: { id: bankId },
data: { enabled: false, archived_at: now, updated_by: request.employee.id },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BANK_INSTITUTION_DISABLED",
targetType: "bank_institution",
targetId: bankId,
extra: { code: current.code },
}),
});
return { id: bankId, disabled: true };
});
return sendData(reply, request, result);
},
);
fastify.get(
"/payment-accounts",
{ preHandler: fastify.requirePermission("payment.account.read") },
async (request, reply) => {
const rows = await fastify.prisma.paymentAccount.findMany({
orderBy: [{ legal_entity: { code: "asc" } }, { created_at: "desc" }],
include: ACCOUNT_INCLUDE,
});
return sendData(reply, request, { items: rows.map((row) => mapAccount(fastify, row)) });
},
);
fastify.post(
"/payment-accounts",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const body = parse(ACCOUNT_BODY_SCHEMA, request.body);
let created;
try {
created = await fastify.prisma.$transaction(async (tx) => {
await ensureReferences(tx, body);
const record = await tx.paymentAccount.create({
data: {
legal_entity_id: body.legalEntityId,
bank_institution_id: body.bankInstitutionId,
account_name: body.accountName,
account_number_encrypted: encryptStringOrThrow(fastify.config, body.accountNumber),
created_by: request.employee.id,
updated_by: request.employee.id,
},
include: ACCOUNT_INCLUDE,
});
const mapped = mapAccount(fastify, record);
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_CREATED",
targetType: "payment_account",
targetId: record.id,
extra: { legalEntityId: body.legalEntityId, bankInstitutionCode: record.bank_institution.code, accountNumberMasked: mapped.accountNumberMasked },
}),
});
await notifyAccountChange(tx, mapped, "created");
return mapped;
});
} catch (error) {
handleWriteError(error);
}
return sendData(reply.code(201), request, created);
},
);
fastify.post(
"/payment-accounts/:id/replace",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const accountId = parseId(request.params);
const body = parse(ACCOUNT_BODY_SCHEMA, request.body);
let created;
try {
created = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.paymentAccount.findFirst({
where: { id: accountId, enabled: true, archived_at: null },
include: ACCOUNT_INCLUDE,
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (current.legal_entity_id !== body.legalEntityId) {
throw validationError({ legalEntityId: "must_match_current_account" });
}
await ensureReferences(tx, body);
const now = new Date();
await tx.paymentAccount.update({
where: { id: current.id },
data: { enabled: false, archived_at: now, updated_at: now, updated_by: request.employee.id },
});
const next = await tx.paymentAccount.create({
data: {
legal_entity_id: body.legalEntityId,
bank_institution_id: body.bankInstitutionId,
account_name: body.accountName,
account_number_encrypted: encryptStringOrThrow(fastify.config, body.accountNumber),
created_by: request.employee.id,
updated_by: request.employee.id,
},
include: ACCOUNT_INCLUDE,
});
const mapped = mapAccount(fastify, next);
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_REPLACED",
targetType: "payment_account",
targetId: next.id,
extra: { previousAccountId: current.id, legalEntityId: body.legalEntityId, accountNumberMasked: mapped.accountNumberMasked },
}),
});
await notifyAccountChange(tx, mapped, "replaced");
return mapped;
});
} catch (error) {
handleWriteError(error);
}
return sendData(reply, request, created);
},
);
fastify.post(
"/payment-accounts/:id/disable",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const accountId = parseId(request.params);
const result = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.paymentAccount.findFirst({
where: { id: accountId, enabled: true, archived_at: null },
include: ACCOUNT_INCLUDE,
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
const mapped = mapAccount(fastify, current);
const now = new Date();
await tx.paymentAccount.update({
where: { id: current.id },
data: { enabled: false, archived_at: now, updated_at: now, updated_by: request.employee.id },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_DISABLED",
targetType: "payment_account",
targetId: current.id,
extra: { legalEntityId: current.legal_entity_id, accountNumberMasked: mapped.accountNumberMasked },
}),
});
await notifyAccountChange(tx, mapped, "disabled");
return { id: current.id, disabled: true };
});
return sendData(reply, request, result);
},
);
fastify.post(
"/payment-accounts/:id/reveal",
{ preHandler: fastify.requirePermission("payment.account.read_full") },
async (request, reply) => {
const accountId = parseId(request.params);
const { reason } = parse(REVEAL_BODY_SCHEMA, request.body);
const account = await fastify.prisma.paymentAccount.findUnique({
where: { id: accountId },
select: { id: true, account_number_encrypted: true },
});
if (!account) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED",
targetType: "payment_account",
targetId: account.id,
extra: { reason },
}),
});
return sendData(reply, request, { accountNumber: decryptAccount(fastify, account.account_number_encrypted) });
},
);
}
module.exports = paymentAccountsRoutes;
+263 -6
查看文件
@@ -18,6 +18,7 @@ const { flushNotificationDispatchTasks } = require("../services/notificationDisp
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { generatePaymentOrderXls, PaymentOrderXlsError } = require("../services/paymentOrderXls");
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
buildAttachmentContentDisposition,
@@ -38,6 +39,10 @@ const {
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
const PAYMENT_CHANNEL_SCHEMA = z.enum(["bank", "alipay", "wechat", "cash", "other"]);
const PAYMENT_ACCOUNT_INCLUDE = {
legal_entity: { select: { id: true, code: true, name: true } },
bank_institution: { select: { id: true, code: true, name: true } },
};
const LIST_QUERY_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA.optional(),
@@ -251,6 +256,22 @@ function ensureBatchReviewPermission(fastify, request) {
}
}
function ensurePaymentOrderExportPermission(fastify, request) {
const roleCodes = request.employee?.roleCodes ?? [];
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
if (!hasFinanceRole || !fastify.hasPermission(request.employee, "payment.order.export")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensurePayeeBankRevealPermission(fastify, request) {
const roleCodes = request.employee?.roleCodes ?? [];
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
if (!hasFinanceRole || !fastify.hasPermission(request.employee, "employee.bank.read")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function roleCodesOf(employee) {
return (employee?.roles ?? []).map((role) => role.role_code).filter(Boolean);
}
@@ -492,18 +513,27 @@ async function lockPaymentEntities(tx, entityType, entityIds) {
`);
}
function buildPayeeSnapshot(config, owner) {
function buildPayeeSnapshot(config, owner, options = {}) {
const encryptedAccount = owner?.bank_account_encrypted;
const bankAccount = encryptedAccount
? decryptStringOrThrow(config, encryptedAccount)
: null;
let bankAccount = null;
try {
bankAccount = encryptedAccount ? decryptStringOrThrow(config, encryptedAccount) : null;
} catch (error) {
if (options.includeEncrypted) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
}
throw error;
}
if (encryptedAccount && !bankAccount) {
throw new Error("Payee bank account could not be decrypted");
}
return {
name: owner?.name ?? null,
bankName: owner?.bank_name ?? null,
bankInstitutionId: owner?.bank_institution?.id ?? null,
bankInstitutionCode: owner?.bank_institution?.code ?? null,
bankName: owner?.bank_institution?.name ?? owner?.bank_name ?? null,
bankAccountMasked: maskBankAccount(bankAccount),
...(options.includeEncrypted ? { bankAccountEncrypted: encryptedAccount ?? null } : {}),
};
}
@@ -525,8 +555,10 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
owner: {
select: {
name: true,
legal_entity_id: true,
bank_name: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
},
},
@@ -539,6 +571,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
currencyCode: "CNY",
status: item.status,
departmentId: item.department_id ?? null,
legalEntityId: item.owner?.legal_entity_id ?? null,
snapshotJson: {
formNumber: item.form_number ?? null,
description: item.description ?? null,
@@ -547,7 +580,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
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),
payee: buildPayeeSnapshot(config, item.owner, { includeEncrypted: true }),
},
}));
}
@@ -662,6 +695,7 @@ async function loadLegacyPayees(tx, batch, config) {
name: true,
bank_name: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
},
},
@@ -669,6 +703,41 @@ async function loadLegacyPayees(tx, batch, config) {
return new Map(reimbursements.map((item) => [item.id, buildPayeeSnapshot(config, item.owner)]));
}
function readPaymentAccountSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null;
const accountName = normalizeSnapshotText(snapshot.accountName);
const bankName = normalizeSnapshotText(snapshot.bankName);
const bankInstitutionCode = normalizeSnapshotText(snapshot.bankInstitutionCode);
const accountNumberMasked = normalizeMaskedBankAccount(snapshot.accountNumberMasked);
if (!accountName || !bankName || !bankInstitutionCode || !accountNumberMasked) return null;
return { accountName, bankName, bankInstitutionCode, accountNumberMasked };
}
function paymentOrderExportBlockers(batch) {
const blockers = [];
const add = (code, entityCode) => blockers.push({ code, ...(entityCode ? { entityCode } : {}) });
if (batch.status !== "reviewed") add("status_not_reviewed");
if (batch.entity_type !== "reimbursement") add("reimbursement_only");
if (batch.default_payment_channel !== "bank") add("bank_channel_required");
if (!batch.payment_account_snapshot_json) {
add("legacy_payment_account_snapshot_missing");
} else {
const payer = readPaymentAccountSnapshot(batch.payment_account_snapshot_json);
if (!payer) add("payment_account_snapshot_invalid");
if (payer && !payer.bankInstitutionCode) add("payer_bank_institution_missing");
else if (payer && payer.bankInstitutionCode !== "CCB") add("payer_bank_not_supported");
}
if (!(batch.items ?? []).length) add("items_missing");
for (const item of batch.items ?? []) {
const entityCode = normalizeSnapshotText(item.entity_code) ?? undefined;
const payee = item.snapshot_json?.payee;
if (!payee?.bankAccountEncrypted) add("legacy_payee_snapshot_missing", entityCode);
if (!payee?.bankInstitutionCode) add("payee_bank_institution_missing", entityCode);
else if (payee.bankInstitutionCode !== "CCB") add("payee_bank_not_supported", entityCode);
}
return blockers;
}
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
const includePayee = options.includePayee === true;
const batchRisk = summarizeBatchRisk(batch);
@@ -681,6 +750,7 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
riskMap.get(entityId).push(item);
}
const paymentOrderExportBlockersValue = paymentOrderExportBlockers(batch);
return {
id: batch.id,
batchNo: batch.batch_no,
@@ -697,6 +767,13 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
secondaryReviewComment: batch.secondary_review_comment ?? null,
executionSummaryJson: batch.execution_summary_json ?? null,
receiptAttachmentCount: batch._count?.receipt_attachments ?? 0,
paymentAccount: readPaymentAccountSnapshot(batch.payment_account_snapshot_json),
paymentOrderExportCount: Number(batch.payment_order_export_count ?? 0),
paymentOrderLastExportedAt: batch.payment_order_last_exported_at?.toISOString?.() ?? null,
paymentOrderLastExportedBy: batch.payment_order_last_exported_by ?? null,
canExportPaymentOrder: paymentOrderExportBlockersValue.length === 0,
paymentOrderExportBlockReason: paymentOrderExportBlockersValue[0]?.code ?? null,
paymentOrderExportBlockers: paymentOrderExportBlockersValue,
createdAt: batch.created_at?.toISOString?.() ?? null,
createdBy: batch.created_by ?? null,
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
@@ -1335,6 +1412,60 @@ async function paymentBatchesRoutes(fastify) {
ensureBatchEntityStatus(body.entityType, snapshot);
}
const isBankBatch = (body.defaultPaymentChannel ?? "bank") === "bank";
let paymentAccount = null;
let paymentAccountSnapshot = null;
let legalEntityId = null;
if (isBankBatch) {
const legalEntityIds = Array.from(
new Set(snapshots.map((snapshot) => snapshot.legalEntityId).filter(Boolean)),
);
if (legalEntityIds.length !== 1 || snapshots.some((snapshot) => !snapshot.legalEntityId)) {
throw buildValidationError({ legalEntity: "single_legal_entity_required" }, 409);
}
legalEntityId = legalEntityIds[0];
const invalidPayee = snapshots.find((snapshot) => {
const payee = snapshot.snapshotJson?.payee;
return payee?.bankInstitutionCode !== "CCB" || !payee?.bankAccountEncrypted;
});
if (invalidPayee) {
throw buildValidationError({
payeeBankAccount: `${invalidPayee.entityCode}:ccb_account_required`,
}, 409);
}
paymentAccount = await tx.paymentAccount.findFirst({
where: {
legal_entity_id: legalEntityId,
enabled: true,
archived_at: null,
bank_institution: { code: "CCB", enabled: true, archived_at: null },
},
include: PAYMENT_ACCOUNT_INCLUDE,
});
if (!paymentAccount) {
throw buildValidationError({ paymentAccount: "active_ccb_account_required" }, 409);
}
let paymentAccountNumber;
try {
paymentAccountNumber = decryptStringOrThrow(
fastify.config,
paymentAccount.account_number_encrypted,
);
} catch (error) {
throw buildValidationError({ paymentAccount: "decrypt_failed" }, 409);
}
paymentAccountSnapshot = {
paymentAccountId: paymentAccount.id,
legalEntityId,
accountName: paymentAccount.account_name,
bankInstitutionId: paymentAccount.bank_institution.id,
bankInstitutionCode: paymentAccount.bank_institution.code,
bankName: paymentAccount.bank_institution.name,
accountNumberEncrypted: paymentAccount.account_number_encrypted,
accountNumberMasked: maskBankAccount(paymentAccountNumber),
};
}
const reservedItems = await tx.paymentBatchItem.findMany({
where: {
entity_id: { in: entityIds },
@@ -1365,6 +1496,9 @@ async function paymentBatchesRoutes(fastify) {
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
default_payment_channel: body.defaultPaymentChannel ?? "bank",
voucher_prefix: normalizeNullableString(body.voucherPrefix),
legal_entity_id: legalEntityId,
payment_account_id: paymentAccount?.id ?? null,
payment_account_snapshot_json: paymentAccountSnapshot,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -1433,6 +1567,129 @@ async function paymentBatchesRoutes(fastify) {
});
});
fastify.post(
"/payment-batches/:id/payment-order",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensurePaymentOrderExportPermission(fastify, request);
const batchId = parseId(request.params?.id);
const { reason } = parseBody(REASON_SCHEMA, request.body);
let result;
try {
result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (batch.status !== "reviewed") {
throw buildValidationError({ status: "payment_order_requires_reviewed_batch" }, 409);
}
if (batch.entity_type !== "reimbursement") {
throw buildValidationError({ entityType: "payment_order_reimbursement_only" }, 409);
}
const generated = generatePaymentOrderXls(fastify.config, batch);
const exportCount = Number(batch.payment_order_export_count ?? 0) + 1;
const exportedAt = new Date();
await tx.paymentBatch.update({
where: { id: batch.id },
data: {
payment_order_export_count: exportCount,
payment_order_last_exported_at: exportedAt,
payment_order_last_exported_by: request.employee.id,
updated_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: exportCount === 1 ? "PAYMENT_ORDER_EXPORTED" : "PAYMENT_ORDER_REEXPORTED",
targetType: "payment_batch",
targetId: batch.id,
extra: {
batchNo: batch.batch_no,
rowCount: generated.rowCount,
totalAmount: generated.totalAmount,
reason,
exportCount,
},
}),
});
return { ...generated, batchNo: batch.batch_no };
});
} catch (error) {
const resultCode = error instanceof PaymentOrderXlsError
? error.code
: error?.details?.status ?? error?.code ?? "export_failed";
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ORDER_EXPORT_BLOCKED",
targetType: "payment_batch",
targetId: batchId,
extra: { reason, resultCode },
}),
}).catch((auditError) => {
fastify.log.error({ err: auditError, requestId: request.id }, "payment order block audit failed");
});
if (error instanceof PaymentOrderXlsError) {
throw new AppError({
code: "PAYMENT_ORDER_EXPORT_BLOCKED",
message: "银行制单生成失败",
statusCode: 409,
details: { reason: error.code, ...error.details },
});
}
throw error;
}
const safeBatchNo = String(result.batchNo).replace(/[^A-Za-z0-9-]/g, "_");
reply.header("content-type", "application/vnd.ms-excel");
reply.header("content-disposition", `attachment; filename="payment-order-${safeBatchNo}.xls"`);
reply.header("cache-control", "no-store");
reply.header("x-content-type-options", "nosniff");
return reply.send(result.buffer);
},
);
fastify.post(
"/payment-batches/:id/items/:itemId/payee-bank/reveal",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensurePayeeBankRevealPermission(fastify, request);
const batchId = parseId(request.params?.id);
const itemId = parseId(request.params?.itemId, "itemId");
const { reason } = parseBody(REASON_SCHEMA, request.body);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payee_bank_reveal_not_available" }, 409);
}
const item = batch.items.find((candidate) => candidate.id === itemId);
if (!item) throw buildNotFoundError();
const encryptedAccount = item.snapshot_json?.payee?.bankAccountEncrypted;
if (!encryptedAccount) {
throw buildValidationError({ payeeBankAccount: "legacy_snapshot_missing" }, 409);
}
let bankAccount;
try {
bankAccount = decryptStringOrThrow(fastify.config, encryptedAccount);
} catch (error) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED",
targetType: "payment_batch",
targetId: batch.id,
extra: { paymentBatchItemId: item.id, entityCode: item.entity_code, reason },
}),
});
return sendData(reply, request, { bankAccount });
},
);
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchReadPermission(fastify, request);
await ensureFeatureEnabled(fastify);
+103 -23
查看文件
@@ -97,12 +97,16 @@ const resetPasswordBodySchema = z.object({
const sensitiveBodySchema = z
.object({
bankName: z.string().trim().max(100).nullable().optional(),
bankAccount: z.string().trim().max(100).nullable().optional(),
bankInstitutionId: z.string().uuid().nullable().optional(),
bankAccount: z.string().trim().regex(/^\d{6,32}$/).nullable().optional(),
idNumber: z.string().trim().max(64).nullable().optional(),
})
.strict();
const sensitiveRevealBodySchema = z.object({
reason: z.string().trim().min(2).max(500),
}).strict();
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "email", "departmentName"];
const USER_IMPORT_OPTIONAL_HEADERS = ["phone", "title", "directManagerEmployeeNo", "roles", "hireDate"];
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
@@ -1863,7 +1867,8 @@ async function usersRoutes(fastify, opts = {}) {
},
async (request, reply) => {
const userId = parseUserId(request.params);
const canReadBank = fastify.hasPermission(request.employee, "employee.bank.read")
const canReadBank = request.employee.id === userId
|| fastify.hasPermission(request.employee, "employee.bank.read")
|| fastify.hasPermission(request.employee, "employee.bank.manage");
const canReadIdentity = fastify.hasPermission(request.employee, "employee.identity.manage");
if (!canReadBank && !canReadIdentity) {
@@ -1877,7 +1882,9 @@ async function usersRoutes(fastify, opts = {}) {
...(canReadBank
? {
bank_name: true,
bank_institution_id: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
}
: {}),
...(canReadIdentity ? { id_number_encrypted: true } : {}),
@@ -1895,10 +1902,19 @@ async function usersRoutes(fastify, opts = {}) {
const data = {};
try {
if (canReadBank) {
data.bankName = employee.bank_name ? String(employee.bank_name) : null;
data.bankAccount = employee.bank_account_encrypted
const bankAccount = employee.bank_account_encrypted
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
: null;
data.bankInstitution = employee.bank_institution
? {
id: employee.bank_institution.id,
code: employee.bank_institution.code,
name: employee.bank_institution.name,
}
: null;
data.bankName = employee.bank_institution?.name
?? (employee.bank_name ? String(employee.bank_name) : null);
data.bankAccountMasked = bankAccount ? maskBankAccount(bankAccount) : null;
}
if (canReadIdentity) {
data.idNumber = employee.id_number_encrypted
@@ -1915,22 +1931,71 @@ async function usersRoutes(fastify, opts = {}) {
});
}
if (canReadIdentity) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_SENSITIVE_VIEWED",
targetType: "employee",
targetId: userId,
extra: { scopes: ["identity"] },
}),
});
}
return sendData(reply, request, data);
},
);
fastify.post(
`${routeBasePath}/:id/sensitive/bank/reveal`,
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const userId = parseUserId(request.params);
const parsed = sensitiveRevealBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const canRevealBank = fastify.hasPermission(request.employee, "employee.bank.read")
|| fastify.hasPermission(request.employee, "employee.bank.manage");
if (!canRevealBank) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: userId },
select: { id: true, bank_account_encrypted: true },
});
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
let bankAccount = null;
try {
bankAccount = employee.bank_account_encrypted
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
: null;
} catch (error) {
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500, details: {} });
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_SENSITIVE_VIEWED",
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
targetType: "employee",
targetId: userId,
extra: {
scopes: [
...(canReadBank ? ["bank"] : []),
...(canReadIdentity ? ["identity"] : []),
],
},
extra: { reason: parsed.data.reason },
}),
});
return sendData(reply, request, data);
return sendData(reply, request, { bankAccount });
},
);
@@ -1942,7 +2007,7 @@ async function usersRoutes(fastify, opts = {}) {
async (request, reply) => {
const userId = parseUserId(request.params);
const body = parseSensitiveBody(request.body);
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankName")
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankInstitutionId")
|| Object.prototype.hasOwnProperty.call(body, "bankAccount");
const writesIdentity = Object.prototype.hasOwnProperty.call(body, "idNumber");
if (writesBank) {
@@ -1960,7 +2025,7 @@ async function usersRoutes(fastify, opts = {}) {
where: { id: userId },
select: {
id: true,
...(writesBank ? { bank_name: true } : {}),
...(writesBank ? { bank_name: true, bank_institution_id: true } : {}),
},
});
if (!current) {
@@ -1977,13 +2042,28 @@ async function usersRoutes(fastify, opts = {}) {
updated_by: request.employee.id,
};
if (Object.prototype.hasOwnProperty.call(body, "bankName")) {
const nextBankName =
typeof body.bankName === "string" ? body.bankName.trim() : body.bankName;
data.bank_name = nextBankName ? String(nextBankName) : "";
changes.bankName = {
before: current.bank_name ? String(current.bank_name) : null,
after: data.bank_name || null,
if (Object.prototype.hasOwnProperty.call(body, "bankInstitutionId")) {
let bankInstitution = null;
if (body.bankInstitutionId) {
bankInstitution = await tx.bankInstitution.findFirst({
where: { id: body.bankInstitutionId, enabled: true, archived_at: null },
select: { id: true, code: true, name: true },
});
if (!bankInstitution) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { bankInstitutionId: "not_available" },
});
}
}
data.bank_institution_id = bankInstitution?.id ?? null;
data.bank_name = bankInstitution?.name ?? "";
changes.bankInstitution = {
beforeId: current.bank_institution_id ?? null,
afterId: bankInstitution?.id ?? null,
afterCode: bankInstitution?.code ?? null,
};
}
+36
查看文件
@@ -567,6 +567,36 @@ async function seedExpenseTypes(prisma) {
}
}
async function seedBankInstitutions(prisma) {
const existing = await prisma.bankInstitution.findUnique({
where: { code: "CCB" },
select: { id: true },
});
if (existing) {
return { created: 0, preserved: 1 };
}
return prisma.bankInstitution.create({
data: {
code: "CCB",
name: "中国建设银行",
enabled: true,
archived_at: null,
created_by: null,
updated_by: null,
},
}).then(
() => ({ created: 1, preserved: 0 }),
(error) => {
if (error?.code === "P2002") {
return { created: 0, preserved: 1 };
}
throw error;
},
);
}
async function seedApprovalRules(prisma) {
const existingCount = await prisma.approvalRule.count();
if (existingCount > 0) {
@@ -1032,6 +1062,7 @@ async function main() {
await prisma.$connect();
try {
const bankInstitutionSeed = await seedBankInstitutions(prisma);
await seedExpenseTypes(prisma);
const approvalRuleSeed = await seedApprovalRules(prisma);
await seedDocumentSequence(prisma);
@@ -1046,6 +1077,10 @@ async function main() {
// eslint-disable-next-line no-console
console.log("Seeded config data:");
// eslint-disable-next-line no-console
console.log(
`- bank institutions: ${bankInstitutionSeed.created} created / ${bankInstitutionSeed.preserved} preserved`,
);
// eslint-disable-next-line no-console
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
// eslint-disable-next-line no-console
console.log(
@@ -1096,6 +1131,7 @@ module.exports = {
DEFAULT_FEATURE_FLAGS,
ENABLED_EXPENSE_TYPES,
seedApprovalRules,
seedBankInstitutions,
seedExpenseTypes,
seedFeatureFlags,
};
+197
查看文件
@@ -0,0 +1,197 @@
const XLSX = require("xlsx");
const { decryptStringOrThrow } = require("./crypto");
const HEADERS = Object.freeze([
"*序号",
"*付款方客户账号",
"*付款方账户名称",
"*收款方行别代码\n(01-本行 02-国内他行)",
"*收款方客户账号",
"*收款方账户名称",
"收款方开户行名称",
"收款方联行号",
"客户方流水号",
"*金额",
"*用途",
"备注",
"是否短信通知收款人(0-不通知,1-通知,默认为0-不通知。)",
"收款人手机号码",
"短信通知附加信息",
]);
class PaymentOrderXlsError extends Error {
constructor(code, details = {}) {
super(code);
this.name = "PaymentOrderXlsError";
this.code = code;
this.details = details;
}
}
function buildCustomerSerial(batchNo, rowNumber) {
const serial = `${String(batchNo).trim()}-${String(rowNumber).padStart(3, "0")}`;
if (serial.length > 32) {
throw new PaymentOrderXlsError("customer_serial_too_long");
}
if (!/^[A-Z0-9-]+$/.test(serial)) {
throw new PaymentOrderXlsError("customer_serial_invalid_characters");
}
return serial;
}
function requiredText(value, code) {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized) throw new PaymentOrderXlsError(code);
return normalized;
}
function decryptRequired(config, value, code) {
try {
return requiredText(value ? decryptStringOrThrow(config, value) : "", code);
} catch (error) {
if (error instanceof PaymentOrderXlsError) throw error;
throw new PaymentOrderXlsError(code);
}
}
function stringCell(value = "") {
return { t: "s", v: String(value) };
}
function buildPaymentOrderRows(config, batch) {
const blockers = [];
const addBlocker = (code, entityCode) => {
blockers.push({ code, ...(entityCode ? { entityCode: String(entityCode) } : {}) });
};
const payer = batch?.payment_account_snapshot_json;
if (!payer || typeof payer !== "object" || Array.isArray(payer)) {
addBlocker("payment_account_snapshot_missing");
}
if (payer && !payer.bankInstitutionCode) {
addBlocker("payer_bank_institution_missing");
} else if (payer && payer.bankInstitutionCode !== "CCB") {
addBlocker("payer_bank_not_supported");
}
let payerAccount = "";
let payerName = "";
if (payer) {
try {
payerAccount = decryptRequired(config, payer.accountNumberEncrypted, "payer_account_unavailable");
} catch (error) {
addBlocker(error.code ?? "payer_account_unavailable");
}
try {
payerName = requiredText(payer.accountName, "payer_name_missing");
} catch (error) {
addBlocker(error.code ?? "payer_name_missing");
}
}
const items = [...(batch.items ?? [])].sort((left, right) =>
String(left.entity_code).localeCompare(String(right.entity_code), "zh-CN")
|| String(left.id).localeCompare(String(right.id)),
);
if (!items.length) addBlocker("items_missing");
const serials = new Set();
let totalAmount = 0;
const preparedRows = items.map((item, index) => {
const payee = item.snapshot_json?.payee;
const entityCodeHint = typeof item.entity_code === "string" && item.entity_code.trim()
? item.entity_code.trim()
: undefined;
if (!payee?.bankInstitutionCode) {
addBlocker("payee_bank_institution_missing", entityCodeHint);
} else if (payee.bankInstitutionCode !== "CCB") {
addBlocker("payee_bank_not_supported", entityCodeHint);
}
let payeeAccount = "";
let payeeName = "";
let entityCode = "";
try {
payeeAccount = decryptRequired(config, payee?.bankAccountEncrypted, "payee_account_unavailable");
} catch (error) {
addBlocker(error.code ?? "payee_account_unavailable", entityCodeHint);
}
try {
payeeName = requiredText(payee?.name, "payee_name_missing");
} catch (error) {
addBlocker(error.code ?? "payee_name_missing", entityCodeHint);
}
try {
entityCode = requiredText(item.entity_code, "entity_code_missing");
} catch (error) {
addBlocker(error.code ?? "entity_code_missing");
}
const amount = Number(item.amount);
if (!Number.isFinite(amount) || amount <= 0) {
addBlocker("amount_invalid", entityCodeHint);
}
let serial = "";
try {
serial = buildCustomerSerial(batch.batch_no, index + 1);
if (serials.has(serial)) addBlocker("customer_serial_duplicate", entityCodeHint);
serials.add(serial);
} catch (error) {
addBlocker(error.code ?? "customer_serial_invalid", entityCodeHint);
}
if (Number.isFinite(amount) && amount > 0) totalAmount += amount;
return { payee, payeeAccount, payeeName, entityCode, amount, serial };
});
if (blockers.length) {
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
}
const rows = preparedRows.map(({ payee, payeeAccount, payeeName, entityCode, amount, serial }, index) => {
return [
stringCell(index + 1),
stringCell(payerAccount),
stringCell(payerName),
stringCell("01"),
stringCell(payeeAccount),
stringCell(payeeName),
stringCell(payee.bankName ?? "中国建设银行"),
stringCell(""),
stringCell(serial),
{ t: "n", v: Number(amount.toFixed(2)), z: "0.00" },
stringCell("报销款"),
stringCell(entityCode),
stringCell("0"),
stringCell(""),
stringCell(""),
];
});
return { rows, totalAmount: totalAmount.toFixed(2), serials: [...serials] };
}
function buildSheet(rows) {
const sheet = {};
const allRows = [HEADERS.map(stringCell), ...rows];
for (let row = 0; row < allRows.length; row += 1) {
for (let column = 0; column < HEADERS.length; column += 1) {
sheet[XLSX.utils.encode_cell({ r: row, c: column })] = allRows[row][column] ?? stringCell("");
}
}
sheet["!ref"] = XLSX.utils.encode_range({ r: 0, c: 0 }, { r: allRows.length - 1, c: HEADERS.length - 1 });
return sheet;
}
function generatePaymentOrderXls(config, batch) {
const { rows, totalAmount, serials } = buildPaymentOrderRows(config, batch);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, buildSheet(rows), "Sheet1");
XLSX.utils.book_append_sheet(workbook, {}, "Sheet2");
XLSX.utils.book_append_sheet(workbook, {}, "Sheet3");
const buffer = XLSX.write(workbook, { bookType: "biff8", type: "buffer" });
return { buffer, rowCount: rows.length, totalAmount, serials };
}
module.exports = {
HEADERS,
PaymentOrderXlsError,
buildCustomerSerial,
buildPaymentOrderRows,
generatePaymentOrderXls,
};
+1 -1
查看文件
@@ -86,7 +86,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
contractsRegistry: { key: "contractsRegistry", label: "合同", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
featureFlags: { key: "featureFlags", label: "功能管理", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
+11 -2
查看文件
@@ -2,6 +2,9 @@ const Fastify = require('fastify');
const meRoutes = require('../../../src/routes/me');
const { AppError } = require('../../../src/utils/AppError');
const { encryptStringOrThrow } = require('../../../src/services/crypto');
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64');
function createPrismaState(overrides = {}) {
const auditEntries = [];
@@ -18,6 +21,7 @@ function createPrismaState(overrides = {}) {
emergency_contact_phone: null,
bank_account_encrypted: '',
bank_name: '',
bank_institution: null,
avatar_path: null,
id_number_encrypted: null,
notification_push_enabled: true,
@@ -53,6 +57,7 @@ function createPrismaState(overrides = {}) {
async function buildApp(prisma) {
const app = Fastify();
app.decorate('prisma', prisma);
app.decorate('config', { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
app.decorate('authenticate', async (request) => {
if (!request.headers.authorization) {
throw new AppError({
@@ -130,8 +135,12 @@ describe('me routes', () => {
const { prisma } = createPrismaState({
emergency_contact_name: '联系人',
emergency_contact_phone: '13900000000',
bank_account_encrypted: 'ENC',
bank_name: '招商银行',
bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
'6222000012345678',
),
bank_name: '中国建设银行',
bank_institution: { id: 'bank-1', code: 'CCB', name: '中国建设银行' },
avatar_path: null,
id_number_encrypted: null,
});
@@ -0,0 +1,108 @@
const Fastify = require("fastify");
const paymentAccountsRoutes = require("../../../src/routes/paymentAccounts");
const { encryptStringOrThrow } = require("../../../src/services/crypto");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
const ACCOUNT_ID = "22222222-2222-4222-8222-222222222222";
const LEGAL_ENTITY_ID = "33333333-3333-4333-8333-333333333333";
const BANK_ID = "44444444-4444-4444-8444-444444444444";
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
const SYNTHETIC_ACCOUNT = "6217000000000012345";
function buildRecord(overrides = {}) {
return {
id: ACCOUNT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
bank_institution_id: BANK_ID,
account_name: "合成测试公司",
account_number_encrypted: encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_ACCOUNT),
enabled: true,
archived_at: null,
created_at: new Date("2026-08-02T00:00:00.000Z"),
updated_at: new Date("2026-08-02T00:00:00.000Z"),
legal_entity: { id: LEGAL_ENTITY_ID, code: "TEST", name: "合成测试公司" },
bank_institution: { id: BANK_ID, code: "CCB", name: "中国建设银行" },
...overrides,
};
}
async function buildApp(permissionCodes) {
const app = Fastify();
const actor = { id: ACTOR_ID, roleCodes: [], permissionCodes };
const record = buildRecord();
const auditCreate = vi.fn(async () => ({ id: "audit" }));
const notificationCreateMany = vi.fn(async ({ data }) => ({ count: data.length }));
const paymentAccountCreate = vi.fn(async ({ data }) => buildRecord({ ...data }));
const tx = {
legalEntity: { findFirst: vi.fn(async () => ({ id: LEGAL_ENTITY_ID, name: "合成测试公司" })) },
bankInstitution: { findFirst: vi.fn(async () => ({ id: BANK_ID, code: "CCB", name: "中国建设银行" })) },
paymentAccount: {
create: paymentAccountCreate,
update: vi.fn(),
findFirst: vi.fn(async () => record),
},
auditLog: { create: auditCreate },
employeeRole: { findMany: vi.fn(async () => [{ employee_id: ACTOR_ID }]) },
notification: { createMany: notificationCreateMany },
};
app.decorate("config", { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
app.decorate("prisma", {
bankInstitution: { findMany: vi.fn(async () => [{ id: BANK_ID, code: "CCB", name: "中国建设银行" }]) },
paymentAccount: {
findMany: vi.fn(async () => [record]),
findUnique: vi.fn(async () => ({ id: ACCOUNT_ID, account_number_encrypted: record.account_number_encrypted })),
},
auditLog: { create: auditCreate },
$transaction: vi.fn(async (callback) => callback(tx)),
});
app.decorate("authenticate", async (request) => { request.employee = actor; });
app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permission));
app.decorate("requirePermission", (permission) => async (request) => {
request.employee = actor;
if (!app.hasPermission(actor, permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
});
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } }));
await app.register(paymentAccountsRoutes, { prefix: "/api" });
return { app, auditCreate, notificationCreateMany, paymentAccountCreate };
}
describe("payment account routes", () => {
it("returns only a masked account on normal reads", async () => {
const testApp = await buildApp(["payment.account.read"]);
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items[0].accountNumberMasked).toBe("6217***********2345");
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT);
await testApp.app.close();
});
it("requires a reason before revealing the full account and audits the successful view", async () => {
const testApp = await buildApp(["payment.account.read_full"]);
const missingReason = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: {} });
expect(missingReason.statusCode).toBe(400);
const response = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: { reason: "银行制单核对" } });
expect(response.statusCode).toBe(200);
expect(response.json().data.accountNumber).toBe(SYNTHETIC_ACCOUNT);
expect(testApp.auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED", extra: { reason: "银行制单核对" } }) });
await testApp.app.close();
});
it("creates an encrypted account with masked audit and notifies administrators", async () => {
const testApp = await buildApp(["payment.account.manage"]);
const response = await testApp.app.inject({ method: "POST", url: "/api/payment-accounts", payload: { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT } });
expect(response.statusCode).toBe(201);
expect(testApp.paymentAccountCreate.mock.calls[0][0].data.account_number_encrypted).toMatch(/^v1:/);
expect(JSON.stringify(testApp.auditCreate.mock.calls)).not.toContain(SYNTHETIC_ACCOUNT);
expect(testApp.notificationCreateMany).toHaveBeenCalledOnce();
await testApp.app.close();
});
it("does not grant payment-account access to a system administrator by role implication", async () => {
const testApp = await buildApp(["system.settings", "system.accounts.manage"]);
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
expect(response.statusCode).toBe(403);
await testApp.app.close();
});
});
@@ -51,6 +51,9 @@ const EXECUTED_REIMBURSEMENT_ID = "bbbbbbbb-2222-4222-8222-222222222222";
const OWNER_ID = "77777777-7777-4777-8777-777777777777";
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
const LEGAL_ENTITY_ID = "12121212-1212-4121-8121-121212121212";
const BANK_INSTITUTION_ID = "13131313-1313-4131-8131-131313131313";
const PAYMENT_ACCOUNT_ID = "14141414-1414-4141-8141-141414141414";
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");
@@ -174,7 +177,13 @@ function buildReimbursement(overrides = {}) {
id: OWNER_ID,
employee_no: "EMP001",
name: "测试员工",
bank_name: "测试银行",
legal_entity_id: LEGAL_ENTITY_ID,
bank_name: "中国建设银行",
bank_institution: {
id: BANK_INSTITUTION_ID,
code: "CCB",
name: "中国建设银行",
},
bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
@@ -238,6 +247,21 @@ function buildPrisma(options = {}) {
},
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
reservedEntityIds: options.reservedEntityIds ?? [],
paymentAccount: {
id: PAYMENT_ACCOUNT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
bank_institution_id: BANK_INSTITUTION_ID,
account_name: "测试公司",
account_number_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
`6217${"0".repeat(8)}1234`,
),
enabled: true,
archived_at: null,
legal_entity: { id: LEGAL_ENTITY_ID, code: "HYB", name: "测试公司" },
bank_institution: { id: BANK_INSTITUTION_ID, code: "CCB", name: "中国建设银行" },
...(options.paymentAccount ?? {}),
},
employees: options.employees ?? [
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
@@ -254,6 +278,9 @@ function buildPrisma(options = {}) {
};
const tx = {
paymentAccount: {
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
},
paymentBatch: {
findUnique: vi.fn(async ({ where }) => {
if (where.id !== state.batch.id) {
@@ -967,9 +994,13 @@ describe("payment batch reimbursement routes", () => {
expect(detailResponse.json().data.item.id).toBe(BATCH_ID);
expect(detailResponse.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(detailResponse.json().data.item.paymentOrderExportBlockers).toEqual(expect.arrayContaining([
{ code: "legacy_payment_account_snapshot_missing" },
{ code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" },
]));
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
await app.close();
@@ -1002,7 +1033,7 @@ describe("payment batch reimbursement routes", () => {
expect(response.statusCode).toBe(200);
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
@@ -1150,17 +1181,17 @@ describe("payment batch reimbursement routes", () => {
expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
snapshot_json: expect.objectContaining({
payee: {
payee: expect.objectContaining({
name: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
},
}),
}),
}),
});
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
@@ -1210,7 +1241,8 @@ describe("payment batch reimbursement routes", () => {
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
});
expect(response.statusCode).toBe(500);
expect(response.statusCode).toBe(409);
expect(response.json().error.details.payeeBankAccount).toBe("decrypt_failed");
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
@@ -1517,7 +1549,7 @@ describe("payment batch reimbursement routes", () => {
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
expect(detail.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(state.batch.items[0].snapshot_json).toEqual({});
@@ -2293,4 +2325,116 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("exports a reviewed CCB batch as BIFF8 and records the first export", async () => {
const payerEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
`6217${"0".repeat(8)}1234`,
);
const payeeEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
);
const { app, tx, state } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "payment.order.export"],
roleCodes: ["finance"],
},
batch: {
status: "reviewed",
payment_account_snapshot_json: {
accountName: "合成测试公司",
bankName: "中国建设银行",
bankInstitutionCode: "CCB",
accountNumberMasked: "6217********1234",
accountNumberEncrypted: payerEncrypted,
},
payment_order_export_count: 0,
items: [buildPaymentBatch().items[0],].map((item) => ({
...item,
snapshot_json: {
payee: {
name: "测试员工",
bankName: "中国建设银行",
bankInstitutionCode: "CCB",
bankAccountMasked: "6222********5678",
bankAccountEncrypted: payeeEncrypted,
},
},
})),
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "导入企业网银" },
});
expect(response.statusCode).toBe(200);
expect(response.rawPayload.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
expect(response.headers["cache-control"]).toBe("no-store");
expect(state.batch.payment_order_export_count).toBe(1);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ reason: "导入企业网银", exportCount: 1 }) }),
});
await app.close();
});
it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => {
const { app, tx } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "payment.order.export"],
roleCodes: ["finance"],
},
batch: { status: "reviewed", payment_account_snapshot_json: null },
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "旧批次尝试" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reason).toBe("payment_account_snapshot_missing");
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
await app.close();
});
it("reveals the frozen payee account to finance only after a reason and audits the view", async () => {
const payeeEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
);
const { app, tx } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "employee.bank.read"],
roleCodes: ["finance"],
},
batch: {
status: "reviewed",
items: buildPaymentBatch().items.map((item) => ({
...item,
snapshot_json: { payee: { bankAccountEncrypted: payeeEncrypted } },
})),
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}/payee-bank/reveal`,
headers: { authorization: "Bearer test" },
payload: { reason: "人工转账核对" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.bankAccount).toBe(SYNTHETIC_BANK_ACCOUNT);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED", extra: expect.objectContaining({ reason: "人工转账核对", paymentBatchItemId: ITEM_ID }) }),
});
await app.close();
});
});
@@ -85,15 +85,16 @@ describe("user sensitive field permissions", () => {
await testApp.app.close();
});
it("returns only bank fields to finance and audits the authorized scope", async () => {
it("returns only masked bank fields to finance without a full-account view audit", async () => {
const testApp = await buildApp(["employee.bank.read"]);
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
bankInstitution: null,
bankName: "合成银行",
bankAccount: "6222000012345678",
bankAccountMasked: "6222********5678",
});
expect(response.json().data).not.toHaveProperty("idNumber");
expect(testApp.employeeFindUnique).toHaveBeenCalledWith({
@@ -101,15 +102,12 @@ describe("user sensitive field permissions", () => {
select: {
id: true,
bank_name: true,
bank_institution_id: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
});
expect(testApp.auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "SYSTEM_USER_SENSITIVE_VIEWED",
extra: { scopes: ["bank"] },
}),
});
expect(testApp.auditCreate).not.toHaveBeenCalled();
await testApp.app.close();
});
@@ -119,7 +117,7 @@ describe("user sensitive field permissions", () => {
const response = await testApp.app.inject({
method: "PATCH",
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
payload: { bankName: "新银行" },
payload: { bankAccount: "6222000099995678" },
});
expect(response.statusCode).toBe(403);
@@ -134,7 +132,7 @@ describe("user sensitive field permissions", () => {
const response = await testApp.app.inject({
method: "PATCH",
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
payload: { bankName: "新银行", idNumber: "110101199001011234" },
payload: { bankAccount: "6222000099995678", idNumber: "110101199001011234" },
});
expect(response.statusCode).toBe(403);
@@ -143,6 +141,33 @@ describe("user sensitive field permissions", () => {
await testApp.app.close();
});
it("reveals a full bank account only after a reason and records the view audit", async () => {
const testApp = await buildApp(["employee.bank.read"]);
const missingReason = await testApp.app.inject({
method: "POST",
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
payload: {},
});
expect(missingReason.statusCode).toBe(400);
expect(testApp.auditCreate).not.toHaveBeenCalled();
const response = await testApp.app.inject({
method: "POST",
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
payload: { reason: "付款核对" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({ bankAccount: "6222000012345678" });
expect(testApp.auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
extra: { reason: "付款核对" },
}),
});
await testApp.app.close();
});
it("writes only authorized fields and keeps plaintext out of the audit", async () => {
const testApp = await buildApp(["employee.bank.manage", "employee.identity.manage"]);
@@ -0,0 +1,83 @@
const XLSX = require("xlsx");
const { encryptStringOrThrow } = require("../../../src/services/crypto");
const {
HEADERS,
PaymentOrderXlsError,
buildCustomerSerial,
generatePaymentOrderXls,
} = require("../../../src/services/paymentOrderXls");
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
const encrypt = (value) => encryptStringOrThrow(config, value);
function buildBatch(overrides = {}) {
return {
batch_no: "PB-20260802-002",
payment_account_snapshot_json: {
bankInstitutionCode: "CCB",
accountName: "合成测试公司",
accountNumberEncrypted: encrypt("6217000000000012345"),
},
items: [
{
id: "b",
entity_code: "BX-20260802-0002",
amount: "2.00",
snapshot_json: { payee: { name: "乙", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } },
},
{
id: "a",
entity_code: "BX-20260802-0001",
amount: "1.20",
snapshot_json: { payee: { name: "甲", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000012345") } },
},
],
...overrides,
};
}
describe("payment order BIFF8 generator", () => {
it("writes the fixed workbook contract with typed string identifiers and stable ordering", () => {
const first = generatePaymentOrderXls(config, buildBatch());
const second = generatePaymentOrderXls(config, buildBatch());
const workbook = XLSX.read(first.buffer, { type: "buffer", cellNF: true });
const sheet = workbook.Sheets.Sheet1;
expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
expect(workbook.SheetNames).toEqual(["Sheet1", "Sheet2", "Sheet3"]);
expect(XLSX.utils.sheet_to_json(sheet, { header: 1 })[0]).toEqual(HEADERS);
expect(sheet.B2).toMatchObject({ t: "s", v: "6217000000000012345" });
expect(sheet.D2).toMatchObject({ t: "s", v: "01" });
expect(sheet.E2).toMatchObject({ t: "s", v: "6222000000000012345" });
expect(sheet.I2).toMatchObject({ t: "s", v: "PB-20260802-002-001" });
expect(sheet.J2).toMatchObject({ t: "n", v: 1.2 });
expect(sheet.M2).toMatchObject({ t: "s", v: "0" });
expect(first.serials).toEqual(second.serials);
expect(first.totalAmount).toBe("3.20");
});
it("fails the whole export and reports every blocking payee issue", () => {
const batch = buildBatch();
batch.items[1].snapshot_json.payee.bankInstitutionCode = "OTHER";
batch.items[0].snapshot_json.payee.bankAccountEncrypted = null;
try {
generatePaymentOrderXls(config, batch);
throw new Error("expected payment order generation to fail");
} catch (error) {
expect(error).toBeInstanceOf(PaymentOrderXlsError);
expect(error.code).toBe("payee_bank_not_supported");
expect(error.details.blockers).toEqual(expect.arrayContaining([
{ code: "payee_bank_not_supported", entityCode: "BX-20260802-0001" },
{ code: "payee_account_unavailable", entityCode: "BX-20260802-0002" },
]));
}
});
it("validates customer serial length and character set without truncation", () => {
expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-001");
expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError);
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
});
});