145 lines
6.3 KiB
Python
145 lines
6.3 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
from zipfile import ZipFile
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.schemas.document_archives import DocumentArchiveBatchDownloadRequest, DocumentArchiveGenerateResult
|
||
from app.services.auth import AuthContext, require_authenticated_user
|
||
from app.services.document_archives import (
|
||
ARCHIVE_STATUS_READY,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
DOCUMENT_TYPE_PURCHASE_ORDER,
|
||
DOCUMENT_TYPE_PURCHASE_RECEIPT,
|
||
DOCUMENT_TYPE_QUALITY_INSPECTION,
|
||
DOCUMENT_TYPE_SALES_ORDER,
|
||
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
|
||
FILE_FORMAT_PDF,
|
||
build_archive_zip,
|
||
default_archive_root,
|
||
generate_document_archive,
|
||
get_latest_archive,
|
||
safe_filename,
|
||
)
|
||
|
||
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
|
||
|
||
DOCUMENT_TYPE_KEY_MAP = {
|
||
"sales-order": DOCUMENT_TYPE_SALES_ORDER,
|
||
"purchase-order": DOCUMENT_TYPE_PURCHASE_ORDER,
|
||
"purchase-receipt": DOCUMENT_TYPE_PURCHASE_RECEIPT,
|
||
"quality-inspection": DOCUMENT_TYPE_QUALITY_INSPECTION,
|
||
"production-material-out": DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
"production-inbound-settlement": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
"warehouse-operation": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
|
||
DOCUMENT_TYPE_SALES_ORDER: DOCUMENT_TYPE_SALES_ORDER,
|
||
DOCUMENT_TYPE_PURCHASE_ORDER: DOCUMENT_TYPE_PURCHASE_ORDER,
|
||
DOCUMENT_TYPE_PURCHASE_RECEIPT: DOCUMENT_TYPE_PURCHASE_RECEIPT,
|
||
DOCUMENT_TYPE_QUALITY_INSPECTION: DOCUMENT_TYPE_QUALITY_INSPECTION,
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
DOCUMENT_TYPE_WAREHOUSE_OPERATION: DOCUMENT_TYPE_WAREHOUSE_OPERATION,
|
||
}
|
||
|
||
|
||
def normalize_document_type_key(document_type_key: str) -> str:
|
||
document_type = DOCUMENT_TYPE_KEY_MAP.get(document_type_key)
|
||
if document_type is None:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单或仓库出入库单",
|
||
)
|
||
return document_type
|
||
|
||
|
||
def _content_disposition(filename: str, inline: bool) -> str:
|
||
disposition = "inline" if inline else "attachment"
|
||
ascii_filename = safe_filename(filename.encode("ascii", "ignore").decode("ascii"))
|
||
encoded_filename = quote(filename, safe="")
|
||
return f"{disposition}; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}"
|
||
|
||
|
||
def _archive_file_response(path: str | Path, filename: str, inline: bool) -> FileResponse:
|
||
file_path = Path(path)
|
||
if not file_path.exists():
|
||
raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成")
|
||
return FileResponse(
|
||
file_path,
|
||
media_type="application/pdf",
|
||
filename=filename,
|
||
headers={"Content-Disposition": _content_disposition(filename, inline)},
|
||
)
|
||
|
||
|
||
def _extract_user_id(context: AuthContext | dict[str, Any] | Any) -> int | None:
|
||
if isinstance(context, dict):
|
||
value = context.get("user_id", context.get("uid", context.get("id")))
|
||
return int(value) if value is not None else None
|
||
user = getattr(context, "user", None)
|
||
value = getattr(user, "id", None)
|
||
if value is None:
|
||
value = getattr(context, "user_id", None)
|
||
return int(value) if value is not None else None
|
||
|
||
|
||
def _latest_ready_pdf_or_404(db: Session, document_type: str, business_id: int):
|
||
archive = get_latest_archive(db, document_type, business_id)
|
||
if archive is None or archive.status != ARCHIVE_STATUS_READY or archive.file_format != FILE_FORMAT_PDF:
|
||
raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成")
|
||
return archive
|
||
|
||
|
||
def _ensure_zip_has_downloadable_archives(zip_path: Path) -> None:
|
||
with ZipFile(zip_path) as archive_zip:
|
||
if not archive_zip.namelist():
|
||
raise HTTPException(status_code=404, detail="没有可下载的归档文件,请先生成PDF归档")
|
||
|
||
|
||
@router.post("/{document_type_key}/{business_id}/generate", response_model=DocumentArchiveGenerateResult)
|
||
def generate_archive(
|
||
document_type_key: str,
|
||
business_id: int,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> DocumentArchiveGenerateResult:
|
||
document_type = normalize_document_type_key(document_type_key)
|
||
return generate_document_archive(db, document_type, business_id, created_by=_extract_user_id(context))
|
||
|
||
|
||
@router.get("/{document_type_key}/{business_id}/latest/preview")
|
||
def preview_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse:
|
||
document_type = normalize_document_type_key(document_type_key)
|
||
archive = _latest_ready_pdf_or_404(db, document_type, business_id)
|
||
return _archive_file_response(archive.file_path, archive.file_name, inline=True)
|
||
|
||
|
||
@router.get("/{document_type_key}/{business_id}/latest/download")
|
||
def download_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse:
|
||
document_type = normalize_document_type_key(document_type_key)
|
||
archive = _latest_ready_pdf_or_404(db, document_type, business_id)
|
||
return _archive_file_response(archive.file_path, archive.file_name, inline=False)
|
||
|
||
|
||
@router.post("/batch-download")
|
||
def batch_download_archives(payload: DocumentArchiveBatchDownloadRequest, db: Session = Depends(get_db)) -> FileResponse:
|
||
document_type = normalize_document_type_key(payload.document_type)
|
||
batch_dir = Path(default_archive_root()) / "batch"
|
||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
|
||
zip_filename = safe_filename(f"{document_type}_归档_{timestamp}") + ".zip"
|
||
zip_path = build_archive_zip(db, document_type, payload.business_ids, batch_dir / zip_filename)
|
||
_ensure_zip_has_downloadable_archives(zip_path)
|
||
return FileResponse(
|
||
zip_path,
|
||
media_type="application/zip",
|
||
filename=zip_filename,
|
||
headers={"Content-Disposition": _content_disposition(zip_filename, inline=False)},
|
||
)
|