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)}, )