Add HTTP print API for packing list and sign-off receipt

- core/printing.py: SumatraPDF silent print backend (dry_run, timeout, error capture)
- core/print_document.py: orchestrate generate() + print, per-report paper size, printer fallback
- core/print_api.py: FastAPI POST /api/print + GET /api/health, HTTPBearer auth, English Swagger at /api/docs
- serve.py: uvicorn entrypoint
- config: printers/print/api sections in settings.yaml + core/settings.py
- requirements.txt: add fastapi, uvicorn
- .gitignore: ignore logs/
This commit is contained in:
Misaka_Company
2026-08-05 16:22:59 +08:00
parent df8f25f6da
commit bf9199189a
7 changed files with 399 additions and 1 deletions

232
core/print_api.py Normal file
View File

@@ -0,0 +1,232 @@
"""
FastAPI print service for WareShipManifest.
All endpoints are mounted under the ``/api`` prefix:
POST /api/print generate a shipping document PDF and physically print it
GET /api/health liveness probe
Interactive Swagger UI is served at ``/api/docs`` and the OpenAPI schema at
``/api/openapi.json``. All documentation is in English.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict, Field
from core.settings import PROJECT_ROOT, settings
from core import print_document as pd
app = FastAPI(
title="WareShipManifest Print Service API",
description=(
"HTTP API for the WareShipManifest print service.\n\n"
"## Overview\n"
"Generate warehouse shipping documents (packing list / sign-off receipt) "
"as PDF and send them to a physical printer through SumatraPDF.\n\n"
"## Endpoints\n"
"* `POST /api/print` — generate a document and print it "
"(or preview with `dry_run`).\n"
"* `GET /api/health` — liveness probe.\n\n"
"## Authentication\n"
"When `api.token` is configured in `settings.yaml`, every request must "
"carry a bearer token. Click the **Authorize** button (top right of this "
"page), enter the token value, and it will be sent automatically as "
"`Authorization: Bearer <token>`. Requests without a valid token are "
"rejected with `401` (missing) or `403` (invalid).\n\n"
"## Notes\n"
"* `dry_run=true` generates the PDF and builds the print command but "
"does **not** send paper to the printer — useful for testing.\n"
"* The target printer falls back to `printers.default` in `settings.yaml` "
"when `printer` is omitted.\n"
"* Paper size and orientation are chosen automatically per report type: "
"packing_list → A5 landscape, sign_receipt → A4 portrait.\n"
"* A JSON line is appended to `logs/print.log` for every request."
),
version="1.0.0",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
openapi_tags=[
{
"name": "print",
"description": "Generate shipping documents and send them to a printer.",
}
],
)
api_router = APIRouter(prefix="/api", tags=["print"])
security = HTTPBearer(auto_error=False)
class PrintRequest(BaseModel):
report_type: Literal["packing_list", "sign_receipt"] = Field(
...,
description="Type of document to generate and print.",
examples=["packing_list", "sign_receipt"],
)
params: dict[str, Any] = Field(
...,
description=(
"Report parameters. packing_list requires `paichan_no` (str) and "
"`box_no` (int); sign_receipt requires `receipt_no` (str, 11-digit)."
),
examples=[{"paichan_no": "R07425", "box_no": 1}],
)
printer: Optional[str] = Field(
None,
description="Target printer name. Falls back to `printers.default` in settings.yaml when omitted.",
examples=["Canon G1030 series (网络 USB1)"],
)
dry_run: bool = Field(
False,
description="When true, generate the PDF and build the print command but do NOT send it to the printer.",
examples=[True, False],
)
model_config = ConfigDict(
json_schema_extra={
"example": {
"report_type": "packing_list",
"params": {"paichan_no": "R07425", "box_no": 1},
"dry_run": False,
}
}
)
class PrintResponse(BaseModel):
ok: bool = Field(..., description="Whether the print job succeeded.")
report_type: str = Field(..., description="The report type that was requested.")
printer: Optional[str] = Field(None, description="The printer the job was sent to.")
pdf: str = Field(..., description="Absolute path of the generated PDF file.")
dry_run: bool = Field(
..., description="True when the PDF was generated but not physically printed."
)
returncode: Optional[int] = Field(
None, description="Exit code of the SumatraPDF print process (null in dry_run)."
)
error: Optional[str] = Field(None, description="Error message, if any.")
class PrintError(BaseModel):
detail: str = Field(
..., description="Human-readable error message describing what went wrong."
)
def _verify_token(
creds: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> None:
"""Skip auth when no token is configured (local debugging); otherwise verify Bearer."""
if not settings.api.token:
return
if creds is None:
raise HTTPException(status_code=401, detail="Missing bearer token")
if creds.credentials != settings.api.token:
raise HTTPException(status_code=403, detail="Invalid token")
def _log(result: dict) -> None:
log_path = PROJECT_ROOT / "logs" / "print.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
entry = {**result, "ts": datetime.now().isoformat()}
with open(log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
@api_router.post(
"/print",
summary="Generate and print a shipping document",
description=(
"Generate the requested document as a PDF using the existing report "
"pipeline, then send the PDF to the target printer via SumatraPDF in "
"silent mode.\n\n"
"**Flow**\n"
"1. Validate the request and the required parameters.\n"
"2. Generate the PDF from the database.\n"
"3. Build and execute the SumatraPDF print command "
"(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."
),
response_description="Print job result, including the generated PDF path.",
response_model=PrintResponse,
responses={
400: {
"description": "Invalid or missing parameters (e.g. required field not provided).",
"model": PrintError,
},
401: {"description": "Missing bearer token (auth enabled).", "model": PrintError},
403: {"description": "Invalid bearer token.", "model": PrintError},
404: {
"description": "Referenced record not found (e.g. receipt_no / box does not exist).",
"model": PrintError,
},
422: {
"description": "Report generation or printing failed at runtime.",
"model": PrintError,
},
500: {"description": "Unexpected server error.", "model": PrintError},
},
dependencies=[Depends(_verify_token)],
)
def print_endpoint(req: PrintRequest):
try:
result = pd.print_document(
report_type=req.report_type,
params=req.params,
printer=req.printer,
dry_run=req.dry_run,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"print failed: {e}")
_log(result)
return {
"ok": result.get("ok", False),
"report_type": req.report_type,
"printer": result.get("printer"),
"pdf": str(result.get("pdf")),
"dry_run": result.get("dry_run", False),
"returncode": result.get("returncode"),
"error": result.get("error"),
}
@api_router.get(
"/health",
summary="Liveness probe",
description=(
"Returns `{\"status\": \"ok\"}` when the service is running. "
"No authentication is required. Use it for health checks / uptime monitoring."
),
response_description="Service status.",
responses={
200: {
"description": "Service is alive.",
"content": {
"application/json": {
"example": {"status": "ok"},
}
},
}
},
)
def health():
return {"status": "ok"}
app.include_router(api_router)

61
core/print_document.py Normal file
View File

@@ -0,0 +1,61 @@
"""打印编排:生成报表 PDF -> 送打印机。
复用现有 run.generate() 完成「查询→装配→渲染→PDF→页脚盖印」全链路
再调用 core.printing 把 PDF 静默打印到默认/指定打印机。
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# run.py 在项目根,生成逻辑已验证;此处直接复用,不改动 run.py
import run # noqa: E402
from core.settings import settings # noqa: E402
from core import printing # noqa: E402
VALID_REPORTS = ("packing_list", "sign_receipt")
def print_document(
report_type: str,
params: dict,
printer: str | None = None,
dry_run: bool = False,
) -> dict:
"""生成报表 PDF 并打印。
:param report_type: packing_list | sign_receipt
:param params: 报表参数(如 paichan_no/box_no 或 receipt_no
:param printer: 指定打印机名;为 None 时用 settings.printers.default
:param dry_run: True 时只构造打印命令不真正执行(用于验证链路)
:return: 结构化结果(含 pdf 路径、打印命令、ok 状态)
"""
if report_type not in VALID_REPORTS:
raise ValueError(f"未知报表类型:{report_type}(支持 {list(VALID_REPORTS)}")
# 1. 生成 PDFrun.generate 内部已做 required 参数校验与 DB 查询)
output = run._default_output(report_type, params)
out_path = run.generate(report_type, params, output)
# 2. 选打印机与纸张(装箱单 A5 横向 / 签收单 A4 纵向)
target_printer = printer or settings.printers.default
paper_settings = settings.print.settings_by_report.get(
report_type, settings.print.default_settings
)
# 3. 打印
result = printing.print_pdf(
pdf_path=out_path,
printer=target_printer,
settings=paper_settings,
sumatrapdf_path=settings.print.sumatrapdf_path,
dry_run=dry_run,
)
result["report_type"] = report_type
result["params"] = params
return result

