From 483f7ae6bff27835a8a783f5352394bd9137d758 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 3 Aug 2026 08:54:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(corporate-bank-account-payment-order):=20?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=88=B6=E5=8D=95=E8=A1=A8=E5=A4=B4=E5=AF=8C?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=B9=B6=E6=A0=87=E8=AF=86=E8=A1=8C=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/security-privacy.md | 8 +++ .../design.md | 5 +- .../implement.md | 19 +++++- .../prd.md | 7 +- .../check-payment-order-xls-roundtrip.js | 2 +- server/scripts/verify-payment-order-xls.py | 59 +++++++++++++++++ .../scripts/fill-payment-order-template.py | 65 +++++++++++++++++++ server/src/services/paymentOrderXls.js | 2 +- .../unit/services/paymentOrderXls.test.js | 2 +- 9 files changed, 160 insertions(+), 9 deletions(-) diff --git a/.trellis/spec/hyb-oa/security-privacy.md b/.trellis/spec/hyb-oa/security-privacy.md index cc059cf4..cb1ef842 100644 --- a/.trellis/spec/hyb-oa/security-privacy.md +++ b/.trellis/spec/hyb-oa/security-privacy.md @@ -71,6 +71,10 @@ 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. +- BIFF8 rich-text runs are part of the template contract. Open the template + with `formatting_info=True`, copy every source font property, and explicitly + call `write_rich_text`; `xlutils.filter` does not preserve those runs by + default. Never replace this with a hard-coded "red" font approximation. - 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 @@ -126,6 +130,10 @@ 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. +- Compare rich-text segments semantically, including start offsets and complete + font properties. Required headers must assert the first `*` uses the source + red font and all remaining text uses the source black font; whole-cell style + equality is insufficient. - 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. 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 53f88baf..7a82aaa5 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 @@ -125,7 +125,7 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业 - 行别代码:固定字符串 `01`。 - 收款方账号/名称、开户行:收款快照。 - 联行号:空字符串。 -- 客户流水号:单一 `buildCustomerReference(batchNo, rowIndex)` 函数生成,写入前校验长度、字符集和批内唯一。 +- 客户流水号:单一 `buildCustomerSerial(batchNo, rowIndex)` 函数生成 `-R`,写入前校验长度、字符集和批内唯一;`R` 显式区分批次序号与行序号。 - 金额:初版数值单元格 + 两位小数格式。 - 用途:`报销款`。 - 备注:报销单编号。 @@ -139,6 +139,7 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业 - 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。 +- `xlutils.filter` 不自动保留 BIFF8 富文本 run。填充器从 `formatting_info=True` 的 `rich_text_runlist_map` 读取每个表头分段,把模板中的完整字体属性转换为 `xlwt.Font`,再显式调用 `write_rich_text`;不得只硬编码红色或重造一种近似字体。 - 模板已有三行数据样式;超过三笔时复制第一个数据行的样式并设置相同行高。金额列复制原样式后只把 `num_format_str` 改为 `0.00`,七个标识字段以字符串写入。 - 生成器不再依赖 SheetJS;`xlsx` 依赖从服务端移除。先完成审计和制单计数事务,再设置 `application/vnd.ms-excel`、安全附件名、`nosniff`、`private, no-store` 等响应头并发送。 - 不写对象存储、临时制单文件或服务器持久目录。网络中断后的已记录导出按保守口径视为已生成,用户可填写新原因重导。 @@ -165,7 +166,7 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业 ## 9. 验证与依赖门禁 - Node 单元/路由测试覆盖权限矩阵、单一启用账户并发约束、通知、审计、批次快照、历史拒绝、CCB-only、确定性排序、重导和隐私负向断言。 -- 使用合成数据生成 `.xls`,由 CI 的固定 Python 环境独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性,并比较列宽、行高、字体、加粗、底色、边框、对齐等模板样式签名。 +- 使用合成数据生成 `.xls`,由 CI 的固定 Python 环境独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性,并比较列宽、行高、字体、加粗、底色、边框、对齐及富文本 run 等模板样式签名。 - 覆盖超过三笔的扩展行、模板 hash 漂移、解释器/依赖缺失、子进程失败、输出越界和 stderr 隐私负向断言。 - 用合成非建行项证明整批失败且没有文件、制单计数或成功审计。 - Excel/WPS 人工打开验证中文、长字符串和金额;企业网银只有存在明确“仅校验”步骤时才使用虚构建行数据做最终确认。 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 3bfb10a1..68237fef 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 @@ -100,6 +100,23 @@ - 聚焦测试在资格门禁修订后为 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 包下载长时间无进展被主动取消,未形成镜像通过证据;生产镜像、部署、公网与网银验证仍待单独批准。 +- 本地生产镜像已真实构建通过;Alpine Python 3.14.5 成功从带哈希 requirements 安装固定依赖,镜像内生成与独立 BIFF8 读回合同通过。部署、公网与网银验证仍待单独批准。 - 当前实现已生成并独立读回一份 gitignored 合成 Excel 验收文件;原型文件此前经用户 Excel 人工打开认可,当前实现文件仍可用于最终人工外观确认。 - 付款/隐私只读复核在资格门禁修订后确认无 Blocker、High 或 Medium;唯一 Low(失败路径缺显式“无成功审计”断言)已补测试并复验。 + +## 12. Excel 富文本与流水号可读性修订(2026-08-03) + +- [x] Excel 人工验收发现原模板红色必填星号在生成文件中变黑;`xlrd` 诊断确认原模板存在 rich-text run,而生成文件的 run map 为空。 +- [x] 用户批准显式恢复模板富文本,并把客户流水号从 `批次号-001` 改为 `批次号-R001`;首份文件尚未导入网银,不产生历史格式兼容成本。 +- [x] 从模板逐段复制全部字体属性并调用 `write_rich_text`,不硬编码近似红色字体。 +- [x] 独立读回逐段断言必填星号红色、其余文字黑色,并覆盖模板其他富文本表头。 +- [x] 更新流水号单元测试与独立读回,重新生成 Excel 交付人工验收。 +- [x] 完成聚焦/全量验证和生产镜像真实构建;构建通过前不批准部署。 + +### 富文本修订验证记录 + +- 模板读取保持 `formatting_info=True`;填充器显式恢复所有模板富文本 run,并逐段复制字体名、字号、粗体、颜色、字符集及其他字体属性。 +- 独立读回逐段比较模板与生成文件,确认必填星号使用模板红色、其余表头文字使用模板黑色;第四笔扩展行样式仍与模板首个数据行一致。 +- 客户流水号已统一为 `批次号-R三位行号`,合成验收文件确认首行 `R001`、第四行 `R004`;固定验证夹具日期与 PRD 示例统一为 2026-08-03。 +- 一次人工验收文件校验失败被定位为诊断阶段名滞后:空短信附加信息实际为 BIFF8 `BLANK`,真正失败来自后续固定流水号夹具日期不一致;校验器已为字段值断言设置独立阶段名,避免再次误报为空单元格问题。 +- 聚焦测试 70 项、服务端全量 1199 项、宿主机独立 BIFF8 合同和已构建生产镜像内合同均通过;未部署、未写生产银行资料。 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 2acce054..a02d80d4 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 @@ -66,10 +66,10 @@ - 仅 `reviewed`(待财务付款)批次允许制单;`draft`、`failed`、`cancelled` 与 `executed` 均禁止制单。 - `reviewed` 状态允许因文件丢失或本地操作失败重复导出,每次仍须填写原因并独立审计;重新下载前必须明确提示“重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。”重复导出的业务字段必须来自同一冻结快照,不允许临时覆盖账号、金额或用途。 - `executed` 后不得重新生成可导入网银的指令文件;付款归档依赖银行回单与付款台账,避免把归档需要和可重复付款指令混为一体。 -- 原样保留三个工作表、15 列顺序与表头,以及原模板的列宽、表头/数据行行高、字体、加粗、底色、边框、对齐和换行;不得把格式伪装成 `.xls` 扩展名。 +- 原样保留三个工作表、15 列顺序与表头,以及原模板的列宽、表头/数据行行高、字体、加粗、底色、边框、对齐、换行和单元格内富文本分段;必填表头的首个 `*` 保持原模板红色,其余文字保持原模板黑色和字体属性。不得把格式伪装成 `.xls` 扩展名。 - 仓库可提交一份经脱敏检查、仅含空白表头和预格式化空行的受控模板资产;运行时只读取该固定资产,不接受用户上传模板或请求指定路径。模板哈希漂移时制单失败关闭。 - 模板预置数据行不足时,新行必须复制首个数据行的样式和行高;金额列仅覆盖为数值类型与 `0.00` 格式,不改变其他视觉属性。 -- 用途固定为“报销款”;客户方流水号由付款批次编号与三位行序号生成;备注写对应报销单编号;开户行名称取冻结快照;付款凭证号不进入制单。 +- 用途固定为“报销款”;客户方流水号格式为 `<付款批次编号>-R<三位行序号>`,例如 `PB-20260803-001-R001`;备注写对应报销单编号;开户行名称取冻结快照;付款凭证号不进入制单。 - 批次项必须使用显式、稳定且带唯一兜底键的排序,禁止依赖数据库自然顺序;同一批次重复导出时每项的行序号和客户流水号必须不变。 - 客户方流水号由单一生成函数负责,并在写文件前校验长度、字符集和批次内唯一性;不允许静默截断或分散内联拼接。首轮验证若证明银行只接受纯数字,应只调整该生成函数与对应合同。 - 付款方账号、收款方账号、联行号、客户流水号、手机号、行别代码和短信通知标志七列必须以字符串单元格写入,禁止数值类型推断;短信通知标志首版写字符串 `0`。 @@ -83,7 +83,7 @@ ### R5 本地与人工验证 -- 使用合成数据生成 `.xls`,再用独立解析验证工作表、表头、字段类型、中文、金额和模板视觉签名;至少覆盖列宽、行高、字体、加粗、底色、边框和对齐。 +- 使用合成数据生成 `.xls`,再用独立解析验证工作表、表头、字段类型、中文、金额和模板视觉签名;至少覆盖列宽、行高、字体、加粗、底色、边框、对齐和表头富文本分段,逐段断言必填星号红色、其余文字黑色。 - 独立解析器及版本必须进入可复现的测试环境,不能依赖某台开发机偶然安装的 `xlrd`。 - 至少使用 Excel 或 WPS 人工打开一次,确认中文和账号显示。 - 只有企业网银存在明确的“仅校验、不提交”环节时,才允许用完全虚构数据做最终格式确认;否则不进行生产网银试验。 @@ -120,6 +120,7 @@ - [ ] 批次详情和处理进度能区分“未生成制单”与“已生成制单待回单”,便于观察实际使用覆盖率。 - [ ] 授权财务人员填写原因后可下载符合模板的 BIFF8 `.xls`,下载行为留下脱敏审计。 - [ ] Excel 打开生成文件时,原模板列宽、行高、字体、加粗、底色、边框、对齐和换行保持不变;超过三笔时新增行仍复制相同数据行样式。 +- [ ] 所有必填表头逐段保留原模板富文本:首个星号红色、其余文字黑色;客户流水号使用 `批次号-R三位行号`,不存在两个无标签 `001` 连续造成的误读。 - [ ] 重新下载前展示重复付款风险原文;每次重导独立记录原因,但不会宣称旧文件已失效。 - [ ] 只有 `reviewed` 批次可制单;同一 `reviewed` 批次可带新原因重导,其他四种状态均被明确拒绝。 - [ ] 批次进入 `executed` 后无法重新生成银行制单,银行回单与付款台账仍可用于归档核验。 diff --git a/server/scripts/check-payment-order-xls-roundtrip.js b/server/scripts/check-payment-order-xls-roundtrip.js index 9a6f9925..0eee9ebe 100644 --- a/server/scripts/check-payment-order-xls-roundtrip.js +++ b/server/scripts/check-payment-order-xls-roundtrip.js @@ -15,7 +15,7 @@ const syntheticAccounts = [ ]; const generated = generatePaymentOrderXls(config, { - batch_no: "PB-20260802-001", + batch_no: "PB-20260803-001", payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成测试公司", diff --git a/server/scripts/verify-payment-order-xls.py b/server/scripts/verify-payment-order-xls.py index b82be858..f9c96eb3 100644 --- a/server/scripts/verify-payment-order-xls.py +++ b/server/scripts/verify-payment-order-xls.py @@ -31,6 +31,40 @@ EXPECTED_HEADERS = [ "收款人手机号码", "短信通知附加信息", ] +REQUIRED_HEADER_COLUMNS = (0, 1, 2, 3, 4, 5, 9, 10) + + +def font_signature(font: xlrd.formatting.Font) -> tuple: + return ( + font.name, + font.height, + font.bold, + font.italic, + font.struck_out, + font.outline, + font.shadow, + font.colour_index, + font.weight, + font.escapement, + font.underline_type, + font.family, + font.character_set, + ) + + +def rich_text_signature(book: xlrd.book.Book, sheet: xlrd.sheet.Sheet, row: int, column: int) -> list[tuple]: + text = sheet.cell_value(row, column) + runs = sheet.rich_text_runlist_map.get((row, column), []) + if not runs: + return [] + signature = [] + if runs[0][0] > 0: + base_font_index = book.xf_list[sheet.cell_xf_index(row, column)].font_index + signature.append((0, runs[0][0], text[:runs[0][0]], font_signature(book.font_list[base_font_index]))) + for index, (offset, font_index) in enumerate(runs): + end = runs[index + 1][0] if index + 1 < len(runs) else len(text) + signature.append((offset, end, text[offset:end], font_signature(book.font_list[font_index]))) + return signature def visual_signature(book: xlrd.book.Book, sheet: xlrd.sheet.Sheet, row: int, column: int) -> tuple: @@ -80,6 +114,21 @@ def main() -> int: sheet = generated.sheet_by_index(0) stage = "headers" assert sheet.row_values(0) == EXPECTED_HEADERS + stage = "header_rich_text" + assert set(sheet.rich_text_runlist_map) == set(source_sheet.rich_text_runlist_map) + for row_column in source_sheet.rich_text_runlist_map: + row, column = row_column + assert rich_text_signature(generated, sheet, row, column) == rich_text_signature( + template, + source_sheet, + row, + column, + ) + for column in REQUIRED_HEADER_COLUMNS: + parts = rich_text_signature(generated, sheet, 0, column) + assert parts[0][2] == "*" and parts[0][3][7] == 10 + assert "".join(part[2] for part in parts[1:]) == EXPECTED_HEADERS[column][1:] + assert all(part[3][7] == 8 for part in parts[1:]) stage = "column_widths" stage = "explicit_column_widths" assert {column: info.width for column, info in sheet.colinfo_map.items()} == { @@ -103,11 +152,21 @@ def main() -> int: 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) + stage = "payer_account_value" assert sheet.cell_value(1, 1) == "6217000000000012345" + stage = "bank_type_value" assert sheet.cell_value(1, 3) == "01" + stage = "payee_account_value" assert sheet.cell_value(1, 4) == "6222000000000012345" + stage = "first_customer_serial_value" + assert sheet.cell_value(1, 8) == "PB-20260803-001-R001" + stage = "last_customer_serial_value" + assert sheet.cell_value(4, 8) == "PB-20260803-001-R004" + stage = "payee_name_value" assert sheet.cell_value(1, 5) == "合成员工1" + stage = "purpose_value" assert sheet.cell_value(1, 10) == "报销款" + stage = "sms_flag_value" assert sheet.cell_value(1, 12) == "0" for row in range(1, 5): assert sheet.cell_type(row, 9) == xlrd.XL_CELL_NUMBER diff --git a/server/src/scripts/fill-payment-order-template.py b/server/src/scripts/fill-payment-order-template.py index dae4a276..6f480c78 100644 --- a/server/src/scripts/fill-payment-order-template.py +++ b/server/src/scripts/fill-payment-order-template.py @@ -13,6 +13,7 @@ import sys try: import xlrd + import xlwt from xlutils.filter import process, XLRDReader, XLWTWriter except ImportError: sys.stderr.write("[payment-order-template][ERROR] runtime_dependency_unavailable\n") @@ -26,6 +27,17 @@ COLUMN_COUNT = 15 TEMPLATE_DATA_ROW = 1 MAX_INPUT_BYTES = 2 * 1024 * 1024 MAX_ROWS = 5000 +EXPECTED_RICH_TEXT_CELLS = { + (0, 0), + (0, 1), + (0, 2), + (0, 3), + (0, 4), + (0, 5), + (0, 9), + (0, 10), + (0, 12), +} class TemplateFillError(Exception): @@ -78,6 +90,58 @@ def read_template() -> tuple[bytes, xlrd.book.Book]: return template_bytes, book +def copy_template_font(source: xlrd.formatting.Font) -> xlwt.Font: + target = xlwt.Font() + target.height = source.height + target.italic = source.italic + target.struck_out = source.struck_out + target.outline = source.outline + target.shadow = source.shadow + target.colour_index = source.colour_index + target.bold = source.bold + target._weight = source.weight + target.escapement = source.escapement + target.underline = source.underline_type + target.family = source.family + target.charset = source.character_set + target.name = source.name + return target + + +def restore_template_rich_text( + source_book: xlrd.book.Book, + writer: XLWTWriter, + target_sheet: xlwt.Worksheet, +) -> None: + source_sheet = source_book.sheet_by_index(0) + rich_text_runs = source_sheet.rich_text_runlist_map + if not EXPECTED_RICH_TEXT_CELLS.issubset(rich_text_runs): + raise TemplateFillError("template_structure_mismatch") + + for (row, column), runs in sorted(rich_text_runs.items()): + text = source_sheet.cell_value(row, column) + if not isinstance(text, str) or not runs: + raise TemplateFillError("template_structure_mismatch") + parts: list[object] = [] + first_offset = runs[0][0] + if first_offset < 0 or first_offset >= len(text): + raise TemplateFillError("template_structure_mismatch") + if first_offset > 0: + parts.append(text[:first_offset]) + for index, (offset, font_index) in enumerate(runs): + end = runs[index + 1][0] if index + 1 < len(runs) else len(text) + if offset < first_offset or end <= offset or end > len(text): + raise TemplateFillError("template_structure_mismatch") + try: + source_font = source_book.font_list[font_index] + except IndexError: + raise TemplateFillError("template_structure_mismatch") from None + parts.append((text[offset:end], copy_template_font(source_font))) + + style_index = source_sheet.cell_xf_index(row, column) + target_sheet.write_rich_text(row, column, parts, writer.style_list[style_index]) + + def fill_workbook(rows: list[list[object]], source_book: xlrd.book.Book) -> bytes: writer = XLWTWriter() try: @@ -86,6 +150,7 @@ def fill_workbook(rows: list[list[object]], source_book: xlrd.book.Book) -> byte source_sheet = source_book.sheet_by_index(0) target_sheet = target_book.get_sheet(0) target_sheet._cell_overwrite_ok = True + restore_template_rich_text(source_book, writer, target_sheet) source_row_info = source_sheet.rowinfo_map.get(TEMPLATE_DATA_ROW) source_row_height = source_row_info.height if source_row_info else 735 diff --git a/server/src/services/paymentOrderXls.js b/server/src/services/paymentOrderXls.js index 36b5a801..66f59dd1 100644 --- a/server/src/services/paymentOrderXls.js +++ b/server/src/services/paymentOrderXls.js @@ -13,7 +13,7 @@ class PaymentOrderXlsError extends Error { } function buildCustomerSerial(batchNo, rowNumber) { - const serial = `${String(batchNo).trim()}-${String(rowNumber).padStart(3, "0")}`; + const serial = `${String(batchNo).trim()}-R${String(rowNumber).padStart(3, "0")}`; if (serial.length > 32) { throw new PaymentOrderXlsError("customer_serial_too_long"); } diff --git a/server/tests/unit/services/paymentOrderXls.test.js b/server/tests/unit/services/paymentOrderXls.test.js index b859dbb9..b6320790 100644 --- a/server/tests/unit/services/paymentOrderXls.test.js +++ b/server/tests/unit/services/paymentOrderXls.test.js @@ -82,7 +82,7 @@ describe("payment order BIFF8 generator", () => { }); it("validates customer serial length and character set without truncation", () => { - expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-001"); + expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-R001"); expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError); expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError); });