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:
@ -11,10 +11,18 @@ from app.models.message import ProductMessage
|
||||
from app.schemas.product import (
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
ProductOutboundMaterialResponse,
|
||||
ProductResponse,
|
||||
ProductScanResponse,
|
||||
ProductScrapCreate,
|
||||
ProductScrapResponse,
|
||||
)
|
||||
from app.services import (
|
||||
product_service,
|
||||
product_finalize_service,
|
||||
product_scrap_service,
|
||||
product_outbound_material_service,
|
||||
)
|
||||
from app.services import product_service, product_finalize_service
|
||||
from app.services.auth_service import get_current_user
|
||||
from app.services.qrcode_service import generate_qrcode_png
|
||||
|
||||
@ -107,11 +115,169 @@ async def create_product_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建产品 — 初始位置自动设为当前登录用户"""
|
||||
"""创建产品 — 初始位置自动设为当前登录用户
|
||||
|
||||
`mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库单挂到这个新产品上,
|
||||
所以不存在「产品建好了但出库单没挂上」的中间态。
|
||||
"""
|
||||
creator_username = current_user.get("username", "")
|
||||
return await product_service.create_product(db, data, creator_username)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设备的 MOM 出库明细(统一后的唯一一组接口)
|
||||
#
|
||||
# 原先这里是两套并存的接口:
|
||||
# · /outbound-orders —— 产品 ↔ 出库**单**(单据级,product_outbounds)
|
||||
# · /materials —— 任务 ↔ 出库**明细**(明细级,task_outbound_materials)
|
||||
# 两者本来就是同一个概念,却因为粒度不同被拆开:用户要面对两个入口、两张卡,
|
||||
# 而且走单据级挂的料**没有明细行 id,报不了废**。现已合并 ——
|
||||
# 一张表 product_outbound_materials、一组接口、界面上只有一张卡。
|
||||
# ============================================================
|
||||
|
||||
class ProductOutboundMaterialsAdd(BaseModel):
|
||||
"""挂载 MOM 出库明细的请求体"""
|
||||
# 提交的是 MOM 出库**明细行** ID(trans_outbound.id)。前端按整张出库单勾选,
|
||||
# 提交时把该单全部明细 ID 带过来 —— 本表按明细行成行,一张单展开成 N 行。
|
||||
# ⚠️ 只传 ID,物料快照由后端现查 MOM —— 不接受前端传快照,否则可伪造。
|
||||
mom_line_ids: list[int] = Field(default_factory=list, description="MOM 出库明细行ID")
|
||||
# 挂到哪条任务(**可空**)。一线按任务领料,所以人工挂载时会给;
|
||||
# 但任务只是溯源信息,不参与展示/报废/删除 —— 那些一律按设备走。
|
||||
task_id: str | None = Field(None, description="所属任务ID(可空,仅溯源)")
|
||||
|
||||
|
||||
@router.get("/{product_id}/outbound-materials",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def get_product_outbound_materials_endpoint(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
|
||||
|
||||
回答「这台设备对应 MOM 的哪些出库单、领了哪些料」——
|
||||
网页端编辑产品弹窗与移动端「领用物料」页读的都是它,两端同源。
|
||||
"""
|
||||
import uuid
|
||||
return await product_outbound_material_service.list_product_materials(
|
||||
db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
@router.post("/{product_id}/outbound-materials",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def add_product_outbound_materials_endpoint(
|
||||
product_id: str,
|
||||
data: ProductOutboundMaterialsAdd,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
|
||||
|
||||
幂等:已挂过的明细会被跳过。返回该设备当前**全部**出库明细。
|
||||
"""
|
||||
import uuid
|
||||
pid = uuid.UUID(product_id)
|
||||
product = await product_service.get_product(db, pid) # 不存在则 404
|
||||
added = await product_outbound_material_service.link_outbound_lines(
|
||||
db, product, data.mom_line_ids,
|
||||
task_id=uuid.UUID(data.task_id) if data.task_id else None,
|
||||
added_by=current_user.get("username"),
|
||||
)
|
||||
if added:
|
||||
await db.commit()
|
||||
return await product_outbound_material_service.list_product_materials(db, pid)
|
||||
|
||||
|
||||
@router.delete("/{product_id}/outbound-materials/by-order/{outbound_no}",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def remove_product_outbound_order_endpoint(
|
||||
product_id: str,
|
||||
outbound_no: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""整张出库单一起摘掉(挂错了要能撤)。
|
||||
|
||||
界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
|
||||
规则与逐条删**完全一致**:只要这张单在本设备上有一行来自 MOM 回调
|
||||
自动存档(`source='webhook'`),整单就返回 409 —— 那是系统事实,
|
||||
要撤得去 MOM 撤回。这样整单删不会变成绕过单行规则的后门。
|
||||
|
||||
⚠️ 路径放在 `/{material_id}` **之前**注册:`by-order` 是固定段,
|
||||
但要避免被 `{material_id}` 抢先匹配(Starlette 按注册顺序匹配)。
|
||||
"""
|
||||
import uuid
|
||||
return await product_outbound_material_service.remove_product_order(
|
||||
db, uuid.UUID(product_id), outbound_no)
|
||||
|
||||
|
||||
@router.delete("/{product_id}/outbound-materials/{material_id}",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def remove_product_outbound_material_endpoint(
|
||||
product_id: str,
|
||||
material_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
|
||||
|
||||
⚠️ 只允许删人工挂的(`source='manual'`):MOM 出库回调自动存档的行返回 409
|
||||
—— 那是系统事实,要撤得去 MOM 撤回,由回调置「已撤回」留痕。
|
||||
|
||||
返回该设备**剩余**的全部出库明细,前端整体覆盖即可。
|
||||
"""
|
||||
import uuid
|
||||
return await product_outbound_material_service.remove_product_material(
|
||||
db, uuid.UUID(product_id), material_id)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 生产报废 —— Track 发起,MOM 走「退回(不良品) → 报废申请 → 审批 → 执行」
|
||||
# ============================================================
|
||||
|
||||
@router.get("/{product_id}/scraps", response_model=list[ProductScrapResponse])
|
||||
async def list_product_scraps_endpoint(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""列出该产品的生产报废记录(按提交时间倒序)。
|
||||
|
||||
状态与金额是**实时回查 MOM** 的:报废没有回调,本地存的那份会过期,
|
||||
而「到底批没批、执行没执行」正是用户要看的。
|
||||
MOM 暂时查不到时降级用本地快照,但**不伪造金额**(未执行时 total_loss 是 null)。
|
||||
"""
|
||||
import uuid
|
||||
return await product_scrap_service.list_product_scraps(db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
@router.post("/{product_id}/scraps", response_model=ProductScrapResponse)
|
||||
async def create_product_scrap_endpoint(
|
||||
product_id: str,
|
||||
data: ProductScrapCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""提交一条生产报废(领用的料在生产中损坏)。
|
||||
|
||||
- 只传 `mom_line_id` + 数量 + `track_ref`,物料信息由后端从本产品已挂的
|
||||
出库物料里取 —— 不接受前端传快照。
|
||||
- 后端校验 `mom_line_id` **确实挂在本产品上**:可见范围是整台设备,
|
||||
跨设备防护只能靠这道校验(不靠隐藏)。
|
||||
- 申请人 = 当前登录人(Track 的 sub 就是 MOM sys_user.id)。
|
||||
- 幂等:同一个 `track_ref` 重发不会产生第二张 MOM 报废单。
|
||||
"""
|
||||
import uuid
|
||||
return await product_scrap_service.submit_product_scrap(
|
||||
db, uuid.UUID(product_id),
|
||||
mom_line_id=data.mom_line_id,
|
||||
quantity=data.quantity,
|
||||
track_ref=data.track_ref,
|
||||
reason=data.reason,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=ProductResponse)
|
||||
async def update_product_endpoint(
|
||||
product_id: str,
|
||||
|
||||
@ -64,9 +64,23 @@ async def create_task_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建任务"""
|
||||
return await task_service.create_task(db, data)
|
||||
"""创建任务
|
||||
|
||||
`mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库明细挂到新任务上,
|
||||
所以不存在「任务建好了但物料没挂上」的中间态。
|
||||
"""
|
||||
return await task_service.create_task(
|
||||
db, data, operator_id=current_user.get("username"),
|
||||
)
|
||||
|
||||
|
||||
# 注:原先这里有「任务挂载 MOM 出库物料」的三个端点
|
||||
# (GET/POST /tasks/{id}/outbound-materials、DELETE .../{material_id})。
|
||||
# 物料已统一为**设备级**,这三个端点连同 task_service 里的实现一起删除 ——
|
||||
# 挂载/查看/删除/报废一律走:
|
||||
# GET/POST /products/{id}/outbound-materials
|
||||
# DELETE /products/{id}/outbound-materials/{material_id}
|
||||
# 保留任务级入口只会让「同一个东西两个地方」重新长出来。
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskResponse)
|
||||
async def update_task_endpoint(
|
||||
|
||||
@ -19,9 +19,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.lifecycle import sync_product_status
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.services import product_outbound_material_service
|
||||
|
||||
router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
|
||||
|
||||
@ -259,6 +261,18 @@ async def mom_inbound_webhook(
|
||||
if product.status != prev_status:
|
||||
changed = True
|
||||
|
||||
# 4) 把最近一次出库单标记为已撤回。
|
||||
# ⚠️ 只置位、**不删行** ——「出过又撤了」本身就是要看得见的历史(建表时的
|
||||
# 取舍,见 models/product_outbound.py)。产品详情会把撤回的单据照常画
|
||||
# 出来并打「已撤回」,而不是让它凭空消失。
|
||||
# ⚠️ 只标最近一条未撤回的:一批里同一台设备理论上不该出现两条未撤回的
|
||||
# 出库单(出库后设备已不在仓库池,再出库匹配不到),但真出现时标错
|
||||
# 一条也好过把历史全标脏。
|
||||
if is_revoke:
|
||||
# 「标哪一张单」的取舍写在服务层里(见 mark_revoked 的 docstring)
|
||||
if await product_outbound_material_service.mark_revoked(db, product.id):
|
||||
changed = True
|
||||
|
||||
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
|
||||
log_task = await _pick_warehouse_log_task(db, product)
|
||||
if log_task is not None:
|
||||
@ -393,10 +407,19 @@ class MomOutboundPayload(BaseModel):
|
||||
operator: str | None = None # 出库操作人(写入 task_logs.operator_id)
|
||||
outbound_time: datetime | None = None # 出库时间
|
||||
company_name: str | None = None # 目标公司(IRIS / LICA),MOM 据此分流到不同 Track 实例
|
||||
# ↓ 2026-09 新增:MOM 一直在发、此前被 Pydantic 静默丢弃。当前无人读取,
|
||||
# ↓ 2026-09 新增:MOM 一直在发、此前被 Pydantic 静默丢弃。
|
||||
# 先接住是为了与 LICA 实例(~/track-lica)对同一载荷的解析结果保持一致 ——
|
||||
# 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None。
|
||||
outbound_type: str | None = None # SALES / USE / PRODUCTION
|
||||
# ↓ 2026-09 新增:单据上下文,落进 product_outbounds 供产品详情展示
|
||||
# ⚠️ 不在这里声明的字段会被 Pydantic **静默丢弃**、且不报任何错 ——
|
||||
# MOM 那边发了也等于没发。这是本功能最容易踩的坑(company_name 当初
|
||||
# 也是这么丢的)。
|
||||
outbound_no: str | None = None # MOM 出库单号(批量出库多商品共用)
|
||||
request_no: str | None = None # MOM 出库申请单号
|
||||
consumer_name: str | None = None # 领用人/客户(自由填写,非可靠标识)
|
||||
applicant_name: str | None = None # 申请人姓名(MOM 侧解析后传来)
|
||||
remark: str | None = None # 出库单备注
|
||||
|
||||
|
||||
@router.post("/mom-outbound")
|
||||
@ -487,8 +510,34 @@ async def mom_outbound_webhook(
|
||||
))
|
||||
changed = True
|
||||
|
||||
# ── 存档 MOM 单据 ──
|
||||
# 一次出库一行(**明细级**,与人工挂载同一张表),产品详情据此回答
|
||||
# 「这台设备对应 MOM 的哪张单、领了哪些料」。
|
||||
#
|
||||
# 幂等:服务层按 (product_id, outbound_no) 查重后再写(MOM 的 notify_track
|
||||
# 走守护线程且不重试,但同一条回调仍可能因运维手工重放而重入 —— 重复写入会
|
||||
# 让产品详情出现两张一模一样的单据)。表上另有部分唯一索引兜底。
|
||||
#
|
||||
# ⚠️ outbound_no 为空则整段跳过(表里该列 NOT NULL)。这是与旧版 MOM 的
|
||||
# 向前兼容:MOM 没升级时本来就不发这些字段,此时静默不存档,其余逻辑
|
||||
# 照常 —— 不要因为缺字段就 4xx,那会让 MOM 把正常出库当故障。
|
||||
#
|
||||
# ⚠️ 明细是靠 outbound_no 去 MOM **现查**的(回调载荷里没有明细)——
|
||||
# 不查的话这台设备「领了哪些料」永远是空的,也就报不了废。
|
||||
if payload.outbound_no:
|
||||
if await product_outbound_material_service.archive_from_webhook(
|
||||
db, product, payload,
|
||||
):
|
||||
changed = True
|
||||
|
||||
# ── 动态生成"扫码出库"主线任务节点 + 操作日志(流转树最底部长出出库节点) ──
|
||||
if await _append_warehouse_task(db, product, "扫码出库", "通过 MOM 系统扫码出库完成"):
|
||||
# 单号拼进备注,不查子表也能在流转树里看出是哪张单出的库。
|
||||
# ⚠️ 只动 remark,**不要**往 task_name 里塞 —— task_name 会被直接写成
|
||||
# product.overall_status(services/task_service.py:435,782)。
|
||||
outbound_note = f"(单号 {payload.outbound_no})" if payload.outbound_no else ""
|
||||
if await _append_warehouse_task(
|
||||
db, product, "扫码出库", f"通过 MOM 系统扫码出库完成{outbound_note}",
|
||||
):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
|
||||
Reference in New Issue
Block a user