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

@@ -0,0 +1,301 @@
"""签收单数据装配(纯逻辑,可单测)。
把 run_query 返回的明细行list[dict])转换成 ReportBro 模板所需的数据结构。
方案 A多页分页下的分工
- 单头标量 + 明细数组 `items` 在此装配,交给 ReportBro 表格自动换页。
- 明细数组每个元素是一行seq/product_name/model/range_/qty/paichan_order/box_no
- 页脚家具(单据号、第 X/共 Y 页、签收栏、备注)由 stamp_footer() 在生成后
用 PyMuPDF 逐页盖印——reportbro-lib 文档页脚 band 不渲染自由文本,且其
page_number/page_count 未注册为可解析参数,无法在模板内直接输出页码。
字段超长保护:表格行高固定 8mm约容纳 2 行),超出 2 行的字段会被截断加「…」,
避免 ReportBro 表格固定行高下文字被裁切丢失可读性。
"""
from __future__ import annotations
import math
import os
import re
from datetime import date, datetime
from typing import Any
# 列宽(mm) 与字号(pt);截断估算需与 _build_template 的列布局对齐。
# 可用宽度 = 列宽 - 左右 paddingproduct_name 有 1.5+1.5 padding
_COL_METRICS = {
"product_name": (47.0, 9.0),
"model": (79.0, 9.0),
"range_": (40.0, 9.0),
"paichan_order": (48.0, 9.0),
}
PT = 2.834645669
_SLACK_MM = 1.0
_MAX_LINES = 2 # 8mm 行高约容纳 2 行
# 调试边框:与模板侧 _text_style 共用同一套取值FAINT 色 + 0.3mm 线宽),
# 当 config report.debug_border=true 时,给 fitz 盖印区(单据号/页码/备注/签字)也画占位框,
# 否则这些区域因非 ReportBro 元素而永远没有调试框。
_FAINT_RGB = (0.7098, 0.7098, 0.7098)
_DBW = 0.3 * PT # 调试边框线宽(mm→pt)
def _debug_border() -> bool:
"""读取 report.debug_border 开关,与模板侧 _text_style 共用同一配置。"""
try:
from core.settings import settings
return settings.report.debug_border
except Exception:
return False
_CJK_FONT_PATH = os.environ.get("REPORT_CJK_FONT", r"C:/Windows/Fonts/simhei.ttf")
_FP = None
def _fpdf_engine(font_size: float):
"""惰性创建复用了 simhei 字体的 fpdf2 引擎c_margin=0换行宽度公式与 reportbro 对齐)。"""
global _FP
if _FP is None:
try:
from fpdf import FPDF
pdf = FPDF(unit="pt", format="A4", orientation="P")
pdf.add_font("simhei", fname=_CJK_FONT_PATH)
pdf.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
def _wrap_lines(text: str, col_w_mm: float, font_size: float) -> int:
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))
_CTRL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
def _clean(value: Any) -> str:
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 _to_date(value: Any) -> str:
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)
def _fit(text: str, field: str) -> str:
"""超长字段截断到约 2 行并加「…」,防止固定行高下被裁切。"""
if not text or field not in _COL_METRICS:
return text
cw, fs = _COL_METRICS[field]
if _wrap_lines(text, cw, fs) <= _MAX_LINES:
return text
# 二分/逐字符截断到 2 行容量
max_pt = (_MAX_LINES * cw + _SLACK_MM) * PT
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if _text_width_pt(text[:mid], fs) <= max_pt:
lo = mid
else:
hi = mid - 1
return text[:max(1, lo - 1)].rstrip() + ""
class EmptyReceiptError(ValueError):
"""查询结果为空 —— 该序列号不存在或无明细。"""
def build_context(rows: list[dict[str, Any]]) -> dict[str, Any]:
"""把查询行装配成 ReportBro 模板数据。
:param rows: query.sql 的结果(每行一个 (箱, 产品) 明细,冗余主表头字段)。
:return: dict含单头标量参数 + 明细数组 items + 合计箱数/件数。
:raises EmptyReceiptError: rows 为空。
"""
if not rows:
raise EmptyReceiptError("签收单不存在或无可打印明细,请核对序列号。")
first = rows[0]
total_qty = sum(int(float(r.get("qty") or 0)) for r in rows)
boxes = {(r.get("paichan_no"), r.get("box_no")) for r in rows}
total_boxes = len(boxes)
ctx: dict[str, Any] = {
"receipt_no": _clean(first.get("receipt_no")),
"receipt_date": _to_date(first.get("receipt_date")).replace("-", "/"),
"seq": int(first.get("seq") or 0),
"shipper_name": _clean(first.get("shipper_name")),
"shipper_address": _clean(first.get("shipper_address")),
"shipper_phone": _clean(first.get("shipper_phone")),
"receiver_name": _clean(first.get("receiver_name")),
"receiver_address": _clean(first.get("receiver_address")),
"receiver_phone": _clean(first.get("receiver_phone")),
"customer_order_no": _clean(first.get("customer_order_no")),
"customer_total_no": _clean(first.get("customer_total_no")),
"transport_mode": _clean(first.get("transport_mode")),
"ship_date": _to_date(first.get("ship_date")).replace("-", "/"),
"sign_date": _to_date(first.get("sign_date")).replace("-", "/"),
"signed_by": _clean(first.get("signed_by")),
"maker": _clean(first.get("maker")),
"receipt_remark": _clean(first.get("receipt_remark")),
"total_qty": total_qty,
"total_boxes": total_boxes,
}
items = []
for i, r in enumerate(rows):
paichan_order = " ".join([_clean(r.get("paichan_no")), _clean(r.get("order_no"))]).strip()
items.append({
"seq": str(i + 1),
"product_name": _fit(_clean(r.get("product_name")), "product_name"),
"model": _fit(_clean(r.get("model")), "model"),
"range_": _fit(_clean(r.get("range_")), "range_"),
"qty": str(int(float(r.get("qty") or 0))),
"paichan_order": _fit(paichan_order, "paichan_order"),
"box_no": str(r.get("box_no")) if r.get("box_no") is not None else "",
})
ctx["items"] = items
return ctx
# ----------------------- 页脚盖印PyMuPDF -----------------------
def stamp_footer(pdf_path: str, ctx: dict[str, Any]) -> None:
"""在已生成的 PDF 上逐页盖印页脚家具:单据号(右) + 第 X/共 Y 页(居中)。
备注与签收栏**不再**盖在页脚 —— 它们由模板表格页脚带(合计行)下方预留的 18mm
空间承载,本函数在末页定位「合计」文字后,把备注 + 签收栏精确画在合计正下方,
实现「紧跟合计后面、不落入页码页脚区」的效果。
reportbro-lib 文档页脚 band 不渲染自由文本、page_number/page_count 未注册为可解析
参数,故单据号/页码仍在此用 fitz 盖印。
"""
import fitz # 延迟导入,非签收单报表不依赖
font_path = os.environ.get("REPORT_CJK_FONT", r"C:/Windows/Fonts/simhei.ttf")
if not os.path.exists(font_path):
# 字体缺失则跳过盖印,仅告警(不阻断主流程)
print(f"⚠️ 页脚盖印跳过:字体不存在 {font_path}")
return
doc = fitz.open(pdf_path)
page_count = doc.page_count
if page_count == 0:
doc.close()
return
font = fitz.Font(fontfile=font_path)
W, H = doc[0].rect.width, doc[0].rect.height # pt
M = 12 * PT # 左右页边距mm→pt
right_x = W - M
y_pg = H - 6 * PT # 页码/单据号基线:距底 6mm
db = _debug_border() # 与模板侧共用开关
def _db_rect(page, rect):
sh = page.new_shape()
sh.draw_rect(rect)
sh.finish(width=_DBW, color=_FAINT_RGB)
sh.commit()
def _db_vline(page, x, y0, y1):
sh = page.new_shape()
sh.draw_line(fitz.Point(x, y0), fitz.Point(x, y1))
sh.finish(width=_DBW, color=_FAINT_RGB)
sh.commit()
sign_fields = [
"收货人签字____________",
"盖章____________",
"签收日期____________",
f"制单:{ctx.get('maker', '')}",
]
remark = ctx.get("receipt_remark", "")
for i, page in enumerate(doc):
# 单据号(右对齐)
docno = ctx.get("receipt_no", "")
if docno:
pw = font.text_length(docno, fontsize=8)
page.insert_text((right_x - pw, y_pg), docno,
fontname="simhei", fontfile=font_path, fontsize=8,
color=(0.54, 0.54, 0.54))
# 第 X 页 / 共 Y 页(居中)
pgtext = f"{i + 1} 页 / 共 {page_count}"
pgw = font.text_length(pgtext, fontsize=8)
page.insert_text(((W - pgw) / 2, y_pg), pgtext,
fontname="simhei", fontfile=font_path, fontsize=8,
color=(0.54, 0.54, 0.54))
# 调试边框:单据号 + 页码各外包一个 FAINT 框(与模板元素一致)
if db:
_db_rect(page, fitz.Rect(right_x - pw - 1, y_pg - 2, right_x + 1, y_pg + 6))
_db_rect(page, fitz.Rect((W - pgw) / 2 - 1, y_pg - 2, (W + pgw) / 2 + 1, y_pg + 6))
# 末页:在「合计」正下方预留空间内画备注 + 签收栏(紧跟合计,不进页脚区)
last = doc[page_count - 1]
words = last.get_text("words")
he = [w for w in words if w[4] == "合计"]
if he:
y_he_bottom = max(w[3] for w in he) # 「合计」文字底边 yPDF 坐标,原点左下)
# 合计下方分隔线由表格底边线承担footer 带高度=FOOT_H底边线紧贴合计下方
# 此处不再额外画线,避免与表格底边线重叠成双线。
FS = 9 # 字号(pt)
# 行高:原「备注 +6mm」与「签收 +11mm」的基线间距为 5mm现改为两倍 = 10mm。
# 备注行 / 签收行各占一个 10mm 行高,文本在各自行内垂直居中。
ROW_H = 10 * PT
top1 = y_he_bottom + 6 * PT # 备注行顶:距合计底 6mm保持原视觉起点
y_remark = top1 + (ROW_H + FS) / 2 # 备注文本垂直居中于 [top1, top1+ROW_H]
# 备注(若有)
if remark:
last.insert_text((M, y_remark), f"备注:{remark}",
fontname="simhei", fontfile=font_path, fontsize=FS,
color=(0.1, 0.1, 0.1))
if db:
_db_rect(last, fitz.Rect(M, top1, W - M, top1 + ROW_H))
# 签收栏:紧随备注行之后,同样行高翻倍 + 垂直居中4 段等分整行
top2 = top1 + ROW_H
y_sign = top2 + (ROW_H + FS) / 2
step = (W - 2 * M) / 4
for j, fld in enumerate(sign_fields):
last.insert_text((M + j * step, y_sign), fld,
fontname="simhei", fontfile=font_path, fontsize=FS,
color=(0.1, 0.1, 0.1))
if db:
# 签收整行外包框 + 内部 3 条等分竖线(对齐 4 段文字)
_db_rect(last, fitz.Rect(M, top2, W - M, top2 + ROW_H))
for j in range(1, 4):
_db_vline(last, M + j * step, top2, top2 + ROW_H)
import tempfile
tmp_path = pdf_path + ".stamp.tmp"
doc.save(tmp_path)
doc.close()
os.replace(tmp_path, pdf_path)