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
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""任务系统完善:独占/限额/指派/推荐/字段(自包含内存库)。"""
from __future__ import annotations
from contextlib import asynccontextmanager
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.infrastructure.models import Base, TaskCategory # noqa: F401
from app.infrastructure.repositories import Database
from app.services.task_service import TaskService
@asynccontextmanager
async def _db():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
Session = async_sessionmaker(engine, expire_on_commit=False)
async with Session() as session:
yield Database(session=session)
await engine.dispose()
def _task(mode="grab", **kw):
base = dict(task_code="TK-X", title="任务", mode=mode, status="published",
category="技术开发", delivery_days=10, headcount=0, exclusive=False)
base.update(kw)
return base
@pytest.mark.asyncio
async def test_exclusive_claim_rejects_second():
"""独占任务仅一人可接。"""
async with _db() as db:
t = await db.tasks.create(_task(exclusive=True, headcount=1))
a = {"id": "u1", "username": "opc01", "nickname": "阿健"}
await TaskService(db).claim(t["task_code"], a, source="scan")
with pytest.raises(HTTPException):
await TaskService(db).claim(t["task_code"], {"id": "u2"}, "scan")
@pytest.mark.asyncio
async def test_headcount_limit_rejects_overflow():
"""限额任务达上限后拒绝再接。"""
async with _db() as db:
t = await db.tasks.create(_task(headcount=2))
await TaskService(db).claim(t["task_code"], {"id": "u1"}, "scan")
await TaskService(db).claim(t["task_code"], {"id": "u2"}, "scan")
with pytest.raises(HTTPException):
await TaskService(db).claim(t["task_code"], {"id": "u3"}, "scan")
@pytest.mark.asyncio
async def test_assign_marks_claimed():
"""指派:assign 模式 → claimed(claimed_by=taker) + TaskClaim assigned。"""
async with _db() as db:
t = await db.tasks.create(_task(mode="assign"))
await TaskService(db).assign(t["id"], "u_taker", {"id": "u_pub"})
assert t is not None
after = await db.tasks.get(t["id"])
assert after["status"] == "claimed"
assert after["claimed_by"] == "u_taker"
claims = await db.task_claims.list_by_task(t["id"])
assert claims[0]["status"] == "assigned" and claims[0]["claim_source"] == "assign"
@pytest.mark.asyncio
async def test_assign_rejects_non_assign_mode():
async with _db() as db:
t = await db.tasks.create(_task(mode="grab"))
with pytest.raises(HTTPException):
await TaskService(db).assign(t["id"], "u_taker", {})
@pytest.mark.asyncio
async def test_recommend_then_select():
"""推荐:→ 候选人 recommended → 选定 → claimed + assigned,其余 withdrawn。"""
async with _db() as db:
t = await db.tasks.create(_task(mode="recommend"))
actor = {"id": "u_pub", "username": "pub"}
recs = await TaskService(db).recommend(t["id"], ["u1", "u2"], actor)
assert len(recs) == 2
assert all(r["status"] == "recommended" for r in recs)
after = await TaskService(db).select_recommend(t["id"], "u1", actor)
assert after["status"] == "claimed" and after["claimed_by"] == "u1"
claims = await db.task_claims.list_by_task(t["id"])
by_user = {c["claimer_user_id"]: c["status"] for c in claims}
assert by_user == {"u1": "assigned", "u2": "withdrawn"}
@pytest.mark.asyncio
async def test_list_published_returns_new_fields():
"""大屏取数:list_published 返回新字段。"""
async with _db() as db:
t = await db.tasks.create(_task(delivery_days=21, headcount=5, exclusive=False, category_id="tc_08"))
items = await db.tasks.list_published()
found = next(i for i in items if i["id"] == t["id"])
for k in ("delivery_days", "headcount", "exclusive", "published_at", "publisher_id", "category_id"):
assert k in found
@pytest.mark.asyncio
async def test_publish_sets_published_at():
async with _db() as db:
t = await db.tasks.create(_task(status="pending"))
after = await db.tasks.publish(t["id"])
assert after["status"] == "published" and after["published_at"]
@pytest.mark.asyncio
async def test_category_list_empty_ok():
async with _db() as db:
assert await db.task_categories.list() == []