Files
track/backend/app/api/v1/endpoints/webhooks.py
duxingchen 262e28b9f2 fix: 仓储任务节点修复(assignee=None + WAREHOUSE 类型) + 前端防御性渲染
- webhook 生成任务 assignee_id 置 None,不再填中文,避免前端解析报错跳过渲染
- task_type=WAREHOUSE,并同步 isMain 判断(后端+双端前端)使其画在中央主干道
- 前端对扫码入库/出库任务:不请求用户数据,直接显示 📦 + 'MOM 仓储系统'
2026-09-01 17:25:22 +08:00

296 lines
11 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, TaskRecord
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 / external_serial双字段联合或 sku 匹配"当前位于仓库"的产品 ──
product = None
if payload.serial_number:
product = (
await db.execute(
select(Product).where(
or_(
Product.serial_number == payload.serial_number,
Product.external_serial == 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 await _append_warehouse_task(db, product, "扫码入库", "通过 MOM 系统扫码入库完成"):
changed = True
if changed:
await db.commit()
return {"ok": True, "matched": True, "serial_number": product.serial_number}
async def _append_warehouse_task(
db: AsyncSession,
product: Product,
task_name: str,
record_remark: str,
) -> bool:
"""在流转树主干道最底部追加一个主线任务节点(扫码入库/扫码出库)+ 操作日志。
逻辑:
- 找该产品最后一个主线任务created_at 最晚且为主线)的 id 作为 parent_task_id
保证树状主干连贯;
- 插入 task_type='TRANSFER' 的主线任务(现有主线枚举 → is_main=True画在中央主干道
状态直接 COMPLETED
- 生成一条 TaskRecord 供前端"查看操作日志"展示。
返回是否新增了节点(供调用方置 changed=True 触发提交)。
"""
from uuid import uuid4
from datetime import datetime as _dt
# 0) 幂等:该产品若已有同名主线任务(扫码入库/扫码出库),则不重复插入
existing = (
await db.execute(
select(Task.id).where(
Task.product_id == product.id,
Task.task_name == task_name,
).limit(1)
)
).scalars().first()
if existing:
return False
# 1) 最后一个主线任务created_at 最晚,且无父任务 或 task_type 为主线枚举)
last_main_task = (
await db.execute(
select(Task)
.where(
Task.product_id == product.id,
or_(
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
),
)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
# 2) 插入"扫码入库/扫码出库"主线任务
new_task = Task(
product_id=product.id,
parent_task_id=last_main_task.id if last_main_task else None,
task_name=task_name,
# ★ assignee_id 显式置 None不能填非 UUID 字符串,否则前端解析头像/用户信息报错导致节点跳过渲染
assignee_id=None,
status="COMPLETED",
task_type="WAREHOUSE", # 仓储任务类型is_main 判断已兼容 WAREHOUSE → 画在中央主干道)
completed_at=_dt.now(),
remark=record_remark,
)
db.add(new_task)
await db.flush() # 生成 new_task.id
# 3) 生成操作日志TaskRecord,供前端"查看操作日志"有真实数据
db.add(TaskRecord(
task_id=new_task.id,
remark=record_remark,
))
return True
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(
or_(
Product.serial_number == payload.serial_number,
Product.external_serial == 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 await _append_warehouse_task(db, product, "扫码出库", "通过 MOM 系统扫码出库完成"):
changed = True
if changed:
await db.commit()
return {"ok": True, "matched": True, "serial_number": product.serial_number}