# backend/app/services/risk.py from sqlmodel import Session, select from ..config import get_settings from ..models import Person, Project, Task from ..schemas import RiskOut STATUS_LABEL = {"todo": "시작 전", "doing": "진행 중", "waiting": "대기 중", "review": "검토 중"} DEPS = [ {"blocker": "예산 섹션 작성", "blocked": "경영진 검토 요청 메일"}, {"blocker": "데이터 전처리 파이프라인", "blocked": "사용자 인터뷰 5건 정리"}, ] def _due_day(t: Task) -> int: return t.due.day if t.due else 99 def _due_txt(t: Task) -> str: return f"6/{t.due.day}" if t.due else "" def _status_value(t: Task) -> str: return t.status.value if hasattr(t.status, "value") else t.status def compute_risks(s: Session, area: str = "work") -> list[RiskOut]: today = get_settings().risk_today # 8 all_tasks = s.exec(select(Task)).all() people = {p.id: p for p in s.exec(select(Person)).all()} # area 필터: 작업이 속한 project 의 folder 로 판정 proj_folder = {p.id: p.folder_id for p in s.exec(select(Project)).all()} def in_area(t: Task) -> bool: return proj_folder.get(t.project_id) == area scoped = [t for t in all_tasks if in_area(t)] roots = [t for t in scoped if t.parent_id is None] risks: list[RiskOut] = [] # ① 지연 위험 (최상위 작업, 첫 1건) late = [ t for t in sorted(roots, key=lambda x: x.sort_order) if _status_value(t) != "done" and t.due and _due_day(t) <= today ] if late: t = late[0] risks.append( RiskOut( kind="지연 위험", icon="clock", tone="coral", task_id=t.id, cta="작업 열기", text=f"**{t.title}** — 오늘({_due_txt(t)}) 마감인데 아직 {STATUS_LABEL.get(_status_value(t), '')}이에요", # noqa: E501 ) ) # ② 업무 쏠림 (전체 노드 카운트) counts: dict[str, int] = {} for n in scoped: if _status_value(n) != "done" and n.assignee_id: counts[n.assignee_id] = counts.get(n.assignee_id, 0) + 1 entries = [(w, c) for w, c in counts.items() if w in people] if len(entries) > 1: entries.sort(key=lambda x: -x[1]) top_who, top_n = entries[0] avg = sum(c for _, c in entries) / len(entries) if top_n >= avg * 1.5 and top_n >= 4: mult = round(top_n / avg, 1) p = people[top_who] if p.is_me: text = ( f"미완료 작업 **{top_n}건**이 내게 몰려 있어요 — 팀 평균의 **{mult}배**. " f"벅찬 작업은 위임 & 추적으로 넘겨보세요" ) else: text = ( f"**{p.name}님**에게 미완료 작업 **{top_n}건**이 몰려 있어요 — " f"평균의 **{mult}배**, 일부 재배분을 추천드려요" ) risks.append(RiskOut(kind="업무 쏠림", icon="scale", tone="amber", text=text)) # ③ 의존성 (가장 임박한 1건) def find_by_title(title: str) -> Task | None: for n in scoped: if n.title == title: return n return None for d in DEPS: a, b = find_by_title(d["blocker"]), find_by_title(d["blocked"]) if a and b and _status_value(a) != "done" and _status_value(b) != "done": suffix = f"({_due_txt(b)})" if b.due else "" risks.append( RiskOut( kind="의존성", icon="link", tone="violet", task_id=b.id, cta="후속 작업 보기", text=f"**{d['blocker']}**이(가) 늦어지면 **{d['blocked']}**{suffix}까지 함께 밀려요", # noqa: E501 ) ) break return risks[:3]