64
core/printing.py Normal file
View File

@@ -0,0 +1,64 @@
"""打印后端:把已生成的 PDF 静默送到物理/网络打印机。
MVP 使用 SumatraPDF 命令行打印(轻量、支持 -print-to 指定打印机与纸张,
配合 -silent 适合服务端无人值守打印)。
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def build_command(
sumatrapdf_path: str, printer: str, settings: str, pdf_path: str
) -> list[str]:
"""构造 SumatraPDF 打印命令。"""
return [
sumatrapdf_path,
"-print-to",
printer,
"-print-settings",
settings,
"-silent",
pdf_path,
]
def print_pdf(
pdf_path: str | Path,
printer: str,
settings: str = "paper=A4,orientation=portrait,color=bw",
sumatrapdf_path: str = "C:/Program Files/SumatraPDF/SumatraPDF.exe",
timeout: int = 60,
dry_run: bool = False,
) -> dict:
"""调用 SumatraPDF 把 PDF 静默打印到指定打印机。
返回结构化结果dry_run=True 时只构造命令并上报,不真正执行(用于验证链路)。
"""
pdf_path = str(pdf_path)
cmd = build_command(sumatrapdf_path, printer, settings, pdf_path)
result: dict = {
"printer": printer,
"pdf": pdf_path,
"command": cmd,
"dry_run": dry_run,
}
if dry_run:
result["ok"] = True
result["note"] = "dry_run: command constructed, not executed"
return result
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=False
)
result["returncode"] = proc.returncode
result["stdout"] = proc.stdout
result["stderr"] = proc.stderr
result["ok"] = proc.returncode == 0
if proc.returncode != 0:
result["error"] = (proc.stderr or proc.stdout or "unknown error").strip()
except subprocess.TimeoutExpired:
result["ok"] = False
result["error"] = f"print timeout after {timeout}s"
return result

View File

@@ -48,11 +48,37 @@ class ReportConfig(BaseModel):
debug_border: bool = False
class PrintersConfig(BaseModel):
"""打印机配置MVP单一默认打印机"""
default: str
class PrintConfig(BaseModel):
"""打印后端配置"""
backend: str = "sumatrapdf"
sumatrapdf_path: str = "C:/Program Files/SumatraPDF/SumatraPDF.exe"
default_settings: str = "paper=A4,orientation=portrait,color=bw"
settings_by_report: dict[str, str] = {}
class ApiConfig(BaseModel):
"""HTTP 打印服务配置"""
host: str = "0.0.0.0"
port: int = 8000
token: str = ""
class Settings(BaseModel):
"""应用配置"""
database: DatabaseConfig
report: ReportConfig = ReportConfig()
printers: PrintersConfig
print: PrintConfig = PrintConfig()
api: ApiConfig = ApiConfig()
@property
def database_url(self) -> URL: