Files
WareShipManifest/core/print_api.py
Misaka_Company bf9199189a 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/
2026-08-05 16:22:59 +08:00

233 lines
8.4 KiB
Python

"""
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)