Files
WareShipManifest/reports/packing_list/transform.py
Misaka_Company df8f25f6da 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/
2026-08-04 08:55:09 +08:00

188 lines
7.8 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.
"""装箱单数据装配(纯逻辑,可单测)。
把 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.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):
"""查询结果为空 —— 该排产号 + 箱号不存在或无明细。"""
def _to_date(value: Any) -> str:
"""把 created_atdatetime/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