- 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/
164 lines
5.8 KiB
Python
164 lines
5.8 KiB
Python
"""装箱单报表生成 CLI。
|
||
|
||
用法:
|
||
python run.py --report packing_list \
|
||
--param paichan_no=R04398 --param box_no=1 \
|
||
--output out/R04398_box1.pdf
|
||
|
||
每个报表位于 reports/<name>/ 目录下,约定包含:
|
||
query.sql 参数化查询
|
||
transform.py build_context(rows) -> dict(模板数据)
|
||
template.report ReportBro 模板 JSON
|
||
报表模块需导出 build_context。本入口负责:解析参数 → 查询 → 装配 → 渲染 PDF。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import importlib
|
||
import json
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
# 确保项目根目录在 sys.path(便于 python run.py 直接运行)
|
||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||
if str(PROJECT_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
|
||
from core.db import run_query # noqa: E402
|
||
from core.fonts import additional_fonts # noqa: E402
|
||
import yaml # noqa: E402
|
||
|
||
|
||
def parse_params(param_strs: list[str]) -> dict:
|
||
"""把 ['paichan_no=R04398', 'box_no=1'] 解析为 {'paichan_no':'R04398','box_no':1}。
|
||
|
||
数值型字符串自动转 int,便于 SQL 参数绑定。
|
||
"""
|
||
params = {}
|
||
for item in param_strs or []:
|
||
if "=" not in item:
|
||
raise ValueError(f"参数格式错误(应为 key=value):{item}")
|
||
key, value = item.split("=", 1)
|
||
key, value = key.strip(), value.strip()
|
||
# 尝试转 int(箱号等);失败保持字符串
|
||
try:
|
||
params[key] = int(value)
|
||
except ValueError:
|
||
params[key] = value
|
||
return params
|
||
|
||
|
||
def _load_config(report_dir: Path) -> dict | None:
|
||
"""读取报表目录下的 config.yaml(不存在返回 None)。"""
|
||
cfg_path = report_dir / "config.yaml"
|
||
if not cfg_path.exists():
|
||
return None
|
||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||
return yaml.safe_load(f)
|
||
|
||
|
||
def _required_params(report_dir: Path) -> set[str]:
|
||
"""从 config.yaml 收集标记为 required 的参数名(兼容各报表)。"""
|
||
cfg = _load_config(report_dir)
|
||
if not cfg or "params" not in cfg:
|
||
return set()
|
||
return {
|
||
name
|
||
for name, spec in cfg["params"].items()
|
||
if isinstance(spec, dict) and spec.get("required")
|
||
}
|
||
|
||
|
||
def _default_output(report_name: str, params: dict) -> str:
|
||
"""根据参数智能生成默认输出路径。"""
|
||
if "receipt_no" in params:
|
||
return f"out/{params['receipt_no']}.pdf"
|
||
if "paichan_no" in params:
|
||
bn = params.get("box_no", 0)
|
||
return f"out/{params['paichan_no']}_box{bn}.pdf"
|
||
return f"out/{report_name}.pdf"
|
||
|
||
|
||
def generate(report_name: str, params: dict, output: str) -> Path:
|
||
"""生成指定报表的 PDF。"""
|
||
from reportbro import Report # 延迟导入,CLI 报错时信息更清晰
|
||
|
||
report_dir = PROJECT_ROOT / "reports" / report_name
|
||
if not report_dir.is_dir():
|
||
raise FileNotFoundError(f"报表不存在:{report_dir}(检查 --report 名称)")
|
||
|
||
# 必需参数校验(从 config.yaml 读取,兼容各报表)
|
||
required = _required_params(report_dir)
|
||
missing = required - params.keys()
|
||
if missing:
|
||
raise ValueError(f"缺少必需参数:{', '.join(sorted(missing))}(用 --param key=value 提供)")
|
||
|
||
# 1. 查询
|
||
rows = run_query(report_dir / "query.sql", params)
|
||
|
||
# 2. 装配模板数据
|
||
transform = importlib.import_module(f"reports.{report_name}.transform")
|
||
context = transform.build_context(rows)
|
||
context["now"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
|
||
# 3. 渲染:模板元素按数据动态布局(明细行高度自适应)
|
||
layout = importlib.import_module(f"reports.{report_name}._build_template")
|
||
report_def = layout.build_report_definition(context)
|
||
report = Report(
|
||
report_definition=report_def,
|
||
data=context,
|
||
additional_fonts=additional_fonts(),
|
||
)
|
||
if report.errors:
|
||
raise RuntimeError(
|
||
f"模板渲染错误({report_name}):\n"
|
||
+ json.dumps(report.errors, ensure_ascii=False, indent=2, default=str)
|
||
)
|
||
|
||
out_path = Path(output)
|
||
if not out_path.is_absolute():
|
||
out_path = PROJECT_ROOT / out_path
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
report.generate_pdf(str(out_path))
|
||
|
||
# 报表级后处理:如报表模块提供 stamp_footer(签收单用 PyMuPDF 盖印页脚家具),
|
||
# 则在已生成 PDF 上逐页盖印单据号/页码/签收栏等。其它报表无此函数则跳过。
|
||
stamp_fn = getattr(transform, "stamp_footer", None)
|
||
if stamp_fn is not None:
|
||
stamp_fn(str(out_path), context)
|
||
|
||
return out_path
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description="生成装箱单 PDF")
|
||
parser.add_argument("--report", default="packing_list", help="报表名(默认 packing_list)")
|
||
parser.add_argument(
|
||
"--param", action="append", default=[],
|
||
help="报表参数,格式 key=value(可多次指定)",
|
||
)
|
||
parser.add_argument("--output", "-o", help="输出 PDF 路径(默认 out/<paichan_no>_box<box_no>.pdf)")
|
||
args = parser.parse_args(argv)
|
||
|
||
try:
|
||
params = parse_params(args.param)
|
||
output = args.output or _default_output(args.report, params)
|
||
|
||
out_path = generate(args.report, params, output)
|
||
print(f"✅ 已生成:{out_path}")
|
||
print(f" 报表:{args.report} 参数:{params}")
|
||
return 0
|
||
except FileNotFoundError as e:
|
||
print(f"❌ {e}", file=sys.stderr)
|
||
except ValueError as e:
|
||
print(f"❌ {e}", file=sys.stderr)
|
||
except Exception as e: # noqa: BLE001
|
||
# 找不到箱等业务错误在此给出清晰提示
|
||
print(f"❌ 生成失败:{e}", file=sys.stderr)
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|