diff --git a/alembic/versions/0009_task_system_fields.py b/alembic/versions/0009_task_system_fields.py new file mode 100644 index 0000000..ce35aa4 --- /dev/null +++ b/alembic/versions/0009_task_system_fields.py @@ -0,0 +1,51 @@ +"""task system fields (headcount/exclusive/delivery_days/published_at/publisher_id/category_id) + task_categories + +Revision ID: 0009_task_system_fields +Revises: f9f4eaef2e6f +Create Date: 2026-08-25 + +手写,仅含任务系统补全改动:tasks 加列 + 新建 task_categories 字典表。 +(勿 autogenerate——上次误带 training_* 删除与 users 列改动。) +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = '0009_task_system_fields' +down_revision = 'f9f4eaef2e6f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ---- task_categories 分类字典表 ---- + op.create_table('task_categories', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False, server_default=''), + sa.Column('sort', sa.Integer(), nullable=False, server_default='0'), + sa.Column('modes', sa.String(), nullable=False, server_default=''), + sa.Column('status', sa.String(), nullable=False, server_default='active'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_task_categories_name', 'task_categories', ['name'], unique=False) + + # ---- tasks 加列 ---- + op.add_column('tasks', sa.Column('category_id', sa.String(), nullable=True)) + op.add_column('tasks', sa.Column('delivery_days', sa.Integer(), nullable=True)) + op.add_column('tasks', sa.Column('headcount', sa.Integer(), nullable=True)) + op.add_column('tasks', sa.Column('exclusive', sa.Boolean(), nullable=True)) + op.add_column('tasks', sa.Column('published_at', sa.String(), nullable=True)) + op.add_column('tasks', sa.Column('publisher_id', sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('tasks', 'publisher_id') + op.drop_column('tasks', 'published_at') + op.drop_column('tasks', 'exclusive') + op.drop_column('tasks', 'headcount') + op.drop_column('tasks', 'delivery_days') + op.drop_column('tasks', 'category_id') + op.drop_index('ix_task_categories_name', table_name='task_categories') + op.drop_table('task_categories') diff --git a/app/api/routers/rbac_opc.py b/app/api/routers/rbac_opc.py index 6271787..7db390f 100644 --- a/app/api/routers/rbac_opc.py +++ b/app/api/routers/rbac_opc.py @@ -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, diff --git a/app/api/routers/rbac_operator.py b/app/api/routers/rbac_operator.py index 22d7787..ed5e0cd 100644 --- a/app/api/routers/rbac_operator.py +++ b/app/api/routers/rbac_operator.py @@ -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 diff --git a/app/api/schemas/opc.py b/app/api/schemas/opc.py index 5957a1e..76b82a0 100644 --- a/app/api/schemas/opc.py +++ b/app/api/schemas/opc.py @@ -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 diff --git a/app/api/schemas/operator.py b/app/api/schemas/operator.py index 22a1b88..3390fc3 100644 --- a/app/api/schemas/operator.py +++ b/app/api/schemas/operator.py @@ -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 diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 3ad0819..187f6eb 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -208,19 +208,25 @@ class Task(Base): id: Mapped[str] = mapped_column(String, primary_key=True) task_code: Mapped[str] = mapped_column(String, default="", index=True) # 展示/扫码短码(唯一) title: Mapped[str] = mapped_column(String, default="") - category: Mapped[str] = mapped_column(String, default="") # 设计创意/技术开发/... + category: Mapped[str] = mapped_column(String, default="") # 分类字典 name(见 TaskCategory) + category_id: Mapped[str | None] = mapped_column(ForeignKey("task_categories.id"), nullable=True) sub_category: Mapped[str] = mapped_column(String, default="") description: Mapped[str] = mapped_column(Text, default="") tags: Mapped[str] = mapped_column(String, default="") # 逗号分隔展示标签 - mode: Mapped[str] = mapped_column(String, default="grab") # grab/bid/designated/dispatch + mode: Mapped[str] = mapped_column(String, default="grab") # grab/bid/assign/recommend(旧 designated→assign/dispatch→recommend) budget_min: Mapped[int] = mapped_column(Integer, default=0) budget_max: Mapped[int] = mapped_column(Integer, default=0) - deadline: Mapped[str] = mapped_column(String, default="") + deadline: Mapped[str] = mapped_column(String, default="") # 报名/接单截止时间 + delivery_days: Mapped[int] = mapped_column(Integer, default=0) # 交付时限(接单后 N 天) + headcount: Mapped[int] = mapped_column(Integer, default=0) # 限制接单人数(0=不限) + exclusive: Mapped[bool] = mapped_column(Boolean, default=False) # 是否独占(仅一人可接) display_priority: Mapped[int] = mapped_column(Integer, default=0) # 大屏展示排序(越大越前) status: Mapped[str] = mapped_column(String, default="draft") # draft/pending/review/published/claimed/doing/completed/cancelled + published_at: Mapped[str] = mapped_column(String, default="") # 发布时间(→published 时) claimed_by: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) claimed_at: Mapped[str] = mapped_column(String, default="") doing_at: Mapped[str] = mapped_column(String, default="") + publisher_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) # 发包方(用户) publisher_org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True) publisher_name: Mapped[str] = mapped_column(String, default="") created_at: Mapped[str] = mapped_column(String, default="") @@ -228,7 +234,7 @@ class Task(Base): class TaskClaim(Base): - """任务接单流水(扫码/抢单/投标中标),rich 记录任务状态流转历史。""" + """任务接单流水(扫码/抢单/投标/指派/推荐),rich 记录任务状态流转历史。""" __tablename__ = "task_claims" @@ -240,13 +246,25 @@ class TaskClaim(Base): ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ) claimer_name: Mapped[str] = mapped_column(String, default="") - claim_source: Mapped[str] = mapped_column(String, default="scan") # scan/grab/bid/dispatch + claim_source: Mapped[str] = mapped_column(String, default="scan") # scan/grab/bid/assign/recommend claimed_at: Mapped[str] = mapped_column(String, default="") - status: Mapped[str] = mapped_column(String, default="claimed") # claimed/doing/completed/withdrawn + status: Mapped[str] = mapped_column(String, default="claimed") # claimed/doing/completed/withdrawn/assigned/recommended created_at: Mapped[str] = mapped_column(String, default="") updated_at: Mapped[str] = mapped_column(String, default="") +class TaskCategory(Base): + """任务分类字典(顶层 35 类,取自 任务类别.json)。""" + + __tablename__ = "task_categories" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, default="", index=True) + sort: Mapped[int] = mapped_column(Integer, default=0) + modes: Mapped[str] = mapped_column(String, default="") # 兼容位掩码 hint(逗号分隔,可选) + status: Mapped[str] = mapped_column(String, default="active") # active/disabled + + class ServiceProvider(Base): """服务商(运营端审核/评级/考核)。""" diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index d32b520..7874059 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -10,7 +10,7 @@ import json import secrets from datetime import datetime, timezone -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from .. import config @@ -46,6 +46,7 @@ from .models import ( SubsidyApplication, SystemConfig, Task, + TaskCategory, TaskClaim, TrainingEnrollment, User, @@ -897,13 +898,17 @@ class TaskRepository: def _to_dict(self, t: Task) -> dict: return { "id": t.id, "task_code": t.task_code, "title": t.title, - "category": t.category, "sub_category": t.sub_category, - "description": t.description, "tags": t.tags, - "mode": t.mode, "budget_min": t.budget_min, "budget_max": t.budget_max, - "deadline": t.deadline, "display_priority": t.display_priority, - "status": t.status, + "category": t.category, "category_id": t.category_id, + "sub_category": t.sub_category, "description": t.description, + "tags": t.tags, "mode": t.mode, + "budget_min": t.budget_min, "budget_max": t.budget_max, + "deadline": t.deadline, "delivery_days": t.delivery_days, + "headcount": t.headcount, "exclusive": t.exclusive, + "display_priority": t.display_priority, + "status": t.status, "published_at": t.published_at, "claimed_by": t.claimed_by, "claimed_at": t.claimed_at, "doing_at": t.doing_at, + "publisher_id": t.publisher_id, "publisher_org_id": t.publisher_org_id, "publisher_name": t.publisher_name, "created_at": t.created_at, "updated_at": t.updated_at, } @@ -938,13 +943,18 @@ class TaskRepository: now = utcnow_iso() t = Task(id=new_id("task"), task_code=fields.get("task_code", ""), title=fields.get("title", ""), - category=fields.get("category", ""), sub_category=fields.get("sub_category", ""), - description=fields.get("description", ""), + category=fields.get("category", ""), category_id=fields.get("category_id"), + sub_category=fields.get("sub_category", ""), description=fields.get("description", ""), tags=fields.get("tags", ""), mode=fields.get("mode", "grab"), budget_min=fields.get("budget_min", 0), budget_max=fields.get("budget_max", 0), deadline=fields.get("deadline", ""), + delivery_days=fields.get("delivery_days", 0), + headcount=fields.get("headcount", 0), + exclusive=fields.get("exclusive", False), display_priority=fields.get("display_priority", 0), status=fields.get("status", "draft"), + published_at=fields.get("published_at", ""), + publisher_id=fields.get("publisher_id"), publisher_org_id=fields.get("publisher_org_id"), publisher_name=fields.get("publisher_name", ""), created_at=now, updated_at=now) @@ -957,9 +967,10 @@ class TaskRepository: if t is None: return None for key in ( - "task_code", "title", "category", "sub_category", "description", - "tags", "mode", "budget_min", "budget_max", "deadline", - "display_priority", "publisher_name", + "task_code", "title", "category", "category_id", "sub_category", + "description", "tags", "mode", "budget_min", "budget_max", "deadline", + "delivery_days", "headcount", "exclusive", "display_priority", + "publisher_id", "publisher_org_id", "publisher_name", ): if key in fields: setattr(t, key, fields.get(key)) @@ -976,6 +987,17 @@ class TaskRepository: await self.session.commit() return self._to_dict(t) + async def publish(self, task_id: str) -> dict | None: + t = await self.session.get(Task, task_id) + if t is None: + return None + t.status = "published" + if not t.published_at: + t.published_at = utcnow_iso() + t.updated_at = utcnow_iso() + await self.session.commit() + return self._to_dict(t) + async def claim(self, task_id: str, user_id: str) -> dict | None: """published → claimed:记录 claimed_by / claimed_at。""" t = await self.session.get(Task, task_id) @@ -1013,12 +1035,12 @@ class TaskClaimRepository: } async def create(self, task_id: str, user_id: str, name: str, - source: str = "scan") -> dict: + source: str = "scan", status: str = "claimed") -> dict: now = utcnow_iso() c = TaskClaim(id=new_id("claim"), task_id=task_id, claimer_user_id=user_id, claimer_name=name, claim_source=source, claimed_at=now, - status="claimed", created_at=now, updated_at=now) + status=status, created_at=now, updated_at=now) self.session.add(c) await self.session.commit() return self._to_dict(c) @@ -1031,6 +1053,18 @@ class TaskClaimRepository: q = select(TaskClaim).where(TaskClaim.claimer_user_id == user_id) return [self._to_dict(c) for c in await self.session.scalars(q)] + async def count_active_by_task(self, task_id: str) -> int: + """在接/已派/做单中的 TaskClaim 计数(用于 限额/独占 判定)。""" + q = select(func.count(TaskClaim.id)).where( + TaskClaim.task_id == task_id, + TaskClaim.status.in_(["claimed", "doing", "assigned"]), + ) + return int((await self.session.scalar(q)) or 0) + + async def get(self, claim_id: str) -> dict | None: + c = await self.session.get(TaskClaim, claim_id) + return self._to_dict(c) if c else None + async def set_status(self, claim_id: str, status: str) -> dict | None: c = await self.session.get(TaskClaim, claim_id) if c is None: @@ -1041,6 +1075,21 @@ class TaskClaimRepository: return self._to_dict(c) +class TaskCategoryRepository: + def __init__(self, session: AsyncSession): + self.session = session + + def _to_dict(self, c: TaskCategory) -> dict: + return { + "id": c.id, "name": c.name, "sort": c.sort, + "modes": c.modes, "status": c.status, + } + + async def list(self) -> list[dict]: + q = select(TaskCategory).order_by(TaskCategory.sort.asc(), TaskCategory.name.asc()) + return [self._to_dict(c) for c in await self.session.scalars(q)] + + # --------------------------------------------------------------------------- # 服务商 # --------------------------------------------------------------------------- @@ -2021,6 +2070,7 @@ class Database: self.audit = AuditRepository(self.session) self.tasks = TaskRepository(self.session) self.task_claims = TaskClaimRepository(self.session) + self.task_categories = TaskCategoryRepository(self.session) self.providers = ProviderRepository(self.session) self.content = ContentRepository(self.session) self.config = ConfigRepository(self.session) diff --git a/app/infrastructure/seed.py b/app/infrastructure/seed.py index c38ebf3..af15254 100644 --- a/app/infrastructure/seed.py +++ b/app/infrastructure/seed.py @@ -37,6 +37,7 @@ from .models import ( SubsidyApplication, SystemConfig, Task, + TaskCategory, TrainingEnrollment, User, UserIdentity, @@ -277,6 +278,7 @@ async def seed_data(session: AsyncSession) -> None: await _sync_permissions(session, now) await _ensure_extra_demo_users(session, now) await _seed_opc_business(session, now) + await _seed_task_categories(session, now) await _migrate_identities(session, now) await _seed_port_dashboards(session, now) await _seed_market(session, now) @@ -335,6 +337,7 @@ async def seed_data(session: AsyncSession) -> None: for seed in AGENT_SEED: await _add_agent(session, seed, uid, None, now) + await _seed_task_categories(session, now) await _seed_operator_business(session, now) await _seed_opc_business(session, now) await _migrate_identities(session, now) @@ -351,36 +354,67 @@ async def seed_data(session: AsyncSession) -> None: # --------------------------------------------------------------------------- # 运营端业务演示数据(任务/服务商/内容/配置) # --------------------------------------------------------------------------- +# 任务分类字典(顶层 35 类,取自 design/云超服平台/任务类别.json,幂等:表空才插) +_TASK_CATEGORY_NAMES = [ + "平面设计", "移动端开发", "网站建设服务", "软件开发", "硬件开发", "知识产权", + "市场推广", "音视频", "动漫游戏设计", "UI设计", "工业设计与制造", "策划", + "写作和内容", "工程设计", "空间设计服务", "人力资源", "科技服务", "技术服务", + "AI人工智能", "VR/AR/MR", "翻译服务", "SaaS服务", "区块链", "企业后勤服务", + "企业办公服务", "文印广告", "法律服务", "工商财税", "学习培训", "源码交易", + "生产制造", "直播服务", "服装配饰设计", "摄影摄像服务", "数据服务", +] + + +async def _seed_task_categories(session: AsyncSession, now: str) -> None: + if await session.scalar(select(TaskCategory.id).limit(1)) is not None: + return + session.add_all([ + TaskCategory(id=f"tc_{i:02d}", name=name, sort=i + 1, status="active") + for i, name in enumerate(_TASK_CATEGORY_NAMES) + ]) + await session.commit() + + async def _seed_operator_business(session: AsyncSession, now: str) -> None: if await session.scalar(select(Task.id).limit(1)) is not None: return session.add_all([ Task(id="task_001", task_code="TK-001-UI", title="电商小程序首页 UI 设计", - category="设计创意", sub_category="UI设计", + category="设计创意", category_id="tc_10", sub_category="UI设计", description="为电商小程序设计首页 UI,含 5 个页面", tags="UI,设计", mode="bid", budget_min=2000, budget_max=5000, - deadline="2026-09-01", display_priority=10, + deadline="2026-09-01", delivery_days=7, headcount=3, + display_priority=10, published_at=now, status="published", publisher_org_id="o_ent_001", publisher_name="云南某某甲方企业", created_at=now, updated_at=now), Task(id="task_002", task_code="TK-002-CT", title="品牌文案写作(月度)", - category="文案写作", sub_category="内容写作", + category="写作和内容", category_id="tc_13", sub_category="内容写作", description="每月 8 篇品牌公众号文案", tags="文案,内容", - mode="designated", budget_min=3000, budget_max=6000, - deadline="2026-12-31", display_priority=5, - status="review", publisher_org_id="o_ent_001", + mode="assign", budget_min=3000, budget_max=6000, + deadline="2026-12-31", delivery_days=14, headcount=1, + display_priority=5, status="review", publisher_org_id="o_ent_001", publisher_name="云南某某甲方企业", created_at=now, updated_at=now), Task(id="task_003", task_code="TK-003-DEV", title="小程序前后端开发", - category="技术开发", sub_category="小程序开发", + category="移动端开发", category_id="tc_02", sub_category="小程序开发", description="点餐小程序全栈开发,3 期交付", tags="开发", mode="bid", budget_min=30000, budget_max=60000, - deadline="2026-10-15", display_priority=20, - status="pending", publisher_org_id="o_ent_001", + deadline="2026-10-15", delivery_days=30, headcount=3, + display_priority=20, status="pending", publisher_org_id="o_ent_001", publisher_name="云南某某甲方企业", created_at=now, updated_at=now), - Task(id="task_004", task_code="TK-004-GRAB", title="短视频剪辑(扫码即领)", - category="技术开发", sub_category="视频剪辑", - description="为园区企业制作 3 条产品短视频,扫码即可接单,2 周内交付。", + Task(id="task_004", task_code="TK-004-GRAB", title="短视频剪辑(扫码即领·独占)", + category="音视频", category_id="tc_08", sub_category="视频剪辑", + description="为园区企业制作 3 条产品短视频,扫码即可接单,独占一人,2 周内交付。", tags="短视频,剪辑", mode="grab", budget_min=1500, budget_max=3000, - deadline="2026-11-30", display_priority=30, + deadline="2026-11-30", delivery_days=14, exclusive=True, headcount=1, + display_priority=30, published_at=now, + status="published", publisher_org_id="o_ent_001", + publisher_name="园区运营方", created_at=now, updated_at=now), + Task(id="task_005", task_code="TK-005-REC", title="农产品品牌 Logo 设计(推荐)", + category="平面设计", category_id="tc_01", sub_category="品牌设计", + description="为农产品品牌设计 Logo,推荐合适合伙人接单。", + tags="Logo,品牌设计", mode="recommend", budget_min=800, budget_max=2000, + deadline="2026-12-20", delivery_days=10, headcount=1, + display_priority=25, published_at=now, status="published", publisher_org_id="o_ent_001", publisher_name="园区运营方", created_at=now, updated_at=now), ]) diff --git a/app/park/routers.py b/app/park/routers.py index e7b9671..52c833b 100644 --- a/app/park/routers.py +++ b/app/park/routers.py @@ -477,6 +477,12 @@ async def park_tasks( "summary": (t["description"] or "")[:160], "budget_min": t["budget_min"], "budget_max": t["budget_max"], + "mode": t.get("mode", "grab"), + "deadline": t.get("deadline", ""), + "delivery_days": t.get("delivery_days", 0), + "headcount": t.get("headcount", 0), + "exclusive": t.get("exclusive", False), + "publisher_name": t.get("publisher_name", ""), "status": t["status"], "claimed_by": t["claimed_by"], "claimed_at": t["claimed_at"], diff --git a/app/services/task_service.py b/app/services/task_service.py index 2eb81d6..1cb395e 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -29,14 +29,22 @@ class TaskService: return await self.claim_by_id(task["id"], actor, source=source) async def claim_by_id(self, task_id: str, actor: dict, source: str) -> dict: - """published + grab 模式 → claimed(claimed_by/claimed_at + TaskClaim 流水)。""" + """published + grab 模式 → claimed(claimed_by/claimed_at + TaskClaim 流水)。 + + 校验接单限制:独占(exclusive) 仅一人可接;headcount>0 时达上限拒绝。 + """ task = await self.db.tasks.get(task_id) if task is None: raise HTTPException(status_code=404, detail="任务不存在") - if task["status"] != "published": + if task["status"] not in ("published", "claimed"): raise HTTPException(status_code=400, detail="任务不可接单") if task["mode"] != "grab": 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="该任务接单人数已达上限") updated = await self.db.tasks.claim(task_id, actor["id"]) await self.db.task_claims.create( task_id, actor["id"], @@ -44,6 +52,62 @@ class TaskService: ) return updated + async def assign(self, task_id: str, taker_user_id: str, actor: dict) -> dict: + """指派:发包方直接指派某人 → claimed(claimed_by=taker)。仅 published + assign 模式。""" + 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 + updated = await self.db.tasks.claim(task_id, taker_user_id) + await self.db.task_claims.create( + task_id, taker_user_id, taker_name, source="assign", status="assigned", + ) + return updated + + async def recommend(self, task_id: str, candidates: list[str], actor: dict) -> list[dict]: + """推荐:发包方/系统推荐候选人,逐个写 TaskClaim(status=recommended)。仅 published + recommend。""" + 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", + ) + out.append(record) + return out + + async def select_recommend(self, task_id: str, taker_user_id: str, actor: dict) -> dict: + """选定推荐人:published → claimed(claimed_by=所选);该推荐 assigned、其余 withdrawn。""" + 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): + target = "assigned" if rec["claimer_user_id"] == taker_user_id else "withdrawn" + await self.db.task_claims.set_status(rec["id"], target) + updated = await self.db.tasks.claim(task_id, taker_user_id) + if not any(t["claimer_user_id"] == taker_user_id + for t in await self.db.task_claims.list_by_task(task_id)): + await self.db.task_claims.create( + task_id, taker_user_id, taker_name, source="recommend", status="assigned", + ) + return updated + async def start_doing(self, task_id: str, actor: dict) -> dict: """claimed → doing。""" task = await self.db.tasks.get(task_id) diff --git a/tests/test_task_claim.py b/tests/test_task_claim.py index 712b03c..ccbfd31 100644 --- a/tests/test_task_claim.py +++ b/tests/test_task_claim.py @@ -25,13 +25,16 @@ async def _db(): def _grab_published(**kw): - return dict( + base = dict( task_code="TK-004-GRAB", title="短视频剪辑", mode="grab", status="published", category="技术开发", budget_min=1500, budget_max=3000, - display_priority=30, **kw, + display_priority=30, ) + base.update(kw) + return base +@pytest.mark.asyncio async def test_claim_marks_claimed_and_records_claim(): """published + grab → claimed(claimed_by/claimed_at),并写 task_claims 流水。""" async with _db() as db: @@ -45,6 +48,7 @@ async def test_claim_marks_claimed_and_records_claim(): assert claims and claims[0]["claim_source"] == "scan" +@pytest.mark.asyncio async def test_claim_disallowed_non_grab_mode(): """bid 模式不可扫码接单。""" async with _db() as db: @@ -53,21 +57,34 @@ async def test_claim_disallowed_non_grab_mode(): await TaskService(db).claim(t["task_code"], {"id": "u1"}, "scan") +@pytest.mark.asyncio async def test_claim_unknown_code_404(): async with _db() as db: with pytest.raises(HTTPException): await TaskService(db).claim("TK-NOT-EXIST", {"id": "u1"}, "scan") -async def test_claim_already_claimed_rejected(): - """已 claim 的任务再 claim 应报错。""" +@pytest.mark.asyncio +async def test_exclusive_grab_second_claim_rejected(): + """独占 grab:再 claim 应报错(限额语义见 test_task_system)。""" async with _db() as db: - t = await db.tasks.create(_grab_published()) + t = await db.tasks.create(_grab_published(exclusive=True, headcount=1)) await TaskService(db).claim(t["task_code"], {"id": "u1"}, "scan") with pytest.raises(HTTPException): await TaskService(db).claim(t["task_code"], {"id": "u2"}, "scan") +@pytest.mark.asyncio +async def test_unlimited_grab_allows_multiple_takers(): + """非独占、不限人数的 grab 允许多人接单。""" + async with _db() as db: + t = await db.tasks.create(_grab_published()) + r1 = await TaskService(db).claim(t["task_code"], {"id": "u1"}, "scan") + r2 = await TaskService(db).claim(t["task_code"], {"id": "u2"}, "scan") + assert r1["status"] == "claimed" and r2["status"] == "claimed" + + +@pytest.mark.asyncio async def test_doing_then_complete(): """claimed → doing → completed。""" async with _db() as db: @@ -80,6 +97,7 @@ async def test_doing_then_complete(): assert done["status"] == "completed" +@pytest.mark.asyncio async def test_list_published_returns_display_fields(): """大屏取数:list_published 返回新字段且按 display_priority 降序。""" async with _db() as db: diff --git a/tests/test_task_system.py b/tests/test_task_system.py new file mode 100644 index 0000000..2ab29d4 --- /dev/null +++ b/tests/test_task_system.py @@ -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() == []