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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user