feat: Track 打通已完成/已入库/已出库状态闭环与外部联动
- 完工转交入库同步 status=COMPLETED;MOM 入库/出库回调同步 status - 新增 mom-outbound webhook(发货出库标记已出库),lookup 返回 material_id - VALID_OVERALL_STATUS 新增已出库;update_overall_status 同步 status 字段 - WIP 矩阵区分已入库/待仓库收货;虚拟节点正确处理已出库/转入在库人
This commit is contained in:
61
backend/app/api/v1/endpoints/external_products.py
Normal file
61
backend/app/api/v1/endpoints/external_products.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""外部系统产品查询 API — 供 MOM 端扫码自动带出设备数据"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.product import Product
|
||||
|
||||
router = APIRouter(prefix="/external/products", tags=["外部查询"])
|
||||
|
||||
|
||||
@router.get("/lookup")
|
||||
async def external_product_lookup(
|
||||
code: str = Query(..., description="16 位系统序列号 或 自定义业务序列号"),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""供 MOM 端扫码查询产品基础信息。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- code 同时匹配 Product.serial_number(16 位)与 external_serial(业务序列号)。
|
||||
- 命中返回格式化设备信息;未命中返回 404。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 联合查询:serial_number 或 external_serial 匹配 code ──
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.options(selectinload(Product.order))
|
||||
.where(
|
||||
or_(
|
||||
Product.serial_number == code,
|
||||
Product.external_serial == code,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"serial_number": product.serial_number,
|
||||
"external_serial": product.external_serial,
|
||||
"material_id": product.material_id,
|
||||
"sku": product.spec_model,
|
||||
"material_name": product.material_name,
|
||||
"spec_model": product.spec_model,
|
||||
"material_type": product.material_type,
|
||||
"order_no": product.order.order_no if product.order else "",
|
||||
},
|
||||
}
|
||||
211
backend/app/api/v1/endpoints/webhooks.py
Normal file
211
backend/app/api/v1/endpoints/webhooks.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""外部系统回调 Webhook — Track 作为接收方
|
||||
|
||||
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
|
||||
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task
|
||||
from app.models.task_log import TaskLog
|
||||
|
||||
router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
|
||||
|
||||
|
||||
class MomInboundPayload(BaseModel):
|
||||
"""MOM 仓储系统确认接收入库的回调载荷"""
|
||||
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
|
||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||
operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
|
||||
inbound_time: datetime | None = None # 入库确认时间
|
||||
|
||||
|
||||
@router.post("/mom-inbound")
|
||||
async def mom_inbound_webhook(
|
||||
payload: MomInboundPayload,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""MOM 仓储系统确认接收产品入库后回调本接口。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 用 serial_number(优先)或 sku 查询当前位于 virtual_warehouse 的产品;
|
||||
命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
||||
- 未命中返回 200(MOM 可能入库了非 Track 生产的物料,直接忽略)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 按 serial_number(优先)或 sku 匹配"当前位于仓库"的产品 ──
|
||||
product = None
|
||||
if payload.serial_number:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
Product.serial_number == payload.serial_number,
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
elif payload.sku:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.spec_model == payload.sku,
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
|
||||
if product is None:
|
||||
return {"ok": True, "matched": False}
|
||||
|
||||
# ── 标记"已实收"闭环 ──
|
||||
changed = False
|
||||
if product.overall_status != "已入库":
|
||||
product.overall_status = "已入库"
|
||||
# 双字段同步:整体状态与产品状态保持一致(前端徽标依赖 status)
|
||||
product.status = "ARCHIVED"
|
||||
changed = True
|
||||
|
||||
# 记录仓库接收日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态)
|
||||
inbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if inbound_task is None:
|
||||
inbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
if inbound_task is not None:
|
||||
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
||||
db.add(TaskLog(
|
||||
task_id=inbound_task.id,
|
||||
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
||||
action_type="warehouse_inbound",
|
||||
remark=f"MOM 仓储系统确认接收入库(inbound_time: {time_str})",
|
||||
))
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True, "matched": True, "serial_number": product.serial_number}
|
||||
|
||||
|
||||
class MomOutboundPayload(BaseModel):
|
||||
"""MOM 仓储系统发货出库的回调载荷"""
|
||||
serial_number: str | None = None # 产品 16 位身份证(优先匹配)
|
||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||
operator: str | None = None # 出库操作人(写入 task_logs.operator_id)
|
||||
outbound_time: datetime | None = None # 出库时间
|
||||
|
||||
|
||||
@router.post("/mom-outbound")
|
||||
async def mom_outbound_webhook(
|
||||
payload: MomOutboundPayload,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""MOM 仓储系统发货出库后回调本接口,将 Track 产品标记为"已出库"。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 用 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品;
|
||||
命中则标记"已出库"(overall_status=已出库 + status=OUTBOUND + 记录 task_logs)。
|
||||
- 未命中返回 200(MOM 出库的可能是非 Track 生产的物料,直接忽略)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 按 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品 ──
|
||||
product = None
|
||||
where_cond = or_(
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
Product.overall_status.in_(["已入库", "在库"]),
|
||||
)
|
||||
if payload.serial_number:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
Product.serial_number == payload.serial_number,
|
||||
where_cond,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
elif payload.sku:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(Product.spec_model == payload.sku, where_cond)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
# ── 未命中:可能出库的是非 Track 生产的物料,直接忽略 ──
|
||||
if product is None:
|
||||
return {"ok": True, "matched": False}
|
||||
|
||||
# ── 标记"已出库" ──
|
||||
changed = False
|
||||
if product.overall_status != "已出库":
|
||||
product.overall_status = "已出库"
|
||||
product.status = "OUTBOUND"
|
||||
changed = True
|
||||
|
||||
# 记录出库日志(优先"在库"任务,其次该产品最新任务)
|
||||
outbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if outbound_task is None:
|
||||
outbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
if outbound_task is not None:
|
||||
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"
|
||||
db.add(TaskLog(
|
||||
task_id=outbound_task.id,
|
||||
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
||||
action_type="warehouse_outbound",
|
||||
remark=f"MOM 仓储系统发货出库(outbound_time: {time_str})",
|
||||
))
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True, "matched": True, "serial_number": product.serial_number}
|
||||
Reference in New Issue
Block a user