627 lines
33 KiB
Python
627 lines
33 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""业务层 · 任务状态机 v3(抢单 / 报名 / 招标 / 指派 / 推荐 → 统一履约链)。
|
||
|
||
v3 核心变更(0056):
|
||
1. claim 双写:所有匹配方式(grab/register_select/assign/select_recommend/win_bid)
|
||
统一落 TaskClaim + 翻转 tasks 状态;履约动作(start/deliver/accept)同步 claim。
|
||
2. 归属校验:start_doing / deliver / accept / withdraw 必须校验操作者为接单人。
|
||
3. 统一状态机:claimed → doing → delivered → accepted(completed);旧 in_progress 语义并入 doing。
|
||
4. 验收链路:delivered → accepted(completed) / reject 返工(doing),返工上限 3 次。
|
||
5. 退出与超时:claimed 可退出释放;扫描任务处理 24h 未开始释放 / 72h 自动验收 / 截止过期。
|
||
6. 资格预检扩展:信用等级下限、认证、发票能力、企业专属。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from fastapi import HTTPException
|
||
|
||
from ..infrastructure.repositories import Database
|
||
|
||
MAX_REJECT = 3
|
||
|
||
|
||
def _utcnow() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||
|
||
|
||
def _parse_ts(ts: str) -> datetime | None:
|
||
if not ts:
|
||
return None
|
||
try:
|
||
d = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||
if d.tzinfo is None:
|
||
d = d.replace(tzinfo=timezone.utc)
|
||
return d
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
|
||
def _hours_since(ts: str) -> float | None:
|
||
t = _parse_ts(ts)
|
||
if t is None:
|
||
return None
|
||
return (datetime.now(timezone.utc) - t).total_seconds() / 3600
|
||
|
||
|
||
def _sync_task_group(task_id: str) -> None:
|
||
"""任务群后台同步(fire-and-forget,IM 服务不可用时静默降级)。"""
|
||
try:
|
||
from ..im import client as im_client
|
||
|
||
asyncio.create_task(im_client.sync_task_group(task_id))
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
async def _credit_event(db: Database, user_id: str, code: str, reason: str,
|
||
ref_type: str = "", ref_id: str = "") -> None:
|
||
"""信用事件统一入口(引擎不可用时静默降级,不阻断业务)。"""
|
||
try:
|
||
from .credit_engine import on_credit_event
|
||
|
||
await on_credit_event(db, code, {
|
||
"user_id": user_id, "reason": reason,
|
||
"ref_type": ref_type, "ref_id": ref_id,
|
||
})
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
async def _notify(db: Database, user_id: str, ntype: str, title: str, content: str,
|
||
*, event_code: str = "", level: str = "info", link: str = "",
|
||
ref_id: str = "") -> None:
|
||
"""任务域通知:三通道(落库 + MQTT + SSE),失败静默。"""
|
||
try:
|
||
from .notification_service import notify as _notify_svc
|
||
|
||
await _notify_svc(db, user_id, ntype, title, content,
|
||
event_code=event_code, level=level, link=link,
|
||
ref_type="task", ref_id=ref_id or "")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
class TaskService:
|
||
"""统一任务状态流转 v3。"""
|
||
|
||
def __init__(self, db: Database):
|
||
self.db = db
|
||
|
||
# ── 资格预检引擎 v2 ─────────────────────────────────────────
|
||
|
||
async def _actor_profile(self, actor: dict) -> dict:
|
||
uid = actor.get("id")
|
||
if not uid:
|
||
return {}
|
||
full = (await self.db.users.get_by_id(uid)) or {}
|
||
credit = await self.db.credit_scores.get(uid)
|
||
return {
|
||
**full,
|
||
"credit_level": (credit or {}).get("level", "L1"),
|
||
"credit_score": (credit or {}).get("total_score", 0),
|
||
}
|
||
|
||
async def can_accept(self, task: dict | None, actor: dict | None) -> dict:
|
||
"""接单资格判定 v2:可见性 + 园区 + eligibility 扩展条件集。
|
||
|
||
eligibility 硬性条件(一票否决):opc_certified / region / gender /
|
||
years_min / field / skill / case_req / credit_level_min / certified /
|
||
invoice_ok / enterprise_only / deposit。
|
||
返回 {ok, reasons[], soft[]};reasons 为空即通过。
|
||
"""
|
||
if not task:
|
||
return {"ok": False, "reasons": ["任务不存在"], "soft": []}
|
||
reasons: list[str] = []
|
||
soft: list[str] = []
|
||
actor = actor or {}
|
||
full = await self._actor_profile(actor)
|
||
user_park = full.get("park_company_id") or full.get("park_id") or actor.get("park_id")
|
||
uid = actor.get("id")
|
||
|
||
if uid and task.get("visibility") == "assigned_only":
|
||
target = task.get("assign_opc_id") or task.get("assign_park_id")
|
||
if target and target != uid and target != user_park:
|
||
reasons.append("仅被指派方可接")
|
||
|
||
if task.get("park_id") and user_park != task.get("park_id"):
|
||
reasons.append("任务为本园区专属")
|
||
|
||
if task.get("assign_type") == "park" and not task.get("park_released") and task.get("assign_park_id") != user_park:
|
||
reasons.append("由园区分派")
|
||
|
||
try:
|
||
cond = json.loads(task.get("eligibility") or "{}")
|
||
except Exception: # noqa: BLE001
|
||
cond = {}
|
||
if not isinstance(cond, dict):
|
||
cond = {}
|
||
|
||
# 硬性一票否决
|
||
if cond.get("opc_certified") and not full.get("opc_certified") and not full.get("opc_cert"):
|
||
reasons.append("需 OPC 认证")
|
||
if cond.get("region") and cond["region"] != "any" and (full.get("region_id") or "") != cond["region"]:
|
||
reasons.append("地域不符")
|
||
if cond.get("gender") and full.get("gender") != cond["gender"]:
|
||
reasons.append("不符合性别要求")
|
||
if cond.get("years_min") and (int(full.get("exp_years") or 0) < int(cond["years_min"])):
|
||
reasons.append(f"需从业 {cond['years_min']} 年以上")
|
||
if cond.get("field") and cond["field"] not in (full.get("fields") or []) and cond["field"] not in (full.get("field") or ""):
|
||
reasons.append("领域不符")
|
||
if cond.get("skill") and cond["skill"] not in (full.get("skills") or []) and cond["skill"] not in (full.get("skill") or ""):
|
||
reasons.append("专长不符")
|
||
if cond.get("case_req") and (int(full.get("case_count") or 0) < int(cond["case_req"])):
|
||
reasons.append(f"需 {cond['case_req']} 例服务案例")
|
||
if cond.get("credit_level_min"):
|
||
required = str(cond["credit_level_min"]).upper().strip()
|
||
level = (full.get("credit_level") or "L1").upper().strip()
|
||
order = {"L1": 1, "L2": 2, "L3": 3, "L4": 4, "L5": 5, "L6": 6, "L7": 7, "L8": 8}
|
||
if order.get(level, 1) < order.get(required, 8):
|
||
reasons.append(f"需信用等级 {required} 及以上")
|
||
if cond.get("certified") and not full.get("certifications"):
|
||
reasons.append("需完成认证")
|
||
if cond.get("invoice_ok") and not full.get("can_invoice"):
|
||
reasons.append("需具备开票能力")
|
||
if cond.get("enterprise_only") and (full.get("account_type") or "") not in ("enterprise", "company"):
|
||
reasons.append("仅企业用户可接")
|
||
if cond.get("deposit") and int(cond["deposit"] or 0) > 0:
|
||
reasons.append("需缴纳接单保证金")
|
||
|
||
# 软性提示(不阻断)
|
||
if cond.get("price_range") and isinstance(cond["price_range"], dict):
|
||
pr = cond["price_range"]
|
||
low = int(pr.get("min") or 0)
|
||
if low > 0 and (task.get("budget_max") or 0) < low:
|
||
soft.append("预算低于习惯报价区间")
|
||
if full.get("credit_score") is not None and (full.get("credit_score") or 0) < 50:
|
||
soft.append("信用分较低,接单竞争力弱")
|
||
|
||
reasons = [r for r in reasons if r]
|
||
return {"ok": not reasons, "reasons": reasons, "soft": soft}
|
||
|
||
# ── 统一匹配落单(claim 双写)────────────────────────────────
|
||
|
||
async def _match(self, task: dict, actor: dict, taker_id: str, taker_name: str,
|
||
source: str, claim_status: str = "claimed",
|
||
form: dict | None = None) -> dict:
|
||
"""匹配成功统一动作:任务 → claimed + TaskClaim 流水 + IM 同步 + 信用事件。"""
|
||
updated = await self.db.tasks.claim(task["id"], taker_id)
|
||
await self.db.task_claims.create(
|
||
task["id"], taker_id, taker_name, source=source, status=claim_status,
|
||
form=form or {},
|
||
)
|
||
_sync_task_group(task["id"])
|
||
await _credit_event(self.db, taker_id, "task_match", "任务匹配成功",
|
||
ref_type="task", ref_id=task["id"])
|
||
return updated
|
||
|
||
async def grab(self, task_id: str, actor: dict, form: dict | None = None) -> dict:
|
||
return await self.claim_by_id(task_id, actor, source="grab", form=form)
|
||
|
||
async def claim(self, task_code: str, actor: dict, source: str = "scan") -> dict:
|
||
task = await self.db.tasks.get_by_code(task_code)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return await self.claim_by_id(task["id"], actor, source=source)
|
||
|
||
async def claim_by_id(self, task_id: str, actor: dict, source: str,
|
||
form: dict | None = None) -> dict:
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] not in ("published", "claimed"):
|
||
raise HTTPException(status_code=400, detail="任务不可接单")
|
||
if task["mode"] != "grab":
|
||
raise HTTPException(status_code=400, detail="任务不支持扫码接单")
|
||
existing = await self.db.task_claims.get_by_task_and_user(task_id, actor["id"])
|
||
if existing and existing.get("status") not in ("withdrawn", "cancelled"):
|
||
raise HTTPException(status_code=400, detail="你已接取该任务")
|
||
active = await self.db.task_claims.count_active_by_task(task_id)
|
||
if task.get("exclusive") and active >= 1:
|
||
raise HTTPException(status_code=400, detail="该任务为独占,已被接单")
|
||
if (task.get("headcount") or 0) > 0 and active >= task["headcount"]:
|
||
raise HTTPException(status_code=400, detail="该任务接单人数已达上限")
|
||
acc = await self.can_accept(task, actor)
|
||
if not acc["ok"]:
|
||
raise HTTPException(status_code=403, detail="不满足接单条件:" + ";".join(acc["reasons"]))
|
||
return await self._match(task, actor, actor["id"],
|
||
actor.get("nickname") or actor.get("username", ""),
|
||
source, form=form)
|
||
|
||
async def assign(self, task_id: str, taker_user_id: str, actor: dict) -> dict:
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] != "published":
|
||
raise HTTPException(status_code=400, detail="任务不可指派")
|
||
if task["mode"] != "assign":
|
||
raise HTTPException(status_code=400, detail="任务不支持指派")
|
||
taker = await self.db.users.get_by_id(taker_user_id)
|
||
taker_name = (taker or {}).get("nickname") or (taker or {}).get("username") or taker_user_id
|
||
return await self._match(task, actor, taker_user_id, taker_name,
|
||
source="assign", claim_status="assigned")
|
||
|
||
async def recommend(self, task_id: str, candidates: list[str], actor: dict) -> list[dict]:
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] != "published":
|
||
raise HTTPException(status_code=400, detail="任务不可推荐")
|
||
if task["mode"] != "recommend":
|
||
raise HTTPException(status_code=400, detail="任务不支持推荐")
|
||
out: list[dict] = []
|
||
for uid in candidates or []:
|
||
u = await self.db.users.get_by_id(uid)
|
||
name = (u or {}).get("nickname") or (u or {}).get("username") or uid
|
||
record = await self.db.task_claims.create(
|
||
task_id, uid, name, source="recommend", status="recommended",
|
||
)
|
||
out.append(record)
|
||
return out
|
||
|
||
async def select_recommend(self, task_id: str, taker_user_id: str, actor: dict) -> dict:
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] != "published":
|
||
raise HTTPException(status_code=400, detail="任务不可选定")
|
||
taker = await self.db.users.get_by_id(taker_user_id)
|
||
taker_name = (taker or {}).get("nickname") or (taker or {}).get("username") or taker_user_id
|
||
for rec in await self.db.task_claims.list_by_task(task_id):
|
||
if rec.get("claimer_user_id") == taker_user_id:
|
||
await self.db.task_claims.set_status(rec["id"], "assigned")
|
||
elif rec.get("status") == "recommended":
|
||
await self.db.task_claims.set_status(rec["id"], "withdrawn")
|
||
return await self._match(task, actor, taker_user_id, taker_name,
|
||
source="recommend", claim_status="assigned")
|
||
|
||
async def register(self, task_id: str, actor: dict, form: dict | None = None) -> dict:
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None or task["status"] != "published" or task["mode"] != "register":
|
||
raise HTTPException(status_code=400, detail="任务不可报名")
|
||
for c in await self.db.task_claims.list_by_task(task_id):
|
||
if c.get("claimer_user_id") == actor["id"] and c.get("status") not in ("withdrawn", "cancelled"):
|
||
raise HTTPException(status_code=400, detail="你已报名该任务")
|
||
quota = task.get("register_quota") or 0
|
||
if quota > 0:
|
||
active = sum(1 for c in await self.db.task_claims.list_by_task(task_id)
|
||
if c.get("claim_source") == "register" and c.get("status") == "registered")
|
||
if active >= quota:
|
||
raise HTTPException(status_code=400, detail="该任务报名人数已达上限")
|
||
acc = await self.can_accept(task, actor)
|
||
if not acc["ok"]:
|
||
raise HTTPException(status_code=403, detail="不满足报名条件:" + ";".join(acc["reasons"]))
|
||
return await self.db.task_claims.create(
|
||
task_id, actor["id"], actor.get("nickname") or actor.get("username", ""),
|
||
source="register", status="registered", form=form or {},
|
||
)
|
||
|
||
async def register_select(self, task_id: str, claim_id: str, actor: dict) -> dict:
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
for c in await self.db.task_claims.list_by_task(task_id):
|
||
if c.get("claim_source") == "register" and c.get("status") == "registered":
|
||
target = "assigned" if c["id"] == claim_id else "withdrawn"
|
||
await self.db.task_claims.set_status(c["id"], target)
|
||
chosen = await self.db.task_claims.get(claim_id)
|
||
if chosen is None:
|
||
raise HTTPException(status_code=404, detail="报名记录不存在")
|
||
return await self._match(task, actor, chosen.get("claimer_user_id") or "",
|
||
chosen.get("claimer_name") or "",
|
||
source="register", claim_status="assigned")
|
||
|
||
async def bid(self, task_id: str, actor: dict, quote: int, plan: str,
|
||
material: str = "", deliver_days: int = 0,
|
||
team_json: str = "[]", cases_json: str = "[]",
|
||
commit_json: str = "{}") -> dict:
|
||
"""投标 v2:六段标书(报价/方案/材料/工期/团队/案例/承诺)。"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None or task["status"] != "published" or task["mode"] != "bid":
|
||
raise HTTPException(status_code=400, detail="任务不可投标")
|
||
acc = await self.can_accept(task, actor)
|
||
if not acc["ok"]:
|
||
raise HTTPException(status_code=403, detail="不符合报名条件:" + ";".join(acc["reasons"]))
|
||
if task.get("bid_quota"):
|
||
existing = await self.db.bids.list_for_task(task_id)
|
||
if len(existing) >= task["bid_quota"]:
|
||
raise HTTPException(status_code=400, detail="该竞标报名人数已达上限")
|
||
for b in await self.db.bids.list_for_task(task_id):
|
||
if b.get("opc_id") == actor["id"]:
|
||
raise HTTPException(status_code=400, detail="你已报名该竞标")
|
||
return await self.db.bids.create(
|
||
task_id, actor["id"], actor.get("nickname") or actor["username"],
|
||
quote, plan, material, deliver_days, team_json, cases_json, commit_json,
|
||
)
|
||
|
||
async def review_bid(self, task_id: str, bid_id: str, actor: dict,
|
||
scores: dict | None = None, note: str = "") -> dict:
|
||
"""评审评分(招标):综合得分 = 报价30% + 方案35% + 工期10% + 团队10% + 信用15%。
|
||
|
||
scores 可选各维度 0-100;缺省维度从标书推导(报价按预算区间、工期按 delivery_days)。
|
||
"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
bid = await self.db.bids.get(bid_id)
|
||
if bid is None or bid["task_id"] != task_id:
|
||
raise HTTPException(status_code=404, detail="竞标不存在")
|
||
scores = scores or {}
|
||
quote_score = float(scores.get("quote", 0))
|
||
plan_score = float(scores.get("plan", 0))
|
||
days_score = float(scores.get("days", 0))
|
||
team_score = float(scores.get("team", 0))
|
||
credit_score = float(scores.get("credit", 0))
|
||
# 缺省推导
|
||
if quote_score <= 0:
|
||
q = bid.get("quote") or 0
|
||
bmax = task.get("budget_max") or 0
|
||
bmin = task.get("budget_min") or 0
|
||
if bmax > bmin > 0:
|
||
quote_score = max(0, min(100, round(100 * (1 - (q - bmin) / (bmax - bmin)))))
|
||
elif bmax > 0:
|
||
quote_score = max(0, min(100, round(100 * (1 - q / bmax))))
|
||
else:
|
||
quote_score = 70
|
||
if plan_score <= 0:
|
||
plan_score = 80 if bid.get("plan") else 60
|
||
if days_score <= 0:
|
||
dd = bid.get("deliver_days") or 0
|
||
tdd = task.get("delivery_days") or 0
|
||
days_score = 80 if (tdd == 0 or (dd and dd <= tdd)) else 55
|
||
if team_score <= 0:
|
||
team_score = 80 if (bid.get("team_json") or "[]") != "[]" else 60
|
||
if credit_score <= 0:
|
||
credit_score = 70
|
||
total = round(quote_score * 0.30 + plan_score * 0.35 + days_score * 0.10
|
||
+ team_score * 0.10 + credit_score * 0.15, 2)
|
||
score_json = json.dumps({
|
||
"quote": round(quote_score, 1), "plan": round(plan_score, 1),
|
||
"days": round(days_score, 1), "team": round(team_score, 1),
|
||
"credit": round(credit_score, 1),
|
||
}, ensure_ascii=False)
|
||
return await self.db.bids.set_review(bid_id, total, score_json, note)
|
||
|
||
async def win_bid(self, task_id: str, bid_id: str, actor: dict) -> dict:
|
||
"""定标中标:bid → win,其余 → lost;生成 claim(双写)→ claimed。"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
bid = await self.db.bids.get(bid_id)
|
||
if bid is None or bid["task_id"] != task_id:
|
||
raise HTTPException(status_code=404, detail="竞标不存在")
|
||
await self.db.bids.set_status(bid_id, "win")
|
||
for b in await self.db.bids.list_for_task(task_id):
|
||
if b["id"] != bid_id and b.get("status") == "submitted":
|
||
await self.db.bids.set_status(b["id"], "lost")
|
||
return await self._match(task, actor, bid["opc_id"], bid["opc_name"],
|
||
source="bid", claim_status="claimed")
|
||
|
||
# ── 履约链:归属校验 + claim 双写 ────────────────────────────
|
||
|
||
async def _ensure_taker(self, task: dict, actor: dict) -> dict:
|
||
"""校验操作者是否为当前接单人(claimed_by 优先,兼容 claim 流水)。"""
|
||
uid = actor.get("id")
|
||
if task.get("claimed_by") and task["claimed_by"] == uid:
|
||
claims = await self.db.task_claims.list_active_by_task(task["id"])
|
||
return claims[0] if claims else None
|
||
claim = await self.db.task_claims.get_by_task_and_user(task["id"], uid or "")
|
||
if claim and claim.get("status") in ("claimed", "assigned", "doing", "delivered"):
|
||
return claim
|
||
raise HTTPException(status_code=403, detail="仅任务接单人可执行该操作")
|
||
|
||
async def start_doing(self, task_id: str, actor: dict) -> dict:
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] != "claimed":
|
||
raise HTTPException(status_code=400, detail="任务未接单,无法开始")
|
||
claim = await self._ensure_taker(task, actor)
|
||
await self.db.tasks.set_doing(task_id)
|
||
if claim:
|
||
await self.db.task_claims.set_status(claim["id"], "doing")
|
||
# 里程碑物化
|
||
try:
|
||
config = json.loads(task.get("milestone_json") or "[]")
|
||
except Exception: # noqa: BLE001
|
||
config = []
|
||
if isinstance(config, list) and config:
|
||
await self.db.task_milestones.ensure_from_config(task_id, config)
|
||
await _credit_event(self.db, actor["id"], "task_start", "开始执行任务",
|
||
ref_type="task", ref_id=task_id)
|
||
return await self.db.tasks.get(task_id)
|
||
|
||
async def deliver(self, task_id: str, actor: dict, attachments: list | None = None,
|
||
note: str = "", milestone_id: str = "") -> dict:
|
||
"""交付 v2:doing → delivered(版本化 TaskDelivery 记录 + claim 双写)。"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] != "doing":
|
||
raise HTTPException(status_code=400, detail="任务未在进行中,无法交付")
|
||
claim = await self._ensure_taker(task, actor)
|
||
version = await self.db.task_deliveries.next_version(task_id)
|
||
await self.db.task_deliveries.create(
|
||
task_id, version,
|
||
attachments_json=json.dumps(attachments or [], ensure_ascii=False),
|
||
note=note, milestone_id=milestone_id,
|
||
)
|
||
if milestone_id:
|
||
await self.db.task_milestones.set_status(
|
||
milestone_id, "delivered", delivered_at=_utcnow())
|
||
await self.db.tasks.set_delivered(task_id, note)
|
||
if claim:
|
||
await self.db.task_claims.set_status(claim["id"], "delivered")
|
||
await _notify(self.db, task.get("publisher_id") or "", "task",
|
||
"任务已交付", f"「{task.get('title')}」已交付,请及时验收",
|
||
event_code="task.delivered", link="/opc/my-tasks", ref_id=task_id)
|
||
return await self.db.tasks.get(task_id)
|
||
|
||
async def accept(self, task_id: str, actor: dict, checklist: list | None = None,
|
||
score: dict | None = None, comment: str = "",
|
||
milestone_id: str = "") -> dict:
|
||
"""验收通过:delivered → completed(on_time 判定 + claim 双写 + 信用)。"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] != "delivered":
|
||
raise HTTPException(status_code=400, detail="任务未处于待验收状态")
|
||
if (task.get("publisher_id") or "") != actor.get("id"):
|
||
raise HTTPException(status_code=403, detail="仅发包方可验收")
|
||
on_time = True
|
||
if task.get("delivery_days"):
|
||
delivered_at = _parse_ts(task.get("deliver_at") or "")
|
||
started_at = _parse_ts(task.get("doing_at") or "")
|
||
if delivered_at and started_at:
|
||
span_hours = (delivered_at - started_at).total_seconds() / 3600
|
||
on_time = span_hours <= int(task["delivery_days"]) * 24
|
||
delivery_id = ""
|
||
deliveries = await self.db.task_deliveries.list_by_task(task_id)
|
||
if deliveries:
|
||
delivery_id = deliveries[-1]["id"]
|
||
await self.db.task_acceptances.create(
|
||
task_id, delivery_id, milestone_id,
|
||
checklist_json=json.dumps(checklist or [], ensure_ascii=False),
|
||
score_json=json.dumps(score or {}, ensure_ascii=False),
|
||
result="pass", comment=comment, reviewer_id=actor["id"],
|
||
)
|
||
if milestone_id:
|
||
await self.db.task_milestones.set_status(milestone_id, "accepted", accepted_at=_utcnow())
|
||
await self.db.tasks.set_accepted(task_id, on_time=on_time)
|
||
claims = await self.db.task_claims.list_active_by_task(task_id)
|
||
for c in claims:
|
||
await self.db.task_claims.set_status(c["id"], "completed")
|
||
await _credit_event(self.db, task.get("claimed_by") or "", "task_accept",
|
||
"任务验收通过", ref_type="task", ref_id=task_id)
|
||
await _credit_event(self.db, task.get("publisher_id") or "", "task_publish_success",
|
||
"任务完成", ref_type="task", ref_id=task_id)
|
||
await _notify(self.db, task.get("claimed_by") or "", "task",
|
||
"任务验收通过", f"「{task.get('title')}」已通过验收,可进行互评与结算",
|
||
event_code="task.accepted", level="success", link="/opc/my-tasks", ref_id=task_id)
|
||
return await self.db.tasks.get(task_id)
|
||
|
||
async def reject(self, task_id: str, actor: dict, comment: str = "") -> dict:
|
||
"""验收不通过:delivered → doing(返工),返工上限 MAX_REJECT。"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] != "delivered":
|
||
raise HTTPException(status_code=400, detail="任务未处于待验收状态")
|
||
if (task.get("publisher_id") or "") != actor.get("id"):
|
||
raise HTTPException(status_code=403, detail="仅发包方可验收")
|
||
count = int(task.get("reject_count") or 0) + 1
|
||
if count > MAX_REJECT:
|
||
raise HTTPException(status_code=400, detail=f"返工已达上限({MAX_REJECT} 次),请发起争议")
|
||
deliveries = await self.db.task_deliveries.list_by_task(task_id)
|
||
delivery_id = deliveries[-1]["id"] if deliveries else ""
|
||
await self.db.task_acceptances.create(
|
||
task_id, delivery_id, "",
|
||
checklist_json="[]", score_json="{}", result="reject",
|
||
comment=comment, reviewer_id=actor["id"],
|
||
)
|
||
await self.db.tasks.reject(task_id, count)
|
||
claims = await self.db.task_claims.list_active_by_task(task_id)
|
||
for c in claims:
|
||
await self.db.task_claims.set_status(c["id"], "doing")
|
||
await _notify(self.db, task.get("claimed_by") or "", "task",
|
||
"交付未通过验收", f"「{task.get('title')}」需返工(第 {count} 次):{comment}",
|
||
event_code="task.reworked", level="warning", link="/opc/my-tasks", ref_id=task_id)
|
||
return await self.db.tasks.get(task_id)
|
||
|
||
async def withdraw(self, task_id: str, actor: dict, reason: str = "") -> dict:
|
||
"""接单人退出:claimed → published(释放);doing 阶段需发包方同意(暂仅 claimed)。"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if task["status"] not in ("claimed", "doing"):
|
||
raise HTTPException(status_code=400, detail="当前状态不可退出")
|
||
if task["status"] == "doing":
|
||
raise HTTPException(status_code=400, detail="已开始任务需联系发包方或发起争议退出")
|
||
claim = await self._ensure_taker(task, actor)
|
||
await self.db.tasks.release_claim(task_id)
|
||
if claim:
|
||
await self.db.task_claims.set_status(claim["id"], "withdrawn")
|
||
await _credit_event(self.db, actor["id"], "task_withdraw", "接单后退出",
|
||
ref_type="task", ref_id=task_id)
|
||
await _notify(self.db, task.get("publisher_id") or "", "task",
|
||
"接单人退出", f"「{task.get('title')}」接单人已退出,任务重新开放",
|
||
event_code="task.withdrawn", link="/opc/my-tasks", ref_id=task_id)
|
||
return await self.db.tasks.get(task_id)
|
||
|
||
async def cancel(self, task_id: str, actor: dict, reason: str = "") -> dict:
|
||
"""发包方取消:published / claimed → cancelled。"""
|
||
task = await self.db.tasks.get(task_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if (task.get("publisher_id") or "") != actor.get("id"):
|
||
raise HTTPException(status_code=403, detail="仅发包方可取消任务")
|
||
if task["status"] not in ("published", "claimed"):
|
||
raise HTTPException(status_code=400, detail="当前状态不可取消")
|
||
await self.db.tasks.set_status(task_id, "cancelled")
|
||
await self.db.task_claims.release_by_task(task_id, "cancelled")
|
||
await _notify(self.db, task.get("claimed_by") or "", "task",
|
||
"任务已取消", f"「{task.get('title')}」已被发包方取消",
|
||
event_code="task.cancelled", level="warning", link="/opc/my-tasks", ref_id=task_id)
|
||
return await self.db.tasks.get(task_id)
|
||
|
||
# ── 定时扫描(cron 入口)────────────────────────────────────
|
||
|
||
async def scan_pending(self) -> dict:
|
||
"""定时任务:处理超时判定。
|
||
|
||
1. claimed 超 24h 未开始 → 释放回 published(接单人信用扣分)。
|
||
2. delivered 超 72h 未验收 → 自动验收通过。
|
||
3. published 且已过 deadline → cancelled(expired)。
|
||
"""
|
||
stats = {"released": 0, "auto_accepted": 0, "expired": 0}
|
||
for t in await self.db.tasks.list():
|
||
status = t.get("status")
|
||
if status == "claimed":
|
||
h = _hours_since(t.get("claimed_at") or "")
|
||
if h is not None and h > 24:
|
||
await self.db.tasks.release_claim(t["id"])
|
||
claims = await self.db.task_claims.list_active_by_task(t["id"])
|
||
for c in claims:
|
||
await self.db.task_claims.set_status(c["id"], "withdrawn")
|
||
await _credit_event(self.db, c["claimer_user_id"], "task_overdue",
|
||
"接单后超时未开始", ref_type="task", ref_id=t["id"])
|
||
stats["released"] += 1
|
||
elif status == "delivered":
|
||
h = _hours_since(t.get("deliver_at") or "")
|
||
if h is not None and h > 72:
|
||
await self.db.tasks.set_accepted(t["id"], on_time=False)
|
||
claims = await self.db.task_claims.list_active_by_task(t["id"])
|
||
for c in claims:
|
||
await self.db.task_claims.set_status(c["id"], "completed")
|
||
await _credit_event(self.db, t.get("claimed_by") or "", "task_accept",
|
||
"交付超时自动验收通过", ref_type="task", ref_id=t["id"])
|
||
stats["auto_accepted"] += 1
|
||
elif status == "published":
|
||
dl = _parse_ts(t.get("deadline") or "")
|
||
if dl is not None and dl < datetime.now(timezone.utc):
|
||
await self.db.tasks.set_expired(t["id"])
|
||
claims = await self.db.task_claims.list_by_task(t["id"])
|
||
for c in claims:
|
||
if c.get("status") == "registered":
|
||
await self.db.task_claims.set_status(c["id"], "withdrawn")
|
||
stats["expired"] += 1
|
||
return stats
|
||
|
||
# ── 兼容旧入口 ──────────────────────────────────────────────
|
||
|
||
async def complete(self, task_id: str, actor: dict) -> dict:
|
||
"""doing → delivered 的旧语义迁移为「立即交付」:保留兼容。"""
|
||
return await self.deliver(task_id, actor)
|
||
|
||
async def enterprise_submit(self, task_id: str) -> dict:
|
||
"""企业提交审核:pending 任务 → published。"""
|
||
return await self.db.tasks.set_status(task_id, "published")
|
||
|
||
async def review(self, task_id: str, accept: bool) -> dict:
|
||
"""企业验收(旧入口,无 actor):仅承接已完成状态迁移。"""
|
||
if accept:
|
||
return await self.db.tasks.set_accepted(task_id)
|
||
return await self.db.tasks.set_status(task_id, "doing")
|