feat(task): 完善任务系统 —— 分类字典/字段/四种接单方式/独占限额/交付时限

- Task 加 published_at/delivery_days/headcount/exclusive/publisher_id/category_id;新增 TaskCategory 分类字典表(35 类)。
- mode 归一 grab/bid/assign/recommend(旧 designated→assign、dispatch→recommend);TaskService 加 assign/recommend/select_recommend,claim 支持独占/限额、published+claimed 均可抢(多人)。
- TaskClaim 支持 source=assign/recommend、status=assigned/recommended/withdrawn + count_active_by_task;TaskCategoryRepository;Database 装配 task_categories。
- 端口:operator GET /admin/task-categories + POST tasks/{id}/assign|recommend|select;opc 同;park /api/tasks 返回 mode/deadline/delivery_days/headcount/exclusive/publisher_name。
- 迁移 0009(tasks 加列+task_categories)已应用+stamp;seed 35 分类字典 + task 新字段(含独占/推荐演示) + 补种已初始化库。
- 测试 test_task_system.py(独占/限额/指派/推荐/list_published/publish/category)+补齐 test_task_claim 语义, 15 passed。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-25 19:30:16 +08:00
parent b9b5e2e2ff
commit ea904d3a50
12 changed files with 555 additions and 43 deletions
+49 -1
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from ..dependencies import get_db
from ..schemas.opc import BidRequest, ProfileUpdate, FinanceRecordCreate, TaskClaimRequest
from ..schemas.opc import BidRequest, ProfileUpdate, FinanceRecordCreate, TaskClaimRequest, TaskAssignRequest, TaskRecommendRequest, TaskSelectRecommendRequest
from ...rbac import require_roles, write_audit
from ...infrastructure.repositories import Database, new_id, utcnow_iso
from ...infrastructure.models import FinanceRecord
@@ -180,6 +180,54 @@ async def opc_task_complete(
return updated
@router.post("/tasks/{task_id}/assign", summary="指派任务给某人")
async def opc_task_assign(
task_id: str,
req: TaskAssignRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).assign(task_id, req.taker_user_id, actor)
await write_audit(db, action="task.assign", resource="task", resource_id=task_id,
detail=req.taker_user_id, user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/recommend", summary="推荐候选人")
async def opc_task_recommend(
task_id: str,
req: TaskRecommendRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
records = await TaskService(db).recommend(task_id, req.candidates, actor)
await write_audit(db, action="task.recommend", resource="task", resource_id=task_id,
detail=",".join(req.candidates), user=actor, request=request)
return {"items": records}
@router.post("/tasks/{task_id}/select", summary="选定推荐人")
async def opc_task_select_recommend(
task_id: str,
req: TaskSelectRecommendRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).select_recommend(task_id, req.taker_user_id, actor)
await write_audit(db, action="task.select", resource="task", resource_id=task_id,
detail=req.taker_user_id, user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/bid", summary="OPC 投标")
async def opc_bid(
task_id: str,
+67 -2
View File
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from ..dependencies import get_db
from ..schemas.operator import TaskCreateRequest, TaskUpdateRequest, TaskStatusRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityCreateRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest
from ..schemas.operator import TaskCreateRequest, TaskUpdateRequest, TaskStatusRequest, TaskAssignRequest, TaskRecommendRequest, TaskSelectRecommendRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityCreateRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest
from ...rbac import require_permission, require_roles, write_audit
from ...infrastructure.repositories import Database
from ...services import compute_client
@@ -37,6 +37,7 @@ async def create_task(
actor: dict = Depends(require_permission("action:task.manage")),
):
fields = req.model_dump(exclude_none=True)
fields["mode"] = _normalize_mode(fields.get("mode", "grab"))
if not fields.get("task_code"):
fields["task_code"] = _gen_task_code()
task = await db.tasks.create(fields)
@@ -45,6 +46,13 @@ async def create_task(
return task
def _normalize_mode(mode: str) -> str:
"""接单方式归一:grab/bid/assign/recommend;旧值 designated→assign、dispatch→recommend。"""
mapping = {"designated": "assign", "dispatch": "recommend"}
m = mapping.get(mode, mode)
return m if m in ("grab", "bid", "assign", "recommend") else "grab"
@router.patch("/tasks/{task_id}", summary="更新任务(增改)")
async def update_task(
task_id: str,
@@ -61,6 +69,62 @@ async def update_task(
return task
@router.get("/task-categories", summary="任务分类字典")
async def list_task_categories(
db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator")),
):
return await db.task_categories.list()
@router.post("/tasks/{task_id}/assign", summary="指派任务给某人")
async def assign_task(
task_id: str,
req: TaskAssignRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:task.manage")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).assign(task_id, req.taker_user_id, actor)
await write_audit(db, action="task.assign", resource="task", resource_id=task_id,
detail=req.taker_user_id, user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/recommend", summary="推荐候选人")
async def recommend_task(
task_id: str,
req: TaskRecommendRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:task.manage")),
):
from ...services.task_service import TaskService
records = await TaskService(db).recommend(task_id, req.candidates, actor)
await write_audit(db, action="task.recommend", resource="task", resource_id=task_id,
detail=",".join(req.candidates), user=actor, request=request)
return {"items": records}
@router.post("/tasks/{task_id}/select", summary="选定推荐人")
async def select_recommend_task(
task_id: str,
req: TaskSelectRecommendRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:task.manage")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).select_recommend(task_id, req.taker_user_id, actor)
await write_audit(db, action="task.select", resource="task", resource_id=task_id,
detail=req.taker_user_id, user=actor, request=request)
return updated
def _gen_task_code() -> str:
"""生成便于扫码展示的短码:TK-YYYYMMDD-XXXX(基于时间戳短采样)。"""
import time as _t
@@ -81,7 +145,8 @@ async def set_task_status(
):
if await db.tasks.get(task_id) is None:
raise HTTPException(status_code=404, detail="Task not found")
task = await db.tasks.set_status(task_id, req.status)
# 发布时记录发布时间
task = (await db.tasks.publish(task_id)) if req.status == "published" else await db.tasks.set_status(task_id, req.status)
await write_audit(db, action="task.status", resource="task", resource_id=task_id,
detail=req.status, user=actor, request=request)
return task
+12
View File
@@ -10,6 +10,18 @@ class TaskClaimRequest(BaseModel):
task_id: str = ""
class TaskAssignRequest(BaseModel):
taker_user_id: str
class TaskRecommendRequest(BaseModel):
candidates: list[str] = []
class TaskSelectRecommendRequest(BaseModel):
taker_user_id: str = ""
class ProfileUpdate(BaseModel):
nickname: str | None = None
+31 -1
View File
@@ -3,21 +3,27 @@ from pydantic import BaseModel
class TaskCreateRequest(BaseModel):
title: str
category: str = ""
category_id: str | None = None
sub_category: str = ""
description: str = ""
tags: str = ""
mode: str = "grab"
mode: str = "grab" # grab/bid/assign/recommend
budget_min: int = 0
budget_max: int = 0
deadline: str = ""
delivery_days: int = 0
headcount: int = 0
exclusive: bool = False
task_code: str = ""
display_priority: int = 0
publisher_id: str | None = None
publisher_name: str = ""
class TaskUpdateRequest(BaseModel):
title: str | None = None
category: str | None = None
category_id: str | None = None
sub_category: str | None = None
description: str | None = None
tags: str | None = None
@@ -25,8 +31,12 @@ class TaskUpdateRequest(BaseModel):
budget_min: int | None = None
budget_max: int | None = None
deadline: str | None = None
delivery_days: int | None = None
headcount: int | None = None
exclusive: bool | None = None
task_code: str | None = None
display_priority: int | None = None
publisher_id: str | None = None
publisher_name: str | None = None
@@ -34,6 +44,26 @@ class TaskStatusRequest(BaseModel):
status: str # review/published/completed/cancelled/claimed/doing
class TaskAssignRequest(BaseModel):
taker_user_id: str
class TaskRecommendRequest(BaseModel):
candidates: list[str] = []
class TaskSelectRecommendRequest(BaseModel):
taker_user_id: str
class TaskCategoryItem(BaseModel):
id: str
name: str
sort: int = 0
modes: str = ""
status: str = "active"
class ProviderCreateRequest(BaseModel):
name: str