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