# -*- 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: """交付 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: """接单人退出: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")