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:
@@ -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