Files
tps-dashboard/backend/app/services/excel_service.py
T

246 lines
8.3 KiB
Python
Raw Normal View History

"""
Excel 解析服务 — 批量导入核心逻辑
业务规则:
- 前置校验:所有涉及年份的 yearly_targets 必须存在,否则整体报错(不入库任何行)
- 幂等:检测到重复 (year, episode_number) → 整体 409,不入库
- 事务:逐行提交,成功立即 commit,失败行收集到 errors[]
- 软引用+快照:编导姓名匹配不到在职用户 → editor_id=NULL + editor_name_snapshot=姓名
- is_rerun 解析:接受是/否、true/false、1/0,大小写不敏感
- Phase 2 不支持重播行(is_rerun=是 → 报错标记为失败行)
"""
import uuid
from datetime import date
from typing import Any
import pandas as pd
from sqlmodel import Session, select
from app.models.episode import Episode
from app.models.user import User
from app.models.yearly_target import YearlyTarget
TRUTHY = {"是", "true", "1", "yes"}
FALSY = {"否", "false", "0", "no"}
def parse_bool(val: Any) -> bool | None:
"""解析布尔值,接受多种格式。"""
if val is None:
return None
v = str(val).strip().lower()
if v == "":
return None
if v in TRUTHY:
return True
if v in FALSY:
return False
raise ValueError(f"无法解析布尔值: {val!r}")
def parse_date(val: Any) -> date | None:
"""解析日期,支持 YYYY-MM-DD 和 YYYY/MM/DD。"""
if val is None or str(val).strip() == "":
return None
v = str(val).strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"):
try:
return date.fromisoformat(v)
except ValueError:
pass
raise ValueError(f"无法解析日期: {val!r}")
def parse_float(val: Any) -> float | None:
"""解析浮点数,接受百分比字符串。"""
if val is None:
return None
v = str(val).strip()
if v == "":
return None
if v.endswith("%"):
v = v[:-1]
return float(v)
def find_editor_by_name(session: Session, name: str):
"""按 display_name 匹配在职编导。匹配上返 (id, name),匹配不上返 (None, name)。"""
if not name or str(name).strip() == "":
return None, ""
name = str(name).strip()
user = session.exec(
select(User).where(User.display_name == name, User.is_active == True)
).first()
if user:
return user.id, name
return None, name
class ExcelService:
def __init__(self, session: Session):
self.session = session
self.errors: list[dict] = []
self.batch_id = str(uuid.uuid4())
def validate_yearly_targets(self, air_dates: list[date]):
"""前置校验:所有涉及年份的 yearly_targets 必须存在,否则整体报错。"""
distinct_years = set(dt.year for dt in air_dates if dt is not None)
missing = []
for yr in sorted(distinct_years):
exists = self.session.exec(
select(YearlyTarget).where(YearlyTarget.year == yr)
).first()
if not exists:
missing.append(yr)
if missing:
raise ValueError(
f"导入前请先录入以下年份的年度目标:{', '.join(map(str, missing))}。"
"请先在年度目标页录入目标后再导入。"
)
def check_duplicates(self, rows: list[dict]):
"""检测重复 (year, episode_number),有重复则整体报错。"""
seen = set()
duplicates = []
for row in rows:
key = (row["_air_year"], row["episode_number"])
if key in seen:
duplicates.append(row)
seen.add(key)
if duplicates:
dup_list = [
f"{r['_air_year']}年第 {r['episode_number']} 期"
for r in duplicates
]
raise ValueError(
f"文件中以下期次与库中记录重复:{', '.join(dup_list)}。"
"请先手动删除重复期次后再重新导入。"
)
def import_episodes(self, file_content: bytes) -> dict:
"""解析 Excel,批量导入 episodes。返回 ImportResult 结构。"""
# 1. 读取 Excel
df = pd.read_excel(file_content, engine="openpyxl")
rows = df.to_dict(orient="records")
# 2. 解析 air_date 并提取年份
parsed_rows = []
air_dates = []
for i, row in enumerate(rows, start=2): # Excel 行号从2开始(第1行=表头)
raw = dict(row)
try:
air_dt = parse_date(row.get("air_date"))
if air_dt is None:
raise ValueError("air_date 不能为空")
parsed_rows.append({
"_row_number": i,
"_air_year": air_dt.year,
"_air_date": air_dt,
"episode_number": int(row["episode_number"]),
"program_name": str(row["program_name"]).strip(),
"audience_share": parse_float(row.get("audience_share")),
"audience_rating": parse_float(row.get("audience_rating")),
"is_rerun": parse_bool(row.get("is_rerun")),
"editor_name_snapshot": str(row.get("editor_name", "")).strip() or "未知编导",
"notes": str(row.get("notes", "")).strip() or None,
"_raw": raw,
})
air_dates.append(air_dt)
except Exception as e:
self.errors.append({
"row_number": i,
"reason": str(e),
"raw_data": raw,
})
# 3. 前置校验:年份 targets
try:
self.validate_yearly_targets(air_dates)
except ValueError:
raise # 直接抛给调用方,整体 400
# 4. 重复检测
try:
self.check_duplicates(parsed_rows)
except ValueError:
raise # 直接抛给调用方,整体 409
# 5. 逐行入库
for row in parsed_rows:
try:
self._import_one_row(row)
except Exception as e:
self.errors.append({
"row_number": row["_row_number"],
"reason": str(e),
"raw_data": row["_raw"],
})
# 6. 计算结果
total = len(rows)
success = total - len(self.errors)
return {
"batch_id": self.batch_id,
"total_rows": total,
"success_count": success,
"failed_count": len(self.errors),
"errors": self.errors,
}
def _import_one_row(self, row: dict):
"""导入单行,处理 is_rerun 逻辑。"""
# 重播行 Phase 2 不支持
if row["is_rerun"] is True:
raise ValueError(
"Phase 2 不支持重播期次导入。"
"有重播行请空着或用其他工具录入,系统已标记为失败行。"
)
# 编辑匹配
editor_id, editor_name = find_editor_by_name(self.session, row["editor_name_snapshot"])
if editor_id is None and row["editor_name_snapshot"] == "未知编导":
editor_name = row["editor_name_snapshot"]
# 插入
episode = Episode(
episode_number=row["episode_number"],
program_name=row["program_name"],
air_date=row["_air_date"],
editor_id=editor_id,
editor_name_snapshot=editor_name,
audience_share=row["audience_share"],
audience_rating=row["audience_rating"],
is_rerun=False,
original_episode_id=None,
notes=row["notes"],
)
self.session.add(episode)
self.session.commit()
self.session.refresh(episode)
def generate_error_excel(errors: list[dict]) -> bytes:
"""生成失败行 Excel,供责编下载修正。"""
import io
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "失败行"
# 表头
headers = ["row_number", "reason"] + list(errors[0]["raw_data"].keys()) if errors else ["row_number", "reason"]
ws.append(headers)
# 失败行
for err in errors:
row_data = [err["row_number"], err["reason"]] + list(err["raw_data"].values())
ws.append(row_data)
wb.add_named_style("error_header")
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()