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