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。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent 551819e0e3
commit 5d5aea1015
15 changed files with 2030 additions and 15 deletions

View File

@ -0,0 +1,356 @@
"""设备出库明细 — 业务逻辑层(合并后的唯一入口)
「这台设备对应 MOM 的哪些出库单、领了哪些料」在本模块只有一个概念、
一张表(`product_outbound_materials`)、一组函数。原先那两张表
(`product_outbounds` 单据级 / `task_outbound_materials` 明细级)已停止写入,
只作回滚备份保留 —— 详见该模型的模块注释。
═══ 两条写入路径 ═══
· `link_outbound_lines` —— 人在界面上挂(网页端/移动端选 MOM 出库单)
→ source='manual',可删
· `archive_from_webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备)
→ source='webhook',系统事实,不可删(要撤得去 MOM 撤回)
两条路径共用同一张表、同一套幂等约束,但**来源不同、删除规则不同** ——
这是它们唯一的差别,`source` 列把它记下来。
"""
import logging
import uuid
from fastapi.concurrency import run_in_threadpool
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.product import Product
from app.models.product_outbound_material import ProductOutboundMaterial
from app.schemas.product import ProductOutboundMaterialResponse
from app.services import mom_outbound_service
logger = logging.getLogger(__name__)
async def list_product_materials(
db: AsyncSession, product_id: uuid.UUID,
) -> list[ProductOutboundMaterialResponse]:
"""列出某设备挂载的全部出库明细。
排序:先按 MOM 出库时间倒序,没有时间的沉底,再按写入时间兜底 ——
避免 outbound_time 为空的行插在最前面。
"""
rows = (
await db.execute(
select(ProductOutboundMaterial)
.where(ProductOutboundMaterial.product_id == product_id)
.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
ProductOutboundMaterial.id.desc(),
)
)
).scalars().all()
items = [ProductOutboundMaterialResponse.model_validate(r) for r in rows]
fill_added_by_names(items)
return items
def fill_added_by_names(
items: list[ProductOutboundMaterialResponse],
) -> list[ProductOutboundMaterialResponse]:
"""就地补上 `added_by_name`(谁挂上去的,中文姓名),并**返回同一个列表**。
返回列表是为了能写成 `fill(x)` 直接当值用 —— 就地修改却返回 None 的话,
`fill(x) or []` 会静默变成空数组(写这行时就差点踩到)。
界面要显示「谁挂的」——`added_by` 存的是 Track 用户名(如 `zhangsan01`),
直接摆出来现场看不懂。解析交给服务端:移动端与网页端各抄一份 username→姓名
的映射迟早会漂移,而且 MOM 的 username 是「姓名/账号」格式,规则不止一条。
⚠️ 解析失败**不能影响列表**:MOM 连不上时姓名降级为空串,前端回落到显示
用户名。查一次是批量 SQL(mom_cache 还带 2h TTL),不给每行单独打库。
"""
from app.services.mom_cache import get_display_names
names = [i.added_by for i in items if i.added_by]
if not names:
return items
try:
mapping = get_display_names(list(dict.fromkeys(names)))
except Exception as e:
logger.warning(f"[OutboundMaterial] 解析挂载人姓名失败,降级显示用户名: {e}")
return items
for i in items:
if i.added_by:
i.added_by_name = mapping.get(i.added_by, "")
return items
async def link_outbound_lines(
db: AsyncSession, product: Product, mom_line_ids: list[int],
*, task_id: uuid.UUID | None = None, added_by: str | None = None,
) -> int:
"""把 MOM 出库**明细行**挂到设备上(人工路径)。返回实际新增行数。
用户勾的是**整张出库单**,提交时把该单全部明细行 id 一起带过来 ——
本表按明细行成行,所以一张单会展开成 N 行。
⚠️ 只接受 `mom_line_ids`,物料快照一律由后端拿 id 去 MOM 现查,
否则前端可以伪造「挂的是什么」。
⚠️ MOM 查询是同步 psycopg2,用 run_in_threadpool 扔出去,别阻塞事件循环。
⚠️ 调用方负责 commit —— 本函数只 flush,好让挂载与产品创建同事务。
幂等:已挂过的明细跳过(部分唯一索引兜底)。
"""
ids = list(dict.fromkeys(int(i) for i in mom_line_ids)) # 去重且保持顺序
if not ids:
return 0
lines = await run_in_threadpool(mom_outbound_service.get_lines_by_ids, ids)
if not lines:
# MOM 侧查不到(数据被清理 / ID 传错)—— 静默返回 0,由调用方比对数量
return 0
existing = set(
(
await db.execute(
select(ProductOutboundMaterial.mom_line_id).where(
ProductOutboundMaterial.product_id == product.id,
ProductOutboundMaterial.mom_line_id.in_(
[ln["line_id"] for ln in lines]),
)
)
).scalars().all()
)
added = 0
for ln in lines:
if ln["line_id"] in existing:
continue
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=task_id,
mom_line_id=ln["line_id"],
outbound_no=ln["outbound_no"],
request_no=ln.get("request_no") or None,
applicant_name=None, # 人工挂载拿不到申请人:MOM 的 request_id 存量全为 NULL
remark=None,
sku=ln.get("sku") or None,
material_name=ln.get("material_name") or None,
spec_model=ln.get("spec_model") or None,
quantity=ln.get("quantity"),
unit_price=ln.get("unit_price"),
outbound_type=ln.get("outbound_type") or None,
consumer_name=ln.get("consumer_name") or None,
operator_name=ln.get("operator_name") or None,
warehouse_location=ln.get("warehouse_location") or None,
outbound_time=ln.get("outbound_time"),
source="manual",
added_by=added_by,
))
added += 1
await db.flush()
return added
async def archive_from_webhook(
db: AsyncSession, product: Product, payload, *, company_name: str | None = None,
) -> bool:
"""MOM 出库回调 → 存档到本表(webhook 路径)。返回是否有新写入。
按 `payload.outbound_no` 去 MOM **现查明细**,逐行落 —— 本表是明细级,
只写一条单据级信息的话,这台设备上「领了什么料」就永远是空的。
⚠️ 查不到明细(MOM 数据被清理、或该单确实没有可解析的明细)时,
退化成写**一行 `mom_line_id=NULL` 的存档**:宁可显示「有这张单但看不到明细」,
也不要静默丢掉这张单 —— 用户会以为出库记录丢了。
⚠️ 幂等:已有存档就跳过(webhook 可能因运维重放而重入)。
"""
outbound_no = (getattr(payload, "outbound_no", "") or "").strip()
if not outbound_no:
return False
# 已有该单的存档 → 不重复写。**按 outbound_no 判**(不是按明细行),
# 因为一张单的所有明细必然一起写入,任一行存在即整单已存过。
already = (
await db.execute(
select(ProductOutboundMaterial.id).where(
ProductOutboundMaterial.product_id == product.id,
ProductOutboundMaterial.outbound_no == outbound_no,
).limit(1)
)
).scalar_one_or_none()
if already is not None:
return False
lines = await run_in_threadpool(
mom_outbound_service.get_lines_by_outbound_no, outbound_no)
if not lines:
# 退化成单据级存档:能看、能标撤回,但没有明细行 id,**不能报废**
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=None,
mom_line_id=None,
outbound_no=outbound_no,
request_no=getattr(payload, "request_no", None) or None,
applicant_name=getattr(payload, "applicant_name", None) or None,
remark=getattr(payload, "remark", None) or None,
outbound_type=getattr(payload, "outbound_type", None) or None,
consumer_name=getattr(payload, "consumer_name", None) or None,
operator_name=(getattr(payload, "operator", None) or "")[:100] or None,
outbound_time=getattr(payload, "outbound_time", None),
source="webhook",
))
logger.info(f"[OutboundMaterial] {outbound_no} MOM 查不到明细,退化为单据级存档")
return True
for ln in lines:
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=None,
mom_line_id=ln["line_id"],
outbound_no=outbound_no,
request_no=getattr(payload, "request_no", None) or None,
applicant_name=getattr(payload, "applicant_name", None) or None,
remark=getattr(payload, "remark", None) or None,
sku=ln.get("sku") or None,
material_name=ln.get("material_name") or None,
spec_model=ln.get("spec_model") or None,
quantity=ln.get("quantity"),
unit_price=ln.get("unit_price"),
outbound_type=(getattr(payload, "outbound_type", None)
or ln.get("outbound_type") or None),
consumer_name=(getattr(payload, "consumer_name", None)
or ln.get("consumer_name") or None),
operator_name=(getattr(payload, "operator", None)
or ln.get("operator_name") or None),
warehouse_location=ln.get("warehouse_location") or None,
outbound_time=(getattr(payload, "outbound_time", None)
or ln.get("outbound_time")),
source="webhook",
))
return True
async def mark_revoked(
db: AsyncSession, product_id: uuid.UUID, outbound_no: str | None = None,
) -> bool:
"""MOM 撤回回调 → 把该单在本设备上的存档标为已撤回。返回是否有改动。
**只置位不删行** ——「出过又撤了」本身就是要看得见的历史。
:param outbound_no: 指定单号则只标那一单;为空则标**最近一条未撤回的**。
MOM 的撤回载荷不保证带单号,而「最近一条未撤回的」是这批里最可能被撤的那张
—— 一批里同一台设备理论上不该出现两条未撤回的出库单(出库后设备已不在
仓库池,再出库匹配不到)。真出现时标错一条,也好过把历史全标脏。
"""
from app.core.time_utils import get_beijing_time
q = select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.is_revoked.is_(False),
)
if outbound_no:
q = q.where(ProductOutboundMaterial.outbound_no == outbound_no)
rows = (await db.execute(q)).scalars().all()
else:
# 先定位到「哪一张单」,再把那张单的**全部明细行**一起标 ——
# 只标一行的会让同一张单呈现「一半撤回一半没撤」的鬼状态
latest = (
await db.execute(
q.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
).limit(1)
)
).scalars().first()
if latest is None:
return False
rows = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.outbound_no == latest.outbound_no,
)
)
).scalars().all()
changed = False
for r in rows:
if not r.is_revoked:
r.is_revoked = True
r.revoked_at = get_beijing_time()
changed = True
return changed
async def remove_product_order(
db: AsyncSession, product_id: uuid.UUID, outbound_no: str,
) -> list[ProductOutboundMaterialResponse]:
"""整张出库单一起摘掉(挂错了要能撤)。
界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下,所以补这个。
规则与逐条删**完全一致**:只要这张单在本设备上有一行是 `webhook`
(MOM 回调自动存档的系统事实),整单就不给删 —— 要撤得去 MOM 撤回。
这样「整单删」不会成为绕过单行规则的后门。
"""
no = (outbound_no or "").strip()
if not no:
raise HTTPException(status_code=400, detail="出库单号不能为空")
rows = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.outbound_no == no,
)
)
).scalars().all()
if not rows:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="这张出库单没有挂在这台设备上")
if any(r.source != "manual" for r in rows):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="该出库单含 MOM 回调自动存档的记录,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
)
for r in rows:
await db.delete(r)
await db.commit()
return await list_product_materials(db, product_id)
async def remove_product_material(
db: AsyncSession, product_id: uuid.UUID, material_id: int,
) -> list[ProductOutboundMaterialResponse]:
"""摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
只允许删 `source='manual'`:webhook 存档是 MOM 出库回调留下的系统事实,
删了 Track 与 MOM 就对不上(MOM 那边单还在)。要撤该去 MOM 撤回,
由回调置 `is_revoked` 留痕。
"""
row = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.id == material_id,
ProductOutboundMaterial.product_id == product_id,
)
)
).scalars().first()
if row is None:
# 不属于这台设备的一律按「查不到」处理,不泄漏别的设备挂了什么
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="这条出库明细没有挂在这台设备上")
if row.source != "manual":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="该出库单由 MOM 回调自动存档,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
)
await db.delete(row)
await db.commit()
return await list_product_materials(db, product_id)

