193 lines
8.4 KiB
Python
193 lines
8.4 KiB
Python
"""周看板:重点项目 + 定开项目的单周总览。
|
||
|
||
左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐:
|
||
· 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目
|
||
· 定开项目 = 打了 ★ 的定开项目,本周期有交付记录的,以及本周期新入库的(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,
|
||
}
|