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:
Misaka_Company
2026-08-04 08:55:09 +08:00
parent 22b764ef19
commit df8f25f6da
11 changed files with 1503 additions and 70 deletions

View File

@@ -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.834645669pt72/25.4),与 _build_template 的 PT 保持一致
PT = 2.834645669
# ---- 明细行高度估算:用 reportbro 同款 fpdf2 引擎实测字形宽度算换行数方案B----
# 对照 R04398_box1.pdf 真实渲染逆推出的关键事实:
# * ReportBro 的有效换行宽度 = 整列宽 + ~1mm 容差padding 不参与换行宽度计算)。
# 实证model 行实测宽 127.5pt = 45mm = 列宽44 + 1mmweihao 行 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_MMpadding 不参与换行宽度计算。
_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,
}