View File

@ -15,10 +15,23 @@ from app.core.lifecycle import (
sync_product_status,
)
from app.models.product import Product
from app.models.product_outbound_material import ProductOutboundMaterial
from app.models.production_order import ProductionOrder
from app.models.task import Task
from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse
from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse
from app.schemas.product import (
ProductCreate,
ProductUpdate,
ProductOutboundMaterialResponse,
ProductResponse,
ProductScanResponse,
)
from app.schemas.task import (
TaskSummaryResponse,
TaskResponse,
TaskRecordResponse,
)
# 设备出库明细:扫码响应要附「谁挂上去的」中文名(见 fill_added_by_names)
from app.services import product_outbound_material_service
def _task_to_response(task: Task) -> TaskResponse:
@ -51,6 +64,10 @@ def _task_to_response(task: Task) -> TaskResponse:
child_tasks=[_task_to_response(c) for c in task.child_tasks],
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
created_by=getattr(task, "created_by", None),
# ⚠️ 这里**不再**带 outbound_materials:物料已统一为**设备级**
# (product_outbound_materials),挂在任务上只会变成第二个数据源 ——
# 正是这次要消除的「同一件事两个地方」。设备出库明细看扫码响应的
# `outbound_records`(已改为读新表)。
)
@ -272,6 +289,23 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
# 🔧 中文名映射(负责人 + 创建人,供前端显示"谁转入在库"等)
assignee_names = _lookup_display_names(list(assignee_ids))
# 🔧 设备的 MOM 出库明细(统一后的唯一来源)—— 产品详情据此回答「这台设备
# 对应 MOM 的哪些出库单、领了哪些料」。撤回的记录照常返回、由前端打
# 「已撤回」标记,不在后端过滤掉:「出过又撤了」也是历史。
# 排序:先按 MOM 记录的出库时间,没有的(旧数据/字段缺失)沉底,再按写入
# 时间兜底 —— 避免 outbound_time 为空的行插在最前面。
outbound_rows = (
await db.execute(
select(ProductOutboundMaterial)
.where(ProductOutboundMaterial.product_id == product.id)
.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
ProductOutboundMaterial.id.desc(),
)
)
).scalars().all()
return ProductScanResponse(
id=product.id,
serial_number=product.serial_number,
@ -294,9 +328,40 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
],
task_tree=task_tree,
assignee_names=assignee_names, # 🔧 username→中文姓名
# ⚠️ Product 模型没有 to_dict(),本响应是逐字段手工构造的 —— 漏赋值不会
# 报错,只会永远返回默认值(空列表)。
# 附「谁挂上去的」中文名(服务端解析,两端共用;MOM 挂了就降级显示用户名)
outbound_records=product_outbound_material_service.fill_added_by_names(
[ProductOutboundMaterialResponse.model_validate(r) for r in outbound_rows]
),
)
async def _link_mom_outbound_orders(
db: AsyncSession, product: Product, mom_line_ids: list[int],
) -> int:
"""建产品时勾选的 MOM 出库明细 —— 转交给统一后的设备出库明细服务。
合并后只有一张表(`product_outbound_materials`)、一套挂载逻辑。
这里保留薄封装是因为「建产品时勾选」这条路仍然是产品发起的:
存储、幂等、快照一律由那个服务负责 —— 不再有第二份实现。
返回实际新增行数。
"""
from app.services import product_outbound_material_service
return await product_outbound_material_service.link_outbound_lines(
db, product, mom_line_ids,
)
# 注:原先这里还有 get_product_materials / get_product_outbound_orders /
# add_product_outbound_orders / remove_product_outbound_order 四个函数 ——
# 它们服务的是「单据级」的 product_outbounds 与「任务级」的
# task_outbound_materials。两张表已统一到 product_outbound_materials,
# 读写一律走 product_outbound_material_service,故一并删除。
# 旧表与其数据仍在库里(只停写),需要时可按迁移的 downgrade 回滚。
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product:
"""获取产品,不存在则 404"""
result = await db.execute(
@ -349,6 +414,10 @@ async def create_product(db: AsyncSession, data: ProductCreate, creator_username
current_location_id=creator_username or None, # 谁创建,初始位置就是谁
)
db.add(product)
# 挂钩建档时选中的 MOM 出库单 —— 与产品**同事务**:产品建失败时不会留下
# 孤立的挂载行。此前 commit 一次就够,现在多这一步在 commit 之前。
if data.mom_line_ids:
await _link_mom_outbound_orders(db, product, data.mom_line_ids)
await db.commit()
await db.refresh(product, ["order"])

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import uuid
from fastapi import HTTPException, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@ -38,7 +39,6 @@ from app.schemas.task import (
TaskSummaryResponse,
TaskListResponse,
)
# 特殊位置常量
VIRTUAL_WAREHOUSE = "virtual_warehouse"
@ -332,6 +332,10 @@ def _to_response(task: Task) -> TaskResponse:
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 [])],
# ⚠️ 这里**不再**带 outbound_materials:物料已统一为**设备级**
# (product_outbound_materials),挂在任务上只会变成第二个数据源 ——
# 正是这次要消除的「同一件事两个地方」。设备出库明细走
# GET /products/{id}/outbound-materials(扫码响应里也有)。
)
@ -409,9 +413,50 @@ async def get_top_level_tasks(db: AsyncSession, product_id: uuid.UUID) -> list[T
return [TaskSummaryResponse.model_validate(t) for t in tasks]
async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
"""创建任务,并同步产品宏观状态"""
task = Task(**data.model_dump())
# ============================================================
# 任务挂载 MOM 出库物料
# ============================================================
async def _mount_outbound_lines(
db: AsyncSession,
task: Task,
mom_line_ids: list[int],
operator_id: str | None,
) -> int:
"""建任务时勾选的 MOM 出库明细 —— 转交给统一后的设备出库明细服务。
合并后物料不再是「任务的」而是「**设备的**」,只有一张表
(`product_outbound_materials`)、一套挂载逻辑。这里保留薄封装是因为
「建任务时勾选」这条路仍然是任务发起的:任务 id 作为**溯源信息**传下去
(这条料挂在哪条任务上),而存储、幂等、快照一律由那个服务负责 ——
不再有第二份实现。
返回实际新增行数。
"""
from app.models.product import Product
from app.services import product_outbound_material_service
product = await db.get(Product, task.product_id)
if product is None:
# 任务必然有产品(外键约束),走到这里说明数据被绕过改过。
# 静默跳过:不能因为挂料失败而让整个建任务事务炸掉
logger.warning(f"[Task] 任务 {task.id} 的产品不存在,跳过出库明细挂载")
return 0
return await product_outbound_material_service.link_outbound_lines(
db, product, mom_line_ids, task_id=task.id, added_by=operator_id,
)
async def create_task(
db: AsyncSession, data: TaskCreate, operator_id: str | None = None,
) -> TaskResponse:
"""创建任务,并同步产品宏观状态
operator_id: 创建人。用于记录是谁把出库物料挂上来的。
"""
# ⚠️ 必须 exclude 掉 mom_line_ids:它不是 Task 的列,展开进去会直接
# TypeError('mom_line_ids' is an invalid keyword argument for Task)。
task = Task(**data.model_dump(exclude={"mom_line_ids"}))
db.add(task)
# 同步产品宏观状态 + 当前位置
@ -438,11 +483,22 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
product.current_location_id = data.assignee_id
# 挂载出库物料 —— 放在所有校验之后、commit 之前,与任务**同事务**:
# 校验失败时不会留下「任务没建成、物料却挂上了」的残留。
if data.mom_line_ids:
await _mount_outbound_lines(db, task, data.mom_line_ids, operator_id)
await db.commit()
await db.refresh(task)
return _to_response(task)
# 注:原先这里有 add_task_outbound_materials / remove_task_outbound_material
# (任务级增删出库物料)。物料统一为**设备级**后已删除 —— 挂载/删除一律走
# product_outbound_material_service,界面上也只有「设备」一个维度,
# 不再提供任务级的第二套读写入口。
async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) -> TaskResponse:
"""更新任务"""
task = await _get_task_or_404(db, task_id)

View File

@ -96,6 +96,10 @@ async def load_task_tree_by_root(
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)))
)
@ -157,6 +161,8 @@ async def load_task_trees_by_product(
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)))
)