功能:实现托盘两阶段退回

This commit is contained in:
焦龙言
2026-07-26 23:33:01 +08:00
parent cda87a4d65
commit e58e110dd1
5 changed files with 2361 additions and 15 deletions
@@ -0,0 +1,155 @@
"""托盘两阶段退回接口。"""
from typing import Annotated
from fastapi import Path, Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
PalletScanResponseModel,
ReturnConfirmModel,
ReturnModel,
ReturnPageQueryModel,
ReturnRequestModel,
ReturnScanQueryModel,
)
from module_business.service.return_service import ReturnService
from utils.response_util import ResponseUtil
return_controller = APIRouterPro(
prefix='/business/return',
tags=['业务管理-托盘退回'],
dependencies=[PreAuthDependency()],
)
@return_controller.get(
'/list',
summary='获取托盘退回单分页列表接口',
description='按当前用户来源现场或目标仓库数据范围获取退回单',
response_model=PageResponseModel[ReturnModel],
dependencies=[UserInterfaceAuthDependency('business:return:list')],
)
async def get_business_return_list(
request: Request,
return_page_query: Annotated[ReturnPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReturnService.get_return_list_services(
query_db,
return_page_query,
current_user,
is_page=True,
)
return ResponseUtil.success(model_content=result)
@return_controller.get(
'/warehouse-options',
summary='获取退回目标仓库选项接口',
description='返回全部启用仓库作为客户发起退回的目标字典',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:return:query')],
)
async def get_business_return_warehouse_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReturnService.get_warehouse_options_services(query_db, current_user)
return ResponseUtil.success(data=result)
@return_controller.get(
'/scan/{qr_token}',
summary='扫描退回托盘接口',
description='按申请现场或确认仓库数据范围重新查询托盘退回状态',
response_model=DataResponseModel[PalletScanResponseModel],
dependencies=[UserInterfaceAuthDependency('business:return:query')],
)
async def scan_business_return_pallet(
request: Request,
qr_token: Annotated[
str,
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
],
return_scan_query: Annotated[ReturnScanQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReturnService.scan_return_pallet_services(
query_db,
qr_token,
return_scan_query,
current_user,
)
return ResponseUtil.success(data=result)
@return_controller.post(
'/request',
summary='发起托盘退回申请接口',
description='原子冻结同一客户现场的全部退回托盘并保存库存快照',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:return:request')],
)
@Log(title='托盘退回', business_type=BusinessType.INSERT)
async def request_business_return(
request: Request,
request_command: ReturnRequestModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReturnService.request_return_services(query_db, request_command, current_user)
return ResponseUtil.success(msg=result.message)
@return_controller.get(
'/{return_id}',
summary='获取托盘退回单详情接口',
description='按来源现场或目标仓库数据范围获取指定退回单',
response_model=DataResponseModel[ReturnModel],
dependencies=[UserInterfaceAuthDependency('business:return:query')],
)
async def query_detail_business_return(
request: Request,
return_id: Annotated[int, Path(gt=0, description='退回单ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReturnService.return_detail_services(query_db, return_id, current_user)
return ResponseUtil.success(data=result)
@return_controller.put(
'/{return_id}/confirm',
summary='确认托盘退回接口',
description='核对整单扫码集合和申请快照后原子移入目标仓库',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:return:confirm')],
)
@Log(title='托盘退回', business_type=BusinessType.UPDATE)
async def confirm_business_return(
request: Request,
return_id: Annotated[int, Path(gt=0, description='退回单ID')],
confirm_command: ReturnConfirmModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReturnService.confirm_return_services(
query_db,
return_id,
confirm_command,
current_user,
)
return ResponseUtil.success(msg=result.message)
@@ -1,7 +1,7 @@
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from typing import Any from typing import Any
from sqlalchemy import ColumnElement, Select, delete, false, select, update from sqlalchemy import ColumnElement, Select, delete, false, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -94,6 +94,27 @@ class OperationDao:
conditions.append(BizShipmentOrder.target_site_id.in_(scoped_site_ids) if scoped_site_ids else false()) conditions.append(BizShipmentOrder.target_site_id.in_(scoped_site_ids) if scoped_site_ids else false())
return conditions return conditions
@staticmethod
def _return_scope_conditions(
site_ids: list[int] | None,
warehouse_ids: list[int] | None,
) -> list[ColumnElement[bool]]:
if site_ids is None and warehouse_ids is None:
return []
visibility_conditions: list[ColumnElement[bool]] = []
if site_ids is not None:
scoped_site_ids = sorted(set(site_ids))
visibility_conditions.append(
BizReturnOrder.source_site_id.in_(scoped_site_ids) if scoped_site_ids else false()
)
if warehouse_ids is not None:
scoped_warehouse_ids = sorted(set(warehouse_ids))
visibility_conditions.append(
BizReturnOrder.target_warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()
)
return [or_(*visibility_conditions)]
@staticmethod @staticmethod
async def _paginate( async def _paginate(
db: AsyncSession, db: AsyncSession,
@@ -300,13 +321,32 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
return_id: int, return_id: int,
site_ids: list[int] | None = None,
warehouse_ids: list[int] | None = None,
) -> BizReturnOrder | None: ) -> BizReturnOrder | None:
return await cls._get_for_update( return_order = await cls._get_for_update(
db, db,
BizReturnOrder, BizReturnOrder,
BizReturnOrder.return_id, BizReturnOrder.return_id,
return_id, return_id,
*cls._return_scope_conditions(site_ids, warehouse_ids),
) )
if return_order is None:
return None
# 父单锁定后使用 locking read 获取当前明细,避免 MySQL RR 沿用旧快照。
item_result = await db.execute(
select(BizReturnItem)
.where(BizReturnItem.return_id == return_id)
.order_by(BizReturnItem.pallet_id, BizReturnItem.return_item_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return_order.items = sorted(
item_result.scalars().all(),
key=lambda item: (item.pallet_id, item.return_item_id),
)
return return_order
@classmethod @classmethod
async def get_active_filling_for_update( async def get_active_filling_for_update(
@@ -387,12 +427,15 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
idempotency_key: str, idempotency_key: str,
site_ids: list[int] | None = None,
warehouse_ids: list[int] | None = None,
) -> BizReturnOrder | None: ) -> BizReturnOrder | None:
return await cls._get_by_key( return await cls._get_by_key(
db, db,
BizReturnOrder, BizReturnOrder,
BizReturnOrder.request_idempotency_key, BizReturnOrder.request_idempotency_key,
idempotency_key, idempotency_key,
*cls._return_scope_conditions(site_ids, warehouse_ids),
) )
@classmethod @classmethod
@@ -400,12 +443,15 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
idempotency_key: str, idempotency_key: str,
site_ids: list[int] | None = None,
warehouse_ids: list[int] | None = None,
) -> BizReturnOrder | None: ) -> BizReturnOrder | None:
return await cls._get_by_key( return await cls._get_by_key(
db, db,
BizReturnOrder, BizReturnOrder,
BizReturnOrder.confirm_idempotency_key, BizReturnOrder.confirm_idempotency_key,
idempotency_key, idempotency_key,
*cls._return_scope_conditions(site_ids, warehouse_ids),
) )
@classmethod @classmethod
@@ -718,8 +764,10 @@ class OperationDao:
db: AsyncSession, db: AsyncSession,
query_object: ReturnPageQueryModel, query_object: ReturnPageQueryModel,
is_page: bool = True, is_page: bool = True,
site_ids: list[int] | None = None,
warehouse_ids: list[int] | None = None,
) -> PageModel | list[dict[str, Any]]: ) -> PageModel | list[dict[str, Any]]:
conditions = [] conditions = cls._return_scope_conditions(site_ids, warehouse_ids)
if query_object.return_no: if query_object.return_no:
conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%')) conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%'))
if query_object.source_site_id: if query_object.source_site_id:
@@ -743,6 +791,8 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
return_id: int, return_id: int,
site_ids: list[int] | None = None,
warehouse_ids: list[int] | None = None,
) -> BizReturnOrder | None: ) -> BizReturnOrder | None:
return await cls._get_order_detail( return await cls._get_order_detail(
db, db,
@@ -752,6 +802,7 @@ class OperationDao:
BizReturnItem, BizReturnItem,
BizReturnItem.return_id, BizReturnItem.return_id,
BizReturnItem.return_item_id, BizReturnItem.return_item_id,
*cls._return_scope_conditions(site_ids, warehouse_ids),
) )
@classmethod @classmethod
File diff suppressed because it is too large Load Diff
@@ -123,7 +123,7 @@ class TestDaoPublicContract(unittest.TestCase):
expected = { expected = {
'get_inbound_for_update': ('db', 'inbound_id', 'warehouse_ids'), 'get_inbound_for_update': ('db', 'inbound_id', 'warehouse_ids'),
'get_shipment_for_update': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'), 'get_shipment_for_update': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'),
'get_return_for_update': ('db', 'return_id'), 'get_return_for_update': ('db', 'return_id', 'site_ids', 'warehouse_ids'),
'get_active_filling_for_update': ('db', 'filling_id'), 'get_active_filling_for_update': ('db', 'filling_id'),
'get_inbound_by_confirm_idempotency_key': ('db', 'idempotency_key'), 'get_inbound_by_confirm_idempotency_key': ('db', 'idempotency_key'),
'get_shipment_by_confirm_idempotency_key': ( 'get_shipment_by_confirm_idempotency_key': (
@@ -134,8 +134,18 @@ class TestDaoPublicContract(unittest.TestCase):
), ),
'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'warehouse_ids'), 'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'warehouse_ids'),
'get_usage_by_idempotency_key': ('db', 'idempotency_key', 'site_ids'), 'get_usage_by_idempotency_key': ('db', 'idempotency_key', 'site_ids'),
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'), 'get_return_by_request_idempotency_key': (
'get_return_by_confirm_idempotency_key': ('db', 'idempotency_key'), 'db',
'idempotency_key',
'site_ids',
'warehouse_ids',
),
'get_return_by_confirm_idempotency_key': (
'db',
'idempotency_key',
'site_ids',
'warehouse_ids',
),
'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'), 'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'),
'add_inbound': ('db', 'inbound'), 'add_inbound': ('db', 'inbound'),
'update_inbound': ('db', 'inbound'), 'update_inbound': ('db', 'inbound'),
@@ -151,7 +161,7 @@ class TestDaoPublicContract(unittest.TestCase):
'replace_shipment_items': ('db', 'shipment_id', 'items'), 'replace_shipment_items': ('db', 'shipment_id', 'items'),
'get_usage_by_id': ('db', 'usage_id', 'site_ids'), 'get_usage_by_id': ('db', 'usage_id', 'site_ids'),
'add_usage_record': ('db', 'usage'), 'add_usage_record': ('db', 'usage'),
'get_return_by_id': ('db', 'return_id'), 'get_return_by_id': ('db', 'return_id', 'site_ids', 'warehouse_ids'),
'add_return_order': ('db', 'return_order'), 'add_return_order': ('db', 'return_order'),
'update_return_order': ('db', 'return_order'), 'update_return_order': ('db', 'return_order'),
'add_return_items': ('db', 'items'), 'add_return_items': ('db', 'items'),
@@ -228,8 +238,22 @@ class TestDaoPublicContract(unittest.TestCase):
self.assertNotIn('for_update', inspect.signature(OperationDao._get_by_key).parameters) self.assertNotIn('for_update', inspect.signature(OperationDao._get_by_key).parameters)
return_list_signature = inspect.signature(OperationDao.get_return_list) return_list_signature = inspect.signature(OperationDao.get_return_list)
self.assertEqual(('db', 'query_object', 'is_page'), tuple(return_list_signature.parameters)) self.assertEqual(
('db', 'query_object', 'is_page', 'site_ids', 'warehouse_ids'),
tuple(return_list_signature.parameters),
)
self.assertIs(True, return_list_signature.parameters['is_page'].default) self.assertIs(True, return_list_signature.parameters['is_page'].default)
for method_name in (
'get_return_for_update',
'get_return_by_request_idempotency_key',
'get_return_by_confirm_idempotency_key',
'get_return_by_id',
'get_return_list',
):
with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name))
self.assertIsNone(signature.parameters['site_ids'].default)
self.assertIsNone(signature.parameters['warehouse_ids'].default)
def test_dao_source_never_controls_transaction_boundary(self) -> None: def test_dao_source_never_controls_transaction_boundary(self) -> None:
for module in (inventory_dao_module, operation_dao_module): for module in (inventory_dao_module, operation_dao_module):
@@ -423,13 +447,6 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
async def test_for_update_queries_target_primary_keys_and_lock_rows(self) -> None: async def test_for_update_queries_target_primary_keys_and_lock_rows(self) -> None:
cases = ( cases = (
(
OperationDao.get_return_for_update,
BizReturnOrder,
'return_id',
13,
(),
),
( (
OperationDao.get_active_filling_for_update, OperationDao.get_active_filling_for_update,
BizFillingRecord, BizFillingRecord,
File diff suppressed because it is too large Load Diff