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/
This commit is contained in:
@@ -44,10 +44,10 @@ COLS = [
|
||||
("seq", "序号", 0, 12, "center"),
|
||||
("product_name", "产品名称", 12, 30, "left"),
|
||||
("model", "产品型号", 42, 44, "left"),
|
||||
("range_", "量程", 86, 20, "left"),
|
||||
("qty", "数量", 106, 14, "right"),
|
||||
("weihao", "位号", 120, 26, "left"),
|
||||
("remark", "备注", 146, 40, "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)以抵容量程加宽
|
||||
]
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ def _debug_border() -> bool:
|
||||
|
||||
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):
|
||||
debug_border=None, line_spacing=1.25):
|
||||
"""文本样式。padding 默认 0 让坐标精确可控;可经 pad_l/pad_r 加左右内边距(mm)。
|
||||
|
||||
debug_border 由配置文件 report.debug_border 决定(True 时给元素加边框便于核对占位)。
|
||||
@@ -75,7 +75,7 @@ def _text_style(id_, *, size=10, bold=False, color=INK, halign="left",
|
||||
"underline": False, "strikethrough": False,
|
||||
"horizontalAlignment": halign, "verticalAlignment": valign,
|
||||
"textColor": color, "backgroundColor": "",
|
||||
"lineSpacing": 1.25,
|
||||
"lineSpacing": line_spacing,
|
||||
"paddingLeft": pad_l, "paddingTop": 0, "paddingRight": pad_r, "paddingBottom": 0,
|
||||
"borderColor": FAINT,
|
||||
"borderWidth": 0.3 if db else 0,
|
||||
@@ -90,27 +90,37 @@ 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, name="info_lbl"),
|
||||
_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, name="info_val", font="bahnschrift"),
|
||||
_text_style(112, size=28.5, bold=True, color=INK, name="info_val_big", font="bahnschrift"),
|
||||
_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"),
|
||||
_text_style(106, size=8.5, color=INK, name="cell_l"),
|
||||
_text_style(107, size=8.5, color=INK, halign="center", name="cell_c"),
|
||||
_text_style(108, size=8.5, bold=True, color=INK, halign="right", name="cell_r"),
|
||||
# 明细单元格 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),
|
||||
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),
|
||||
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},
|
||||
]
|
||||
|
||||
|
||||
@@ -225,23 +235,24 @@ def build_doc_elements(context: dict[str, Any]) -> list[dict]:
|
||||
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))
|
||||
|
||||
# ===== 信息条(2×2 网格,标签灰 + 值用 Bahnschrift)=====
|
||||
# 行1:排产号 | 箱号;行2:订单号 | 装箱日期(订单号在排产号正下方)
|
||||
# 排产号、订单号用大号(112, 28.5pt);箱号、装箱日期用普通号(104, 19pt)。
|
||||
# ===== 信息条(单行三列横排:排产号 | 订单号 | 箱号)=====
|
||||
# 三列不等宽:排产号/订单号值较长给宽列,箱号值短给窄列。
|
||||
# 排产号、订单号用大号(112, 28.5pt);箱号用普通号(104, 19pt)。
|
||||
# 装箱日期已移至底部「合计」行右侧(见合计区域),不在此处。
|
||||
y = y + mm(15)
|
||||
info = [("排产号", "${paichan_no}", 112), ("箱号", "${box_no}", 104),
|
||||
("订单号", "${order_no}", 112), ("装箱日期", "${pack_date}", 104)]
|
||||
col_w = CW / 2
|
||||
lbl_w = mm(28)
|
||||
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 in info:
|
||||
row = info.index((lbl, val, val_style)) // 2
|
||||
col = info.index((lbl, val, val_style)) % 2
|
||||
x = L + col * col_w
|
||||
yy = y + row * (rh + mm(2))
|
||||
els.append(_text(nid_(), x, yy, lbl_w, rh, lbl, style_id=103))
|
||||
els.append(_text(nid_(), x + lbl_w, yy, col_w - lbl_w, rh, val, style_id=val_style))
|
||||
y = y + 2 * (rh + mm(2)) + mm(2)
|
||||
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))
|
||||
|
||||
# ===== 列头 =====
|
||||
@@ -262,7 +273,11 @@ def build_doc_elements(context: dict[str, Any]) -> list[dict]:
|
||||
default=-1,
|
||||
)
|
||||
for i in range(8):
|
||||
h = mm(row_heights[i]) if i < len(row_heights) else 0
|
||||
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(
|
||||
@@ -270,18 +285,20 @@ def build_doc_elements(context: dict[str, Any]) -> list[dict]:
|
||||
"${r%d_%s}" % (i, field),
|
||||
style_id=field_style.get(field, col_style[align]), print_if=show,
|
||||
))
|
||||
y += h
|
||||
# 行分隔线:非最后一条可见行画虚线(reportbro 线条元素不支持 dash,
|
||||
# 用短实线段模拟),并略粗一点点;最后一条可见行的底线为粗实线,框住明细区底部。
|
||||
# 去掉原 i<7 限制——占位空行 row_heights=0 本就不画线;且最后一条若恰为
|
||||
# 第 8 行(i=7) 时也需能画粗线。
|
||||
if row_heights[i] > 0:
|
||||
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 + mm(1), CW, style_id=202, weight=0.45))
|
||||
els.append(_line(nid_(), L, y, CW, style_id=202, weight=0.45))
|
||||
else:
|
||||
els.extend(_dashed_line(nid_, L, y + mm(1), CW, style_id=201,
|
||||
weight=0.2, dash_mm=3.0, gap_mm=2.0))
|
||||
y += mm(2)
|
||||
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 尺寸校验)
|
||||
|
||||
# ===== 合计 =====
|
||||
# 明细区底部框线已合并到最后一条记录的粗线(见上行循环),此处不再重复画横线;
|
||||
@@ -294,6 +311,15 @@ def build_doc_elements(context: dict[str, Any]) -> list[dict]:
|
||||
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
|
||||
|
||||
|
||||
@@ -11,11 +11,82 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
MAX_ROWS = 8 # 模板预留的最大明细行数
|
||||
|
||||
# 1mm = 2.834645669pt(72/25.4),与 _build_template 的 PT 保持一致
|
||||
PT = 2.834645669
|
||||
|
||||
# ---- 明细行高度估算:用 reportbro 同款 fpdf2 引擎实测字形宽度算换行数(方案B)----
|
||||
# 对照 R04398_box1.pdf 真实渲染逆推出的关键事实:
|
||||
# * ReportBro 的有效换行宽度 = 整列宽 + ~1mm 容差(padding 不参与换行宽度计算)。
|
||||
# 实证:model 行实测宽 127.5pt = 45mm = 列宽44 + 1mm;weihao 行 76.5pt = 27mm = 列宽26 + 1mm。
|
||||
# * 行被贪心填满到该宽度,故行数 = ceil( 字形总宽 / (列宽 + 1mm) )。
|
||||
# * 用 fpdf2 的 get_string_width 取真实轮廓宽度(与 reportbro 渲染同引擎、同字体 simhei),
|
||||
# 替代原先 fitz 测宽(fitz 比 reportbro 宽约 4~6%,会高估行数 → band 虚高 → 下边距异常)。
|
||||
# * ceil 是行数的下界估计:绝不低估(→ 绝不裁切丢失数据);仅在极边界可能多估 1 行(仅留白,安全)。
|
||||
_CJK_FONT_PATH = os.environ.get("REPORT_CJK_FONT", r"C:/Windows/Fonts/simhei.ttf")
|
||||
_SLACK_MM = 1.0 # ReportBro 允许行略微超出列宽约 1mm 不裁切
|
||||
|
||||
_FP = None # 惰性创建的 fpdf2 引擎单例(同款 simhei 字体,c_margin=0)
|
||||
|
||||
def _fpdf_engine(font_size: float):
|
||||
"""返回复用了 simhei 字体的 fpdf2 引擎;字体不可用则返回 None(走字符数回退)。"""
|
||||
global _FP
|
||||
if _FP is None:
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
pdf = FPDF(unit="pt", format="A5", orientation="L")
|
||||
pdf.add_font("simhei", fname=_CJK_FONT_PATH)
|
||||
pdf.c_margin = 0 # 与 reportbro 一致:强制 c_margin=0,使换行宽度公式对齐
|
||||
_FP = pdf
|
||||
except Exception:
|
||||
_FP = None
|
||||
return None
|
||||
_FP.set_font("simhei", size=font_size)
|
||||
return _FP
|
||||
|
||||
|
||||
def _text_width_pt(text: str, font_size: float) -> float:
|
||||
"""字符串真实轮廓宽度(点)。引擎不可用时回退到字符数粗估。"""
|
||||
eng = _fpdf_engine(font_size)
|
||||
if eng is not None:
|
||||
return eng.get_string_width(text)
|
||||
cjk = sum(1 for c in text if ord(c) > 0x2E80)
|
||||
asc = len(text) - cjk
|
||||
return (cjk * 3.0 + asc * 1.6) / PT # mm → pt
|
||||
|
||||
|
||||
# 列宽(mm) 与左右 padding 合计(mm) 与字号(pt);须与 _build_template.COLS 对齐。
|
||||
# 注意:换行宽度用「整列宽 + 1mm 容差」(见 _SLACK_MM),padding 不参与换行宽度计算。
|
||||
_COL_METRICS = {
|
||||
"product_name": (30, 0.0, 8.5),
|
||||
"model": (44, 0.0, 8.5),
|
||||
"range_": (30, 0.0, 8.5),
|
||||
"weihao": (26, 0.0, 8.5),
|
||||
"remark": (30, 0.0, 8.5),
|
||||
}
|
||||
_LINE_MM = 2.82 # 单行实际行高(8.5pt @ lineSpacing=1.0,实测字形 2.82mm/行)
|
||||
|
||||
|
||||
def _wrap_lines(text: str, col_w_mm: float, pad_mm: float, font_size: float) -> int:
|
||||
"""估算某字段在给定列宽下换行后的行数(与 reportbro 实际渲染行数一致)。
|
||||
|
||||
行数 = ceil( 字形总宽 / (列宽 + 1mm 容差) )。
|
||||
pad_mm 当前不参与计算(reportbro 实测 padding 不进入换行宽度),保留形参以兼容调用点。
|
||||
"""
|
||||
if not text:
|
||||
return 1
|
||||
max_pt = (col_w_mm + _SLACK_MM) * PT
|
||||
w = _text_width_pt(text, font_size)
|
||||
return max(1, math.ceil(w / max_pt))
|
||||
|
||||
|
||||
|
||||
class EmptyBoxError(ValueError):
|
||||
"""查询结果为空 —— 该排产号 + 箱号不存在或无明细。"""
|
||||
@@ -37,37 +108,37 @@ def _to_date(value: Any) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
_CTRL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
"""None → 空串;其余去除首尾空白。位号/型号等空值统一留白。"""
|
||||
"""None → 空串;其余去除首尾空白与控制字符。
|
||||
|
||||
含换行/制表符的脏数据(库里偶发)会让 ReportBro 在渲染时找不到 simhei
|
||||
字形而抛 ``Character "\\n" is not included`` 错误。这里把 \\r\\n\\t 换成空格、
|
||||
剔除其余 ASCII 控制字符,保证模板渲染稳定。
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
s = str(value)
|
||||
s = s.replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
||||
s = _CTRL_RE.sub("", s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _estimate_row_height(row: dict[str, Any]) -> float:
|
||||
"""估算单行明细的高度(mm)。
|
||||
|
||||
长字段(产品名称/型号/位号/备注)会换行,行高取决于换行后最多的行数。
|
||||
按各列宽度与字符宽估算行数,取最大值,每文本行约 4.2mm。
|
||||
列宽与模板 COLS 保持一致(A5 横向)。
|
||||
各字段按真实字体宽度算换行数(见 _wrap_lines),取最大行数作为本行高度。
|
||||
行高 = 行数 × 单行实际行高(2.82mm) + 1.0mm 底部留白。
|
||||
配合 _build_template 中分隔线「画在行底、行距 1mm」的布局,
|
||||
分隔线到上行文本底约 1mm、到下行文本顶约 1mm,间距均匀。
|
||||
字体/列宽/padding 均与 _build_template 对齐,避免漂移。
|
||||
"""
|
||||
col_caps = { # field: (宽度mm, 约每行字符数)
|
||||
"product_name": (30, 18),
|
||||
"model": (40, 24),
|
||||
"weihao": (24, 16),
|
||||
"remark": (24, 16),
|
||||
"range_": (18, 12),
|
||||
}
|
||||
max_lines = 1
|
||||
for field, (_w, cap) in col_caps.items():
|
||||
text = _clean(row.get(field))
|
||||
if not text:
|
||||
continue
|
||||
# 按容量向上取整;长串(含分隔符)按容量分段
|
||||
lines = max(1, -(-len(text) // cap)) # ceil division
|
||||
max_lines = max(max_lines, lines)
|
||||
# 基础行高 + 每文本行高度
|
||||
return 3.0 + max_lines * 4.2
|
||||
for field, (cw, pad, fs) in _COL_METRICS.items():
|
||||
max_lines = max(max_lines, _wrap_lines(_clean(row.get(field)), cw, pad, fs))
|
||||
return 1.0 + max_lines * _LINE_MM
|
||||
|
||||
|
||||
def build_context(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
@@ -87,7 +158,7 @@ def build_context(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
ctx: dict[str, Any] = {
|
||||
"paichan_no": _clean(first.get("paichan_no")),
|
||||
"box_no": int(first.get("box_no") or 0),
|
||||
"pack_date": _to_date(first.get("created_at")),
|
||||
"pack_date": _to_date(first.get("created_at")).replace("-", "/"),
|
||||
"order_no": _clean(first.get("order_no")),
|
||||
"total_qty": total_qty,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user