Add sign-off receipt report and refine packing list: single-line info bar, real-glyph row height, auto-paginated detail table

- Packing list: info bar changed to a single-line three-column layout (schedule no. | order no. | box no.); packing date moved to bottom-right on the same row as the total; date separator changed to slash
- Packing list: range column widened 20->30mm; quantity/position-no./remark start shifted right 10mm; remark column narrowed to compensate; total width unchanged
- Packing list: detail cells now use line_spacing=1.0 + valign=top; separators changed to thin solid lines at row bottom (+1mm line spacing); placeholder empty rows no longer step y
- Packing list: row height estimation now uses fpdf2's same simhei glyph measured width (replacing rough character-count estimate); _clean adds control-character cleanup
- New sign-off receipt report reports/sign_receipt: master+detail linked query, ReportBro auto-paginated table, PyMuPDF stamped footer (document no. / page no. / sign-off column)
- run.py: required params now read from each report's config.yaml; default output path auto-generated; added transform.stamp_footer post-processing hook
- .gitignore: ignore .workbuddy/
This commit is contained in:
Misaka_Company
2026-08-04 08:55:09 +08:00
parent 22b764ef19
commit df8f25f6da
11 changed files with 1503 additions and 70 deletions

View File

@@ -0,0 +1,319 @@
"""签收单模板:样式定义 + 文档元素布局(方案 AReportBro 表格自动换页)。
设计理念:
明细改用 ReportBro **Table 元素**绑定数组参数 `items`,由 ReportBro 自动按内容高度
换页、每页重复列头、末页渲染合计。完整的「多页分页」由 ReportBro 原生支持,
不再像早期方案那样在 Python 端逐行算高度 + 绝对定位(那套在内容超一页时会报错/截断)。
- 标题 / 收发信息 / 运输信息:仍是 content band 内的绝对定位文本,仅第 1 页出现。
- 明细Tableheader 行 repeatHeader + body 行绑定 items + footer 行放合计)。
- 页脚家具(单据号、第 X/共 Y 页、签收栏、备注):由 run.py 在生成后用 PyMuPDF
逐页盖印——因为 reportbro-lib 的文档页脚 band 不渲染自由文本,且 page_number/
page_count 未注册为可解析参数,无法在模板内直接输出页码。
坐标系mm。A4 横向 297 x 210。docElements 的 x/y/width/height 均经 mm() 换算为 pt。
"""
from __future__ import annotations
from typing import Any
PT = 2.834645669 # 1mm = 2.834645669pt72/25.4
def mm(v: float) -> int:
"""mm → pt取整。所有坐标/尺寸输出前必须经过此换算。"""
return round(v * PT)
# ---- 页面与边距mm设计值----
PAGE_W = 297 # A4 横向:宽
PAGE_H = 210 # A4 横向:高
MARGIN_T = 10
MARGIN_B = 16 # 底部留白较大:容纳盖印的签收栏 + 页码
MARGIN_L = 12
MARGIN_R = 12
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R # 273mm
CONTENT_H = PAGE_H - MARGIN_T - MARGIN_B # 184mm
CJK = "simhei"
INK = "#1a1a1a"
MUTE = "#8a8a8a"
FAINT = "#b5b5b5"
RULE = "#1a1a1a"
# ---- 明细列定义:(字段后缀, 表头, x偏移mm, 宽度mm, 对齐) ----
# 宽度合计 = 272mmA4 横向可用 273mm留 1mm 余量避免越界)。所有内容居中。
COLS = [
("seq", "序号", 0, 14, "center"),
# 产品名称列宽收窄:省出的 3.4mm 加到箱号列(箱号按 1.2× = 20.4mm),总宽仍 272mm
("product_name", "产品名称", 14, 46.6, "center"),
("model", "产品型号", 60.6, 79, "center"),
("range_", "量程", 139.6, 40, "center"),
("qty", "数量", 179.6, 24, "center"),
("paichan_order", "排产号/订单号", 203.6, 48, "center"),
("box_no", "箱号", 251.6, 20.4, "center"),
]
assert sum(c[3] for c in COLS) == 272, sum(c[3] for c in COLS)
# 表格行高mm
HDR_H = 7.0
BODY_H = 8.0
FOOT_H = 8.0
# ---------- 样式 ----------
def _debug_border() -> bool:
try:
from core.settings import settings
return settings.report.debug_border
except Exception:
return False
def _text_style(id_, *, size=10, bold=False, color=INK, halign="left",
valign="middle", name=None, pad_l=0, pad_r=0, font=CJK,
debug_border=None, line_spacing=1.25):
"""文本样式。padding 默认 0 让坐标精确可控;可经 pad_l/pad_r 加左右内边距mm
debug_border 由配置文件 report.debug_border 决定True 时给元素加边框便于核对占位)。
"""
db = _debug_border() if debug_border is None else debug_border
return {
"id": id_, "type": "text", "name": name or f"s{id_}",
"font": font, "fontSize": size, "bold": bold, "italic": False,
"underline": False, "strikethrough": False,
"horizontalAlignment": halign, "verticalAlignment": valign,
"textColor": color, "backgroundColor": "",
"lineSpacing": line_spacing,
"paddingLeft": pad_l, "paddingTop": 0, "paddingRight": pad_r, "paddingBottom": 0,
"borderColor": FAINT,
"borderWidth": 0.3 if db else 0,
"borderRadius": 0,
"borderAll": db,
"borderLeft": db, "borderTop": db,
"borderRight": db, "borderBottom": db,
}
def styles() -> list[dict]:
return [
_text_style(101, size=18, bold=True, halign="center", name="title"),
_text_style(103, size=8, color=INK, name="info_lbl"),
_text_style(105, size=9.5, bold=True, color=INK, halign="center", name="colhdr"),
_text_style(106, size=9, color=INK, name="cell_l", line_spacing=1.0, valign="middle"),
_text_style(107, size=9, color=INK, halign="center", name="cell_c", line_spacing=1.0, valign="middle"),
_text_style(109, size=11.5, bold=True, color=INK, halign="left", name="total"),
_text_style(118, size=13.5, bold=True, color=INK, halign="left", name="big_val_l"),
_text_style(119, size=9, bold=True, color=INK, name="info_val_bold", line_spacing=1.0, valign="middle"),
# 收发信息非加粗值(与标签垂直居中)
_text_style(123, size=9, color=INK, name="info_val", line_spacing=1.0, valign="middle"),
# 表格单元格
_text_style(127, size=9, bold=True, color=MUTE, halign="center", name="tbl_hdr", valign="middle"),
_text_style(128, size=9, color=INK, halign="center", name="tbl_cell", valign="middle"),
_text_style(129, size=9, bold=True, color=INK, halign="center", name="tbl_ftr", valign="middle"),
# 表格 + 表头带样式
# 边框风格沿用项目既有约定(与 packing_list 一致):无竖向网格、无左右外框,
# 仅横向细线分隔;线条用主色 RULE#1a1a1a而非浅灰表头不加灰底纹。
{"id": 130, "type": "table", "name": "tbl", "border": "row",
"borderColor": RULE, "borderWidth": 0.3},
{"id": 131, "type": "tableBand", "name": "tbl_hdr_band",
"backgroundColor": "", "alternateBackgroundColor": ""},
# 线段样式
{"id": 201, "type": "line", "name": "rule_faint", "color": FAINT, "borderWidth": 0.3},
{"id": 202, "type": "line", "name": "rule_strong", "color": RULE, "borderWidth": 2.5},
]
def document_properties() -> dict:
return {
"pageFormat": "A4", "orientation": "landscape",
"marginLeft": mm(MARGIN_L), "marginRight": mm(MARGIN_R),
"marginTop": mm(MARGIN_T), "marginBottom": mm(MARGIN_B),
"headerDisplay": "never", "headerSize": 0,
"footerDisplay": "never", "footerSize": 0,
"patternLocale": "zh", "patternCurrencySymbol": "",
"patternNumberGroupSymbol": "",
}
def parameters() -> list[dict]:
"""模板参数:单头标量 + 明细数组 items子字段"""
scalar = [
("receipt_no", "string"), ("seq", "number"), ("receipt_date", "string"),
("shipper_name", "string"), ("shipper_address", "string"), ("shipper_phone", "string"),
("receiver_name", "string"), ("receiver_address", "string"), ("receiver_phone", "string"),
("customer_order_no", "string"), ("customer_total_no", "string"),
("transport_mode", "string"), ("ship_date", "string"),
("sign_date", "string"), ("signed_by", "string"), ("maker", "string"),
("receipt_remark", "string"), ("total_qty", "number"), ("total_boxes", "number"),
]
params = [{"id": i + 1, "name": n, "type": t, "nullable": True}
for i, (n, t) in enumerate(scalar)]
# 明细数组参数
params.append({"id": 100, "name": "items", "type": "array", "nullable": True, "children": [
{"id": 101, "name": "seq", "type": "string", "nullable": True},
{"id": 102, "name": "product_name", "type": "string", "nullable": True},
{"id": 103, "name": "model", "type": "string", "nullable": True},
{"id": 104, "name": "range_", "type": "string", "nullable": True},
{"id": 105, "name": "qty", "type": "string", "nullable": True},
{"id": 106, "name": "paichan_order", "type": "string", "nullable": True},
{"id": 107, "name": "box_no", "type": "string", "nullable": True},
]})
return params
# ---------- 元素工厂 ----------
def _text(id_, x, y, w, h, content, *, style_id, containerId="0_content"):
return {
"id": id_, "elementType": "text", "containerId": containerId,
"x": x, "y": y, "width": w, "height": h,
"content": content, "styleId": style_id, "eval": False,
"printIf": "", "removeEmptyElement": False, "alwaysPrintOnSamePage": False,
"link": "", "pattern": "", "cs_condition": "",
"richText": False, "richTextHtml": "",
"spreadsheet_hide": True, "spreadsheet_column": 0,
"spreadsheet_colspan": 1, "spreadsheet_addEmptyRow": False,
}
def _line(id_, x, y, w, *, style_id, weight=0.3):
return {
"id": id_, "elementType": "line", "containerId": "0_content",
"x": x, "y": y, "width": w, "height": mm(weight),
"styleId": style_id, "printIf": "", "removeEmptyElement": False,
"spreadsheet_hide": True, "spreadsheet_column": 0,
"spreadsheet_addEmptyRow": False,
}
def _tcell(cid, width, content, style_id, colspan=1):
"""表格单元格TableTextElement。x/y 由表格自动计算,只需提供 width/height。"""
return {
"id": cid, "elementType": "text", "x": 0, "y": 0, "width": width, "height": mm(BODY_H),
"content": content, "styleId": style_id, "eval": False, "printIf": "",
"removeEmptyElement": False, "colspan": colspan, "growWeight": 0,
}
def build_doc_elements(context: dict[str, Any]) -> list[dict]:
"""按数据生成文档元素:第 1 页标题/收发信息/运输信息 + 自动换页的明细表格。
返回 content band 内的元素列表(不含页脚——页脚由 PyMuPDF 盖印)。
"""
L = 0
CW = mm(CONTENT_W) - 2 # 满宽减 2pt 余量,避免误差越界
els: list[dict] = []
nid = [2000]
def nid_():
nid[0] += 1
return nid[0]
# ===== 标题区(仅第 1 页)=====
y = 2.0
els.append(_text(nid_(), L, mm(y), mm(70), mm(5), "客户总排行号", style_id=103))
els.append(_text(nid_(), L, mm(y + 5), mm(70), mm(8), "${customer_total_no}", style_id=118))
els.append(_text(nid_(), L, mm(y), CW, mm(13), "签 收 单", style_id=101))
y = y + 16
# 标题双线:粗线(与装箱单标题双线同款 weight=0.45在上细线fpdf 默认最细)在下,
# 间距 1.5mm,风格与装箱单保持一致。
els.append(_line(nid_(), L, mm(y), CW, style_id=202, weight=0.45))
els.append(_line(nid_(), L, mm(y + 1.5), CW, style_id=202, weight=0))
y = y + 4
# ===== 收发信息区(左发货 / 右收货)=====
# 运输方式 / 发货日期并入左列(与发货单位/地址/电话同列对齐),
# 客户订单编号并入右列(与收货电话同列对齐)。左列因此比右列多一行(发货日期),
# 用户确认「多出来一个没得问题」。
info_left = [
("发货单位", "${shipper_name}", True),
("发货地址", "${shipper_address}", False),
("发货电话", "${shipper_phone}", False),
("运输方式", "${transport_mode}", False),
("发货日期", "${ship_date}", False),
]
info_right = [
("收货人", "${receiver_name}", True),
("收货地址", "${receiver_address}", False),
("收货电话", "${receiver_phone}", False),
("客户订单编号", "${customer_order_no}", False),
]
col_w = 112
label_w = 24
x_l = L
x_r = L + mm(137)
def draw_col(x: float, rows) -> float:
yy = y
for lbl, val, bold in rows:
h = 12 if "地址" in lbl else 9
els.append(_text(nid_(), x, mm(yy), mm(label_w), mm(h), lbl, style_id=103))
els.append(_text(nid_(), x + mm(label_w), mm(yy), mm(col_w - label_w), mm(h),
val, style_id=119 if bold else 123))
yy += h
return yy
yl = draw_col(x_l, info_left)
yr = draw_col(x_r, info_right)
# 去掉「签收单表头(收发信息区)下方」的额外分隔线,仅保留明细表头上方由表格
# border:"row" 渲染的顶边线作为两者之间的唯一分隔线。y 步进保持原值(+5)
# 使明细表格纵向位置不变。
y = max(yl, yr) + 5
# ===== 明细表格(自动换页)=====
col_w_pt = [mm(c[3]) for c in COLS]
def header_row():
cells = []
for i, (_f, label, _xoff, _w, _a) in enumerate(COLS):
cells.append(_tcell(3000 + i, col_w_pt[i], label, 127))
return {"id": 4000, "height": mm(HDR_H), "repeatHeader": True, "columnData": cells}
def body_row():
cells = []
for i, (f, _label, _xoff, _w, _a) in enumerate(COLS):
cells.append(_tcell(5000 + i, col_w_pt[i], "${%s}" % f, 128))
# alwaysPrintOnSamePage: 当前页剩余高度放不下整行(含折行后的真实高度)时,
# 整行推到下一页,绝不在两页之间断开一条记录(尤其产品名称/型号折行时)。
return {"id": 4100, "height": mm(BODY_H), "alwaysPrintOnSamePage": True,
"columnData": cells}
def footer_row():
# 合计数量(序号列)+ 数量列填 total_qty + 箱号列填共 X 箱(与箱号字段同列对齐,
# 不再放在排产号/订单号列)。产品名称列宽已收窄、箱号列宽已加宽以容纳该内容。
cells = [
_tcell(6000, col_w_pt[0], "合计", 129),
_tcell(6001, col_w_pt[1], "", 129),
_tcell(6002, col_w_pt[2], "", 129),
_tcell(6003, col_w_pt[3], "", 129),
_tcell(6004, col_w_pt[4], "${total_qty}", 129),
_tcell(6005, col_w_pt[5], "", 129),
_tcell(6006, col_w_pt[6], "共 ${total_boxes}", 129),
]
# footer 带高度回到 FOOT_H表格底边线rendering.py 始终画在表格最底)即落在
# 「紧贴合计下方」,作为合计区与下方备注/签收栏的分隔线。备注/签收栏由 stamp_footer
# 用 fitz 定位「合计」后画在正下方,落在表格底边线之下。
return {"id": 4200, "height": mm(FOOT_H), "columnData": cells}
table = {
"id": 7000, "elementType": "table", "containerId": "0_content",
"x": 0, "y": mm(y), "width": sum(col_w_pt), "height": mm(HDR_H + BODY_H),
"dataSource": "items", "columns": len(COLS), "styleId": 130,
"header": True, "headerData": header_row(),
"contentDataRows": [body_row()],
"footer": True, "footerData": footer_row(),
}
els.append(table)
return els
def build_report_definition(context: dict[str, Any]) -> dict:
"""组装完整 report_definition运行时调用"""
return {
"version": 6,
"documentProperties": document_properties(),
"parameters": parameters(),
"styles": styles(),
"docElements": build_doc_elements(context),
}