Files
WareShipManifest/reports/sign_receipt/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

302 lines
12 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 模板所需的数据结构。
方案 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)