unis_manager/app/routers/board.py

496 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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
from .focus import _week_period
# 周期按时间自动创建:确保「今天所在的这一周」一定存在,不再需要手动新建第几周。
# (_week_period 已用于「每周重点工作」,没有则补一条,口径与 week_of_date 一致)
_week_period(db, *constants.week_of_date(date.today()))
db.commit()
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)
# 该周的实际起止日期(口径见 constants.month_week1),供前端按「顶栏选中的周期」取数
d["first_day"] = constants.period_first_day(p.year, p.month, p.week)
d["last_day"] = constants.period_last_day(p.year, p.month, p.week)
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
# 月视图的日期范围 = 该月各周的并集,与 /weekly 的月口径一致
if "first_day" not in item or d["first_day"] < item["first_day"]:
item["first_day"] = d["first_day"]
if "last_day" not in item or d["last_day"] > item["last_day"]:
item["last_day"] = d["last_day"]
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),
# 风险 / 阻塞分开计数:at_risk=风险、blocked=阻塞
"at_risk_count": sum(1 for u in updates if u.status == "at_risk"),
"blocked_count": sum(1 for u in updates if u.status == "blocked"),
# 平均进度只统计本周期有更新的项目,避免历史一次性事项拉低数值
"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],
}