From 5261d2849bf3098b433f7356d22a4df2fff94cee Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 2 Aug 2026 23:36:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BC=81=E4=B8=9A=E9=93=B6?= =?UTF-8?q?=E8=A1=8C=E8=B4=A6=E6=88=B7=E4=B8=8E=E5=BB=BA=E8=A1=8C=E5=88=B6?= =?UTF-8?q?=E5=8D=95=EF=BC=88corporate-bank-account-payment-order=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-minimal.yml | 7 + .trellis/spec/hyb-oa/security-privacy.md | 101 +++++ .../check.jsonl | 1 + .../design.md | 178 ++++++++ .../implement.jsonl | 1 + .../implement.md | 83 ++++ .../prd.md | 137 ++++++ .../research/current-system-and-biff8.md | 46 ++ .../task.json | 26 ++ client/src/api/paymentAccounts.ts | 61 +++ client/src/api/paymentBatches.ts | 27 ++ client/src/api/types.ts | 5 + client/src/api/userSensitive.ts | 28 ++ client/src/auth/permissions.ts | 8 +- client/src/components/navigation/NavLinks.tsx | 2 +- client/src/pages/EmployeeProfilePage.tsx | 69 +++ client/src/pages/MobileProfilePage.tsx | 7 + client/src/pages/PaymentAccountsPage.tsx | 169 +++++++ client/src/pages/PaymentBatchesPage.tsx | 146 +++++- client/src/pages/PortalHomePage.tsx | 2 +- client/src/pages/ProfilePage.tsx | 8 + client/src/pages/SystemSettingsPage.tsx | 1 + client/src/router/index.tsx | 12 +- client/src/router/lazy/PaymentAccountsPage.ts | 1 + client/src/router/lazy/index.ts | 1 + docs/产品设计规则.md | 4 +- docs/管理员手册.md | 8 + server/package-lock.json | 13 + server/package.json | 1 + .../migration.sql | 78 ++++ server/prisma/schema.prisma | 60 +++ .../check-payment-order-xls-roundtrip.js | 42 ++ server/src/app.js | 2 + server/src/constants/permissions.js | 8 +- server/src/routes/me.js | 21 +- server/src/routes/paymentAccounts.js | 419 ++++++++++++++++++ server/src/routes/paymentBatches.js | 269 ++++++++++- server/src/routes/users.js | 126 +++++- server/src/scripts/seed-config.js | 36 ++ server/src/services/paymentOrderXls.js | 197 ++++++++ server/src/services/workspaceSettings.js | 2 +- server/tests/unit/routes/me.test.js | 13 +- .../tests/unit/routes/paymentAccounts.test.js | 108 +++++ .../paymentBatches.reimbursement.test.js | 162 ++++++- .../users.sensitive-permissions.test.js | 45 +- .../unit/services/paymentOrderXls.test.js | 83 ++++ 46 files changed, 2751 insertions(+), 73 deletions(-) create mode 100644 .trellis/tasks/08-02-corporate-bank-account-payment-order/check.jsonl create mode 100644 .trellis/tasks/08-02-corporate-bank-account-payment-order/design.md create mode 100644 .trellis/tasks/08-02-corporate-bank-account-payment-order/implement.jsonl create mode 100644 .trellis/tasks/08-02-corporate-bank-account-payment-order/implement.md create mode 100644 .trellis/tasks/08-02-corporate-bank-account-payment-order/prd.md create mode 100644 .trellis/tasks/08-02-corporate-bank-account-payment-order/research/current-system-and-biff8.md create mode 100644 .trellis/tasks/08-02-corporate-bank-account-payment-order/task.json create mode 100644 client/src/api/paymentAccounts.ts create mode 100644 client/src/api/userSensitive.ts create mode 100644 client/src/pages/PaymentAccountsPage.tsx create mode 100644 client/src/router/lazy/PaymentAccountsPage.ts create mode 100644 server/prisma/migrations/20260802143000_corporate_bank_account_payment_order/migration.sql create mode 100644 server/scripts/check-payment-order-xls-roundtrip.js create mode 100644 server/src/routes/paymentAccounts.js create mode 100644 server/src/services/paymentOrderXls.js create mode 100644 server/tests/unit/routes/paymentAccounts.test.js create mode 100644 server/tests/unit/services/paymentOrderXls.test.js diff --git a/.github/workflows/ci-minimal.yml b/.github/workflows/ci-minimal.yml index 6921676b..c1962e7b 100644 --- a/.github/workflows/ci-minimal.yml +++ b/.github/workflows/ci-minimal.yml @@ -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 diff --git a/.trellis/spec/hyb-oa/security-privacy.md b/.trellis/spec/hyb-oa/security-privacy.md index 45fd5121..8c05b276 100644 --- a/.trellis/spec/hyb-oa/security-privacy.md +++ b/.trellis/spec/hyb-oa/security-privacy.md @@ -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 diff --git a/.trellis/tasks/08-02-corporate-bank-account-payment-order/check.jsonl b/.trellis/tasks/08-02-corporate-bank-account-payment-order/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-02-corporate-bank-account-payment-order/design.md b/.trellis/tasks/08-02-corporate-bank-account-payment-order/design.md new file mode 100644 index 00000000..067fbfc2 --- /dev/null +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/design.md @@ -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`、角色/隐私只读检查和一条合成/受控流程通过后才通知财务使用制单。 +- 代码回滚保留新增表和可空字段,不删除已产生的审计、通知或制单统计;旧应用忽略新字段并继续人工付款。 diff --git a/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.jsonl b/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.md b/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.md new file mode 100644 index 00000000..2aa506c2 --- /dev/null +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.md @@ -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 的 `xlsx@0.18.5`。 +- [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 个未登记文件警告。 +- 依赖:`xlsx@0.20.3` 来自官方精确 tarball且锁定;生产依赖审计仍有 14 项仓库既有告警,未执行破坏性 `npm audit fix --force`。 +- 尚未完成:Excel/WPS 人工打开、前端 E2E/截图、生产数据库迁移、真实资料配置、部署、公网检查和企业网银校验。 diff --git a/.trellis/tasks/08-02-corporate-bank-account-payment-order/prd.md b/.trellis/tasks/08-02-corporate-bank-account-payment-order/prd.md new file mode 100644 index 00000000..3606543e --- /dev/null +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/prd.md @@ -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`、联行号维护和混合银行批次制单。 +- 为历史付款批次补写或回填完整账号快照。 +- 将真实账号、模板业务数据或生成的制单文件提交到仓库。 diff --git a/.trellis/tasks/08-02-corporate-bank-account-payment-order/research/current-system-and-biff8.md b/.trellis/tasks/08-02-corporate-bank-account-payment-order/research/current-system-and-biff8.md new file mode 100644 index 00000000..f08ab4cc --- /dev/null +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/research/current-system-and-biff8.md @@ -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 兜底排序,确保重复导出的行号和流水号一致。 +- 制单是推荐效率工具,不是付款完成门禁;旧批次和人工付款路径继续以银行回单为完成证据。 +- 首次导出后的处理进度需要可见,但完整文件不在服务端留存;批次只记录导出次数、最近导出时间/人和审计证据。 diff --git a/.trellis/tasks/08-02-corporate-bank-account-payment-order/task.json b/.trellis/tasks/08-02-corporate-bank-account-payment-order/task.json new file mode 100644 index 00000000..2107d464 --- /dev/null +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/task.json @@ -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": {} +} diff --git a/client/src/api/paymentAccounts.ts b/client/src/api/paymentAccounts.ts new file mode 100644 index 00000000..034c79be --- /dev/null +++ b/client/src/api/paymentAccounts.ts @@ -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("/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(`/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 }), + }); +} diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index 37c51c8b..df6f6033 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -49,6 +49,18 @@ export type PaymentBatch = { secondaryReviewComment: string | null; executionSummaryJson: Record | 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 { + 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`, diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 5d003049..8d5ce70b 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -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; diff --git a/client/src/api/userSensitive.ts b/client/src/api/userSensitive.ts new file mode 100644 index 00000000..3355a115 --- /dev/null +++ b/client/src/api/userSensitive.ts @@ -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(`/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 }), + }); +} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 91141eff..f7aa5d9c 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -100,6 +100,9 @@ const ROLE_PERMISSIONS: Record = { "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 = { "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 = { "attendance.sync", "payroll.manage", "employee.bank.read", + "payment.account.manage", + "payment.account.read", + "payment.account.read_full", + "payment.order.export", ], administration: [ "expense.proxy_submit", diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 88be3f28..d77336c2 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -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"] }, diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index ca1fa2be..a4ca4554 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -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() {
+ {canReadBank ? ( +
+

收款信息

+
+
开户银行
{bankQuery.data?.bankName ?? "未填写"}
+
银行账号
{bankQuery.data?.bankAccountMasked ?? "未填写"}
+
+ {canManageBank ? ( +
+
+
setBankAccountInput(event.target.value)} placeholder={bankQuery.data?.bankAccountMasked ? "不修改则留空" : "请输入完整账号"} />
+ +
+ ) : null} + {canRevealBank ? ( +
+ {revealedBankAccount ? <>
{revealedBankAccount}
: <>