Files
WareShipManifest/reports/sign_receipt/_build_template.py
Misaka_Company df8f25f6da 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/
2026-08-04 08:55:09 +08:00

320 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""签收单模板:样式定义 + 文档元素布局(方案 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),
}