fix(corporate-bank-account-payment-order): 恢复制单表头富文本并标识行号
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户