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:
319
reports/sign_receipt/_build_template.py
Normal file
319
reports/sign_receipt/_build_template.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""签收单模板:样式定义 + 文档元素布局(方案 A:ReportBro 表格自动换页)。
|
||||
|
||||
设计理念:
|
||||
明细改用 ReportBro **Table 元素**绑定数组参数 `items`,由 ReportBro 自动按内容高度
|
||||
换页、每页重复列头、末页渲染合计。完整的「多页分页」由 ReportBro 原生支持,
|
||||
不再像早期方案那样在 Python 端逐行算高度 + 绝对定位(那套在内容超一页时会报错/截断)。
|
||||
|
||||
- 标题 / 收发信息 / 运输信息:仍是 content band 内的绝对定位文本,仅第 1 页出现。
|
||||
- 明细:Table(header 行 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.834645669pt(72/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, 对齐) ----
|
||||
# 宽度合计 = 272mm(A4 横向可用 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),
|
||||
}
|
||||
Reference in New Issue
Block a user