功能:实现多托盘发货出库

This commit is contained in:
焦龙言
2026-07-26 19:11:52 +08:00
parent 66890ca63c
commit 7317631a13
8 changed files with 2867 additions and 25 deletions
@@ -0,0 +1,213 @@
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,
ConfirmCommandModel,
PalletScanResponseModel,
ShipmentCreateModel,
ShipmentModel,
ShipmentPageQueryModel,
ShipmentScanQueryModel,
ShipmentUpdateModel,
)
from module_business.service.shipment_service import ShipmentService
from utils.response_util import ResponseUtil
shipment_controller = APIRouterPro(
prefix='/business/shipment',
tags=['业务管理-整托发货'],
dependencies=[PreAuthDependency()],
)
@shipment_controller.get(
'/list',
summary='获取整托发货单分页列表接口',
description='按当前用户仓库与现场双数据范围获取整托发货单',
response_model=PageResponseModel[ShipmentModel],
dependencies=[UserInterfaceAuthDependency('business:shipment:list')],
)
async def get_business_shipment_list(
request: Request,
shipment_page_query: Annotated[ShipmentPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.get_shipment_list_services(
query_db,
shipment_page_query,
current_user,
is_page=True,
)
return ResponseUtil.success(model_content=result)
@shipment_controller.get(
'/warehouse-options',
summary='获取发货来源仓库选项接口',
description='仅返回当前用户有权访问的启用仓库',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
)
async def get_business_shipment_warehouse_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.get_warehouse_options_services(query_db, current_user)
return ResponseUtil.success(data=result)
@shipment_controller.get(
'/site-options',
summary='获取发货目标现场选项接口',
description='仅返回当前用户有权访问的启用客户现场',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
)
async def get_business_shipment_site_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.get_site_options_services(query_db, current_user)
return ResponseUtil.success(data=result)
@shipment_controller.get(
'/scan/{qr_token}',
summary='扫描整托发货托盘接口',
description='查询托盘是否可加入指定仓库至现场的整托发货单',
response_model=DataResponseModel[PalletScanResponseModel],
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
)
async def scan_business_shipment_pallet(
request: Request,
qr_token: Annotated[
str,
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
],
shipment_scan_query: Annotated[ShipmentScanQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.scan_shipment_pallet_services(
query_db,
qr_token,
shipment_scan_query,
current_user,
)
return ResponseUtil.success(data=result)
@shipment_controller.get(
'/{shipment_id}',
summary='获取整托发货单详情接口',
description='按仓库与现场双数据范围获取指定整托发货单',
response_model=DataResponseModel[ShipmentModel],
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
)
async def query_detail_business_shipment(
request: Request,
shipment_id: Annotated[int, Path(gt=0, description='发货单ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.shipment_detail_services(query_db, shipment_id, current_user)
return ResponseUtil.success(data=result)
@shipment_controller.post(
'',
summary='新增整托发货草稿接口',
description='新增多托盘整托发货草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:shipment:add')],
)
@Log(title='整托发货', business_type=BusinessType.INSERT)
async def add_business_shipment(
request: Request,
add_shipment: ShipmentCreateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.add_shipment_services(query_db, add_shipment, current_user)
return ResponseUtil.success(msg=result.message)
@shipment_controller.put(
'',
summary='编辑整托发货草稿接口',
description='编辑尚未确认的多托盘整托发货草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:shipment:edit')],
)
@Log(title='整托发货', business_type=BusinessType.UPDATE)
async def edit_business_shipment(
request: Request,
edit_shipment: ShipmentUpdateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.edit_shipment_services(query_db, edit_shipment, current_user)
return ResponseUtil.success(msg=result.message)
@shipment_controller.delete(
'/{shipment_ids}',
summary='删除整托发货草稿接口',
description='原子删除一个或多个尚未确认的整托发货草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:shipment:remove')],
)
@Log(title='整托发货', business_type=BusinessType.DELETE)
async def delete_business_shipment(
request: Request,
shipment_ids: Annotated[
str,
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的发货单ID'),
],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.delete_shipment_services(
query_db,
[int(shipment_id) for shipment_id in shipment_ids.split(',')],
current_user,
)
return ResponseUtil.success(msg=result.message)
@shipment_controller.put(
'/{shipment_id}/confirm',
summary='确认整托发货单接口',
description='原子确认整张发货单并将全部托盘移至目标现场',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:shipment:confirm')],
)
@Log(title='整托发货', business_type=BusinessType.UPDATE)
async def confirm_business_shipment(
request: Request,
shipment_id: Annotated[int, Path(gt=0, description='发货单ID')],
confirm_command: ConfirmCommandModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ShipmentService.confirm_shipment_services(
query_db,
shipment_id,
confirm_command,
current_user,
)
return ResponseUtil.success(msg=result.message)
@@ -28,16 +28,22 @@ class AccessDao:
return mapped_user_id is not None return mapped_user_id is not None
@classmethod @classmethod
async def user_has_site(cls, db: AsyncSession, user_id: int, site_id: int) -> bool: async def user_has_site(
cls,
db: AsyncSession,
user_id: int,
site_id: int,
*,
for_update: bool = False,
) -> bool:
"""判断用户是否关联指定客户现场。""" """判断用户是否关联指定客户现场。"""
mapped_user_id = ( statement = select(BizUserSite.user_id).where(
await db.execute(
select(BizUserSite.user_id).where(
BizUserSite.user_id == user_id, BizUserSite.user_id == user_id,
BizUserSite.site_id == site_id, BizUserSite.site_id == site_id,
) )
) if for_update:
).scalar_one_or_none() statement = statement.with_for_update()
mapped_user_id = (await db.execute(statement)).scalar_one_or_none()
return mapped_user_id is not None return mapped_user_id is not None
@@ -69,6 +69,22 @@ class OperationDao:
scoped_warehouse_ids = sorted(set(warehouse_ids)) scoped_warehouse_ids = sorted(set(warehouse_ids))
return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()] return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
@staticmethod
def _shipment_scope_conditions(
warehouse_ids: list[int] | None,
site_ids: list[int] | None,
) -> list[ColumnElement[bool]]:
conditions = []
if warehouse_ids is not None:
scoped_warehouse_ids = sorted(set(warehouse_ids))
conditions.append(
BizShipmentOrder.source_warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()
)
if site_ids is not None:
scoped_site_ids = sorted(set(site_ids))
conditions.append(BizShipmentOrder.target_site_id.in_(scoped_site_ids) if scoped_site_ids else false())
return conditions
@staticmethod @staticmethod
async def _paginate( async def _paginate(
db: AsyncSession, db: AsyncSession,
@@ -243,13 +259,32 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
shipment_id: int, shipment_id: int,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> BizShipmentOrder | None: ) -> BizShipmentOrder | None:
return await cls._get_for_update( shipment = await cls._get_for_update(
db, db,
BizShipmentOrder, BizShipmentOrder,
BizShipmentOrder.shipment_id, BizShipmentOrder.shipment_id,
shipment_id, shipment_id,
*cls._shipment_scope_conditions(warehouse_ids, site_ids),
) )
if shipment is None:
return None
# 父单锁定后读取当前明细,避免 MySQL RR 沿用确认前的快照。
item_result = await db.execute(
select(BizShipmentItem)
.where(BizShipmentItem.shipment_id == shipment_id)
.order_by(BizShipmentItem.pallet_id, BizShipmentItem.shipment_item_id)
.with_for_update()
.execution_options(populate_existing=True)
)
shipment.items = sorted(
item_result.scalars().all(),
key=lambda item: (item.pallet_id, item.shipment_item_id),
)
return shipment
@classmethod @classmethod
async def get_return_for_update( async def get_return_for_update(
@@ -297,12 +332,15 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
idempotency_key: str, idempotency_key: str,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> BizShipmentOrder | None: ) -> BizShipmentOrder | None:
return await cls._get_by_key( return await cls._get_by_key(
db, db,
BizShipmentOrder, BizShipmentOrder,
BizShipmentOrder.confirm_idempotency_key, BizShipmentOrder.confirm_idempotency_key,
idempotency_key, idempotency_key,
*cls._shipment_scope_conditions(warehouse_ids, site_ids),
) )
@classmethod @classmethod
@@ -520,8 +558,10 @@ class OperationDao:
db: AsyncSession, db: AsyncSession,
query_object: ShipmentPageQueryModel, query_object: ShipmentPageQueryModel,
is_page: bool = True, is_page: bool = True,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> PageModel | list[dict[str, Any]]: ) -> PageModel | list[dict[str, Any]]:
conditions = [] conditions = cls._shipment_scope_conditions(warehouse_ids, site_ids)
if query_object.shipment_no: if query_object.shipment_no:
conditions.append(BizShipmentOrder.shipment_no.like(f'%{query_object.shipment_no}%')) conditions.append(BizShipmentOrder.shipment_no.like(f'%{query_object.shipment_no}%'))
if query_object.source_warehouse_id: if query_object.source_warehouse_id:
@@ -545,6 +585,8 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
shipment_id: int, shipment_id: int,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> BizShipmentOrder | None: ) -> BizShipmentOrder | None:
return await cls._get_order_detail( return await cls._get_order_detail(
db, db,
@@ -554,6 +596,7 @@ class OperationDao:
BizShipmentItem, BizShipmentItem,
BizShipmentItem.shipment_id, BizShipmentItem.shipment_id,
BizShipmentItem.shipment_item_id, BizShipmentItem.shipment_item_id,
*cls._shipment_scope_conditions(warehouse_ids, site_ids),
) )
@classmethod @classmethod
@@ -38,9 +38,20 @@ class AccessService:
db: AsyncSession, db: AsyncSession,
current_user: CurrentUserModel, current_user: CurrentUserModel,
site_id: int, site_id: int,
*,
for_update: bool = False,
) -> None: ) -> None:
"""校验当前用户是否有权访问指定客户现场。""" """校验当前用户是否有权访问指定客户现场。"""
if current_user.user.admin: if current_user.user.admin:
return return
if not await AccessDao.user_has_site(db, current_user.user.user_id, site_id): if for_update:
has_site = await AccessDao.user_has_site(
db,
current_user.user.user_id,
site_id,
for_update=True,
)
else:
has_site = await AccessDao.user_has_site(db, current_user.user.user_id, site_id)
if not has_site:
raise PermissionException(data='', message='无权访问该客户现场') raise PermissionException(data='', message='无权访问该客户现场')
@@ -0,0 +1,840 @@
import hashlib
import json
import secrets
from datetime import datetime
from typing import Any, NoReturn
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel, PageModel
from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState
from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletEvent,
BizPalletInventory,
BizShipmentItem,
BizShipmentOrder,
)
from module_business.entity.vo.master_vo import SitePageQueryModel, WarehousePageQueryModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
ConfirmCommandModel,
PalletScanResponseModel,
ShipmentCreateModel,
ShipmentModel,
ShipmentPageQueryModel,
ShipmentScanQueryModel,
ShipmentUpdateModel,
)
from module_business.service.access_service import AccessService
class ShipmentService:
"""多托盘整托发货服务层。"""
@staticmethod
def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]:
user = current_user.user
if user is None or user.user_id is None or user.user_id <= 0 or not user.user_name:
raise ServiceException(message='当前用户信息无效')
return user.user_id, user.user_name
@staticmethod
def _is_admin(current_user: CurrentUserModel) -> bool:
return bool(current_user.user and current_user.user.admin)
@staticmethod
def _record_value(record: Any, field: str) -> Any:
if not isinstance(record, dict):
return getattr(record, field)
if field in record:
return record[field]
words = field.split('_')
alias = words[0] + ''.join(word.capitalize() for word in words[1:])
return record.get(alias)
@classmethod
def _raise_shipment_not_accessible(cls, current_user: CurrentUserModel) -> NoReturn:
if cls._is_admin(current_user):
raise ServiceException(message='发货单不存在')
raise PermissionException(data='', message='无权访问该发货单')
@classmethod
def _raise_pallet_not_accessible(
cls,
current_user: CurrentUserModel,
admin_message: str = '托盘不存在',
) -> NoReturn:
if cls._is_admin(current_user):
raise ServiceException(message=admin_message)
raise PermissionException(data='', message='无权访问该托盘')
@classmethod
async def _scopes(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> tuple[list[int] | None, list[int] | None]:
if cls._is_admin(current_user):
return None, None
user_id, _ = cls._current_identity(current_user)
warehouse_ids = sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id)))
site_ids = sorted(set(await AccessDao.list_user_site_ids(query_db, user_id)))
return warehouse_ids, site_ids
@staticmethod
async def _validate_active_route(
query_db: AsyncSession,
source_warehouse_id: int,
target_site_id: int,
current_user: CurrentUserModel,
) -> tuple[Any, Any]:
await AccessService.assert_warehouse_access(query_db, current_user, source_warehouse_id)
warehouse = await MasterDao.get_warehouse(query_db, source_warehouse_id)
if warehouse is None:
raise ServiceException(message='来源仓库不存在')
if warehouse.status != '0':
raise ServiceException(message='来源仓库已停用')
await AccessService.assert_site_access(query_db, current_user, target_site_id)
site = await MasterDao.get_site(query_db, target_site_id)
if site is None:
raise ServiceException(message='目标现场不存在')
if site.status != '0':
raise ServiceException(message='目标现场已停用')
return warehouse, site
@staticmethod
def _validate_pallet_ids(pallet_ids: list[int]) -> list[int]:
if not pallet_ids or any(type(pallet_id) is not int or pallet_id <= 0 for pallet_id in pallet_ids):
raise ServiceException(message='托盘ID必须为正整数')
if len(pallet_ids) != len(set(pallet_ids)):
raise ServiceException(message='托盘ID不能重复')
return sorted(pallet_ids)
@staticmethod
def _validate_locked_pallet(
pallet: BizPallet,
inventory: BizPalletInventory,
filling: BizFillingRecord,
source_warehouse_id: int,
) -> None:
if pallet.pallet_id != inventory.pallet_id:
raise ServiceException(message='托盘资产与库存不一致')
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许发货')
if (
inventory.flow_state != PalletFlowState.FILLED_IN_WAREHOUSE.value
or inventory.warehouse_id != source_warehouse_id
or inventory.site_id is not None
or inventory.current_line_id is not None
or inventory.active_filling_id is None
):
raise ServiceException(message='托盘不符合整托发货条件')
if type(inventory.remaining_packages) is not int or inventory.remaining_packages <= 0:
raise ServiceException(message='托盘剩余包数必须为正整数')
if inventory.remaining_packages > pallet.capacity_packages:
raise ServiceException(message='托盘剩余包数不能超过容量')
if type(inventory.version) is not int or inventory.version < 0:
raise ServiceException(message='托盘库存版本无效')
if (
filling.filling_id != inventory.active_filling_id
or filling.pallet_id != pallet.pallet_id
or filling.warehouse_id != source_warehouse_id
or filling.active_slot != 'ACTIVE'
):
raise ServiceException(message='托盘活动装填周期无效')
@classmethod
async def _lock_and_validate_pallets(
cls,
query_db: AsyncSession,
pallet_ids: list[int],
source_warehouse_id: int,
) -> tuple[list[BizPalletInventory], list[BizPallet], list[BizFillingRecord]]:
ordered_ids = cls._validate_pallet_ids(pallet_ids)
inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids)
pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids)
if (
len(inventories) != len(ordered_ids)
or len(pallets) != len(ordered_ids)
or {inventory.pallet_id for inventory in inventories} != set(ordered_ids)
or {pallet.pallet_id for pallet in pallets} != set(ordered_ids)
):
raise ServiceException(message='存在不存在或库存缺失的托盘')
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets}
active_filling_ids = []
for pallet_id in ordered_ids:
active_filling_id = inventory_by_id[pallet_id].active_filling_id
if active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期')
active_filling_ids.append(active_filling_id)
fillings = []
for filling_id in sorted(set(active_filling_ids)):
filling = await OperationDao.get_active_filling_for_update(query_db, filling_id)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
fillings.append(filling)
if len(fillings) != len(ordered_ids):
raise ServiceException(message='托盘活动装填周期不完整')
filling_by_id = {filling.filling_id: filling for filling in fillings}
ordered_inventories = [inventory_by_id[pallet_id] for pallet_id in ordered_ids]
ordered_pallets = [pallet_by_id[pallet_id] for pallet_id in ordered_ids]
ordered_fillings = [filling_by_id[inventory.active_filling_id] for inventory in ordered_inventories]
for pallet, inventory, filling in zip(
ordered_pallets,
ordered_inventories,
ordered_fillings,
strict=True,
):
cls._validate_locked_pallet(pallet, inventory, filling, source_warehouse_id)
return ordered_inventories, ordered_pallets, ordered_fillings
@staticmethod
def _build_items(
shipment_id: int,
pallet_ids: list[int],
user_name: str,
current_time: datetime,
) -> list[BizShipmentItem]:
return [
BizShipmentItem(
shipment_id=shipment_id,
pallet_id=pallet_id,
active_filling_id=None,
package_qty=None,
create_by=user_name,
create_time=current_time,
)
for pallet_id in sorted(pallet_ids)
]
@staticmethod
def _success(message: str) -> CrudResponseModel:
return CrudResponseModel(is_success=True, message=message)
@staticmethod
def _build_confirm_request_hash(shipment_id: int) -> str:
payload = json.dumps(
{'shipment_id': shipment_id},
sort_keys=True,
separators=(',', ':'),
).encode()
return hashlib.sha256(payload).hexdigest()
@classmethod
async def get_shipment_list_services(
cls,
query_db: AsyncSession,
query_object: ShipmentPageQueryModel,
current_user: CurrentUserModel,
is_page: bool = True,
) -> PageModel | list[ShipmentModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if (
warehouse_ids is not None
and query_object.source_warehouse_id is not None
and query_object.source_warehouse_id not in warehouse_ids
):
raise PermissionException(data='', message='无权访问该仓库')
if (
site_ids is not None
and query_object.target_site_id is not None
and query_object.target_site_id not in site_ids
):
raise PermissionException(data='', message='无权访问该客户现场')
if warehouse_ids == [] or site_ids == []:
if is_page:
return PageModel(
rows=[],
pageNum=query_object.page_num,
pageSize=query_object.page_size,
total=0,
hasNext=False,
)
return []
result = await OperationDao.get_shipment_list(
query_db,
query_object,
is_page,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
rows = result.rows if isinstance(result, PageModel) else result
models = [ShipmentModel.model_validate(record) for record in rows]
if isinstance(result, PageModel):
return result.model_copy(update={'rows': models})
return models
@classmethod
async def shipment_detail_services(
cls,
query_db: AsyncSession,
shipment_id: int,
current_user: CurrentUserModel,
) -> ShipmentModel:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
shipment = await OperationDao.get_shipment_by_id(
query_db,
shipment_id,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if shipment is None:
cls._raise_shipment_not_accessible(current_user)
return ShipmentModel.model_validate(shipment)
@classmethod
async def get_warehouse_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> list[BusinessOptionModel]:
warehouse_ids, _ = await cls._scopes(query_db, current_user)
allowed_ids = None if warehouse_ids is None else set(warehouse_ids)
warehouses = await MasterDao.list_warehouse(
query_db,
WarehousePageQueryModel(status='0'),
False,
)
options = []
for warehouse in warehouses:
warehouse_id = cls._record_value(warehouse, 'warehouse_id')
if cls._record_value(warehouse, 'status') != '0':
continue
if allowed_ids is not None and warehouse_id not in allowed_ids:
continue
options.append(
BusinessOptionModel(
id=warehouse_id,
code=cls._record_value(warehouse, 'warehouse_code'),
name=cls._record_value(warehouse, 'warehouse_name'),
)
)
return sorted(options, key=lambda option: option.id)
@classmethod
async def get_site_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> list[BusinessOptionModel]:
_, site_ids = await cls._scopes(query_db, current_user)
allowed_ids = None if site_ids is None else set(site_ids)
sites = await MasterDao.list_site(
query_db,
SitePageQueryModel(status='0'),
False,
)
options = []
for site in sites:
site_id = cls._record_value(site, 'site_id')
if cls._record_value(site, 'status') != '0':
continue
if allowed_ids is not None and site_id not in allowed_ids:
continue
options.append(
BusinessOptionModel(
id=site_id,
code=cls._record_value(site, 'site_code'),
name=cls._record_value(site, 'site_name'),
)
)
return sorted(options, key=lambda option: option.id)
@staticmethod
def _validate_scan_pallet(
pallet: BizPallet,
inventory: BizPalletInventory,
filling: BizFillingRecord,
source_warehouse_id: int,
) -> None:
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许发货')
if (
inventory.flow_state != PalletFlowState.FILLED_IN_WAREHOUSE.value
or inventory.warehouse_id != source_warehouse_id
or inventory.site_id is not None
or inventory.current_line_id is not None
or inventory.active_filling_id is None
or type(inventory.remaining_packages) is not int
or inventory.remaining_packages <= 0
or inventory.remaining_packages > pallet.capacity_packages
):
raise ServiceException(message='托盘不符合整托发货条件')
if (
filling.filling_id != inventory.active_filling_id
or filling.pallet_id != pallet.pallet_id
or filling.warehouse_id != source_warehouse_id
or filling.active_slot != 'ACTIVE'
):
raise ServiceException(message='托盘活动装填周期无效')
@classmethod
async def scan_shipment_pallet_services(
cls,
query_db: AsyncSession,
qr_token: str,
query_object: ShipmentScanQueryModel,
current_user: CurrentUserModel,
) -> PalletScanResponseModel:
await AccessService.assert_warehouse_access(
query_db,
current_user,
query_object.source_warehouse_id,
)
warehouse = await MasterDao.get_warehouse(query_db, query_object.source_warehouse_id)
if warehouse is None:
raise ServiceException(message='来源仓库不存在')
if warehouse.status != '0':
raise ServiceException(message='来源仓库已停用')
await AccessService.assert_site_access(
query_db,
current_user,
query_object.target_site_id,
)
site = await MasterDao.get_site(query_db, query_object.target_site_id)
if site is None:
raise ServiceException(message='目标现场不存在')
if site.status != '0':
raise ServiceException(message='目标现场已停用')
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token)
if pallet is None:
cls._raise_pallet_not_accessible(current_user)
inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id)
if inventory is None:
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
if inventory.pallet_id != pallet.pallet_id or inventory.warehouse_id != query_object.source_warehouse_id:
cls._raise_pallet_not_accessible(current_user, '托盘不在来源仓库')
if inventory.site_id is not None or inventory.current_line_id is not None:
cls._raise_pallet_not_accessible(current_user, '托盘已在客户现场')
if inventory.active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期')
filling = await OperationDao.get_filling_by_id(
query_db,
inventory.active_filling_id,
warehouse_ids=[query_object.source_warehouse_id],
)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在')
cls._validate_scan_pallet(
pallet,
inventory,
filling,
query_object.source_warehouse_id,
)
return PalletScanResponseModel(
pallet_id=pallet.pallet_id,
pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE,
warehouse_id=inventory.warehouse_id,
site_id=None,
current_line_id=None,
active_filling_id=inventory.active_filling_id,
strain_id=filling.strain_id,
strain_code=filling.strain_code_snapshot,
strain_name=filling.strain_name_snapshot,
batch_no=filling.batch_no,
package_spec=filling.package_spec,
capacity_packages=pallet.capacity_packages,
remaining_packages=inventory.remaining_packages,
allowed_actions=['shipment'],
)
@classmethod
async def add_shipment_services(
cls,
query_db: AsyncSession,
page_object: ShipmentCreateModel,
current_user: CurrentUserModel,
) -> CrudResponseModel:
try:
_, user_name = cls._current_identity(current_user)
await cls._validate_active_route(
query_db,
page_object.source_warehouse_id,
page_object.target_site_id,
current_user,
)
ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids)
await cls._lock_and_validate_pallets(
query_db,
ordered_ids,
page_object.source_warehouse_id,
)
current_time = datetime.now()
shipment = BizShipmentOrder(
shipment_no=f'SH-{secrets.token_hex(16)}',
source_warehouse_id=page_object.source_warehouse_id,
target_site_id=page_object.target_site_id,
status=DocumentStatus.DRAFT.value,
create_by=user_name,
create_time=current_time,
update_by=user_name,
update_time=current_time,
)
shipment = await OperationDao.add_shipment(query_db, shipment)
await OperationDao.replace_shipment_items(
query_db,
shipment.shipment_id,
cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time),
)
await query_db.commit()
return cls._success('新增成功')
except IntegrityError:
await query_db.rollback()
raise ServiceException(message='新增发货单失败,发货单号或明细已存在') from None
except Exception:
await query_db.rollback()
raise
@classmethod
async def edit_shipment_services(
cls,
query_db: AsyncSession,
page_object: ShipmentUpdateModel,
current_user: CurrentUserModel,
) -> CrudResponseModel:
try:
_, user_name = cls._current_identity(current_user)
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
shipment = await OperationDao.get_shipment_for_update(
query_db,
page_object.shipment_id,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if shipment is None:
cls._raise_shipment_not_accessible(current_user)
if shipment.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认发货单不允许编辑')
if any(item.active_filling_id is not None or item.package_qty is not None for item in shipment.items):
raise ServiceException(message='发货草稿明细快照异常')
await cls._validate_active_route(
query_db,
page_object.source_warehouse_id,
page_object.target_site_id,
current_user,
)
ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids)
await cls._lock_and_validate_pallets(
query_db,
ordered_ids,
page_object.source_warehouse_id,
)
current_time = datetime.now()
shipment.source_warehouse_id = page_object.source_warehouse_id
shipment.target_site_id = page_object.target_site_id
shipment.update_by = user_name
shipment.update_time = current_time
await OperationDao.update_shipment(query_db, shipment)
await OperationDao.replace_shipment_items(
query_db,
shipment.shipment_id,
cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time),
)
await query_db.commit()
return cls._success('修改成功')
except IntegrityError:
await query_db.rollback()
raise ServiceException(message='修改发货单失败,单据或明细状态已变化') from None
except Exception:
await query_db.rollback()
raise
@classmethod
async def delete_shipment_services(
cls,
query_db: AsyncSession,
shipment_ids: list[int],
current_user: CurrentUserModel,
) -> CrudResponseModel:
try:
if not shipment_ids or any(
type(shipment_id) is not int or shipment_id <= 0 for shipment_id in shipment_ids
):
raise ServiceException(message='发货单ID必须为正整数')
if len(shipment_ids) != len(set(shipment_ids)):
raise ServiceException(message='发货单ID不能重复')
ordered_ids = sorted(shipment_ids)
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
for shipment_id in ordered_ids:
shipment = await OperationDao.get_shipment_for_update(
query_db,
shipment_id,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if shipment is None:
cls._raise_shipment_not_accessible(current_user)
if shipment.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认发货单不允许删除')
for shipment_id in ordered_ids:
await OperationDao.delete_shipment(query_db, shipment_id)
await query_db.commit()
return cls._success('删除成功')
except IntegrityError:
await query_db.rollback()
raise ServiceException(message='删除发货单失败,单据状态或关联数据已变化') from None
except Exception:
await query_db.rollback()
raise
@staticmethod
def _is_exact_confirmed(
shipment: BizShipmentOrder,
shipment_id: int,
idempotency_key: str,
request_hash: str,
) -> bool:
return (
shipment.shipment_id == shipment_id
and shipment.status == DocumentStatus.CONFIRMED.value
and shipment.confirm_idempotency_key == idempotency_key
and shipment.confirm_request_hash == request_hash
)
@classmethod
async def _lock_and_reauthorize_route(
cls,
query_db: AsyncSession,
shipment: BizShipmentOrder,
current_user: CurrentUserModel,
) -> None:
warehouse = await MasterDao.get_warehouse(
query_db,
shipment.source_warehouse_id,
for_update=True,
)
if warehouse is None:
raise ServiceException(message='来源仓库不存在')
if warehouse.status != '0':
raise ServiceException(message='来源仓库已停用')
try:
await AccessService.assert_warehouse_access(
query_db,
current_user,
shipment.source_warehouse_id,
for_update=True,
)
except PermissionException:
cls._raise_shipment_not_accessible(current_user)
site = await MasterDao.get_site(
query_db,
shipment.target_site_id,
for_update=True,
)
if site is None:
raise ServiceException(message='目标现场不存在')
if site.status != '0':
raise ServiceException(message='目标现场已停用')
try:
await AccessService.assert_site_access(
query_db,
current_user,
shipment.target_site_id,
for_update=True,
)
except PermissionException:
cls._raise_shipment_not_accessible(current_user)
@classmethod
async def _recover_confirm_integrity_error(
cls,
query_db: AsyncSession,
shipment_id: int,
idempotency_key: str,
request_hash: str,
current_user: CurrentUserModel,
) -> CrudResponseModel:
try:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
recovered = await OperationDao.get_shipment_by_confirm_idempotency_key(
query_db,
idempotency_key,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if recovered is None:
raise ServiceException(message='确认发货失败,幂等冲突')
if not cls._is_exact_confirmed(
recovered,
shipment_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认发货失败,幂等键已被其他请求使用')
await cls._validate_active_route(
query_db,
recovered.source_warehouse_id,
recovered.target_site_id,
current_user,
)
return cls._success('确认成功')
finally:
await query_db.rollback()
@classmethod
async def confirm_shipment_services( # noqa: PLR0912, PLR0915
cls,
query_db: AsyncSession,
shipment_id: int,
page_object: ConfirmCommandModel,
current_user: CurrentUserModel,
) -> CrudResponseModel:
try:
user_id, user_name = cls._current_identity(current_user)
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
shipment = await OperationDao.get_shipment_by_id(
query_db,
shipment_id,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if shipment is None:
cls._raise_shipment_not_accessible(current_user)
request_hash = cls._build_confirm_request_hash(shipment_id)
idempotency_key = page_object.idempotency_key
existing = await OperationDao.get_shipment_by_confirm_idempotency_key(
query_db,
idempotency_key,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if existing is not None and not cls._is_exact_confirmed(
existing,
shipment_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认发货失败,幂等键已被其他请求使用')
locked_shipment = await OperationDao.get_shipment_for_update(
query_db,
shipment_id,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if locked_shipment is None:
cls._raise_shipment_not_accessible(current_user)
if locked_shipment.status == DocumentStatus.CONFIRMED.value:
if cls._is_exact_confirmed(
locked_shipment,
shipment_id,
idempotency_key,
request_hash,
):
await cls._lock_and_reauthorize_route(query_db, locked_shipment, current_user)
await query_db.rollback()
return cls._success('确认成功')
raise ServiceException(message='发货单已确认,幂等信息不匹配')
if locked_shipment.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='当前发货单状态不允许确认')
if existing is not None:
raise ServiceException(message='确认发货失败,并发状态不一致')
if locked_shipment.confirm_idempotency_key is not None or locked_shipment.confirm_request_hash is not None:
raise ServiceException(message='发货单确认信息不完整')
items = list(locked_shipment.items)
pallet_ids = [item.pallet_id for item in items]
ordered_ids = cls._validate_pallet_ids(pallet_ids)
if any(item.active_filling_id is not None or item.package_qty is not None for item in items):
raise ServiceException(message='发货草稿明细快照异常')
inventories, _, _ = await cls._lock_and_validate_pallets(
query_db,
ordered_ids,
locked_shipment.source_warehouse_id,
)
await cls._lock_and_reauthorize_route(query_db, locked_shipment, current_user)
for inventory in inventories:
PalletStateMachine.ensure_transition(
PalletFlowState(inventory.flow_state),
PalletFlowState.AT_SITE_UNUSED,
)
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
current_time = datetime.now()
locked_shipment.status = DocumentStatus.CONFIRMED.value
locked_shipment.confirm_idempotency_key = idempotency_key
locked_shipment.confirm_request_hash = request_hash
locked_shipment.confirmed_by = user_id
locked_shipment.confirmed_at = current_time
locked_shipment.update_by = user_name
locked_shipment.update_time = current_time
for item in items:
inventory = inventory_by_id[item.pallet_id]
item.active_filling_id = inventory.active_filling_id
item.package_qty = inventory.remaining_packages
await OperationDao.update_shipment(query_db, locked_shipment)
for inventory in inventories:
before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id
before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
inventory.warehouse_id = None
inventory.site_id = locked_shipment.target_site_id
inventory.current_line_id = None
inventory.version += 1
inventory.updated_at = current_time
event = BizPalletEvent(
pallet_id=inventory.pallet_id,
event_type=PalletEventType.SHIPMENT_CONFIRMED.value,
source_type='SHIPMENT',
source_id=shipment_id,
before_state=before_state,
after_state=PalletFlowState.AT_SITE_UNUSED.value,
before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id,
before_line_id=before_line_id,
after_warehouse_id=None,
after_site_id=locked_shipment.target_site_id,
after_line_id=None,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages,
operator_id=user_id,
occurred_at=current_time,
idempotency_key=idempotency_key,
request_hash=request_hash,
)
await InventoryDao.add_event(query_db, inventory, event)
await query_db.commit()
return cls._success('确认成功')
except IntegrityError:
await query_db.rollback()
return await cls._recover_confirm_integrity_error(
query_db,
shipment_id,
page_object.idempotency_key,
cls._build_confirm_request_hash(shipment_id),
current_user,
)
except Exception:
await query_db.rollback()
raise
@@ -3,6 +3,7 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.sql import Select, operators from sqlalchemy.sql import Select, operators
@@ -88,6 +89,33 @@ class AccessServiceTest(unittest.IsolatedAsyncioTestCase):
user_has_site.assert_awaited_once_with(db, 8, 7) user_has_site.assert_awaited_once_with(db, 8, 7)
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_locking_site_access_is_forwarded(self, user_has_site: AsyncMock) -> None:
parameter = inspect.signature(AccessService.assert_site_access).parameters.get('for_update')
self.assertIsNotNone(parameter)
self.assertIs(inspect.Parameter.KEYWORD_ONLY, parameter.kind)
self.assertIs(False, parameter.default)
user_has_site.return_value = True
user = SimpleNamespace(user=SimpleNamespace(user_id=8, admin=False))
db = AsyncMock(spec=AsyncSession)
await AccessService.assert_site_access(db, user, 7, for_update=True)
user_has_site.assert_awaited_once_with(db, 8, 7, for_update=True)
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_admin_bypasses_locking_site_mapping(self, user_has_site: AsyncMock) -> None:
user = SimpleNamespace(user=SimpleNamespace(user_id=1, admin=True))
await AccessService.assert_site_access(
AsyncMock(spec=AsyncSession),
user,
7,
for_update=True,
)
user_has_site.assert_not_awaited()
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock) @patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_unmapped_site_is_forbidden(self, user_has_site: AsyncMock) -> None: async def test_unmapped_site_is_forbidden(self, user_has_site: AsyncMock) -> None:
user_has_site.return_value = False user_has_site.return_value = False
@@ -176,7 +204,10 @@ class AccessDaoTest(unittest.IsolatedAsyncioTestCase):
await AccessDao.user_has_warehouse(db, 8, 99) await AccessDao.user_has_warehouse(db, 8, 99)
statement = db.execute.await_args.args[0] statement = db.execute.await_args.args[0]
sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split()) for dialect in (mysql.dialect(), postgresql.dialect()):
sql = ' '.join(
str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).split()
)
self.assertIs(for_update, 'FOR UPDATE' in sql) self.assertIs(for_update, 'FOR UPDATE' in sql)
async def test_user_has_site_returns_mapping_existence(self) -> None: async def test_user_has_site_returns_mapping_existence(self) -> None:
@@ -200,6 +231,30 @@ class AccessDaoTest(unittest.IsolatedAsyncioTestCase):
), ),
) )
async def test_user_has_site_current_read_is_optional_keyword_only_lock(self) -> None:
parameter = inspect.signature(AccessDao.user_has_site).parameters.get('for_update')
self.assertIsNotNone(parameter)
self.assertIs(inspect.Parameter.KEYWORD_ONLY, parameter.kind)
self.assertIs(False, parameter.default)
for for_update in (False, True):
with self.subTest(for_update=for_update):
result = MagicMock()
result.scalar_one_or_none.return_value = 8
db = self._db_with_result(result)
if for_update:
await AccessDao.user_has_site(db, 8, 7, for_update=True)
else:
await AccessDao.user_has_site(db, 8, 7)
statement = db.execute.await_args.args[0]
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = ' '.join(
str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).split()
)
self.assertIs(for_update, 'FOR UPDATE' in sql)
async def test_list_user_warehouse_ids_returns_scalar_ids(self) -> None: async def test_list_user_warehouse_ids_returns_scalar_ids(self) -> None:
result = MagicMock() result = MagicMock()
result.scalars.return_value.all.return_value = (11, 13) result.scalars.return_value.all.return_value = (11, 13)
@@ -122,11 +122,16 @@ class TestDaoPublicContract(unittest.TestCase):
def test_operation_dao_critical_parameter_names_and_defaults_are_stable(self) -> None: def test_operation_dao_critical_parameter_names_and_defaults_are_stable(self) -> None:
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'), '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'),
'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': ('db', 'idempotency_key'), 'get_shipment_by_confirm_idempotency_key': (
'db',
'idempotency_key',
'warehouse_ids',
'site_ids',
),
'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'), 'get_usage_by_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'), 'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
@@ -139,7 +144,7 @@ class TestDaoPublicContract(unittest.TestCase):
'get_filling_by_id': ('db', 'filling_id', 'warehouse_ids'), 'get_filling_by_id': ('db', 'filling_id', 'warehouse_ids'),
'add_filling_record': ('db', 'filling'), 'add_filling_record': ('db', 'filling'),
'update_filling_record': ('db', 'filling'), 'update_filling_record': ('db', 'filling'),
'get_shipment_by_id': ('db', 'shipment_id'), 'get_shipment_by_id': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'),
'add_shipment': ('db', 'shipment'), 'add_shipment': ('db', 'shipment'),
'update_shipment': ('db', 'shipment'), 'update_shipment': ('db', 'shipment'),
'delete_shipment': ('db', 'shipment_id'), 'delete_shipment': ('db', 'shipment_id'),
@@ -177,6 +182,16 @@ class TestDaoPublicContract(unittest.TestCase):
if warehouse_ids is not None: if warehouse_ids is not None:
self.assertIsNone(warehouse_ids.default) self.assertIsNone(warehouse_ids.default)
for method_name in (
'get_shipment_for_update',
'get_shipment_by_confirm_idempotency_key',
'get_shipment_by_id',
):
with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name))
self.assertIsNone(signature.parameters['warehouse_ids'].default)
self.assertIsNone(signature.parameters['site_ids'].default)
filling_list_signature = inspect.signature(OperationDao.get_filling_list) filling_list_signature = inspect.signature(OperationDao.get_filling_list)
self.assertEqual( self.assertEqual(
('db', 'query_object', 'is_page', 'warehouse_ids'), ('db', 'query_object', 'is_page', 'warehouse_ids'),
@@ -185,7 +200,16 @@ class TestDaoPublicContract(unittest.TestCase):
self.assertIs(True, filling_list_signature.parameters['is_page'].default) self.assertIs(True, filling_list_signature.parameters['is_page'].default)
self.assertIsNone(filling_list_signature.parameters['warehouse_ids'].default) self.assertIsNone(filling_list_signature.parameters['warehouse_ids'].default)
for method_name in ('get_shipment_list', 'get_usage_list', 'get_return_list'): shipment_list_signature = inspect.signature(OperationDao.get_shipment_list)
self.assertEqual(
('db', 'query_object', 'is_page', 'warehouse_ids', 'site_ids'),
tuple(shipment_list_signature.parameters),
)
self.assertIs(True, shipment_list_signature.parameters['is_page'].default)
self.assertIsNone(shipment_list_signature.parameters['warehouse_ids'].default)
self.assertIsNone(shipment_list_signature.parameters['site_ids'].default)
for method_name in ('get_usage_list', 'get_return_list'):
with self.subTest(method=method_name): with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name)) signature = inspect.signature(getattr(OperationDao, method_name))
self.assertEqual(('db', 'query_object', 'is_page'), tuple(signature.parameters)) self.assertEqual(('db', 'query_object', 'is_page'), tuple(signature.parameters))
@@ -383,13 +407,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_shipment_for_update,
BizShipmentOrder,
'shipment_id',
12,
(),
),
( (
OperationDao.get_return_for_update, OperationDao.get_return_for_update,
BizReturnOrder, BizReturnOrder,
@@ -426,6 +443,69 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
for fragment in extra_fragments: for fragment in extra_fragments:
self.assertIn(fragment, sql) self.assertIn(fragment, sql)
async def test_shipment_for_update_locks_scoped_parent_then_current_items(self) -> None:
shipment = SimpleNamespace(shipment_id=12)
items = [
SimpleNamespace(shipment_item_id=1, shipment_id=12, pallet_id=5),
SimpleNamespace(shipment_item_id=3, shipment_id=12, pallet_id=2),
]
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = shipment
item_result = MagicMock()
item_result.scalars.return_value.all.return_value = items
self.db.execute.side_effect = [parent_result, item_result]
actual = await OperationDao.get_shipment_for_update(
self.db,
12,
warehouse_ids=[7, 3, 7],
site_ids=[11, 9, 11],
)
self.assertIs(shipment, actual)
self.assertEqual([2, 5], [item.pallet_id for item in actual.items])
self.assertEqual(2, self.db.execute.await_count)
parent_statement = self.db.execute.await_args_list[0].args[0]
item_statement = self.db.execute.await_args_list[1].args[0]
self.assertTrue(parent_statement.get_final_froms()[0].compare(BizShipmentOrder.__table__))
self.assertTrue(item_statement.get_final_froms()[0].compare(BizShipmentItem.__table__))
self.assertIs(True, parent_statement.get_execution_options().get('populate_existing'))
self.assertIs(True, item_statement.get_execution_options().get('populate_existing'))
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
parent_sql = compile_sql(parent_statement, dialect)
item_sql = compile_sql(item_statement, dialect)
self.assertIn('biz_shipment_order.shipment_id = 12', parent_sql)
self.assertIn('biz_shipment_order.source_warehouse_id IN (3, 7)', parent_sql)
self.assertIn('biz_shipment_order.target_site_id IN (9, 11)', parent_sql)
self.assertIn('FOR UPDATE', parent_sql)
self.assertIn('biz_shipment_item.shipment_id = 12', item_sql)
self.assertIn(
'ORDER BY biz_shipment_item.pallet_id, biz_shipment_item.shipment_item_id',
item_sql,
)
self.assertIn('FOR UPDATE', item_sql)
async def test_shipment_for_update_missing_scoped_parent_skips_item_lock(self) -> None:
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = None
self.db.execute.return_value = parent_result
actual = await OperationDao.get_shipment_for_update(
self.db,
12,
warehouse_ids=[],
site_ids=[9],
)
self.assertIsNone(actual)
self.db.execute.assert_awaited_once()
parent_statement = self.db.execute.await_args.args[0]
mysql_sql = compile_sql(parent_statement, mysql.dialect())
postgresql_sql = compile_sql(parent_statement, postgresql.dialect())
self.assertIn('false = 1', mysql_sql)
self.assertIn('false', postgresql_sql)
async def test_inbound_for_update_locks_parent_then_current_items(self) -> None: async def test_inbound_for_update_locks_parent_then_current_items(self) -> None:
inbound = SimpleNamespace(inbound_id=11) inbound = SimpleNamespace(inbound_id=11)
items = [ items = [
@@ -716,6 +796,74 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('warehouse_id IN', sql) self.assertNotIn('warehouse_id IN', sql)
self.assertNotIn('WHERE false', sql) self.assertNotIn('WHERE false', sql)
async def test_shipment_list_applies_both_scopes_and_empty_scope_is_false(self) -> None:
query = ShipmentPageQueryModel(page_num=2, page_size=7)
marker = object()
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate:
actual = await OperationDao.get_shipment_list(
self.db,
query,
is_page=True,
warehouse_ids=[7, 3, 7],
site_ids=[11, 9, 11],
)
self.assertIs(marker, actual)
statement = paginate.await_args.args[1]
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
sql = compile_sql(statement, dialect)
self.assertIn('biz_shipment_order.source_warehouse_id IN (3, 7)', sql)
self.assertIn('biz_shipment_order.target_site_id IN (9, 11)', sql)
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as empty_paginate:
await OperationDao.get_shipment_list(
self.db,
query,
warehouse_ids=[7],
site_ids=[],
)
empty_statement = empty_paginate.await_args.args[1]
self.assertIn('false = 1', compile_sql(empty_statement, mysql.dialect()))
self.assertIn('false', compile_sql(empty_statement, postgresql.dialect()))
async def test_shipment_detail_and_confirm_key_push_both_scopes_into_parent_sql(self) -> None:
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = None
self.db.execute.return_value = parent_result
self.assertIsNone(
await OperationDao.get_shipment_by_id(
self.db,
12,
warehouse_ids=[7, 3],
site_ids=[11, 9],
)
)
self.db.execute.assert_awaited_once()
detail_statement = self.db.execute.await_args.args[0]
self.db.reset_mock()
self.db.execute.return_value = parent_result
self.assertIsNone(
await OperationDao.get_shipment_by_confirm_idempotency_key(
self.db,
'confirm-key',
warehouse_ids=[7, 3],
site_ids=[11, 9],
)
)
key_statement = self.db.execute.await_args.args[0]
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
detail_sql = compile_sql(detail_statement, dialect)
key_sql = compile_sql(key_statement, dialect)
for sql in (detail_sql, key_sql):
self.assertIn('biz_shipment_order.source_warehouse_id IN (3, 7)', sql)
self.assertIn('biz_shipment_order.target_site_id IN (9, 11)', sql)
self.assertIn("biz_shipment_order.confirm_idempotency_key = 'confirm-key'", key_sql)
self.assertNotIn('FOR UPDATE', key_sql)
def test_time_conditions_include_fractional_last_second_with_exclusive_next_day(self) -> None: def test_time_conditions_include_fractional_last_second_with_exclusive_next_day(self) -> None:
lower, upper = OperationDao._time_conditions( lower, upper = OperationDao._time_conditions(
BizUsageRecord.occurred_at, BizUsageRecord.occurred_at,
File diff suppressed because it is too large Load Diff