479 lines
16 KiB
Python
479 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
from collections import defaultdict
|
||
from datetime import date
|
||
from typing import Iterable
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from .. import constants
|
||
from ..constants import parse_period_label
|
||
from ..db import get_db
|
||
from ..models import Period, Project, Task, Update
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class PeriodCreate(BaseModel):
|
||
label: str | None = None
|
||
year: int | None = None
|
||
month: int | None = None
|
||
week: int | None = None
|
||
start_date: str | None = None
|
||
end_date: str | None = None
|
||
|
||
# 状态严重程度排序(越靠前越需要关注)
|
||
_SEVERITY = ["blocked", "at_risk", "paused", "not_started", "in_progress", "done"]
|
||
|
||
|
||
def worst_status(codes: Iterable[str]) -> str:
|
||
codes = [c for c in codes if c]
|
||
for c in _SEVERITY:
|
||
if c in codes:
|
||
return c
|
||
return "in_progress"
|
||
|
||
|
||
def resolve_period_ids(
|
||
db: Session,
|
||
period_id: int | None = None,
|
||
year: int | None = None,
|
||
month: int | None = None,
|
||
) -> list[int]:
|
||
"""把 (period_id) 或 (year+month) 解析为具体的周周期 id 列表。"""
|
||
if period_id:
|
||
p = db.get(Period, period_id)
|
||
return [p.id] if p else []
|
||
if year and month:
|
||
rows = (
|
||
db.query(Period.id)
|
||
.filter(Period.year == year, Period.month == month)
|
||
.order_by(Period.sort_key)
|
||
.all()
|
||
)
|
||
return [r[0] for r in rows]
|
||
latest = db.query(Period).order_by(Period.sort_key.desc()).first()
|
||
return [latest.id] if latest else []
|
||
|
||
|
||
# ---------------------------------------------------------------- 周期
|
||
@router.get("/periods")
|
||
def list_periods(db: Session = Depends(get_db)):
|
||
from ..models import CustomUpdate
|
||
|
||
weeks = db.query(Period).order_by(Period.sort_key).all()
|
||
uc = dict(
|
||
db.query(Update.period_id, func.count(Update.id)).group_by(Update.period_id).all()
|
||
)
|
||
cc = dict(
|
||
db.query(CustomUpdate.period_id, func.count(CustomUpdate.id))
|
||
.group_by(CustomUpdate.period_id)
|
||
.all()
|
||
)
|
||
|
||
months: dict[tuple[int, int], dict] = {}
|
||
week_list = []
|
||
for p in weeks:
|
||
d = p.to_dict()
|
||
d["update_count"] = uc.get(p.id, 0)
|
||
d["custom_update_count"] = cc.get(p.id, 0)
|
||
week_list.append(d)
|
||
|
||
key = (p.year, p.month)
|
||
item = months.setdefault(
|
||
key,
|
||
{
|
||
"year": p.year,
|
||
"month": p.month,
|
||
"label": f"{p.year}年{p.month}月",
|
||
"sort_key": p.year * 10000 + p.month * 100,
|
||
"period_ids": [],
|
||
"week_count": 0,
|
||
},
|
||
)
|
||
item["period_ids"].append(p.id)
|
||
item["week_count"] += 1
|
||
y, m, w = constants.week_of_date(date.today())
|
||
today_key = y * 10000 + m * 100 + w
|
||
return {
|
||
"weeks": week_list,
|
||
"months": sorted(months.values(), key=lambda x: x["sort_key"]),
|
||
# 周期表里可能已经预建了后面的周,今天所在的这一周单独给出去当默认值
|
||
"today_id": next((d["id"] for d in week_list if d["sort_key"] == today_key), None),
|
||
}
|
||
|
||
|
||
@router.post("/periods")
|
||
def create_period(payload: PeriodCreate, db: Session = Depends(get_db)):
|
||
"""新增一个周期。可直接给 label(如『9月第1周』),年份自动接续在最新周期之后。"""
|
||
latest = db.query(Period).order_by(Period.sort_key.desc()).first()
|
||
|
||
year, month, week = payload.year, payload.month, payload.week
|
||
if month is None:
|
||
if not payload.label:
|
||
raise HTTPException(400, "请提供 label(如『9月第1周』)或 month")
|
||
parsed = parse_period_label(payload.label, 0)
|
||
if not parsed:
|
||
raise HTTPException(400, "无法识别周期,请使用『9月第1周』这类格式")
|
||
label_year, month, week = parsed
|
||
if label_year: # 标签里显式写了年份
|
||
year = label_year
|
||
|
||
week = week or 0
|
||
if year is None:
|
||
if latest is None:
|
||
year = 2026
|
||
elif month < latest.month:
|
||
# 只按月份判断跨年:补录同年更早的周次(如已有9月第4周,再补9月第1周)应留在同一年
|
||
year = latest.year + 1
|
||
else:
|
||
year = latest.year
|
||
|
||
sort_key = year * 10000 + month * 100 + week
|
||
existed = db.query(Period).filter(Period.sort_key == sort_key).one_or_none()
|
||
if existed:
|
||
return {"period": existed.to_dict(), "created": False}
|
||
|
||
label = (payload.label or "").strip() or f"{month}月第{week}周"
|
||
p = Period(
|
||
label=label,
|
||
kind="week",
|
||
year=year,
|
||
month=month,
|
||
week=week,
|
||
start_date=payload.start_date,
|
||
end_date=payload.end_date,
|
||
sort_key=sort_key,
|
||
)
|
||
db.add(p)
|
||
db.commit()
|
||
db.refresh(p)
|
||
return {"period": p.to_dict(), "created": True}
|
||
|
||
|
||
@router.delete("/periods/{pid}")
|
||
def delete_period(pid: int, db: Session = Depends(get_db)):
|
||
"""删除周期。仅允许删除没有任何执行记录(通用 / 定开)的空周期。"""
|
||
from ..models import CustomUpdate
|
||
|
||
p = db.get(Period, pid)
|
||
if not p:
|
||
raise HTTPException(404, "周期不存在")
|
||
n1 = db.query(Update).filter(Update.period_id == pid).count()
|
||
n2 = db.query(CustomUpdate).filter(CustomUpdate.period_id == pid).count()
|
||
if n1 or n2:
|
||
raise HTTPException(400, f"该周期下已有 {n1 + n2} 条执行记录,请先删除记录再删周期")
|
||
db.delete(p)
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@router.get("/meta")
|
||
def meta(db: Session = Depends(get_db)):
|
||
from ..ai_parser import ai_enabled, get_ai_settings
|
||
|
||
s = get_ai_settings(db)
|
||
return {
|
||
"statuses": [
|
||
{"code": c, "name": n, "color": col} for c, n, col in constants.STATUSES
|
||
],
|
||
"priorities": [
|
||
{"code": c, "name": n, "color": col} for c, n, col in constants.PRIORITIES
|
||
],
|
||
"ai": {
|
||
"enabled": ai_enabled(db),
|
||
"model": s.get("ai_model"),
|
||
"api_base": s.get("ai_api_base"),
|
||
"has_key": bool((s.get("ai_api_key") or "").strip()),
|
||
},
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- 看板
|
||
@router.get("/board")
|
||
def board(
|
||
period_id: int | None = None,
|
||
year: int | None = None,
|
||
month: int | None = None,
|
||
category_id: int | None = None,
|
||
key_only: bool = False,
|
||
keyword: str | None = None,
|
||
idle: str = Query("key", pattern="^(none|key|all)$"),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
period_ids = resolve_period_ids(db, period_id, year, month)
|
||
if not period_ids:
|
||
return {"period_ids": [], "periods": [], "rows": [], "stats": None}
|
||
|
||
periods = (
|
||
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
|
||
)
|
||
|
||
q = db.query(Project).filter(Project.archived.is_(False))
|
||
if category_id:
|
||
q = q.filter(Project.category_id == category_id)
|
||
if key_only:
|
||
q = q.filter(Project.is_key.is_(True))
|
||
if keyword:
|
||
q = q.filter(Project.name.contains(keyword))
|
||
projects = q.order_by(Project.is_key.desc(), Project.sort_order, Project.id).all()
|
||
|
||
ups = (
|
||
db.query(Update)
|
||
.filter(Update.period_id.in_(period_ids))
|
||
.order_by(Update.id)
|
||
.all()
|
||
)
|
||
by_project: dict[int, list[Update]] = defaultdict(list)
|
||
for u in ups:
|
||
by_project[u.project_id].append(u)
|
||
|
||
tasks = (
|
||
db.query(Task)
|
||
.filter(Task.project_id.in_([p.id for p in projects]))
|
||
.order_by(Task.sort_order, Task.id)
|
||
.all()
|
||
)
|
||
tasks_by_project: dict[int, list[Task]] = defaultdict(list)
|
||
for t in tasks:
|
||
tasks_by_project[t.project_id].append(t)
|
||
|
||
rows = []
|
||
for p in projects:
|
||
items = by_project.get(p.id, [])
|
||
if not items:
|
||
if idle == "none" or (idle == "key" and not p.is_key):
|
||
continue
|
||
|
||
task_cards = []
|
||
used_task_ids = set()
|
||
for t in tasks_by_project.get(p.id, []):
|
||
t_ups = [u for u in items if u.task_id == t.id]
|
||
for u in t_ups:
|
||
used_task_ids.add(u.id)
|
||
task_cards.append(
|
||
{
|
||
"task": t.to_dict(),
|
||
"updates": [u.to_dict() for u in t_ups],
|
||
}
|
||
)
|
||
other_ups = [u for u in items if u.id not in used_task_ids]
|
||
|
||
progresses = [u.progress for u in items if u.progress is not None]
|
||
if progresses:
|
||
progress, progress_known = round(max(progresses), 1), True
|
||
else:
|
||
progress, progress_known = round(p.progress or 0, 1), bool(items)
|
||
status = worst_status([u.status for u in items]) if items else p.status
|
||
|
||
rows.append(
|
||
{
|
||
"project": p.to_dict(),
|
||
"progress": progress,
|
||
"progress_known": progress_known,
|
||
"status": status,
|
||
"update_count": len(items),
|
||
"summary": next((u.summary for u in items if u.summary), None)
|
||
or (items[-1].content or "")[:80]
|
||
if items
|
||
else None,
|
||
"has_update": bool(items),
|
||
"risks": [u.risk for u in items if u.risk],
|
||
"task_cards": task_cards,
|
||
"other_updates": [u.to_dict() for u in other_ups],
|
||
"updates": [u.to_dict() for u in items],
|
||
}
|
||
)
|
||
|
||
rows.sort(
|
||
key=lambda r: (
|
||
not r["project"]["is_key"],
|
||
r["status"] != "blocked",
|
||
r["status"] != "at_risk",
|
||
not r["has_update"],
|
||
-(r["progress"] or 0),
|
||
)
|
||
)
|
||
|
||
return {
|
||
"period_ids": period_ids,
|
||
"periods": [p.to_dict() for p in periods],
|
||
"rows": rows,
|
||
"stats": _quick_stats(db, period_ids, rows),
|
||
}
|
||
|
||
|
||
def _quick_stats(db: Session, period_ids: list[int], rows: list[dict]) -> dict:
|
||
updates = db.query(Update).filter(Update.period_id.in_(period_ids)).all()
|
||
by_status: dict[str, int] = defaultdict(int)
|
||
for u in updates:
|
||
by_status[u.status] += 1
|
||
active = [r for r in rows if r["has_update"]]
|
||
progresses = [r["progress"] for r in active if r["progress"]]
|
||
return {
|
||
"project_total": len(rows),
|
||
"project_active": len(active),
|
||
"project_idle": len(rows) - len(active),
|
||
"key_total": sum(1 for r in rows if r["project"]["is_key"]),
|
||
"key_updated": sum(1 for r in active if r["project"]["is_key"]),
|
||
"update_count": len(updates),
|
||
"avg_progress": round(sum(progresses) / len(progresses), 1) if progresses else 0,
|
||
"status_counts": {k: by_status.get(k, 0) for k, _, _ in constants.STATUSES},
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- 统计面板
|
||
@router.get("/stats")
|
||
def stats(
|
||
period_id: int | None = None,
|
||
year: int | None = None,
|
||
month: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
period_ids = resolve_period_ids(db, period_id, year, month)
|
||
periods = (
|
||
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
|
||
if period_ids
|
||
else []
|
||
)
|
||
scope_label = (
|
||
"、".join(p.label for p in periods[:3]) + ("…" if len(periods) > 3 else "")
|
||
if periods
|
||
else (f"{year}年{month}月" if year and month else "—")
|
||
)
|
||
|
||
updates = (
|
||
db.query(Update).filter(Update.period_id.in_(period_ids)).all() if period_ids else []
|
||
)
|
||
projects = db.query(Project).filter(Project.archived.is_(False)).all()
|
||
|
||
# 全量:每个周期的更新量 / 覆盖项目数 / 平均进度(趋势图)
|
||
trend_rows = (
|
||
db.query(
|
||
Period.id,
|
||
Period.label,
|
||
Period.sort_key,
|
||
Period.year,
|
||
Period.month,
|
||
Period.week,
|
||
func.count(Update.id),
|
||
)
|
||
.outerjoin(Update, Update.period_id == Period.id)
|
||
.group_by(Period.id)
|
||
.order_by(Period.sort_key)
|
||
.all()
|
||
)
|
||
trend = [
|
||
{
|
||
"period_id": r[0],
|
||
"label": r[1],
|
||
"sort_key": r[2],
|
||
"year": r[3],
|
||
"month": r[4],
|
||
"week": r[5],
|
||
"update_count": r[6],
|
||
}
|
||
for r in trend_rows
|
||
]
|
||
|
||
# 分类分布
|
||
cat_counter: dict[int, int] = defaultdict(int)
|
||
for p in projects:
|
||
cat_counter[p.category_id or 0] += 1
|
||
cat_names = {}
|
||
for p in projects:
|
||
if p.category_id:
|
||
cat_names[p.category_id] = (p.category.name, p.category.color)
|
||
category_dist = []
|
||
for cid, cnt in sorted(cat_counter.items(), key=lambda x: -x[1]):
|
||
name, color = cat_names.get(cid, ("未分类", "#94a3b8"))
|
||
category_dist.append({"id": cid, "name": name, "color": color, "count": cnt})
|
||
|
||
# 状态分布(本周期)
|
||
status_dist = []
|
||
for code, name, color in constants.STATUSES:
|
||
status_dist.append(
|
||
{
|
||
"code": code,
|
||
"name": name,
|
||
"color": color,
|
||
"count": sum(1 for u in updates if u.status == code),
|
||
}
|
||
)
|
||
|
||
# 风险清单
|
||
risks = [
|
||
u.to_dict(with_project=True)
|
||
for u in updates
|
||
if u.status in constants.NEGATIVE_STATUS or (u.risk or "").strip()
|
||
]
|
||
|
||
# 重点工作未更新
|
||
updated_ids = {u.project_id for u in updates}
|
||
idle_key = [
|
||
p.to_dict() for p in projects if p.is_key and p.id not in updated_ids
|
||
]
|
||
|
||
# 进度排行
|
||
prog_rows = []
|
||
for p in projects:
|
||
p_ups = [u for u in updates if u.project_id == p.id]
|
||
prs = [u.progress for u in p_ups if u.progress is not None]
|
||
prog = max(prs) if prs else p.progress
|
||
prog_rows.append(
|
||
{
|
||
"id": p.id,
|
||
"name": p.name,
|
||
"progress": round(prog or 0, 1),
|
||
"is_key": p.is_key,
|
||
"status": worst_status([u.status for u in p_ups]) if p_ups else p.status,
|
||
"color": (p.category.color if p.category else "#2563eb"),
|
||
"estimated": not prs,
|
||
}
|
||
)
|
||
prog_rows.sort(key=lambda x: (-x["progress"], x["name"]))
|
||
top_progress = [r for r in prog_rows if r["id"] in updated_ids][:10] or prog_rows[:10]
|
||
low_progress = [
|
||
r
|
||
for r in prog_rows
|
||
if r["progress"] < 30 and r["status"] not in ("done",) and r["id"] in updated_ids
|
||
][:8]
|
||
|
||
recent = (
|
||
db.query(Update)
|
||
.filter(Update.period_id.in_(period_ids))
|
||
.order_by(Update.id.desc())
|
||
.limit(10)
|
||
.all()
|
||
if period_ids
|
||
else []
|
||
)
|
||
|
||
covered_prog = [p["progress"] for p in prog_rows if p["id"] in updated_ids]
|
||
return {
|
||
"scope_label": scope_label,
|
||
"period_ids": period_ids,
|
||
"kpi": {
|
||
"project_total": len(projects),
|
||
"key_total": sum(1 for p in projects if p.is_key),
|
||
"update_count": len(updates),
|
||
"covered_projects": len(updated_ids),
|
||
"risk_count": len(risks),
|
||
# 平均进度只统计本周期有更新的项目,避免历史一次性事项拉低数值
|
||
"avg_progress": round(sum(covered_prog) / len(covered_prog), 1)
|
||
if covered_prog
|
||
else 0,
|
||
},
|
||
"trend": trend[-14:],
|
||
"category_dist": category_dist,
|
||
"status_dist": status_dist,
|
||
"risks": risks,
|
||
"idle_key": idle_key,
|
||
"top_progress": top_progress,
|
||
"low_progress": low_progress,
|
||
"recent_updates": [u.to_dict(with_project=True) for u in recent],
|
||
}
|