fix(corporate-bank-account-payment-order): 恢复制单表头富文本并标识行号

这个提交包含在:
2026-08-03 08:54:02 +08:00
父节点 23c3768c46
当前提交 483f7ae6bf
共修改 9 个文件,包含 160 行新增和 9 行删除
+8
查看文件
@@ -71,6 +71,10 @@
bank template's column widths, row heights, fonts, fills, borders or bank template's column widths, row heights, fonts, fills, borders or
alignment. The asset contains headers and formatted blank rows only, and its alignment. The asset contains headers and formatted blank rows only, and its
SHA-256 is verified before every fill. 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, - Node owns authorization, snapshot decryption, blockers, ordering, serials,
amounts, audit and export counters. Python only copies the fixed template and 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 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 amounts and deterministic row order. Compare visual signatures for column
widths, row heights, font/bold, fill, border, alignment and wrapped text; widths, row heights, font/bold, fill, border, alignment and wrapped text;
include a row beyond the three preformatted template rows. 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 - Negative tests cover missing Python/dependencies, template hash drift, child
failure and a child that echoes the synthetic input to stderr; exposed errors failure and a child that echoes the synthetic input to stderr; exposed errors
and logs must not contain the account or original row payload. and logs must not contain the account or original row payload.
@@ -125,7 +125,7 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业
- 行别代码:固定字符串 `01`。 - 行别代码:固定字符串 `01`。
- 收款方账号/名称、开户行:收款快照。 - 收款方账号/名称、开户行:收款快照。
- 联行号:空字符串。 - 联行号:空字符串。
- 客户流水号:单一 `buildCustomerReference(batchNo, rowIndex)` 函数生成,写入前校验长度、字符集和批内唯一。 - 客户流水号:单一 `buildCustomerSerial(batchNo, rowIndex)` 函数生成 `<batchNo>-R<NNN>`,写入前校验长度、字符集和批内唯一;`R` 显式区分批次序号与行序号。
- 金额:初版数值单元格 + 两位小数格式。 - 金额:初版数值单元格 + 两位小数格式。
- 用途:`报销款`。 - 用途:`报销款`。
- 备注:报销单编号。 - 备注:报销单编号。
@@ -139,6 +139,7 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业
- Node 继续负责解密、资格校验、聚合 blockers、排序、流水号、金额和审计;固定 Python 填充器只接收已经校验的行 DTO,不承担权限或状态机判断。 - Node 继续负责解密、资格校验、聚合 blockers、排序、流水号、金额和审计;固定 Python 填充器只接收已经校验的行 DTO,不承担权限或状态机判断。
- Node 通过子进程 stdin 发送 JSON,Python 从 stdout 返回 BIFF8 二进制。完整账号不得进入 argv、env、日志或 stderr;子进程使用固定解释器路径、超时和 `maxBuffer`,并校验退出码、输出大小与 OLE 魔数。 - 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。 - 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`,七个标识字段以字符串写入。 - 模板已有三行数据样式;超过三笔时复制第一个数据行的样式并设置相同行高。金额列复制原样式后只把 `num_format_str` 改为 `0.00`,七个标识字段以字符串写入。
- 生成器不再依赖 SheetJS;`xlsx` 依赖从服务端移除。先完成审计和制单计数事务,再设置 `application/vnd.ms-excel`、安全附件名、`nosniff`、`private, no-store` 等响应头并发送。 - 生成器不再依赖 SheetJS;`xlsx` 依赖从服务端移除。先完成审计和制单计数事务,再设置 `application/vnd.ms-excel`、安全附件名、`nosniff`、`private, no-store` 等响应头并发送。
- 不写对象存储、临时制单文件或服务器持久目录。网络中断后的已记录导出按保守口径视为已生成,用户可填写新原因重导。 - 不写对象存储、临时制单文件或服务器持久目录。网络中断后的已记录导出按保守口径视为已生成,用户可填写新原因重导。
@@ -165,7 +166,7 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业
## 9. 验证与依赖门禁 ## 9. 验证与依赖门禁
- Node 单元/路由测试覆盖权限矩阵、单一启用账户并发约束、通知、审计、批次快照、历史拒绝、CCB-only、确定性排序、重导和隐私负向断言。 - Node 单元/路由测试覆盖权限矩阵、单一启用账户并发约束、通知、审计、批次快照、历史拒绝、CCB-only、确定性排序、重导和隐私负向断言。
- 使用合成数据生成 `.xls`,由 CI 的固定 Python 环境独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性,并比较列宽、行高、字体、加粗、底色、边框、对齐等模板样式签名。 - 使用合成数据生成 `.xls`,由 CI 的固定 Python 环境独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性,并比较列宽、行高、字体、加粗、底色、边框、对齐及富文本 run 等模板样式签名。
- 覆盖超过三笔的扩展行、模板 hash 漂移、解释器/依赖缺失、子进程失败、输出越界和 stderr 隐私负向断言。 - 覆盖超过三笔的扩展行、模板 hash 漂移、解释器/依赖缺失、子进程失败、输出越界和 stderr 隐私负向断言。
- 用合成非建行项证明整批失败且没有文件、制单计数或成功审计。 - 用合成非建行项证明整批失败且没有文件、制单计数或成功审计。
- Excel/WPS 人工打开验证中文、长字符串和金额;企业网银只有存在明确“仅校验”步骤时才使用虚构建行数据做最终确认。 - Excel/WPS 人工打开验证中文、长字符串和金额;企业网银只有存在明确“仅校验”步骤时才使用虚构建行数据做最终确认。
@@ -100,6 +100,23 @@
- 聚焦测试在资格门禁修订后为 70 项通过;新增覆盖 admin 非财务拒绝、四种非 reviewed 状态、历史 item 非银行 override、模板 hash、依赖缺失、子进程超时/输出越界和 stderr 隐私。 - 聚焦测试在资格门禁修订后为 70 项通过;新增覆盖 admin 非财务拒绝、四种非 reviewed 状态、历史 item 非银行 override、模板 hash、依赖缺失、子进程超时/输出越界和 stderr 隐私。
- 后端全量最终为 176 个文件、1199 项全部通过;此前 Dockerfile 缓存层文本合同失败已通过恢复独立 `RUN cd server && npm ci` 修复并纳入最终全量。 - 后端全量最终为 176 个文件、1199 项全部通过;此前 Dockerfile 缓存层文本合同失败已通过恢复独立 `RUN cd server && npm ci` 修复并纳入最终全量。
- 前端 typecheck/build、Prisma validate、流程合同、Trellis governance、敏感扫描和 diff 检查通过;文档审计仅保留仓库既有 22 个未登记文件警告。 - 前端 typecheck/build、Prisma validate、流程合同、Trellis governance、敏感扫描和 diff 检查通过;文档审计仅保留仓库既有 22 个未登记文件警告。
- 本地 Docker 构建因 Alpine 包下载长时间无进展被主动取消,未形成镜像通过证据;生产镜像、部署、公网与网银验证仍待单独批准。 - 本地生产镜像已真实构建通过;Alpine Python 3.14.5 成功从带哈希 requirements 安装固定依赖,镜像内生成与独立 BIFF8 读回合同通过。部署、公网与网银验证仍待单独批准。
- 当前实现已生成并独立读回一份 gitignored 合成 Excel 验收文件;原型文件此前经用户 Excel 人工打开认可,当前实现文件仍可用于最终人工外观确认。 - 当前实现已生成并独立读回一份 gitignored 合成 Excel 验收文件;原型文件此前经用户 Excel 人工打开认可,当前实现文件仍可用于最终人工外观确认。
- 付款/隐私只读复核在资格门禁修订后确认无 Blocker、High 或 Medium;唯一 Low(失败路径缺显式“无成功审计”断言)已补测试并复验。 - 付款/隐私只读复核在资格门禁修订后确认无 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 合同和已构建生产镜像内合同均通过;未部署、未写生产银行资料。
@@ -66,10 +66,10 @@
- 仅 `reviewed`(待财务付款)批次允许制单;`draft`、`failed`、`cancelled` 与 `executed` 均禁止制单。 - 仅 `reviewed`(待财务付款)批次允许制单;`draft`、`failed`、`cancelled` 与 `executed` 均禁止制单。
- `reviewed` 状态允许因文件丢失或本地操作失败重复导出,每次仍须填写原因并独立审计;重新下载前必须明确提示“重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。”重复导出的业务字段必须来自同一冻结快照,不允许临时覆盖账号、金额或用途。 - `reviewed` 状态允许因文件丢失或本地操作失败重复导出,每次仍须填写原因并独立审计;重新下载前必须明确提示“重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。”重复导出的业务字段必须来自同一冻结快照,不允许临时覆盖账号、金额或用途。
- `executed` 后不得重新生成可导入网银的指令文件;付款归档依赖银行回单与付款台账,避免把归档需要和可重复付款指令混为一体。 - `executed` 后不得重新生成可导入网银的指令文件;付款归档依赖银行回单与付款台账,避免把归档需要和可重复付款指令混为一体。
- 原样保留三个工作表、15 列顺序与表头,以及原模板的列宽、表头/数据行行高、字体、加粗、底色、边框、对齐和换行;不得把格式伪装成 `.xls` 扩展名。 - 原样保留三个工作表、15 列顺序与表头,以及原模板的列宽、表头/数据行行高、字体、加粗、底色、边框、对齐、换行和单元格内富文本分段;必填表头的首个 `*` 保持原模板红色,其余文字保持原模板黑色和字体属性。不得把格式伪装成 `.xls` 扩展名。
- 仓库可提交一份经脱敏检查、仅含空白表头和预格式化空行的受控模板资产;运行时只读取该固定资产,不接受用户上传模板或请求指定路径。模板哈希漂移时制单失败关闭。 - 仓库可提交一份经脱敏检查、仅含空白表头和预格式化空行的受控模板资产;运行时只读取该固定资产,不接受用户上传模板或请求指定路径。模板哈希漂移时制单失败关闭。
- 模板预置数据行不足时,新行必须复制首个数据行的样式和行高;金额列仅覆盖为数值类型与 `0.00` 格式,不改变其他视觉属性。 - 模板预置数据行不足时,新行必须复制首个数据行的样式和行高;金额列仅覆盖为数值类型与 `0.00` 格式,不改变其他视觉属性。
- 用途固定为“报销款”;客户方流水号由付款批次编号与三位行序号生成;备注写对应报销单编号;开户行名称取冻结快照;付款凭证号不进入制单。 - 用途固定为“报销款”;客户方流水号格式为 `<付款批次编号>-R<三位行序号>`,例如 `PB-20260803-001-R001`;备注写对应报销单编号;开户行名称取冻结快照;付款凭证号不进入制单。
- 批次项必须使用显式、稳定且带唯一兜底键的排序,禁止依赖数据库自然顺序;同一批次重复导出时每项的行序号和客户流水号必须不变。 - 批次项必须使用显式、稳定且带唯一兜底键的排序,禁止依赖数据库自然顺序;同一批次重复导出时每项的行序号和客户流水号必须不变。
- 客户方流水号由单一生成函数负责,并在写文件前校验长度、字符集和批次内唯一性;不允许静默截断或分散内联拼接。首轮验证若证明银行只接受纯数字,应只调整该生成函数与对应合同。 - 客户方流水号由单一生成函数负责,并在写文件前校验长度、字符集和批次内唯一性;不允许静默截断或分散内联拼接。首轮验证若证明银行只接受纯数字,应只调整该生成函数与对应合同。
- 付款方账号、收款方账号、联行号、客户流水号、手机号、行别代码和短信通知标志七列必须以字符串单元格写入,禁止数值类型推断;短信通知标志首版写字符串 `0`。 - 付款方账号、收款方账号、联行号、客户流水号、手机号、行别代码和短信通知标志七列必须以字符串单元格写入,禁止数值类型推断;短信通知标志首版写字符串 `0`。
@@ -83,7 +83,7 @@
### R5 本地与人工验证 ### R5 本地与人工验证
- 使用合成数据生成 `.xls`,再用独立解析验证工作表、表头、字段类型、中文、金额和模板视觉签名;至少覆盖列宽、行高、字体、加粗、底色、边框和对齐。 - 使用合成数据生成 `.xls`,再用独立解析验证工作表、表头、字段类型、中文、金额和模板视觉签名;至少覆盖列宽、行高、字体、加粗、底色、边框、对齐和表头富文本分段,逐段断言必填星号红色、其余文字黑色。
- 独立解析器及版本必须进入可复现的测试环境,不能依赖某台开发机偶然安装的 `xlrd`。 - 独立解析器及版本必须进入可复现的测试环境,不能依赖某台开发机偶然安装的 `xlrd`。
- 至少使用 Excel 或 WPS 人工打开一次,确认中文和账号显示。 - 至少使用 Excel 或 WPS 人工打开一次,确认中文和账号显示。
- 只有企业网银存在明确的“仅校验、不提交”环节时,才允许用完全虚构数据做最终格式确认;否则不进行生产网银试验。 - 只有企业网银存在明确的“仅校验、不提交”环节时,才允许用完全虚构数据做最终格式确认;否则不进行生产网银试验。
@@ -120,6 +120,7 @@
- [ ] 批次详情和处理进度能区分“未生成制单”与“已生成制单待回单”,便于观察实际使用覆盖率。 - [ ] 批次详情和处理进度能区分“未生成制单”与“已生成制单待回单”,便于观察实际使用覆盖率。
- [ ] 授权财务人员填写原因后可下载符合模板的 BIFF8 `.xls`,下载行为留下脱敏审计。 - [ ] 授权财务人员填写原因后可下载符合模板的 BIFF8 `.xls`,下载行为留下脱敏审计。
- [ ] Excel 打开生成文件时,原模板列宽、行高、字体、加粗、底色、边框、对齐和换行保持不变;超过三笔时新增行仍复制相同数据行样式。 - [ ] Excel 打开生成文件时,原模板列宽、行高、字体、加粗、底色、边框、对齐和换行保持不变;超过三笔时新增行仍复制相同数据行样式。
- [ ] 所有必填表头逐段保留原模板富文本:首个星号红色、其余文字黑色;客户流水号使用 `批次号-R三位行号`,不存在两个无标签 `001` 连续造成的误读。
- [ ] 重新下载前展示重复付款风险原文;每次重导独立记录原因,但不会宣称旧文件已失效。 - [ ] 重新下载前展示重复付款风险原文;每次重导独立记录原因,但不会宣称旧文件已失效。
- [ ] 只有 `reviewed` 批次可制单;同一 `reviewed` 批次可带新原因重导,其他四种状态均被明确拒绝。 - [ ] 只有 `reviewed` 批次可制单;同一 `reviewed` 批次可带新原因重导,其他四种状态均被明确拒绝。
- [ ] 批次进入 `executed` 后无法重新生成银行制单,银行回单与付款台账仍可用于归档核验。 - [ ] 批次进入 `executed` 后无法重新生成银行制单,银行回单与付款台账仍可用于归档核验。
@@ -15,7 +15,7 @@ const syntheticAccounts = [
]; ];
const generated = generatePaymentOrderXls(config, { const generated = generatePaymentOrderXls(config, {
batch_no: "PB-20260802-001", batch_no: "PB-20260803-001",
payment_account_snapshot_json: { payment_account_snapshot_json: {
bankInstitutionCode: "CCB", bankInstitutionCode: "CCB",
accountName: "合成测试公司", accountName: "合成测试公司",
@@ -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: 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) sheet = generated.sheet_by_index(0)
stage = "headers" stage = "headers"
assert sheet.row_values(0) == EXPECTED_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 = "column_widths"
stage = "explicit_column_widths" stage = "explicit_column_widths"
assert {column: info.width for column, info in sheet.colinfo_map.items()} == { 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): for column in (7, 13, 14):
stage = f"empty_cell_{column}" stage = f"empty_cell_{column}"
assert sheet.cell_type(row, column) in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK) 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" assert sheet.cell_value(1, 1) == "6217000000000012345"
stage = "bank_type_value"
assert sheet.cell_value(1, 3) == "01" assert sheet.cell_value(1, 3) == "01"
stage = "payee_account_value"
assert sheet.cell_value(1, 4) == "6222000000000012345" 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" assert sheet.cell_value(1, 5) == "合成员工1"
stage = "purpose_value"
assert sheet.cell_value(1, 10) == "报销款" assert sheet.cell_value(1, 10) == "报销款"
stage = "sms_flag_value"
assert sheet.cell_value(1, 12) == "0" assert sheet.cell_value(1, 12) == "0"
for row in range(1, 5): for row in range(1, 5):
assert sheet.cell_type(row, 9) == xlrd.XL_CELL_NUMBER assert sheet.cell_type(row, 9) == xlrd.XL_CELL_NUMBER
@@ -13,6 +13,7 @@ import sys
try: try:
import xlrd import xlrd
import xlwt
from xlutils.filter import process, XLRDReader, XLWTWriter from xlutils.filter import process, XLRDReader, XLWTWriter
except ImportError: except ImportError:
sys.stderr.write("[payment-order-template][ERROR] runtime_dependency_unavailable\n") sys.stderr.write("[payment-order-template][ERROR] runtime_dependency_unavailable\n")
@@ -26,6 +27,17 @@ COLUMN_COUNT = 15
TEMPLATE_DATA_ROW = 1 TEMPLATE_DATA_ROW = 1
MAX_INPUT_BYTES = 2 * 1024 * 1024 MAX_INPUT_BYTES = 2 * 1024 * 1024
MAX_ROWS = 5000 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): class TemplateFillError(Exception):
@@ -78,6 +90,58 @@ def read_template() -> tuple[bytes, xlrd.book.Book]:
return template_bytes, 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: def fill_workbook(rows: list[list[object]], source_book: xlrd.book.Book) -> bytes:
writer = XLWTWriter() writer = XLWTWriter()
try: 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) source_sheet = source_book.sheet_by_index(0)
target_sheet = target_book.get_sheet(0) target_sheet = target_book.get_sheet(0)
target_sheet._cell_overwrite_ok = True 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_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 = source_row_info.height if source_row_info else 735
+1 -1
查看文件
@@ -13,7 +13,7 @@ class PaymentOrderXlsError extends Error {
} }
function buildCustomerSerial(batchNo, rowNumber) { 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) { if (serial.length > 32) {
throw new PaymentOrderXlsError("customer_serial_too_long"); throw new PaymentOrderXlsError("customer_serial_too_long");
} }
@@ -82,7 +82,7 @@ describe("payment order BIFF8 generator", () => {
}); });
it("validates customer serial length and character set without truncation", () => { 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 invalid", 1)).toThrow(PaymentOrderXlsError);
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError); expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
}); });