- 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/
188 lines
7.8 KiB
Python
188 lines
7.8 KiB
Python
"""装箱单数据装配(纯逻辑,可单测)。
|
||
|
||
把 run_query 返回的行(list[dict])转换成 ReportBro 模板所需的数据结构。
|
||
|
||
排版策略:弃用 ReportBro 表格元素(其行高不自适应、行不自动堆叠),
|
||
改为「Python 端逐行计算高度 + 预展开为标量参数」,模板用纯文本元素
|
||
在精确坐标渲染。本模块负责:
|
||
- 计算 box 头信息、合计件数
|
||
- 估算每行明细的高度(依据型号/位号等长字段的字符数),用于模板定位
|
||
模板与展示逻辑严格分离:合计/日期/行高在此算好,模板只渲染。
|
||
"""
|
||
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):
|
||
"""查询结果为空 —— 该排产号 + 箱号不存在或无明细。"""
|
||
|
||
|
||
def _to_date(value: Any) -> str:
|
||
"""把 created_at(datetime/iso str)格式化为 YYYY-MM-DD;失败则原样返回。"""
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, datetime):
|
||
return value.strftime("%Y-%m-%d")
|
||
if isinstance(value, date):
|
||
return value.strftime("%Y-%m-%d")
|
||
if isinstance(value, str):
|
||
try:
|
||
return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d")
|
||
except ValueError:
|
||
return value[:10]
|
||
return str(value)
|
||
|
||
|
||
_CTRL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
|
||
|
||
|
||
def _clean(value: Any) -> str:
|
||
"""None → 空串;其余去除首尾空白与控制字符。
|
||
|
||
含换行/制表符的脏数据(库里偶发)会让 ReportBro 在渲染时找不到 simhei
|
||
字形而抛 ``Character "\\n" is not included`` 错误。这里把 \\r\\n\\t 换成空格、
|
||
剔除其余 ASCII 控制字符,保证模板渲染稳定。
|
||
"""
|
||
if value is None:
|
||
return ""
|
||
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)。
|
||
|
||
各字段按真实字体宽度算换行数(见 _wrap_lines),取最大行数作为本行高度。
|
||
行高 = 行数 × 单行实际行高(2.82mm) + 1.0mm 底部留白。
|
||
配合 _build_template 中分隔线「画在行底、行距 1mm」的布局,
|
||
分隔线到上行文本底约 1mm、到下行文本顶约 1mm,间距均匀。
|
||
字体/列宽/padding 均与 _build_template 对齐,避免漂移。
|
||
"""
|
||
max_lines = 1
|
||
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]:
|
||
"""把查询行装配成 ReportBro 模板数据。
|
||
|
||
:param rows: query.sql 的结果。
|
||
:return: dict,含箱头标量参数 + 预展开的明细行标量参数(r0_*..r7_*)+
|
||
每行 y 坐标(row_y0..)+ 合计。
|
||
:raises EmptyBoxError: rows 为空。
|
||
"""
|
||
if not rows:
|
||
raise EmptyBoxError("找不到该箱:排产号与箱号无装箱明细,请核对参数。")
|
||
|
||
first = rows[0]
|
||
total_qty = sum(int(r.get("box_qty") or 0) for r in rows)
|
||
|
||
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")).replace("-", "/"),
|
||
"order_no": _clean(first.get("order_no")),
|
||
"total_qty": total_qty,
|
||
}
|
||
|
||
# 预展开明细行:r{i}_{field},并在模板列出的行范围内填值(超出则留空)。
|
||
# 每行的 y 偏移由模板侧按预估行高累加;此处也输出每行高度供模板使用。
|
||
row_heights = []
|
||
for i in range(MAX_ROWS):
|
||
if i < len(rows):
|
||
r = rows[i]
|
||
row_heights.append(_estimate_row_height(r))
|
||
else:
|
||
row_heights.append(0.0)
|
||
src = rows[i] if i < len(rows) else {}
|
||
ctx[f"r{i}_seq"] = str(i + 1) if i < len(rows) else ""
|
||
ctx[f"r{i}_product_name"] = _clean(src.get("product_name"))
|
||
ctx[f"r{i}_model"] = _clean(src.get("model"))
|
||
ctx[f"r{i}_range_"] = _clean(src.get("range_"))
|
||
ctx[f"r{i}_qty"] = str(int(src.get("box_qty") or 0)) if i < len(rows) else ""
|
||
ctx[f"r{i}_weihao"] = _clean(src.get("weihao"))
|
||
ctx[f"r{i}_remark"] = _clean(src.get("remark"))
|
||
# 该行是否有数据(模板用 printIf 控制空行不渲染)
|
||
ctx[f"r{i}_show"] = "1" if i < len(rows) else ""
|
||
|
||
ctx["row_heights"] = row_heights
|
||
return ctx
|