Files
track/backend/app/services/task_service.py

878 lines
31 KiB
Python
Raw Normal View History

"""任务服务 — 核心业务逻辑:接收、驳回返工、裂变转交、无限嵌套子任务"""
from __future__ import annotations
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED
from app.core.time_utils import get_beijing_time
from app.models.product import Product
from app.models.task_log import TaskLog
from app.schemas.task import (
TaskCreate,
TaskUpdate,
TaskCompleteRequest,
TaskRejectRequest,
TaskTransferRequest,
SubtaskCreate,
TaskRecordCreate,
TaskRecordResponse,
TaskResponse,
TaskCompleteResponse,
TaskTransferResponse,
TaskSummaryResponse,
TaskListResponse,
)
# 特殊位置常量
VIRTUAL_WAREHOUSE = "virtual_warehouse"
# ============================================================
# 内部辅助函数
# ============================================================
async def _get_task_or_404(db: AsyncSession, task_id: uuid.UUID) -> Task:
"""获取任务,不存在则 404"""
result = await db.execute(
select(Task)
.options(
selectinload(Task.child_tasks),
selectinload(Task.parent_task),
selectinload(Task.product),
selectinload(Task.records),
)
.where(Task.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"任务不存在: {task_id}",
)
return task
async def _get_task_with_children_recursive(db: AsyncSession, task_id: uuid.UUID) -> Task:
"""递归加载任务及其所有子孙任务"""
result = await db.execute(
select(Task)
.options(
selectinload(Task.child_tasks),
selectinload(Task.records),
)
.where(Task.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"任务不存在: {task_id}",
)
# 递归加载每一层子任务
async def _load_children(t: Task):
for child in t.child_tasks:
child_result = await db.execute(
select(Task)
.options(
selectinload(Task.child_tasks),
selectinload(Task.records),
)
.where(Task.id == child.id)
)
refreshed_child = child_result.scalar_one()
t.child_tasks[t.child_tasks.index(child)] = refreshed_child
await _load_children(refreshed_child)
await _load_children(task)
return task
def _to_flat_response(task: Task) -> TaskResponse:
"""扁平序列化,不递归 children避免 MissingGreenlet"""
product_sn = ""
product_material = ""
try:
if task.product:
product_sn = task.product.serial_number or ""
product_material = (task.product.material_name or task.product.material_id or "")
except Exception:
pass
return TaskResponse(
id=task.id,
product_id=task.product_id,
product_sn=product_sn,
product_material=product_material,
parent_task_id=task.parent_task_id,
task_name=task.task_name,
assignee_id=task.assignee_id,
status=task.status,
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=task.is_rework,
remark=task.remark,
reject_reason=task.reject_reason,
received_at=task.received_at,
completed_at=task.completed_at,
created_at=task.created_at,
child_tasks=[],
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
)
def _to_response(task: Task) -> TaskResponse:
"""将 Task ORM 对象转为递归 TaskResponse"""
product_sn = ""
product_material = ""
try:
if task.product:
product_sn = task.product.serial_number or ""
product_material = (task.product.material_name or task.product.material_id or "")
except Exception:
pass
return TaskResponse(
id=task.id,
product_id=task.product_id,
product_sn=product_sn,
product_material=product_material,
parent_task_id=task.parent_task_id,
task_name=task.task_name,
assignee_id=task.assignee_id,
status=task.status,
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=task.is_rework,
remark=task.remark,
reject_reason=task.reject_reason,
received_at=task.received_at,
completed_at=task.completed_at,
created_at=task.created_at,
child_tasks=[_to_response(c) for c in task.child_tasks],
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
)
async def _check_all_critical_children_completed(
db: AsyncSession, task_id: uuid.UUID
) -> tuple[bool, list[str]]:
"""
检查指定任务下所有 notify_parent_on_complete=True 的子任务是否都已完成
返回 (是否全部完成, 未完成的子任务名称列表)
"""
result = await db.execute(
select(Task).where(
Task.parent_task_id == task_id,
Task.notify_parent_on_complete.is_(True),
)
)
critical_children = result.scalars().all()
incomplete = [
child.task_name
for child in critical_children
if child.status != TASK_STATUS_COMPLETED
]
return len(incomplete) == 0, incomplete
async def _create_task_log(
db: AsyncSession,
task_id: uuid.UUID,
action_type: str,
operator_id: str | None = None,
remark: str | None = None,
) -> TaskLog:
"""创建任务操作日志"""
log = TaskLog(
task_id=task_id,
operator_id=operator_id,
action_type=action_type,
remark=remark,
)
db.add(log)
return log
# ============================================================
# 公开 API — 查询
# ============================================================
async def get_task(db: AsyncSession, task_id: uuid.UUID) -> TaskResponse:
"""获取任务详情 — 递归包含所有子任务"""
task = await _get_task_with_children_recursive(db, task_id)
return _to_response(task)
async def get_top_level_tasks(db: AsyncSession, product_id: uuid.UUID) -> list[TaskSummaryResponse]:
"""获取产品的顶层任务列表"""
result = await db.execute(
select(Task)
.where(
Task.product_id == product_id,
Task.parent_task_id.is_(None),
)
.order_by(Task.created_at)
)
tasks = result.scalars().all()
return [TaskSummaryResponse.model_validate(t) for t in tasks]
async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
"""创建任务,并同步产品宏观状态"""
task = Task(**data.model_dump())
db.add(task)
# 同步产品宏观状态 + 当前位置
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
product = product_result.scalar_one_or_none()
if product:
if data.task_name:
product.overall_status = "在库" if "virtual_warehouse" in data.task_name else data.task_name
# 派发给人 → 产品离开仓库
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
product.current_location_id = data.assignee_id
await db.commit()
await db.refresh(task)
return _to_response(task)
async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) -> TaskResponse:
"""更新任务"""
task = await _get_task_or_404(db, task_id)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(task, field, value)
await db.commit()
await db.refresh(task)
return _to_response(task)
async def get_all_tasks(
db: AsyncSession, product_id: uuid.UUID | None = None,
assignee_id: str | None = None, skip: int = 0, limit: int = 50
) -> TaskListResponse:
"""获取任务列表,可按产品/负责人筛选"""
stmt = select(Task).options(
selectinload(Task.child_tasks),
selectinload(Task.records),
selectinload(Task.product),
)
if product_id:
stmt = stmt.where(Task.product_id == product_id)
if assignee_id:
stmt = stmt.where(Task.assignee_id == assignee_id)
stmt = stmt.offset(skip).limit(limit).order_by(Task.created_at.desc())
result = await db.execute(stmt)
tasks = result.scalars().all()
# 返回扁平列表(不递归 children避免 MissingGreenlet
flat_tasks = [_to_flat_response(t) for t in tasks]
return TaskListResponse(tasks=flat_tasks, total=len(flat_tasks))
# ============================================================
# 核心业务 0结束分支终止当前节点
# ============================================================
async def end_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
) -> TaskResponse:
"""
结束当前分支标记任务为 COMPLETED不创建下游任务
用于工序已完结无需转交下一人的场景
"""
task = await _get_task_or_404(db, task_id)
# 校验:必须等待所有协助分支完成
await _check_children_done(db, task_id)
if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(status_code=409, detail="此分支已经结束")
if task.status == TASK_STATUS_PENDING:
raise HTTPException(status_code=409, detail="请先接收任务再结束分支")
now = get_beijing_time()
task.status = TASK_STATUS_COMPLETED
task.completed_at = now
await _create_task_log(db, task_id, action_type="end",
operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)")
await db.commit()
await db.refresh(task)
return _to_response(task)
# ============================================================
# 核心业务 0.3:撤回转交
# ============================================================
async def recall_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
) -> TaskResponse:
"""撤回 PENDING 转交任务,恢复父任务为 WIP。"""
task = await _get_task_or_404(db, task_id)
if task.status != TASK_STATUS_PENDING:
raise HTTPException(status_code=409, detail="只有待接收(PENDING)的任务可以撤回")
if not task.parent_task_id:
raise HTTPException(status_code=400, detail="顶层任务无法撤回")
parent = await _get_task_or_404(db, task.parent_task_id)
if parent.status != TASK_STATUS_COMPLETED:
raise HTTPException(status_code=409, detail="父任务状态异常,无法撤回")
# 恢复父任务
parent.status = TASK_STATUS_WIP
parent.completed_at = None
# 更新产品位置到父任务负责人
product_result = await db.execute(select(Product).where(Product.id == parent.product_id))
product = product_result.scalar_one_or_none()
if product and parent.assignee_id:
product.current_location_id = parent.assignee_id
product.overall_status = parent.task_name
# 删除 PENDING 子任务
await db.delete(task)
await _create_task_log(db, parent.id, action_type="recall", operator_id=operator_id,
remark=f"撤回转交「{task.task_name}」→ {task.assignee_id},恢复父任务为 WIP")
await db.commit()
await db.refresh(parent)
return _to_response(parent)
# ============================================================
# 核心业务 0.5:派发协助分支(不改变父任务状态)
# ============================================================
async def spawn_subtask(
db: AsyncSession, task_id: uuid.UUID, data, operator_id: str | None = None
) -> TaskResponse:
"""在当前任务下创建并行子任务,父任务状态保持不变。"""
task = await _get_task_or_404(db, task_id)
if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(status_code=409, detail="任务已完成,无法派发协助分支")
if task.status == TASK_STATUS_REJECTED:
raise HTTPException(status_code=409, detail="任务已驳回,无法派发协助分支")
child = Task(
product_id=task.product_id,
parent_task_id=task.id,
task_name=data.task_name,
assignee_id=data.assignee_id,
status=TASK_STATUS_PENDING,
notify_parent_on_complete=False,
is_rework=False,
remark=data.remark or None,
)
db.add(child)
await db.flush()
# 协助分支不改变产品宏观状态(只有主分支影响 overall_status
await _create_task_log(db, child.id, action_type="create", operator_id=operator_id,
remark=f"协助分支(由「{task.task_name}」派发,分配给 {data.assignee_id}")
await db.commit()
await db.refresh(child)
return _to_response(child)
async def _check_children_done(db: AsyncSession, task_id: uuid.UUID):
"""检查当前任务的所有子任务是否都已完结。未完结则抛出 409。"""
result = await db.execute(
select(Task).where(Task.parent_task_id == task_id)
)
children = result.scalars().all()
incomplete = [c for c in children if c.status not in (
TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, ARCHIVED_STATUS
)]
if incomplete:
names = "".join(c.task_name for c in incomplete)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"当前工序还有未完成的协助分支({names}),必须等待分支结束才能转交或完工!",
)
return True
ARCHIVED_STATUS = "ARCHIVED"
# ============================================================
# 核心业务 1确认接收 (PENDING → WIP)
# ============================================================
async def receive_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
remark: str | None = None, task_name: str | None = None,
) -> TaskResponse:
"""
操作员确认接收任务工人选定工序名称后接收
校验只有状态为 PENDING 的任务可接收
动作状态改为 WIP记录 received_at更新 task_name同步产品宏观状态
"""
task = await _get_task_or_404(db, task_id)
# 权限校验:只有负责人本人可接收
if operator_id and task.assignee_id and operator_id != task.assignee_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
)
# 校验:只有 PENDING 状态可接收
if task.status != TASK_STATUS_PENDING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"只有待接收(PENDING)状态的任务可接收,当前状态: {task.status}",
)
now = get_beijing_time()
task.status = TASK_STATUS_WIP
task.received_at = now
if remark:
task.remark = remark
if task_name:
task.task_name = task_name
await _create_task_log(
db, task_id,
action_type="receive",
operator_id=operator_id,
remark=remark or f"操作员确认接收任务「{task.task_name}",
)
# 接收时同步产品位置到接收人 + 宏观状态同步
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
product = product_result.scalar_one_or_none()
if product:
if task.assignee_id:
product.current_location_id = task.assignee_id
if task_name:
product.overall_status = task_name
await db.commit()
await db.refresh(task)
return _to_response(task)
# ============================================================
# 核心业务 2品质驳回 (→ REJECTED + 返工闭环)
# ============================================================
async def reject_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None
) -> TaskResponse:
"""
品质驳回将当前任务标记为 REJECTED并自动创建返工任务给上一道工序负责人
防呆闭环逻辑
1. 将当前任务状态改为 REJECTED记录 reject_reason completed_at
2. 查找当前任务的父任务 (parent_task) 的负责人 (assignee_id)
- 若有父任务返工任务分配给父任务的 assignee_id
- 若无父任务顶层任务返工任务分配给当前任务自己的 assignee_id
3. 为该负责人新建一个完全一样的任务 is_rework=Truestatus=PENDING
4. 新返工任务挂在同一个 parent_task_id 与原任务同级
"""
task = await _get_task_or_404(db, task_id)
# 权限校验:只有负责人本人可驳回
if operator_id and task.assignee_id and operator_id != task.assignee_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
)
# 校验:不能重复驳回已完成/已驳回的任务
if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"任务状态为 {task.status},无法驳回",
)
now = get_beijing_time()
# --- 1. 标记当前任务为已驳回 ---
task.status = TASK_STATUS_REJECTED
task.reject_reason = request.reason
task.completed_at = now
await _create_task_log(
db, task_id,
action_type="reject",
operator_id=operator_id,
remark=f"品质驳回: {request.reason}",
)
# --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) ---
rework_assignee_id: str | None = None
if task.parent_task_id:
# 有父任务:返工给父任务的负责人(即上一环的转交人 A
parent_result = await db.execute(
select(Task).where(Task.id == task.parent_task_id)
)
parent_task = parent_result.scalar_one_or_none()
if parent_task:
rework_assignee_id = parent_task.assignee_id
if not rework_assignee_id:
# 无父任务(顶层转交):从任务日志追溯创建人(转交发起者 A
log_result = await db.execute(
select(TaskLog).where(
TaskLog.task_id == task_id,
TaskLog.action_type == "create",
).order_by(TaskLog.created_at.asc()).limit(1)
)
create_log = log_result.scalar_one_or_none()
if create_log and create_log.operator_id:
rework_assignee_id = create_log.operator_id
if not rework_assignee_id:
# 最后兜底:用当前任务的负责人(通常不应该走到这里)
rework_assignee_id = task.assignee_id
# --- 3. 创建返工任务 ---
rework_task = Task(
product_id=task.product_id,
parent_task_id=task.parent_task_id, # 与原任务同级
task_name=task.task_name,
assignee_id=rework_assignee_id,
status=TASK_STATUS_PENDING,
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=True,
)
db.add(rework_task)
await db.flush()
await _create_task_log(
db, rework_task.id,
action_type="create",
operator_id=operator_id,
remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}",
)
await db.commit()
await db.refresh(task)
return _to_response(task)
# ============================================================
# 核心业务 3完工并裂变转交 (→ COMPLETED + 裂变创建下家任务)
# ============================================================
async def transfer_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None
) -> TaskTransferResponse:
"""
完工并裂变转交
动作 1闭环当前节点
- 将当前任务状态改为 COMPLETED记录 completed_at
动作 2解析下家
- 遍历 next_assignees 列表
- 如果包含 'virtual_warehouse'则将 Product current_location_id 设为 'virtual_warehouse'
- 为每一个 assignee_id virtual_warehouse新建一条 Task 记录状态 PENDING
裂变逻辑
- 如果 len(next_assignees) > 1多路裂变 所有新任务挂到当前任务下parent_task_id = 当前任务ID
- 如果当前任务本身就是子任务 parent_task_id单路转交也挂到同一父任务下
- 否则顶层单路转交新任务与当前任务同级parent_task_id = None
- 更新 Product current_location_id 为对应的人员非仓库
"""
task = await _get_task_or_404(db, task_id)
# 校验:不能重复完成
if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"任务「{task.task_name}」已经完成(当前状态: {task.status}),请刷新页面",
)
# 校验PENDING 状态不允许转交(必须先接收)
if task.status == TASK_STATUS_PENDING:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"任务「{task.task_name}」尚未接收(当前状态: PENDING请先接收再转交",
)
# 校验:卡点逻辑 — 检查关键子任务
all_done, incomplete_names = await _check_all_critical_children_completed(db, task_id)
if not all_done:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"请等待相关子任务完成:{', '.join(incomplete_names)}",
)
now = get_beijing_time()
# --- 动作 1闭环当前节点 ---
task.status = TASK_STATUS_COMPLETED
task.completed_at = now
await _create_task_log(
db, task_id,
action_type="complete",
operator_id=operator_id,
remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序",
)
# --- 动作 2解析下家 & 裂变 ---
# 兼容新旧格式
if request.next_tasks:
branches = [
(b.task_name, a)
for b in request.next_tasks
for a in (b.assignees or [])
]
else:
branches = [
(request.next_task_name, a)
for a in (request.next_assignees or [])
]
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1)
is_child_task = task.parent_task_id is not None
created_tasks: list[Task] = []
for task_name, assignee_id in real_branches:
if is_fission:
new_parent_task_id = task.id
elif is_child_task:
new_parent_task_id = task.parent_task_id
else:
new_parent_task_id = None
new_task = Task(
product_id=task.product_id,
parent_task_id=new_parent_task_id,
task_name=task_name,
assignee_id=assignee_id,
status=TASK_STATUS_PENDING,
notify_parent_on_complete=False,
is_rework=False,
remark=request.note or None,
)
db.add(new_task)
created_tasks.append(new_task)
# 批量 flush 以生成 ID
await db.flush()
for nt in created_tasks:
await _create_task_log(
db, nt.id,
action_type="create",
operator_id=operator_id,
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}",
)
# --- 更新 Product 的 current_location_id ---
product_result = await db.execute(
select(Product).where(Product.id == task.product_id)
)
product = product_result.scalar_one_or_none()
if product:
if has_warehouse and not real_branches:
product.current_location_id = VIRTUAL_WAREHOUSE
product.overall_status = "在库"
elif real_branches:
product.current_location_id = real_branches[0][1]
product.overall_status = real_branches[0][0]
await db.commit()
# --- 构建响应 ---
refreshed_task = await _get_task_with_children_recursive(db, task_id)
created_task_responses = []
for nt in created_tasks:
await db.refresh(nt)
created_task_responses.append(_to_response(nt))
assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
location_info = ""
if has_warehouse:
location_info = ",产品已入库(virtual_warehouse)"
return TaskTransferResponse(
completed_task=_to_response(refreshed_task),
created_tasks=created_task_responses,
message=(
f"任务「{task.task_name}」已完成,"
f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}"
f"(接收人: {assignee_list}{location_info}"
),
)
# ============================================================
# 保留兼容:旧版 complete_task单步完成/转交)
# ============================================================
async def complete_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskCompleteRequest
) -> TaskCompleteResponse:
"""
核心业务完成任务 + 可选创建下一步任务
逻辑
1. 检查当前任务是否已完成幂等
2. 检查所有 notify_parent_on_complete=True 的子任务是否都已完成
如果存在未完成的关键子任务返回 400 错误
3. 将当前任务状态改为 completed记录日志
4. 如果提供了 next_task_name next_assignee_id创建下一步任务
5. 返回完成结果
"""
task = await _get_task_or_404(db, task_id)
# 注入子任务数据到当前对象以便后续检查
children_result = await db.execute(
select(Task).where(Task.parent_task_id == task_id)
)
task.child_tasks = children_result.scalars().all()
# --- 1. 幂等检查 ---
if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"任务「{task.task_name}」已经完成,无需重复操作",
)
# --- 2. 卡点逻辑:检查关键子任务 ---
all_done, incomplete_names = await _check_all_critical_children_completed(db, task_id)
if not all_done:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"请等待相关子任务完成:{', '.join(incomplete_names)}",
)
now = get_beijing_time()
# --- 3. 标记当前任务为已完成 ---
task.status = TASK_STATUS_COMPLETED
task.completed_at = now
await _create_task_log(
db, task_id,
action_type="complete",
operator_id=request.operator_id,
remark=request.remark or f"完成任务: {task.task_name}",
)
# --- 4. 可选:创建下一步任务(转交) ---
next_task = None
if request.next_task_name and request.next_assignee_id:
next_task = Task(
product_id=task.product_id,
parent_task_id=task.parent_task_id, # 与已完成任务同级
task_name=request.next_task_name,
assignee_id=request.next_assignee_id,
status=TASK_STATUS_PENDING,
notify_parent_on_complete=False,
)
db.add(next_task)
await db.flush()
await _create_task_log(
db, next_task.id,
action_type="create",
operator_id=request.operator_id,
remark=f"由任务「{task.task_name}」完成后转交创建",
)
await db.commit()
# --- 5. 构建响应 ---
refreshed_task = await _get_task_with_children_recursive(db, task_id)
next_task_response = None
if next_task:
await db.refresh(next_task)
next_task_response = _to_response(next_task)
return TaskCompleteResponse(
completed_task=_to_response(refreshed_task),
next_task=next_task_response,
message=f"任务「{task.task_name}」已完成"
+ (f",已创建下一步任务「{request.next_task_name}" if next_task else ""),
)
# ============================================================
# 子任务
# ============================================================
async def create_subtask(
db: AsyncSession, parent_task_id: uuid.UUID, data: SubtaskCreate
) -> TaskResponse:
"""
在现有任务下创建子任务 支持无限层级嵌套
新子任务继承父任务的 product_id
"""
parent = await _get_task_or_404(db, parent_task_id)
# 禁止在已完成/已驳回的任务下创建子任务
if parent.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无法在状态为 {parent.status} 的任务「{parent.task_name}」下创建子任务",
)
subtask = Task(
product_id=parent.product_id,
parent_task_id=parent_task_id,
task_name=data.task_name,
assignee_id=data.assignee_id,
status=TASK_STATUS_PENDING,
notify_parent_on_complete=data.notify_parent_on_complete,
)
db.add(subtask)
await db.commit()
await db.refresh(subtask)
await _create_task_log(
db, subtask.id,
action_type="create",
operator_id=data.assignee_id,
remark=f"创建子任务「{data.task_name}」,父任务: 「{parent.task_name}",
)
await db.commit()
return _to_response(subtask)
# ============================================================
# 任务进度记录 — 随时备注/传图
# ============================================================
async def add_task_record(
db: AsyncSession, task_id: uuid.UUID, data: TaskRecordCreate
) -> TaskResponse:
"""追加进度记录(备注+图片),不改变任务状态"""
import json
task = await _get_task_with_children_recursive(db, task_id)
record = TaskRecord(
task_id=task_id,
remark=data.remark or None,
images=json.dumps(data.images) if data.images else None,
)
db.add(record)
await db.commit()
await db.refresh(record)
# 重新加载 task 带上新 record
return await get_task(db, task_id)