Files
track/backend/app/api/v1/endpoints/webhooks.py
duxingchen 73faa1fd93 feat: Track 打通已完成/已入库/已出库状态闭环与外部联动
- 完工转交入库同步 status=COMPLETED;MOM 入库/出库回调同步 status
- 新增 mom-outbound webhook(发货出库标记已出库),lookup 返回 material_id
- VALID_OVERALL_STATUS 新增已出库;update_overall_status 同步 status 字段
- WIP 矩阵区分已入库/待仓库收货;虚拟节点正确处理已出库/转入在库人
2026-09-01 13:53:24 +08:00

212 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""外部系统回调 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_modelserial 缺失时的兜底匹配)
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
- 未命中返回 200MOM 可能入库了非 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_modelserial 缺失时的兜底匹配)
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
- 未命中返回 200MOM 出库的可能是非 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}