- 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/
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""签收单数据装配(纯逻辑,可单测)。
|
||
|
||
把 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 的列布局对齐。
|
||
# 可用宽度 = 列宽 - 左右 padding(product_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) # 「合计」文字底边 y(PDF 坐标,原点左下)
|
||
# 合计下方分隔线由表格底边线承担(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)
|