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

678 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""任务服务 — 核心业务逻辑:接收、驳回返工、裂变转交、无限嵌套子任务"""
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_response(task: Task) -> TaskResponse:
"""将 Task ORM 对象转为递归 TaskResponse"""
return TaskResponse(
id=task.id,
product_id=task.product_id,
product_sn=task.product.serial_number if task.product else "",
product_material=task.product.material_name or task.product.material_id or "" if task.product else "",
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,
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)
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()
# 构造嵌套响应时只返回顶层任务
all_tasks = [_to_response(t) for t in tasks if t.parent_task_id is None]
return TaskListResponse(tasks=all_tasks, total=len(all_tasks))
# ============================================================
# 核心业务 1确认接收 (PENDING → WIP)
# ============================================================
async def receive_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
remark: str | None = None,
) -> TaskResponse:
"""
操作员确认接收任务。
校验:只有状态为 PENDING 的任务可接收。
动作:状态改为 WIP记录 received_at写入 remark。
"""
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(
now = get_beijing_time()
task.status = TASK_STATUS_WIP
task.received_at = now
if remark:
task.remark = remark
await _create_task_log(
db, task_id,
action_type="receive",
operator_id=operator_id,
remark=remark or f"操作员确认接收任务「{task.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
elif real_branches:
product.current_location_id = real_branches[0][1]
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(real_assignees)
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)