- packing_list renders A5 content into top region of an A4 portrait page when paper=a4, so A5 paper loaded horizontally in an A4 tray prints upright - run.generate/print_document/print_api thread the optional paper param through - README: new Print Service section with A4 carrier usage + curl example - Swagger: POST /api/print description documents the A4 carrier mode
347 lines
18 KiB
Python
347 lines
18 KiB
Python
"""装箱单模板:样式定义 + 文档元素布局。
|
||
|
||
设计理念:**无边框、单色、字体驱动**的列表式排版。
|
||
弃用 ReportBro 表格元素(行高不自适应、行不自动堆叠),改用纯文本元素
|
||
+ 细横线手工排版。每行明细的高度由 transform 按字段长度估算,
|
||
本模块据此计算各元素的精确 y 坐标 —— 因此 docElements 在运行时按数据动态生成。
|
||
|
||
坐标系:mm。A4 纵向 210 x 297。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
# ---- 单位说明 ----
|
||
# ReportBro 的页面尺寸由 pageFormat=A4 自动换算为 pt(210mm→595pt),
|
||
# 但 docElements 的 x/y/width/height 与 documentProperties 的边距都按 **pt** 直接使用
|
||
# (边距不换算)。因此本文件所有「设计尺寸」以 mm 书写便于阅读,输出前用 PT 换算为 pt。
|
||
PT = 2.834645669 # 1mm = 2.834645669pt(72/25.4)
|
||
|
||
|
||
def mm(v: float) -> int:
|
||
"""mm → pt(取整)。所有坐标/尺寸输出前必须经过此换算。"""
|
||
return round(v * PT)
|
||
|
||
|
||
# ---- 页面与边距(mm,设计值)----
|
||
# A5 横向:210mm(宽) × 148mm(高)。ReportBro 用 orientation=landscape + pageFormat=A5。
|
||
PAGE_W = 210 # 横向时的宽度
|
||
PAGE_H = 148 # 横向时的高度
|
||
MARGIN_L = 12
|
||
MARGIN_R = 12
|
||
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R # 186mm
|
||
|
||
CJK = "simhei"
|
||
INK = "#1a1a1a" # 主文字(近黑)
|
||
MUTE = "#8a8a8a" # 辅助文字/列头(中灰)
|
||
FAINT = "#b5b5b5" # 行间细线(浅灰)
|
||
RULE = "#1a1a1a" # 区段加重线(与主文字同色)
|
||
|
||
# ---- 列定义:序号/产品名称/产品型号/量程/数量/位号/备注 ----
|
||
# (字段后缀, 表头, x偏移mm, 宽度mm, 对齐)
|
||
# 宽度合计 = 186mm。产品名称与型号给宽列,其余窄列。
|
||
COLS = [
|
||
("seq", "序号", 0, 12, "center"),
|
||
("product_name", "产品名称", 12, 30, "left"),
|
||
("model", "产品型号", 42, 44, "left"),
|
||
("range_", "量程", 86, 30, "left"), # 列宽 +50%(20→30,+10mm)
|
||
("qty", "数量", 116, 14, "right"), # 起点随量程加宽右移 10mm
|
||
("weihao", "位号", 130, 26, "left"), # 起点同步右移 10mm
|
||
("remark", "备注", 156, 30, "left"), # 起点右移 10mm,列宽 -10mm(40→30)以抵容量程加宽
|
||
]
|
||
|
||
|
||
# ---------- 样式 ----------
|
||
def _debug_border() -> bool:
|
||
"""从配置文件读取 debug_border 开关(config/settings.yaml → report.debug_border)。"""
|
||
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(102, size=8, color=MUTE, halign="center", name="subtitle"),
|
||
_text_style(103, size=8, color=INK, halign="center", name="info_lbl"),
|
||
# 信息条值:Bahnschrift 字体。排产号/订单号放大1.5倍(28.5),箱号/装箱日期保持19。
|
||
_text_style(104, size=19, bold=True, color=INK, halign="center", name="info_val", font="bahnschrift"),
|
||
_text_style(112, size=28.5, bold=True, color=INK, halign="center", name="info_val_big", font="bahnschrift"),
|
||
_text_style(105, size=9.5, bold=True, color=INK, halign="center", name="colhdr"),
|
||
# 明细单元格 valign=top:文字从 band 顶部确定性排布(避免 middle 在窄 band 内偏上、
|
||
# 导致分隔线错位压到下一行文字)。配合 transform 的紧贴字形行高,线上下均约 1mm。
|
||
_text_style(106, size=8.5, color=INK, name="cell_l", line_spacing=1.0, valign="top"),
|
||
_text_style(107, size=8.5, color=INK, halign="center", name="cell_c", line_spacing=1.0, valign="top"),
|
||
_text_style(108, size=8.5, bold=True, color=INK, halign="right", name="cell_r", line_spacing=1.0, valign="top"),
|
||
_text_style(109, size=11.5, bold=True, color=INK, halign="center", name="total"),
|
||
# 合计数量:居中 + 加大两号(13.5) + 加粗
|
||
_text_style(113, size=13.5, bold=True, color=INK, halign="center", name="total_qty"),
|
||
# 右下角装箱日期(标题+值合并为单一文本「装箱日期:${pack_date}」):
|
||
# 因含中文,必须用 CJK 字体(simhei);保留 13.5pt/加粗/右对齐,字号字重与「合计」数量对齐。
|
||
_text_style(114, size=13.5, bold=True, color=INK, halign="right", name="pack_date_val",
|
||
font="simhei"),
|
||
# 产品名称/型号:居中 + 左右各 5% 边距(按列宽算,padding 单位 mm)
|
||
# product_name 列宽 30mm → 5%≈1.5mm;model 列宽 44mm → 5%≈2.2mm
|
||
_text_style(110, size=8.5, color=INK, halign="center", name="cell_name",
|
||
pad_l=1.5, pad_r=1.5, line_spacing=1.0, valign="top"),
|
||
_text_style(111, size=8.5, color=INK, halign="center", name="cell_model",
|
||
pad_l=2.2, pad_r=2.2, line_spacing=1.0, valign="top"),
|
||
{"id": 201, "type": "line", "name": "rule_faint",
|
||
"color": FAINT, "borderWidth": 0.3},
|
||
{"id": 202, "type": "line", "name": "rule_strong",
|
||
"color": RULE, "borderWidth": 2.5},
|
||
# 详情列表细分隔线:纯黑、细(weight 由元素 height 控制)。独立于 rule_faint(201),
|
||
# 避免影响信息条等其它区域的浅灰细线。
|
||
{"id": 203, "type": "line", "name": "rule_detail",
|
||
"color": "#000000", "borderWidth": 0.3},
|
||
]
|
||
|
||
|
||
def document_properties(paper: str = "a5") -> dict:
|
||
# 边距按 pt 给出(ReportBro 不换算 margin;pageFormat=A5 自动换算页面尺寸为 pt)。
|
||
# A4 承载模式:物理页面仍是 A4 纵向(210x297mm),但内容按 A5 横向(210x148mm)设计、
|
||
# 自然落在页面顶部 148mm 区域。配合打印机把 A5 纸横向装载进 A4 纸盒,发送 A4 指令即可在
|
||
# 横向 A5 纸上正常出纸,省去反复调整不同幅面纸盒卡扣的过程。
|
||
# 因 A5 横向与 A4 纵向宽度同为 210mm,元素 x/宽度完全不变,仅画面高度由 148→297mm。
|
||
if paper == "a4":
|
||
fmt, orient = "A4", "portrait"
|
||
else:
|
||
fmt, orient = "A5", "landscape"
|
||
return {
|
||
"pageFormat": fmt, "orientation": orient,
|
||
"marginLeft": mm(MARGIN_L), "marginRight": mm(MARGIN_R),
|
||
"marginTop": mm(10), "marginBottom": mm(10),
|
||
"headerDisplay": "never", "headerSize": 0,
|
||
"footerDisplay": "never", "footerSize": 0,
|
||
"patternLocale": "zh", "patternCurrencySymbol": "",
|
||
"patternNumberGroupSymbol": "",
|
||
}
|
||
|
||
|
||
def parameters() -> list[dict]:
|
||
"""模板参数。明细行预展开为 r0_*..r7_* 标量。"""
|
||
params = [
|
||
{"id": 1, "name": "paichan_no", "type": "string", "nullable": False},
|
||
{"id": 2, "name": "box_no", "type": "number", "nullable": False},
|
||
{"id": 3, "name": "pack_date", "type": "string", "nullable": False},
|
||
{"id": 4, "name": "order_no", "type": "string", "nullable": False},
|
||
{"id": 5, "name": "total_qty", "type": "number", "nullable": False},
|
||
{"id": 6, "name": "now", "type": "string", "nullable": False},
|
||
]
|
||
pid = 100
|
||
for i in range(8):
|
||
for field, _l, _x, _w, _a in COLS:
|
||
pid += 1
|
||
# 行字段统一用 string:空行为 ""、有数据行由 transform 转成字符串,
|
||
# 避免 number 参数收到空串报错。
|
||
params.append({"id": pid, "name": f"r{i}_{field}", "type": "string", "nullable": True})
|
||
# printIf 标记本行是否渲染
|
||
params.append({"id": pid + 1, "name": f"r{i}_show", "type": "string", "nullable": True})
|
||
return params
|
||
|
||
|
||
# ---------- 元素工厂 ----------
|
||
def _text(id_, x, y, w, h, content, *, style_id, print_if=""):
|
||
return {
|
||
"id": id_, "elementType": "text", "containerId": "0_content",
|
||
"x": x, "y": y, "width": w, "height": h,
|
||
"content": content, "styleId": style_id, "eval": False,
|
||
"printIf": print_if, "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):
|
||
"""线条元素。weight=线粗(mm),映射到元素 height(reportbro 线条粗细由 height 决定,
|
||
样式的 borderWidth 对线条无效)。0 = 细线(fpdf 默认最细)。"""
|
||
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 _dashed_line(nid_, x, y, w, *, style_id, weight=0.2, dash_mm=3.0, gap_mm=2.0):
|
||
"""用多段短实线模拟虚线(reportbro 线条元素本身不支持 dash)。
|
||
|
||
用于详情列表内的细分隔线:保持浅灰(style 201)配色,线粗由 weight 控制。
|
||
坐标/尺寸与 _line 一致,均为 pt;dash/gap 以 mm 设计后经 mm() 换算。
|
||
"""
|
||
els: list[dict] = []
|
||
dash = mm(dash_mm)
|
||
gap = mm(gap_mm)
|
||
pos = x
|
||
end = x + w
|
||
# 末尾余量 < 0.5pt 时不再画,避免极小残段
|
||
while pos < end - 0.5:
|
||
seg_w = min(dash, end - pos)
|
||
els.append(_line(nid_(), pos, y, seg_w, style_id=style_id, weight=weight))
|
||
pos += dash + gap
|
||
return els
|
||
|
||
|
||
def build_doc_elements(context: dict[str, Any]) -> list[dict]:
|
||
"""按数据动态生成所有文档元素(标题/信息条/列头/明细行/合计/页脚)。
|
||
|
||
所有坐标/尺寸以 mm 设计书写,输出时统一经 mm() 换算为 pt
|
||
(ReportBro 元素坐标按 pt 使用,详见文件头单位说明)。
|
||
明细行高度取自 context['row_heights'](单位 mm),逐行累加 y 坐标。
|
||
|
||
注意:元素 x/y 是相对内容容器的坐标(容器原点=左边距),故 x 从 0 起,
|
||
不要再加左边距(左边距由 reportbro 在渲染时统一偏移)。
|
||
"""
|
||
L = 0 # 元素相对内容容器左边,从 0 开始
|
||
CW = mm(CONTENT_W)
|
||
els: list[dict] = []
|
||
nid = [2000]
|
||
|
||
def nid_():
|
||
nid[0] += 1
|
||
return nid[0]
|
||
|
||
# 详情数据行各字段样式:默认居中(107);产品名称(110)/产品型号(111)居中+左右5%边距。
|
||
# 表头仍用 colhdr 样式 105,不受影响。
|
||
col_style = {"left": 107, "center": 107, "right": 107}
|
||
field_style = {"product_name": 110, "model": 111}
|
||
|
||
# ===== 标题(紧贴内容区顶部,容器原点已在 marginTop 处)=====
|
||
y = mm(1)
|
||
els.append(_text(nid_(), L, y, CW, mm(10), "装箱单", style_id=101))
|
||
# 标题双线:粗线(与详情列表表头下粗线同款 weight=0.45)在上,细线在下
|
||
els.append(_line(nid_(), L, y + mm(12), CW, style_id=202, weight=0.45))
|
||
els.append(_line(nid_(), L, y + mm(13.5), CW, style_id=202))
|
||
|
||
# ===== 信息条(单行三列横排:排产号 | 订单号 | 箱号)=====
|
||
# 三列不等宽:排产号/订单号值较长给宽列,箱号值短给窄列。
|
||
# 排产号、订单号用大号(112, 28.5pt);箱号用普通号(104, 19pt)。
|
||
# 装箱日期已移至底部「合计」行右侧(见合计区域),不在此处。
|
||
y = y + mm(15)
|
||
info = [
|
||
# (标签, 值模板, 值样式, 列起点mm, 列总宽mm, 标签宽mm)
|
||
# 标签宽收窄、值宽增大:标签仅 2~3 汉字占不满,内容较挤需让出空间。
|
||
("排产号", "${paichan_no}", 112, 0, 62, 18),
|
||
("订单号", "${order_no}", 112, 62, 94, 18),
|
||
("箱号", "${box_no}", 104, 156, 30, 12),
|
||
]
|
||
rh = mm(13) # 行高放高,容纳放大后的值字号
|
||
for lbl, val, val_style, col_x, col_w, lbl_w in info:
|
||
x = L + mm(col_x)
|
||
els.append(_text(nid_(), x, y, mm(lbl_w), rh, lbl, style_id=103))
|
||
els.append(_text(nid_(), x + mm(lbl_w), y, mm(col_w - lbl_w), rh, val, style_id=val_style))
|
||
y = y + rh + mm(2) + mm(2)
|
||
els.append(_line(nid_(), L, y, CW, style_id=201))
|
||
|
||
# ===== 列头 =====
|
||
y = y + mm(4)
|
||
hdr_h = mm(6)
|
||
for _f, label, xoff, w, _a in COLS:
|
||
els.append(_text(nid_(), L + mm(xoff), y, mm(w), hdr_h, label, style_id=105))
|
||
y = y + hdr_h + mm(2)
|
||
els.append(_line(nid_(), L, y, CW, style_id=202, weight=0.45)) # 表头下横线加粗
|
||
|
||
# ===== 明细行(逐行定位,高度自适应)=====
|
||
y = y + mm(2)
|
||
row_heights: list[float] = context.get("row_heights", [])
|
||
# 最后一条可见行下标:用其底部粗线框住明细区底部(替代原「合计上方」那条重复横线)。
|
||
# build_context 保证至少有 1 行(空箱会抛 EmptyBoxError),故 last_idx 恒 >= 0。
|
||
last_idx = max(
|
||
(i for i in range(len(row_heights)) if row_heights[i] > 0),
|
||
default=-1,
|
||
)
|
||
for i in range(8):
|
||
base = row_heights[i] if i < len(row_heights) else 0.0
|
||
# 占位空行(row_heights=0)的元素仍需合法高度(>0),否则 reportbro 报
|
||
# errorMsgInvalidSize;但它不渲染(print_if 为空),故用最小占位高,且
|
||
# 不参与纵向步进(y 不前进),避免把合计区往下挤。
|
||
h = mm(base) if base > 0 else mm(1.0)
|
||
show = f"${{r{i}_show}}" # printIf: r{i}_show 为 "1" 才渲染
|
||
for field, _label, xoff, w, align in COLS:
|
||
els.append(_text(
|
||
nid_(), L + mm(xoff), y, mm(w), h,
|
||
"${r%d_%s}" % (i, field),
|
||
style_id=field_style.get(field, col_style[align]), print_if=show,
|
||
))
|
||
if base > 0:
|
||
y += h
|
||
# 行分隔线:非最后一条可见行画细实线(style 203,weight=0.1);
|
||
# 最后一条可见行的底线为粗实线(style 202),框住明细区底部。
|
||
# 占位空行 base==0 不画线、也不步进;最后一条若恰为第 8 行(i=7) 也画粗线。
|
||
if i == last_idx:
|
||
els.append(_line(nid_(), L, y, CW, style_id=202, weight=0.45))
|
||
else:
|
||
els.append(_line(nid_(), L, y, CW, style_id=203, weight=0.1))
|
||
# 分隔线画在行底(y):transform 已把行高预留为「文本块高 + 1mm 底部留白」,
|
||
# 因此线到上行文字底约 1mm;行间再步进 1mm,下一行文字顶到线也约 1mm,
|
||
# 线与上下内容间距均匀,均约 1mm。
|
||
y += mm(1)
|
||
# 占位空行:y 不前进(base==0 时 h 仅用于满足 reportbro 尺寸校验)
|
||
|
||
# ===== 合计 =====
|
||
# 明细区底部框线已合并到最后一条记录的粗线(见上行循环),此处不再重复画横线;
|
||
# 保留原「合计上方横线」所占的纵向间距,使合计整体位置与改动前一致。
|
||
y = y + mm(4)
|
||
# 标签框宽度对齐量程列(x=86-106,宽20);数量框与数量列同宽(x=106-120)、居中。
|
||
range_x = COLS[3][2] # 量程列起点 86
|
||
range_w = COLS[3][3] # 量程列宽 20
|
||
qty_x = COLS[4][2] # 数量列起点 106
|
||
qty_w = COLS[4][3] # 数量列宽 14
|
||
els.append(_text(nid_(), L + mm(range_x), y, mm(range_w), mm(8), "合计", style_id=109))
|
||
els.append(_text(nid_(), L + mm(qty_x), y, mm(qty_w), mm(8), "${total_qty}", style_id=113))
|
||
# 装箱日期:与「合计」同一行(右下角)。标题与日期合并为单一文本「装箱日期:${pack_date}」,
|
||
# 覆盖位号列+备注列(130-186,宽56mm)整体右对齐;字体与「合计」数量同款(114: 13.5pt 加粗右对齐)。
|
||
# 日期年月日分隔符在 transform 中已改为斜杠(/)。
|
||
pack_x = COLS[5][2] # 位号列起点 130
|
||
# 宽度用「内容区右缘 − 起点」精确贴合,避免 mm(130)+mm(56) 分别取整进位导致
|
||
# 右缘超出内容区 1pt(reportbro 校验 elem.x+width > container.width 会报 errorMsgInvalidSize)。
|
||
pack_w = mm(CONTENT_W) - mm(pack_x) # = mm(186) - mm(130),精确落在内容区右缘
|
||
els.append(_text(nid_(), L + mm(pack_x), y, pack_w, mm(8),
|
||
"装箱日期:${pack_date}", style_id=114))
|
||
|
||
return els
|
||
|
||
|
||
def build_report_definition(context: dict[str, Any], paper: str = "a5") -> dict:
|
||
"""组装完整 report_definition(运行时调用,按数据动态布局)。
|
||
|
||
:param paper: "a5"(默认,A5 横向直打)或 "a4"(A4 纵向承载模式)。
|
||
"""
|
||
return {
|
||
"version": 6,
|
||
"documentProperties": document_properties(paper),
|
||
"parameters": parameters(),
|
||
"styles": styles(),
|
||
"docElements": build_doc_elements(context),
|
||
}
|