【直接完结:保留动作,剥离状态】 - transfer_task 权限硬拦截:仅 SUPER_ADMIN / SUPERVISOR。判据取签名保护的 operator_role,刻意不用客户端可通过 ?operator_id= 伪造的 operator_id - 直接完结【不再改写】overall_status —— 它只是任务闭环动作,不改变物理状态。 已出库设备完结后依然是「已出库」,回归 WIP 矩阵的已出库列 - 物理终态保护:create_task / receive_task / transfer_task 三处写入点统一加 _is_physical_terminal 守卫,禁止用任务名覆写 已入库/在库/已出库。 历史缺陷:「发货测试」会把设备的「已出库」标识静默抹掉,跨报表口径随之打架 【位置与工序口径】 - _recalc_product_location:已出库且闲置 → 位置清空(货发走就离场)。 只清「已出库」;已入库/在库的设备确实还在仓库里,位置必须保留 - MOM 出库回调同步清空 current_location_id - 新增 ProductResponse.current_step:只认活跃主干任务,无活跃任务时返回 宏观终态或空。不再让已 COMPLETED 的历史工序(扫码出库/测试…)冒充"当前工序" 【售后烙印去死锁】 - 拆分 OUTBOUND_QC_STEPS(发货测试) / AFTER_SALES_REPAIR_STEPS(售后维修) - resolve_phase_for_step 与 _mark_after_sales_if_reactivated 双双改为仅 「售后维修」触发 AFTER_SALES,解除「已出库 + 发货测试」被永久烙印的死锁 - PRODUCTION_OVERALL_STEPS 补入「发货测试」,使出厂质检在生产阶段合法 (否则 _enforce_step_isolation 会把已出库设备的发货测试直接 400) 【游魂收口】 - get_wip_matrix / get_wip_matrix_detail 的 is_terminal 增加"名下再无任何活跃 任务则视同终结态",让被直接完结的生产设备接受时间筛选,不再恒挂在看板上 冒充在制。两处必须一字不差同步,否则会出现"矩阵有数、下钻为空"
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""外部系统回调 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_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 / 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_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(
|
||
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
|
||
|
||
# 🚚 同步出清厂内位置(与 _recalc_product_location 的「货发走就离场」对齐):
|
||
# 本回调的匹配条件之一就是 current_location_id == "virtual_warehouse",
|
||
# 即设备此刻还挂在仓库池里。既然 MOM 已确认发货,就不该再显示为厂内仓库/工位。
|
||
# 置空后产品列表的「当前位置」显示为「—」。
|
||
if product.current_location_id is not None:
|
||
product.current_location_id = None
|
||
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}
|