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:
61
backend/app/api/v1/endpoints/external_products.py
Normal file
61
backend/app/api/v1/endpoints/external_products.py
Normal 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_number(16 位)与 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 "",
|
||||||
|
},
|
||||||
|
}
|
||||||
211
backend/app/api/v1/endpoints/webhooks.py
Normal file
211
backend/app/api/v1/endpoints/webhooks.py
Normal 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_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}
|
||||||
@ -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.app_version import router as app_version_router
|
||||||
from app.api.v1.endpoints.analytics import router as analytics_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.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()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@ -31,3 +33,5 @@ api_router.include_router(notifications_router)
|
|||||||
api_router.include_router(app_version_router)
|
api_router.include_router(app_version_router)
|
||||||
api_router.include_router(analytics_router)
|
api_router.include_router(analytics_router)
|
||||||
api_router.include_router(holidays_router)
|
api_router.include_router(holidays_router)
|
||||||
|
api_router.include_router(webhooks_router)
|
||||||
|
api_router.include_router(external_products_router)
|
||||||
|
|||||||
@ -19,6 +19,9 @@ class Settings(BaseSettings):
|
|||||||
# ---- CORS 跨域白名单(JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ----
|
# ---- CORS 跨域白名单(JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ----
|
||||||
CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]'
|
CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]'
|
||||||
|
|
||||||
|
# ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ----
|
||||||
|
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def CORS_ORIGINS_LIST(self) -> list[str]:
|
def CORS_ORIGINS_LIST(self) -> list[str]:
|
||||||
"""将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用"""
|
"""将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用"""
|
||||||
|
|||||||
@ -160,12 +160,27 @@ async def get_dashboard_stats(
|
|||||||
)
|
)
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.message import ProductMessage
|
from app.models.message import ProductMessage
|
||||||
|
|
||||||
# ── 产品(实时快照,不过滤) ──
|
# ── 产品(实时快照,不过滤) ──
|
||||||
|
# 状态口径(用户确认):废弃 Product.status 恒值判断,
|
||||||
|
# COMPLETED=待仓库收货,ARCHIVED=已入库('在库' 旧命名等价)。
|
||||||
|
# 产品流转卡片第三段"已入库"= 全部完结(待收货 + 已实收 + 旧在库),保证三段和 = 总数。
|
||||||
p_total = await db.scalar(select(func.count(Product.id)))
|
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"))
|
finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库"])
|
||||||
p_done = await db.scalar(select(func.count(Product.id)).where(Product.status == "completed"))
|
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/返工 — 永远不过滤) ──
|
# ── 任务实时快照(PENDING/WIP/返工 — 永远不过滤) ──
|
||||||
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
|
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.assignee_id,
|
||||||
Task.created_at,
|
Task.created_at,
|
||||||
Product.current_location_id,
|
Product.current_location_id,
|
||||||
|
Product.overall_status,
|
||||||
|
Product.status,
|
||||||
)
|
)
|
||||||
.join(Task, Task.product_id == Product.id)
|
.join(Task, Task.product_id == Product.id)
|
||||||
.where(
|
.where(
|
||||||
@ -654,7 +671,7 @@ async def get_wip_matrix(
|
|||||||
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
|
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
|
||||||
device_cur: dict[str, tuple] = {}
|
device_cur: dict[str, tuple] = {}
|
||||||
seen: set[str] = set()
|
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:
|
if pid in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(pid)
|
seen.add(pid)
|
||||||
@ -668,10 +685,15 @@ async def get_wip_matrix(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if dimension == "task_name":
|
if dimension == "task_name":
|
||||||
# 🔧 在库按设备实际位置判断:virtual_warehouse → 在库(生产完成)
|
# 🔧 在仓库的设备按"已完成/已入库"区分:
|
||||||
|
# 已入库 = MOM 已扫码实收 (overall_status=='已入库'/'在库' 或 status=='ARCHIVED')
|
||||||
|
# 待仓库收货 = 完工已转交仓库、MOM 尚未扫码实收
|
||||||
# 否则按最新主任务工序名(活跃/完成态都归该工序)
|
# 否则按最新主任务工序名(活跃/完成态都归该工序)
|
||||||
if loc == "virtual_warehouse":
|
if loc == "virtual_warehouse":
|
||||||
key = "在库"
|
if overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED":
|
||||||
|
key = "已入库"
|
||||||
|
else:
|
||||||
|
key = "待仓库收货"
|
||||||
else:
|
else:
|
||||||
key = task_name or "—"
|
key = task_name or "—"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -154,7 +154,7 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
|
|||||||
t.created_by = prev[-1].assignee_id
|
t.created_by = prev[-1].assignee_id
|
||||||
assignee_ids.add(t.created_by)
|
assignee_ids.add(t.created_by)
|
||||||
|
|
||||||
# 🔧 在库设备若无「在库」任务,追加虚拟「在库」节点(展示谁转入在库)
|
# 🔧 在库设备若无「在库」任务,追加虚拟节点(区分"待收货"与"已实收")
|
||||||
def _has_warehouse_task(tasks):
|
def _has_warehouse_task(tasks):
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
if t.task_name and ("在库" in t.task_name or "入库" in t.task_name):
|
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)
|
has_warehouse_task = _has_warehouse_task(task_tree)
|
||||||
if product.current_location_id == "virtual_warehouse" and not has_warehouse_task and all_mains:
|
if product.current_location_id == "virtual_warehouse" and not has_warehouse_task and all_mains:
|
||||||
from app.schemas.task import TaskResponse
|
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]
|
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 == "已出库":
|
||||||
|
# 情况 C:MOM 已发货出库 → 虚拟节点反映"已出库",负责人为出库操作人
|
||||||
|
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:
|
||||||
|
# 情况 B:MOM 已扫码实收 → "在库" 已完成,负责人为仓库接收人
|
||||||
|
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:
|
||||||
|
# 情况 A:MOM 还没扫码 → "待收货" 进行中,负责人为占位"待仓库扫码"
|
||||||
|
node_name = "待收货"
|
||||||
|
node_status = "IN_PROGRESS"
|
||||||
|
node_assignee = "待仓库扫码"
|
||||||
|
node_created_by = last_transfer_operator or last_main.assignee_id
|
||||||
|
|
||||||
virtual = TaskResponse(
|
virtual = TaskResponse(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
product_sn=product.serial_number,
|
product_sn=product.serial_number,
|
||||||
product_material=product.material_name or product.material_id or "",
|
product_material=product.material_name or product.material_id or "",
|
||||||
parent_task_id=None,
|
parent_task_id=None,
|
||||||
task_name="在库",
|
task_name=node_name,
|
||||||
assignee_id="virtual_warehouse",
|
assignee_id=node_assignee,
|
||||||
status="COMPLETED",
|
status=node_status,
|
||||||
notify_parent_on_complete=False,
|
notify_parent_on_complete=False,
|
||||||
is_rework=False,
|
is_rework=False,
|
||||||
task_type=None,
|
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,
|
created_at=last_main.created_at,
|
||||||
child_tasks=[],
|
child_tasks=[],
|
||||||
records=[],
|
records=[],
|
||||||
created_by=last_main.assignee_id, # 最后一道主工序负责人(近似转入人)
|
created_by=node_created_by,
|
||||||
)
|
)
|
||||||
task_tree.append(virtual)
|
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:
|
if virtual.created_by:
|
||||||
assignee_ids.add(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(
|
async def update_overall_status(
|
||||||
@ -411,6 +481,14 @@ async def update_overall_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
product.overall_status = status_value
|
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.commit()
|
||||||
await db.refresh(product)
|
await db.refresh(product)
|
||||||
|
|
||||||
@ -451,36 +529,74 @@ async def get_all_products(
|
|||||||
)
|
)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
# 状态筛选 — 大小写不敏感,支持组合过滤
|
# 状态筛选 — 大小写不敏感
|
||||||
|
# 口径与 macro_status 完全一致(废弃 Product.status 的恒值判断),业务状态定义:
|
||||||
|
# COMPLETED = 车间完工待实收 → overall_status == '待仓库收货'
|
||||||
|
# ARCHIVED = 仓库已实收 → overall_status == '已入库'('在库' 为旧命名,等价)
|
||||||
if status_filter and status_filter.strip():
|
if status_filter and status_filter.strip():
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, and_, exists
|
||||||
sf = status_filter.strip().upper()
|
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":
|
if sf == "DONE":
|
||||||
# "已完成" 匹配 COMPLETED 或 ARCHIVED
|
# "已完成/已入库" = 待仓库收货(COMPLETED) 或 已入库(ARCHIVED)
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(or_(archived_cond, completed_cond))
|
||||||
or_(
|
elif sf == "ARCHIVED":
|
||||||
func.upper(Product.status) == "COMPLETED",
|
# 已入库 → ARCHIVED
|
||||||
func.upper(Product.status) == "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":
|
elif sf == "PENDING":
|
||||||
# "待流转" — 产品状态 PENDING 且所有顶层任务均未分配人
|
stmt = stmt.where(not_finished, _has_task_status("PENDING"))
|
||||||
stmt = (
|
|
||||||
stmt.outerjoin(Task, Task.product_id == Product.id)
|
|
||||||
.where(func.upper(Product.status) == "PENDING")
|
|
||||||
.where(Task.assignee_id.is_(None))
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
elif sf == "PENDING_ASSIGNED":
|
elif sf == "PENDING_ASSIGNED":
|
||||||
# "待接收" — 产品状态 PENDING 但已有任务被分配(等待工人扫码)
|
# 存在已分配(等待扫码)的待接收任务
|
||||||
stmt = (
|
stmt = stmt.where(not_finished, exists(
|
||||||
stmt.outerjoin(Task, Task.product_id == Product.id)
|
select(Task.id).where(
|
||||||
.where(func.upper(Product.status) == "PENDING")
|
Task.product_id == Product.id,
|
||||||
.where(Task.assignee_id.isnot(None))
|
Task.status == "PENDING",
|
||||||
.distinct()
|
Task.assignee_id.isnot(None),
|
||||||
)
|
)
|
||||||
|
))
|
||||||
else:
|
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())
|
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:
|
for row in task_result:
|
||||||
macro_map[row[0]] = prio_to_status.get(row[1], None)
|
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(宏观状态名)
|
# 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name(宏观状态名)
|
||||||
overall_names: dict[uuid.UUID, str] = {}
|
overall_names: dict[uuid.UUID, str] = {}
|
||||||
if product_ids:
|
if product_ids:
|
||||||
@ -630,11 +767,11 @@ async def get_all_products(
|
|||||||
|
|
||||||
# 🔧 生产总天数(自然天 + 工作日):自创建至今
|
# 🔧 生产总天数(自然天 + 工作日):自创建至今
|
||||||
import math
|
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
|
from app.models.holiday import Holiday as _Holiday
|
||||||
hres2 = await db.execute(select(_Holiday.day))
|
hres2 = await db.execute(select(_Holiday.day))
|
||||||
holidays2 = {r[0] for r in hres2}
|
holidays2 = {r[0] for r in hres2}
|
||||||
now2 = get_beijing_time()
|
now2 = _gbt()
|
||||||
|
|
||||||
def _prod_days(created_at):
|
def _prod_days(created_at):
|
||||||
created = _tb(created_at)
|
created = _tb(created_at)
|
||||||
@ -649,6 +786,23 @@ async def get_all_products(
|
|||||||
for _p in products:
|
for _p in products:
|
||||||
production_days_map[_p.id] = _prod_days(_p.created_at)
|
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 [
|
return [
|
||||||
ProductResponse(
|
ProductResponse(
|
||||||
id=p.id,
|
id=p.id,
|
||||||
@ -671,7 +825,7 @@ async def get_all_products(
|
|||||||
else ("仓库" if p.current_location_id == "virtual_warehouse"
|
else ("仓库" if p.current_location_id == "virtual_warehouse"
|
||||||
else merged_name_map.get(p.current_location_id) if p.current_location_id else None)
|
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,
|
overall_status=overall_names.get(p.id) or p.overall_status,
|
||||||
status=p.status,
|
status=p.status,
|
||||||
created_at=p.created_at,
|
created_at=p.created_at,
|
||||||
|
|||||||
@ -845,7 +845,9 @@ async def transfer_task(
|
|||||||
if product:
|
if product:
|
||||||
if has_warehouse and not real_branches:
|
if has_warehouse and not real_branches:
|
||||||
product.current_location_id = VIRTUAL_WAREHOUSE
|
product.current_location_id = VIRTUAL_WAREHOUSE
|
||||||
product.overall_status = "在库"
|
product.overall_status = "待仓库收货"
|
||||||
|
# 双字段同步:完工转交入库 → 产品标记为已完成(MOM 扫码实收后才变已入库)
|
||||||
|
product.status = "COMPLETED"
|
||||||
elif real_branches:
|
elif real_branches:
|
||||||
product.current_location_id = real_branches[0][1]
|
product.current_location_id = real_branches[0][1]
|
||||||
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):
|
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):
|
||||||
|
|||||||
@ -43,3 +43,6 @@ Pillow==12.3.0
|
|||||||
|
|
||||||
# MOM 登录对接 — Werkzeug scrypt 密码验证
|
# MOM 登录对接 — Werkzeug scrypt 密码验证
|
||||||
werkzeug==3.0.6
|
werkzeug==3.0.6
|
||||||
|
|
||||||
|
# MOM 仓储入库推送 — 异步 HTTP 客户端
|
||||||
|
httpx==0.28.1
|
||||||
|
|||||||
@ -45,6 +45,8 @@ services:
|
|||||||
# 🚀 MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不再写死 IP
|
# 🚀 MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不再写死 IP
|
||||||
MOM_DB_HOST: inventory_db
|
MOM_DB_HOST: inventory_db
|
||||||
MOM_DB_PORT: "5432"
|
MOM_DB_PORT: "5432"
|
||||||
|
# 🚀 MOM 仓储系统回调 Webhook 验签 Key(与 MOM 侧 TRACK_WEBHOOK_KEY 保持一致)
|
||||||
|
TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768
|
||||||
ports:
|
ports:
|
||||||
- "8011:8000"
|
- "8011:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user