"""外部系统回调 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}