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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@ config/settings.yaml
|
|||||||
|
|
||||||
# generated output
|
# generated output
|
||||||
out/
|
out/
|
||||||
|
logs/
|
||||||
# verification artifacts
|
# verification artifacts
|
||||||
*.preview.png
|
*.preview.png
|
||||||
_smoke_*
|
_smoke_*
|
||||||
|
|||||||
232
core/print_api.py
Normal file
232
core/print_api.py
Normal 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
61
core/print_document.py
Normal 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. 生成 PDF(run.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
64
core/printing.py
Normal 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
|
||||||
@@ -48,11 +48,37 @@ class ReportConfig(BaseModel):
|
|||||||
debug_border: bool = False
|
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):
|
class Settings(BaseModel):
|
||||||
"""应用配置"""
|
"""应用配置"""
|
||||||
|
|
||||||
database: DatabaseConfig
|
database: DatabaseConfig
|
||||||
report: ReportConfig = ReportConfig()
|
report: ReportConfig = ReportConfig()
|
||||||
|
printers: PrintersConfig
|
||||||
|
print: PrintConfig = PrintConfig()
|
||||||
|
api: ApiConfig = ApiConfig()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> URL:
|
def database_url(self) -> URL:
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ pydantic-yaml>=0.12
|
|||||||
# dev / verification only (not required at runtime)
|
# dev / verification only (not required at runtime)
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
pymupdf>=1.24
|
pymupdf>=1.24
|
||||||
|
|
||||||
|
# HTTP 打印服务(新增打印接口)
|
||||||
|
fastapi>=0.110
|
||||||
|
uvicorn>=0.29
|
||||||
|
|||||||
Reference in New Issue
Block a user