diff --git a/README.md b/README.md index 73acbd1..e704a00 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,52 @@ WareShipManifest/ - 数据处理与展示分离:排序在 SQL、合计与日期在 transform、模板只渲染。 - 生成的 PDF 可用 `pymupdf`(`fitz`)渲染 PNG 做肉眼核对(验证用,非运行时依赖)。 +## 打印服务(HTTP 接口) + +系统内置一个 FastAPI 打印服务(`serve.py` / `core/print_api.py`),把装箱单 / 签收单直接发到物理打印机(后端为 SumatraPDF)。 + +### 启动 + +```bash +.venv/Scripts/python.exe serve.py +# 文档:http://:8000/api/docs 健康检查:http://:8000/api/health +``` + +### 调用约定 + +`POST /api/print`(需 Bearer 鉴权,token 见 `config/settings.yaml` 的 `api.token`): + +| 字段 | 必填 | 说明 | +|---|---|---| +| `report_type` | 是 | `packing_list` / `sign_receipt` | +| `params` | 是 | 报表参数:装箱单 `{paichan_no, box_no}`,签收单 `{receipt_no}` | +| `printer` | 否 | 打印机名;缺省用 `printers.default` | +| `dry_run` | 否 | `true` 只生成 PDF 不出纸;默认 `false`(真出纸) | +| `paper` | 否 | 见下方「A4 承载模式」 | + +### A4 承载模式(装箱单专用) + +现场把 **A5 纸横向装入 A4 纸盒**可避免频繁调卡扣,但横向装载会让 A5 内容在页面上颠倒。 +解决方案:传 `"paper": "a4"`,接口会把 A5 内容渲染到 **A4 纵向页面顶部 148mm 区域**,并按 A4 纵向发打印指令—— +横向装载的 A5 纸自动承接页面顶部内容,出纸正立、完整。该模式已用真机实测验证。 + +**启用条件(必须同时满足):** +1. 请求带 `"paper": "a4"`(仅对 `packing_list` 生效,`sign_receipt` 忽略该字段); +2. 物理上 A4 纸盒里的 **A5 纸为横向装载**。 + +标准 A5 托盘(竖向装载)则**不要传 `paper`**,退回 A5 横向直打。 + +```bash +curl -X POST http://:8000/api/print \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"report_type":"packing_list","params":{"paichan_no":"R07425","box_no":1},"paper":"a4","dry_run":false}' +``` + +> 承载模式生成的 PDF 文件名带 `_a4` 后缀(`..._a4.pdf`),以别于 A5 直打文件。 +> 集成时请直接读取响应里的 `pdf` 字段,勿硬编码路径。 + ## 路线图 -- [ ] 后期对接真实打印机(PDF 方案可直接复用)。 +- [x] 对接真实打印机(FastAPI + SumatraPDF 打印服务,支持 A4 承载模式)。 - [ ] 发货信息单(地址 / 收件人 / 总件数 / 物料编码)—— 与装箱单拆分,另出报表。 diff --git a/core/print_api.py b/core/print_api.py index b40c53f..d49e25b 100644 --- a/core/print_api.py +++ b/core/print_api.py @@ -46,6 +46,9 @@ app = FastAPI( "when `printer` is omitted.\n" "* Paper size and orientation are chosen automatically per report type: " "packing_list → A5 landscape, sign_receipt → A4 portrait.\n" + "* Packing list supports an A4 carrier mode via `paper=\"a4\"`: the A5 content is " + "rendered on an A4 portrait page (top region) and printed as A4, so it comes out " + "correctly on A5 paper loaded horizontally in an A4 tray.\n" "* A JSON line is appended to `logs/print.log` for every request." ), version="1.0.0", @@ -83,6 +86,17 @@ class PrintRequest(BaseModel): description="Target printer name. Falls back to `printers.default` in settings.yaml when omitted.", examples=["Canon G1030 series (网络 USB1)"], ) + paper: Optional[Literal["a4"]] = Field( + None, + description=( + "Paper carrier mode. Omit (null) for the default A5 landscape packing list. " + "Set to \"a4\" to render the packing list on an A4 portrait page with the A5 " + "content placed in the top region, so it prints correctly on A5 paper loaded " + "horizontally in an A4 tray (saves re-adjusting the paper clamps). Only " + "applies to `packing_list`; ignored for other report types." + ), + examples=["a4"], + ) dry_run: bool = Field( False, description="When true, generate the PDF and build the print command but do NOT send it to the printer.", @@ -140,8 +154,9 @@ def _log(result: dict) -> None: f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n") -@api_router.post( - "/print", +@app.post( + "/api/print", + tags=["print"], summary="Generate and print a shipping document", description=( "Generate the requested document as a PDF using the existing report " @@ -154,7 +169,20 @@ def _log(result: dict) -> None: "(skipped when `dry_run=true`).\n" "4. Append a JSON line to `logs/print.log`.\n" "5. Return the job result.\n\n" - "Set `dry_run=true` to generate the PDF without consuming paper." + "Set `dry_run=true` to generate the PDF without consuming paper.\n\n" + "**A4 Carrier Mode (packing_list only)**\n" + "To avoid re-adjusting the printer's paper clamps, A5 paper can be loaded " + "**horizontally** in an A4 tray. But horizontal loading would otherwise print " + "the A5 content upside-down. This mode fixes it: pass `paper=\"a4\"` and the " + "A5 content is rendered into the **top 148mm region of an A4 portrait page**, " + "then printed as A4 — the horizontally loaded A5 sheet receives the top region " + "and comes out upright and complete. Verified on a real printer.\n\n" + "Both conditions are required:\n" + "1. Request carries `paper=\"a4\"` (only affects `packing_list`; ignored for others).\n" + "2. Physically, the A5 paper is loaded **horizontally** in the A4 tray.\n\n" + "For a standard A5 tray (vertical loading), omit `paper` to fall back to normal " + "A5 landscape direct print. The A4-carrier PDF is named with an `_a4` suffix; " + "always read the returned `pdf` field rather than hard-coding the path." ), response_description="Print job result, including the generated PDF path.", response_model=PrintResponse, @@ -184,6 +212,7 @@ def print_endpoint(req: PrintRequest): params=req.params, printer=req.printer, dry_run=req.dry_run, + paper=req.paper, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/core/print_document.py b/core/print_document.py index cd3397f..1591222 100644 --- a/core/print_document.py +++ b/core/print_document.py @@ -26,6 +26,7 @@ def print_document( params: dict, printer: str | None = None, dry_run: bool = False, + paper: str | None = None, ) -> dict: """生成报表 PDF 并打印。 @@ -33,6 +34,8 @@ def print_document( :param params: 报表参数(如 paichan_no/box_no 或 receipt_no) :param printer: 指定打印机名;为 None 时用 settings.printers.default :param dry_run: True 时只构造打印命令不真正执行(用于验证链路) + :param paper: 可选纸张承载模式。packing_list 支持 "a4" —— 渲染到 A4 纵向页面顶部, + 配合横向装载的 A5 纸正常出纸;其余报表/取值忽略。 :return: 结构化结果(含 pdf 路径、打印命令、ok 状态) """ if report_type not in VALID_REPORTS: @@ -40,13 +43,20 @@ def print_document( # 1. 生成 PDF(run.generate 内部已做 required 参数校验与 DB 查询) output = run._default_output(report_type, params) - out_path = run.generate(report_type, params, output) + # A4 承载模式的 PDF 用独立文件名,避免覆盖 A5 直打结果 + if report_type == "packing_list" and paper == "a4": + output = output[:-4] + "_a4.pdf" if output.lower().endswith(".pdf") else output + "_a4" + out_path = run.generate(report_type, params, output, paper=paper) # 2. 选打印机与纸张(装箱单 A5 横向 / 签收单 A4 纵向) target_printer = printer or settings.printers.default - paper_settings = settings.print.settings_by_report.get( - report_type, settings.print.default_settings - ) + if report_type == "packing_list" and paper == "a4": + # A4 承载:物理发送 A4 纵向指令,横向装载的 A5 纸自动承接页面顶部 148mm 内容区 + paper_settings = "paper=A4,orientation=portrait,color=bw" + else: + paper_settings = settings.print.settings_by_report.get( + report_type, settings.print.default_settings + ) # 3. 打印 result = printing.print_pdf( diff --git a/reports/packing_list/_build_template.py b/reports/packing_list/_build_template.py index 3b40b53..307b84d 100644 --- a/reports/packing_list/_build_template.py +++ b/reports/packing_list/_build_template.py @@ -124,10 +124,18 @@ def styles() -> list[dict]: ] -def document_properties() -> dict: +def document_properties(paper: str = "a5") -> dict: # 边距按 pt 给出(ReportBro 不换算 margin;pageFormat=A5 自动换算页面尺寸为 pt)。 + # A4 承载模式:物理页面仍是 A4 纵向(210x297mm),但内容按 A5 横向(210x148mm)设计、 + # 自然落在页面顶部 148mm 区域。配合打印机把 A5 纸横向装载进 A4 纸盒,发送 A4 指令即可在 + # 横向 A5 纸上正常出纸,省去反复调整不同幅面纸盒卡扣的过程。 + # 因 A5 横向与 A4 纵向宽度同为 210mm,元素 x/宽度完全不变,仅画面高度由 148→297mm。 + if paper == "a4": + fmt, orient = "A4", "portrait" + else: + fmt, orient = "A5", "landscape" return { - "pageFormat": "A5", "orientation": "landscape", + "pageFormat": fmt, "orientation": orient, "marginLeft": mm(MARGIN_L), "marginRight": mm(MARGIN_R), "marginTop": mm(10), "marginBottom": mm(10), "headerDisplay": "never", "headerSize": 0, @@ -324,11 +332,14 @@ def build_doc_elements(context: dict[str, Any]) -> list[dict]: return els -def build_report_definition(context: dict[str, Any]) -> dict: - """组装完整 report_definition(运行时调用,按数据动态布局)。""" +def build_report_definition(context: dict[str, Any], paper: str = "a5") -> dict: + """组装完整 report_definition(运行时调用,按数据动态布局)。 + + :param paper: "a5"(默认,A5 横向直打)或 "a4"(A4 纵向承载模式)。 + """ return { "version": 6, - "documentProperties": document_properties(), + "documentProperties": document_properties(paper), "parameters": parameters(), "styles": styles(), "docElements": build_doc_elements(context), diff --git a/run.py b/run.py index b031d04..db48535 100644 --- a/run.py +++ b/run.py @@ -15,6 +15,7 @@ from __future__ import annotations import argparse import importlib +import inspect import json import sys from datetime import datetime @@ -80,8 +81,12 @@ def _default_output(report_name: str, params: dict) -> str: return f"out/{report_name}.pdf" -def generate(report_name: str, params: dict, output: str) -> Path: - """生成指定报表的 PDF。""" +def generate(report_name: str, params: dict, output: str, paper: str | None = None) -> Path: + """生成指定报表的 PDF。 + + :param paper: 可选纸张承载模式(如 "a4")。透传给模板的 build_report_definition; + 模板不接收该参数时(如签收单)自动忽略,不影响原有渲染。 + """ from reportbro import Report # 延迟导入,CLI 报错时信息更清晰 report_dir = PROJECT_ROOT / "reports" / report_name @@ -104,7 +109,10 @@ def generate(report_name: str, params: dict, output: str) -> Path: # 3. 渲染:模板元素按数据动态布局(明细行高度自适应) layout = importlib.import_module(f"reports.{report_name}._build_template") - report_def = layout.build_report_definition(context) + if "paper" in inspect.signature(layout.build_report_definition).parameters: + report_def = layout.build_report_definition(context, paper=paper) + else: + report_def = layout.build_report_definition(context) report = Report( report_definition=report_def, data=context,