PMFlow/backend/app/services/adaptive_capacity_service.py

615 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Adaptive human capacity planning for PMFlow Team Layer and Space."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from typing import Any, Optional
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import DevWorkspaceBinding, HumanCapacityOverride, Project, User, UserProject, UserWorkload
from ..schemas.pmflow_os import (
PMFlowCapacityFit,
PMFlowCapacityOverridePayload,
PMFlowCapacityOverrideRecord,
PMFlowCapacityOverrideSimulation,
PMFlowDailyCapacityPlan,
PMFlowHumanTouchEstimate,
PMFlowHumanWorkType,
PMFlowMemberCapacityPlan,
PMFlowWorkTypeDistribution,
PMFlowWorkTypeMinutes,
)
from ..utils.timezone import now_cn_naive
from .pmflow_os_projection_service import PMFlowOSProjectionService
WORK_TYPES: tuple[PMFlowHumanWorkType, ...] = ("focus", "review", "acceptance", "coordination")
@dataclass
class _MemberContext:
user: User
tools: list[str]
workload_ratio: float
class AdaptiveCapacityService:
"""Computes daily capacity plans from current task/review/execution facts plus overrides."""
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
self.db = db
self.tenant_id = int(tenant_id)
async def get_plan(
self,
*,
project_id: int,
current_user_id: int,
plan_date: Optional[date] = None,
extra_override: Optional[PMFlowCapacityOverridePayload] = None,
) -> PMFlowDailyCapacityPlan:
plan_date = plan_date or now_cn_naive().date()
projection = PMFlowOSProjectionService(self.db, tenant_id=self.tenant_id)
truth_state = await projection.get_truth_state(project_id=project_id, current_user_id=current_user_id)
execution_summary = await projection.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
members = await self._load_members(project_id=project_id)
project = await self._load_project(project_id=project_id)
member_ids = [int(ctx.user.id) for ctx in members]
overrides = await self._load_active_overrides(project_id=project_id, plan_date=plan_date, member_ids=member_ids)
if extra_override is not None:
overrides[int(extra_override.userId)] = self._payload_to_record(extra_override, plan_date=plan_date, created_by=current_user_id)
touches = self._build_touches(
project_id=project_id,
owner_id=int(project.owner_id or current_user_id) if project else current_user_id,
member_ids=member_ids,
truth_state=truth_state,
execution_summary=execution_summary,
)
team_minutes = self._sum_minutes(touches)
team_mix = self._distribution(team_minutes)
plans = [
self._member_plan(
ctx=ctx,
project_id=project_id,
team_mix=team_mix,
touches=[touch for touch in touches if str(touch.assignedUserId or "") == str(ctx.user.id)],
override=overrides.get(int(ctx.user.id)),
)
for ctx in members
]
team_effective = sum(item.effectiveMinutes for item in plans)
warnings = list(execution_summary.warnings[:6])
if not members:
warnings.append("capacity_members_empty: 当前项目没有可计算人类容量的成员。")
if not touches:
warnings.append("capacity_touches_empty: 当前没有真实人工触点,容量计划保持空态。")
recommendations = self._recommendations(touches=touches, plans=plans)
return PMFlowDailyCapacityPlan(
projectId=str(project_id),
date=plan_date.isoformat(),
generatedAt=now_cn_naive().isoformat(),
teamDemandMix=team_mix,
teamEffectiveMinutes=team_effective,
teamPlannedTouchMinutes=team_minutes,
memberPlans=plans,
humanTouches=touches[:80],
warnings=warnings[:12],
recommendations=recommendations[:8],
)
async def simulate_override(
self,
*,
project_id: int,
current_user_id: int,
payload: PMFlowCapacityOverridePayload,
) -> PMFlowCapacityOverrideSimulation:
plan_date = self._parse_date(payload.date)
before = await self.get_plan(project_id=project_id, current_user_id=current_user_id, plan_date=plan_date)
after = await self.get_plan(
project_id=project_id,
current_user_id=current_user_id,
plan_date=plan_date,
extra_override=payload,
)
before_member = self._find_member_plan(before, payload.userId)
after_member = self._find_member_plan(after, payload.userId)
impact = [
f"有效容量 {before_member.effectiveMinutes}min -> {after_member.effectiveMinutes}min",
f"剩余可计划容量 {before_member.remainingMinutes}min -> {after_member.remainingMinutes}min",
f"状态 {before_member.capacityStatus} -> {after_member.capacityStatus}",
]
if payload.addedMinutes:
impact.append("加班只增加时间预算,不消除疲劳与高风险判断密度。")
return PMFlowCapacityOverrideSimulation(beforePlan=before_member, afterPlan=after_member, impactSummary=impact, warnings=after.warnings[:6])
async def apply_override(
self,
*,
project_id: int,
current_user_id: int,
payload: PMFlowCapacityOverridePayload,
) -> PMFlowCapacityOverrideRecord:
plan_date = self._parse_date(payload.date)
expires_at = self._parse_expires(payload.expiresAt, plan_date=plan_date)
record = HumanCapacityOverride(
tenant_id=self.tenant_id,
project_id=int(project_id),
user_id=int(payload.userId),
date=plan_date,
override_type=payload.overrideType,
added_minutes=int(payload.addedMinutes or 0),
overtime_type=payload.overtimeType,
max_planned_utilization=payload.maxPlannedUtilization,
work_type_bias=dict(payload.workTypeBias or {}),
high_risk_limit=payload.highRiskLimit,
reason=payload.reason.strip(),
expires_at=expires_at,
created_by=int(current_user_id),
)
self.db.add(record)
await self.db.commit()
await self.db.refresh(record)
return self._override_record(record)
async def list_overrides(self, *, project_id: int, plan_date: Optional[date] = None) -> list[PMFlowCapacityOverrideRecord]:
plan_date = plan_date or now_cn_naive().date()
rows = (
await self.db.execute(
select(HumanCapacityOverride)
.where(
and_(
HumanCapacityOverride.tenant_id == self.tenant_id,
HumanCapacityOverride.project_id == int(project_id),
HumanCapacityOverride.date == plan_date,
)
)
.order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc())
)
).scalars().all()
return [self._override_record(row) for row in rows]
async def _load_project(self, *, project_id: int) -> Optional[Project]:
return (
await self.db.execute(
select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == int(project_id)))
)
).scalar_one_or_none()
async def _load_members(self, *, project_id: int) -> list[_MemberContext]:
rows = (
await self.db.execute(
select(User)
.join(UserProject, UserProject.user_id == User.id)
.where(
and_(
User.tenant_id == self.tenant_id,
UserProject.tenant_id == self.tenant_id,
UserProject.project_id == int(project_id),
User.is_active.is_(True),
)
)
.order_by(User.id.asc())
)
).scalars().all()
member_ids = [int(user.id) for user in rows]
tool_map = await self._load_tools(project_id=project_id, member_ids=member_ids)
workload_map = await self._load_workloads(member_ids=member_ids)
return [
_MemberContext(user=user, tools=tool_map.get(int(user.id), []), workload_ratio=workload_map.get(int(user.id), 0.0))
for user in rows
]
async def _load_tools(self, *, project_id: int, member_ids: list[int]) -> dict[int, list[str]]:
if not member_ids:
return {}
rows = (
await self.db.execute(
select(DevWorkspaceBinding.user_id, DevWorkspaceBinding.editor_type).where(
and_(
DevWorkspaceBinding.tenant_id == self.tenant_id,
DevWorkspaceBinding.project_id == int(project_id),
DevWorkspaceBinding.user_id.in_(member_ids),
DevWorkspaceBinding.status == "active",
)
)
)
).all()
labels = {"cursor": "Cursor", "vscode": "VS Code", "codex": "Codex"}
tool_map: dict[int, list[str]] = {}
for user_id, editor_type in rows:
tool_map.setdefault(int(user_id), [])
label = labels.get(str(editor_type or "").lower(), str(editor_type or "Local Bridge"))
if label not in tool_map[int(user_id)]:
tool_map[int(user_id)].append(label)
return tool_map
async def _load_workloads(self, *, member_ids: list[int]) -> dict[int, float]:
if not member_ids:
return {}
rows = (
await self.db.execute(
select(UserWorkload.user_id, UserWorkload.workload_ratio).where(
and_(UserWorkload.tenant_id == self.tenant_id, UserWorkload.user_id.in_(member_ids))
)
)
).all()
return {int(user_id): float(ratio or 0.0) for user_id, ratio in rows}
async def _load_active_overrides(
self,
*,
project_id: int,
plan_date: date,
member_ids: list[int],
) -> dict[int, PMFlowCapacityOverrideRecord]:
if not member_ids:
return {}
rows = (
await self.db.execute(
select(HumanCapacityOverride)
.where(
and_(
HumanCapacityOverride.tenant_id == self.tenant_id,
HumanCapacityOverride.project_id == int(project_id),
HumanCapacityOverride.user_id.in_(member_ids),
HumanCapacityOverride.date == plan_date,
HumanCapacityOverride.expires_at >= now_cn_naive(),
)
)
.order_by(HumanCapacityOverride.created_at.desc(), HumanCapacityOverride.id.desc())
)
).scalars().all()
overrides: dict[int, PMFlowCapacityOverrideRecord] = {}
for row in rows:
overrides.setdefault(int(row.user_id), self._override_record(row))
return overrides
def _build_touches(self, *, project_id: int, owner_id: int, member_ids: list[int], truth_state: Any, execution_summary: Any) -> list[PMFlowHumanTouchEstimate]:
touches: list[PMFlowHumanTouchEstimate] = []
fallback_user = str(owner_id if owner_id in member_ids else (member_ids[0] if member_ids else owner_id))
for item in getattr(truth_state, "changeItems", []) or []:
if self._get(item, "status") != "pending_review":
continue
risk = self._risk(self._get(item, "riskLevel") or self._get(item, "risk_level"))
minutes = 60 if risk == "high" else 35 if risk == "medium" else 20
touches.append(
PMFlowHumanTouchEstimate(
sourceType="changeset_confirmation",
sourceId=str(self._get(item, "id") or f"change-{len(touches)}"),
targetHref=f"/change-review?projectId={project_id}&changeSetId={self._get(item, 'changeSetId') or ''}",
workType="focus",
estimatedMinutes=minutes,
riskLevel=risk,
urgency="urgent" if risk == "high" else "normal",
bindingType="hard_bound",
assignedUserId=fallback_user,
roleBinding="OWNER",
contextReadiness="partial",
aiReductionPotentialMinutes=25 if risk != "low" else 10,
title=str(self._get(item, "title") or "待确认 ChangeSet"),
summary=str(self._get(item, "summary") or "需要人类确认 AI 提取结果。"),
)
)
for routing in getattr(getattr(truth_state, "reviewerRouting", None), "items", []) or []:
reviewer = getattr(routing, "suggestedReviewer", None)
assigned = str(getattr(reviewer, "userId", "") or fallback_user)
risk = self._risk(getattr(routing, "riskLevel", "low"))
touches.append(
PMFlowHumanTouchEstimate(
sourceType="review",
sourceId=str(getattr(routing, "changeSetId", "")),
targetHref=str(getattr(routing, "targetUrl", "") or f"/change-review?projectId={project_id}"),
workType="review",
estimatedMinutes=75 if risk == "high" else 45 if risk == "medium" else 30,
riskLevel=risk,
urgency=str(getattr(routing, "priority", "normal")),
bindingType="role_bound",
assignedUserId=assigned,
roleBinding=str(getattr(routing, "fallbackReviewerRole", None) or "REVIEWER"),
contextReadiness="partial",
aiReductionPotentialMinutes=30 if risk == "high" else 15,
title=str(getattr(routing, "changeSetTitle", "待审 ChangeSet")),
summary=str(getattr(routing, "reviewReason", "需要人工审查 ChangeSet。")),
)
)
for idx, event in enumerate(execution_summary.recentProgressEvents or []):
event_type = str(event.get("event_type") or "")
if event_type not in {"pr_opened", "test_passed", "checks_passed", "handoff_consumed", "failed", "blocked"}:
continue
work_type: PMFlowHumanWorkType = "coordination" if event_type in {"failed", "blocked"} else "acceptance"
touches.append(
PMFlowHumanTouchEstimate(
sourceType="ai_failure_recovery" if work_type == "coordination" else "acceptance",
sourceId=str(event.get("id") or f"event-{idx}"),
targetHref=f"/execution-bridge?projectId={project_id}",
workType=work_type,
estimatedMinutes=45 if work_type == "coordination" else 30,
riskLevel="medium" if work_type == "coordination" else "low",
urgency="elevated" if work_type == "coordination" else "normal",
bindingType="ai_shiftable",
assignedUserId=str(event.get("user_id") or fallback_user),
roleBinding="BUILDER",
contextReadiness="partial",
aiReductionPotentialMinutes=20,
title=str(event.get("summary") or "AI 执行结果待处理"),
summary="AI 执行结果需要验收或阻塞恢复。",
)
)
if not execution_summary.repoBindings:
touches.append(self._coordination_touch(project_id, fallback_user, "缺少 repo 绑定", "未接入 repo,AI 执行包缺少真实代码上下文。", 30))
if not execution_summary.promptPackage:
touches.append(self._coordination_touch(project_id, fallback_user, "缺少执行包", "尚未生成可交给 Cursor / VS Code / Codex 的执行包。", 20))
return touches
def _coordination_touch(self, project_id: int, assigned_user_id: str, title: str, summary: str, minutes: int) -> PMFlowHumanTouchEstimate:
return PMFlowHumanTouchEstimate(
sourceType="context_completion",
sourceId=title,
targetHref=f"/execution-bridge?projectId={project_id}",
workType="coordination",
estimatedMinutes=minutes,
riskLevel="medium",
urgency="normal",
bindingType="ai_shiftable",
assignedUserId=assigned_user_id,
roleBinding="BUILDER",
contextReadiness="blocked",
aiReductionPotentialMinutes=10,
title=title,
summary=summary,
)
def _member_plan(
self,
*,
ctx: _MemberContext,
project_id: int,
team_mix: PMFlowWorkTypeDistribution,
touches: list[PMFlowHumanTouchEstimate],
override: Optional[PMFlowCapacityOverrideRecord],
) -> PMFlowMemberCapacityPlan:
base_minutes = 480
added = int(override.addedMinutes if override else 0)
effective = base_minutes + added
max_util = float(override.maxPlannedUtilization if override and override.maxPlannedUtilization else 0.85)
planned = self._sum_minutes(touches)
current_mix = self._distribution(planned)
baseline = self._suggested_baseline(role=str(ctx.user.role or ""), team_mix=team_mix, planned=planned, override=override)
utilization = planned.total / max(effective, 1)
capacity_status = self._capacity_status(utilization=utilization, override=override, tools=ctx.tools)
remaining = round(effective * max_util - planned.total)
bottleneck = self._bottleneck(planned)
explanations = self._explanations(ctx=ctx, planned=planned, baseline=baseline, override=override, touches=touches)
return PMFlowMemberCapacityPlan(
userId=str(ctx.user.id),
displayName=str(ctx.user.name or ctx.user.username),
role=str(ctx.user.role or "MEMBER"),
baseAvailableMinutes=base_minutes,
calendarBlockedMinutes=0,
effectiveMinutes=effective,
reservedBufferMinutes=round(effective * max(0.0, 1 - max_util)),
maxPlannedUtilization=max_util,
suggestedBaseline=baseline,
currentMix=current_mix,
plannedTouchMinutes=planned,
capacityStatus=capacity_status,
remainingMinutes=remaining,
bottleneckType=bottleneck,
fatigueRisk=self._fatigue_risk(planned=planned, effective=effective),
riskDensity={
"high": len([touch for touch in touches if touch.riskLevel == "high"]),
"medium": len([touch for touch in touches if touch.riskLevel == "medium"]),
"total": len(touches),
},
canTakeMore=self._capacity_fit(project_id=project_id, planned=planned, effective=effective, max_util=max_util),
manualOverride=override,
explanations=explanations,
)
def _suggested_baseline(
self,
*,
role: str,
team_mix: PMFlowWorkTypeDistribution,
planned: PMFlowWorkTypeMinutes,
override: Optional[PMFlowCapacityOverrideRecord],
) -> PMFlowWorkTypeDistribution:
assigned_mix = self._distribution(planned) if planned.total else None
base = {key: getattr(team_mix, key) for key in WORK_TYPES}
if assigned_mix and planned.total:
base = {key: (getattr(assigned_mix, key) * 0.7) + (base[key] * 0.3) for key in WORK_TYPES}
elif not any(base.values()):
role_token = role.upper()
if "OWNER" in role_token:
base = {"focus": 0.5, "review": 0.15, "acceptance": 0.2, "coordination": 0.15}
elif "REVIEWER" in role_token:
base = {"focus": 0.2, "review": 0.5, "acceptance": 0.2, "coordination": 0.1}
elif "OPERATOR" in role_token:
base = {"focus": 0.15, "review": 0.1, "acceptance": 0.25, "coordination": 0.5}
else:
base = {"focus": 0.25, "review": 0.2, "acceptance": 0.35, "coordination": 0.2}
if override and override.workTypeBias:
for key in WORK_TYPES:
if key in override.workTypeBias:
base[key] = max(0.0, float(override.workTypeBias[key]))
return self._normalize_distribution(base)
def _capacity_fit(self, *, project_id: int, planned: PMFlowWorkTypeMinutes, effective: int, max_util: float) -> list[PMFlowCapacityFit]:
candidates: list[tuple[PMFlowHumanWorkType, str, int]] = [
("focus", "低/中风险判断", 45),
("review", "普通审查", 45),
("acceptance", "AI 结果验收", 30),
("coordination", "补上下文/工具阻塞", 25),
]
result: list[PMFlowCapacityFit] = []
for work_type, label, minutes in candidates:
next_total = planned.total + minutes
next_type = getattr(planned, work_type) + minutes
total_util = next_total / max(effective, 1)
type_share = next_type / max(effective, 1)
status = "recommended"
reason = "加入后仍处于可计划容量内。"
if total_util > max_util * 1.1:
status, reason = "not_recommended", "加入后超过今日建议计划容量。"
elif total_util > max_util or type_share > self._soft_share(work_type):
status, reason = "allowed_with_warning", "加入后接近容量或该类型疲劳风险上升。"
result.append(
PMFlowCapacityFit(
workType=work_type,
label=label,
status=status,
estimatedMinutes=minutes,
afterTotalUtilization=round(total_util, 3),
afterTypeShare=round(type_share, 3),
reason=reason,
)
)
return result
def _recommendations(self, *, touches: list[PMFlowHumanTouchEstimate], plans: list[PMFlowMemberCapacityPlan]) -> list[str]:
reduction = sum(max(0, touch.aiReductionPotentialMinutes) for touch in touches)
recommendations: list[str] = []
if reduction:
recommendations.append(f"建议先让 AI 预处理可转移触点,预计释放 {reduction} 分钟人工判断/审查容量。")
overloaded = [plan.displayName for plan in plans if plan.capacityStatus in {"near_full", "overloaded"}]
if overloaded:
recommendations.append(f"{'、'.join(overloaded[:3])} 接近或超过今日建议容量,新增任务前需要模拟。")
if not recommendations:
recommendations.append("当前容量计划未发现明显瓶颈,可继续按执行能力图谱推进。")
return recommendations
def _sum_minutes(self, touches: list[PMFlowHumanTouchEstimate]) -> PMFlowWorkTypeMinutes:
totals = {key: 0 for key in WORK_TYPES}
for touch in touches:
totals[touch.workType] += int(touch.estimatedMinutes or 0)
return PMFlowWorkTypeMinutes(**totals, total=sum(totals.values()))
def _distribution(self, minutes: PMFlowWorkTypeMinutes) -> PMFlowWorkTypeDistribution:
total = max(minutes.total, 1)
return PMFlowWorkTypeDistribution(**{key: round(getattr(minutes, key) / total, 3) for key in WORK_TYPES})
def _normalize_distribution(self, values: dict[str, float]) -> PMFlowWorkTypeDistribution:
total = sum(max(0.0, float(values.get(key, 0.0))) for key in WORK_TYPES) or 1.0
return PMFlowWorkTypeDistribution(**{key: round(max(0.0, float(values.get(key, 0.0))) / total, 3) for key in WORK_TYPES})
def _capacity_status(self, *, utilization: float, override: Optional[PMFlowCapacityOverrideRecord], tools: list[str]) -> str:
if override and override.overrideType == "lock_capacity":
return "blocked"
if utilization > 1.1:
return "overloaded"
if override and override.addedMinutes > 0:
return "manually_extended"
if utilization >= 0.9:
return "near_full"
if utilization >= 0.7:
return "attention"
return "normal"
def _fatigue_risk(self, *, planned: PMFlowWorkTypeMinutes, effective: int) -> dict[str, str]:
risks: dict[str, str] = {}
for key in WORK_TYPES:
share = getattr(planned, key) / max(effective, 1)
if share > self._hard_share(key):
risks[key] = "high"
elif share > self._soft_share(key):
risks[key] = "medium"
else:
risks[key] = "low"
return risks
def _soft_share(self, work_type: str) -> float:
return {"focus": 0.7, "review": 0.65, "acceptance": 0.75, "coordination": 0.6}.get(work_type, 0.7)
def _hard_share(self, work_type: str) -> float:
return {"focus": 0.85, "review": 0.8, "acceptance": 0.9, "coordination": 0.75}.get(work_type, 0.85)
def _bottleneck(self, minutes: PMFlowWorkTypeMinutes) -> Optional[str]:
values = {key: getattr(minutes, key) for key in WORK_TYPES}
if not any(values.values()):
return None
return max(values.items(), key=lambda item: item[1])[0]
def _explanations(
self,
*,
ctx: _MemberContext,
planned: PMFlowWorkTypeMinutes,
baseline: PMFlowWorkTypeDistribution,
override: Optional[PMFlowCapacityOverrideRecord],
touches: list[PMFlowHumanTouchEstimate],
) -> list[str]:
explanations = [
f"今日动态基线来自当前任务池:深度判断 {round(baseline.focus * 100)}%,审查 {round(baseline.review * 100)}%,验收 {round(baseline.acceptance * 100)}%,协调 {round(baseline.coordination * 100)}%。",
f"当前已计划人工触点 {planned.total} 分钟,包含 {len(touches)} 个真实触点。",
]
if override:
explanations.append(f"负责人已手工调整:{override.reason}")
if not ctx.tools:
explanations.append("未检测到活跃 Cursor / VS Code / Codex 工具连接,工具准备度会影响可承接建议。")
return explanations[:4]
def _payload_to_record(self, payload: PMFlowCapacityOverridePayload, *, plan_date: date, created_by: int) -> PMFlowCapacityOverrideRecord:
return PMFlowCapacityOverrideRecord(
id="simulated",
userId=str(payload.userId),
date=plan_date.isoformat(),
overrideType=payload.overrideType,
addedMinutes=int(payload.addedMinutes or 0),
overtimeType=payload.overtimeType,
maxPlannedUtilization=payload.maxPlannedUtilization,
workTypeBias=dict(payload.workTypeBias or {}),
highRiskLimit=payload.highRiskLimit,
reason=payload.reason,
expiresAt=self._parse_expires(payload.expiresAt, plan_date=plan_date).isoformat(),
createdBy=str(created_by),
createdAt=None,
)
def _override_record(self, row: HumanCapacityOverride) -> PMFlowCapacityOverrideRecord:
return PMFlowCapacityOverrideRecord(
id=str(row.id),
userId=str(row.user_id),
date=row.date.isoformat(),
overrideType=row.override_type,
addedMinutes=int(row.added_minutes or 0),
overtimeType=row.overtime_type,
maxPlannedUtilization=row.max_planned_utilization,
workTypeBias=dict(row.work_type_bias or {}),
highRiskLimit=row.high_risk_limit,
reason=row.reason,
expiresAt=row.expires_at.isoformat(),
createdBy=str(row.created_by),
createdAt=row.created_at.isoformat() if row.created_at else None,
)
def _find_member_plan(self, plan: PMFlowDailyCapacityPlan, user_id: str) -> PMFlowMemberCapacityPlan:
for item in plan.memberPlans:
if str(item.userId) == str(user_id):
return item
raise ValueError("capacity_member_not_found")
def _parse_date(self, value: Optional[str]) -> date:
if not value:
return now_cn_naive().date()
return date.fromisoformat(str(value)[:10])
def _parse_expires(self, value: Optional[str], *, plan_date: date) -> datetime:
if value:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return parsed.replace(tzinfo=None)
return datetime.combine(plan_date, time(23, 59, 59))
def _risk(self, value: Any) -> str:
token = str(value or "low").lower()
return token if token in {"low", "medium", "high"} else "low"
def _get(self, obj: Any, key: str) -> Any:
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)