"""签收单数据装配(纯逻辑,可单测)。 把 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)