diff --git a/backend/app/api/v1/endpoints/external_products.py b/backend/app/api/v1/endpoints/external_products.py new file mode 100644 index 0000000..d387439 --- /dev/null +++ b/backend/app/api/v1/endpoints/external_products.py @@ -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 "", + }, + } diff --git a/backend/app/api/v1/endpoints/webhooks.py b/backend/app/api/v1/endpoints/webhooks.py new file mode 100644 index 0000000..b60a76e --- /dev/null +++ b/backend/app/api/v1/endpoints/webhooks.py @@ -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} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 682304b..e40ff7b 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6149f8c..b4eb988 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 使用""" diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 835f12b..9159a2d 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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: diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 2f3b6a7..11a8e87 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -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, diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 06d662b..2597800 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -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"): diff --git a/backend/requirements.txt b/backend/requirements.txt index 92335fe..1b39c9f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -43,3 +43,6 @@ Pillow==12.3.0 # MOM 登录对接 — Werkzeug scrypt 密码验证 werkzeug==3.0.6 + +# MOM 仓储入库推送 — 异步 HTTP 客户端 +httpx==0.28.1 diff --git a/docker-compose.yml b/docker-compose.yml index a64e00e..15a1c8f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: