diff --git a/.github/workflows/ci-minimal.yml b/.github/workflows/ci-minimal.yml index c1962e7b..30f4a8ba 100644 --- a/.github/workflows/ci-minimal.yml +++ b/.github/workflows/ci-minimal.yml @@ -34,7 +34,10 @@ jobs: run: npm ci - name: Install BIFF8 roundtrip verifier - run: python3 -m pip install --disable-pip-version-check xlrd==2.0.2 + run: | + python3 -m venv "$RUNNER_TEMP/payment-order-venv" + "$RUNNER_TEMP/payment-order-venv/bin/pip" install --disable-pip-version-check --require-hashes -r server/requirements/payment-order-biff8.txt + echo "PAYMENT_ORDER_PYTHON_BIN=$RUNNER_TEMP/payment-order-venv/bin/python" >> "$GITHUB_ENV" - name: Verify payment-order BIFF8 contract working-directory: server diff --git a/.trellis/spec/hyb-oa/security-privacy.md b/.trellis/spec/hyb-oa/security-privacy.md index 8c05b276..cc059cf4 100644 --- a/.trellis/spec/hyb-oa/security-privacy.md +++ b/.trellis/spec/hyb-oa/security-privacy.md @@ -36,6 +36,10 @@ 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`. +- Runtime: Node passes validated row JSON through stdin to the fixed + `fill-payment-order-template.py`; the dedicated Python runtime returns BIFF8 + bytes on stdout. `PAYMENT_ORDER_PYTHON_BIN` may select the fixed interpreter, + but requests cannot select the script or template path. ### 3. Contracts @@ -62,6 +66,19 @@ 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. +- Generate bank files by filling the controlled, blank BIFF8 template asset. + Rebuilding a workbook with matching headers is forbidden when it loses the + bank template's column widths, row heights, fonts, fills, borders or + alignment. The asset contains headers and formatted blank rows only, and its + SHA-256 is verified before every fill. +- Node owns authorization, snapshot decryption, blockers, ordering, serials, + amounts, audit and export counters. Python only copies the fixed template and + fills the already validated 15-cell rows. Full accounts travel only on child + stdin; never place them in argv, environment variables, logs, stderr or a + temporary workbook file. +- CI and the production image install `xlrd`, `xlwt` and `xlutils` from the same + `--require-hashes` requirements. Do not fall back to a style-dropping + generator when Python, a dependency or the template is unavailable. ### 4. Validation & Error Matrix @@ -76,6 +93,12 @@ - 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. +- Template hash/structure mismatch -> `409 PAYMENT_ORDER_EXPORT_BLOCKED`, no + export increment and no file bytes; manual payment plus receipt remains + available. +- Python executable/dependency missing, timeout, oversized output, non-zero exit + or invalid OLE magic -> fixed safe generation error; never copy child stderr + into the API error or application log. ### 5. Good/Base/Bad Cases @@ -85,6 +108,10 @@ 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. +- Good: a fourth synthetic row copies the first template data-row style and + height while the amount cell changes only to numeric `0.00` formatting. +- Bad: create a fresh workbook from the 15 labels, or invoke Python with an + account in argv/env, even if the resulting values look correct in a parser. ### 6. Tests Required @@ -94,9 +121,14 @@ 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. +- BIFF8 template-fill tests plus independent readback for three sheets, 15 + headers, leading-zero `01`, long account text, Chinese, numeric `0.00` + amounts and deterministic row order. Compare visual signatures for column + widths, row heights, font/bold, fill, border, alignment and wrapped text; + include a row beyond the three preformatted template rows. +- Negative tests cover missing Python/dependencies, template hash drift, child + failure and a child that echoes the synthetic input to stderr; exposed errors + and logs must not contain the account or original row payload. - 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. 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 index 067fbfc2..53f88baf 100644 --- a/.trellis/tasks/08-02-corporate-bank-account-payment-order/design.md +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/design.md @@ -133,14 +133,15 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业 禁止部分成功:任意一项失败时不生成文件、不更新制单状态、不写成功导出审计。 -### 6.3 BIFF8 与下载 +### 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` 等响应头并发送。 -- 不写对象存储或服务器持久目录。网络中断后的已记录导出按保守口径视为已生成,用户可填写新原因重导。 +- 仓库保存一份经脱敏和元数据检查的固定空白 BIFF8 模板资产;它保留三个工作表、15 列表头和三行预格式化空数据行,不含真实业务数据。运行时校验固定 SHA-256,不解析上传文件,也不接受请求控制模板路径。 +- Node 继续负责解密、资格校验、聚合 blockers、排序、流水号、金额和审计;固定 Python 填充器只接收已经校验的行 DTO,不承担权限或状态机判断。 +- Node 通过子进程 stdin 发送 JSON,Python 从 stdout 返回 BIFF8 二进制。完整账号不得进入 argv、env、日志或 stderr;子进程使用固定解释器路径、超时和 `maxBuffer`,并校验退出码、输出大小与 OLE 魔数。 +- Python 使用固定版本 `xlrd==2.0.2`、`xlwt==1.3.0`、`xlutils==2.0.0`,以 `XLWTWriter + process(XLRDReader(...))` 复制原工作簿,再按模板首个数据行样式写值。生产镜像与 CI 从同一份 `--require-hashes` requirements 安装到专用 venv。 +- 模板已有三行数据样式;超过三笔时复制第一个数据行的样式并设置相同行高。金额列复制原样式后只把 `num_format_str` 改为 `0.00`,七个标识字段以字符串写入。 +- 生成器不再依赖 SheetJS;`xlsx` 依赖从服务端移除。先完成审计和制单计数事务,再设置 `application/vnd.ms-excel`、安全附件名、`nosniff`、`private, no-store` 等响应头并发送。 +- 不写对象存储、临时制单文件或服务器持久目录。网络中断后的已记录导出按保守口径视为已生成,用户可填写新原因重导。 ## 7. 页面动作与进度 @@ -164,10 +165,11 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业 ## 9. 验证与依赖门禁 - Node 单元/路由测试覆盖权限矩阵、单一启用账户并发约束、通知、审计、批次快照、历史拒绝、CCB-only、确定性排序、重导和隐私负向断言。 -- 使用合成数据生成 `.xls`,由 CI 明确安装的 `xlrd==2.0.2` 独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性。 +- 使用合成数据生成 `.xls`,由 CI 的固定 Python 环境独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性,并比较列宽、行高、字体、加粗、底色、边框、对齐等模板样式签名。 +- 覆盖超过三笔的扩展行、模板 hash 漂移、解释器/依赖缺失、子进程失败、输出越界和 stderr 隐私负向断言。 - 用合成非建行项证明整批失败且没有文件、制单计数或成功审计。 - Excel/WPS 人工打开验证中文、长字符串和金额;企业网银只有存在明确“仅校验”步骤时才使用虚构建行数据做最终确认。 -- 安装依赖后运行 npm audit/许可证与 lockfile 审查;如官方精确 tarball不可重复获取或引入不可接受告警,暂停而非换用 registry 旧版。 +- 固定 Python requirements 必须带完整哈希并在 CI/镜像使用 `--require-hashes`;任何包或模板哈希不符时暂停,不允许联网浮动安装或自动降级到重建工作簿。 ## 10. 迁移、上线与回滚 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 index 52eec9be..3bfb10a1 100644 --- a/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.md +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/implement.md @@ -81,3 +81,25 @@ - 治理:Trellis task 校验、governance、process contract、敏感内容扫描和 `git diff --check` 通过;文档注册审计仅保留仓库既有 22 个未登记文件警告。 - 依赖:`xlsx@0.20.3` 来自官方精确 tarball且锁定;生产依赖审计仍有 14 项仓库既有告警,未执行破坏性 `npm audit fix --force`。 - 尚未完成:Excel/WPS 人工打开、前端 E2E/截图、生产数据库迁移、真实资料配置、部署、公网检查和企业网银校验。 + +## 11. 原模板保真修订(2026-08-03) + +- [x] Excel 人工打开否决 SheetJS 重建输出:字段合同正确,但原模板列宽、行高、字体、底色、边框和对齐丢失。 +- [x] 用户批准改为“固定脱敏原模板 + Python BIFF8 保真填充”,不再接受重建工作簿方案。 +- [x] 提交经敏感检查的空白模板资产并固定 SHA-256;不提交 `input/` 原件或任何生成制单文件。 +- [x] 用固定 Python requirements 实现 stdin JSON / stdout BIFF8 填充器,stderr 仅固定错误码。 +- [x] Node 改为子进程桥接,保留现有资格、冻结快照、blockers、排序、流水号、审计与导出统计合同。 +- [x] 生产镜像和 CI 使用同一带哈希 requirements 和绝对 venv 解释器;移除服务端 SheetJS 生成依赖。 +- [x] 独立读回增加模板视觉签名、超过三行扩展、模板/运行时失败和隐私负向测试。 +- [x] 重新运行聚焦/全量后端、前端、治理和只读复核;用合成数据交付 Excel 人工复核,不部署。 + +### 修订验证记录 + +- 受控模板仅有 15 个表头和三行预格式化空数据行;模板 SHA-256 与运行时常量一致,字符串扫描未发现账号、URL、凭据或业务数据。 +- 固定 Python 环境的独立读回已确认三个工作表、15 列、账号/`01` 文本、数值金额 `0.00`,并逐格比较列宽、行高、字体/加粗、底色、边框、对齐;第四笔扩展行同样通过。 +- 聚焦测试在资格门禁修订后为 70 项通过;新增覆盖 admin 非财务拒绝、四种非 reviewed 状态、历史 item 非银行 override、模板 hash、依赖缺失、子进程超时/输出越界和 stderr 隐私。 +- 后端全量最终为 176 个文件、1199 项全部通过;此前 Dockerfile 缓存层文本合同失败已通过恢复独立 `RUN cd server && npm ci` 修复并纳入最终全量。 +- 前端 typecheck/build、Prisma validate、流程合同、Trellis governance、敏感扫描和 diff 检查通过;文档审计仅保留仓库既有 22 个未登记文件警告。 +- 本地 Docker 构建因 Alpine 包下载长时间无进展被主动取消,未形成镜像通过证据;生产镜像、部署、公网与网银验证仍待单独批准。 +- 当前实现已生成并独立读回一份 gitignored 合成 Excel 验收文件;原型文件此前经用户 Excel 人工打开认可,当前实现文件仍可用于最终人工外观确认。 +- 付款/隐私只读复核在资格门禁修订后确认无 Blocker、High 或 Medium;唯一 Low(失败路径缺显式“无成功审计”断言)已补测试并复验。 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 index 3606543e..2acce054 100644 --- a/.trellis/tasks/08-02-corporate-bank-account-payment-order/prd.md +++ b/.trellis/tasks/08-02-corporate-bank-account-payment-order/prd.md @@ -59,14 +59,16 @@ ### R4 银行制单导出 -- 从符合状态与权限要求的付款批次生成与空白模板一致的 BIFF8 `.xls`。 +- 从符合状态与权限要求的付款批次生成与受控空白原模板视觉和结构一致的 BIFF8 `.xls`;不得重建一个仅字段相同但丢失列宽、行高、字体、底色、边框或对齐的工作簿。 - 银行制单是推荐的效率工具而非付款完成硬门禁;旧快照、格式异常或人工付款场景仍可通过线下转账、上传回单和登记完成闭环,银行回单继续是银行渠道付款完成的证据门禁。 - 新批次已复核、资料完整且尚未制单时,顶部主动作显示“下载银行制单”;首次制单后主动作切换为“上传银行回单”,重新下载进入“更多”。已经人工付款的用户可直接在回单区域上传,不被制单状态阻断。 - 批次详情显示是否已生成制单;处理进度在未制单时显示“等待财务线下付款并上传回单”,制单后显示“已生成银行制单,等待付款完成并上传回单”。 - 仅 `reviewed`(待财务付款)批次允许制单;`draft`、`failed`、`cancelled` 与 `executed` 均禁止制单。 - `reviewed` 状态允许因文件丢失或本地操作失败重复导出,每次仍须填写原因并独立审计;重新下载前必须明确提示“重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。”重复导出的业务字段必须来自同一冻结快照,不允许临时覆盖账号、金额或用途。 - `executed` 后不得重新生成可导入网银的指令文件;付款归档依赖银行回单与付款台账,避免把归档需要和可重复付款指令混为一体。 -- 原样保留三个工作表、15 列顺序与表头;不得把格式伪装成 `.xls` 扩展名。 +- 原样保留三个工作表、15 列顺序与表头,以及原模板的列宽、表头/数据行行高、字体、加粗、底色、边框、对齐和换行;不得把格式伪装成 `.xls` 扩展名。 +- 仓库可提交一份经脱敏检查、仅含空白表头和预格式化空行的受控模板资产;运行时只读取该固定资产,不接受用户上传模板或请求指定路径。模板哈希漂移时制单失败关闭。 +- 模板预置数据行不足时,新行必须复制首个数据行的样式和行高;金额列仅覆盖为数值类型与 `0.00` 格式,不改变其他视觉属性。 - 用途固定为“报销款”;客户方流水号由付款批次编号与三位行序号生成;备注写对应报销单编号;开户行名称取冻结快照;付款凭证号不进入制单。 - 批次项必须使用显式、稳定且带唯一兜底键的排序,禁止依赖数据库自然顺序;同一批次重复导出时每项的行序号和客户流水号必须不变。 - 客户方流水号由单一生成函数负责,并在写文件前校验长度、字符集和批次内唯一性;不允许静默截断或分散内联拼接。首轮验证若证明银行只接受纯数字,应只调整该生成函数与对应合同。 @@ -74,20 +76,21 @@ - 金额首版按数值写入并设置两位小数格式,必须无科学计数法和精度损失;若银行安全导入验证明确拒绝,再改为两位小数字符串。 - 收款人名称、用途与备注中文不得乱码。 - 收款方联行号首版固定留空;首次安全验证使用字符串 `01`、建行虚构账号和空联行号,覆盖真实生产主场景。跨行 `02` 与联行号留待支持非建行员工时另立增量任务。 -- 导出文件通过响应直接下载,不持久化到服务器存储,不进入 Git,不记录原始制单内容。 +- 导出文件通过响应直接下载,不持久化到服务器存储,不记录原始制单内容;只有脱敏空白模板资产可以进入 Git。 +- 完整账号只可通过子进程标准输入传给固定填充器,不得进入命令行参数、环境变量、日志或错误输出;生成的二进制只从标准输出返回,并设置明确的大小上限。 - 导出使用独立权限、必填原因和逐次审计;审计只记录操作人、批次、笔数、合计金额及原因,不记录完整账号。 - 旧快照批次、非银行渠道、缺少付款账户、缺少员工银行标识或账号、公司或员工银行不是建行的批次必须在导出前一次性列出不可制单原因。 ### R5 本地与人工验证 -- 使用合成数据生成 `.xls`,再用独立于生成器的解析器读回验证工作表、表头、字段类型、中文与金额。 +- 使用合成数据生成 `.xls`,再用独立解析验证工作表、表头、字段类型、中文、金额和模板视觉签名;至少覆盖列宽、行高、字体、加粗、底色、边框和对齐。 - 独立解析器及版本必须进入可复现的测试环境,不能依赖某台开发机偶然安装的 `xlrd`。 - 至少使用 Excel 或 WPS 人工打开一次,确认中文和账号显示。 - 只有企业网银存在明确的“仅校验、不提交”环节时,才允许用完全虚构数据做最终格式确认;否则不进行生产网银试验。 ### R6 依赖安全 -- BIFF8 生成依赖必须在设计阶段确定受支持版本、来源、锁定和完整性校验方式。 +- BIFF8 生成依赖必须在设计阶段确定受支持版本、来源、锁定和完整性校验方式;CI 与生产镜像必须使用同一份带哈希的固定 Python requirements。 - 不得静默接受已知高危依赖告警;若只能使用存在已知解析漏洞的版本,必须证明运行路径只生成、不解析不可信输入,并单独获得风险例外批准。 ## Test Plan @@ -116,6 +119,7 @@ - [ ] 未制单批次、已制单批次和人工付款路径均可按既定主动作/次动作继续,制单状态不是上传回单或登记付款完成的硬门禁。 - [ ] 批次详情和处理进度能区分“未生成制单”与“已生成制单待回单”,便于观察实际使用覆盖率。 - [ ] 授权财务人员填写原因后可下载符合模板的 BIFF8 `.xls`,下载行为留下脱敏审计。 +- [ ] Excel 打开生成文件时,原模板列宽、行高、字体、加粗、底色、边框、对齐和换行保持不变;超过三笔时新增行仍复制相同数据行样式。 - [ ] 重新下载前展示重复付款风险原文;每次重导独立记录原因,但不会宣称旧文件已失效。 - [ ] 只有 `reviewed` 批次可制单;同一 `reviewed` 批次可带新原因重导,其他四种状态均被明确拒绝。 - [ ] 批次进入 `executed` 后无法重新生成银行制单,银行回单与付款台账仍可用于归档核验。 @@ -126,6 +130,7 @@ - [ ] 使用字符串 `01`、建行合成账号和空联行号完成本地验证;合成非建行项证明整批失败且不会生成部分文件。 - [ ] 缺失资料或状态不合格时,页面一次性说明全部阻塞项及处理方式。 - [ ] 自动化、本地人工打开和可选网银校验均使用合成数据,不触发真实付款。 +- [ ] 模板哈希漂移、Python 或固定依赖缺失、填充器失败时返回固定安全错误,stderr、日志和测试证据均不含完整账号或原始 payload,人工付款路径不受影响。 ## Out of Scope @@ -134,4 +139,4 @@ - 首版支持多个同时启用的公司付款账户。 - 非建行、跨行 `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 index f08ab4cc..decbd729 100644 --- 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 @@ -6,7 +6,8 @@ - 工作簿固定包含 `Sheet1`、`Sheet2`、`Sheet3`;只有 `Sheet1` 有 15 列表头,另两个工作表为空。 - 必填列为序号、付款方账号/名称、收款方行别代码、收款方账号/名称、金额、用途。 - 数据行多数使用文本格式;账号、行别代码、联行号、流水号、手机号和短信标志必须在写入值时显式使用字符串类型,不能依赖显示格式补救。 -- 模板文件只作为本地分析输入,不进入 Git、Trellis 或运行时服务器。 +- 2026-08-03 Excel 人工打开证明 SheetJS 重建工作簿虽满足字段合同,但无法保留原模板列宽、行高、宋体/加粗、底色、边框和对齐,因此该方案被否决。 +- 原始 `input/` 文件仍只作为本地分析输入;实施允许从中制作一份经脱敏、元数据和样式签名检查的受控空白模板资产进入 Git 与运行时镜像。含业务数据的模板和生成文件仍禁止提交。 ## 当前数据与权限 @@ -26,15 +27,15 @@ ## 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的来源更直接;若正式安装前完整性或许可证门禁不通过,停止并重新审查,不静默换包。 +- SheetJS CE 即使读取模板并启用样式选项后再写 BIFF8,仍无法保真复制字体、底色、边框、对齐和行高,因此从运行时生成路径移除。 +- 隔离验证确认 `xlrd==2.0.2`、`xlwt==1.3.0`、`xlutils==2.0.0` 使用 `XLWTWriter + process(XLRDReader(...))` 可保留原模板视觉合同;金额列可在复制样式后单独覆盖 `0.00`。 +- 固定 Python 填充器只读取仓库内受控模板,只从 stdin 接受 Node 已校验的合成行 DTO,并把二进制写 stdout;不接受上传模板或外部路径。 +- Python 包必须以带哈希 requirements 固定,并由 CI 与生产镜像同源安装。解释器、依赖、模板或哈希异常均 fail closed,不回退到无样式生成。 ## 独立读回验证 -- 当前开发机可用 `xlrd 2.0.2`,但仓库没有 Python requirements/pyproject,不能把本机安装当成 CI 合同。 -- 实施应增加一个最小、固定版本的独立验证环境:生成器由 Node/SheetJS 执行,读回器由 Python `xlrd==2.0.2` 执行;CI 明确安装该版本后运行合成文件验证。 +- 当前开发机可用 `xlrd 2.0.2`,但本轮需新增最小、带哈希的固定 requirements,使生成和读回环境在 CI 与镜像可复现。 +- 生成由固定 Python 模板填充器完成;合同脚本再以独立打开步骤读回并比较原模板样式签名,而不是依赖生成器返回的内部对象。 - 验证文件必须写入临时目录并在结束后清理;失败输出只报告字段名和断言,不打印合成账号之外的任何内容。 ## 关键设计约束 diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index 35edc946..4aeb5628 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -102,10 +102,14 @@ function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string } bank_channel_required: "付款渠道不是银行转账", legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款", payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整", + payer_account_unavailable: "冻结的公司付款账号不可用", payer_bank_institution_missing: "公司付款账户缺少规范化开户银行", payer_bank_not_supported: "公司付款账户不是建设银行", items_missing: "批次没有可制单的报销明细", legacy_payee_snapshot_missing: "未冻结完整收款账号,仅支持人工付款", + payee_name_missing: "冻结的收款人姓名不完整", + payee_bank_name_missing: "冻结的收款开户行名称不完整", + payee_account_masked_missing: "冻结的收款账号摘要不完整", payee_bank_institution_missing: "未冻结规范化开户银行,仅支持人工付款", payee_bank_not_supported: "收款账户不是建设银行,首版请人工转账", }; diff --git a/deploy/trialrun/Dockerfile b/deploy/trialrun/Dockerfile index 8101fee0..343a3833 100644 --- a/deploy/trialrun/Dockerfile +++ b/deploy/trialrun/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine AS base WORKDIR /app -RUN apk add --no-cache bash curl +RUN apk add --no-cache bash curl python3 py3-pip FROM base AS client_deps COPY client/package.json client/package-lock.json ./client/ @@ -16,7 +16,10 @@ RUN cd client && npm run build FROM base AS server_deps COPY server/package.json server/package-lock.json ./server/ +COPY server/requirements/payment-order-biff8.txt ./server/requirements/payment-order-biff8.txt COPY server/prisma ./server/prisma +RUN python3 -m venv /opt/payment-order-venv \ + && /opt/payment-order-venv/bin/pip install --disable-pip-version-check --no-cache-dir --require-hashes -r server/requirements/payment-order-biff8.txt RUN cd server && npm ci FROM server_deps AS server_build @@ -28,4 +31,5 @@ COPY --from=client_build /app/client/dist ./client/dist WORKDIR /app/server ENV NODE_ENV=production +ENV PAYMENT_ORDER_PYTHON_BIN=/opt/payment-order-venv/bin/python CMD ["npm", "start"] diff --git a/server/package-lock.json b/server/package-lock.json index bc35683a..4e6126ec 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -41,7 +41,6 @@ "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" }, @@ -9351,18 +9350,6 @@ "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", diff --git a/server/package.json b/server/package.json index b2f6d912..66433e0b 100644 --- a/server/package.json +++ b/server/package.json @@ -84,7 +84,6 @@ "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" }, diff --git a/server/requirements/payment-order-biff8.txt b/server/requirements/payment-order-biff8.txt new file mode 100644 index 00000000..0992e42d --- /dev/null +++ b/server/requirements/payment-order-biff8.txt @@ -0,0 +1,3 @@ +xlrd==2.0.2 --hash=sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9 +xlwt==1.3.0 --hash=sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e +xlutils==2.0.0 --hash=sha256:b56640862c030e9d53104e7f1d750135fdfabf6bf55e425e5ac40fdee9dbaeb9 diff --git a/server/scripts/check-payment-order-xls-roundtrip.js b/server/scripts/check-payment-order-xls-roundtrip.js index 86754443..9a6f9925 100644 --- a/server/scripts/check-payment-order-xls-roundtrip.js +++ b/server/scripts/check-payment-order-xls-roundtrip.js @@ -1,42 +1,59 @@ -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 }); +const fieldEncryptionKey = Buffer.alloc(32, 7).toString("base64"); +const config = { fieldEncryptionKey }; +const encrypt = (value) => encryptStringOrThrow(config, value); +const syntheticAccounts = [ + "6222000000000012345", + "6222000000000023456", + "6222000000000034567", + "6222000000000045678", +]; + +const generated = generatePaymentOrderXls(config, { + batch_no: "PB-20260802-001", + payment_account_snapshot_json: { + bankInstitutionCode: "CCB", + accountName: "合成测试公司", + bankName: "中国建设银行", + accountNumberEncrypted: encrypt("6217000000000012345"), + }, + items: syntheticAccounts.map((account, index) => ({ + id: String(index + 1), + entity_code: `BX-20260802-${String(index + 1).padStart(4, "0")}`, + amount: `${index + 1}.20`, + snapshot_json: { + payee: { + name: `合成员工${index + 1}`, + bankName: "中国建设银行", + bankInstitutionCode: "CCB", + bankAccountEncrypted: encrypt(account), + }, + }, + })), +}); + +const pythonBin = process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3"; +const verifier = spawnSync(pythonBin, [path.resolve(__dirname, "verify-payment-order-xls.py")], { + input: generated.buffer, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + timeout: 15_000, + env: { + PATH: path.isAbsolute(pythonBin) ? path.dirname(pythonBin) : "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONNOUSERSITE: "1", + }, +}); + +if (verifier.status !== 0 || verifier.error) { + process.stderr.write(verifier.stderr || "[payment-order-xls][FAIL] verifier unavailable\n"); + process.exitCode = 1; +} else { + process.stdout.write(verifier.stdout); } diff --git a/server/scripts/verify-payment-order-xls.py b/server/scripts/verify-payment-order-xls.py new file mode 100644 index 00000000..b82be858 --- /dev/null +++ b/server/scripts/verify-payment-order-xls.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Independently verify generated BIFF8 data and visual template contracts.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +try: + import xlrd +except ImportError: + sys.stderr.write("[payment-order-xls][FAIL] verifier dependency unavailable\n") + raise SystemExit(1) + + +TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "src" / "assets" / "payment-order" / "ccb-payment-order-template.xls" +EXPECTED_HEADERS = [ + "*序号", + "*付款方客户账号", + "*付款方账户名称", + "*收款方行别代码\n(01-本行 02-国内他行)", + "*收款方客户账号", + "*收款方账户名称", + "收款方开户行名称", + "收款方联行号", + "客户方流水号", + "*金额", + "*用途", + "备注", + "是否短信通知收款人(0-不通知,1-通知,默认为0-不通知。)", + "收款人手机号码", + "短信通知附加信息", +] + + +def visual_signature(book: xlrd.book.Book, sheet: xlrd.sheet.Sheet, row: int, column: int) -> tuple: + xf = book.xf_list[sheet.cell_xf_index(row, column)] + font = book.font_list[xf.font_index] + alignment = xf.alignment + background = xf.background + border = xf.border + return ( + font.name, + font.height, + font.bold, + font.italic, + font.underline_type, + font.colour_index, + alignment.hor_align, + alignment.vert_align, + alignment.text_wrapped, + alignment.rotation, + alignment.shrink_to_fit, + background.fill_pattern, + background.pattern_colour_index, + background.background_colour_index, + border.left_line_style, + border.right_line_style, + border.top_line_style, + border.bottom_line_style, + border.left_colour_index, + border.right_colour_index, + border.top_colour_index, + border.bottom_colour_index, + ) + + +def main() -> int: + stage = "read" + try: + generated_bytes = sys.stdin.buffer.read(16 * 1024 * 1024 + 1) + assert len(generated_bytes) <= 16 * 1024 * 1024 + template = xlrd.open_workbook(str(TEMPLATE_PATH), formatting_info=True) + generated = xlrd.open_workbook(file_contents=generated_bytes, formatting_info=True) + stage = "structure" + assert generated.sheet_names() == ["Sheet1", "Sheet2", "Sheet3"] + assert [(sheet.nrows, sheet.ncols) for sheet in generated.sheets()] == [(5, 15), (0, 0), (0, 0)] + + source_sheet = template.sheet_by_index(0) + sheet = generated.sheet_by_index(0) + stage = "headers" + assert sheet.row_values(0) == EXPECTED_HEADERS + stage = "column_widths" + stage = "explicit_column_widths" + assert {column: info.width for column, info in sheet.colinfo_map.items()} == { + column: info.width for column, info in source_sheet.colinfo_map.items() + } + stage = "header_height" + assert sheet.rowinfo_map[0].height == source_sheet.rowinfo_map[0].height + + for row in range(1, 5): + stage = "data_row_height" + assert sheet.rowinfo_map[row].height == source_sheet.rowinfo_map[1].height + for column in range(15): + stage = "visual_style" + assert visual_signature(generated, sheet, row, column) == visual_signature(template, source_sheet, 1, column) + + stage = "cell_types" + for row in range(1, 5): + for column in (0, 1, 2, 3, 4, 5, 6, 8, 10, 11, 12): + stage = f"text_cell_{column}" + assert sheet.cell_type(row, column) == xlrd.XL_CELL_TEXT + for column in (7, 13, 14): + stage = f"empty_cell_{column}" + assert sheet.cell_type(row, column) in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK) + assert sheet.cell_value(1, 1) == "6217000000000012345" + assert sheet.cell_value(1, 3) == "01" + assert sheet.cell_value(1, 4) == "6222000000000012345" + assert sheet.cell_value(1, 5) == "合成员工1" + assert sheet.cell_value(1, 10) == "报销款" + assert sheet.cell_value(1, 12) == "0" + for row in range(1, 5): + assert sheet.cell_type(row, 9) == xlrd.XL_CELL_NUMBER + stage = "amount_format" + assert generated.format_map[generated.xf_list[sheet.cell_xf_index(row, 9)].format_key].format_str == "0.00" + sys.stdout.write("[payment-order-xls][PASS] original BIFF8 template data and visual contracts verified\n") + return 0 + except Exception: + sys.stderr.write(f"[payment-order-xls][FAIL] BIFF8 template contract mismatch: {stage}\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/src/assets/payment-order/ccb-payment-order-template.xls b/server/src/assets/payment-order/ccb-payment-order-template.xls new file mode 100644 index 00000000..9e0d1224 Binary files /dev/null and b/server/src/assets/payment-order/ccb-payment-order-template.xls differ diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index 9b4dff4a..3746cfa2 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -718,12 +718,12 @@ function paymentOrderExportBlockers(batch) { 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 (!batch.payment_account_snapshot_json.accountNumberEncrypted) add("payer_account_unavailable"); if (payer && !payer.bankInstitutionCode) add("payer_bank_institution_missing"); else if (payer && payer.bankInstitutionCode !== "CCB") add("payer_bank_not_supported"); } @@ -731,9 +731,14 @@ function paymentOrderExportBlockers(batch) { for (const item of batch.items ?? []) { const entityCode = normalizeSnapshotText(item.entity_code) ?? undefined; const payee = item.snapshot_json?.payee; + if (resolveItemPaymentChannel(batch, item) !== "bank") add("bank_channel_required", entityCode); 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); + const safePayee = readSnapshotPayee(item.snapshot_json); + if (!safePayee?.name) add("payee_name_missing", entityCode); + if (!safePayee?.bankName) add("payee_bank_name_missing", entityCode); + if (!safePayee?.bankAccountMasked) add("payee_account_masked_missing", entityCode); } return blockers; } @@ -1581,11 +1586,9 @@ async function paymentBatchesRoutes(fastify) { 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 blockers = paymentOrderExportBlockers(batch); + if (blockers.length) { + throw new PaymentOrderXlsError(blockers[0].code, { blockers }); } const generated = generatePaymentOrderXls(fastify.config, batch); const exportCount = Number(batch.payment_order_export_count ?? 0) + 1; diff --git a/server/src/scripts/fill-payment-order-template.py b/server/src/scripts/fill-payment-order-template.py new file mode 100644 index 00000000..dae4a276 --- /dev/null +++ b/server/src/scripts/fill-payment-order-template.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Fill the fixed blank CCB BIFF8 template without exposing row data.""" + +from __future__ import annotations + +import copy +import hashlib +import io +import json +import math +from pathlib import Path +import sys + +try: + import xlrd + from xlutils.filter import process, XLRDReader, XLWTWriter +except ImportError: + sys.stderr.write("[payment-order-template][ERROR] runtime_dependency_unavailable\n") + raise SystemExit(70) + + +TEMPLATE_SHA256 = "72e83ab6b7cd71263ca00196746de896e02a02390bab5c896dabd76421e40c0e" +TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "assets" / "payment-order" / "ccb-payment-order-template.xls" +EXPECTED_SHEETS = ["Sheet1", "Sheet2", "Sheet3"] +COLUMN_COUNT = 15 +TEMPLATE_DATA_ROW = 1 +MAX_INPUT_BYTES = 2 * 1024 * 1024 +MAX_ROWS = 5000 + + +class TemplateFillError(Exception): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def read_payload() -> dict: + payload_bytes = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1) + if len(payload_bytes) > MAX_INPUT_BYTES: + raise TemplateFillError("input_too_large") + try: + payload = json.loads(payload_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise TemplateFillError("input_invalid") from None + if not isinstance(payload, dict) or set(payload) != {"rows"}: + raise TemplateFillError("input_invalid") + rows = payload.get("rows") + if not isinstance(rows, list) or not rows or len(rows) > MAX_ROWS: + raise TemplateFillError("rows_invalid") + for row in rows: + if not isinstance(row, list) or len(row) != COLUMN_COUNT: + raise TemplateFillError("row_invalid") + for column, value in enumerate(row): + if column == 9: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: + raise TemplateFillError("amount_invalid") + elif not isinstance(value, str): + raise TemplateFillError("text_cell_invalid") + return payload + + +def read_template() -> tuple[bytes, xlrd.book.Book]: + try: + template_bytes = TEMPLATE_PATH.read_bytes() + except OSError: + raise TemplateFillError("template_unavailable") from None + if hashlib.sha256(template_bytes).hexdigest() != TEMPLATE_SHA256: + raise TemplateFillError("template_hash_mismatch") + try: + book = xlrd.open_workbook(file_contents=template_bytes, formatting_info=True) + except Exception: + raise TemplateFillError("template_invalid") from None + if book.sheet_names() != EXPECTED_SHEETS: + raise TemplateFillError("template_structure_mismatch") + sheet = book.sheet_by_index(0) + if sheet.nrows < 4 or sheet.ncols != COLUMN_COUNT: + raise TemplateFillError("template_structure_mismatch") + return template_bytes, book + + +def fill_workbook(rows: list[list[object]], source_book: xlrd.book.Book) -> bytes: + writer = XLWTWriter() + try: + process(XLRDReader(source_book, "ccb-payment-order-template.xls"), writer) + target_book = writer.output[0][1] + source_sheet = source_book.sheet_by_index(0) + target_sheet = target_book.get_sheet(0) + target_sheet._cell_overwrite_ok = True + + source_row_info = source_sheet.rowinfo_map.get(TEMPLATE_DATA_ROW) + source_row_height = source_row_info.height if source_row_info else 735 + source_row_height_mismatch = source_row_info.height_mismatch if source_row_info else False + + for row_offset, values in enumerate(rows): + target_row_index = row_offset + 1 + target_row = target_sheet.row(target_row_index) + target_row.height = source_row_height + target_row.height_mismatch = source_row_height_mismatch + for column, value in enumerate(values): + style_index = source_sheet.cell_xf_index(TEMPLATE_DATA_ROW, column) + style = writer.style_list[style_index] + if column == 9: + style = copy.deepcopy(style) + style.num_format_str = "0.00" + target_sheet.write(target_row_index, column, value, style) + + output = io.BytesIO() + target_book.save(output) + return output.getvalue() + except TemplateFillError: + raise + except Exception: + raise TemplateFillError("fill_failed") from None + + +def main() -> int: + try: + payload = read_payload() + _, source_book = read_template() + output = fill_workbook(payload["rows"], source_book) + if not output.startswith(bytes.fromhex("d0cf11e0a1b11ae1")): + raise TemplateFillError("output_invalid") + sys.stdout.buffer.write(output) + return 0 + except TemplateFillError as error: + sys.stderr.write(f"[payment-order-template][ERROR] {error.code}\n") + return 70 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/src/services/paymentOrderXls.js b/server/src/services/paymentOrderXls.js index 38e254e0..36b5a801 100644 --- a/server/src/services/paymentOrderXls.js +++ b/server/src/services/paymentOrderXls.js @@ -1,25 +1,8 @@ -const XLSX = require("xlsx"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); const { decryptStringOrThrow } = require("./crypto"); -const HEADERS = Object.freeze([ - "*序号", - "*付款方客户账号", - "*付款方账户名称", - "*收款方行别代码\n(01-本行 02-国内他行)", - "*收款方客户账号", - "*收款方账户名称", - "收款方开户行名称", - "收款方联行号", - "客户方流水号", - "*金额", - "*用途", - "备注", - "是否短信通知收款人(0-不通知,1-通知,默认为0-不通知。)", - "收款人手机号码", - "短信通知附加信息", -]); - class PaymentOrderXlsError extends Error { constructor(code, details = {}) { super(code); @@ -55,10 +38,6 @@ function decryptRequired(config, value, code) { } } -function stringCell(value = "") { - return { t: "s", v: String(value) }; -} - function buildPaymentOrderRows(config, batch) { const blockers = []; const addBlocker = (code, entityCode) => { @@ -86,6 +65,11 @@ function buildPaymentOrderRows(config, batch) { } catch (error) { addBlocker(error.code ?? "payer_name_missing"); } + try { + requiredText(payer.bankName, "payer_bank_name_missing"); + } catch (error) { + addBlocker(error.code ?? "payer_bank_name_missing"); + } } const items = [...(batch.items ?? [])].sort((left, right) => @@ -108,6 +92,7 @@ function buildPaymentOrderRows(config, batch) { } let payeeAccount = ""; let payeeName = ""; + let payeeBankName = ""; let entityCode = ""; try { payeeAccount = decryptRequired(config, payee?.bankAccountEncrypted, "payee_account_unavailable"); @@ -119,6 +104,11 @@ function buildPaymentOrderRows(config, batch) { } catch (error) { addBlocker(error.code ?? "payee_name_missing", entityCodeHint); } + try { + payeeBankName = requiredText(payee?.bankName, "payee_bank_name_missing"); + } catch (error) { + addBlocker(error.code ?? "payee_bank_name_missing", entityCodeHint); + } try { entityCode = requiredText(item.entity_code, "entity_code_missing"); } catch (error) { @@ -137,59 +127,90 @@ function buildPaymentOrderRows(config, batch) { addBlocker(error.code ?? "customer_serial_invalid", entityCodeHint); } if (Number.isFinite(amount) && amount > 0) totalAmount += amount; - return { payee, payeeAccount, payeeName, entityCode, amount, serial }; + return { payeeAccount, payeeName, payeeBankName, entityCode, amount, serial }; }); if (blockers.length) { throw new PaymentOrderXlsError(blockers[0].code, { blockers }); } - const rows = preparedRows.map(({ payee, payeeAccount, payeeName, entityCode, amount, serial }, index) => { + const rows = preparedRows.map(({ payeeAccount, payeeName, payeeBankName, 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(""), + String(index + 1), + payerAccount, + payerName, + "01", + payeeAccount, + payeeName, + payeeBankName, + "", + serial, + Number(amount.toFixed(2)), + "报销款", + entityCode, + "0", + "", + "", ]; }); 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(""); - } +const PYTHON_ERROR_CODES = new Set([ + "runtime_dependency_unavailable", + "template_unavailable", + "template_hash_mismatch", + "template_invalid", + "template_structure_mismatch", + "input_too_large", + "input_invalid", + "rows_invalid", + "row_invalid", + "amount_invalid", + "text_cell_invalid", + "fill_failed", + "output_invalid", +]); + +function mapTemplateProcessError(result) { + const stderr = Buffer.isBuffer(result?.stderr) ? result.stderr.toString("utf8") : String(result?.stderr ?? ""); + const matched = stderr.match(/^\[payment-order-template\]\[ERROR\] ([a-z_]+)\s*$/); + const internalCode = matched && PYTHON_ERROR_CODES.has(matched[1]) ? matched[1] : "process_failed"; + if (internalCode === "runtime_dependency_unavailable" || result?.error?.code === "ENOENT") { + return "payment_order_generator_unavailable"; } - sheet["!ref"] = XLSX.utils.encode_range({ r: 0, c: 0 }, { r: allRows.length - 1, c: HEADERS.length - 1 }); - return sheet; + if (internalCode.startsWith("template_")) return "payment_order_template_unavailable"; + return "payment_order_generation_failed"; } -function generatePaymentOrderXls(config, batch) { +function generatePaymentOrderXls(config, batch, options = {}) { 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" }); + const pythonBin = options.pythonBin ?? process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3"; + const scriptPath = options.scriptPath ?? path.resolve(__dirname, "../scripts/fill-payment-order-template.py"); + const maxBuffer = options.maxBuffer ?? 16 * 1024 * 1024; + const result = spawnSync(pythonBin, [scriptPath], { + input: Buffer.from(JSON.stringify({ rows }), "utf8"), + maxBuffer, + timeout: options.timeout ?? 15_000, + windowsHide: true, + env: { + PATH: path.isAbsolute(pythonBin) ? path.dirname(pythonBin) : "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONNOUSERSITE: "1", + }, + }); + if (result.status !== 0 || result.error || !Buffer.isBuffer(result.stdout)) { + throw new PaymentOrderXlsError(mapTemplateProcessError(result)); + } + const buffer = result.stdout; + if (buffer.length > maxBuffer || buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1") { + throw new PaymentOrderXlsError("payment_order_generation_failed"); + } return { buffer, rowCount: rows.length, totalAmount, serials }; } module.exports = { - HEADERS, PaymentOrderXlsError, buildCustomerSerial, buildPaymentOrderRows, diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js index bcd8187a..4ad172e7 100644 --- a/server/tests/unit/routes/paymentBatches.reimbursement.test.js +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -2382,6 +2382,57 @@ describe("payment batch reimbursement routes", () => { await app.close(); }); + it("does not let an admin without a finance business role export a payment order", async () => { + const { app, tx } = await buildApp({ + actor: { + id: ACTOR_ID, + department_id: "dept-1", + permissionCodes: ["*"], + roleCodes: ["admin"], + }, + }); + 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(403); + expect(tx.paymentBatch.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED" }), + }); + await app.close(); + }); + + it.each(["draft", "failed", "cancelled", "executed"])( + "rejects direct payment-order export while the batch is %s", + async (status) => { + const { app, tx } = await buildApp({ + actor: { + id: ACTOR_ID, + department_id: "dept-1", + permissionCodes: ["payment.batch.manage", "payment.order.export"], + roleCodes: ["finance"], + }, + batch: { status }, + }); + 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("status_not_reviewed"); + expect(tx.paymentBatch.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalledWith({ + data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }), + }); + await app.close(); + }, + ); + it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => { const { app, tx } = await buildApp({ actor: { @@ -2399,11 +2450,72 @@ describe("payment batch reimbursement routes", () => { payload: { reason: "旧批次尝试" }, }); expect(response.statusCode).toBe(409); - expect(response.json().error.details.reason).toBe("payment_account_snapshot_missing"); + expect(response.json().error.details.reason).toBe("legacy_payment_account_snapshot_missing"); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); await app.close(); }); + it("rejects direct payment-order export when an item effective channel is not bank", 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 } = await buildApp({ + actor: { + id: ACTOR_ID, + department_id: "dept-1", + permissionCodes: ["payment.batch.manage", "payment.order.export"], + roleCodes: ["finance"], + }, + batch: { + status: "reviewed", + default_payment_channel: "bank", + payment_account_snapshot_json: { + accountName: "合成测试公司", + bankName: "中国建设银行", + bankInstitutionCode: "CCB", + accountNumberMasked: "6217********1234", + accountNumberEncrypted: payerEncrypted, + }, + items: buildPaymentBatch().items.map((item) => ({ + ...item, + payment_channel_override: "cash", + 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(409); + expect(response.json().error.details).toEqual(expect.objectContaining({ + reason: "bank_channel_required", + blockers: expect.arrayContaining([ + { code: "bank_channel_required", entityCode: "BX-20260429-0001" }, + ]), + })); + expect(tx.paymentBatch.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalledWith({ + data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }), + }); + 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 }, diff --git a/server/tests/unit/services/paymentOrderXls.test.js b/server/tests/unit/services/paymentOrderXls.test.js index 97e1130e..b859dbb9 100644 --- a/server/tests/unit/services/paymentOrderXls.test.js +++ b/server/tests/unit/services/paymentOrderXls.test.js @@ -1,8 +1,9 @@ -const XLSX = require("xlsx"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const { encryptStringOrThrow } = require("../../../src/services/crypto"); const { - HEADERS, PaymentOrderXlsError, buildCustomerSerial, generatePaymentOrderXls, @@ -18,6 +19,7 @@ function buildBatch(overrides = {}) { payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成测试公司", + bankName: "中国建设银行", accountNumberEncrypted: encrypt("6217000000000012345"), }, items: [ @@ -39,21 +41,12 @@ function buildBatch(overrides = {}) { } describe("payment order BIFF8 generator", () => { - it("writes the fixed workbook contract with typed string identifiers and stable ordering", () => { + it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => { 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.rowCount).toBe(2); expect(first.serials).toEqual(second.serials); expect(first.totalAmount).toBe("3.20"); }); @@ -75,9 +68,107 @@ describe("payment order BIFF8 generator", () => { } }); + it("does not invent a bank display name when the frozen snapshot is incomplete", () => { + const batch = buildBatch(); + delete batch.items[0].snapshot_json.payee.bankName; + expect(() => generatePaymentOrderXls(config, batch)).toThrow(expect.objectContaining({ + code: "payee_bank_name_missing", + details: { + blockers: expect.arrayContaining([ + { code: "payee_bank_name_missing", 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); }); + + it("fails closed when the fixed Python runtime is unavailable", () => { + expect(() => generatePaymentOrderXls(config, buildBatch(), { + pythonBin: "/definitely-missing/payment-order-python", + })).toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" })); + }); + + it("fails closed when the fixed Python dependencies are unavailable", () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-dependency-test-")); + try { + const fakePython = path.join(temporaryRoot, "python"); + fs.writeFileSync( + fakePython, + "#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n", + { mode: 0o700 }, + ); + expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython })) + .toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" })); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); + + it("fails closed when the controlled template hash drifts", () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-template-test-")); + try { + const scriptDirectory = path.join(temporaryRoot, "src", "scripts"); + const assetDirectory = path.join(temporaryRoot, "src", "assets", "payment-order"); + fs.mkdirSync(scriptDirectory, { recursive: true }); + fs.mkdirSync(assetDirectory, { recursive: true }); + const scriptPath = path.resolve(__dirname, "../../../src/scripts/fill-payment-order-template.py"); + const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls"); + const copiedScriptPath = path.join(scriptDirectory, "fill-payment-order-template.py"); + const copiedTemplatePath = path.join(assetDirectory, "ccb-payment-order-template.xls"); + fs.copyFileSync(scriptPath, copiedScriptPath); + const template = fs.readFileSync(templatePath); + template[template.length - 1] ^= 1; + fs.writeFileSync(copiedTemplatePath, template); + + expect(() => generatePaymentOrderXls(config, buildBatch(), { + scriptPath: copiedScriptPath, + })).toThrow(expect.objectContaining({ code: "payment_order_template_unavailable" })); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); + + it("does not copy child stderr or account payload into the exposed error", () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-stderr-test-")); + try { + const fakePython = path.join(temporaryRoot, "python"); + fs.writeFileSync(fakePython, "#!/bin/sh\ntee /dev/stderr >/dev/null\nexit 70\n", { mode: 0o700 }); + let caught; + try { + generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(PaymentOrderXlsError); + expect(caught.code).toBe("payment_order_generation_failed"); + expect(JSON.stringify(caught)).not.toContain("6217000000000012345"); + expect(JSON.stringify(caught)).not.toContain("6222000000000012345"); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); + + it.each([ + ["timeout", "#!/bin/sh\nwhile :; do :; done\n", { timeout: 20 }], + [ + "oversized output", + "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do printf X; i=$((i + 1)); done\n", + { maxBuffer: 1024 }, + ], + ])("fails closed on child %s", (_label, script, options) => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-process-test-")); + try { + const fakePython = path.join(temporaryRoot, "python"); + fs.writeFileSync(fakePython, script, { mode: 0o700 }); + expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython, ...options })) + .toThrow(expect.objectContaining({ code: "payment_order_generation_failed" })); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); });