feat: 后端新增产品收口接口(已入库/已出库,管理员,含扫码节点与操作日志,可反向纠错与强收口)
This commit is contained in:
@ -14,7 +14,7 @@ from app.schemas.product import (
|
||||
ProductResponse,
|
||||
ProductScanResponse,
|
||||
)
|
||||
from app.services import product_service
|
||||
from app.services import product_service, product_finalize_service
|
||||
from app.services.auth_service import get_current_user
|
||||
from app.services.qrcode_service import generate_qrcode_png
|
||||
|
||||
@ -147,6 +147,42 @@ async def update_product_overall_status(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 产品收口 — 管理员将产品修正为「已入库」/「已出库」(可反向纠错)
|
||||
# ============================================================
|
||||
|
||||
class ProductFinalizeRequest(BaseModel):
|
||||
"""管理员收口入参"""
|
||||
status: str = Field(..., min_length=1, max_length=20, description="收口目标: 已入库 | 已出库")
|
||||
note: str | None = Field(None, max_length=200, description="备注(选填)")
|
||||
|
||||
|
||||
@router.post("/scan/{serial_number}/finalize", response_model=ProductScanResponse)
|
||||
async def finalize_product_status_endpoint(
|
||||
serial_number: str,
|
||||
data: ProductFinalizeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""管理员将产品整体收口为「已入库」或「已出库」(支持 入库<->出库 反向互切纠错)。
|
||||
|
||||
与 MOM 出入库回调落库语义一致:同步 product.overall_status/status、写
|
||||
warehouse_inbound/outbound 日志、幂等追加「扫码入库/扫码出库」主线收尾节点。
|
||||
权限:仅 SUPER_ADMIN / SUPERVISOR。
|
||||
"""
|
||||
from fastapi import HTTPException, status
|
||||
from app.services.task_service import ADMIN_ROLES
|
||||
|
||||
if (current_user or {}).get("role") not in ADMIN_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅超级管理员或主管可执行入库/出库收口",
|
||||
)
|
||||
return await product_finalize_service.finalize_product_status(
|
||||
db, serial_number, data.status, current_user, data.note,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 协同留言板
|
||||
# ============================================================
|
||||
|
||||
146
backend/app/services/product_finalize_service.py
Normal file
146
backend/app/services/product_finalize_service.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""产品收口服务 — 管理员一键把产品修正为「已入库」或「已出库」(可反向互切纠错)。
|
||||
|
||||
落库语义与 MOM 仓储回调(webhooks.mom_inbound / mom_outbound)保持一致:
|
||||
1) 改 product.overall_status + product.status(映射为 ARCHIVED / OUTBOUND);
|
||||
2) 写一条 warehouse_inbound / warehouse_outbound 的 task_log;
|
||||
3) 幂等追加「扫码入库 / 扫码出库」WAREHOUSE 主线节点 + TaskRecord。
|
||||
|
||||
与 update_overall_status(仅改状态、面向主线负责人)不同:本服务面向管理员做收口,
|
||||
会补全流转树收尾节点,使任务全景/详情/矩阵能正确显示入库/出库收口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.services.product_service import get_product_by_serial
|
||||
|
||||
# target -> (product.status 映射码, 追加节点名, task_log.action_type)
|
||||
_FINALIZE_MAP = {
|
||||
"已入库": ("ARCHIVED", "扫码入库", "warehouse_inbound"),
|
||||
"已出库": ("OUTBOUND", "扫码出库", "warehouse_outbound"),
|
||||
}
|
||||
|
||||
|
||||
async def _append_warehouse_node(db: AsyncSession, product: Product, task_name: str) -> None:
|
||||
"""在流转树主干末尾幂等追加仓储收口主线节点 + TaskRecord(对齐 webhooks._append_warehouse_task)"""
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Task.id).where(
|
||||
Task.product_id == product.id,
|
||||
Task.task_name == task_name,
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return
|
||||
|
||||
last_main = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(
|
||||
Task.product_id == product.id,
|
||||
(Task.parent_task_id.is_(None))
|
||||
| (Task.task_type.in_(["TRANSFER", "RECOVERY"])),
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
node = Task(
|
||||
product_id=product.id,
|
||||
parent_task_id=last_main.id if last_main else None,
|
||||
task_name=task_name,
|
||||
assignee_id=None, # ★ 不能填非 UUID,否则前端头像解析报错导致节点跳过渲染
|
||||
status="COMPLETED",
|
||||
task_type="WAREHOUSE",
|
||||
completed_at=get_beijing_time(),
|
||||
remark=f"管理员收口:追加{task_name}收尾节点",
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
db.add(TaskRecord(task_id=node.id, remark=f"管理员收口:追加{task_name}收尾节点"))
|
||||
|
||||
|
||||
async def finalize_product_status(
|
||||
db: AsyncSession,
|
||||
serial_number: str,
|
||||
target: str,
|
||||
current_user: dict | None,
|
||||
note: str | None = None,
|
||||
):
|
||||
"""把产品整体收口到 target(已入库 / 已出库)。已处于目标态则幂等直接返回。"""
|
||||
target = (target or "").strip()
|
||||
if target not in _FINALIZE_MAP:
|
||||
raise HTTPException(status_code=400, detail="收口目标状态仅支持:已入库 / 已出库")
|
||||
|
||||
product = (
|
||||
await db.execute(select(Product).where(Product.serial_number == serial_number))
|
||||
).scalar_one_or_none()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到序列号为 {serial_number} 的产品")
|
||||
|
||||
# 幂等:已在目标状态则不动(避免重复写日志/节点)
|
||||
if product.overall_status == target:
|
||||
return await get_product_by_serial(db, serial_number)
|
||||
|
||||
# 管理员强收口:仍有在产/待接收任务的,一并结束为完工收口,避免宏观口径分裂
|
||||
res = await db.execute(
|
||||
update(Task)
|
||||
.where(Task.product_id == product.id, Task.status.in_(["WIP", "PENDING"]))
|
||||
.values(
|
||||
status="COMPLETED",
|
||||
received_at=get_beijing_time(),
|
||||
completed_at=get_beijing_time(),
|
||||
)
|
||||
)
|
||||
ended_active = res.rowcount or 0
|
||||
|
||||
status_code, node_name, action_type = _FINALIZE_MAP[target]
|
||||
product.overall_status = target
|
||||
product.status = status_code
|
||||
product.current_location_id = "virtual_warehouse"
|
||||
|
||||
# task_log 绑目标任务:优先「在库」任务,其次该产品最新任务(对齐 webhook)
|
||||
log_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if log_task is None:
|
||||
log_task = (
|
||||
await db.execute(
|
||||
select(Task).where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
operator = (current_user or {}).get("username") or "virtual_warehouse"
|
||||
remark = f"管理员[{operator}]收口为{target}"
|
||||
if ended_active:
|
||||
remark += f";一并结束{ended_active}个进行中/待接收任务"
|
||||
if note and note.strip():
|
||||
remark += f";备注:{note.strip()}"
|
||||
if log_task is not None:
|
||||
db.add(
|
||||
TaskLog(
|
||||
task_id=log_task.id,
|
||||
operator_id=str(operator)[:64],
|
||||
action_type=action_type,
|
||||
remark=remark,
|
||||
)
|
||||
)
|
||||
|
||||
await _append_warehouse_node(db, product, node_name)
|
||||
await db.commit()
|
||||
return await get_product_by_serial(db, serial_number)
|
||||
Reference in New Issue
Block a user