Files
track/backend/app/services/task_tree_loader.py
duxingchen 5d5aea1015 refactor(outbound): 出库单据与领用物料合并成一张表
这两者本来就是同一件事(这台设备对应 MOM 的哪些出库单、领了哪些料),
却因为粒度不同被拆成两张表、界面上两张卡:用户要面对两个入口两个删除按钮,
还会问「我在那边挂的怎么这边看不见」。更糟的是**单据级那张没有 mom_line_id,
挂上去的料根本报不了废**。

- 新建 product_outbound_materials,统一到**明细级**(只有它带 mom_line_id,
  而报废要用它定位)。单据级信息(申请单号/备注/撤回)作为冗余列落在每条明细上。
  task_id 改为可空 —— 任务只是溯源信息,不再是组织维度,展示/报废/删除按设备走。
- 接口从 7 个收敛成 3 个(GET/POST/DELETE /products/{id}/outbound-materials,
  外加整单删 by-order)。任务级那套连同 TaskResponse.outbound_materials 一起删掉:
  保留第二个入口只会让「同一个东西两个地方」重新长出来。
- MOM 回调存档改为按 outbound_no 去 MOM **现查明细**逐行落 —— 不查的话
  这台设备「领了什么料」永远是空的,也就报不了废。查不到时退化成单据级存档,
  宁可显示「有这张单但看不到明细」,也不要静默丢掉这张单。
- 扫码响应补 outbound_materials(附「谁挂上去的」中文名,服务端解析)。
  ⚠️ 依赖 task_tree_loader 的 selectinload —— 异步 session 下懒加载会
  MissingGreenlet。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
2026-09-23 15:18:06 +08:00

183 lines
6.5 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.

"""
共享 CTE 任务树加载器 — 使用 PostgreSQL Recursive CTE 一次性拉取完整任务树
解决问题:原 _load_task_tree / _get_task_with_children_recursive 使用
Python 递归逐层 SELECTN 个节点产生 N+1 次数据库查询。
现在无论树深度多大,仅执行 2 条查询CTE + records selectinload
"""
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 noload, selectinload
from sqlalchemy.orm.attributes import set_committed_value
from app.models.task import Task
# ============================================================
# 内存树组装O(N) 时间 / O(N) 空间)
# ============================================================
def _build_tree_in_memory(tasks: list[Task]) -> dict[uuid.UUID, Task]:
"""
给定扁平 Task ORM 列表,在内存中通过哈希表组装嵌套树结构。
关键安全设计:
- 使用临时字典 temp_children_map 暂存父子关系,绝对不直接操作 ORM 的 child_tasks。
- 通过 set_committed_value 注入最终列表,告诉 SQLAlchemy 这是"已提交数据"
避免 add_task_record 等场景中 db.commit() 时触发级联 UPDATE 污染数据库。
时间复杂度: O(N),空间复杂度: O(N)。
"""
if not tasks:
return {}
# ── Pass 1: 临时字典存储关系(不触碰 ORM 属性)──
temp_children_map: dict[uuid.UUID, list[Task]] = {t.id: [] for t in tasks}
task_map: dict[uuid.UUID, Task] = {t.id: t for t in tasks}
# ── Pass 2: 挂载到临时字典 ──
for t in tasks:
pid = t.parent_task_id
if pid is not None and pid in temp_children_map:
temp_children_map[pid].append(t)
# ── Pass 3: 排序 + set_committed_value 安全注入 ──
for t in tasks:
children = temp_children_map[t.id]
if children:
children.sort(key=lambda x: x.created_at)
# 关键标记为已提交数据SQLAlchemy 不会对其生成 UPDATE
set_committed_value(t, 'child_tasks', children)
return task_map
# ============================================================
# 公开 API按单一任务 ID 加载子树
# ============================================================
async def load_task_tree_by_root(
db: AsyncSession, task_id: uuid.UUID
) -> Task:
"""
使用 Recursive CTE 加载以 task_id 为根的完整任务子树。
返回: 根 Task ORM 对象child_tasks 已递归填充)。
Raises:
HTTPException(404): 根任务不存在。
"""
# ── Step 1: Recursive CTE — 收集所有子孙节点 ID ──
# WITH RECURSIVE task_tree AS (
# SELECT tasks.* FROM tasks WHERE tasks.id = :tid
# UNION ALL
# SELECT tasks.* FROM tasks
# JOIN task_tree ON tasks.parent_task_id = task_tree.id
# )
anchor = (
select(Task)
.where(Task.id == task_id)
.cte(name="task_tree", recursive=True)
)
task_tree_cte = anchor.union_all(
select(Task).join(anchor, Task.parent_task_id == anchor.c.id)
)
# ── Step 2: 批量加载所有任务 + 关联数据 ──
stmt = (
select(Task)
.options(
noload(Task.child_tasks), # 禁掉模型默认 selectinload由内存树接管
noload(Task.parent_task), # 组装树不需要 parent 引用
selectinload(Task.records), # 🔥 一次性预加载所有进度记录
selectinload(Task.product), # 🔥 一次性预加载产品引用
# 🔥 预加载挂载的出库物料:扫码响应要带它(移动端「领用物料」靠它渲染)。
# ★ 必须在这里预加载,不能等 _task_to_response 里现取 ——
# 异步 session 下懒加载会抛 MissingGreenlet本仓踩过的坑
selectinload(Task.outbound_materials),
)
.where(Task.id.in_(select(task_tree_cte.c.id)))
)
result = await db.execute(stmt)
all_tasks = result.unique().scalars().all()
if not all_tasks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"任务不存在: {task_id}",
)
# ── Step 3: 内存组装 ──
task_map = _build_tree_in_memory(all_tasks)
# 根任务一定在 map 中CTE anchor 保证了这一点)
return task_map[task_id]
# ============================================================
# 公开 API按产品 ID 加载所有任务树
# ============================================================
async def load_task_trees_by_product(
db: AsyncSession, product_id: uuid.UUID
) -> list[Task]:
"""
使用 Recursive CTE 加载指定产品下的所有任务树。
返回: 顶层任务列表parent_task_id IS NULL每项的 child_tasks 已递归填充。
若无任务则返回空列表。
"""
# ── Step 1: Recursive CTE ──
# WITH RECURSIVE product_task_tree AS (
# SELECT tasks.* FROM tasks
# WHERE tasks.product_id = :pid AND tasks.parent_task_id IS NULL
# UNION ALL
# SELECT tasks.* FROM tasks
# JOIN product_task_tree ON tasks.parent_task_id = product_task_tree.id
# )
anchor = (
select(Task)
.where(
Task.product_id == product_id,
Task.parent_task_id.is_(None),
)
.cte(name="product_task_tree", recursive=True)
)
task_tree_cte = anchor.union_all(
select(Task).join(anchor, Task.parent_task_id == anchor.c.id)
)
# ── Step 2: 批量加载 ──
stmt = (
select(Task)
.options(
noload(Task.child_tasks),
noload(Task.parent_task),
selectinload(Task.records),
selectinload(Task.product),
# 同上:扫码响应要带挂载的出库物料,必须预加载(懒加载会 MissingGreenlet
selectinload(Task.outbound_materials),
)
.where(Task.id.in_(select(task_tree_cte.c.id)))
)
result = await db.execute(stmt)
all_tasks = result.unique().scalars().all()
if not all_tasks:
return []
# ── Step 3: 内存组装 ──
_build_tree_in_memory(all_tasks)
# ── Step 4: 返回排序后的顶层任务 ──
roots = [t for t in all_tasks if t.parent_task_id is None]
roots.sort(key=lambda t: t.created_at)
return roots