"""周看板:重点项目 + 定制项目的单周总览。 左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐: · 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目 · 定制项目 = 打了 ★ 的定制项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」) 前端左栏只给一个入口,点开后在右栏按「本周新增 / 本周有变化」分组展示。 右栏点开某一条时再按需拉 /projects/{id} 或 /custom/projects/{id} 看全量历史。 工作看板左栏的「本周工作重点」不在本接口里:前端并发取 /api/focus 后合并成一组。 """ from __future__ import annotations from collections import defaultdict from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from .. import constants from ..db import get_db from ..models import CustomProject, CustomUpdate, Period, Project, Update, all_owners from .board import resolve_period_ids, worst_status router = APIRouter() @router.get("/weekly") def weekly( period_id: int | None = None, year: int | None = None, month: int | None = None, owner: str | None = None, all_custom: bool = False, db: Session = Depends(get_db), ): period_ids = resolve_period_ids(db, period_id, year, month) if not period_ids: return {"periods": [], "projects": [], "custom": [], "kpi": {}} periods = db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all() scope_sort = max(p.sort_key for p in periods) # 本周期(月视图 = 该月所有周)的日期范围,用来判断「本周期新入库」, # 口径与「入库时间」一致:该月第一个周一算第 1 周 scope_start = min(constants.period_first_day(p.year, p.month, p.week) for p in periods) scope_end = max(constants.period_last_day(p.year, p.month, p.week) for p in periods) # 全部周次(算「连续几周没更新」用) all_weeks = [p.sort_key for p in db.query(Period).order_by(Period.sort_key).all()] week_index = {sk: i for i, sk in enumerate(all_weeks)} def idle_weeks(last_sort: int | None) -> int | None: if not last_sort or last_sort not in week_index: return None return max(0, week_index[scope_sort] - week_index[last_sort]) # ---------------------------------------------------------------- 常规项目 ups_by_project: dict[int, list[Update]] = defaultdict(list) for u in db.query(Update).filter(Update.period_id.in_(period_ids)).order_by(Update.id).all(): ups_by_project[u.project_id].append(u) def last_seen(model, fk) -> tuple[dict[int, int], dict[int, str]]: sorts: dict[int, int] = {} labels: dict[int, str] = {} rows = ( db.query( fk, Period.sort_key, Period.year, Period.month, Period.week ) .join(Period, Period.id == model.period_id) .order_by(Period.sort_key, model.id) .all() ) for oid, sk, yy, mm, ww in rows: sorts[oid] = sk labels[oid] = f"{yy}年{mm}月" + (f"第{ww}周" if ww else "") return sorts, labels last_sort, last_label = last_seen(Update, Update.project_id) project_rows = [] for p in db.query(Project).filter(Project.archived.is_(False)).order_by( Project.is_key.desc(), Project.sort_order, Project.id ).all(): items = ups_by_project.get(p.id, []) if owner and owner not in all_owners(p.owner, p.other_owners): continue if not (p.is_key or items): continue progresses = [u.progress for u in items if u.progress is not None] progress = round(max(progresses), 1) if progresses else round(p.progress or 0, 1) project_rows.append( { "kind": "project", "id": p.id, "name": p.name, "owner": p.owner, "is_key": p.is_key, "category": p.category.to_dict() if p.category else None, "status": worst_status([u.status for u in items]) if items else p.status, "progress": progress, "progress_source": "本周期" if progresses else "项目档案", "update_count": len(items), "has_update": bool(items), "summary": next((u.summary for u in items if u.summary), None) or (items[-1].content or "")[:120] if items else None, "risks": [u.risk for u in items if u.risk], "next_steps": [u.next_step for u in items if u.next_step], "updates": [u.to_dict() for u in items], "task_count": len(p.tasks), "last_update": last_label.get(p.id), "idle_weeks": idle_weeks(last_sort.get(p.id)), } ) # ---------------------------------------------------------------- 定制项目 cups_by_project: dict[int, list[CustomUpdate]] = defaultdict(list) for u in ( db.query(CustomUpdate).filter(CustomUpdate.period_id.in_(period_ids)).order_by(CustomUpdate.id).all() ): cups_by_project[u.custom_project_id].append(u) cup_last_sort, cup_last_label = last_seen(CustomUpdate, CustomUpdate.custom_project_id) custom_rows = [] inflight_stages = constants.CUSTOM_INFLIGHT_STAGES for cp in db.query(CustomProject).filter(CustomProject.archived.is_(False)).all(): items = cups_by_project.get(cp.id, []) if owner and cp.owner != owner: continue entry = (cp.entry_date or "").strip() is_new = bool(scope_start and scope_end and entry and scope_start <= entry <= scope_end) if not (cp.is_key or items or is_new): if not all_custom or cp.stage not in inflight_stages: continue d = cp.to_dict() d.update( { "kind": "custom", "is_new": is_new, "update_count": len(items), "has_update": bool(items), "summary": next((u.summary for u in items if u.summary), None) or next((u.content for u in items if u.content), None), "risks": [u.risk for u in items if u.risk], "next_steps": [u.next_step for u in items if u.next_step], "period_amount_delta": round(sum(u.amount_delta or 0 for u in items), 2), "period_revenue_delta": round(sum(u.revenue_delta or 0 for u in items), 2), "updates": [u.to_dict() for u in items], "idle_weeks": idle_weeks(cup_last_sort.get(cp.id)), "last_update": cup_last_label.get(cp.id), } ) custom_rows.append(d) # ---------------------------------------------------------------- 排序 & KPI def sort_key(r: dict): return ( not r["is_key"], not r["has_update"], r.get("status") != "blocked", -float(r.get("progress") or 0), r["name"], ) project_rows.sort(key=sort_key) custom_rows.sort( key=lambda r: ( not r["is_key"], not r["has_update"], -float(r.get("amount") or 0), r["name"], ) ) key_all = sum(1 for p in db.query(Project).filter(Project.archived.is_(False), Project.is_key.is_(True)).all()) risks = sum(len(r["risks"]) for r in project_rows) + sum(len(r["risks"]) for r in custom_rows) kpi = { "key_total": key_all, "key_updated": sum(1 for r in project_rows if r["is_key"] and r["has_update"]), "key_idle": sum(1 for r in project_rows if r["is_key"] and not r["has_update"]), "project_listed": len(project_rows), "update_count": sum(r["update_count"] for r in project_rows), "custom_total": len(custom_rows), "custom_new": sum(1 for r in custom_rows if r["is_new"]), "custom_updated": sum(1 for r in custom_rows if r["has_update"]), "custom_amount": round(sum(float(r.get("amount") or 0) for r in custom_rows), 2), "amount_delta": round(sum(r["period_amount_delta"] for r in custom_rows), 2), "revenue_delta": round(sum(r["period_revenue_delta"] for r in custom_rows), 2), "risk_count": risks, } return { "periods": [p.to_dict() for p in periods], "projects": project_rows, "custom": custom_rows, "kpi": kpi, }