- 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/
320 lines
15 KiB
Python
320 lines
15 KiB
Python
"""签收单模板:样式定义 + 文档元素布局(方案 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),
|
||
}
|