Files
server-core/app/services/task_service.py
T

747 lines
40 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.
# -*- 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 {}
# 性能优化:用户档案缓存 30 秒,避免每次 can_accept 都重复查询
from ..infrastructure.cache import cache as redis_cache
cache_key = f"actor:profile:{uid}"
cached = await redis_cache.get_json(cache_key)
if cached is not None:
return cached
full = (await self.db.users.get_by_id(uid)) or {}
credit = await self.db.credit_scores.get(uid)
# 勋章(active
badges: list[str] = []
try:
for ub in await self.db.user_badges.list_active(uid):
if ub.get("badge_code"):
badges.append(ub["badge_code"])
except Exception: # noqa: BLE001
badges = []
# 认证(approved/active
certs: list[str] = []
try:
for c in await self.db.certifications.list_by_user(uid):
if c.get("status") in ("approved", "active") and c.get("cert_type"):
certs.append(c["cert_type"])
except Exception: # noqa: BLE001
certs = []
result = {
**full,
"credit_level": (credit or {}).get("level", "L1"),
"credit_score": (credit or {}).get("total_score", 0),
"badges": badges,
"certifications": certs,
}
# 异步写缓存,不阻塞返回
import asyncio
asyncio.create_task(redis_cache.set_json(cache_key, result, ttl=30))
return result
async def can_accept(self, task: dict | None, actor: dict | None, *, actor_profile: dict | None = 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 为空即通过。
性能优化:支持传入预取的 actor_profile,避免批量判断时重复查询用户档案。
"""
if not task:
return {"ok": False, "reasons": ["任务不存在"], "soft": []}
reasons: list[str] = []
soft: list[str] = []
actor = actor or {}
# 性能优化:优先使用传入的预取档案,避免重复 DB 查询
full = actor_profile if actor_profile is not None else 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("需缴纳接单保证金")
# ── 独立资格字段(0061):最低信用分 / 要求勋章 / 要求认证 ──
min_score = int(task.get("min_credit_score") or 0)
if min_score > 0 and int(full.get("credit_score") or 0) < min_score:
reasons.append(f"信用分需 ≥ {min_score}(当前 {full.get('credit_score', 0)}")
try:
req_badges = json.loads(task.get("required_badges") or "[]")
except Exception: # noqa: BLE001
req_badges = []
if isinstance(req_badges, list) and req_badges:
have_badges = set(full.get("badges") or [])
missing_b = [b for b in req_badges if b not in have_badges]
if missing_b:
reasons.append(f"需持有徽章:{''.join(missing_b)}")
try:
req_certs = json.loads(task.get("required_certifications") or "[]")
except Exception: # noqa: BLE001
req_certs = []
if isinstance(req_certs, list) and req_certs:
have_certs = set(full.get("certifications") or [])
missing_c = [c for c in req_certs if c not in have_certs]
if missing_c:
reasons.append(f"需完成认证:{''.join(missing_c)}")
# 软性提示(不阻断)
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:
"""指派:published → assigned(等待被指派人确认),不再直接锁定。"""
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
await self.db.tasks.set_assigned(task["id"], taker_user_id)
await self.db.task_claims.create(
task["id"], taker_user_id, taker_name,
source="assign", status="assigned",
)
await _notify(self.db, taker_user_id, "task",
"你有一条指派任务", f"{task.get('title')}」已指派给你,请确认是否接受",
event_code="task.assigned", link="/opc/my-tasks", ref_id=task_id)
_sync_task_group(task_id)
return await self.db.tasks.get(task_id)
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",
)
await _notify(self.db, uid, "task",
"你收到任务邀约", f"{task.get('title')}」邀请你承接,请及时回应",
event_code="task.invited", link="/opc/my-tasks", ref_id=task_id)
out.append(record)
return out
async def select_recommend(self, task_id: str, taker_user_id: str, actor: dict) -> dict:
"""选定邀约对象:published → assigned(等待受邀方确认)。"""
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")
await self.db.tasks.set_assigned(task_id, taker_user_id)
await _notify(self.db, taker_user_id, "task",
"你收到任务邀约", f"{task.get('title')}」已选定你承接,请确认是否接受",
event_code="task.assigned", link="/opc/my-tasks", ref_id=task_id)
return await self.db.tasks.get(task_id)
async def accept_invite(self, task_id: str, actor: dict) -> dict:
"""接单方确认接受:assigned → claimed(邀约/指派确认,进入签约前置)。"""
task = await self.db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if task["status"] != "assigned":
raise HTTPException(status_code=400, detail="任务未处于待确认状态")
if (task.get("claimed_by") or "") != actor.get("id"):
raise HTTPException(status_code=403, detail="仅被指派/邀约方可确认")
claim = await self.db.task_claims.get_by_task_and_user(task_id, actor["id"])
if claim and claim.get("status") == "assigned":
await self.db.task_claims.set_status(claim["id"], "claimed")
await self.db.tasks.set_claimed(task_id, actor["id"])
await _credit_event(self.db, actor["id"], "task_match", "确认接受任务",
ref_type="task", ref_id=task_id)
await _notify(self.db, task.get("publisher_id") or "", "task",
"接单方已确认", f"{task.get('title')}」接单方已确认接受,请及时签约托管",
event_code="task.confirmed", level="success", link="/opc/my-tasks", ref_id=task_id)
return await self.db.tasks.get(task_id)
async def sign(self, task_id: str, actor: dict, pay_type: str = "") -> dict:
"""签约(发布方):claimed → signed,托管付款锁定,任务进入服务期。"""
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="任务未处于待签约状态")
if (task.get("publisher_id") or "") != actor.get("id"):
raise HTTPException(status_code=403, detail="仅发包方可签约")
if pay_type and pay_type in ("escrow", "stage", "offline"):
await self.db.tasks.update(task_id, {"pay_type": pay_type})
await self.db.tasks.set_signed(task_id)
await _credit_event(self.db, actor["id"], "task_sign", "签约托管,任务进入服务期",
ref_type="task", ref_id=task_id)
await _notify(self.db, task.get("claimed_by") or "", "task",
"任务已签约", f"{task.get('title')}」已完成签约与托管,可以开始做单",
event_code="task.signed", level="success", link="/opc/my-tasks", ref_id=task_id)
return await self.db.tasks.get(task_id)
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", "signed", "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"] not in ("claimed", "signed"):
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:
"""交付 v2doing → 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 → completedon_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:
"""接单人退出:assigned/claimed → published(释放);doing 阶段需发包方同意(暂不支持)。"""
task = await self.db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if task["status"] not in ("assigned", "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", "assigned", "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 == "assigned":
h = _hours_since(t.get("assigned_at") or 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 _notify(self.db, t.get("claimed_by") or "", "task",
"邀约已失效", f"{t.get('title')}」未在 24h 内确认,邀约已自动撤回",
event_code="task.invite_expired", level="warning", link="/opc/my-tasks", ref_id=t["id"])
stats["released"] += 1
elif 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")