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:
2026-09-01 13:53:24 +08:00
parent 7f5bedf87c
commit 73faa1fd93
9 changed files with 503 additions and 41 deletions

View 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_number16 位)与 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 "",
},
}

View 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_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}

View File

@ -14,6 +14,8 @@ from app.api.v1.endpoints.notifications import router as notifications_router
from app.api.v1.endpoints.app_version import router as app_version_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.holidays import router as holidays_router
from app.api.v1.endpoints.webhooks import router as webhooks_router
from app.api.v1.endpoints.external_products import router as external_products_router
api_router = APIRouter()
@ -31,3 +33,5 @@ api_router.include_router(notifications_router)
api_router.include_router(app_version_router)
api_router.include_router(analytics_router)
api_router.include_router(holidays_router)
api_router.include_router(webhooks_router)
api_router.include_router(external_products_router)

View File

@ -19,6 +19,9 @@ class Settings(BaseSettings):
# ---- CORS 跨域白名单JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ----
CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]'
# ---- MOM 仓储系统回调 WebhookTrack 作为接收方,验签用) ----
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
@property
def CORS_ORIGINS_LIST(self) -> list[str]:
"""将 JSON 字符串解析为 Python list供 CORSMiddleware 使用"""

View File

@ -160,12 +160,27 @@ async def get_dashboard_stats(
)
from app.models.notification import Notification
from app.models.message import ProductMessage
# ── 产品(实时快照,不过滤) ──
# 状态口径(用户确认):废弃 Product.status 恒值判断,
# COMPLETED=待仓库收货ARCHIVED=已入库('在库' 旧命名等价)。
# 产品流转卡片第三段"已入库"= 全部完结(待收货 + 已实收 + 旧在库),保证三段和 = 总数。
p_total = await db.scalar(select(func.count(Product.id)))
p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending"))
p_progress = await db.scalar(select(func.count(Product.id)).where(Product.status == "in_progress"))
p_done = await db.scalar(select(func.count(Product.id)).where(Product.status == "completed"))
finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库"])
p_done = await db.scalar(select(func.count(Product.id)).where(finished_cond))
# 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
has_active_task = select(Task.id).where(
Task.product_id == Product.id,
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
).exists()
p_progress = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task)
)
# 待流转 = 总数 - 在制 - 完结(三段互斥,保证进度条总和=总数)
p_pending = max((p_total or 0) - (p_progress or 0) - (p_done or 0), 0)
# ── 任务实时快照PENDING/WIP/返工 — 永远不过滤) ──
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
@ -639,6 +654,8 @@ async def get_wip_matrix(
Task.assignee_id,
Task.created_at,
Product.current_location_id,
Product.overall_status,
Product.status,
)
.join(Task, Task.product_id == Product.id)
.where(
@ -654,7 +671,7 @@ async def get_wip_matrix(
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
device_cur: dict[str, tuple] = {}
seen: set[str] = set()
for pid, spec, task_name, assignee, created, loc in rows:
for pid, spec, task_name, assignee, created, loc, overall_status, product_status in rows:
if pid in seen:
continue
seen.add(pid)
@ -668,10 +685,15 @@ async def get_wip_matrix(
continue
if dimension == "task_name":
# 🔧 在库按设备实际位置判断virtual_warehouse → 在库(生产完成)
# 🔧 在仓库的设备按"已完成/已入库"区分:
# 已入库 = MOM 已扫码实收 (overall_status=='已入库'/'在库' 或 status=='ARCHIVED')
# 待仓库收货 = 完工已转交仓库、MOM 尚未扫码实收
# 否则按最新主任务工序名(活跃/完成态都归该工序)
if loc == "virtual_warehouse":
key = "在库"
if overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED":
key = "已入库"
else:
key = "待仓库收货"
else:
key = task_name or ""
else:

View File

@ -154,7 +154,7 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
t.created_by = prev[-1].assignee_id
assignee_ids.add(t.created_by)
# 🔧 在库设备若无「在库」任务,追加虚拟「在库」节点(展示谁转入在库
# 🔧 在库设备若无「在库」任务,追加虚拟节点(区分"待收货"与"已实收"
def _has_warehouse_task(tasks):
for t in tasks:
if t.task_name and ("在库" in t.task_name or "入库" in t.task_name):
@ -165,16 +165,83 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
has_warehouse_task = _has_warehouse_task(task_tree)
if product.current_location_id == "virtual_warehouse" and not has_warehouse_task and all_mains:
from app.schemas.task import TaskResponse
from app.models.task_log import TaskLog as _TL
# 🔧 反查 webhook 入库接收日志:判断 MOM 是否已扫码实收
inbound_log = (
await db.execute(
select(_TL)
.join(Task, _TL.task_id == Task.id)
.where(
Task.product_id == product.id,
_TL.action_type == "warehouse_inbound",
)
.order_by(_TL.created_at.desc())
.limit(1)
)
).scalars().first()
last_main = all_mains[-1]
# 🔧 最后操作"转入库"的人 = 该产品最后一次 complete 日志的操作人
# (完工转交入库会记一条 complete 日志operator 为发起转入库操作的人,
# 可能是最后工序负责人本人,也可能是代操作的主管)
transfer_log = (
await db.execute(
select(_TL)
.join(Task, _TL.task_id == Task.id)
.where(
Task.product_id == product.id,
_TL.action_type == "complete",
)
.order_by(_TL.created_at.desc())
.limit(1)
)
).scalars().first()
last_transfer_operator = transfer_log.operator_id if transfer_log else None
# 🔧 反查出库日志:产品是否已被 MOM 发货出库
outbound_log = (
await db.execute(
select(_TL)
.join(Task, _TL.task_id == Task.id)
.where(
Task.product_id == product.id,
_TL.action_type == "warehouse_outbound",
)
.order_by(_TL.created_at.desc())
.limit(1)
)
).scalars().first()
if product.overall_status == "已出库":
# 情况 CMOM 已发货出库 → 虚拟节点反映"已出库",负责人为出库操作人
node_name = "已出库"
node_status = "OUTBOUND"
node_assignee = (outbound_log.operator_id if outbound_log else None) or last_transfer_operator or last_main.assignee_id
node_created_by = last_transfer_operator or last_main.assignee_id
elif inbound_log is not None:
# 情况 BMOM 已扫码实收 → "在库" 已完成,负责人为仓库接收人
node_name = "在库"
node_status = "COMPLETED"
node_assignee = inbound_log.operator_id or "仓库"
# "转入在库"始终显示操作转入库的人(车间),而不是 MOM 接收人
node_created_by = last_transfer_operator or last_main.assignee_id
else:
# 情况 AMOM 还没扫码 → "待收货" 进行中,负责人为占位"待仓库扫码"
node_name = "待收货"
node_status = "IN_PROGRESS"
node_assignee = "待仓库扫码"
node_created_by = last_transfer_operator or last_main.assignee_id
virtual = TaskResponse(
id=uuid.uuid4(),
product_id=product.id,
product_sn=product.serial_number,
product_material=product.material_name or product.material_id or "",
parent_task_id=None,
task_name="在库",
assignee_id="virtual_warehouse",
status="COMPLETED",
task_name=node_name,
assignee_id=node_assignee,
status=node_status,
notify_parent_on_complete=False,
is_rework=False,
task_type=None,
@ -185,9 +252,12 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
created_at=last_main.created_at,
child_tasks=[],
records=[],
created_by=last_main.assignee_id, # 最后一道主工序负责人(近似转入人)
created_by=node_created_by,
)
task_tree.append(virtual)
# 真实 username 负责人(含仓库接收人)加入中文名映射;占位符无需映射
if virtual.assignee_id and virtual.assignee_id not in ("待仓库扫码", "virtual_warehouse"):
assignee_ids.add(virtual.assignee_id)
if virtual.created_by:
assignee_ids.add(virtual.created_by)
@ -347,7 +417,7 @@ async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductU
)
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库"}
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库"}
async def update_overall_status(
@ -411,6 +481,14 @@ async def update_overall_status(
)
product.overall_status = status_value
# 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷)
_OVERALL_TO_STATUS = {
"已入库": "ARCHIVED",
"在库": "ARCHIVED",
"已出库": "OUTBOUND",
"待仓库收货": "COMPLETED",
}
product.status = _OVERALL_TO_STATUS.get(status_value, "WIP")
await db.commit()
await db.refresh(product)
@ -451,36 +529,74 @@ async def get_all_products(
)
).distinct()
# 状态筛选 — 大小写不敏感,支持组合过滤
# 状态筛选 — 大小写不敏感
# 口径与 macro_status 完全一致(废弃 Product.status 的恒值判断),业务状态定义:
# COMPLETED = 车间完工待实收 → overall_status == '待仓库收货'
# ARCHIVED = 仓库已实收 → overall_status == '已入库''在库' 为旧命名,等价)
if status_filter and status_filter.strip():
from sqlalchemy import func
from sqlalchemy import func, and_, exists
sf = status_filter.strip().upper()
# 最后一条任务created_at 最新)状态为 COMPLETED 的产品子查询(兼容旧数据用)
ranked = (
select(
Task.product_id, Task.status,
func.row_number().over(
partition_by=Task.product_id,
order_by=Task.created_at.desc(),
).label("rn"),
).subquery("sf_latest_task")
)
latest_completed_ids = select(ranked.c.product_id).where(
ranked.c.rn == 1, ranked.c.status == "COMPLETED",
)
archived_cond = Product.overall_status.in_(["已入库", "在库"])
completed_cond = or_(
Product.overall_status == "待仓库收货",
# 兼容旧数据无定位current_location_id IS NULL且最后一条任务已完成
and_(
Product.current_location_id.is_(None),
Product.id.in_(latest_completed_ids),
),
)
# 未进入"完结"态NULL 视为未完结,避免三值逻辑误过滤)
not_finished = or_(
Product.overall_status.is_(None),
~Product.overall_status.in_(["待仓库收货", "已入库", "在库"]),
)
def _has_task_status(task_status: str):
"""存在指定状态任务 且 未完结的 EXISTS 谓词"""
return exists(
select(Task.id).where(Task.product_id == Product.id, Task.status == task_status)
)
if sf == "DONE":
# "已完成" 匹配 COMPLETED 或 ARCHIVED
stmt = stmt.where(
or_(
func.upper(Product.status) == "COMPLETED",
func.upper(Product.status) == "ARCHIVED",
)
)
# "已完成/已入库" = 待仓库收货(COMPLETED)已入库(ARCHIVED)
stmt = stmt.where(or_(archived_cond, completed_cond))
elif sf == "ARCHIVED":
# 已入库 → ARCHIVED
stmt = stmt.where(archived_cond)
elif sf == "COMPLETED":
# 待仓库收货 → COMPLETED含旧的无定位已完成数据
stmt = stmt.where(completed_cond)
elif sf == "WIP":
stmt = stmt.where(not_finished, _has_task_status("WIP"))
elif sf == "PENDING":
# "待流转" — 产品状态 PENDING 且所有顶层任务均未分配人
stmt = (
stmt.outerjoin(Task, Task.product_id == Product.id)
.where(func.upper(Product.status) == "PENDING")
.where(Task.assignee_id.is_(None))
.distinct()
)
stmt = stmt.where(not_finished, _has_task_status("PENDING"))
elif sf == "PENDING_ASSIGNED":
# "待接收" — 产品状态 PENDING 但已有任务被分配等待工人扫码)
stmt = (
stmt.outerjoin(Task, Task.product_id == Product.id)
.where(func.upper(Product.status) == "PENDING")
.where(Task.assignee_id.isnot(None))
.distinct()
# 存在已分配(等待扫码)的待接收任务
stmt = stmt.where(not_finished, exists(
select(Task.id).where(
Task.product_id == Product.id,
Task.status == "PENDING",
Task.assignee_id.isnot(None),
)
))
else:
stmt = stmt.where(func.upper(Product.status) == sf)
# 兜底:其他状态码按"存在该状态任务"匹配(未完结)
stmt = stmt.where(not_finished, _has_task_status(sf))
stmt = stmt.offset(skip).limit(limit).order_by(Product.created_at.desc())
@ -512,6 +628,27 @@ async def get_all_products(
for row in task_result:
macro_map[row[0]] = prio_to_status.get(row[1], None)
# 🔧 每个产品最后一条任务created_at 最新)的状态 → 用于"已完成"判定
last_task_status_map: dict[uuid.UUID, str] = {}
if product_ids:
from sqlalchemy import func as sa_func
ranked = (
select(
Task.product_id, Task.status,
sa_func.row_number().over(
partition_by=Task.product_id,
order_by=Task.created_at.desc(),
).label("rn"),
)
.where(Task.product_id.in_(product_ids))
.subquery("last_task")
)
last_result = await db.execute(
select(ranked.c.product_id, ranked.c.status).where(ranked.c.rn == 1)
)
for row in last_result:
last_task_status_map[row[0]] = row[1]
# 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name宏观状态名
overall_names: dict[uuid.UUID, str] = {}
if product_ids:
@ -630,11 +767,11 @@ async def get_all_products(
# 🔧 生产总天数(自然天 + 工作日):自创建至今
import math
from app.core.time_utils import to_beijing as _tb, working_duration_hours as _wdh
from app.core.time_utils import get_beijing_time as _gbt, to_beijing as _tb, working_duration_hours as _wdh
from app.models.holiday import Holiday as _Holiday
hres2 = await db.execute(select(_Holiday.day))
holidays2 = {r[0] for r in hres2}
now2 = get_beijing_time()
now2 = _gbt()
def _prod_days(created_at):
created = _tb(created_at)
@ -649,6 +786,23 @@ async def get_all_products(
for _p in products:
production_days_map[_p.id] = _prod_days(_p.created_at)
def _resolve_macro_status(p: Product) -> str:
"""宏观状态 — 以 overall_status 为核心的状态定义(用户确认):
- ARCHIVED(已入库): overall_status == '已入库''在库' 为旧命名,等价)
- COMPLETED(已完成): overall_status == '待仓库收货'
兼容旧数据无定位current_location_id IS NULL且最后一条任务 COMPLETED
- 其余: 沿用原 WIP/PENDING 任务优先级;无任何任务的产品 → PENDING
"""
if p.overall_status in ("已入库", "在库"):
return "ARCHIVED"
if p.overall_status == "已出库":
return "OUTBOUND"
if p.overall_status == "待仓库收货":
return "COMPLETED"
if last_task_status_map.get(p.id) == "COMPLETED" and p.current_location_id is None:
return "COMPLETED"
return macro_map.get(p.id) or "PENDING"
return [
ProductResponse(
id=p.id,
@ -671,7 +825,7 @@ async def get_all_products(
else ("仓库" if p.current_location_id == "virtual_warehouse"
else merged_name_map.get(p.current_location_id) if p.current_location_id else None)
),
macro_status=macro_map.get(p.id) or p.status,
macro_status=_resolve_macro_status(p),
overall_status=overall_names.get(p.id) or p.overall_status,
status=p.status,
created_at=p.created_at,

View File

@ -845,7 +845,9 @@ async def transfer_task(
if product:
if has_warehouse and not real_branches:
product.current_location_id = VIRTUAL_WAREHOUSE
product.overall_status = "在库"
product.overall_status = "待仓库收货"
# 双字段同步:完工转交入库 → 产品标记为已完成MOM 扫码实收后才变已入库)
product.status = "COMPLETED"
elif real_branches:
product.current_location_id = real_branches[0][1]
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):

View File

@ -43,3 +43,6 @@ Pillow==12.3.0
# MOM 登录对接 — Werkzeug scrypt 密码验证
werkzeug==3.0.6
# MOM 仓储入库推送 — 异步 HTTP 客户端
httpx==0.28.1

View File

@ -45,6 +45,8 @@ services:
# 🚀 MOM 老系统数据库 — 通过容器名解析projects_default 网络内 DNS不再写死 IP
MOM_DB_HOST: inventory_db
MOM_DB_PORT: "5432"
# 🚀 MOM 仓储系统回调 Webhook 验签 Key与 MOM 侧 TRACK_WEBHOOK_KEY 保持一致)
TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768
ports:
- "8011:8000"
volumes: