Files
WareShipManifest/reports/packing_list/_build_template.py
Misaka_Company ee2fab3847 Add A4 carrier print mode (paper=a4) and document it in README + Swagger
- 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
2026-08-05 16:53:47 +08:00

347 lines
18 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.
"""装箱单模板:样式定义 + 文档元素布局。
设计理念:**无边框、单色、字体驱动**的列表式排版。
弃用 ReportBro 表格元素(行高不自适应、行不自动堆叠),改用纯文本元素
+ 细横线手工排版。每行明细的高度由 transform 按字段长度估算,
本模块据此计算各元素的精确 y 坐标 —— 因此 docElements 在运行时按数据动态生成。
坐标系mm。A4 纵向 210 x 297。
"""
from __future__ import annotations
from typing import Any
# ---- 单位说明 ----
# ReportBro 的页面尺寸由 pageFormat=A4 自动换算为 pt210mm→595pt
# 但 docElements 的 x/y/width/height 与 documentProperties 的边距都按 **pt** 直接使用
# (边距不换算)。因此本文件所有「设计尺寸」以 mm 书写便于阅读,输出前用 PT 换算为 pt。
PT = 2.834645669 # 1mm = 2.834645669pt72/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列宽 -10mm40→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.5mmmodel 列宽 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 不换算 marginpageFormat=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),映射到元素 heightreportbro 线条粗细由 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 一致,均为 ptdash/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 203weight=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) 分别取整进位导致
# 右缘超出内容区 1ptreportbro 校验 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),
}