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
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.
@@ -125,7 +125,7 @@ POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业
- 行别代码:固定字符串 `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 通过子进程 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 人工打开验证中文、长字符串和金额;企业网银只有存在明确“仅校验”步骤时才使用虚构建行数据做最终确认。
@@ -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 合同和已构建生产镜像内合同均通过;未部署、未写生产银行资料。
@@ -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` 后无法重新生成银行制单,银行回单与付款台账仍可用于归档核验。
@@ -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: "合成测试公司",
@@ -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
@@ -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
+1 -1
查看文件
@@ -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");
}
@@ -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);
});