unis_manager/app/routers/weekly.py

205 lines
9.0 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.

"""周看板:重点项目 + 定开项目的单周总览。
左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐:
· 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目
· 定开项目 = 打了 ★ 的定开项目,本周期有交付记录的,以及本周期新入库的(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)
# 「本周新增 / 本周验收」与「定开项目 · 本周进展」同一口径(见 web/app.js 的
# customWeeklySections):新增按「下单时间」,验收按「更新时间 + 状态=已验收」。
sign = (cp.sign_date or "").strip()[:10]
upd = (cp.update_date or "").strip()[:10]
is_signed_new = bool(scope_start and scope_end and sign and scope_start <= sign <= scope_end)
is_accepted = bool(
scope_start
and scope_end
and upd
and scope_start <= upd <= scope_end
and cp.project_status == "已验收"
)
if not (cp.is_key or items or is_new or is_signed_new or is_accepted):
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,
}