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.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)
|
||||
|
||||
@ -19,6 +19,9 @@ class Settings(BaseSettings):
|
||||
# ---- CORS 跨域白名单(JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ----
|
||||
CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]'
|
||||
|
||||
# ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ----
|
||||
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS_LIST(self) -> list[str]:
|
||||
"""将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用"""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 == "已出库":
|
||||
# 情况 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(
|
||||
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,
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -43,3 +43,6 @@ Pillow==12.3.0
|
||||
|
||||
# MOM 登录对接 — Werkzeug scrypt 密码验证
|
||||
werkzeug==3.0.6
|
||||
|
||||
# MOM 仓储入库推送 — 异步 HTTP 客户端
|
||||
httpx==0.28.1
|
||||
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user