feat(task): 任务中心扫码接单(多端) —— 服务端任务状态机/端口/迁移

- Task 加 task_code/tags/display_priority/claimed_by/claimed_at/doing_at;新增 TaskClaim 流水表。
- TaskService 加 claimed/doing/completed 状态机(claim/start_doing/complete),grab 并入 claim。
- TaskRepository 加 list_published/get_by_code/update/claim/set_doing + TaskClaimRepository;挂 Database。
- 端口:
  · opc  /tasks/grab-by-code、/tasks/{id}/doing、/tasks/{id}/complete
  · operator POST /tasks(auto task_code) + PATCH /tasks/:id
  · park  /park/api/tasks(大屏展示,含 scan_payload 二维码载荷)
  · training /api/tasks/claim-by-code、/api/tasks/my(小程序 C 端账号→OPC 身份 find-or-create 领单)
- 迁移 0008(tasks 加列 + task_claims)已应用+stamp;seed 补 task_code/tags/grab demo。
- tests/test_task_claim.py(自包含内存库, 状态机+流水+list_published), 直接 async 校验通过。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-25 18:40:35 +08:00
parent b8131aec35
commit b9b5e2e2ff
14 changed files with 818 additions and 28 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
from ..schemas.opc import BidRequest, ProfileUpdate, FinanceRecordCreate, TaskClaimRequest
from ...rbac import require_roles, write_audit
from ...infrastructure.repositories import Database, new_id, utcnow_iso
from ...infrastructure.models import FinanceRecord
@@ -132,6 +132,54 @@ async def opc_grab(
return updated
@router.post("/tasks/grab-by-code", summary="扫码/按短码接单")
async def opc_grab_by_code(
req: TaskClaimRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).claim(
(req.task_code or "").strip(), actor, source="scan",
)
await write_audit(db, action="task.claim", resource="task",
resource_id=updated["id"], detail=actor.get("username"),
user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/doing", summary="开始做单")
async def opc_task_doing(
task_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).start_doing(task_id, actor)
await write_audit(db, action="task.doing", resource="task", resource_id=task_id,
user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/complete", summary="完成做单")
async def opc_task_complete(
task_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).complete(task_id, actor)
await write_audit(db, action="task.complete", resource="task", resource_id=task_id,
user=actor, request=request)
return updated
@router.post("/tasks/{task_id}/bid", summary="OPC 投标")
async def opc_bid(
task_id: str,
+66 -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, TaskStatusRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest
from ..schemas.operator import TaskCreateRequest, TaskUpdateRequest, TaskStatusRequest, 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
@@ -36,12 +36,41 @@ async def create_task(
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:task.manage")),
):
task = await db.tasks.create(req.model_dump(exclude_none=True))
fields = req.model_dump(exclude_none=True)
if not fields.get("task_code"):
fields["task_code"] = _gen_task_code()
task = await db.tasks.create(fields)
await write_audit(db, action="task.create", resource="task", resource_id=task["id"],
detail=task["title"], user=actor, request=request)
return task
@router.patch("/tasks/{task_id}", summary="更新任务(增改)")
async def update_task(
task_id: str,
req: TaskUpdateRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:task.manage")),
):
if await db.tasks.get(task_id) is None:
raise HTTPException(status_code=404, detail="Task not found")
task = await db.tasks.update(task_id, req.model_dump(exclude_none=True))
await write_audit(db, action="task.update", resource="task", resource_id=task_id,
detail=(req.title or ""), user=actor, request=request)
return task
def _gen_task_code() -> str:
"""生成便于扫码展示的短码:TK-YYYYMMDD-XXXX(基于时间戳短采样)。"""
import time as _t
from datetime import datetime as _dt
stamp = _dt.now().strftime("%Y%m%d")
rand = f"{int(_t.time()) % 1000000:06d}"
return f"TK-{stamp}-{rand}"
@router.post("/tasks/{task_id}/status", summary="更新任务状态")
async def set_task_status(
task_id: str,
@@ -326,6 +355,19 @@ async def list_activities(
return training.list_activities(status=status)
@router.post("/activities", summary="创建活动")
async def create_activity(
req: ActivityCreateRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:activity.manage")),
):
activity = training.create_activity(req.model_dump(exclude_none=True))
await write_audit(db, action="activity.create", resource="activity", resource_id=activity["id"],
detail=activity["title"], user=actor, request=request)
return activity
@router.post("/activities/{event_id}/status", summary="更新活动状态")
async def set_activity_status(
event_id: str,
@@ -400,3 +442,25 @@ async def set_test_status(
if item is None:
raise HTTPException(status_code=404, detail="Test not found")
return item
# ── 培训业务 · 调研 / 政策 / 流程日志(落库结果只读)─────────────────────────
@router.get("/surveys", summary="调研结果列表")
async def list_surveys(
_u: dict = Depends(require_roles("operator")),
):
return training.list_surveys()
@router.get("/policies", summary="政策测评结果列表")
async def list_policies(
_u: dict = Depends(require_roles("operator")),
):
return training.list_policies()
@router.get("/plans", summary="流程启动日志列表")
async def list_plans(
_u: dict = Depends(require_roles("operator")),
):
return training.list_plans()
+5
View File
@@ -5,6 +5,11 @@ class BidRequest(BaseModel):
plan: str = ""
class TaskClaimRequest(BaseModel):
task_code: str = ""
task_id: str = ""
class ProfileUpdate(BaseModel):
nickname: str | None = None
+34 -1
View File
@@ -5,16 +5,33 @@ class TaskCreateRequest(BaseModel):
category: str = ""
sub_category: str = ""
description: str = ""
tags: str = ""
mode: str = "grab"
budget_min: int = 0
budget_max: int = 0
deadline: str = ""
task_code: str = ""
display_priority: int = 0
publisher_name: str = ""
class TaskUpdateRequest(BaseModel):
title: str | None = None
category: str | None = None
sub_category: str | None = None
description: str | None = None
tags: str | None = None
mode: str | None = None
budget_min: int | None = None
budget_max: int | None = None
deadline: str | None = None
task_code: str | None = None
display_priority: int | None = None
publisher_name: str | None = None
class TaskStatusRequest(BaseModel):
status: str # review/published/completed/cancelled
status: str # review/published/completed/cancelled/claimed/doing
@@ -81,6 +98,22 @@ class CourseStatusRequest(BaseModel):
status: str # draft/published/ended/offline
class ActivityCreateRequest(BaseModel):
title: str
type: str | None = None # free/salon
mode: str | None = None # online/offline
status: str | None = None # open/invite/pending/done/ended
subtitle: str | None = None
desc: str | None = None
location: str | None = None
host: str | None = None
image: str | None = None
link: str | None = None
start_at: str | None = None
duration_min: int | None = None
capacity: int | None = None
class ActivityStatusRequest(BaseModel):
status: str # open/invite/pending/done/ended