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

@ -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"])