172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
"""
|
||
Excel 批量导入 API — 模板下载 + 批量导入 + 失败行下载
|
||
"""
|
||
|
||
import io
|
||
import uuid
|
||
from datetime import date
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status
|
||
from fastapi.responses import StreamingResponse
|
||
from openpyxl import Workbook
|
||
from sqlmodel import Session
|
||
|
||
from app.core.deps import require_role
|
||
from app.db.session import get_session
|
||
from app.models.user import User, UserRole
|
||
from app.schemas.imports import ImportError, ImportResult
|
||
from app.services.excel_service import ExcelService, generate_error_excel
|
||
|
||
router = APIRouter(prefix="/api/imports", tags=["批量导入"])
|
||
|
||
|
||
# ============================================================================
|
||
# 内存失败行存储(进程生命周期,重启后丢失)
|
||
# 仅用于 Phase 2,生产 Phase 4+ 替换为 Redis 或 DB
|
||
# ============================================================================
|
||
_error_store: dict[str, list[dict]] = {}
|
||
|
||
|
||
# ============================================================================
|
||
# 模板下载
|
||
# ============================================================================
|
||
|
||
@router.get("/template")
|
||
def download_template(
|
||
type: str = Query("episodes", description="模板类型,仅支持 episodes"),
|
||
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
|
||
):
|
||
"""下载 episodes 批量导入 Excel 模板。"""
|
||
if type != "episodes":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Phase 2 不支持排期模板下载",
|
||
)
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "节目期次导入模板"
|
||
|
||
# 列说明(第1行)
|
||
headers = [
|
||
"episode_number", "program_name", "air_date",
|
||
"editor_name", "audience_share", "audience_rating",
|
||
"is_rerun", "notes",
|
||
]
|
||
ws.append(headers)
|
||
|
||
# 第2行:示例数据
|
||
sample = [
|
||
1, "《军事科技》武器解析系列", "2026-01-15",
|
||
"张颖", "0.72", "3.5",
|
||
"否", "首期播出",
|
||
]
|
||
ws.append(sample)
|
||
|
||
# 第3行:说明注释(灰色字体)
|
||
from openpyxl.styles import Font, PatternFill, Alignment
|
||
|
||
grey_font = Font(color="808080")
|
||
note_row = [
|
||
"期次号(整数,每年从1开始)",
|
||
"节目名称",
|
||
"播出日期(YYYY-MM-DD)",
|
||
"编导姓名(快照,匹配不上则只存姓名)",
|
||
"收视份额(0-1之间的小数,可空)",
|
||
"收视率(可空)",
|
||
"是否重播(是/否),Phase 2 不支持重播导入",
|
||
"备注",
|
||
]
|
||
ws.append(note_row)
|
||
for cell in ws[len(headers)]:
|
||
cell.font = grey_font
|
||
|
||
# 第4行:is_rerun 说明
|
||
rerun_note = ["", "", "", "", "", "", "Phase 2 不支持重播导入,有重播行请空着", ""]
|
||
ws.append(rerun_note)
|
||
for cell in ws[4]:
|
||
cell.font = grey_font
|
||
|
||
# 设置列宽
|
||
ws.column_dimensions["A"].width = 16
|
||
ws.column_dimensions["B"].width = 28
|
||
ws.column_dimensions["C"].width = 14
|
||
ws.column_dimensions["D"].width = 16
|
||
ws.column_dimensions["E"].width = 16
|
||
ws.column_dimensions["F"].width = 16
|
||
ws.column_dimensions["G"].width = 40
|
||
ws.column_dimensions["H"].width = 20
|
||
|
||
output = io.BytesIO()
|
||
wb.save(output)
|
||
output.seek(0)
|
||
|
||
return StreamingResponse(
|
||
output,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": "attachment; filename=episodes_import_template.xlsx"},
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# 批量导入
|
||
# ============================================================================
|
||
|
||
@router.post("/episodes", response_model=ImportResult)
|
||
def import_episodes(
|
||
file: UploadFile = File(..., description="Excel 文件(.xlsx)"),
|
||
session: Session = Depends(get_session),
|
||
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
|
||
):
|
||
"""批量导入节目期次 Excel。"""
|
||
if not file.filename.endswith(".xlsx"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="仅支持 .xlsx 格式,请使用模板下载获得正确格式",
|
||
)
|
||
|
||
content = file.file.read()
|
||
service = ExcelService(session)
|
||
|
||
try:
|
||
result = service.import_episodes(content)
|
||
except ValueError as e:
|
||
msg = str(e)
|
||
if "年度目标" in msg:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
|
||
if "重复" in msg or "库中记录重复" in msg:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=msg)
|
||
# 其他解析/校验错误
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
|
||
|
||
# 存储失败行(供后续下载)
|
||
if result["errors"]:
|
||
_error_store[result["batch_id"]] = result["errors"]
|
||
result["error_excel_url"] = f"/api/imports/errors/{result['batch_id']}"
|
||
|
||
return result
|
||
|
||
|
||
# ============================================================================
|
||
# 失败行下载
|
||
# ============================================================================
|
||
|
||
@router.get("/errors/{batch_id}")
|
||
def download_error_excel(
|
||
batch_id: str,
|
||
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
|
||
):
|
||
"""下载指定批次的失败行 Excel(供责编修正后重新导入)。"""
|
||
errors = _error_store.get(batch_id)
|
||
if errors is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="批次不存在或已过期(进程重启后数据丢失),请重新上传",
|
||
)
|
||
|
||
content = generate_error_excel(errors)
|
||
return StreamingResponse(
|
||
io.BytesIO(content),
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename=import_errors_{batch_id}.xlsx"},
|
||
) |